Skip to content

Commit 65adff0

Browse files
committed
Merge pull request #18 from clue-labs/cancellation2
Support promise cancellation
2 parents a3b8c28 + 46c5b2b commit 65adff0

File tree

6 files changed

+227
-23
lines changed

6 files changed

+227
-23
lines changed

README.md

Lines changed: 107 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -132,16 +132,91 @@ If no cancellation handler is passed to the `Promise` constructor, then invoking
132132
its `cancel()` method it is effectively a NO-OP.
133133
This means that it may still be pending and can hence continue consuming resources.
134134

135-
> Note: If you're stuck on legacy versions (PHP 5.3), then this is also a NO-OP,
136-
as the Promise cancellation API is currently only available in
137-
[react/promise v2.1.0](https://github.com/reactphp/promise)
138-
which in turn requires PHP 5.4 or up.
139-
It is assumed that if you're actually still stuck on PHP 5.3, resource cleanup
140-
is likely one of your smaller problems.
141-
142135
For more details on the promise cancellation, please refer to the
143136
[Promise documentation](https://github.com/reactphp/promise#cancellablepromiseinterface).
144137

138+
#### Input cancellation
139+
140+
Irrespective of the timout handling, you can also explicitly `cancel()` the
141+
input `$promise` at any time.
142+
This means that the `timeout()` handling does not affect cancellation of the
143+
input `$promise`, as demonstrated in the following example:
144+
145+
```php
146+
$promise = accessSomeRemoteResource();
147+
$timeout = Timer\timeout($promise, 10.0, $loop);
148+
149+
$promise->cancel();
150+
```
151+
152+
The registered [cancellation handler](#cancellation-handler) is responsible for
153+
handling the `cancel()` call:
154+
155+
* A described above, a common use involves resource cleanup and will then *reject*
156+
the `Promise`.
157+
If the input `$promise` is being rejected, then the timeout will be aborted
158+
and the resulting promise will also be rejected.
159+
* If the input `$promise` is still pending, then the timout will continue
160+
running until the timer expires.
161+
The same happens if the input `$promise` does not register a
162+
[cancellation handler](#cancellation-handler).
163+
164+
#### Output cancellation
165+
166+
Similarily, you can also explicitly `cancel()` the resulting promise like this:
167+
168+
```php
169+
$promise = accessSomeRemoteResource();
170+
$timeout = Timer\timeout($promise, 10.0, $loop);
171+
172+
$timeout->cancel();
173+
```
174+
175+
Note how this looks very similar to the above [input cancellation](#input-cancellation)
176+
example. Accordingly, it also behaves very similar.
177+
178+
Calling `cancel()` on the resulting promise will merely try
179+
to `cancel()` the input `$promise`.
180+
This means that we do not take over responsibility of the outcome and it's
181+
entirely up to the input `$promise` to handle cancellation support.
182+
183+
The registered [cancellation handler](#cancellation-handler) is responsible for
184+
handling the `cancel()` call:
185+
186+
* As described above, a common use involves resource cleanup and will then *reject*
187+
the `Promise`.
188+
If the input `$promise` is being rejected, then the timeout will be aborted
189+
and the resulting promise will also be rejected.
190+
* If the input `$promise` is still pending, then the timout will continue
191+
running until the timer expires.
192+
The same happens if the input `$promise` does not register a
193+
[cancellation handler](#cancellation-handler).
194+
195+
To re-iterate, note that calling `cancel()` on the resulting promise will merely
196+
try to cancel the input `$promise` only.
197+
It is then up to the cancellation handler of the input promise to settle the promise.
198+
If the input promise is still pending when the timeout occurs, then the normal
199+
[timeout cancellation](#timeout-cancellation) handling will trigger, effectively rejecting
200+
the output promise with a [`TimeoutException`](#timeoutexception).
201+
202+
This is done for consistency with the [timeout cancellation](#timeout-cancellation)
203+
handling and also because it is assumed this is often used like this:
204+
205+
```php
206+
$timeout = Timer\timeout(accessSomeRemoteResource(), 10.0, $loop);
207+
208+
$timeout->cancel();
209+
```
210+
211+
As described above, this example works as expected and cleans up any resources
212+
allocated for the input `$promise`.
213+
214+
Note that if the given input `$promise` does not support cancellation, then this
215+
is a NO-OP.
216+
This means that while the resulting promise will still be rejected after the
217+
timeout, the underlying input `$promise` may still be pending and can hence
218+
continue consuming resources.
219+
145220
#### Collections
146221

147222
If you want to wait for multiple promises to resolve, you can use the normal promise primitives like this:
@@ -176,20 +251,44 @@ Timer\resolve(1.5, $loop)->then(function ($time) {
176251
});
177252
```
178253

254+
#### Resolve cancellation
255+
256+
You can explicitly `cancel()` the resulting timer promise at any time:
257+
258+
```php
259+
$timer = Timer\resolve(2.0, $loop);
260+
261+
$timer->cancel();
262+
```
263+
264+
This will abort the timer and *reject* with a `RuntimeException`.
265+
179266
### reject()
180267

181268
The `reject($time, LoopInterface $loop)` function can be used to create a new Promise
182269
which rejects in `$time` seconds with a `TimeoutException`.
183270

184271
```php
185272
Timer\reject(2.0, $loop)->then(null, function (TimeoutException $e) {
186-
echo '
273+
echo 'Rejected after ' . $e->getTimeout() . ' seconds ' . PHP_EOL;
187274
});
188275
```
189276

190277
This function complements the [`resolve()`](#resolve) function
191278
and can be used as a basic building block for higher-level promise consumers.
192279

280+
#### Reject cancellation
281+
282+
You can explicitly `cancel()` the resulting timer promise at any time:
283+
284+
```php
285+
$timer = Timer\reject(2.0, $loop);
286+
287+
$timer->cancel();
288+
```
289+
290+
This will abort the timer and *reject* with a `RuntimeException`.
291+
193292
### TimeoutException
194293

195294
The `TimeoutException` extends PHP's built-in `RuntimeException`.
@@ -209,12 +308,6 @@ The recommended way to install this library is [through composer](http://getcomp
209308
}
210309
```
211310

212-
> Note: If you're stuck on legacy versions (PHP 5.3), then the `cancel()` method
213-
is not available,
214-
as the Promise cancellation API is currently only available in
215-
[react/promise v2.1.0](https://github.com/reactphp/promise)
216-
which in turn requires PHP 5.4 or up.
217-
218311
## License
219312

220313
MIT

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,6 @@
1717
"require": {
1818
"php": ">=5.3",
1919
"react/event-loop": "~0.4.0|~0.3.0",
20-
"react/promise": "~2.0|~1.1"
20+
"react/promise": "~2.1|~1.2"
2121
}
2222
}

src/functions.php

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99

1010
function timeout(PromiseInterface $promise, $time, LoopInterface $loop)
1111
{
12+
// cancelling this promise will only try to cancel the input promise,
13+
// thus leaving responsibility to the input promise.
14+
$canceller = null;
15+
if ($promise instanceof CancellablePromiseInterface) {
16+
$canceller = array($promise, 'cancel');
17+
}
18+
1219
return new Promise(function ($resolve, $reject) use ($loop, $time, $promise) {
1320
$timer = $loop->addTimer($time, function () use ($time, $promise, $reject) {
1421
$reject(new TimeoutException($time, 'Timed out after ' . $time . ' seconds'));
@@ -25,15 +32,20 @@ function timeout(PromiseInterface $promise, $time, LoopInterface $loop)
2532
$loop->cancelTimer($timer);
2633
$reject($v);
2734
});
28-
});
35+
}, $canceller);
2936
}
3037

3138
function resolve($time, LoopInterface $loop)
3239
{
33-
return new Promise(function ($resolve) use ($loop, $time) {
34-
$loop->addTimer($time, function () use ($time, $resolve) {
40+
return new Promise(function ($resolve) use ($loop, $time, &$timer) {
41+
// resolve the promise when the timer fires in $time seconds
42+
$timer = $loop->addTimer($time, function () use ($time, $resolve) {
3543
$resolve($time);
3644
});
45+
}, function ($resolveUnused, $reject) use (&$timer, $loop) {
46+
// cancelling this promise will cancel the timer and reject
47+
$loop->cancelTimer($timer);
48+
$reject(new \RuntimeException('Timer cancelled'));
3749
});
3850
}
3951

tests/FunctionRejectTest.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?php
22

33
use React\Promise\Timer;
4+
use React\Promise\CancellablePromiseInterface;
45

56
class FunctionRejectTest extends TestCase
67
{
@@ -19,4 +20,13 @@ public function testPromiseWillBeRejectedOnTimeout()
1920

2021
$this->expectPromiseRejected($promise);
2122
}
23+
24+
public function testCancelingPromiseWillRejectTimer()
25+
{
26+
$promise = Timer\reject(0.01, $this->loop);
27+
28+
$promise->cancel();
29+
30+
$this->expectPromiseRejected($promise);
31+
}
2232
}

tests/FunctionResolveTest.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?php
22

33
use React\Promise\Timer;
4+
use React\Promise\CancellablePromiseInterface;
45

56
class FunctionResolveTest extends TestCase
67
{
@@ -19,4 +20,35 @@ public function testPromiseWillBeResolvedOnTimeout()
1920

2021
$this->expectPromiseResolved($promise);
2122
}
23+
24+
public function testWillStartLoopTimer()
25+
{
26+
$loop = $this->getMock('React\EventLoop\LoopInterface');
27+
$loop->expects($this->once())->method('addTimer')->with($this->equalTo(0.01));
28+
29+
Timer\resolve(0.01, $loop);
30+
}
31+
32+
public function testCancellingPromiseWillCancelLoopTimer()
33+
{
34+
$loop = $this->getMock('React\EventLoop\LoopInterface');
35+
36+
$timer = $this->getMock('React\EventLoop\Timer\TimerInterface');
37+
$loop->expects($this->once())->method('addTimer')->will($this->returnValue($timer));
38+
39+
$promise = Timer\resolve(0.01, $loop);
40+
41+
$loop->expects($this->once())->method('cancelTimer')->with($this->equalTo($timer));
42+
43+
$promise->cancel();
44+
}
45+
46+
public function testCancelingPromiseWillRejectTimer()
47+
{
48+
$promise = Timer\resolve(0.01, $this->loop);
49+
50+
$promise->cancel();
51+
52+
$this->expectPromiseRejected($promise);
53+
}
2254
}

tests/FunctionTimeoutTest.php

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,73 @@ public function testPendingWillRejectOnTimeout()
6262

6363
public function testPendingCancellableWillBeCancelledOnTimeout()
6464
{
65-
if (!interface_exists('React\Promise\CancellablePromiseInterface', true)) {
66-
$this->markTestSkipped('Your (outdated?) Promise API does not support cancellable promises');
67-
}
68-
6965
$promise = $this->getMock('React\Promise\CancellablePromiseInterface');
7066
$promise->expects($this->once())->method('cancel');
7167

72-
7368
Timer\timeout($promise, 0.01, $this->loop);
7469

7570
$this->loop->run();
7671
}
72+
73+
public function testCancelTimeoutWithoutCancellationhandlerWillNotCancelTimerAndWillNotReject()
74+
{
75+
$promise = new \React\Promise\Promise(function () { });
76+
77+
$loop = $this->getMock('React\EventLoop\LoopInterface');
78+
79+
$timer = $this->getMock('React\EventLoop\Timer\TimerInterface');
80+
$loop->expects($this->once())->method('addTimer')->will($this->returnValue($timer));
81+
$loop->expects($this->never())->method('cancelTimer');
82+
83+
$timeout = Timer\timeout($promise, 0.01, $loop);
84+
85+
$timeout->cancel();
86+
87+
$this->expectPromisePending($timeout);
88+
}
89+
90+
public function testCancelTimeoutWillCancelGivenPromise()
91+
{
92+
$promise = new \React\Promise\Promise(function () { }, $this->expectCallableOnce());
93+
94+
$timeout = Timer\timeout($promise, 0.01, $this->loop);
95+
96+
$timeout->cancel();
97+
}
98+
99+
public function testCancelGivenPromiseWillReject()
100+
{
101+
$promise = new \React\Promise\Promise(function () { }, function ($resolve, $reject) { $reject(); });
102+
103+
$timeout = Timer\timeout($promise, 0.01, $this->loop);
104+
105+
$promise->cancel();
106+
107+
$this->expectPromiseRejected($promise);
108+
$this->expectPromiseRejected($timeout);
109+
}
110+
111+
public function testCancelTimeoutWillRejectIfGivenPromiseWillReject()
112+
{
113+
$promise = new \React\Promise\Promise(function () { }, function ($resolve, $reject) { $reject(); });
114+
115+
$timeout = Timer\timeout($promise, 0.01, $this->loop);
116+
117+
$timeout->cancel();
118+
119+
$this->expectPromiseRejected($promise);
120+
$this->expectPromiseRejected($timeout);
121+
}
122+
123+
public function testCancelTimeoutWillResolveIfGivenPromiseWillResolve()
124+
{
125+
$promise = new \React\Promise\Promise(function () { }, function ($resolve, $reject) { $resolve(); });
126+
127+
$timeout = Timer\timeout($promise, 0.01, $this->loop);
128+
129+
$timeout->cancel();
130+
131+
$this->expectPromiseResolved($promise);
132+
$this->expectPromiseResolved($timeout);
133+
}
77134
}

0 commit comments

Comments
 (0)