Repository navigation
Tracing JIT (8.4+): wrong result after interrupts are handled in a loop that uses ?? or ?: #24186
Description
Description
Paragraphs in quotes were written with an LLM (Claude), per CONTRIBUTING.md's "LLM usage in GitHub comments". They were checked against the php-8.5.11 source and the runs below. The code, commands and outputs are unedited.
The following code (needs the pcntl and posix extensions):
<?php
function f(array $values): array {
$results = [];
for ($index = 1; $index <= 4; $index++) {
$results[] = $values[$index] ?? null;
}
return $results;
}
pcntl_async_signals(true);
pcntl_signal(SIGUSR1, function () {});
$parent = getmypid();
if (($child = pcntl_fork()) === 0) {
while (posix_kill($parent, SIGUSR1)) {
usleep(1000);
}
exit(0);
}
$bad = 0;
for ($i = 0; $i < 1000000; $i++) {
if (f([1 => 1, 2 => 2, 3 => 3, 4 => 4]) !== [1, 2, 3, 4]) {
$bad++;
}
}
posix_kill($child, SIGKILL);
pcntl_waitpid($child, $status);
var_dump($bad);php -d opcache.enable_cli=1 -d opcache.file_update_protection=0 -d opcache.jit_buffer_size=64M -d opcache.jit=tracing test.php
Resulted in this output (5 of 5 runs wrong; the number varies):
int(13)
But I expected this output instead:
int(0)
With the tracing JIT,
f()sometimes returns a wrong array when interrupts (EG(vm_interrupt)) arrive while its loop runs. Here they come frompcntl_async_signals()with a no-op handler. A sampling profiler that setsEG(vm_interrupt)(Excimer) triggers it the same way. The wrong arrays are[1,1,2,3,4]or[1,2,1,2,3,4]: after the interrupt, the loop runs again from the value$indexhad when the trace was entered.It is right (0 wrong of 3 to 5 runs each) with
opcache.jit=disable,opcache.jit=off,opcache.jit=1054(tracing without register allocation), and without the signals. With the tracing JIT, it depends on the loop body:
loop body opcache.jit=tracingopcache.jit=function$results[] = $values[$index] ?? null;5 / 5 runs wrong 5 / 5 $results[] = $values[$index] ?: null;5 / 5 5 / 5 $results[] = $values[$index];0 / 5 5 / 5 $results[] = $index;0 / 5 5 / 5 The function JIT column is GH-23983. This report is about the tracing column. The tracing JIT fails only with
??and?:: the trace compiler has no inline code forZEND_COALESCEandZEND_JMP_SET, so it compiles them as calls to their VM handlers.
Analysis
opcache.jit_debug(TRACE_BYTECODE, TRACE_EXIT_INFO, IR_FINAL), no signals,??version. The loop trace (TRACE 1) keeps$indexinrbxand never stores it to its frame slot (0x70) inside the loop. COALESCE is a call to its VM handler, with an IP relative to the trace's initial IP. The interrupt check at the loop end has no snapshot and jumps to a stub:l_16 = SNAPSHOT/3(l_13, null, null, d_14 {R2} {%rbx}); # other guards have snapshots ... l_89 = RSTORE(l_87, d_88, 15); # IP = initial IP - 0xa0 (COALESCE) l_90 = CALL(l_89, c_22); # COALESCE handler ... int64_t d_135 {R2} {%rbx} = ADD(d_14 {R2} {%rbx}, c_36); # BIND(0x70) ($index++) uint8_t d_139 {R15} {%al}, l_139 = LOAD(l_138, c_37); # EG(vm_interrupt) l_140 = GUARD_NOT(l_139, d_139 {R15} {%al}, c_38); # no SNAPSHOT; c_38 = interrupt stub l_141 = LOOP_END(l_140); ---- TRACE 1 exit info exit_0: 0010/0000/1 CV0($values):array exit_1: 0012/0001/3 CV0($values):array CV1($results):array CV2($index):int(rbx) exit_2: 0006/0004/3 CV0($values):array CV1($results):array CV2($index):int(rbx) exit_3: 0007/0007/3 CV0($values):array CV1($results):array CV2($index):int(rbx)With
$results[] = $values[$index];the same check isSNAPSHOT/3(l_94, null, null, d_93 {R2} {%rbx})+GUARD_NOT(..., exit_5), withexit_5: 0008/0015/4/VM ... CV2($index):int(rbx): a deoptimizing exit that writesrbxback. That version is right.Source (php-8.5.11):
- An opcode without inline code in the trace compiler goes to
zend_jit_trace_handler()(zend_jit_trace.c:6486-6498), which callszend_jit_set_ip()(zend_jit_ir.c:17144). That marks the use of the initial IP (zend_jit_ir.c:1088-1089), so the trace getsZEND_JIT_TRACE_USES_INITIAL_IP(zend_jit_trace.c:7186-7187).- For a loop trace with that flag, zend_jit_trace.c:7226-7238 uses the deoptimizing exit only if
ra && zend_jit_trace_stack_needs_deoptimization(stack, ...); otherwisezend_jit_stub_handlers[jit_stub_interrupt_handler].zend_jit_trace_stack_needs_deoptimization()(zend_jit_trace.c:3493-3505) testsSTACK_REG()andSTACK_FLAGS(). Since the IR JIT, a value held in a register is aSTACK_REF(); trace code generation only ever sets.regtoZREG_NONE(zend_jit_trace.c:3580, 5987, 6526, 6642, 6644; zend_jit_ir.c:8035, 12598, 12603, 14621). Real registers are written only into the compiled exit mapt->stack_mapbyzend_jit_snapshot_handler()(zend_jit_ir.c:788, 828-884). So the test returns 0 although$indexlives only inrbx, and the loop gets the stub.- The stub (zend_jit_ir.c:2072-2100) calls
zend_interrupt_functionand continues atEX(opline), the loop's first opline, whose handler is the trace. The trace reloads$indexfrom the stale frame slot.In PHP-8.3 (DynASM JIT) the same test saw real registers, because code generation stored them in the stack (
SET_STACK_REG_EX(stack, i, ra[i]->reg, ZREG_LOAD), PHP-8.3 zend_jit_trace.c:4218). PHP-8.4 and master have the 8.5.11 condition and function unchanged (today: zend_jit_trace.c:7157-7169 on PHP-8.4, 7254-7266 on master). So 8.4.x and master are expected to fail too, and 8.3 not. Only 8.5.11 was run.Possible fixes (untested): use the deoptimizing exit whenever
rais set at zend_jit_trace.c:7226; or letzend_jit_trace_stack_needs_deoptimization()count slots with a liveSTACK_REF()that is notZREG_LOAD|ZREG_STORE; or handle the interrupt in place withzend_fcall_interrupt(), as GH-24003 does for the function JIT. The side-trace link path (zend_jit_trace.c:7243-7313) stores register values to memory before it links, and it did not fail in these runs.A deterministic test may be possible with zend_test's
VmInterruptComparable(ext/zend_test/object_handlers.c:251-255 setsEG(vm_interrupt)inside a comparison, with no check until the loop's back edge): compare such an object in a loop body that also contains??. Not tried: this build has no zend_test. Signals raised by the loop itself (posix_kill(getmypid(), ...)) do not reproduce it, because the JIT handles a pending interrupt right after an internal call (zend_jit_ir.c:10586-10591).
Relation to GH-23983 and GH-24003
Same family, different code path. GH-23983 is the function JIT: its loop-header check
zend_jit_check_timeout(..., NULL)(zend_jit.c:1577-1579) leaves JIT code without writing registers back. GH-24003 (open, against PHP-8.4) replaces that one call with an inlinezend_fcall_interrupt()and changes nothing in zend_jit_trace.c, so it does not cover this case. The GH-23983 test passes under tracing ("Seems fine under tracing JIT") because its loops contain no opcode compiled as a handler call, like the plain-fetch and$indexrows above.
Workaround
opcache.jit=1054(tracing, no register allocation): 0 wrong of 5 runs for every variant above. For the function JIT,opcache.jit=1005.
PHP Version
PHP 8.5.11 (cli) (built: Sep 26 2026 07:57:28) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.5.11, Copyright (c) Zend Technologies
with Zend OPcache v8.5.11, Copyright (c), by Zend Technologies
Arch Linux package php 8.5.11-1 (Debug Build: no, Thread Safety: disabled, Zend Signal Handling: enabled).
Operating System
CachyOS (Arch Linux), kernel 7.2.2, x86_64
Activity
Src-Bhavesh commentedon Oct 8, 2026
Is help with a deterministic regression test for the tracing-JIT case welcome, or is someone already handling it alongside #24003? I would first try the suggested zend_test interrupt mechanism, compare tracing with register allocation enabled/disabled, and keep the test focused on the loop containing ?? or ?:. I would confirm the expected tracing exit behavior before attempting a compiler change, and review and test the implementation before submission.
ndossche commentedon Oct 8, 2026
I'll have a look tomorrow or this weekend at how this differs and what needs to be done.