Skip to content

finally block runs twice when a VM interrupt throws as it returns to "return" in the try block #24480

Open

Description

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

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)

Activity

  1. added a commit that references this issue on Oct 21, 2026
    284c365
  2. self-assigned this
    on Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions