Commit 785f7140d33 for php
commit 785f7140d3366854b3a6ee3d38ff93409a41e3cc
Author: Nicolas Grekas <nicolas.grekas@gmail.com>
Date: Mon Oct 12 16:45:28 2026 +0200
Fix skipped catch block when switching fibers during exception unwinding (#24117)
zend_fiber_object_destroy() and gc_call_destructors_in_fiber() put the
pending exception aside while running another fiber, but not
EG(opline_before_exception), which that fiber overwrites. When the frame
handling the exception is already at ZEND_HANDLE_EXCEPTION, e.g. because a
destructor ran earlier during i_free_compiled_variables(), the later
zend_rethrow_exception() does not set it again, and the exception is
dispatched from the wrong op, skipping the enclosing try block.
diff --git a/NEWS b/NEWS
index f4f2cc3b835..f54a52230b4 100644
--- a/NEWS
+++ b/NEWS
@@ -16,6 +16,8 @@ PHP NEWS
. Fixed bug GH-24218 (Invalid opcode for count() of a literal array
without SCCP). (lazerg)
. Fix throwing behaviour with recursive arrays in VM. (ndossche)
+ . Fixed skipped catch block when switching fibers during exception unwinding
+ (Nicolas Grekas)
- DOM:
. Fixed bug GH-23352 (UAF reading an attribute value node retained across
diff --git a/Zend/tests/fibers/destroy-suspended-fiber-during-unwinding.phpt b/Zend/tests/fibers/destroy-suspended-fiber-during-unwinding.phpt
new file mode 100644
index 00000000000..617e9d20ac7
--- /dev/null
+++ b/Zend/tests/fibers/destroy-suspended-fiber-during-unwinding.phpt
@@ -0,0 +1,95 @@
+--TEST--
+Destroying a suspended fiber during exception unwinding does not skip the enclosing catch block
+--FILE--
+<?php
+
+class D {
+ public function __destruct() {
+ echo "D::__destruct\n";
+ }
+}
+
+function suspended_fiber(): Fiber {
+ $fiber = new Fiber(static function () {
+ Fiber::suspend();
+ });
+ $fiber->start();
+
+ return $fiber;
+}
+
+function destructor_first() {
+ $d = new D();
+ $fiber = suspended_fiber();
+ throw new Exception('destructor_first');
+}
+
+function fiber_first() {
+ $fiber = suspended_fiber();
+ $d = new D();
+ throw new Exception('fiber_first');
+}
+
+function throw_in_fiber_finally() {
+ $d = new D();
+ $fiber = new Fiber(static function () {
+ try {
+ Fiber::suspend();
+ } finally {
+ throw new Exception('fiber finally');
+ }
+ });
+ $fiber->start();
+ throw new Exception('throw_in_fiber_finally');
+}
+
+function catches(callable $f) {
+ try {
+ $f();
+ } catch (Exception $e) {
+ echo 'caught: ', $e->getMessage(), $e->getPrevious() ? ', previous: '.$e->getPrevious()->getMessage() : '', "\n";
+ }
+}
+
+function catches_after_finally(callable $f) {
+ try {
+ try {
+ $f();
+ } finally {
+ echo "finally\n";
+ }
+ } catch (Exception $e) {
+ echo 'caught: ', $e->getMessage(), "\n";
+ }
+}
+
+foreach (['destructor_first', 'fiber_first', 'throw_in_fiber_finally'] as $f) {
+ echo "-- $f\n";
+ try {
+ catches($f);
+ catches_after_finally($f);
+ } catch (Throwable $e) {
+ echo 'escaped: ', $e->getMessage(), "\n";
+ }
+}
+
+?>
+--EXPECT--
+-- destructor_first
+D::__destruct
+caught: destructor_first
+D::__destruct
+finally
+caught: destructor_first
+-- fiber_first
+D::__destruct
+caught: fiber_first
+D::__destruct
+finally
+caught: fiber_first
+-- throw_in_fiber_finally
+D::__destruct
+caught: fiber finally, previous: throw_in_fiber_finally
+D::__destruct
+finally
+caught: fiber finally
diff --git a/Zend/tests/fibers/destructors_012.phpt b/Zend/tests/fibers/destructors_012.phpt
new file mode 100644
index 00000000000..4a095e0e3de
--- /dev/null
+++ b/Zend/tests/fibers/destructors_012.phpt
@@ -0,0 +1,63 @@
+--TEST--
+Fibers in destructors 012: GC destructor fiber running during exception unwinding does not skip the enclosing catch block
+--INI--
+zend.enable_gc=1
+--FILE--
+<?php
+
+class D {
+ public function __destruct() {
+ echo "D::__destruct\n";
+ }
+}
+
+class Cycle {
+ public $self;
+ public function __construct() {
+ $this->self = $this;
+ }
+ public function __destruct() {
+ try {
+ throw new Exception('Cycle::__destruct');
+ } catch (Exception) {
+ echo "Cycle::__destruct\n";
+ }
+ }
+}
+
+$objects = [];
+for ($i = 0; $i < 50000; $i++) {
+ $objects[] = new stdClass();
+}
+
+function f() {
+ global $objects;
+ $d = new D();
+ // Releasing these copies during unwinding fills the GC root buffer
+ $copies = [...$objects];
+ new Cycle();
+ throw new Exception('f');
+}
+
+function c() {
+ try {
+ f();
+ } catch (Exception $e) {
+ echo 'caught: ', $e->getMessage(), "\n";
+ }
+}
+
+$fiber = new Fiber(function () {
+ try {
+ c();
+ } catch (Throwable $e) {
+ echo 'escaped: ', $e->getMessage(), "\n";
+ }
+});
+$fiber->start();
+
+?>
+--EXPECT--
+D::__destruct
+Cycle::__destruct
+caught: f
diff --git a/Zend/zend_fibers.c b/Zend/zend_fibers.c
index d571a622e47..e5219a69a3e 100644
--- a/Zend/zend_fibers.c
+++ b/Zend/zend_fibers.c
@@ -775,6 +775,7 @@ static void zend_fiber_object_destroy(zend_object *object)
}
zend_object *exception = EG(exception);
+ const zend_op *opline_before_exception = EG(opline_before_exception);
EG(exception) = NULL;
zval graceful_exit;
@@ -784,6 +785,8 @@ static void zend_fiber_object_destroy(zend_object *object)
zend_fiber_transfer transfer = zend_fiber_resume_internal(fiber, &graceful_exit, true);
+ EG(opline_before_exception) = opline_before_exception;
+
zval_ptr_dtor(&graceful_exit);
if (transfer.flags & ZEND_FIBER_TRANSFER_FLAG_ERROR) {
diff --git a/Zend/zend_gc.c b/Zend/zend_gc.c
index fbe46a435fd..eb54ead7b11 100644
--- a/Zend/zend_gc.c
+++ b/Zend/zend_gc.c
@@ -1897,6 +1897,7 @@ static zend_never_inline void gc_call_destructors_in_fiber(uint32_t end)
GC_G(dtor_end) = GC_G(first_unused);
zend_object *exception = NULL;
+ const zend_op *opline_before_exception = EG(opline_before_exception);
remember_prev_exception(&exception);
zend_object *old_exception = exception;
@@ -1933,6 +1934,7 @@ static zend_never_inline void gc_call_destructors_in_fiber(uint32_t end)
}
EG(exception) = exception;
+ EG(opline_before_exception) = opline_before_exception;
/* Destructors ran in another fiber, out of reach of zend_call_function()'s rethrow */
if (exception && !old_exception && EG(current_execute_data) && EG(current_execute_data)->func