diff --git a/lib/Future.php b/lib/Future.php index 1c31c08..b76261c 100644 --- a/lib/Future.php +++ b/lib/Future.php @@ -107,6 +107,14 @@ final class Future return $this->state->isComplete(); } + /** + * Do not forward unhandled errors to the event loop handler. + */ + public function ignore(): void + { + $this->state->ignore(); + } + /** * Awaits the operation to complete. * diff --git a/lib/Internal/FutureState.php b/lib/Internal/FutureState.php index 6688b39..30a1ee9 100644 --- a/lib/Internal/FutureState.php +++ b/lib/Internal/FutureState.php @@ -2,6 +2,7 @@ namespace Amp\Internal; +use Amp\UnhandledFutureError; use Revolt\EventLoop\Loop; use Amp\Future; @@ -17,8 +18,10 @@ final class FutureState private bool $complete = false; + private bool $handled = false; + /** - * @var array + * @var array */ private array $callbacks = []; @@ -29,12 +32,21 @@ final class FutureState private ?\Throwable $throwable = null; + public function __destruct() + { + if ($this->throwable && !$this->handled) { + $throwable = new UnhandledFutureError($this->throwable); + Loop::queue(static fn () => throw $throwable); + } + } + /** * Registers a callback to be notified once the operation is complete or errored. * * The callback is invoked directly from the event loop context, so suspension within the callback is not possible. * - * @param (callable(?\Throwable, ?T, string): void) $callback Callback invoked on error / successful completion of the future. + * @param callable(?\Throwable, ?T, string): void $callback Callback invoked on error / successful completion of + * the future. * * @return string Identifier that can be used to cancel interest for this future. */ @@ -42,6 +54,8 @@ final class FutureState { $id = self::$nextId++; + $this->handled = true; // Even if unsubscribed later, consider the future handled. + if ($this->complete) { Loop::queue($callback, $this->throwable, $this->result, $id); } else { @@ -105,6 +119,14 @@ final class FutureState return $this->complete; } + /** + * Suppress the exception thrown to the loop error handler if and operation error is not handled by a callback. + */ + public function ignore(): void + { + $this->handled = true; + } + private function invokeCallbacks(): void { $this->complete = true; diff --git a/lib/UnhandledFutureError.php b/lib/UnhandledFutureError.php new file mode 100644 index 0000000..017cf56 --- /dev/null +++ b/lib/UnhandledFutureError.php @@ -0,0 +1,18 @@ +getMessage() + . '"; Await the Future with Future::await() before the future is destroyed or use ' + . 'Future::ignore() to suppress this exception'; + + parent::__construct($message, 0, $previous); + } +} diff --git a/test/Future/AllTest.php b/test/Future/AllTest.php index c867e4c..1140789 100644 --- a/test/Future/AllTest.php +++ b/test/Future/AllTest.php @@ -8,6 +8,7 @@ use Amp\Future; use Amp\TimeoutCancellationToken; use PHPUnit\Framework\TestCase; use Revolt\EventLoop\Loop; +use function Amp\delay; use function Amp\Future\all; class AllTest extends TestCase @@ -39,6 +40,17 @@ class AllTest extends TestCase all([Future::error(new \Exception('foo')), Future::complete(2)]); } + public function testTwoThrowingWithOneLater(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('foo'); + + $deferred = new Deferred; + Loop::delay(0.1, static fn () => $deferred->error(new \Exception('bar'))); + + all([Future::error(new \Exception('foo')), $deferred->getFuture()]); + } + public function testTwoGeneratorThrows(): void { $this->expectException(\Exception::class); diff --git a/test/Future/FutureTest.php b/test/Future/FutureTest.php index 200c0ee..e9509e3 100644 --- a/test/Future/FutureTest.php +++ b/test/Future/FutureTest.php @@ -6,14 +6,16 @@ use Amp\CancellationTokenSource; use Amp\CancelledException; use Amp\Deferred; use Amp\Future; +use Amp\PHPUnit\AsyncTestCase; +use Amp\PHPUnit\LoopCaughtException; +use Amp\PHPUnit\TestException; use Amp\TimeoutCancellationToken; -use PHPUnit\Framework\TestCase; use Revolt\EventLoop\Loop; use function Amp\coroutine; use function Amp\delay; use function Revolt\EventLoop\queue; -class FutureTest extends TestCase +class FutureTest extends AsyncTestCase { public function testIterate(): void { @@ -154,8 +156,43 @@ class FutureTest extends TestCase $deferred->complete(1); $source->cancel(); + } - delay(0.01); // Tick the event loop to enter defer callback. + + public function testUnhandledError(): void + { + $deferred = new Deferred; + $deferred->error(new TestException); + unset($deferred); + + $this->expectException(LoopCaughtException::class); + } + + public function testUnhandledErrorFromFutureError(): void + { + $future = Future::error(new TestException); + unset($future); + + $this->expectException(LoopCaughtException::class); + } + + public function testIgnoringUnhandledErrors(): void + { + $deferred = new Deferred; + $deferred->getFuture()->ignore(); + $deferred->error(new TestException); + unset($deferred); + + Loop::setErrorHandler($this->createCallback(0)); + } + + public function testIgnoreUnhandledErrorFromFutureError(): void + { + $future = Future::error(new TestException); + $future->ignore(); + unset($future); + + Loop::setErrorHandler($this->createCallback(0)); } /**