[php-src] Issue #24480: finally block runs twice when a VM interrupt throws as it returns to "return" in the try block

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

« previous php.bugs (#253052) next »