Repository navigation
Conversation
…es them The VM checks for interrupts after setting opline to the next op, e.g. the target of a jump, and in the CALL VM the op after a call to a user function. The JIT checks at loop headers and in the exit stub of a side exit. If the interrupt function throws (an async pcntl signal handler, a request timeout from Excimer, ...), the exception is attributed to that op, which did not run. Live ranges end at the op that consumes or frees the var, and ZEND_HANDLE_EXCEPTION only frees vars that are live past the throwing op, so a var that the interrupted op was about to free leaks. For example, as a foreach loop over a generator exits, FE_FETCH (or FE_RESET, for an empty generator) jumps to the FE_FREE, and the generator leaks if the interrupt throws there. This is the common case if the interrupt arrives while the generator runs, since returning from a user function does not check for interrupts in the hybrid VM. The same happens with a switch on a string, or the result of a ternary or match expression that is assigned after the jump. The interrupt helpers already undefine the result of the interrupted op, since ZEND_HANDLE_EXCEPTION would free it. Move that to a new zend_cleanup_interrupted_op(), which also frees the vars whose live range ends at the interrupted op, and the operand that the op before it defined, which has no live range at all. Add zend_test.observer.set_vm_interrupt_on_end, which sets a pending interrupt that throws as an observed function returns, to test this deterministically. Assisted-by: Claude Opus 5.5
|
Hi @cscott would it be possible to try this test case ? --TEST--
VM interrupt handled while Generator::throw() resumes the generator
--EXTENSIONS--
zend_test
--INI--
zend_test.observer.enabled=1
zend_test.observer.show_output=0
zend_test.observer.observe_function_names=gen
zend_test.observer.set_vm_interrupt_on_begin=1
--FILE--
<?php
class Dtor {
public function __destruct() {
echo "destroyed\n";
}
}
function gen() {
try {
yield new Dtor();
} catch (Exception $e) {
echo $e::class, ': ', $e->getMessage(), PHP_EOL;
}
echo "resumed\n";
yield 2;
}
$g = gen();
var_dump($g->current() instanceof Dtor);
var_dump($g->throw(new Exception('thrown')));
unset($g);
echo "done\n";
?>
--EXPECT--
bool(true)
Exception: thrown
resumed
destroyed
int(2)
done |
|
|
…terrupt Generator::throw() resumes the generator at ZEND_HANDLE_EXCEPTION with EG(opline_before_exception) set to the YIELD, and the executor checks for interrupts as it is entered. If an interrupt was pending (here, set by an observer as the generator resumes), zend_cleanup_interrupted_op() took the pending exception for one thrown by the interrupt function, and freed the yielded value, whose live range ends at the YIELD, although the YIELD had already moved it into the generator. Check whether an exception was pending before calling the interrupt function, instead of looking at EX(opline). The result of the op is still UNDEFed in both cases, as before. Test case by @devnexen. Assisted-by: Claude Opus 5.5
|
Ok, added a new patch on the branch which fixes @devnexen's test case (and adds it to
In the new commit the VM helper and the JIT stub now record whether an exception was pending before calling the interrupt function, and the new cleanup only runs if it wasn't. |
The VM checks for interrupts after setting
oplineto the next op, such as a jump target (and, in the CALL VM, the op after a call to a user function); the JIT checks at loop headers and in side-exit stubs. If the interrupt function throws (async pcntl signal handler, Excimer timeout, ...), the exception is attributed to that op, which has not run. Live ranges end at the op that consumes a var, soZEND_HANDLE_EXCEPTIONdoes not free vars that this op was about to consume:The same happens with
switchon a string, and with ternary/matchresults assigned after the jump.The interrupt helpers already undefine the result of the interrupted op. This PR moves that to
zend_cleanup_interrupted_op(), which also frees vars whose live range ends at the interrupted op, and the operand defined by the op just before it (which has no live range). Interrupt timing is unchanged.Tests use
zend_test.observer.set_vm_interrupt_on_end(also added by the fix for GH-24480), which makes an interrupt pending and throwing as an observed function returns.Tested on debug builds of the hybrid and CALL VMs, without opcache and with opcache with JIT off, function JIT and tracing JIT.