From 5ffb17d4ee8e1394f61e6cbd858a512df1cbb679 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:59:42 +0000 Subject: [PATCH 1/2] Fix leak when the added previous exception is already in the chain zend_exception_set_previous() takes ownership of add_previous. When the walk over exception's previous chain reaches add_previous, the function returns without releasing it, e.g. for an exception thrown in finally whose previous chain holds the pending exception. --- ...try_finally_previous_already_in_chain.phpt | 38 +++++++++++++++++++ Zend/zend_exceptions.c | 2 + 2 files changed, 40 insertions(+) create mode 100644 Zend/tests/try/try_finally_previous_already_in_chain.phpt 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 000000000000..fd0bdc20cb79 --- /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-- +getPrevious()) { + echo $t->getMessage(), "\n"; + } + } +} +?> +--EXPECT-- +thrown +pending +thrown +middle +pending diff --git a/Zend/zend_exceptions.c b/Zend/zend_exceptions.c index 2607cae594c5..02fbaf7e1ad1 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); } /* }}} */ From ef3d8e022b9cd031fa558a77af5c42a6e405f3f5 Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:38:16 +0000 Subject: [PATCH 2/2] Test the chain leak through the other callers of zend_exception_set_previous() A destructor during unwinding, a GC destructor fiber, a generator destroyed during unwinding and a fiber destroyed during unwinding each reach the leak through their own call. --- ..._previous_already_in_chain_destructor.phpt | 29 +++++++++++++ ...uctor-throw-previous-already-in-chain.phpt | 41 +++++++++++++++++++ ...-with-throw-previous-already-in-chain.phpt | 28 +++++++++++++ ...yield_throw_previous_already_in_chain.phpt | 30 ++++++++++++++ 4 files changed, 128 insertions(+) create mode 100644 Zend/tests/exception_previous_already_in_chain_destructor.phpt create mode 100644 Zend/tests/fibers/gc-destructor-throw-previous-already-in-chain.phpt create mode 100644 Zend/tests/fibers/unfinished-fiber-with-throw-previous-already-in-chain.phpt create mode 100644 Zend/tests/generators/finally/yield_throw_previous_already_in_chain.phpt 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 000000000000..d302609aa76c --- /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-- +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 000000000000..0e50ab9fb29a --- /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-- +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 000000000000..cf27c8eeff02 --- /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-- +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 000000000000..9205eedbc5ea --- /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-- +current(); + throw $pending; +} + +try { + f(); +} catch (Throwable $t) { + for (; $t !== null; $t = $t->getPrevious()) { + echo $t->getMessage(), "\n"; + } +} +?> +--EXPECT-- +thrown +pending