Skip to content

Fix GH-17626: JIT corrupts opline handler when blacklisting root trace - #23571

Open
RV7PR wants to merge 1 commit into
php:PHP-8.4from
RV7PR:fix/gh-17626-trace-exit-invalidate
Open

Fix GH-17626: JIT corrupts opline handler when blacklisting root trace#23571
RV7PR wants to merge 1 commit into
php:PHP-8.4from
RV7PR:fix/gh-17626-trace-exit-invalidate

Conversation

@RV7PR

@RV7PR RV7PR commented Sep 4, 2026

Copy link
Copy Markdown

Fixes #17626

The bug

zend_jit_trace_exit() handles ZEND_JIT_EXIT_INVALIDATE by walking up to the root trace t. If ZEND_JIT_TRACE_NUM >= JIT_G(max_root_traces) it blacklists the root and puts the original VM handler back, but writes it to opline (the exit opline of the trace that just exited) instead of t->opline (the root's start opline). The sibling else branch uses t->opline.

The opcodes live in shared memory, so the corruption is process-wide and permanent until restart. That matches the reports: it appears after long uptime (the 1024 root-trace limit has been reached), after deploys / composer updates (a recompiled callee makes an INVALIDATE guard fail), frequently in trait methods on 8.5 (classes using a trait share the opcodes and therefore the trace), and only goes away after restarting FPM / the container.

When the root trace starts at the entry of a function with typed parameters the root opline is ZEND_RECV, so the RECV handler ends up on an INIT_* opline. It reads op1.num as the argument number (2 for parent::, the stack size for INIT_FCALL, a literal offset for Foo::bar()), calls zend_missing_arg_error() on a frame that has all its arguments and produces the contradictory "N passed ... and exactly N expected" message from the issue. Other root oplines produce the SIGSEGVs mentioned there.

Introduced by 350af54 (fix for GH-14475); present in PHP-8.3, PHP-8.4, PHP-8.5 and master.

The fix

Restore the handler on the root trace's opline, t->opline, as the other branch already does.

Tests

Both tests set opcache.jit_max_root_traces=2 so the limit is reached right after the first root trace, and use a typed function so the root trace starts at a RECV opcode.

  • gh17626.phpt: namespaced caller() calls an unqualified user function from another file (INIT_NS_FCALL_BY_NAME, guarded with ZEND_JIT_EXIT_INVALIDATE because the callee is immutable). The callee's file is touched and invalidated at the end, so under --repeat 2 (the CI repeat job; same approach as gh8591-001.phpt and init_fcall_003.phpt) the second run recompiles the callee and the guard fails. Without the fix: ArgumentCountError: Too few arguments to function GH17626\caller(), 1 passed ... and exactly 1 expected. This is the trigger 8.3/8.4 users hit after a deploy or composer update.
  • gh17626_002.phpt: a typed trait method calling parent::m(), used by two classes with different parents (grandparent in another file so zend_jit_may_be_modified() keeps the guard). zend_jit_init_static_method_call() emits an INVALIDATE guard for this only on PHP-8.5 and master, so this test fails without the fix from 8.5 on without needing --repeat. It is the trigger behind the Carbon / trait reports on 8.5.

Verified on macOS arm64: PHP-8.4 unfixed/fixed with and without --repeat 2, master unfixed/fixed; full ext/opcache/tests/jit passes with the fix (474/0 on 8.4, 487/0 on master).

Disclaimer

I am the reporter of #17626. The root-cause analysis, the fix, the tests and this write-up were produced with Claude Fable 5.1 (Anthropic) at my direction; I ran the reproductions and test runs on my machine and verified the results, but I cannot review the C myself. I will relay any review questions and apply requested changes.

…race

When a trace exits through a ZEND_JIT_EXIT_INVALIDATE guard while
ZEND_JIT_TRACE_NUM has already reached opcache.jit_max_root_traces,
zend_jit_trace_exit() blacklists the root trace and restores the
original VM handler. It wrote that handler to the exit opline (the
INIT_* opline whose callee guard failed) instead of the root trace's
start opline. The opcodes live in SHM, so every worker keeps executing
the foreign handler until restart.

For a root trace that starts at the entry of a function with typed
parameters the copied handler is ZEND_RECV, which then runs on an
INIT_FCALL / INIT_STATIC_METHOD_CALL opline and raises
"Too few arguments to function X(), N passed ... and exactly N expected"
from a frame that has all its arguments. Other root oplines lead to
crashes instead.

Introduced by 350af54 (phpGH-14475).
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