Skip to content

Fix temporaries leaking when a VM interrupt throws at the op that fre… - #24487

Open
cscott wants to merge 2 commits into
php:PHP-8.4from
cscott:T440568-interrupt-live-range-leak
Open

cscott wants to merge 2 commits into
php:PHP-8.4from
cscott:T440568-interrupt-live-range-leak

Conversation

@cscott

@cscott cscott commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

The VM checks for interrupts after setting opline to 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, so ZEND_HANDLE_EXCEPTION does not free vars that this op was about to consume:

function gen() { yield 1; release(); } // interrupt pending as release() returns
foreach (gen() as $v) {}               // FE_FETCH jumps to FE_FREE: generator leaks

The same happens with switch on a string, and with ternary/match results 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.

…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
@devnexen

devnexen commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

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

@cscott

cscott commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

There are test cases in the PR -- what do you want me to "try" exactly? That it fails, that's it's an improvement in some way on my test case? I'm not entirely sure what you're asking for.
I think I've figured out that you're saying that your test case should pass, but that with my patch it doesn't pass, indicating a problem with my patch. I'm working on it.

…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
@cscott

cscott commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor Author

Ok, added a new patch on the branch which fixes @devnexen's test case (and adds it to Zend/tests). Can be squashed with the parent but I figured it would be better for review to keep them separate for now.

Generator::throw() resumes the generator at ZEND_HANDLE_EXCEPTION, with opline_before_exception pointing at the YIELD, and the executor checks for interrupts on entry. zend_cleanup_interrupted_op() decided whether the interrupt function had thrown by checking EX(opline)->opcode == ZEND_HANDLE_EXCEPTION, which is also true here, so it freed the new Dtor() temporary whose live range ends at the YIELD. But the YIELD had already moved it into the generator, so it was freed twice. (oops)

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants