diff --git a/README.md b/README.md index 93f8286..8c6fbb4 100644 --- a/README.md +++ b/README.md @@ -132,16 +132,91 @@ If no cancellation handler is passed to the `Promise` constructor, then invoking its `cancel()` method it is effectively a NO-OP. This means that it may still be pending and can hence continue consuming resources. -> Note: If you're stuck on legacy versions (PHP 5.3), then this is also a NO-OP, -as the Promise cancellation API is currently only available in -[react/promise v2.1.0](https://github.com/reactphp/promise) -which in turn requires PHP 5.4 or up. -It is assumed that if you're actually still stuck on PHP 5.3, resource cleanup -is likely one of your smaller problems. - For more details on the promise cancellation, please refer to the [Promise documentation](https://github.com/reactphp/promise#cancellablepromiseinterface). +#### Input cancellation + +Irrespective of the timout handling, you can also explicitly `cancel()` the +input `$promise` at any time. +This means that the `timeout()` handling does not affect cancellation of the +input `$promise`, as demonstrated in the following example: + +```php +$promise = accessSomeRemoteResource(); +$timeout = Timer\timeout($promise, 10.0, $loop); + +$promise->cancel(); +``` + +The registered [cancellation handler](#cancellation-handler) is responsible for +handling the `cancel()` call: + +* A described above, a common use involves resource cleanup and will then *reject* + the `Promise`. + If the input `$promise` is being rejected, then the timeout will be aborted + and the resulting promise will also be rejected. +* If the input `$promise` is still pending, then the timout will continue + running until the timer expires. + The same happens if the input `$promise` does not register a + [cancellation handler](#cancellation-handler). + +#### Output cancellation + +Similarily, you can also explicitly `cancel()` the resulting promise like this: + +```php +$promise = accessSomeRemoteResource(); +$timeout = Timer\timeout($promise, 10.0, $loop); + +$timeout->cancel(); +``` + +Note how this looks very similar to the above [input cancellation](#input-cancellation) +example. Accordingly, it also behaves very similar. + +Calling `cancel()` on the resulting promise will merely try +to `cancel()` the input `$promise`. +This means that we do not take over responsibility of the outcome and it's +entirely up to the input `$promise` to handle cancellation support. + +The registered [cancellation handler](#cancellation-handler) is responsible for +handling the `cancel()` call: + +* As described above, a common use involves resource cleanup and will then *reject* + the `Promise`. + If the input `$promise` is being rejected, then the timeout will be aborted + and the resulting promise will also be rejected. +* If the input `$promise` is still pending, then the timout will continue + running until the timer expires. + The same happens if the input `$promise` does not register a + [cancellation handler](#cancellation-handler). + +To re-iterate, note that calling `cancel()` on the resulting promise will merely +try to cancel the input `$promise` only. +It is then up to the cancellation handler of the input promise to settle the promise. +If the input promise is still pending when the timeout occurs, then the normal +[timeout cancellation](#timeout-cancellation) handling will trigger, effectively rejecting +the output promise with a [`TimeoutException`](#timeoutexception). + +This is done for consistency with the [timeout cancellation](#timeout-cancellation) +handling and also because it is assumed this is often used like this: + +```php +$timeout = Timer\timeout(accessSomeRemoteResource(), 10.0, $loop); + +$timeout->cancel(); +``` + +As described above, this example works as expected and cleans up any resources +allocated for the input `$promise`. + +Note that if the given input `$promise` does not support cancellation, then this +is a NO-OP. +This means that while the resulting promise will still be rejected after the +timeout, the underlying input `$promise` may still be pending and can hence +continue consuming resources. + #### Collections If you want to wait for multiple promises to resolve, you can use the normal promise primitives like this: @@ -176,6 +251,18 @@ Timer\resolve(1.5, $loop)->then(function ($time) { }); ``` +#### Resolve cancellation + +You can explicitly `cancel()` the resulting timer promise at any time: + +```php +$timer = Timer\resolve(2.0, $loop); + +$timer->cancel(); +``` + +This will abort the timer and *reject* with a `RuntimeException`. + ### reject() The `reject($time, LoopInterface $loop)` function can be used to create a new Promise @@ -183,13 +270,25 @@ which rejects in `$time` seconds with a `TimeoutException`. ```php Timer\reject(2.0, $loop)->then(null, function (TimeoutException $e) { - echo ' + echo 'Rejected after ' . $e->getTimeout() . ' seconds ' . PHP_EOL; }); ``` This function complements the [`resolve()`](#resolve) function and can be used as a basic building block for higher-level promise consumers. +#### Reject cancellation + +You can explicitly `cancel()` the resulting timer promise at any time: + +```php +$timer = Timer\reject(2.0, $loop); + +$timer->cancel(); +``` + +This will abort the timer and *reject* with a `RuntimeException`. + ### TimeoutException The `TimeoutException` extends PHP's built-in `RuntimeException`. @@ -209,12 +308,6 @@ The recommended way to install this library is [through composer](http://getcomp } ``` -> Note: If you're stuck on legacy versions (PHP 5.3), then the `cancel()` method -is not available, -as the Promise cancellation API is currently only available in -[react/promise v2.1.0](https://github.com/reactphp/promise) -which in turn requires PHP 5.4 or up. - ## License MIT diff --git a/composer.json b/composer.json index 9ea0209..8be4b17 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,6 @@ "require": { "php": ">=5.3", "react/event-loop": "~0.4.0|~0.3.0", - "react/promise": "~2.0|~1.1" + "react/promise": "~2.1|~1.2" } } diff --git a/src/functions.php b/src/functions.php index 0a5a299..8d7b015 100644 --- a/src/functions.php +++ b/src/functions.php @@ -9,6 +9,13 @@ function timeout(PromiseInterface $promise, $time, LoopInterface $loop) { + // cancelling this promise will only try to cancel the input promise, + // thus leaving responsibility to the input promise. + $canceller = null; + if ($promise instanceof CancellablePromiseInterface) { + $canceller = array($promise, 'cancel'); + } + return new Promise(function ($resolve, $reject) use ($loop, $time, $promise) { $timer = $loop->addTimer($time, function () use ($time, $promise, $reject) { $reject(new TimeoutException($time, 'Timed out after ' . $time . ' seconds')); @@ -25,15 +32,20 @@ function timeout(PromiseInterface $promise, $time, LoopInterface $loop) $loop->cancelTimer($timer); $reject($v); }); - }); + }, $canceller); } function resolve($time, LoopInterface $loop) { - return new Promise(function ($resolve) use ($loop, $time) { - $loop->addTimer($time, function () use ($time, $resolve) { + return new Promise(function ($resolve) use ($loop, $time, &$timer) { + // resolve the promise when the timer fires in $time seconds + $timer = $loop->addTimer($time, function () use ($time, $resolve) { $resolve($time); }); + }, function ($resolveUnused, $reject) use (&$timer, $loop) { + // cancelling this promise will cancel the timer and reject + $loop->cancelTimer($timer); + $reject(new \RuntimeException('Timer cancelled')); }); } diff --git a/tests/FunctionRejectTest.php b/tests/FunctionRejectTest.php index c8a46da..b9d6eb0 100644 --- a/tests/FunctionRejectTest.php +++ b/tests/FunctionRejectTest.php @@ -1,6 +1,7 @@ expectPromiseRejected($promise); } + + public function testCancelingPromiseWillRejectTimer() + { + $promise = Timer\reject(0.01, $this->loop); + + $promise->cancel(); + + $this->expectPromiseRejected($promise); + } } diff --git a/tests/FunctionResolveTest.php b/tests/FunctionResolveTest.php index 19cd373..f3a99e1 100644 --- a/tests/FunctionResolveTest.php +++ b/tests/FunctionResolveTest.php @@ -1,6 +1,7 @@ expectPromiseResolved($promise); } + + public function testWillStartLoopTimer() + { + $loop = $this->getMock('React\EventLoop\LoopInterface'); + $loop->expects($this->once())->method('addTimer')->with($this->equalTo(0.01)); + + Timer\resolve(0.01, $loop); + } + + public function testCancellingPromiseWillCancelLoopTimer() + { + $loop = $this->getMock('React\EventLoop\LoopInterface'); + + $timer = $this->getMock('React\EventLoop\Timer\TimerInterface'); + $loop->expects($this->once())->method('addTimer')->will($this->returnValue($timer)); + + $promise = Timer\resolve(0.01, $loop); + + $loop->expects($this->once())->method('cancelTimer')->with($this->equalTo($timer)); + + $promise->cancel(); + } + + public function testCancelingPromiseWillRejectTimer() + { + $promise = Timer\resolve(0.01, $this->loop); + + $promise->cancel(); + + $this->expectPromiseRejected($promise); + } } diff --git a/tests/FunctionTimeoutTest.php b/tests/FunctionTimeoutTest.php index 35d5091..0abd18a 100644 --- a/tests/FunctionTimeoutTest.php +++ b/tests/FunctionTimeoutTest.php @@ -62,16 +62,73 @@ public function testPendingWillRejectOnTimeout() public function testPendingCancellableWillBeCancelledOnTimeout() { - if (!interface_exists('React\Promise\CancellablePromiseInterface', true)) { - $this->markTestSkipped('Your (outdated?) Promise API does not support cancellable promises'); - } - $promise = $this->getMock('React\Promise\CancellablePromiseInterface'); $promise->expects($this->once())->method('cancel'); - Timer\timeout($promise, 0.01, $this->loop); $this->loop->run(); } + + public function testCancelTimeoutWithoutCancellationhandlerWillNotCancelTimerAndWillNotReject() + { + $promise = new \React\Promise\Promise(function () { }); + + $loop = $this->getMock('React\EventLoop\LoopInterface'); + + $timer = $this->getMock('React\EventLoop\Timer\TimerInterface'); + $loop->expects($this->once())->method('addTimer')->will($this->returnValue($timer)); + $loop->expects($this->never())->method('cancelTimer'); + + $timeout = Timer\timeout($promise, 0.01, $loop); + + $timeout->cancel(); + + $this->expectPromisePending($timeout); + } + + public function testCancelTimeoutWillCancelGivenPromise() + { + $promise = new \React\Promise\Promise(function () { }, $this->expectCallableOnce()); + + $timeout = Timer\timeout($promise, 0.01, $this->loop); + + $timeout->cancel(); + } + + public function testCancelGivenPromiseWillReject() + { + $promise = new \React\Promise\Promise(function () { }, function ($resolve, $reject) { $reject(); }); + + $timeout = Timer\timeout($promise, 0.01, $this->loop); + + $promise->cancel(); + + $this->expectPromiseRejected($promise); + $this->expectPromiseRejected($timeout); + } + + public function testCancelTimeoutWillRejectIfGivenPromiseWillReject() + { + $promise = new \React\Promise\Promise(function () { }, function ($resolve, $reject) { $reject(); }); + + $timeout = Timer\timeout($promise, 0.01, $this->loop); + + $timeout->cancel(); + + $this->expectPromiseRejected($promise); + $this->expectPromiseRejected($timeout); + } + + public function testCancelTimeoutWillResolveIfGivenPromiseWillResolve() + { + $promise = new \React\Promise\Promise(function () { }, function ($resolve, $reject) { $resolve(); }); + + $timeout = Timer\timeout($promise, 0.01, $this->loop); + + $timeout->cancel(); + + $this->expectPromiseResolved($promise); + $this->expectPromiseResolved($timeout); + } }