Skip to content

Commit ac376a5

Browse files
committed
Implement cancellation for pending/deferred promises
1 parent 3ba6144 commit ac376a5

7 files changed

Lines changed: 221 additions & 6 deletions

File tree

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,18 @@ $deferred->reject(mixed $reason = null);
113113
$deferred->progress(mixed $update = null);
114114
```
115115

116+
The constructor of the `Deferred` accepts an optional `$canceller` argument.
117+
See [Promise](#promise-1) for more information.
118+
119+
120+
``` php
121+
$deferred = new React\Promise\Deferred(function ($resolve, $reject, $progress) {
122+
throw new \Exception('Promise cancelled');
123+
});
124+
125+
$deferred->cancel();
126+
```
127+
116128
### Promise
117129

118130
The Promise represents the eventual outcome, which is either fulfillment
@@ -135,7 +147,13 @@ $resolver = function (callable $resolve, callable $reject, callable $notify) {
135147
// or $notify($progressNotification);
136148
};
137149

138-
$promise = new React\Promise\Promise($resolver);
150+
$canceller = function (callable $resolve, callable $reject, callable $progress) {
151+
// Cancel/abort any running operations like network connections, streams etc.
152+
153+
$reject(new \Exception('Promise cancelled'));
154+
};
155+
156+
$promise = new React\Promise\Promise($resolver, $canceller);
139157
```
140158

141159
The promise constructor receives a resolver function which will be called
@@ -152,6 +170,9 @@ immediately with 3 arguments:
152170
If the resolver throws an exception, the promise will be rejected with that
153171
thrown exception as the rejection reason.
154172

173+
The resolver function will be called immediately, the canceller function only
174+
once all consumers called the `cancel()` method of the promise.
175+
155176
A Promise has a single method `then()` which registers new fulfilled, error and
156177
progress handlers with this Promise (all parameters are optional):
157178

@@ -506,6 +527,8 @@ a promise has no effect.
506527

507528
#### Implementations
508529

530+
* [Deferred](#deferred-1)
531+
* [Promise](#promise-1)
509532
* [FulfilledPromise](#fulfilledpromise)
510533
* [RejectedPromise](#rejectedpromise)
511534
* [LazyPromise](#lazypromise)

src/React/Promise/Deferred.php

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,47 @@
22

33
namespace React\Promise;
44

5-
class Deferred implements PromiseInterface, ResolverInterface, PromisorInterface
5+
class Deferred implements PromiseInterface, ResolverInterface, PromisorInterface, CancellablePromiseInterface
66
{
77
private $completed;
88
private $promise;
99
private $resolver;
1010
private $handlers = array();
1111
private $progressHandlers = array();
12+
private $canceller;
13+
14+
private $requiredCancelRequests = 0;
15+
private $cancelRequests = 0;
16+
17+
public function __construct($canceller = null)
18+
{
19+
$this->canceller = $canceller;
20+
}
1221

1322
public function then($fulfilledHandler = null, $errorHandler = null, $progressHandler = null)
1423
{
1524
if (null !== $this->completed) {
1625
return $this->completed->then($fulfilledHandler, $errorHandler, $progressHandler);
1726
}
1827

19-
$deferred = new static();
28+
$canceller = null;
29+
if ($this->canceller !== null) {
30+
$this->requiredCancelRequests++;
31+
32+
$that = $this;
33+
$current =& $this->cancelRequests;
34+
$required =& $this->requiredCancelRequests;
35+
36+
$canceller = function () use ($that, &$current, &$required) {
37+
if (++$current < $required) {
38+
return;
39+
}
40+
41+
$that->cancel();
42+
};
43+
}
44+
45+
$deferred = new static($canceller);
2046

2147
if (is_callable($progressHandler)) {
2248
$progHandler = function ($update) use ($deferred, $progressHandler) {
@@ -96,6 +122,32 @@ public function resolver()
96122
return $this->resolver;
97123
}
98124

125+
public function cancel()
126+
{
127+
if (null === $this->canceller || null !== $this->completed) {
128+
return;
129+
}
130+
131+
try {
132+
$that = $this;
133+
134+
call_user_func(
135+
$this->canceller,
136+
function ($value = null) use ($that) {
137+
$that->resolve($value);
138+
},
139+
function ($reason = null) use ($that) {
140+
$that->reject($reason);
141+
},
142+
function ($update = null) use ($that) {
143+
$that->progress($update);
144+
}
145+
);
146+
} catch (\Exception $e) {
147+
$this->reject($e);
148+
}
149+
}
150+
99151
protected function processQueue($queue, $value)
100152
{
101153
foreach ($queue as $handler) {

src/React/Promise/DeferredPromise.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,9 @@ public function then($fulfilledHandler = null, $errorHandler = null, $progressHa
1515
{
1616
return $this->deferred->then($fulfilledHandler, $errorHandler, $progressHandler);
1717
}
18+
19+
public function cancel()
20+
{
21+
$this->deferred->cancel();
22+
}
1823
}

src/React/Promise/Promise.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
namespace React\Promise;
44

5-
class Promise implements PromiseInterface
5+
class Promise implements PromiseInterface, CancellablePromiseInterface
66
{
77
private $deferred;
88

9-
public function __construct($resolver)
9+
public function __construct($resolver, $canceller = null)
1010
{
1111
if (!is_callable($resolver)) {
1212
throw new \InvalidArgumentException(
@@ -17,7 +17,7 @@ public function __construct($resolver)
1717
);
1818
}
1919

20-
$this->deferred = new Deferred();
20+
$this->deferred = new Deferred($canceller);
2121
$this->call($resolver);
2222
}
2323

@@ -26,6 +26,11 @@ public function then($fulfilledHandler = null, $errorHandler = null, $progressHa
2626
return $this->deferred->then($fulfilledHandler, $errorHandler, $progressHandler);
2727
}
2828

29+
public function cancel()
30+
{
31+
$this->deferred->cancel();
32+
}
33+
2934
private function call($callback)
3035
{
3136
$deferred = $this->deferred;

tests/React/Promise/DeferredPromiseTest.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,16 @@ public function shouldForwardToDeferred()
2020
$p = new DeferredPromise($mock);
2121
$p->then(1, 2, 3);
2222
}
23+
24+
/** @test */
25+
public function shouldForwardCancelToDeferred()
26+
{
27+
$mock = $this->getMock('React\\Promise\\Deferred');
28+
$mock
29+
->expects($this->once())
30+
->method('cancel');
31+
32+
$p = new DeferredPromise($mock);
33+
$p->cancel();
34+
}
2335
}

tests/React/Promise/DeferredTest.php

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,113 @@ public function shouldReturnSilentlyOnProgressWhenAlreadyRejected()
8484

8585
$this->assertNull($d->progress());
8686
}
87+
88+
/** @test */
89+
public function shouldIgnoreCancellationWithNoCancellationHandlerAndStayPending()
90+
{
91+
$d = new Deferred();
92+
$d->cancel();
93+
94+
$d->then($this->expectCallableNever(), $this->expectCallableNever());
95+
}
96+
97+
/** @test */
98+
public function shouldIgnoreCancellationWhenAlreadySettled()
99+
{
100+
$d = new Deferred($this->expectCallableNever());
101+
$d->resolve();
102+
103+
$d->cancel();
104+
105+
$d->then($this->expectCallableOnce(), $this->expectCallableNever());
106+
}
107+
108+
/** @test */
109+
public function shouldInvokeCancellationHandlerAndStayPendingWhenCallingCancel()
110+
{
111+
$d = new Deferred($this->expectCallableOnce());
112+
$d->cancel();
113+
114+
$d->then($this->expectCallableNever(), $this->expectCallableNever());
115+
}
116+
117+
/** @test */
118+
public function shouldResolveWhenCancellationHandlerResolves()
119+
{
120+
$d = new Deferred(function ($resolve) {
121+
$resolve();
122+
});
123+
124+
$d->cancel();
125+
126+
$d->then($this->expectCallableOnce(), $this->expectCallableNever());
127+
}
128+
129+
/** @test */
130+
public function shouldRejectWhenCancellationHandlerRejects()
131+
{
132+
$d = new Deferred(function ($_, $reject) {
133+
$reject();
134+
});
135+
136+
$d->cancel();
137+
138+
$d->then($this->expectCallableNever(), $this->expectCallableOnce());
139+
}
140+
141+
/** @test */
142+
public function shouldRejectWhenCancellationHandlerThrows()
143+
{
144+
$d = new Deferred(function () {
145+
throw new \Exception();
146+
});
147+
148+
$d->cancel();
149+
150+
$d->then($this->expectCallableNever(), $this->expectCallableOnce());
151+
}
152+
153+
/** @test */
154+
public function shouldProgressWhenCancellationHandlerEmitsProgress()
155+
{
156+
$d = new Deferred(function ($_, $__, $progress) {
157+
$progress();
158+
});
159+
160+
$d->then(null, null, $this->expectCallableOnce());
161+
162+
$d->cancel();
163+
}
164+
165+
/** @test */
166+
public function shouldInvokeCancellationHandleWhenCancellingDerived()
167+
{
168+
$d = new Deferred($this->expectCallableOnce());
169+
170+
$p = $d->then();
171+
$p->cancel();
172+
}
173+
174+
/** @test */
175+
public function shouldNotInvokeCancellationHandleWhenCancellingNotAllDerived()
176+
{
177+
$d = new Deferred($this->expectCallableNever());
178+
179+
$p1 = $d->then();
180+
$p2 = $d->then();
181+
182+
$p1->cancel();
183+
}
184+
185+
/** @test */
186+
public function shouldInvokeCancellationHandleWhenCancellingAllDerived()
187+
{
188+
$d = new Deferred($this->expectCallableOnce());
189+
190+
$p1 = $d->then();
191+
$p2 = $d->then();
192+
193+
$p1->cancel();
194+
$p2->cancel();
195+
}
87196
}

tests/React/Promise/PromiseTest.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,13 @@ public function shouldProgress()
8181

8282
$notify(1);
8383
}
84+
85+
/** @test */
86+
public function shouldInvokeCancellationHandlerAndStayPendingWhenCallingCancel()
87+
{
88+
$promise = new Promise(function() { }, $this->expectCallableOnce());
89+
$promise->cancel();
90+
91+
$promise->then($this->expectCallableNever(), $this->expectCallableNever());
92+
}
8493
}

0 commit comments

Comments
 (0)