Skip to content

fix: never borrow opcache memory in persisted zvals - #2620

Open
nicolas-grekas wants to merge 1 commit into
php:mainfrom
nicolas-grekas:zval-own
Open

fix: never borrow opcache memory in persisted zvals#2620
nicolas-grekas wants to merge 1 commit into
php:mainfrom
nicolas-grekas:zval-own

Conversation

@nicolas-grekas

@nicolas-grekas nicolas-grekas commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

persistent_zval_persist shared two kinds of pointer instead of copying them: interned strings and opcache-immutable arrays. Both live in opcache shared memory, which does not survive an opcache restart.

The problem

On restart opcache rewinds its shared memory to a startup watermark:

  • accel_interned_strings_restore_state() does memset(saved_top, 0, top - saved_top) and resets top. Everything interned after startup and preload is zeroed.
  • zend_shared_alloc_restore_state() rewinds each segment's pos, so the memory holding immutable arrays is handed out again.

The segment stays mapped, so this is not a clean segfault. A persisted tree that outlives a restart points at reused bytes.

The free path is the worst case. persistent_zval_free re-derived ownership by re-reading ZSTR_IS_INTERNED at free time. IS_STR_INTERNED is GC_IMMUTABLE, a GC flag bit, so once a restart zeroes the string header that check reads false, the borrowed pointer is treated as owned, and zend_string_free reaches pefree(s, GC_FLAGS(s) & IS_STR_PERSISTENT) with flags of 0. That hands a shared memory pointer to the request allocator. The immutable array branch has the same shape: if the flag no longer reads set, the tree is walked and zend_hash_destroy plus pefree run on a shared memory HashTable.

Restarts fire for ACCEL_RESTART_OOM, ACCEL_RESTART_HASH and ACCEL_RESTART_USER. FrankenPHP diverts the userland opcache_reset() to a thread reboot via frankenphp_override_opcache_reset(), so the exposure is out of memory and hash overflow.

The fix

Persist copies everything into pemalloc memory. Ownership becomes an invariant of a persisted tree rather than a per-node property re-derived later, so:

  • persistent_zval_free is unconditional.
  • The borrow branches in persistent_zval_to_request are unreachable and are dropped.
  • persistent_zval_validate no longer short-circuits on immutable arrays. Persist walks their leaves now, and an unsupported leaf would otherwise reach ZEND_UNREACHABLE.

Cost is one copy at persist time, which is the rare operation. Reads are unchanged.

Why not materialise from the restart hook instead

Keeping the fast paths and copying borrowed references from zend_accel_schedule_restart_hook, which fires before the rewind, would also work in principle. I did not take it:

  • the hook is PHP 8.4+ only, so 8.2 and 8.3 need this copy anyway
  • it needs every live persisted tree to be reachable from one global registry
  • it cannot cheaply tell a below-watermark interned string from an above-watermark one, so it would copy everything regardless, at the moment opcache has just run out of memory

This is not covered by #2621

#2621 restores the thread reboot hook. It does not make this redundant, because the two protect different things:

A persisted tree is allocated with pemalloc, so it is process-lifetime memory. A thread reboot does not touch it, which is exactly the point of a shared-state API, so it is still holding shared memory pointers when the rewind lands. The reboot is also PHP 8.4+ only, and it is dispatched asynchronously because rebootAllThreads() cannot block the thread it is rebooting, so the rewind can still be performed by another thread's RINIT before the reboot finishes. Both changes are needed.

Reachability

Not reachable on main today. The only caller is frankenphp_test_persist_roundtrip, compiled only under FRANKENPHP_TEST, which persists, reads and frees inside a single call. This matters for a shared-state API (frankenphp_set_vars / frankenphp_get_vars) that keeps a persisted tree across requests.

Tests

persist-roundtrip.php gains a compile-time constant array and a literal-keyed array. Those are exactly the inputs that used to take the borrow paths and now take the copy paths, so the new code is exercised for value correctness.

There is no regression test for the crash itself. Driving opcache into a restart from a test is possible, #2621 does it, but a test for this path needs something that keeps a persisted tree across requests, and nothing on main does.

Verification

  • CI: Tests, Integration Tests and Lint pass on PHP 8.2, 8.3, 8.4 and 8.5, plus macOS.
  • On dunglas/frankenphp:builder-php8.5: TestPersistentZvalRoundtrip passes with -DFRANKENPHP_TEST, so the new constant-array and literal-key cases actually run, and the full library suite passes in 21.6s.
  • clang-format and gofmt clean.

The failing checks are cross-architecture image builds and the static GNU binaries, which fail the same way on other open PRs that do not touch this code.

persistent_zval_persist shared two kinds of pointer instead of copying:
interned strings and opcache-immutable arrays. Both live in opcache's
shared memory. On restart opcache rewinds that memory to a startup
watermark: accel_interned_strings_restore_state() memsets everything
interned past the watermark and resets top, and
zend_shared_alloc_restore_state() rewinds each segment position. The
memory stays mapped and is handed out again, so a persisted tree that
outlives a restart points at reused bytes.

The free path was the worst case. persistent_zval_free re-derived
ownership by re-reading ZSTR_IS_INTERNED at free time. IS_STR_INTERNED is
a GC flag bit, so once a restart zeroes the string header that check
reads false, the borrowed pointer is treated as owned, and
zend_string_free calls pefree(s, 0) on shared memory. The immutable array
branch has the same shape.

Persist now copies everything into pemalloc memory, so ownership is an
invariant of a persisted tree instead of a per-node property re-derived
later. Free is unconditional, and the borrow branches in
persistent_zval_to_request are unreachable and dropped.
persistent_zval_validate no longer short-circuits on immutable arrays,
since persist walks their leaves now and an unsupported leaf would
otherwise reach ZEND_UNREACHABLE.

Restarts fire on out of memory, hash overflow and opcache_reset().
FrankenPHP diverts the userland opcache_reset() to a thread reboot, so
the exposure is the first two.

Not reachable on main today: the only caller is
frankenphp_test_persist_roundtrip, compiled only under FRANKENPHP_TEST,
which persists, reads and frees inside one call. It matters for a
shared-state API that keeps a persisted tree across requests.
@nicolas-grekas
nicolas-grekas marked this pull request as draft August 22, 2026 05:47
@nicolas-grekas
nicolas-grekas marked this pull request as ready for review August 22, 2026 07:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant