Commit 07fc1f9d2f8 for php

commit 07fc1f9d2f8da87519fcf955cef9ce23daf3944e
Author: C. Scott Ananian <cscott@cscott.net>
Date:   Wed Oct 21 00:12:14 2026 -0400

    Fix GH-24480: finally block running twice when a VM interrupt throws in FAST_RET

    "return" inside a try block with a finally block compiles to FAST_CALL,
    which runs the finally block, followed by the RETURN. Both are inside the
    try range. FAST_RET jumps back to the RETURN with ZEND_VM_JMP_EX(), which
    checks for interrupts after setting opline to the RETURN. If an interrupt
    is pending at that point and the interrupt function throws (an async
    pcntl signal handler, a request timeout from Excimer, ...), the exception
    is attributed to the RETURN, i.e. to the try block, so the finally block
    runs a second time and the pending return value leaks. This is the
    common case when the interrupt arrives during a user function called
    from the finally block, since returning from a user function does not
    check for interrupts in the hybrid VM.

    The CALL VM checks for interrupts after every return from a user
    function instead, with opline at the FAST_RET that follows the call.
    There the exception is attributed to FAST_RET, which is finally_end and
    so counted as outside the try/finally: the pending return value, and any
    exception the finally block was delaying, leak.

    Handle interrupts in FAST_RET before the jump, while opline is still
    FAST_RET, and treat an exception thrown at FAST_RET as thrown inside its
    finally block in ZEND_HANDLE_EXCEPTION and
    zend_dispatch_try_catch_finally_helper. This discards the pending return
    value and chains a delayed exception, as for any other exception thrown
    in the finally block, in both VM kinds.

    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.

    Closes GH-24480
    Close GH-24486

diff --git a/NEWS b/NEWS
index ff1857f443a..0a32ea70a19 100644
--- a/NEWS
+++ b/NEWS
@@ -46,6 +46,9 @@ PHP                                                                        NEWS
     (Jorg Sowa)
   . Fixed bug GH-25061 ($x = $x - 1 on null is optimized to PRE_DEC).
     (Lazizbek Ergashev)
