diff --git a/NEWS.md b/NEWS.md index b44df19ca2b9b2..3468f479596293 100644 --- a/NEWS.md +++ b/NEWS.md @@ -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. diff --git a/ext/-test-/scheduler/extconf.rb b/ext/-test-/scheduler/extconf.rb new file mode 100644 index 00000000000000..159699bd8e3ac1 --- /dev/null +++ b/ext/-test-/scheduler/extconf.rb @@ -0,0 +1,2 @@ +# frozen_string_literal: false +create_makefile("-test-/scheduler") diff --git a/ext/-test-/scheduler/scheduler.c b/ext/-test-/scheduler/scheduler.c new file mode 100644 index 00000000000000..f3badceb92fcc6 --- /dev/null +++ b/ext/-test-/scheduler/scheduler.c @@ -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); +} diff --git a/include/ruby/fiber/scheduler.h b/include/ruby/fiber/scheduler.h index b06884f596665e..537a3a7bb27a5c 100644 --- a/include/ruby/fiber/scheduler.h +++ b/include/ruby/fiber/scheduler.h @@ -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); diff --git a/io.c b/io.c index 549e7e1317ef30..91537895d2b3a5 100644 --- a/io.c +++ b/io.c @@ -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); @@ -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); } /* @@ -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); diff --git a/prism/prism.c b/prism/prism.c index 77ac74192e6f66..93392da3499fbe 100644 --- a/prism/prism.c +++ b/prism/prism.c @@ -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); } @@ -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); diff --git a/scheduler.c b/scheduler.c index 8351fc9945fd8f..63c22b55aaa8d5 100644 --- a/scheduler.c +++ b/scheduler.c @@ -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; @@ -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"); @@ -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)); @@ -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 @@ -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; } diff --git a/spec/ruby/core/file/path_spec.rb b/spec/ruby/core/file/path_spec.rb index dfa0c4ec027c99..726febcc2bd0eb 100644 --- a/spec/ruby/core/file/path_spec.rb +++ b/spec/ruby/core/file/path_spec.rb @@ -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 diff --git a/test/-ext-/scheduler/test_interrupt_with_scheduler.rb b/test/-ext-/scheduler/test_interrupt_with_scheduler.rb new file mode 100644 index 00000000000000..3f9a7f55a07d11 --- /dev/null +++ b/test/-ext-/scheduler/test_interrupt_with_scheduler.rb @@ -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 diff --git a/test/fiber/scheduler.rb b/test/fiber/scheduler.rb index 2401cb30d34563..60261d69e2213f 100644 --- a/test/fiber/scheduler.rb +++ b/test/fiber/scheduler.rb @@ -65,69 +65,79 @@ def next_timeout end end - def run - # $stderr.puts [__method__, Fiber.current].inspect - + def run_once readable = writable = nil - while @readable.any? or @writable.any? or @waiting.any? or @blocking.any? - # May only handle file descriptors up to 1024... - begin - readable, writable = IO.select(@readable.keys + [@urgent.first], @writable.keys, [], next_timeout) - rescue IOError - # Ignore - this can happen if the IO is closed while we are waiting. - end + begin + readable, writable = IO.select(@readable.keys + [@urgent.first], @writable.keys, [], next_timeout) + rescue IOError + # Ignore - this can happen if the IO is closed while we are waiting. + end - # puts "readable: #{readable}" if readable&.any? - # puts "writable: #{writable}" if writable&.any? + # puts "readable: #{readable}" if readable&.any? + # puts "writable: #{writable}" if writable&.any? - selected = {} + selected = {} - readable&.each do |io| - if fiber = @readable.delete(io) - @writable.delete(io) if @writable[io] == fiber - selected[fiber] = IO::READABLE - elsif io == @urgent.first - @urgent.first.read_nonblock(1024) - end + readable&.each do |io| + if fiber = @readable.delete(io) + @writable.delete(io) if @writable[io] == fiber + selected[fiber] = IO::READABLE + elsif io == @urgent.first + @urgent.first.read_nonblock(1024) end + end - writable&.each do |io| - if fiber = @writable.delete(io) - @readable.delete(io) if @readable[io] == fiber - selected[fiber] = selected.fetch(fiber, 0) | IO::WRITABLE - end + writable&.each do |io| + if fiber = @writable.delete(io) + @readable.delete(io) if @readable[io] == fiber + selected[fiber] = selected.fetch(fiber, 0) | IO::WRITABLE end + end - selected.each do |fiber, events| - fiber.transfer(events) - end + selected.each do |fiber, events| + fiber.transfer(events) + end - if @waiting.any? - time = current_time - waiting, @waiting = @waiting, {} - - waiting.each do |fiber, timeout| - if fiber.alive? - if timeout <= time - fiber.transfer - else - @waiting[fiber] = timeout - end + if @waiting.any? + time = current_time + waiting, @waiting = @waiting, {} + + waiting.each do |fiber, timeout| + if fiber.alive? + if timeout <= time + fiber.transfer + else + @waiting[fiber] = timeout end end end + end - if @ready.any? - ready = nil + if @ready.any? + ready = nil - @lock.synchronize do - ready, @ready = @ready, [] - end + @lock.synchronize do + ready, @ready = @ready, [] + end - ready.each do |fiber| - fiber.transfer if fiber.alive? - end + ready.each do |fiber| + fiber.transfer if fiber.alive? + end + end + end + + def run + # $stderr.puts [__method__, Fiber.current].inspect + + # Use Thread.handle_interrupt like Async::Scheduler does + # This defers signal processing, which is the root cause of the gRPC bug + # See: https://github.com/socketry/async/blob/main/lib/async/scheduler.rb + Thread.handle_interrupt(::SignalException => :never) do + while @readable.any? or @writable.any? or @waiting.any? or @blocking.any? + run_once + + break if Thread.pending_interrupt? end end end diff --git a/test/ruby/test_file_exhaustive.rb b/test/ruby/test_file_exhaustive.rb index 222578be269581..394dc47603f782 100644 --- a/test/ruby/test_file_exhaustive.rb +++ b/test/ruby/test_file_exhaustive.rb @@ -204,12 +204,21 @@ def test_path end conv_error = ->(method, msg = "converting with #{method}") { - o = Struct.new(method).new(42) - assert_raise(TypeError, msg) {File.path(o)} - o = Struct.new(method).new("abc".encode(Encoding::UTF_32BE)) - assert_raise(Encoding::CompatibilityError, msg) {File.path(o)} - o = Struct.new(method).new("\0") - assert_raise(ArgumentError, msg) {File.path(o)} + test = ->(&new) do + o = new.(42) + assert_raise(TypeError, msg) {File.path(o)} + + o = new.("abc".encode(Encoding::UTF_32BE)) + assert_raise(Encoding::CompatibilityError, msg) {File.path(o)} + + ["\0", "a\0", "a\0c"].each do |path| + o = new.(path) + assert_raise(ArgumentError, msg) {File.path(o)} + end + end + + test.call(&:itself) + test.call(&Struct.new(method).method(:new)) } conv_error[:to_path] diff --git a/test/test_timeout.rb b/test/test_timeout.rb index d6ae0c9b50b0ec..3be9013f7abbc4 100644 --- a/test/test_timeout.rb +++ b/test/test_timeout.rb @@ -7,7 +7,10 @@ class TestTimeout < Test::Unit::TestCase def test_public_methods assert_equal [:timeout], Timeout.private_instance_methods(false) assert_equal [], Timeout.public_instance_methods(false) + assert_equal [:timeout], Timeout.singleton_class.public_instance_methods(false) + + assert_equal [:Error, :ExitException, :VERSION], Timeout.constants.sort end def test_work_is_done_in_same_thread_as_caller diff --git a/thread.c b/thread.c index 7c88c979029a10..0beb84a5b4575b 100644 --- a/thread.c +++ b/thread.c @@ -221,7 +221,18 @@ vm_check_ints_blocking(rb_execution_context_t *ec) th->pending_interrupt_queue_checked = 0; RUBY_VM_SET_INTERRUPT(ec); } - return rb_threadptr_execute_interrupts(th, 1); + + int result = rb_threadptr_execute_interrupts(th, 1); + + // When a signal is received, we yield to the scheduler as soon as possible: + if (result || RUBY_VM_INTERRUPTED(ec)) { + VALUE scheduler = rb_fiber_scheduler_current(); + if (scheduler != Qnil) { + rb_fiber_scheduler_yield(scheduler); + } + } + + return result; } int diff --git a/vm_dump.c b/vm_dump.c index c1a8d707359aa0..e2b4804ab0d583 100644 --- a/vm_dump.c +++ b/vm_dump.c @@ -1436,7 +1436,7 @@ rb_vm_bugreport(const void *ctx, FILE *errout) "---------------------------------------------------\n"); kprintf("Total ractor count: %u\n", vm->ractor.cnt); kprintf("Ruby thread count for this ractor: %u\n", rb_ec_ractor_ptr(ec)->threads.cnt); - if (rb_fiber_scheduler_get() != Qnil) { + if (ec->thread_ptr->scheduler != Qnil) { kprintf("Note that the Fiber scheduler is enabled\n"); } kputs("\n");