From dd77d8eec76c390e585227df52206e5be56d5133 Mon Sep 17 00:00:00 2001 From: Daniil Gentili Date: Sun, 25 Oct 2026 16:36:03 +0100 Subject: [PATCH 1/6] UvDriver: stop the loop from timer callbacks for libuv < 1.45 libuv < 1.45 runs the due timers at the start of a UV_RUN_ONCE iteration, then polls with the timeout of the next timer: when a timer became due between the non-blocking and the blocking dispatch, its callback was enqueued and uv_run() blocked until the next I/O event, forever if another referenced handle (e.g. a signal watcher) kept the loop alive. uv_stop() makes the following poll non-blocking, and is harmless on newer versions, which run the timers after the poll. Fixes #132. Claude-Session: https://claude.ai/code/session_0185K6MyhFXHvLLtf79c6Xif --- src/EventLoop/Driver/UvDriver.php | 5 ++++ test/Driver/UvDriverTest.php | 41 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/EventLoop/Driver/UvDriver.php b/src/EventLoop/Driver/UvDriver.php index 98c81dd5..03d80203 100644 --- a/src/EventLoop/Driver/UvDriver.php +++ b/src/EventLoop/Driver/UvDriver.php @@ -72,6 +72,11 @@ public function __construct() \assert($callback instanceof TimerCallback); $this->enqueueCallback($callback); + + // libuv < 1.45 runs the due timers before polling in UV_RUN_ONCE, then polls with the timeout of the + // next timer: without one, it blocks until an I/O event while another handle keeps the loop alive. + // A stopped loop polls without blocking (the flag is reset when uv_run() returns). + \uv_stop($this->handle); }; $this->signalCallback = function ($event): void { diff --git a/test/Driver/UvDriverTest.php b/test/Driver/UvDriverTest.php index 59ed54e2..67ddf4c9 100644 --- a/test/Driver/UvDriverTest.php +++ b/test/Driver/UvDriverTest.php @@ -26,4 +26,45 @@ public function testSupported(): void { self::assertTrue(UvDriver::isSupported()); } + + /** + * Short timers with another referenced watcher: on libuv < 1.45, a timer due when the driver entered its + * blocking run left the loop blocked forever. Run in a child process, killed if it hangs. + */ + public function testShortTimersWithASignalWatcher(): void + { + $code = <<<'PHP' + require $argv[1]; + $loop = new Revolt\EventLoop\Driver\UvDriver(); + Revolt\EventLoop::setDriver($loop); + Revolt\EventLoop::onSignal(\defined('SIGUSR2') ? SIGUSR2 : 31, static fn () => null); + for ($i = 0; $i < 3000; $i++) { + $suspension = Revolt\EventLoop::getSuspension(); + Revolt\EventLoop::delay(0.001, $suspension->resume(...)); + $suspension->suspend(); + } + echo 'done'; + PHP; + + $process = \proc_open( + [\PHP_BINARY, '-r', $code, '--', __DIR__ . '/../../vendor/autoload.php'], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + ); + self::assertIsResource($process); + + $deadline = \microtime(true) + 30; + while (\proc_get_status($process)['running'] && \microtime(true) < $deadline) { + \usleep(50_000); + } + $running = \proc_get_status($process)['running']; + if ($running) { + \proc_terminate($process, 9); + } + $output = \stream_get_contents($pipes[1]) . \stream_get_contents($pipes[2]); + \proc_close($process); + + self::assertFalse($running, "3000 delays of 1 ms didn't complete in 30 seconds: $output"); + self::assertSame('done', $output); + } } From 9eafa6be67cbf9ad2210364d3cdfabdbdb86ecbe Mon Sep 17 00:00:00 2001 From: Daniil Gentili Date: Sun, 25 Oct 2026 19:40:34 +0100 Subject: [PATCH 2/6] Use hrtime for deadline in UvDriverTest --- test/Driver/UvDriverTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/Driver/UvDriverTest.php b/test/Driver/UvDriverTest.php index 67ddf4c9..66cf8c73 100644 --- a/test/Driver/UvDriverTest.php +++ b/test/Driver/UvDriverTest.php @@ -53,8 +53,8 @@ public function testShortTimersWithASignalWatcher(): void ); self::assertIsResource($process); - $deadline = \microtime(true) + 30; - while (\proc_get_status($process)['running'] && \microtime(true) < $deadline) { + $deadline = \hrtime(true) + 30; + while (\proc_get_status($process)['running'] && \hrtime(true) < $deadline) { \usleep(50_000); } $running = \proc_get_status($process)['running']; From 41b4406c2d077b9244ba507f25036a54697b5607 Mon Sep 17 00:00:00 2001 From: Daniil Gentili Date: Sun, 25 Oct 2026 19:42:35 +0100 Subject: [PATCH 3/6] Update UvDriverTest.php --- test/Driver/UvDriverTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/Driver/UvDriverTest.php b/test/Driver/UvDriverTest.php index 66cf8c73..8bc493b6 100644 --- a/test/Driver/UvDriverTest.php +++ b/test/Driver/UvDriverTest.php @@ -53,7 +53,7 @@ public function testShortTimersWithASignalWatcher(): void ); self::assertIsResource($process); - $deadline = \hrtime(true) + 30; + $deadline = \hrtime(true) + 30_000_000_000; while (\proc_get_status($process)['running'] && \hrtime(true) < $deadline) { \usleep(50_000); } From 48fad0b7af6186135f1cdf6bcfc386db13249a63 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Sun, 25 Oct 2026 20:55:35 +0100 Subject: [PATCH 4/6] Make uv_stop workaround version dependent Calling uv_stop on libuv >= 1.45 will not run polling at all after a timer has run at the beginning of the tick, because it was already due. --- src/EventLoop/Driver/UvDriver.php | 42 ++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/src/EventLoop/Driver/UvDriver.php b/src/EventLoop/Driver/UvDriver.php index 03d80203..c94f83d2 100644 --- a/src/EventLoop/Driver/UvDriver.php +++ b/src/EventLoop/Driver/UvDriver.php @@ -14,11 +14,39 @@ final class UvDriver extends AbstractDriver { + private static string $libuvVersion; + private static bool $libuvStopWorkaround; + public static function isSupported(): bool { return \extension_loaded("uv"); } + private static function getLibuvVersion(): ?string + { + if (isset(self::$libuvVersion)) { + if (self::$libuvVersion === '') { + return null; + } + + return self::$libuvVersion; + } + + \ob_start(); + \phpinfo(\INFO_MODULES); + $info = \ob_get_clean(); + + if (\preg_match('/libuv Version[^0-9]+([0-9.]+)/', $info, $matches)) { + self::$libuvVersion = $matches[1]; + + return $matches[1]; + } + + self::$libuvVersion = ''; + + return null; + } + /** @var resource|\UVLoop A uv_loop resource created with uv_loop_new() */ private $handle; /** @var array */ @@ -35,6 +63,13 @@ public function __construct() { parent::__construct(); + $libuvVersion = self::getLibuvVersion(); + + // libuv < 1.45 runs the due timers before polling in UV_RUN_ONCE, then polls with the timeout of the + // next timer: without one, it blocks until an I/O event while another handle keeps the loop alive. + // If we can't determine the version, assume it is a newer version including the fix. + self::$libuvStopWorkaround ??= $libuvVersion !== null && \version_compare($libuvVersion, '1.45', '<'); + $this->handle = \uv_loop_new(); /** @psalm-suppress UnusedClosureParam */ @@ -73,10 +108,9 @@ public function __construct() $this->enqueueCallback($callback); - // libuv < 1.45 runs the due timers before polling in UV_RUN_ONCE, then polls with the timeout of the - // next timer: without one, it blocks until an I/O event while another handle keeps the loop alive. - // A stopped loop polls without blocking (the flag is reset when uv_run() returns). - \uv_stop($this->handle); + if (self::$libuvStopWorkaround) { + \uv_stop($this->handle); + } }; $this->signalCallback = function ($event): void { From b0654ef4e1d72d6d0183c35a1cb9dafbab6b8ec7 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Sun, 25 Oct 2026 21:01:13 +0100 Subject: [PATCH 5/6] Add uv_stop stop php-cs-fixer config --- .php-cs-fixer.dist.php | 1 + 1 file changed, 1 insertion(+) diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index b4432f70..8fb3aaef 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -47,6 +47,7 @@ public function getRules(): array 'uv_now', 'uv_run', 'uv_poll_init_socket', + 'uv_stop', 'uv_timer_init', 'uv_timer_start', 'uv_timer_stop', From 56c7aad29fec10adbf77b89c483658d793137602 Mon Sep 17 00:00:00 2001 From: Niklas Keller Date: Tue, 3 Nov 2026 21:32:55 +0100 Subject: [PATCH 6/6] Fix psalm errors in UvDriver Co-Authored-By: Claude Opus 5.5 --- src/EventLoop/Driver/UvDriver.php | 41 ++++++++++++++----------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/src/EventLoop/Driver/UvDriver.php b/src/EventLoop/Driver/UvDriver.php index c94f83d2..667e949a 100644 --- a/src/EventLoop/Driver/UvDriver.php +++ b/src/EventLoop/Driver/UvDriver.php @@ -14,8 +14,7 @@ final class UvDriver extends AbstractDriver { - private static string $libuvVersion; - private static bool $libuvStopWorkaround; + private static ?bool $libuvStopWorkaround = null; public static function isSupported(): bool { @@ -24,29 +23,30 @@ public static function isSupported(): bool private static function getLibuvVersion(): ?string { - if (isset(self::$libuvVersion)) { - if (self::$libuvVersion === '') { - return null; - } - - return self::$libuvVersion; - } - \ob_start(); \phpinfo(\INFO_MODULES); - $info = \ob_get_clean(); + $info = (string) \ob_get_clean(); if (\preg_match('/libuv Version[^0-9]+([0-9.]+)/', $info, $matches)) { - self::$libuvVersion = $matches[1]; - return $matches[1]; } - self::$libuvVersion = ''; - return null; } + private static function needsStopWorkaround(): bool + { + if (self::$libuvStopWorkaround === null) { + // libuv < 1.45 runs the due timers before polling in UV_RUN_ONCE, then polls with the timeout of the + // next timer: without one, it blocks until an I/O event while another handle keeps the loop alive. + // If we can't determine the version, assume it is a newer version including the fix. + $libuvVersion = self::getLibuvVersion(); + self::$libuvStopWorkaround = $libuvVersion !== null && \version_compare($libuvVersion, '1.45', '<'); + } + + return self::$libuvStopWorkaround; + } + /** @var resource|\UVLoop A uv_loop resource created with uv_loop_new() */ private $handle; /** @var array */ @@ -63,12 +63,7 @@ public function __construct() { parent::__construct(); - $libuvVersion = self::getLibuvVersion(); - - // libuv < 1.45 runs the due timers before polling in UV_RUN_ONCE, then polls with the timeout of the - // next timer: without one, it blocks until an I/O event while another handle keeps the loop alive. - // If we can't determine the version, assume it is a newer version including the fix. - self::$libuvStopWorkaround ??= $libuvVersion !== null && \version_compare($libuvVersion, '1.45', '<'); + $stopWorkaround = self::needsStopWorkaround(); $this->handle = \uv_loop_new(); @@ -101,14 +96,14 @@ public function __construct() } }; - $this->timerCallback = function ($event): void { + $this->timerCallback = function ($event) use ($stopWorkaround): void { $callback = $this->uvCallbacks[(int) $event][0]; \assert($callback instanceof TimerCallback); $this->enqueueCallback($callback); - if (self::$libuvStopWorkaround) { + if ($stopWorkaround) { \uv_stop($this->handle); } };