+  . Fixed bug GH-24480 (Finally block running a second time when a VM interrupt
+    throws as the finally block returns to a "return" in its try block).
+    (C. Scott Ananian)

 - CLI:
   . Fixed bug GH-24697 (PHP_HTTP_PARSER_STRICT build option doesn't work).
diff --git a/Zend/tests/try/finally_interrupt_after_return.phpt b/Zend/tests/try/finally_interrupt_after_return.phpt
new file mode 100644
index 00000000000..1dfd3db0059
--- /dev/null
+++ b/Zend/tests/try/finally_interrupt_after_return.phpt
@@ -0,0 +1,42 @@
+--TEST--
+VM interrupt throwing at the end of a finally block after "return" in the try block
+--DESCRIPTION--
+"return" inside a try block runs the finally block with FAST_CALL, and FAST_RET
+then jumps back to the RETURN, which is inside the try block. An interrupt that
+throws as the finally block ends belongs to the finally block: it must not run
+the finally block again, and it discards the pending return value.
+--EXTENSIONS--
+zend_test
+--INI--
+zend_test.observer.enabled=1
+zend_test.observer.show_output=0
+zend_test.observer.observe_function_names=release
+zend_test.observer.set_vm_interrupt_on_end=1
+--FILE--
+<?php
+$runs = 0;
+
+function release() {
+    global $runs;
+    $runs++;
+}
+
+function test() {
+    try {
+        return str_repeat('a', 3);
+    } finally {
+        release();
+    }
+}
+
+try {
+    $result = test();
+    echo "test() returned $result\n";
+} catch (Exception $e) {
+    echo get_class($e), ': ', $e->getMessage(), "\n";
+}
+echo "finally ran $runs time(s)\n";
+?>
+--EXPECT--
+Exception: VM interrupt
+finally ran 1 time(s)
diff --git a/Zend/tests/try/finally_interrupt_after_return_generator.phpt b/Zend/tests/try/finally_interrupt_after_return_generator.phpt
new file mode 100644
index 00000000000..da5c64bae3c
--- /dev/null
+++ b/Zend/tests/try/finally_interrupt_after_return_generator.phpt
@@ -0,0 +1,43 @@
+--TEST--
+VM interrupt throwing at the end of a generator's finally block after "return" in the try block
+--EXTENSIONS--
+zend_test
+--INI--
+zend_test.observer.enabled=1
+zend_test.observer.show_output=0
+zend_test.observer.observe_function_names=release
+zend_test.observer.set_vm_interrupt_on_end=1
+--FILE--
+<?php
+$runs = 0;
+
+function release() {
+    global $runs;
+    $runs++;
+}
+
+function gen() {
+    try {
+        yield 1;
+        return str_repeat('a', 3);
+    } finally {
+        release();
+    }
+}
+
+$gen = gen();
+try {
+    echo "yielded ", $gen->current(), "\n";
+    $gen->next();
+    echo "gen() returned ", $gen->getReturn(), "\n";
+} catch (Exception $e) {
+    echo get_class($e), ': ', $e->getMessage(), "\n";
+}
+var_dump($gen->valid());
+echo "finally ran $runs time(s)\n";
+?>
+--EXPECT--
+yielded 1
+Exception: VM interrupt
+bool(false)
+finally ran 1 time(s)
diff --git a/Zend/tests/try/finally_interrupt_after_return_nested.phpt b/Zend/tests/try/finally_interrupt_after_return_nested.phpt
new file mode 100644
index 00000000000..f4a2d09c077
--- /dev/null
+++ b/Zend/tests/try/finally_interrupt_after_return_nested.phpt
@@ -0,0 +1,47 @@
+--TEST--
+VM interrupt throwing at the end of nested finally blocks after "return" in the try block
+--DESCRIPTION--
+The inner finally block's FAST_RET jumps to the outer FAST_CALL, which is inside
+the inner try block. An interrupt that throws there must not run the inner
+finally block again, but the outer finally block still runs.
+--EXTENSIONS--
+zend_test
+--INI--
+zend_test.observer.enabled=1
+zend_test.observer.show_output=0
+zend_test.observer.observe_function_names=release
+zend_test.observer.set_vm_interrupt_on_end=1
+--FILE--
+<?php
+$runs = [];
+
+function release($name) {
+    global $runs;
+    $runs[] = $name;
+}
+
+function test() {
+    try {
+        try {
+            return str_repeat('a', 3);
+        } finally {
+            release('inner');
+        }
+    } finally {
+        release('outer');
+    }
+}
+
+try {
+    $result = test();
+    echo "test() returned $result\n";
+} catch (Exception $e) {
+    echo get_class($e), ': ', $e->getMessage(), "\n";
+    echo 'previous: ', get_class($e->getPrevious()), ': ', $e->getPrevious()->getMessage(), "\n";
+}
+echo implode(', ', $runs), "\n";
+?>
+--EXPECT--
+Exception: VM interrupt
+previous: Exception: VM interrupt
+inner, outer
diff --git a/Zend/zend_vm_def.h b/Zend/zend_vm_def.h
index 53f33e0ce5a..f9432cb6585 100644
--- a/Zend/zend_vm_def.h
+++ b/Zend/zend_vm_def.h
@@ -8086,7 +8086,9 @@ ZEND_VM_HELPER(zend_dispatch_try_catch_finally_helper, ANY, ANY, uint32_t try_ca
 			Z_OPLINE_NUM_P(fast_call) = (uint32_t)-1;
 			ZEND_VM_JMP_EX(&EX(func)->op_array.opcodes[try_catch->finally_op], 0);

-		} else if (op_num < try_catch->finally_end) {
+		} else if (op_num <= try_catch->finally_end) {
+			/* Exception thrown in the finally block, or at its FAST_RET
+			 * (by an interrupt) */
 			zval *fast_call = EX_VAR(EX(func)->op_array.opcodes[try_catch->finally_end].op1.var);

 			/* cleanup incomplete RETURN statement */
@@ -8166,7 +8168,7 @@ ZEND_VM_HANDLER(149, ZEND_HANDLE_EXCEPTION, ANY, ANY)
 			/* further blocks will not be relevant... */
 			break;
 		}
-		if (throw_op_num < try_catch->catch_op || throw_op_num < try_catch->finally_end) {
+		if (throw_op_num < try_catch->catch_op || throw_op_num <= try_catch->finally_end) {
 			current_try_catch_offset = i;
 		}
 	}
@@ -8585,7 +8587,13 @@ ZEND_VM_HANDLER(163, ZEND_FAST_RET, ANY, TRY_CATCH)
 	if (Z_OPLINE_NUM_P(fast_call) != (uint32_t)-1) {
 		const zend_op *fast_ret = EX(func)->op_array.opcodes + Z_OPLINE_NUM_P(fast_call);

-		ZEND_VM_JMP_EX(fast_ret + 1, 0);
+		/* Handle interrupts before jumping: fast_ret + 1 is inside the try
+		 * block, so an exception thrown there would run the finally block
+		 * again. An exception thrown at FAST_RET belongs to the finally
+		 * block. */
+		ZEND_VM_INTERRUPT_CHECK();
+		ZEND_VM_SET_OPCODE_NO_INTERRUPT(fast_ret + 1);
+		ZEND_VM_CONTINUE();
 	}

 	/* special case for unhandled exceptions */
diff --git a/Zend/zend_vm_execute.h b/Zend/zend_vm_execute.h
index 258567bb34e..3137c55eac0 100644
Binary files a/Zend/zend_vm_execute.h and b/Zend/zend_vm_execute.h differ
diff --git a/ext/zend_test/observer.c b/ext/zend_test/observer.c
index 6f3950e87fa..98bbce214ee 100644
--- a/ext/zend_test/observer.c
+++ b/ext/zend_test/observer.c
@@ -22,6 +22,7 @@
 #include "zend_smart_str.h"
 #include "ext/standard/php_var.h"
 #include "zend_generators.h"
+#include "zend_exceptions.h"

 static zend_observer_fcall_handlers observer_fcall_init(zend_execute_data *execute_data);

@@ -129,6 +130,11 @@ static void observer_end(zend_execute_data *execute_data, zval *retval)
 {
 	assert_observer_opline(execute_data);

+	if (ZT_G(observer_set_vm_interrupt_on_end) && !EG(exception)) {
+		ZT_G(observer_throw_on_vm_interrupt) = true;
+		zend_atomic_bool_store_ex(&EG(vm_interrupt), true);
+	}
+
 	if (!ZT_G(observer_show_output)) {
 		return;
 	}
@@ -158,6 +164,11 @@ static void zend_test_interrupt_function(zend_execute_data *execute_data)
 	if (zend_test_prev_interrupt_function) {
 		zend_test_prev_interrupt_function(execute_data);
 	}
+
+	if (ZT_G(observer_throw_on_vm_interrupt)) {
+		ZT_G(observer_throw_on_vm_interrupt) = false;
+		zend_throw_exception(NULL, "VM interrupt", 0);
+	}
 }

 static void observer_show_init(zend_function *fbc)
@@ -377,6 +388,7 @@ PHP_INI_BEGIN()
 	STD_PHP_INI_ENTRY("zend_test.observer.show_opcode_in_user_handler", "", PHP_INI_SYSTEM, OnUpdateString, observer_show_opcode_in_user_handler, zend_zend_test_globals, zend_test_globals)
 	STD_PHP_INI_BOOLEAN("zend_test.observer.set_vm_interrupt_on_begin", "0", PHP_INI_SYSTEM, OnUpdateBool, observer_set_vm_interrupt_on_begin, zend_zend_test_globals, zend_test_globals)
 	STD_PHP_INI_BOOLEAN("zend_test.observer.set_vm_interrupt_on_begin_without_callback", "0", PHP_INI_SYSTEM, OnUpdateBool, observer_set_vm_interrupt_on_begin_without_callback, zend_zend_test_globals, zend_test_globals)
+	STD_PHP_INI_BOOLEAN("zend_test.observer.set_vm_interrupt_on_end", "0", PHP_INI_SYSTEM, OnUpdateBool, observer_set_vm_interrupt_on_end, zend_zend_test_globals, zend_test_globals)
 	STD_PHP_INI_BOOLEAN("zend_test.observer.fiber_init", "0", PHP_INI_SYSTEM, OnUpdateBool, observer_fiber_init, zend_zend_test_globals, zend_test_globals)
 	STD_PHP_INI_BOOLEAN("zend_test.observer.fiber_switch", "0", PHP_INI_SYSTEM, OnUpdateBool, observer_fiber_switch, zend_zend_test_globals, zend_test_globals)
 	STD_PHP_INI_BOOLEAN("zend_test.observer.fiber_destroy", "0", PHP_INI_SYSTEM, OnUpdateBool, observer_fiber_destroy, zend_zend_test_globals, zend_test_globals)
@@ -419,7 +431,7 @@ void zend_test_observer_init(INIT_FUNC_ARGS)
 		zend_execute_internal = zend_test_execute_internal;
 	}

