Skip to content

User opcode handlers corrupt VM state under PHP 8.6's tail-call VM (macOS arm64) #280

Description

@lisachenko

Summary

On macOS arm64 with PHP 8.6 (Homebrew/setup-php build), installing a user opcode handler via OpCode::setHandler() on EXT_STMT (with Compiler::COMPILE_EXTENDED_STMT enabled) corrupts VM state as soon as the handler's PHP closure runs: closure bound variables read back as garbage (e.g. an int counter reads as a ZEngine\Core object), and full consumers (lisachenko/zdebug) segfault (EXC_BAD_ACCESS at 0x10 inside ZEND_DO_UCALL_SPEC_RETVAL_USED_TAILCALL_HANDLER).

The same code is green on:

  • macOS arm64 + PHP 8.5 (identical probe run as control — all steps pass)
  • macOS x64 (Intel) + PHP 8.6 — same PHP source build
  • Linux x64 + PHP 8.6

z-engine's own CI is green on macOS arm64 + 8.6 because OpCodeHookTest hooks ADD inside a small probe function without COMPILE_EXTENDED_STMT; the corruption shows once EXT_STMT-instrumented code (compiled after the handler is installed) starts dispatching through the hook.

Why only arm64 + 8.6

PHP 8.6 introduced a new VM dispatch kind, ZEND_VM_KIND_TAILCALL (Zend/zend_vm_opcodes.h in php-8.6.0beta2):

#elif (defined(__GNUC__) && defined(HAVE_GCC_GLOBAL_REGS))
# define ZEND_VM_KIND  ZEND_VM_KIND_HYBRID
#elif defined(HAVE_MUSTTAIL) && defined(HAVE_PRESERVE_NONE) && (defined(__x86_64__) || defined(_M_X64) || defined(__aarch64__)) && defined(__clang__)
# define ZEND_VM_KIND  ZEND_VM_KIND_TAILCALL
#endif
  • Linux (gcc) and macOS x64 (clang with global register support) build the HYBRID VM — unaffected.
  • macOS arm64 clang has no HAVE_GCC_GLOBAL_REGS, so the build selects TAILCALL: opcode handlers become const zend_op *(*)(zend_execute_data *, const zend_op *) with the preserve_none calling convention, chained via musttail. The crash frames (*_TAILCALL_HANDLER) confirm the failing build runs this VM.

zend_user_opcode_handlers[opline->opcode](execute_data) is still called with the classic int (*)(zend_execute_data *) signature from ZEND_USER_OPCODE_SPEC_TAILCALL_HANDLER, so the trampoline installation itself works (a trivial handler on ADD fires fine). The breakage appears when the handler re-enters PHP execution (the FFI callback runs a userland closure) while the interrupted frame's opcode came from EXT_STMT-instrumented code — pointing at an interaction between libffi callback re-entry, nested execute_ex, and the preserve_none handler chain. It may ultimately be a php-src beta bug rather than z-engine's, but z-engine is where consumers hit it, and where a repro/workaround can live.

Minimal repro (pure z-engine, no consumer code)

probe.php:

<?php
require __DIR__ . '/vendor/autoload.php';

use ZEngine\Core;
use ZEngine\System\Compiler;
use ZEngine\System\OpCode;

Core::init();
Core::$compiler->setOptions(Core::$compiler->getOptions() | Compiler::COMPILE_EXTENDED_STMT);

$fires = 0;
OpCode::setHandler(OpCode::EXT_STMT, function ($scope) use (&$fires): int {
    $fires++;
    return Core::ZEND_USER_OPCODE_DISPATCH;
});

require __DIR__ . '/payload.php'; // any file: a class, new, a method call, a function call
echo "EXT_STMT fires: {$fires}\n";

Run with php -d ffi.enable=1 -d opcache.jit=off probe.php on macOS arm64 + PHP 8.6.0-dev:

PHP Warning:  Uncaught TypeError: Cannot increment ZEngine\Core in probe.php:12
PHP Fatal error:  Throwing from FFI callbacks is not allowed in payload.php on line 15

The by-ref use (&$fires) int reads back as a ZEngine\Core object on the very first handler invocation. Adding RETURN/THROW handlers fails the same way (Cannot use object of type ZEngine\Core as array on an array counter). On PHP 8.5 (same machine, same script) everything passes.

Crash signature from a full consumer (zdebug)

exception: EXC_BAD_ACCESS / SIGSEGV, KERN_INVALID_ADDRESS at 0x0000000000000010
frame 0: ZEND_DO_UCALL_SPEC_RETVAL_USED_TAILCALL_HANDLER + 116
frame 1: execute_ex + 156
frame 2: zend_execute.cold.1 / zend_execute / zend_execute_script / php_execute_script_ex

