From 249f9f6d3d44ff07df0ffcaebd98848b365360ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 7 Oct 2021 07:05:53 +0200 Subject: [PATCH 01/41] Require PHP 8.1+ and add `mixed` type declarations --- .gitattributes | 1 - .github/workflows/ci.yml | 8 -------- README.md | 10 ++++++---- composer.json | 4 ++-- phpunit.xml.dist | 1 - phpunit.xml.legacy | 18 ------------------ src/functions.php | 4 ++-- 7 files changed, 10 insertions(+), 36 deletions(-) delete mode 100644 phpunit.xml.legacy diff --git a/.gitattributes b/.gitattributes index 21be40c..aa6c312 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,5 +2,4 @@ /.github/ export-ignore /.gitignore export-ignore /phpunit.xml.dist export-ignore -/phpunit.xml.legacy export-ignore /tests/ export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b1b36b..1b83b36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,11 +12,6 @@ jobs: matrix: php: - 8.1 - - 8.0 - - 7.4 - - 7.3 - - 7.2 - - 7.1 steps: - uses: actions/checkout@v2 - uses: shivammathur/setup-php@v2 @@ -25,6 +20,3 @@ jobs: coverage: xdebug - run: composer install - run: vendor/bin/phpunit --coverage-text - if: ${{ matrix.php >= 7.3 }} - - run: vendor/bin/phpunit --coverage-text -c phpunit.xml.legacy - if: ${{ matrix.php < 7.3 }} diff --git a/README.md b/README.md index 522bef6..2869657 100644 --- a/README.md +++ b/README.md @@ -338,14 +338,16 @@ $ composer require react/async:dev-main See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. This project aims to run on any platform and thus does not require any PHP -extensions and supports running on PHP 7.1 through current PHP 8+. +extensions and supports running on PHP 8.1+. It's *highly recommended to use the latest supported PHP version* for this project. We're committed to providing long-term support (LTS) options and to provide a smooth upgrade path. If you're using an older PHP version, you may use the -[`2.x` branch](https://github.com/reactphp/async/tree/2.x) which provides a -compatible API but does not take advantage of newer language features. You may -target both versions at the same time to support a wider range of PHP versions. +[`3.x` branch](https://github.com/reactphp/async/tree/3.x) (PHP 7.1+) or +[`2.x` branch](https://github.com/reactphp/async/tree/2.x) (PHP 5.3+) which both +provide a compatible API but do not take advantage of newer language features. +You may target multiple versions at the same time to support a wider range of +PHP versions. ## Tests diff --git a/composer.json b/composer.json index a839932..45e183a 100644 --- a/composer.json +++ b/composer.json @@ -26,12 +26,12 @@ } ], "require": { - "php": ">=7.1", + "php": ">=8.1", "react/event-loop": "^1.2", "react/promise": "^2.8 || ^1.2.1" }, "require-dev": { - "phpunit/phpunit": "^9.3 || ^7.5" + "phpunit/phpunit": "^9.3" }, "autoload": { "files": [ diff --git a/phpunit.xml.dist b/phpunit.xml.dist index fa88e7e..f4b5805 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,6 +1,5 @@ - - - - - - - ./tests/ - - - - - ./src/ - - - diff --git a/src/functions.php b/src/functions.php index ad91688..45c8116 100644 --- a/src/functions.php +++ b/src/functions.php @@ -50,7 +50,7 @@ * @throws \Throwable when the promise is rejected with a `Throwable` * @throws \UnexpectedValueException when the promise is rejected with an unexpected value (Promise API v1 or v2 only) */ -function await(PromiseInterface $promise) +function await(PromiseInterface $promise): mixed { $wait = true; $resolved = null; @@ -212,7 +212,7 @@ function ($error) use (&$exception, &$rejected, &$wait) { * @return PromiseInterface * @since 3.0.0 */ -function coroutine(callable $function, ...$args): PromiseInterface +function coroutine(callable $function, mixed ...$args): PromiseInterface { try { $generator = $function(...$args); 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 02/41] 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 03/41] 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 04/41] 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 { From 4355fcf8a3bdabfdc5a39362851db6ad3f441cbf Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Mon, 20 Dec 2021 17:42:48 +0100 Subject: [PATCH 05/41] Make `async` return a callable By making this change `async()` can now also by used in event loop callbacks such as timers: ```php Loop::addTimer(0.01, async(function () { echo 'Sleeping for one second'; await(asleep(1)); echo 'Waking up again'; })); ``` With this change, current `async()` usage changes from: ```php async(function () { // }); ``` To: ```php async(function () { // })(); ``` --- src/functions.php | 7 +++---- tests/AsyncTest.php | 18 +++++++++--------- tests/AwaitTest.php | 2 +- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/functions.php b/src/functions.php index c25fa05..f003db1 100644 --- a/src/functions.php +++ b/src/functions.php @@ -14,14 +14,13 @@ * 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 + * @return callable(): PromiseInterface * @since 4.0.0 * @see coroutine() */ -function async(callable $function, mixed ...$args): PromiseInterface +function async(callable $function): callable { - return new Promise(function (callable $resolve, callable $reject) use ($function, $args): void { + return static fn (mixed ...$args): PromiseInterface => 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)); diff --git a/tests/AsyncTest.php b/tests/AsyncTest.php index de75a27..ad856cb 100644 --- a/tests/AsyncTest.php +++ b/tests/AsyncTest.php @@ -15,7 +15,7 @@ public function testAsyncReturnsPendingPromise() { $promise = async(function () { return 42; - }); + })(); $promise->then($this->expectCallableNever(), $this->expectCallableNever()); } @@ -24,7 +24,7 @@ public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturns( { $promise = async(function () { return 42; - }); + })(); $value = await($promise); @@ -35,7 +35,7 @@ public function testAsyncReturnsPromiseThatRejectsWithExceptionWhenCallbackThrow { $promise = async(function () { throw new \RuntimeException('Foo', 42); - }); + })(); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Foo'); @@ -51,7 +51,7 @@ public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsA }); return await($promise); - }); + })(); $value = await($promise); @@ -66,15 +66,15 @@ public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsA }); return await($promise); - }); + })(); - $promise2 = async(function () { - $promise = new Promise(function ($resolve) { - Loop::addTimer(0.11, fn () => $resolve(42)); + $promise2 = async(function (int $theAnswerToLifeTheUniverseAndEverything): int { + $promise = new Promise(function ($resolve) use ($theAnswerToLifeTheUniverseAndEverything): void { + Loop::addTimer(0.11, fn () => $resolve($theAnswerToLifeTheUniverseAndEverything)); }); return await($promise); - }); + })(42); $time = microtime(true); $values = await(all([$promise1, $promise2])); diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index 0be7a11..782f6da 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -160,6 +160,6 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWi 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))]; + yield 'async' => [static fn (React\Promise\PromiseInterface $promise): mixed => React\Async\await(React\Async\async(static fn(): mixed => $promise)())]; } } From 546cb73e385b2bcc42f47072c2ce50569ffd0e3d Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Mon, 13 Dec 2021 18:43:47 +0100 Subject: [PATCH 06/41] Fast forward resolved/rejected promises with fibers await This makes `await`ing an already resolved promise significantly faster. --- src/FiberInterface.php | 2 +- src/SimpleFiber.php | 8 +------- src/functions.php | 38 +++++++++++++++++++++++++++++++++++--- tests/AwaitTest.php | 30 ++++++++++++++++++++++++++++++ 4 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/FiberInterface.php b/src/FiberInterface.php index e1ba086..e40304e 100644 --- a/src/FiberInterface.php +++ b/src/FiberInterface.php @@ -17,7 +17,7 @@ interface FiberInterface { public function resume(mixed $value): void; - public function throw(mixed $throwable): void; + public function throw(\Throwable $throwable): void; public function suspend(): mixed; } diff --git a/src/SimpleFiber.php b/src/SimpleFiber.php index 9586c0d..f45e628 100644 --- a/src/SimpleFiber.php +++ b/src/SimpleFiber.php @@ -27,14 +27,8 @@ public function resume(mixed $value): void Loop::futureTick(fn() => $this->fiber->resume($value)); } - public function throw(mixed $throwable): void + public function throw(\Throwable $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; diff --git a/src/functions.php b/src/functions.php index f003db1..05300e2 100644 --- a/src/functions.php +++ b/src/functions.php @@ -77,17 +77,49 @@ function async(callable $function): callable */ function await(PromiseInterface $promise): mixed { - $fiber = FiberFactory::create(); + $fiber = null; + $resolved = false; + $rejected = false; + $resolvedValue = null; + $rejectedThrowable = null; $promise->then( - function (mixed $value) use (&$resolved, $fiber): void { + function (mixed $value) use (&$resolved, &$resolvedValue, &$fiber): void { + if ($fiber === null) { + $resolved = true; + $resolvedValue = $value; + return; + } + $fiber->resume($value); }, - function (mixed $throwable) use (&$resolved, $fiber): void { + function (mixed $throwable) use (&$rejected, &$rejectedThrowable, &$fiber): void { + if (!$throwable instanceof \Throwable) { + $throwable = new \UnexpectedValueException( + 'Promise rejected with unexpected value of type ' . (is_object($throwable) ? get_class($throwable) : gettype($throwable)) + ); + } + + if ($fiber === null) { + $rejected = true; + $rejectedThrowable = $throwable; + return; + } + $fiber->throw($throwable); } ); + if ($resolved) { + return $resolvedValue; + } + + if ($rejected) { + throw $rejectedThrowable; + } + + $fiber = FiberFactory::create(); + return $fiber->suspend(); } diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index 782f6da..1bb61e0 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -157,6 +157,36 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWi $this->assertEquals(0, gc_collect_cycles()); } + /** + * @dataProvider provideAwaiters + */ + public function testAlreadyFulfilledPromiseShouldNotSuspendFiber(callable $await) + { + for ($i = 0; $i < 6; $i++) { + $this->assertSame($i, $await(React\Promise\resolve($i))); + } + } + + /** + * @dataProvider provideAwaiters + */ + public function testNestedAwaits(callable $await) + { + $this->assertTrue($await(new Promise(function ($resolve) use ($await) { + $resolve($await(new Promise(function ($resolve) use ($await) { + $resolve($await(new Promise(function ($resolve) use ($await) { + $resolve($await(new Promise(function ($resolve) use ($await) { + $resolve($await(new Promise(function ($resolve) use ($await) { + Loop::addTimer(0.01, function () use ($resolve) { + $resolve(true); + }); + }))); + }))); + }))); + }))); + }))); + } + public function provideAwaiters(): iterable { yield 'await' => [static fn (React\Promise\PromiseInterface $promise): mixed => React\Async\await($promise)]; From 32483f4b51da6830d63dc0d2f83fcdf3cab2abc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 25 Jan 2022 15:59:58 +0100 Subject: [PATCH 07/41] Add documentation for `async()` function and Fiber-based `await()` --- README.md | 171 +++++++++++++++++++++++++++++++++++++++++++-- src/functions.php | 173 ++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 327 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index d4a1c96..03130a9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Async +# Async Utilities [![CI status](https://github.com/reactphp/async/workflows/CI/badge.svg)](https://github.com/reactphp/async/actions) @@ -16,6 +16,7 @@ an event loop, it can be used with this library. **Table of Contents** * [Usage](#usage) + * [async()](#async) * [await()](#await) * [coroutine()](#coroutine) * [parallel()](#parallel) @@ -53,6 +54,146 @@ use React\Async; Async\await(…); ``` +### async() + +The `async(callable $function): callable` function can be used to +return an async function for a function that uses [`await()`](#await) internally. + +This function is specifically designed to complement the [`await()` function](#await). +The [`await()` function](#await) can be considered *blocking* from the +perspective of the calling code. You can avoid this blocking behavior by +wrapping it in an `async()` function call. Everything inside this function +will still be blocked, but everything outside this function can be executed +asynchronously without blocking: + +```php +Loop::addTimer(0.5, React\Async\async(function() { + echo 'a'; + React\async\await(React\Promise\Timer\sleep(1.0)); + echo 'c'; +})); + +Loop::addTimer(1.0, fn() => echo 'b'); + +// prints "a" at t=0.5s +// prints "b" at t=1.0s +// prints "c" at t=1.5s +``` + +See also the [`await()` function](#await) for more details. + +Note that this function only works in tandem with the [`await()` function](#await). +In particular, this function does not "magically" make any blocking function +non-blocking: + +```php +Loop::addTimer(0.5, React\Async\async(function() { + echo 'a'; + sleep(1); // broken: using PHP's blocking sleep() for demonstration purposes + echo 'c'; +})); + +Loop::addTimer(1.0, fn() => echo 'b'); + +// prints "a" at t=0.5s +// prints "c" at t=1.5s: Correct timing, but wrong order +// prints "b" at t=1.5s: Triggered too late because it was blocked +``` + +As an alternative, you should always make sure to use this function in tandem +with the [`await()` function](#await) and an async API returning a promise +as shown in the previous example. + +The `async()` function is specifically designed for cases where it is used +as a callback (such as an event loop timer, event listener, or promise +callback). For this reason, it returns a new function wrapping the given +`$function` instead of directly invoking it and returning its value. + +```php +use function React\Async\async; + +Loop::addTimer(1.0, async(function () { … })); +$connection->on('close', async(function () { … })); +$stream->on('data', async(function ($data) { … })); +$promise->then(async(function (int $result) { … })); +``` + +You can invoke this wrapping function to invoke the given `$function` with +any arguments given as-is. The function will always return a Promise which +will be fulfilled with whatever your `$function` returns. Likewise, it will +return a promise that will be rejected if you throw an `Exception` or +`Throwable` from your `$function`. This allows you to easily create +Promise-based functions: + +```php +$promise = React\Async\async(function (): int { + $browser = new React\Http\Browser(); + $urls = [ + 'https://example.com/alice', + 'https://example.com/bob' + ]; + + $bytes = 0; + foreach ($urls as $url) { + $response = React\Async\await($browser->get($url)); + assert($response instanceof Psr\Http\Message\ResponseInterface); + $bytes += $response->getBody()->getSize(); + } + return $bytes; +})(); + +$promise->then(function (int $bytes) { + echo 'Total size: ' . $bytes . PHP_EOL; +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +The previous example uses [`await()`](#await) inside a loop to highlight how +this vastly simplifies consuming asynchronous operations. At the same time, +this naive example does not leverage concurrent execution, as it will +essentially "await" between each operation. In order to take advantage of +concurrent execution within the given `$function`, you can "await" multiple +promises by using a single [`await()`](#await) together with Promise-based +primitives like this: + +```php +$promise = React\Async\async(function (): int { + $browser = new React\Http\Browser(); + $urls = [ + 'https://example.com/alice', + 'https://example.com/bob' + ]; + + $promises = []; + foreach ($urls as $url) { + $promises[] = $browser->get($url); + } + + try { + $responses = React\Async\await(React\Promise\all($promises)); + } catch (Exception $e) { + foreach ($promises as $promise) { + $promise->cancel(); + } + throw $e; + } + + $bytes = 0; + foreach ($responses as $response) { + assert($response instanceof Psr\Http\Message\ResponseInterface); + $bytes += $response->getBody()->getSize(); + } + return $bytes; +})(); + +$promise->then(function (int $bytes) { + echo 'Total size: ' . $bytes . PHP_EOL; +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + ### await() The `await(PromiseInterface $promise): mixed` function can be used to @@ -63,8 +204,27 @@ $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 -suspend the fiber it's called from until the promise is settled. +either fulfilled or rejected. While the promise is pending, this function +can be considered *blocking* from the perspective of the calling code. +You can avoid this blocking behavior by wrapping it in an [`async()` function](#async) +call. Everything inside this function will still be blocked, but everything +outside this function can be executed asynchronously without blocking: + +```php +Loop::addTimer(0.5, React\Async\async(function() { + echo 'a'; + React\async\await(React\Promise\Timer\sleep(1.0)); + echo 'c'; +})); + +Loop::addTimer(1.0, fn() => echo 'b'); + +// prints "a" at t=0.5s +// prints "b" at t=1.0s +// prints "c" at t=1.5s +``` + +See also the [`async()` function](#async) for more details. Once the promise is fulfilled, this function will return whatever the promise resolved to. @@ -125,10 +285,11 @@ when the promise is fulfilled. The `yield` statement returns whatever the promise is fulfilled with. If the promise is rejected, it will throw an `Exception` or `Throwable`. -The `coroutine()` function will always return a Proimise which will be +The `coroutine()` function will always return a Promise which will be fulfilled with whatever your `$function` returns. Likewise, it will return a promise that will be rejected if you throw an `Exception` or `Throwable` -from your `$function`. This allows you easily create Promise-based functions: +from your `$function`. This allows you to easily create Promise-based +functions: ```php $promise = React\Async\coroutine(function () { diff --git a/src/functions.php b/src/functions.php index 05300e2..ac350e5 100644 --- a/src/functions.php +++ b/src/functions.php @@ -11,7 +11,142 @@ use function React\Promise\resolve; /** - * Execute an async Fiber-based function to "await" promises. + * Return an async function for a function that uses [`await()`](#await) internally. + * + * This function is specifically designed to complement the [`await()` function](#await). + * The [`await()` function](#await) can be considered *blocking* from the + * perspective of the calling code. You can avoid this blocking behavior by + * wrapping it in an `async()` function call. Everything inside this function + * will still be blocked, but everything outside this function can be executed + * asynchronously without blocking: + * + * ```php + * Loop::addTimer(0.5, React\Async\async(function() { + * echo 'a'; + * React\async\await(React\Promise\Timer\sleep(1.0)); + * echo 'c'; + * })); + * + * Loop::addTimer(1.0, fn() => echo 'b'); + * + * // prints "a" at t=0.5s + * // prints "b" at t=1.0s + * // prints "c" at t=1.5s + * ``` + * + * See also the [`await()` function](#await) for more details. + * + * Note that this function only works in tandem with the [`await()` function](#await). + * In particular, this function does not "magically" make any blocking function + * non-blocking: + * + * ```php + * Loop::addTimer(0.5, React\Async\async(function() { + * echo 'a'; + * sleep(1); // broken: using PHP's blocking sleep() for demonstration purposes + * echo 'c'; + * })); + * + * Loop::addTimer(1.0, fn() => echo 'b'); + * + * // prints "a" at t=0.5s + * // prints "c" at t=1.5s: Correct timing, but wrong order + * // prints "b" at t=1.5s: Triggered too late because it was blocked + * ``` + * + * As an alternative, you should always make sure to use this function in tandem + * with the [`await()` function](#await) and an async API returning a promise + * as shown in the previous example. + * + * The `async()` function is specifically designed for cases where it is used + * as a callback (such as an event loop timer, event listener, or promise + * callback). For this reason, it returns a new function wrapping the given + * `$function` instead of directly invoking it and returning its value. + * + * ```php + * use function React\Async\async; + * + * Loop::addTimer(1.0, async(function () { … })); + * $connection->on('close', async(function () { … })); + * $stream->on('data', async(function ($data) { … })); + * $promise->then(async(function (int $result) { … })); + * ``` + * + * You can invoke this wrapping function to invoke the given `$function` with + * any arguments given as-is. The function will always return a Promise which + * will be fulfilled with whatever your `$function` returns. Likewise, it will + * return a promise that will be rejected if you throw an `Exception` or + * `Throwable` from your `$function`. This allows you to easily create + * Promise-based functions: + * + * ```php + * $promise = React\Async\async(function (): int { + * $browser = new React\Http\Browser(); + * $urls = [ + * 'https://example.com/alice', + * 'https://example.com/bob' + * ]; + * + * $bytes = 0; + * foreach ($urls as $url) { + * $response = React\Async\await($browser->get($url)); + * assert($response instanceof Psr\Http\Message\ResponseInterface); + * $bytes += $response->getBody()->getSize(); + * } + * return $bytes; + * })(); + * + * $promise->then(function (int $bytes) { + * echo 'Total size: ' . $bytes . PHP_EOL; + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * The previous example uses [`await()`](#await) inside a loop to highlight how + * this vastly simplifies consuming asynchronous operations. At the same time, + * this naive example does not leverage concurrent execution, as it will + * essentially "await" between each operation. In order to take advantage of + * concurrent execution within the given `$function`, you can "await" multiple + * promises by using a single [`await()`](#await) together with Promise-based + * primitives like this: + * + * ```php + * $promise = React\Async\async(function (): int { + * $browser = new React\Http\Browser(); + * $urls = [ + * 'https://example.com/alice', + * 'https://example.com/bob' + * ]; + * + * $promises = []; + * foreach ($urls as $url) { + * $promises[] = $browser->get($url); + * } + * + * try { + * $responses = React\Async\await(React\Promise\all($promises)); + * } catch (Exception $e) { + * foreach ($promises as $promise) { + * $promise->cancel(); + * } + * throw $e; + * } + * + * $bytes = 0; + * foreach ($responses as $response) { + * assert($response instanceof Psr\Http\Message\ResponseInterface); + * $bytes += $response->getBody()->getSize(); + * } + * return $bytes; + * })(); + * + * $promise->then(function (int $bytes) { + * echo 'Total size: ' . $bytes . PHP_EOL; + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` * * @param callable(mixed ...$args):mixed $function * @return callable(): PromiseInterface @@ -38,18 +173,31 @@ function async(callable $function): callable * Block waiting for the given `$promise` to be fulfilled. * * ```php - * $result = React\Async\await($promise, $loop); + * $result = React\Async\await($promise); * ``` * * This function will only return after the given `$promise` has settled, i.e. - * either fulfilled or rejected. + * either fulfilled or rejected. While the promise is pending, this function + * can be considered *blocking* from the perspective of the calling code. + * You can avoid this blocking behavior by wrapping it in an [`async()` function](#async) + * call. Everything inside this function will still be blocked, but everything + * outside this function can be executed asynchronously without blocking: + * + * ```php + * Loop::addTimer(0.5, React\Async\async(function() { + * echo 'a'; + * React\async\await(React\Promise\Timer\sleep(1.0)); + * echo 'c'; + * })); + * + * Loop::addTimer(1.0, fn() => echo 'b'); + * + * // prints "a" at t=0.5s + * // prints "b" at t=1.0s + * // prints "c" at t=1.5s + * ``` * - * 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. + * See also the [`async()` function](#async) for more details. * * Once the promise is fulfilled, this function will return whatever the promise * resolved to. @@ -60,7 +208,7 @@ function async(callable $function): callable * * ```php * try { - * $result = React\Async\await($promise, $loop); + * $result = React\Async\await($promise); * // promise successfully fulfilled with $result * echo 'Result: ' . $result; * } catch (Throwable $e) { @@ -162,10 +310,11 @@ function (mixed $throwable) use (&$rejected, &$rejectedThrowable, &$fiber): void * promise is fulfilled with. If the promise is rejected, it will throw an * `Exception` or `Throwable`. * - * The `coroutine()` function will always return a Proimise which will be + * The `coroutine()` function will always return a Promise which will be * fulfilled with whatever your `$function` returns. Likewise, it will return * a promise that will be rejected if you throw an `Exception` or `Throwable` - * from your `$function`. This allows you easily create Promise-based functions: + * from your `$function`. This allows you to easily create Promise-based + * functions: * * ```php * $promise = React\Async\coroutine(function () { From f02bfcb1a1eebadf8c3eed201fc2fc169bc3887a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 10 Feb 2022 10:01:17 +0100 Subject: [PATCH 08/41] Clean up any garbage references when awaiting rejected promise --- src/functions.php | 19 +++++++++++++++++++ tests/AwaitTest.php | 12 +++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/functions.php b/src/functions.php index ac350e5..cbd19fb 100644 --- a/src/functions.php +++ b/src/functions.php @@ -246,6 +246,25 @@ function (mixed $throwable) use (&$rejected, &$rejectedThrowable, &$fiber): void $throwable = new \UnexpectedValueException( 'Promise rejected with unexpected value of type ' . (is_object($throwable) ? get_class($throwable) : gettype($throwable)) ); + + // avoid garbage references by replacing all closures in call stack. + // what a lovely piece of code! + $r = new \ReflectionProperty('Exception', 'trace'); + $trace = $r->getValue($throwable); + + // Exception trace arguments only available when zend.exception_ignore_args is not set + // @codeCoverageIgnoreStart + foreach ($trace as $ti => $one) { + if (isset($one['args'])) { + foreach ($one['args'] as $ai => $arg) { + if ($arg instanceof \Closure) { + $trace[$ti]['args'][$ai] = 'Object(' . \get_class($arg) . ')'; + } + } + } + } + // @codeCoverageIgnoreEnd + $r->setValue($throwable, $trace); } if ($fiber === null) { diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index 1bb61e0..2bf1314 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -53,9 +53,15 @@ public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWith $reject(null); }); - $this->expectException(\UnexpectedValueException::class); - $this->expectExceptionMessage('Promise rejected with unexpected value of type NULL'); - $await($promise); + try { + $await($promise); + } catch (\UnexpectedValueException $exception) { + $this->assertInstanceOf(\UnexpectedValueException::class, $exception); + $this->assertEquals('Promise rejected with unexpected value of type NULL', $exception->getMessage()); + $this->assertEquals(0, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + $this->assertNotEquals('', $exception->getTraceAsString()); + } } /** From 166b144fb028f08b1ea0b3047d6686757c9fb964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 12 Feb 2022 11:40:40 +0100 Subject: [PATCH 09/41] Rename `main` branch to `4.x` and update installation instructions --- README.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 03130a9..838b63c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,16 @@ to have an actual event loop and non-blocking libraries interacting with that event loop for it to work. As long as you have a Promise-based API that runs in an event loop, it can be used with this library. +> **Development version:** This branch contains the code for the upcoming 4.0 +> release which will be the way forward for this package. However, we will still +> actively support 3.0 and 2.0 for those not yet on PHP 8.1+. +> +> If you're using an older PHP version, you may use the +> [`3.x` branch](https://github.com/reactphp/async/tree/3.x) (PHP 7.1+) or +> [`2.x` branch](https://github.com/reactphp/async/tree/2.x) (PHP 5.3+) which both +> provide a compatible API but do not take advantage of newer language features. +> See also [installation instructions](#install) for more details. + **Table of Contents** * [Usage](#usage) @@ -487,7 +497,7 @@ Once released, this project will follow [SemVer](https://semver.org/). At the moment, this will install the latest development version: ```bash -$ composer require react/async:dev-main +$ composer require react/async:^4@dev ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. @@ -502,7 +512,11 @@ smooth upgrade path. If you're using an older PHP version, you may use the [`2.x` branch](https://github.com/reactphp/async/tree/2.x) (PHP 5.3+) which both provide a compatible API but do not take advantage of newer language features. You may target multiple versions at the same time to support a wider range of -PHP versions. +PHP versions like this: + +```bash +$ composer require "react/async:^4@dev || ^3@dev || ^2@dev" +``` ## Tests From ce2379f147a4f103fbca7b4ef09a48781f549dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 10 Feb 2022 14:06:41 +0100 Subject: [PATCH 10/41] Improve `async()` to avoid unneeded `futureTick()` calls --- src/functions.php | 2 +- tests/AsyncTest.php | 59 ++++++++++++++++++++++++++++++++++++++------- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/functions.php b/src/functions.php index cbd19fb..e016853 100644 --- a/src/functions.php +++ b/src/functions.php @@ -164,7 +164,7 @@ function async(callable $function): callable } }); - Loop::futureTick(static fn() => $fiber->start()); + $fiber->start(); }); } diff --git a/tests/AsyncTest.php b/tests/AsyncTest.php index ad856cb..dccbe54 100644 --- a/tests/AsyncTest.php +++ b/tests/AsyncTest.php @@ -8,25 +8,35 @@ use function React\Async\async; use function React\Async\await; use function React\Promise\all; +use function React\Promise\reject; +use function React\Promise\resolve; class AsyncTest extends TestCase { - public function testAsyncReturnsPendingPromise() + public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsValue() { $promise = async(function () { return 42; })(); - $promise->then($this->expectCallableNever(), $this->expectCallableNever()); + $value = null; + $promise->then(function ($v) use (&$value) { + $value = $v; + }); + + $this->assertEquals(42, $value); } - public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturns() + public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsPromiseThatFulfillsWithValue() { $promise = async(function () { - return 42; + return resolve(42); })(); - $value = await($promise); + $value = null; + $promise->then(function ($v) use (&$value) { + $value = $v; + }); $this->assertEquals(42, $value); } @@ -37,10 +47,41 @@ public function testAsyncReturnsPromiseThatRejectsWithExceptionWhenCallbackThrow throw new \RuntimeException('Foo', 42); })(); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Foo'); - $this->expectExceptionCode(42); - await($promise); + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Foo', $exception->getMessage()); + $this->assertEquals(42, $exception->getCode()); + } + + public function testAsyncReturnsPromiseThatRejectsWithExceptionWhenCallbackReturnsPromiseThatRejectsWithException() + { + $promise = async(function () { + return reject(new \RuntimeException('Foo', 42)); + })(); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + assert($exception instanceof \RuntimeException); + $this->assertInstanceOf(\RuntimeException::class, $exception); + $this->assertEquals('Foo', $exception->getMessage()); + $this->assertEquals(42, $exception->getCode()); + } + + public function testAsyncReturnsPendingPromiseWhenCallbackReturnsPendingPromise() + { + $promise = async(function () { + return new Promise(function () { }); + })(); + + $promise->then($this->expectCallableNever(), $this->expectCallableNever()); } public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsAfterAwaitingPromise() From 27f0027f2737d9fa11c0d304de93a154dd16cff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 10 Feb 2022 16:29:45 +0100 Subject: [PATCH 11/41] Improve `await()` in `async()` to avoid unneeded `futureTick()` calls --- src/SimpleFiber.php | 4 ++-- tests/AsyncTest.php | 44 ++++++++++++++++++++++++++++++++++++++++++++ tests/AwaitTest.php | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/SimpleFiber.php b/src/SimpleFiber.php index f45e628..e7b866a 100644 --- a/src/SimpleFiber.php +++ b/src/SimpleFiber.php @@ -24,7 +24,7 @@ public function resume(mixed $value): void return; } - Loop::futureTick(fn() => $this->fiber->resume($value)); + $this->fiber->resume($value); } public function throw(\Throwable $throwable): void @@ -34,7 +34,7 @@ public function throw(\Throwable $throwable): void return; } - Loop::futureTick(fn() => $this->fiber->throw($throwable)); + $this->fiber->throw($throwable); } public function suspend(): mixed diff --git a/tests/AsyncTest.php b/tests/AsyncTest.php index dccbe54..82f98f0 100644 --- a/tests/AsyncTest.php +++ b/tests/AsyncTest.php @@ -4,6 +4,7 @@ use React; use React\EventLoop\Loop; +use React\Promise\Deferred; use React\Promise\Promise; use function React\Async\async; use function React\Async\await; @@ -84,6 +85,49 @@ public function testAsyncReturnsPendingPromiseWhenCallbackReturnsPendingPromise( $promise->then($this->expectCallableNever(), $this->expectCallableNever()); } + public function testAsyncWithAwaitReturnsReturnsPromiseFulfilledWithValueImmediatelyWhenPromiseIsFulfilled() + { + $deferred = new Deferred(); + + $promise = async(function () use ($deferred) { + return await($deferred->promise()); + })(); + + $return = null; + $promise->then(function ($value) use (&$return) { + $return = $value; + }); + + $this->assertNull($return); + + $deferred->resolve(42); + + $this->assertEquals(42, $return); + } + + public function testAsyncWithAwaitReturnsPromiseRejectedWithExceptionImmediatelyWhenPromiseIsRejected() + { + $deferred = new Deferred(); + + $promise = async(function () use ($deferred) { + return await($deferred->promise()); + })(); + + $exception = null; + $promise->then(null, function ($reason) use (&$exception) { + $exception = $reason; + }); + + $this->assertNull($exception); + + $deferred->reject(new \RuntimeException('Test', 42)); + + $this->assertInstanceof(\RuntimeException::class, $exception); + assert($exception instanceof \RuntimeException); + $this->assertEquals('Test', $exception->getMessage()); + $this->assertEquals(42, $exception->getCode()); + } + public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsAfterAwaitingPromise() { $promise = async(function () { diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index 2bf1314..6ef938f 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -22,6 +22,27 @@ public function testAwaitThrowsExceptionWhenPromiseIsRejectedWithException(calla $await($promise); } + /** + * @dataProvider provideAwaiters + */ + public function testAwaitThrowsExceptionWithoutRunningLoop(callable $await) + { + $now = true; + Loop::futureTick(function () use (&$now) { + $now = false; + }); + + $promise = new Promise(function () { + throw new \Exception('test'); + }); + + try { + $await($promise); + } catch (\Exception $e) { + $this->assertTrue($now); + } + } + /** * @dataProvider provideAwaiters */ @@ -91,6 +112,24 @@ public function testAwaitReturnsValueWhenPromiseIsFullfilled(callable $await) $this->assertEquals(42, $await($promise)); } + /** + * @dataProvider provideAwaiters + */ + public function testAwaitReturnsValueImmediatelyWithoutRunningLoop(callable $await) + { + $now = true; + Loop::futureTick(function () use (&$now) { + $now = false; + }); + + $promise = new Promise(function ($resolve) { + $resolve(42); + }); + + $this->assertEquals(42, $await($promise)); + $this->assertTrue($now); + } + /** * @dataProvider provideAwaiters */ From 8f01f4b777a39bd02a021a3df3eeb2604a8e889d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Wed, 16 Feb 2022 17:52:43 +0100 Subject: [PATCH 12/41] Improve `await()` in main to avoid unneeded `futureTick()` calls --- src/SimpleFiber.php | 12 ++++++++++-- tests/AsyncTest.php | 16 ++++++++++++++++ tests/AwaitTest.php | 46 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/SimpleFiber.php b/src/SimpleFiber.php index e7b866a..0aa272a 100644 --- a/src/SimpleFiber.php +++ b/src/SimpleFiber.php @@ -20,7 +20,11 @@ public function __construct() public function resume(mixed $value): void { if ($this->fiber === null) { - Loop::futureTick(static fn() => \Fiber::suspend(static fn() => $value)); + if (\Fiber::getCurrent() !== self::$scheduler) { + Loop::futureTick(static fn() => \Fiber::suspend(static fn() => $value)); + } else { + \Fiber::suspend(static fn() => $value); + } return; } @@ -30,7 +34,11 @@ public function resume(mixed $value): void public function throw(\Throwable $throwable): void { if ($this->fiber === null) { - Loop::futureTick(static fn() => \Fiber::suspend(static fn() => throw $throwable)); + if (\Fiber::getCurrent() !== self::$scheduler) { + Loop::futureTick(static fn() => \Fiber::suspend(static fn() => throw $throwable)); + } else { + \Fiber::suspend(static fn() => throw $throwable); + } return; } diff --git a/tests/AsyncTest.php b/tests/AsyncTest.php index 82f98f0..a4287fd 100644 --- a/tests/AsyncTest.php +++ b/tests/AsyncTest.php @@ -143,6 +143,22 @@ public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsA $this->assertEquals(42, $value); } + public function testAsyncReturnsPromiseThatRejectsWithExceptionWhenCallbackThrowsAfterAwaitingPromise() + { + $promise = async(function () { + $promise = new Promise(function ($_, $reject) { + Loop::addTimer(0.001, fn () => $reject(new \RuntimeException('Foo', 42))); + }); + + return await($promise); + })(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Foo'); + $this->expectExceptionCode(42); + await($promise); + } + public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsAfterAwaitingTwoConcurrentPromises() { $promise1 = async(function () { diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index 6ef938f..c055332 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -4,6 +4,7 @@ use React; use React\EventLoop\Loop; +use React\Promise\Deferred; use React\Promise\Promise; class AwaitTest extends TestCase @@ -43,6 +44,30 @@ public function testAwaitThrowsExceptionWithoutRunningLoop(callable $await) } } + /** + * @dataProvider provideAwaiters + */ + public function testAwaitThrowsExceptionImmediatelyWhenPromiseIsRejected(callable $await) + { + $deferred = new Deferred(); + + $ticks = 0; + Loop::futureTick(function () use (&$ticks) { + ++$ticks; + Loop::futureTick(function () use (&$ticks) { + ++$ticks; + }); + }); + + Loop::futureTick(fn() => $deferred->reject(new \RuntimeException())); + + try { + $await($deferred->promise()); + } catch (\RuntimeException $e) { + $this->assertEquals(1, $ticks); + } + } + /** * @dataProvider provideAwaiters */ @@ -130,6 +155,27 @@ public function testAwaitReturnsValueImmediatelyWithoutRunningLoop(callable $awa $this->assertTrue($now); } + /** + * @dataProvider provideAwaiters + */ + public function testAwaitReturnsValueImmediatelyWhenPromiseIsFulfilled(callable $await) + { + $deferred = new Deferred(); + + $ticks = 0; + Loop::futureTick(function () use (&$ticks) { + ++$ticks; + Loop::futureTick(function () use (&$ticks) { + ++$ticks; + }); + }); + + Loop::futureTick(fn() => $deferred->resolve(42)); + + $this->assertEquals(42, $await($deferred->promise())); + $this->assertEquals(1, $ticks); + } + /** * @dataProvider provideAwaiters */ From c5d53ee64a3ca8b3f1128ba4ada578e4cef17ac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 17 Feb 2022 08:53:23 +0100 Subject: [PATCH 13/41] Improve `await()` for `asyc()` to avoid unneeded `futureTick()` calls --- src/SimpleFiber.php | 25 +++++++++++++++++---- tests/AwaitTest.php | 54 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/SimpleFiber.php b/src/SimpleFiber.php index 0aa272a..ed20e2a 100644 --- a/src/SimpleFiber.php +++ b/src/SimpleFiber.php @@ -10,6 +10,7 @@ final class SimpleFiber implements FiberInterface { private static ?\Fiber $scheduler = null; + private static ?\Closure $suspend = null; private ?\Fiber $fiber = null; public function __construct() @@ -20,29 +21,45 @@ public function __construct() public function resume(mixed $value): void { if ($this->fiber === null) { + $suspend = static fn() => $value; if (\Fiber::getCurrent() !== self::$scheduler) { - Loop::futureTick(static fn() => \Fiber::suspend(static fn() => $value)); + self::$suspend = $suspend; } else { - \Fiber::suspend(static fn() => $value); + \Fiber::suspend($suspend); } return; } $this->fiber->resume($value); + + if (self::$suspend) { + $suspend = self::$suspend; + self::$suspend = null; + + \Fiber::suspend($suspend); + } } public function throw(\Throwable $throwable): void { if ($this->fiber === null) { + $suspend = static fn() => throw $throwable; if (\Fiber::getCurrent() !== self::$scheduler) { - Loop::futureTick(static fn() => \Fiber::suspend(static fn() => throw $throwable)); + self::$suspend = $suspend; } else { - \Fiber::suspend(static fn() => throw $throwable); + \Fiber::suspend($suspend); } return; } $this->fiber->throw($throwable); + + if (self::$suspend) { + $suspend = self::$suspend; + self::$suspend = null; + + \Fiber::suspend($suspend); + } } public function suspend(): mixed diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index c055332..2dd8159 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -6,6 +6,7 @@ use React\EventLoop\Loop; use React\Promise\Deferred; use React\Promise\Promise; +use function React\Async\async; class AwaitTest extends TestCase { @@ -68,6 +69,34 @@ public function testAwaitThrowsExceptionImmediatelyWhenPromiseIsRejected(callabl } } + /** + * @dataProvider provideAwaiters + */ + public function testAwaitAsyncThrowsExceptionImmediatelyWhenPromiseIsRejected(callable $await) + { + $deferred = new Deferred(); + + $ticks = 0; + Loop::futureTick(function () use (&$ticks) { + ++$ticks; + Loop::futureTick(function () use (&$ticks) { + ++$ticks; + }); + }); + + Loop::futureTick(fn() => $deferred->reject(new \RuntimeException())); + + $promise = async(function () use ($deferred, $await) { + return $await($deferred->promise()); + })(); + + try { + $await($promise); + } catch (\RuntimeException $e) { + $this->assertEquals(1, $ticks); + } + } + /** * @dataProvider provideAwaiters */ @@ -176,6 +205,31 @@ public function testAwaitReturnsValueImmediatelyWhenPromiseIsFulfilled(callable $this->assertEquals(1, $ticks); } + /** + * @dataProvider provideAwaiters + */ + public function testAwaitAsyncReturnsValueImmediatelyWhenPromiseIsFulfilled(callable $await) + { + $deferred = new Deferred(); + + $ticks = 0; + Loop::futureTick(function () use (&$ticks) { + ++$ticks; + Loop::futureTick(function () use (&$ticks) { + ++$ticks; + }); + }); + + Loop::futureTick(fn() => $deferred->resolve(42)); + + $promise = async(function () use ($deferred, $await) { + return $await($deferred->promise()); + })(); + + $this->assertEquals(42, $await($promise)); + $this->assertEquals(1, $ticks); + } + /** * @dataProvider provideAwaiters */ From 4d8331fbfabc0e37cdfae898684da497fbf89ea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 17 Feb 2022 10:45:00 +0100 Subject: [PATCH 14/41] Refactor `SimpleFiber` to simplify async code flow --- src/SimpleFiber.php | 32 ++++++++++---------------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/src/SimpleFiber.php b/src/SimpleFiber.php index ed20e2a..acf3fad 100644 --- a/src/SimpleFiber.php +++ b/src/SimpleFiber.php @@ -20,19 +20,13 @@ public function __construct() public function resume(mixed $value): void { - if ($this->fiber === null) { - $suspend = static fn() => $value; - if (\Fiber::getCurrent() !== self::$scheduler) { - self::$suspend = $suspend; - } else { - \Fiber::suspend($suspend); - } - return; + if ($this->fiber !== null) { + $this->fiber->resume($value); + } else { + self::$suspend = static fn() => $value; } - $this->fiber->resume($value); - - if (self::$suspend) { + if (self::$suspend !== null && \Fiber::getCurrent() === self::$scheduler) { $suspend = self::$suspend; self::$suspend = null; @@ -42,19 +36,13 @@ public function resume(mixed $value): void public function throw(\Throwable $throwable): void { - if ($this->fiber === null) { - $suspend = static fn() => throw $throwable; - if (\Fiber::getCurrent() !== self::$scheduler) { - self::$suspend = $suspend; - } else { - \Fiber::suspend($suspend); - } - return; + if ($this->fiber !== null) { + $this->fiber->throw($throwable); + } else { + self::$suspend = static fn() => throw $throwable; } - $this->fiber->throw($throwable); - - if (self::$suspend) { + if (self::$suspend !== null && \Fiber::getCurrent() === self::$scheduler) { $suspend = self::$suspend; self::$suspend = null; From 262ef5919b7c51671a494ef4189e61c723d9fdd3 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Mon, 20 Dec 2021 23:31:35 +0100 Subject: [PATCH 15/41] Improve `async()` by making its promises cancelable Since `async()` returns a promise and those are normally cancelable, implementing this puts them in line with the rest of our ecosystem. As such the following example will throw a timeout exception from the canceled `sleep()` call. ```php $promise = async(static function (): int { echo 'a'; await(sleep(2)); echo 'b'; return time(); })(); $promise->cancel(); await($promise); ```` This builds on top of #15, #18, #19, #26, #28, #30, and #32. --- README.md | 23 ++++++++++ composer.json | 3 +- src/FiberMap.php | 55 +++++++++++++++++++++++ src/functions.php | 82 +++++++++++++++++++++++++++++----- tests/AsyncTest.php | 105 ++++++++++++++++++++++++++++++++++++++++++++ tests/AwaitTest.php | 36 +++++++++++++++ 6 files changed, 293 insertions(+), 11 deletions(-) create mode 100644 src/FiberMap.php diff --git a/README.md b/README.md index 838b63c..d5f8bb4 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,29 @@ $promise->then(function (int $bytes) { }); ``` +Promises returned by `async()` can be cancelled, and when done any currently and future awaited promise inside that and +any nested fibers with their awaited promises will also be cancelled. As such the following example will only output +`ab` as the [`sleep()`](https://reactphp.org/promise-timer/#sleep) between `a` and `b` is cancelled throwing a timeout +exception that bubbles up through the fibers ultimately to the end user through the [`await()`](#await) on the last line +of the example. + +```php +$promise = async(static function (): int { + echo 'a'; + await(async(static function(): void { + echo 'b'; + await(sleep(2)); + echo 'c'; + })()); + echo 'd'; + + return time(); +})(); + +$promise->cancel(); +await($promise); +``` + ### await() The `await(PromiseInterface $promise): mixed` function can be used to diff --git a/composer.json b/composer.json index d749726..5d93ed4 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,8 @@ "react/promise": "^2.8 || ^1.2.1" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^9.3", + "react/promise-timer": "^1.8" }, "autoload": { "psr-4": { diff --git a/src/FiberMap.php b/src/FiberMap.php new file mode 100644 index 0000000..7ee07d1 --- /dev/null +++ b/src/FiberMap.php @@ -0,0 +1,55 @@ +cancel(); + * await($promise); + * ``` + * * @param callable(mixed ...$args):mixed $function * @return callable(): PromiseInterface * @since 4.0.0 @@ -155,17 +180,37 @@ */ function async(callable $function): callable { - return static fn (mixed ...$args): PromiseInterface => 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); + return static function (mixed ...$args) use ($function): PromiseInterface { + $fiber = null; + $promise = new Promise(function (callable $resolve, callable $reject) use ($function, $args, &$fiber): void { + $fiber = new \Fiber(function () use ($resolve, $reject, $function, $args, &$fiber): void { + try { + $resolve($function(...$args)); + } catch (\Throwable $exception) { + $reject($exception); + } finally { + FiberMap::unregister($fiber); + } + }); + + FiberMap::register($fiber); + + $fiber->start(); + }, function () use (&$fiber): void { + FiberMap::cancel($fiber); + $promise = FiberMap::getPromise($fiber); + if ($promise instanceof CancellablePromiseInterface) { + $promise->cancel(); } }); - $fiber->start(); - }); + $lowLevelFiber = \Fiber::getCurrent(); + if ($lowLevelFiber !== null) { + FiberMap::setPromise($lowLevelFiber, $promise); + } + + return $promise; + }; } @@ -230,9 +275,18 @@ function await(PromiseInterface $promise): mixed $rejected = false; $resolvedValue = null; $rejectedThrowable = null; + $lowLevelFiber = \Fiber::getCurrent(); + + if ($lowLevelFiber !== null && FiberMap::isCancelled($lowLevelFiber) && $promise instanceof CancellablePromiseInterface) { + $promise->cancel(); + } $promise->then( - function (mixed $value) use (&$resolved, &$resolvedValue, &$fiber): void { + function (mixed $value) use (&$resolved, &$resolvedValue, &$fiber, $lowLevelFiber, $promise): void { + if ($lowLevelFiber !== null) { + FiberMap::unsetPromise($lowLevelFiber, $promise); + } + if ($fiber === null) { $resolved = true; $resolvedValue = $value; @@ -241,7 +295,11 @@ function (mixed $value) use (&$resolved, &$resolvedValue, &$fiber): void { $fiber->resume($value); }, - function (mixed $throwable) use (&$rejected, &$rejectedThrowable, &$fiber): void { + function (mixed $throwable) use (&$rejected, &$rejectedThrowable, &$fiber, $lowLevelFiber, $promise): void { + if ($lowLevelFiber !== null) { + FiberMap::unsetPromise($lowLevelFiber, $promise); + } + if (!$throwable instanceof \Throwable) { $throwable = new \UnexpectedValueException( 'Promise rejected with unexpected value of type ' . (is_object($throwable) ? get_class($throwable) : gettype($throwable)) @@ -285,6 +343,10 @@ function (mixed $throwable) use (&$rejected, &$rejectedThrowable, &$fiber): void throw $rejectedThrowable; } + if ($lowLevelFiber !== null) { + FiberMap::setPromise($lowLevelFiber, $promise); + } + $fiber = FiberFactory::create(); return $fiber->suspend(); diff --git a/tests/AsyncTest.php b/tests/AsyncTest.php index a4287fd..0d85302 100644 --- a/tests/AsyncTest.php +++ b/tests/AsyncTest.php @@ -11,6 +11,7 @@ use function React\Promise\all; use function React\Promise\reject; use function React\Promise\resolve; +use function React\Promise\Timer\sleep; class AsyncTest extends TestCase { @@ -185,4 +186,108 @@ public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsA $this->assertGreaterThan(0.1, $time); $this->assertLessThan(0.12, $time); } + + public function testCancel() + { + self::expectOutputString('a'); + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Timer cancelled'); + + $promise = async(static function (): int { + echo 'a'; + await(sleep(2)); + echo 'b'; + + return time(); + })(); + + $promise->cancel(); + await($promise); + } + + public function testCancelTryCatch() + { + self::expectOutputString('ab'); + + $promise = async(static function (): int { + echo 'a'; + try { + await(sleep(2)); + } catch (\Throwable) { + // No-Op + } + echo 'b'; + + return time(); + })(); + + $promise->cancel(); + await($promise); + } + + public function testNestedCancel() + { + self::expectOutputString('abc'); + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Timer cancelled'); + + $promise = async(static function (): int { + echo 'a'; + await(async(static function(): void { + echo 'b'; + await(async(static function(): void { + echo 'c'; + await(sleep(2)); + echo 'd'; + })()); + echo 'e'; + })()); + echo 'f'; + + return time(); + })(); + + $promise->cancel(); + await($promise); + } + + public function testCancelFiberThatCatchesExceptions() + { + self::expectOutputString('ab'); + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Timer cancelled'); + + $promise = async(static function (): int { + echo 'a'; + try { + await(sleep(2)); + } catch (\Throwable) { + // No-Op + } + echo 'b'; + await(sleep(0.1)); + echo 'c'; + + return time(); + })(); + + $promise->cancel(); + await($promise); + } + + public function testNotAwaitedPromiseWillNotBeCanceled() + { + self::expectOutputString('acb'); + + async(static function (): int { + echo 'a'; + sleep(0.001)->then(static function (): void { + echo 'b'; + }); + echo 'c'; + + return time(); + })()->cancel(); + Loop::run(); + } } diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index 2dd8159..f2faceb 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -332,6 +332,42 @@ public function testNestedAwaits(callable $await) }))); } + /** + * @dataProvider provideAwaiters + */ + public function testResolvedPromisesShouldBeDetached(callable $await) + { + $await(async(function () use ($await): int { + $fiber = \Fiber::getCurrent(); + $await(React\Promise\Timer\sleep(0.01)); + $this->assertNull(React\Async\FiberMap::getPromise($fiber)); + + return time(); + })()); + } + + /** + * @dataProvider provideAwaiters + */ + public function testRejectedPromisesShouldBeDetached(callable $await) + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Boom!'); + + $await(async(function () use ($await): int { + $fiber = \Fiber::getCurrent(); + try { + $await(React\Promise\reject(new \Exception('Boom!'))); + } catch (\Throwable $throwable) { + throw $throwable; + } finally { + $this->assertNull(React\Async\FiberMap::getPromise($fiber)); + } + + return time(); + })()); + } + public function provideAwaiters(): iterable { yield 'await' => [static fn (React\Promise\PromiseInterface $promise): mixed => React\Async\await($promise)]; From b7242c6d06ac4d883360c2f363ffd59a232431ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Fri, 4 Mar 2022 11:50:32 +0100 Subject: [PATCH 16/41] Skip cancellation of promise within fiber without cancellation support --- src/FiberMap.php | 2 +- tests/AwaitTest.php | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/FiberMap.php b/src/FiberMap.php index 7ee07d1..5c7c7bf 100644 --- a/src/FiberMap.php +++ b/src/FiberMap.php @@ -25,7 +25,7 @@ public static function cancel(\Fiber $fiber): void public static function isCancelled(\Fiber $fiber): bool { - return self::$status[\spl_object_id($fiber)]; + return self::$status[\spl_object_id($fiber)] ?? false; } public static function setPromise(\Fiber $fiber, PromiseInterface $promise): void diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index f2faceb..93a69f8 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -97,6 +97,27 @@ public function testAwaitAsyncThrowsExceptionImmediatelyWhenPromiseIsRejected(ca } } + /** + * @dataProvider provideAwaiters + */ + public function testAwaitThrowsExceptionImmediatelyInCustomFiberWhenPromiseIsRejected(callable $await) + { + $fiber = new \Fiber(function () use ($await) { + $promise = new Promise(function ($resolve) { + throw new \RuntimeException('Test'); + }); + + return $await($promise); + }); + + try { + $fiber->start(); + } catch (\RuntimeException $e) { + $this->assertTrue($fiber->isTerminated()); + $this->assertEquals('Test', $e->getMessage()); + } + } + /** * @dataProvider provideAwaiters */ @@ -230,6 +251,25 @@ public function testAwaitAsyncReturnsValueImmediatelyWhenPromiseIsFulfilled(call $this->assertEquals(1, $ticks); } + /** + * @dataProvider provideAwaiters + */ + public function testAwaitReturnsValueImmediatelyInCustomFiberWhenPromiseIsFulfilled(callable $await) + { + $fiber = new \Fiber(function () use ($await) { + $promise = new Promise(function ($resolve) { + $resolve(42); + }); + + return $await($promise); + }); + + $fiber->start(); + + $this->assertTrue($fiber->isTerminated()); + $this->assertEquals(42, $fiber->getReturn()); + } + /** * @dataProvider provideAwaiters */ From 74544b07e744c0ca74046293a3b1818a75f49815 Mon Sep 17 00:00:00 2001 From: Simon Frings Date: Fri, 18 Mar 2022 10:49:59 +0100 Subject: [PATCH 17/41] Add badge to show number of project installations --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d5f8bb4..380cc5f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Async Utilities [![CI status](https://github.com/reactphp/async/workflows/CI/badge.svg)](https://github.com/reactphp/async/actions) +[![installs on Packagist](https://img.shields.io/packagist/dt/react/async?color=blue&label=installs%20on%20Packagist)](https://packagist.org/packages/react/async) Async utilities for [ReactPHP](https://reactphp.org/). From a58b1797782f896351b6f6f7e3572b4d124dd54e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 5 Jun 2022 19:39:31 +0200 Subject: [PATCH 18/41] Support `iterable` type for `parallel()` + `series()` + `waterfall()` --- README.md | 6 ++--- src/functions.php | 24 ++++++++++++++----- tests/ParallelTest.php | 45 +++++++++++++++++++++++++++++++++++ tests/SeriesTest.php | 45 +++++++++++++++++++++++++++++++++++ tests/WaterfallTest.php | 52 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 163 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 380cc5f..405486d 100644 --- a/README.md +++ b/README.md @@ -396,7 +396,7 @@ $promise->then(function (int $bytes) { ### parallel() -The `parallel(array> $tasks): PromiseInterface,Exception>` function can be used +The `parallel(iterable> $tasks): PromiseInterface,Exception>` function can be used like this: ```php @@ -438,7 +438,7 @@ React\Async\parallel([ ### series() -The `series(array> $tasks): PromiseInterface,Exception>` function can be used +The `series(iterable> $tasks): PromiseInterface,Exception>` function can be used like this: ```php @@ -480,7 +480,7 @@ React\Async\series([ ### waterfall() -The `waterfall(array> $tasks): PromiseInterface` function can be used +The `waterfall(iterable> $tasks): PromiseInterface` function can be used like this: ```php diff --git a/src/functions.php b/src/functions.php index c39d56d..64ab364 100644 --- a/src/functions.php +++ b/src/functions.php @@ -533,10 +533,10 @@ function coroutine(callable $function, mixed ...$args): PromiseInterface } /** - * @param array> $tasks + * @param iterable> $tasks * @return PromiseInterface,Exception> */ -function parallel(array $tasks): PromiseInterface +function parallel(iterable $tasks): PromiseInterface { $pending = []; $deferred = new Deferred(function () use (&$pending) { @@ -550,6 +550,10 @@ function parallel(array $tasks): PromiseInterface $results = []; $errored = false; + if (!\is_array($tasks)) { + $tasks = \iterator_to_array($tasks); + } + $numTasks = count($tasks); if (0 === $numTasks) { $deferred->resolve($results); @@ -591,10 +595,10 @@ function parallel(array $tasks): PromiseInterface } /** - * @param array> $tasks + * @param iterable> $tasks * @return PromiseInterface,Exception> */ -function series(array $tasks): PromiseInterface +function series(iterable $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { @@ -605,6 +609,10 @@ function series(array $tasks): PromiseInterface }); $results = []; + if (!\is_array($tasks)) { + $tasks = \iterator_to_array($tasks); + } + /** @var callable():void $next */ $taskCallback = function ($result) use (&$results, &$next) { $results[] = $result; @@ -631,10 +639,10 @@ function series(array $tasks): PromiseInterface } /** - * @param array> $tasks + * @param iterable> $tasks * @return PromiseInterface */ -function waterfall(array $tasks): PromiseInterface +function waterfall(iterable $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { @@ -644,6 +652,10 @@ function waterfall(array $tasks): PromiseInterface $pending = null; }); + if (!\is_array($tasks)) { + $tasks = \iterator_to_array($tasks); + } + /** @var callable $next */ $next = function ($value = null) use (&$tasks, &$next, $deferred, &$pending) { if (0 === count($tasks)) { diff --git a/tests/ParallelTest.php b/tests/ParallelTest.php index b77a3ca..284ccc0 100644 --- a/tests/ParallelTest.php +++ b/tests/ParallelTest.php @@ -17,6 +17,19 @@ public function testParallelWithoutTasks() $promise->then($this->expectCallableOnceWith(array())); } + public function testParallelWithoutTasksFromEmptyGeneratorResolvesWithEmptyArray() + { + $tasks = (function () { + if (false) { + yield; + } + })(); + + $promise = React\Async\parallel($tasks); + + $promise->then($this->expectCallableOnceWith([])); + } + public function testParallelWithTasks() { $tasks = array( @@ -49,6 +62,38 @@ function () { $timer->assertInRange(0.1, 0.2); } + public function testParallelWithTasksFromGeneratorResolvesWithArrayOfFulfillmentValues() + { + $tasks = (function () { + yield function () { + return new Promise(function ($resolve) { + Loop::addTimer(0.1, function () use ($resolve) { + $resolve('foo'); + }); + }); + }; + yield function () { + return new Promise(function ($resolve) { + Loop::addTimer(0.11, function () use ($resolve) { + $resolve('bar'); + }); + }); + }; + })(); + + $promise = React\Async\parallel($tasks); + + $promise->then($this->expectCallableOnceWith(array('foo', 'bar'))); + + $timer = new Timer($this); + $timer->start(); + + Loop::run(); + + $timer->stop(); + $timer->assertInRange(0.1, 0.2); + } + public function testParallelWithErrorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() { $called = 0; diff --git a/tests/SeriesTest.php b/tests/SeriesTest.php index 7cedf91..38937eb 100644 --- a/tests/SeriesTest.php +++ b/tests/SeriesTest.php @@ -17,6 +17,19 @@ public function testSeriesWithoutTasks() $promise->then($this->expectCallableOnceWith(array())); } + public function testSeriesWithoutTasksFromEmptyGeneratorResolvesWithEmptyArray() + { + $tasks = (function () { + if (false) { + yield; + } + })(); + + $promise = React\Async\series($tasks); + + $promise->then($this->expectCallableOnceWith([])); + } + public function testSeriesWithTasks() { $tasks = array( @@ -49,6 +62,38 @@ function () { $timer->assertInRange(0.10, 0.20); } + public function testSeriesWithTasksFromGeneratorResolvesWithArrayOfFulfillmentValues() + { + $tasks = (function () { + yield function () { + return new Promise(function ($resolve) { + Loop::addTimer(0.051, function () use ($resolve) { + $resolve('foo'); + }); + }); + }; + yield function () { + return new Promise(function ($resolve) { + Loop::addTimer(0.051, function () use ($resolve) { + $resolve('bar'); + }); + }); + }; + })(); + + $promise = React\Async\series($tasks); + + $promise->then($this->expectCallableOnceWith(array('foo', 'bar'))); + + $timer = new Timer($this); + $timer->start(); + + Loop::run(); + + $timer->stop(); + $timer->assertInRange(0.10, 0.20); + } + public function testSeriesWithError() { $called = 0; diff --git a/tests/WaterfallTest.php b/tests/WaterfallTest.php index b0c5c3c..70f1ee6 100644 --- a/tests/WaterfallTest.php +++ b/tests/WaterfallTest.php @@ -17,6 +17,19 @@ public function testWaterfallWithoutTasks() $promise->then($this->expectCallableOnceWith(null)); } + public function testWaterfallWithoutTasksFromEmptyGeneratorResolvesWithNull() + { + $tasks = (function () { + if (false) { + yield; + } + })(); + + $promise = React\Async\waterfall($tasks); + + $promise->then($this->expectCallableOnceWith(null)); + } + public function testWaterfallWithTasks() { $tasks = array( @@ -56,6 +69,45 @@ function ($bar) { $timer->assertInRange(0.15, 0.30); } + public function testWaterfallWithTasksFromGeneratorResolvesWithFinalFulfillmentValue() + { + $tasks = (function () { + yield function ($foo = 'foo') { + return new Promise(function ($resolve) use ($foo) { + Loop::addTimer(0.05, function () use ($resolve, $foo) { + $resolve($foo); + }); + }); + }; + yield function ($foo) { + return new Promise(function ($resolve) use ($foo) { + Loop::addTimer(0.05, function () use ($resolve, $foo) { + $resolve($foo . 'bar'); + }); + }); + }; + yield function ($bar) { + return new Promise(function ($resolve) use ($bar) { + Loop::addTimer(0.05, function () use ($resolve, $bar) { + $resolve($bar . 'baz'); + }); + }); + }; + })(); + + $promise = React\Async\waterfall($tasks); + + $promise->then($this->expectCallableOnceWith('foobarbaz')); + + $timer = new Timer($this); + $timer->start(); + + Loop::run(); + + $timer->stop(); + $timer->assertInRange(0.15, 0.30); + } + public function testWaterfallWithError() { $called = 0; From 2343d9cd8cde75d2b253002ed1ddf5e84c714c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 11 Jun 2022 14:22:27 +0200 Subject: [PATCH 19/41] Take advantage of iterators instead of converting to array first --- src/functions.php | 63 ++++++++++++++++++++++++----------------- tests/ParallelTest.php | 20 +++++++++++++ tests/SeriesTest.php | 42 +++++++++++++++++++++++++++ tests/WaterfallTest.php | 42 +++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 26 deletions(-) diff --git a/src/functions.php b/src/functions.php index 64ab364..6b4e2b7 100644 --- a/src/functions.php +++ b/src/functions.php @@ -548,19 +548,10 @@ function parallel(iterable $tasks): PromiseInterface $pending = []; }); $results = []; - $errored = false; + $continue = true; - if (!\is_array($tasks)) { - $tasks = \iterator_to_array($tasks); - } - - $numTasks = count($tasks); - if (0 === $numTasks) { - $deferred->resolve($results); - } - - $taskErrback = function ($error) use (&$pending, $deferred, &$errored) { - $errored = true; + $taskErrback = function ($error) use (&$pending, $deferred, &$continue) { + $continue = false; $deferred->reject($error); foreach ($pending as $promise) { @@ -572,25 +563,31 @@ function parallel(iterable $tasks): PromiseInterface }; foreach ($tasks as $i => $task) { - $taskCallback = function ($result) use (&$results, &$pending, $numTasks, $i, $deferred) { + $taskCallback = function ($result) use (&$results, &$pending, &$continue, $i, $deferred) { $results[$i] = $result; + unset($pending[$i]); - if (count($results) === $numTasks) { + if (!$pending && !$continue) { $deferred->resolve($results); } }; - $promise = call_user_func($task); + $promise = \call_user_func($task); assert($promise instanceof PromiseInterface); $pending[$i] = $promise; $promise->then($taskCallback, $taskErrback); - if ($errored) { + if (!$continue) { break; } } + $continue = false; + if (!$pending) { + $deferred->resolve($results); + } + return $deferred->promise(); } @@ -609,8 +606,9 @@ function series(iterable $tasks): PromiseInterface }); $results = []; - if (!\is_array($tasks)) { - $tasks = \iterator_to_array($tasks); + if ($tasks instanceof \IteratorAggregate) { + $tasks = $tasks->getIterator(); + assert($tasks instanceof \Iterator); } /** @var callable():void $next */ @@ -620,13 +618,19 @@ function series(iterable $tasks): PromiseInterface }; $next = function () use (&$tasks, $taskCallback, $deferred, &$results, &$pending) { - if (0 === count($tasks)) { + if ($tasks instanceof \Iterator ? !$tasks->valid() : !$tasks) { $deferred->resolve($results); return; } - $task = array_shift($tasks); - $promise = call_user_func($task); + if ($tasks instanceof \Iterator) { + $task = $tasks->current(); + $tasks->next(); + } else { + $task = \array_shift($tasks); + } + + $promise = \call_user_func($task); assert($promise instanceof PromiseInterface); $pending = $promise; @@ -652,19 +656,26 @@ function waterfall(iterable $tasks): PromiseInterface $pending = null; }); - if (!\is_array($tasks)) { - $tasks = \iterator_to_array($tasks); + if ($tasks instanceof \IteratorAggregate) { + $tasks = $tasks->getIterator(); + assert($tasks instanceof \Iterator); } /** @var callable $next */ $next = function ($value = null) use (&$tasks, &$next, $deferred, &$pending) { - if (0 === count($tasks)) { + if ($tasks instanceof \Iterator ? !$tasks->valid() : !$tasks) { $deferred->resolve($value); return; } - $task = array_shift($tasks); - $promise = call_user_func_array($task, func_get_args()); + if ($tasks instanceof \Iterator) { + $task = $tasks->current(); + $tasks->next(); + } else { + $task = \array_shift($tasks); + } + + $promise = \call_user_func_array($task, func_get_args()); assert($promise instanceof PromiseInterface); $pending = $promise; diff --git a/tests/ParallelTest.php b/tests/ParallelTest.php index 284ccc0..1a5759b 100644 --- a/tests/ParallelTest.php +++ b/tests/ParallelTest.php @@ -5,6 +5,7 @@ use React; use React\EventLoop\Loop; use React\Promise\Promise; +use function React\Promise\reject; class ParallelTest extends TestCase { @@ -126,6 +127,25 @@ function () use (&$called) { $this->assertSame(2, $called); } + public function testParallelWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + { + $called = 0; + + $tasks = (function () use (&$called) { + while (true) { + yield function () use (&$called) { + return reject(new \RuntimeException('Rejected ' . ++$called)); + }; + } + })(); + + $promise = React\Async\parallel($tasks); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Rejected 1'))); + + $this->assertSame(1, $called); + } + public function testParallelWithErrorWillCancelPendingPromises() { $cancelled = 0; diff --git a/tests/SeriesTest.php b/tests/SeriesTest.php index 38937eb..2583639 100644 --- a/tests/SeriesTest.php +++ b/tests/SeriesTest.php @@ -5,6 +5,7 @@ use React; use React\EventLoop\Loop; use React\Promise\Promise; +use function React\Promise\reject; class SeriesTest extends TestCase { @@ -125,6 +126,47 @@ function () use (&$called) { $this->assertSame(1, $called); } + public function testSeriesWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + { + $called = 0; + + $tasks = (function () use (&$called) { + while (true) { + yield function () use (&$called) { + return reject(new \RuntimeException('Rejected ' . ++$called)); + }; + } + })(); + + $promise = React\Async\series($tasks); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Rejected 1'))); + + $this->assertSame(1, $called); + } + + public function testSeriesWithErrorFromInfiniteIteratorAggregateReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + { + $tasks = new class() implements \IteratorAggregate { + public $called = 0; + + public function getIterator(): \Iterator + { + while (true) { + yield function () { + return reject(new \RuntimeException('Rejected ' . ++$this->called)); + }; + } + } + }; + + $promise = React\Async\series($tasks); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Rejected 1'))); + + $this->assertSame(1, $tasks->called); + } + public function testSeriesWillCancelFirstPendingPromiseWhenCallingCancelOnResultingPromise() { $cancelled = 0; diff --git a/tests/WaterfallTest.php b/tests/WaterfallTest.php index 70f1ee6..ace1877 100644 --- a/tests/WaterfallTest.php +++ b/tests/WaterfallTest.php @@ -5,6 +5,7 @@ use React; use React\EventLoop\Loop; use React\Promise\Promise; +use function React\Promise\reject; class WaterfallTest extends TestCase { @@ -139,6 +140,47 @@ function () use (&$called) { $this->assertSame(1, $called); } + public function testWaterfallWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + { + $called = 0; + + $tasks = (function () use (&$called) { + while (true) { + yield function () use (&$called) { + return reject(new \RuntimeException('Rejected ' . ++$called)); + }; + } + })(); + + $promise = React\Async\waterfall($tasks); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Rejected 1'))); + + $this->assertSame(1, $called); + } + + public function testWaterfallWithErrorFromInfiniteIteratorAggregateReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + { + $tasks = new class() implements \IteratorAggregate { + public $called = 0; + + public function getIterator(): \Iterator + { + while (true) { + yield function () { + return reject(new \RuntimeException('Rejected ' . ++$this->called)); + }; + } + } + }; + + $promise = React\Async\waterfall($tasks); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Rejected 1'))); + + $this->assertSame(1, $tasks->called); + } + public function testWaterfallWillCancelFirstPendingPromiseWhenCallingCancelOnResultingPromise() { $cancelled = 0; From 1615580e05c7334da07508b0d04b16c560d6743d Mon Sep 17 00:00:00 2001 From: Nicolas Hedger Date: Mon, 20 Jun 2022 16:42:03 +0200 Subject: [PATCH 20/41] chore(docs): remove leading dollar sign --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 405486d..e4ecab2 100644 --- a/README.md +++ b/README.md @@ -521,7 +521,7 @@ Once released, this project will follow [SemVer](https://semver.org/). At the moment, this will install the latest development version: ```bash -$ composer require react/async:^4@dev +composer require react/async:^4@dev ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. @@ -539,7 +539,7 @@ You may target multiple versions at the same time to support a wider range of PHP versions like this: ```bash -$ composer require "react/async:^4@dev || ^3@dev || ^2@dev" +composer require "react/async:^4@dev || ^3@dev || ^2@dev" ``` ## Tests @@ -548,13 +548,13 @@ To run the test suite, you first need to clone this repo and then install all dependencies [through Composer](https://getcomposer.org/): ```bash -$ composer install +composer install ``` To run the test suite, go to the project root and run: ```bash -$ php vendor/bin/phpunit +vendor/bin/phpunit ``` ## License From 992580af8217acf1d64d69273ede1e5a519379da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 2 Jun 2022 15:15:16 +0200 Subject: [PATCH 21/41] Consistent cancellation semantics for `coroutine()` --- src/functions.php | 8 +++---- tests/CoroutineTest.php | 47 +++++++++++++++++++++++++---------------- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/src/functions.php b/src/functions.php index 6b4e2b7..b627ced 100644 --- a/src/functions.php +++ b/src/functions.php @@ -485,12 +485,10 @@ function coroutine(callable $function, mixed ...$args): PromiseInterface $promise = null; $deferred = new Deferred(function () use (&$promise) { - // cancel pending promise(s) as long as generator function keeps yielding - while ($promise instanceof CancellablePromiseInterface) { - $temp = $promise; - $promise = null; - $temp->cancel(); + if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { + $promise->cancel(); } + $promise = null; }); /** @var callable $next */ diff --git a/tests/CoroutineTest.php b/tests/CoroutineTest.php index 6e461d5..adc82bc 100644 --- a/tests/CoroutineTest.php +++ b/tests/CoroutineTest.php @@ -106,42 +106,53 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionYieldsInvalidValue( $promise->then(null, $this->expectCallableOnceWith(new \UnexpectedValueException('Expected coroutine to yield React\Promise\PromiseInterface, but got integer'))); } - - public function testCoroutineWillCancelPendingPromiseWhenCallingCancelOnResultingPromise() + public function testCancelCoroutineWillReturnRejectedPromiseWhenCancellingPendingPromiseRejects() { - $cancelled = 0; - $promise = coroutine(function () use (&$cancelled) { - yield new Promise(function () use (&$cancelled) { - ++$cancelled; + $promise = coroutine(function () { + yield new Promise(function () { }, function () { + throw new \RuntimeException('Operation cancelled'); }); }); $promise->cancel(); - $this->assertEquals(1, $cancelled); + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Operation cancelled'))); } - public function testCoroutineWillCancelAllPendingPromisesWhenFunctionContinuesToYieldWhenCallingCancelOnResultingPromise() + public function testCancelCoroutineWillReturnFulfilledPromiseWhenCancellingPendingPromiseRejectsInsideCatchThatReturnsValue() { $promise = coroutine(function () { - $promise = new Promise(function () { }, function () { - throw new \RuntimeException('Frist operation cancelled', 21); - }); - try { - yield $promise; + yield new Promise(function () { }, function () { + throw new \RuntimeException('Operation cancelled'); + }); } catch (\RuntimeException $e) { - // ignore exception and continue + return 42; } + }); - yield new Promise(function () { }, function () { - throw new \RuntimeException('Second operation cancelled', 42); - }); + $promise->cancel(); + + $promise->then($this->expectCallableOnceWith(42)); + } + + public function testCancelCoroutineWillReturnPendigPromiseWhenCancellingFirstPromiseRejectsInsideCatchThatYieldsSecondPromise() + { + $promise = coroutine(function () { + try { + yield new Promise(function () { }, function () { + throw new \RuntimeException('First operation cancelled'); + }); + } catch (\RuntimeException $e) { + yield new Promise(function () { }, function () { + throw new \RuntimeException('Second operation never cancelled'); + }); + } }); $promise->cancel(); - $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Second operation cancelled', 42))); + $promise->then($this->expectCallableNever(), $this->expectCallableNever()); } public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorReturns() From df3c4a128e708cbfa332078df72ee5e452576e85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 7 Jun 2022 15:47:12 +0200 Subject: [PATCH 22/41] Consistent cancellation semantics for `async()` --- README.md | 14 +++--- composer.json | 3 +- src/FiberMap.php | 10 ----- src/functions.php | 20 ++++----- tests/AsyncTest.php | 106 ++++++++++++++++---------------------------- tests/AwaitTest.php | 4 +- 6 files changed, 59 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index e4ecab2..76b5e49 100644 --- a/README.md +++ b/README.md @@ -205,18 +205,20 @@ $promise->then(function (int $bytes) { }); ``` -Promises returned by `async()` can be cancelled, and when done any currently and future awaited promise inside that and -any nested fibers with their awaited promises will also be cancelled. As such the following example will only output -`ab` as the [`sleep()`](https://reactphp.org/promise-timer/#sleep) between `a` and `b` is cancelled throwing a timeout -exception that bubbles up through the fibers ultimately to the end user through the [`await()`](#await) on the last line -of the example. +The returned promise is implemented in such a way that it can be cancelled +when it is still pending. Cancelling a pending promise will cancel any awaited +promises inside that fiber or any nested fibers. As such, the following example +will only output `ab` and cancel the pending [`sleep()`](https://reactphp.org/promise-timer/#sleep). +The [`await()`](#await) calls in this example would throw a `RuntimeException` +from the cancelled [`sleep()`](https://reactphp.org/promise-timer/#sleep) call +that bubbles up through the fibers. ```php $promise = async(static function (): int { echo 'a'; await(async(static function(): void { echo 'b'; - await(sleep(2)); + await(React\Promise\Timer\sleep(2)); echo 'c'; })()); echo 'd'; diff --git a/composer.json b/composer.json index 5d93ed4..d749726 100644 --- a/composer.json +++ b/composer.json @@ -31,8 +31,7 @@ "react/promise": "^2.8 || ^1.2.1" }, "require-dev": { - "phpunit/phpunit": "^9.3", - "react/promise-timer": "^1.8" + "phpunit/phpunit": "^9.3" }, "autoload": { "psr-4": { diff --git a/src/FiberMap.php b/src/FiberMap.php index 5c7c7bf..36846b4 100644 --- a/src/FiberMap.php +++ b/src/FiberMap.php @@ -23,11 +23,6 @@ public static function cancel(\Fiber $fiber): void self::$status[\spl_object_id($fiber)] = true; } - public static function isCancelled(\Fiber $fiber): bool - { - return self::$status[\spl_object_id($fiber)] ?? false; - } - public static function setPromise(\Fiber $fiber, PromiseInterface $promise): void { self::$map[\spl_object_id($fiber)] = $promise; @@ -38,11 +33,6 @@ public static function unsetPromise(\Fiber $fiber, PromiseInterface $promise): v unset(self::$map[\spl_object_id($fiber)]); } - public static function has(\Fiber $fiber): bool - { - return array_key_exists(\spl_object_id($fiber), self::$map); - } - public static function getPromise(\Fiber $fiber): ?PromiseInterface { return self::$map[\spl_object_id($fiber)] ?? null; diff --git a/src/functions.php b/src/functions.php index b627ced..f738168 100644 --- a/src/functions.php +++ b/src/functions.php @@ -148,20 +148,20 @@ * }); * ``` * - * Promises returned by `async()` can be cancelled, and when done any currently - * and future awaited promise inside that and any nested fibers with their - * awaited promises will also be cancelled. As such the following example will - * only output `ab` as the [`sleep()`](https://reactphp.org/promise-timer/#sleep) - * between `a` and `b` is cancelled throwing a timeout exception that bubbles up - * through the fibers ultimately to the end user through the [`await()`](#await) - * on the last line of the example. + * The returned promise is implemented in such a way that it can be cancelled + * when it is still pending. Cancelling a pending promise will cancel any awaited + * promises inside that fiber or any nested fibers. As such, the following example + * will only output `ab` and cancel the pending [`sleep()`](https://reactphp.org/promise-timer/#sleep). + * The [`await()`](#await) calls in this example would throw a `RuntimeException` + * from the cancelled [`sleep()`](https://reactphp.org/promise-timer/#sleep) call + * that bubbles up through the fibers. * * ```php * $promise = async(static function (): int { * echo 'a'; * await(async(static function(): void { * echo 'b'; - * await(sleep(2)); + * await(React\Promise\Timer\sleep(2)); * echo 'c'; * })()); * echo 'd'; @@ -277,10 +277,6 @@ function await(PromiseInterface $promise): mixed $rejectedThrowable = null; $lowLevelFiber = \Fiber::getCurrent(); - if ($lowLevelFiber !== null && FiberMap::isCancelled($lowLevelFiber) && $promise instanceof CancellablePromiseInterface) { - $promise->cancel(); - } - $promise->then( function (mixed $value) use (&$resolved, &$resolvedValue, &$fiber, $lowLevelFiber, $promise): void { if ($lowLevelFiber !== null) { diff --git a/tests/AsyncTest.php b/tests/AsyncTest.php index 0d85302..7b86bc8 100644 --- a/tests/AsyncTest.php +++ b/tests/AsyncTest.php @@ -11,7 +11,6 @@ use function React\Promise\all; use function React\Promise\reject; use function React\Promise\resolve; -use function React\Promise\Timer\sleep; class AsyncTest extends TestCase { @@ -187,49 +186,60 @@ public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsA $this->assertLessThan(0.12, $time); } - public function testCancel() + public function testCancelAsyncWillReturnRejectedPromiseWhenCancellingPendingPromiseRejects() { - self::expectOutputString('a'); - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Timer cancelled'); + $promise = async(function () { + await(new Promise(function () { }, function () { + throw new \RuntimeException('Operation cancelled'); + })); + })(); - $promise = async(static function (): int { - echo 'a'; - await(sleep(2)); - echo 'b'; + $promise->cancel(); - return time(); + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Operation cancelled'))); + } + + public function testCancelAsyncWillReturnFulfilledPromiseWhenCancellingPendingPromiseRejectsInsideCatchThatReturnsValue() + { + $promise = async(function () { + try { + await(new Promise(function () { }, function () { + throw new \RuntimeException('Operation cancelled'); + })); + } catch (\RuntimeException $e) { + return 42; + } })(); $promise->cancel(); - await($promise); + + $promise->then($this->expectCallableOnceWith(42)); } - public function testCancelTryCatch() + public function testCancelAsycWillReturnPendigPromiseWhenCancellingFirstPromiseRejectsInsideCatchThatAwaitsSecondPromise() { - self::expectOutputString('ab'); - - $promise = async(static function (): int { - echo 'a'; + $promise = async(function () { try { - await(sleep(2)); - } catch (\Throwable) { - // No-Op + await(new Promise(function () { }, function () { + throw new \RuntimeException('First operation cancelled'); + })); + } catch (\RuntimeException $e) { + await(new Promise(function () { }, function () { + throw new \RuntimeException('Second operation never cancelled'); + })); } - echo 'b'; - - return time(); })(); $promise->cancel(); - await($promise); + + $promise->then($this->expectCallableNever(), $this->expectCallableNever()); } - public function testNestedCancel() + public function testCancelAsyncWillCancelNestedAwait() { self::expectOutputString('abc'); - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Timer cancelled'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Operation cancelled'); $promise = async(static function (): int { echo 'a'; @@ -237,7 +247,9 @@ public function testNestedCancel() echo 'b'; await(async(static function(): void { echo 'c'; - await(sleep(2)); + await(new Promise(function () { }, function () { + throw new \RuntimeException('Operation cancelled'); + })); echo 'd'; })()); echo 'e'; @@ -250,44 +262,4 @@ public function testNestedCancel() $promise->cancel(); await($promise); } - - public function testCancelFiberThatCatchesExceptions() - { - self::expectOutputString('ab'); - $this->expectException(\Exception::class); - $this->expectExceptionMessage('Timer cancelled'); - - $promise = async(static function (): int { - echo 'a'; - try { - await(sleep(2)); - } catch (\Throwable) { - // No-Op - } - echo 'b'; - await(sleep(0.1)); - echo 'c'; - - return time(); - })(); - - $promise->cancel(); - await($promise); - } - - public function testNotAwaitedPromiseWillNotBeCanceled() - { - self::expectOutputString('acb'); - - async(static function (): int { - echo 'a'; - sleep(0.001)->then(static function (): void { - echo 'b'; - }); - echo 'c'; - - return time(); - })()->cancel(); - Loop::run(); - } } diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index 93a69f8..3d2b886 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -379,7 +379,9 @@ public function testResolvedPromisesShouldBeDetached(callable $await) { $await(async(function () use ($await): int { $fiber = \Fiber::getCurrent(); - $await(React\Promise\Timer\sleep(0.01)); + $await(new Promise(function ($resolve) { + Loop::addTimer(0.01, fn() => $resolve(null)); + })); $this->assertNull(React\Async\FiberMap::getPromise($fiber)); return time(); From e68e9a85e15c7861d61919776b3abddebd7d0140 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 9 Jun 2022 19:02:21 +0200 Subject: [PATCH 23/41] Forward compatibility with upcoming Promise v3 --- composer.json | 2 +- src/functions.php | 12 +++++------- tests/SeriesTest.php | 2 +- tests/WaterfallTest.php | 2 +- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/composer.json b/composer.json index d749726..798768a 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "require": { "php": ">=8.1", "react/event-loop": "^1.2", - "react/promise": "^2.8 || ^1.2.1" + "react/promise": "^3.0 || ^2.8 || ^1.2.1" }, "require-dev": { "phpunit/phpunit": "^9.3" diff --git a/src/functions.php b/src/functions.php index f738168..0532394 100644 --- a/src/functions.php +++ b/src/functions.php @@ -2,8 +2,6 @@ namespace React\Async; -use React\EventLoop\Loop; -use React\Promise\CancellablePromiseInterface; use React\Promise\Deferred; use React\Promise\Promise; use React\Promise\PromiseInterface; @@ -199,7 +197,7 @@ function async(callable $function): callable }, function () use (&$fiber): void { FiberMap::cancel($fiber); $promise = FiberMap::getPromise($fiber); - if ($promise instanceof CancellablePromiseInterface) { + if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { $promise->cancel(); } }); @@ -535,7 +533,7 @@ function parallel(iterable $tasks): PromiseInterface $pending = []; $deferred = new Deferred(function () use (&$pending) { foreach ($pending as $promise) { - if ($promise instanceof CancellablePromiseInterface) { + if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { $promise->cancel(); } } @@ -549,7 +547,7 @@ function parallel(iterable $tasks): PromiseInterface $deferred->reject($error); foreach ($pending as $promise) { - if ($promise instanceof CancellablePromiseInterface) { + if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { $promise->cancel(); } } @@ -593,7 +591,7 @@ function series(iterable $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { - if ($pending instanceof CancellablePromiseInterface) { + if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { $pending->cancel(); } $pending = null; @@ -644,7 +642,7 @@ function waterfall(iterable $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { - if ($pending instanceof CancellablePromiseInterface) { + if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { $pending->cancel(); } $pending = null; diff --git a/tests/SeriesTest.php b/tests/SeriesTest.php index 2583639..404c907 100644 --- a/tests/SeriesTest.php +++ b/tests/SeriesTest.php @@ -174,7 +174,7 @@ public function testSeriesWillCancelFirstPendingPromiseWhenCallingCancelOnResult $tasks = array( function () { return new Promise(function ($resolve) { - $resolve(); + $resolve(null); }); }, function () use (&$cancelled) { diff --git a/tests/WaterfallTest.php b/tests/WaterfallTest.php index ace1877..2fbbc23 100644 --- a/tests/WaterfallTest.php +++ b/tests/WaterfallTest.php @@ -188,7 +188,7 @@ public function testWaterfallWillCancelFirstPendingPromiseWhenCallingCancelOnRes $tasks = array( function () { return new Promise(function ($resolve) { - $resolve(); + $resolve(null); }); }, function () use (&$cancelled) { From f5d8b974bc5caedefe09be053cbc372ec647e441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Tue, 5 Jul 2022 08:44:15 +0200 Subject: [PATCH 24/41] Improve examples for `async()` and `await()` --- README.md | 24 +++++++++++++++--------- src/functions.php | 24 +++++++++++++++--------- tests/AsyncTest.php | 4 ++-- 3 files changed, 32 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 76b5e49..3f47281 100644 --- a/README.md +++ b/README.md @@ -78,13 +78,15 @@ will still be blocked, but everything outside this function can be executed asynchronously without blocking: ```php -Loop::addTimer(0.5, React\Async\async(function() { +Loop::addTimer(0.5, React\Async\async(function () { echo 'a'; - React\async\await(React\Promise\Timer\sleep(1.0)); + React\Async\await(React\Promise\Timer\sleep(1.0)); echo 'c'; })); -Loop::addTimer(1.0, fn() => echo 'b'); +Loop::addTimer(1.0, function () { + echo 'b'; +}); // prints "a" at t=0.5s // prints "b" at t=1.0s @@ -98,13 +100,15 @@ In particular, this function does not "magically" make any blocking function non-blocking: ```php -Loop::addTimer(0.5, React\Async\async(function() { +Loop::addTimer(0.5, React\Async\async(function () { echo 'a'; sleep(1); // broken: using PHP's blocking sleep() for demonstration purposes echo 'c'; })); -Loop::addTimer(1.0, fn() => echo 'b'); +Loop::addTimer(1.0, function () { + echo 'b'; +}); // prints "a" at t=0.5s // prints "c" at t=1.5s: Correct timing, but wrong order @@ -216,7 +220,7 @@ that bubbles up through the fibers. ```php $promise = async(static function (): int { echo 'a'; - await(async(static function(): void { + await(async(static function (): void { echo 'b'; await(React\Promise\Timer\sleep(2)); echo 'c'; @@ -247,13 +251,15 @@ call. Everything inside this function will still be blocked, but everything outside this function can be executed asynchronously without blocking: ```php -Loop::addTimer(0.5, React\Async\async(function() { +Loop::addTimer(0.5, React\Async\async(function () { echo 'a'; - React\async\await(React\Promise\Timer\sleep(1.0)); + React\Async\await(React\Promise\Timer\sleep(1.0)); echo 'c'; })); -Loop::addTimer(1.0, fn() => echo 'b'); +Loop::addTimer(1.0, function () { + echo 'b'; +}); // prints "a" at t=0.5s // prints "b" at t=1.0s diff --git a/src/functions.php b/src/functions.php index 0532394..092115b 100644 --- a/src/functions.php +++ b/src/functions.php @@ -19,13 +19,15 @@ * asynchronously without blocking: * * ```php - * Loop::addTimer(0.5, React\Async\async(function() { + * Loop::addTimer(0.5, React\Async\async(function () { * echo 'a'; - * React\async\await(React\Promise\Timer\sleep(1.0)); + * React\Async\await(React\Promise\Timer\sleep(1.0)); * echo 'c'; * })); * - * Loop::addTimer(1.0, fn() => echo 'b'); + * Loop::addTimer(1.0, function () { + * echo 'b'; + * }); * * // prints "a" at t=0.5s * // prints "b" at t=1.0s @@ -39,13 +41,15 @@ * non-blocking: * * ```php - * Loop::addTimer(0.5, React\Async\async(function() { + * Loop::addTimer(0.5, React\Async\async(function () { * echo 'a'; * sleep(1); // broken: using PHP's blocking sleep() for demonstration purposes * echo 'c'; * })); * - * Loop::addTimer(1.0, fn() => echo 'b'); + * Loop::addTimer(1.0, function () { + * echo 'b'; + * }); * * // prints "a" at t=0.5s * // prints "c" at t=1.5s: Correct timing, but wrong order @@ -157,7 +161,7 @@ * ```php * $promise = async(static function (): int { * echo 'a'; - * await(async(static function(): void { + * await(async(static function (): void { * echo 'b'; * await(React\Promise\Timer\sleep(2)); * echo 'c'; @@ -227,13 +231,15 @@ function async(callable $function): callable * outside this function can be executed asynchronously without blocking: * * ```php - * Loop::addTimer(0.5, React\Async\async(function() { + * Loop::addTimer(0.5, React\Async\async(function () { * echo 'a'; - * React\async\await(React\Promise\Timer\sleep(1.0)); + * React\Async\await(React\Promise\Timer\sleep(1.0)); * echo 'c'; * })); * - * Loop::addTimer(1.0, fn() => echo 'b'); + * Loop::addTimer(1.0, function () { + * echo 'b'; + * }); * * // prints "a" at t=0.5s * // prints "b" at t=1.0s diff --git a/tests/AsyncTest.php b/tests/AsyncTest.php index 7b86bc8..7698b42 100644 --- a/tests/AsyncTest.php +++ b/tests/AsyncTest.php @@ -243,9 +243,9 @@ public function testCancelAsyncWillCancelNestedAwait() $promise = async(static function (): int { echo 'a'; - await(async(static function(): void { + await(async(static function (): void { echo 'b'; - await(async(static function(): void { + await(async(static function (): void { echo 'c'; await(new Promise(function () { }, function () { throw new \RuntimeException('Operation cancelled'); From 2aa8d89057e1059f59666e4204100636249b7be0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 11 Jul 2022 16:21:02 +0200 Subject: [PATCH 25/41] Prepare v4.0.0 release --- CHANGELOG.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++---- README.md | 20 +++++------------ composer.json | 2 +- 3 files changed, 63 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d17161..75f7944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,59 @@ -CHANGELOG -========= +# Changelog -* 1.0.0 (2013-02-07) +## 4.0.0 (2022-07-11) - * First tagged release +A major new feature release, see [**release announcement**](https://clue.engineering/2022/announcing-reactphp-async). + +* We'd like to emphasize that this component is production ready and battle-tested. + We plan to support all long-term support (LTS) releases for at least 24 months, + so you have a rock-solid foundation to build on top of. + +* The v4 release will be the way forward for this package. However, we will still + actively support v3 and v2 to provide a smooth upgrade path for those not yet + on PHP 8.1+. If you're using an older PHP version, you may use either version + which all provide a compatible API but may not take advantage of newer language + features. You may target multiple versions at the same time to support a wider range of + PHP versions: + + * [`4.x` branch](https://github.com/reactphp/async/tree/4.x) (PHP 8.1+) + * [`3.x` branch](https://github.com/reactphp/async/tree/3.x) (PHP 7.1+) + * [`2.x` branch](https://github.com/reactphp/async/tree/2.x) (PHP 5.3+) + +This update involves some major new features and a minor BC break over the +`v3.0.0` release. We've tried hard to avoid BC breaks where possible and +minimize impact otherwise. We expect that most consumers of this package will be +affected by BC breaks, but updating should take no longer than a few minutes. +See below for more details: + +* Feature / BC break: Require PHP 8.1+ and add `mixed` type declarations. + (#14 by @clue) + +* Feature: Add Fiber-based `async()` and `await()` functions. + (#15, #18, #19 and #20 by @WyriHaximus and #26, #28, #30, #32, #34, #55 and #57 by @clue) + +* Project maintenance, rename `main` branch to `4.x` and update installation instructions. + (#29 by @clue) + +The following changes had to be ported to this release due to our branching +strategy, but also appeared in the `v3.0.0` release: + +* Feature: Support iterable type for `parallel()` + `series()` + `waterfall()`. + (#49 by @clue) + +* Feature: Forward compatibility with upcoming Promise v3. + (#48 by @clue) + +* Minor documentation improvements. + (#36 by @SimonFrings and #51 by @nhedger) + +## 3.0.0 (2022-07-11) + +See [`3.x` CHANGELOG](https://github.com/reactphp/async/blob/3.x/CHANGELOG.md) for more details. + +## 2.0.0 (2022-07-11) + +See [`2.x` CHANGELOG](https://github.com/reactphp/async/blob/2.x/CHANGELOG.md) for more details. + +## 1.0.0 (2013-02-07) + +* First tagged release diff --git a/README.md b/README.md index 3f47281..0169cc1 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI status](https://github.com/reactphp/async/workflows/CI/badge.svg)](https://github.com/reactphp/async/actions) [![installs on Packagist](https://img.shields.io/packagist/dt/react/async?color=blue&label=installs%20on%20Packagist)](https://packagist.org/packages/react/async) -Async utilities for [ReactPHP](https://reactphp.org/). +Async utilities and fibers for [ReactPHP](https://reactphp.org/). This library allows you to manage async control flow. It provides a number of combinators for [Promise](https://github.com/reactphp/promise)-based APIs. @@ -14,16 +14,6 @@ to have an actual event loop and non-blocking libraries interacting with that event loop for it to work. As long as you have a Promise-based API that runs in an event loop, it can be used with this library. -> **Development version:** This branch contains the code for the upcoming 4.0 -> release which will be the way forward for this package. However, we will still -> actively support 3.0 and 2.0 for those not yet on PHP 8.1+. -> -> If you're using an older PHP version, you may use the -> [`3.x` branch](https://github.com/reactphp/async/tree/3.x) (PHP 7.1+) or -> [`2.x` branch](https://github.com/reactphp/async/tree/2.x) (PHP 5.3+) which both -> provide a compatible API but do not take advantage of newer language features. -> See also [installation instructions](#install) for more details. - **Table of Contents** * [Usage](#usage) @@ -525,11 +515,11 @@ React\Async\waterfall([ The recommended way to install this library is [through Composer](https://getcomposer.org/). [New to Composer?](https://getcomposer.org/doc/00-intro.md) -Once released, this project will follow [SemVer](https://semver.org/). -At the moment, this will install the latest development version: +This project follows [SemVer](https://semver.org/). +This will install the latest supported version from this branch: ```bash -composer require react/async:^4@dev +composer require react/async:^4 ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. @@ -547,7 +537,7 @@ You may target multiple versions at the same time to support a wider range of PHP versions like this: ```bash -composer require "react/async:^4@dev || ^3@dev || ^2@dev" +composer require "react/async:^4 || ^3 || ^2" ``` ## Tests diff --git a/composer.json b/composer.json index 798768a..8bdcd35 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "react/async", - "description": "Async utilities for ReactPHP", + "description": "Async utilities and fibers for ReactPHP", "keywords": ["async", "ReactPHP"], "license": "MIT", "authors": [ From 5e6c1265fc3de471e7a18f79472f5cc4bf483664 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Sun, 14 Aug 2022 00:48:56 +0200 Subject: [PATCH 26/41] Test on PHP 8.2 With PHP 8.2 coming out later this year, we should be reading for it's release to ensure all out code works on it. Refs: https://github.com/reactphp/event-loop/pull/258 --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b83b36..f3d940a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,7 @@ jobs: strategy: matrix: php: + - 8.2 - 8.1 steps: - uses: actions/checkout@v2 From 61baa83b87f53c7cd173bb0a1011023b36df37c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Wed, 26 Oct 2022 10:33:47 +0200 Subject: [PATCH 27/41] Update test suite and report failed assertions --- .github/workflows/ci.yml | 5 +++-- composer.json | 2 +- phpunit.xml.dist | 15 ++++++++++++--- tests/TestCase.php | 9 +-------- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3d940a..0e13e6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,17 +7,18 @@ on: jobs: PHPUnit: name: PHPUnit (PHP ${{ matrix.php }}) - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: php: - 8.2 - 8.1 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} coverage: xdebug + ini-file: development - run: composer install - run: vendor/bin/phpunit --coverage-text diff --git a/composer.json b/composer.json index 8bdcd35..2d1ad50 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "react/promise": "^3.0 || ^2.8 || ^1.2.1" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^9.5" }, "autoload": { "psr-4": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index f4b5805..63ec536 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,10 +1,11 @@ - + convertDeprecationsToExceptions="true"> ./tests/ @@ -15,4 +16,12 @@ ./src/ + + + + + + + + diff --git a/tests/TestCase.php b/tests/TestCase.php index ee0f476..904c63b 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,7 +2,6 @@ namespace React\Tests\Async; -use PHPUnit\Framework\MockObject\MockBuilder; use PHPUnit\Framework\TestCase as BaseTestCase; class TestCase extends BaseTestCase @@ -40,12 +39,6 @@ protected function expectCallableNever() protected function createCallableMock() { - if (method_exists(MockBuilder::class, 'addMethods')) { - // PHPUnit 9+ - return $this->getMockBuilder(\stdClass::class)->addMethods(['__invoke'])->getMock(); - } else { - // PHPUnit < 9 - return $this->getMockBuilder(\stdClass::class)->setMethods(['__invoke'])->getMock(); - } + return $this->getMockBuilder(\stdClass::class)->addMethods(['__invoke'])->getMock(); } } From 608a67c5c28398afa3778955021fe7fd1e2cb018 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 5 Dec 2022 18:54:32 +0100 Subject: [PATCH 28/41] Add new `delay()` function to delay program execution --- README.md | 112 +++++++++++++++++++++++++++++++++++-- src/functions.php | 131 ++++++++++++++++++++++++++++++++++++++++++-- tests/DelayTest.php | 91 ++++++++++++++++++++++++++++++ 3 files changed, 325 insertions(+), 9 deletions(-) create mode 100644 tests/DelayTest.php diff --git a/README.md b/README.md index 0169cc1..ebf6543 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ an event loop, it can be used with this library. * [async()](#async) * [await()](#await) * [coroutine()](#coroutine) + * [delay()](#delay) * [parallel()](#parallel) * [series()](#series) * [waterfall()](#waterfall) @@ -202,17 +203,16 @@ $promise->then(function (int $bytes) { The returned promise is implemented in such a way that it can be cancelled when it is still pending. Cancelling a pending promise will cancel any awaited promises inside that fiber or any nested fibers. As such, the following example -will only output `ab` and cancel the pending [`sleep()`](https://reactphp.org/promise-timer/#sleep). +will only output `ab` and cancel the pending [`delay()`](#delay). The [`await()`](#await) calls in this example would throw a `RuntimeException` -from the cancelled [`sleep()`](https://reactphp.org/promise-timer/#sleep) call -that bubbles up through the fibers. +from the cancelled [`delay()`](#delay) call that bubbles up through the fibers. ```php $promise = async(static function (): int { echo 'a'; await(async(static function (): void { echo 'b'; - await(React\Promise\Timer\sleep(2)); + delay(2); echo 'c'; })()); echo 'd'; @@ -392,6 +392,110 @@ $promise->then(function (int $bytes) { }); ``` +## delay() + +The `delay(float $seconds): void` function can be used to +delay program execution for duration given in `$seconds`. + +```php +React\Async\delay($seconds); +``` + +This function will only return after the given number of `$seconds` have +elapsed. If there are no other events attached to this loop, it will behave +similar to PHP's [`sleep()` function](https://www.php.net/manual/en/function.sleep.php). + +```php +echo 'a'; +React\Async\delay(1.0); +echo 'b'; + +// prints "a" at t=0.0s +// prints "b" at t=1.0s +``` + +Unlike PHP's [`sleep()` function](https://www.php.net/manual/en/function.sleep.php), +this function may not necessarily halt execution of the entire process thread. +Instead, it allows the event loop to run any other events attached to the +same loop until the delay returns: + +```php +echo 'a'; +Loop::addTimer(1.0, function () { + echo 'b'; +}); +React\Async\delay(3.0); +echo 'c'; + +// prints "a" at t=0.0s +// prints "b" at t=1.0s +// prints "c" at t=3.0s +``` + +This behavior is especially useful if you want to delay the program execution +of a particular routine, such as when building a simple polling or retry +mechanism: + +```php +try { + something(); +} catch (Throwable) { + // in case of error, retry after a short delay + React\Async\delay(1.0); + something(); +} +``` + +Because this function only returns after some time has passed, it can be +considered *blocking* from the perspective of the calling code. You can avoid +this blocking behavior by wrapping it in an [`async()` function](#async) call. +Everything inside this function will still be blocked, but everything outside +this function can be executed asynchronously without blocking: + +```php +Loop::addTimer(0.5, React\Async\async(function () { + echo 'a'; + React\Async\delay(1.0); + echo 'c'; +})); + +Loop::addTimer(1.0, function () { + echo 'b'; +}); + +// prints "a" at t=0.5s +// prints "b" at t=1.0s +// prints "c" at t=1.5s +``` + +See also the [`async()` function](#async) for more details. + +Internally, the `$seconds` argument will be used as a timer for the loop so that +it keeps running until this timer triggers. This implies that if you pass a +really small (or negative) value, it will still start a timer and will thus +trigger at the earliest possible time in the future. + +The function is implemented in such a way that it can be cancelled when it is +running inside an [`async()` function](#async). Cancelling the resulting +promise will clean up any pending timers and throw a `RuntimeException` from +the pending delay which in turn would reject the resulting promise. + +```php +$promise = async(function () { + echo 'a'; + delay(3.0); + echo 'b'; +}); + +Loop::addTimer(2.0, function () use ($promise) { + $promise->cancel(); +}); + +// prints "a" at t=0.0s +// rejects $promise at t=2.0 +// never prints "b" +``` + ### parallel() The `parallel(iterable> $tasks): PromiseInterface,Exception>` function can be used diff --git a/src/functions.php b/src/functions.php index 092115b..e4101df 100644 --- a/src/functions.php +++ b/src/functions.php @@ -2,6 +2,8 @@ namespace React\Async; +use React\EventLoop\Loop; +use React\EventLoop\TimerInterface; use React\Promise\Deferred; use React\Promise\Promise; use React\Promise\PromiseInterface; @@ -153,17 +155,16 @@ * The returned promise is implemented in such a way that it can be cancelled * when it is still pending. Cancelling a pending promise will cancel any awaited * promises inside that fiber or any nested fibers. As such, the following example - * will only output `ab` and cancel the pending [`sleep()`](https://reactphp.org/promise-timer/#sleep). + * will only output `ab` and cancel the pending [`delay()`](#delay). * The [`await()`](#await) calls in this example would throw a `RuntimeException` - * from the cancelled [`sleep()`](https://reactphp.org/promise-timer/#sleep) call - * that bubbles up through the fibers. + * from the cancelled [`delay()`](#delay) call that bubbles up through the fibers. * * ```php * $promise = async(static function (): int { * echo 'a'; * await(async(static function (): void { * echo 'b'; - * await(React\Promise\Timer\sleep(2)); + * delay(2); * echo 'c'; * })()); * echo 'd'; @@ -215,7 +216,6 @@ function async(callable $function): callable }; } - /** * Block waiting for the given `$promise` to be fulfilled. * @@ -352,6 +352,127 @@ function (mixed $throwable) use (&$rejected, &$rejectedThrowable, &$fiber, $lowL return $fiber->suspend(); } +/** + * Delay program execution for duration given in `$seconds`. + * + * ```php + * React\Async\delay($seconds); + * ``` + * + * This function will only return after the given number of `$seconds` have + * elapsed. If there are no other events attached to this loop, it will behave + * similar to PHP's [`sleep()` function](https://www.php.net/manual/en/function.sleep.php). + * + * ```php + * echo 'a'; + * React\Async\delay(1.0); + * echo 'b'; + * + * // prints "a" at t=0.0s + * // prints "b" at t=1.0s + * ``` + * + * Unlike PHP's [`sleep()` function](https://www.php.net/manual/en/function.sleep.php), + * this function may not necessarily halt execution of the entire process thread. + * Instead, it allows the event loop to run any other events attached to the + * same loop until the delay returns: + * + * ```php + * echo 'a'; + * Loop::addTimer(1.0, function () { + * echo 'b'; + * }); + * React\Async\delay(3.0); + * echo 'c'; + * + * // prints "a" at t=0.0s + * // prints "b" at t=1.0s + * // prints "c" at t=3.0s + * ``` + * + * This behavior is especially useful if you want to delay the program execution + * of a particular routine, such as when building a simple polling or retry + * mechanism: + * + * ```php + * try { + * something(); + * } catch (Throwable) { + * // in case of error, retry after a short delay + * React\Async\delay(1.0); + * something(); + * } + * ``` + * + * Because this function only returns after some time has passed, it can be + * considered *blocking* from the perspective of the calling code. You can avoid + * this blocking behavior by wrapping it in an [`async()` function](#async) call. + * Everything inside this function will still be blocked, but everything outside + * this function can be executed asynchronously without blocking: + * + * ```php + * Loop::addTimer(0.5, React\Async\async(function () { + * echo 'a'; + * React\Async\delay(1.0); + * echo 'c'; + * })); + * + * Loop::addTimer(1.0, function () { + * echo 'b'; + * }); + * + * // prints "a" at t=0.5s + * // prints "b" at t=1.0s + * // prints "c" at t=1.5s + * ``` + * + * See also the [`async()` function](#async) for more details. + * + * Internally, the `$seconds` argument will be used as a timer for the loop so that + * it keeps running until this timer triggers. This implies that if you pass a + * really small (or negative) value, it will still start a timer and will thus + * trigger at the earliest possible time in the future. + * + * The function is implemented in such a way that it can be cancelled when it is + * running inside an [`async()` function](#async). Cancelling the resulting + * promise will clean up any pending timers and throw a `RuntimeException` from + * the pending delay which in turn would reject the resulting promise. + * + * ```php + * $promise = async(function () { + * echo 'a'; + * delay(3.0); + * echo 'b'; + * }); + * + * Loop::addTimer(2.0, function () use ($promise) { + * $promise->cancel(); + * }); + * + * // prints "a" at t=0.0s + * // rejects $promise at t=2.0 + * // never prints "b" + * ``` + * + * @return void + * @throws \RuntimeException when the function is cancelled inside an `async()` function + * @see async() + * @uses await() + */ +function delay(float $seconds): void +{ + /** @var ?TimerInterface $timer */ + $timer = null; + + await(new Promise(function (callable $resolve) use ($seconds, &$timer): void { + $timer = Loop::addTimer($seconds, fn() => $resolve(null)); + }, function () use (&$timer): void { + assert($timer instanceof TimerInterface); + Loop::cancelTimer($timer); + throw new \RuntimeException('Delay cancelled'); + })); +} + /** * Execute a Generator-based coroutine to "await" promises. * diff --git a/tests/DelayTest.php b/tests/DelayTest.php new file mode 100644 index 0000000..46f0fba --- /dev/null +++ b/tests/DelayTest.php @@ -0,0 +1,91 @@ +assertGreaterThan(0.01, $time); + $this->assertLessThan(0.03, $time); + } + + public function testDelaySmallPeriodBlocksForCloseToZeroSeconds() + { + $time = microtime(true); + delay(0.000001); + $time = microtime(true) - $time; + + $this->assertLessThan(0.01, $time); + } + + public function testDelayNegativePeriodBlocksForCloseToZeroSeconds() + { + $time = microtime(true); + delay(-1); + $time = microtime(true) - $time; + + $this->assertLessThan(0.01, $time); + } + + public function testAwaitAsyncDelayBlocksForGivenPeriod() + { + $promise = async(function () { + delay(0.02); + })(); + + $time = microtime(true); + await($promise); + $time = microtime(true) - $time; + + $this->assertGreaterThan(0.01, $time); + $this->assertLessThan(0.03, $time); + } + + public function testAwaitAsyncDelayCancelledImmediatelyStopsTimerAndBlocksForCloseToZeroSeconds() + { + $promise = async(function () { + delay(1.0); + })(); + $promise->cancel(); + + $time = microtime(true); + try { + await($promise); + } catch (\RuntimeException $e) { + $this->assertEquals('Delay cancelled', $e->getMessage()); + } + $time = microtime(true) - $time; + + $this->assertLessThan(0.03, $time); + } + + public function testAwaitAsyncDelayCancelledAfterSmallPeriodStopsTimerAndBlocksUntilCancelled() + { + $promise = async(function () { + delay(1.0); + })(); + Loop::addTimer(0.02, fn() => $promise->cancel()); + + $time = microtime(true); + try { + await($promise); + } catch (\RuntimeException $e) { + $this->assertEquals('Delay cancelled', $e->getMessage()); + } + $time = microtime(true) - $time; + + $this->assertGreaterThan(0.01, $time); + $this->assertLessThan(0.03, $time); + } +} From 55b44c0e593306c80a9b655f57c9518ed8a91511 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Wed, 25 Jan 2023 08:09:18 +0100 Subject: [PATCH 29/41] Template params can only have one argument The fact that a promise can also be rejected with a Throwable and/or Exception is implied and there is no need to also define that here. Refs: https://github.com/reactphp/promise/pull/223 --- README.md | 6 +++--- src/functions.php | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ebf6543..4380c86 100644 --- a/README.md +++ b/README.md @@ -498,7 +498,7 @@ Loop::addTimer(2.0, function () use ($promise) { ### parallel() -The `parallel(iterable> $tasks): PromiseInterface,Exception>` function can be used +The `parallel(iterable> $tasks): PromiseInterface>` function can be used like this: ```php @@ -540,7 +540,7 @@ React\Async\parallel([ ### series() -The `series(iterable> $tasks): PromiseInterface,Exception>` function can be used +The `series(iterable> $tasks): PromiseInterface>` function can be used like this: ```php @@ -582,7 +582,7 @@ React\Async\series([ ### waterfall() -The `waterfall(iterable> $tasks): PromiseInterface` function can be used +The `waterfall(iterable> $tasks): PromiseInterface` function can be used like this: ```php diff --git a/src/functions.php b/src/functions.php index e4101df..797911b 100644 --- a/src/functions.php +++ b/src/functions.php @@ -652,8 +652,8 @@ function coroutine(callable $function, mixed ...$args): PromiseInterface } /** - * @param iterable> $tasks - * @return PromiseInterface,Exception> + * @param iterable> $tasks + * @return PromiseInterface> */ function parallel(iterable $tasks): PromiseInterface { @@ -711,8 +711,8 @@ function parallel(iterable $tasks): PromiseInterface } /** - * @param iterable> $tasks - * @return PromiseInterface,Exception> + * @param iterable> $tasks + * @return PromiseInterface> */ function series(iterable $tasks): PromiseInterface { @@ -762,8 +762,8 @@ function series(iterable $tasks): PromiseInterface } /** - * @param iterable> $tasks - * @return PromiseInterface + * @param iterable> $tasks + * @return PromiseInterface */ function waterfall(iterable $tasks): PromiseInterface { From 0fdd6a4f55c37a9c99dea12761d518b7fc599d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Wed, 26 Oct 2022 11:26:02 +0200 Subject: [PATCH 30/41] Add PHPStan to test environment --- .gitattributes | 1 + .github/workflows/ci.yml | 17 +++++++++++++++++ README.md | 6 ++++++ composer.json | 1 + phpstan.neon.dist | 11 +++++++++++ src/functions.php | 6 +++--- tests/AsyncTest.php | 4 ++++ tests/AwaitTest.php | 2 +- tests/CoroutineTest.php | 4 ++++ tests/ParallelTest.php | 1 + tests/SeriesTest.php | 1 + tests/WaterfallTest.php | 1 + 12 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 phpstan.neon.dist diff --git a/.gitattributes b/.gitattributes index aa6c312..838c8fa 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,6 @@ /.gitattributes export-ignore /.github/ export-ignore /.gitignore export-ignore +/phpstan.neon.dist export-ignore /phpunit.xml.dist export-ignore /tests/ export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e13e6f..75213de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,3 +22,20 @@ jobs: ini-file: development - run: composer install - run: vendor/bin/phpunit --coverage-text + + PHPStan: + name: PHPStan (PHP ${{ matrix.php }}) + runs-on: ubuntu-22.04 + strategy: + matrix: + php: + - 8.2 + - 8.1 + steps: + - uses: actions/checkout@v3 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - run: composer install + - run: vendor/bin/phpstan diff --git a/README.md b/README.md index 4380c86..71a6288 100644 --- a/README.md +++ b/README.md @@ -659,6 +659,12 @@ To run the test suite, go to the project root and run: vendor/bin/phpunit ``` +On top of this, we use PHPStan on level 3 to ensure type safety across the project: + +```bash +vendor/bin/phpstan +``` + ## License MIT, see [LICENSE file](LICENSE). diff --git a/composer.json b/composer.json index 2d1ad50..848dd38 100644 --- a/composer.json +++ b/composer.json @@ -31,6 +31,7 @@ "react/promise": "^3.0 || ^2.8 || ^1.2.1" }, "require-dev": { + "phpstan/phpstan": "1.10.18", "phpunit/phpunit": "^9.5" }, "autoload": { diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..903fb1f --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,11 @@ +parameters: + level: 3 + + paths: + - src/ + - tests/ + + reportUnmatchedIgnoredErrors: false + ignoreErrors: + # ignore generic usage like `PromiseInterface` until fixed upstream + - '/^PHPDoc .* contains generic type React\\Promise\\PromiseInterface<.+> but interface React\\Promise\\PromiseInterface is not generic\.$/' diff --git a/src/functions.php b/src/functions.php index 797911b..12f7d03 100644 --- a/src/functions.php +++ b/src/functions.php @@ -177,7 +177,7 @@ * ``` * * @param callable(mixed ...$args):mixed $function - * @return callable(): PromiseInterface + * @return callable(mixed ...$args): PromiseInterface * @since 4.0.0 * @see coroutine() */ @@ -587,7 +587,7 @@ function delay(float $seconds): void * }); * ``` * - * @param callable(...$args):\Generator $function + * @param callable(mixed ...$args):\Generator $function * @param mixed ...$args Optional list of additional arguments that will be passed to the given `$function` as is * @return PromiseInterface * @since 3.0.0 @@ -730,9 +730,9 @@ function series(iterable $tasks): PromiseInterface assert($tasks instanceof \Iterator); } - /** @var callable():void $next */ $taskCallback = function ($result) use (&$results, &$next) { $results[] = $result; + assert($next instanceof \Closure); $next(); }; diff --git a/tests/AsyncTest.php b/tests/AsyncTest.php index 7698b42..6e07112 100644 --- a/tests/AsyncTest.php +++ b/tests/AsyncTest.php @@ -194,6 +194,7 @@ public function testCancelAsyncWillReturnRejectedPromiseWhenCancellingPendingPro })); })(); + assert(method_exists($promise, 'cancel')); $promise->cancel(); $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Operation cancelled'))); @@ -211,6 +212,7 @@ public function testCancelAsyncWillReturnFulfilledPromiseWhenCancellingPendingPr } })(); + assert(method_exists($promise, 'cancel')); $promise->cancel(); $promise->then($this->expectCallableOnceWith(42)); @@ -230,6 +232,7 @@ public function testCancelAsycWillReturnPendigPromiseWhenCancellingFirstPromiseR } })(); + assert(method_exists($promise, 'cancel')); $promise->cancel(); $promise->then($this->expectCallableNever(), $this->expectCallableNever()); @@ -259,6 +262,7 @@ public function testCancelAsyncWillCancelNestedAwait() return time(); })(); + assert(method_exists($promise, 'cancel')); $promise->cancel(); await($promise); } diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index 3d2b886..3333062 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -361,7 +361,7 @@ public function testNestedAwaits(callable $await) $resolve($await(new Promise(function ($resolve) use ($await) { $resolve($await(new Promise(function ($resolve) use ($await) { $resolve($await(new Promise(function ($resolve) use ($await) { - $resolve($await(new Promise(function ($resolve) use ($await) { + $resolve($await(new Promise(function ($resolve) { Loop::addTimer(0.01, function () use ($resolve) { $resolve(true); }); diff --git a/tests/CoroutineTest.php b/tests/CoroutineTest.php index adc82bc..5ec4cde 100644 --- a/tests/CoroutineTest.php +++ b/tests/CoroutineTest.php @@ -114,6 +114,7 @@ public function testCancelCoroutineWillReturnRejectedPromiseWhenCancellingPendin }); }); + assert(method_exists($promise, 'cancel')); $promise->cancel(); $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Operation cancelled'))); @@ -131,6 +132,7 @@ public function testCancelCoroutineWillReturnFulfilledPromiseWhenCancellingPendi } }); + assert(method_exists($promise, 'cancel')); $promise->cancel(); $promise->then($this->expectCallableOnceWith(42)); @@ -150,6 +152,7 @@ public function testCancelCoroutineWillReturnPendigPromiseWhenCancellingFirstPro } }); + assert(method_exists($promise, 'cancel')); $promise->cancel(); $promise->then($this->expectCallableNever(), $this->expectCallableNever()); @@ -209,6 +212,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseReject }); }); + assert(method_exists($promise, 'cancel')); $promise->cancel(); unset($promise); diff --git a/tests/ParallelTest.php b/tests/ParallelTest.php index 1a5759b..98bbce2 100644 --- a/tests/ParallelTest.php +++ b/tests/ParallelTest.php @@ -193,6 +193,7 @@ function () use (&$cancelled) { ); $promise = React\Async\parallel($tasks); + assert(method_exists($promise, 'cancel')); $promise->cancel(); $this->assertSame(2, $cancelled); diff --git a/tests/SeriesTest.php b/tests/SeriesTest.php index 404c907..0bc5017 100644 --- a/tests/SeriesTest.php +++ b/tests/SeriesTest.php @@ -185,6 +185,7 @@ function () use (&$cancelled) { ); $promise = React\Async\series($tasks); + assert(method_exists($promise, 'cancel')); $promise->cancel(); $this->assertSame(1, $cancelled); diff --git a/tests/WaterfallTest.php b/tests/WaterfallTest.php index 2fbbc23..d2f947f 100644 --- a/tests/WaterfallTest.php +++ b/tests/WaterfallTest.php @@ -199,6 +199,7 @@ function () use (&$cancelled) { ); $promise = React\Async\waterfall($tasks); + assert(method_exists($promise, 'cancel')); $promise->cancel(); $this->assertSame(1, $cancelled); From 6185725a7bf708f030fa456fb9edc986c222caf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Wed, 26 Oct 2022 12:37:20 +0200 Subject: [PATCH 31/41] Improve type definitions and update to PHPStan level `max` --- README.md | 2 +- phpstan.neon.dist | 2 +- src/FiberMap.php | 12 +++++++++-- src/SimpleFiber.php | 10 +++++++++- src/functions.php | 25 ++++++++++++++++++----- tests/AsyncTest.php | 31 +++++++++++++++-------------- tests/AwaitTest.php | 44 ++++++++++++++++++++++------------------- tests/CoroutineTest.php | 42 +++++++++++++++++++-------------------- tests/DelayTest.php | 16 +++++++++------ tests/ParallelTest.php | 25 ++++++++++++----------- tests/SeriesTest.php | 27 +++++++++++++------------ tests/TestCase.php | 25 +++++++++++------------ tests/Timer.php | 18 ++++++++--------- tests/WaterfallTest.php | 27 +++++++++++++------------ 14 files changed, 173 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index 71a6288..523dd67 100644 --- a/README.md +++ b/README.md @@ -659,7 +659,7 @@ To run the test suite, go to the project root and run: vendor/bin/phpunit ``` -On top of this, we use PHPStan on level 3 to ensure type safety across the project: +On top of this, we use PHPStan on max level to ensure type safety across the project: ```bash vendor/bin/phpstan diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 903fb1f..b7f8ddb 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,5 @@ parameters: - level: 3 + level: max paths: - src/ diff --git a/src/FiberMap.php b/src/FiberMap.php index 36846b4..0648788 100644 --- a/src/FiberMap.php +++ b/src/FiberMap.php @@ -9,35 +9,43 @@ */ final class FiberMap { + /** @var array */ private static array $status = []; - private static array $map = []; + /** @var array */ + private static array $map = []; + + /** @param \Fiber $fiber */ public static function register(\Fiber $fiber): void { self::$status[\spl_object_id($fiber)] = false; - self::$map[\spl_object_id($fiber)] = []; } + /** @param \Fiber $fiber */ public static function cancel(\Fiber $fiber): void { self::$status[\spl_object_id($fiber)] = true; } + /** @param \Fiber $fiber */ public static function setPromise(\Fiber $fiber, PromiseInterface $promise): void { self::$map[\spl_object_id($fiber)] = $promise; } + /** @param \Fiber $fiber */ public static function unsetPromise(\Fiber $fiber, PromiseInterface $promise): void { unset(self::$map[\spl_object_id($fiber)]); } + /** @param \Fiber $fiber */ public static function getPromise(\Fiber $fiber): ?PromiseInterface { return self::$map[\spl_object_id($fiber)] ?? null; } + /** @param \Fiber $fiber */ public static function unregister(\Fiber $fiber): void { unset(self::$status[\spl_object_id($fiber)], self::$map[\spl_object_id($fiber)]); diff --git a/src/SimpleFiber.php b/src/SimpleFiber.php index acf3fad..8c5460a 100644 --- a/src/SimpleFiber.php +++ b/src/SimpleFiber.php @@ -9,8 +9,12 @@ */ final class SimpleFiber implements FiberInterface { + /** @var ?\Fiber */ private static ?\Fiber $scheduler = null; + private static ?\Closure $suspend = null; + + /** @var ?\Fiber */ private ?\Fiber $fiber = null; public function __construct() @@ -57,13 +61,17 @@ public function suspend(): mixed self::$scheduler = new \Fiber(static fn() => Loop::run()); // Run event loop to completion on shutdown. \register_shutdown_function(static function (): void { + assert(self::$scheduler instanceof \Fiber); if (self::$scheduler->isSuspended()) { self::$scheduler->resume(); } }); } - return (self::$scheduler->isStarted() ? self::$scheduler->resume() : self::$scheduler->start())(); + $ret = (self::$scheduler->isStarted() ? self::$scheduler->resume() : self::$scheduler->start()); + assert(\is_callable($ret)); + + return $ret(); } return \Fiber::suspend(); diff --git a/src/functions.php b/src/functions.php index 12f7d03..a3ce111 100644 --- a/src/functions.php +++ b/src/functions.php @@ -176,8 +176,8 @@ * await($promise); * ``` * - * @param callable(mixed ...$args):mixed $function - * @return callable(mixed ...$args): PromiseInterface + * @param callable $function + * @return callable(mixed ...): PromiseInterface * @since 4.0.0 * @see coroutine() */ @@ -192,6 +192,7 @@ function async(callable $function): callable } catch (\Throwable $exception) { $reject($exception); } finally { + assert($fiber instanceof \Fiber); FiberMap::unregister($fiber); } }); @@ -200,6 +201,7 @@ function async(callable $function): callable $fiber->start(); }, function () use (&$fiber): void { + assert($fiber instanceof \Fiber); FiberMap::cancel($fiber); $promise = FiberMap::getPromise($fiber); if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { @@ -287,6 +289,7 @@ function (mixed $value) use (&$resolved, &$resolvedValue, &$fiber, $lowLevelFibe FiberMap::unsetPromise($lowLevelFiber, $promise); } + /** @var ?\Fiber $fiber */ if ($fiber === null) { $resolved = true; $resolvedValue = $value; @@ -309,6 +312,7 @@ function (mixed $throwable) use (&$rejected, &$rejectedThrowable, &$fiber, $lowL // what a lovely piece of code! $r = new \ReflectionProperty('Exception', 'trace'); $trace = $r->getValue($throwable); + assert(\is_array($trace)); // Exception trace arguments only available when zend.exception_ignore_args is not set // @codeCoverageIgnoreStart @@ -340,6 +344,7 @@ function (mixed $throwable) use (&$rejected, &$rejectedThrowable, &$fiber, $lowL } if ($rejected) { + assert($rejectedThrowable instanceof \Throwable); throw $rejectedThrowable; } @@ -587,7 +592,7 @@ function delay(float $seconds): void * }); * ``` * - * @param callable(mixed ...$args):\Generator $function + * @param callable(mixed ...$args):(\Generator|mixed) $function * @param mixed ...$args Optional list of additional arguments that will be passed to the given `$function` as is * @return PromiseInterface * @since 3.0.0 @@ -606,6 +611,7 @@ function coroutine(callable $function, mixed ...$args): PromiseInterface $promise = null; $deferred = new Deferred(function () use (&$promise) { + /** @var ?PromiseInterface $promise */ if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { $promise->cancel(); } @@ -626,6 +632,7 @@ function coroutine(callable $function, mixed ...$args): PromiseInterface return; } + /** @var mixed $promise */ $promise = $generator->current(); if (!$promise instanceof PromiseInterface) { $next = null; @@ -635,6 +642,7 @@ function coroutine(callable $function, mixed ...$args): PromiseInterface return; } + assert($next instanceof \Closure); $promise->then(function ($value) use ($generator, $next) { $generator->send($value); $next(); @@ -657,6 +665,7 @@ function coroutine(callable $function, mixed ...$args): PromiseInterface */ function parallel(iterable $tasks): PromiseInterface { + /** @var array $pending */ $pending = []; $deferred = new Deferred(function () use (&$pending) { foreach ($pending as $promise) { @@ -718,6 +727,7 @@ function series(iterable $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { + /** @var ?PromiseInterface $pending */ if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { $pending->cancel(); } @@ -732,7 +742,7 @@ function series(iterable $tasks): PromiseInterface $taskCallback = function ($result) use (&$results, &$next) { $results[] = $result; - assert($next instanceof \Closure); + /** @var \Closure $next */ $next(); }; @@ -746,9 +756,11 @@ function series(iterable $tasks): PromiseInterface $task = $tasks->current(); $tasks->next(); } else { + assert(\is_array($tasks)); $task = \array_shift($tasks); } + assert(\is_callable($task)); $promise = \call_user_func($task); assert($promise instanceof PromiseInterface); $pending = $promise; @@ -762,13 +774,14 @@ function series(iterable $tasks): PromiseInterface } /** - * @param iterable> $tasks + * @param iterable<(callable():PromiseInterface)|(callable(mixed):PromiseInterface)> $tasks * @return PromiseInterface */ function waterfall(iterable $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { + /** @var ?PromiseInterface $pending */ if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { $pending->cancel(); } @@ -791,9 +804,11 @@ function waterfall(iterable $tasks): PromiseInterface $task = $tasks->current(); $tasks->next(); } else { + assert(\is_array($tasks)); $task = \array_shift($tasks); } + assert(\is_callable($task)); $promise = \call_user_func_array($task, func_get_args()); assert($promise instanceof PromiseInterface); $pending = $promise; diff --git a/tests/AsyncTest.php b/tests/AsyncTest.php index 6e07112..70a84b1 100644 --- a/tests/AsyncTest.php +++ b/tests/AsyncTest.php @@ -14,7 +14,7 @@ class AsyncTest extends TestCase { - public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsValue() + public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsValue(): void { $promise = async(function () { return 42; @@ -28,7 +28,7 @@ public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsV $this->assertEquals(42, $value); } - public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsPromiseThatFulfillsWithValue() + public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsPromiseThatFulfillsWithValue(): void { $promise = async(function () { return resolve(42); @@ -42,7 +42,7 @@ public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsP $this->assertEquals(42, $value); } - public function testAsyncReturnsPromiseThatRejectsWithExceptionWhenCallbackThrows() + public function testAsyncReturnsPromiseThatRejectsWithExceptionWhenCallbackThrows(): void { $promise = async(function () { throw new \RuntimeException('Foo', 42); @@ -59,7 +59,7 @@ public function testAsyncReturnsPromiseThatRejectsWithExceptionWhenCallbackThrow $this->assertEquals(42, $exception->getCode()); } - public function testAsyncReturnsPromiseThatRejectsWithExceptionWhenCallbackReturnsPromiseThatRejectsWithException() + public function testAsyncReturnsPromiseThatRejectsWithExceptionWhenCallbackReturnsPromiseThatRejectsWithException(): void { $promise = async(function () { return reject(new \RuntimeException('Foo', 42)); @@ -76,7 +76,7 @@ public function testAsyncReturnsPromiseThatRejectsWithExceptionWhenCallbackRetur $this->assertEquals(42, $exception->getCode()); } - public function testAsyncReturnsPendingPromiseWhenCallbackReturnsPendingPromise() + public function testAsyncReturnsPendingPromiseWhenCallbackReturnsPendingPromise(): void { $promise = async(function () { return new Promise(function () { }); @@ -85,7 +85,7 @@ public function testAsyncReturnsPendingPromiseWhenCallbackReturnsPendingPromise( $promise->then($this->expectCallableNever(), $this->expectCallableNever()); } - public function testAsyncWithAwaitReturnsReturnsPromiseFulfilledWithValueImmediatelyWhenPromiseIsFulfilled() + public function testAsyncWithAwaitReturnsReturnsPromiseFulfilledWithValueImmediatelyWhenPromiseIsFulfilled(): void { $deferred = new Deferred(); @@ -105,7 +105,7 @@ public function testAsyncWithAwaitReturnsReturnsPromiseFulfilledWithValueImmedia $this->assertEquals(42, $return); } - public function testAsyncWithAwaitReturnsPromiseRejectedWithExceptionImmediatelyWhenPromiseIsRejected() + public function testAsyncWithAwaitReturnsPromiseRejectedWithExceptionImmediatelyWhenPromiseIsRejected(): void { $deferred = new Deferred(); @@ -122,13 +122,13 @@ public function testAsyncWithAwaitReturnsPromiseRejectedWithExceptionImmediately $deferred->reject(new \RuntimeException('Test', 42)); + /** @var \RuntimeException $exception */ $this->assertInstanceof(\RuntimeException::class, $exception); - assert($exception instanceof \RuntimeException); $this->assertEquals('Test', $exception->getMessage()); $this->assertEquals(42, $exception->getCode()); } - public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsAfterAwaitingPromise() + public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsAfterAwaitingPromise(): void { $promise = async(function () { $promise = new Promise(function ($resolve) { @@ -143,7 +143,7 @@ public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsA $this->assertEquals(42, $value); } - public function testAsyncReturnsPromiseThatRejectsWithExceptionWhenCallbackThrowsAfterAwaitingPromise() + public function testAsyncReturnsPromiseThatRejectsWithExceptionWhenCallbackThrowsAfterAwaitingPromise(): void { $promise = async(function () { $promise = new Promise(function ($_, $reject) { @@ -159,7 +159,7 @@ public function testAsyncReturnsPromiseThatRejectsWithExceptionWhenCallbackThrow await($promise); } - public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsAfterAwaitingTwoConcurrentPromises() + public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsAfterAwaitingTwoConcurrentPromises(): void { $promise1 = async(function () { $promise = new Promise(function ($resolve) { @@ -174,6 +174,7 @@ public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsA Loop::addTimer(0.11, fn () => $resolve($theAnswerToLifeTheUniverseAndEverything)); }); + /** @var int */ return await($promise); })(42); @@ -186,7 +187,7 @@ public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsA $this->assertLessThan(0.12, $time); } - public function testCancelAsyncWillReturnRejectedPromiseWhenCancellingPendingPromiseRejects() + public function testCancelAsyncWillReturnRejectedPromiseWhenCancellingPendingPromiseRejects(): void { $promise = async(function () { await(new Promise(function () { }, function () { @@ -200,7 +201,7 @@ public function testCancelAsyncWillReturnRejectedPromiseWhenCancellingPendingPro $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Operation cancelled'))); } - public function testCancelAsyncWillReturnFulfilledPromiseWhenCancellingPendingPromiseRejectsInsideCatchThatReturnsValue() + public function testCancelAsyncWillReturnFulfilledPromiseWhenCancellingPendingPromiseRejectsInsideCatchThatReturnsValue(): void { $promise = async(function () { try { @@ -218,7 +219,7 @@ public function testCancelAsyncWillReturnFulfilledPromiseWhenCancellingPendingPr $promise->then($this->expectCallableOnceWith(42)); } - public function testCancelAsycWillReturnPendigPromiseWhenCancellingFirstPromiseRejectsInsideCatchThatAwaitsSecondPromise() + public function testCancelAsycWillReturnPendigPromiseWhenCancellingFirstPromiseRejectsInsideCatchThatAwaitsSecondPromise(): void { $promise = async(function () { try { @@ -238,7 +239,7 @@ public function testCancelAsycWillReturnPendigPromiseWhenCancellingFirstPromiseR $promise->then($this->expectCallableNever(), $this->expectCallableNever()); } - public function testCancelAsyncWillCancelNestedAwait() + public function testCancelAsyncWillCancelNestedAwait(): void { self::expectOutputString('abc'); $this->expectException(\RuntimeException::class); diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index 3333062..3158a1b 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -6,6 +6,7 @@ use React\EventLoop\Loop; use React\Promise\Deferred; use React\Promise\Promise; +use React\Promise\PromiseInterface; use function React\Async\async; class AwaitTest extends TestCase @@ -13,7 +14,7 @@ class AwaitTest extends TestCase /** * @dataProvider provideAwaiters */ - public function testAwaitThrowsExceptionWhenPromiseIsRejectedWithException(callable $await) + public function testAwaitThrowsExceptionWhenPromiseIsRejectedWithException(callable $await): void { $promise = new Promise(function () { throw new \Exception('test'); @@ -27,7 +28,7 @@ public function testAwaitThrowsExceptionWhenPromiseIsRejectedWithException(calla /** * @dataProvider provideAwaiters */ - public function testAwaitThrowsExceptionWithoutRunningLoop(callable $await) + public function testAwaitThrowsExceptionWithoutRunningLoop(callable $await): void { $now = true; Loop::futureTick(function () use (&$now) { @@ -48,7 +49,7 @@ public function testAwaitThrowsExceptionWithoutRunningLoop(callable $await) /** * @dataProvider provideAwaiters */ - public function testAwaitThrowsExceptionImmediatelyWhenPromiseIsRejected(callable $await) + public function testAwaitThrowsExceptionImmediatelyWhenPromiseIsRejected(callable $await): void { $deferred = new Deferred(); @@ -72,7 +73,7 @@ public function testAwaitThrowsExceptionImmediatelyWhenPromiseIsRejected(callabl /** * @dataProvider provideAwaiters */ - public function testAwaitAsyncThrowsExceptionImmediatelyWhenPromiseIsRejected(callable $await) + public function testAwaitAsyncThrowsExceptionImmediatelyWhenPromiseIsRejected(callable $await): void { $deferred = new Deferred(); @@ -100,7 +101,7 @@ public function testAwaitAsyncThrowsExceptionImmediatelyWhenPromiseIsRejected(ca /** * @dataProvider provideAwaiters */ - public function testAwaitThrowsExceptionImmediatelyInCustomFiberWhenPromiseIsRejected(callable $await) + public function testAwaitThrowsExceptionImmediatelyInCustomFiberWhenPromiseIsRejected(callable $await): void { $fiber = new \Fiber(function () use ($await) { $promise = new Promise(function ($resolve) { @@ -121,7 +122,7 @@ public function testAwaitThrowsExceptionImmediatelyInCustomFiberWhenPromiseIsRej /** * @dataProvider provideAwaiters */ - public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithFalse(callable $await) + public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithFalse(callable $await): void { if (!interface_exists('React\Promise\CancellablePromiseInterface')) { $this->markTestSkipped('Promises must be rejected with a \Throwable instance since Promise v3'); @@ -139,7 +140,7 @@ public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWith /** * @dataProvider provideAwaiters */ - public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithNull(callable $await) + public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithNull(callable $await): void { if (!interface_exists('React\Promise\CancellablePromiseInterface')) { $this->markTestSkipped('Promises must be rejected with a \Throwable instance since Promise v3'); @@ -163,7 +164,7 @@ public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWith /** * @dataProvider provideAwaiters */ - public function testAwaitThrowsErrorWhenPromiseIsRejectedWithError(callable $await) + public function testAwaitThrowsErrorWhenPromiseIsRejectedWithError(callable $await): void { $promise = new Promise(function ($_, $reject) { throw new \Error('Test', 42); @@ -178,7 +179,7 @@ public function testAwaitThrowsErrorWhenPromiseIsRejectedWithError(callable $awa /** * @dataProvider provideAwaiters */ - public function testAwaitReturnsValueWhenPromiseIsFullfilled(callable $await) + public function testAwaitReturnsValueWhenPromiseIsFullfilled(callable $await): void { $promise = new Promise(function ($resolve) { $resolve(42); @@ -190,7 +191,7 @@ public function testAwaitReturnsValueWhenPromiseIsFullfilled(callable $await) /** * @dataProvider provideAwaiters */ - public function testAwaitReturnsValueImmediatelyWithoutRunningLoop(callable $await) + public function testAwaitReturnsValueImmediatelyWithoutRunningLoop(callable $await): void { $now = true; Loop::futureTick(function () use (&$now) { @@ -208,7 +209,7 @@ public function testAwaitReturnsValueImmediatelyWithoutRunningLoop(callable $awa /** * @dataProvider provideAwaiters */ - public function testAwaitReturnsValueImmediatelyWhenPromiseIsFulfilled(callable $await) + public function testAwaitReturnsValueImmediatelyWhenPromiseIsFulfilled(callable $await): void { $deferred = new Deferred(); @@ -229,7 +230,7 @@ public function testAwaitReturnsValueImmediatelyWhenPromiseIsFulfilled(callable /** * @dataProvider provideAwaiters */ - public function testAwaitAsyncReturnsValueImmediatelyWhenPromiseIsFulfilled(callable $await) + public function testAwaitAsyncReturnsValueImmediatelyWhenPromiseIsFulfilled(callable $await): void { $deferred = new Deferred(); @@ -254,7 +255,7 @@ public function testAwaitAsyncReturnsValueImmediatelyWhenPromiseIsFulfilled(call /** * @dataProvider provideAwaiters */ - public function testAwaitReturnsValueImmediatelyInCustomFiberWhenPromiseIsFulfilled(callable $await) + public function testAwaitReturnsValueImmediatelyInCustomFiberWhenPromiseIsFulfilled(callable $await): void { $fiber = new \Fiber(function () use ($await) { $promise = new Promise(function ($resolve) { @@ -273,7 +274,7 @@ public function testAwaitReturnsValueImmediatelyInCustomFiberWhenPromiseIsFulfil /** * @dataProvider provideAwaiters */ - public function testAwaitShouldNotCreateAnyGarbageReferencesForResolvedPromise(callable $await) + public function testAwaitShouldNotCreateAnyGarbageReferencesForResolvedPromise(callable $await): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -293,7 +294,7 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForResolvedPromise(c /** * @dataProvider provideAwaiters */ - public function testAwaitShouldNotCreateAnyGarbageReferencesForRejectedPromise(callable $await) + public function testAwaitShouldNotCreateAnyGarbageReferencesForRejectedPromise(callable $await): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -317,7 +318,7 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForRejectedPromise(c /** * @dataProvider provideAwaiters */ - public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithNullValue(callable $await) + public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithNullValue(callable $await): void { if (!interface_exists('React\Promise\CancellablePromiseInterface')) { $this->markTestSkipped('Promises must be rejected with a \Throwable instance since Promise v3'); @@ -345,7 +346,7 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWi /** * @dataProvider provideAwaiters */ - public function testAlreadyFulfilledPromiseShouldNotSuspendFiber(callable $await) + public function testAlreadyFulfilledPromiseShouldNotSuspendFiber(callable $await): void { for ($i = 0; $i < 6; $i++) { $this->assertSame($i, $await(React\Promise\resolve($i))); @@ -355,7 +356,7 @@ public function testAlreadyFulfilledPromiseShouldNotSuspendFiber(callable $await /** * @dataProvider provideAwaiters */ - public function testNestedAwaits(callable $await) + public function testNestedAwaits(callable $await): void { $this->assertTrue($await(new Promise(function ($resolve) use ($await) { $resolve($await(new Promise(function ($resolve) use ($await) { @@ -375,10 +376,11 @@ public function testNestedAwaits(callable $await) /** * @dataProvider provideAwaiters */ - public function testResolvedPromisesShouldBeDetached(callable $await) + public function testResolvedPromisesShouldBeDetached(callable $await): void { $await(async(function () use ($await): int { $fiber = \Fiber::getCurrent(); + assert($fiber instanceof \Fiber); $await(new Promise(function ($resolve) { Loop::addTimer(0.01, fn() => $resolve(null)); })); @@ -391,13 +393,14 @@ public function testResolvedPromisesShouldBeDetached(callable $await) /** * @dataProvider provideAwaiters */ - public function testRejectedPromisesShouldBeDetached(callable $await) + public function testRejectedPromisesShouldBeDetached(callable $await): void { $this->expectException(\Exception::class); $this->expectExceptionMessage('Boom!'); $await(async(function () use ($await): int { $fiber = \Fiber::getCurrent(); + assert($fiber instanceof \Fiber); try { $await(React\Promise\reject(new \Exception('Boom!'))); } catch (\Throwable $throwable) { @@ -410,6 +413,7 @@ public function testRejectedPromisesShouldBeDetached(callable $await) })()); } + /** @return iterable> */ public function provideAwaiters(): iterable { yield 'await' => [static fn (React\Promise\PromiseInterface $promise): mixed => React\Async\await($promise)]; diff --git a/tests/CoroutineTest.php b/tests/CoroutineTest.php index 5ec4cde..c9b7439 100644 --- a/tests/CoroutineTest.php +++ b/tests/CoroutineTest.php @@ -9,7 +9,7 @@ class CoroutineTest extends TestCase { - public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsWithoutGenerator() + public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsWithoutGenerator(): void { $promise = coroutine(function () { return 42; @@ -18,10 +18,10 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsWithoutGene $promise->then($this->expectCallableOnceWith(42)); } - public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsImmediately() + public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsImmediately(): void { $promise = coroutine(function () { - if (false) { + if (false) { // @phpstan-ignore-line yield; } return 42; @@ -30,7 +30,7 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsImmediately $promise->then($this->expectCallableOnceWith(42)); } - public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldingPromise() + public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldingPromise(): void { $promise = coroutine(function () { $value = yield resolve(42); @@ -40,7 +40,7 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldi $promise->then($this->expectCallableOnceWith(42)); } - public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsWithoutGenerator() + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsWithoutGenerator(): void { $promise = coroutine(function () { throw new \RuntimeException('Foo'); @@ -49,10 +49,10 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsWithoutGenera $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); } - public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsImmediately() + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsImmediately(): void { $promise = coroutine(function () { - if (false) { + if (false) { // @phpstan-ignore-line yield; } throw new \RuntimeException('Foo'); @@ -61,7 +61,7 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsImmediately() $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); } - public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYieldingPromise() + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYieldingPromise(): void { $promise = coroutine(function () { $reason = yield resolve('Foo'); @@ -71,7 +71,7 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYielding $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); } - public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYieldingRejectedPromise() + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYieldingRejectedPromise(): void { $promise = coroutine(function () { try { @@ -84,7 +84,7 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYielding $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); } - public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldingRejectedPromise() + public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldingRejectedPromise(): void { $promise = coroutine(function () { try { @@ -97,7 +97,7 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldi $promise->then($this->expectCallableOnceWith(42)); } - public function testCoroutineReturnsRejectedPromiseIfFunctionYieldsInvalidValue() + public function testCoroutineReturnsRejectedPromiseIfFunctionYieldsInvalidValue(): void { $promise = coroutine(function () { yield 42; @@ -106,7 +106,7 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionYieldsInvalidValue( $promise->then(null, $this->expectCallableOnceWith(new \UnexpectedValueException('Expected coroutine to yield React\Promise\PromiseInterface, but got integer'))); } - public function testCancelCoroutineWillReturnRejectedPromiseWhenCancellingPendingPromiseRejects() + public function testCancelCoroutineWillReturnRejectedPromiseWhenCancellingPendingPromiseRejects(): void { $promise = coroutine(function () { yield new Promise(function () { }, function () { @@ -120,7 +120,7 @@ public function testCancelCoroutineWillReturnRejectedPromiseWhenCancellingPendin $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Operation cancelled'))); } - public function testCancelCoroutineWillReturnFulfilledPromiseWhenCancellingPendingPromiseRejectsInsideCatchThatReturnsValue() + public function testCancelCoroutineWillReturnFulfilledPromiseWhenCancellingPendingPromiseRejectsInsideCatchThatReturnsValue(): void { $promise = coroutine(function () { try { @@ -138,7 +138,7 @@ public function testCancelCoroutineWillReturnFulfilledPromiseWhenCancellingPendi $promise->then($this->expectCallableOnceWith(42)); } - public function testCancelCoroutineWillReturnPendigPromiseWhenCancellingFirstPromiseRejectsInsideCatchThatYieldsSecondPromise() + public function testCancelCoroutineWillReturnPendigPromiseWhenCancellingFirstPromiseRejectsInsideCatchThatYieldsSecondPromise(): void { $promise = coroutine(function () { try { @@ -158,7 +158,7 @@ public function testCancelCoroutineWillReturnPendigPromiseWhenCancellingFirstPro $promise->then($this->expectCallableNever(), $this->expectCallableNever()); } - public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorReturns() + public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorReturns(): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -168,7 +168,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorRet gc_collect_cycles(); $promise = coroutine(function () { - if (false) { + if (false) { // @phpstan-ignore-line yield; } return 42; @@ -179,7 +179,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorRet $this->assertEquals(0, gc_collect_cycles()); } - public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithExceptionImmediately() + public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithExceptionImmediately(): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -198,7 +198,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseReject $this->assertEquals(0, gc_collect_cycles()); } - public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithExceptionOnCancellation() + public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithExceptionOnCancellation(): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -219,7 +219,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseReject $this->assertEquals(0, gc_collect_cycles()); } - public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorThrowsBeforeFirstYield() + public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorThrowsBeforeFirstYield(): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -229,7 +229,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorThr $promise = coroutine(function () { throw new \RuntimeException('Failed', 42); - yield; + yield; // @phpstan-ignore-line }); unset($promise); @@ -237,7 +237,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorThr $this->assertEquals(0, gc_collect_cycles()); } - public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorYieldsInvalidValue() + public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorYieldsInvalidValue(): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); diff --git a/tests/DelayTest.php b/tests/DelayTest.php index 46f0fba..2cadd6f 100644 --- a/tests/DelayTest.php +++ b/tests/DelayTest.php @@ -10,7 +10,7 @@ class DelayTest extends TestCase { - public function testDelayBlocksForGivenPeriod() + public function testDelayBlocksForGivenPeriod(): void { $time = microtime(true); delay(0.02); @@ -20,7 +20,7 @@ public function testDelayBlocksForGivenPeriod() $this->assertLessThan(0.03, $time); } - public function testDelaySmallPeriodBlocksForCloseToZeroSeconds() + public function testDelaySmallPeriodBlocksForCloseToZeroSeconds(): void { $time = microtime(true); delay(0.000001); @@ -29,7 +29,7 @@ public function testDelaySmallPeriodBlocksForCloseToZeroSeconds() $this->assertLessThan(0.01, $time); } - public function testDelayNegativePeriodBlocksForCloseToZeroSeconds() + public function testDelayNegativePeriodBlocksForCloseToZeroSeconds(): void { $time = microtime(true); delay(-1); @@ -38,7 +38,7 @@ public function testDelayNegativePeriodBlocksForCloseToZeroSeconds() $this->assertLessThan(0.01, $time); } - public function testAwaitAsyncDelayBlocksForGivenPeriod() + public function testAwaitAsyncDelayBlocksForGivenPeriod(): void { $promise = async(function () { delay(0.02); @@ -52,11 +52,13 @@ public function testAwaitAsyncDelayBlocksForGivenPeriod() $this->assertLessThan(0.03, $time); } - public function testAwaitAsyncDelayCancelledImmediatelyStopsTimerAndBlocksForCloseToZeroSeconds() + public function testAwaitAsyncDelayCancelledImmediatelyStopsTimerAndBlocksForCloseToZeroSeconds(): void { $promise = async(function () { delay(1.0); })(); + + assert(method_exists($promise, 'cancel')); $promise->cancel(); $time = microtime(true); @@ -70,11 +72,13 @@ public function testAwaitAsyncDelayCancelledImmediatelyStopsTimerAndBlocksForClo $this->assertLessThan(0.03, $time); } - public function testAwaitAsyncDelayCancelledAfterSmallPeriodStopsTimerAndBlocksUntilCancelled() + public function testAwaitAsyncDelayCancelledAfterSmallPeriodStopsTimerAndBlocksUntilCancelled(): void { $promise = async(function () { delay(1.0); })(); + + assert(method_exists($promise, 'cancel')); Loop::addTimer(0.02, fn() => $promise->cancel()); $time = microtime(true); diff --git a/tests/ParallelTest.php b/tests/ParallelTest.php index 98bbce2..37b1e10 100644 --- a/tests/ParallelTest.php +++ b/tests/ParallelTest.php @@ -6,10 +6,11 @@ use React\EventLoop\Loop; use React\Promise\Promise; use function React\Promise\reject; +use function React\Promise\resolve; class ParallelTest extends TestCase { - public function testParallelWithoutTasks() + public function testParallelWithoutTasks(): void { $tasks = array(); @@ -18,11 +19,11 @@ public function testParallelWithoutTasks() $promise->then($this->expectCallableOnceWith(array())); } - public function testParallelWithoutTasksFromEmptyGeneratorResolvesWithEmptyArray() + public function testParallelWithoutTasksFromEmptyGeneratorResolvesWithEmptyArray(): void { $tasks = (function () { - if (false) { - yield; + if (false) { // @phpstan-ignore-line + yield fn () => resolve(null); } })(); @@ -31,7 +32,7 @@ public function testParallelWithoutTasksFromEmptyGeneratorResolvesWithEmptyArray $promise->then($this->expectCallableOnceWith([])); } - public function testParallelWithTasks() + public function testParallelWithTasks(): void { $tasks = array( function () { @@ -63,7 +64,7 @@ function () { $timer->assertInRange(0.1, 0.2); } - public function testParallelWithTasksFromGeneratorResolvesWithArrayOfFulfillmentValues() + public function testParallelWithTasksFromGeneratorResolvesWithArrayOfFulfillmentValues(): void { $tasks = (function () { yield function () { @@ -95,7 +96,7 @@ public function testParallelWithTasksFromGeneratorResolvesWithArrayOfFulfillment $timer->assertInRange(0.1, 0.2); } - public function testParallelWithErrorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + public function testParallelWithErrorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks(): void { $called = 0; @@ -127,12 +128,12 @@ function () use (&$called) { $this->assertSame(2, $called); } - public function testParallelWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + public function testParallelWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks(): void { $called = 0; $tasks = (function () use (&$called) { - while (true) { + while (true) { // @phpstan-ignore-line yield function () use (&$called) { return reject(new \RuntimeException('Rejected ' . ++$called)); }; @@ -146,7 +147,7 @@ public function testParallelWithErrorFromInfiniteGeneratorReturnsPromiseRejected $this->assertSame(1, $called); } - public function testParallelWithErrorWillCancelPendingPromises() + public function testParallelWithErrorWillCancelPendingPromises(): void { $cancelled = 0; @@ -175,7 +176,7 @@ function () use (&$cancelled) { $this->assertSame(1, $cancelled); } - public function testParallelWillCancelPendingPromisesWhenCallingCancelOnResultingPromise() + public function testParallelWillCancelPendingPromisesWhenCallingCancelOnResultingPromise(): void { $cancelled = 0; @@ -199,7 +200,7 @@ function () use (&$cancelled) { $this->assertSame(2, $cancelled); } - public function testParallelWithDelayedErrorReturnsPromiseRejectedWithExceptionFromTask() + public function testParallelWithDelayedErrorReturnsPromiseRejectedWithExceptionFromTask(): void { $called = 0; diff --git a/tests/SeriesTest.php b/tests/SeriesTest.php index 0bc5017..9b20815 100644 --- a/tests/SeriesTest.php +++ b/tests/SeriesTest.php @@ -6,10 +6,11 @@ use React\EventLoop\Loop; use React\Promise\Promise; use function React\Promise\reject; +use function React\Promise\resolve; class SeriesTest extends TestCase { - public function testSeriesWithoutTasks() + public function testSeriesWithoutTasks(): void { $tasks = array(); @@ -18,11 +19,11 @@ public function testSeriesWithoutTasks() $promise->then($this->expectCallableOnceWith(array())); } - public function testSeriesWithoutTasksFromEmptyGeneratorResolvesWithEmptyArray() + public function testSeriesWithoutTasksFromEmptyGeneratorResolvesWithEmptyArray(): void { $tasks = (function () { - if (false) { - yield; + if (false) { // @phpstan-ignore-line + yield fn () => resolve(null); } })(); @@ -31,7 +32,7 @@ public function testSeriesWithoutTasksFromEmptyGeneratorResolvesWithEmptyArray() $promise->then($this->expectCallableOnceWith([])); } - public function testSeriesWithTasks() + public function testSeriesWithTasks(): void { $tasks = array( function () { @@ -63,7 +64,7 @@ function () { $timer->assertInRange(0.10, 0.20); } - public function testSeriesWithTasksFromGeneratorResolvesWithArrayOfFulfillmentValues() + public function testSeriesWithTasksFromGeneratorResolvesWithArrayOfFulfillmentValues(): void { $tasks = (function () { yield function () { @@ -95,7 +96,7 @@ public function testSeriesWithTasksFromGeneratorResolvesWithArrayOfFulfillmentVa $timer->assertInRange(0.10, 0.20); } - public function testSeriesWithError() + public function testSeriesWithError(): void { $called = 0; @@ -126,12 +127,12 @@ function () use (&$called) { $this->assertSame(1, $called); } - public function testSeriesWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + public function testSeriesWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks(): void { $called = 0; $tasks = (function () use (&$called) { - while (true) { + while (true) { // @phpstan-ignore-line yield function () use (&$called) { return reject(new \RuntimeException('Rejected ' . ++$called)); }; @@ -145,14 +146,14 @@ public function testSeriesWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWi $this->assertSame(1, $called); } - public function testSeriesWithErrorFromInfiniteIteratorAggregateReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + public function testSeriesWithErrorFromInfiniteIteratorAggregateReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks(): void { $tasks = new class() implements \IteratorAggregate { - public $called = 0; + public int $called = 0; public function getIterator(): \Iterator { - while (true) { + while (true) { // @phpstan-ignore-line yield function () { return reject(new \RuntimeException('Rejected ' . ++$this->called)); }; @@ -167,7 +168,7 @@ public function getIterator(): \Iterator $this->assertSame(1, $tasks->called); } - public function testSeriesWillCancelFirstPendingPromiseWhenCallingCancelOnResultingPromise() + public function testSeriesWillCancelFirstPendingPromiseWhenCallingCancelOnResultingPromise(): void { $cancelled = 0; diff --git a/tests/TestCase.php b/tests/TestCase.php index 904c63b..e43397d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,42 +2,39 @@ namespace React\Tests\Async; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase as BaseTestCase; class TestCase extends BaseTestCase { - protected function expectCallableOnce() + protected function expectCallableOnce(): callable { $mock = $this->createCallableMock(); - $mock - ->expects($this->once()) - ->method('__invoke'); + $mock->expects($this->once())->method('__invoke'); + assert(is_callable($mock)); return $mock; } - protected function expectCallableOnceWith($value) + protected function expectCallableOnceWith(mixed $value): callable { $mock = $this->createCallableMock(); - $mock - ->expects($this->once()) - ->method('__invoke') - ->with($value); + $mock->expects($this->once())->method('__invoke')->with($value); + assert(is_callable($mock)); return $mock; } - protected function expectCallableNever() + protected function expectCallableNever(): callable { $mock = $this->createCallableMock(); - $mock - ->expects($this->never()) - ->method('__invoke'); + $mock->expects($this->never())->method('__invoke'); + assert(is_callable($mock)); return $mock; } - protected function createCallableMock() + protected function createCallableMock(): MockObject { return $this->getMockBuilder(\stdClass::class)->addMethods(['__invoke'])->getMock(); } diff --git a/tests/Timer.php b/tests/Timer.php index 0a37a73..0e755a7 100644 --- a/tests/Timer.php +++ b/tests/Timer.php @@ -4,41 +4,41 @@ class Timer { - private $testCase; - private $start; - private $stop; + private TestCase $testCase; + private float $start; + private float $stop; public function __construct(TestCase $testCase) { $this->testCase = $testCase; } - public function start() + public function start(): void { $this->start = microtime(true); } - public function stop() + public function stop(): void { $this->stop = microtime(true); } - public function getInterval() + public function getInterval(): float { return $this->stop - $this->start; } - public function assertLessThan($milliseconds) + public function assertLessThan(float $milliseconds): void { $this->testCase->assertLessThan($milliseconds, $this->getInterval()); } - public function assertGreaterThan($milliseconds) + public function assertGreaterThan(float $milliseconds): void { $this->testCase->assertGreaterThan($milliseconds, $this->getInterval()); } - public function assertInRange($minMs, $maxMs) + public function assertInRange(float $minMs, float $maxMs): void { $this->assertGreaterThan($minMs); $this->assertLessThan($maxMs); diff --git a/tests/WaterfallTest.php b/tests/WaterfallTest.php index d2f947f..2b274b2 100644 --- a/tests/WaterfallTest.php +++ b/tests/WaterfallTest.php @@ -6,10 +6,11 @@ use React\EventLoop\Loop; use React\Promise\Promise; use function React\Promise\reject; +use function React\Promise\resolve; class WaterfallTest extends TestCase { - public function testWaterfallWithoutTasks() + public function testWaterfallWithoutTasks(): void { $tasks = array(); @@ -18,11 +19,11 @@ public function testWaterfallWithoutTasks() $promise->then($this->expectCallableOnceWith(null)); } - public function testWaterfallWithoutTasksFromEmptyGeneratorResolvesWithNull() + public function testWaterfallWithoutTasksFromEmptyGeneratorResolvesWithNull(): void { $tasks = (function () { - if (false) { - yield; + if (false) { // @phpstan-ignore-line + yield fn () => resolve(null); } })(); @@ -31,7 +32,7 @@ public function testWaterfallWithoutTasksFromEmptyGeneratorResolvesWithNull() $promise->then($this->expectCallableOnceWith(null)); } - public function testWaterfallWithTasks() + public function testWaterfallWithTasks(): void { $tasks = array( function ($foo = 'foo') { @@ -70,7 +71,7 @@ function ($bar) { $timer->assertInRange(0.15, 0.30); } - public function testWaterfallWithTasksFromGeneratorResolvesWithFinalFulfillmentValue() + public function testWaterfallWithTasksFromGeneratorResolvesWithFinalFulfillmentValue(): void { $tasks = (function () { yield function ($foo = 'foo') { @@ -109,7 +110,7 @@ public function testWaterfallWithTasksFromGeneratorResolvesWithFinalFulfillmentV $timer->assertInRange(0.15, 0.30); } - public function testWaterfallWithError() + public function testWaterfallWithError(): void { $called = 0; @@ -140,12 +141,12 @@ function () use (&$called) { $this->assertSame(1, $called); } - public function testWaterfallWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + public function testWaterfallWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks(): void { $called = 0; $tasks = (function () use (&$called) { - while (true) { + while (true) { // @phpstan-ignore-line yield function () use (&$called) { return reject(new \RuntimeException('Rejected ' . ++$called)); }; @@ -159,14 +160,14 @@ public function testWaterfallWithErrorFromInfiniteGeneratorReturnsPromiseRejecte $this->assertSame(1, $called); } - public function testWaterfallWithErrorFromInfiniteIteratorAggregateReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + public function testWaterfallWithErrorFromInfiniteIteratorAggregateReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks(): void { $tasks = new class() implements \IteratorAggregate { - public $called = 0; + public int $called = 0; public function getIterator(): \Iterator { - while (true) { + while (true) { // @phpstan-ignore-line yield function () { return reject(new \RuntimeException('Rejected ' . ++$this->called)); }; @@ -181,7 +182,7 @@ public function getIterator(): \Iterator $this->assertSame(1, $tasks->called); } - public function testWaterfallWillCancelFirstPendingPromiseWhenCallingCancelOnResultingPromise() + public function testWaterfallWillCancelFirstPendingPromiseWhenCallingCancelOnResultingPromise(): void { $cancelled = 0; From b64af2c8ef02a41f89be5aa711c7ee3810f2023d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sat, 15 Apr 2023 12:30:39 +0200 Subject: [PATCH 32/41] Minor documentation improvements --- README.md | 14 +++++++------- src/functions.php | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 523dd67..ef66aa0 100644 --- a/README.md +++ b/README.md @@ -392,7 +392,7 @@ $promise->then(function (int $bytes) { }); ``` -## delay() +### delay() The `delay(float $seconds): void` function can be used to delay program execution for duration given in `$seconds`. @@ -421,7 +421,7 @@ same loop until the delay returns: ```php echo 'a'; -Loop::addTimer(1.0, function () { +Loop::addTimer(1.0, function (): void { echo 'b'; }); React\Async\delay(3.0); @@ -453,13 +453,13 @@ Everything inside this function will still be blocked, but everything outside this function can be executed asynchronously without blocking: ```php -Loop::addTimer(0.5, React\Async\async(function () { +Loop::addTimer(0.5, React\Async\async(function (): void { echo 'a'; React\Async\delay(1.0); echo 'c'; })); -Loop::addTimer(1.0, function () { +Loop::addTimer(1.0, function (): void { echo 'b'; }); @@ -481,13 +481,13 @@ promise will clean up any pending timers and throw a `RuntimeException` from the pending delay which in turn would reject the resulting promise. ```php -$promise = async(function () { +$promise = async(function (): void { echo 'a'; delay(3.0); echo 'b'; -}); +})(); -Loop::addTimer(2.0, function () use ($promise) { +Loop::addTimer(2.0, function () use ($promise): void { $promise->cancel(); }); diff --git a/src/functions.php b/src/functions.php index a3ce111..5a02406 100644 --- a/src/functions.php +++ b/src/functions.php @@ -384,7 +384,7 @@ function (mixed $throwable) use (&$rejected, &$rejectedThrowable, &$fiber, $lowL * * ```php * echo 'a'; - * Loop::addTimer(1.0, function () { + * Loop::addTimer(1.0, function (): void { * echo 'b'; * }); * React\Async\delay(3.0); @@ -416,13 +416,13 @@ function (mixed $throwable) use (&$rejected, &$rejectedThrowable, &$fiber, $lowL * this function can be executed asynchronously without blocking: * * ```php - * Loop::addTimer(0.5, React\Async\async(function () { + * Loop::addTimer(0.5, React\Async\async(function (): void { * echo 'a'; * React\Async\delay(1.0); * echo 'c'; * })); * - * Loop::addTimer(1.0, function () { + * Loop::addTimer(1.0, function (): void { * echo 'b'; * }); * @@ -444,13 +444,13 @@ function (mixed $throwable) use (&$rejected, &$rejectedThrowable, &$fiber, $lowL * the pending delay which in turn would reject the resulting promise. * * ```php - * $promise = async(function () { + * $promise = async(function (): void { * echo 'a'; * delay(3.0); * echo 'b'; - * }); + * })(); * - * Loop::addTimer(2.0, function () use ($promise) { + * Loop::addTimer(2.0, function () use ($promise): void { * $promise->cancel(); * }); * From b9641ac600b4b144e71a87dcf1be4d41dd3a3548 Mon Sep 17 00:00:00 2001 From: Simon Frings Date: Thu, 22 Jun 2023 16:10:50 +0200 Subject: [PATCH 33/41] Prepare v4.1.0 release --- CHANGELOG.md | 21 +++++++++++++++++++++ README.md | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75f7944..0aff589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 4.1.0 (2023-06-22) + +* Feature: Add new `delay()` function to delay program execution. + (#69 by @clue) + + ```php + echo 'a'; + Loop::addTimer(1.0, function () { + echo 'b'; + }); + React\Async\delay(3.0); + echo 'c'; + + // prints "a" at t=0.0s + // prints "b" at t=1.0s + // prints "c" at t=3.0s + ``` + +* Update test suite, add PHPStan with `max` level and report failed assertions. + (#66 and #76 by @clue and #61 and #73 by @WyriHaximus) + ## 4.0.0 (2022-07-11) A major new feature release, see [**release announcement**](https://clue.engineering/2022/announcing-reactphp-async). diff --git a/README.md b/README.md index ef66aa0..6e71f49 100644 --- a/README.md +++ b/README.md @@ -623,7 +623,7 @@ This project follows [SemVer](https://semver.org/). This will install the latest supported version from this branch: ```bash -composer require react/async:^4 +composer require react/async:^4.1 ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. From 9b58514158ce7861aca3bed1d41bf15157d42651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 6 Jul 2023 09:57:27 +0200 Subject: [PATCH 34/41] Update test suite to avoid unhandled promise rejections --- tests/CoroutineTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/CoroutineTest.php b/tests/CoroutineTest.php index c9b7439..2c674c5 100644 --- a/tests/CoroutineTest.php +++ b/tests/CoroutineTest.php @@ -193,6 +193,8 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseReject }); }); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + unset($promise); $this->assertEquals(0, gc_collect_cycles()); @@ -232,6 +234,8 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorThr yield; // @phpstan-ignore-line }); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + unset($promise); $this->assertEquals(0, gc_collect_cycles()); @@ -249,6 +253,8 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorYie yield 42; }); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + unset($promise); $this->assertEquals(0, gc_collect_cycles()); From 643316a33dc6ef6341c45071416cb796449da651 Mon Sep 17 00:00:00 2001 From: Cees-Jan Kiewiet Date: Thu, 24 Mar 2022 08:03:54 +0100 Subject: [PATCH 35/41] Add template annotations These annotations will aid static analyses like PHPStan and Psalm to enhance type-safety for this project and projects depending on it These changes make the following example understandable by PHPStan: ```php final readonly class User { public function __construct( public string $name, ) } /** * \React\Promise\PromiseInterface */ function getCurrentUserFromDatabase(): \React\Promise\PromiseInterface { // The following line would do the database query and fetch the result from it // but keeping it simple for the sake of the example. return \React\Promise\resolve(new User('WyriHaximus')); } // For the sake of this example we're going to assume the following code runs // in \React\Async\async call echo await(getCurrentUserFromDatabase())->name; // This echos: WyriHaximus ``` --- README.md | 12 ++++---- src/FiberMap.php | 19 ++++++++++--- src/functions.php | 56 ++++++++++++++++++++++++------------ tests/AwaitTest.php | 2 +- tests/CoroutineTest.php | 10 +++---- tests/ParallelTest.php | 3 ++ tests/SeriesTest.php | 6 ++++ tests/WaterfallTest.php | 6 ++++ tests/types/async.php | 17 +++++++++++ tests/types/await.php | 23 +++++++++++++++ tests/types/coroutine.php | 60 +++++++++++++++++++++++++++++++++++++++ tests/types/parallel.php | 33 +++++++++++++++++++++ tests/types/series.php | 33 +++++++++++++++++++++ tests/types/waterfall.php | 42 +++++++++++++++++++++++++++ 14 files changed, 288 insertions(+), 34 deletions(-) create mode 100644 tests/types/async.php create mode 100644 tests/types/await.php create mode 100644 tests/types/coroutine.php create mode 100644 tests/types/parallel.php create mode 100644 tests/types/series.php create mode 100644 tests/types/waterfall.php diff --git a/README.md b/README.md index 6e71f49..d8dd55c 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Async\await(…); ### async() -The `async(callable $function): callable` function can be used to +The `async(callable():(PromiseInterface|T) $function): (callable():PromiseInterface)` function can be used to return an async function for a function that uses [`await()`](#await) internally. This function is specifically designed to complement the [`await()` function](#await). @@ -226,7 +226,7 @@ await($promise); ### await() -The `await(PromiseInterface $promise): mixed` function can be used to +The `await(PromiseInterface $promise): T` function can be used to block waiting for the given `$promise` to be fulfilled. ```php @@ -278,7 +278,7 @@ try { ### coroutine() -The `coroutine(callable $function, mixed ...$args): PromiseInterface` function can be used to +The `coroutine(callable(mixed ...$args):(\Generator|PromiseInterface|T) $function, mixed ...$args): PromiseInterface` function can be used to execute a Generator-based coroutine to "await" promises. ```php @@ -498,7 +498,7 @@ Loop::addTimer(2.0, function () use ($promise): void { ### parallel() -The `parallel(iterable> $tasks): PromiseInterface>` function can be used +The `parallel(iterable> $tasks): PromiseInterface>` function can be used like this: ```php @@ -540,7 +540,7 @@ React\Async\parallel([ ### series() -The `series(iterable> $tasks): PromiseInterface>` function can be used +The `series(iterable> $tasks): PromiseInterface>` function can be used like this: ```php @@ -582,7 +582,7 @@ React\Async\series([ ### waterfall() -The `waterfall(iterable> $tasks): PromiseInterface` function can be used +The `waterfall(iterable> $tasks): PromiseInterface` function can be used like this: ```php diff --git a/src/FiberMap.php b/src/FiberMap.php index 0648788..f843a2d 100644 --- a/src/FiberMap.php +++ b/src/FiberMap.php @@ -6,13 +6,15 @@ /** * @internal + * + * @template T */ final class FiberMap { /** @var array */ private static array $status = []; - /** @var array */ + /** @var array> */ private static array $map = []; /** @param \Fiber $fiber */ @@ -27,19 +29,28 @@ public static function cancel(\Fiber $fiber): void self::$status[\spl_object_id($fiber)] = true; } - /** @param \Fiber $fiber */ + /** + * @param \Fiber $fiber + * @param PromiseInterface $promise + */ public static function setPromise(\Fiber $fiber, PromiseInterface $promise): void { self::$map[\spl_object_id($fiber)] = $promise; } - /** @param \Fiber $fiber */ + /** + * @param \Fiber $fiber + * @param PromiseInterface $promise + */ public static function unsetPromise(\Fiber $fiber, PromiseInterface $promise): void { unset(self::$map[\spl_object_id($fiber)]); } - /** @param \Fiber $fiber */ + /** + * @param \Fiber $fiber + * @return ?PromiseInterface + */ public static function getPromise(\Fiber $fiber): ?PromiseInterface { return self::$map[\spl_object_id($fiber)] ?? null; diff --git a/src/functions.php b/src/functions.php index 5a02406..6c27936 100644 --- a/src/functions.php +++ b/src/functions.php @@ -176,8 +176,14 @@ * await($promise); * ``` * - * @param callable $function - * @return callable(mixed ...): PromiseInterface + * @template T + * @template A1 (any number of function arguments, see https://github.com/phpstan/phpstan/issues/8214) + * @template A2 + * @template A3 + * @template A4 + * @template A5 + * @param callable(A1,A2,A3,A4,A5): (PromiseInterface|T) $function + * @return callable(A1=,A2=,A3=,A4=,A5=): PromiseInterface * @since 4.0.0 * @see coroutine() */ @@ -268,8 +274,9 @@ function async(callable $function): callable * } * ``` * - * @param PromiseInterface $promise - * @return mixed returns whatever the promise resolves to + * @template T + * @param PromiseInterface $promise + * @return T returns whatever the promise resolves to * @throws \Exception when the promise is rejected with an `Exception` * @throws \Throwable when the promise is rejected with a `Throwable` * @throws \UnexpectedValueException when the promise is rejected with an unexpected value (Promise API v1 or v2 only) @@ -279,6 +286,8 @@ function await(PromiseInterface $promise): mixed $fiber = null; $resolved = false; $rejected = false; + + /** @var T $resolvedValue */ $resolvedValue = null; $rejectedThrowable = null; $lowLevelFiber = \Fiber::getCurrent(); @@ -292,6 +301,7 @@ function (mixed $value) use (&$resolved, &$resolvedValue, &$fiber, $lowLevelFibe /** @var ?\Fiber $fiber */ if ($fiber === null) { $resolved = true; + /** @var T $resolvedValue */ $resolvedValue = $value; return; } @@ -305,7 +315,7 @@ function (mixed $throwable) use (&$rejected, &$rejectedThrowable, &$fiber, $lowL if (!$throwable instanceof \Throwable) { $throwable = new \UnexpectedValueException( - 'Promise rejected with unexpected value of type ' . (is_object($throwable) ? get_class($throwable) : gettype($throwable)) + 'Promise rejected with unexpected value of type ' . (is_object($throwable) ? get_class($throwable) : gettype($throwable)) /** @phpstan-ignore-line */ ); // avoid garbage references by replacing all closures in call stack. @@ -592,9 +602,16 @@ function delay(float $seconds): void * }); * ``` * - * @param callable(mixed ...$args):(\Generator|mixed) $function + * @template T + * @template TYield + * @template A1 (any number of function arguments, see https://github.com/phpstan/phpstan/issues/8214) + * @template A2 + * @template A3 + * @template A4 + * @template A5 + * @param callable(A1, A2, A3, A4, A5):(\Generator, TYield, PromiseInterface|T>|PromiseInterface|T) $function * @param mixed ...$args Optional list of additional arguments that will be passed to the given `$function` as is - * @return PromiseInterface + * @return PromiseInterface * @since 3.0.0 */ function coroutine(callable $function, mixed ...$args): PromiseInterface @@ -611,7 +628,7 @@ function coroutine(callable $function, mixed ...$args): PromiseInterface $promise = null; $deferred = new Deferred(function () use (&$promise) { - /** @var ?PromiseInterface $promise */ + /** @var ?PromiseInterface $promise */ if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { $promise->cancel(); } @@ -632,7 +649,6 @@ function coroutine(callable $function, mixed ...$args): PromiseInterface return; } - /** @var mixed $promise */ $promise = $generator->current(); if (!$promise instanceof PromiseInterface) { $next = null; @@ -642,6 +658,7 @@ function coroutine(callable $function, mixed ...$args): PromiseInterface return; } + /** @var PromiseInterface $promise */ assert($next instanceof \Closure); $promise->then(function ($value) use ($generator, $next) { $generator->send($value); @@ -660,12 +677,13 @@ function coroutine(callable $function, mixed ...$args): PromiseInterface } /** - * @param iterable> $tasks - * @return PromiseInterface> + * @template T + * @param iterable|T)> $tasks + * @return PromiseInterface> */ function parallel(iterable $tasks): PromiseInterface { - /** @var array $pending */ + /** @var array> $pending */ $pending = []; $deferred = new Deferred(function () use (&$pending) { foreach ($pending as $promise) { @@ -720,14 +738,15 @@ function parallel(iterable $tasks): PromiseInterface } /** - * @param iterable> $tasks - * @return PromiseInterface> + * @template T + * @param iterable|T)> $tasks + * @return PromiseInterface> */ function series(iterable $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { - /** @var ?PromiseInterface $pending */ + /** @var ?PromiseInterface $pending */ if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { $pending->cancel(); } @@ -774,14 +793,15 @@ function series(iterable $tasks): PromiseInterface } /** - * @param iterable<(callable():PromiseInterface)|(callable(mixed):PromiseInterface)> $tasks - * @return PromiseInterface + * @template T + * @param iterable<(callable():(PromiseInterface|T))|(callable(mixed):(PromiseInterface|T))> $tasks + * @return PromiseInterface<($tasks is non-empty-array|\Traversable ? T : null)> */ function waterfall(iterable $tasks): PromiseInterface { $pending = null; $deferred = new Deferred(function () use (&$pending) { - /** @var ?PromiseInterface $pending */ + /** @var ?PromiseInterface $pending */ if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { $pending->cancel(); } diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index 3158a1b..25e269b 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -413,7 +413,7 @@ public function testRejectedPromisesShouldBeDetached(callable $await): void })()); } - /** @return iterable> */ + /** @return iterable): mixed>> */ public function provideAwaiters(): iterable { yield 'await' => [static fn (React\Promise\PromiseInterface $promise): mixed => React\Async\await($promise)]; diff --git a/tests/CoroutineTest.php b/tests/CoroutineTest.php index 2c674c5..1df4cdc 100644 --- a/tests/CoroutineTest.php +++ b/tests/CoroutineTest.php @@ -22,7 +22,7 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsImmediately { $promise = coroutine(function () { if (false) { // @phpstan-ignore-line - yield; + yield resolve(null); } return 42; }); @@ -53,7 +53,7 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsImmediately() { $promise = coroutine(function () { if (false) { // @phpstan-ignore-line - yield; + yield resolve(null); } throw new \RuntimeException('Foo'); }); @@ -99,7 +99,7 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldi public function testCoroutineReturnsRejectedPromiseIfFunctionYieldsInvalidValue(): void { - $promise = coroutine(function () { + $promise = coroutine(function () { // @phpstan-ignore-line yield 42; }); @@ -169,7 +169,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorRet $promise = coroutine(function () { if (false) { // @phpstan-ignore-line - yield; + yield resolve(null); } return 42; }); @@ -249,7 +249,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorYie gc_collect_cycles(); - $promise = coroutine(function () { + $promise = coroutine(function () { // @phpstan-ignore-line yield 42; }); diff --git a/tests/ParallelTest.php b/tests/ParallelTest.php index 37b1e10..ad24589 100644 --- a/tests/ParallelTest.php +++ b/tests/ParallelTest.php @@ -12,6 +12,9 @@ class ParallelTest extends TestCase { public function testParallelWithoutTasks(): void { + /** + * @var array> $tasks + */ $tasks = array(); $promise = React\Async\parallel($tasks); diff --git a/tests/SeriesTest.php b/tests/SeriesTest.php index 9b20815..69cafd5 100644 --- a/tests/SeriesTest.php +++ b/tests/SeriesTest.php @@ -12,6 +12,9 @@ class SeriesTest extends TestCase { public function testSeriesWithoutTasks(): void { + /** + * @var array> $tasks + */ $tasks = array(); $promise = React\Async\series($tasks); @@ -151,6 +154,9 @@ public function testSeriesWithErrorFromInfiniteIteratorAggregateReturnsPromiseRe $tasks = new class() implements \IteratorAggregate { public int $called = 0; + /** + * @return \Iterator> + */ public function getIterator(): \Iterator { while (true) { // @phpstan-ignore-line diff --git a/tests/WaterfallTest.php b/tests/WaterfallTest.php index 2b274b2..be174a9 100644 --- a/tests/WaterfallTest.php +++ b/tests/WaterfallTest.php @@ -12,6 +12,9 @@ class WaterfallTest extends TestCase { public function testWaterfallWithoutTasks(): void { + /** + * @var array> $tasks + */ $tasks = array(); $promise = React\Async\waterfall($tasks); @@ -165,6 +168,9 @@ public function testWaterfallWithErrorFromInfiniteIteratorAggregateReturnsPromis $tasks = new class() implements \IteratorAggregate { public int $called = 0; + /** + * @return \Iterator> + */ public function getIterator(): \Iterator { while (true) { // @phpstan-ignore-line diff --git a/tests/types/async.php b/tests/types/async.php new file mode 100644 index 0000000..b5ba8fe --- /dev/null +++ b/tests/types/async.php @@ -0,0 +1,17 @@ +', async(static fn (): bool => true)()); +assertType('React\Promise\PromiseInterface', async(static fn (): PromiseInterface => resolve(true))()); +assertType('React\Promise\PromiseInterface', async(static fn (): bool => await(resolve(true)))()); + +assertType('React\Promise\PromiseInterface', async(static fn (int $a): int => $a)(42)); +assertType('React\Promise\PromiseInterface', async(static fn (int $a, int $b): int => $a + $b)(10, 32)); +assertType('React\Promise\PromiseInterface', async(static fn (int $a, int $b, int $c): int => $a + $b + $c)(10, 22, 10)); +assertType('React\Promise\PromiseInterface', async(static fn (int $a, int $b, int $c, int $d): int => $a + $b + $c + $d)(10, 22, 5, 5)); +assertType('React\Promise\PromiseInterface', async(static fn (int $a, int $b, int $c, int $d, int $e): int => $a + $b + $c + $d + $e)(10, 12, 10, 5, 5)); diff --git a/tests/types/await.php b/tests/types/await.php new file mode 100644 index 0000000..07d51b6 --- /dev/null +++ b/tests/types/await.php @@ -0,0 +1,23 @@ + true)())); +assertType('bool', await(async(static fn (): PromiseInterface => resolve(true))())); +assertType('bool', await(async(static fn (): bool => await(resolve(true)))())); + +final class AwaitExampleUser +{ + public string $name; + + public function __construct(string $name) { + $this->name = $name; + } +} + +assertType('string', await(resolve(new AwaitExampleUser('WyriHaximus')))->name); diff --git a/tests/types/coroutine.php b/tests/types/coroutine.php new file mode 100644 index 0000000..4c0f84c --- /dev/null +++ b/tests/types/coroutine.php @@ -0,0 +1,60 @@ +', coroutine(static function () { + return true; +})); + +assertType('React\Promise\PromiseInterface', coroutine(static function () { + return resolve(true); +})); + +// assertType('React\Promise\PromiseInterface', coroutine(static function () { +// return (yield resolve(true)); +// })); + +assertType('React\Promise\PromiseInterface', coroutine(static function () { +// $bool = yield resolve(true); +// assertType('bool', $bool); + + return time(); +})); + +// assertType('React\Promise\PromiseInterface', coroutine(static function () { +// $bool = yield resolve(true); +// assertType('bool', $bool); + +// return $bool; +// })); + +assertType('React\Promise\PromiseInterface', coroutine(static function () { + yield resolve(time()); + + return true; +})); + +assertType('React\Promise\PromiseInterface', coroutine(static function () { + for ($i = 0; $i <= 10; $i++) { + yield resolve($i); + } + + return true; +})); + +assertType('React\Promise\PromiseInterface', coroutine(static fn(int $a): int => $a, 42)); +assertType('React\Promise\PromiseInterface', coroutine(static fn(int $a, int $b): int => $a + $b, 10, 32)); +assertType('React\Promise\PromiseInterface', coroutine(static fn(int $a, int $b, int $c): int => $a + $b + $c, 10, 22, 10)); +assertType('React\Promise\PromiseInterface', coroutine(static fn(int $a, int $b, int $c, int $d): int => $a + $b + $c + $d, 10, 22, 5, 5)); +assertType('React\Promise\PromiseInterface', coroutine(static fn(int $a, int $b, int $c, int $d, int $e): int => $a + $b + $c + $d + $e, 10, 12, 10, 5, 5)); + +assertType('bool', await(coroutine(static function () { + return true; +}))); + +// assertType('bool', await(coroutine(static function () { +// return (yield resolve(true)); +// }))); diff --git a/tests/types/parallel.php b/tests/types/parallel.php new file mode 100644 index 0000000..dacd024 --- /dev/null +++ b/tests/types/parallel.php @@ -0,0 +1,33 @@ +', parallel([])); + +assertType('React\Promise\PromiseInterface>', parallel([ + static fn (): PromiseInterface => resolve(true), + static fn (): PromiseInterface => resolve(time()), + static fn (): PromiseInterface => resolve(microtime(true)), +])); + +assertType('React\Promise\PromiseInterface>', parallel([ + static fn (): bool => true, + static fn (): int => time(), + static fn (): float => microtime(true), +])); + +assertType('array', await(parallel([ + static fn (): PromiseInterface => resolve(true), + static fn (): PromiseInterface => resolve(time()), + static fn (): PromiseInterface => resolve(microtime(true)), +]))); + +assertType('array', await(parallel([ + static fn (): bool => true, + static fn (): int => time(), + static fn (): float => microtime(true), +]))); diff --git a/tests/types/series.php b/tests/types/series.php new file mode 100644 index 0000000..9a233e3 --- /dev/null +++ b/tests/types/series.php @@ -0,0 +1,33 @@ +', series([])); + +assertType('React\Promise\PromiseInterface>', series([ + static fn (): PromiseInterface => resolve(true), + static fn (): PromiseInterface => resolve(time()), + static fn (): PromiseInterface => resolve(microtime(true)), +])); + +assertType('React\Promise\PromiseInterface>', series([ + static fn (): bool => true, + static fn (): int => time(), + static fn (): float => microtime(true), +])); + +assertType('array', await(series([ + static fn (): PromiseInterface => resolve(true), + static fn (): PromiseInterface => resolve(time()), + static fn (): PromiseInterface => resolve(microtime(true)), +]))); + +assertType('array', await(series([ + static fn (): bool => true, + static fn (): int => time(), + static fn (): float => microtime(true), +]))); diff --git a/tests/types/waterfall.php b/tests/types/waterfall.php new file mode 100644 index 0000000..1470785 --- /dev/null +++ b/tests/types/waterfall.php @@ -0,0 +1,42 @@ +', waterfall([])); + +assertType('React\Promise\PromiseInterface', waterfall([ + static fn (): PromiseInterface => resolve(microtime(true)), +])); + +assertType('React\Promise\PromiseInterface', waterfall([ + static fn (): float => microtime(true), +])); + +// Desired, but currently unsupported with the current set of templates +//assertType('React\Promise\PromiseInterface', waterfall([ +// static fn (): PromiseInterface => resolve(true), +// static fn (bool $bool): PromiseInterface => resolve(time()), +// static fn (int $int): PromiseInterface => resolve(microtime(true)), +//])); + +assertType('float', await(waterfall([ + static fn (): PromiseInterface => resolve(microtime(true)), +]))); + +// Desired, but currently unsupported with the current set of templates +//assertType('float', await(waterfall([ +// static fn (): PromiseInterface => resolve(true), +// static fn (bool $bool): PromiseInterface => resolve(time()), +// static fn (int $int): PromiseInterface => resolve(microtime(true)), +//]))); + +// assertType('React\Promise\PromiseInterface', waterfall(new EmptyIterator())); + +$iterator = new ArrayIterator([ + static fn (): PromiseInterface => resolve(true), +]); +assertType('React\Promise\PromiseInterface', waterfall($iterator)); From 9eb633253fb4595c51c41223de1973cdf6ca789f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Sun, 1 Oct 2023 12:40:11 +0200 Subject: [PATCH 36/41] Run tests on PHP 8.3 and update test suite --- .github/workflows/ci.yml | 6 ++++-- composer.json | 8 +++++--- phpunit.xml.dist | 4 ++-- src/functions_include.php | 3 ++- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75213de..749ba36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,10 +11,11 @@ jobs: strategy: matrix: php: + - 8.3 - 8.2 - 8.1 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} @@ -29,10 +30,11 @@ jobs: strategy: matrix: php: + - 8.3 - 8.2 - 8.1 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} diff --git a/composer.json b/composer.json index 848dd38..9c2f076 100644 --- a/composer.json +++ b/composer.json @@ -31,8 +31,8 @@ "react/promise": "^3.0 || ^2.8 || ^1.2.1" }, "require-dev": { - "phpstan/phpstan": "1.10.18", - "phpunit/phpunit": "^9.5" + "phpstan/phpstan": "1.10.39", + "phpunit/phpunit": "^9.6" }, "autoload": { "psr-4": { @@ -43,6 +43,8 @@ ] }, "autoload-dev": { - "psr-4": { "React\\Tests\\Async\\": "tests/" } + "psr-4": { + "React\\Tests\\Async\\": "tests/" + } } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 63ec536..bc79560 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ - + diff --git a/src/functions_include.php b/src/functions_include.php index 92a7439..05c78fa 100644 --- a/src/functions_include.php +++ b/src/functions_include.php @@ -3,6 +3,7 @@ namespace React\Async; // @codeCoverageIgnoreStart -if (!function_exists(__NAMESPACE__ . '\\parallel')) { +if (!\function_exists(__NAMESPACE__ . '\\parallel')) { require __DIR__ . '/functions.php'; } +// @codeCoverageIgnoreEnd From 7c3738e837b38c9513af44398b8c1b2b1be1fbbc Mon Sep 17 00:00:00 2001 From: Simon Frings Date: Wed, 22 Nov 2023 17:43:46 +0100 Subject: [PATCH 37/41] Prepare v4.2.0 release --- CHANGELOG.md | 21 +++++++++++++++++++++ README.md | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aff589..40be74e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## 4.2.0 (2023-11-22) + +* Feature: Add Promise v3 template types for all public functions. + (#40 by @WyriHaximus) + + All our public APIs now use Promise v3 template types to guide IDEs and static + analysis tools (like PHPStan), helping with proper type usage and improving + code quality: + + ```php + assertType('bool', await(resolve(true))); + assertType('PromiseInterface', async(fn(): bool => true)()); + assertType('PromiseInterface', coroutine(fn(): bool => true)); + ``` + +* Feature: Full PHP 8.3 compatibility. + (#81 by @clue) + +* Update test suite to avoid unhandled promise rejections. + (#79 by @clue) + ## 4.1.0 (2023-06-22) * Feature: Add new `delay()` function to delay program execution. diff --git a/README.md b/README.md index d8dd55c..f39b98c 100644 --- a/README.md +++ b/README.md @@ -623,7 +623,7 @@ This project follows [SemVer](https://semver.org/). This will install the latest supported version from this branch: ```bash -composer require react/async:^4.1 +composer require react/async:^4.2 ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades. From 6a15425dff13307037bf3aa4b1accad610a3c628 Mon Sep 17 00:00:00 2001 From: Simon Frings Date: Thu, 2 May 2024 14:24:44 +0200 Subject: [PATCH 38/41] Use Promise v3 template types --- src/functions.php | 7 +++++++ tests/AwaitTest.php | 6 +++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/functions.php b/src/functions.php index 6c27936..8d314d8 100644 --- a/src/functions.php +++ b/src/functions.php @@ -191,6 +191,7 @@ function async(callable $function): callable { return static function (mixed ...$args) use ($function): PromiseInterface { $fiber = null; + /** @var PromiseInterface $promise*/ $promise = new Promise(function (callable $resolve, callable $reject) use ($function, $args, &$fiber): void { $fiber = new \Fiber(function () use ($resolve, $reject, $function, $args, &$fiber): void { try { @@ -627,6 +628,7 @@ function coroutine(callable $function, mixed ...$args): PromiseInterface } $promise = null; + /** @var Deferred $deferred*/ $deferred = new Deferred(function () use (&$promise) { /** @var ?PromiseInterface $promise */ if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { @@ -685,6 +687,7 @@ function parallel(iterable $tasks): PromiseInterface { /** @var array> $pending */ $pending = []; + /** @var Deferred> $deferred */ $deferred = new Deferred(function () use (&$pending) { foreach ($pending as $promise) { if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { @@ -734,6 +737,7 @@ function parallel(iterable $tasks): PromiseInterface $deferred->resolve($results); } + /** @var PromiseInterface> Remove once defining `Deferred()` above is supported by PHPStan, see https://github.com/phpstan/phpstan/issues/11032 */ return $deferred->promise(); } @@ -745,6 +749,7 @@ function parallel(iterable $tasks): PromiseInterface function series(iterable $tasks): PromiseInterface { $pending = null; + /** @var Deferred> $deferred */ $deferred = new Deferred(function () use (&$pending) { /** @var ?PromiseInterface $pending */ if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { @@ -789,6 +794,7 @@ function series(iterable $tasks): PromiseInterface $next(); + /** @var PromiseInterface> Remove once defining `Deferred()` above is supported by PHPStan, see https://github.com/phpstan/phpstan/issues/11032 */ return $deferred->promise(); } @@ -800,6 +806,7 @@ function series(iterable $tasks): PromiseInterface function waterfall(iterable $tasks): PromiseInterface { $pending = null; + /** @var Deferred $deferred*/ $deferred = new Deferred(function () use (&$pending) { /** @var ?PromiseInterface $pending */ if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index 25e269b..7eced26 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -129,7 +129,7 @@ public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWith } $promise = new Promise(function ($_, $reject) { - $reject(false); + $reject(false); // @phpstan-ignore-line }); $this->expectException(\UnexpectedValueException::class); @@ -147,7 +147,7 @@ public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWith } $promise = new Promise(function ($_, $reject) { - $reject(null); + $reject(null); // @phpstan-ignore-line }); try { @@ -331,7 +331,7 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWi gc_collect_cycles(); $promise = new Promise(function ($_, $reject) { - $reject(null); + $reject(null); // @phpstan-ignore-line }); try { $await($promise); From 221956d97f6efdb239d102afdce4b1a13a1ea5a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Wed, 18 Oct 2023 18:26:12 +0200 Subject: [PATCH 39/41] Improve performance by avoiding unneeded references in `FiberMap` --- src/FiberMap.php | 24 +----------------------- src/functions.php | 13 +++++-------- 2 files changed, 6 insertions(+), 31 deletions(-) diff --git a/src/FiberMap.php b/src/FiberMap.php index f843a2d..5996ec5 100644 --- a/src/FiberMap.php +++ b/src/FiberMap.php @@ -11,24 +11,9 @@ */ final class FiberMap { - /** @var array */ - private static array $status = []; - /** @var array> */ private static array $map = []; - /** @param \Fiber $fiber */ - public static function register(\Fiber $fiber): void - { - self::$status[\spl_object_id($fiber)] = false; - } - - /** @param \Fiber $fiber */ - public static function cancel(\Fiber $fiber): void - { - self::$status[\spl_object_id($fiber)] = true; - } - /** * @param \Fiber $fiber * @param PromiseInterface $promise @@ -40,9 +25,8 @@ public static function setPromise(\Fiber $fiber, PromiseInterface $promise): voi /** * @param \Fiber $fiber - * @param PromiseInterface $promise */ - public static function unsetPromise(\Fiber $fiber, PromiseInterface $promise): void + public static function unsetPromise(\Fiber $fiber): void { unset(self::$map[\spl_object_id($fiber)]); } @@ -55,10 +39,4 @@ public static function getPromise(\Fiber $fiber): ?PromiseInterface { return self::$map[\spl_object_id($fiber)] ?? null; } - - /** @param \Fiber $fiber */ - public static function unregister(\Fiber $fiber): void - { - unset(self::$status[\spl_object_id($fiber)], self::$map[\spl_object_id($fiber)]); - } } diff --git a/src/functions.php b/src/functions.php index 8d314d8..bcf40c1 100644 --- a/src/functions.php +++ b/src/functions.php @@ -200,16 +200,13 @@ function async(callable $function): callable $reject($exception); } finally { assert($fiber instanceof \Fiber); - FiberMap::unregister($fiber); + FiberMap::unsetPromise($fiber); } }); - FiberMap::register($fiber); - $fiber->start(); }, function () use (&$fiber): void { assert($fiber instanceof \Fiber); - FiberMap::cancel($fiber); $promise = FiberMap::getPromise($fiber); if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { $promise->cancel(); @@ -294,9 +291,9 @@ function await(PromiseInterface $promise): mixed $lowLevelFiber = \Fiber::getCurrent(); $promise->then( - function (mixed $value) use (&$resolved, &$resolvedValue, &$fiber, $lowLevelFiber, $promise): void { + function (mixed $value) use (&$resolved, &$resolvedValue, &$fiber, $lowLevelFiber): void { if ($lowLevelFiber !== null) { - FiberMap::unsetPromise($lowLevelFiber, $promise); + FiberMap::unsetPromise($lowLevelFiber); } /** @var ?\Fiber $fiber */ @@ -309,9 +306,9 @@ function (mixed $value) use (&$resolved, &$resolvedValue, &$fiber, $lowLevelFibe $fiber->resume($value); }, - function (mixed $throwable) use (&$rejected, &$rejectedThrowable, &$fiber, $lowLevelFiber, $promise): void { + function (mixed $throwable) use (&$rejected, &$rejectedThrowable, &$fiber, $lowLevelFiber): void { if ($lowLevelFiber !== null) { - FiberMap::unsetPromise($lowLevelFiber, $promise); + FiberMap::unsetPromise($lowLevelFiber); } if (!$throwable instanceof \Throwable) { From 0409cb2f841a1391dae562b9a4e38b437ca6f376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Mon, 20 May 2024 18:46:25 +0200 Subject: [PATCH 40/41] Improve PHP 8.4+ support by avoiding implicitly nullable types --- composer.json | 2 +- src/FiberFactory.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 9c2f076..5d4082b 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ "require": { "php": ">=8.1", "react/event-loop": "^1.2", - "react/promise": "^3.0 || ^2.8 || ^1.2.1" + "react/promise": "^3.2 || ^2.8 || ^1.2.1" }, "require-dev": { "phpstan/phpstan": "1.10.39", diff --git a/src/FiberFactory.php b/src/FiberFactory.php index 93480e6..ee90818 100644 --- a/src/FiberFactory.php +++ b/src/FiberFactory.php @@ -22,7 +22,7 @@ public static function create(): FiberInterface return (self::factory())(); } - public static function factory(\Closure $factory = null): \Closure + public static function factory(?\Closure $factory = null): \Closure { if ($factory !== null) { self::$factory = $factory; From 635d50e30844a484495713e8cb8d9e079c0008a5 Mon Sep 17 00:00:00 2001 From: Simon Frings Date: Tue, 4 Jun 2024 16:39:05 +0200 Subject: [PATCH 41/41] Prepare v4.3.0 release --- CHANGELOG.md | 15 +++++++++++++-- README.md | 2 +- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40be74e..bafac9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,20 @@ # Changelog +## 4.3.0 (2024-06-04) + +* Feature: Improve performance by avoiding unneeded references in `FiberMap`. + (#88 by @clue) + +* Feature: Improve PHP 8.4+ support by avoiding implicitly nullable type declarations. + (#87 by @clue) + +* Improve type safety for test environment. + (#86 by @SimonFrings) + ## 4.2.0 (2023-11-22) * Feature: Add Promise v3 template types for all public functions. - (#40 by @WyriHaximus) + (#40 by @WyriHaximus and @clue) All our public APIs now use Promise v3 template types to guide IDEs and static analysis tools (like PHPStan), helping with proper type usage and improving @@ -24,7 +35,7 @@ ## 4.1.0 (2023-06-22) * Feature: Add new `delay()` function to delay program execution. - (#69 by @clue) + (#69 and #78 by @clue) ```php echo 'a'; diff --git a/README.md b/README.md index f39b98c..9a49cde 100644 --- a/README.md +++ b/README.md @@ -623,7 +623,7 @@ This project follows [SemVer](https://semver.org/). This will install the latest supported version from this branch: ```bash -composer require react/async:^4.2 +composer require react/async:^4.3 ``` See also the [CHANGELOG](CHANGELOG.md) for details about version upgrades.