-	if (ZT_G(observer_set_vm_interrupt_on_begin)) {
+	if (ZT_G(observer_set_vm_interrupt_on_begin) || ZT_G(observer_set_vm_interrupt_on_end)) {
 		zend_test_prev_interrupt_function = zend_interrupt_function;
 		zend_interrupt_function = zend_test_interrupt_function;
 	}
diff --git a/ext/zend_test/php_test.h b/ext/zend_test/php_test.h
index ee2e334fb55..ea567169d25 100644
--- a/ext/zend_test/php_test.h
+++ b/ext/zend_test/php_test.h
@@ -47,6 +47,8 @@ ZEND_BEGIN_MODULE_GLOBALS(zend_test)
 	char *observer_show_opcode_in_user_handler;
 	int observer_set_vm_interrupt_on_begin;
 	int observer_set_vm_interrupt_on_begin_without_callback;
+	int observer_set_vm_interrupt_on_end;
+	bool observer_throw_on_vm_interrupt;
 	int observer_nesting_depth;
 	int observer_fiber_init;
 	int observer_fiber_switch;
diff --git a/ext/zend_test/test.c b/ext/zend_test/test.c
index a11fc54af46..854dd2979f7 100644
--- a/ext/zend_test/test.c
+++ b/ext/zend_test/test.c
@@ -1628,6 +1628,7 @@ PHP_RINIT_FUNCTION(zend_test)
 	ALLOC_HASHTABLE(ZT_G(global_weakmap));
 	zend_hash_init(ZT_G(global_weakmap), 8, NULL, ZVAL_PTR_DTOR, 0);
 	ZT_G(observer_nesting_depth) = 0;
+	ZT_G(observer_throw_on_vm_interrupt) = false;
 	zend_test_mm_custom_handlers_rinit();

 	if (ZT_G(rearm_vm_interrupt)) {