Stop turbo-ext from touching refcounts of immutable arrays and interned strings - #6561
Merged
Merged
Conversation
zv::Args stored a HashTable argument with a bare ZVAL_ARR, which types an immutable table (the shared zend_empty_array of a PHP [] literal or zv::Arr::empty()) as refcounted. When the argument vector reached a PHP method, the addref in zend_call_function() wrote into read-only memory (SIGBUS on macOS arm64, SIGSEGV on Linux). ForeachHandler hit this for a constant array without keys: its conditional holder tables stay empty and are handed to a PHP override of MutatingScope::addConditionalExpressions(). Wrap immutable tables as plain IS_ARRAY, like zv::Arr::adoptTable() and copyOfTable() do. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
positionalNames() collected each parameter's Variable name with
Z_ADDREF_P, which increments the refcount of an interned string too.
Names built by PHP code (a `new Variable('foo')` literal) are interned,
and with opcache those live in shared memory: the write faults under
opcache.protect_memory=1 and trips the refcount assertion of debug PHP
builds. Z_TRY_ADDREF_P leaves non-refcounted strings alone.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKX5yrrUnnbeRtc2oURC2u
…esolver The offset-write branch wrapped a borrowed inner table with a bare ZVAL_ARR and addref'ed it, the pattern that faults when the table is immutable. The keysBySlot entries are always separated tables today, so nothing crashes, but zv::Arr::copyOfTable() handles the immutable case and says what the code means. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EKX5yrrUnnbeRtc2oURC2u
The snapshot was taken after separating, so it shared the table the loop deletes from: the deletes went into a table of refcount 2 that was also being iterated. Only the current entry is ever deleted, so the result was right, but the write violated copy-on-write and trips HT_ASSERT_RC1 on debug PHP builds. Taking the snapshot first makes separate() give the deletes their own table, at the same one duplication as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EKX5yrrUnnbeRtc2oURC2u
ondrejmirtes
force-pushed
the
turbo-immutable-refcount-fixes
branch
from
September 23, 2026 10:24
05d811f to
01c272f
Compare
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.
Supersedes #6558. It keeps @zonuexe's commit and adds fixes for the other places in turbo-ext with the same class of bug.
In PHP, a shared read-only array or string must never have its refcount incremented. For arrays that means the shared empty array of a
[]literal (it lives in.rodata), for strings any interned string, which with opcache lives in shared memory.ZVAL_ARRandZ_ADDREF_Pstill increment it, which writes into memory that may be read-only.Commits
zv::Argswrapped aHashTable *with a bareZVAL_ARR, and the addref inzend_call_function()then wrote intozend_empty_array.ForeachHandlerhands empty conditional holder tables to a PHP override ofMutatingScope::addConditionalExpressions().setAccessible(true)beforeReflectionProperty::getValue()(required on PHP < 8.1, where the old-PHPUnit jobs failed withCannot access non-public member). I added the matching ignore tobuild/php-85.neon, like the one forObjectTypeTest.zv.hchange reverted: thearray{}data set exits with 138 (SIGBUS) on macOS arm64. With the change it passes, and without the extension it passes either way.positionalNames()didZ_ADDREF_Pon each parameter's Variable name. The native parser allocates those strings, but names built by PHP code (e.g. anew Variable('foo')literal) are interned.Z_TRY_ADDREF_P.PhpDocsResolverTestbuilds the method node in PHP. Before the fix it exits with 138 under-d opcache.enable_cli=1 -d opcache.protect_memory=1with the extension loaded; after the fix it passes. CI doesn't run withprotect_memory, so there the test only checks the resolved parameter types. It won't catch a regression of the refcount write itself.ZVAL_ARR+Z_ADDREFon a borrowed table withzv::Arr::copyOfTable().keysBySlotentries are always separated tables. Hardening only.HT_ASSERT_RC1on debug PHP builds. Taking the snapshot beforeseparate()fixes it, still with one duplication.make bump-turboAudit
I checked every other
ZVAL_ARR/RETURN_ARR, every refcount change on arrays, the raw array writes and everyzv::Argsuse inturbo-ext/src. The other sites wrap freshly allocated tables, already mark shared constants as non-refcounted, or only read the table.Closes #6558
🤖 Generated with Claude Code
https://claude.ai/code/session_01EKX5yrrUnnbeRtc2oURC2u