Commit 6fa1af1e921 for php

commit 6fa1af1e9212bc688959287f4f59fd999e06971b
Author: Edmond <1571649+edmonddantes@users.noreply.github.com>
Date:   Thu Oct 8 22:43:54 2026 +0800

    Fix leak when the added previous exception is already in the chain (#24177)

    zend_exception_set_previous() takes ownership of add_previous. When walking
    the exception's previous chain reaches add_previous itself, the function
    returns without releasing that reference. Release it after the loop, as on
    the other paths that discard the added previous exception.

    This can happen when an exception thrown in finally already contains the
    pending exception in its previous chain. Add regression tests covering
    direct and nested chains, destructors during unwinding, generator and fiber
    destruction, and GC destructors running in a fiber.

    Closes #24177

diff --git a/NEWS b/NEWS
index e1a0b5527c7..df64bebbbc8 100644
--- a/NEWS
+++ b/NEWS
@@ -2,6 +2,10 @@ PHP                                                                        NEWS
 |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
 ?? ??? ????, PHP 8.4.28

+- Core:
+  . Fixed memory leak when an exception already contains the pending exception
+    in its previous chain. (Edmond)
+
 - DOM:
   . Fixed bug GH-23352 (UAF reading an attribute value node retained across
     DOMDocument::adoptNode()). (David Carlier)
diff --git a/Zend/tests/exception_previous_already_in_chain_destructor.phpt b/Zend/tests/exception_previous_already_in_chain_destructor.phpt
new file mode 100644
index 00000000000..d302609aa76
--- /dev/null
+++ b/Zend/tests/exception_previous_already_in_chain_destructor.phpt
@@ -0,0 +1,29 @@
+--TEST--
+Exception thrown in a destructor during unwinding that already has the pending exception in its previous chain
+--FILE--
+<?php
+class ThrowsInDestructor {
+    public function __construct(private Throwable $pending) {}
+
+    public function __destruct() {
+        throw new LogicException("thrown", 0, $this->pending);
+    }
+}
+
+function f() {
+    $pending = new RuntimeException("pending");
+    $object = new ThrowsInDestructor($pending);
+    throw $pending;
+}
+
+try {
+    f();
+} catch (Throwable $t) {
+    for (; $t !== null; $t = $t->getPrevious()) {
+        echo $t->getMessage(), "\n";
+    }
+}
+?>
+--EXPECT--
+thrown
+pending
diff --git a/Zend/tests/fibers/gc-destructor-throw-previous-already-in-chain.phpt b/Zend/tests/fibers/gc-destructor-throw-previous-already-in-chain.phpt
new file mode 100644
index 00000000000..0e50ab9fb29
--- /dev/null
+++ b/Zend/tests/fibers/gc-destructor-throw-previous-already-in-chain.phpt
@@ -0,0 +1,41 @@
+--TEST--
+Exception thrown by a GC destructor that already has the exception of a previous, suspended destructor in its previous chain
+--FILE--
+<?php
+class Cycle {
+    public static ?Throwable $pending = null;
+    public $self;
+
+    public function __construct() {
+        $this->self = $this;
+    }
+
+    public function __destruct() {
+        if (self::$pending === null) {
+            try {
+                Fiber::suspend();
+            } finally {
+                self::$pending = new RuntimeException("pending");
+                throw self::$pending;
+            }
+        }
+        throw new LogicException("thrown", 0, self::$pending);
+    }
+}
+
+$fiber = new Fiber(function () {
+    new Cycle;
+    new Cycle;
+    try {
+        gc_collect_cycles();
+    } catch (Throwable $t) {
+        for (; $t !== null; $t = $t->getPrevious()) {
+            echo $t->getMessage(), "\n";
+        }
+    }
+});
+$fiber->start();
+?>
+--EXPECT--
+thrown
+pending
diff --git a/Zend/tests/fibers/unfinished-fiber-with-throw-previous-already-in-chain.phpt b/Zend/tests/fibers/unfinished-fiber-with-throw-previous-already-in-chain.phpt
new file mode 100644
index 00000000000..cf27c8eeff0
--- /dev/null
+++ b/Zend/tests/fibers/unfinished-fiber-with-throw-previous-already-in-chain.phpt
@@ -0,0 +1,28 @@
+--TEST--
+Exception thrown in finally of a fiber destroyed during unwinding that already has the pending exception in its previous chain
+--FILE--
+<?php
+function f() {
+    $pending = new RuntimeException("pending");
+    $fiber = new Fiber(function () use ($pending) {
+        try {
+            Fiber::suspend();
+        } finally {
+            throw new LogicException("thrown", 0, $pending);
+        }
+    });
+    $fiber->start();
+    throw $pending;
+}
+
+try {
+    f();
+} catch (Throwable $t) {
+    for (; $t !== null; $t = $t->getPrevious()) {
+        echo $t->getMessage(), "\n";
+    }
+}
+?>
+--EXPECT--
+thrown
+pending
diff --git a/Zend/tests/generators/finally/yield_throw_previous_already_in_chain.phpt b/Zend/tests/generators/finally/yield_throw_previous_already_in_chain.phpt
new file mode 100644
index 00000000000..9205eedbc5e
--- /dev/null
+++ b/Zend/tests/generators/finally/yield_throw_previous_already_in_chain.phpt
@@ -0,0 +1,30 @@
+--TEST--
+Exception thrown in finally of a generator destroyed during unwinding that already has the pending exception in its previous chain
+--FILE--
+<?php
+function gen(Throwable $pending) {
+    try {
+        yield;
+    } finally {
+        throw new LogicException("thrown", 0, $pending);
+    }
+}
+
+function f() {
+    $pending = new RuntimeException("pending");
+    $generator = gen($pending);
+    $generator->current();
+    throw $pending;
+}
+
+try {
+    f();
+} catch (Throwable $t) {
+    for (; $t !== null; $t = $t->getPrevious()) {
+        echo $t->getMessage(), "\n";
+    }
+}
+?>
+--EXPECT--
+thrown
+pending
diff --git a/Zend/tests/try/try_finally_previous_already_in_chain.phpt b/Zend/tests/try/try_finally_previous_already_in_chain.phpt
new file mode 100644
index 00000000000..fd0bdc20cb7
--- /dev/null
+++ b/Zend/tests/try/try_finally_previous_already_in_chain.phpt
@@ -0,0 +1,38 @@
+--TEST--
+Exception thrown in finally that already has the pending exception in its previous chain
+--FILE--
+<?php
+function direct() {
+    $e = new RuntimeException("pending");
+    try {
+        throw $e;
+    } finally {
+        throw new LogicException("thrown", 0, $e);
+    }
+}
+
+function nested() {
+    $e = new RuntimeException("pending");
+    try {
+        throw $e;
+    } finally {
+        throw new LogicException("thrown", 0, new Exception("middle", 0, $e));
+    }
+}
+
+foreach (['direct', 'nested'] as $function) {
+    try {
+        $function();
+    } catch (Throwable $t) {
+        for (; $t !== null; $t = $t->getPrevious()) {
+            echo $t->getMessage(), "\n";
+        }
+    }
+}
+?>
+--EXPECT--
+thrown
+pending
+thrown
+middle
+pending
diff --git a/Zend/zend_exceptions.c b/Zend/zend_exceptions.c
index 2607cae594c..02fbaf7e1ad 100644
--- a/Zend/zend_exceptions.c
+++ b/Zend/zend_exceptions.c
@@ -135,6 +135,8 @@ void zend_exception_set_previous(zend_object *exception, zend_object *add_previo
 		}
 		ex = previous;
 	} while (Z_OBJ_P(ex) != add_previous);
+
+	OBJ_RELEASE(add_previous);
 }
 /* }}} */