Skip to content

Fix AArch64 JIT stub handlers pointing at discarded veneers after opcache restart - #24383

Closed
austinderrick wants to merge 1 commit into
php:PHP-8.4from
austinderrick:fix-aarch64-jit-restart-veneers
Closed

austinderrick wants to merge 1 commit into
php:PHP-8.4from
austinderrick:fix-aarch64-jit-restart-veneers

Conversation

@austinderrick

@austinderrick austinderrick commented Oct 17, 2026 •

Copy link
Copy Markdown

On AArch64, opcache_reset() with the tracing JIT leaves zend_jit_stub_handlers[] pointing at veneers in code that the restart discarded. Traces compiled afterwards overwrite those veneers, so a later branch to an affected stub jumps into unrelated machine code. PHP-FPM workers then crash with SIGSEGV or hang at 100% CPU until FPM is restarted.

Cause

  1. On AArch64, zend_jit_stub_handlers[] lives at the start of the JIT buffer.
  2. When a trace is out of branch range of a stub, IR emits a veneer in that trace's code, and zend_jit_commit_veneers() replaces zend_jit_stub_handlers[i] with the veneer address.
  3. zend_jit_restart() resets the buffer position (dasm_ptr[0] = dasm_ptr[1]) but keeps the table.

In a Laravel application, a gdb dump of the table showed six entries (exception_handler, exception_handler_undef, interrupt_handler, leave_function_handler, icall_throw, leave_throw) moving from the stub area to about 1 MB into the buffer after warm-up. After opcache_reset(), they still pointed there while the buffer was back to 3.9 KB in use.

The failure needs the code compiled after the reset to differ from the code before it. When the same code runs in the same order, IR rebuilds the same veneers at the same addresses.

Fix

Save the stub table at the end of zend_jit_startup() and restore it, with the pending-veneer slots cleared, in zend_jit_restart().

Reproduction

Official php:8.4-fpm-trixie image (8.4.23, arm64) behind nginx, pm = static with 4 children:

opcache.enable=1
opcache.jit=tracing
opcache.jit_buffer_size=64M
opcache.validate_timestamps=0
opcache.jit_max_root_traces=100000
opcache.jit_max_side_traces=100000
opcache.jit_max_exit_counters=65536
max_execution_time=0

Two generated workloads: A is 1,500 classes with hot loops, and B is 3,000 classes with polymorphic branches, closures and caught exceptions (about 8 MB of JIT code). Page b.php runs B; page ab.php runs A and then B; reset.php calls opcache_reset().

Sequence on a fresh FPM Result
b.php x2, ab.php x2 All 200, ab.php in 0.3 to 0.8 s
b.php x2, reset.php, ab.php ab.php never returns; workers at 100% CPU
Same, opcache.jit=disable All 200
Same, x86_64 All 200
Same, arm64 with this patch All 200

In the Laravel application, the same sequence crashes every worker with SIGSEGV inside zend_jit_trace_exit or in JIT code calling zend_error_unchecked, also on 8.4.26. With this patch applied to 8.4.23, repeated resets return 200 with no crashes.

This may be the same bug as GH-11609.

Reproduction scripts

Put the files in one directory with this layout and run ./run.sh on an arm64 host with Docker. It generates both workloads, then starts php:8.4-fpm behind nginx twice: once without a reset and once with reset.php between the warm-up and ab.php.

run.sh  gen.php  gen2.php
conf/jit.ini  conf/www.conf  conf/nginx.conf
app/a.inc  app/b.inc  app/b.php  app/ab.php  app/reset.php

Output on arm64 (8.4.23 and 8.4.26 behave the same):

Without reset:
  b.php: 200 in 1.533545s
  b.php: 200 in 0.149145s
  ab.php: 200 in 0.778559s
  ab.php: 200 in 0.313916s
With reset:
  b.php: 200 in 1.135747s
  b.php: 200 in 0.151503s
  reset.php
  ab.php: 504 in 60.047946s
  ab.php: 504 in 60.067511s

