From 83bd43ebeee797bce3df1b67e7732fdd6d95bc5b Mon Sep 17 00:00:00 2001 From: Tavian Barnes Date: Fri, 28 Aug 2026 13:21:10 -0400 Subject: [PATCH 1/2] Return false from io_wait() for timeouts Callers of `rb_io_wait()` expect a `false` return value for timeouts, not nil. This could lead to very strange errors from code like TCPSocket.new("192.0.2.1", 80, connect_timeout: 1) which would raise TypeError: no implicit conversion from nil to integer It now raises the correct IO::TimeoutError: user specified timeout for 192.0.2.1:80 --- lib/async/scheduler.rb | 4 ++-- releases.md | 4 ++++ test/async/scheduler/io.rb | 12 ++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/async/scheduler.rb b/lib/async/scheduler.rb index f128896d..4143d331 100644 --- a/lib/async/scheduler.rb +++ b/lib/async/scheduler.rb @@ -326,8 +326,8 @@ def io_wait(io, events, timeout = nil) # A selector wait may return a falsy result when the fiber is resumed without the requested IO becoming ready. For example, a deferred unblock from a previous blocking operation may arrive after the fiber has moved on to this wait. Retry these stale or spurious wake-ups without resetting the original timer. until result = @selector.io_wait(fiber, io, events) - # If the original timer resumed the fiber, the falsy result represents the timeout rather than a spurious wake-up: - return nil if expired + # If the original timer resumed the fiber, the false result represents the timeout rather than a spurious wake-up: + return false if expired end return result diff --git a/releases.md b/releases.md index dc0ff4bf..158857c0 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Fixed `Scheduler#io_wait` returning `nil` instead of `false` when an explicit timeout expired. Native callers such as `Socket#connect` with `connect_timeout:` distinguish a timeout by checking for `false`, so the `nil` caused `TypeError: no implicit conversion from nil to integer` instead of the intended `IO::TimeoutError`. + ## v2.45.0 - Fixed scheduler I/O and process waits returning prematurely after stale or interrupted wake-ups. I/O waits now preserve their original timeout, while blocking process waits retry and non-blocking `Process::WNOHANG` waits still return `nil`. diff --git a/test/async/scheduler/io.rb b/test/async/scheduler/io.rb index 3a7389dd..cc2aa8fa 100644 --- a/test/async/scheduler/io.rb +++ b/test/async/scheduler/io.rb @@ -24,6 +24,18 @@ s2.close end + it "returns false from io_wait when an explicit timeout expires" do + s1, s2 = Socket.pair :UNIX, :STREAM, 0 + + # Native callers (e.g. `wait_connectable` in `Socket#connect` with `connect_timeout:`) distinguish a timeout from a readiness mask by checking for `false`, matching the non-scheduler `rb_io_wait`: + result = reactor.io_wait(s1, IO::READABLE, 0.001) + + expect(result).to be == false + ensure + s1.close + s2.close + end + it "can read a single character" do s1, s2 = Socket.pair :UNIX, :STREAM, 0 From a06c77c88ced124b931ddbe7f7f4ff57ed49dbdc Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 29 Aug 2026 10:02:45 +1200 Subject: [PATCH 2/2] Document Scheduler#io_wait return value Assisted-By: devx/5ab61f1c-bb1a-47a2-937a-b53ce39bb329 --- lib/async/scheduler.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/async/scheduler.rb b/lib/async/scheduler.rb index 4143d331..ffdd9e17 100644 --- a/lib/async/scheduler.rb +++ b/lib/async/scheduler.rb @@ -307,6 +307,7 @@ def address_resolve(hostname) # @parameter io [IO] The IO object to wait on. # @parameter events [Integer] The events to wait for, e.g. `IO::READABLE`, `IO::WRITABLE`, etc. # @parameter timeout [Float | Nil] The maximum time to wait, or if nil, indefinitely. + # @returns [Integer | false] The subset of events that are ready, or `false` if the timeout expires. def io_wait(io, events, timeout = nil) fiber = Fiber.current expired = false