Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,18 +383,19 @@ A lot of work has gone into making Ractors more stable, performant, and usable.

## JIT

* ZJIT
* Introduce an experimental method-based JIT compiler.
To enable `--zjit` support, build Ruby with Rust 1.85.0 or later.
* As of Ruby 4.0.0, ZJIT is faster than the interpreter, but not yet as fast as YJIT.
We encourage experimentation with ZJIT, but advise against deploying it in production for now.
* Our goal is to make ZJIT faster than YJIT and production-ready in Ruby 4.1.
* YJIT
* YJIT stats
* `RubyVM::YJIT.runtime_stats`
* `ratio_in_yjit` no longer works in the default build.
Use `--enable-yjit=stats` on `configure` to enable it on `--yjit-stats`.
* Add `invalidate_everything` to default stats, which is
incremented when every code is invalidated by TracePoint.
* Add `mem_size:` and `call_threshold:` options to `RubyVM::YJIT.enable`.
* ZJIT
* Add an experimental method-based JIT compiler.
Use `--enable-zjit` on `configure` to enable the `--zjit` support.
* As of Ruby 4.0.0-preview1, ZJIT is not yet ready for speeding up most benchmarks.
Please refrain from evaluating ZJIT just yet. Stay tuned for the Ruby 4.0 release.
* RJIT
* `--rjit` is removed. We will move the implementation of the third-party JIT API
to the [ruby/rjit](https://github.com/ruby/rjit) repository.
Expand Down
2 changes: 2 additions & 0 deletions ext/-test-/scheduler/extconf.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# frozen_string_literal: false
create_makefile("-test-/scheduler")
88 changes: 88 additions & 0 deletions ext/-test-/scheduler/scheduler.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#include "ruby/ruby.h"
#include "ruby/thread.h"
#include "ruby/fiber/scheduler.h"

/*
* Test extension for reproducing the gRPC interrupt handling bug.
*
* This reproduces the exact issue from grpc/grpc commit 69f229e (June 2025):
* https://github.com/grpc/grpc/commit/69f229edd1d79ab7a7dfda98e3aef6fd807adcad
*
* The bug occurs when:
* 1. A fiber scheduler uses Thread.handle_interrupt(::SignalException => :never)
* (like Async::Scheduler does)
* 2. Native code uses rb_thread_call_without_gvl in a retry loop that checks
* the interrupted flag and retries (like gRPC's completion queue)
* 3. A signal (SIGINT/SIGTERM) is sent
* 4. The unblock_func sets interrupted=1, but Thread.handle_interrupt defers the signal
* 5. The loop sees interrupted=1 and retries without yielding to the scheduler
* 6. The deferred interrupt never gets processed -> infinite hang
*
* The fix is in vm_check_ints_blocking() in thread.c, which should yield to
* the fiber scheduler when interrupts are pending, allowing the scheduler to
* detect Thread.pending_interrupt? and exit its run loop.
*/

struct blocking_state {
volatile int interrupted;
};

static void
unblock_callback(void *argument)
{
struct blocking_state *blocking_state = (struct blocking_state *)argument;
blocking_state->interrupted = 1;
}

static void *
blocking_operation(void *argument)
{
struct blocking_state *blocking_state = (struct blocking_state *)argument;

while (true) {
struct timeval tv = {1, 0}; // 1 second timeout.

int result = select(0, NULL, NULL, NULL, &tv);

if (result == -1 && errno == EINTR) {
blocking_state->interrupted = 1;
return NULL;
}

// Otherwise, timeout -> loop again.
}

return NULL;
}

static VALUE
scheduler_blocking_loop(VALUE self)
{
struct blocking_state blocking_state = {
.interrupted = 0,
};

while (true) {
blocking_state.interrupted = 0;

rb_thread_call_without_gvl(
blocking_operation, &blocking_state,
unblock_callback, &blocking_state
);

// The bug: When interrupted, loop retries without yielding to scheduler.
// With Thread.handle_interrupt(:never), this causes an infinite hang,
// because the deferred interrupt never gets a chance to be processed.
} while (blocking_state.interrupted);

return Qnil;
}

void
Init_scheduler(void)
{
VALUE mBug = rb_define_module("Bug");
VALUE mScheduler = rb_define_module_under(mBug, "Scheduler");

rb_define_module_function(mScheduler, "blocking_loop", scheduler_blocking_loop, 0);
}
8 changes: 8 additions & 0 deletions include/ruby/fiber/scheduler.h
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,14 @@ VALUE rb_fiber_scheduler_kernel_sleep(VALUE scheduler, VALUE duration);
*/
VALUE rb_fiber_scheduler_kernel_sleepv(VALUE scheduler, int argc, VALUE * argv);

/**
* Yield to the scheduler, to be resumed on the next scheduling cycle.
*
* @param[in] scheduler Target scheduler.
* @return What `scheduler.yield` returns.
*/
VALUE rb_fiber_scheduler_yield(VALUE scheduler);

/* Description TBW */
#if 0
VALUE rb_fiber_scheduler_timeout_after(VALUE scheduler, VALUE timeout, VALUE exception, VALUE message);
Expand Down
13 changes: 10 additions & 3 deletions io.c
Original file line number Diff line number Diff line change
Expand Up @@ -6254,6 +6254,14 @@ internal_pwrite_func(void *_arg)
{
struct prdwr_internal_arg *arg = _arg;

return (VALUE)pwrite(arg->fd, arg->buf, arg->count, arg->offset);
}

static VALUE
pwrite_internal_call(VALUE _arg)
{
struct prdwr_internal_arg *arg = (struct prdwr_internal_arg *)_arg;

VALUE scheduler = rb_fiber_scheduler_current();
if (scheduler != Qnil) {
VALUE result = rb_fiber_scheduler_io_pwrite_memory(scheduler, arg->io->self, arg->offset, arg->buf, arg->count, 0);
Expand All @@ -6263,8 +6271,7 @@ internal_pwrite_func(void *_arg)
}
}


return (VALUE)pwrite(arg->fd, arg->buf, arg->count, arg->offset);
return rb_io_blocking_region_wait(arg->io, internal_pwrite_func, arg, RUBY_IO_WRITABLE);
}

/*
Expand Down Expand Up @@ -6316,7 +6323,7 @@ rb_io_pwrite(VALUE io, VALUE str, VALUE offset)
arg.buf = RSTRING_PTR(tmp);
arg.count = (size_t)RSTRING_LEN(tmp);

n = (ssize_t)rb_io_blocking_region_wait(fptr, internal_pwrite_func, &arg, RUBY_IO_WRITABLE);
n = (ssize_t)pwrite_internal_call((VALUE)&arg);
if (n < 0) rb_sys_fail_path(fptr->pathv);
rb_str_tmp_frozen_release(str, tmp);

Expand Down
4 changes: 2 additions & 2 deletions prism/prism.c
Original file line number Diff line number Diff line change
Expand Up @@ -12762,7 +12762,7 @@ parse_target(pm_parser_t *parser, pm_node_t *target, bool multiple, bool splat_p
return UP(pm_local_variable_target_node_create(parser, &message_loc, name, 0));
}

if (*call->message_loc.start == '_' || parser->encoding->alnum_char(call->message_loc.start, call->message_loc.end - call->message_loc.start)) {
if (peek_at(parser, call->message_loc.start) == '_' || parser->encoding->alnum_char(call->message_loc.start, call->message_loc.end - call->message_loc.start)) {
if (multiple && PM_NODE_FLAG_P(call, PM_CALL_NODE_FLAGS_SAFE_NAVIGATION)) {
pm_parser_err_node(parser, (const pm_node_t *) call, PM_ERR_UNEXPECTED_SAFE_NAVIGATION);
}
Expand Down Expand Up @@ -16118,7 +16118,7 @@ parse_pattern(pm_parser_t *parser, pm_constant_id_list_t *captures, uint8_t flag
static void
parse_pattern_capture(pm_parser_t *parser, pm_constant_id_list_t *captures, pm_constant_id_t capture, const pm_location_t *location) {
// Skip this capture if it starts with an underscore.
if (*location->start == '_') return;
if (peek_at(parser, location->start) == '_') return;

if (pm_constant_id_list_includes(captures, capture)) {
pm_parser_err(parser, location->start, location->end, PM_ERR_PATTERN_CAPTURE_DUPLICATE);
Expand Down
28 changes: 26 additions & 2 deletions scheduler.c
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ static ID id_scheduler_close;
static ID id_block;
static ID id_unblock;

static ID id_yield;

static ID id_timeout_after;
static ID id_kernel_sleep;
static ID id_process_wait;
Expand Down Expand Up @@ -321,6 +323,7 @@ Init_Fiber_Scheduler(void)

id_block = rb_intern_const("block");
id_unblock = rb_intern_const("unblock");
id_yield = rb_intern_const("yield");

id_timeout_after = rb_intern_const("timeout_after");
id_kernel_sleep = rb_intern_const("kernel_sleep");
Expand Down Expand Up @@ -460,12 +463,14 @@ rb_fiber_scheduler_current_for_threadptr(rb_thread_t *thread)
}
}

VALUE
rb_fiber_scheduler_current(void)
VALUE rb_fiber_scheduler_current(void)
{
RUBY_ASSERT(ruby_thread_has_gvl_p());

return rb_fiber_scheduler_current_for_threadptr(GET_THREAD());
}

// This function is allowed to be called without holding the GVL.
VALUE rb_fiber_scheduler_current_for_thread(VALUE thread)
{
return rb_fiber_scheduler_current_for_threadptr(rb_thread_ptr(thread));
Expand Down Expand Up @@ -536,6 +541,23 @@ rb_fiber_scheduler_kernel_sleepv(VALUE scheduler, int argc, VALUE * argv)
return rb_funcallv(scheduler, id_kernel_sleep, argc, argv);
}

/**
* Document-method: Fiber::Scheduler#yield
* call-seq: yield
*
* Yield to the scheduler, to be resumed on the next scheduling cycle.
*/
VALUE
rb_fiber_scheduler_yield(VALUE scheduler)
{
// First try to call the scheduler's yield method, if it exists:
VALUE result = rb_check_funcall(scheduler, id_yield, 0, NULL);
if (!UNDEF_P(result)) return result;

// Otherwise, we can emulate yield by sleeping for 0 seconds:
return rb_fiber_scheduler_kernel_sleep(scheduler, RB_INT2NUM(0));
}

