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):
But I expected this output instead:
With the tracing JIT, f() sometimes returns a wrong array when interrupts (EG(vm_interrupt)) arrive while its loop runs. Here they come from pcntl_async_signals() with a no-op handler. A sampling profiler that sets EG(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 $index had 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=tracing |
opcache.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 for ZEND_COALESCE and ZEND_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 $index in rbx and 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 is SNAPSHOT/3(l_94, null, null, d_93 {R2} {%rbx}) + GUARD_NOT(..., exit_5), with exit_5: 0008/0015/4/VM ... CV2($index):int(rbx): a deoptimizing exit that writes rbx back. 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 calls zend_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 gets ZEND_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, ...); otherwise zend_jit_stub_handlers[jit_stub_interrupt_handler].
zend_jit_trace_stack_needs_deoptimization() (zend_jit_trace.c:3493-3505) tests STACK_REG() and STACK_FLAGS(). Since the IR JIT, a value held in a register is a STACK_REF(); trace code generation only ever sets .reg to ZREG_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 map t->stack_map by zend_jit_snapshot_handler() (zend_jit_ir.c:788, 828-884). So the test returns 0 although $index lives only in rbx, and the loop gets the stub.
- The stub (zend_jit_ir.c:2072-2100) calls
zend_interrupt_function and continues at EX(opline), the loop's first opline, whose handler is the trace. The trace reloads $index from 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 ra is set at zend_jit_trace.c:7226; or let zend_jit_trace_stack_needs_deoptimization() count slots with a live STACK_REF() that is not ZREG_LOAD|ZREG_STORE; or handle the interrupt in place with zend_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 sets EG(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).
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 inline zend_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 $index rows 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
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):
Resulted in this output (5 of 5 runs wrong; the number varies):
But I expected this output instead:
Analysis
Relation to GH-23983 and GH-24003
Workaround
PHP Version
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