[php-src] Issue #24186: Tracing JIT (8.4+): wrong result after interrupts are handled in a loop that uses ?? or ?:
| From: | broskees | Date: | Thu, 08 Oct 2026 04:35:38 +0000 |
| Subject: | [php-src] Issue #24186: Tracing JIT (8.4+): wrong result after interrupts are handled in a loop that uses ?? or ?: | ||
| Groups: | php.bugs | ||
| Request: | Send a blank email to php-bugs+get-252939@lists.php.net to get a copy of this message | ||
Issue: https://github.com/php/php-src/issues/24186
Author: broskees
### 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
<?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 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):
>
> 1. An opcode without inline code in the trace compiler goes to »z�»ö
> ±¶‚§q>&0žzend_jit_trace_handler()
> ([zend_jit_trace.c:6486-6498](https://github.com/php/php-src/blob/php-8.5.11/ext/opcache/jit/zend_jit_trace.c#L6486-L6498)),
> which calls zend_jit_set_ip()
> ([zend_jit_ir.c:17144](https://github.com/php/php-src/blob/php-8.5.11/ext/opcache/jit/zend_jit_ir.c#L17144)).
> That marks the use of the initial IP
> ([zend_jit_ir.c:1088-1089](https://github.com/php/php-src/blob/php-8.5.11/ext/opcache/jit/zend_jit_ir.c#L1088-L1089)),
> so the trace gets ZEND_JIT_TRACE_USES_INITIAL_IP
> ([zend_jit_trace.c:7186-7187](https://github.com/php/php-src/blob/php-8.5.11/ext/opcache/jit/zend_jit_trace.c#L7186-L7187)).
> 2. For a loop trace with that flag,
> [zend_jit_trace.c:7226-7238](https://github.com/php/php-src/blob/php-8.5.11/ext/opcache/jit/zend_jit_trace.c#L7226-L7238)
> uses the deoptimizing exit only if ra &&
> zend_jit_trace_stack_needs_deoptimization(stack, ...); otherwise
> zend_jit_stub_handlers[jit_stub_interrupt_handler].
> 3. zend_jit_trace_stack_needs_deoptimization()
> ([zend_jit_trace.c:3493-3505](https://github.com/php/php-src/blob/php-8.5.11/ext/opcache/jit/zend_jit_trace.c#L3493-L3505))
> 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 âêé¬üÔì©
> €ñËgkzend_jit_snapshot_handler()
> ([zend_jit_ir.c:788](https://github.com/php/php-src/blob/php-8.5.11/ext/opcache/jit/zend_jit_ir.c#L788),
> [828-884](https://github.com/php/php-src/blob/php-8.5.11/ext/opcache/jit/zend_jit_ir.c#L828-L884)).
> So the test returns 0 although $index lives only in
> rbx, and the loop gets the stub.
> 4. The stub
> ([zend_jit_ir.c:2072-2100](https://github.com/php/php-src/blob/php-8.5.11/ext/opcache/jit/zend_jit_ir.c#L2072-L2100))
> 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](https://github.com/php/php-src/blob/php-8.3.30/ext/opcache/jit/zend_jit_trace.c#L4218)).
> 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](https://github.com/php/php-src/blob/php-8.5.11/ext/zend_test/object_handlers.c#L251-L255)
> 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](https://github.com/php/php-src/blob/php-8.5.11/ext/opcache/jit/zend_jit_ir.c#L10586-L10591)).
#### 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](https://github.com/php/php-src/blob/php-8.5.11/ext/opcache/jit/zend_jit.c#L1577-L1579))
> 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