diff --git a/src/functions.php b/src/functions.php index ad91688..478623d 100644 --- a/src/functions.php +++ b/src/functions.php @@ -53,18 +53,47 @@ function await(PromiseInterface $promise) { $wait = true; - $resolved = null; - $exception = null; + $resolved = false; $rejected = false; + $resolvedValue = null; + $rejectedThrowable = null; $promise->then( - function ($c) use (&$resolved, &$wait) { - $resolved = $c; + function ($c) use (&$resolved, &$resolvedValue, &$wait) { + $resolvedValue = $c; + $resolved = true; $wait = false; Loop::stop(); }, - function ($error) use (&$exception, &$rejected, &$wait) { - $exception = $error; + function ($error) use (&$rejected, &$rejectedThrowable, &$wait) { + // promise is rejected with an unexpected value (Promise API v1 or v2 only) + if (!$error instanceof \Exception && !$error instanceof \Throwable) { + $error = new \UnexpectedValueException( + 'Promise rejected with unexpected value of type ' . (is_object($error) ? get_class($error) : gettype($error)) + ); + + // avoid garbage references by replacing all closures in call stack. + // what a lovely piece of code! + $r = new \ReflectionProperty('Exception', 'trace'); + $r->setAccessible(true); + $trace = $r->getValue($error); + + // 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($error, $trace); + } + + $rejectedThrowable = $error; $rejected = true; $wait = false; Loop::stop(); @@ -75,25 +104,25 @@ function ($error) use (&$exception, &$rejected, &$wait) { // argument does not show up in the stack trace in PHP 7+ only. $promise = null; + if ($rejected) { + throw $rejectedThrowable; + } + + if ($resolved) { + return $resolvedValue; + } + while ($wait) { Loop::run(); } if ($rejected) { - // promise is rejected with an unexpected value (Promise API v1 or v2 only) - if (!$exception instanceof \Throwable) { - $exception = new \UnexpectedValueException( - 'Promise rejected with unexpected value of type ' . (is_object($exception) ? get_class($exception) : gettype($exception)) - ); - } - - throw $exception; + throw $rejectedThrowable; } - return $resolved; + return $resolvedValue; } - /** * Execute a Generator-based coroutine to "await" promises. * diff --git a/tests/AwaitTest.php b/tests/AwaitTest.php index 95a8b5f..e1274dd 100644 --- a/tests/AwaitTest.php +++ b/tests/AwaitTest.php @@ -122,6 +122,38 @@ public function testAwaitShouldNotCreateAnyGarbageReferencesForRejectedPromise() $this->assertEquals(0, gc_collect_cycles()); } + public function testAlreadyFulfilledPromiseShouldShortCircuitAndNotRunLoop() + { + for ($i = 0; $i < 6; $i++) { + $this->assertSame($i, React\Async\await(React\Promise\resolve($i))); + } + } + + public function testPendingPromiseShouldNotShortCircuitAndRunLoop() + { + Loop::futureTick($this->expectCallableOnce()); + + $this->assertSame(1, React\Async\await(new Promise(static function (callable $resolve) { + Loop::futureTick(static function () use ($resolve) { + $resolve(1); + }); + }))); + } + + public function testPendingPromiseShouldNotShortCircuitAndRunLoopAndThrowOnRejection() + { + Loop::futureTick($this->expectCallableOnce()); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('test'); + + $this->assertSame(1, React\Async\await(new Promise(static function (callable $resolve, callable $reject) { + Loop::futureTick(static function () use ($reject) { + $reject(new \Exception('test')); + }); + }))); + } + public function testAwaitShouldNotCreateAnyGarbageReferencesForPromiseRejectedWithNullValue() { if (!interface_exists('React\Promise\CancellablePromiseInterface')) {