(reading offset 0x10 of a NULL zend_function, i.e. a call frame whose func was clobbered). Downstream CI evidence: lisachenko/zdebug#24 — the diagnostic job Diagnose arm64 (PHP 8.5/8.6) in run https://github.com/lisachenko/zdebug/actions/runs/33254290174 shows the layered probes (L1 Core::init ✅, L2 COMPILE_EXTENDED_STMT ✅, L3 EXT_STMT handler ❌ on 8.6 / ✅ on 8.5, L5 module registration ✅, L6 full boot ❌ SIGSEGV) plus the lldb backtrace and the macOS .ips crash report.

Suggested directions

  • Reproduce with a C user opcode handler (no FFI) to split "TAILCALL VM vs user opcode handlers re-entering the VM" (a php-src bug worth reporting upstream while 8.6 is in beta) from "TAILCALL VM vs FFI callback trampolines" (z-engine-side).
  • If it is php-src's: report before 8.6.0 GA; the tail-call VM is new in this cycle.
  • Until resolved, consumers on Apple Silicon can be pointed at a HYBRID/CALL-VM PHP build; z-engine could also detect ZEND_VM_KIND_TAILCALL at Core::init() (e.g. via php -i/Reflection on the build metadata or a runtime probe) and fail fast with a clear message instead of corrupting the debuggee.

