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.
Summary
On macOS arm64 with PHP 8.6 (Homebrew/setup-php build), installing a user opcode handler via
OpCode::setHandler()onEXT_STMT(withCompiler::COMPILE_EXTENDED_STMTenabled) corrupts VM state as soon as the handler's PHP closure runs: closure bound variables read back as garbage (e.g. anintcounter reads as aZEngine\Coreobject), and full consumers (lisachenko/zdebug) segfault (EXC_BAD_ACCESS at 0x10insideZEND_DO_UCALL_SPEC_RETVAL_USED_TAILCALL_HANDLER).The same code is green on:
z-engine's own CI is green on macOS arm64 + 8.6 because
OpCodeHookTesthooksADDinside a small probe function withoutCOMPILE_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.hin php-8.6.0beta2):HAVE_GCC_GLOBAL_REGS, so the build selects TAILCALL: opcode handlers becomeconst zend_op *(*)(zend_execute_data *, const zend_op *)with thepreserve_nonecalling convention, chained viamusttail. 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 classicint (*)(zend_execute_data *)signature fromZEND_USER_OPCODE_SPEC_TAILCALL_HANDLER, so the trampoline installation itself works (a trivial handler onADDfires 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, nestedexecute_ex, and thepreserve_nonehandler 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:Run with
php -d ffi.enable=1 -d opcache.jit=off probe.phpon macOS arm64 + PHP 8.6.0-dev:The by-ref
use (&$fires)int reads back as aZEngine\Coreobject on the very first handler invocation. AddingRETURN/THROWhandlers fails the same way (Cannot use object of type ZEngine\Core as arrayon an array counter). On PHP 8.5 (same machine, same script) everything passes.Crash signature from a full consumer (zdebug)
(reading offset 0x10 of a NULL
zend_function, i.e. a call frame whosefuncwas clobbered). Downstream CI evidence: lisachenko/zdebug#24 — the diagnostic jobDiagnose arm64 (PHP 8.5/8.6)in run https://github.com/lisachenko/zdebug/actions/runs/33254290174 shows the layered probes (L1Core::init✅, L2COMPILE_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.ipscrash report.Suggested directions
ZEND_VM_KIND_TAILCALLatCore::init()(e.g. viaphp -i/Reflectionon the build metadata or a runtime probe) and fail fast with a clear message instead of corrupting the debuggee.