diff --git a/README.md b/README.md index 73987a77..d1d08234 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ Table of Contents * [When::reject()](#whenreject) * [When::lazy()](#whenlazy) * [Promisor](#promisor) + * [CancellablePromiseInterface](#cancellablepromiseinterface) + * [CancellablePromiseInterface::cancel()](#cancellablepromiseinterfacecancel) 4. [Examples](#examples) * [How to use Deferred](#how-to-use-deferred) * [How Promise forwarding works](#how-promise-forwarding-works) @@ -111,6 +113,18 @@ $deferred->reject(mixed $reason = null); $deferred->progress(mixed $update = null); ``` +The constructor of the `Deferred` accepts an optional `$canceller` argument. +See [Promise](#promise-1) for more information. + + +``` php +$deferred = new React\Promise\Deferred(function ($resolve, $reject, $progress) { + throw new \Exception('Promise cancelled'); +}); + +$deferred->cancel(); +``` + ### Promise The Promise represents the eventual outcome, which is either fulfillment @@ -133,7 +147,13 @@ $resolver = function (callable $resolve, callable $reject, callable $notify) { // or $notify($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 @@ -150,6 +170,9 @@ immediately with 3 arguments: If the resolver throws 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 all consumers called the `cancel()` method of the promise. + A Promise has a single method `then()` which registers new fulfilled, error and progress handlers with this Promise (all parameters are optional): @@ -484,6 +507,32 @@ The `React\Promise\PromisorInterface` provides a common interface for objects that provide a promise. `React\Promise\Deferred` implements it, but since it is part of the public API anyone can implement it. +### 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 resolved or rejected), calling `cancel()` on +a promise has no effect. + +#### Implementations + +* [Deferred](#deferred-1) +* [Promise](#promise-1) +* [FulfilledPromise](#fulfilledpromise) +* [RejectedPromise](#rejectedpromise) +* [LazyPromise](#lazypromise) + Examples -------- diff --git a/src/React/Promise/CancellablePromiseInterface.php b/src/React/Promise/CancellablePromiseInterface.php new file mode 100644 index 00000000..896db2d3 --- /dev/null +++ b/src/React/Promise/CancellablePromiseInterface.php @@ -0,0 +1,11 @@ +canceller = $canceller; + } public function then($fulfilledHandler = null, $errorHandler = null, $progressHandler = null) { @@ -16,7 +34,24 @@ public function then($fulfilledHandler = null, $errorHandler = null, $progressHa return $this->completed->then($fulfilledHandler, $errorHandler, $progressHandler); } - $deferred = new static(); + $canceller = null; + if ($this->canceller !== null) { + $this->requiredCancelRequests++; + + $that = $this; + $current =& $this->cancelRequests; + $required =& $this->requiredCancelRequests; + + $canceller = function () use ($that, &$current, &$required) { + if (++$current < $required) { + return; + } + + $that->cancel(); + }; + } + + $deferred = new static($canceller); if (is_callable($progressHandler)) { $progHandler = function ($update) use ($deferred, $progressHandler) { @@ -96,6 +131,35 @@ public function resolver() return $this->resolver; } + public function cancel() + { + if (null === $this->canceller || null !== $this->completed) { + return; + } + + $canceller = $this->canceller; + $this->canceller = null; + + try { + $that = $this; + + call_user_func( + $canceller, + function ($value = null) use ($that) { + $that->resolve($value); + }, + function ($reason = null) use ($that) { + $that->reject($reason); + }, + function ($update = null) use ($that) { + $that->progress($update); + } + ); + } catch (\Exception $e) { + $this->reject($e); + } + } + protected function processQueue($queue, $value) { foreach ($queue as $handler) { diff --git a/src/React/Promise/DeferredPromise.php b/src/React/Promise/DeferredPromise.php index 6ca2f4fc..ea63cb38 100644 --- a/src/React/Promise/DeferredPromise.php +++ b/src/React/Promise/DeferredPromise.php @@ -15,4 +15,9 @@ public function then($fulfilledHandler = null, $errorHandler = null, $progressHa { return $this->deferred->then($fulfilledHandler, $errorHandler, $progressHandler); } + + public function cancel() + { + $this->deferred->cancel(); + } } diff --git a/src/React/Promise/FulfilledPromise.php b/src/React/Promise/FulfilledPromise.php index 5aef4e96..96650609 100644 --- a/src/React/Promise/FulfilledPromise.php +++ b/src/React/Promise/FulfilledPromise.php @@ -2,7 +2,7 @@ namespace React\Promise; -class FulfilledPromise implements PromiseInterface +class FulfilledPromise implements PromiseInterface, CancellablePromiseInterface { private $result; @@ -27,4 +27,8 @@ public function then($fulfilledHandler = null, $errorHandler = null, $progressHa return new RejectedPromise($exception); } } + + public function cancel() + { + } } diff --git a/src/React/Promise/LazyPromise.php b/src/React/Promise/LazyPromise.php index 92774d64..01da25cb 100644 --- a/src/React/Promise/LazyPromise.php +++ b/src/React/Promise/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,19 @@ public function __construct($factory) } public function then($fulfilledHandler = null, $errorHandler = null, $progressHandler = null) + { + return $this->promise()->then($fulfilledHandler, $errorHandler, $progressHandler); + } + + public function cancel() + { + $promise = $this->promise(); + if ($promise instanceof CancellablePromiseInterface) { + $promise->cancel(); + } + } + + private function promise() { if (null === $this->promise) { try { @@ -21,7 +34,6 @@ public function then($fulfilledHandler = null, $errorHandler = null, $progressHa $this->promise = new RejectedPromise($exception); } } - - return $this->promise->then($fulfilledHandler, $errorHandler, $progressHandler); + return $this->promise; } } diff --git a/src/React/Promise/Promise.php b/src/React/Promise/Promise.php index 01547973..8475bf5e 100644 --- a/src/React/Promise/Promise.php +++ b/src/React/Promise/Promise.php @@ -2,11 +2,11 @@ namespace React\Promise; -class Promise implements PromiseInterface +class Promise implements PromiseInterface, CancellablePromiseInterface { private $deferred; - public function __construct($resolver) + public function __construct($resolver, $canceller = null) { if (!is_callable($resolver)) { throw new \InvalidArgumentException( @@ -17,7 +17,7 @@ public function __construct($resolver) ); } - $this->deferred = new Deferred(); + $this->deferred = new Deferred($canceller); $this->call($resolver); } @@ -26,6 +26,11 @@ public function then($fulfilledHandler = null, $errorHandler = null, $progressHa return $this->deferred->then($fulfilledHandler, $errorHandler, $progressHandler); } + public function cancel() + { + $this->deferred->cancel(); + } + private function call($callback) { $deferred = $this->deferred; diff --git a/src/React/Promise/RejectedPromise.php b/src/React/Promise/RejectedPromise.php index dcb38d6d..da0a9af4 100644 --- a/src/React/Promise/RejectedPromise.php +++ b/src/React/Promise/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($fulfilledHandler = null, $errorHandler = null, $progressHa return new RejectedPromise($exception); } } + + public function cancel() + { + } } diff --git a/tests/React/Promise/DeferredPromiseTest.php b/tests/React/Promise/DeferredPromiseTest.php index 123f65c7..2cd0fab9 100644 --- a/tests/React/Promise/DeferredPromiseTest.php +++ b/tests/React/Promise/DeferredPromiseTest.php @@ -20,4 +20,16 @@ public function shouldForwardToDeferred() $p = new DeferredPromise($mock); $p->then(1, 2, 3); } + + /** @test */ + public function shouldForwardCancelToDeferred() + { + $mock = $this->getMock('React\\Promise\\Deferred'); + $mock + ->expects($this->once()) + ->method('cancel'); + + $p = new DeferredPromise($mock); + $p->cancel(); + } } diff --git a/tests/React/Promise/DeferredTest.php b/tests/React/Promise/DeferredTest.php index 51eb2520..b5c2fdba 100644 --- a/tests/React/Promise/DeferredTest.php +++ b/tests/React/Promise/DeferredTest.php @@ -84,4 +84,130 @@ public function shouldReturnSilentlyOnProgressWhenAlreadyRejected() $this->assertNull($d->progress()); } + + /** @test */ + public function shouldIgnoreCancellationWithNoCancellationHandlerAndStayPending() + { + $d = new Deferred(); + $d->cancel(); + + $d->then($this->expectCallableNever(), $this->expectCallableNever()); + } + + /** @test */ + public function shouldIgnoreCancellationWhenAlreadySettled() + { + $d = new Deferred($this->expectCallableNever()); + $d->resolve(); + + $d->cancel(); + + $d->then($this->expectCallableOnce(), $this->expectCallableNever()); + } + + /** @test */ + public function shouldInvokeCancellationHandlerAndStayPendingWhenCallingCancel() + { + $d = new Deferred($this->expectCallableOnce()); + $d->cancel(); + + $d->then($this->expectCallableNever(), $this->expectCallableNever()); + } + + /** @test */ + public function shouldInvokeCancellationHandlerOnlyOnceWhenCallingCancelMultipleTimes() + { + $d = new Deferred($this->expectCallableOnce()); + $d->cancel(); + $d->cancel(); + } + + /** @test */ + public function shouldResolveWhenCancellationHandlerResolves() + { + $d = new Deferred(function ($resolve) { + $resolve(); + }); + + $d->cancel(); + + $d->then($this->expectCallableOnce(), $this->expectCallableNever()); + } + + /** @test */ + public function shouldRejectWhenCancellationHandlerRejects() + { + $d = new Deferred(function ($_, $reject) { + $reject(); + }); + + $d->cancel(); + + $d->then($this->expectCallableNever(), $this->expectCallableOnce()); + } + + /** @test */ + public function shouldRejectWhenCancellationHandlerThrows() + { + $d = new Deferred(function () { + throw new \Exception(); + }); + + $d->cancel(); + + $d->then($this->expectCallableNever(), $this->expectCallableOnce()); + } + + /** @test */ + public function shouldProgressWhenCancellationHandlerEmitsProgress() + { + $d = new Deferred(function ($_, $__, $progress) { + $progress(); + }); + + $d->then(null, null, $this->expectCallableOnce()); + + $d->cancel(); + } + + /** @test */ + public function shouldInvokeCancellationHandleWhenCancellingDerived() + { + $d = new Deferred($this->expectCallableOnce()); + + $p = $d->then(); + $p->cancel(); + } + + /** @test */ + public function shouldNotInvokeCancellationHandleWhenCancellingNotAllDerived() + { + $d = new Deferred($this->expectCallableNever()); + + $p1 = $d->then(); + $p2 = $d->then(); + + $p1->cancel(); + } + + /** @test */ + public function shouldInvokeCancellationHandleWhenCancellingAllDerived() + { + $d = new Deferred($this->expectCallableOnce()); + + $p1 = $d->then(); + $p2 = $d->then(); + + $p1->cancel(); + $p2->cancel(); + } + + /** + * @test + * @expectedException InvalidArgumentException + */ + public function shouldThrowIfCancellerIsNotACallable() + { + new Deferred(false); + } } diff --git a/tests/React/Promise/FulfilledPromiseTest.php b/tests/React/Promise/FulfilledPromiseTest.php index ef574a8e..fb1fafce 100644 --- a/tests/React/Promise/FulfilledPromiseTest.php +++ b/tests/React/Promise/FulfilledPromiseTest.php @@ -140,4 +140,12 @@ public function shouldSwitchFromCallbacksToErrbacksWhenCallbackThrows() $mock2 ); } + + /** @test */ + public function shouldNotBeAffectedByCancellation() + { + $p = new FulfilledPromise(1); + $p->cancel(); + $p->then($this->expectCallableOnce()); + } } diff --git a/tests/React/Promise/LazyPromiseTest.php b/tests/React/Promise/LazyPromiseTest.php index 52a2517f..7c0f6aa1 100644 --- a/tests/React/Promise/LazyPromiseTest.php +++ b/tests/React/Promise/LazyPromiseTest.php @@ -63,12 +63,12 @@ public function shouldReturnPromiseIfFactoryReturnsNull() $p = new LazyPromise($factory); $this->assertInstanceOf('React\\Promise\\PromiseInterface', $p->then()); } - + /** @test */ public function shouldReturnRejectedPromiseIfFactoryThrowsException() { $exception = new \Exception(); - + $factory = $this->createCallableMock(); $factory ->expects($this->once()) @@ -85,4 +85,34 @@ public function shouldReturnRejectedPromiseIfFactoryThrowsException() $p->then($this->expectCallableNever(), $errorHandler); } + + /** @test */ + public function shouldInvokeCancellationHandlerAndStayPendingWhenCallingCancel() + { + $once = $this->expectCallableOnce(); + + $factory = function () use ($once){ + return new Deferred($once); + }; + + $p = new LazyPromise($factory); + $p->cancel(); + + $p->then($this->expectCallableNever(), $this->expectCallableNever()); + } + + /** @test */ + public function shouldNotInvokeCancellationHandlerIfPromiseIsNotCancellable() + { + $mock = $this->getMock('React\\Promise\\PromiseInterface'); + + $factory = function () use ($mock){ + return $mock; + }; + + $p = new LazyPromise($factory); + $p->cancel(); + + $p->then($this->expectCallableNever(), $this->expectCallableNever()); + } } diff --git a/tests/React/Promise/PromiseTest.php b/tests/React/Promise/PromiseTest.php index 9d61986d..e6f22fbd 100644 --- a/tests/React/Promise/PromiseTest.php +++ b/tests/React/Promise/PromiseTest.php @@ -81,4 +81,22 @@ public function shouldProgress() $notify(1); } + + /** @test */ + public function shouldInvokeCancellationHandlerAndStayPendingWhenCallingCancel() + { + $promise = new Promise(function() { }, $this->expectCallableOnce()); + $promise->cancel(); + + $promise->then($this->expectCallableNever(), $this->expectCallableNever()); + } + + /** + * @test + * @expectedException InvalidArgumentException + */ + public function shouldThrowIfCancellerIsNotACallable() + { + new Promise(function () { }, false); + } } diff --git a/tests/React/Promise/RejectedPromiseTest.php b/tests/React/Promise/RejectedPromiseTest.php index df953803..d2cb860f 100644 --- a/tests/React/Promise/RejectedPromiseTest.php +++ b/tests/React/Promise/RejectedPromiseTest.php @@ -145,4 +145,12 @@ function ($val) { $mock ); } + + /** @test */ + public function shouldNotBeAffectedByCancellation() + { + $p = new RejectedPromise(1); + $p->cancel(); + $p->then($this->expectCallableNever(), $this->expectCallableOnce()); + } }