From 6e55c623f0baba0f95eabe67bd467a172926d103 Mon Sep 17 00:00:00 2001 From: Tavian Barnes Date: Sat, 22 Aug 2026 13:57:26 -0400 Subject: [PATCH 01/11] Tolerate spurious wake-ups in io_wait and process_wait Fixes: https://github.com/socketry/async/issues/467 --- lib/async/scheduler.rb | 17 +++++++++++++++-- test/async/scheduler.rb | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/lib/async/scheduler.rb b/lib/async/scheduler.rb index 46610357..47ed6d3c 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,11 @@ def io_wait(io, events, timeout = nil) end end - return @selector.io_wait(fiber, io, events) + until result = @selector.io_wait(fiber, io, events) + return nil if expired + end + + return result ensure timer&.cancel! end @@ -415,7 +421,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 + + while true + 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 From c5f183410f213f9f9797ebbf2bef3465e3c5a449 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 26 Aug 2026 11:04:47 +1200 Subject: [PATCH 02/11] Add process wait stale wake-up test Signed-off-by: Samuel Williams --- test/async/scheduler.rb | 62 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/test/async/scheduler.rb b/test/async/scheduler.rb index 44f9fc45..e30d14b7 100644 --- a/test/async/scheduler.rb +++ b/test/async/scheduler.rb @@ -268,6 +268,68 @@ end end + with "#process_wait" do + it "ignores stale wake-ups from previous blocking operations" do + input, output = IO.pipe + pid = Process.spawn(RbConfig.ruby, "-e", "STDIN.read", in: input) + input.close + input = nil + status = 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 the process wait: + _, status = Process.wait2(pid) + end + + producer = parent.async do + sleep(0.01) + queue.push(:wakeup) + + # Release the child process after the stale wake-up has been delivered: + sleep(0.01) + output.close + output = nil + 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(status).to be(:success?) + ensure + input&.close + output&.close + + if pid + begin + Process.kill(:KILL, pid) + rescue Errno::ESRCH + # The process already exited. + end + + begin + Process.wait(pid) + rescue Errno::ECHILD + # The process was already reaped. + end + end + end + end + with "#block" do it "ignores stale wake-ups from previous blocking operations" do input, output = IO.pipe From 90d0ce10d2e94c6e36cba32bdcf59368e67c30d6 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 26 Aug 2026 11:10:38 +1200 Subject: [PATCH 03/11] Handle spurious nil process wait results Signed-off-by: Samuel Williams --- lib/async/scheduler.rb | 8 ++++++-- test/async/scheduler.rb | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/lib/async/scheduler.rb b/lib/async/scheduler.rb index 47ed6d3c..f04ae96e 100644 --- a/lib/async/scheduler.rb +++ b/lib/async/scheduler.rb @@ -426,8 +426,12 @@ def process_wait(pid, flags) while true status = @selector.process_wait(fiber, pid, flags) - # `false` indicates the wake-up was spurious, e.g. a stale {unblock} - return status unless status == false + # Native selectors return `false` after a spurious wake-up. Thread-backed waits may return `nil`, but `nil` is also the expected result for a non-blocking wait: + if status.nil? + return nil unless (flags & ::Process::WNOHANG).zero? + elsif status != false + return status + end end end diff --git a/test/async/scheduler.rb b/test/async/scheduler.rb index e30d14b7..a7f7075d 100644 --- a/test/async/scheduler.rb +++ b/test/async/scheduler.rb @@ -269,6 +269,32 @@ end with "#process_wait" do + it "retries spurious nil results from blocking waits" do + status = Object.new + results = [nil, status] + selector = Object.new + + selector.define_singleton_method(:process_wait) do |fiber, pid, flags| + results.shift + end + + scheduler = Async::Scheduler.new(selector: selector) + + expect(scheduler.process_wait(123, 0)).to be_equal(status) + end + + it "returns nil from non-blocking waits" do + selector = Object.new + + selector.define_singleton_method(:process_wait) do |fiber, pid, flags| + nil + end + + scheduler = Async::Scheduler.new(selector: selector) + + expect(scheduler.process_wait(123, Process::WNOHANG)).to be_nil + end + it "ignores stale wake-ups from previous blocking operations" do input, output = IO.pipe pid = Process.spawn(RbConfig.ruby, "-e", "STDIN.read", in: input) From 0ac8609120e7ce71194220d8303aeeee32f0470c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 26 Aug 2026 11:31:47 +1200 Subject: [PATCH 04/11] Keep process wait regression coverage backend-independent Signed-off-by: Samuel Williams --- test/async/scheduler.rb | 73 ++++++++--------------------------------- 1 file changed, 14 insertions(+), 59 deletions(-) diff --git a/test/async/scheduler.rb b/test/async/scheduler.rb index a7f7075d..dbba47d6 100644 --- a/test/async/scheduler.rb +++ b/test/async/scheduler.rb @@ -269,9 +269,9 @@ end with "#process_wait" do - it "retries spurious nil results from blocking waits" do + it "retries spurious false results from blocking waits" do status = Object.new - results = [nil, status] + results = [false, status] selector = Object.new selector.define_singleton_method(:process_wait) do |fiber, pid, flags| @@ -283,77 +283,32 @@ expect(scheduler.process_wait(123, 0)).to be_equal(status) end - it "returns nil from non-blocking waits" do + it "retries spurious nil results from blocking waits" do + status = Object.new + results = [nil, status] selector = Object.new selector.define_singleton_method(:process_wait) do |fiber, pid, flags| - nil + results.shift end scheduler = Async::Scheduler.new(selector: selector) - expect(scheduler.process_wait(123, Process::WNOHANG)).to be_nil + expect(scheduler.process_wait(123, 0)).to be_equal(status) end - it "ignores stale wake-ups from previous blocking operations" do - input, output = IO.pipe - pid = Process.spawn(RbConfig.ruby, "-e", "STDIN.read", in: input) - input.close - input = nil - status = nil + it "returns nil from non-blocking waits" do + selector = Object.new - 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 the process wait: - _, status = Process.wait2(pid) - end - - producer = parent.async do - sleep(0.01) - queue.push(:wakeup) - - # Release the child process after the stale wake-up has been delivered: - sleep(0.01) - output.close - output = nil - 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 + selector.define_singleton_method(:process_wait) do |fiber, pid, flags| + nil end - expect(status).to be(:success?) - ensure - input&.close - output&.close + scheduler = Async::Scheduler.new(selector: selector) - if pid - begin - Process.kill(:KILL, pid) - rescue Errno::ESRCH - # The process already exited. - end - - begin - Process.wait(pid) - rescue Errno::ECHILD - # The process was already reaped. - end - end + expect(scheduler.process_wait(123, Process::WNOHANG)).to be_nil end + end with "#block" do From 57ffab45d42a93bfb539dbd770c8fa733d2542c5 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 26 Aug 2026 11:58:46 +1200 Subject: [PATCH 05/11] Leave threaded process wait recovery to io-event Signed-off-by: Samuel Williams --- lib/async/scheduler.rb | 8 ++------ test/async/scheduler.rb | 14 -------------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/lib/async/scheduler.rb b/lib/async/scheduler.rb index f04ae96e..28f669a4 100644 --- a/lib/async/scheduler.rb +++ b/lib/async/scheduler.rb @@ -426,12 +426,8 @@ def process_wait(pid, flags) while true status = @selector.process_wait(fiber, pid, flags) - # Native selectors return `false` after a spurious wake-up. Thread-backed waits may return `nil`, but `nil` is also the expected result for a non-blocking wait: - if status.nil? - return nil unless (flags & ::Process::WNOHANG).zero? - elsif status != false - return status - end + # `false` indicates the wake-up was spurious, e.g. a stale {unblock}: + return status unless status == false end end diff --git a/test/async/scheduler.rb b/test/async/scheduler.rb index dbba47d6..9a554716 100644 --- a/test/async/scheduler.rb +++ b/test/async/scheduler.rb @@ -283,20 +283,6 @@ expect(scheduler.process_wait(123, 0)).to be_equal(status) end - it "retries spurious nil results from blocking waits" do - status = Object.new - results = [nil, status] - selector = Object.new - - selector.define_singleton_method(:process_wait) do |fiber, pid, flags| - results.shift - end - - scheduler = Async::Scheduler.new(selector: selector) - - expect(scheduler.process_wait(123, 0)).to be_equal(status) - end - it "returns nil from non-blocking waits" do selector = Object.new From cf1c7bd362fc9d4a187a86fbb81a58cf2ae0805c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 26 Aug 2026 21:13:31 +1200 Subject: [PATCH 06/11] Require io-event 1.21 for interrupted process waits --- async.gemspec | 2 +- lib/async/scheduler.rb | 2 +- test/async/scheduler.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/async.gemspec b/async.gemspec index 5b999667..37c64c03 100644 --- a/async.gemspec +++ b/async.gemspec @@ -29,5 +29,5 @@ Gem::Specification.new do |spec| spec.add_dependency "console", "~> 1.29" spec.add_dependency "fiber-annotation" - spec.add_dependency "io-event", "~> 1.11" + spec.add_dependency "io-event", "~> 1.21" end diff --git a/lib/async/scheduler.rb b/lib/async/scheduler.rb index 28f669a4..22923b96 100644 --- a/lib/async/scheduler.rb +++ b/lib/async/scheduler.rb @@ -426,7 +426,7 @@ def process_wait(pid, flags) while true status = @selector.process_wait(fiber, pid, flags) - # `false` indicates the wake-up was spurious, e.g. a stale {unblock}: + # `false` indicates the process wait was interrupted before completion: return status unless status == false end end diff --git a/test/async/scheduler.rb b/test/async/scheduler.rb index 9a554716..d6142446 100644 --- a/test/async/scheduler.rb +++ b/test/async/scheduler.rb @@ -269,7 +269,7 @@ end with "#process_wait" do - it "retries spurious false results from blocking waits" do + it "retries interrupted process waits" do status = Object.new results = [false, status] selector = Object.new From 30733086e8526776d96a686fb157a15e5dc50d36 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 26 Aug 2026 22:12:07 +1200 Subject: [PATCH 07/11] Assert stale IO waits preserve timeout result --- test/async/scheduler.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/async/scheduler.rb b/test/async/scheduler.rb index d6142446..221d6964 100644 --- a/test/async/scheduler.rb +++ b/test/async/scheduler.rb @@ -301,6 +301,7 @@ it "ignores stale wake-ups from previous blocking operations" do input, output = IO.pipe duration = nil + result = nil Sync do |parent| queue = Thread::Queue.new @@ -316,7 +317,7 @@ # 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) + result = input.wait_readable(0.02) end end @@ -332,6 +333,7 @@ producer.wait end + expect(result).to be_nil expect(duration).to be >= 0.02 ensure input&.close From c3e3c323b07a172d786d1e3bff1879725d56dec7 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 26 Aug 2026 22:20:32 +1200 Subject: [PATCH 08/11] Explain interrupted wait retry behavior --- lib/async/scheduler.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/async/scheduler.rb b/lib/async/scheduler.rb index 22923b96..bfc0f496 100644 --- a/lib/async/scheduler.rb +++ b/lib/async/scheduler.rb @@ -324,7 +324,11 @@ def io_wait(io, events, timeout = nil) end end + # 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 end @@ -423,10 +427,12 @@ def fiber_interrupt(fiber, exception) def process_wait(pid, flags) fiber = Fiber.current + # A native process wait may be interrupted before the child exits. + # io-event reports this as `false` and leaves the retry policy to the scheduler. + # Retry only `false`, since `nil` is a legitimate result for `Process::WNOHANG`. while true status = @selector.process_wait(fiber, pid, flags) - # `false` indicates the process wait was interrupted before completion: return status unless status == false end end From dea5002021497d8dc4087bdbbf4e70dd7560ff83 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 26 Aug 2026 22:21:12 +1200 Subject: [PATCH 09/11] Use soft wrapping for wait comments --- lib/async/scheduler.rb | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/async/scheduler.rb b/lib/async/scheduler.rb index bfc0f496..3bfd4bbc 100644 --- a/lib/async/scheduler.rb +++ b/lib/async/scheduler.rb @@ -324,9 +324,7 @@ def io_wait(io, events, timeout = nil) end end - # 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. + # 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 @@ -427,9 +425,7 @@ def fiber_interrupt(fiber, exception) def process_wait(pid, flags) fiber = Fiber.current - # A native process wait may be interrupted before the child exits. - # io-event reports this as `false` and leaves the retry policy to the scheduler. - # Retry only `false`, since `nil` is a legitimate result for `Process::WNOHANG`. + # A native process wait may be interrupted before the child exits. io-event reports this as `false` and leaves the retry policy to the scheduler. Retry only `false`, since `nil` is a legitimate result for `Process::WNOHANG`. while true status = @selector.process_wait(fiber, pid, flags) From 9bc3c572c0a211b5e213c67ba381c108527d278d Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 26 Aug 2026 22:49:49 +1200 Subject: [PATCH 10/11] Add unreleased notes for interrupted waits --- releases.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/releases.md b/releases.md index 1ecb3dea..08d46124 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,10 @@ # Releases +## Unreleased + + - 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`. + - Require `io-event` v1.21 or later for consistent interrupted process wait handling across selector backends. + ## v2.44.0 - Fixed scheduler cleanup after forking while other fibers are blocked. From f9a897acd5f750d0d11c74c4f84027bc6839cd95 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Wed, 26 Aug 2026 22:51:05 +1200 Subject: [PATCH 11/11] Simplify unreleased wait notes --- releases.md | 1 - 1 file changed, 1 deletion(-) diff --git a/releases.md b/releases.md index 08d46124..8e095419 100644 --- a/releases.md +++ b/releases.md @@ -3,7 +3,6 @@ ## Unreleased - 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`. - - Require `io-event` v1.21 or later for consistent interrupted process wait handling across selector backends. ## v2.44.0