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
@lisachenko

Copy link
Copy Markdown
Owner Author

@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

Copy link
Copy Markdown
Owner Author

Searched php/php-src issues and PRs (queries: zend_set_user_opcode_handler, ZEND_USER_OPCODE_DISPATCH, USER_OPCODE, "user opcode handler" + tailcall/tail-call, plus web search) — no existing report covers this bug. Nobody has filed the user-opcode-handler mis-resume under the tail-call VM yet, so the report drafted on #280 is still worth submitting upstream.

Closest related upstream items (same bug class — the tail-call VM returning a next-opline/tagged value that the caller mishandles — but all about vm_interrupt, not user opcode handlers):

  • php/php-src#17849 — "Tail call VM" by arnaud-lb: the PR that introduced the tail-call VM (handlers return the next opline up the musttail chain), useful context and the right people to CC (arnaud-lb, morrisonlevi).
  • php/php-src#21922 — "Fix tailcall helper dispatch after VM interrupt" (merged, PHP-8.5): ZEND_VM_DISPATCH_TO_HELPER() dereferenced an opline tagged with ZEND_VM_ENTER_BIT returned by a helper during an interrupt — precisely the same tagged/next-opline mishandled by the resuming code pattern our diagnosis found in ZEND_USER_OPCODE_SPEC_TAILCALL_HANDLER's ZEND_USER_OPCODE_DISPATCH path.
  • php/php-src#22265 — "Fix another vm_interrupt bug for tailcall VM" (merged, PHP-8.5): a follow-up crash in the same area (missing SAVE_OPLINE before ZEND_VM_LOOP_INTERRUPT).

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 (tools/diagnostics/issue-280/pure-ffi-repro.php, no z-engine code involved), and the analysis in the #280 comment. I can't file it from this session (no access to php/php-src), so that comment is ready to copy-paste as the issue body.


Generated by Claude Code

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