Commit 1bc7e57e9ae for php
commit 1bc7e57e9ae8b3b1e19ef9e323830817c7445ac6
Author: Nicolas Grekas <nicolas.grekas@gmail.com>
Date: Mon Oct 5 18:46:53 2026 +0200
Rethrow exceptions from destructors called by the GC in a fiber (#24118)
When the GC runs destructors in its dedicated fiber, zend_call_function()
cannot rethrow their exception into the frame that triggered the GC, since
that frame belongs to another fiber. gc_call_destructors_in_fiber() then
restores EG(exception) without rethrowing it, so the code following the
statement that triggered the GC keeps running until something checks
EG(exception), and the exception is dispatched from a stale
EG(opline_before_exception), which can skip the enclosing catch block.
Rethrow it into the current frame when no exception was pending before the
GC run, as zend_fiber_object_destroy() does.
diff --git a/Zend/tests/fibers/destructors_013.phpt b/Zend/tests/fibers/destructors_013.phpt
new file mode 100644
index 00000000000..0e555b37056
--- /dev/null
+++ b/Zend/tests/fibers/destructors_013.phpt
@@ -0,0 +1,49 @@
+--TEST--
+Fibers in destructors 013: Exception thrown by a destructor called by the GC is thrown by the statement that triggered the GC
+--INI--
+zend.enable_gc=1
+--FILE--
+<?php
+
+class Cycle {
+ public $self;
+ public function __construct() {
+ $this->self = $this;
+ }
+ public function __destruct() {
+ echo "Cycle::__destruct\n";
+ throw new Exception('Cycle::__destruct');
+ }
+}
+
+$objects = [];
+for ($i = 0; $i < 20000; $i++) {
+ $objects[] = new stdClass();
+}
+
+function f() {
+ global $objects;
+ try {
+ $copies = [...$objects];
+ new Cycle();
+ // Releasing the copies fills the GC root buffer
+ $copies = null;
+ echo "Not reached\n";
+ } catch (Exception $e) {
+ echo 'Caught: ', $e->getMessage(), "\n";
+ }
+}
+
+$fiber = new Fiber(function () {
+ try {
+ f();
+ } catch (Exception $e) {
+ echo 'Escaped: ', $e->getMessage(), "\n";
+ }
+});
+$fiber->start();
+
+?>
+--EXPECT--
+Cycle::__destruct
+Caught: Cycle::__destruct
diff --git a/Zend/tests/fibers/destructors_014.phpt b/Zend/tests/fibers/destructors_014.phpt
new file mode 100644
index 00000000000..be8c335e678
--- /dev/null
+++ b/Zend/tests/fibers/destructors_014.phpt
@@ -0,0 +1,55 @@
+--TEST--
+Fibers in destructors 014: Exception thrown by a destructor called by the GC during exception unwinding is chained
+--INI--
+zend.enable_gc=1
+--FILE--
+<?php
+
+class Cycle {
+ public $self;
+ public function __construct() {
+ $this->self = $this;
+ }
+ public function __destruct() {
+ echo "Cycle::__destruct\n";
+ throw new Exception('Cycle::__destruct');
+ }
+}
+
+$objects = [];
+for ($i = 0; $i < 20000; $i++) {
+ $objects[] = new stdClass();
+}
+
+function f() {
+ global $objects;
+ // Releasing these copies during unwinding fills the GC root buffer
+ $copies = [...$objects];
+ new Cycle();
+ throw new Exception('f');
+}
+
+function g() {
+ try {
+ f();
+ echo "Not reached\n";
+ } catch (Exception $e) {
+ echo 'Caught: ', $e->getMessage(), "\n";
+ echo 'Previous: ', $e->getPrevious()->getMessage(), "\n";
+ }
+}
+
+$fiber = new Fiber(function () {
+ try {
+ g();
+ } catch (Exception $e) {
+ echo 'Escaped: ', $e->getMessage(), "\n";
+ }
+});
+$fiber->start();
+
+?>
+--EXPECT--
+Cycle::__destruct
+Caught: Cycle::__destruct
+Previous: f
diff --git a/Zend/tests/fibers/destructors_015.phpt b/Zend/tests/fibers/destructors_015.phpt
new file mode 100644
index 00000000000..481c69fc795
--- /dev/null
+++ b/Zend/tests/fibers/destructors_015.phpt
@@ -0,0 +1,60 @@
+--TEST--
+Fibers in destructors 015: Exception thrown by a destructor called by the GC from internal code
+--INI--
+zend.enable_gc=1
+--FILE--
+<?php
+
+class Cycle {
+ public $self;
+ public function __construct() {
+ $this->self = $this;
+ }
+ public function __destruct() {
+ echo "Cycle::__destruct\n";
+ throw new Exception('Cycle::__destruct');
+ }
+}
+
+$fiber = new Fiber(function () {
+ try {
+ new Cycle();
+ gc_collect_cycles();
+ echo "Not reached\n";
+ } catch (Exception $e) {
+ echo 'Caught: ', $e->getMessage(), "\n";
+ }
+});
+$fiber->start();
+
+$objects = [];
+for ($i = 0; $i < 20000; $i++) {
+ $objects[] = new stdClass();
+}
+
+function create_fiber() {
+ global $objects;
+ $copies = [...$objects];
+
+ // The fiber releases the copies after its function returned, which fills the GC root buffer
+ return new Fiber(function () use ($copies) {
+ new Cycle();
+ echo "Return\n";
+ });
+}
+
+$fiber = create_fiber();
+try {
+ $fiber->start();
+ echo "Not reached\n";
+} catch (Exception $e) {
+ echo 'Caught: ', $e->getMessage(), "\n";
+}
+
+?>
+--EXPECT--
+Cycle::__destruct
+Caught: Cycle::__destruct
+Return
+Cycle::__destruct
+Caught: Cycle::__destruct
diff --git a/Zend/zend_gc.c b/Zend/zend_gc.c
index 669a009defe..fbe46a435fd 100644
--- a/Zend/zend_gc.c
+++ b/Zend/zend_gc.c
@@ -1898,6 +1898,7 @@ static zend_never_inline void gc_call_destructors_in_fiber(uint32_t end)
zend_object *exception = NULL;
remember_prev_exception(&exception);
+ zend_object *old_exception = exception;
if (UNEXPECTED(!fiber)) {
fiber = gc_create_destructor_fiber();
@@ -1932,6 +1933,12 @@ static zend_never_inline void gc_call_destructors_in_fiber(uint32_t end)
}
EG(exception) = 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
+ && ZEND_USER_CODE(EG(current_execute_data)->func->common.type)) {
+ zend_rethrow_exception(EG(current_execute_data));
+ }
}
ZEND_API int zend_gc_collect_cycles(void)