Project

General

Profile

Actions

Bug #21166

open

Fiber Scheduler is unable to be interrupted by `IO#close`.

Added by ioquatix (Samuel Williams) 4 days ago. Updated 3 days ago.

Status:
Open
Assignee:
-
Target version:
-
[ruby-core:121215]

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.

Actions #1

Updated by ioquatix (Samuel Williams) 4 days ago

  • Description updated (diff)

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) 4 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.

Actions

Also available in: Atom PDF

Like0
Like0Like0Like0Like0Like0