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)