[php-src] Issue #24186: Tracing JIT (8.4+): wrong result after interrupts are handled in a loop that uses ?? or ?:

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

« previous php.bugs (#252939) next »