From 9abf531ac346dfd59c0e1e449d05fd529a1f43a5 Mon Sep 17 00:00:00 2001 From: Jan Sorgalla Date: Thu, 11 Sep 2014 10:09:01 +0200 Subject: [PATCH 1/7] Introduce CancellablePromiseInterface --- CHANGELOG.md | 5 + README.md | 48 +++++- src/CancellablePromiseInterface.php | 8 + src/Deferred.php | 8 +- src/FulfilledPromise.php | 6 +- src/LazyPromise.php | 15 +- src/Promise.php | 52 ++++-- src/RejectedPromise.php | 6 +- tests/DeferredTest.php | 4 +- tests/FulfilledPromiseTest.php | 2 +- tests/LazyPromiseTest.php | 4 +- tests/PromiseTest.php | 4 +- tests/PromiseTest/CancelTestTrait.php | 160 ++++++++++++++++++ tests/PromiseTest/FullTestTrait.php | 3 +- tests/PromiseTest/ProgressTestTrait.php | 2 +- .../PromiseTest/PromiseFulfilledTestTrait.php | 22 ++- tests/PromiseTest/PromisePendingTestTrait.php | 10 +- .../PromiseTest/PromiseRejectedTestTrait.php | 22 ++- tests/PromiseTest/PromiseSettledTestTrait.php | 22 ++- tests/PromiseTest/RejectTestTrait.php | 2 +- tests/PromiseTest/ResolveTestTrait.php | 2 +- tests/RejectedPromiseTest.php | 2 +- 22 files changed, 364 insertions(+), 45 deletions(-) create mode 100644 src/CancellablePromiseInterface.php create mode 100644 tests/PromiseTest/CancelTestTrait.php diff --git a/CHANGELOG.md b/CHANGELOG.md index fe50904a..e194e42d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +* 2.1.x (xxxx-xx-xx) + + * Introduce new CancellablePromiseInterface implemented by all promises + * Add new .cancel() method (part of the CancellablePromiseInterface) + * 2.0.0 (2013-12-10) New major release. The goal was to streamline the API and to make it more diff --git a/README.md b/README.md index 281f450e..3410faca 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ Table of Contents * [Deferred::progress()](#deferredprogress) * [PromiseInterface](#promiseinterface) * [PromiseInterface::then()](#promiseinterfacethen) + * [CancellablePromiseInterface](#cancellablepromiseinterface) + * [CancellablePromiseInterface::cancel()](#cancellablepromiseinterfacecancel) * [Promise](#promise-1) * [FulfilledPromise](#fulfilledpromise) * [RejectedPromise](#rejectedpromise) @@ -96,6 +98,9 @@ The `resolve` and `reject` methods control the state of the deferred. The `progress` method is for progress notification. +The constructor of the `Deferred` accepts an optional `$canceller` argument. +See [Promise](#promise-1) for more information. + #### Deferred::promise() ``` php @@ -197,6 +202,31 @@ the same call to `then()`: * [resolve()](#resolve) - Creating a resolved promise * [reject()](#reject) - Creating a rejected promise +### CancellablePromiseInterface + +A cancellable promise provides a mechanism for consumers to notify the creator +of the promise that they are not longer interested in the result of an +operation. + +#### CancellablePromiseInterface::cancel() + +``` php +$promise->cancel(); +``` + +The `cancel()` method notifies the creator of the promise that there is no +further interest in the results of the operation. + +Once a promise is settled (either fulfilled or rejected), calling `cancel()` on +a promise has no effect. + +#### Implementations + +* [Promise](#promise-1) +* [FulfilledPromise](#fulfilledpromise) +* [RejectedPromise](#rejectedpromise) +* [LazyPromise](#lazypromise) + ### Promise Creates a promise whose state is controlled by the functions passed to @@ -214,11 +244,17 @@ $resolver = function (callable $resolve, callable $reject, callable $progress) { // or $progress($progressNotification); }; -$promise = new React\Promise\Promise($resolver); +$canceller = function (callable $resolve, callable $reject, callable $progress) { + // Cancel/abort any running operations like network connections, streams etc. + + $reject(new \Exception('Promise cancelled')); +}; + +$promise = new React\Promise\Promise($resolver, $canceller); ``` -The promise constructor receives a resolver function which will be called -with 3 arguments: +The promise constructor receives a resolver function and an optional canceller +function which both will be called with 3 arguments: * `$resolve($value)` - Primary function that seals the fate of the returned promise. Accepts either a non-promise value, or another promise. @@ -228,9 +264,11 @@ with 3 arguments: * `$reject($reason)` - Function that rejects the promise. * `$progress($update)` - Function that issues progress events for the promise. -If the resolver throws an exception, the promise will be rejected with that -thrown exception as the rejection reason. +If the resolver or canceller throw an exception, the promise will be rejected +with that thrown exception as the rejection reason. +The resolver function will be called immediately, the canceller function only +once a consumer calls the `cancel()` method of the promise. ### FulfilledPromise diff --git a/src/CancellablePromiseInterface.php b/src/CancellablePromiseInterface.php new file mode 100644 index 00000000..01507a67 --- /dev/null +++ b/src/CancellablePromiseInterface.php @@ -0,0 +1,8 @@ +canceller = $canceller; + } public function promise() { @@ -16,7 +22,7 @@ public function promise() $this->resolveCallback = $resolve; $this->rejectCallback = $reject; $this->progressCallback = $progress; - }); + }, $this->canceller); } return $this->promise; diff --git a/src/FulfilledPromise.php b/src/FulfilledPromise.php index 44e1a798..0fa0bd89 100644 --- a/src/FulfilledPromise.php +++ b/src/FulfilledPromise.php @@ -2,7 +2,7 @@ namespace React\Promise; -class FulfilledPromise implements PromiseInterface +class FulfilledPromise implements PromiseInterface, CancellablePromiseInterface { private $value; @@ -29,4 +29,8 @@ public function then(callable $onFulfilled = null, callable $onRejected = null, return new RejectedPromise($exception); } } + + public function cancel() + { + } } diff --git a/src/LazyPromise.php b/src/LazyPromise.php index 82acce19..91f3e308 100644 --- a/src/LazyPromise.php +++ b/src/LazyPromise.php @@ -2,7 +2,7 @@ namespace React\Promise; -class LazyPromise implements PromiseInterface +class LazyPromise implements PromiseInterface, CancellablePromiseInterface { private $factory; private $promise; @@ -13,6 +13,16 @@ public function __construct(callable $factory) } public function then(callable $onFulfilled = null, callable $onRejected = null, callable $onProgress = null) + { + return $this->promise()->then($onFulfilled, $onRejected, $onProgress); + } + + public function cancel() + { + return $this->promise()->cancel(); + } + + private function promise() { if (null === $this->promise) { try { @@ -21,7 +31,6 @@ public function then(callable $onFulfilled = null, callable $onRejected = null, $this->promise = new RejectedPromise($exception); } } - - return $this->promise->then($onFulfilled, $onRejected, $onProgress); + return $this->promise; } } diff --git a/src/Promise.php b/src/Promise.php index 061d960c..a530ce06 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -2,30 +2,18 @@ namespace React\Promise; -class Promise implements PromiseInterface +class Promise implements PromiseInterface, CancellablePromiseInterface { + private $canceller; private $result; private $handlers = []; private $progressHandlers = []; - public function __construct(callable $resolver) + public function __construct(callable $resolver, callable $canceller = null) { - try { - $resolver( - function ($value = null) { - $this->resolve($value); - }, - function ($reason = null) { - $this->reject($reason); - }, - function ($update = null) { - $this->progress($update); - } - ); - } catch (\Exception $e) { - $this->reject($e); - } + $this->canceller = $canceller; + $this->call($resolver); } public function then(callable $onFulfilled = null, callable $onRejected = null, callable $onProgress = null) @@ -34,7 +22,16 @@ public function then(callable $onFulfilled = null, callable $onRejected = null, return $this->result->then($onFulfilled, $onRejected, $onProgress); } - return new static($this->resolver($onFulfilled, $onRejected, $onProgress)); + return new static($this->resolver($onFulfilled, $onRejected, $onProgress), [$this, 'cancel']); + } + + public function cancel() + { + if (null === $this->canceller || null !== $this->result) { + return; + } + + $this->call($this->canceller); } private function resolver(callable $onFulfilled = null, callable $onRejected = null, callable $onProgress = null) @@ -101,4 +98,23 @@ private function settle(PromiseInterface $result) $this->result = $result; } + + private function call(callable $callback) + { + try { + $callback( + function ($value = null) { + $this->resolve($value); + }, + function ($reason = null) { + $this->reject($reason); + }, + function ($update = null) { + $this->progress($update); + } + ); + } catch (\Exception $e) { + $this->reject($e); + } + } } diff --git a/src/RejectedPromise.php b/src/RejectedPromise.php index 994d7d8c..c2cff33a 100644 --- a/src/RejectedPromise.php +++ b/src/RejectedPromise.php @@ -2,7 +2,7 @@ namespace React\Promise; -class RejectedPromise implements PromiseInterface +class RejectedPromise implements PromiseInterface, CancellablePromiseInterface { private $reason; @@ -27,4 +27,8 @@ public function then(callable $onFulfilled = null, callable $onRejected = null, return new RejectedPromise($exception); } } + + public function cancel() + { + } } diff --git a/tests/DeferredTest.php b/tests/DeferredTest.php index d9520caa..754a84df 100644 --- a/tests/DeferredTest.php +++ b/tests/DeferredTest.php @@ -8,9 +8,9 @@ class DeferredTest extends TestCase { use PromiseTest\FullTestTrait; - public function getPromiseTestAdapter() + public function getPromiseTestAdapter(callable $canceller = null) { - $d = new Deferred(); + $d = new Deferred($canceller); return new CallbackPromiseAdapter([ 'promise' => [$d, 'promise'], diff --git a/tests/FulfilledPromiseTest.php b/tests/FulfilledPromiseTest.php index 82ea0540..e72ecebc 100644 --- a/tests/FulfilledPromiseTest.php +++ b/tests/FulfilledPromiseTest.php @@ -9,7 +9,7 @@ class FulfilledPromiseTest extends TestCase use PromiseTest\PromiseSettledTestTrait, PromiseTest\PromiseFulfilledTestTrait; - public function getPromiseTestAdapter() + public function getPromiseTestAdapter(callable $canceller = null) { $promise = null; diff --git a/tests/LazyPromiseTest.php b/tests/LazyPromiseTest.php index 2d4728f4..a6eb4463 100644 --- a/tests/LazyPromiseTest.php +++ b/tests/LazyPromiseTest.php @@ -8,9 +8,9 @@ class LazyPromiseTest extends TestCase { use PromiseTest\FullTestTrait; - public function getPromiseTestAdapter() + public function getPromiseTestAdapter(callable $canceller = null) { - $d = new Deferred(); + $d = new Deferred($canceller); $factory = function () use ($d) { return $d->promise(); diff --git a/tests/PromiseTest.php b/tests/PromiseTest.php index ac0196be..18e30f7c 100644 --- a/tests/PromiseTest.php +++ b/tests/PromiseTest.php @@ -8,7 +8,7 @@ class PromiseTest extends TestCase { use PromiseTest\FullTestTrait; - public function getPromiseTestAdapter() + public function getPromiseTestAdapter(callable $canceller = null) { $resolveCallback = $rejectCallback = $progressCallback = null; @@ -16,7 +16,7 @@ public function getPromiseTestAdapter() $resolveCallback = $resolve; $rejectCallback = $reject; $progressCallback = $progress; - }); + }, $canceller); return new CallbackPromiseAdapter([ 'promise' => function () use ($promise) { diff --git a/tests/PromiseTest/CancelTestTrait.php b/tests/PromiseTest/CancelTestTrait.php new file mode 100644 index 00000000..522cdd76 --- /dev/null +++ b/tests/PromiseTest/CancelTestTrait.php @@ -0,0 +1,160 @@ +createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->isType('callable'), $this->isType('callable'), $this->isType('callable')); + + $adapter = $this->getPromiseTestAdapter($mock); + + $adapter->promise()->cancel(); + } + + /** @test */ + public function cancelShouldFulfillPromiseIfCancellerFulfills() + { + $adapter = $this->getPromiseTestAdapter(function ($resolve) { + $resolve(1); + }); + + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(1)); + + $adapter->promise() + ->then($mock, $this->expectCallableNever()); + + $adapter->promise()->cancel(); + } + + /** @test */ + public function cancelShouldRejectPromiseIfCancellerRejects() + { + $adapter = $this->getPromiseTestAdapter(function ($resolve, $reject) { + $reject(1); + }); + + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(1)); + + $adapter->promise() + ->then($this->expectCallableNever(), $mock); + + $adapter->promise()->cancel(); + } + + /** @test */ + public function cancelShouldRejectPromiseWithExceptionIfCancellerThrows() + { + $e = new \Exception(); + + $adapter = $this->getPromiseTestAdapter(function () use ($e) { + throw $e; + }); + + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo($e)); + + $adapter->promise() + ->then($this->expectCallableNever(), $mock); + + $adapter->promise()->cancel(); + } + + /** @test */ + public function cancelShouldProgressPromiseIfCancellerNotifies() + { + $adapter = $this->getPromiseTestAdapter(function ($resolve, $reject, $progress) { + $progress(1); + }); + + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->with($this->identicalTo(1)); + + $adapter->promise() + ->then($this->expectCallableNever(), $this->expectCallableNever(), $mock); + + $adapter->promise()->cancel(); + } + + /** @test */ + public function cancelShouldCallCancellerOnlyOnceIfCancellerResolves() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke') + ->will($this->returnCallback(function($resolve) { + $resolve(); + })); + + $adapter = $this->getPromiseTestAdapter($mock); + + $adapter->promise()->cancel(); + $adapter->promise()->cancel(); + } + + /** @test */ + public function cancelShouldHaveNoEffectIfCancellerDoesNothing() + { + $adapter = $this->getPromiseTestAdapter(function () {}); + + $adapter->promise() + ->then($this->expectCallableNever(), $this->expectCallableNever()); + + $adapter->promise()->cancel(); + $adapter->promise()->cancel(); + } + + /** @test */ + public function cancelShouldCallCancellerFromDeepNestedPromiseChain() + { + $mock = $this->createCallableMock(); + $mock + ->expects($this->once()) + ->method('__invoke'); + + $adapter = $this->getPromiseTestAdapter($mock); + + $promise = $adapter->promise() + ->then(function () { + return new Promise\Promise(function() {}); + }) + ->then(function() { + $d = new Promise\Deferred(); + + return $d->promise(); + }) + ->then(function () { + return new Promise\Promise(function() {}); + }); + + $promise->cancel(); + } +} diff --git a/tests/PromiseTest/FullTestTrait.php b/tests/PromiseTest/FullTestTrait.php index 125911a4..fd8f9342 100644 --- a/tests/PromiseTest/FullTestTrait.php +++ b/tests/PromiseTest/FullTestTrait.php @@ -10,5 +10,6 @@ trait FullTestTrait PromiseRejectedTestTrait, ResolveTestTrait, RejectTestTrait, - ProgressTestTrait; + ProgressTestTrait, + CancelTestTrait; } diff --git a/tests/PromiseTest/ProgressTestTrait.php b/tests/PromiseTest/ProgressTestTrait.php index fa275508..0d160a81 100644 --- a/tests/PromiseTest/ProgressTestTrait.php +++ b/tests/PromiseTest/ProgressTestTrait.php @@ -7,7 +7,7 @@ trait ProgressTestTrait /** * @return \React\Promise\PromiseAdapter\PromiseAdapterInterface */ - abstract public function getPromiseTestAdapter(); + abstract public function getPromiseTestAdapter(callable $canceller = null); /** @test */ public function progressShouldProgress() diff --git a/tests/PromiseTest/PromiseFulfilledTestTrait.php b/tests/PromiseTest/PromiseFulfilledTestTrait.php index b5ad91c0..5ae29422 100644 --- a/tests/PromiseTest/PromiseFulfilledTestTrait.php +++ b/tests/PromiseTest/PromiseFulfilledTestTrait.php @@ -7,7 +7,7 @@ trait PromiseFulfilledTestTrait /** * @return \React\Promise\PromiseAdapter\PromiseAdapterInterface */ - abstract public function getPromiseTestAdapter(); + abstract public function getPromiseTestAdapter(callable $canceller = null); /** @test */ public function fulfilledPromiseShouldBeImmutable() @@ -175,4 +175,24 @@ public function thenShouldSwitchFromCallbacksToErrbacksWhenCallbackThrows() $mock2 ); } + + /** @test */ + public function cancelShouldReturnNullForFulfilledPromise() + { + $adapter = $this->getPromiseTestAdapter(); + + $adapter->resolve(); + + $this->assertNull($adapter->promise()->cancel()); + } + + /** @test */ + public function cancelShouldHaveNoEffectForFulfilledPromise() + { + $adapter = $this->getPromiseTestAdapter($this->expectCallableNever()); + + $adapter->resolve(); + + $adapter->promise()->cancel(); + } } diff --git a/tests/PromiseTest/PromisePendingTestTrait.php b/tests/PromiseTest/PromisePendingTestTrait.php index 0341bf7b..0b188013 100644 --- a/tests/PromiseTest/PromisePendingTestTrait.php +++ b/tests/PromiseTest/PromisePendingTestTrait.php @@ -7,7 +7,7 @@ trait PromisePendingTestTrait /** * @return \React\Promise\PromiseAdapter\PromiseAdapterInterface */ - abstract public function getPromiseTestAdapter(); + abstract public function getPromiseTestAdapter(callable $canceller = null); /** @test */ public function thenShouldReturnAPromiseForPendingPromise() @@ -24,4 +24,12 @@ public function thenShouldReturnAllowNullForPendingPromise() $this->assertInstanceOf('React\\Promise\\PromiseInterface', $adapter->promise()->then(null, null, null)); } + + /** @test */ + public function cancelShouldReturnNullForPendingPromise() + { + $adapter = $this->getPromiseTestAdapter(); + + $this->assertNull($adapter->promise()->cancel()); + } } diff --git a/tests/PromiseTest/PromiseRejectedTestTrait.php b/tests/PromiseTest/PromiseRejectedTestTrait.php index 51f0a05b..a9cfa68a 100644 --- a/tests/PromiseTest/PromiseRejectedTestTrait.php +++ b/tests/PromiseTest/PromiseRejectedTestTrait.php @@ -7,7 +7,7 @@ trait PromiseRejectedTestTrait /** * @return \React\Promise\PromiseAdapter\PromiseAdapterInterface */ - abstract public function getPromiseTestAdapter(); + abstract public function getPromiseTestAdapter(callable $canceller = null); /** @test */ public function rejectedPromiseShouldBeImmutable() @@ -180,4 +180,24 @@ function ($val) { $mock ); } + + /** @test */ + public function cancelShouldReturnNullForRejectedPromise() + { + $adapter = $this->getPromiseTestAdapter(); + + $adapter->reject(); + + $this->assertNull($adapter->promise()->cancel()); + } + + /** @test */ + public function cancelShouldHaveNoEffectForRejectedPromise() + { + $adapter = $this->getPromiseTestAdapter($this->expectCallableNever()); + + $adapter->reject(); + + $adapter->promise()->cancel(); + } } diff --git a/tests/PromiseTest/PromiseSettledTestTrait.php b/tests/PromiseTest/PromiseSettledTestTrait.php index 72df3e39..c5602847 100644 --- a/tests/PromiseTest/PromiseSettledTestTrait.php +++ b/tests/PromiseTest/PromiseSettledTestTrait.php @@ -7,7 +7,7 @@ trait PromiseSettledTestTrait /** * @return \React\Promise\PromiseAdapter\PromiseAdapterInterface */ - abstract public function getPromiseTestAdapter(); + abstract public function getPromiseTestAdapter(callable $canceller = null); /** @test */ public function thenShouldReturnAPromiseForSettledPromise() @@ -26,4 +26,24 @@ public function thenShouldReturnAllowNullForSettledPromise() $adapter->settle(); $this->assertInstanceOf('React\\Promise\\PromiseInterface', $adapter->promise()->then(null, null, null)); } + + /** @test */ + public function cancelShouldReturnNullForSettledPromise() + { + $adapter = $this->getPromiseTestAdapter(); + + $adapter->settle(); + + $this->assertNull($adapter->promise()->cancel()); + } + + /** @test */ + public function cancelShouldHaveNoEffectForSettledPromise() + { + $adapter = $this->getPromiseTestAdapter($this->expectCallableNever()); + + $adapter->settle(); + + $adapter->promise()->cancel(); + } } diff --git a/tests/PromiseTest/RejectTestTrait.php b/tests/PromiseTest/RejectTestTrait.php index cce4926b..66e5e734 100644 --- a/tests/PromiseTest/RejectTestTrait.php +++ b/tests/PromiseTest/RejectTestTrait.php @@ -9,7 +9,7 @@ trait RejectTestTrait /** * @return \React\Promise\PromiseAdapter\PromiseAdapterInterface */ - abstract public function getPromiseTestAdapter(); + abstract public function getPromiseTestAdapter(callable $canceller = null); /** @test */ public function rejectShouldRejectWithAnImmediateValue() diff --git a/tests/PromiseTest/ResolveTestTrait.php b/tests/PromiseTest/ResolveTestTrait.php index f37735f6..4ee20191 100644 --- a/tests/PromiseTest/ResolveTestTrait.php +++ b/tests/PromiseTest/ResolveTestTrait.php @@ -9,7 +9,7 @@ trait ResolveTestTrait /** * @return \React\Promise\PromiseAdapter\PromiseAdapterInterface */ - abstract public function getPromiseTestAdapter(); + abstract public function getPromiseTestAdapter(callable $canceller = null); /** @test */ public function resolveShouldResolve() diff --git a/tests/RejectedPromiseTest.php b/tests/RejectedPromiseTest.php index c8c4c685..040dd2e2 100644 --- a/tests/RejectedPromiseTest.php +++ b/tests/RejectedPromiseTest.php @@ -9,7 +9,7 @@ class RejectedPromiseTest extends TestCase use PromiseTest\PromiseSettledTestTrait, PromiseTest\PromiseRejectedTestTrait; - public function getPromiseTestAdapter() + public function getPromiseTestAdapter(callable $canceller = null) { $promise = null; From 75fbae09ac958b76a33e6c9d314505f5f151e95e Mon Sep 17 00:00:00 2001 From: Jan Sorgalla Date: Fri, 12 Sep 2014 12:03:37 +0200 Subject: [PATCH 2/7] Hide arguments from nested cancel() call --- src/Promise.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Promise.php b/src/Promise.php index a530ce06..d7767461 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -22,7 +22,9 @@ public function then(callable $onFulfilled = null, callable $onRejected = null, return $this->result->then($onFulfilled, $onRejected, $onProgress); } - return new static($this->resolver($onFulfilled, $onRejected, $onProgress), [$this, 'cancel']); + return new static($this->resolver($onFulfilled, $onRejected, $onProgress), function($resolve, $reject, $progress) { + $this->cancel(); + }); } public function cancel() From 8b7e0f03aad58a69189cdb7303e3d7da7d418486 Mon Sep 17 00:00:00 2001 From: Jan Sorgalla Date: Wed, 17 Sep 2014 16:16:31 +0200 Subject: [PATCH 3/7] Ensure promise is only cancelled if all derived promises cancel --- README.md | 2 +- src/Promise.php | 9 ++++++ tests/PromiseTest/CancelTestTrait.php | 46 +++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3410faca..1bb5c32c 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ If the resolver or canceller throw an exception, the promise will be rejected with that thrown exception as the rejection reason. The resolver function will be called immediately, the canceller function only -once a consumer calls the `cancel()` method of the promise. +once all consumers called the `cancel()` method of the promise. ### FulfilledPromise diff --git a/src/Promise.php b/src/Promise.php index d7767461..6fe9a3a0 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -10,6 +10,9 @@ class Promise implements PromiseInterface, CancellablePromiseInterface private $handlers = []; private $progressHandlers = []; + private $requiredCancelRequests = 0; + private $cancelRequests = 0; + public function __construct(callable $resolver, callable $canceller = null) { $this->canceller = $canceller; @@ -22,7 +25,13 @@ public function then(callable $onFulfilled = null, callable $onRejected = null, return $this->result->then($onFulfilled, $onRejected, $onProgress); } + $this->requiredCancelRequests++; + return new static($this->resolver($onFulfilled, $onRejected, $onProgress), function($resolve, $reject, $progress) { + if (++$this->cancelRequests < $this->requiredCancelRequests) { + return; + } + $this->cancel(); }); } diff --git a/tests/PromiseTest/CancelTestTrait.php b/tests/PromiseTest/CancelTestTrait.php index 522cdd76..0ff78ab6 100644 --- a/tests/PromiseTest/CancelTestTrait.php +++ b/tests/PromiseTest/CancelTestTrait.php @@ -157,4 +157,50 @@ public function cancelShouldCallCancellerFromDeepNestedPromiseChain() $promise->cancel(); } + + /** @test */ + public function cancelCalledOnChildrenSouldOnlyCancelWhenAllChildrenCancelled() + { + $adapter = $this->getPromiseTestAdapter($this->expectCallableNever()); + + $child1 = $adapter->promise() + ->then() + ->then(); + + $adapter->promise() + ->then(); + + $child1->cancel(); + } + + /** @test */ + public function cancelShouldTriggerCancellerWhenAllChildrenCancel() + { + $adapter = $this->getPromiseTestAdapter($this->expectCallableOnce()); + + $child1 = $adapter->promise() + ->then() + ->then(); + + $child2 = $adapter->promise() + ->then(); + + $child1->cancel(); + $child2->cancel(); + } + + /** @test */ + public function cancelShouldAlwaysTriggerCancellerWhenCalledOnRootPromise() + { + $adapter = $this->getPromiseTestAdapter($this->expectCallableOnce()); + + $adapter->promise() + ->then() + ->then(); + + $adapter->promise() + ->then(); + + $adapter->promise()->cancel(); + } } From d5b6f8e05c6be03071c9e5ecf25397cd6cbccbc4 Mon Sep 17 00:00:00 2001 From: Jan Sorgalla Date: Sat, 4 Oct 2014 13:07:09 +0200 Subject: [PATCH 4/7] CS fixes --- src/LazyPromise.php | 1 + src/Promise.php | 2 +- tests/PromiseTest/CancelTestTrait.php | 8 ++++---- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/LazyPromise.php b/src/LazyPromise.php index 91f3e308..401dd34f 100644 --- a/src/LazyPromise.php +++ b/src/LazyPromise.php @@ -31,6 +31,7 @@ private function promise() $this->promise = new RejectedPromise($exception); } } + return $this->promise; } } diff --git a/src/Promise.php b/src/Promise.php index 6fe9a3a0..ead3106e 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -27,7 +27,7 @@ public function then(callable $onFulfilled = null, callable $onRejected = null, $this->requiredCancelRequests++; - return new static($this->resolver($onFulfilled, $onRejected, $onProgress), function($resolve, $reject, $progress) { + return new static($this->resolver($onFulfilled, $onRejected, $onProgress), function ($resolve, $reject, $progress) { if (++$this->cancelRequests < $this->requiredCancelRequests) { return; } diff --git a/tests/PromiseTest/CancelTestTrait.php b/tests/PromiseTest/CancelTestTrait.php index 0ff78ab6..d6c09566 100644 --- a/tests/PromiseTest/CancelTestTrait.php +++ b/tests/PromiseTest/CancelTestTrait.php @@ -110,7 +110,7 @@ public function cancelShouldCallCancellerOnlyOnceIfCancellerResolves() $mock ->expects($this->once()) ->method('__invoke') - ->will($this->returnCallback(function($resolve) { + ->will($this->returnCallback(function ($resolve) { $resolve(); })); @@ -144,15 +144,15 @@ public function cancelShouldCallCancellerFromDeepNestedPromiseChain() $promise = $adapter->promise() ->then(function () { - return new Promise\Promise(function() {}); + return new Promise\Promise(function () {}); }) - ->then(function() { + ->then(function () { $d = new Promise\Deferred(); return $d->promise(); }) ->then(function () { - return new Promise\Promise(function() {}); + return new Promise\Promise(function () {}); }); $promise->cancel(); From 77f78d9bc33cacd28bdd30482403da48895b19c5 Mon Sep 17 00:00:00 2001 From: Jan Sorgalla Date: Sun, 5 Oct 2014 19:42:19 +0200 Subject: [PATCH 5/7] Avoid unnecessary creation of closures when no canceller is given --- src/Promise.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Promise.php b/src/Promise.php index ead3106e..aa8d914e 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -25,6 +25,10 @@ public function then(callable $onFulfilled = null, callable $onRejected = null, return $this->result->then($onFulfilled, $onRejected, $onProgress); } + if (null === $this->canceller) { + return new static($this->resolver($onFulfilled, $onRejected, $onProgress)); + } + $this->requiredCancelRequests++; return new static($this->resolver($onFulfilled, $onRejected, $onProgress), function ($resolve, $reject, $progress) { From 5b6ef3f2ec7268249daf518ae76f5b9324651630 Mon Sep 17 00:00:00 2001 From: Jan Sorgalla Date: Sun, 5 Oct 2014 22:00:30 +0200 Subject: [PATCH 6/7] Add return tag to cancel() --- src/CancellablePromiseInterface.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/CancellablePromiseInterface.php b/src/CancellablePromiseInterface.php index 01507a67..8283dbfe 100644 --- a/src/CancellablePromiseInterface.php +++ b/src/CancellablePromiseInterface.php @@ -4,5 +4,8 @@ interface CancellablePromiseInterface { + /** + * @return void + */ public function cancel(); } From 1bced89e108328b87cff3fd2f385c41cef83c21b Mon Sep 17 00:00:00 2001 From: Jan Sorgalla Date: Wed, 8 Oct 2014 20:47:37 +0200 Subject: [PATCH 7/7] Make CancellablePromiseInterface extend PromiseInterface --- src/CancellablePromiseInterface.php | 2 +- src/FulfilledPromise.php | 2 +- src/LazyPromise.php | 2 +- src/Promise.php | 2 +- src/RejectedPromise.php | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CancellablePromiseInterface.php b/src/CancellablePromiseInterface.php index 8283dbfe..896db2d3 100644 --- a/src/CancellablePromiseInterface.php +++ b/src/CancellablePromiseInterface.php @@ -2,7 +2,7 @@ namespace React\Promise; -interface CancellablePromiseInterface +interface CancellablePromiseInterface extends PromiseInterface { /** * @return void diff --git a/src/FulfilledPromise.php b/src/FulfilledPromise.php index 0fa0bd89..bfa3f995 100644 --- a/src/FulfilledPromise.php +++ b/src/FulfilledPromise.php @@ -2,7 +2,7 @@ namespace React\Promise; -class FulfilledPromise implements PromiseInterface, CancellablePromiseInterface +class FulfilledPromise implements CancellablePromiseInterface { private $value; diff --git a/src/LazyPromise.php b/src/LazyPromise.php index 401dd34f..e1172e54 100644 --- a/src/LazyPromise.php +++ b/src/LazyPromise.php @@ -2,7 +2,7 @@ namespace React\Promise; -class LazyPromise implements PromiseInterface, CancellablePromiseInterface +class LazyPromise implements CancellablePromiseInterface { private $factory; private $promise; diff --git a/src/Promise.php b/src/Promise.php index aa8d914e..09ebb32b 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -2,7 +2,7 @@ namespace React\Promise; -class Promise implements PromiseInterface, CancellablePromiseInterface +class Promise implements CancellablePromiseInterface { private $canceller; private $result; diff --git a/src/RejectedPromise.php b/src/RejectedPromise.php index c2cff33a..ee318cbf 100644 --- a/src/RejectedPromise.php +++ b/src/RejectedPromise.php @@ -2,7 +2,7 @@ namespace React\Promise; -class RejectedPromise implements PromiseInterface, CancellablePromiseInterface +class RejectedPromise implements CancellablePromiseInterface { private $reason;