diff --git a/README.md b/README.md index 2869657..d4a1c96 100644 --- a/README.md +++ b/README.md @@ -63,14 +63,8 @@ $result = React\Async\await($promise); ``` This function will only return after the given `$promise` has settled, i.e. -either fulfilled or rejected. - -While the promise is pending, this function will assume control over the event -loop. Internally, it will `run()` the [default loop](https://github.com/reactphp/event-loop#loop) -until the promise settles and then calls `stop()` to terminate execution of the -loop. This means this function is more suited for short-lived promise executions -when using promise-based APIs is not feasible. For long-running applications, -using promise-based APIs by leveraging chained `then()` calls is usually preferable. +either fulfilled or rejected. While the promise is pending, this function will +suspend the fiber it's called from until the promise is settled. Once the promise is fulfilled, this function will return whatever the promise resolved to. diff --git a/composer.json b/composer.json index 45e183a..d749726 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,9 @@ "phpunit/phpunit": "^9.3" }, "autoload": { + "psr-4": { + "React\\Async\\": "src/" + }, "files": [ "src/functions_include.php" ] diff --git a/src/FiberFactory.php b/src/FiberFactory.php new file mode 100644 index 0000000..93480e6 --- /dev/null +++ b/src/FiberFactory.php @@ -0,0 +1,33 @@ + new SimpleFiber(); + } +} diff --git a/src/FiberInterface.php b/src/FiberInterface.php new file mode 100644 index 0000000..e1ba086 --- /dev/null +++ b/src/FiberInterface.php @@ -0,0 +1,23 @@ +fiber = \Fiber::getCurrent(); + } + + public function resume(mixed $value): void + { + if ($this->fiber === null) { + Loop::futureTick(static fn() => \Fiber::suspend(static fn() => $value)); + return; + } + + Loop::futureTick(fn() => $this->fiber->resume($value)); + } + + public function throw(mixed $throwable): void + { + if (!$throwable instanceof \Throwable) { + $throwable = new \UnexpectedValueException( + 'Promise rejected with unexpected value of type ' . (is_object($throwable) ? get_class($throwable) : gettype($throwable)) + ); + } + + if ($this->fiber === null) { + Loop::futureTick(static fn() => \Fiber::suspend(static fn() => throw $throwable)); + return; + } + + Loop::futureTick(fn() => $this->fiber->throw($throwable)); + } + + public function suspend(): mixed + { + if ($this->fiber === null) { + if (self::$scheduler === null || self::$scheduler->isTerminated()) { + self::$scheduler = new \Fiber(static fn() => Loop::run()); + // Run event loop to completion on shutdown. + \register_shutdown_function(static function (): void { + if (self::$scheduler->isSuspended()) { + self::$scheduler->resume(); + } + }); + } + + return (self::$scheduler->isStarted() ? self::$scheduler->resume() : self::$scheduler->start())(); + } + + return \Fiber::suspend(); + } +} diff --git a/src/functions.php b/src/functions.php index 45c8116..c25fa05 100644 --- a/src/functions.php +++ b/src/functions.php @@ -5,10 +5,36 @@ use React\EventLoop\Loop; use React\Promise\CancellablePromiseInterface; use React\Promise\Deferred; +use React\Promise\Promise; use React\Promise\PromiseInterface; use function React\Promise\reject; use function React\Promise\resolve; +/** + * Execute an async Fiber-based function to "await" promises. + * + * @param callable(mixed ...$args):mixed $function + * @param mixed ...$args Optional list of additional arguments that will be passed to the given `$function` as is + * @return PromiseInterface + * @since 4.0.0 + * @see coroutine() + */ +function async(callable $function, mixed ...$args): PromiseInterface +{ + return new Promise(function (callable $resolve, callable $reject) use ($function, $args): void { + $fiber = new \Fiber(function () use ($resolve, $reject, $function, $args): void { + try { + $resolve($function(...$args)); + } catch (\Throwable $exception) { + $reject($exception); + } + }); + + Loop::futureTick(static fn() => $fiber->start()); + }); +} + + /** * Block waiting for the given `$promise` to be fulfilled. * @@ -52,48 +78,20 @@ */ function await(PromiseInterface $promise): mixed { - $wait = true; - $resolved = null; - $exception = null; - $rejected = false; + $fiber = FiberFactory::create(); $promise->then( - function ($c) use (&$resolved, &$wait) { - $resolved = $c; - $wait = false; - Loop::stop(); + function (mixed $value) use (&$resolved, $fiber): void { + $fiber->resume($value); }, - function ($error) use (&$exception, &$rejected, &$wait) { - $exception = $error; - $rejected = true; - $wait = false; - Loop::stop(); + function (mixed $throwable) use (&$resolved, $fiber): void { + $fiber->throw($throwable); } ); - // Explicitly overwrite argument with null value. This ensure that this - // argument does not show up in the stack trace in PHP 7+ only. - $promise = null; - - while ($wait) { - Loop::run(); - } - - if ($rejected) { - // promise is rejected with an unexpected value (Promise API v1 or v2 only) - if (!$exception instanceof \Throwable) { - $exception = new \UnexpectedValueException( - 'Promise rejected with unexpected value of type ' . (is_object($exception) ? get_class($exception) : gettype($exception)) - ); - } - - throw $exception; - } - - return $resolved; + return $fiber->suspend(); } - /** * Execute a Generator-based coroutine to "await" promises. * diff --git a/tests/AsyncTest.php b/tests/AsyncTest.php new file mode 100644 index 0000000..de75a27 --- /dev/null +++ b/tests/AsyncTest.php @@ -0,0 +1,87 @@ +then($this->expectCallableNever(), $this->expectCallableNever()); + } + + public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturns() + { + $promise = async(function () { + return 42; + }); + + $value = await($promise); + + $this->assertEquals(42, $value); + } + + public function testAsyncReturnsPromiseThatRejectsWithExceptionWhenCallbackThrows() + { + $promise = async(function () { + throw new \RuntimeException('Foo', 42); + }); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Foo'); + $this->expectExceptionCode(42); + await($promise); + } + + public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsAfterAwaitingPromise() + { + $promise = async(function () { + $promise = new Promise(function ($resolve) { + Loop::addTimer(0.001, fn () => $resolve(42)); + }); + + return await($promise); + }); + + $value = await($promise); + + $this->assertEquals(42, $value); + } + + public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsAfterAwaitingTwoConcurrentPromises() + { + $promise1 = async(function () { + $promise = new Promise(function ($resolve) { + Loop::addTimer(0.11, fn () => $resolve(21)); + }); + + return await($promise); + }); + + $promise2 = async(function () { + $promise = new Promise(function ($resolve) { + Loop::addTimer(0.11, fn () => $resolve(42)); + }); + + return await($promise); + }); + + $time = microtime(true); + $values = await(all([$promise1, $promise2])); + $time = microtime(true) - $time; + + $this->assertEquals([21, 42], $values); + $this->assertGreaterThan(0.1, $time); + $this->assertLessThan(0.12, $time); + } +} diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index 95a8b5f..0be7a11 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -8,7 +8,10 @@ class AwaitTest extends TestCase { - public function testAwaitThrowsExceptionWhenPromiseIsRejectedWithException() + /** + * @dataProvider provideAwaiters + */ + public function testAwaitThrowsExceptionWhenPromiseIsRejectedWithException(callable $await) { $promise = new Promise(function () { throw new \Exception('test'); @@ -16,10 +19,13 @@ public function testAwaitThrowsExceptionWhenPromiseIsRejectedWithException() $this->expectException(\Exception::class); $this->expectExceptionMessage('test'); - React\Async\await($promise); + $await($promise); } - public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithFalse() + /** + * @dataProvider provideAwaiters + */ + public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithFalse(callable $await) { if (!interface_exists('React\Promise\CancellablePromiseInterface')) { $this->markTestSkipped('Promises must be rejected with a \Throwable instance since Promise v3'); @@ -31,10 +37,13 @@ public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWith $this->expectException(\UnexpectedValueException::class); $this->expectExceptionMessage('Promise rejected with unexpected value of type bool'); - React\Async\await($promise); + $await($promise); } - public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithNull() + /** + * @dataProvider provideAwaiters + */ + public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithNull(callable $await) { if (!interface_exists('React\Promise\CancellablePromiseInterface')) { $this->markTestSkipped('Promises must be rejected with a \Throwable instance since Promise v3'); @@ -46,10 +55,13 @@ public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWith $this->expectException(\UnexpectedValueException::class); $this->expectExceptionMessage('Promise rejected with unexpected value of type NULL'); - React\Async\await($promise); + $await($promise); } - public function testAwaitThrowsErrorWhenPromiseIsRejectedWithError() + /** + * @dataProvider provideAwaiters + */ + public function testAwaitThrowsErrorWhenPromiseIsRejectedWithError(callable $await) { $promise = new Promise(function ($_, $reject) { throw new \Error('Test', 42); @@ -58,33 +70,25 @@ public function testAwaitThrowsErrorWhenPromiseIsRejectedWithError() $this->expectException(\Error::class); $this->expectExceptionMessage('Test'); $this->expectExceptionCode(42); - React\Async\await($promise); + $await($promise); } - public function testAwaitReturnsValueWhenPromiseIsFullfilled() + /** + * @dataProvider provideAwaiters + */ + public function testAwaitReturnsValueWhenPromiseIsFullfilled(callable $await) { $promise = new Promise(function ($resolve) { $resolve(42); }); - $this->assertEquals(42, React\Async\await($promise)); + $this->assertEquals(42, $await($promise)); } - public function testAwaitReturnsValueWhenPromiseIsFulfilledEvenWhenOtherTimerStopsLoop() - { - $promise = new Promise(function ($resolve) { - Loop::addTimer(0.02, function () use ($resolve) { - $resolve(2); - }); - }); - Loop::addTimer(0.01, function () { - Loop::stop(); - }); - - $this->assertEquals(2, React\Async\await($promise)); - } - - public function testAwaitShouldNotCreateAnyGarbageReferencesForResolvedPromise() + /** + * @dataProvider provideAwaiters + */ + public function testAwaitShouldNotCreateAnyGarbageReferencesForResolvedPromise(callable $await) { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -95,13 +99,16 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForResolvedPromise() $promise = new Promise(function ($resolve) { $resolve(42); }); - React\Async\await($promise); + $await($promise); unset($promise); $this->assertEquals(0, gc_collect_cycles()); } - public function testAwaitShouldNotCreateAnyGarbageReferencesForRejectedPromise() + /** + * @dataProvider provideAwaiters + */ + public function testAwaitShouldNotCreateAnyGarbageReferencesForRejectedPromise(callable $await) { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -113,7 +120,7 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForRejectedPromise() throw new \RuntimeException(); }); try { - React\Async\await($promise); + $await($promise); } catch (\Exception $e) { // no-op } @@ -122,7 +129,10 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForRejectedPromise() $this->assertEquals(0, gc_collect_cycles()); } - public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithNullValue() + /** + * @dataProvider provideAwaiters + */ + public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithNullValue(callable $await) { if (!interface_exists('React\Promise\CancellablePromiseInterface')) { $this->markTestSkipped('Promises must be rejected with a \Throwable instance since Promise v3'); @@ -138,7 +148,7 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWi $reject(null); }); try { - React\Async\await($promise); + $await($promise); } catch (\Exception $e) { // no-op } @@ -146,4 +156,10 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWi $this->assertEquals(0, gc_collect_cycles()); } + + public function provideAwaiters(): iterable + { + yield 'await' => [static fn (React\Promise\PromiseInterface $promise): mixed => React\Async\await($promise)]; + yield 'async' => [static fn (React\Promise\PromiseInterface $promise): mixed => React\Async\await(React\Async\async(static fn(): mixed => $promise))]; + } }