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)) {