Activity

  1. lisachenko commented on Aug 29, 2026

    @lisachenko
    OwnerAuthor

    Root cause found — this is a php-src bug in the 8.6 tail-call VM, not a z-engine bug. z-engine now fails fast on affected builds (PR #281); below is a ready-to-file upstream report for php/php-src (I can't open issues there from this session — please forward).


    Draft php-src report

    Title: User opcode handlers resume execution against a stale frame under ZEND_VM_KIND_TAILCALL

    Version: PHP 8.6.0beta2 / 8.6.0-dev (any build selecting ZEND_VM_KIND_TAILCALL, i.e. clang with musttail/preserve_none but without HAVE_GCC_GLOBAL_REGS — notably all Homebrew/macOS arm64 builds). Hybrid and call VM builds of the same source are unaffected.

    Summary: A user opcode handler (zend_set_user_opcode_handler) that returns ZEND_USER_OPCODE_DISPATCH corrupts execution when the hooked opcode executes in any frame other than the one execute_ex() was entered with (any function call, any include). Symptoms range from calls dispatching to the wrong function (stale run-time cache slots of the wrong frame) to SIGSEGV in ZEND_DO_UCALL_SPEC_RETVAL_USED_TAILCALL_HANDLER (KERN_INVALID_ADDRESS at 0x10, i.e. a NULL zend_function deref).

    Minimal repro (only ext-ffi, php -d ffi.enable=1 -d opcache.jit=off repro.php):

    <?php
    $engine = FFI::cdef('
        typedef int (*user_opcode_handler_t)(void *execute_data);
        int zend_vm_kind(void);
        int zend_set_user_opcode_handler(unsigned char opcode, user_opcode_handler_t handler);
    ');
    echo 'vm_kind=', $engine->zend_vm_kind(), "\n";  // 5 = ZEND_VM_KIND_TAILCALL
    
    // ZEND_ADD = 1; ZEND_USER_OPCODE_DISPATCH = 2
    $handler = static function ($executeData): int { return 2; };
    $engine->zend_set_user_opcode_handler(1, $handler);
    
    $payload = tempnam(sys_get_temp_dir(), 'p') . '.php';
    file_put_contents($payload, '<?php function f(int $a, int $b): int { return $a + $b; } var_dump(f(1, 2));');
    require $payload;   // compiled after install => its ADD dispatches through the handler
    unlink($payload);
    echo "OK\n";

    On hybrid/call builds: int(3) + OK. On a TAILCALL build (macOS arm64): vm_kind=5 then SIGSEGV at the first ADD inside f(). Verified on GitHub's macos-latest (arm64, crash) vs macos-15-intel (x64, hybrid, passes): https://github.com/lisachenko/z-engine/actions/runs/33257997984

    Analysis (Zend/zend_vm_execute.h as generated for 8.6.0beta2):

    ZEND_USER_OPCODE_SPEC_TAILCALL_HANDLER's continuation cases use the generic ZEND_VM_DISPATCH macro, which is never redefined for the tail-call section:

    #define ZEND_VM_DISPATCH(opcode, opline) return zend_vm_get_opcode_handler_func(opcode, opline)(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU);
    ...
    case ZEND_USER_OPCODE_DISPATCH:
        ZEND_VM_DISPATCH(opline->opcode, opline);
    default:
        ZEND_VM_DISPATCH((uint8_t)(ret & 0xff), opline);

    zend_vm_get_opcode_handler_func() resolves the single-step fastcall variant (execute one op, return the next opline / ENTER_BIT state). The tail-call handler then plain-returns that value — which, because every frame transition inside the chain happened via musttail, unwinds straight to execute_ex(). Its loop:

    opline = (opline->handler)(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU);
    if (UNEXPECTED(((uintptr_t)opline & ZEND_VM_ENTER_BIT))) {
        opline = (const zend_op*)((uintptr_t)opline & ~ZEND_VM_ENTER_BIT);
        if (EXPECTED(opline != NULL)) {
            execute_data = EG(current_execute_data);   /* refreshed ONLY here */
            ...

    only refreshes its execute_data local in the ENTER_BIT branch. A user opcode returning DISPATCH for a normal advancing opcode yields a plain next-opline, so the loop's next iteration executes the current frame's oplines against execute_ex's entry frame. With mismatched EX(run_time_cache) the very next INIT_FCALL/DO_*CALL resolves a cached callee of the wrong frame — in our traces a fwrite(STDERR, "...") in the included file executed as the outer script's cached unlink() (unlink(): Argument #2 ($context) must be of type resource or null, string given with the outer file's name and the inner file's line), and object construction dispatched to an unrelated class's constructor before the SIGSEGV. ZEND_USER_OPCODE_CONTINUE/ENTER/LEAVE are unaffected (they stay on the musttail chain / go through ZEND_VM_ENTER()); DISPATCH of frame-changing opcodes survives by luck via the ENTER_BIT path.

    Suggested fix: in the tail-call variant of the ZEND_USER_OPCODE handler (via zend_vm_gen.php), don't return the single-step result up the chain — resume it:

    case ZEND_USER_OPCODE_DISPATCH:
        opline = zend_vm_get_opcode_handler_func(opline->opcode, opline)(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU);
        goto user_opcode_resume;
    default:
        opline = zend_vm_get_opcode_handler_func((uint8_t)(ret & 0xff), opline)(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU);
    user_opcode_resume:
        if (UNEXPECTED(((uintptr_t)opline & ZEND_VM_ENTER_BIT))) {
            opline = (const zend_op*)((uintptr_t)opline & ~ZEND_VM_ENTER_BIT);
            if (UNEXPECTED(opline == NULL)) {
                ZEND_VM_RETURN();
            }
            execute_data = EG(current_execute_data);
        }
        ZEND_VM_CONTINUE();

    (zend_vm_call_opcode_handler()'s TAILCALL branch performs exactly this unwrap, so the same pattern applied inside the handler restores the invariant.)


    Supporting evidence from the diagnostics on this branch (tools/diagnostics/issue-280/, runs on PR #281):

    • install-only passes; every mode that lets the handler fire in a nested frame corrupts (probe run: https://github.com/lisachenko/z-engine/actions/runs/33256998594)
    • crash report: EXC_BAD_ACCESS KERN_INVALID_ADDRESS at 0x10 in ZEND_DO_UCALL_SPEC_RETVAL_USED_TAILCALL_HANDLER
    • identical probes green on PHP 8.5 arm64 and PHP 8.6 x64/linux

    Generated by Claude Code

  2. added a commit that references this issue on Aug 29, 2026
  3. lisachenko commented on Oct 2, 2026

    @lisachenko
    OwnerAuthor

    Filed upstream: php/php-src#24081 — user opcode handlers returning ZEND_USER_OPCODE_DISPATCH mis-resume execution against a stale frame under the tail-call VM.

    Reference points for anyone following along:


    Generated by Claude Code

  4. lisachenko commented on Oct 2, 2026

    @lisachenko
    OwnerAuthor

    The bug affects the tail-call VM (ZEND_VM_KIND_TAILCALL) as such and has been present since the VM landed in 8.5.0 — the generated handler and execute_ex() loop are identical in PHP-8.5 and PHP-8.6. Reproduced on 8.6.0-dev builds that select the tail-call VM (vm_kind=5); 8.5 binaries in the wild are typically unaffected only because common builds don't satisfy the HAVE_MUSTTAIL/HAVE_PRESERVE_NONE selection and fall back to the CALL VM. Any 8.5 build that does select it should reproduce with the same ext-ffi script.

  5. lisachenko commented on Oct 4, 2026

    @lisachenko
    OwnerAuthor

    Will be fixed once php/php-src#24109 merged. @claude We can try to use this branch to see if it fixes the bug or not. Report here the results.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions