Skip to content

Commit 7824c42

Browse files
committed
Add coroutine()
1 parent dad206b commit 7824c42

File tree

2 files changed

+167
-0
lines changed

2 files changed

+167
-0
lines changed

src/functions.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use React\EventLoop\Loop;
66
use React\Promise\Deferred;
77
use React\Promise\PromiseInterface;
8+
use function React\Promise\reject;
89

910
/**
1011
* Block waiting for the given `$promise` to be fulfilled.
@@ -90,6 +91,65 @@ function ($error) use (&$exception, &$rejected, &$wait) {
9091
return $resolved;
9192
}
9293

94+
95+
/**
96+
*
97+
* @template T
98+
* @param callable(...$args):\Generator<mixed,PromiseInterface,mixed,T> $coroutine
99+
* @param mixed ...$args
100+
* @return PromiseInterface<T>
101+
*/
102+
function coroutine(callable $coroutine, ...$args): PromiseInterface
103+
{
104+
try {
105+
$generator = $coroutine(...$args);
106+
} catch (\Throwable $e) {
107+
return reject($e);
108+
}
109+
110+
if (!$generator instanceof \Generator) {
111+
return reject(new \UnexpectedValueException(
112+
'Expected coroutine to return ' . \Generator::class . ', but got ' . (is_object($generator) ? get_class($generator) : gettype($generator))
113+
));
114+
}
115+
116+
$deferred = new Deferred();
117+
118+
/** @var callable $next */
119+
$next = function () use ($deferred, $generator, &$next) {
120+
try {
121+
if (!$generator->valid()) {
122+
$deferred->resolve($generator->getReturn());
123+
return;
124+
}
125+
} catch (\Throwable $e) {
126+
$deferred->reject($e);
127+
return;
128+
}
129+
130+
$promise = $generator->current();
131+
if (!$promise instanceof PromiseInterface) {
132+
$deferred->reject(new \UnexpectedValueException(
133+
'Expected coroutine to yield ' . PromiseInterface::class . ', but got ' . (is_object($promise) ? get_class($promise) : gettype($promise))
134+
));
135+
return;
136+
}
137+
138+
$promise->then(function ($value) use ($generator, $next) {
139+
$generator->send($value);
140+
$next();
141+
}, function (\Throwable $reason) use ($generator, $next) {
142+
$generator->throw($reason);
143+
$next();
144+
})->then(null, function (\Throwable $reason) use ($deferred) {
145+
$deferred->reject($reason);
146+
});
147+
};
148+
$next();
149+
150+
return $deferred->promise();
151+
}
152+
93153
/**
94154
* @param array<callable():PromiseInterface<mixed,Exception>> $tasks
95155
* @return PromiseInterface<array<mixed>,Exception>

tests/CoroutineTest.php

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
namespace React\Tests\Async;
4+
5+
use function React\Async\coroutine;
6+
use function React\Promise\reject;
7+
use function React\Promise\resolve;
8+
9+
class CoroutineTest extends TestCase
10+
{
11+
public function testCoroutineReturnsFulfilledPromiseIfCoroutineReturnsImmediately()
12+
{
13+
$promise = coroutine(function () {
14+
if (false) {
15+
yield;
16+
}
17+
return 42;
18+
});
19+
20+
$promise->then($this->expectCallableOnceWith(42));
21+
}
22+
23+
public function testCoroutineReturnsFulfilledPromiseIfCoroutineReturnsAfterYieldingPromise()
24+
{
25+
$promise = coroutine(function () {
26+
$value = yield resolve(42);
27+
return $value;
28+
});
29+
30+
$promise->then($this->expectCallableOnceWith(42));
31+
}
32+
33+
public function testCoroutineReturnsRejectedPromiseIfCoroutineThrowsWithoutGenerator()
34+
{
35+
$promise = coroutine(function () {
36+
throw new \RuntimeException('Foo');
37+
});
38+
39+
$promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo')));
40+
}
41+
42+
public function testCoroutineReturnsRejectedPromiseIfCoroutineThrowsImmediately()
43+
{
44+
$promise = coroutine(function () {
45+
if (false) {
46+
yield;
47+
}
48+
throw new \RuntimeException('Foo');
49+
});
50+
51+
$promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo')));
52+
}
53+
54+
public function testCoroutineReturnsRejectedPromiseIfCoroutineThrowsAfterYieldingPromise()
55+
{
56+
$promise = coroutine(function () {
57+
$reason = yield resolve('Foo');
58+
throw new \RuntimeException($reason);
59+
});
60+
61+
$promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo')));
62+
}
63+
64+
public function testCoroutineReturnsRejectedPromiseIfCoroutineThrowsAfterYieldingRejectedPromise()
65+
{
66+
$promise = coroutine(function () {
67+
try {
68+
yield reject(new \OverflowException('Foo'));
69+
} catch (\OverflowException $e) {
70+
throw new \RuntimeException($e->getMessage());
71+
}
72+
});
73+
74+
$promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo')));
75+
}
76+
77+
public function testCoroutineReturnsFulfilledPromiseIfCoroutineReturnsAfterYieldingRejectedPromise()
78+
{
79+
$promise = coroutine(function () {
80+
try {
81+
yield reject(new \OverflowException('Foo', 42));
82+
} catch (\OverflowException $e) {
83+
return $e->getCode();
84+
}
85+
});
86+
87+
$promise->then($this->expectCallableOnceWith(42));
88+
}
89+
90+
public function testCoroutineReturnsRejectedPromiseIfGeneratorFunctionReturnsInvalidValue()
91+
{
92+
$promise = coroutine(function () {
93+
return 42;
94+
});
95+
96+
$promise->then(null, $this->expectCallableOnceWith(new \UnexpectedValueException('Expected coroutine to return Generator, but got integer')));
97+
}
98+
99+
public function testCoroutineReturnsRejectedPromiseIfGeneratorFunctionYieldsInvalidValue()
100+
{
101+
$promise = coroutine(function () {
102+
yield 42;
103+
});
104+
105+
$promise->then(null, $this->expectCallableOnceWith(new \UnexpectedValueException('Expected coroutine to yield React\Promise\PromiseInterface, but got integer')));
106+
}
107+
}

0 commit comments

Comments
 (0)