With JIT=disable ./run.sh, with ./run.sh php:8.4-fpm --platform linux/amd64, and with this patch applied to 8.4.23, every request returns 200.

run.sh
#!/bin/bash
# Usage: ./run.sh [image] [extra docker run args...]
#   ./run.sh                                     # php:8.4-fpm, native arch
#   ./run.sh php:8.4-fpm --platform linux/amd64  # x86_64 control
#   JIT=disable ./run.sh                         # JIT-off control
set -u
cd "$(dirname "$0")"; d=$PWD
IMG=${1:-php:8.4-fpm}; shift || true
ini=conf/jit.ini
if [ "${JIT:-}" = disable ]; then sed 's/^opcache.jit=tracing/opcache.jit=disable/' conf/jit.ini > conf/jit-off.ini; ini=conf/jit-off.ini; fi
[ -d app/a ] || docker run --rm -v "$d":/w -w /w "$@" "$IMG" sh -c 'php gen.php && php gen2.php'
docker network create jitrepro >/dev/null 2>&1
up() {
    docker rm -f jitrepro-php jitrepro-web >/dev/null 2>&1
    docker run -d --name jitrepro-php --network jitrepro --network-alias php "$@" \
        -v "$d/app":/app -v "$d/$ini":/usr/local/etc/php/conf.d/zz-jit.ini:ro \
        -v "$d/conf/www.conf":/usr/local/etc/php-fpm.d/zz-www.conf:ro "$IMG" >/dev/null
    docker run -d --name jitrepro-web --network jitrepro -p 8099:80 \
        -v "$d/conf/nginx.conf":/etc/nginx/conf.d/default.conf:ro nginx:1.27-alpine >/dev/null
    for i in $(seq 1 30); do curl -s -o /dev/null http://localhost:8099/reset.php && break; sleep 1; done
    docker exec jitrepro-php php -r 'echo PHP_VERSION, " ", php_uname("m"), "\n";'
}
t() { curl -s -o /dev/null -w "  $1: %{http_code} in %{time_total}s\n" --max-time 120 http://localhost:8099/$1; }
up "$@"
echo "Without reset:"; curl -s -o /dev/null http://localhost:8099/reset.php; t b.php; t b.php; t ab.php; t ab.php
up "$@"
echo "With reset:"; t b.php; t b.php; curl -s -o /dev/null http://localhost:8099/reset.php; echo "  reset.php"; t ab.php; t ab.php
echo "Worker crashes: $(docker logs jitrepro-php 2>&1 | grep -c 'signal 11')"
docker rm -f jitrepro-php jitrepro-web >/dev/null 2>&1; docker network rm jitrepro >/dev/null 2>&1
gen.php
<?php
// Workload A: 1,500 classes with hot loops.
@mkdir(__DIR__ . '/app/a');
for ($i = 0; $i < 1500; $i++) {
    $src = "<?php\nclass C$i {\n";
    for ($m = 0; $m < 6; $m++) {
        $src .= "  public static function m$m(array \$a, int \$k): int {\n"
            . "    \$s = $i;\n"
            . "    foreach (\$a as \$j => \$v) { if ((\$v + \$k) % " . ($m + 2) . " === 0) { \$s += \$v * $m; } else { \$s ^= \$j; } \$s .= ''; \$s = (int)\$s; }\n"
            . "    \$str = str_repeat('x', \$k % 7); \$s += strlen(\$str);\n"
            . "    return \$s;\n  }\n";
    }
    file_put_contents(__DIR__ . "/app/a/C$i.php", $src . "}\n");
}
gen2.php
<?php
// Workload B: 3,000 classes with polymorphic branches, closures and caught exceptions.
@mkdir(__DIR__ . '/app/b');
for ($i = 0; $i < 3000; $i++) {
    $src = "<?php\nclass P$i implements ArrayAccess {\n  private array \$d = [];\n"
        . "  public function offsetExists(mixed \$o): bool { return isset(\$this->d[\$o]); }\n"
        . "  public function offsetGet(mixed \$o): mixed { return \$this->d[\$o] ?? null; }\n"
        . "  public function offsetSet(mixed \$o, mixed \$v): void { \$this->d[\$o] = \$v; }\n"
        . "  public function offsetUnset(mixed \$o): void { unset(\$this->d[\$o]); }\n"
        . "  public function run(array \$a): mixed { \$r = 0; foreach (\$a as \$k => \$v) {\n"
        . "    if (is_int(\$v)) { \$r += \$v * $i; } elseif (is_string(\$v)) { \$r .= substr(\$v, 0, 2); } elseif (is_array(\$v)) { \$r = count(\$v) + (int)\$r; } else { \$r = \$v ?? \$r; }\n"
        . "    \$this[\$k % 17] = \$r; \$x = fn(\$y) => \$y . \$k; \$r = is_string(\$r) ? strlen(\$x(\$r)) : \$r; }\n"
        . "    try { if (\$r % " . (($i % 9) + 2) . " === 0) { throw new RuntimeException('x'); } } catch (Throwable \$e) { \$r++; }\n"
        . "    return \$r; }\n}\n";
    file_put_contents(__DIR__ . "/app/b/P$i.php", $src);
}
conf/jit.ini
opcache.enable=1
opcache.jit=tracing
opcache.jit_buffer_size=64M
opcache.validate_timestamps=0
opcache.jit_max_root_traces=100000
opcache.jit_max_side_traces=100000
opcache.jit_max_exit_counters=65536
max_execution_time=0
error_reporting=E_ALL & ~E_DEPRECATED
display_errors=0
conf/www.conf
[www]
pm = static
pm.max_children = 4
conf/nginx.conf
server {
  listen 80;
  root /app;
  location ~ \.php$ {
    fastcgi_pass php:9000;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME /app$fastcgi_script_name;
  }
}
app/a.inc
<?php
$a = range(1, 200);
for ($i = 0; $i < 1500; $i++) {
    require_once __DIR__ . "/a/C$i.php";
    $c = "C$i";
    for ($r = 0; $r < 4; $r++) {
        for ($m = 0; $m < 6; $m++) { $c::{"m$m"}($a, $r + $m); }
    }
}
app/b.inc
<?php
$mix = [];
for ($j = 0; $j < 120; $j++) { $mix[] = [$j, "s$j", [$j], null, 1.5][$j % 5]; }
for ($i = 0; $i < 3000; $i++) {
    require_once __DIR__ . "/b/P$i.php";
    $c = "P$i";
    (new $c())->run($mix);
}
app/b.php
<?php
require __DIR__ . "/b.inc";
$s = opcache_get_status(false);
echo "ok jit_used=", $s["jit"]["buffer_size"] - $s["jit"]["buffer_free"], "\n";
app/ab.php
<?php
require __DIR__ . "/a.inc";
require __DIR__ . "/b.inc";
echo "ok\n";
app/reset.php
<?php
var_dump(opcache_reset());

…ache restart

zend_jit_commit_veneers() replaces zend_jit_stub_handlers[] entries with veneers
emitted inside trace code. zend_jit_restart() discards that code but kept the
table, so traces compiled after opcache_reset() branched into unrelated code and
PHP-FPM workers crashed or hung. Save the table at startup and restore it on
restart.
@ndossche

Copy link
Copy Markdown
Member

Is this a duplicate of #24273
If yes, please tell your LLM to first look for duplicates, thanks

@austinderrick

Copy link
Copy Markdown
Author

Closing as a duplicate of #24273, which fixes the same bug and was opened first. I posted the NTS reproduction there.

pavimus added a commit to pavimus/php-src that referenced this pull request Oct 17, 2026
Based on the reproducer by Derrick Austin from php#24383: more than 1 MB of
tracing JIT code, opcache_reset(), then different code. Without the fix
the server crashes on AArch64 after the reset.

Co-authored-by: Derrick Austin <derrick.austin@interworks.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants