Skip to content

Commit c989ee1

Browse files
authored
Merge pull request #13 from clue-labs/coroutine-cancellation
Support promise cancellation for `coroutine()` and clean up garbage references
2 parents 945ad1d + 4541391 commit c989ee1

File tree

2 files changed

+149
-3
lines changed

2 files changed

+149
-3
lines changed

src/functions.php

+15-3
Original file line numberDiff line numberDiff line change
@@ -224,22 +224,33 @@ function coroutine(callable $function, ...$args): PromiseInterface
224224
return resolve($generator);
225225
}
226226

227-
$deferred = new Deferred();
227+
$promise = null;
228+
$deferred = new Deferred(function () use (&$promise) {
229+
// cancel pending promise(s) as long as generator function keeps yielding
230+
while ($promise instanceof CancellablePromiseInterface) {
231+
$temp = $promise;
232+
$promise = null;
233+
$temp->cancel();
234+
}
235+
});
228236

229237
/** @var callable $next */
230-
$next = function () use ($deferred, $generator, &$next) {
238+
$next = function () use ($deferred, $generator, &$next, &$promise) {
231239
try {
232240
if (!$generator->valid()) {
241+
$next = null;
233242
$deferred->resolve($generator->getReturn());
234243
return;
235244
}
236245
} catch (\Throwable $e) {
246+
$next = null;
237247
$deferred->reject($e);
238248
return;
239249
}
240250

241251
$promise = $generator->current();
242252
if (!$promise instanceof PromiseInterface) {
253+
$next = null;
243254
$deferred->reject(new \UnexpectedValueException(
244255
'Expected coroutine to yield ' . PromiseInterface::class . ', but got ' . (is_object($promise) ? get_class($promise) : gettype($promise))
245256
));
@@ -252,7 +263,8 @@ function coroutine(callable $function, ...$args): PromiseInterface
252263
}, function (\Throwable $reason) use ($generator, $next) {
253264
$generator->throw($reason);
254265
$next();
255-
})->then(null, function (\Throwable $reason) use ($deferred) {
266+
})->then(null, function (\Throwable $reason) use ($deferred, &$next) {
267+
$next = null;
256268
$deferred->reject($reason);
257269
});
258270
};

tests/CoroutineTest.php

+134
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace React\Tests\Async;
44

5+
use React\Promise\Promise;
56
use function React\Async\coroutine;
67
use function React\Promise\reject;
78
use function React\Promise\resolve;
@@ -104,4 +105,137 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionYieldsInvalidValue(
104105

105106
$promise->then(null, $this->expectCallableOnceWith(new \UnexpectedValueException('Expected coroutine to yield React\Promise\PromiseInterface, but got integer')));
106107
}
108+
109+
110+
public function testCoroutineWillCancelPendingPromiseWhenCallingCancelOnResultingPromise()
111+
{
112+
$cancelled = 0;
113+
$promise = coroutine(function () use (&$cancelled) {
114+
yield new Promise(function () use (&$cancelled) {
115+
++$cancelled;
116+
});
117+
});
118+
119+
$promise->cancel();
120+
121+
$this->assertEquals(1, $cancelled);
122+
}
123+
124+
public function testCoroutineWillCancelAllPendingPromisesWhenFunctionContinuesToYieldWhenCallingCancelOnResultingPromise()
125+
{
126+
$promise = coroutine(function () {
127+
$promise = new Promise(function () { }, function () {
128+
throw new \RuntimeException('Frist operation cancelled', 21);
129+
});
130+
131+
try {
132+
yield $promise;
133+
} catch (\RuntimeException $e) {
134+
// ignore exception and continue
135+
}
136+
137+
yield new Promise(function () { }, function () {
138+
throw new \RuntimeException('Second operation cancelled', 42);
139+
});
140+
});
141+
142+
$promise->cancel();
143+
144+
$promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Second operation cancelled', 42)));
145+
}
146+
147+
public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorReturns()
148+
{
149+
if (class_exists('React\Promise\When')) {
150+
$this->markTestSkipped('Not supported on legacy Promise v1 API');
151+
}
152+
153+
gc_collect_cycles();
154+
gc_collect_cycles();
155+
156+
$promise = coroutine(function () {
157+
if (false) {
158+
yield;
159+
}
160+
return 42;
161+
});
162+
163+
unset($promise);
164+
165+
$this->assertEquals(0, gc_collect_cycles());
166+
}
167+
168+
public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithExceptionImmediately()
169+
{
170+
if (class_exists('React\Promise\When')) {
171+
$this->markTestSkipped('Not supported on legacy Promise v1 API');
172+
}
173+
174+
gc_collect_cycles();
175+
176+
$promise = coroutine(function () {
177+
yield new Promise(function () {
178+
throw new \RuntimeException('Failed', 42);
179+
});
180+
});
181+
182+
unset($promise);
183+
184+
$this->assertEquals(0, gc_collect_cycles());
185+
}
186+
187+
public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithExceptionOnCancellation()
188+
{
189+
if (class_exists('React\Promise\When')) {
190+
$this->markTestSkipped('Not supported on legacy Promise v1 API');
191+
}
192+
193+
gc_collect_cycles();
194+
195+
$promise = coroutine(function () {
196+
yield new Promise(function () { }, function () {
197+
throw new \RuntimeException('Operation cancelled', 42);
198+
});
199+
});
200+
201+
$promise->cancel();
202+
unset($promise);
203+
204+
$this->assertEquals(0, gc_collect_cycles());
205+
}
206+
207+
public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorThrowsBeforeFirstYield()
208+
{
209+
if (class_exists('React\Promise\When')) {
210+
$this->markTestSkipped('Not supported on legacy Promise v1 API');
211+
}
212+
213+
gc_collect_cycles();
214+
215+
$promise = coroutine(function () {
216+
throw new \RuntimeException('Failed', 42);
217+
yield;
218+
});
219+
220+
unset($promise);
221+
222+
$this->assertEquals(0, gc_collect_cycles());
223+
}
224+
225+
public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorYieldsInvalidValue()
226+
{
227+
if (class_exists('React\Promise\When')) {
228+
$this->markTestSkipped('Not supported on legacy Promise v1 API');
229+
}
230+
231+
gc_collect_cycles();
232+
233+
$promise = coroutine(function () {
234+
yield 42;
235+
});
236+
237+
unset($promise);
238+
239+
$this->assertEquals(0, gc_collect_cycles());
240+
}
107241
}

0 commit comments

Comments
 (0)