diff --git a/README.md b/README.md index d5ee493..13ed842 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ an event loop, it can be used with this library. **Table of Contents** * [Usage](#usage) + * [await()](#await) * [parallel()](#parallel) * [series()](#series) * [waterfall()](#waterfall) @@ -32,15 +33,15 @@ All functions reside under the `React\Async` namespace. The below examples refer to all functions with their fully-qualified names like this: ```php -React\Async\parallel(…); +React\Async\await(…); ``` As of PHP 5.6+ you can also import each required function into your code like this: ```php -use function React\Async\parallel; +use function React\Async\await; -parallel(…); +await(…); ``` Alternatively, you can also use an import statement similar to this: @@ -48,7 +49,44 @@ Alternatively, you can also use an import statement similar to this: ```php use React\Async; -Async\parallel(…); +Async\await(…); +``` + +### await() + +The `await(PromiseInterface $promise): mixed` function can be used to +block waiting for the given `$promise` to be fulfilled. + +```php +$result = React\Async\await($promise); +``` + +This function will only return after the given `$promise` has settled, i.e. +either fulfilled or rejected. + +While the promise is pending, this function will assume control over the event +loop. Internally, it will `run()` the [default loop](https://github.com/reactphp/event-loop#loop) +until the promise settles and then calls `stop()` to terminate execution of the +loop. This means this function is more suited for short-lived promise executions +when using promise-based APIs is not feasible. For long-running applications, +using promise-based APIs by leveraging chained `then()` calls is usually preferable. + +Once the promise is fulfilled, this function will return whatever the promise +resolved to. + +Once the promise is rejected, this will throw whatever the promise rejected +with. If the promise did not reject with an `Exception` or `Throwable` (PHP 7+), +then this function will throw an `UnexpectedValueException` instead. + +```php +try { + $result = React\Async\await($promise); + // promise successfully fulfilled with $result + echo 'Result: ' . $result; +} catch (Throwable $e) { + // promise rejected with $e + echo 'Error: ' . $e->getMessage(); +} ``` ### parallel() diff --git a/composer.json b/composer.json index 10cdd53..f21b4c2 100644 --- a/composer.json +++ b/composer.json @@ -27,11 +27,11 @@ ], "require": { "php": ">=5.3.2", + "react/event-loop": "^1.2", "react/promise": "^2.8 || ^1.2.1" }, "require-dev": { - "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35", - "react/event-loop": "^1.2" + "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35" }, "suggest": { "react/event-loop": "You need an event loop for this to make sense." diff --git a/src/functions.php b/src/functions.php index 5a9dc22..350f914 100644 --- a/src/functions.php +++ b/src/functions.php @@ -2,9 +2,94 @@ namespace React\Async; +use React\EventLoop\Loop; use React\Promise\Deferred; use React\Promise\PromiseInterface; +/** + * Block waiting for the given `$promise` to be fulfilled. + * + * ```php + * $result = React\Async\await($promise, $loop); + * ``` + * + * This function will only return after the given `$promise` has settled, i.e. + * either fulfilled or rejected. + * + * While the promise is pending, this function will assume control over the event + * loop. Internally, it will `run()` the [default loop](https://github.com/reactphp/event-loop#loop) + * until the promise settles and then calls `stop()` to terminate execution of the + * loop. This means this function is more suited for short-lived promise executions + * when using promise-based APIs is not feasible. For long-running applications, + * using promise-based APIs by leveraging chained `then()` calls is usually preferable. + * + * Once the promise is fulfilled, this function will return whatever the promise + * resolved to. + * + * Once the promise is rejected, this will throw whatever the promise rejected + * with. If the promise did not reject with an `Exception` or `Throwable` (PHP 7+), + * then this function will throw an `UnexpectedValueException` instead. + * + * ```php + * try { + * $result = React\Async\await($promise, $loop); + * // promise successfully fulfilled with $result + * echo 'Result: ' . $result; + * } catch (Throwable $e) { + * // promise rejected with $e + * echo 'Error: ' . $e->getMessage(); + * } + * ``` + * + * @param PromiseInterface $promise + * @return mixed returns whatever the promise resolves to + * @throws \Exception when the promise is rejected with an `Exception` + * @throws \Throwable when the promise is rejected with a `Throwable` (PHP 7+) + * @throws \UnexpectedValueException when the promise is rejected with an unexpected value (Promise API v1 or v2 only) + */ +function await(PromiseInterface $promise) +{ + $wait = true; + $resolved = null; + $exception = null; + $rejected = false; + + $promise->then( + function ($c) use (&$resolved, &$wait) { + $resolved = $c; + $wait = false; + Loop::stop(); + }, + function ($error) use (&$exception, &$rejected, &$wait) { + $exception = $error; + $rejected = true; + $wait = false; + Loop::stop(); + } + ); + + // Explicitly overwrite argument with null value. This ensure that this + // argument does not show up in the stack trace in PHP 7+ only. + $promise = null; + + while ($wait) { + Loop::run(); + } + + if ($rejected) { + // promise is rejected with an unexpected value (Promise API v1 or v2 only) + if (!$exception instanceof \Exception && !$exception instanceof \Throwable) { + $exception = new \UnexpectedValueException( + 'Promise rejected with unexpected value of type ' . (is_object($exception) ? get_class($exception) : gettype($exception)) + ); + } + + throw $exception; + } + + return $resolved; +} + /** * @param array> $tasks * @return PromiseInterface,Exception> diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php new file mode 100644 index 0000000..1acc7e3 --- /dev/null +++ b/tests/AwaitTest.php @@ -0,0 +1,164 @@ +setExpectedException('Exception', 'test'); + React\Async\await($promise); + } + + public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithFalse() + { + 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); + }); + + $this->setExpectedException('UnexpectedValueException', 'Promise rejected with unexpected value of type bool'); + React\Async\await($promise); + } + + public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithNull() + { + 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); + }); + + $this->setExpectedException('UnexpectedValueException', 'Promise rejected with unexpected value of type NULL'); + React\Async\await($promise); + } + + /** + * @requires PHP 7 + */ + public function testAwaitThrowsErrorWhenPromiseIsRejectedWithError() + { + $promise = new Promise(function ($_, $reject) { + throw new \Error('Test', 42); + }); + + $this->setExpectedException('Error', 'Test', 42); + React\Async\await($promise); + } + + public function testAwaitReturnsValueWhenPromiseIsFullfilled() + { + $promise = new Promise(function ($resolve) { + $resolve(42); + }); + + $this->assertEquals(42, React\Async\await($promise)); + } + + public function testAwaitReturnsValueWhenPromiseIsFulfilledEvenWhenOtherTimerStopsLoop() + { + $promise = new Promise(function ($resolve) { + Loop::addTimer(0.02, function () use ($resolve) { + $resolve(2); + }); + }); + Loop::addTimer(0.01, function () { + Loop::stop(); + }); + + $this->assertEquals(2, React\Async\await($promise)); + } + + public function testAwaitShouldNotCreateAnyGarbageReferencesForResolvedPromise() + { + if (class_exists('React\Promise\When') && PHP_VERSION_ID >= 50400) { + $this->markTestSkipped('Not supported on legacy Promise v1 API with PHP 5.4+'); + } + + gc_collect_cycles(); + + $promise = new Promise(function ($resolve) { + $resolve(42); + }); + React\Async\await($promise); + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testAwaitShouldNotCreateAnyGarbageReferencesForRejectedPromise() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + gc_collect_cycles(); + + $promise = new Promise(function () { + throw new \RuntimeException(); + }); + try { + React\Async\await($promise); + } catch (\Exception $e) { + // no-op + } + unset($promise, $e); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithNullValue() + { + if (!interface_exists('React\Promise\CancellablePromiseInterface')) { + $this->markTestSkipped('Promises must be rejected with a \Throwable instance since Promise v3'); + } + + if (class_exists('React\Promise\When') && PHP_VERSION_ID >= 50400) { + $this->markTestSkipped('Not supported on legacy Promise v1 API with PHP 5.4+'); + } + + gc_collect_cycles(); + + $promise = new Promise(function ($_, $reject) { + $reject(null); + }); + try { + React\Async\await($promise); + } catch (\Exception $e) { + // no-op + } + unset($promise, $e); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function setExpectedException($exception, $exceptionMessage = '', $exceptionCode = null) + { + if (method_exists($this, 'expectException')) { + // PHPUnit 5+ + $this->expectException($exception); + if ($exceptionMessage !== '') { + $this->expectExceptionMessage($exceptionMessage); + } + if ($exceptionCode !== null) { + $this->expectExceptionCode($exceptionCode); + } + } else { + // legacy PHPUnit 4 + parent::setExpectedException($exception, $exceptionMessage, $exceptionCode); + } + } +}