From 6f7f05b745de5aa44bb92c680bf43e6e1a332c1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 5 Nov 2021 21:07:09 +0100 Subject: [PATCH 1/3] Add Fiber-based `await()` function --- README.md | 10 ++----- composer.json | 3 +++ src/SimpleFiber.php | 64 +++++++++++++++++++++++++++++++++++++++++++++ src/functions.php | 41 +++++------------------------ tests/AwaitTest.php | 14 ---------- 5 files changed, 76 insertions(+), 56 deletions(-) create mode 100644 src/SimpleFiber.php 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/SimpleFiber.php b/src/SimpleFiber.php new file mode 100644 index 0000000..5c1c50c --- /dev/null +++ b/src/SimpleFiber.php @@ -0,0 +1,64 @@ +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..08146b0 100644 --- a/src/functions.php +++ b/src/functions.php @@ -5,6 +5,7 @@ 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; @@ -52,48 +53,20 @@ */ function await(PromiseInterface $promise): mixed { - $wait = true; - $resolved = null; - $exception = null; - $rejected = false; + $fiber = new SimpleFiber(); $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/AwaitTest.php b/tests/AwaitTest.php index 95a8b5f..cf8088b 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -70,20 +70,6 @@ public function testAwaitReturnsValueWhenPromiseIsFullfilled() $this->assertEquals(42, React\Async\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() { if (class_exists('React\Promise\When')) { From 984382f722ff9a44143dacbe022c65b2f3cd0bfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 5 Nov 2021 22:33:18 +0100 Subject: [PATCH 2/3] Add Fiber-based `async()` function --- src/functions.php | 25 +++++++++++++ tests/AsyncTest.php | 87 +++++++++++++++++++++++++++++++++++++++++++++ tests/AwaitTest.php | 62 +++++++++++++++++++++++--------- 3 files changed, 158 insertions(+), 16 deletions(-) create mode 100644 tests/AsyncTest.php diff --git a/src/functions.php b/src/functions.php index 08146b0..21d60c6 100644 --- a/src/functions.php +++ b/src/functions.php @@ -10,6 +10,31 @@ 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. * 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 cf8088b..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,19 +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 testAwaitShouldNotCreateAnyGarbageReferencesForResolvedPromise() + /** + * @dataProvider provideAwaiters + */ + public function testAwaitShouldNotCreateAnyGarbageReferencesForResolvedPromise(callable $await) { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -81,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'); @@ -99,7 +120,7 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForRejectedPromise() throw new \RuntimeException(); }); try { - React\Async\await($promise); + $await($promise); } catch (\Exception $e) { // no-op } @@ -108,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'); @@ -124,7 +148,7 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWi $reject(null); }); try { - React\Async\await($promise); + $await($promise); } catch (\Exception $e) { // no-op } @@ -132,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))]; + } } From 145ed6a63fb1c8b43147c1de0dcd5a6e406ab20d Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Sun, 21 Nov 2021 23:22:19 +0100 Subject: [PATCH 3/3] Add fiber interoperability support While there is no technical need to add this for this, we also don't want to block other projects creating adapters. However, this interoperability support is undocumented and as such unsupported. Use at your own risk. --- src/FiberFactory.php | 33 +++++++++++++++++++++++++++++++++ src/FiberInterface.php | 23 +++++++++++++++++++++++ src/SimpleFiber.php | 2 +- src/functions.php | 2 +- 4 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 src/FiberFactory.php create mode 100644 src/FiberInterface.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 @@ +then( function (mixed $value) use (&$resolved, $fiber): void {