diff --git a/.github/workflows/diagnose-280.yml b/.github/workflows/diagnose-280.yml new file mode 100644 index 00000000..f95a6f05 --- /dev/null +++ b/.github/workflows/diagnose-280.yml @@ -0,0 +1,86 @@ +# 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: + 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: 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: 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: | + lldb --batch \ + -o 'run' \ + -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: Latest macOS crash report + if: always() + run: | + 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/AGENTS.md b/AGENTS.md index 8ff966c0..21f274cd 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 22c1fe2b..39a51791 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 114fe3c0..3e3d90d4 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 77b2f0a1..94843e43 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 79c35aac..4a1041ed 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 cd4d1788..d2b31620 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 00000000..2d7a21de --- /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()); + } +} diff --git a/tools/diagnostics/issue-280/payload.php b/tools/diagnostics/issue-280/payload.php new file mode 100644 index 00000000..443cb5cb --- /dev/null +++ b/tools/diagnostics/issue-280/payload.php @@ -0,0 +1,49 @@ + + * + * 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); + +fwrite(STDERR, "STAGE payload-first-statement\n"); + +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 00000000..7867c83e --- /dev/null +++ b/tools/diagnostics/issue-280/probe.php @@ -0,0 +1,153 @@ + + * + * 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); + +/** 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', 'install-only' => 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); +} +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'); + +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"; +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"; 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 00000000..045c5713 --- /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";