fix: never borrow opcache memory in persisted zvals - #2620
Open
nicolas-grekas wants to merge 1 commit into
Open
Conversation
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
marked this pull request as draft
August 22, 2026 05:47
nicolas-grekas
marked this pull request as ready for review
August 22, 2026 07:45
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
persistent_zval_persistshared 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()doesmemset(saved_top, 0, top - saved_top)and resetstop. Everything interned after startup and preload is zeroed.zend_shared_alloc_restore_state()rewinds each segment'spos, 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_freere-derived ownership by re-readingZSTR_IS_INTERNEDat free time.IS_STR_INTERNEDisGC_IMMUTABLE, a GC flag bit, so once a restart zeroes the string header that check reads false, the borrowed pointer is treated as owned, andzend_string_freereachespefree(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 andzend_hash_destroypluspefreerun on a shared memory HashTable.Restarts fire for
ACCEL_RESTART_OOM,ACCEL_RESTART_HASHandACCEL_RESTART_USER. FrankenPHP diverts the userlandopcache_reset()to a thread reboot viafrankenphp_override_opcache_reset(), so the exposure is out of memory and hash overflow.The fix
Persist copies everything into
pemallocmemory. Ownership becomes an invariant of a persisted tree rather than a per-node property re-derived later, so:persistent_zval_freeis unconditional.persistent_zval_to_requestare unreachable and are dropped.persistent_zval_validateno longer short-circuits on immutable arrays. Persist walks their leaves now, and an unsupported leaf would otherwise reachZEND_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: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 becauserebootAllThreads()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
maintoday. The only caller isfrankenphp_test_persist_roundtrip, compiled only underFRANKENPHP_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.phpgains 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
maindoes.Verification
dunglas/frankenphp:builder-php8.5:TestPersistentZvalRoundtrippasses with-DFRANKENPHP_TEST, so the new constant-array and literal-key cases actually run, and the full library suite passes in 21.6s.clang-formatandgofmtclean.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.