[php-src] PR #24383: Fix AArch64 JIT stub handlers pointing at discarded veneers after opcache restart
| From: | austinderrick | Date: | Sat, 17 Oct 2026 14:40:41 +0000 |
| Subject: | [php-src] PR #24383: Fix AArch64 JIT stub handlers pointing at discarded veneers after opcache restart | ||
| Groups: | php.git-pulls | ||
| Request: | Send a blank email to git-pulls+get-39519@lists.php.net to get a copy of this message | ||
Pull Request: https://github.com/php/php-src/pull/24383
Author: austinderrick
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:
```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
```
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. I can share
the generator scripts.
This may be the same bug as GH-11609.