[php-src] Issue #24471: Tracing JIT: float temporary kept only in a register is marked type_only in a side trace exit, VM then reads a stale value
| From: | mollenthiel | Date: | Wed, 21 Oct 2026 08:55:16 +0000 |
| Subject: | [php-src] Issue #24471: Tracing JIT: float temporary kept only in a register is marked type_only in a side trace exit, VM then reads a stale value | ||
| Groups: | php.bugs | ||
| Request: | Send a blank email to php-bugs+get-253050@lists.php.net to get a copy of this message | ||
Issue: https://github.com/php/php-src/issues/24471
Author: mollenthiel
### Description
With
opcache.jit=tracing, a float temporary that is only held in a register is
described as float(type_only) in the exit info of a side trace. When that exit is
taken, the VM resumes and reads the stale value from the stack slot, so a float multiplication
silently returns garbage (a denormal like 1.38E-309 instead of 17.0). No
error, no warning.
The following code:
```php
<?php
declare(strict_types=1);
enum Kind: string { case Normal = 'normal'; case Heavy = 'heavy'; case Spell =
'spell'; }
final class Dice {
public function succeeds(float $a, float $d, float $f): bool { return $f < 1 / (1 + 10 **
(($d - $a) / 400)); }
}
final class Foe { public function __construct(public float $damage, public float $attack) {} }
final class Rules { public float $heavyMultiplier = 2.0; public float $heavyAttackBonus = 10.0; }
final class Fight { public Kind $kind = Kind::Normal; public function __construct(public Foe $foe)
{} }
final class Engine {
public array $log = [];
public function __construct(private Rules $rules, private Dice $dice) {}
public function attack(Fight $fight, float $roll): void {
$kind = $fight->kind;
if (Kind::Spell === $kind) { return; }
$attack = $fight->foe->attack + (Kind::Heavy === $kind ?
$this->rules->heavyAttackBonus : 0.0);
$dodged = !$this->dice->succeeds($attack, 20.0, $roll);
if ($dodged) { return; }
$damage = $fight->foe->damage * (Kind::Heavy === $kind ?
$this->rules->heavyMultiplier : 1.0);
$this->hit($fight, $kind, $damage);
}
private function hit(Fight $fight, Kind $kind, float $damage): void { $this->log[] = [$kind,
$damage]; }
}
$engine = new Engine(new Rules(), new Dice());
$fight = new Fight(new Foe(8.5, 25.0));
$hits = 0; $wrong = 0;
for ($i = 0; $i < 100000; $i++) {
$fight->kind = ($i % 7 === 3) ? Kind::Heavy : Kind::Normal;
$engine->log = [];
$engine->attack($fight, ($i * 0.6180339887) - floor($i * 0.6180339887));
foreach ($engine->log as [$kind, $damage]) {
$hits++;
$expected = $kind === Kind::Heavy ? 17.0 : 8.5;
if ($damage !== $expected && $wrong++ < 3) { echo "i=$i {$kind->value}:
$damage instead of $expected\n"; }
}
}
echo "hits: $hits, wrong: $wrong\n";
```
run with:
```
php -n -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=64M -d
opcache.jit_max_trace_length=64 repro.php
```
Resulted in this output:
```
i=157 heavy: 1.3837599830125E-309 instead of 17
i=164 heavy: 1.3837599830125E-309 instead of 17
i=178 heavy: 1.3837599830125E-309 instead of 17
hits: 50925, wrong: 7440
```
But I expected this output instead:
```
hits: 50925, wrong: 0
```
(which is what the interpreter, opcache.jit=function and opcache.jit=1251
print)
### Details
- Needs register allocation and optimization level 2 or more: 1254, 1253,
1252, 1154, 0254 and 1255 are wrong;
1251, 1054 and function are correct.
- opcache.jit_max_trace_length between 40 and 80 triggers it on this small script; with
the default (1024) this script happens to be correct, but the original application (a game combat
engine, much larger) gives wrong results with the default settings.
- The bug is very sensitive to code layout: removing a line or renaming some identifiers can make it
disappear.
With opcache.jit_debug=0x14c000 (exit info), trace 6 is a side trace started from trace
4 exit 4, recorded on the Kind::Normal path of Engine::attack():
```
0026 T7 = FETCH_OBJ_R CV0($fight) string("foe")
0027 T6 = FETCH_OBJ_R T7 string("damage") ; val(float)
0028 T8 = FETCH_CLASS_CONSTANT string("Kind") string("Heavy")
0029 T7 = IS_IDENTICAL CV2($kind) T8
0030 ;JMPZ T7 0034
0034 T7 = QM_ASSIGN float(1)
0035 CV5($damage) = MUL T6 T7
```
and its exit info has:
```
exit_10: 0031/0076/9 CV0($fight):object CV2($kind):object CV4($dodged):bool CV5($damage):undef
X6:float(type_only) X7:object X8:object
```
When $kind is Kind::Heavy, exit 10 is taken and the VM resumes at 0031
(fetch heavyMultiplier), then 0035 MUL T6 T7 reads T6 from memory. But T6
($fight->foe->damage, loaded at 0027) was only kept in a register and never
stored to its slot, so type_only restores the type over a stale value. The log shows
the wrong results exactly when TRACE 6 exit 10 is taken.
This exact script is wrong on PHP 8.5.7 (Debian package from deb.sury.org) and on a release build of
master (8.7.0-dev, b9ea38ef, --disable-all --enable-opcache). A variant of it that
differs only in identifiers was also wrong on 8.5.11 (deb.sury.org) and on a PHP-8.5 build
(8.5.13-dev).
Found in a real application; I used Claude (an AI assistant) to reduce it and read the JIT debug
output.
### PHP Version
```plain
PHP 8.5.7 (cli) (built: Jun 6 2026 05:59:50) (NTS)
Copyright (c) The PHP Group
Built by Debian
Zend Engine v4.5.7, Copyright (c) Zend Technologies
with Zend OPcache v8.5.7, Copyright (c), by Zend Technologies
Also reproduced on master:
PHP 8.7.0-dev (cli) (built: Oct 20 2026 22:13:31) (NTS)
Zend Engine v4.7.0-dev, Copyright © Zend by Perforce
with Zend OPcache v8.7.0-dev, Copyright ©, by Zend by Perforce
```
### Operating System
Debian 13 (trixie), x86_64