Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/workflows/diagnose-280.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 7 additions & 0 deletions phpstan.dist.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions src/Core.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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()
*
Expand Down Expand Up @@ -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
*
Expand Down
9 changes: 9 additions & 0 deletions src/System/Hook/OpCodeHook.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions src/System/Hook/OpCodeHookException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
}
}
66 changes: 66 additions & 0 deletions tests/System/Hook/OpCodeHookVmKindGuardTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

/**
* Z-Engine framework
*
* @copyright Copyright 2026, Lisachenko Alexander <lisachenko.it@gmail.com>
*
* 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());
}
}
49 changes: 49 additions & 0 deletions tools/diagnostics/issue-280/payload.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

/**
* Z-Engine framework
*
* @copyright Copyright 2026, Lisachenko Alexander <lisachenko.it@gmail.com>
*
* 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
Loading
Loading