diff --git a/.gitattributes b/.gitattributes index 21be40c..838c8fa 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,6 @@ /.gitattributes export-ignore /.github/ export-ignore /.gitignore export-ignore +/phpstan.neon.dist 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 465821a..749ba36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,29 +7,37 @@ on: jobs: PHPUnit: name: PHPUnit (PHP ${{ matrix.php }}) - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: php: + - 8.3 + - 8.2 - 8.1 - - 8.0 - - 7.4 - - 7.3 - - 7.2 - - 7.1 - - 7.0 - - 5.6 - - 5.5 - - 5.4 - - 5.3 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} coverage: xdebug + ini-file: development - 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 }} + + PHPStan: + name: PHPStan (PHP ${{ matrix.php }}) + runs-on: ubuntu-22.04 + strategy: + matrix: + php: + - 8.3 + - 8.2 + - 8.1 + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + - run: composer install + - run: vendor/bin/phpstan diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d17161..bafac9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,112 @@ -CHANGELOG -========= +# Changelog -* 1.0.0 (2013-02-07) +## 4.3.0 (2024-06-04) - * First tagged release +* 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 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 + 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. + (#69 and #78 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). + +* 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 13ed842..9a49cde 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ -# Async +# 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/). +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. @@ -16,7 +17,10 @@ an event loop, it can be used with this library. **Table of Contents** * [Usage](#usage) + * [async()](#async) * [await()](#await) + * [coroutine()](#coroutine) + * [delay()](#delay) * [parallel()](#parallel) * [series()](#series) * [waterfall()](#waterfall) @@ -52,9 +56,177 @@ use React\Async; Async\await(…); ``` +### async() + +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). +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, function () { + 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, function () { + 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; +}); +``` + +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 [`delay()`](#delay). +The [`await()`](#await) calls in this example would throw a `RuntimeException` +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'; + delay(2); + echo 'c'; + })()); + echo 'd'; + + return time(); +})(); + +$promise->cancel(); +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 @@ -62,21 +234,36 @@ $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, function () { + 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. Once the promise is rejected, this will throw whatever the promise rejected -with. If the promise did not reject with an `Exception` or `Throwable` (PHP 7+), -then this function will throw an `UnexpectedValueException` instead. +with. If the promise did not reject with an `Exception` or `Throwable`, then +this function will throw an `UnexpectedValueException` instead. ```php try { @@ -89,9 +276,229 @@ try { } ``` +### coroutine() + +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 +React\Async\coroutine(function () { + $browser = new React\Http\Browser(); + + try { + $response = yield $browser->get('https://example.com/'); + assert($response instanceof Psr\Http\Message\ResponseInterface); + echo $response->getBody(); + } catch (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; + } +}); +``` + +Using Generator-based coroutines is an alternative to directly using the +underlying promise APIs. For many use cases, this makes using promise-based +APIs much simpler, as it resembles a synchronous code flow more closely. +The above example performs the equivalent of directly using the promise APIs: + +```php +$browser = new React\Http\Browser(); + +$browser->get('https://example.com/')->then(function (Psr\Http\Message\ResponseInterface $response) { + echo $response->getBody(); +}, function (Exception $e) { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +The `yield` keyword can be used to "await" a promise resolution. Internally, +it will turn the entire given `$function` into a [`Generator`](https://www.php.net/manual/en/class.generator.php). +This allows the execution to be interrupted and resumed at the same place +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 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\coroutine(function () { + $browser = new React\Http\Browser(); + $urls = [ + 'https://example.com/alice', + 'https://example.com/bob' + ]; + + $bytes = 0; + foreach ($urls as $url) { + $response = yield $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 a `yield` statement 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 `yield` together with Promise-based primitives +like this: + +```php +$promise = React\Async\coroutine(function () { + $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 = yield 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; +}); +``` + +### 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 (): void { + 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 (): void { + echo 'a'; + React\Async\delay(1.0); + echo 'c'; +})); + +Loop::addTimer(1.0, function (): void { + 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 (): void { + echo 'a'; + delay(3.0); + echo 'b'; +})(); + +Loop::addTimer(2.0, function () use ($promise): void { + $promise->cancel(); +}); + +// prints "a" at t=0.0s +// rejects $promise at t=2.0 +// never prints "b" +``` + ### parallel() -The `parallel(array> $tasks): PromiseInterface,Exception>` function can be used +The `parallel(iterable> $tasks): PromiseInterface>` function can be used like this: ```php @@ -133,7 +540,7 @@ React\Async\parallel([ ### series() -The `series(array> $tasks): PromiseInterface,Exception>` function can be used +The `series(iterable> $tasks): PromiseInterface>` function can be used like this: ```php @@ -175,7 +582,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 @@ -212,32 +619,50 @@ 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:dev-main +composer require react/async:^4.3 ``` 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 legacy PHP 5.3 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 +[`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 like this: + +```bash +composer require "react/async:^4 || ^3 || ^2" +``` + ## Tests 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 +``` + +On top of this, we use PHPStan on max level to ensure type safety across the project: + +```bash +vendor/bin/phpstan ``` ## License diff --git a/composer.json b/composer.json index 609b5f5..5d4082b 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": [ @@ -26,19 +26,25 @@ } ], "require": { - "php": ">=5.3.2", + "php": ">=8.1", "react/event-loop": "^1.2", - "react/promise": "^2.8 || ^1.2.1" + "react/promise": "^3.2 || ^2.8 || ^1.2.1" }, "require-dev": { - "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35" + "phpstan/phpstan": "1.10.39", + "phpunit/phpunit": "^9.6" }, "autoload": { + "psr-4": { + "React\\Async\\": "src/" + }, "files": [ "src/functions_include.php" ] }, "autoload-dev": { - "psr-4": { "React\\Tests\\Async\\": "tests/" } + "psr-4": { + "React\\Tests\\Async\\": "tests/" + } } } diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..b7f8ddb --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,11 @@ +parameters: + level: max + + 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/phpunit.xml.dist b/phpunit.xml.dist index fa88e7e..bc79560 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,11 +1,11 @@ - - + convertDeprecationsToExceptions="true"> ./tests/ @@ -16,4 +16,12 @@ ./src/ + + + + + + + + diff --git a/phpunit.xml.legacy b/phpunit.xml.legacy deleted file mode 100644 index fbb43e8..0000000 --- a/phpunit.xml.legacy +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - ./tests/ - - - - - ./src/ - - - diff --git a/src/FiberFactory.php b/src/FiberFactory.php new file mode 100644 index 0000000..ee90818 --- /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..e40304e --- /dev/null +++ b/src/FiberInterface.php @@ -0,0 +1,23 @@ +> */ + private static array $map = []; + + /** + * @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 + */ + public static function unsetPromise(\Fiber $fiber): void + { + unset(self::$map[\spl_object_id($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/SimpleFiber.php b/src/SimpleFiber.php new file mode 100644 index 0000000..8c5460a --- /dev/null +++ b/src/SimpleFiber.php @@ -0,0 +1,79 @@ + */ + private static ?\Fiber $scheduler = null; + + private static ?\Closure $suspend = null; + + /** @var ?\Fiber */ + private ?\Fiber $fiber = null; + + public function __construct() + { + $this->fiber = \Fiber::getCurrent(); + } + + public function resume(mixed $value): void + { + if ($this->fiber !== null) { + $this->fiber->resume($value); + } else { + self::$suspend = static fn() => $value; + } + + if (self::$suspend !== null && \Fiber::getCurrent() === self::$scheduler) { + $suspend = self::$suspend; + self::$suspend = null; + + \Fiber::suspend($suspend); + } + } + + public function throw(\Throwable $throwable): void + { + if ($this->fiber !== null) { + $this->fiber->throw($throwable); + } else { + self::$suspend = static fn() => throw $throwable; + } + + if (self::$suspend !== null && \Fiber::getCurrent() === self::$scheduler) { + $suspend = self::$suspend; + self::$suspend = null; + + \Fiber::suspend($suspend); + } + } + + 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 { + assert(self::$scheduler instanceof \Fiber); + if (self::$scheduler->isSuspended()) { + self::$scheduler->resume(); + } + }); + } + + $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 0f2ef7e..bcf40c1 100644 --- a/src/functions.php +++ b/src/functions.php @@ -3,37 +3,267 @@ namespace React\Async; use React\EventLoop\Loop; -use React\Promise\CancellablePromiseInterface; +use React\EventLoop\TimerInterface; use React\Promise\Deferred; +use React\Promise\Promise; use React\Promise\PromiseInterface; +use function React\Promise\reject; +use function React\Promise\resolve; + +/** + * 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, function () { + * 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, function () { + * 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; + * }); + * ``` + * + * 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 [`delay()`](#delay). + * The [`await()`](#await) calls in this example would throw a `RuntimeException` + * 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'; + * delay(2); + * echo 'c'; + * })()); + * echo 'd'; + * + * return time(); + * })(); + * + * $promise->cancel(); + * await($promise); + * ``` + * + * @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() + */ +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 { + $resolve($function(...$args)); + } catch (\Throwable $exception) { + $reject($exception); + } finally { + assert($fiber instanceof \Fiber); + FiberMap::unsetPromise($fiber); + } + }); + + $fiber->start(); + }, function () use (&$fiber): void { + assert($fiber instanceof \Fiber); + $promise = FiberMap::getPromise($fiber); + if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { + $promise->cancel(); + } + }); + + $lowLevelFiber = \Fiber::getCurrent(); + if ($lowLevelFiber !== null) { + FiberMap::setPromise($lowLevelFiber, $promise); + } + + return $promise; + }; +} /** * 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, function () { + * 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. * * Once the promise is rejected, this will throw whatever the promise rejected - * with. If the promise did not reject with an `Exception` or `Throwable` (PHP 7+), - * then this function will throw an `UnexpectedValueException` instead. + * with. If the promise did not reject with an `Exception` or `Throwable`, then + * this function will throw an `UnexpectedValueException` instead. * * ```php * try { - * $result = React\Async\await($promise, $loop); + * $result = React\Async\await($promise); * // promise successfully fulfilled with $result * echo 'Result: ' . $result; * } catch (Throwable $e) { @@ -42,142 +272,517 @@ * } * ``` * - * @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` (PHP 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; - $exception = null; + $fiber = null; + $resolved = false; $rejected = false; + /** @var T $resolvedValue */ + $resolvedValue = null; + $rejectedThrowable = null; + $lowLevelFiber = \Fiber::getCurrent(); + $promise->then( - function ($c) use (&$resolved, &$wait) { - $resolved = $c; - $wait = false; - Loop::stop(); + function (mixed $value) use (&$resolved, &$resolvedValue, &$fiber, $lowLevelFiber): void { + if ($lowLevelFiber !== null) { + FiberMap::unsetPromise($lowLevelFiber); + } + + /** @var ?\Fiber $fiber */ + if ($fiber === null) { + $resolved = true; + /** @var T $resolvedValue */ + $resolvedValue = $value; + return; + } + + $fiber->resume($value); }, - function ($error) use (&$exception, &$rejected, &$wait) { - $exception = $error; - $rejected = true; - $wait = false; - Loop::stop(); + function (mixed $throwable) use (&$rejected, &$rejectedThrowable, &$fiber, $lowLevelFiber): void { + if ($lowLevelFiber !== null) { + FiberMap::unsetPromise($lowLevelFiber); + } + + if (!$throwable instanceof \Throwable) { + $throwable = new \UnexpectedValueException( + '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. + // 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 + 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) { + $rejected = true; + $rejectedThrowable = $throwable; + return; + } + + $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 ($resolved) { + return $resolvedValue; } if ($rejected) { - // promise is rejected with an unexpected value (Promise API v1 or v2 only) - if (!$exception instanceof \Exception && !$exception instanceof \Throwable) { - $exception = new \UnexpectedValueException( - 'Promise rejected with unexpected value of type ' . (is_object($exception) ? get_class($exception) : gettype($exception)) - ); - } + assert($rejectedThrowable instanceof \Throwable); + throw $rejectedThrowable; + } - throw $exception; + if ($lowLevelFiber !== null) { + FiberMap::setPromise($lowLevelFiber, $promise); } - return $resolved; + $fiber = FiberFactory::create(); + + return $fiber->suspend(); } /** - * @param array> $tasks - * @return PromiseInterface,Exception> + * 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 (): void { + * 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 (): void { + * echo 'a'; + * React\Async\delay(1.0); + * echo 'c'; + * })); + * + * Loop::addTimer(1.0, function (): void { + * 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 (): void { + * echo 'a'; + * delay(3.0); + * echo 'b'; + * })(); + * + * Loop::addTimer(2.0, function () use ($promise): void { + * $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 parallel(array $tasks) +function delay(float $seconds): void { - $pending = array(); + /** @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. + * + * ```php + * React\Async\coroutine(function () { + * $browser = new React\Http\Browser(); + * + * try { + * $response = yield $browser->get('https://example.com/'); + * assert($response instanceof Psr\Http\Message\ResponseInterface); + * echo $response->getBody(); + * } catch (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * } + * }); + * ``` + * + * Using Generator-based coroutines is an alternative to directly using the + * underlying promise APIs. For many use cases, this makes using promise-based + * APIs much simpler, as it resembles a synchronous code flow more closely. + * The above example performs the equivalent of directly using the promise APIs: + * + * ```php + * $browser = new React\Http\Browser(); + * + * $browser->get('https://example.com/')->then(function (Psr\Http\Message\ResponseInterface $response) { + * echo $response->getBody(); + * }, function (Exception $e) { + * echo 'Error: ' . $e->getMessage() . PHP_EOL; + * }); + * ``` + * + * The `yield` keyword can be used to "await" a promise resolution. Internally, + * it will turn the entire given `$function` into a [`Generator`](https://www.php.net/manual/en/class.generator.php). + * This allows the execution to be interrupted and resumed at the same place + * 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 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\coroutine(function () { + * $browser = new React\Http\Browser(); + * $urls = [ + * 'https://example.com/alice', + * 'https://example.com/bob' + * ]; + * + * $bytes = 0; + * foreach ($urls as $url) { + * $response = yield $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 a `yield` statement 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 `yield` together with Promise-based primitives + * like this: + * + * ```php + * $promise = React\Async\coroutine(function () { + * $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 = yield 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; + * }); + * ``` + * + * @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 + * @since 3.0.0 + */ +function coroutine(callable $function, mixed ...$args): PromiseInterface +{ + try { + $generator = $function(...$args); + } catch (\Throwable $e) { + return reject($e); + } + + if (!$generator instanceof \Generator) { + return resolve($generator); + } + + $promise = null; + /** @var Deferred $deferred*/ + $deferred = new Deferred(function () use (&$promise) { + /** @var ?PromiseInterface $promise */ + if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { + $promise->cancel(); + } + $promise = null; + }); + + /** @var callable $next */ + $next = function () use ($deferred, $generator, &$next, &$promise) { + try { + if (!$generator->valid()) { + $next = null; + $deferred->resolve($generator->getReturn()); + return; + } + } catch (\Throwable $e) { + $next = null; + $deferred->reject($e); + return; + } + + $promise = $generator->current(); + if (!$promise instanceof PromiseInterface) { + $next = null; + $deferred->reject(new \UnexpectedValueException( + 'Expected coroutine to yield ' . PromiseInterface::class . ', but got ' . (is_object($promise) ? get_class($promise) : gettype($promise)) + )); + return; + } + + /** @var PromiseInterface $promise */ + assert($next instanceof \Closure); + $promise->then(function ($value) use ($generator, $next) { + $generator->send($value); + $next(); + }, function (\Throwable $reason) use ($generator, $next) { + $generator->throw($reason); + $next(); + })->then(null, function (\Throwable $reason) use ($deferred, &$next) { + $next = null; + $deferred->reject($reason); + }); + }; + $next(); + + return $deferred->promise(); +} + +/** + * @template T + * @param iterable|T)> $tasks + * @return PromiseInterface> + */ +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 CancellablePromiseInterface) { + if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { $promise->cancel(); } } - $pending = array(); + $pending = []; }); - $results = array(); - $errored = false; + $results = []; + $continue = true; - $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) { - if ($promise instanceof CancellablePromiseInterface) { + if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { $promise->cancel(); } } - $pending = array(); + $pending = []; }; 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); + } + + /** @var PromiseInterface> Remove once defining `Deferred()` above is supported by PHPStan, see https://github.com/phpstan/phpstan/issues/11032 */ return $deferred->promise(); } /** - * @param array> $tasks - * @return PromiseInterface,Exception> + * @template T + * @param iterable|T)> $tasks + * @return PromiseInterface> */ -function series(array $tasks) +function series(iterable $tasks): PromiseInterface { $pending = null; + /** @var Deferred> $deferred */ $deferred = new Deferred(function () use (&$pending) { - if ($pending instanceof CancellablePromiseInterface) { + /** @var ?PromiseInterface $pending */ + if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { $pending->cancel(); } $pending = null; }); - $results = array(); + $results = []; + + if ($tasks instanceof \IteratorAggregate) { + $tasks = $tasks->getIterator(); + assert($tasks instanceof \Iterator); + } - /** @var callable():void $next */ $taskCallback = function ($result) use (&$results, &$next) { $results[] = $result; + /** @var \Closure $next */ $next(); }; $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 { + assert(\is_array($tasks)); + $task = \array_shift($tasks); + } + + assert(\is_callable($task)); + $promise = \call_user_func($task); assert($promise instanceof PromiseInterface); $pending = $promise; @@ -186,32 +791,49 @@ function series(array $tasks) $next(); + /** @var PromiseInterface> Remove once defining `Deferred()` above is supported by PHPStan, see https://github.com/phpstan/phpstan/issues/11032 */ return $deferred->promise(); } /** - * @param array> $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(array $tasks) +function waterfall(iterable $tasks): PromiseInterface { $pending = null; + /** @var Deferred $deferred*/ $deferred = new Deferred(function () use (&$pending) { - if ($pending instanceof CancellablePromiseInterface) { + /** @var ?PromiseInterface $pending */ + if ($pending instanceof PromiseInterface && \method_exists($pending, 'cancel')) { $pending->cancel(); } $pending = null; }); + 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 { + 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/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 diff --git a/tests/AsyncTest.php b/tests/AsyncTest.php new file mode 100644 index 0000000..70a84b1 --- /dev/null +++ b/tests/AsyncTest.php @@ -0,0 +1,270 @@ +then(function ($v) use (&$value) { + $value = $v; + }); + + $this->assertEquals(42, $value); + } + + public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsPromiseThatFulfillsWithValue(): void + { + $promise = async(function () { + return resolve(42); + })(); + + $value = null; + $promise->then(function ($v) use (&$value) { + $value = $v; + }); + + $this->assertEquals(42, $value); + } + + public function testAsyncReturnsPromiseThatRejectsWithExceptionWhenCallbackThrows(): void + { + $promise = async(function () { + throw 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 testAsyncReturnsPromiseThatRejectsWithExceptionWhenCallbackReturnsPromiseThatRejectsWithException(): void + { + $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(): void + { + $promise = async(function () { + return new Promise(function () { }); + })(); + + $promise->then($this->expectCallableNever(), $this->expectCallableNever()); + } + + public function testAsyncWithAwaitReturnsReturnsPromiseFulfilledWithValueImmediatelyWhenPromiseIsFulfilled(): void + { + $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(): void + { + $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)); + + /** @var \RuntimeException $exception */ + $this->assertInstanceof(\RuntimeException::class, $exception); + $this->assertEquals('Test', $exception->getMessage()); + $this->assertEquals(42, $exception->getCode()); + } + + public function testAsyncReturnsPromiseThatFulfillsWithValueWhenCallbackReturnsAfterAwaitingPromise(): void + { + $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 testAsyncReturnsPromiseThatRejectsWithExceptionWhenCallbackThrowsAfterAwaitingPromise(): void + { + $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(): void + { + $promise1 = async(function () { + $promise = new Promise(function ($resolve) { + Loop::addTimer(0.11, fn () => $resolve(21)); + }); + + return await($promise); + })(); + + $promise2 = async(function (int $theAnswerToLifeTheUniverseAndEverything): int { + $promise = new Promise(function ($resolve) use ($theAnswerToLifeTheUniverseAndEverything): void { + Loop::addTimer(0.11, fn () => $resolve($theAnswerToLifeTheUniverseAndEverything)); + }); + + /** @var int */ + return await($promise); + })(42); + + $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); + } + + public function testCancelAsyncWillReturnRejectedPromiseWhenCancellingPendingPromiseRejects(): void + { + $promise = async(function () { + await(new Promise(function () { }, function () { + throw new \RuntimeException('Operation cancelled'); + })); + })(); + + assert(method_exists($promise, 'cancel')); + $promise->cancel(); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Operation cancelled'))); + } + + public function testCancelAsyncWillReturnFulfilledPromiseWhenCancellingPendingPromiseRejectsInsideCatchThatReturnsValue(): void + { + $promise = async(function () { + try { + await(new Promise(function () { }, function () { + throw new \RuntimeException('Operation cancelled'); + })); + } catch (\RuntimeException $e) { + return 42; + } + })(); + + assert(method_exists($promise, 'cancel')); + $promise->cancel(); + + $promise->then($this->expectCallableOnceWith(42)); + } + + public function testCancelAsycWillReturnPendigPromiseWhenCancellingFirstPromiseRejectsInsideCatchThatAwaitsSecondPromise(): void + { + $promise = async(function () { + try { + 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'); + })); + } + })(); + + assert(method_exists($promise, 'cancel')); + $promise->cancel(); + + $promise->then($this->expectCallableNever(), $this->expectCallableNever()); + } + + public function testCancelAsyncWillCancelNestedAwait(): void + { + self::expectOutputString('abc'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Operation cancelled'); + + $promise = async(static function (): int { + echo 'a'; + await(async(static function (): void { + echo 'b'; + await(async(static function (): void { + echo 'c'; + await(new Promise(function () { }, function () { + throw new \RuntimeException('Operation cancelled'); + })); + echo 'd'; + })()); + echo 'e'; + })()); + echo 'f'; + + return time(); + })(); + + assert(method_exists($promise, 'cancel')); + $promise->cancel(); + await($promise); + } +} diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index 1acc7e3..7eced26 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -4,88 +4,280 @@ use React; 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 { - public function testAwaitThrowsExceptionWhenPromiseIsRejectedWithException() + /** + * @dataProvider provideAwaiters + */ + public function testAwaitThrowsExceptionWhenPromiseIsRejectedWithException(callable $await): void { $promise = new Promise(function () { throw new \Exception('test'); }); - $this->setExpectedException('Exception', 'test'); - React\Async\await($promise); + $this->expectException(\Exception::class); + $this->expectExceptionMessage('test'); + $await($promise); } - public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithFalse() + /** + * @dataProvider provideAwaiters + */ + public function testAwaitThrowsExceptionWithoutRunningLoop(callable $await): void + { + $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 + */ + public function testAwaitThrowsExceptionImmediatelyWhenPromiseIsRejected(callable $await): void + { + $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 + */ + public function testAwaitAsyncThrowsExceptionImmediatelyWhenPromiseIsRejected(callable $await): void + { + $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 + */ + public function testAwaitThrowsExceptionImmediatelyInCustomFiberWhenPromiseIsRejected(callable $await): void + { + $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 + */ + 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'); } $promise = new Promise(function ($_, $reject) { - $reject(false); + $reject(false); // @phpstan-ignore-line }); - $this->setExpectedException('UnexpectedValueException', 'Promise rejected with unexpected value of type bool'); - React\Async\await($promise); + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Promise rejected with unexpected value of type bool'); + $await($promise); } - public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithNull() + /** + * @dataProvider provideAwaiters + */ + 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'); } $promise = new Promise(function ($_, $reject) { - $reject(null); + $reject(null); // @phpstan-ignore-line }); - $this->setExpectedException('UnexpectedValueException', 'Promise rejected with unexpected value of type NULL'); - React\Async\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()); + } } /** - * @requires PHP 7 + * @dataProvider provideAwaiters */ - public function testAwaitThrowsErrorWhenPromiseIsRejectedWithError() + public function testAwaitThrowsErrorWhenPromiseIsRejectedWithError(callable $await): void { $promise = new Promise(function ($_, $reject) { throw new \Error('Test', 42); }); - $this->setExpectedException('Error', 'Test', 42); - React\Async\await($promise); + $this->expectException(\Error::class); + $this->expectExceptionMessage('Test'); + $this->expectExceptionCode(42); + $await($promise); } - public function testAwaitReturnsValueWhenPromiseIsFullfilled() + /** + * @dataProvider provideAwaiters + */ + public function testAwaitReturnsValueWhenPromiseIsFullfilled(callable $await): void { $promise = new Promise(function ($resolve) { $resolve(42); }); - $this->assertEquals(42, React\Async\await($promise)); + $this->assertEquals(42, $await($promise)); } - public function testAwaitReturnsValueWhenPromiseIsFulfilledEvenWhenOtherTimerStopsLoop() + /** + * @dataProvider provideAwaiters + */ + public function testAwaitReturnsValueImmediatelyWithoutRunningLoop(callable $await): void { + $now = true; + Loop::futureTick(function () use (&$now) { + $now = false; + }); + $promise = new Promise(function ($resolve) { - Loop::addTimer(0.02, function () use ($resolve) { - $resolve(2); + $resolve(42); + }); + + $this->assertEquals(42, $await($promise)); + $this->assertTrue($now); + } + + /** + * @dataProvider provideAwaiters + */ + public function testAwaitReturnsValueImmediatelyWhenPromiseIsFulfilled(callable $await): void + { + $deferred = new Deferred(); + + $ticks = 0; + Loop::futureTick(function () use (&$ticks) { + ++$ticks; + Loop::futureTick(function () use (&$ticks) { + ++$ticks; }); }); - Loop::addTimer(0.01, function () { - Loop::stop(); + + Loop::futureTick(fn() => $deferred->resolve(42)); + + $this->assertEquals(42, $await($deferred->promise())); + $this->assertEquals(1, $ticks); + } + + /** + * @dataProvider provideAwaiters + */ + public function testAwaitAsyncReturnsValueImmediatelyWhenPromiseIsFulfilled(callable $await): void + { + $deferred = new Deferred(); + + $ticks = 0; + Loop::futureTick(function () use (&$ticks) { + ++$ticks; + Loop::futureTick(function () use (&$ticks) { + ++$ticks; + }); }); - $this->assertEquals(2, React\Async\await($promise)); + 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 + */ + public function testAwaitReturnsValueImmediatelyInCustomFiberWhenPromiseIsFulfilled(callable $await): void + { + $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()); } - public function testAwaitShouldNotCreateAnyGarbageReferencesForResolvedPromise() + /** + * @dataProvider provideAwaiters + */ + public function testAwaitShouldNotCreateAnyGarbageReferencesForResolvedPromise(callable $await): void { - if (class_exists('React\Promise\When') && PHP_VERSION_ID >= 50400) { - $this->markTestSkipped('Not supported on legacy Promise v1 API with PHP 5.4+'); + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); } gc_collect_cycles(); @@ -93,13 +285,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): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -111,7 +306,7 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForRejectedPromise() throw new \RuntimeException(); }); try { - React\Async\await($promise); + $await($promise); } catch (\Exception $e) { // no-op } @@ -120,23 +315,26 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForRejectedPromise() $this->assertEquals(0, gc_collect_cycles()); } - public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithNullValue() + /** + * @dataProvider provideAwaiters + */ + 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'); } - if (class_exists('React\Promise\When') && PHP_VERSION_ID >= 50400) { - $this->markTestSkipped('Not supported on legacy Promise v1 API with PHP 5.4+'); + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); } gc_collect_cycles(); $promise = new Promise(function ($_, $reject) { - $reject(null); + $reject(null); // @phpstan-ignore-line }); try { - React\Async\await($promise); + $await($promise); } catch (\Exception $e) { // no-op } @@ -145,20 +343,80 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWi $this->assertEquals(0, gc_collect_cycles()); } - public function setExpectedException($exception, $exceptionMessage = '', $exceptionCode = null) + /** + * @dataProvider provideAwaiters + */ + public function testAlreadyFulfilledPromiseShouldNotSuspendFiber(callable $await): void { - if (method_exists($this, 'expectException')) { - // PHPUnit 5+ - $this->expectException($exception); - if ($exceptionMessage !== '') { - $this->expectExceptionMessage($exceptionMessage); - } - if ($exceptionCode !== null) { - $this->expectExceptionCode($exceptionCode); - } - } else { - // legacy PHPUnit 4 - parent::setExpectedException($exception, $exceptionMessage, $exceptionCode); + for ($i = 0; $i < 6; $i++) { + $this->assertSame($i, $await(React\Promise\resolve($i))); } } + + /** + * @dataProvider provideAwaiters + */ + public function testNestedAwaits(callable $await): void + { + $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) { + Loop::addTimer(0.01, function () use ($resolve) { + $resolve(true); + }); + }))); + }))); + }))); + }))); + }))); + } + + /** + * @dataProvider provideAwaiters + */ + 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)); + })); + $this->assertNull(React\Async\FiberMap::getPromise($fiber)); + + return time(); + })()); + } + + /** + * @dataProvider provideAwaiters + */ + 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) { + throw $throwable; + } finally { + $this->assertNull(React\Async\FiberMap::getPromise($fiber)); + } + + return time(); + })()); + } + + /** @return iterable): mixed>> */ + 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)())]; + } } diff --git a/tests/CoroutineTest.php b/tests/CoroutineTest.php new file mode 100644 index 0000000..1df4cdc --- /dev/null +++ b/tests/CoroutineTest.php @@ -0,0 +1,262 @@ +then($this->expectCallableOnceWith(42)); + } + + public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsImmediately(): void + { + $promise = coroutine(function () { + if (false) { // @phpstan-ignore-line + yield resolve(null); + } + return 42; + }); + + $promise->then($this->expectCallableOnceWith(42)); + } + + public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldingPromise(): void + { + $promise = coroutine(function () { + $value = yield resolve(42); + return $value; + }); + + $promise->then($this->expectCallableOnceWith(42)); + } + + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsWithoutGenerator(): void + { + $promise = coroutine(function () { + throw new \RuntimeException('Foo'); + }); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); + } + + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsImmediately(): void + { + $promise = coroutine(function () { + if (false) { // @phpstan-ignore-line + yield resolve(null); + } + throw new \RuntimeException('Foo'); + }); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); + } + + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYieldingPromise(): void + { + $promise = coroutine(function () { + $reason = yield resolve('Foo'); + throw new \RuntimeException($reason); + }); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); + } + + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYieldingRejectedPromise(): void + { + $promise = coroutine(function () { + try { + yield reject(new \OverflowException('Foo')); + } catch (\OverflowException $e) { + throw new \RuntimeException($e->getMessage()); + } + }); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); + } + + public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldingRejectedPromise(): void + { + $promise = coroutine(function () { + try { + yield reject(new \OverflowException('Foo', 42)); + } catch (\OverflowException $e) { + return $e->getCode(); + } + }); + + $promise->then($this->expectCallableOnceWith(42)); + } + + public function testCoroutineReturnsRejectedPromiseIfFunctionYieldsInvalidValue(): void + { + $promise = coroutine(function () { // @phpstan-ignore-line + yield 42; + }); + + $promise->then(null, $this->expectCallableOnceWith(new \UnexpectedValueException('Expected coroutine to yield React\Promise\PromiseInterface, but got integer'))); + } + + public function testCancelCoroutineWillReturnRejectedPromiseWhenCancellingPendingPromiseRejects(): void + { + $promise = coroutine(function () { + yield new Promise(function () { }, function () { + throw new \RuntimeException('Operation cancelled'); + }); + }); + + assert(method_exists($promise, 'cancel')); + $promise->cancel(); + + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Operation cancelled'))); + } + + public function testCancelCoroutineWillReturnFulfilledPromiseWhenCancellingPendingPromiseRejectsInsideCatchThatReturnsValue(): void + { + $promise = coroutine(function () { + try { + yield new Promise(function () { }, function () { + throw new \RuntimeException('Operation cancelled'); + }); + } catch (\RuntimeException $e) { + return 42; + } + }); + + assert(method_exists($promise, 'cancel')); + $promise->cancel(); + + $promise->then($this->expectCallableOnceWith(42)); + } + + public function testCancelCoroutineWillReturnPendigPromiseWhenCancellingFirstPromiseRejectsInsideCatchThatYieldsSecondPromise(): void + { + $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'); + }); + } + }); + + assert(method_exists($promise, 'cancel')); + $promise->cancel(); + + $promise->then($this->expectCallableNever(), $this->expectCallableNever()); + } + + public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorReturns(): void + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + gc_collect_cycles(); + gc_collect_cycles(); + + $promise = coroutine(function () { + if (false) { // @phpstan-ignore-line + yield resolve(null); + } + return 42; + }); + + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithExceptionImmediately(): void + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + gc_collect_cycles(); + + $promise = coroutine(function () { + yield new Promise(function () { + throw new \RuntimeException('Failed', 42); + }); + }); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithExceptionOnCancellation(): void + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + gc_collect_cycles(); + + $promise = coroutine(function () { + yield new Promise(function () { }, function () { + throw new \RuntimeException('Operation cancelled', 42); + }); + }); + + assert(method_exists($promise, 'cancel')); + $promise->cancel(); + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorThrowsBeforeFirstYield(): void + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + gc_collect_cycles(); + + $promise = coroutine(function () { + throw new \RuntimeException('Failed', 42); + yield; // @phpstan-ignore-line + }); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorYieldsInvalidValue(): void + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + gc_collect_cycles(); + + $promise = coroutine(function () { // @phpstan-ignore-line + yield 42; + }); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } +} diff --git a/tests/DelayTest.php b/tests/DelayTest.php new file mode 100644 index 0000000..2cadd6f --- /dev/null +++ b/tests/DelayTest.php @@ -0,0 +1,95 @@ +assertGreaterThan(0.01, $time); + $this->assertLessThan(0.03, $time); + } + + public function testDelaySmallPeriodBlocksForCloseToZeroSeconds(): void + { + $time = microtime(true); + delay(0.000001); + $time = microtime(true) - $time; + + $this->assertLessThan(0.01, $time); + } + + public function testDelayNegativePeriodBlocksForCloseToZeroSeconds(): void + { + $time = microtime(true); + delay(-1); + $time = microtime(true) - $time; + + $this->assertLessThan(0.01, $time); + } + + public function testAwaitAsyncDelayBlocksForGivenPeriod(): void + { + $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(): void + { + $promise = async(function () { + delay(1.0); + })(); + + assert(method_exists($promise, 'cancel')); + $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(): void + { + $promise = async(function () { + delay(1.0); + })(); + + assert(method_exists($promise, 'cancel')); + 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); + } +} diff --git a/tests/ParallelTest.php b/tests/ParallelTest.php index b77a3ca..ad24589 100644 --- a/tests/ParallelTest.php +++ b/tests/ParallelTest.php @@ -5,11 +5,16 @@ use React; 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 { + /** + * @var array> $tasks + */ $tasks = array(); $promise = React\Async\parallel($tasks); @@ -17,7 +22,20 @@ public function testParallelWithoutTasks() $promise->then($this->expectCallableOnceWith(array())); } - public function testParallelWithTasks() + public function testParallelWithoutTasksFromEmptyGeneratorResolvesWithEmptyArray(): void + { + $tasks = (function () { + if (false) { // @phpstan-ignore-line + yield fn () => resolve(null); + } + })(); + + $promise = React\Async\parallel($tasks); + + $promise->then($this->expectCallableOnceWith([])); + } + + public function testParallelWithTasks(): void { $tasks = array( function () { @@ -49,7 +67,39 @@ function () { $timer->assertInRange(0.1, 0.2); } - public function testParallelWithErrorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks() + public function testParallelWithTasksFromGeneratorResolvesWithArrayOfFulfillmentValues(): void + { + $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(): void { $called = 0; @@ -81,7 +131,26 @@ function () use (&$called) { $this->assertSame(2, $called); } - public function testParallelWithErrorWillCancelPendingPromises() + public function testParallelWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks(): void + { + $called = 0; + + $tasks = (function () use (&$called) { + while (true) { // @phpstan-ignore-line + 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(): void { $cancelled = 0; @@ -110,7 +179,7 @@ function () use (&$cancelled) { $this->assertSame(1, $cancelled); } - public function testParallelWillCancelPendingPromisesWhenCallingCancelOnResultingPromise() + public function testParallelWillCancelPendingPromisesWhenCallingCancelOnResultingPromise(): void { $cancelled = 0; @@ -128,12 +197,13 @@ function () use (&$cancelled) { ); $promise = React\Async\parallel($tasks); + assert(method_exists($promise, 'cancel')); $promise->cancel(); $this->assertSame(2, $cancelled); } - public function testParallelWithDelayedErrorReturnsPromiseRejectedWithExceptionFromTask() + public function testParallelWithDelayedErrorReturnsPromiseRejectedWithExceptionFromTask(): void { $called = 0; diff --git a/tests/SeriesTest.php b/tests/SeriesTest.php index 7cedf91..69cafd5 100644 --- a/tests/SeriesTest.php +++ b/tests/SeriesTest.php @@ -5,11 +5,16 @@ use React; 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 { + /** + * @var array> $tasks + */ $tasks = array(); $promise = React\Async\series($tasks); @@ -17,7 +22,20 @@ public function testSeriesWithoutTasks() $promise->then($this->expectCallableOnceWith(array())); } - public function testSeriesWithTasks() + public function testSeriesWithoutTasksFromEmptyGeneratorResolvesWithEmptyArray(): void + { + $tasks = (function () { + if (false) { // @phpstan-ignore-line + yield fn () => resolve(null); + } + })(); + + $promise = React\Async\series($tasks); + + $promise->then($this->expectCallableOnceWith([])); + } + + public function testSeriesWithTasks(): void { $tasks = array( function () { @@ -49,7 +67,39 @@ function () { $timer->assertInRange(0.10, 0.20); } - public function testSeriesWithError() + public function testSeriesWithTasksFromGeneratorResolvesWithArrayOfFulfillmentValues(): void + { + $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(): void { $called = 0; @@ -80,14 +130,58 @@ function () use (&$called) { $this->assertSame(1, $called); } - public function testSeriesWillCancelFirstPendingPromiseWhenCallingCancelOnResultingPromise() + public function testSeriesWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks(): void + { + $called = 0; + + $tasks = (function () use (&$called) { + while (true) { // @phpstan-ignore-line + 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(): void + { + $tasks = new class() implements \IteratorAggregate { + public int $called = 0; + + /** + * @return \Iterator> + */ + public function getIterator(): \Iterator + { + while (true) { // @phpstan-ignore-line + 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(): void { $cancelled = 0; $tasks = array( function () { return new Promise(function ($resolve) { - $resolve(); + $resolve(null); }); }, function () use (&$cancelled) { @@ -98,6 +192,7 @@ function () use (&$cancelled) { ); $promise = React\Async\series($tasks); + assert(method_exists($promise, 'cancel')); $promise->cancel(); $this->assertSame(1, $cancelled); diff --git a/tests/TestCase.php b/tests/TestCase.php index 48f2879..e43397d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,49 +2,40 @@ 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 { - if (method_exists('PHPUnit\Framework\MockObject\MockBuilder', 'addMethods')) { - // PHPUnit 9+ - return $this->getMockBuilder('stdClass')->addMethods(array('__invoke'))->getMock(); - } else { - // legacy PHPUnit 4 - PHPUnit 8 - return $this->getMockBuilder('stdClass')->setMethods(array('__invoke'))->getMock(); - } + 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 b0c5c3c..be174a9 100644 --- a/tests/WaterfallTest.php +++ b/tests/WaterfallTest.php @@ -5,11 +5,16 @@ use React; 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 { + /** + * @var array> $tasks + */ $tasks = array(); $promise = React\Async\waterfall($tasks); @@ -17,7 +22,20 @@ public function testWaterfallWithoutTasks() $promise->then($this->expectCallableOnceWith(null)); } - public function testWaterfallWithTasks() + public function testWaterfallWithoutTasksFromEmptyGeneratorResolvesWithNull(): void + { + $tasks = (function () { + if (false) { // @phpstan-ignore-line + yield fn () => resolve(null); + } + })(); + + $promise = React\Async\waterfall($tasks); + + $promise->then($this->expectCallableOnceWith(null)); + } + + public function testWaterfallWithTasks(): void { $tasks = array( function ($foo = 'foo') { @@ -56,7 +74,46 @@ function ($bar) { $timer->assertInRange(0.15, 0.30); } - public function testWaterfallWithError() + public function testWaterfallWithTasksFromGeneratorResolvesWithFinalFulfillmentValue(): void + { + $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(): void { $called = 0; @@ -87,14 +144,58 @@ function () use (&$called) { $this->assertSame(1, $called); } - public function testWaterfallWillCancelFirstPendingPromiseWhenCallingCancelOnResultingPromise() + public function testWaterfallWithErrorFromInfiniteGeneratorReturnsPromiseRejectedWithExceptionFromTaskAndStopsCallingAdditionalTasks(): void + { + $called = 0; + + $tasks = (function () use (&$called) { + while (true) { // @phpstan-ignore-line + 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(): void + { + $tasks = new class() implements \IteratorAggregate { + public int $called = 0; + + /** + * @return \Iterator> + */ + public function getIterator(): \Iterator + { + while (true) { // @phpstan-ignore-line + 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(): void { $cancelled = 0; $tasks = array( function () { return new Promise(function ($resolve) { - $resolve(); + $resolve(null); }); }, function () use (&$cancelled) { @@ -105,6 +206,7 @@ function () use (&$cancelled) { ); $promise = React\Async\waterfall($tasks); + assert(method_exists($promise, 'cancel')); $promise->cancel(); $this->assertSame(1, $cancelled); 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)); pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy