Skip to content

Fix tracing JIT specializing calls to not persistent functions - #24333

Open
pavimus wants to merge 1 commit into
php:PHP-8.4from
pavimus:investigate-jit-nonpersistent-func
Open

pavimus wants to merge 1 commit into
php:PHP-8.4from
pavimus:investigate-jit-nonpersistent-func

Conversation

@pavimus

@pavimus pavimus commented Oct 14, 2026

Copy link
Copy Markdown

Prepared with an AI assistant (Claude), which also ran the reproduction and the tests on my machine; I reviewed the result. The text below is AI-generated (quoted, as asked in CONTRIBUTING.md).

While testing OPcache restarts under ZTS (#24276) we saw rare false TypeErrors
(Unsupported operand types: int|float + int in code that only adds integers) and crashes in
i_free_compiled_variables() with a garbage EX(func), only with opcache.jit=tracing. They turned
out to be unrelated to threads and to restarts: a single-threaded php -S (ZTS and NTS builds) with
a full OPcache SHM crashes the same way, without any restart.

The tracer records the called function of INIT_METHOD_CALL and friends, and the trace compiler
then guards on it and uses it to compute the call frame size (zend_vm_calc_used_stack()) and to
decide how arguments are passed. For a user function that is not persistent (its
script is not cached, e.g. because SHM is full, the file is blacklisted, or the code comes from
eval()), zend_jit_func_guard() compares op_array.opcodes, which is request memory. In a later
request another function may get the same opcodes address; the guard passes and the trace
pushes a call frame of the wrong size. When it is too small, the frames of the calls made by the
callee are pushed over its own CVs and temporaries: it then reads garbage types, and leaving a
nested call crashes on a garbage EX(func). zend_jit_init_fcall_guard() already refuses such functions
("op_array and op_array->opcodes are not persistent. We can't link.").

The fix makes the tracer not record such functions, the same way as megamorphic calls: the call
is compiled without a known function, and recording stops at the DO_FCALL.

The test serves index.php with php -S; work() is cached and calls $o->run() in a hot loop,
and class X comes from a blacklisted file that alternates between two versions with the same
number of opcodes but 17 and 26 CVs. After a warm-up request both versions get the same opcodes
address in every request (checked with gdb), so the trace recorded with the smaller version is
entered with the bigger one. Without the fix the result for the second version is wrong on every
such request (NTS debug and ZTS release builds, AArch64 Linux); with the fix it is correct.

On the original workload (a generated application of 2520 classes, 32M SHM that fills up, php -S,
one connection) the crash depends on the sequence of requests; replaying a recorded sequence of
206 requests crashes in i_free_compiled_variables() on the same request every time (3 of 3).
In the core, work() (cached) called run() of a class compiled in that request; that frame
needs 38 slots, but the next frame starts 28 slots above it. With the fix the same sequence
passes (0 of 3), and random runs (318 000 requests) neither crash nor return wrong results.
make test for ext/opcache and Zend/tests passes, also with opcache.jit=tracing forced.

We only have AArch64 hardware; nothing in the mechanism is specific to it. It may also explain
#22102.

The change conflicts when merged up into PHP-8.5 (the recorder code moved); I have an adapted
version for master (same fix and test, passes ext/opcache and Zend/tests with tracing JIT) and
can open it or push it here, whichever is preferred.

🤖 Generated with Claude Code

The tracer records the called function for INIT_* opcodes, and the trace
compiler then guards on it and computes the call frame size and argument
passing from it. For a user function that is not persistent (its script is
not cached, e.g. because SHM is full or the file is blacklisted), the guard
compares op_array.opcodes, which is request memory. Another function may get
the same opcodes address in a later request; the guard passes, and the trace
pushes a call frame of the wrong size. If it is too small, the calls made by
the callee push their frames over its CVs and temporaries: it reads garbage
types, and leaving a nested call crashes on a garbage EX(func).

Do not record such functions, like megamorphic calls.
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.

1 participant