[php-src] Issue #24480: finally block runs twice when a VM interrupt throws as it returns to "return" in the try block
| From: | cscott | Date: | Wed, 21 Oct 2026 15:02:12 +0000 |
| Subject: | [php-src] Issue #24480: finally block runs twice when a VM interrupt throws as it returns to "return" in the try block | ||
| Groups: | php.bugs | ||
| Request: | Send a blank email to php-bugs+get-253052@lists.php.net to get a copy of this message | ||
Issue: https://github.com/php/php-src/issues/24480
Author: cscott
### Description
When an interrupt is pending as a
finally block finishes after a return in
its try block, and the interrupt function throws (an async pcntl signal handler, a
request timeout from Excimer, etc.), the finally block runs a second time.
We see this in production at Wikimedia (PHP 8.3, Excimer request timeouts): https://phabricator.wikimedia.org/T440568
return X; in a try block compiles to FAST_CALL + RETURN, both
inside the try range. ZEND_FAST_RET jumps back to the RETURN with
ZEND_VM_JMP_EX(), which checks for interrupts *after* setting opline to
the RETURN. The exception is attributed to the try block, so the finally block runs
again, and the pending return value is leaked. This is the common case when the interrupt arrives
during a user function called from the finally block, because returning from a user function does
not check for interrupts in the hybrid VM.
In the CALL VM the interrupt is handled right after the user function returns, at the
FAST_RET. That op is finally_end, which counts as outside the try/finally,
so the finally block runs once but the return value (and any exception the finally block was
delaying) leaks.
```php
<?php
pcntl_async_signals(true);
pcntl_signal(SIGALRM, function () { throw new RuntimeException('timeout'); });
$a = array_map('strval', range(1, 3_000_000));
$b = array_map('strval', range(1, 3_000_000));
$A = array_fill(0, 30, $a); // tune so that $A == $B takes > 1s
$B = array_fill(0, 30, $b);
$runs = 0;
function release() {
global $runs, $A, $B;
$runs++;
$GLOBALS['eq'] = $A == $B; // the alarm fires during this single opcode
}
function test() {
try {
return 'result';
} finally {
release();
}
}
pcntl_alarm(1);
try {
echo test(), "\n";
} catch (Throwable $e) {
echo get_class($e), ': ', $e->getMessage(), "\n";
}
echo "finally ran $runs time(s)\n";
```
Resulted in this output:
```
RuntimeException: timeout
finally ran 2 time(s)
```
But I expected this output instead:
```
RuntimeException: timeout
finally ran 1 time(s)
```
### PHP Version
```plain
Reproduced with the script above on PHP 8.2.32, 8.4.26 and 8.5.7, and in production on 8.3.35.
ZEND_FAST_RET is unchanged on master, so is expected to apply to PHP 8.6 as well.
```
### Operating System
Debian (Linux, x86_64)