Skip to content

Commit ad46c94

Browse files
trowskidanog
andauthored
Discard {main} suspension on uncaught exception from loop (#71)
Co-authored-by: Daniil Gentili <[email protected]>
1 parent c29e030 commit ad46c94

File tree

3 files changed

+78
-12
lines changed

3 files changed

+78
-12
lines changed

src/EventLoop/Internal/AbstractDriver.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,7 @@ final protected function error(\Closure $closure, \Throwable $exception): void
395395
$this->interrupt = static fn () => $exception instanceof UncaughtThrowable
396396
? throw $exception
397397
: throw UncaughtThrowable::throwingCallback($closure, $exception);
398+
unset($this->suspensions[$this]); // Remove suspension for {main}
398399
return;
399400
}
400401

@@ -625,11 +626,10 @@ private function createErrorCallback(): void
625626
try {
626627
$errorHandler($exception);
627628
} catch (\Throwable $exception) {
628-
$this->setInterrupt(
629-
static fn () => $exception instanceof UncaughtThrowable
630-
? throw $exception
631-
: throw UncaughtThrowable::throwingErrorHandler($errorHandler, $exception)
632-
);
629+
$this->interrupt = static fn () => $exception instanceof UncaughtThrowable
630+
? throw $exception
631+
: throw UncaughtThrowable::throwingErrorHandler($errorHandler, $exception);
632+
unset($this->suspensions[$this]); // Remove suspension for {main}
633633
}
634634
};
635635
}

src/EventLoop/Internal/DriverSuspension.php

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@ final class DriverSuspension implements Suspension
2121
/** @var \WeakReference<\Fiber>|null */
2222
private readonly ?\WeakReference $fiberRef;
2323

24-
private ?\FiberError $fiberError = null;
24+
private ?\Error $error = null;
2525

2626
private bool $pending = false;
2727

28+
private bool $deadMain = false;
29+
2830
public function __construct(
2931
private readonly \Closure $run,
3032
private readonly \Closure $queue,
@@ -38,8 +40,13 @@ public function __construct(
3840

3941
public function resume(mixed $value = null): void
4042
{
43+
// Ignore spurious resumes to old dead {main} suspension
44+
if ($this->deadMain) {
45+
return;
46+
}
47+
4148
if (!$this->pending) {
42-
throw $this->fiberError ?? new \Error('Must call suspend() before calling resume()');
49+
throw $this->error ?? new \Error('Must call suspend() before calling resume()');
4350
}
4451

4552
$this->pending = false;
@@ -62,6 +69,13 @@ public function resume(mixed $value = null): void
6269

6370
public function suspend(): mixed
6471
{
72+
// Throw exception when trying to use old dead {main} suspension
73+
if ($this->deadMain) {
74+
throw new \Error(
75+
'Suspension cannot be suspended after an uncaught exception is thrown from the event loop',
76+
);
77+
}
78+
6579
if ($this->pending) {
6680
throw new \Error('Must call resume() or throw() before calling suspend() again');
6781
}
@@ -73,6 +87,7 @@ public function suspend(): mixed
7387
}
7488

7589
$this->pending = true;
90+
$this->error = null;
7691

7792
// Awaiting from within a fiber.
7893
if ($fiber) {
@@ -81,12 +96,12 @@ public function suspend(): mixed
8196
try {
8297
$value = \Fiber::suspend();
8398
$this->suspendedFiber = null;
84-
} catch (\FiberError $exception) {
99+
} catch (\FiberError $error) {
85100
$this->pending = false;
86101
$this->suspendedFiber = null;
87-
$this->fiberError = $exception;
102+
$this->error = $error;
88103

89-
throw $exception;
104+
throw $error;
90105
}
91106

92107
// Setting $this->suspendedFiber = null in finally will set the fiber to null if a fiber is destroyed
@@ -100,7 +115,9 @@ public function suspend(): mixed
100115

101116
/** @psalm-suppress RedundantCondition $this->pending should be changed when resumed. */
102117
if ($this->pending) {
103-
$this->pending = false;
118+
// This is now a dead {main} suspension.
119+
$this->deadMain = true;
120+
104121
$result && $result(); // Unwrap any uncaught exceptions from the event loop
105122

106123
\gc_collect_cycles(); // Collect any circular references before dumping pending suspensions.
@@ -127,8 +144,13 @@ public function suspend(): mixed
127144

128145
public function throw(\Throwable $throwable): void
129146
{
147+
// Ignore spurious resumes to old dead {main} suspension
148+
if ($this->deadMain) {
149+
return;
150+
}
151+
130152
if (!$this->pending) {
131-
throw $this->fiberError ?? new \Error('Must call suspend() before calling throw()');
153+
throw $this->error ?? new \Error('Must call suspend() before calling throw()');
132154
}
133155

134156
$this->pending = false;

test/EventLoopTest.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,50 @@ public function testSuspensionThrowingErrorViaInterrupt(): void
298298
} catch (UncaughtThrowable $t) {
299299
self::assertSame($error, $t->getPrevious());
300300
}
301+
302+
$suspension->resume(); // Calling resume on the same suspension should not throw an Error.
303+
$suspension->throw(new \RuntimeException()); // Calling throw on the same suspension should not throw an Error.
304+
305+
try {
306+
$suspension->suspend(); // Calling suspend on the same suspension should throw an Error.
307+
self::fail("Error was not thrown");
308+
} catch (\Error $e) {
309+
self::assertStringContainsString('suspended after an uncaught exception', $e->getMessage());
310+
}
311+
312+
// Creating a new Suspension and re-entering the event loop (e.g. in a shutdown function) should work.
313+
$suspension = EventLoop::getSuspension();
314+
EventLoop::queue($suspension->resume(...));
315+
$suspension->suspend();
316+
}
317+
318+
public function testSuspensionThrowingErrorViaInterrupt2(): void
319+
{
320+
$suspension = EventLoop::getSuspension();
321+
$error = new \Error("Test error");
322+
EventLoop::queue(static fn () => throw $error);
323+
EventLoop::queue($suspension->resume(...), 123);
324+
try {
325+
$suspension->suspend();
326+
self::fail("Error was not thrown");
327+
} catch (UncaughtThrowable $t) {
328+
self::assertSame($error, $t->getPrevious());
329+
}
330+
331+
$suspension->resume(); // Calling resume on the same suspension should not throw an Error.
332+
$suspension->throw(new \RuntimeException()); // Calling throw on the same suspension should not throw an Error.
333+
334+
try {
335+
$suspension->suspend(); // Calling suspend on the same suspension should throw an Error.
336+
self::fail("Error was not thrown");
337+
} catch (\Error $e) {
338+
self::assertStringContainsString('suspended after an uncaught exception', $e->getMessage());
339+
}
340+
341+
// Creating a new Suspension and re-entering the event loop (e.g. in a shutdown function) should work.
342+
$suspension = EventLoop::getSuspension();
343+
EventLoop::queue($suspension->resume(...), 321);
344+
$this->assertEquals(321, $suspension->suspend());
301345
}
302346

303347
public function testFiberDestroyedWhileSuspended(): void

0 commit comments

Comments
 (0)