diff --git a/README.md b/README.md index 64516b2..d131aab 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Table of Contents * [all()](#all) * [race()](#race) * [any()](#any) + * [set_rejection_handler()](#set-rejection-handler) 4. [Examples](#examples) * [How to use Deferred](#how-to-use-deferred) * [How promise forwarding works](#how-promise-forwarding-works) @@ -437,6 +438,8 @@ 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). +See also the [`set_rejection_handler()` function](#set-rejection-handler). + #### all() ```php @@ -478,6 +481,46 @@ which holds all rejection reasons. The rejection reasons can be obtained with The returned promise will also reject with a `React\Promise\Exception\LengthException` if `$promisesOrValues` contains 0 items. +#### set_rejection_handler() + +```php +React\Promise\set_rejection_handler(?callable $callback): ?callable; +``` + +Sets the global rejection handler for unhandled promise rejections. + +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. See also the [`reject()` function](#reject) +for more details. + +The `?callable $callback` argument MUST be a valid callback function that +accepts a single `Throwable` argument or a `null` value to restore the +default promise rejection handler. The return value of the callback function +will be ignored and has no effect, so you SHOULD return a `void` value. The +callback function MUST NOT throw or the program will be terminated with a +fatal error. + +The function returns the previous rejection handler or `null` if using the +default promise rejection handler. + +The default promise rejection handler will log an error message plus its stack +trace: + +```php +// Unhandled promise rejection with RuntimeException: Unhandled in example.php:2 +React\Promise\reject(new RuntimeException('Unhandled')); +``` + +The promise rejection handler may be used to use customize the log message or +write to custom log targets. As a rule of thumb, this function should only be +used as a last resort and promise rejections are best handled with either the +[`then()` method](#promiseinterfacethen), the +[`catch()` method](#promiseinterfacecatch), or the +[`finally()` method](#promiseinterfacefinally). +See also the [`reject()` function](#reject) for more details. + Examples -------- diff --git a/src/Internal/RejectedPromise.php b/src/Internal/RejectedPromise.php index a29cc92..59a6e75 100644 --- a/src/Internal/RejectedPromise.php +++ b/src/Internal/RejectedPromise.php @@ -5,6 +5,7 @@ use React\Promise\PromiseInterface; use function React\Promise\_checkTypehint; use function React\Promise\resolve; +use function React\Promise\set_rejection_handler; /** * @internal @@ -25,16 +26,31 @@ public function __construct(\Throwable $reason) $this->reason = $reason; } + /** @throws void */ 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(); + $handler = set_rejection_handler(null); + if ($handler === null) { + $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); + \error_log($message); + return; + } + + try { + $handler($this->reason); + } catch (\Throwable $e) { + $message = 'Fatal error: Uncaught ' . \get_class($e) . ' from unhandled promise rejection handler: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() . PHP_EOL; + $message .= 'Stack trace:' . PHP_EOL . $e->getTraceAsString(); + + \error_log($message); + exit(255); + } } public function then(callable $onFulfilled = null, callable $onRejected = null): PromiseInterface diff --git a/src/functions.php b/src/functions.php index c8107f8..6003abd 100644 --- a/src/functions.php +++ b/src/functions.php @@ -206,6 +206,53 @@ function (\Throwable $reason) use ($i, &$reasons, &$toReject, $reject, &$continu }, $cancellationQueue); } +/** + * Sets the global rejection handler for unhandled promise rejections. + * + * 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. See also the [`reject()` function](#reject) + * for more details. + * + * The `?callable $callback` argument MUST be a valid callback function that + * accepts a single `Throwable` argument or a `null` value to restore the + * default promise rejection handler. The return value of the callback function + * will be ignored and has no effect, so you SHOULD return a `void` value. The + * callback function MUST NOT throw or the program will be terminated with a + * fatal error. + * + * The function returns the previous rejection handler or `null` if using the + * default promise rejection handler. + * + * The default promise rejection handler will log an error message plus its + * stack trace: + * + * ```php + * // Unhandled promise rejection with RuntimeException: Unhandled in example.php:2 + * React\Promise\reject(new RuntimeException('Unhandled')); + * ``` + * + * The promise rejection handler may be used to use customize the log message or + * write to custom log targets. As a rule of thumb, this function should only be + * used as a last resort and promise rejections are best handled with either the + * [`then()` method](#promiseinterfacethen), the + * [`catch()` method](#promiseinterfacecatch), or the + * [`finally()` method](#promiseinterfacefinally). + * See also the [`reject()` function](#reject) for more details. + * + * @param callable(\Throwable):void|null $callback + * @return callable(\Throwable):void|null + */ +function set_rejection_handler(?callable $callback): ?callable +{ + static $current = null; + $previous = $current; + $current = $callback; + + return $previous; +} + /** * @internal */ diff --git a/tests/FunctionSetRejectionHandlerShouldBeInvokedForUnhandled.phpt b/tests/FunctionSetRejectionHandlerShouldBeInvokedForUnhandled.phpt new file mode 100644 index 0000000..dfd9244 --- /dev/null +++ b/tests/FunctionSetRejectionHandlerShouldBeInvokedForUnhandled.phpt @@ -0,0 +1,25 @@ +--TEST-- +The callback given to set_rejection_handler() should be invoked for unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +getMessage() . PHP_EOL; +}); + +reject(new RuntimeException('foo')); + +echo 'done' . PHP_EOL; + +?> +--EXPECT-- +Unhandled RuntimeException: foo +done diff --git a/tests/FunctionSetRejectionHandlerShouldInvokeLastHandlerForUnhandled.phpt b/tests/FunctionSetRejectionHandlerShouldInvokeLastHandlerForUnhandled.phpt new file mode 100644 index 0000000..bd5f03d --- /dev/null +++ b/tests/FunctionSetRejectionHandlerShouldInvokeLastHandlerForUnhandled.phpt @@ -0,0 +1,37 @@ +--TEST-- +The callback given to the last set_rejection_handler() should be invoked for unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +getMessage() . PHP_EOL; +}); + +// previous rejection handler should be first rejection handler callback +var_dump($ret === $first); + +reject(new RuntimeException('foo')); + +echo 'done' . PHP_EOL; + +?> +--EXPECT-- +bool(true) +bool(true) +Unhandled RuntimeException: foo +done diff --git a/tests/FunctionSetRejectionHandlerThatHasUnhandledShouldReportUnhandled.phpt b/tests/FunctionSetRejectionHandlerThatHasUnhandledShouldReportUnhandled.phpt new file mode 100644 index 0000000..103bcbf --- /dev/null +++ b/tests/FunctionSetRejectionHandlerThatHasUnhandledShouldReportUnhandled.phpt @@ -0,0 +1,30 @@ +--TEST-- +The callback given to set_rejection_handler() should be invoked for outer unhandled rejection but should use default rejection handler for unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +getMessage() . PHP_EOL; +}); + +reject(new RuntimeException('foo')); + +echo 'done' . PHP_EOL; + +?> +--EXPECTF-- +Unhandled promise rejection with UnexpectedValueException: bar in %s:%d +Stack trace: +#0 %A{main} +Unhandled RuntimeException: foo +done diff --git a/tests/FunctionSetRejectionHandlerThatThrowsShouldTerminateProgramForUnhandled.phpt b/tests/FunctionSetRejectionHandlerThatThrowsShouldTerminateProgramForUnhandled.phpt new file mode 100644 index 0000000..b1171a7 --- /dev/null +++ b/tests/FunctionSetRejectionHandlerThatThrowsShouldTerminateProgramForUnhandled.phpt @@ -0,0 +1,26 @@ +--TEST-- +The callback given to set_rejection_handler() should not throw an exception or the program should terminate for unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- + +--EXPECTF-- +Fatal error: Uncaught UnexpectedValueException from unhandled promise rejection handler: This function should never throw in %s:%d +Stack trace: +#0 %A{main} diff --git a/tests/FunctionSetRejectionHandlerThatTriggersDefaultHandlerShouldTerminateProgramForUnhandled.phpt b/tests/FunctionSetRejectionHandlerThatTriggersDefaultHandlerShouldTerminateProgramForUnhandled.phpt new file mode 100644 index 0000000..14b5c7c --- /dev/null +++ b/tests/FunctionSetRejectionHandlerThatTriggersDefaultHandlerShouldTerminateProgramForUnhandled.phpt @@ -0,0 +1,24 @@ +--TEST-- +The callback given to set_rejection_handler() may trigger a fatal error for unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +getMessage(), E_USER_ERROR); +}); + +reject(new RuntimeException('foo')); + +echo 'NEVER'; + +?> +--EXPECTF-- +Fatal error: Unexpected RuntimeException: foo in %s line %d diff --git a/tests/FunctionSetRejectionHandlerThatTriggersErrorHandlerThatThrowsShouldTerminateProgramForUnhandled.phpt b/tests/FunctionSetRejectionHandlerThatTriggersErrorHandlerThatThrowsShouldTerminateProgramForUnhandled.phpt new file mode 100644 index 0000000..b691039 --- /dev/null +++ b/tests/FunctionSetRejectionHandlerThatTriggersErrorHandlerThatThrowsShouldTerminateProgramForUnhandled.phpt @@ -0,0 +1,35 @@ +--TEST-- +The callback given to set_rejection_handler() may trigger a fatal error which in turn throws an exception which will terminate the program for unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +getMessage(), E_USER_ERROR); +}); + +reject(new RuntimeException('foo')); + +echo 'NEVER'; + +?> +--EXPECTF-- +Fatal error: Uncaught OverflowException from unhandled promise rejection handler: This function should never throw in %s:%d +Stack trace: +#0 [internal function]: {closure}(%S) +#1 %s(%d): trigger_error(%S) +#2 %s/src/Internal/RejectedPromise.php(%d): {closure}(%S) +#3 %s/src/functions.php(%d): React\Promise\Internal\RejectedPromise->__destruct() +#4 %s(%d): React\Promise\reject(%S) +#5 %A{main} diff --git a/tests/FunctionSetRejectionHandlerThatUsesNestedSetRejectionHandlerShouldInvokeInnerHandlerForUnhandled.phpt b/tests/FunctionSetRejectionHandlerThatUsesNestedSetRejectionHandlerShouldInvokeInnerHandlerForUnhandled.phpt new file mode 100644 index 0000000..3a8dafe --- /dev/null +++ b/tests/FunctionSetRejectionHandlerThatUsesNestedSetRejectionHandlerShouldInvokeInnerHandlerForUnhandled.phpt @@ -0,0 +1,36 @@ +--TEST-- +The callback given to set_rejection_handler() should be invoked for outer unhandled rejection and may set new rejection handler for inner unhandled rejection +--INI-- +# suppress legacy PHPUnit 7 warning for Xdebug 3 +xdebug.default_enable= +--FILE-- +getMessage() . PHP_EOL; + }); + + // previous rejection handler should be unset while handling a rejection + var_dump($ret === null); + + reject(new \UnexpectedValueException('bar')); + + echo 'Unhandled outer ' . get_class($e) . ': ' . $e->getMessage() . PHP_EOL; +}); + +reject(new RuntimeException('foo')); + +echo 'done' . PHP_EOL; + +?> +--EXPECT-- +bool(true) +Unhandled inner UnexpectedValueException: bar +Unhandled outer RuntimeException: foo +done