From 9a528127c96f3045a051d9a88b620ba261f855ed Mon Sep 17 00:00:00 2001 From: Tavian Barnes Date: Sat, 22 Aug 2026 13:57:26 -0400 Subject: [PATCH] Tolerate spurious wake-ups in io_wait and process_wait Fixes: https://github.com/socketry/async/issues/467 --- lib/async/scheduler.rb | 19 +++++++++++++++++-- test/async/scheduler.rb | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/lib/async/scheduler.rb b/lib/async/scheduler.rb index 46610357..7d0e1953 100644 --- a/lib/async/scheduler.rb +++ b/lib/async/scheduler.rb @@ -309,10 +309,12 @@ def address_resolve(hostname) # @parameter timeout [Float | Nil] The maximum time to wait, or if nil, indefinitely. def io_wait(io, events, timeout = nil) fiber = Fiber.current + expired = false if timeout # If an explicit timeout is specified, we expect that the user will handle it themselves: timer = @timers.after(timeout) do + expired = true fiber.transfer end elsif timeout = io.timeout @@ -322,7 +324,13 @@ def io_wait(io, events, timeout = nil) end end - return @selector.io_wait(fiber, io, events) + loop do + if result = @selector.io_wait(fiber, io, events) + return result + elsif expired + return nil + end + end ensure timer&.cancel! end @@ -415,7 +423,14 @@ def fiber_interrupt(fiber, exception) # @returns [Process::Status] A process status instance. # @asynchronous May be non-blocking.. def process_wait(pid, flags) - return @selector.process_wait(Fiber.current, pid, flags) + fiber = Fiber.current + + loop do + status = @selector.process_wait(fiber, pid, flags) + + # `false` indicates the wake-up was spurious, e.g. a stale {unblock} + return status unless status == false + end end # Wait for the specified IOs to become ready for the specified events. diff --git a/test/async/scheduler.rb b/test/async/scheduler.rb index c8b9b80c..44f9fc45 100644 --- a/test/async/scheduler.rb +++ b/test/async/scheduler.rb @@ -269,6 +269,46 @@ end with "#block" do + it "ignores stale wake-ups from previous blocking operations" do + input, output = IO.pipe + duration = nil + + Sync do |parent| + queue = Thread::Queue.new + + child = parent.async do |task| + begin + task.with_timeout(0.02) do + queue.pop + end + rescue Async::TimeoutError + # Expected - the item was pushed after the timeout already expired. + end + + # The deferred wake-up from `queue.push` must not spuriously interrupt a subsequent IO operation: + duration = Async::Clock.measure do + input.wait_readable(0.02) + end + end + + producer = parent.async do + sleep(0.01) + queue.push(:wakeup) + end + + # Prevent the event loop from running until both the producer's sleep and the child's timeout are overdue, so that the wake-up from `queue.push` is still pending when the timeout fires: + Fiber.blocking{sleep(0.03)} + + child.wait + producer.wait + end + + expect(duration).to be >= 0.02 + ensure + input&.close + output&.close + end + it "can block and unblock the scheduler after closing" do scheduler = Async::Scheduler.new