fix: user opcode handlers under PHP 8.6's tail-call VM (macOS arm64) - #281
lisachenko wants to merge 10 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 |
What
Fixes #280 on z-engine's side: on PHP 8.6 builds using the new tail-call VM (
ZEND_VM_KIND_TAILCALL— clang without global-register support, notably Apple Silicon macOS), user opcode handlers mis-resume execution inside the engine and corrupt the process. The diagnostics on this branch reduced it to a php-src bug (pure ext-ffi repro, no z-engine code — see the upstream-ready report on #280): the generatedZEND_USER_OPCODE_SPEC_TAILCALL_HANDLERreturns the single-step dispatch result up the musttail chain andexecute_ex()resumes it against its stale entry frame.Until php-src resolves it, z-engine fails fast instead of corrupting the debuggee:
Core::vmKind()— reportszend_vm_kind()through a dedicated one-symbol FFI binding (usable beforeinit(), no generated-header changes), withVM_KIND_*constants mirroringZend/zend_vm_opcodes.hOpCodeHook::install()throwsOpCodeHookException::tailCallVmUnsupported()onVM_KIND_TAILCALL, naming the issueOpCodeHookVmKindGuardTestruns in every CI leg (deliberately not in theinternalgroup — the existing opcode-hook tests are internal-only, which is why the macOS legs never caught this): on tail-call builds it asserts the refusal, elsewhere the unchanged install/uninstall lifecycleDiagnostics kept as the upstream repro harness
tools/diagnostics/issue-280/(probe ladder +pure-ffi-repro.php) and the now dispatch-onlyDiagnose issue 280workflow stay in-tree: re-dispatching it against a newer PHP build answers "is the php-src bug fixed yet"; both go away with the guard once upstream resolves it.Verification
vm_kind=5, SIGSEGV) proving the underlying bug is engine-level — run 33257997984Downstream: lisachenko/zdebug#24 keeps its macOS arm64 + 8.6 leg experimental until upstream is fixed; with this guard, consumers get a clear
OpCodeHookExceptioninstead of corrupted debuggees.🤖 Generated with Claude Code
https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG