diff --git a/ext/io/event/selector/uring.c b/ext/io/event/selector/uring.c index a0e4e810..ab0e40ff 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 7faed994..f605b1b3 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. diff --git a/lib/io/event/debug/selector.rb b/lib/io/event/debug/selector.rb index d087ed69..b611d861 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 873a3ea9..e5b9daac 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