diff --git a/README.md b/README.md index 7bfb2382..64516b2a 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,8 @@ having `$onFulfilled` (which they registered via `$promise->then()`) called with If `$value` itself is a promise, the promise will transition to the state of this promise once it is resolved. +See also the [`resolve()` function](#resolve). + #### Deferred::reject() ```php @@ -136,6 +138,8 @@ computation failed. All consumers are notified by having `$onRejected` (which they registered via `$promise->then()`) called with `$reason`. +See also the [`reject()` function](#reject). + ### PromiseInterface The promise interface provides the common interface for all promise @@ -361,6 +365,19 @@ a trusted promise that follows the state of the thenable is returned. If `$promiseOrValue` is a promise, it will be returned as is. +The resulting `$promise` implements the [`PromiseInterface`](#promiseinterface) +and can be consumed like any other promise: + +```php +$promise = React\Promise\resolve(42); + +$promise->then(function (int $result): void { + var_dump($result); +}, function (\Throwable $e): void { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + #### reject() ```php @@ -374,6 +391,52 @@ both user land [`\Exception`](https://www.php.net/manual/en/class.exception.php) [`\Error`](https://www.php.net/manual/en/class.error.php) internal PHP errors. By enforcing `\Throwable` as reason to reject a promise, any language error or user land exception can be used to reject a promise. +The resulting `$promise` implements the [`PromiseInterface`](#promiseinterface) +and can be consumed like any other promise: + +```php +$promise = React\Promise\reject(new RuntimeException('Request failed')); + +$promise->then(function (int $result): void { + var_dump($result); +}, function (\Throwable $e): void { + echo 'Error: ' . $e->getMessage() . PHP_EOL; +}); +``` + +Note that rejected promises should always be handled similar to how any +exceptions should always be caught in a `try` + `catch` block. If you remove the +last reference to a rejected promise that has not been handled, it will +report an unhandled promise rejection: + +```php +function incorrect(): int +{ + $promise = React\Promise\reject(new RuntimeException('Request failed')); + + // Commented out: No rejection handler registered here. + // $promise->then(null, function (\Throwable $e): void { /* ignore */ }); + + // Returning from a function will remove all local variable references, hence why + // this will report an unhandled promise rejection here. + return 42; +} + +// Calling this function will log an error message plus its stack trace: +// Unhandled promise rejection with RuntimeException: Request failed in example.php:10 +incorrect(); +``` + +A rejected promise will be considered "handled" if you catch the rejection +reason with either the [`then()` method](#promiseinterfacethen), the +[`catch()` method](#promiseinterfacecatch), or the +[`finally()` method](#promiseinterfacefinally). Note that each of these methods +return a new promise that may again be rejected if you re-throw an exception. + +A rejected promise will also be considered "handled" if you abort the operation +with the [`cancel()` method](#promiseinterfacecancel) (which in turn would +usually reject the promise if it is still pending). + #### all() ```php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 895c8410..293865dd 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,3 +4,7 @@ parameters: paths: - src/ - tests/ + + fileExtensions: + - php + - phpt diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 42422782..bba7c900 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -10,6 +10,7 @@ ./tests/ + ./tests/ diff --git a/phpunit.xml.legacy b/phpunit.xml.legacy index 5a312e5d..4bdf7718 100644 --- a/phpunit.xml.legacy +++ b/phpunit.xml.legacy @@ -8,6 +8,7 @@ ./tests/ + ./tests/ diff --git a/src/Internal/RejectedPromise.php b/src/Internal/RejectedPromise.php index cbd8ef53..a29cc92d 100644 --- a/src/Internal/RejectedPromise.php +++ b/src/Internal/RejectedPromise.php @@ -14,6 +14,9 @@ final class RejectedPromise implements PromiseInterface /** @var \Throwable */ private $reason; + /** @var bool */ + private $handled = false; + /** * @param \Throwable $reason */ @@ -22,12 +25,26 @@ public function __construct(\Throwable $reason) $this->reason = $reason; } + public function __destruct() + { + if ($this->handled) { + return; + } + + $message = 'Unhandled promise rejection with ' . \get_class($this->reason) . ': ' . $this->reason->getMessage() . ' in ' . $this->reason->getFile() . ':' . $this->reason->getLine() . PHP_EOL; + $message .= 'Stack trace:' . PHP_EOL . $this->reason->getTraceAsString(); + + \error_log($message); + } + public function then(callable $onFulfilled = null, callable $onRejected = null): PromiseInterface { if (null === $onRejected) { return $this; } + $this->handled = true; + try { return resolve($onRejected($this->reason)); } catch (\Throwable $exception) { @@ -55,6 +72,7 @@ public function finally(callable $onFulfilledOrRejected): PromiseInterface public function cancel(): void { + $this->handled = true; } /** diff --git a/src/Promise.php b/src/Promise.php index a2d72b6d..819e414a 100644 --- a/src/Promise.php +++ b/src/Promise.php @@ -18,6 +18,9 @@ final class Promise implements PromiseInterface /** @var int */ private $requiredCancelRequests = 0; + /** @var bool */ + private $cancelled = false; + public function __construct(callable $resolver, callable $canceller = null) { $this->canceller = $canceller; @@ -89,12 +92,18 @@ public function finally(callable $onFulfilledOrRejected): PromiseInterface public function cancel(): void { + $this->cancelled = true; $canceller = $this->canceller; $this->canceller = null; $parentCanceller = null; if (null !== $this->result) { + // Forward cancellation to rejected promise to avoid reporting unhandled rejection + if ($this->result instanceof RejectedPromise) { + $this->result->cancel(); + } + // Go up the promise chain and reach the top most promise which is // itself not following another promise $root = $this->unwrap($this->result); @@ -191,6 +200,11 @@ private function settle(PromiseInterface $result): void foreach ($handlers as $handler) { $handler($result); } + + // Forward cancellation to rejected promise to avoid reporting unhandled rejection + if ($this->cancelled && $result instanceof RejectedPromise) { + $result->cancel(); + } } private function unwrap(PromiseInterface $promise): PromiseInterface diff --git a/src/functions.php b/src/functions.php index c42b715e..c8107f8d 100644 --- a/src/functions.php +++ b/src/functions.php @@ -100,7 +100,7 @@ function (\Throwable $reason) use (&$continue, $reject): void { } ); - if (!$continue) { + if (!$continue && !\is_array($promisesOrValues)) { break; } } @@ -136,7 +136,7 @@ function race(iterable $promisesOrValues): PromiseInterface $continue = false; }); - if (!$continue) { + if (!$continue && !\is_array($promisesOrValues)) { break; } } @@ -187,7 +187,7 @@ function (\Throwable $reason) use ($i, &$reasons, &$toReject, $reject, &$continu } ); - if (!$continue) { + if (!$continue && !\is_array($promisesOrValues)) { break; } } diff --git a/tests/DeferredTest.php b/tests/DeferredTest.php index bb8f08d6..186ed1d7 100644 --- a/tests/DeferredTest.php +++ b/tests/DeferredTest.php @@ -54,9 +54,13 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerHoldsReferenc gc_collect_cycles(); gc_collect_cycles(); // clear twice to avoid leftovers in PHP 7.4 with ext-xdebug and code coverage turned on + /** @var Deferred $deferred */ $deferred = new Deferred(function () use (&$deferred) { assert($deferred instanceof Deferred); }); + + $deferred->promise()->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + $deferred->reject(new \Exception('foo')); unset($deferred); diff --git a/tests/DeferredTestCancelNoopThenRejectShouldNotReportUnhandled.phpt b/tests/DeferredTestCancelNoopThenRejectShouldNotReportUnhandled.phpt new file mode 100644 index 00000000..e4e1d64d --- /dev/null +++ b/tests/DeferredTestCancelNoopThenRejectShouldNotReportUnhandled.phpt @@ -0,0 +1,21 @@ +--TEST-- +Calling cancel() and then reject() should not report unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +promise()->cancel(); +$deferred->reject(new RuntimeException('foo')); + +echo 'void' . PHP_EOL; + +?> +--EXPECT-- +void diff --git a/tests/DeferredTestCancelThatRejectsShouldNotReportUnhandled.phpt b/tests/DeferredTestCancelThatRejectsShouldNotReportUnhandled.phpt new file mode 100644 index 00000000..d581a062 --- /dev/null +++ b/tests/DeferredTestCancelThatRejectsShouldNotReportUnhandled.phpt @@ -0,0 +1,20 @@ +--TEST-- +Calling cancel() that rejects should not report unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +promise()->cancel(); + +echo 'void' . PHP_EOL; + +?> +--EXPECT-- +void diff --git a/tests/DeferredTestRejectShouldReportUnhandled.phpt b/tests/DeferredTestRejectShouldReportUnhandled.phpt new file mode 100644 index 00000000..46d8afa6 --- /dev/null +++ b/tests/DeferredTestRejectShouldReportUnhandled.phpt @@ -0,0 +1,20 @@ +--TEST-- +Calling reject() without any handlers should report unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +reject(new RuntimeException('foo')); + +?> +--EXPECTF-- +Unhandled promise rejection with RuntimeException: foo in %s:%d +Stack trace: +#0 %A{main} diff --git a/tests/DeferredTestRejectThenCancelShouldNotReportUnhandled.phpt b/tests/DeferredTestRejectThenCancelShouldNotReportUnhandled.phpt new file mode 100644 index 00000000..ecda961a --- /dev/null +++ b/tests/DeferredTestRejectThenCancelShouldNotReportUnhandled.phpt @@ -0,0 +1,21 @@ +--TEST-- +Calling reject() and then cancel() should not report unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +reject(new RuntimeException('foo')); +$deferred->promise()->cancel(); + +echo 'void' . PHP_EOL; + +?> +--EXPECT-- +void diff --git a/tests/FunctionAllTest.php b/tests/FunctionAllTest.php index 94b5c049..10cfa2ff 100644 --- a/tests/FunctionAllTest.php +++ b/tests/FunctionAllTest.php @@ -106,7 +106,7 @@ public function shouldRejectIfAnyInputPromiseRejects(): void ->method('__invoke') ->with(self::identicalTo($exception2)); - all([resolve(1), reject($exception2), resolve($exception3)]) + all([resolve(1), reject($exception2), reject($exception3)]) ->then($this->expectCallableNever(), $mock); } diff --git a/tests/FunctionAllTestRejectedShouldReportUnhandled.phpt b/tests/FunctionAllTestRejectedShouldReportUnhandled.phpt new file mode 100644 index 00000000..b4ee4c9f --- /dev/null +++ b/tests/FunctionAllTestRejectedShouldReportUnhandled.phpt @@ -0,0 +1,23 @@ +--TEST-- +Calling all() with rejected promises should report unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- + +--EXPECTF-- +Unhandled promise rejection with RuntimeException: foo in %s:%d +Stack trace: +#0 %A{main} diff --git a/tests/FunctionAllTestRejectedThenMatchingThatReturnsShouldNotReportUnhandled.phpt b/tests/FunctionAllTestRejectedThenMatchingThatReturnsShouldNotReportUnhandled.phpt new file mode 100644 index 00000000..c69bfeac --- /dev/null +++ b/tests/FunctionAllTestRejectedThenMatchingThatReturnsShouldNotReportUnhandled.phpt @@ -0,0 +1,23 @@ +--TEST-- +Calling all() and then then() should not report unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +then(null, function (\Throwable $e) { + echo 'Handled ' . get_class($e) . ': ' . $e->getMessage() . PHP_EOL; +}); + +?> +--EXPECT-- +Handled RuntimeException: foo diff --git a/tests/FunctionAnyTestRejectedShouldReportUnhandled.phpt b/tests/FunctionAnyTestRejectedShouldReportUnhandled.phpt new file mode 100644 index 00000000..935518ae --- /dev/null +++ b/tests/FunctionAnyTestRejectedShouldReportUnhandled.phpt @@ -0,0 +1,28 @@ +--TEST-- +Calling any() with rejected promises should report unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- + +--EXPECTF-- +Unhandled promise rejection with React\Promise\Exception\CompositeException: All promises rejected. in %s:%d +Stack trace: +#0 %s/src/Promise.php(%d): React\Promise\{closure}(%S) +#1 %s/src/Promise.php(%d): React\Promise\Promise->call(%S) +#2 %s/src/functions.php(%d): React\Promise\Promise->__construct(%S) +#3 %s(%d): React\Promise\any(%S) +#4 %A{main} + diff --git a/tests/FunctionAnyTestRejectedThenMatchingThatReturnsShouldNotReportUnhandled.phpt b/tests/FunctionAnyTestRejectedThenMatchingThatReturnsShouldNotReportUnhandled.phpt new file mode 100644 index 00000000..840e3a78 --- /dev/null +++ b/tests/FunctionAnyTestRejectedThenMatchingThatReturnsShouldNotReportUnhandled.phpt @@ -0,0 +1,23 @@ +--TEST-- +Calling any() and then then() should not report unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +then(null, function (\Throwable $e) { + echo 'Handled ' . get_class($e) . ': ' . $e->getMessage() . PHP_EOL; +}); + +?> +--EXPECT-- +Handled React\Promise\Exception\CompositeException: All promises rejected. diff --git a/tests/FunctionRaceTestRejectedShouldReportUnhandled.phpt b/tests/FunctionRaceTestRejectedShouldReportUnhandled.phpt new file mode 100644 index 00000000..de963d33 --- /dev/null +++ b/tests/FunctionRaceTestRejectedShouldReportUnhandled.phpt @@ -0,0 +1,23 @@ +--TEST-- +Calling race() with rejected promises should report unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- + +--EXPECTF-- +Unhandled promise rejection with RuntimeException: foo in %s:%d +Stack trace: +#0 %A{main} diff --git a/tests/FunctionRaceTestRejectedThenMatchingThatReturnsShouldNotReportUnhandled.phpt b/tests/FunctionRaceTestRejectedThenMatchingThatReturnsShouldNotReportUnhandled.phpt new file mode 100644 index 00000000..110f5f43 --- /dev/null +++ b/tests/FunctionRaceTestRejectedThenMatchingThatReturnsShouldNotReportUnhandled.phpt @@ -0,0 +1,23 @@ +--TEST-- +Calling race() and then then() should not report unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +then(null, function (\Throwable $e) { + echo 'Handled ' . get_class($e) . ': ' . $e->getMessage() . PHP_EOL; +}); + +?> +--EXPECT-- +Handled RuntimeException: foo diff --git a/tests/FunctionRejectTestCancelShouldNotReportUnhandled.phpt b/tests/FunctionRejectTestCancelShouldNotReportUnhandled.phpt new file mode 100644 index 00000000..c6f17d5b --- /dev/null +++ b/tests/FunctionRejectTestCancelShouldNotReportUnhandled.phpt @@ -0,0 +1,19 @@ +--TEST-- +Calling reject() and then cancel() should not report unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +cancel(); + +echo 'void' . PHP_EOL; + +?> +--EXPECT-- +void diff --git a/tests/FunctionRejectTestCatchMatchingShouldNotReportUnhandled.phpt b/tests/FunctionRejectTestCatchMatchingShouldNotReportUnhandled.phpt new file mode 100644 index 00000000..2fe0f207 --- /dev/null +++ b/tests/FunctionRejectTestCatchMatchingShouldNotReportUnhandled.phpt @@ -0,0 +1,19 @@ +--TEST-- +Calling reject() and then catch() with matching type should not report unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +catch(function (RuntimeException $e): void { + echo 'Handled ' . get_class($e) . ': ' . $e->getMessage() . PHP_EOL; +}); + +?> +--EXPECT-- +Handled RuntimeException: foo diff --git a/tests/FunctionRejectTestCatchMismatchShouldReportUnhandled.phpt b/tests/FunctionRejectTestCatchMismatchShouldReportUnhandled.phpt new file mode 100644 index 00000000..a90a5499 --- /dev/null +++ b/tests/FunctionRejectTestCatchMismatchShouldReportUnhandled.phpt @@ -0,0 +1,21 @@ +--TEST-- +Calling reject() and then catch() with mismatched type should report unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +catch(function (UnexpectedValueException $unexpected): void { + echo 'This will never be shown because the types do not match' . PHP_EOL; +}); + +?> +--EXPECTF-- +Unhandled promise rejection with RuntimeException: foo in %s:%d +Stack trace: +#0 %A{main} diff --git a/tests/FunctionRejectTestFinallyThatReturnsShouldReportUnhandled.phpt b/tests/FunctionRejectTestFinallyThatReturnsShouldReportUnhandled.phpt new file mode 100644 index 00000000..1f2e576f --- /dev/null +++ b/tests/FunctionRejectTestFinallyThatReturnsShouldReportUnhandled.phpt @@ -0,0 +1,22 @@ +--TEST-- +Calling reject() and then finally() should call handler and report unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +finally(function (): void { + echo 'Foo' . PHP_EOL; +}); + +?> +--EXPECTF-- +Foo +Unhandled promise rejection with RuntimeException: foo in %s:%d +Stack trace: +#0 %A{main} diff --git a/tests/FunctionRejectTestFinallyThatThrowsNewExceptionShouldReportUnhandledForNewExceptionOnly.phpt b/tests/FunctionRejectTestFinallyThatThrowsNewExceptionShouldReportUnhandledForNewExceptionOnly.phpt new file mode 100644 index 00000000..6e6eaa93 --- /dev/null +++ b/tests/FunctionRejectTestFinallyThatThrowsNewExceptionShouldReportUnhandledForNewExceptionOnly.phpt @@ -0,0 +1,25 @@ +--TEST-- +Calling reject() and then finally() should call handler and report unhandled rejection for new exception from handler +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +finally(function (): void { + throw new \RuntimeException('Finally!'); +}); + +?> +--EXPECTF-- +Unhandled promise rejection with RuntimeException: Finally! in %s:%d +Stack trace: +#0 %s/src/Internal/RejectedPromise.php(%d): {closure}(%S) +#1 %s/src/Internal/RejectedPromise.php(%d): React\Promise\Internal\RejectedPromise->React\Promise\Internal\{closure}(%S) +#2 %s/src/Internal/RejectedPromise.php(%d): React\Promise\Internal\RejectedPromise->then(%S) +#3 %s(%d): React\Promise\Internal\RejectedPromise->finally(%S) +#4 %A{main} diff --git a/tests/FunctionRejectTestShouldReportUnhandled.phpt b/tests/FunctionRejectTestShouldReportUnhandled.phpt new file mode 100644 index 00000000..9f2d7c77 --- /dev/null +++ b/tests/FunctionRejectTestShouldReportUnhandled.phpt @@ -0,0 +1,19 @@ +--TEST-- +Calling reject() without any handlers should report unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- + +--EXPECTF-- +Unhandled promise rejection with RuntimeException: foo in %s:%d +Stack trace: +#0 %A{main} diff --git a/tests/FunctionRejectTestThenMatchingThatReturnsShouldNotReportUnhandled.phpt b/tests/FunctionRejectTestThenMatchingThatReturnsShouldNotReportUnhandled.phpt new file mode 100644 index 00000000..90f6c42a --- /dev/null +++ b/tests/FunctionRejectTestThenMatchingThatReturnsShouldNotReportUnhandled.phpt @@ -0,0 +1,19 @@ +--TEST-- +Calling reject() and then then() should not report unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +then(null, function (\Throwable $e) { + echo 'Handled ' . get_class($e) . ': ' . $e->getMessage() . PHP_EOL; +}); + +?> +--EXPECT-- +Handled RuntimeException: foo diff --git a/tests/FunctionRejectTestThenMatchingThatThrowsNewExceptionShouldReportUnhandledRejectionForNewExceptionOnly.phpt b/tests/FunctionRejectTestThenMatchingThatThrowsNewExceptionShouldReportUnhandledRejectionForNewExceptionOnly.phpt new file mode 100644 index 00000000..b6b7f2fe --- /dev/null +++ b/tests/FunctionRejectTestThenMatchingThatThrowsNewExceptionShouldReportUnhandledRejectionForNewExceptionOnly.phpt @@ -0,0 +1,23 @@ +--TEST-- +Calling reject() and then then() should report unhandled rejection for new exception from handler +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +then(null, function () { + throw new \RuntimeException('bar'); +}); + +?> +--EXPECTF-- +Unhandled promise rejection with RuntimeException: bar in %s:%d +Stack trace: +#0 %s/src/Internal/RejectedPromise.php(%d): {closure}(%S) +#1 %s(%d): React\Promise\Internal\RejectedPromise->then(%S) +#2 %A{main} diff --git a/tests/FunctionRejectTestThenMismatchThrowsTypeErrorAndShouldReportUnhandledForTypeErrorOnlyOnPhp7.phpt b/tests/FunctionRejectTestThenMismatchThrowsTypeErrorAndShouldReportUnhandledForTypeErrorOnlyOnPhp7.phpt new file mode 100644 index 00000000..7a0cf9a1 --- /dev/null +++ b/tests/FunctionRejectTestThenMismatchThrowsTypeErrorAndShouldReportUnhandledForTypeErrorOnlyOnPhp7.phpt @@ -0,0 +1,25 @@ +--TEST-- +Calling reject() and then then() with invalid type should report unhandled rejection for TypeError +--SKIPIF-- += 80000) die("Skipped: PHP 7 only."); ?> +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +then(null, function (UnexpectedValueException $unexpected): void { + echo 'This will never be shown because the types do not match' . PHP_EOL; +}); + +?> +--EXPECTF-- +Unhandled promise rejection with TypeError: Argument 1 passed to {closure}() must be an instance of UnexpectedValueException, instance of RuntimeException given, called in %s/src/Internal/RejectedPromise.php on line %d in %s:%d +Stack trace: +#0 %s/src/Internal/RejectedPromise.php(%d): {closure}(%S) +#1 %s(%d): React\Promise\Internal\RejectedPromise->then(%S) +#2 %A{main} diff --git a/tests/FunctionRejectTestThenMismatchThrowsTypeErrorAndShouldReportUnhandledForTypeErrorOnlyOnPhp8.phpt b/tests/FunctionRejectTestThenMismatchThrowsTypeErrorAndShouldReportUnhandledForTypeErrorOnlyOnPhp8.phpt new file mode 100644 index 00000000..c8694f34 --- /dev/null +++ b/tests/FunctionRejectTestThenMismatchThrowsTypeErrorAndShouldReportUnhandledForTypeErrorOnlyOnPhp8.phpt @@ -0,0 +1,25 @@ +--TEST-- +Calling reject() and then then() with invalid type should report unhandled rejection for TypeError +--SKIPIF-- + +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +then(null, function (UnexpectedValueException $unexpected): void { + echo 'This will never be shown because the types do not match' . PHP_EOL; +}); + +?> +--EXPECTF-- +Unhandled promise rejection with TypeError: {closure}(): Argument #1 ($unexpected) must be of type UnexpectedValueException, RuntimeException given, called in %s/src/Internal/RejectedPromise.php on line %d in %s:%d +Stack trace: +#0 %s/src/Internal/RejectedPromise.php(%d): {closure}(%S) +#1 %s(%d): React\Promise\Internal\RejectedPromise->then(%S) +#2 %A{main} diff --git a/tests/FunctionResolveTest.php b/tests/FunctionResolveTest.php index a1637b53..1eacd344 100644 --- a/tests/FunctionResolveTest.php +++ b/tests/FunctionResolveTest.php @@ -128,6 +128,10 @@ function ($val) { /** @test */ public function shouldSupportVeryDeepNestedPromises(): void { + if (PHP_VERSION_ID < 70200 && ini_get('xdebug.max_nesting_level') !== false) { + $this->markTestSkipped('Skip unhandled rejection on legacy PHP 7.1'); + } + $deferreds = []; for ($i = 0; $i < 150; $i++) { diff --git a/tests/FunctionResolveTestThenShouldNotReportUnhandled.phpt b/tests/FunctionResolveTestThenShouldNotReportUnhandled.phpt new file mode 100644 index 00000000..aabeae16 --- /dev/null +++ b/tests/FunctionResolveTestThenShouldNotReportUnhandled.phpt @@ -0,0 +1,17 @@ +--TEST-- +Calling resolve() and then then() should not report unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +then('var_dump'); + +?> +--EXPECT-- +int(42) diff --git a/tests/PromiseTest.php b/tests/PromiseTest.php index ae6b92bc..06d89eb1 100644 --- a/tests/PromiseTest.php +++ b/tests/PromiseTest.php @@ -66,6 +66,9 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfResolverThrowsExceptio $promise = new Promise(function () { throw new \Exception('foo'); }); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + unset($promise); $this->assertSame(0, gc_collect_cycles()); @@ -78,6 +81,9 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfResolverRejectsWithExc $promise = new Promise(function ($resolve, $reject) { $reject(new \Exception('foo')); }); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + unset($promise); $this->assertSame(0, gc_collect_cycles()); @@ -116,6 +122,9 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfResolverThrowsExceptio $promise = new Promise(function ($resolve, $reject) { throw new \Exception('foo'); }); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + unset($promise); $this->assertSame(0, gc_collect_cycles()); @@ -137,6 +146,7 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfResolverThrowsExceptio public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerWithReferenceThrowsException(): void { gc_collect_cycles(); + /** @var Promise $promise */ $promise = new Promise(function () {}, function () use (&$promise) { assert($promise instanceof Promise); throw new \Exception('foo'); @@ -155,10 +165,14 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerWithReference public function shouldRejectWithoutCreatingGarbageCyclesIfResolverWithReferenceThrowsException(): void { gc_collect_cycles(); + /** @var Promise $promise */ $promise = new Promise(function () use (&$promise) { assert($promise instanceof Promise); throw new \Exception('foo'); }); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + unset($promise); $this->assertSame(0, gc_collect_cycles()); @@ -172,11 +186,15 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfResolverWithReferenceT public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerHoldsReferenceAndResolverThrowsException(): void { gc_collect_cycles(); + /** @var Promise $promise */ $promise = new Promise(function () { throw new \Exception('foo'); }, function () use (&$promise) { assert($promise instanceof Promise); }); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + unset($promise); $this->assertSame(0, gc_collect_cycles()); @@ -260,6 +278,9 @@ public function shouldFulfillIfFullfilledWithSimplePromise(): void $promise = new Promise(function () { throw new Exception('foo'); }); + + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + unset($promise); self::assertSame(0, gc_collect_cycles()); diff --git a/tests/PromiseTest/PromiseRejectedTestTrait.php b/tests/PromiseTest/PromiseRejectedTestTrait.php index af8bcaf7..12287ce8 100644 --- a/tests/PromiseTest/PromiseRejectedTestTrait.php +++ b/tests/PromiseTest/PromiseRejectedTestTrait.php @@ -261,7 +261,7 @@ public function catchShouldNotInvokeRejectionHandlerIfReaonsDoesNotMatchTypehint $adapter->promise() ->catch(function (InvalidArgumentException $reason) use ($mock) { $mock($reason); - }); + })->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection } /** @test */ @@ -461,7 +461,7 @@ public function otherwiseShouldNotInvokeRejectionHandlerIfReaonsDoesNotMatchType $adapter->promise() ->otherwise(function (InvalidArgumentException $reason) use ($mock) { $mock($reason); - }); + })->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection } /** diff --git a/tests/PromiseTest/PromiseSettledTestTrait.php b/tests/PromiseTest/PromiseSettledTestTrait.php index 03ded7e0..3f63dd5d 100644 --- a/tests/PromiseTest/PromiseSettledTestTrait.php +++ b/tests/PromiseTest/PromiseSettledTestTrait.php @@ -2,6 +2,7 @@ namespace React\Promise\PromiseTest; +use React\Promise\Internal\RejectedPromise; use React\Promise\PromiseAdapter\PromiseAdapterInterface; use React\Promise\PromiseInterface; @@ -16,6 +17,10 @@ public function thenShouldReturnAPromiseForSettledPromise(): void $adapter->settle(null); self::assertInstanceOf(PromiseInterface::class, $adapter->promise()->then()); + + if ($adapter->promise() instanceof RejectedPromise) { + $adapter->promise()->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + } } /** @test */ @@ -25,6 +30,10 @@ public function thenShouldReturnAllowNullForSettledPromise(): void $adapter->settle(null); self::assertInstanceOf(PromiseInterface::class, $adapter->promise()->then(null, null)); + + if ($adapter->promise() instanceof RejectedPromise) { + $adapter->promise()->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + } } /** @test */ @@ -43,7 +52,11 @@ public function finallyShouldReturnAPromiseForSettledPromise(): void $adapter = $this->getPromiseTestAdapter(); $adapter->settle(null); - self::assertInstanceOf(PromiseInterface::class, $adapter->promise()->finally(function () {})); + self::assertInstanceOf(PromiseInterface::class, $promise = $adapter->promise()->finally(function () {})); + + if ($promise instanceof RejectedPromise) { + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + } } /** @@ -55,6 +68,10 @@ public function alwaysShouldReturnAPromiseForSettledPromise(): void $adapter = $this->getPromiseTestAdapter(); $adapter->settle(null); - self::assertInstanceOf(PromiseInterface::class, $adapter->promise()->always(function () {})); + self::assertInstanceOf(PromiseInterface::class, $promise = $adapter->promise()->always(function () {})); + + if ($promise instanceof RejectedPromise) { + $promise->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection + } } } diff --git a/tests/PromiseTestCancelThatRejectsAfterwardsShouldNotReportUnhandled.phpt b/tests/PromiseTestCancelThatRejectsAfterwardsShouldNotReportUnhandled.phpt new file mode 100644 index 00000000..0125a4f2 --- /dev/null +++ b/tests/PromiseTestCancelThatRejectsAfterwardsShouldNotReportUnhandled.phpt @@ -0,0 +1,24 @@ +--TEST-- +Calling cancel() that rejects afterwards should not report unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +cancel(); + +assert($reject instanceof \Closure); +$reject(new \RuntimeException('Cancelled')); + +echo 'void' . PHP_EOL; + +?> +--EXPECT-- +void diff --git a/tests/PromiseTestCancelThatRejectsShouldNotReportUnhandled.phpt b/tests/PromiseTestCancelThatRejectsShouldNotReportUnhandled.phpt new file mode 100644 index 00000000..fc29c646 --- /dev/null +++ b/tests/PromiseTestCancelThatRejectsShouldNotReportUnhandled.phpt @@ -0,0 +1,20 @@ +--TEST-- +Calling cancel() that rejects should not report unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +cancel(); + +echo 'void' . PHP_EOL; + +?> +--EXPECT-- +void