From b3085b34655fef396ab50e8f6e20e8416d694fe5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 14:06:13 +0000 Subject: [PATCH 1/5] chore(diagnostics): add issue #280 probe ladder + temporary CI workflow 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 Claude-Session: https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG --- .github/workflows/diagnose-280.yml | 96 +++++++++++++++++ tools/diagnostics/issue-280/payload.php | 47 ++++++++ tools/diagnostics/issue-280/probe.php | 138 ++++++++++++++++++++++++ 3 files changed, 281 insertions(+) create mode 100644 .github/workflows/diagnose-280.yml create mode 100644 tools/diagnostics/issue-280/payload.php create mode 100644 tools/diagnostics/issue-280/probe.php diff --git a/.github/workflows/diagnose-280.yml b/.github/workflows/diagnose-280.yml new file mode 100644 index 0000000..cd00159 --- /dev/null +++ b/.github/workflows/diagnose-280.yml @@ -0,0 +1,96 @@ +# TEMPORARY: diagnostics for issue #280 (user opcode handlers corrupt VM state +# under PHP 8.6's tail-call VM on macOS arm64). Runs the tools/diagnostics/issue-280 +# probe ladder on the failing runner (macos-latest, arm64) with macos-15-intel as +# the in-run control. Removed together with the probes once the fix lands. +name: Diagnose issue 280 + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: diagnose-280-${{ github.ref }} + cancel-in-progress: true + +env: + PHP_MINOR: '8.6' + +jobs: + probes: + name: Probes (${{ matrix.runner-arch.arch }}) + runs-on: ${{ matrix.runner-arch.runner }} + env: + HOMEBREW_NO_AUTO_UPDATE: '1' + HOMEBREW_NO_INSTALL_CLEANUP: '1' + ZENGINE_STRICT_LAYOUT_CHECK: '1' + PHP_FLAGS: -d ffi.enable=1 -d zend.assertions=1 -d opcache.enable_cli=0 -d opcache.jit=off + strategy: + fail-fast: false + matrix: + runner-arch: + - { runner: macos-latest, arch: arm64 } + - { runner: macos-15-intel, arch: x64 } + steps: + - uses: actions/checkout@v7 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_MINOR }} + extensions: ffi, opcache + ini-values: ffi.enable=1, zend.assertions=1, opcache.enable=1, opcache.enable_cli=0, opcache.jit=off, opcache.jit_buffer_size=0 + coverage: none + + - name: Environment report + run: | + uname -m + php -v + php -r 'echo "ZEND_THREAD_SAFE=", var_export(ZEND_THREAD_SAFE, true), " PHP_DEBUG=", PHP_DEBUG, PHP_EOL;' + + - name: Install dependencies + uses: ramsey/composer-install@v4 + + - name: 'Probe: noop (handler touches nothing)' + if: always() + run: php $PHP_FLAGS tools/diagnostics/issue-280/probe.php noop; echo "exit=$?" + + - name: 'Probe: log-const (one internal call per fire)' + if: always() + run: php $PHP_FLAGS tools/diagnostics/issue-280/probe.php log-const; echo "exit=$?" + + - name: 'Probe: globals (symbol-table counter)' + if: always() + run: php $PHP_FLAGS tools/diagnostics/issue-280/probe.php globals; echo "exit=$?" + + - name: 'Probe: use-ref (by-ref closure counter, the failing shape)' + if: always() + run: php $PHP_FLAGS tools/diagnostics/issue-280/probe.php use-ref; echo "exit=$?" + + - name: 'Probe: diag (engine invariants per fire)' + if: always() + run: php $PHP_FLAGS tools/diagnostics/issue-280/probe.php diag; echo "exit=$?" + + - name: 'Probe: add-baseline (ADD handler, no extended stmt)' + if: always() + run: php $PHP_FLAGS tools/diagnostics/issue-280/probe.php add-baseline; echo "exit=$?" + + - name: 'use-ref under lldb (backtrace on crash)' + if: always() + run: | + lldb --batch \ + -o 'run' \ + -k 'thread backtrace all' \ + -k 'quit' \ + -- "$(which php)" $PHP_FLAGS tools/diagnostics/issue-280/probe.php use-ref || true + + - name: macOS crash reports + if: always() + run: | + for f in ~/Library/Logs/DiagnosticReports/php*; do + [ -e "$f" ] || continue + echo "===== $f =====" + head -c 6000 "$f" + done diff --git a/tools/diagnostics/issue-280/payload.php b/tools/diagnostics/issue-280/payload.php new file mode 100644 index 0000000..0098e0f --- /dev/null +++ b/tools/diagnostics/issue-280/payload.php @@ -0,0 +1,47 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + * Deterministic debuggee for the issue #280 probes. Compiled AFTER the probe + * installs its handler (and, for the EXT_STMT modes, after COMPILE_EXTENDED_STMT + * is switched on), so every statement here dispatches through the hook. + */ +declare(strict_types=1); + +class Probe280Service +{ + public function handle(int $value): int + { + $doubled = $value * 2; + try { + if ($value > 100) { + throw new RuntimeException('expected'); + } + } catch (RuntimeException) { + $doubled += 200; + } + + return $doubled; + } +} + +function probe280Helper(int $value): int +{ + return $value + 1; +} + +$service = new Probe280Service(); +$total = 0; +foreach ([1, 2] as $value) { + $total += $service->handle($value); +} +$total += probe280Helper(5); +$total += $service->handle(101); + +echo 'PAYLOAD TOTAL=' . $total . "\n"; // 2 + 4 + 6 + 402 = 414, the canary diff --git a/tools/diagnostics/issue-280/probe.php b/tools/diagnostics/issue-280/probe.php new file mode 100644 index 0000000..1afb215 --- /dev/null +++ b/tools/diagnostics/issue-280/probe.php @@ -0,0 +1,138 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + * Standalone probes for issue #280: user opcode handlers corrupt VM state under + * PHP 8.6's tail-call VM (macOS arm64). Each mode installs the handler RAW via + * zend_set_user_opcode_handler - no OpCodeHook, no ExecutionData - so a failure + * points at the engine boundary itself, not at z-engine's wrapper logic. + * + * Run as: php -d ffi.enable=1 -d opcache.jit=off probe.php + * + * Modes, from most inert to most revealing: + * noop EXT_STMT handler whose body is `return 2;` - does the mere + * round trip through an FFI-callback PHP closure per statement + * corrupt the instrumented payload? + * log-const + one file_put_contents() on a literal path per fire (a real + * nested internal call, still no variables touched) + * globals + a $GLOBALS counter (symbol-table access, no closure statics) + * use-ref + a by-ref `use (&$fires)` counter - the shape that fails in + * lisachenko/zdebug#24's diagnostics + * diag per-fire dump of the engine invariants: the execute_data + * argument vs EG(current_execute_data), vm_stack_top/end, the + * frame's prev/func/opline pointers and the current opcode + * add-baseline ADD handler with the use-ref counter and NO extended-stmt + * compilation - the shape z-engine's own suite already proves + * + * Every handler returns 2 (ZEND_USER_OPCODE_DISPATCH) so the payload executes + * exactly as it would uninstrumented; PAYLOAD TOTAL=414 is the canary. + * Diagnostics go to /tmp/probe-280.log (literal on purpose: reading any PHP + * variable inside the handler is part of what is under test). + */ +declare(strict_types=1); + +require dirname(__DIR__, 3) . '/vendor/autoload.php'; + +use ZEngine\Core; +use ZEngine\System\Compiler; +use ZEngine\System\OpCode; + +const PROBE_LOG = '/tmp/probe-280.log'; + +$mode = $argv[1] ?? 'noop'; +@unlink(PROBE_LOG); + +Core::init(); + +$fires = 0; + +$handler = match ($mode) { + 'noop' => static function ($executeData): int { + return 2; + }, + 'log-const' => static function ($executeData): int { + file_put_contents('/tmp/probe-280.log', '.', FILE_APPEND); + + return 2; + }, + 'globals' => static function ($executeData): int { + $GLOBALS['probe280Fires'] = ($GLOBALS['probe280Fires'] ?? 0) + 1; + + return 2; + }, + 'use-ref', 'add-baseline' => static function ($executeData) use (&$fires): int { + $fires++; + + return 2; + }, + 'diag' => static function ($executeData): int { + try { + // Core::$engine is core-private; a diagnostic tool may reflect. Resolved + // per fire on purpose: closure captures are part of what is under test. + $engine = new \ReflectionProperty(Core::class, 'engine')->getValue(); + \assert($engine instanceof \FFI); + $eg = $engine->executor_globals; + $arg = Core::addressOf($executeData); + $ced = $eg->current_execute_data; + $prev = $executeData->prev_execute_data; + $func = $executeData->func; + $opl = $executeData->opline; + // Inside the handler EG(current_execute_data) is the handler closure's own + // frame; the invariant that must hold is that its prev_execute_data chains + // back to the interrupted frame (chain=1). vm_stack_top must sit above arg. + $cedPrev = $ced?->prev_execute_data; + $line = sprintf( + "arg=%x eg_ced=%x ced_prev=%x chain=%d top=%x end=%x prev=%x func=%x opline=%x opcode=%d\n", + $arg, + $ced === null ? 0 : Core::addressOf($ced), + $cedPrev === null ? 0 : Core::addressOf($cedPrev), + ($cedPrev !== null && Core::addressOf($cedPrev) === $arg) ? 1 : 0, + Core::addressOf($eg->vm_stack_top), + Core::addressOf($eg->vm_stack_end), + $prev === null ? 0 : Core::addressOf($prev), + $func === null ? 0 : Core::addressOf($func), + $opl === null ? 0 : Core::addressOf($opl), + $opl === null ? -1 : $opl->opcode, + ); + file_put_contents('/tmp/probe-280.log', $line, FILE_APPEND); + } catch (\Throwable $error) { + file_put_contents('/tmp/probe-280.log', 'EX: ' . $error->getMessage() . "\n", FILE_APPEND); + } + + return 2; + }, + default => throw new InvalidArgumentException("Unknown probe mode: {$mode}"), +}; + +$opCode = $mode === 'add-baseline' ? OpCode::ADD : OpCode::EXT_STMT; +if ($mode !== 'add-baseline') { + Core::$compiler->setOptions(Core::$compiler->getOptions() | Compiler::COMPILE_EXTENDED_STMT); +} + +$result = Core::call('zend_set_user_opcode_handler', $opCode, $handler); +if ($result === Core::FAILURE) { + fwrite(STDERR, "Failed to install the user opcode handler\n"); + exit(1); +} + +require __DIR__ . '/payload.php'; + +// Restore before engine shutdown: payload op_arrays still carry the opcode +Core::call('zend_set_user_opcode_handler', $opCode, null); + +echo "fires={$fires}\n"; +echo 'globals=' . ($GLOBALS['probe280Fires'] ?? 0) . "\n"; +if (is_file(PROBE_LOG)) { + $log = (string) file_get_contents(PROBE_LOG); + $lines = $log === '' ? [] : explode("\n", trim($log)); + echo 'log-lines=' . count($lines) . "\n"; + // The full trace is short for this payload; print it whole for CI logs + echo $log; +} +echo "MODE {$mode} DONE\n"; From c4e7b8feda61e23bf232850d922cedef94bf165b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 14:12:47 +0000 Subject: [PATCH 2/5] chore(diagnostics): stage markers, install-only mode, working lldb backtrace 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 Claude-Session: https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG --- .github/workflows/diagnose-280.yml | 51 +++++++++---------------- tools/diagnostics/issue-280/payload.php | 2 + tools/diagnostics/issue-280/probe.php | 19 ++++++++- 3 files changed, 37 insertions(+), 35 deletions(-) diff --git a/.github/workflows/diagnose-280.yml b/.github/workflows/diagnose-280.yml index cd00159..fd0a740 100644 --- a/.github/workflows/diagnose-280.yml +++ b/.github/workflows/diagnose-280.yml @@ -53,44 +53,29 @@ jobs: - name: Install dependencies uses: ramsey/composer-install@v4 - - name: 'Probe: noop (handler touches nothing)' - if: always() - run: php $PHP_FLAGS tools/diagnostics/issue-280/probe.php noop; echo "exit=$?" - - - name: 'Probe: log-const (one internal call per fire)' - if: always() - run: php $PHP_FLAGS tools/diagnostics/issue-280/probe.php log-const; echo "exit=$?" - - - name: 'Probe: globals (symbol-table counter)' - if: always() - run: php $PHP_FLAGS tools/diagnostics/issue-280/probe.php globals; echo "exit=$?" - - - name: 'Probe: use-ref (by-ref closure counter, the failing shape)' - if: always() - run: php $PHP_FLAGS tools/diagnostics/issue-280/probe.php use-ref; echo "exit=$?" - - - name: 'Probe: diag (engine invariants per fire)' - if: always() - run: php $PHP_FLAGS tools/diagnostics/issue-280/probe.php diag; echo "exit=$?" - - - name: 'Probe: add-baseline (ADD handler, no extended stmt)' - if: always() - run: php $PHP_FLAGS tools/diagnostics/issue-280/probe.php add-baseline; echo "exit=$?" + - name: Run every probe mode + run: | + for mode in install-only noop log-const globals use-ref diag add-baseline; do + echo "=== MODE ${mode} ===" + php $PHP_FLAGS tools/diagnostics/issue-280/probe.php "$mode" || echo "exit=$?" + done - - name: 'use-ref under lldb (backtrace on crash)' + - name: noop under lldb (backtrace on crash) if: always() run: | lldb --batch \ -o 'run' \ - -k 'thread backtrace all' \ - -k 'quit' \ - -- "$(which php)" $PHP_FLAGS tools/diagnostics/issue-280/probe.php use-ref || true + -o 'bt' \ + -o 'register read' \ + -o 'disassemble --pc --count 12' \ + -o 'quit' \ + -- "$(which php)" $PHP_FLAGS tools/diagnostics/issue-280/probe.php noop || true - - name: macOS crash reports + - name: Latest macOS crash report if: always() run: | - for f in ~/Library/Logs/DiagnosticReports/php*; do - [ -e "$f" ] || continue - echo "===== $f =====" - head -c 6000 "$f" - done + latest=$(ls -t ~/Library/Logs/DiagnosticReports/php* 2>/dev/null | head -1 || true) + if [ -n "$latest" ]; then + echo "===== $latest =====" + head -c 24000 "$latest" + fi diff --git a/tools/diagnostics/issue-280/payload.php b/tools/diagnostics/issue-280/payload.php index 0098e0f..443cb5c 100644 --- a/tools/diagnostics/issue-280/payload.php +++ b/tools/diagnostics/issue-280/payload.php @@ -14,6 +14,8 @@ */ declare(strict_types=1); +fwrite(STDERR, "STAGE payload-first-statement\n"); + class Probe280Service { public function handle(int $value): int diff --git a/tools/diagnostics/issue-280/probe.php b/tools/diagnostics/issue-280/probe.php index 1afb215..7867c83 100644 --- a/tools/diagnostics/issue-280/probe.php +++ b/tools/diagnostics/issue-280/probe.php @@ -48,12 +48,21 @@ $mode = $argv[1] ?? 'noop'; @unlink(PROBE_LOG); +/** Stage markers: stderr is unbuffered, so the last marker brackets a crash */ +function stage(string $name): void +{ + fwrite(STDERR, "STAGE {$name}\n"); +} + +stage('autoload'); + Core::init(); +stage('init'); $fires = 0; $handler = match ($mode) { - 'noop' => static function ($executeData): int { + 'noop', 'install-only' => static function ($executeData): int { return 2; }, 'log-const' => static function ($executeData): int { @@ -114,17 +123,23 @@ if ($mode !== 'add-baseline') { Core::$compiler->setOptions(Core::$compiler->getOptions() | Compiler::COMPILE_EXTENDED_STMT); } +stage('options'); $result = Core::call('zend_set_user_opcode_handler', $opCode, $handler); if ($result === Core::FAILURE) { fwrite(STDERR, "Failed to install the user opcode handler\n"); exit(1); } +stage('installed'); -require __DIR__ . '/payload.php'; +if ($mode !== 'install-only') { + require __DIR__ . '/payload.php'; + stage('payload-done'); +} // Restore before engine shutdown: payload op_arrays still carry the opcode Core::call('zend_set_user_opcode_handler', $opCode, null); +stage('uninstalled'); echo "fires={$fires}\n"; echo 'globals=' . ($GLOBALS['probe280Fires'] ?? 0) . "\n"; From a5d85bf738b5456ed7b4c751487a9e3e8af3c7f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 14:33:51 +0000 Subject: [PATCH 3/5] fix(hook): refuse user opcode handlers on PHP 8.6's tail-call VM (#280) 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 Claude-Session: https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG --- AGENTS.md | 6 ++ README.md | 2 +- phpstan.dist.neon | 7 ++ src/Core.php | 36 ++++++++++ src/System/Hook/OpCodeHook.php | 9 +++ src/System/Hook/OpCodeHookException.php | 11 ++++ .../System/Hook/OpCodeHookVmKindGuardTest.php | 66 +++++++++++++++++++ 7 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 tests/System/Hook/OpCodeHookVmKindGuardTest.php diff --git a/AGENTS.md b/AGENTS.md index 8ff966c..21f274c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -191,6 +191,12 @@ composer test:internal # destructive/segfault-prone group, process-isolated build** (`tools/docker/php-debug.Dockerfile`, which CI builds inline and runs the group in). Process isolation keeps one crash from taking down the whole run. +- On PHP 8.6 builds using the tail-call VM (`Core::vmKind()` = + `VM_KIND_TAILCALL`; clang without global-register support, notably Apple + Silicon), user opcode handlers are refused by `OpCode::setHandler()` — the + engine mis-resumes execution after a user handler there and corrupts the + process (issue #280, a php-src bug). The guard test + (`OpCodeHookVmKindGuardTest`) covers both branches in every CI leg. - FFI must be enabled (`ffi.enable=1`) and the JIT disabled (`opcache.jit=off`) — the JIT rewrites the executor internals z-engine hooks into. The PHPUnit config sets what it can; `ffi.enable` and `zend.assertions` must come from diff --git a/README.md b/README.md index 22c1fe2..39a5179 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Engine memory layouts change between every PHP minor version, so each PHP minor | 8.4 | linux-x64 (nts, zts), darwin-x64 (nts, zts), darwin-arm64 (nts, zts), windows-x64 (nts, zts) | `8.4` | ✅ supported | | 8.0 | linux-x64-nts | `8.0` | 🧊 frozen (legacy) | -¹ PHP 8.6 is pre-release; definitions track the latest beta. darwin-* and windows-* artifacts land through the generation workflows as 8.6 builds become available on those runners. +¹ PHP 8.6 is pre-release; definitions track the latest beta. darwin-* and windows-* artifacts land through the generation workflows as 8.6 builds become available on those runners. One 8.6 caveat: on builds using the new **tail-call VM** (`ZEND_VM_KIND_TAILCALL` — clang without global-register support, notably Apple Silicon), user opcode handlers mis-resume execution inside the engine and corrupt the process ([#280](https://github.com/lisachenko/z-engine/issues/280)); `OpCode::setHandler()` refuses with a clear error there until php-src resolves it. Everything not built on user opcode hooks is unaffected. ² `darwin-x64-zts` on 8.5 lands as soon as a ZTS PHP 8.5 build exists for Intel macOS runners — the generation workflow picks it up automatically. diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 114fe3c..3e3d90d 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -70,6 +70,13 @@ parameters: - identifier: offsetAccess.nonOffsetAccessible path: src/Type/StructArray.php + # Core::vmKind() binds a dedicated one-symbol cdef (`int zend_vm_kind(void)`): + # methods declared by cdef source are not statically resolvable, exactly like the + # engine binding behind call() - scoped to this one symbol in this one file. + - + identifier: method.notFound + message: '#Call to an undefined method FFI::zend_vm_kind\(\)#' + path: src/Core.php # The dimension tests exist to prove that a plain `count($object)` reaches the engine's # count_elements handler on a class that never declared the count itself. Rewriting them # as assertCount() would measure PHPUnit's Count constraint instead of the language diff --git a/src/Core.php b/src/Core.php index 77b2f0a..94843e4 100644 --- a/src/Core.php +++ b/src/Core.php @@ -164,6 +164,15 @@ class Core public const int SUCCESS = 0; public const int FAILURE = -1; + /** + * VM dispatch kinds, as reported by zend_vm_kind() (Zend/zend_vm_opcodes.h) + */ + public const int VM_KIND_CALL = 1; + public const int VM_KIND_SWITCH = 2; + public const int VM_KIND_GOTO = 3; + public const int VM_KIND_HYBRID = 4; + public const int VM_KIND_TAILCALL = 5; /* new in PHP 8.6: clang musttail/preserve_none chains */ + /** * This should be equal to ZEND_MM_ALIGNMENT */ @@ -199,6 +208,11 @@ class Core */ private static FFI $engine; + /** + * Cached zend_vm_kind() answer - a compile-time property of the php binary + */ + private static ?int $vmKind = null; + /** * Windows only: binding to the C runtime that owns the malloc heap, for persistentFree() * @@ -632,6 +646,28 @@ public static function platformKey(): string ); } + /** + * The engine's VM dispatch kind, one of the VM_KIND_* constants + * + * Read through a dedicated one-symbol FFI binding rather than the generated engine + * definitions: the answer is needed BEFORE init() (OpCodeHook::install() guards on it, + * and a consumer may probe platform support without booting the whole engine), and a + * plain `int zend_vm_kind(void)` carries no struct layout that the generated artifacts + * would need to verify. zend_vm_kind() is ZEND_API since PHP 7, so the symbol resolves + * on every supported build. + */ + public static function vmKind(): int + { + if (self::$vmKind === null) { + $probe = FFI::cdef('int zend_vm_kind(void);', self::engineLibrary()); + $kind = $probe->zend_vm_kind(); + \assert(\is_int($kind)); + self::$vmKind = $kind; + } + + return self::$vmKind; + } + /** * Library FFI::cdef() must bind the engine definitions to, or null for the process image * diff --git a/src/System/Hook/OpCodeHook.php b/src/System/Hook/OpCodeHook.php index 79c35aa..4a1041e 100644 --- a/src/System/Hook/OpCodeHook.php +++ b/src/System/Hook/OpCodeHook.php @@ -95,6 +95,15 @@ public function install(): void if (Core::isShutdown()) { throw new \LogicException('Cannot install an engine hook after Core::shutdown()'); } + // PHP 8.6's tail-call VM mis-resumes execution after a user opcode handler: the + // generated ZEND_USER_OPCODE_SPEC_TAILCALL_HANDLER returns the single-step + // dispatch result up the musttail chain, and execute_ex() then continues with its + // stale frame pointer - any handler firing outside execute_ex's entry frame + // executes the following oplines against the WRONG frame (wrong run-time cache, + // wrong CVs), corrupting the debuggee. Refuse loudly instead (issue #280). + if (Core::vmKind() === Core::VM_KIND_TAILCALL) { + throw OpCodeHookException::tailCallVmUnsupported(); + } $previousHandler = Core::call('zend_get_user_opcode_handler', $this->opCode); assert($previousHandler === null || $previousHandler instanceof CData); $this->originalHandler = $previousHandler; diff --git a/src/System/Hook/OpCodeHookException.php b/src/System/Hook/OpCodeHookException.php index cd4d178..d2b3162 100644 --- a/src/System/Hook/OpCodeHookException.php +++ b/src/System/Hook/OpCodeHookException.php @@ -32,4 +32,15 @@ public static function handlerRestoreFailed(): self { return new self('Can not restore original opcode handler'); } + + public static function tailCallVmUnsupported(): self + { + return new self( + 'User opcode handlers are unsupported on this PHP build: its tail-call VM ' + . '(ZEND_VM_KIND_TAILCALL, e.g. clang builds on Apple Silicon since PHP 8.6) ' + . 'resumes execution against a stale frame after a user opcode handler fires, ' + . 'corrupting the process. Use a hybrid/call-VM PHP build instead. ' + . 'See https://github.com/lisachenko/z-engine/issues/280', + ); + } } diff --git a/tests/System/Hook/OpCodeHookVmKindGuardTest.php b/tests/System/Hook/OpCodeHookVmKindGuardTest.php new file mode 100644 index 0000000..2d7a21d --- /dev/null +++ b/tests/System/Hook/OpCodeHookVmKindGuardTest.php @@ -0,0 +1,66 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + */ +declare(strict_types=1); + +namespace ZEngine\System\Hook; + +use PHPUnit\Framework\TestCase; +use ZEngine\Core; +use ZEngine\System\OpCode; + +/** + * The tail-call VM guard for issue #280 + * + * PHP 8.6's ZEND_VM_KIND_TAILCALL (clang builds without global-register support, e.g. + * Apple Silicon) resumes execution against a stale execute_ex frame after a user opcode + * handler fires, so OpCodeHook::install() must refuse there instead of corrupting the + * process. On every other VM kind the install/uninstall lifecycle must be untouched. + * + * Deliberately NOT in the `internal` group: this guard is exactly what protects release + * builds, so it runs in every CI leg - including macOS arm64, the only runner where the + * tail-call branch is actually taken. The handler is never dispatched (no probe code is + * compiled while it is installed), which keeps the happy path safe for release builds. + */ +final class OpCodeHookVmKindGuardTest extends TestCase +{ + public function testVmKindIsReported(): void + { + $kind = Core::vmKind(); + + $this->assertContains($kind, [ + Core::VM_KIND_CALL, + Core::VM_KIND_SWITCH, + Core::VM_KIND_GOTO, + Core::VM_KIND_HYBRID, + Core::VM_KIND_TAILCALL, + ], 'zend_vm_kind() must answer one of the known VM kinds'); + } + + public function testInstallRefusesOnTheTailCallVmAndWorksElsewhere(): void + { + if (Core::vmKind() === Core::VM_KIND_TAILCALL) { + $this->expectException(OpCodeHookException::class); + $this->expectExceptionMessageMatches('/tail-call VM/'); + OpCode::setHandler(OpCode::EXT_STMT, static fn($scope): int => Core::ZEND_USER_OPCODE_DISPATCH); + + return; + } + + $hook = OpCode::setHandler(OpCode::EXT_STMT, static fn($scope): int => Core::ZEND_USER_OPCODE_DISPATCH); + try { + $this->assertTrue($hook->isInstalled()); + } finally { + $hook->uninstall(); + } + $this->assertFalse($hook->isInstalled()); + } +} From b963f60a340d53fe53a6e1ed8e26ec5a6748c42c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 14:35:07 +0000 Subject: [PATCH 4/5] chore(diagnostics): add the pure-FFI php-src repro for the tail-call 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 Claude-Session: https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG --- .github/workflows/diagnose-280.yml | 4 ++ .../diagnostics/issue-280/pure-ffi-repro.php | 58 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 tools/diagnostics/issue-280/pure-ffi-repro.php diff --git a/.github/workflows/diagnose-280.yml b/.github/workflows/diagnose-280.yml index fd0a740..2426620 100644 --- a/.github/workflows/diagnose-280.yml +++ b/.github/workflows/diagnose-280.yml @@ -60,6 +60,10 @@ jobs: php $PHP_FLAGS tools/diagnostics/issue-280/probe.php "$mode" || echo "exit=$?" done + - name: Pure-FFI repro (no z-engine, for the php-src report) + if: always() + run: php $PHP_FLAGS tools/diagnostics/issue-280/pure-ffi-repro.php || echo "exit=$?" + - name: noop under lldb (backtrace on crash) if: always() run: | diff --git a/tools/diagnostics/issue-280/pure-ffi-repro.php b/tools/diagnostics/issue-280/pure-ffi-repro.php new file mode 100644 index 0000000..045c571 --- /dev/null +++ b/tools/diagnostics/issue-280/pure-ffi-repro.php @@ -0,0 +1,58 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + * + * Self-contained php-src repro for issue #280 - NO z-engine involved, only ext-ffi + * against the engine's own exported API. Run with: + * + * php -d ffi.enable=1 -d opcache.jit=off pure-ffi-repro.php + * + * On hybrid/call-VM builds it prints int(3) and OK. On a ZEND_VM_KIND_TAILCALL + * build (clang without global-register support, e.g. macOS arm64 since PHP 8.6) + * it corrupts execution at the first ADD dispatched inside the function frame: + * ZEND_USER_OPCODE_SPEC_TAILCALL_HANDLER's DISPATCH case returns the single-step + * handler's next opline up the musttail chain, and execute_ex() resumes it + * against the stale frame it was entered with. + */ +declare(strict_types=1); + +$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(), " (5 = ZEND_VM_KIND_TAILCALL)\n"; + +// ZEND_ADD = 1; ZEND_USER_OPCODE_DISPATCH = 2 ("call original opcode handler") +$handler = static function ($executeData): int { + return 2; +}; +if ($engine->zend_set_user_opcode_handler(1, $handler) !== 0) { + fwrite(STDERR, "install failed\n"); + exit(1); +} + +// Compiled AFTER the handler is installed, so its ADD dispatches through it. +// The ADD fires inside the function frame - deeper than the frame execute_ex() +// was entered with, which is what the tail-call VM mis-resumes. +$payload = tempnam(sys_get_temp_dir(), 'p280') . '.php'; +file_put_contents($payload, <<<'PHP' + zend_set_user_opcode_handler(1, null); +echo "OK\n"; From 7d4077faf0af5eeecc4a05b0961b0b9a8e25d240 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 14:43:43 +0000 Subject: [PATCH 5/5] chore(diagnostics): make the issue #280 workflow dispatch-only 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 Claude-Session: https://claude.ai/code/session_01U4cKi87mVQ896uSrw592fG --- .github/workflows/diagnose-280.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/diagnose-280.yml b/.github/workflows/diagnose-280.yml index 2426620..f95a6f0 100644 --- a/.github/workflows/diagnose-280.yml +++ b/.github/workflows/diagnose-280.yml @@ -1,11 +1,12 @@ -# TEMPORARY: diagnostics for issue #280 (user opcode handlers corrupt VM state -# under PHP 8.6's tail-call VM on macOS arm64). Runs the tools/diagnostics/issue-280 -# probe ladder on the failing runner (macos-latest, arm64) with macos-15-intel as -# the in-run control. Removed together with the probes once the fix lands. +# Diagnostics for issue #280 (php-src: user opcode handlers mis-resume under the +# PHP 8.6 tail-call VM). Manually dispatched: runs the tools/diagnostics/issue-280 +# probe ladder and the pure-FFI php-src repro on macos-latest (arm64, the affected +# build) with macos-15-intel as the in-run control. Re-run it against a new PHP +# build to check whether the upstream bug is fixed; drop it (and the probes) once +# php-src resolves the issue and the OpCodeHook guard is retired. name: Diagnose issue 280 on: - pull_request: workflow_dispatch: permissions: