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 0b1b36b..749ba36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,24 +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 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 522bef6..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,8 +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) @@ -53,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 @@ -63,14 +234,29 @@ $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: -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. +```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 [`async()` function](#async) for more details. Once the promise is fulfilled, this function will return whatever the promise resolved to. @@ -92,7 +278,7 @@ try { ### coroutine() -The `coroutine(callable $function, mixed ...$args): PromiseInterface` function can be used to +The `coroutine(callable(mixed ...$args):(\Generator|PromiseInterface|T) $function, mixed ...$args): PromiseInterface` function can be used to execute a Generator-based coroutine to "await" promises. ```php @@ -131,10 +317,11 @@ when the promise is fulfilled. The `yield` statement returns whatever the promise is fulfilled with. If the promise is rejected, it will throw an `Exception` or `Throwable`. -The `coroutine()` function will always return a Proimise which will be +The `coroutine()` function will always return a Promise which will be fulfilled with whatever your `$function` returns. Likewise, it will return a promise that will be rejected if you throw an `Exception` or `Throwable` -from your `$function`. This allows you easily create Promise-based functions: +from your `$function`. This allows you to easily create Promise-based +functions: ```php $promise = React\Async\coroutine(function () { @@ -205,9 +392,113 @@ $promise->then(function (int $bytes) { }); ``` +### delay() + +The `delay(float $seconds): void` function can be used to +delay program execution for duration given in `$seconds`. + +```php +React\Async\delay($seconds); +``` + +This function will only return after the given number of `$seconds` have +elapsed. If there are no other events attached to this loop, it will behave +similar to PHP's [`sleep()` function](https://www.php.net/manual/en/function.sleep.php). + +```php +echo 'a'; +React\Async\delay(1.0); +echo 'b'; + +// prints "a" at t=0.0s +// prints "b" at t=1.0s +``` + +Unlike PHP's [`sleep()` function](https://www.php.net/manual/en/function.sleep.php), +this function may not necessarily halt execution of the entire process thread. +Instead, it allows the event loop to run any other events attached to the +same loop until the delay returns: + +```php +echo 'a'; +Loop::addTimer(1.0, function (): 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 @@ -249,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 @@ -291,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 @@ -328,24 +619,30 @@ 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 PHP 7.1 through current PHP 8+. +extensions and supports running on PHP 8.1+. It's *highly recommended to use the latest supported PHP version* for this project. We're committed to providing long-term support (LTS) options and to provide a smooth upgrade path. If you're using an older PHP version, you may use the -[`2.x` branch](https://github.com/reactphp/async/tree/2.x) which provides a -compatible API but does not take advantage of newer language features. You may -target both versions at the same time to support a wider range of PHP versions. +[`3.x` branch](https://github.com/reactphp/async/tree/3.x) (PHP 7.1+) or +[`2.x` branch](https://github.com/reactphp/async/tree/2.x) (PHP 5.3+) which both +provide a compatible API but do not take advantage of newer language features. +You may target multiple versions at the same time to support a wider range of +PHP versions like this: + +```bash +composer require "react/async:^4 || ^3 || ^2" +``` ## Tests @@ -353,13 +650,19 @@ 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 a839932..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": ">=7.1", + "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 || ^7.5" + "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 2157b25..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 ad91688..bcf40c1 100644 --- a/src/functions.php +++ b/src/functions.php @@ -3,28 +3,256 @@ 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: * - * 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. + * ```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 [`async()` function](#async) for more details. * * Once the promise is fulfilled, this function will return whatever the promise * resolved to. @@ -35,7 +263,7 @@ * * ```php * try { - * $result = React\Async\await($promise, $loop); + * $result = React\Async\await($promise); * // promise successfully fulfilled with $result * echo 'Result: ' . $result; * } catch (Throwable $e) { @@ -44,55 +272,219 @@ * } * ``` * - * @param PromiseInterface $promise - * @return mixed returns whatever the promise resolves to + * @template T + * @param PromiseInterface $promise + * @return T returns whatever the promise resolves to * @throws \Exception when the promise is rejected with an `Exception` * @throws \Throwable when the promise is rejected with a `Throwable` * @throws \UnexpectedValueException when the promise is rejected with an unexpected value (Promise API v1 or v2 only) */ -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 \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(); } +/** + * 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 delay(float $seconds): void +{ + /** @var ?TimerInterface $timer */ + $timer = null; + + await(new Promise(function (callable $resolve) use ($seconds, &$timer): void { + $timer = Loop::addTimer($seconds, fn() => $resolve(null)); + }, function () use (&$timer): void { + assert($timer instanceof TimerInterface); + Loop::cancelTimer($timer); + throw new \RuntimeException('Delay cancelled'); + })); +} /** * Execute a Generator-based coroutine to "await" promises. @@ -133,10 +525,11 @@ function ($error) use (&$exception, &$rejected, &$wait) { * promise is fulfilled with. If the promise is rejected, it will throw an * `Exception` or `Throwable`. * - * The `coroutine()` function will always return a Proimise which will be + * The `coroutine()` function will always return a Promise which will be * fulfilled with whatever your `$function` returns. Likewise, it will return * a promise that will be rejected if you throw an `Exception` or `Throwable` - * from your `$function`. This allows you easily create Promise-based functions: + * from your `$function`. This allows you to easily create Promise-based + * functions: * * ```php * $promise = React\Async\coroutine(function () { @@ -207,12 +600,19 @@ function ($error) use (&$exception, &$rejected, &$wait) { * }); * ``` * - * @param callable(...$args):\Generator $function + * @template T + * @template TYield + * @template A1 (any number of function arguments, see https://github.com/phpstan/phpstan/issues/8214) + * @template A2 + * @template A3 + * @template A4 + * @template A5 + * @param callable(A1, A2, A3, A4, A5):(\Generator, TYield, PromiseInterface|T>|PromiseInterface|T) $function * @param mixed ...$args Optional list of additional arguments that will be passed to the given `$function` as is - * @return PromiseInterface + * @return PromiseInterface * @since 3.0.0 */ -function coroutine(callable $function, ...$args): PromiseInterface +function coroutine(callable $function, mixed ...$args): PromiseInterface { try { $generator = $function(...$args); @@ -225,13 +625,13 @@ function coroutine(callable $function, ...$args): PromiseInterface } $promise = null; + /** @var Deferred $deferred*/ $deferred = new Deferred(function () use (&$promise) { - // cancel pending promise(s) as long as generator function keeps yielding - while ($promise instanceof CancellablePromiseInterface) { - $temp = $promise; - $promise = null; - $temp->cancel(); + /** @var ?PromiseInterface $promise */ + if ($promise instanceof PromiseInterface && \method_exists($promise, 'cancel')) { + $promise->cancel(); } + $promise = null; }); /** @var callable $next */ @@ -257,6 +657,8 @@ function coroutine(callable $function, ...$args): PromiseInterface return; } + /** @var PromiseInterface $promise */ + assert($next instanceof \Closure); $promise->then(function ($value) use ($generator, $next) { $generator->send($value); $next(); @@ -274,34 +676,32 @@ function coroutine(callable $function, ...$args): PromiseInterface } /** - * @param array> $tasks - * @return PromiseInterface,Exception> + * @template T + * @param iterable|T)> $tasks + * @return PromiseInterface> */ -function parallel(array $tasks): 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 = []; }); $results = []; - $errored = false; - - $numTasks = count($tasks); - if (0 === $numTasks) { - $deferred->resolve($results); - } + $continue = true; - $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(); } } @@ -309,57 +709,80 @@ function parallel(array $tasks): PromiseInterface }; foreach ($tasks as $i => $task) { - $taskCallback = function ($result) use (&$results, &$pending, $numTasks, $i, $deferred) { + $taskCallback = function ($result) use (&$results, &$pending, &$continue, $i, $deferred) { $results[$i] = $result; + unset($pending[$i]); - if (count($results) === $numTasks) { + if (!$pending && !$continue) { $deferred->resolve($results); } }; - $promise = call_user_func($task); + $promise = \call_user_func($task); assert($promise instanceof PromiseInterface); $pending[$i] = $promise; $promise->then($taskCallback, $taskErrback); - if ($errored) { + if (!$continue) { break; } } + $continue = false; + if (!$pending) { + $deferred->resolve($results); + } + + /** @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): PromiseInterface +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 = []; - /** @var callable():void $next */ + if ($tasks instanceof \IteratorAggregate) { + $tasks = $tasks->getIterator(); + assert($tasks instanceof \Iterator); + } + $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; @@ -368,32 +791,49 @@ function series(array $tasks): PromiseInterface $next(); + /** @var PromiseInterface> Remove once defining `Deferred()` above is supported by PHPStan, see https://github.com/phpstan/phpstan/issues/11032 */ return $deferred->promise(); } /** - * @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): PromiseInterface +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 95a8b5f..7eced26 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -4,11 +4,17 @@ 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'); @@ -16,40 +22,149 @@ public function testAwaitThrowsExceptionWhenPromiseIsRejectedWithException() $this->expectException(\Exception::class); $this->expectExceptionMessage('test'); - React\Async\await($promise); + $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->expectException(\UnexpectedValueException::class); $this->expectExceptionMessage('Promise rejected with unexpected value of type bool'); - React\Async\await($promise); + $await($promise); } - public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithNull() + /** + * @dataProvider provideAwaiters + */ + public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithNull(callable $await): 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->expectException(\UnexpectedValueException::class); - $this->expectExceptionMessage('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()); + } } - public function testAwaitThrowsErrorWhenPromiseIsRejectedWithError() + /** + * @dataProvider provideAwaiters + */ + public function testAwaitThrowsErrorWhenPromiseIsRejectedWithError(callable $await): void { $promise = new Promise(function ($_, $reject) { throw new \Error('Test', 42); @@ -58,33 +173,108 @@ public function testAwaitThrowsErrorWhenPromiseIsRejectedWithError() $this->expectException(\Error::class); $this->expectExceptionMessage('Test'); $this->expectExceptionCode(42); - React\Async\await($promise); + $await($promise); } - public function testAwaitReturnsValueWhenPromiseIsFullfilled() + /** + * @dataProvider provideAwaiters + */ + public function testAwaitReturnsValueWhenPromiseIsFullfilled(callable $await): 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); } - public function testAwaitShouldNotCreateAnyGarbageReferencesForResolvedPromise() + /** + * @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()); + } + + /** + * @dataProvider provideAwaiters + */ + public function testAwaitShouldNotCreateAnyGarbageReferencesForResolvedPromise(callable $await): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -95,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'); @@ -113,7 +306,7 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForRejectedPromise() throw new \RuntimeException(); }); try { - React\Async\await($promise); + $await($promise); } catch (\Exception $e) { // no-op } @@ -122,7 +315,10 @@ 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'); @@ -135,10 +331,10 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWi 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 } @@ -146,4 +342,81 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWi $this->assertEquals(0, gc_collect_cycles()); } + + /** + * @dataProvider provideAwaiters + */ + public function testAlreadyFulfilledPromiseShouldNotSuspendFiber(callable $await): void + { + 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 index 6e461d5..1df4cdc 100644 --- a/tests/CoroutineTest.php +++ b/tests/CoroutineTest.php @@ -9,7 +9,7 @@ class CoroutineTest extends TestCase { - public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsWithoutGenerator() + public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsWithoutGenerator(): void { $promise = coroutine(function () { return 42; @@ -18,11 +18,11 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsWithoutGene $promise->then($this->expectCallableOnceWith(42)); } - public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsImmediately() + public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsImmediately(): void { $promise = coroutine(function () { - if (false) { - yield; + if (false) { // @phpstan-ignore-line + yield resolve(null); } return 42; }); @@ -30,7 +30,7 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsImmediately $promise->then($this->expectCallableOnceWith(42)); } - public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldingPromise() + public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldingPromise(): void { $promise = coroutine(function () { $value = yield resolve(42); @@ -40,7 +40,7 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldi $promise->then($this->expectCallableOnceWith(42)); } - public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsWithoutGenerator() + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsWithoutGenerator(): void { $promise = coroutine(function () { throw new \RuntimeException('Foo'); @@ -49,11 +49,11 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsWithoutGenera $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); } - public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsImmediately() + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsImmediately(): void { $promise = coroutine(function () { - if (false) { - yield; + if (false) { // @phpstan-ignore-line + yield resolve(null); } throw new \RuntimeException('Foo'); }); @@ -61,7 +61,7 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsImmediately() $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); } - public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYieldingPromise() + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYieldingPromise(): void { $promise = coroutine(function () { $reason = yield resolve('Foo'); @@ -71,7 +71,7 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYielding $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); } - public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYieldingRejectedPromise() + public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYieldingRejectedPromise(): void { $promise = coroutine(function () { try { @@ -84,7 +84,7 @@ public function testCoroutineReturnsRejectedPromiseIfFunctionThrowsAfterYielding $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Foo'))); } - public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldingRejectedPromise() + public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldingRejectedPromise(): void { $promise = coroutine(function () { try { @@ -97,54 +97,68 @@ public function testCoroutineReturnsFulfilledPromiseIfFunctionReturnsAfterYieldi $promise->then($this->expectCallableOnceWith(42)); } - public function testCoroutineReturnsRejectedPromiseIfFunctionYieldsInvalidValue() + public function testCoroutineReturnsRejectedPromiseIfFunctionYieldsInvalidValue(): void { - $promise = coroutine(function () { + $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 testCoroutineWillCancelPendingPromiseWhenCallingCancelOnResultingPromise() + public function testCancelCoroutineWillReturnRejectedPromiseWhenCancellingPendingPromiseRejects(): void { - $cancelled = 0; - $promise = coroutine(function () use (&$cancelled) { - yield new Promise(function () use (&$cancelled) { - ++$cancelled; + $promise = coroutine(function () { + yield new Promise(function () { }, function () { + throw new \RuntimeException('Operation cancelled'); }); }); + assert(method_exists($promise, 'cancel')); $promise->cancel(); - $this->assertEquals(1, $cancelled); + $promise->then(null, $this->expectCallableOnceWith(new \RuntimeException('Operation cancelled'))); } - public function testCoroutineWillCancelAllPendingPromisesWhenFunctionContinuesToYieldWhenCallingCancelOnResultingPromise() + public function testCancelCoroutineWillReturnFulfilledPromiseWhenCancellingPendingPromiseRejectsInsideCatchThatReturnsValue(): void { $promise = coroutine(function () { - $promise = new Promise(function () { }, function () { - throw new \RuntimeException('Frist operation cancelled', 21); - }); - try { - yield $promise; + yield new Promise(function () { }, function () { + throw new \RuntimeException('Operation cancelled'); + }); } catch (\RuntimeException $e) { - // ignore exception and continue + return 42; } + }); - yield new Promise(function () { }, function () { - throw new \RuntimeException('Second operation cancelled', 42); - }); + 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(null, $this->expectCallableOnceWith(new \RuntimeException('Second operation cancelled', 42))); + $promise->then($this->expectCallableNever(), $this->expectCallableNever()); } - public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorReturns() + public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorReturns(): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -154,8 +168,8 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorRet gc_collect_cycles(); $promise = coroutine(function () { - if (false) { - yield; + if (false) { // @phpstan-ignore-line + yield resolve(null); } return 42; }); @@ -165,7 +179,7 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorRet $this->assertEquals(0, gc_collect_cycles()); } - public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithExceptionImmediately() + public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithExceptionImmediately(): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -179,12 +193,14 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseReject }); }); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + unset($promise); $this->assertEquals(0, gc_collect_cycles()); } - public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithExceptionOnCancellation() + public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithExceptionOnCancellation(): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -198,13 +214,14 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesForPromiseReject }); }); + assert(method_exists($promise, 'cancel')); $promise->cancel(); unset($promise); $this->assertEquals(0, gc_collect_cycles()); } - public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorThrowsBeforeFirstYield() + public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorThrowsBeforeFirstYield(): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -214,15 +231,17 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorThr $promise = coroutine(function () { throw new \RuntimeException('Failed', 42); - yield; + yield; // @phpstan-ignore-line }); + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + unset($promise); $this->assertEquals(0, gc_collect_cycles()); } - public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorYieldsInvalidValue() + public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorYieldsInvalidValue(): void { if (class_exists('React\Promise\When')) { $this->markTestSkipped('Not supported on legacy Promise v1 API'); @@ -230,10 +249,12 @@ public function testCoroutineShouldNotCreateAnyGarbageReferencesWhenGeneratorYie gc_collect_cycles(); - $promise = coroutine(function () { + $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 ee0f476..e43397d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,50 +2,40 @@ namespace React\Tests\Async; -use PHPUnit\Framework\MockObject\MockBuilder; +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(MockBuilder::class, 'addMethods')) { - // PHPUnit 9+ - return $this->getMockBuilder(\stdClass::class)->addMethods(['__invoke'])->getMock(); - } else { - // PHPUnit < 9 - return $this->getMockBuilder(\stdClass::class)->setMethods(['__invoke'])->getMock(); - } + return $this->getMockBuilder(\stdClass::class)->addMethods(['__invoke'])->getMock(); } } 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));