From bf6a9f26b53f4879f3b6b3257b9fc828b340c43e Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 12 May 2026 11:22:03 +0900 Subject: [PATCH 1/2] Add io_close hook to TestScheduler When a TestScheduler is active, IO#close was previously falling back to rb_nogvl and blocking_operation_wait. Adding the io_close scheduler hook routes the close through the selector (when supported) or closes the descriptor synchronously via Fiber.blocking, avoiding an unnecessary trip through the worker pool. Handles both legacy IO objects and raw Integer file descriptors (Ruby 4.0+ passes the raw fd to the hook). Co-authored-by: Cursor --- fixtures/io/event/test_scheduler.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/fixtures/io/event/test_scheduler.rb b/fixtures/io/event/test_scheduler.rb index 7faed99..0557380 100644 --- a/fixtures/io/event/test_scheduler.rb +++ b/fixtures/io/event/test_scheduler.rb @@ -117,6 +117,20 @@ def fiber_interrupt(fiber, exception) unblock(nil, FiberInterrupt.new(fiber, exception)) end + # Optional fiber scheduler hook for `IO#close`. + # + # When defined, Ruby routes `IO#close` through here rather than falling + # back to `rb_nogvl` and `blocking_operation_wait`. Ruby 4.0+ passes a + # raw `Integer` file descriptor; earlier versions pass an `IO`. + def io_close(io) + return @selector.io_close(io) if @selector.respond_to?(:io_close) + + # Default: close the descriptor without re-entering the scheduler. + fd = io.is_a?(Integer) ? io : io.fileno + Fiber.blocking{IO.for_fd(fd, autoclose: false).close} + true + end + def io_wait(io, events, timeout = nil) fiber = Fiber.current From 5e632e7d242f4a2069e917c08b767746780eac93 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Tue, 12 May 2026 12:10:00 +0900 Subject: [PATCH 2/2] Forward `io_close` hook through `Debug::Selector` and `TestScheduler`. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruby's fiber-scheduler `io_close` hook (Ruby 4.0+, see `rb_fiber_scheduler_io_close` in CRuby) is invoked with a raw integer file descriptor — never an `IO` object. Earlier Rubies don't invoke the hook at all. Only `URing` implements `io_close` (async close via the ring); other selectors let Ruby use its default `IO#close` path. Both `Debug::Selector` and `TestScheduler` now define a small `Forwarders` module whose methods are mixed into their singleton class only when the wrapped selector actually implements the corresponding method. This preserves async close when wrapping `URing` and keeps `respond_to?` reflecting the real backend. Drops the dead `IO`-object branch from `uring.c`, the `Forwarders` doc, and the test — Ruby's contract is integer-only. Co-authored-by: Cursor --- ext/io/event/selector/uring.c | 13 +++++----- fixtures/io/event/test_scheduler.rb | 36 ++++++++++++++++---------- lib/io/event/debug/selector.rb | 25 ++++++++++++++++++ test/io/event/selector/io_close.rb | 40 +++++++++++++---------------- 4 files changed, 71 insertions(+), 43 deletions(-) diff --git a/ext/io/event/selector/uring.c b/ext/io/event/selector/uring.c index a0e4e81..ab0e40f 100644 --- a/ext/io/event/selector/uring.c +++ b/ext/io/event/selector/uring.c @@ -997,14 +997,13 @@ VALUE IO_Event_Selector_URing_io_pwrite(VALUE self, VALUE fiber, VALUE io, VALUE static const int ASYNC_CLOSE = 1; -VALUE IO_Event_Selector_URing_io_close(VALUE self, VALUE io) { +VALUE IO_Event_Selector_URing_io_close(VALUE self, VALUE _descriptor) { struct IO_Event_Selector_URing *selector = NULL; TypedData_Get_Struct(self, struct IO_Event_Selector_URing, &IO_Event_Selector_URing_Type, selector); - // Ruby's fiber scheduler io_close hook may receive a raw Integer fd - // (observed on Ruby head/4.1) rather than an IO object. - int descriptor = RB_INTEGER_TYPE_P(io) ? RB_NUM2INT(io) : IO_Event_Selector_io_descriptor(io); - + // Ruby's fiber scheduler `io_close` hook is invoked with a raw integer file descriptor (Ruby 4.0+); it does not pass the `IO` object. + int descriptor = RB_NUM2INT(_descriptor); + if (ASYNC_CLOSE) { struct io_uring_sqe *sqe = io_get_sqe(selector); io_uring_prep_close(sqe, descriptor); @@ -1017,8 +1016,8 @@ VALUE IO_Event_Selector_URing_io_close(VALUE self, VALUE io) { } else { close(descriptor); } - - // We don't wait for the result of close since it has no use in pratice: + + // We don't wait for the result of close since it has no use in practice: return Qtrue; } diff --git a/fixtures/io/event/test_scheduler.rb b/fixtures/io/event/test_scheduler.rb index 0557380..f605b1b 100644 --- a/fixtures/io/event/test_scheduler.rb +++ b/fixtures/io/event/test_scheduler.rb @@ -30,6 +30,14 @@ module IO::Event # end.resume # ``` class TestScheduler + # Optional fiber scheduler hooks that we forward to the underlying selector. Mixed into the scheduler's singleton class only when the selector actually implements the corresponding method, so feature detection via `respond_to?` reflects the real backend (and Ruby falls back to its default behaviour otherwise). + module Forwarders + # Fiber scheduler hook for `IO#close`. Ruby invokes this with a raw integer file descriptor (Ruby 4.0+). + def io_close(descriptor) + @selector.io_close(descriptor) + end + end + def initialize(selector: nil, worker_pool: nil, maximum_worker_count: nil) @selector = selector || ::IO::Event::Selector.new(Fiber.current) @@ -42,6 +50,20 @@ def initialize(selector: nil, worker_pool: nil, maximum_worker_count: nil) # Track the number of fibers that are blocked. @blocked = 0 @blocking = {} + + install_optional_forwarders(@selector) + end + + private def install_optional_forwarders(selector) + forwarders = nil + + Forwarders.instance_methods(false).each do |name| + next unless selector.respond_to?(name) + forwarders ||= Module.new + forwarders.define_method(name, Forwarders.instance_method(name)) + end + + singleton_class.include(forwarders) if forwarders end # @attribute [WorkerPool] The worker pool used for executing blocking operations. @@ -117,20 +139,6 @@ def fiber_interrupt(fiber, exception) unblock(nil, FiberInterrupt.new(fiber, exception)) end - # Optional fiber scheduler hook for `IO#close`. - # - # When defined, Ruby routes `IO#close` through here rather than falling - # back to `rb_nogvl` and `blocking_operation_wait`. Ruby 4.0+ passes a - # raw `Integer` file descriptor; earlier versions pass an `IO`. - def io_close(io) - return @selector.io_close(io) if @selector.respond_to?(:io_close) - - # Default: close the descriptor without re-entering the scheduler. - fd = io.is_a?(Integer) ? io : io.fileno - Fiber.blocking{IO.for_fd(fd, autoclose: false).close} - true - end - def io_wait(io, events, timeout = nil) fiber = Fiber.current diff --git a/lib/io/event/debug/selector.rb b/lib/io/event/debug/selector.rb index d087ed6..b611d86 100644 --- a/lib/io/event/debug/selector.rb +++ b/lib/io/event/debug/selector.rb @@ -10,6 +10,17 @@ module Debug # # You can enable this in the default selector by setting the `IO_EVENT_DEBUG_SELECTOR` environment variable. In addition, you can log all selector operations to a file by setting the `IO_EVENT_DEBUG_SELECTOR_LOG` environment variable. This is useful for debugging and understanding the behavior of the event loop. class Selector + # Forwarders for optional selector hooks that not every backing selector implements (e.g. `io_close` is only provided by `URing`). Each method here is mixed into the wrapper's singleton class only when the wrapped selector actually defines a method of the same name, so feature detection via `respond_to?` continues to reflect the real backend. + module Forwarders + # Close a file descriptor, forwarded to the underlying selector. Ruby invokes this hook with a raw integer descriptor (Ruby 4.0+). + # + # @parameter descriptor [Integer] The raw file descriptor being closed. + def io_close(descriptor) + log("Closing file descriptor #{descriptor}") + @selector.io_close(descriptor) + end + end + # Wrap the given selector with debugging. # # @parameter selector [Selector] The selector to wrap. @@ -40,6 +51,20 @@ def initialize(selector, log: nil) end @log = log + + install_optional_forwarders(selector) + end + + private def install_optional_forwarders(selector) + forwarders = nil + + Forwarders.instance_methods(false).each do |name| + next unless selector.class.method_defined?(name) + forwarders ||= Module.new + forwarders.define_method(name, Forwarders.instance_method(name)) + end + + singleton_class.include(forwarders) if forwarders end # The idle duration of the underlying selector. diff --git a/test/io/event/selector/io_close.rb b/test/io/event/selector/io_close.rb index 873a3ea..e5b9daa 100644 --- a/test/io/event/selector/io_close.rb +++ b/test/io/event/selector/io_close.rb @@ -5,37 +5,21 @@ require "io/event" require "io/event/selector" +require "io/event/debug/selector" +# Ruby invokes the `io_close` fiber-scheduler hook with a raw integer file descriptor (Ruby 4.0+, see `rb_fiber_scheduler_io_close` in CRuby). Verify each selector that opts into the hook handles that contract. IOClose = Sus::Shared("io_close") do - it "can close an IO object" do + it "can close a raw file descriptor" do selector = subject.new(Fiber.current) - next unless selector.respond_to?(:io_close) input, output = IO.pipe - begin - expect(selector.io_close(input)).to be_truthy - ensure - input.close rescue nil - output.close rescue nil - selector.close - end - end - - # Ruby head/4.1 passes a raw Integer fd to the io_close scheduler hook - # instead of an IO object. Verify we handle both forms without raising. - it "can close a raw Integer fd" do - selector = subject.new(Fiber.current) - next unless selector.respond_to?(:io_close) - - input, output = IO.pipe - - # Hand ownership of the fd to io_close so Ruby won't double-close it. + # Hand ownership of the fd to `io_close` so Ruby won't double-close it. input.autoclose = false - fd = input.fileno + descriptor = input.fileno begin - expect(selector.io_close(fd)).to be_truthy + expect(selector.io_close(descriptor)).to be_truthy ensure input.close rescue nil output.close rescue nil @@ -47,8 +31,20 @@ IO::Event::Selector.constants.each do |name| klass = IO::Event::Selector.const_get(name) next unless klass.respond_to?(:new) + next unless klass.method_defined?(:io_close) describe(klass, unique: name) do it_behaves_like IOClose end + + # `Debug::Selector` should transparently forward `io_close` to any wrapped selector that implements it (see `Forwarders`). The shared examples build the selector via `subject.new(loop)`, so we hand them a thin factory that closes over the underlying selector class. + debug_class = Class.new do + define_singleton_method(:new) do |loop| + IO::Event::Debug::Selector.new(klass.new(loop)) + end + end + + describe(debug_class, unique: "Debug(#{name})") do + it_behaves_like IOClose + end end