Bug #21166
openFiber Scheduler is unable to be interrupted by `IO#close`.
Description
Background¶
Ruby's IO#close
can cause IO#read
, IO#write
, IO#wait
, IO#wait_readable
and IO#wait_writable
to be interrupted with an IOError (stream closed in another thread). For reference, IO#select
cannot be interrupted in this way.
r, w = IO.pipe
thread = Thread.new do
r.read(1)
end
Thread.pass until thread.status == "sleep"
r.close
thread.join
# ./test.rb:6:in 'IO#read': stream closed in another thread (IOError)
Problem¶
The fiber scheduler provides hooks for io_read
, io_write
and io_wait
which are used by IO#read
, IO#write
, IO#wait
, IO#wait_readable
and IO#wait_writable
, but those hooks are not interrupted when IO#close
is invoked. That is because rb_notify_fd_close
is not scheduler aware, and the fiber scheduler is unable to register itself into the "waiting file descriptor" list.
#!/usr/bin/env ruby
require 'async'
r, w = IO.pipe
thread = Thread.new do
Async do
r.wait_readable
end
end
Thread.pass until thread.status == "sleep"
r.close
thread.join
In this test program, rb_notify_fd_close
will incorrectly terminate the entire fiber scheduler thread:
#<Thread:0x00007faa5b161bf8 /home/samuel/Developer/socketry/io-event/test.rb:7 run> terminated with exception (report_on_exception is true):
/home/samuel/Developer/socketry/io-event/lib/io/event/selector/select.rb:470:in 'IO.select': closed stream (IOError)
from /home/samuel/Developer/socketry/io-event/lib/io/event/selector/select.rb:470:in 'block in IO::Event::Selector::Select#select'
from /home/samuel/Developer/socketry/io-event/lib/io/event/selector/select.rb:468:in 'Thread.handle_interrupt'
from /home/samuel/Developer/socketry/io-event/lib/io/event/selector/select.rb:468:in 'IO::Event::Selector::Select#select'
from /home/samuel/.gem/ruby/3.4.1/gems/async-2.23.0/lib/async/scheduler.rb:396:in 'Async::Scheduler#run_once!'
...
Solution¶
This PR introduces some new functions:
-
VALUE rb_io_interruptible_operation(VALUE self, VALUE(*function)(VALUE), VALUE argument)
for wrapping user IO operations so they can be interrupted. -
IO#interruptable_operation(&block)
the same as above. -
VALUE rb_fiber_scheduler_fiber_interrupt(VALUE scheduler, VALUE fiber, VALUE exception)
for interrupting a specific fiber on the fiber scheduler.
rb_notify_fd_close
is modified so that it is fiber scheduler aware and uses rb_fiber_scheduler_fiber_interrupt
to interrupt a fiber. In addition, we also change the internal struct waiting_fd
to track the rb_execution_context_t
rather than just the rb_thread_t
instance, so that we can correctly wake up either the waiting thread or fiber.
The public interface rb_io_interruptible_operation
and IO#interruptible_operation
are introduced so that the scheduler implementation can wrap IO operations that should be interruptible, e.g.
Fiber.schedule do
io.interruptible_operation do
io.wait_readable
end
end
# Will interrupt above fiber:
io.close
See https://github.com/ruby/ruby/pull/12585 for the proposed implementation and https://github.com/socketry/io-event/pull/130 for example of how io-event
gem uses both the C and Ruby interfaces.
Updated by luke-gru (Luke Gruber) 4 days ago
I'm not knowledgeable when it comes to the fiber scheduler, but why add this new method io.interruptible_operation
when you can accomplish the same thing by allowing the io operation to be interrupted by a close in every case like the regular thread scheduler does? Are there cases where you don't want the IO operation to be interrupted be a close?
Updated by ioquatix (Samuel Williams) 4 days ago
when you can accomplish the same thing by allowing the io operation to be interrupted by a close in every case like the regular thread scheduler does
Because the point at which IO operations become interruptible is not the entire operation, but usually the point at where the fiber yields back to the event loop, e.g. https://github.com/socketry/io-event/pull/130/files#diff-3e7a68b220a9360ead1d2e7a5ec23e7d36de591eab138721efdc61b565fc5194R552 - adding interrupts around the entire operation is unlikely to be safe nor desirable.
Maybe you can propose in more detail how your suggestion would work. I suppose you are suggesting to wrap the scheduler code in rb_fiber_scheduler_io_wait
and so on?
Updated by ioquatix (Samuel Williams) 3 days ago
ยท Edited
The fiber scheduler hook rb_fiber_scheduler_io_wait
could be implemented like this:
static VALUE
fiber_scheduler_io_wait(VALUE _argument) {
VALUE *arguments = (VALUE*)_argument;
return rb_funcallv(arguments[0], id_io_wait, 3, arguments + 1);
}
VALUE
rb_fiber_scheduler_io_wait(VALUE scheduler, VALUE io, VALUE events, VALUE timeout)
{
VALUE arguments[] = {
scheduler, io, events, timeout
};
return rb_io_interruptible_operation(io, fiber_scheduler_io_wait, (VALUE)&arguments);
}
I'll consider how to modify io_read
and io_write
hooks.
Updated by luke-gru (Luke Gruber) 3 days ago
Yeah, this makes more sense to me. The other way introduces a method on all IO
objects that's only useful in the fiber scheduler context, and I could see users forgetting
to call it. I think having it be the default is the more sensible approach, and it avoids an API.