Skip to content

Commit d87b562

Browse files
authored
Merge pull request #248 from clue-labs/report-unhandled
Report any unhandled promise rejections
2 parents d66fa66 + b0d0f3d commit d87b562

File tree

36 files changed

+658
-8
lines changed

36 files changed

+658
-8
lines changed

README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ having `$onFulfilled` (which they registered via `$promise->then()`) called with
125125
If `$value` itself is a promise, the promise will transition to the state of
126126
this promise once it is resolved.
127127

128+
See also the [`resolve()` function](#resolve).
129+
128130
#### Deferred::reject()
129131

130132
```php
@@ -136,6 +138,8 @@ computation failed.
136138
All consumers are notified by having `$onRejected` (which they registered via
137139
`$promise->then()`) called with `$reason`.
138140

141+
See also the [`reject()` function](#reject).
142+
139143
### PromiseInterface
140144

141145
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.
361365

362366
If `$promiseOrValue` is a promise, it will be returned as is.
363367

368+
The resulting `$promise` implements the [`PromiseInterface`](#promiseinterface)
369+
and can be consumed like any other promise:
370+
371+
```php
372+
$promise = React\Promise\resolve(42);
373+
374+
$promise->then(function (int $result): void {
375+
var_dump($result);
376+
}, function (\Throwable $e): void {
377+
echo 'Error: ' . $e->getMessage() . PHP_EOL;
378+
});
379+
```
380+
364381
#### reject()
365382

366383
```php
@@ -374,6 +391,52 @@ both user land [`\Exception`](https://www.php.net/manual/en/class.exception.php)
374391
[`\Error`](https://www.php.net/manual/en/class.error.php) internal PHP errors. By enforcing `\Throwable` as reason to
375392
reject a promise, any language error or user land exception can be used to reject a promise.
376393

394+
The resulting `$promise` implements the [`PromiseInterface`](#promiseinterface)
395+
and can be consumed like any other promise:
396+
397+
```php
398+
$promise = React\Promise\reject(new RuntimeException('Request failed'));
399+
400+
$promise->then(function (int $result): void {
401+
var_dump($result);
402+
}, function (\Throwable $e): void {
403+
echo 'Error: ' . $e->getMessage() . PHP_EOL;
404+
});
405+
```
406+
407+
Note that rejected promises should always be handled similar to how any
408+
exceptions should always be caught in a `try` + `catch` block. If you remove the
409+
last reference to a rejected promise that has not been handled, it will
410+
report an unhandled promise rejection:
411+
412+
```php
413+
function incorrect(): int
414+
{
415+
$promise = React\Promise\reject(new RuntimeException('Request failed'));
416+
417+
// Commented out: No rejection handler registered here.
418+
// $promise->then(null, function (\Throwable $e): void { /* ignore */ });
419+
420+
// Returning from a function will remove all local variable references, hence why
421+
// this will report an unhandled promise rejection here.
422+
return 42;
423+
}
424+
425+
// Calling this function will log an error message plus its stack trace:
426+
// Unhandled promise rejection with RuntimeException: Request failed in example.php:10
427+
incorrect();
428+
```
429+
430+
A rejected promise will be considered "handled" if you catch the rejection
431+
reason with either the [`then()` method](#promiseinterfacethen), the
432+
[`catch()` method](#promiseinterfacecatch), or the
433+
[`finally()` method](#promiseinterfacefinally). Note that each of these methods
434+
return a new promise that may again be rejected if you re-throw an exception.
435+
436+
A rejected promise will also be considered "handled" if you abort the operation
437+
with the [`cancel()` method](#promiseinterfacecancel) (which in turn would
438+
usually reject the promise if it is still pending).
439+
377440
#### all()
378441

379442
```php

phpstan.neon.dist

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@ parameters:
44
paths:
55
- src/
66
- tests/
7+
8+
fileExtensions:
9+
- php
10+
- phpt

phpunit.xml.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
<testsuites>
1111
<testsuite name="Promise Test Suite">
1212
<directory>./tests/</directory>
13+
<directory suffix=".phpt">./tests/</directory>
1314
</testsuite>
1415
</testsuites>
1516
<coverage>

phpunit.xml.legacy

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<testsuites>
99
<testsuite name="Promise Test Suite">
1010
<directory>./tests/</directory>
11+
<directory suffix=".phpt">./tests/</directory>
1112
</testsuite>
1213
</testsuites>
1314
<filter>

src/Internal/RejectedPromise.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ final class RejectedPromise implements PromiseInterface
1414
/** @var \Throwable */
1515
private $reason;
1616

17+
/** @var bool */
18+
private $handled = false;
19+
1720
/**
1821
* @param \Throwable $reason
1922
*/
@@ -22,12 +25,26 @@ public function __construct(\Throwable $reason)
2225
$this->reason = $reason;
2326
}
2427

28+
public function __destruct()
29+
{
30+
if ($this->handled) {
31+
return;
32+
}
33+
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+
37+
\error_log($message);
38+
}
39+
2540
public function then(callable $onFulfilled = null, callable $onRejected = null): PromiseInterface
2641
{
2742
if (null === $onRejected) {
2843
return $this;
2944
}
3045

46+
$this->handled = true;
47+
3148
try {
3249
return resolve($onRejected($this->reason));
3350
} catch (\Throwable $exception) {
@@ -55,6 +72,7 @@ public function finally(callable $onFulfilledOrRejected): PromiseInterface
5572

5673
public function cancel(): void
5774
{
75+
$this->handled = true;
5876
}
5977

6078
/**

src/Promise.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ final class Promise implements PromiseInterface
1818
/** @var int */
1919
private $requiredCancelRequests = 0;
2020

21+
/** @var bool */
22+
private $cancelled = false;
23+
2124
public function __construct(callable $resolver, callable $canceller = null)
2225
{
2326
$this->canceller = $canceller;
@@ -89,12 +92,18 @@ public function finally(callable $onFulfilledOrRejected): PromiseInterface
8992

9093
public function cancel(): void
9194
{
95+
$this->cancelled = true;
9296
$canceller = $this->canceller;
9397
$this->canceller = null;
9498

9599
$parentCanceller = null;
96100

97101
if (null !== $this->result) {
102+
// Forward cancellation to rejected promise to avoid reporting unhandled rejection
103+
if ($this->result instanceof RejectedPromise) {
104+
$this->result->cancel();
105+
}
106+
98107
// Go up the promise chain and reach the top most promise which is
99108
// itself not following another promise
100109
$root = $this->unwrap($this->result);
@@ -191,6 +200,11 @@ private function settle(PromiseInterface $result): void
191200
foreach ($handlers as $handler) {
192201
$handler($result);
193202
}
203+
204+
// Forward cancellation to rejected promise to avoid reporting unhandled rejection
205+
if ($this->cancelled && $result instanceof RejectedPromise) {
206+
$result->cancel();
207+
}
194208
}
195209

196210
private function unwrap(PromiseInterface $promise): PromiseInterface

src/functions.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ function (\Throwable $reason) use (&$continue, $reject): void {
100100
}
101101
);
102102

103-
if (!$continue) {
103+
if (!$continue && !\is_array($promisesOrValues)) {
104104
break;
105105
}
106106
}
@@ -136,7 +136,7 @@ function race(iterable $promisesOrValues): PromiseInterface
136136
$continue = false;
137137
});
138138

139-
if (!$continue) {
139+
if (!$continue && !\is_array($promisesOrValues)) {
140140
break;
141141
}
142142
}
@@ -187,7 +187,7 @@ function (\Throwable $reason) use ($i, &$reasons, &$toReject, $reject, &$continu
187187
}
188188
);
189189

190-
if (!$continue) {
190+
if (!$continue && !\is_array($promisesOrValues)) {
191191
break;
192192
}
193193
}

tests/DeferredTest.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,13 @@ public function shouldRejectWithoutCreatingGarbageCyclesIfCancellerHoldsReferenc
5454
gc_collect_cycles();
5555
gc_collect_cycles(); // clear twice to avoid leftovers in PHP 7.4 with ext-xdebug and code coverage turned on
5656

57+
/** @var Deferred $deferred */
5758
$deferred = new Deferred(function () use (&$deferred) {
5859
assert($deferred instanceof Deferred);
5960
});
61+
62+
$deferred->promise()->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection
63+
6064
$deferred->reject(new \Exception('foo'));
6165
unset($deferred);
6266

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
--TEST--
2+
Calling cancel() and then reject() should not report unhandled rejection
3+
--INI--
4+
# suppress legacy PHPUnit 7 warning for Xdebug 3
5+
xdebug.default_enable=
6+
--FILE--
7+
<?php
8+
9+
use React\Promise\Deferred;
10+
11+
require __DIR__ . '/../vendor/autoload.php';
12+
13+
$deferred = new Deferred();
14+
$deferred->promise()->cancel();
15+
$deferred->reject(new RuntimeException('foo'));
16+
17+
echo 'void' . PHP_EOL;
18+
19+
?>
20+
--EXPECT--
21+
void
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
--TEST--
2+
Calling cancel() that rejects should not report unhandled rejection
3+
--INI--
4+
# suppress legacy PHPUnit 7 warning for Xdebug 3
5+
xdebug.default_enable=
6+
--FILE--
7+
<?php
8+
9+
use React\Promise\Deferred;
10+
11+
require __DIR__ . '/../vendor/autoload.php';
12+
13+
$deferred = new Deferred(function () { throw new \RuntimeException('Cancelled'); });
14+
$deferred->promise()->cancel();
15+
16+
echo 'void' . PHP_EOL;
17+
18+
?>
19+
--EXPECT--
20+
void
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
--TEST--
2+
Calling reject() without any handlers should report unhandled rejection
3+
--INI--
4+
# suppress legacy PHPUnit 7 warning for Xdebug 3
5+
xdebug.default_enable=
6+
--FILE--
7+
<?php
8+
9+
use React\Promise\Deferred;
10+
11+
require __DIR__ . '/../vendor/autoload.php';
12+
13+
$deferred = new Deferred();
14+
$deferred->reject(new RuntimeException('foo'));
15+
16+
?>
17+
--EXPECTF--
18+
Unhandled promise rejection with RuntimeException: foo in %s:%d
19+
Stack trace:
20+
#0 %A{main}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
--TEST--
2+
Calling reject() and then cancel() should not report unhandled rejection
3+
--INI--
4+
# suppress legacy PHPUnit 7 warning for Xdebug 3
5+
xdebug.default_enable=
6+
--FILE--
7+
<?php
8+
9+
use React\Promise\Deferred;
10+
11+
require __DIR__ . '/../vendor/autoload.php';
12+
13+
$deferred = new Deferred();
14+
$deferred->reject(new RuntimeException('foo'));
15+
$deferred->promise()->cancel();
16+
17+
echo 'void' . PHP_EOL;
18+
19+
?>
20+
--EXPECT--
21+
void

tests/FunctionAllTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ public function shouldRejectIfAnyInputPromiseRejects(): void
106106
->method('__invoke')
107107
->with(self::identicalTo($exception2));
108108

109-
all([resolve(1), reject($exception2), resolve($exception3)])
109+
all([resolve(1), reject($exception2), reject($exception3)])
110110
->then($this->expectCallableNever(), $mock);
111111
}
112112

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
--TEST--
2+
Calling all() with rejected promises should report 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\all;
10+
use function React\Promise\reject;
11+
12+
require __DIR__ . '/../vendor/autoload.php';
13+
14+
all([
15+
reject(new RuntimeException('foo')),
16+
reject(new RuntimeException('bar'))
17+
]);
18+
19+
?>
20+
--EXPECTF--
21+
Unhandled promise rejection with RuntimeException: foo in %s:%d
22+
Stack trace:
23+
#0 %A{main}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
--TEST--
2+
Calling all() and then then() should not report 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\all;
10+
use function React\Promise\reject;
11+
12+
require __DIR__ . '/../vendor/autoload.php';
13+
14+
all([
15+
reject(new RuntimeException('foo')),
16+
reject(new RuntimeException('bar'))
17+
])->then(null, function (\Throwable $e) {
18+
echo 'Handled ' . get_class($e) . ': ' . $e->getMessage() . PHP_EOL;
19+
});
20+
21+
?>
22+
--EXPECT--
23+
Handled RuntimeException: foo

0 commit comments

Comments
 (0)