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