Engineering notes / PHP internals
Fixing an Indirect Type Bug in PHP's Tracing JIT
A missing bit in the tracing JIT's fallback SSA type mask could cause PHP to crash when dynamically alternating between by-value and by-reference arguments. The fix was a single flag in zend_jit_trace.c.
Problem. PHP's tracing JIT omitted an indirect-value type from a conservative SSA fallback, allowing a bad dereference path. Change. Restore MAY_BE_INDIRECT and add a PHPT regression. Evidence. The fix was integrated upstream in PR #24699.
I encountered this while investigating GH-24438. The interpreter handled the affected argument-passing pattern, but the tracing JIT could generate a path that failed in zval_get_long_func(). The patch was later committed upstream through PR #24699.
Reproducing the failure
The regression test alternates between two functions. One accepts an array element by value; the other accepts it by reference.
function byVal($value) {
$value = (int) $value + 1000;
return $value;
}
function byRef(&$value) {
$value = (int) $value + 1;
return $value;
}
$fns = ['byVal', 'byRef'];
$arr = [10, 20];
for ($i = 0; $i < 120; $i++) {
$f = $fns[$i % 2];
$f($arr[1]);
$f($arr[0]);
}
var_dump($arr[0], $arr[1]);
Only byRef() changes the array. It runs 60 times, so the expected result is:
int(70)
int(80)
The PHPT test enables tracing JIT, lowers the hot counters and sets opcache.jit_max_polymorphic_calls=0 to exercise the affected path. On an unfixed debug build, that path could trip an assertion in zval_get_long_func(). The original issue also reported a segmentation fault on a release build.
Neither function is inherently invalid. The issue is what the JIT assumes about the argument operand after control moves between traces.
Where the type information was lost
The relevant Zend VM opcodes are FETCH_DIM_FUNC_ARG, which fetches an array element for a call, and SEND_FUNC_ARG, which sends that operand as a function argument.
The tracing JIT can begin a side trace at SEND_FUNC_ARG after FETCH_DIM_FUNC_ARG has already executed in the parent trace. At the side-trace entry, precise SSA type information for the live-in temporary is not necessarily available.
In ext/opcache/jit/zend_jit_trace.c, zend_jit_trace_build_tssa() initializes such variables using a conservative fallback type mask. The temporary-variable mask included MAY_BE_REF and MAY_BE_ANY, but it omitted MAY_BE_INDIRECT.
These bits are not interchangeable. An IS_INDIRECT zval points to another zval; it is distinct from an IS_REFERENCE zval. Neither MAY_BE_REF nor MAY_BE_ANY includes the indirect representation.
The argument-sending implementation already accounts for that case. In particular, zend_jit_send_ref() uses MAY_BE_INDIRECT to decide whether to emit a conditional indirect dereference. When the fallback mask excludes the bit, the compiled path can omit a dereference that the actual operand requires.
One missing bit
The C change adds MAY_BE_INDIRECT to the fallback mask for temporaries in zend_jit_trace_build_tssa(). The relevant portion of the expression is:
- MAY_BE_REF | MAY_BE_ANY
+ MAY_BE_REF | MAY_BE_INDIRECT | MAY_BE_ANY
This shows the changed segment of a longer type-mask assignment, not the entire C line.
The new bit does not require unconditional dereferencing. It preserves the possibility of an indirect zval when the compiler lacks a more precise type, so the existing code-generation path can handle that representation correctly.
No changes to zend_jit_send_ref() were needed.
Checking the fix
I added ext/opcache/tests/jit/gh24438.phpt to hold the reproducer and assert the expected array values. On the original PHP 8.5 debug build, this test failed in zval_get_long_func(); with the type-mask change applied, it passed.
I also compared the original reproducer with interpreter behavior and checked variations involving existing references, array aliasing and copy-on-write. The PHP 8.5 OPcache JIT test suite completed with:
These were local results from PHP 8.5.13-dev on Linux x86_64, using a debug non-thread-safe build. I did not locally validate release, ZTS or other architectures as part of the PR.
Upstream
Niels Dossche reviewed the patch and committed it to PHP 8.4 on 26 October 2026. The upstream commit contains the C mask change, the regression test and a NEWS entry.
The failure came from an incorrect type assumption at a trace boundary. The argument-sending code already supported indirect zvals, but the fallback SSA mask did not say the temporary might hold one. Adding that bit allowed the JIT to use the handling it already had.
← Back to engineering work