Repository: reactphp/promise (php)

Issue:
Detecting unhandled rejections
When using promises, there are a good chance that some code will forget to handle rejections/exceptions in a promise chain. This has the result of effectively hidding any rejection/exception that occurred during the execution of a promise's callback, which is dangerous and can lead to undetected bugs.

What makes this worse is that is suffices of a single error/forget in a promise chain to hide all exceptions thrown anywhere in the chain, including errors in react-php: reactphp/http-client#31, reactphp/dns#26.

It's super easy to not handle a rejection: if a promise chain is ended by anything except `done()`, any errors in the chain will be unhandled and potentially hidden:

```
$promise->always(function() {
    doSomething();
});
```

This example is quite obvious: any exception thrown in doSomething() is caught and hidden from the user.

Other example from react/http-client:

```
$promise->then(function () {
    // ok
}, function ($err) {
    $this->emit('error', $err);
    // in the 'error' callback:
    $this->retry();
})->then(function () {
    doSomething();
});
```

This one is less obvious. In this example, any exception thrown in retry() is caught by the promise and effectively hidden from the user.

Some promise implementations are trying to detect unhandled rejections, and log them: http://bluebirdjs.com/docs/api/error-management-configuration.html

It is possible to do so in react/promise too. For that, we would have to simply watch when promises are instantiated, handled, and destructed. If a non-handled promise is destructed, it means that an unhandled rejection occurred.

Here is a PoC adding tracing functionality to promises: https://github.com/arnaud-lb/promise/commit/90e6e350c0464961c032e196826bc509dfe1cc82

And here is a PoC tracer that detects unhandled rejections: https://gist.github.com/arnaud-lb/a2a5a5480bbd80013f756ff968282936

This can be used like this:

```
<?php

$tracer = new UnhandledRejectionTracer(function ($info) {
    var_dump("unhandled promise", $info);
});
RejectedPromise::setTracer($tracer);
FulfilledPromise::setTracer($tracer);

register_shutdown_function(function () use ($tracer) {
    // handle non-destructed promises
    foreach ($tracer->getRemainingPromises() as $info) {
        var_dump("unhandled promise", $info);
    }
});
```

This immediately discovered a few bugs in my code. I'm using this since a few months already, and this prevents the introduction of new bugs.

Pull request description:
Report any unhandled promise rejections
This changeset adds new functionality to make sure we report any unhandled promise rejections. If you remove the last reference to a rejected promise that has not been handled, it will now 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();
```

The old behavior was to simply hide any unhandled rejections which has been a constant source of frustration for newcomers and for larger applications alike (see #87 and dozens of referenced tickets). The updated implementation provides a better default behavior that is more in line with how exceptions in PHP are also reported by default and significantly improves error reporting.

Any unhandled promise rejections will currently always be written to PHP's default error log location (`STDERR` for cli, Apache error log, etc.). Once this PR is merged, I've prepared a follow-up PR to provide a custom log handler function to write to custom log locations similar to how `set_exception_handler()` and `set_error_handler()` work.

I've also tested this branch against all ReactPHP components and dozens of downstream dependencies. Most projects do indeed execute just fine these changes. However, it is quite common for unhandled promise rejections to occur in their test suites. I've prepared a number of PRs for downstream components that I would file in the next couple of days to address this.

This PR comes with a sophisticated test suite and does not otherwise affect the existing behavior as verified by the existing test suite, so this should be safe to apply. Combined, you're looking a several days worth of work, enjoy! 🎉

Resolves / closes #87
Supersedes / closes #170
Supersedes / closes #222
Builds on top of #246, #240 and others

Patch:
diff --git a/README.md b/README.md
index 7bfb238..64516b2 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 895c841..293865d 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 4242278..bba7c90 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -10,6 +10,7 @@
     <testsuites>
         <testsuite name="Promise Test Suite">
             <directory>./tests/</directory>
+            <directory suffix=".phpt">./tests/</directory>
         </testsuite>
     </testsuites>
     <coverage>
diff --git a/phpunit.xml.legacy b/phpunit.xml.legacy
index 5a312e5..4bdf771 100644
--- a/phpunit.xml.legacy
+++ b/phpunit.xml.legacy
@@ -8,6 +8,7 @@
     <testsuites>
         <testsuite name="Promise Test Suite">
             <directory>./tests/</directory>
+            <directory suffix=".phpt">./tests/</directory>
         </testsuite>
     </testsuites>
     <filter>
diff --git a/src/Internal/RejectedPromise.php b/src/Internal/RejectedPromise.php
index cbd8ef5..a29cc92 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 @@ final class RejectedPromise implements PromiseInterface
         $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 @@ final class RejectedPromise implements PromiseInterface
 
     public function cancel(): void
     {
+        $this->handled = true;
     }
 
     /**
diff --git a/src/Promise.php b/src/Promise.php
index a2d72b6..819e414 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 @@ final class Promise implements 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 @@ final class Promise implements PromiseInterface
         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 c42b715..c8107f8 100644
--- a/src/functions.php
+++ b/src/functions.php
@@ -100,7 +100,7 @@ function all(iterable $promisesOrValues): PromiseInterface
                 }
             );
 
-            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 any(iterable $promisesOrValues): PromiseInterface
                 }
             );
 
-            if (!$continue) {
+            if (!$continue && !\is_array($promisesOrValues)) {
                 break;
             }
         }


Test patch:
diff --git a/tests/DeferredTest.php b/tests/DeferredTest.php
index bb8f08d..186ed1d 100644
--- a/tests/DeferredTest.php
+++ b/tests/DeferredTest.php
@@ -54,9 +54,13 @@ class DeferredTest extends TestCase
         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 0000000..e4e1d64
--- /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--
+<?php
+
+use React\Promise\Deferred;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+$deferred = new Deferred();
+$deferred->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 0000000..d581a06
--- /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--
+<?php
+
+use React\Promise\Deferred;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+$deferred = new Deferred(function () { throw new \RuntimeException('Cancelled'); });
+$deferred->promise()->cancel();
+
+echo 'void' . PHP_EOL;
+
+?>
+--EXPECT--
+void
diff --git a/tests/DeferredTestRejectShouldReportUnhandled.phpt b/tests/DeferredTestRejectShouldReportUnhandled.phpt
new file mode 100644
index 0000000..46d8afa
--- /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--
+<?php
+
+use React\Promise\Deferred;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+$deferred = new Deferred();
+$deferred->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 0000000..ecda961
--- /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--
+<?php
+
+use React\Promise\Deferred;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+$deferred = new Deferred();
+$deferred->reject(new RuntimeException('foo'));
+$deferred->promise()->cancel();
+
+echo 'void' . PHP_EOL;
+
+?>
+--EXPECT--
+void
diff --git a/tests/FunctionAllTest.php b/tests/FunctionAllTest.php
index 94b5c04..10cfa2f 100644
--- a/tests/FunctionAllTest.php
+++ b/tests/FunctionAllTest.php
@@ -106,7 +106,7 @@ class FunctionAllTest extends TestCase
             ->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 0000000..b4ee4c9
--- /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--
+<?php
+
+use function React\Promise\all;
+use function React\Promise\reject;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+all([
+    reject(new RuntimeException('foo')),
+    reject(new RuntimeException('bar'))
+]);
+
+?>
+--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 0000000..c69bfea
--- /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--
+<?php
+
+use function React\Promise\all;
+use function React\Promise\reject;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+all([
+    reject(new RuntimeException('foo')),
+    reject(new RuntimeException('bar'))
+])->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 0000000..935518a
--- /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--
+<?php
+
+use function React\Promise\any;
+use function React\Promise\reject;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+any([
+    reject(new RuntimeException('foo')),
+    reject(new RuntimeException('bar'))
+]);
+
+?>
+--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 0000000..840e3a7
--- /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--
+<?php
+
+use function React\Promise\any;
+use function React\Promise\reject;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+any([
+    reject(new RuntimeException('foo')),
+    reject(new RuntimeException('bar'))
+])->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 0000000..de963d3
--- /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--
+<?php
+
+use function React\Promise\race;
+use function React\Promise\reject;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+race([
+    reject(new RuntimeException('foo')),
+    reject(new RuntimeException('bar'))
+]);
+
+?>
+--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 0000000..110f5f4
--- /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--
+<?php
+
+use function React\Promise\race;
+use function React\Promise\reject;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+race([
+    reject(new RuntimeException('foo')),
+    reject(new RuntimeException('bar'))
+])->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 0000000..c6f17d5
--- /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--
+<?php
+
+use function React\Promise\reject;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+reject(new RuntimeException('foo'))->cancel();
+
+echo 'void' . PHP_EOL;
+
+?>
+--EXPECT--
+void
diff --git a/tests/FunctionRejectTestCatchMatchingShouldNotReportUnhandled.phpt b/tests/FunctionRejectTestCatchMatchingShouldNotReportUnhandled.phpt
new file mode 100644
index 0000000..2fe0f20
--- /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--
+<?php
+
+use function React\Promise\reject;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+reject(new RuntimeException('foo'))->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 0000000..a90a549
--- /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--
+<?php
+
+use function React\Promise\reject;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+reject(new RuntimeException('foo'))->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 0000000..1f2e576
--- /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--
+<?php
+
+use function React\Promise\reject;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+reject(new RuntimeException('foo'))->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 0000000..6e6eaa9
--- /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--
+<?php
+
+use function React\Promise\reject;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+reject(new RuntimeException('foo'))->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 0000000..9f2d7c7
--- /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--
+<?php
+
+use function React\Promise\reject;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+reject(new RuntimeException('foo'));
+
+?>
+--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 0000000..90f6c42
--- /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--
+<?php
+
+use function React\Promise\reject;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+reject(new RuntimeException('foo'))->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 0000000..b6b7f2f
--- /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--
+<?php
+
+use function React\Promise\reject;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+reject(new RuntimeException('foo'))->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 0000000..7a0cf9a
--- /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--
+<?php if (PHP_VERSION_ID >= 80000) die("Skipped: PHP 7 only."); ?>
+--INI--
+# suppress legacy PHPUnit 7 warning for Xdebug 3
+xdebug.default_enable=
+--FILE--
+<?php
+
+use function React\Promise\reject;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+reject(new RuntimeException('foo'))->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 0000000..c8694f3
--- /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--
+<?php if (PHP_VERSION_ID < 80000) die("Skipped: PHP 8+ only."); ?>
+--INI--
+# suppress legacy PHPUnit 7 warning for Xdebug 3
+xdebug.default_enable=
+--FILE--
+<?php
+
+use function React\Promise\reject;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+reject(new RuntimeException('foo'))->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 a1637b5..1eacd34 100644
--- a/tests/FunctionResolveTest.php
+++ b/tests/FunctionResolveTest.php
@@ -128,6 +128,10 @@ class FunctionResolveTest extends TestCase
     /** @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 0000000..aabeae1
--- /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--
+<?php
+
+use function React\Promise\resolve;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+resolve(42)->then('var_dump');
+
+?>
+--EXPECT--
+int(42)
diff --git a/tests/PromiseTest.php b/tests/PromiseTest.php
index ae6b92b..06d89eb 100644
--- a/tests/PromiseTest.php
+++ b/tests/PromiseTest.php
@@ -66,6 +66,9 @@ class PromiseTest extends TestCase
         $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 @@ class PromiseTest extends TestCase
         $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 @@ class PromiseTest extends TestCase
         $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 @@ class PromiseTest extends TestCase
     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 @@ class PromiseTest extends TestCase
     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 @@ class PromiseTest extends TestCase
     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 @@ class PromiseTest extends TestCase
         $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 af8bcaf..12287ce 100644
--- a/tests/PromiseTest/PromiseRejectedTestTrait.php
+++ b/tests/PromiseTest/PromiseRejectedTestTrait.php
@@ -261,7 +261,7 @@ trait PromiseRejectedTestTrait
         $adapter->promise()
             ->catch(function (InvalidArgumentException $reason) use ($mock) {
                 $mock($reason);
-            });
+            })->then(null, $this->expectCallableOnce()); // avoid reporting unhandled rejection
     }
 
     /** @test */
@@ -461,7 +461,7 @@ trait PromiseRejectedTestTrait
         $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 03ded7e..3f63dd5 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 @@ trait PromiseSettledTestTrait
 
         $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 @@ trait PromiseSettledTestTrait
 
         $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 @@ trait PromiseSettledTestTrait
         $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 @@ trait PromiseSettledTestTrait
         $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 0000000..0125a4f
--- /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--
+<?php
+
+use React\Promise\Promise;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+/** @var callable(Throwable):void $reject */
+$promise = new Promise(function () { }, function ($_, callable $callback) use (&$reject) { $reject = $callback; });
+$promise->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 0000000..fc29c64
--- /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--
+<?php
+
+use React\Promise\Promise;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+$promise = new Promise(function () { }, function () { throw new \RuntimeException('Cancelled'); });
+$promise->cancel();
+
+echo 'void' . PHP_EOL;
+
+?>
+--EXPECT--
+void