Repository navigation
Tracing JIT reads a stale stack slot at a side exit #24634
Description
Summary
opcache.jit=tracing miscompiles a function that reads a variable in one branch and assigns a constant to the same variable in another branch.
The else branch uses $exponent = 113 instead of the input exponent, so every normal input gives a wrong result.
0x0400 must give 0x38800000, but gives 0x70800000.
opcache.jit=function and opcache.jit=off give correct results.
The effect is silent wrong numeric results. PHP 8.5.11 NTS, 8.7.0-dev ZTS, and master 4a51e519 give the same wrong bits.
Environment
- PHP 8.7.0-dev (cli) ZTS, commit
02207318726,./configure --enable-zts --disable-all --enable-cli --with-ffi --enable-mbstring --without-pear - PHP 8.7.0-dev (cli) NTS, master
4a51e519(2026-10-24),./configure --disable-all --enable-cli - PHP 8.5.11 (cli) NTS (distribution package)
- Linux 7.1.8, x86_64, AMD Ryzen 9 3900XT
- INI:
opcache.enable=1,opcache.enable_cli=1,opcache.jit=tracing,opcache.jit_buffer_size=64M - INI:
opcache.jit_hot_loop=1,opcache.jit_hot_func=1,opcache.jit_hot_return=1,opcache.jit_hot_side_exit=1 - INI:
opcache.file_update_protection=0
Reproduce
- Save the code below as
bug.php. - Run the command below.
- Read the last line. With tracing it prints
wrong results: 12. - Set
opcache.jit=functionoropcache.jit=off. The script printsok.
php -n -d opcache.enable=1 -d opcache.enable_cli=1 -d opcache.jit=tracing \
-d opcache.jit_buffer_size=64M -d opcache.file_update_protection=0 \
-d opcache.jit_hot_loop=1 -d opcache.jit_hot_func=1 \
-d opcache.jit_hot_return=1 -d opcache.jit_hot_side_exit=1 bug.php<?php
function f16(int $half): float
{
$sign = ($half & 0x8000) << 16;
$exponent = ($half >> 10) & 0x1F;
$mantissa = $half & 0x3FF;
if ($exponent === 0) {
if ($mantissa === 0) {
$bits = $sign;
} else {
$exponent = 127 - 15 + 1;
while (($mantissa & 0x400) === 0) {
$mantissa <<= 1;
$exponent--;
}
$mantissa &= 0x3FF;
$bits = $sign | ($exponent << 23) | ($mantissa << 13);
}
} elseif ($exponent === 31) {
$bits = $sign | 0x7F800000 | ($mantissa << 13);
} else {
$bits = $sign | (($exponent - 15 + 127) << 23) | ($mantissa << 13);
}
return unpack('G', pack('N', $bits))[1];
}
$expected = [0x0400 => 0x38800000, 0x3C00 => 0x3F800000, 0x7C00 => 0x7F800000, 0xBC00 => 0xBF800000];
$wrong = 0;
for ($pass = 0; $pass < 3; $pass++) {
for ($half = 0; $half < 65536; $half++) {
$value = f16($half);
if (isset($expected[$half]) && unpack('N', pack('G', $value))[1] !== $expected[$half]) {
$wrong++;
}
}
}
echo $wrong === 0 ? "ok\n" : "wrong results: {$wrong}\n";The attached bug.phpt uses the same function under run-tests.php. It fails with wrong results: 12, and passes with function or off.
Expected and actual
| half | expected | tracing |
|---|---|---|
| 0x0400 | 0x38800000 | 0x70800000 |
| 0x3C00 | 0x3F800000 | 0x70800000 |
| 0x7C00 | 0x7F800000 | 0x70800000 |
| 0xBC00 | 0xBF800000 | 0xF0800000 |
Analysis
0x70800000is(113 - 15 + 127) << 23.113is the constant that the$exponent === 0branch assigns. Theelsebranch reads it as if that branch ran.- The bug needs register allocation.
opcache.jit=1054(no register allocation) printsok;opcache.jit=1255printswrong results: 12. - The bug needs the
pack()/unpack()return path. Returning$bitsasintprintsok. - A related driver also prints
Warning: Undefined variable $exponentat theelseifline. The JIT marks the CV as undefined at that checkpoint. opcache.file_update_protection=0is required when the script is new. If opcache does not cache the script, the bug does not show.- The trigger depends on the statement layout. Renaming the variable, merging the branches, or changing the driver hides it.
Root cause: zend_jit_trace_allocate_registers() ends the live range of the CV at the if and emits no store. The false branch exits the trace. The interpreter then reads the stale CV slot. fix.patch forces ZREG_STORE for CV definitions, so the slot is current at every side exit.
Reference fix
fix.patch sets ZREG_STORE for CVs defined in a trace, so a side exit cannot read a stale CV slot. It applies cleanly to 4a51e519. On 02207318726, bug.phpt passes and ext/opcache/tests/jit stays green (518 passed, 11 skipped, 0 failed).
Related
- Tracing JIT: float temporary kept only in a register is marked type_only in a side trace exit, VM then reads a stale value #24471: a
type_onlyvalue at a side trace exit is stale. - Fix GH-24471: Tracing JIT reads stale float temporary on side exit #24484: open PR for a related
type_onlyfloat case; it does not cover CV slots. - Tracing JIT: a catch reads an int local as undefined after an internal call throws (8.4 regression) #24445: an internal call leaves an int local undefined.
Activity
dixyes commentedon Oct 24, 2026
fix.patch
bug-phpt-as-php.php