From 97770facc8beda1c0578c678be110f39f02572c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 21 Oct 2021 12:55:48 +0200 Subject: [PATCH 1/5] Import `await()` function from clue/reactphp-block v1.5.0 Change namespace from `Clue\React\Block` to `React\Async` and update all tests with merged namespaces. See https://github.com/clue/reactphp-block for original repo. --- README.md | 64 ++++++++- composer.json | 7 +- src/functions.php | 116 ++++++++++++++++ tests/AwaitTest.php | 314 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 494 insertions(+), 7 deletions(-) create mode 100644 tests/AwaitTest.php diff --git a/README.md b/README.md index d5ee493..3f3f211 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,9 +49,64 @@ Alternatively, you can also use an import statement similar to this: ```php use React\Async; -Async\parallel(…); +Async\await(…); ``` +### await() + +The `await(PromiseInterface $promise, ?LoopInterface $loop = null, ?float $timeout = null): 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. In the meantime, the event loop will run any +events attached to the same loop until the promise settles. + +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`, then this function +will throw an `UnexpectedValueException` instead. + +```php +try { + $result = React\Async\await($promise); + // promise successfully fulfilled with $result + echo 'Result: ' . $result; +} catch (Exception $exception) { + // promise rejected with $exception + echo 'ERROR: ' . $exception->getMessage(); +} +``` + +This function takes an optional `LoopInterface|null $loop` parameter that can be used to +pass the event loop instance to use. You can use a `null` value here in order to +use the [default loop](https://github.com/reactphp/event-loop#loop). This value +SHOULD NOT be given unless you're sure you want to explicitly use a given event +loop instance. + +If no `$timeout` argument is given and the promise stays pending, then this +will potentially wait/block forever until the promise is settled. To avoid +this, API authors creating promises are expected to provide means to +configure a timeout for the promise instead. For more details, see also the +[`timeout()` function](https://github.com/reactphp/promise-timer#timeout). + +If the deprecated `$timeout` argument is given and the promise is still pending once the +timeout triggers, this will `cancel()` the promise and throw a `TimeoutException`. +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. + +Note that this function will assume control over the event loop. Internally, it +will actually `run()` the 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. + ### parallel() The `parallel(array> $tasks): PromiseInterface,Exception>` function can be used diff --git a/composer.json b/composer.json index 10cdd53..61ca8af 100644 --- a/composer.json +++ b/composer.json @@ -27,11 +27,12 @@ ], "require": { "php": ">=5.3.2", - "react/promise": "^2.8 || ^1.2.1" + "react/event-loop": "^1.2", + "react/promise": "^2.8 || ^1.2.1", + "react/promise-timer": "^1.5" }, "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..a28822c 100644 --- a/src/functions.php +++ b/src/functions.php @@ -2,8 +2,124 @@ namespace React\Async; +use React\EventLoop\Loop; +use React\EventLoop\LoopInterface; use React\Promise\Deferred; use React\Promise\PromiseInterface; +use React\Promise\Timer; + +/** + * 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. In the meantime, the event loop will run any + * events attached to the same loop until the promise settles. + * + * 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`, 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 (Exception $exception) { + * // promise rejected with $exception + * echo 'ERROR: ' . $exception->getMessage(); + * } + * ``` + * + * This function takes an optional `LoopInterface|null $loop` parameter that can be used to + * pass the event loop instance to use. You can use a `null` value here in order to + * use the [default loop](https://github.com/reactphp/event-loop#loop). This value + * SHOULD NOT be given unless you're sure you want to explicitly use a given event + * loop instance. + * + * If no `$timeout` argument is given and the promise stays pending, then this + * will potentially wait/block forever until the promise is settled. To avoid + * this, API authors creating promises are expected to provide means to + * configure a timeout for the promise instead. For more details, see also the + * [`timeout()` function](https://github.com/reactphp/promise-timer#timeout). + * + * If the deprecated `$timeout` argument is given and the promise is still pending once the + * timeout triggers, this will `cancel()` the promise and throw a `TimeoutException`. + * 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. + * + * Note that this function will assume control over the event loop. Internally, it + * will actually `run()` the 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. + * + * @param PromiseInterface $promise + * @param ?LoopInterface $loop + * @param ?float $timeout [deprecated] (optional) maximum timeout in seconds or null=wait forever + * @return mixed returns whatever the promise resolves to + * @throws \Exception when the promise is rejected + * @throws \React\Promise\Timer\TimeoutException if the $timeout is given and triggers + */ +function await(PromiseInterface $promise, LoopInterface $loop = null, $timeout = null) +{ + $wait = true; + $resolved = null; + $exception = null; + $rejected = false; + $loop = $loop ?: Loop::get(); + + if ($timeout !== null) { + $promise = Timer\timeout($promise, $timeout, $loop); + } + + $promise->then( + function ($c) use (&$resolved, &$wait, $loop) { + $resolved = $c; + $wait = false; + $loop->stop(); + }, + function ($error) use (&$exception, &$rejected, &$wait, $loop) { + $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) { + 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)) + ); + } elseif (!$exception instanceof \Exception) { + $exception = new \UnexpectedValueException( + 'Promise rejected with unexpected ' . get_class($exception) . ': ' . $exception->getMessage(), + $exception->getCode(), + $exception + ); + } + + throw $exception; + } + + return $resolved; +} /** * @param array> $tasks diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php new file mode 100644 index 0000000..d81cdd3 --- /dev/null +++ b/tests/AwaitTest.php @@ -0,0 +1,314 @@ +loop = Loop::get(); + } + + public function testAwaitOneRejected() + { + $promise = $this->createPromiseRejected(new \Exception('test')); + + $this->setExpectedException('Exception', 'test'); + React\Async\await($promise, $this->loop); + } + + public function testAwaitOneRejectedWithFalseWillWrapInUnexpectedValueException() + { + if (!interface_exists('React\Promise\CancellablePromiseInterface')) { + $this->markTestSkipped('Promises must be rejected with a \Throwable instance since Promise v3'); + } + + $promise = Promise\reject(false); + + $this->setExpectedException('UnexpectedValueException', 'Promise rejected with unexpected value of type bool'); + React\Async\await($promise, $this->loop); + } + + public function testAwaitOneRejectedWithNullWillWrapInUnexpectedValueException() + { + if (!interface_exists('React\Promise\CancellablePromiseInterface')) { + $this->markTestSkipped('Promises must be rejected with a \Throwable instance since Promise v3'); + } + + $promise = Promise\reject(null); + + $this->setExpectedException('UnexpectedValueException', 'Promise rejected with unexpected value of type NULL'); + React\Async\await($promise, $this->loop); + } + + /** + * @requires PHP 7 + */ + public function testAwaitOneRejectedWithPhp7ErrorWillWrapInUnexpectedValueExceptionWithPrevious() + { + $promise = Promise\reject(new \Error('Test', 42)); + + try { + React\Async\await($promise, $this->loop); + $this->fail(); + } catch (\UnexpectedValueException $e) { + $this->assertEquals('Promise rejected with unexpected Error: Test', $e->getMessage()); + $this->assertEquals(42, $e->getCode()); + $this->assertInstanceOf('Throwable', $e->getPrevious()); + $this->assertEquals('Test', $e->getPrevious()->getMessage()); + $this->assertEquals(42, $e->getPrevious()->getCode()); + } + } + + public function testAwaitOneResolved() + { + $promise = $this->createPromiseResolved(2); + + $this->assertEquals(2, React\Async\await($promise, $this->loop)); + } + + public function testAwaitReturnsFulfilledValueWithoutGivingLoop() + { + $promise = Promise\resolve(42); + + $this->assertEquals(42, React\Async\await($promise)); + } + + public function testAwaitOneInterrupted() + { + $promise = $this->createPromiseResolved(2, 0.02); + $this->createTimerInterrupt(0.01); + + $this->assertEquals(2, React\Async\await($promise, $this->loop)); + } + + public function testAwaitOncePendingWillThrowOnTimeout() + { + $promise = new Promise\Promise(function () { }); + + $this->setExpectedException('React\Promise\Timer\TimeoutException'); + React\Async\await($promise, $this->loop, 0.001); + } + + public function testAwaitOncePendingWillThrowAndCallCancellerOnTimeout() + { + $cancelled = false; + $promise = new Promise\Promise(function () { }, function () use (&$cancelled) { + $cancelled = true; + }); + + try { + React\Async\await($promise, $this->loop, 0.001); + } catch (TimeoutException $expected) { + $this->assertTrue($cancelled); + } + } + + public function testAwaitOnceWithTimeoutWillResolvemmediatelyAndCleanUpTimeout() + { + $promise = Promise\resolve(true); + + $time = microtime(true); + React\Async\await($promise, $this->loop, 5.0); + $this->loop->run(); + $time = microtime(true) - $time; + + $this->assertLessThan(0.1, $time); + } + + public function testAwaitOneResolvesShouldNotCreateAnyGarbageReferences() + { + 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 = Promise\resolve(1); + React\Async\await($promise, $this->loop); + unset($promise); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testAwaitOneRejectedShouldNotCreateAnyGarbageReferences() + { + 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 = Promise\reject(new \RuntimeException()); + try { + React\Async\await($promise, $this->loop); + } catch (\Exception $e) { + // no-op + } + unset($promise, $e); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testAwaitOneRejectedWithTimeoutShouldNotCreateAnyGarbageReferences() + { + 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 = Promise\reject(new \RuntimeException()); + try { + React\Async\await($promise, $this->loop, 0.001); + } catch (\Exception $e) { + // no-op + } + unset($promise, $e); + + $this->assertEquals(0, gc_collect_cycles()); + } + + public function testAwaitNullValueShouldNotCreateAnyGarbageReferences() + { + 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 = Promise\reject(null); + try { + React\Async\await($promise, $this->loop); + } catch (\Exception $e) { + // no-op + } + unset($promise, $e); + + $this->assertEquals(0, gc_collect_cycles()); + } + + /** + * @requires PHP 7 + */ + public function testAwaitPendingPromiseWithTimeoutAndCancellerShouldNotCreateAnyGarbageReferences() + { + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); + } + + gc_collect_cycles(); + + $promise = new \React\Promise\Promise(function () { }, function () { + throw new \RuntimeException(); + }); + try { + React\Async\await($promise, $this->loop, 0.001); + } catch (\Exception $e) { + // no-op + } + unset($promise, $e); + + $this->assertEquals(0, gc_collect_cycles()); + } + + /** + * @requires PHP 7 + */ + public function testAwaitPendingPromiseWithTimeoutAndWithoutCancellerShouldNotCreateAnyGarbageReferences() + { + gc_collect_cycles(); + + $promise = new \React\Promise\Promise(function () { }); + try { + React\Async\await($promise, $this->loop, 0.001); + } catch (\Exception $e) { + // no-op + } + unset($promise, $e); + + $this->assertEquals(0, gc_collect_cycles()); + } + + /** + * @requires PHP 7 + */ + public function testAwaitPendingPromiseWithTimeoutAndNoOpCancellerShouldNotCreateAnyGarbageReferences() + { + gc_collect_cycles(); + + $promise = new \React\Promise\Promise(function () { }, function () { + // no-op + }); + try { + React\Async\await($promise, $this->loop, 0.001); + } catch (\Exception $e) { + // no-op + } + unset($promise, $e); + + $this->assertEquals(0, gc_collect_cycles()); + } + + protected function createPromiseResolved($value = null, $delay = 0.01) + { + $deferred = new Deferred(); + + $this->loop->addTimer($delay, function () use ($deferred, $value) { + $deferred->resolve($value); + }); + + return $deferred->promise(); + } + + protected function createPromiseRejected($value = null, $delay = 0.01) + { + $deferred = new Deferred(); + + $this->loop->addTimer($delay, function () use ($deferred, $value) { + $deferred->reject($value); + }); + + return $deferred->promise(); + } + + protected function createTimerInterrupt($delay = 0.01) + { + $loop = $this->loop; + $loop->addTimer($delay, function () use ($loop) { + $loop->stop(); + }); + } + + 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); + } + } +} From 3422dad166f6332cba61f98ae46443a7e22fa50b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 21 Oct 2021 13:00:33 +0200 Subject: [PATCH 2/5] Remove deprecated `$timeout` argument --- README.md | 13 +---- composer.json | 3 +- src/functions.php | 20 +------- tests/AwaitTest.php | 116 -------------------------------------------- 4 files changed, 3 insertions(+), 149 deletions(-) diff --git a/README.md b/README.md index 3f3f211..7bc0d6d 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Async\await(…); ### await() -The `await(PromiseInterface $promise, ?LoopInterface $loop = null, ?float $timeout = null): mixed` function can be used to +The `await(PromiseInterface $promise, ?LoopInterface $loop = null): mixed` function can be used to block waiting for the given `$promise` to be fulfilled. ```php @@ -89,17 +89,6 @@ use the [default loop](https://github.com/reactphp/event-loop#loop). This value SHOULD NOT be given unless you're sure you want to explicitly use a given event loop instance. -If no `$timeout` argument is given and the promise stays pending, then this -will potentially wait/block forever until the promise is settled. To avoid -this, API authors creating promises are expected to provide means to -configure a timeout for the promise instead. For more details, see also the -[`timeout()` function](https://github.com/reactphp/promise-timer#timeout). - -If the deprecated `$timeout` argument is given and the promise is still pending once the -timeout triggers, this will `cancel()` the promise and throw a `TimeoutException`. -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. - Note that this function will assume control over the event loop. Internally, it will actually `run()` the loop until the promise settles and then calls `stop()` to terminate execution of the loop. This means this function is more suited for diff --git a/composer.json b/composer.json index 61ca8af..f21b4c2 100644 --- a/composer.json +++ b/composer.json @@ -28,8 +28,7 @@ "require": { "php": ">=5.3.2", "react/event-loop": "^1.2", - "react/promise": "^2.8 || ^1.2.1", - "react/promise-timer": "^1.5" + "react/promise": "^2.8 || ^1.2.1" }, "require-dev": { "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35" diff --git a/src/functions.php b/src/functions.php index a28822c..b596617 100644 --- a/src/functions.php +++ b/src/functions.php @@ -6,7 +6,6 @@ use React\EventLoop\LoopInterface; use React\Promise\Deferred; use React\Promise\PromiseInterface; -use React\Promise\Timer; /** * Block waiting for the given `$promise` to be fulfilled. @@ -43,17 +42,6 @@ * SHOULD NOT be given unless you're sure you want to explicitly use a given event * loop instance. * - * If no `$timeout` argument is given and the promise stays pending, then this - * will potentially wait/block forever until the promise is settled. To avoid - * this, API authors creating promises are expected to provide means to - * configure a timeout for the promise instead. For more details, see also the - * [`timeout()` function](https://github.com/reactphp/promise-timer#timeout). - * - * If the deprecated `$timeout` argument is given and the promise is still pending once the - * timeout triggers, this will `cancel()` the promise and throw a `TimeoutException`. - * 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. - * * Note that this function will assume control over the event loop. Internally, it * will actually `run()` the loop until the promise settles and then calls `stop()` to * terminate execution of the loop. This means this function is more suited for @@ -63,12 +51,10 @@ * * @param PromiseInterface $promise * @param ?LoopInterface $loop - * @param ?float $timeout [deprecated] (optional) maximum timeout in seconds or null=wait forever * @return mixed returns whatever the promise resolves to * @throws \Exception when the promise is rejected - * @throws \React\Promise\Timer\TimeoutException if the $timeout is given and triggers */ -function await(PromiseInterface $promise, LoopInterface $loop = null, $timeout = null) +function await(PromiseInterface $promise, LoopInterface $loop = null) { $wait = true; $resolved = null; @@ -76,10 +62,6 @@ function await(PromiseInterface $promise, LoopInterface $loop = null, $timeout = $rejected = false; $loop = $loop ?: Loop::get(); - if ($timeout !== null) { - $promise = Timer\timeout($promise, $timeout, $loop); - } - $promise->then( function ($c) use (&$resolved, &$wait, $loop) { $resolved = $c; diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index d81cdd3..a45ef69 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -6,7 +6,6 @@ use React\EventLoop\Loop; use React\Promise; use React\Promise\Deferred; -use React\Promise\Timer\TimeoutException; class AwaitTest extends TestCase { @@ -93,40 +92,6 @@ public function testAwaitOneInterrupted() $this->assertEquals(2, React\Async\await($promise, $this->loop)); } - public function testAwaitOncePendingWillThrowOnTimeout() - { - $promise = new Promise\Promise(function () { }); - - $this->setExpectedException('React\Promise\Timer\TimeoutException'); - React\Async\await($promise, $this->loop, 0.001); - } - - public function testAwaitOncePendingWillThrowAndCallCancellerOnTimeout() - { - $cancelled = false; - $promise = new Promise\Promise(function () { }, function () use (&$cancelled) { - $cancelled = true; - }); - - try { - React\Async\await($promise, $this->loop, 0.001); - } catch (TimeoutException $expected) { - $this->assertTrue($cancelled); - } - } - - public function testAwaitOnceWithTimeoutWillResolvemmediatelyAndCleanUpTimeout() - { - $promise = Promise\resolve(true); - - $time = microtime(true); - React\Async\await($promise, $this->loop, 5.0); - $this->loop->run(); - $time = microtime(true) - $time; - - $this->assertLessThan(0.1, $time); - } - public function testAwaitOneResolvesShouldNotCreateAnyGarbageReferences() { if (class_exists('React\Promise\When') && PHP_VERSION_ID >= 50400) { @@ -161,25 +126,6 @@ public function testAwaitOneRejectedShouldNotCreateAnyGarbageReferences() $this->assertEquals(0, gc_collect_cycles()); } - public function testAwaitOneRejectedWithTimeoutShouldNotCreateAnyGarbageReferences() - { - 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 = Promise\reject(new \RuntimeException()); - try { - React\Async\await($promise, $this->loop, 0.001); - } catch (\Exception $e) { - // no-op - } - unset($promise, $e); - - $this->assertEquals(0, gc_collect_cycles()); - } - public function testAwaitNullValueShouldNotCreateAnyGarbageReferences() { if (!interface_exists('React\Promise\CancellablePromiseInterface')) { @@ -203,68 +149,6 @@ public function testAwaitNullValueShouldNotCreateAnyGarbageReferences() $this->assertEquals(0, gc_collect_cycles()); } - /** - * @requires PHP 7 - */ - public function testAwaitPendingPromiseWithTimeoutAndCancellerShouldNotCreateAnyGarbageReferences() - { - if (class_exists('React\Promise\When')) { - $this->markTestSkipped('Not supported on legacy Promise v1 API'); - } - - gc_collect_cycles(); - - $promise = new \React\Promise\Promise(function () { }, function () { - throw new \RuntimeException(); - }); - try { - React\Async\await($promise, $this->loop, 0.001); - } catch (\Exception $e) { - // no-op - } - unset($promise, $e); - - $this->assertEquals(0, gc_collect_cycles()); - } - - /** - * @requires PHP 7 - */ - public function testAwaitPendingPromiseWithTimeoutAndWithoutCancellerShouldNotCreateAnyGarbageReferences() - { - gc_collect_cycles(); - - $promise = new \React\Promise\Promise(function () { }); - try { - React\Async\await($promise, $this->loop, 0.001); - } catch (\Exception $e) { - // no-op - } - unset($promise, $e); - - $this->assertEquals(0, gc_collect_cycles()); - } - - /** - * @requires PHP 7 - */ - public function testAwaitPendingPromiseWithTimeoutAndNoOpCancellerShouldNotCreateAnyGarbageReferences() - { - gc_collect_cycles(); - - $promise = new \React\Promise\Promise(function () { }, function () { - // no-op - }); - try { - React\Async\await($promise, $this->loop, 0.001); - } catch (\Exception $e) { - // no-op - } - unset($promise, $e); - - $this->assertEquals(0, gc_collect_cycles()); - } - protected function createPromiseResolved($value = null, $delay = 0.01) { $deferred = new Deferred(); From 7763182a28513a886b4e89f713270bfbd0ced09f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 21 Oct 2021 13:11:22 +0200 Subject: [PATCH 3/5] Remove optional `$loop` argument and always use default loop --- README.md | 25 +++++++++---------------- src/functions.php | 38 ++++++++++++++------------------------ tests/AwaitTest.php | 37 +++++++++++++------------------------ 3 files changed, 36 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 7bc0d6d..3ffc05f 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Async\await(…); ### await() -The `await(PromiseInterface $promise, ?LoopInterface $loop = null): mixed` function can be used to +The `await(PromiseInterface $promise): mixed` function can be used to block waiting for the given `$promise` to be fulfilled. ```php @@ -62,8 +62,14 @@ $result = React\Async\await($promise); ``` This function will only return after the given `$promise` has settled, i.e. -either fulfilled or rejected. In the meantime, the event loop will run any -events attached to the same loop until the promise settles. +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. @@ -83,19 +89,6 @@ try { } ``` -This function takes an optional `LoopInterface|null $loop` parameter that can be used to -pass the event loop instance to use. You can use a `null` value here in order to -use the [default loop](https://github.com/reactphp/event-loop#loop). This value -SHOULD NOT be given unless you're sure you want to explicitly use a given event -loop instance. - -Note that this function will assume control over the event loop. Internally, it -will actually `run()` the 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. - ### parallel() The `parallel(array> $tasks): PromiseInterface,Exception>` function can be used diff --git a/src/functions.php b/src/functions.php index b596617..3d0c61a 100644 --- a/src/functions.php +++ b/src/functions.php @@ -3,7 +3,6 @@ namespace React\Async; use React\EventLoop\Loop; -use React\EventLoop\LoopInterface; use React\Promise\Deferred; use React\Promise\PromiseInterface; @@ -15,8 +14,14 @@ * ``` * * This function will only return after the given `$promise` has settled, i.e. - * either fulfilled or rejected. In the meantime, the event loop will run any - * events attached to the same loop until the promise settles. + * 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. @@ -36,43 +41,28 @@ * } * ``` * - * This function takes an optional `LoopInterface|null $loop` parameter that can be used to - * pass the event loop instance to use. You can use a `null` value here in order to - * use the [default loop](https://github.com/reactphp/event-loop#loop). This value - * SHOULD NOT be given unless you're sure you want to explicitly use a given event - * loop instance. - * - * Note that this function will assume control over the event loop. Internally, it - * will actually `run()` the 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. - * * @param PromiseInterface $promise - * @param ?LoopInterface $loop * @return mixed returns whatever the promise resolves to * @throws \Exception when the promise is rejected */ -function await(PromiseInterface $promise, LoopInterface $loop = null) +function await(PromiseInterface $promise) { $wait = true; $resolved = null; $exception = null; $rejected = false; - $loop = $loop ?: Loop::get(); $promise->then( - function ($c) use (&$resolved, &$wait, $loop) { + function ($c) use (&$resolved, &$wait) { $resolved = $c; $wait = false; - $loop->stop(); + Loop::stop(); }, - function ($error) use (&$exception, &$rejected, &$wait, $loop) { + function ($error) use (&$exception, &$rejected, &$wait) { $exception = $error; $rejected = true; $wait = false; - $loop->stop(); + Loop::stop(); } ); @@ -81,7 +71,7 @@ function ($error) use (&$exception, &$rejected, &$wait, $loop) { $promise = null; while ($wait) { - $loop->run(); + Loop::run(); } if ($rejected) { diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index a45ef69..485012e 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -9,22 +9,12 @@ class AwaitTest extends TestCase { - protected $loop; - - /** - * @before - */ - public function setUpLoop() - { - $this->loop = Loop::get(); - } - public function testAwaitOneRejected() { $promise = $this->createPromiseRejected(new \Exception('test')); $this->setExpectedException('Exception', 'test'); - React\Async\await($promise, $this->loop); + React\Async\await($promise); } public function testAwaitOneRejectedWithFalseWillWrapInUnexpectedValueException() @@ -36,7 +26,7 @@ public function testAwaitOneRejectedWithFalseWillWrapInUnexpectedValueException( $promise = Promise\reject(false); $this->setExpectedException('UnexpectedValueException', 'Promise rejected with unexpected value of type bool'); - React\Async\await($promise, $this->loop); + React\Async\await($promise); } public function testAwaitOneRejectedWithNullWillWrapInUnexpectedValueException() @@ -48,7 +38,7 @@ public function testAwaitOneRejectedWithNullWillWrapInUnexpectedValueException() $promise = Promise\reject(null); $this->setExpectedException('UnexpectedValueException', 'Promise rejected with unexpected value of type NULL'); - React\Async\await($promise, $this->loop); + React\Async\await($promise); } /** @@ -59,7 +49,7 @@ public function testAwaitOneRejectedWithPhp7ErrorWillWrapInUnexpectedValueExcept $promise = Promise\reject(new \Error('Test', 42)); try { - React\Async\await($promise, $this->loop); + React\Async\await($promise); $this->fail(); } catch (\UnexpectedValueException $e) { $this->assertEquals('Promise rejected with unexpected Error: Test', $e->getMessage()); @@ -74,7 +64,7 @@ public function testAwaitOneResolved() { $promise = $this->createPromiseResolved(2); - $this->assertEquals(2, React\Async\await($promise, $this->loop)); + $this->assertEquals(2, React\Async\await($promise)); } public function testAwaitReturnsFulfilledValueWithoutGivingLoop() @@ -89,7 +79,7 @@ public function testAwaitOneInterrupted() $promise = $this->createPromiseResolved(2, 0.02); $this->createTimerInterrupt(0.01); - $this->assertEquals(2, React\Async\await($promise, $this->loop)); + $this->assertEquals(2, React\Async\await($promise)); } public function testAwaitOneResolvesShouldNotCreateAnyGarbageReferences() @@ -101,7 +91,7 @@ public function testAwaitOneResolvesShouldNotCreateAnyGarbageReferences() gc_collect_cycles(); $promise = Promise\resolve(1); - React\Async\await($promise, $this->loop); + React\Async\await($promise); unset($promise); $this->assertEquals(0, gc_collect_cycles()); @@ -117,7 +107,7 @@ public function testAwaitOneRejectedShouldNotCreateAnyGarbageReferences() $promise = Promise\reject(new \RuntimeException()); try { - React\Async\await($promise, $this->loop); + React\Async\await($promise); } catch (\Exception $e) { // no-op } @@ -140,7 +130,7 @@ public function testAwaitNullValueShouldNotCreateAnyGarbageReferences() $promise = Promise\reject(null); try { - React\Async\await($promise, $this->loop); + React\Async\await($promise); } catch (\Exception $e) { // no-op } @@ -153,7 +143,7 @@ protected function createPromiseResolved($value = null, $delay = 0.01) { $deferred = new Deferred(); - $this->loop->addTimer($delay, function () use ($deferred, $value) { + Loop::addTimer($delay, function () use ($deferred, $value) { $deferred->resolve($value); }); @@ -164,7 +154,7 @@ protected function createPromiseRejected($value = null, $delay = 0.01) { $deferred = new Deferred(); - $this->loop->addTimer($delay, function () use ($deferred, $value) { + Loop::addTimer($delay, function () use ($deferred, $value) { $deferred->reject($value); }); @@ -173,9 +163,8 @@ protected function createPromiseRejected($value = null, $delay = 0.01) protected function createTimerInterrupt($delay = 0.01) { - $loop = $this->loop; - $loop->addTimer($delay, function () use ($loop) { - $loop->stop(); + Loop::addTimer($delay, function () { + Loop::stop(); }); } From d2a660855053aa8a779a96bcb007c99cee8f202e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 21 Oct 2021 13:56:22 +0200 Subject: [PATCH 4/5] Support throwing `Throwable` as-is (PHP 7+) --- README.md | 10 +++++----- src/functions.php | 21 +++++++++------------ tests/AwaitTest.php | 14 +++----------- 3 files changed, 17 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 3ffc05f..13ed842 100644 --- a/README.md +++ b/README.md @@ -75,17 +75,17 @@ 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`, then this function -will throw an `UnexpectedValueException` instead. +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 (Exception $exception) { - // promise rejected with $exception - echo 'ERROR: ' . $exception->getMessage(); +} catch (Throwable $e) { + // promise rejected with $e + echo 'Error: ' . $e->getMessage(); } ``` diff --git a/src/functions.php b/src/functions.php index 3d0c61a..350f914 100644 --- a/src/functions.php +++ b/src/functions.php @@ -27,23 +27,25 @@ * resolved to. * * Once the promise is rejected, this will throw whatever the promise rejected - * with. If the promise did not reject with an `Exception`, then this function - * will throw an `UnexpectedValueException` instead. + * 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 (Exception $exception) { - * // promise rejected with $exception - * echo 'ERROR: ' . $exception->getMessage(); + * } 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 + * @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) { @@ -75,16 +77,11 @@ function ($error) use (&$exception, &$rejected, &$wait) { } 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)) ); - } elseif (!$exception instanceof \Exception) { - $exception = new \UnexpectedValueException( - 'Promise rejected with unexpected ' . get_class($exception) . ': ' . $exception->getMessage(), - $exception->getCode(), - $exception - ); } throw $exception; diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index 485012e..1ceea80 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -44,20 +44,12 @@ public function testAwaitOneRejectedWithNullWillWrapInUnexpectedValueException() /** * @requires PHP 7 */ - public function testAwaitOneRejectedWithPhp7ErrorWillWrapInUnexpectedValueExceptionWithPrevious() + public function testAwaitRejectedWithPhp7ErrorWillThrowOriginalError() { $promise = Promise\reject(new \Error('Test', 42)); - try { - React\Async\await($promise); - $this->fail(); - } catch (\UnexpectedValueException $e) { - $this->assertEquals('Promise rejected with unexpected Error: Test', $e->getMessage()); - $this->assertEquals(42, $e->getCode()); - $this->assertInstanceOf('Throwable', $e->getPrevious()); - $this->assertEquals('Test', $e->getPrevious()->getMessage()); - $this->assertEquals(42, $e->getPrevious()->getCode()); - } + $this->setExpectedException('Error', 'Test', 42); + React\Async\await($promise); } public function testAwaitOneResolved() From 84a2de59c1a2879767af7927d597b9d765260ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20L=C3=BCck?= Date: Thu, 21 Oct 2021 14:12:31 +0200 Subject: [PATCH 5/5] Clean up test suite for `await()` function --- tests/AwaitTest.php | 103 +++++++++++++++++++------------------------- 1 file changed, 44 insertions(+), 59 deletions(-) diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index 1ceea80..1acc7e3 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -4,38 +4,43 @@ use React; use React\EventLoop\Loop; -use React\Promise; -use React\Promise\Deferred; +use React\Promise\Promise; class AwaitTest extends TestCase { - public function testAwaitOneRejected() + public function testAwaitThrowsExceptionWhenPromiseIsRejectedWithException() { - $promise = $this->createPromiseRejected(new \Exception('test')); + $promise = new Promise(function () { + throw new \Exception('test'); + }); $this->setExpectedException('Exception', 'test'); React\Async\await($promise); } - public function testAwaitOneRejectedWithFalseWillWrapInUnexpectedValueException() + public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithFalse() { if (!interface_exists('React\Promise\CancellablePromiseInterface')) { $this->markTestSkipped('Promises must be rejected with a \Throwable instance since Promise v3'); } - $promise = Promise\reject(false); + $promise = new Promise(function ($_, $reject) { + $reject(false); + }); $this->setExpectedException('UnexpectedValueException', 'Promise rejected with unexpected value of type bool'); React\Async\await($promise); } - public function testAwaitOneRejectedWithNullWillWrapInUnexpectedValueException() + public function testAwaitThrowsUnexpectedValueExceptionWhenPromiseIsRejectedWithNull() { if (!interface_exists('React\Promise\CancellablePromiseInterface')) { $this->markTestSkipped('Promises must be rejected with a \Throwable instance since Promise v3'); } - $promise = Promise\reject(null); + $promise = new Promise(function ($_, $reject) { + $reject(null); + }); $this->setExpectedException('UnexpectedValueException', 'Promise rejected with unexpected value of type NULL'); React\Async\await($promise); @@ -44,37 +49,40 @@ public function testAwaitOneRejectedWithNullWillWrapInUnexpectedValueException() /** * @requires PHP 7 */ - public function testAwaitRejectedWithPhp7ErrorWillThrowOriginalError() + public function testAwaitThrowsErrorWhenPromiseIsRejectedWithError() { - $promise = Promise\reject(new \Error('Test', 42)); + $promise = new Promise(function ($_, $reject) { + throw new \Error('Test', 42); + }); $this->setExpectedException('Error', 'Test', 42); React\Async\await($promise); } - public function testAwaitOneResolved() - { - $promise = $this->createPromiseResolved(2); - - $this->assertEquals(2, React\Async\await($promise)); - } - - public function testAwaitReturnsFulfilledValueWithoutGivingLoop() + public function testAwaitReturnsValueWhenPromiseIsFullfilled() { - $promise = Promise\resolve(42); + $promise = new Promise(function ($resolve) { + $resolve(42); + }); $this->assertEquals(42, React\Async\await($promise)); } - public function testAwaitOneInterrupted() + public function testAwaitReturnsValueWhenPromiseIsFulfilledEvenWhenOtherTimerStopsLoop() { - $promise = $this->createPromiseResolved(2, 0.02); - $this->createTimerInterrupt(0.01); + $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 testAwaitOneResolvesShouldNotCreateAnyGarbageReferences() + 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+'); @@ -82,22 +90,26 @@ public function testAwaitOneResolvesShouldNotCreateAnyGarbageReferences() gc_collect_cycles(); - $promise = Promise\resolve(1); + $promise = new Promise(function ($resolve) { + $resolve(42); + }); React\Async\await($promise); unset($promise); $this->assertEquals(0, gc_collect_cycles()); } - public function testAwaitOneRejectedShouldNotCreateAnyGarbageReferences() + public function testAwaitShouldNotCreateAnyGarbageReferencesForRejectedPromise() { - if (class_exists('React\Promise\When') && PHP_VERSION_ID >= 50400) { - $this->markTestSkipped('Not supported on legacy Promise v1 API with PHP 5.4+'); + if (class_exists('React\Promise\When')) { + $this->markTestSkipped('Not supported on legacy Promise v1 API'); } gc_collect_cycles(); - $promise = Promise\reject(new \RuntimeException()); + $promise = new Promise(function () { + throw new \RuntimeException(); + }); try { React\Async\await($promise); } catch (\Exception $e) { @@ -108,7 +120,7 @@ public function testAwaitOneRejectedShouldNotCreateAnyGarbageReferences() $this->assertEquals(0, gc_collect_cycles()); } - public function testAwaitNullValueShouldNotCreateAnyGarbageReferences() + public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithNullValue() { if (!interface_exists('React\Promise\CancellablePromiseInterface')) { $this->markTestSkipped('Promises must be rejected with a \Throwable instance since Promise v3'); @@ -120,7 +132,9 @@ public function testAwaitNullValueShouldNotCreateAnyGarbageReferences() gc_collect_cycles(); - $promise = Promise\reject(null); + $promise = new Promise(function ($_, $reject) { + $reject(null); + }); try { React\Async\await($promise); } catch (\Exception $e) { @@ -131,35 +145,6 @@ public function testAwaitNullValueShouldNotCreateAnyGarbageReferences() $this->assertEquals(0, gc_collect_cycles()); } - protected function createPromiseResolved($value = null, $delay = 0.01) - { - $deferred = new Deferred(); - - Loop::addTimer($delay, function () use ($deferred, $value) { - $deferred->resolve($value); - }); - - return $deferred->promise(); - } - - protected function createPromiseRejected($value = null, $delay = 0.01) - { - $deferred = new Deferred(); - - Loop::addTimer($delay, function () use ($deferred, $value) { - $deferred->reject($value); - }); - - return $deferred->promise(); - } - - protected function createTimerInterrupt($delay = 0.01) - { - Loop::addTimer($delay, function () { - Loop::stop(); - }); - } - public function setExpectedException($exception, $exceptionMessage = '', $exceptionCode = null) { if (method_exists($this, 'expectException')) {