[php-src] Issue #24683: Tracing JIT: local keeps a stale constant after a trace links into a loop trace
| From: | ChrisOrlando | Date: | Sun, 25 Oct 2026 12:28:35 +0000 |
| Subject: | [php-src] Issue #24683: Tracing JIT: local keeps a stale constant after a trace links into a loop trace | ||
| Groups: | php.bugs | ||
| Request: | Send a blank email to php-bugs+get-253179@lists.php.net to get a copy of this message | ||
Issue: https://github.com/php/php-src/issues/24683
Author: ChrisOrlando
# Tracing JIT: local keeps a stale constant after a trace links into a loop trace
### Description
With the tracing JIT, a local variable that is set to a constant and then overwritten by a
loaded value keeps the *constant* after another trace links into the function's loop trace.
The function then reads through the wrong pointer and returns the wrong value.
In
step() below, case 1 sets $ptr = 0x80080000, uses it to
compute an address, loads
the real pointer (0x1F8010F4) and assigns it to $ptr, then falls through
to case 2.
Once the JIT has compiled the loop, step() sometimes returns with $ptr
still 0x80080000.
The following code:
```php
<?php
// php -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=64M repro.php
//
// step() is a small state machine. Case 1 sets $ptr to a constant, uses it to
// load the real pointer, and stores that in $ptr; it then falls through to case 2,
// which reads through $ptr. After the tracing JIT has compiled the loop in
// step(), $ptr sometimes still holds the constant when step() returns.
final class Bus
{
public int $value = 0;
public function read(int $address): int { return $this->value; }
}
function step(Bus $bus, array &$state, array &$ram, int $pc): int
{
$ptr = $state['ptr']; $i = $state['i']; $n = $state['n']; $ret =
$state['ret'];
$c = 0;
while (true) {
switch ($pc) {
case 1:
$ptr = 0x80080000;
$a = $ptr - 0x6260;
if (($a & 3) !== 0) { goto trap; }
$p = $a & 0x1FFFFFFF;
$inRam = $p < 0x00800000;
$x = $inRam ? $ram[$p >> 2] : $bus->read($a);
$ptr = $x; // 0x1F8010F4
$t = $i !== $n; // unused, but needed to reproduce
$c += 6; // unused, but needed to reproduce
// fall through
case 2:
$a = $ptr;
$p = $a & 0x1FFFFFFF;
$inRam = $p < 0x00800000;
$y = $inRam ? $ram[$p >> 2] : $bus->read($a);
$pc = ($y & 0x7F000000) !== 0 ? 3 : 4;
continue 2;
case 3:
if ($pc === 99) { goto trap; }
$ret = 1;
$pc = $ram[0]; // 5
// fall through
default:
goto leave;
}
}
leave:
$state['ptr'] = $ptr; $state['ret'] = $ret;
return $pc;
trap:
$state['ptr'] = $ptr;
}
$bus = new Bus();
$ram = array_fill(0, 0x80000, 0);
$ram[0] = 5;
$ram[0x79DA0 >> 2] = 0x1F8010F4;
$wrong = 0;
for ($k = 0; $k < 10; $k++) {
$bus->value = ($k % 128) << 24;
$state = ['ptr' => 0x1F8010F4, 'i' => 0, 'n' => 0,
'ret' => 0];
$pc = 2;
for ($m = 0; $m < 100; $m++) {
$pc = step($bus, $state, $ram, $pc);
if ($state['ptr'] !== 0x1F8010F4) {
printf("k=%d m=%d: \$ptr is 0x%X, expected 0x1F8010F4\n", $k, $m,
$state['ptr']);
$wrong++;
}
if ($pc !== 5) {
break;
}
$pc = $state['ret'];
}
}
echo "wrong: $wrong\n";
```
Resulted in this output:
```
k=1 m=29: $ptr is 0x80080000, expected 0x1F8010F4
k=2 m=0: $ptr is 0x80080000, expected 0x1F8010F4
... (one line for each k up to 9)
wrong: 9
```
But I expected this output instead (as with opcache.jit=0 or
opcache.jit=function):
```
wrong: 0
```
### Observations
- With opcache.jit_debug set to trace start, stop and compiled, the log shows trace 1
as the
while loop in step(), and trace 2 as a side trace of it. Trace 3 is
the caller's loop: it inlines the call into step(), including case
1, then reports
stop (link to 1). After that, $ptr comes back stale. So the overwritten
$ptr doesn't
seem to be stored back before entering trace 1, and trace 1 sees the earlier constant.
- Only fails under tracing (tracing, 1254, 1255).
function is fine. Still fails with
opcache.optimization_level=0, so the opcache optimizer is probably not involved.
opcache.jit_hot_loop=1 hides it.
- Very sensitive to the shape of the code. Each of these makes it go away:
- assigning the load to $ptr directly instead of via $x
- removing the unused $t = $i !== $n; or $c += 6;
- changing $c += 6 to $c++ ($c += 2 still fails)
- comparing $i to a constant
- removing either never-taken goto trap check
- making trap: not read $ptr
- replacing $bus->read() with a constant
- using a sparse array instead of array_fill() for $ram
- Found in a PHP emulator that compiles guest machine code to PHP. Booting a console BIOS
hung, because the generated interrupt dispatcher polled the wrong address. There it only
appeared after raising opcache.jit_max_exit_counters, because at the default limit
that
trace was rejected for running out of exit counters. The standalone script fails with
default settings.
- Fresh files need opcache.file_update_protection=0 (or a few seconds' wait) to be
cached
and JIT-compiled at all.
### PHP Version
```
PHP 8.5.12 (cli) (built: Oct 19 2026 17:02:46) (NTS) [Homebrew]
```
### Operating System
macOS (Darwin 27.0.0), Apple M1 (arm64)