Skip to content

fix: user opcode handlers under PHP 8.6's tail-call VM (macOS arm64) - #281

Draft
lisachenko wants to merge 5 commits into
masterfrom
claude/zdebug-php-8.6-testing-2pwowy
Draft

fix: user opcode handlers under PHP 8.6's tail-call VM (macOS arm64)#281
lisachenko wants to merge 5 commits into
masterfrom
claude/zdebug-php-8.6-testing-2pwowy

Conversation

@lisachenko

@lisachenko lisachenko commented Aug 29, 2026

Copy link
Copy Markdown
Owner

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 generated ZEND_USER_OPCODE_SPEC_TAILCALL_HANDLER returns the single-step dispatch result up the musttail chain and execute_ex() resumes it against its stale entry frame.

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 VM_KIND_* constants mirroring Zend/zend_vm_opcodes.h
  • OpCodeHook::install() throws OpCodeHookException::tailCallVmUnsupported() on VM_KIND_TAILCALL, naming the issue
  • OpCodeHookVmKindGuardTest runs in every CI leg (deliberately not in the internal group — 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 lifecycle
  • README/AGENTS document the platform caveat

Diagnostics kept as the upstream repro harness

tools/diagnostics/issue-280/ (probe ladder + pure-ffi-repro.php) and the now dispatch-only Diagnose issue 280 workflow 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

  • macOS arm64 (tail-call VM): full CI green with the guard test taking the refusal branch; the pure-FFI repro crashes there (vm_kind=5, SIGSEGV) proving the underlying bug is engine-level — run 33257997984
  • macOS x64 / linux (hybrid VM): all probes and the repro pass; full suite, PHPStan level max, cs — green
  • Probe evidence for the mechanism (stage-marked ladder, lldb, crash reports): run 33256998594

Downstream: lisachenko/zdebug#24 keeps its macOS arm64 + 8.6 leg experimental until upstream is fixed; with this guard, consumers get a clear OpCodeHookException instead of corrupted debuggees.


🤖 Generated with Claude Code

https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG

claude added 4 commits August 29, 2026 14:06
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
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.

2 participants