[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: 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

« previous php.bugs (#253050) next »