Repository navigation
feat: PHP 8.6 support for user opcode hooks (tail-call VM bug diagnosed, fixed upstream) - #281
lisachenko wants to merge 14 commits into
Conversation
PHP 8.6 on macOS arm64 (the clang/aarch64 tail-call VM build) corrupts VM state when a user opcode handler re-enters PHP (#280, found via lisachenko/zdebug#24). The probes install a raw zend_set_user_opcode_handler callback - no OpCodeHook, no ExecutionData - and climb from a handler that touches nothing to a per-fire dump of EG(current_execute_data) chaining, vm_stack_top/end and the interrupted frame, with an ADD-without-EXT_STMT baseline. The temporary diagnose-280 workflow runs the ladder on macos-latest (arm64, failing) and macos-15-intel (x64, control), plus an lldb backtrace of the failing shape. All modes pass on linux-x64 8.6 (hybrid VM). Probes and workflow are removed once the fix lands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG
…cktrace The first arm64 run segfaulted in every mode with zero output, so the crash point is unknown (lldb's -k commands produced no backtrace and the nearest-symbol frame zend_class_init_statics is unreliable for the static TAILCALL handlers). Stderr stage markers now bracket the crash (autoload / init / options / installed / payload-first-statement / payload-done / uninstalled), install-only separates handler installation from the first dispatch, the probe loop tolerates crashes so all modes report, and lldb uses -o so bt/registers/disassembly actually print. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG
The generated ZEND_USER_OPCODE_SPEC_TAILCALL_HANDLER dispatches through the single-step (fastcall) handler table and returns its next-opline result straight up the musttail chain; execute_ex() only refreshes its execute_data local in the ZEND_VM_ENTER_BIT branch, so a plain returned opline resumes execution against the frame execute_ex was ENTERED with. Any user opcode handler firing in a deeper frame (an include, any function call) then executes the following oplines against the wrong frame: wrong run-time cache (calls dispatch to the wrong function), wrong CVs, and eventually SIGSEGV. Hybrid/call VM builds are immune (opline and frame live in global registers / per-op returns), which is why only clang-without-global-regs builds - notably Apple Silicon macOS - are affected. Diagnosed on the arm64 runner via the layered probes in tools/diagnostics/issue-280 (a payload fwrite executed as the outer frame's cached unlink is the smoking gun); this is a php-src bug to be reported upstream. Until php-src resolves it, z-engine fails fast instead of corrupting the debuggee: - Core::vmKind() reports zend_vm_kind() through a dedicated one-symbol FFI binding (usable before init(), no generated-header changes), with the VM_KIND_* constants mirroring Zend/zend_vm_opcodes.h - OpCodeHook::install() throws OpCodeHookException::tailCallVmUnsupported() on VM_KIND_TAILCALL with a message naming the issue - OpCodeHookVmKindGuardTest runs in every CI leg (deliberately not in the internal group): on tail-call builds it asserts the refusal, on every other build the unchanged install/uninstall lifecycle - README/AGENTS document the platform caveat Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG
…VM bug Reduces the arm64 corruption to ext-ffi against the engine's exported API alone (an ADD user handler returning DISPATCH, fired inside a function frame) - the shape the upstream php-src report needs. Green on hybrid-VM builds (linux 8.5/8.6); the diagnose workflow verifies the crash on the arm64 tail-call build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG
The arm64 verification is done (probe run 33256998594, pure-FFI repro run 33257997984): the corruption is php-src's tail-call VM bug and the OpCodeHook guard covers consumers. Keep the probes and the workflow as the upstream repro harness, but stop running them on every pull request - a manual dispatch against a new PHP build answers 'is it fixed yet'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG
|
@claude Look for the relevant bug report for PHP at https://github.com/php/php-src/ (issues + PRs) and link it here if you will find anything relevant |
|
Searched php/php-src issues and PRs (queries: Closest related upstream items (same bug class — the tail-call VM returning a next-opline/tagged value that the caller mishandles — but all about
Those two merged fixes show the pattern is a known weak spot, which should make the upstream report easy to triage — it can reference them, the pure-FFI repro ( Generated by Claude Code |
|
let's recheck if php has fixed this or not? |
|
Re-checked against the current php-src build (run 36771134832, dispatched on this branch): not fixed yet. Tested: PHP 8.6.0-dev @
I also checked the php-src side directly: no commit touching (The macos-15-intel control leg was still compiling PHP from source when I read the results; it's only the hybrid-VM control and doesn't affect the verdict. Re-dispatching this workflow stays the way to re-test future builds.) Generated by Claude Code |
setup-php currently has no PHP 8.6 build for macos-15-intel: the Set up
PHP step gives up after ~25 minutes ('Could not setup PHP 8.6'),
reproducibly (run 36771134832, both attempts), which fails the whole
dispatch and paints the PR red. The x64 leg is only the hybrid-VM
control - the arm64 leg alone answers 'is the php-src bug fixed yet'
(it still crashes on 8.6.0-dev@dc1145e, see PR #281) - so mark it
continue-on-error until setup-php ships 8.6 for Intel macOS again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG
|
The Pushed d88d693 marking the x64 leg Generated by Claude Code |
…ookTest PHPStan 2.2.16 (resolved fresh in CI - no lockfile) infers an empty new ArrayObject() as ArrayObject<*NEVER*, *NEVER*> and rejects the $log[0] read. Declare the generic type the closure actually appends, and assert the entry's presence instead of overriding the offsetGet null with an inline @var - assertNotNull() narrows it for real. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG
…ache
php-src's 8.6 branch grew four zend_executor_globals fields since the
committed snapshot (vm_stack_page_cache{,_count} and their fiber
counterparts; ZEND_MODULE_API_NO 20250926 -> 20260924, EG size
2336 -> 2368 on linux-x64), so every 'Generated headers up to date'
check went red against the current 8.6.0-dev build. This commit applies
the exact diff the generator produced in CI run 36777824429 (docker is
unavailable in this environment; the check re-derives and re-diffs these
files, so a mismatch would fail it again). The darwin artifacts are
refreshed by the 'Generate darwin headers' workflow dispatched right
after this push.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG
… page cache Same zend_executor_globals drift as the linux refresh (vm_stack page cache fields, API 20250926 -> 20260924; EG 2168 -> 2200 nts). These are the artifacts the 'Generate darwin headers' run 36779982938 produced on the arm64 runners; its commit job was skipped because the x64 legs cannot obtain PHP 8.6 from setup-php on macos-15-intel at the moment, so the arm64 artifacts are committed from the run's uploads instead. The darwin-x64 definitions stay at the previous snapshot until setup-php ships 8.6 for Intel macOS again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG
…n 80600 Resolves the TODO left when 2.2.9 still capped phpVersion at 80599; 2.2.16 accepts 80600 and the analysis is clean at level max. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG
|
Recovery complete — CI on php-src's 8.6 branch moved under this branch in two ways today, and PHPStan released 2.2.16 (no lockfile, so CI resolves fresh):
Remaining red, both outside this PR's control:
If you'd like, these two groups can be made non-blocking (same Generated by Claude Code |
|
Reported the bug to PHP itself: php/php-src#24081 |
|
Should be resolved once php/php-src#24109 will be merged into the main branches |
|
relevant bug in php has been fixed, but not released yet, available only from source |
|
Re-ran the diagnostics against the current nightly (run 38067916786) — verdict in two parts: 1. The fix itself isn't in a testable binary yet. The arm64 nightly setup-php ships is 8.6.0-dev @ 2. Independently, the current arm64 nightly no longer selects the tail-call VM at all: Practical consequences:
Generated by Claude Code |
setup-php ships PHP 8.6 builds for macos-15-intel again (run 38067916786 installed one in seconds), so the continue-on-error escape hatch from d88d693 no longer has a reason to exist - the control leg can get PHP and should fail the run loudly when it misbehaves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG
…tress tests PHPStan 2.3.1 (resolved fresh in CI - no lockfile) folds two tests whose whole point is that engine handlers make the static semantics wrong at runtime: the get-iterator leak scenario iterates a stub that deliberately is not Traversable (statically foreach yields nothing, so the comparison chain collapses to always-true/unreachable), and the resolution-handler stress loop reads (array)$proxy as the last statically-visible write instead of the handler-rebuilt table. Exclude the subprocess scenario like its throw-hook sibling and scope two ignores to the test file, per the conventions already in this config. Verified clean at level max with 2.3.1 locally. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG
#280) php/php-src#24109 (merged into PHP-8.6 as 2cf7ad0) makes the generated ZEND_USER_OPCODE_SPEC_TAILCALL_HANDLER resume the DISPATCH result inside the tail-call chain instead of returning it to execute_ex()'s stale frame, so OpCode::setHandler() no longer needs to refuse on VM_KIND_TAILCALL. Remove the guard, its exception factory, the issue-280 probe ladder and the dispatch-only diagnose workflow; keep Core::vmKind() as plain engine introspection, and keep the every-leg hook coverage the guard test introduced as OpCodeHookLifecycleTest (the gap that let #280 ship unnoticed was that all other hook tests are internal-group only). Docs now record the bug as fixed upstream rather than as a live caveat. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG
|
Cleanup CI on Heads-up from the same verification: the intel build of the current 8.6 nightly ( One follow-up suggestion (out of scope for this PR): dependency drift broke CI three times while this PR was open (PHPStan 2.2.9→2.2.16→2.3.1, each requiring fixes). Committing a Generated by Claude Code |
What
Closes #280. This branch diagnosed the PHP 8.6 tail-call VM corruption of user opcode handlers down to a php-src bug (pure ext-ffi repro, no z-engine code — report), got it filed and fixed upstream (php/php-src#24081 → php/php-src#24109, merged into PHP-8.6 as
2cf7ad0), and now ships the final 8.6 state with the interim guard retired:Core::vmKind()— engine VM-dispatch-kind introspection (zend_vm_kind()through a dedicated one-symbol FFI binding, usable beforeinit()), withVM_KIND_*constants mirroringZend/zend_vm_opcodes.hOpCodeHookLifecycleTest— install/uninstall coverage in every CI leg (deliberately not in theinternalgroup; all other hook tests are internal-only, which is the gap that let User opcode handlers corrupt VM state under PHP 8.6's tail-call VM (macOS arm64) #280 ship unnoticed)phpVersion: 80600The interim
OpCodeHook::install()guard, its exception factory, the issue-280 probe ladder and the dispatch-only diagnose workflow were removed once upstream merged the fix — current arm64 nightlies run engine hooks cleanly (verified: full probe ladder + pure-FFI repro pass, run 38067916786).Verification
eefe4db): hook probes and pure-FFI repro pass end-to-end; full CI matrix on this branchGenerated headers up to datecheck races php-src HEAD (it compiles php-src from source at check time, while the committed headers deliberately match the nightlies the test legs run); it re-syncs with onegen-headersrun once setup-php ships builds with the new module-ABI fieldsDownstream: lisachenko/zdebug#24 flips its macOS arm64 + 8.6 leg back to required.
🤖 Generated with Claude Code
https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG