From b99429ac0579363e5806d3edb7321f2ab67ec968 Mon Sep 17 00:00:00 2001 From: "C. Scott Ananian" Date: Wed, 21 Oct 2026 11:09:43 -0400 Subject: [PATCH 1/2] Fix temporaries leaking when a VM interrupt throws at the op that frees them The VM checks for interrupts after setting opline to the next op, e.g. the target of a jump, and in the CALL VM the op after a call to a user function. The JIT checks at loop headers and in the exit stub of a side exit. If the interrupt function throws (an async pcntl signal handler, a request timeout from Excimer, ...), the exception is attributed to that op, which did not run. Live ranges end at the op that consumes or frees the var, and ZEND_HANDLE_EXCEPTION only frees vars that are live past the throwing op, so a var that the interrupted op was about to free leaks. For example, as a foreach loop over a generator exits, FE_FETCH (or FE_RESET, for an empty generator) jumps to the FE_FREE, and the generator leaks if the interrupt throws there. This is the common case if the interrupt arrives while the generator runs, since returning from a user function does not check for interrupts in the hybrid VM. The same happens with a switch on a string, or the result of a ternary or match expression that is assigned after the jump. The interrupt helpers already undefine the result of the interrupted op, since ZEND_HANDLE_EXCEPTION would free it. Move that to a new zend_cleanup_interrupted_op(), which also frees the vars whose live range ends at the interrupted op, and the operand that the op before it defined, which has no live range at all. 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. Assisted-by: Claude Opus 5.5 --- NEWS | 3 + .../foreach_vm_interrupt_on_exit.phpt | 82 ++++++++ Zend/tests/vm_interrupt_forward_jump.phpt | 93 +++++++++ Zend/zend_execute.c | 189 ++++++++++++++---- Zend/zend_execute.h | 1 + Zend/zend_vm_def.h | 13 +- Zend/zend_vm_execute.h | 13 +- ext/opcache/jit/zend_jit_helpers.c | 12 +- ext/zend_test/observer.c | 14 +- ext/zend_test/php_test.h | 2 + ext/zend_test/test.c | 1 + 11 files changed, 347 insertions(+), 76 deletions(-) create mode 100644 Zend/tests/generators/foreach_vm_interrupt_on_exit.phpt create mode 100644 Zend/tests/vm_interrupt_forward_jump.phpt diff --git a/NEWS b/NEWS index 12d15e699f1f..c0dfd3c00838 100644 --- a/NEWS +++ b/NEWS @@ -26,6 +26,9 @@ PHP NEWS . Fixed bug GH-24321 (ZTS: global_map_ptr_last survives zend_shutdown(), so an in-process engine restart keeps growing the map_ptr table). (David Carlier) + . Fixed temporaries leaking when a VM interrupt throws at the op that frees + them, e.g. as a foreach loop over a generator exits. + (C. Scott Ananian) - Date: . Fixed bug GH-20503 (Assertion failure with ext/date DateInterval property diff --git a/Zend/tests/generators/foreach_vm_interrupt_on_exit.phpt b/Zend/tests/generators/foreach_vm_interrupt_on_exit.phpt new file mode 100644 index 000000000000..acf9e35e0c6d --- /dev/null +++ b/Zend/tests/generators/foreach_vm_interrupt_on_exit.phpt @@ -0,0 +1,82 @@ +--TEST-- +VM interrupt throwing as foreach over a generator exits the loop +--DESCRIPTION-- +When the generator finishes, FE_RESET and FE_FETCH jump forward to the loop's +FE_FREE. An interrupt that throws there must not leak the iterator. +--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-- +getMessage(), "\n"; + } +} + +test('foreach', function () { + foreach (gen() as $v) { + echo "value: $v\n"; + } +}); + +test('foreach, empty', function () { + foreach (gen_empty() as $v) { + echo "value: $v\n"; + } +}); + +test('foreach by reference', function () { + foreach (gen_ref() as &$v) { + echo "value: $v\n"; + } +}); +?> +--EXPECT-- +foreach: +value: 1 +release() +Exception: VM interrupt +foreach, empty: +release() +Exception: VM interrupt +foreach by reference: +value: 1 +release() +Exception: VM interrupt diff --git a/Zend/tests/vm_interrupt_forward_jump.phpt b/Zend/tests/vm_interrupt_forward_jump.phpt new file mode 100644 index 000000000000..dad2fef0d24b --- /dev/null +++ b/Zend/tests/vm_interrupt_forward_jump.phpt @@ -0,0 +1,93 @@ +--TEST-- +VM interrupt pending at a forward jump onto the op that frees a temporary +--DESCRIPTION-- +A jump whose target frees or consumes a temporary that is live across the +jump: the target is the end of that temporary's live range, so an exception +thrown there by an interrupt must not leak the temporary. +--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-- +getMessage(), "\n"; + } +} + +test('ternary', function () { + $c = value(); + $x = $c ? $c . release() : 'c'; +}); + +test('ternary, array element', function () { + $c = value(); + $a = []; + $a['k'] = $c ? $c . release() : 'c'; +}); + +// Interrupts are checked on return from a function in the CALL VM +test('interpolated last', function () { + $f = 'release'; + $x = "<{$f()}"; +}); + +test('match', function () { + $c = value(); + $x = match ($c) { + 'aaa' => $c . release(), + default => 'c', + }; +}); + +test('switch', function () { + switch (value()) { + case 'aaa': + release(); + break; + default: + echo "default\n"; + } +}); +?> +--EXPECT-- +ternary: +release() +Exception: VM interrupt +ternary, array element: +release() +Exception: VM interrupt +interpolated last: +release() +Exception: VM interrupt +match: +release() +Exception: VM interrupt +switch: +release() +Exception: VM interrupt diff --git a/Zend/zend_execute.c b/Zend/zend_execute.c index ae0b337ed2a6..99806883dba6 100644 --- a/Zend/zend_execute.c +++ b/Zend/zend_execute.c @@ -4687,6 +4687,51 @@ static void free_unfinished_calls(zend_execute_data *execute_data) } } +static void cleanup_live_var(zend_execute_data *execute_data, const zend_live_range *range, uint32_t op_num) /* {{{ */ +{ + uint32_t kind = range->var & ZEND_LIVE_MASK; + uint32_t var_num = range->var & ~ZEND_LIVE_MASK; + zval *var = EX_VAR(var_num); + + if (kind == ZEND_LIVE_TMPVAR) { + zval_ptr_dtor_nogc(var); + } else if (kind == ZEND_LIVE_NEW) { + zend_object *obj; + ZEND_ASSERT(Z_TYPE_P(var) == IS_OBJECT); + obj = Z_OBJ_P(var); + zend_object_store_ctor_failed(obj); + OBJ_RELEASE(obj); + } else if (kind == ZEND_LIVE_LOOP) { + if (Z_TYPE_P(var) != IS_ARRAY && Z_FE_ITER_P(var) != (uint32_t)-1) { + zend_hash_iterator_del(Z_FE_ITER_P(var)); + } + zval_ptr_dtor_nogc(var); + } else if (kind == ZEND_LIVE_ROPE) { + zend_string **rope = (zend_string **)var; + zend_op *last = EX(func)->op_array.opcodes + op_num; + while ((last->opcode != ZEND_ROPE_ADD && last->opcode != ZEND_ROPE_INIT) + || last->result.var != var_num) { + ZEND_ASSERT(last >= EX(func)->op_array.opcodes); + last--; + } + if (last->opcode == ZEND_ROPE_INIT) { + zend_string_release_ex(*rope, 0); + } else { + int j = last->extended_value; + do { + zend_string_release_ex(rope[j], 0); + } while (j--); + } + } else if (kind == ZEND_LIVE_SILENCE) { + /* restore previous error_reporting value */ + if (E_HAS_ONLY_FATAL_ERRORS(EG(error_reporting)) + && !E_HAS_ONLY_FATAL_ERRORS(Z_LVAL_P(var))) { + EG(error_reporting) = Z_LVAL_P(var); + } + } +} +/* }}} */ + static void cleanup_live_vars(zend_execute_data *execute_data, uint32_t op_num, uint32_t catch_op_num) /* {{{ */ { int i; @@ -4698,10 +4743,6 @@ static void cleanup_live_vars(zend_execute_data *execute_data, uint32_t op_num, break; } else if (op_num < range->end) { if (!catch_op_num || catch_op_num >= range->end) { - uint32_t kind = range->var & ZEND_LIVE_MASK; - uint32_t var_num = range->var & ~ZEND_LIVE_MASK; - zval *var = EX_VAR(var_num); - /* Handle the split range for loop vars */ if (catch_op_num) { zend_op *final_op = EX(func)->op_array.opcodes + range->end; @@ -4712,48 +4753,116 @@ static void cleanup_live_vars(zend_execute_data *execute_data, uint32_t op_num, } } - if (kind == ZEND_LIVE_TMPVAR) { - zval_ptr_dtor_nogc(var); - } else if (kind == ZEND_LIVE_NEW) { - zend_object *obj; - ZEND_ASSERT(Z_TYPE_P(var) == IS_OBJECT); - obj = Z_OBJ_P(var); - zend_object_store_ctor_failed(obj); - OBJ_RELEASE(obj); - } else if (kind == ZEND_LIVE_LOOP) { - if (Z_TYPE_P(var) != IS_ARRAY && Z_FE_ITER_P(var) != (uint32_t)-1) { - zend_hash_iterator_del(Z_FE_ITER_P(var)); - } - zval_ptr_dtor_nogc(var); - } else if (kind == ZEND_LIVE_ROPE) { - zend_string **rope = (zend_string **)var; - zend_op *last = EX(func)->op_array.opcodes + op_num; - while ((last->opcode != ZEND_ROPE_ADD && last->opcode != ZEND_ROPE_INIT) - || last->result.var != var_num) { - ZEND_ASSERT(last >= EX(func)->op_array.opcodes); - last--; - } - if (last->opcode == ZEND_ROPE_INIT) { - zend_string_release_ex(*rope, 0); - } else { - int j = last->extended_value; - do { - zend_string_release_ex(rope[j], 0); - } while (j--); - } - } else if (kind == ZEND_LIVE_SILENCE) { - /* restore previous error_reporting value */ - if (E_HAS_ONLY_FATAL_ERRORS(EG(error_reporting)) - && !E_HAS_ONLY_FATAL_ERRORS(Z_LVAL_P(var))) { - EG(error_reporting) = Z_LVAL_P(var); - } - } + cleanup_live_var(execute_data, range, op_num); } } } } /* }}} */ +static void zend_cleanup_interrupted_operand(zend_execute_data *execute_data, const zend_op *def, const zend_op *throw_op) /* {{{ */ +{ + uint32_t op_num = throw_op - EX(func)->op_array.opcodes; + uint32_t var_num = def->result.var; + int i; + + if (!(def->result_type & (IS_TMP_VAR|IS_VAR))) { + return; + } + switch (def->opcode) { + /* These don't define a value that has to be destroyed, + * or have their own kind of live range (see emit_live_range()). */ + case ZEND_ADD_ARRAY_ELEMENT: + case ZEND_ADD_ARRAY_UNPACK: + case ZEND_ROPE_INIT: + case ZEND_ROPE_ADD: + case ZEND_JMPZ_EX: + case ZEND_JMPNZ_EX: + case ZEND_BOOL: + case ZEND_BOOL_NOT: + case ZEND_FETCH_CLASS: + case ZEND_DECLARE_ANON_CLASS: + case ZEND_FAST_CALL: + case ZEND_BEGIN_SILENCE: + case ZEND_FE_RESET_R: + case ZEND_FE_RESET_RW: + case ZEND_NEW: + case ZEND_SEPARATE: + return; + } + /* A live range that ends at throw_op was cleaned up already; one that + * continues past it (throw_op does not consume the var) is cleaned up by + * ZEND_HANDLE_EXCEPTION. */ + for (i = 0; i < EX(func)->op_array.last_live_range; i++) { + const zend_live_range *range = &EX(func)->op_array.live_range[i]; + if (range->start > op_num) { + break; + } else if (op_num <= range->end && (range->var & ~ZEND_LIVE_MASK) == var_num) { + return; + } + } + zval_ptr_dtor_nogc(EX_VAR(var_num)); +} +/* }}} */ + +/* Called when the interrupt function throws. The VM checks for interrupts + * after setting opline to the next op (e.g. the target of a jump), so the + * exception is attributed to an op that did not run. */ +ZEND_API void zend_cleanup_interrupted_op(void) /* {{{ */ +{ + zend_execute_data *execute_data = EG(current_execute_data); + const zend_op *throw_op = EG(opline_before_exception); + uint32_t op_num; + int i; + + if (!throw_op || EX(opline)->opcode != ZEND_HANDLE_EXCEPTION) { + /* The exception was already pending when the interrupt was handled + * (e.g. on the jump to a catch block): throw_op did run. */ + return; + } + + /* Live ranges end at the op that frees the var (e.g. the FE_FREE of a + * foreach loop), so ZEND_HANDLE_EXCEPTION does not free vars whose live + * range ends at throw_op. throw_op did not free them, so do it here. */ + op_num = throw_op - EX(func)->op_array.opcodes; + for (i = 0; i < EX(func)->op_array.last_live_range; i++) { + const zend_live_range *range = &EX(func)->op_array.live_range[i]; + if (range->start > op_num) { + break; + } else if (range->end == op_num) { + cleanup_live_var(execute_data, range, op_num); + } + } + + /* A var defined by the op just before throw_op has no live range (it would + * be trivial), e.g. the result of the last branch of a ternary. */ + if (op_num > 0) { + const zend_op *def = throw_op - 1; + + if ((throw_op->op1_type & (IS_TMP_VAR|IS_VAR)) && throw_op->op1.var == def->result.var) { + zend_cleanup_interrupted_operand(execute_data, def, throw_op); + } + if ((throw_op->op2_type & (IS_TMP_VAR|IS_VAR)) && throw_op->op2.var == def->result.var) { + zend_cleanup_interrupted_operand(execute_data, def, throw_op); + } + if (op_num + 1 < EX(func)->op_array.last + && (throw_op+1)->opcode == ZEND_OP_DATA + && ((throw_op+1)->op1_type & (IS_TMP_VAR|IS_VAR)) && (throw_op+1)->op1.var == def->result.var) { + zend_cleanup_interrupted_operand(execute_data, def, throw_op); + } + } + + /* We have to UNDEF result, because ZEND_HANDLE_EXCEPTION is going to free it */ + if (throw_op->result_type & (IS_TMP_VAR|IS_VAR) + && throw_op->opcode != ZEND_ADD_ARRAY_ELEMENT + && throw_op->opcode != ZEND_ADD_ARRAY_UNPACK + && throw_op->opcode != ZEND_ROPE_INIT + && throw_op->opcode != ZEND_ROPE_ADD) { + ZVAL_UNDEF(EX_VAR(throw_op->result.var)); + } +} +/* }}} */ + ZEND_API void zend_cleanup_unfinished_execution(zend_execute_data *execute_data, uint32_t op_num, uint32_t catch_op_num) { cleanup_unfinished_calls(execute_data, op_num); cleanup_live_vars(execute_data, op_num, catch_op_num); diff --git a/Zend/zend_execute.h b/Zend/zend_execute.h index 1dd50ab8a69e..499034643171 100644 --- a/Zend/zend_execute.h +++ b/Zend/zend_execute.h @@ -433,6 +433,7 @@ ZEND_API void zend_clean_and_cache_symbol_table(zend_array *symbol_table); ZEND_API void ZEND_FASTCALL zend_free_compiled_variables(zend_execute_data *execute_data); ZEND_API void zend_unfinished_calls_gc(zend_execute_data *execute_data, zend_execute_data *call, uint32_t op_num, zend_get_gc_buffer *buf); ZEND_API void zend_cleanup_unfinished_execution(zend_execute_data *execute_data, uint32_t op_num, uint32_t catch_op_num); +ZEND_API void zend_cleanup_interrupted_op(void); ZEND_API ZEND_ATTRIBUTE_DEPRECATED HashTable *zend_unfinished_execution_gc(zend_execute_data *execute_data, zend_execute_data *call, zend_get_gc_buffer *gc_buffer); ZEND_API HashTable *zend_unfinished_execution_gc_ex(zend_execute_data *execute_data, zend_execute_data *call, zend_get_gc_buffer *gc_buffer, bool suspended_by_yield); diff --git a/Zend/zend_vm_def.h b/Zend/zend_vm_def.h index df255c5a868f..39108bdefdbe 100644 --- a/Zend/zend_vm_def.h +++ b/Zend/zend_vm_def.h @@ -10433,18 +10433,7 @@ ZEND_VM_HELPER(zend_interrupt_helper, ANY, ANY) } else if (zend_interrupt_function) { zend_interrupt_function(execute_data); if (EG(exception)) { - /* We have to UNDEF result, because ZEND_HANDLE_EXCEPTION is going to free it */ - const zend_op *throw_op = EG(opline_before_exception); - - if (throw_op - && throw_op->result_type & (IS_TMP_VAR|IS_VAR) - && throw_op->opcode != ZEND_ADD_ARRAY_ELEMENT - && throw_op->opcode != ZEND_ADD_ARRAY_UNPACK - && throw_op->opcode != ZEND_ROPE_INIT - && throw_op->opcode != ZEND_ROPE_ADD) { - ZVAL_UNDEF(ZEND_CALL_VAR(EG(current_execute_data), throw_op->result.var)); - - } + zend_cleanup_interrupted_op(); } ZEND_VM_ENTER(); } diff --git a/Zend/zend_vm_execute.h b/Zend/zend_vm_execute.h index 4b313f20cd39..e06d8088650b 100644 --- a/Zend/zend_vm_execute.h +++ b/Zend/zend_vm_execute.h @@ -3888,18 +3888,7 @@ static zend_never_inline ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL zend_interrupt_he } else if (zend_interrupt_function) { zend_interrupt_function(execute_data); if (EG(exception)) { - /* We have to UNDEF result, because ZEND_HANDLE_EXCEPTION is going to free it */ - const zend_op *throw_op = EG(opline_before_exception); - - if (throw_op - && throw_op->result_type & (IS_TMP_VAR|IS_VAR) - && throw_op->opcode != ZEND_ADD_ARRAY_ELEMENT - && throw_op->opcode != ZEND_ADD_ARRAY_UNPACK - && throw_op->opcode != ZEND_ROPE_INIT - && throw_op->opcode != ZEND_ROPE_ADD) { - ZVAL_UNDEF(ZEND_CALL_VAR(EG(current_execute_data), throw_op->result.var)); - - } + zend_cleanup_interrupted_op(); } ZEND_VM_ENTER(); } diff --git a/ext/opcache/jit/zend_jit_helpers.c b/ext/opcache/jit/zend_jit_helpers.c index 9504b5bf79ec..dfdf5a40fd32 100644 --- a/ext/opcache/jit/zend_jit_helpers.c +++ b/ext/opcache/jit/zend_jit_helpers.c @@ -3245,17 +3245,7 @@ static void ZEND_FASTCALL zend_jit_free_trampoline_helper(zend_function *func) static void ZEND_FASTCALL zend_jit_exception_in_interrupt_handler_helper(void) { if (EG(exception)) { - /* We have to UNDEF result, because ZEND_HANDLE_EXCEPTION is going to free it */ - const zend_op *throw_op = EG(opline_before_exception); - - if (throw_op - && throw_op->result_type & (IS_TMP_VAR|IS_VAR) - && throw_op->opcode != ZEND_ADD_ARRAY_ELEMENT - && throw_op->opcode != ZEND_ADD_ARRAY_UNPACK - && throw_op->opcode != ZEND_ROPE_INIT - && throw_op->opcode != ZEND_ROPE_ADD) { - ZVAL_UNDEF(ZEND_CALL_VAR(EG(current_execute_data), throw_op->result.var)); - } + zend_cleanup_interrupted_op(); } } diff --git a/ext/zend_test/observer.c b/ext/zend_test/observer.c index 6f3950e87fa6..98bbce214eea 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 9ab27f5a54f6..738dcb27641f 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 42619c626027..52edbed9ae66 100644 --- a/ext/zend_test/test.c +++ b/ext/zend_test/test.c @@ -1586,6 +1586,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(); return SUCCESS; } From 49ae1fc3ef065ae97182589860d910de55b1e156 Mon Sep 17 00:00:00 2001 From: "C. Scott Ananian" Date: Thu, 22 Oct 2026 16:39:43 -0400 Subject: [PATCH 2/2] Don't free consumed vars when the exception was pending before the interrupt Generator::throw() resumes the generator at ZEND_HANDLE_EXCEPTION with EG(opline_before_exception) set to the YIELD, and the executor checks for interrupts as it is entered. If an interrupt was pending (here, set by an observer as the generator resumes), zend_cleanup_interrupted_op() took the pending exception for one thrown by the interrupt function, and freed the yielded value, whose live range ends at the YIELD, although the YIELD had already moved it into the generator. Check whether an exception was pending before calling the interrupt function, instead of looking at EX(opline). The result of the op is still UNDEFed in both cases, as before. Test case by @devnexen. Assisted-by: Claude Opus 5.5 --- Zend/tests/vm_interrupt_generator_throw.phpt | 40 ++++++++++++ Zend/zend_execute.c | 67 ++++++++++---------- Zend/zend_execute.h | 2 +- Zend/zend_vm_def.h | 4 +- Zend/zend_vm_execute.h | 4 +- ext/opcache/jit/zend_jit_helpers.c | 4 +- ext/opcache/jit/zend_jit_ir.c | 4 +- 7 files changed, 87 insertions(+), 38 deletions(-) create mode 100644 Zend/tests/vm_interrupt_generator_throw.phpt diff --git a/Zend/tests/vm_interrupt_generator_throw.phpt b/Zend/tests/vm_interrupt_generator_throw.phpt new file mode 100644 index 000000000000..71c820280da2 --- /dev/null +++ b/Zend/tests/vm_interrupt_generator_throw.phpt @@ -0,0 +1,40 @@ +--TEST-- +VM interrupt handled while Generator::throw() resumes the generator +--EXTENSIONS-- +zend_test +--INI-- +zend_test.observer.enabled=1 +zend_test.observer.show_output=0 +zend_test.observer.observe_function_names=gen +zend_test.observer.set_vm_interrupt_on_begin=1 +--FILE-- +getMessage(), PHP_EOL; + } + echo "resumed\n"; + yield 2; +} + +$g = gen(); +var_dump($g->current() instanceof Dtor); +var_dump($g->throw(new Exception('thrown'))); +unset($g); +echo "done\n"; +?> +--EXPECT-- +bool(true) +Exception: thrown +resumed +destroyed +int(2) +done diff --git a/Zend/zend_execute.c b/Zend/zend_execute.c index 99806883dba6..a9f2490c55ff 100644 --- a/Zend/zend_execute.c +++ b/Zend/zend_execute.c @@ -4805,50 +4805,53 @@ static void zend_cleanup_interrupted_operand(zend_execute_data *execute_data, co } /* }}} */ -/* Called when the interrupt function throws. The VM checks for interrupts - * after setting opline to the next op (e.g. the target of a jump), so the - * exception is attributed to an op that did not run. */ -ZEND_API void zend_cleanup_interrupted_op(void) /* {{{ */ +/* Called when EG(exception) is set after the interrupt function was called. + * The VM checks for interrupts after setting opline to the next op (e.g. the + * target of a jump), so an exception that the interrupt function throws is + * attributed to an op that did not run. */ +ZEND_API void zend_cleanup_interrupted_op(bool exception_was_pending) /* {{{ */ { zend_execute_data *execute_data = EG(current_execute_data); const zend_op *throw_op = EG(opline_before_exception); uint32_t op_num; int i; - if (!throw_op || EX(opline)->opcode != ZEND_HANDLE_EXCEPTION) { - /* The exception was already pending when the interrupt was handled - * (e.g. on the jump to a catch block): throw_op did run. */ + if (!throw_op) { return; } - /* Live ranges end at the op that frees the var (e.g. the FE_FREE of a - * foreach loop), so ZEND_HANDLE_EXCEPTION does not free vars whose live - * range ends at throw_op. throw_op did not free them, so do it here. */ - op_num = throw_op - EX(func)->op_array.opcodes; - for (i = 0; i < EX(func)->op_array.last_live_range; i++) { - const zend_live_range *range = &EX(func)->op_array.live_range[i]; - if (range->start > op_num) { - break; - } else if (range->end == op_num) { - cleanup_live_var(execute_data, range, op_num); + /* If the exception was already pending when the interrupt was handled + * (e.g. when Generator::throw() resumes a generator), throw_op did run. */ + if (!exception_was_pending && EX(opline)->opcode == ZEND_HANDLE_EXCEPTION) { + /* Live ranges end at the op that frees the var (e.g. the FE_FREE of a + * foreach loop), so ZEND_HANDLE_EXCEPTION does not free vars whose live + * range ends at throw_op. throw_op did not free them, so do it here. */ + op_num = throw_op - EX(func)->op_array.opcodes; + for (i = 0; i < EX(func)->op_array.last_live_range; i++) { + const zend_live_range *range = &EX(func)->op_array.live_range[i]; + if (range->start > op_num) { + break; + } else if (range->end == op_num) { + cleanup_live_var(execute_data, range, op_num); + } } - } - /* A var defined by the op just before throw_op has no live range (it would - * be trivial), e.g. the result of the last branch of a ternary. */ - if (op_num > 0) { - const zend_op *def = throw_op - 1; + /* A var defined by the op just before throw_op has no live range (it would + * be trivial), e.g. the result of the last branch of a ternary. */ + if (op_num > 0) { + const zend_op *def = throw_op - 1; - if ((throw_op->op1_type & (IS_TMP_VAR|IS_VAR)) && throw_op->op1.var == def->result.var) { - zend_cleanup_interrupted_operand(execute_data, def, throw_op); - } - if ((throw_op->op2_type & (IS_TMP_VAR|IS_VAR)) && throw_op->op2.var == def->result.var) { - zend_cleanup_interrupted_operand(execute_data, def, throw_op); - } - if (op_num + 1 < EX(func)->op_array.last - && (throw_op+1)->opcode == ZEND_OP_DATA - && ((throw_op+1)->op1_type & (IS_TMP_VAR|IS_VAR)) && (throw_op+1)->op1.var == def->result.var) { - zend_cleanup_interrupted_operand(execute_data, def, throw_op); + if ((throw_op->op1_type & (IS_TMP_VAR|IS_VAR)) && throw_op->op1.var == def->result.var) { + zend_cleanup_interrupted_operand(execute_data, def, throw_op); + } + if ((throw_op->op2_type & (IS_TMP_VAR|IS_VAR)) && throw_op->op2.var == def->result.var) { + zend_cleanup_interrupted_operand(execute_data, def, throw_op); + } + if (op_num + 1 < EX(func)->op_array.last + && (throw_op+1)->opcode == ZEND_OP_DATA + && ((throw_op+1)->op1_type & (IS_TMP_VAR|IS_VAR)) && (throw_op+1)->op1.var == def->result.var) { + zend_cleanup_interrupted_operand(execute_data, def, throw_op); + } } } diff --git a/Zend/zend_execute.h b/Zend/zend_execute.h index 499034643171..55e22ba88851 100644 --- a/Zend/zend_execute.h +++ b/Zend/zend_execute.h @@ -433,7 +433,7 @@ ZEND_API void zend_clean_and_cache_symbol_table(zend_array *symbol_table); ZEND_API void ZEND_FASTCALL zend_free_compiled_variables(zend_execute_data *execute_data); ZEND_API void zend_unfinished_calls_gc(zend_execute_data *execute_data, zend_execute_data *call, uint32_t op_num, zend_get_gc_buffer *buf); ZEND_API void zend_cleanup_unfinished_execution(zend_execute_data *execute_data, uint32_t op_num, uint32_t catch_op_num); -ZEND_API void zend_cleanup_interrupted_op(void); +ZEND_API void zend_cleanup_interrupted_op(bool exception_was_pending); ZEND_API ZEND_ATTRIBUTE_DEPRECATED HashTable *zend_unfinished_execution_gc(zend_execute_data *execute_data, zend_execute_data *call, zend_get_gc_buffer *gc_buffer); ZEND_API HashTable *zend_unfinished_execution_gc_ex(zend_execute_data *execute_data, zend_execute_data *call, zend_get_gc_buffer *gc_buffer, bool suspended_by_yield); diff --git a/Zend/zend_vm_def.h b/Zend/zend_vm_def.h index 39108bdefdbe..572702e20def 100644 --- a/Zend/zend_vm_def.h +++ b/Zend/zend_vm_def.h @@ -10431,9 +10431,11 @@ ZEND_VM_HELPER(zend_interrupt_helper, ANY, ANY) zend_handle_delayed_oom(); HANDLE_EXCEPTION(); } else if (zend_interrupt_function) { + bool exception_was_pending = EG(exception) != NULL; + zend_interrupt_function(execute_data); if (EG(exception)) { - zend_cleanup_interrupted_op(); + zend_cleanup_interrupted_op(exception_was_pending); } ZEND_VM_ENTER(); } diff --git a/Zend/zend_vm_execute.h b/Zend/zend_vm_execute.h index e06d8088650b..cd6ac5602afd 100644 --- a/Zend/zend_vm_execute.h +++ b/Zend/zend_vm_execute.h @@ -3886,9 +3886,11 @@ static zend_never_inline ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL zend_interrupt_he zend_handle_delayed_oom(); HANDLE_EXCEPTION(); } else if (zend_interrupt_function) { + bool exception_was_pending = EG(exception) != NULL; + zend_interrupt_function(execute_data); if (EG(exception)) { - zend_cleanup_interrupted_op(); + zend_cleanup_interrupted_op(exception_was_pending); } ZEND_VM_ENTER(); } diff --git a/ext/opcache/jit/zend_jit_helpers.c b/ext/opcache/jit/zend_jit_helpers.c index dfdf5a40fd32..dea11f42d2ee 100644 --- a/ext/opcache/jit/zend_jit_helpers.c +++ b/ext/opcache/jit/zend_jit_helpers.c @@ -3242,10 +3242,10 @@ static void ZEND_FASTCALL zend_jit_free_trampoline_helper(zend_function *func) zend_free_trampoline(func); } -static void ZEND_FASTCALL zend_jit_exception_in_interrupt_handler_helper(void) +static void ZEND_FASTCALL zend_jit_exception_in_interrupt_handler_helper(const zend_object *prev_exception) { if (EG(exception)) { - zend_cleanup_interrupted_op(); + zend_cleanup_interrupted_op(prev_exception != NULL); } } diff --git a/ext/opcache/jit/zend_jit_ir.c b/ext/opcache/jit/zend_jit_ir.c index 830dbdf32721..f52fbc0f3178 100644 --- a/ext/opcache/jit/zend_jit_ir.c +++ b/ext/opcache/jit/zend_jit_ir.c @@ -2073,10 +2073,12 @@ static int zend_jit_interrupt_handler_stub(zend_jit_ctx *jit) ir_IF_FALSE(if_delayed_oom); if (zend_interrupt_function) { + ir_ref prev_exception = ir_LOAD_A(jit_EG(exception)); + ir_CALL_1(IR_VOID, ir_CONST_FUNC(zend_interrupt_function), jit_FP(jit)); if_exception = ir_IF(ir_LOAD_A(jit_EG(exception))); ir_IF_TRUE(if_exception); - ir_CALL(IR_VOID, ir_CONST_FUNC(zend_jit_exception_in_interrupt_handler_helper)); + ir_CALL_1(IR_VOID, ir_CONST_FC_FUNC(zend_jit_exception_in_interrupt_handler_helper), prev_exception); ir_MERGE_WITH_EMPTY_FALSE(if_exception); jit_STORE_FP(jit, ir_LOAD_A(jit_EG(current_execute_data)));