#if 0
/*
* Document-method: Fiber::Scheduler#timeout_after
Expand Down Expand Up @@ -929,6 +951,8 @@ fiber_scheduler_io_pwrite(VALUE _argument) {
VALUE
rb_fiber_scheduler_io_pwrite(VALUE scheduler, VALUE io, rb_off_t from, VALUE buffer, size_t length, size_t offset)
{


if (!rb_respond_to(scheduler, id_io_pwrite)) {
return RUBY_Qundef;
}
Expand Down
41 changes: 41 additions & 0 deletions spec/ruby/core/file/path_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,45 @@
path.should_receive(:to_path).and_return("abc")
File.path(path).should == "abc"
end

it "raises TypeError when #to_path result is not a string" do
path = mock("path")
path.should_receive(:to_path).and_return(nil)
-> { File.path(path) }.should raise_error TypeError

path = mock("path")
path.should_receive(:to_path).and_return(42)
-> { File.path(path) }.should raise_error TypeError
end

it "raises ArgumentError for string argument contains NUL character" do
-> { File.path("\0") }.should raise_error ArgumentError
-> { File.path("a\0") }.should raise_error ArgumentError
-> { File.path("a\0c") }.should raise_error ArgumentError
end

it "raises ArgumentError when #to_path result contains NUL character" do
path = mock("path")
path.should_receive(:to_path).and_return("\0")
-> { File.path(path) }.should raise_error ArgumentError

path = mock("path")
path.should_receive(:to_path).and_return("a\0")
-> { File.path(path) }.should raise_error ArgumentError

path = mock("path")
path.should_receive(:to_path).and_return("a\0c")
-> { File.path(path) }.should raise_error ArgumentError
end

it "raises Encoding::CompatibilityError for ASCII-incompatible string argument" do
path = "abc".encode(Encoding::UTF_32BE)
-> { File.path(path) }.should raise_error Encoding::CompatibilityError
end

it "raises Encoding::CompatibilityError when #to_path result is ASCII-incompatible" do
path = mock("path")
path.should_receive(:to_path).and_return("abc".encode(Encoding::UTF_32BE))
-> { File.path(path) }.should raise_error Encoding::CompatibilityError
end
end
55 changes: 55 additions & 0 deletions test/-ext-/scheduler/test_interrupt_with_scheduler.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# frozen_string_literal: true
require 'test/unit'
require 'timeout'
require_relative '../../fiber/scheduler'

class TestSchedulerInterruptHandling < Test::Unit::TestCase
def setup
pend("No fork support") unless Process.respond_to?(:fork)
require '-test-/scheduler'
end

# Test without Thread.handle_interrupt - should work regardless of fix
def test_without_handle_interrupt_signal_works
IO.pipe do |input, output|
pid = fork do
scheduler = Scheduler.new
Fiber.set_scheduler scheduler

Signal.trap(:INT) do
::Thread.current.raise(Interrupt)
end

Fiber.schedule do
# Yield to the scheduler:
sleep(0)

output.puts "ready"
Bug::Scheduler.blocking_loop
end
end

output.close
assert_equal "ready\n", input.gets

sleep 0.1 # Ensure the child is in the blocking loop
$stderr.puts "Sending interrupt"
Process.kill(:INT, pid)

reaper = Thread.new do
Process.waitpid2(pid)
end

unless reaper.join(1)
Process.kill(:KILL, pid)
end

_, status = reaper.value

# It should be interrupted (not killed):
assert_not_equal 0, status.exitstatus
assert_equal true, status.signaled?
assert_equal Signal.list["INT"], status.termsig
end
end
end
Loading