Skip to content

Commit af5814c

Browse files
authored
Merge pull request #249 from clue-labs/set-rejection-handler
Add new `set_rejection_handler()` function for unhandled rejections
2 parents d87b562 + b1e8940 commit af5814c

10 files changed

+322
-3
lines changed

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Table of Contents
4141
* [all()](#all)
4242
* [race()](#race)
4343
* [any()](#any)
44+
* [set_rejection_handler()](#set-rejection-handler)
4445
4. [Examples](#examples)
4546
* [How to use Deferred](#how-to-use-deferred)
4647
* [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
437438
with the [`cancel()` method](#promiseinterfacecancel) (which in turn would
438439
usually reject the promise if it is still pending).
439440

441+
See also the [`set_rejection_handler()` function](#set-rejection-handler).
442+
440443
#### all()
441444

442445
```php
@@ -478,6 +481,46 @@ which holds all rejection reasons. The rejection reasons can be obtained with
478481
The returned promise will also reject with a `React\Promise\Exception\LengthException`
479482
if `$promisesOrValues` contains 0 items.
480483

484+
#### set_rejection_handler()
485+
486+
```php
487+
React\Promise\set_rejection_handler(?callable $callback): ?callable;
488+
```
489+
490+
Sets the global rejection handler for unhandled promise rejections.
491+
492+
Note that rejected promises should always be handled similar to how any
493+
exceptions should always be caught in a `try` + `catch` block. If you remove
494+
the last reference to a rejected promise that has not been handled, it will
495+
report an unhandled promise rejection. See also the [`reject()` function](#reject)
496+
for more details.
497+
498+
The `?callable $callback` argument MUST be a valid callback function that
499+
accepts a single `Throwable` argument or a `null` value to restore the
500+
default promise rejection handler. The return value of the callback function
501+
will be ignored and has no effect, so you SHOULD return a `void` value. The
502+
callback function MUST NOT throw or the program will be terminated with a
503+
fatal error.
504+
505+
The function returns the previous rejection handler or `null` if using the
506+
default promise rejection handler.
507+
508+
The default promise rejection handler will log an error message plus its stack
509+
trace:
510+
511+
```php
512+
// Unhandled promise rejection with RuntimeException: Unhandled in example.php:2
513+
React\Promise\reject(new RuntimeException('Unhandled'));
514+
```
515+
516+
The promise rejection handler may be used to use customize the log message or
517+
write to custom log targets. As a rule of thumb, this function should only be
518+
used as a last resort and promise rejections are best handled with either the
519+
[`then()` method](#promiseinterfacethen), the
520+
[`catch()` method](#promiseinterfacecatch), or the
521+
[`finally()` method](#promiseinterfacefinally).
522+
See also the [`reject()` function](#reject) for more details.
523+
481524
Examples
482525
--------
483526

src/Internal/RejectedPromise.php

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use React\Promise\PromiseInterface;
66
use function React\Promise\_checkTypehint;
77
use function React\Promise\resolve;
8+
use function React\Promise\set_rejection_handler;
89

910
/**
1011
* @internal
@@ -25,16 +26,31 @@ public function __construct(\Throwable $reason)
2526
$this->reason = $reason;
2627
}
2728

29+
/** @throws void */
2830
public function __destruct()
2931
{
3032
if ($this->handled) {
3133
return;
3234
}
3335

34-
$message = 'Unhandled promise rejection with ' . \get_class($this->reason) . ': ' . $this->reason->getMessage() . ' in ' . $this->reason->getFile() . ':' . $this->reason->getLine() . PHP_EOL;
35-
$message .= 'Stack trace:' . PHP_EOL . $this->reason->getTraceAsString();
36+
$handler = set_rejection_handler(null);
37+
if ($handler === null) {
38+
$message = 'Unhandled promise rejection with ' . \get_class($this->reason) . ': ' . $this->reason->getMessage() . ' in ' . $this->reason->getFile() . ':' . $this->reason->getLine() . PHP_EOL;
39+
$message .= 'Stack trace:' . PHP_EOL . $this->reason->getTraceAsString();
3640

37-
\error_log($message);
41+
\error_log($message);
42+
return;
43+
}
44+
45+
try {
46+
$handler($this->reason);
47+
} catch (\Throwable $e) {
48+
$message = 'Fatal error: Uncaught ' . \get_class($e) . ' from unhandled promise rejection handler: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() . PHP_EOL;
49+
$message .= 'Stack trace:' . PHP_EOL . $e->getTraceAsString();
50+
51+
\error_log($message);
52+
exit(255);
53+
}
3854
}
3955

4056
public function then(callable $onFulfilled = null, callable $onRejected = null): PromiseInterface

src/functions.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,53 @@ function (\Throwable $reason) use ($i, &$reasons, &$toReject, $reject, &$continu
206206
}, $cancellationQueue);
207207
}
208208

209+
/**
210+
* Sets the global rejection handler for unhandled promise rejections.
211+
*
212+
* Note that rejected promises should always be handled similar to how any
213+
* exceptions should always be caught in a `try` + `catch` block. If you remove
214+
* the last reference to a rejected promise that has not been handled, it will
215+
* report an unhandled promise rejection. See also the [`reject()` function](#reject)
216+
* for more details.
217+
*
218+
* The `?callable $callback` argument MUST be a valid callback function that
219+
* accepts a single `Throwable` argument or a `null` value to restore the
220+
* default promise rejection handler. The return value of the callback function
221+
* will be ignored and has no effect, so you SHOULD return a `void` value. The
222+
* callback function MUST NOT throw or the program will be terminated with a
223+
* fatal error.
224+
*
225+
* The function returns the previous rejection handler or `null` if using the
226+
* default promise rejection handler.
227+
*
228+
* The default promise rejection handler will log an error message plus its
229+
* stack trace:
230+
*
231+
* ```php
232+
* // Unhandled promise rejection with RuntimeException: Unhandled in example.php:2
233+
* React\Promise\reject(new RuntimeException('Unhandled'));
234+
* ```
235+
*
236+
* The promise rejection handler may be used to use customize the log message or
237+
* write to custom log targets. As a rule of thumb, this function should only be
238+
* used as a last resort and promise rejections are best handled with either the
239+
* [`then()` method](#promiseinterfacethen), the
240+
* [`catch()` method](#promiseinterfacecatch), or the
241+
* [`finally()` method](#promiseinterfacefinally).
242+
* See also the [`reject()` function](#reject) for more details.
243+
*
244+
* @param callable(\Throwable):void|null $callback
245+
* @return callable(\Throwable):void|null
246+
*/
247+
function set_rejection_handler(?callable $callback): ?callable
248+
{
249+
static $current = null;
250+
$previous = $current;
251+
$current = $callback;
252+
253+
return $previous;
254+
}
255+
209256
/**
210257
* @internal
211258
*/
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
--TEST--
2+
The callback given to set_rejection_handler() should be invoked for unhandled rejection
3+
--INI--
4+
# suppress legacy PHPUnit 7 warning for Xdebug 3
5+
xdebug.default_enable=
6+
--FILE--
7+
<?php
8+
9+
use function React\Promise\reject;
10+
use function React\Promise\set_rejection_handler;
11+
12+
require __DIR__ . '/../vendor/autoload.php';
13+
14+
set_rejection_handler(function (Throwable $e): void {
15+
echo 'Unhandled ' . get_class($e) . ': ' . $e->getMessage() . PHP_EOL;
16+
});
17+
18+
reject(new RuntimeException('foo'));
19+
20+
echo 'done' . PHP_EOL;
21+
22+
?>
23+
--EXPECT--
24+
Unhandled RuntimeException: foo
25+
done
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
--TEST--
2+
The callback given to the last set_rejection_handler() should be invoked for unhandled rejection
3+
--INI--
4+
# suppress legacy PHPUnit 7 warning for Xdebug 3
5+
xdebug.default_enable=
6+
--FILE--
7+
<?php
8+
9+
use function React\Promise\reject;
10+
use function React\Promise\set_rejection_handler;
11+
12+
require __DIR__ . '/../vendor/autoload.php';
13+
14+
$ret = set_rejection_handler($first = function (Throwable $e): void {
15+
echo 'THIS WILL NEVER BE CALLED' . PHP_EOL;
16+
});
17+
18+
// previous should be null
19+
var_dump($ret === null);
20+
21+
$ret = set_rejection_handler(function (Throwable $e): void {
22+
echo 'Unhandled ' . get_class($e) . ': ' . $e->getMessage() . PHP_EOL;
23+
});
24+
25+
// previous rejection handler should be first rejection handler callback
26+
var_dump($ret === $first);
27+
28+
reject(new RuntimeException('foo'));
29+
30+
echo 'done' . PHP_EOL;
31+
32+
?>
33+
--EXPECT--
34+
bool(true)
35+
bool(true)
36+
Unhandled RuntimeException: foo
37+
done
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
--TEST--
2+
The callback given to set_rejection_handler() should be invoked for outer unhandled rejection but should use default rejection handler for unhandled rejection
3+
--INI--
4+
# suppress legacy PHPUnit 7 warning for Xdebug 3
5+
xdebug.default_enable=
6+
--FILE--
7+
<?php
8+
9+
use function React\Promise\reject;
10+
use function React\Promise\set_rejection_handler;
11+
12+
require __DIR__ . '/../vendor/autoload.php';
13+
14+
set_rejection_handler(function (Throwable $e): void {
15+
reject(new \UnexpectedValueException('bar'));
16+
17+
echo 'Unhandled ' . get_class($e) . ': ' . $e->getMessage() . PHP_EOL;
18+
});
19+
20+
reject(new RuntimeException('foo'));
21+
22+
echo 'done' . PHP_EOL;
23+
24+
?>
25+
--EXPECTF--
26+
Unhandled promise rejection with UnexpectedValueException: bar in %s:%d
27+
Stack trace:
28+
#0 %A{main}
29+
Unhandled RuntimeException: foo
30+
done
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
--TEST--
2+
The callback given to set_rejection_handler() should not throw an exception or the program should terminate for unhandled rejection
3+
--INI--
4+
# suppress legacy PHPUnit 7 warning for Xdebug 3
5+
xdebug.default_enable=
6+
--FILE--
7+
<?php
8+
9+
use function React\Promise\reject;
10+
use function React\Promise\set_rejection_handler;
11+
12+
require __DIR__ . '/../vendor/autoload.php';
13+
14+
set_rejection_handler(function (Throwable $e): void {
15+
throw new \UnexpectedValueException('This function should never throw');
16+
});
17+
18+
reject(new RuntimeException('foo'));
19+
20+
echo 'NEVER';
21+
22+
?>
23+
--EXPECTF--
24+
Fatal error: Uncaught UnexpectedValueException from unhandled promise rejection handler: This function should never throw in %s:%d
25+
Stack trace:
26+
#0 %A{main}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
--TEST--
2+
The callback given to set_rejection_handler() may trigger a fatal error for unhandled rejection
3+
--INI--
4+
# suppress legacy PHPUnit 7 warning for Xdebug 3
5+
xdebug.default_enable=
6+
--FILE--
7+
<?php
8+
9+
use function React\Promise\reject;
10+
use function React\Promise\set_rejection_handler;
11+
12+
require __DIR__ . '/../vendor/autoload.php';
13+
14+
set_rejection_handler(function (Throwable $e): void {
15+
trigger_error('Unexpected ' . get_class($e) . ': ' .$e->getMessage(), E_USER_ERROR);
16+
});
17+
18+
reject(new RuntimeException('foo'));
19+
20+
echo 'NEVER';
21+
22+
?>
23+
--EXPECTF--
24+
Fatal error: Unexpected RuntimeException: foo in %s line %d
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
--TEST--
2+
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
3+
--INI--
4+
# suppress legacy PHPUnit 7 warning for Xdebug 3
5+
xdebug.default_enable=
6+
--FILE--
7+
<?php
8+
9+
use function React\Promise\reject;
10+
use function React\Promise\set_rejection_handler;
11+
12+
require __DIR__ . '/../vendor/autoload.php';
13+
14+
set_error_handler(function (int $_, string $errstr): void {
15+
throw new \OverflowException('This function should never throw');
16+
});
17+
18+
set_rejection_handler(function (Throwable $e): void {
19+
trigger_error($e->getMessage(), E_USER_ERROR);
20+
});
21+
22+
reject(new RuntimeException('foo'));
23+
24+
echo 'NEVER';
25+
26+
?>
27+
--EXPECTF--
28+
Fatal error: Uncaught OverflowException from unhandled promise rejection handler: This function should never throw in %s:%d
29+
Stack trace:
30+
#0 [internal function]: {closure}(%S)
31+
#1 %s(%d): trigger_error(%S)
32+
#2 %s/src/Internal/RejectedPromise.php(%d): {closure}(%S)
33+
#3 %s/src/functions.php(%d): React\Promise\Internal\RejectedPromise->__destruct()
34+
#4 %s(%d): React\Promise\reject(%S)
35+
#5 %A{main}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
--TEST--
2+
The callback given to set_rejection_handler() should be invoked for outer unhandled rejection and may set new rejection handler for inner unhandled rejection
3+
--INI--
4+
# suppress legacy PHPUnit 7 warning for Xdebug 3
5+
xdebug.default_enable=
6+
--FILE--
7+
<?php
8+
9+
use function React\Promise\reject;
10+
use function React\Promise\set_rejection_handler;
11+
12+
require __DIR__ . '/../vendor/autoload.php';
13+
14+
set_rejection_handler(function (Throwable $e): void {
15+
$ret = set_rejection_handler(function (Throwable $e): void {
16+
echo 'Unhandled inner ' . get_class($e) . ': ' . $e->getMessage() . PHP_EOL;
17+
});
18+
19+
// previous rejection handler should be unset while handling a rejection
20+
var_dump($ret === null);
21+
22+
reject(new \UnexpectedValueException('bar'));
23+
24+
echo 'Unhandled outer ' . get_class($e) . ': ' . $e->getMessage() . PHP_EOL;
25+
});
26+
27+
reject(new RuntimeException('foo'));
28+
29+
echo 'done' . PHP_EOL;
30+
31+
?>
32+
--EXPECT--
33+
bool(true)
34+
Unhandled inner UnexpectedValueException: bar
35+
Unhandled outer RuntimeException: foo
36+
done

0 commit comments

Comments
 (0)