From aa1f42ab641d878bee9f4433ae35a7a9cbd4c79c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 24 Apr 2018 14:00:45 +0200 Subject: [PATCH 1/9] Only pass args to resolver and canceller if callback requires them --- tests/PromiseTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/PromiseTest.php b/tests/PromiseTest.php index 26ebdf9b..69ec0b64 100644 --- a/tests/PromiseTest.php +++ b/tests/PromiseTest.php @@ -49,6 +49,18 @@ public function shouldRejectIfResolverThrowsException() /** @test */ public function shouldRejectWithoutCreatingGarbageCyclesIfResolverThrowsException() + { + gc_collect_cycles(); + $promise = new Promise(function () { + throw new \Exception('foo'); + }); + unset($promise); + + $this->assertSame(0, gc_collect_cycles()); + } + + /** @test */ + public function shouldFulfillIfFullfilledWithSimplePromise() { gc_collect_cycles(); $promise = new Promise(function () { From 4bf585f01b6057161e22c03ef1fa3771706ae2a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 24 Apr 2018 13:41:40 +0200 Subject: [PATCH 2/9] Use static progress callback without binding to promise --- tests/PromiseTest.php | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/PromiseTest.php b/tests/PromiseTest.php index 69ec0b64..49f2d258 100644 --- a/tests/PromiseTest.php +++ b/tests/PromiseTest.php @@ -59,6 +59,39 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfResolverThrowsExceptio $this->assertSame(0, gc_collect_cycles()); } + /** + * test that checks number of garbage cycles after throwing from a resolver + * that has its arguments explicitly set to null (reassigned arguments only + * show up in the stack trace in PHP 7, so we can't test this on legacy PHP) + * + * @test + * @requires PHP 7 + * @link https://3v4l.org/OiDr4 + */ + public function shouldRejectWithoutCreatingGarbageCyclesIfResolverThrowsExceptionWithResolveAndRejectUnset() + { + gc_collect_cycles(); + $promise = new Promise(function ($resolve, $reject) { + $resolve = $reject = null; + throw new \Exception('foo'); + }); + unset($promise); + + $this->assertSame(0, gc_collect_cycles()); + } + + /** @test */ + public function shouldIgnoreNotifyAfterReject() + { + $promise = new Promise(function () { }, function ($resolve, $reject, $notify) { + $reject(new \Exception('foo')); + $notify(42); + }); + + $promise->then(null, null, $this->expectCallableNever()); + $promise->cancel(); + } + /** @test */ public function shouldFulfillIfFullfilledWithSimplePromise() { From 4df9648839f222f3e0ae342786e8bb58bcee8abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 27 Apr 2018 17:40:17 +0200 Subject: [PATCH 3/9] Use static resolve and reject callback without binding to promise --- src/Promise.php | 63 ++++++++++++++++++++++++++++++++++++++----- tests/PromiseTest.php | 52 ++++++++++++++++++++++++++--------- 2 files changed, 97 insertions(+), 18 deletions(-) diff --git a/src/Promise.php b/src/Promise.php index 824a46cc..306d287c 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -195,17 +195,68 @@ private function call(callable $callback): void if ($args === 0) { $callback(); } else { + // keep a reference to this promise instance for the static resolve/reject functions. + // see also resolveFunction() and rejectFunction() for more details. + $target =& $this; + $callback( - function ($value = null) { - $this->resolve($value); - }, - function (\Throwable $reason) { - $this->reject($reason); - } + self::resolveFunction($target), + self::rejectFunction($target) ); } } catch (\Throwable $e) { + $target = null; $this->reject($e); } } + + /** + * Creates a static resolver callback that is not bound to a promise instance. + * + * Moving the closure creation to a static method allows us to create a + * callback that is not bound to a promise instance. By passing the target + * promise instance by reference, we can still execute its resolving logic + * and still clear this reference when settling the promise. This helps + * avoiding garbage cycles if any callback creates an Exception. + * + * These assumptions are covered by the test suite, so if you ever feel like + * refactoring this, go ahead, any alternative suggestions are welcome! + * + * @param Promise $target + * @return callable + */ + private static function resolveFunction(self &$target) + { + return function ($value = null) use (&$target) { + if ($target !== null) { + $target->settle(resolve($value)); + $target = null; + } + }; + } + + /** + * Creates a static rejection callback that is not bound to a promise instance. + * + * Moving the closure creation to a static method allows us to create a + * callback that is not bound to a promise instance. By passing the target + * promise instance by reference, we can still execute its rejection logic + * and still clear this reference when settling the promise. This helps + * avoiding garbage cycles if any callback creates an Exception. + * + * These assumptions are covered by the test suite, so if you ever feel like + * refactoring this, go ahead, any alternative suggestions are welcome! + * + * @param Promise $target + * @return callable + */ + private static function rejectFunction(self &$target) + { + return function ($reason = null) use (&$target) { + if ($target !== null) { + $target->reject($reason); + $target = null; + } + }; + } } diff --git a/tests/PromiseTest.php b/tests/PromiseTest.php index 49f2d258..dbc1f353 100644 --- a/tests/PromiseTest.php +++ b/tests/PromiseTest.php @@ -48,7 +48,19 @@ public function shouldRejectIfResolverThrowsException() } /** @test */ - public function shouldRejectWithoutCreatingGarbageCyclesIfResolverThrowsException() + public function shouldResolveWithoutCreatingGarbageCyclesIfResolverResolvesWithException() + { + gc_collect_cycles(); + $promise = new Promise(function ($resolve) { + $resolve(new \Exception('foo')); + }); + unset($promise); + + $this->assertSame(0, gc_collect_cycles()); + } + + /** @test */ + public function shouldRejectWithoutCreatingGarbageCyclesIfResolverThrowsExceptionWithoutResolver() { gc_collect_cycles(); $promise = new Promise(function () { @@ -59,20 +71,36 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfResolverThrowsExceptio $this->assertSame(0, gc_collect_cycles()); } - /** - * test that checks number of garbage cycles after throwing from a resolver - * that has its arguments explicitly set to null (reassigned arguments only - * show up in the stack trace in PHP 7, so we can't test this on legacy PHP) - * - * @test - * @requires PHP 7 - * @link https://3v4l.org/OiDr4 - */ - public function shouldRejectWithoutCreatingGarbageCyclesIfResolverThrowsExceptionWithResolveAndRejectUnset() + /** @test */ + public function shouldRejectWithoutCreatingGarbageCyclesIfResolverRejectsWithException() + { + gc_collect_cycles(); + $promise = new Promise(function ($resolve, $reject) { + $reject(new \Exception('foo')); + }); + unset($promise); + + $this->assertSame(0, gc_collect_cycles()); + } + + /** @test */ + public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerRejectsWithException() + { + gc_collect_cycles(); + $promise = new Promise(function ($resolve, $reject) { }, function ($resolve, $reject) { + $reject(new \Exception('foo')); + }); + $promise->cancel(); + unset($promise); + + $this->assertSame(0, gc_collect_cycles()); + } + + /** @test */ + public function shouldRejectWithoutCreatingGarbageCyclesIfResolverThrowsException() { gc_collect_cycles(); $promise = new Promise(function ($resolve, $reject) { - $resolve = $reject = null; throw new \Exception('foo'); }); unset($promise); From ab526c8c2f23c0f52f2d517c466e58b989033739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 1 May 2018 21:14:32 +0200 Subject: [PATCH 4/9] Use static child canceller callback without binding to parent promise --- src/Promise.php | 29 ++++++++++++++--------------- tests/PromiseTest.php | 13 +++++++++++++ 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/Promise.php b/src/Promise.php index 306d287c..50e7d6cb 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -27,15 +27,23 @@ public function then(callable $onFulfilled = null, callable $onRejected = null): return new static($this->resolver($onFulfilled, $onRejected)); } - $this->requiredCancelRequests++; + // keep a reference to this promise instance for the static canceller function. + // see also parentCancellerFunction() for more details. + $parent = $this; + ++$parent->requiredCancelRequests; - return new static($this->resolver($onFulfilled, $onRejected), function () { - $this->requiredCancelRequests--; + return new static( + $this->resolver($onFulfilled, $onRejected), + static function () use (&$parent) { + --$parent->requiredCancelRequests; - if ($this->requiredCancelRequests <= 0) { - $this->cancel(); + if ($parent->requiredCancelRequests <= 0) { + $parent->cancel(); + } + + $parent = null; } - }); + ); } public function done(callable $onFulfilled = null, callable $onRejected = null): void @@ -121,15 +129,6 @@ private function resolver(callable $onFulfilled = null, callable $onRejected = n }; } - private function resolve($value = null): void - { - if (null !== $this->result) { - return; - } - - $this->settle(resolve($value)); - } - private function reject(\Throwable $reason): void { if (null !== $this->result) { diff --git a/tests/PromiseTest.php b/tests/PromiseTest.php index dbc1f353..8bfa2ffd 100644 --- a/tests/PromiseTest.php +++ b/tests/PromiseTest.php @@ -96,6 +96,19 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerRejectsWithEx $this->assertSame(0, gc_collect_cycles()); } + /** @test */ + public function shouldRejectWithoutCreatingGarbageCyclesIfParentCancellerRejectsWithException() + { + gc_collect_cycles(); + $promise = new Promise(function ($resolve, $reject) { }, function ($resolve, $reject) { + $reject(new \Exception('foo')); + }); + $promise->then()->then()->then()->cancel(); + unset($promise); + + $this->assertSame(0, gc_collect_cycles()); + } + /** @test */ public function shouldRejectWithoutCreatingGarbageCyclesIfResolverThrowsException() { From 14bc8f11e28db5f40a2f191b77ab12e82798e81b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 4 May 2018 16:16:47 +0200 Subject: [PATCH 5/9] Avoid garbage reference by hiding canceller from call stack on PHP 7+ --- src/Promise.php | 8 +++++++- tests/PromiseTest.php | 26 ++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/Promise.php b/src/Promise.php index 50e7d6cb..bdea264b 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -14,6 +14,7 @@ final class Promise implements PromiseInterface public function __construct(callable $resolver, callable $canceller = null) { $this->canceller = $canceller; + $this->call($resolver); } @@ -174,8 +175,13 @@ private function unwrap($promise): PromiseInterface return $promise; } - private function call(callable $callback): void + private function call(callable $cb): void { + // Explicitly overwrite argument with null value. This ensure that this + // argument does not show up in the stack trace in PHP 7+ only. + $callback = $cb; + $cb = null; + // Use reflection to inspect number of arguments expected by this callback. // We did some careful benchmarking here: Using reflection to avoid unneeded // function arguments is actually faster than blindly passing them. diff --git a/tests/PromiseTest.php b/tests/PromiseTest.php index 8bfa2ffd..1d777187 100644 --- a/tests/PromiseTest.php +++ b/tests/PromiseTest.php @@ -121,6 +121,32 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfResolverThrowsExceptio $this->assertSame(0, gc_collect_cycles()); } + /** + * Test that checks number of garbage cycles after throwing from a canceller + * that explicitly uses a reference to the promise. This is rather synthetic, + * actual use cases often have implicit (hidden) references which ought not + * to be stored in the stack trace. + * + * Reassigned arguments only show up in the stack trace in PHP 7, so we can't + * avoid this on legacy PHP. As an alternative, consider explicitly unsetting + * any references before throwing. + * + * @test + * @requires PHP 7 + */ + public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerWithReferenceThrowsException() + { + gc_collect_cycles(); + + $promise = new Promise(function () {}, function () use (&$promise) { + throw new \Exception('foo'); + }); + $promise->cancel(); + unset($promise); + + $this->assertSame(0, gc_collect_cycles()); + } + /** @test */ public function shouldIgnoreNotifyAfterReject() { From e0a908eeded9124db96293dc164a20866f1d0eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 4 May 2018 16:32:53 +0200 Subject: [PATCH 6/9] Avoid garbage reference by hiding resolver from call stack on PHP 7+ --- src/Promise.php | 7 ++++++- tests/PromiseTest.php | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Promise.php b/src/Promise.php index bdea264b..d00fc58a 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -15,7 +15,12 @@ public function __construct(callable $resolver, callable $canceller = null) { $this->canceller = $canceller; - $this->call($resolver); + // Explicitly overwrite arguments with null values before invoking + // resolver function. This ensure that these arguments do not show up + // in the stack trace in PHP 7+ only. + $cb = $resolver; + $resolver = $canceller = null; + $this->call($cb); } public function then(callable $onFulfilled = null, callable $onRejected = null): PromiseInterface diff --git a/tests/PromiseTest.php b/tests/PromiseTest.php index 1d777187..9aa7971e 100644 --- a/tests/PromiseTest.php +++ b/tests/PromiseTest.php @@ -147,6 +147,24 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerWithReference $this->assertSame(0, gc_collect_cycles()); } + /** + * @test + * @requires PHP 7 + * @see self::shouldRejectWithoutCreatingGarbageCyclesIfCancellerWithReferenceThrowsException + */ + public function shouldRejectWithoutCreatingGarbageCyclesIfResolverWithReferenceThrowsException() + { + gc_collect_cycles(); + + $promise = new Promise(function () use (&$promise) { + throw new \Exception('foo'); + }); + + unset($promise); + + $this->assertSame(0, gc_collect_cycles()); + } + /** @test */ public function shouldIgnoreNotifyAfterReject() { From c8bd43b3efa23af0ee9d9e8a203f4e29138d77a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 6 May 2018 22:02:36 +0200 Subject: [PATCH 7/9] Clean up canceller function references when they are no longer needed --- tests/DeferredTest.php | 37 +++++++++++++++++++++++++++++++++++++ tests/PromiseTest.php | 17 +++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/tests/DeferredTest.php b/tests/DeferredTest.php index 5c86eb83..172cb246 100644 --- a/tests/DeferredTest.php +++ b/tests/DeferredTest.php @@ -19,4 +19,41 @@ public function getPromiseTestAdapter(callable $canceller = null) 'settle' => [$d, 'resolve'], ]); } + + /** @test */ + public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerRejectsWithException() + { + gc_collect_cycles(); + $deferred = new Deferred(function ($resolve, $reject) { + $reject(new \Exception('foo')); + }); + $deferred->promise()->cancel(); + unset($deferred); + + $this->assertSame(0, gc_collect_cycles()); + } + + /** @test */ + public function shouldRejectWithoutCreatingGarbageCyclesIfParentCancellerRejectsWithException() + { + gc_collect_cycles(); + $deferred = new Deferred(function ($resolve, $reject) { + $reject(new \Exception('foo')); + }); + $deferred->promise()->then()->cancel(); + unset($deferred); + + $this->assertSame(0, gc_collect_cycles()); + } + + /** @test */ + public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerHoldsReferenceAndExplicitlyRejectWithException() + { + gc_collect_cycles(); + $deferred = new Deferred(function () use (&$deferred) { }); + $deferred->reject(new \Exception('foo')); + unset($deferred); + + $this->assertSame(0, gc_collect_cycles()); + } } diff --git a/tests/PromiseTest.php b/tests/PromiseTest.php index 9aa7971e..2ea4326f 100644 --- a/tests/PromiseTest.php +++ b/tests/PromiseTest.php @@ -137,7 +137,6 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfResolverThrowsExceptio public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerWithReferenceThrowsException() { gc_collect_cycles(); - $promise = new Promise(function () {}, function () use (&$promise) { throw new \Exception('foo'); }); @@ -155,11 +154,25 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerWithReference public function shouldRejectWithoutCreatingGarbageCyclesIfResolverWithReferenceThrowsException() { gc_collect_cycles(); - $promise = new Promise(function () use (&$promise) { throw new \Exception('foo'); }); + unset($promise); + + $this->assertSame(0, gc_collect_cycles()); + } + /** + * @test + * @requires PHP 7 + * @see self::shouldRejectWithoutCreatingGarbageCyclesIfCancellerWithReferenceThrowsException + */ + public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerHoldsReferenceAndResolverThrowsException() + { + gc_collect_cycles(); + $promise = new Promise(function () { + throw new \Exception('foo'); + }, function () use (&$promise) { }); unset($promise); $this->assertSame(0, gc_collect_cycles()); From afb32763c8bc1371f91bd604d372e3edf8313845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 9 Jun 2018 15:12:16 +0200 Subject: [PATCH 8/9] Simplify static references by using static closure functions --- src/Promise.php | 81 +++++++++++++++---------------------------------- 1 file changed, 25 insertions(+), 56 deletions(-) diff --git a/src/Promise.php b/src/Promise.php index d00fc58a..6f996be2 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -33,8 +33,11 @@ public function then(callable $onFulfilled = null, callable $onRejected = null): return new static($this->resolver($onFulfilled, $onRejected)); } - // keep a reference to this promise instance for the static canceller function. - // see also parentCancellerFunction() for more details. + // This promise has a canceller, so we create a new child promise which + // has a canceller that invokes the parent canceller if all other + // followers are also cancelled. We keep a reference to this promise + // instance for the static canceller function and clear this to avoid + // keeping a cyclic reference between parent and follower. $parent = $this; ++$parent->requiredCancelRequests; @@ -205,13 +208,29 @@ private function call(callable $cb): void if ($args === 0) { $callback(); } else { - // keep a reference to this promise instance for the static resolve/reject functions. - // see also resolveFunction() and rejectFunction() for more details. + // Keep references to this promise instance for the static resolve/reject functions. + // By using static callbacks that are not bound to this instance + // and passing the target promise instance by reference, we can + // still execute its resolving logic and still clear this + // reference when settling the promise. This helps avoiding + // garbage cycles if any callback creates an Exception. + // These assumptions are covered by the test suite, so if you ever feel like + // refactoring this, go ahead, any alternative suggestions are welcome! $target =& $this; $callback( - self::resolveFunction($target), - self::rejectFunction($target) + static function ($value = null) use (&$target) { + if ($target !== null) { + $target->settle(resolve($value)); + $target = null; + } + }, + static function (\Throwable $reason) use (&$target) { + if ($target !== null) { + $target->reject($reason); + $target = null; + } + } ); } } catch (\Throwable $e) { @@ -219,54 +238,4 @@ private function call(callable $cb): void $this->reject($e); } } - - /** - * Creates a static resolver callback that is not bound to a promise instance. - * - * Moving the closure creation to a static method allows us to create a - * callback that is not bound to a promise instance. By passing the target - * promise instance by reference, we can still execute its resolving logic - * and still clear this reference when settling the promise. This helps - * avoiding garbage cycles if any callback creates an Exception. - * - * These assumptions are covered by the test suite, so if you ever feel like - * refactoring this, go ahead, any alternative suggestions are welcome! - * - * @param Promise $target - * @return callable - */ - private static function resolveFunction(self &$target) - { - return function ($value = null) use (&$target) { - if ($target !== null) { - $target->settle(resolve($value)); - $target = null; - } - }; - } - - /** - * Creates a static rejection callback that is not bound to a promise instance. - * - * Moving the closure creation to a static method allows us to create a - * callback that is not bound to a promise instance. By passing the target - * promise instance by reference, we can still execute its rejection logic - * and still clear this reference when settling the promise. This helps - * avoiding garbage cycles if any callback creates an Exception. - * - * These assumptions are covered by the test suite, so if you ever feel like - * refactoring this, go ahead, any alternative suggestions are welcome! - * - * @param Promise $target - * @return callable - */ - private static function rejectFunction(self &$target) - { - return function ($reason = null) use (&$target) { - if ($target !== null) { - $target->reject($reason); - $target = null; - } - }; - } } From 587a0985e831705993437db2642429a879b57900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 11 Jun 2018 19:29:33 +0200 Subject: [PATCH 9/9] Use static internal callbacks without binding to parent promise --- src/Promise.php | 10 +++---- tests/PromiseTest.php | 66 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/src/Promise.php b/src/Promise.php index 6f996be2..d234b643 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -62,7 +62,7 @@ public function done(callable $onFulfilled = null, callable $onRejected = null): return; } - $this->handlers[] = function (PromiseInterface $promise) use ($onFulfilled, $onRejected) { + $this->handlers[] = static function (PromiseInterface $promise) use ($onFulfilled, $onRejected) { $promise ->done($onFulfilled, $onRejected); }; @@ -70,7 +70,7 @@ public function done(callable $onFulfilled = null, callable $onRejected = null): public function otherwise(callable $onRejected): PromiseInterface { - return $this->then(null, function ($reason) use ($onRejected) { + return $this->then(null, static function ($reason) use ($onRejected) { if (!_checkTypehint($onRejected, $reason)) { return new RejectedPromise($reason); } @@ -81,11 +81,11 @@ public function otherwise(callable $onRejected): PromiseInterface public function always(callable $onFulfilledOrRejected): PromiseInterface { - return $this->then(function ($value) use ($onFulfilledOrRejected) { + return $this->then(static function ($value) use ($onFulfilledOrRejected) { return resolve($onFulfilledOrRejected())->then(function () use ($value) { return $value; }); - }, function ($reason) use ($onFulfilledOrRejected) { + }, static function ($reason) use ($onFulfilledOrRejected) { return resolve($onFulfilledOrRejected())->then(function () use ($reason) { return new RejectedPromise($reason); }); @@ -130,7 +130,7 @@ public function cancel(): void private function resolver(callable $onFulfilled = null, callable $onRejected = null): callable { return function ($resolve, $reject) use ($onFulfilled, $onRejected) { - $this->handlers[] = function (PromiseInterface $promise) use ($onFulfilled, $onRejected, $resolve, $reject) { + $this->handlers[] = static function (PromiseInterface $promise) use ($onFulfilled, $onRejected, $resolve, $reject) { $promise ->then($onFulfilled, $onRejected) ->done($resolve, $reject); diff --git a/tests/PromiseTest.php b/tests/PromiseTest.php index 2ea4326f..6af65ce5 100644 --- a/tests/PromiseTest.php +++ b/tests/PromiseTest.php @@ -190,6 +190,72 @@ public function shouldIgnoreNotifyAfterReject() $promise->cancel(); } + + /** @test */ + public function shouldNotLeaveGarbageCyclesWhenRemovingLastReferenceToPendingPromise() + { + gc_collect_cycles(); + $promise = new Promise(function () { }); + unset($promise); + + $this->assertSame(0, gc_collect_cycles()); + } + + /** @test */ + public function shouldNotLeaveGarbageCyclesWhenRemovingLastReferenceToPendingPromiseWithThenFollowers() + { + gc_collect_cycles(); + $promise = new Promise(function () { }); + $promise->then()->then()->then(); + unset($promise); + + $this->assertSame(0, gc_collect_cycles()); + } + + /** @test */ + public function shouldNotLeaveGarbageCyclesWhenRemovingLastReferenceToPendingPromiseWithDoneFollowers() + { + gc_collect_cycles(); + $promise = new Promise(function () { }); + $promise->done(); + unset($promise); + + $this->assertSame(0, gc_collect_cycles()); + } + + /** @test */ + public function shouldNotLeaveGarbageCyclesWhenRemovingLastReferenceToPendingPromiseWithOtherwiseFollowers() + { + gc_collect_cycles(); + $promise = new Promise(function () { }); + $promise->otherwise(function () { }); + unset($promise); + + $this->assertSame(0, gc_collect_cycles()); + } + + /** @test */ + public function shouldNotLeaveGarbageCyclesWhenRemovingLastReferenceToPendingPromiseWithAlwaysFollowers() + { + gc_collect_cycles(); + $promise = new Promise(function () { }); + $promise->always(function () { }); + unset($promise); + + $this->assertSame(0, gc_collect_cycles()); + } + + /** @test */ + public function shouldNotLeaveGarbageCyclesWhenRemovingLastReferenceToPendingPromiseWithProgressFollowers() + { + gc_collect_cycles(); + $promise = new Promise(function () { }); + $promise->then(null, null, function () { }); + unset($promise); + + $this->assertSame(0, gc_collect_cycles()); + } + /** @test */ public function shouldFulfillIfFullfilledWithSimplePromise() {