From 1691eab8a415635f6d74ddbfa2cb995867ae62f5 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Tue, 1 Sep 2026 14:47:43 -0400 Subject: [PATCH 1/7] Add Ractor.check_isolation Run the block in a real non-main Ractor while preserving its closure and argument identities. Downgrade isolation violations to categorized warnings so applications can sweep worker-Ractor compatibility without stopping at the first failure. Support an exclusive scheduler mode for race-free checks and cover the isolation gates, fast paths, messaging, and thread inheritance. --- error.c | 8 + gc.c | 14 +- include/ruby/internal/error.h | 5 + process.c | 2 +- ractor.c | 115 +++++++++-- ractor.rb | 40 ++++ ractor_core.h | 15 ++ ractor_sync.c | 14 ++ ruby.c | 4 + test/ruby/test_ractor.rb | 374 ++++++++++++++++++++++++++++++++++ test/ruby/test_rubyoptions.rb | 8 +- thread.c | 46 ++++- thread_sched.c | 19 +- variable.c | 31 +-- version.c | 5 + vm.c | 103 +++++++--- vm_core.h | 1 + vm_insnhelper.c | 63 +++++- 18 files changed, 795 insertions(+), 72 deletions(-) diff --git a/error.c b/error.c index 923249ebe56fab..097b5cdd4dba64 100644 --- a/error.c +++ b/error.c @@ -89,6 +89,7 @@ static ID id_deprecated; static ID id_experimental; static ID id_performance; static ID id_strict_unused_block; +static ID id_ractor_isolation; static VALUE sym_category; static VALUE sym_highlight; static struct { @@ -224,6 +225,10 @@ rb_warning_category_enabled_p(rb_warning_category_t category) * +:performance+ :: * performance hints * * Shape variation limit + * + * +:ractor_isolation+ :: + * Ractor isolation violations reported by Ractor.check_isolation + * (downgraded from Ractor::IsolationError exceptions to warnings). */ static VALUE @@ -3884,6 +3889,7 @@ Init_Exception(void) id_experimental = rb_intern_const("experimental"); id_performance = rb_intern_const("performance"); id_strict_unused_block = rb_intern_const("strict_unused_block"); + id_ractor_isolation = rb_intern_const("ractor_isolation"); id_top = rb_intern_const("top"); id_bottom = rb_intern_const("bottom"); id_iseq = rb_make_internal_id(); @@ -3897,6 +3903,7 @@ Init_Exception(void) st_add_direct(warning_categories.id2enum, id_experimental, RB_WARN_CATEGORY_EXPERIMENTAL); st_add_direct(warning_categories.id2enum, id_performance, RB_WARN_CATEGORY_PERFORMANCE); st_add_direct(warning_categories.id2enum, id_strict_unused_block, RB_WARN_CATEGORY_STRICT_UNUSED_BLOCK); + st_add_direct(warning_categories.id2enum, id_ractor_isolation, RB_WARN_CATEGORY_RACTOR_ISOLATION); warning_categories.enum2id = rb_init_identtable(); st_add_direct(warning_categories.enum2id, RB_WARN_CATEGORY_NONE, 0); @@ -3904,6 +3911,7 @@ Init_Exception(void) st_add_direct(warning_categories.enum2id, RB_WARN_CATEGORY_EXPERIMENTAL, id_experimental); st_add_direct(warning_categories.enum2id, RB_WARN_CATEGORY_PERFORMANCE, id_performance); st_add_direct(warning_categories.enum2id, RB_WARN_CATEGORY_STRICT_UNUSED_BLOCK, id_strict_unused_block); + st_add_direct(warning_categories.enum2id, RB_WARN_CATEGORY_RACTOR_ISOLATION, id_ractor_isolation); } void diff --git a/gc.c b/gc.c index 793e02b0503d7a..e380aefb8e623c 100644 --- a/gc.c +++ b/gc.c @@ -2097,6 +2097,12 @@ rb_undefine_finalizer(VALUE obj) { rb_check_frozen(obj); + if (rb_gc_obj_foreign_p(obj)) { + rb_ractor_isolation_violation( + "can not undefine a finalizer of an object of another Ractor"); + return obj; + } + rb_gc_impl_undefine_finalizer(rb_gc_get_objspace(), obj); return obj; @@ -2217,7 +2223,13 @@ rb_define_finalizer(VALUE obj, VALUE block) should_be_finalizable(obj); should_be_callable(block); - block = rb_gc_impl_define_finalizer(rb_gc_get_objspace(), obj, block); + if (rb_gc_obj_foreign_p(obj)) { + rb_ractor_isolation_violation( + "can not define a finalizer for an object of another Ractor"); + } + else { + block = rb_gc_impl_define_finalizer(rb_gc_get_objspace(), obj, block); + } block = rb_ary_new3(2, INT2FIX(0), block); OBJ_FREEZE(block); diff --git a/include/ruby/internal/error.h b/include/ruby/internal/error.h index 5bf82bfe7d632e..6127f9d180ffa2 100644 --- a/include/ruby/internal/error.h +++ b/include/ruby/internal/error.h @@ -56,9 +56,13 @@ typedef enum { /** Warning is for checking unused block strictly */ RB_WARN_CATEGORY_STRICT_UNUSED_BLOCK, + /** Warning is for Ractor isolation violations reported by Ractor.check_isolation. */ + RB_WARN_CATEGORY_RACTOR_ISOLATION, + RB_WARN_CATEGORY_DEFAULT_BITS = ( (1U << RB_WARN_CATEGORY_DEPRECATED) | (1U << RB_WARN_CATEGORY_EXPERIMENTAL) | + (1U << RB_WARN_CATEGORY_RACTOR_ISOLATION) | 0), RB_WARN_CATEGORY_ALL_BITS = ( @@ -66,6 +70,7 @@ typedef enum { (1U << RB_WARN_CATEGORY_EXPERIMENTAL) | (1U << RB_WARN_CATEGORY_PERFORMANCE) | (1U << RB_WARN_CATEGORY_STRICT_UNUSED_BLOCK) | + (1U << RB_WARN_CATEGORY_RACTOR_ISOLATION) | 0) } rb_warning_category_t; diff --git a/process.c b/process.c index a94b1b4fced775..e747f77b6a8be1 100644 --- a/process.c +++ b/process.c @@ -4109,7 +4109,7 @@ rb_pid_t rb_fork_ruby(int *status) { if (UNLIKELY(!rb_ractor_main_p())) { - rb_raise(rb_eRactorIsolationError, "can not fork from non-main Ractors"); + rb_ractor_isolation_violation("can not fork from non-main Ractors"); } struct rb_process_status child = {.status = 0}; diff --git a/ractor.c b/ractor.c index 5311c4b96ba861..bfddb3c8b99c54 100644 --- a/ractor.c +++ b/ractor.c @@ -5,6 +5,7 @@ #include "ruby/ractor.h" #include "ruby/re.h" #include "ruby/thread_native.h" +#include "ruby_atomic.h" #include "vm_core.h" #include "vm_sync.h" #include "ractor_core.h" @@ -840,11 +841,12 @@ rb_ractor_main_setup(rb_vm_t *vm, rb_ractor_t *r, rb_thread_t *th) } static VALUE -ractor_create(rb_execution_context_t *ec, VALUE self, VALUE loc, VALUE name, VALUE args, VALUE block) +ractor_create0(rb_execution_context_t *ec, VALUE self, VALUE loc, VALUE name, VALUE args, VALUE block, bool isolation_check) { VALUE rv = ractor_alloc(self); rb_ractor_t *r = RACTOR_PTR(rv); ractor_init(r, name, loc); + r->isolation_check = isolation_check; r->pub.id = ractor_next_id(); RUBY_DEBUG_LOG("r:%u", r->pub.id); @@ -863,6 +865,12 @@ ractor_create(rb_execution_context_t *ec, VALUE self, VALUE loc, VALUE name, VAL return rv; } +static VALUE +ractor_create(rb_execution_context_t *ec, VALUE self, VALUE loc, VALUE name, VALUE args, VALUE block) +{ + return ractor_create0(ec, self, loc, name, args, block, false); +} + #if 0 static VALUE ractor_create_func(VALUE klass, VALUE loc, VALUE name, VALUE args, rb_block_call_func_t func) @@ -1850,6 +1858,12 @@ make_shareable_check_shareable(VALUE obj) } else if (!allow_frozen_shareable_p(obj)) { if (!RB_TYPE_P(obj, T_DATA)) { + if (rb_ractor_isolation_check_p()) { + rb_category_warn(RB_WARN_CATEGORY_RACTOR_ISOLATION, + "can not make shareable object of class %+"PRIsVALUE, + rb_class_of(obj)); + return traverse_stop; + } rb_raise(rb_eRactorError, "can not make shareable object for %+"PRIsVALUE, obj); } @@ -1859,6 +1873,12 @@ make_shareable_check_shareable(VALUE obj) RB_OBJ_SET_SHAREABLE(obj); return traverse_skip; } + else if (rb_ractor_isolation_check_p()) { + rb_category_warn(RB_WARN_CATEGORY_RACTOR_ISOLATION, + "can not make shareable object of class %+"PRIsVALUE + " because it refers unshareable objects", rb_class_of(obj)); + return traverse_stop; + } else { rb_raise(rb_eRactorError, "can not make shareable object for %+"PRIsVALUE" because it refers unshareable objects", obj); @@ -1866,7 +1886,13 @@ make_shareable_check_shareable(VALUE obj) } else if (rb_obj_is_proc(obj)) { rb_proc_ractor_make_shareable(obj, Qundef); - return traverse_cont; + return rb_ractor_shareable_p(obj) ? traverse_cont : traverse_stop; + } + else if (rb_ractor_isolation_check_p()) { + rb_category_warn(RB_WARN_CATEGORY_RACTOR_ISOLATION, + "can not make shareable object of class %+"PRIsVALUE, + rb_class_of(obj)); + return traverse_stop; } else { rb_raise(rb_eRactorError, "can not make shareable object for %+"PRIsVALUE, obj); @@ -1930,9 +1956,10 @@ VALUE rb_ractor_ensure_shareable(VALUE obj, VALUE name) { if (!rb_ractor_shareable_p(obj)) { - VALUE message = rb_sprintf("cannot assign unshareable object to %"PRIsVALUE, - name); - rb_exc_raise(rb_exc_new_str(rb_eRactorIsolationError, message)); + rb_ractor_isolation_violation("cannot assign unshareable object to %"PRIsVALUE, name); + // In check_isolation mode the violation only warned: return obj as-is + // so the caller can keep going. The caller's invariant ("this is now + // shareable") will be wrong, which is exactly the bug we want surfaced. } return obj; } @@ -1941,7 +1968,7 @@ void rb_ractor_ensure_main_ractor(const char *msg) { if (!rb_ractor_main_p()) { - rb_raise(rb_eRactorIsolationError, "%s", msg); + rb_ractor_isolation_violation("%s", msg); } } @@ -3783,13 +3810,11 @@ ractor_local_value_store_if_absent(rb_execution_context_t *ec, VALUE self, VALUE static VALUE ractor_shareable_proc(rb_execution_context_t *ec, VALUE replace_self, bool is_lambda) { - if (!rb_ractor_shareable_p(replace_self)) { - rb_raise(rb_eRactorIsolationError, "self should be shareable: %" PRIsVALUE, replace_self); - } - else { - VALUE proc = is_lambda ? rb_block_lambda() : rb_block_proc(); - return rb_proc_ractor_make_shareable(rb_proc_dup(proc), replace_self); + if (!rb_ractor_shareable_p(replace_self) && !rb_ractor_isolation_check_p()) { + rb_ractor_isolation_violation("self should be shareable: %" PRIsVALUE, replace_self); } + VALUE proc = is_lambda ? rb_block_lambda() : rb_block_proc(); + return rb_proc_ractor_make_shareable(rb_proc_dup(proc), replace_self); } // Ractor#require @@ -4001,4 +4026,70 @@ rb_ractor_autoload_load(VALUE module, ID name) } } +// ============================================================================= +// Ractor.check_isolation { ... } +// +// A development/debugging mode: the block runs in a genuine non-main Ractor, +// without isolating its Proc or copying its arguments. Violations are +// downgraded from Ractor::IsolationError to :ractor_isolation category warnings +// so the program can keep running and report more than the first violation. +// +// As a side effect (matches Ractor semantics), the VM is switched into +// multi-ractor mode the first time check_isolation is enabled. Multi-ractor +// mode cannot be turned off again, so the VM keeps paying that overhead for +// the rest of the process lifetime. +// ============================================================================= + +bool +rb_ractor_isolation_check_p(void) +{ + rb_execution_context_t *ec = rb_current_ec_noinline(); + if (!ec) return false; + rb_ractor_t *r = rb_ec_ractor_ptr(ec); + return r && r->isolation_check; +} + +void +rb_ractor_isolation_violation_str(VALUE message) +{ + if (rb_ractor_isolation_check_p()) { + rb_category_warn(RB_WARN_CATEGORY_RACTOR_ISOLATION, "%s", StringValueCStr(message)); + return; + } + + rb_exc_raise(rb_exc_new_str(rb_eRactorIsolationError, message)); +} + +void +rb_ractor_isolation_violation(const char *fmt, ...) +{ + va_list args; + va_start(args, fmt); + VALUE message = rb_vsprintf(fmt, args); + va_end(args); + + rb_ractor_isolation_violation_str(message); +} + +/* Set during native-thread scheduler initialization; see thread_sched.c. */ +extern int ruby_ractor_exclusive_enabled; + +static rb_atomic_t ractor_check_isolation_advisory_emitted; + +/* Return true to exactly one caller when nonexclusive mode needs its advisory. + * This state cannot live on Ractor itself: setting a class/module ivar from an + * ordinary non-main Ractor is itself an isolation violation. */ +static VALUE +ractor_check_isolation_warn_p(rb_execution_context_t *ec, VALUE self) +{ + if (ruby_ractor_exclusive_enabled) return Qfalse; + return RBOOL(ATOMIC_EXCHANGE(ractor_check_isolation_advisory_emitted, 1) == 0); +} + +static VALUE +ractor_check_isolation_create(rb_execution_context_t *ec, VALUE self, VALUE loc, VALUE name, VALUE args, VALUE block) +{ + return ractor_create0(ec, self, loc, name, args, block, true); +} + #include "ractor.rbinc" diff --git a/ractor.rb b/ractor.rb index e826e61b37655a..46aaac27d19b2f 100644 --- a/ractor.rb +++ b/ractor.rb @@ -538,6 +538,46 @@ def self.main? } end + # call-seq: + # Ractor.check_isolation(*args, name: nil) {|*args| ... } -> result of block + # + # Runs the block in a genuine non-main \Ractor while downgrading isolation + # violations to +:ractor_isolation+ category warnings. Unlike +Ractor.new+, + # the block is not isolated and its arguments are passed by reference, so it + # can close over and inspect existing non-shareable application state. + # + # The block therefore observes production worker-Ractor behavior: + # +Ractor.main?+ is false and +Ractor.current+ is the newly created \Ractor. + # Its return value is delivered through +Ractor#value+, and exceptions are + # re-raised there as for an ordinary \Ractor. + # + # On builds with M:N scheduling, booting with +RUBY_RACTOR_EXCLUSIVE=1+ + # limits the scheduler to one shared native thread. This prevents simultaneous + # Ruby execution on shared native threads, but does not make the block atomic: + # blocking operations can hand the run slot to another \Ractor, and dedicated + # native threads are not covered. Without that mode, this method emits a + # one-time advisory because other \Ractors may run in parallel. + # + # Calling this method switches the VM into multi-Ractor mode permanently. + # Suppress isolation warnings with +Warning[:ractor_isolation] = false+ or + # +-W:no-ractor_isolation+. + def self.check_isolation(*args, name: nil, &block) + b = block # TODO: builtin bug + raise ArgumentError, "must be called with a block" unless block + + if Primitive.ractor_check_isolation_warn_p + Kernel.warn("Ractor.check_isolation: other Ractors can run in parallel " \ + "with the isolation-check Ractor. On builds with M:N scheduling, " \ + "RUBY_RACTOR_EXCLUSIVE=1 prevents simultaneous Ruby execution on " \ + "shared native threads.", uplevel: 1) + end + + loc = caller_locations(1, 1).first + loc = "#{loc.path}:#{loc.lineno}" + Primitive.ractor_check_isolation_create(loc, name, args, b).value + end + + # internal method def self._require feature # :nodoc: if main? diff --git a/ractor_core.h b/ractor_core.h index 6a545251473dc5..b39e3f5a06b923 100644 --- a/ractor_core.h +++ b/ractor_core.h @@ -144,6 +144,7 @@ struct rb_ractor_struct { bool malloc_gc_disabled; bool main_ractor; + bool isolation_check; void *newobj_cache; /* This Ractor's objspace. The main Ractor receives the boot objspace from @@ -235,6 +236,20 @@ VALUE rb_ractor_autoload_load(VALUE space, ID id); VALUE rb_ractor_ensure_shareable(VALUE obj, VALUE name); st_table *rb_ractor_targeted_hooks(rb_ractor_t *cr); +/* True if the current Ractor was created by Ractor.check_isolation. */ +bool rb_ractor_isolation_check_p(void); + +/* Report a Ractor isolation violation: + * - if Ractor.check_isolation is active on the current Ractor, emit a + * :ractor_isolation category warning and return; + * - otherwise, raise Ractor::IsolationError (does not return). + * + * Use the printf-style overload for ad-hoc messages and the _str overload + * when the message is already constructed (e.g. via several rb_str_catf + * calls). */ +PRINTF_ARGS(void rb_ractor_isolation_violation(const char *fmt, ...), 1, 2); +void rb_ractor_isolation_violation_str(VALUE message); + RUBY_SYMBOL_EXPORT_BEGIN void rb_ractor_finish_marking(bool full_mark); diff --git a/ractor_sync.c b/ractor_sync.c index c6e4d8a6d31df1..9c7eff30c34337 100644 --- a/ractor_sync.c +++ b/ractor_sync.c @@ -1068,6 +1068,20 @@ ractor_prepare_payload(rb_execution_context_t *ec, VALUE obj, enum ractor_basket *ptype = basket_type_ref; return obj; } + else if (rb_ractor_isolation_check_p()) { + // Under Ractor.check_isolation, don't copy non-shareable messages. + // Copying can fail outright (e.g. Procs -> "can not copy Proc + // object"), which would abort a real-Ractor sweep at the first + // Ractor::Dispatch call. Exclusive mode (RUBY_RACTOR_EXCLUSIVE) + // guarantees no other Ractor runs concurrently, so passing the + // original object by reference is safe; warn and continue. + rb_category_warn(RB_WARN_CATEGORY_RACTOR_ISOLATION, + "can not copy an unshareable %"PRIsVALUE" across Ractors; " + "passing by reference under Ractor.check_isolation", + rb_class_of(obj)); + *ptype = basket_type_ref; + return obj; + } else { /* Snapshot the object on the sender side without calling the user-visible * #clone. Both forms are off-heap, so an in-flight payload is never a GC diff --git a/ruby.c b/ruby.c index 3d7ef4ff993968..283010d038e43e 100644 --- a/ruby.c +++ b/ruby.c @@ -402,6 +402,7 @@ usage(const char *name, int help, int highlight, int columns) M("experimental", "", "Experimental features."), M("performance", "", "Performance issues."), M("strict_unused_block", "", "Warning unused block strictly"), + M("ractor_isolation", "", "Ractor isolation violations."), }; int i; const char *sb = highlight ? esc_standout+1 : esc_none; @@ -1270,6 +1271,9 @@ proc_W_option(ruby_cmdline_options_t *opt, const char *s, int *warning) else if (NAME_MATCH_P("strict_unused_block", s, len)) { bits = 1U << RB_WARN_CATEGORY_STRICT_UNUSED_BLOCK; } + else if (NAME_MATCH_P("ractor_isolation", s, len)) { + bits = 1U << RB_WARN_CATEGORY_RACTOR_ISOLATION; + } else { rb_warn("unknown warning category: '%s'", s); } diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index e435d0856c72bf..ceff55c171e788 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -798,6 +798,380 @@ def test_io_is_not_shareable end end + def test_check_isolation_runs_in_a_non_main_ractor + assert_ractor(<<~'RUBY', ignore_stderr: true) + result = Ractor.check_isolation(name: "isolation check") do + [Ractor.main?, Ractor.current == Ractor.main, Ractor.current.name] + end + assert_equal [false, false, "isolation check"], result + RUBY + end + + def test_check_isolation_returns_the_block_value_by_reference + assert_ractor(<<~'RUBY', ignore_stderr: true) + obj = Object.new + assert_same obj, Ractor.check_isolation { obj } + RUBY + end + + def test_check_isolation_passes_args_and_closes_over_outer_variables + assert_ractor(<<~'RUBY', ignore_stderr: true) + outer = [1, 2, 3] + arg = Object.new + returned_arg, returned_outer = Ractor.check_isolation(arg) do |a| + [a, outer] + end + assert_same arg, returned_arg + assert_same outer, returned_outer + RUBY + end + + def test_check_isolation_handles_large_argument_lists_without_using_the_native_stack + assert_ractor(<<~'RUBY', ignore_stderr: true) + marker = Object.new + args = Array.new(200_000, marker) + length, first, last = Ractor.check_isolation(*args) do |*values| + [values.length, values.first, values.last] + end + assert_equal 200_000, length + assert_same marker, first + assert_same marker, last + RUBY + end + + def test_check_isolation_requires_a_block + assert_ractor(<<~'RUBY') + assert_raise(ArgumentError) { Ractor.check_isolation } + RUBY + end + + def test_check_isolation_make_shareable_warns_and_continues_for_files + assert_ractor(<<~'RUBY', ignore_stderr: true) + file = File.open(IO::NULL) + begin + result = Ractor.check_isolation(file) do |f| + Ractor.make_shareable(f) + :completed + end + assert_equal :completed, result + refute Ractor.shareable?(file) + ensure + file.close + end + RUBY + end + + def test_check_isolation_warns_instead_of_raising + # Warnings originate in the special Ractor, so capture them with a + # shareable queue rather than replacing the main Ractor's $stderr. + assert_ractor(<<~'RUBY', ignore_stderr: true) + class CheckIsolationFixture + @ivar = "ivar" + @@cvar = [1, 2, 3] + MUTABLE = "mutable" + end + $check_isolation_global = "global" + ISOLATION_WARNINGS = Thread::Queue.new + module CaptureIsolationWarnings + def warn(message, category: nil) + if category == :ractor_isolation && !Thread.current[:capturing_isolation] + Thread.current[:capturing_isolation] = true + begin + ISOLATION_WARNINGS << Ractor.make_shareable(message) + ensure + Thread.current[:capturing_isolation] = false + end + return nil + end + super + end + end + Warning.singleton_class.prepend(CaptureIsolationWarnings) + require "etc" + + h = Hash.new(Mutex.new) + result = Ractor.check_isolation do + CheckIsolationFixture.instance_variable_get(:@ivar) + CheckIsolationFixture.class_variable_get(:@@cvar) + 3.times { CheckIsolationFixture::MUTABLE } # exercise the constant cache + $check_isolation_global + CheckIsolationFixture.instance_variable_set(:@ivar, "new") + Ractor.make_shareable(h) + Etc.passwd + Thread.new { CheckIsolationFixture::MUTABLE }.join + :completed + end + assert_equal :completed, result + + messages = [] + messages << ISOLATION_WARNINGS.pop until ISOLATION_WARNINGS.empty? + combined = messages.join("\n") + assert_match(/instance variables of classes\/modules from non-main Ractors/, combined) + assert_match(/non-shareable class variable @@cvar/, combined) + assert_match(/non-shareable objects in constant CheckIsolationFixture::MUTABLE/, combined) + assert_match(/global variable \$check_isolation_global/, combined) + assert_match(/set instance variables of classes\/modules/, combined) + assert_match(/can not make shareable object/, combined) + assert_match(/ractor unsafe method called from not main ractor/, combined) + RUBY + end + + def test_check_isolation_warns_on_outer_variable_capture + assert_ractor(<<~'RUBY', ignore_stderr: true) + captured = [] + OUTER_VARIABLE_WARNINGS = Thread::Queue.new + module CaptureOuterVariableWarnings + def warn(message, category: nil) + if category == :ractor_isolation + OUTER_VARIABLE_WARNINGS << Ractor.make_shareable(message) + return nil + end + super + end + end + Warning.singleton_class.prepend(CaptureOuterVariableWarnings) + + result = Ractor.check_isolation { captured << :ran; captured } + assert_same captured, result + assert_equal [:ran], captured + messages = [] + messages << OUTER_VARIABLE_WARNINGS.pop until OUTER_VARIABLE_WARNINGS.empty? + assert_match(/can not isolate a Proc because it accesses outer variables \(captured\)/, + messages.join("\n")) + RUBY + end + + def test_check_isolation_does_not_mark_an_invalid_proc_shareable + assert_ractor(<<~'RUBY', ignore_stderr: true) + captured = [] + callable = Ractor.check_isolation do + Ractor.shareable_proc { captured << :called; captured } + end + + refute Ractor.shareable?(callable) + refute callable.frozen? + assert_same captured, callable.call + assert_equal [:called], captured + RUBY + end + + def test_check_isolation_warns_and_executes_captured_define_method + assert_ractor(<<~'RUBY', ignore_stderr: true) + captured = [] + klass = Class.new + klass.define_method(:capture) { captured << :called; captured } + BMETHOD_WARNINGS = Thread::Queue.new + module CaptureBmethodWarnings + def warn(message, category: nil) + if category == :ractor_isolation && !Thread.current[:capturing_bmethod_warning] + Thread.current[:capturing_bmethod_warning] = true + begin + BMETHOD_WARNINGS << Ractor.make_shareable(message) + ensure + Thread.current[:capturing_bmethod_warning] = false + end + return nil + end + super + end + end + Warning.singleton_class.prepend(CaptureBmethodWarnings) + + result = Ractor.check_isolation(klass) { |k| k.new.capture } + assert_same captured, result + assert_equal [:called], captured + messages = [] + messages << BMETHOD_WARNINGS.pop until BMETHOD_WARNINGS.empty? + assert_match(/can not call method capture defined with an un-shareable Proc/, + messages.join("\n")) + RUBY + end + + def test_check_isolation_is_active_in_child_threads + assert_ractor(<<~'RUBY', ignore_stderr: true) + class CheckIsolationChildThreadFixture + VALUE = [] + end + CHILD_THREAD_WARNINGS = Thread::Queue.new + module CaptureChildThreadWarnings + def warn(message, category: nil) + if category == :ractor_isolation && !Thread.current[:capturing_child_thread_warning] + Thread.current[:capturing_child_thread_warning] = true + begin + CHILD_THREAD_WARNINGS << Ractor.make_shareable(message) + ensure + Thread.current[:capturing_child_thread_warning] = false + end + return nil + end + super + end + end + Warning.singleton_class.prepend(CaptureChildThreadWarnings) + + value = Ractor.check_isolation do + Thread.new { CheckIsolationChildThreadFixture::VALUE }.value + end + assert_same CheckIsolationChildThreadFixture::VALUE, value + messages = [] + messages << CHILD_THREAD_WARNINGS.pop until CHILD_THREAD_WARNINGS.empty? + assert_match(/non-shareable objects in constant CheckIsolationChildThreadFixture::VALUE/, + messages.join("\n")) + RUBY + end + + def test_ractor_new_still_enforces_isolation_after_check_isolation + assert_ractor(<<~'RUBY', ignore_stderr: true) + nested_result = Ractor.check_isolation do + captured = Object.new + error = assert_raise(Ractor::IsolationError) do + Ractor.new { captured } + end + error.class + end + assert_equal Ractor::IsolationError, nested_result + + captured = Object.new + assert_raise(Ractor::IsolationError) do + Ractor.new { captured } + end + RUBY + end + + def test_check_isolation_warns_for_finalizers_on_foreign_objects + omit 'per-Ractor objspace semantics of the default GC' unless GC.config[:implementation] == 'default' + assert_ractor(<<~'RUBY', ignore_stderr: true) + object = Object.new + finalizer = proc {} + FINALIZER_WARNINGS = Thread::Queue.new + module CaptureFinalizerWarnings + def warn(message, category: nil) + if category == :ractor_isolation && !Thread.current[:capturing_finalizer_warning] + Thread.current[:capturing_finalizer_warning] = true + begin + FINALIZER_WARNINGS << Ractor.make_shareable(message) + ensure + Thread.current[:capturing_finalizer_warning] = false + end + return nil + end + super + end + end + Warning.singleton_class.prepend(CaptureFinalizerWarnings) + + defined, undefined = Ractor.check_isolation do + [ObjectSpace.define_finalizer(object, finalizer), + ObjectSpace.undefine_finalizer(object)] + end + assert_same finalizer, defined[1] + assert_same object, undefined + + messages = [] + messages << FINALIZER_WARNINGS.pop until FINALIZER_WARNINGS.empty? + combined = messages.join("\n") + assert_match(/can not define a finalizer for an object of another Ractor/, combined) + assert_match(/can not undefine a finalizer of an object of another Ractor/, combined) + RUBY + end + + def test_check_isolation_reraises_block_exceptions + assert_ractor(<<~'RUBY', ignore_stderr: true) + error = assert_raise(Ractor::RemoteError) do + Ractor.check_isolation { raise "boom" } + end + assert_equal "boom", error.cause.message + RUBY + end + + def test_check_isolation_allows_dispatch_to_main + assert_ractor(<<~'RUBY', ignore_stderr: true) + main_port = Ractor::Port.new + Thread.new do + callable, reply = main_port.receive + reply << callable.call + end + + value = Ractor.check_isolation do + reply = Ractor::Port.new + main_port << [Ractor.shareable_proc { 40 + 2 }, reply] + reply.receive + end + assert_equal 42, value + RUBY + end + + def test_check_isolation_emits_nonexclusive_advisory_once + assert_ractor(<<~'RUBY') + assert_warning(/Ractor.check_isolation: other Ractors can run in parallel/) do + Ractor.check_isolation { :first } + end + assert_no_warning(/other Ractors can run in parallel/) do + Ractor.check_isolation { :second } + end + RUBY + end + + def test_check_isolation_first_called_from_an_ordinary_ractor + assert_ractor(<<~'RUBY') + ADVISORY_WARNINGS = Ractor::Port.new + module CaptureCheckIsolationAdvisory + def warn(message, **kwargs) + if message.include?("Ractor.check_isolation: other Ractors can run in parallel") + ADVISORY_WARNINGS << message + return nil + end + super + end + end + Warning.singleton_class.prepend(CaptureCheckIsolationAdvisory) + + result = Ractor.new { Ractor.check_isolation { :ok } }.value + + assert_equal :ok, result + assert_match(/Ractor.check_isolation: other Ractors can run in parallel/, + ADVISORY_WARNINGS.receive) + RUBY + end + + def test_check_isolation_blocks_other_ractors_in_exclusive_mode + assert_separately([{"RUBY_RACTOR_EXCLUSIVE" => "1"}, "-W:no-experimental"], <<~'RUBY', timeout: 30) + omit "M:N scheduling is not supported by this build" unless RUBY_DESCRIPTION.include?("+MN") + + Warning[:ractor_isolation] = false + report = Ractor::Port.new + Thread.new do + report << :ready + t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + loop do + now = Process.clock_gettime(Process::CLOCK_MONOTONIC) + break if now - t0 > 3.0 + report << now + sleep 0.01 + end + report << :done + end + assert_equal :ready, report.receive + + start, finish = Ractor.check_isolation do + t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + x = 0 + x += 1 while Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 < 1.0 + [t0, Process.clock_gettime(Process::CLOCK_MONOTONIC)] + end + + stamps = [] + loop do + message = report.receive + break if message == :done + stamps << message + end + during = stamps.count { |time| time >= start && time <= finish } + assert_equal 0, during, + "expected no other Ractor to run during exclusive isolation check, observed #{during} ticks" + RUBY + end + def assert_make_shareable(obj) refute Ractor.shareable?(obj), "object was already shareable" Ractor.make_shareable(obj) diff --git a/test/ruby/test_rubyoptions.rb b/test/ruby/test_rubyoptions.rb index b50926591ebd76..745f7093e133e0 100644 --- a/test/ruby/test_rubyoptions.rb +++ b/test/ruby/test_rubyoptions.rb @@ -121,7 +121,13 @@ def test_warning assert_in_out_err(%w(-We) + ['p $-W'], "", %w(2), []) assert_in_out_err(%w(-w -W0 -e) + ['p $-W'], "", %w(0), []) - categories = {deprecated: 1, experimental: 0, performance: 2, strict_unused_block: 3} + categories = { + deprecated: 1, + experimental: 0, + performance: 2, + strict_unused_block: 3, + ractor_isolation: 0, + } assert_equal categories.keys.sort, Warning.categories.sort categories.each do |category, level| diff --git a/thread.c b/thread.c index 4736a8cd67ae75..f272bbe26a9b99 100644 --- a/thread.c +++ b/thread.c @@ -625,7 +625,32 @@ thread_do_start_proc(rb_thread_t *th) VALUE self = rb_ractor_self(th->ractor); th->thgroup = th->ractor->thgroup_default = rb_obj_alloc(cThGroup); - VM_ASSERT(FIXNUM_P(args)); + if (th->ractor->isolation_check) { + /* Isolation-check ractor (see thread_create_core): the block is not + * isolated and args were passed by reference as a real Array, so + * invoke the proc directly without going through the mailbox. Keep + * the proc's own self so closures over the enclosing scope keep + * working, mirroring the old inline Ractor.check_isolation block. */ + args_len = RARRAY_LENINT(args); + if (args_len < 8) { + args_ptr = ALLOCA_N(VALUE, args_len); + MEMCPY((VALUE *)args_ptr, RARRAY_CONST_PTR(args), VALUE, args_len); + th->invoke_arg.proc.args = Qnil; + } + else { + args_ptr = RARRAY_CONST_PTR(args); + } + vm_check_ints_blocking(th->ec); + + return rb_vm_invoke_proc( + th->ec, proc, + args_len, args_ptr, + th->invoke_arg.proc.kw_splat, + VM_BLOCK_HANDLER_NONE, + cref + ); + } + args_len = FIX2INT(args); args_ptr = ALLOCA_N(VALUE, args_len); rb_ractor_receive_parameters(th->ec, th->ractor, args_len, (VALUE *)args_ptr); @@ -934,9 +959,19 @@ thread_create_core(VALUE thval, struct thread_create_params *params) th->ractor = params->g; th->ec->ractor_id = rb_ractor_id(th->ractor); th->ractor->threads.main = th; - th->invoke_arg.proc.proc = rb_proc_isolate_bang(params->proc, Qnil); - th->invoke_arg.proc.args = INT2FIX(RARRAY_LENINT(params->args)); th->invoke_arg.proc.kw_splat = rb_keyword_given_p(); + if (th->ractor->isolation_check) { + /* This is a real non-main Ractor, but the Proc and arguments stay + * intact and are passed by reference. Report the Proc-isolation + * errors Ractor.new would raise, then run the original closure. */ + rb_proc_check_isolation_warn(params->proc); + th->invoke_arg.proc.proc = params->proc; + th->invoke_arg.proc.args = params->args; + } + else { + th->invoke_arg.proc.proc = rb_proc_isolate_bang(params->proc, Qnil); + th->invoke_arg.proc.args = INT2FIX(RARRAY_LENINT(params->args)); + } break; case thread_invoke_type_func: @@ -984,7 +1019,9 @@ thread_create_core(VALUE thval, struct thread_create_params *params) EC_PUSH_TAG(ec); if ((state = EC_EXEC_TAG()) == TAG_NONE) { rb_ractor_setup_default_port(params->g); - rb_ractor_send_parameters(ec, params->g, params->args); + if (!params->g->isolation_check) { + rb_ractor_send_parameters(ec, params->g, params->args); + } } EC_POP_TAG(); if (state != TAG_NONE) { @@ -1216,7 +1253,6 @@ rb_thread_create_ractor(rb_ractor_t *r, VALUE args, VALUE proc) return thret; } - struct join_arg { struct rb_waiting_list *waiter; rb_thread_t *target; diff --git a/thread_sched.c b/thread_sched.c index a7d89cc1316eef..b94683e04605f4 100644 --- a/thread_sched.c +++ b/thread_sched.c @@ -1793,18 +1793,28 @@ thread_sched_atfork(struct rb_thread_sched *sched) #endif extern int ruby_mn_threads_enabled; +extern int ruby_ractor_exclusive_enabled; + +static bool +ractor_exclusive_env_p(void) +{ + const char *cstr = getenv("RUBY_RACTOR_EXCLUSIVE"); + return cstr && atoi(cstr) > 0; +} void ruby_mn_threads_params(void) { rb_vm_t *vm = GET_VM(); rb_ractor_t *main_ractor = GET_RACTOR(); + bool exclusive = USE_MN_THREADS && ractor_exclusive_env_p(); const char *mn_threads_cstr = getenv("RUBY_MN_THREADS"); bool enable_mn_threads = false; - if (USE_MN_THREADS && mn_threads_cstr && (enable_mn_threads = atoi(mn_threads_cstr) > 0)) { + if (USE_MN_THREADS && ((mn_threads_cstr && (enable_mn_threads = atoi(mn_threads_cstr) > 0)) || exclusive)) { // enabled + enable_mn_threads = true; ruby_mn_threads_enabled = 1; } main_ractor->threads.sched.enable_mn_threads = enable_mn_threads; @@ -1819,6 +1829,13 @@ ruby_mn_threads_params(void) } } + /* One shared native thread acts as a VM-wide GVL while still handing the + * run slot to another Ractor when the current one blocks. */ + if (exclusive) { + max_cpu = 1; + ruby_ractor_exclusive_enabled = 1; + } + vm->ractor.sched.max_cpu = max_cpu; } diff --git a/variable.c b/variable.c index 564bcda62fe576..81c81739e345f4 100644 --- a/variable.c +++ b/variable.c @@ -613,7 +613,7 @@ rb_find_global_entry(ID id) } if (UNLIKELY(!rb_ractor_main_p()) && (!entry || !entry->ractor_local)) { - rb_raise(rb_eRactorIsolationError, "can not access global variable %s from non-main Ractor", rb_id2name(id)); + rb_ractor_isolation_violation("can not access global variable %s from non-main Ractor", rb_id2name(id)); } return entry; @@ -1150,7 +1150,7 @@ rb_f_global_variables(void) VALUE sym, backref = rb_backref_get(); if (!rb_ractor_main_p()) { - rb_raise(rb_eRactorIsolationError, "can not access global variables from non-main Ractors"); + rb_ractor_isolation_violation("can not access global variables from non-main Ractors"); } /* gvar access (get/set) in boxes creates gvar entries globally */ @@ -1184,7 +1184,7 @@ rb_alias_variable(ID name1, ID name2) struct rb_id_table *gtbl = rb_global_tbl; if (!rb_ractor_main_p()) { - rb_raise(rb_eRactorIsolationError, "can not access global variables from non-main Ractors"); + rb_ractor_isolation_violation("can not access global variables from non-main Ractors"); } RB_VM_LOCKING() { @@ -1217,7 +1217,7 @@ IVAR_ACCESSOR_SHOULD_BE_MAIN_RACTOR(ID id) { if (UNLIKELY(!rb_ractor_main_p())) { if (rb_is_instance_id(id)) { // check only normal ivars - rb_raise(rb_eRactorIsolationError, "can not set instance variables of classes/modules by non-main Ractors"); + rb_ractor_isolation_violation("can not set instance variables of classes/modules by non-main Ractors"); } } } @@ -1226,7 +1226,9 @@ static void CVAR_ACCESSOR_SHOULD_BE_MAIN_RACTOR(VALUE klass, ID id) { if (UNLIKELY(!rb_ractor_main_p())) { - rb_raise(rb_eRactorIsolationError, "can not set class variables from non-main Ractors (%"PRIsVALUE" from %"PRIsVALUE")", rb_id2str(id), klass); + /* See comment on the instance-variable warning below for why we + * pass rb_class_path() rather than the class itself. */ + rb_ractor_isolation_violation("can not set class variables from non-main Ractors (%"PRIsVALUE" from %"PRIsVALUE")", rb_id2str(id), rb_class_path(klass)); } } @@ -1234,9 +1236,9 @@ static void cvar_read_ractor_check(VALUE klass, ID id, VALUE val) { if (UNLIKELY(!rb_ractor_main_p()) && !rb_ractor_shareable_p(val)) { - rb_raise(rb_eRactorIsolationError, + rb_ractor_isolation_violation( "can not read non-shareable class variable %"PRIsVALUE" from non-main Ractors (%"PRIsVALUE")", - rb_id2str(id), klass); + rb_id2str(id), rb_class_path(klass)); } } @@ -1248,7 +1250,7 @@ ivar_ractor_check(VALUE obj, ID id) UNLIKELY(!rb_ractor_main_p()) && UNLIKELY(rb_ractor_shareable_p(obj))) { - rb_raise(rb_eRactorIsolationError, "can not access instance variables of shareable objects from non-main Ractors"); + rb_ractor_isolation_violation("can not access instance variables of shareable objects from non-main Ractors"); } } @@ -1561,12 +1563,13 @@ rb_ivar_lookup(VALUE obj, ID id, VALUE undef) if (is_class && val != undef && rb_is_instance_id(id)) { if (UNLIKELY(!rb_ractor_main_p()) && !rb_ractor_shareable_p(val)) { - rb_raise( - rb_eRactorIsolationError, + /* Avoid calling a user-overridable to_s while reporting the + * violation; it may recurse through the same class ivar lookup. */ + rb_ractor_isolation_violation( "can not get unshareable values from instance variables of classes/modules from " "non-main Ractors (%"PRIsVALUE" from %"PRIsVALUE")", rb_id2str(id), - obj + rb_class_path(obj) ); } } @@ -1598,7 +1601,7 @@ rb_ivar_get_at(VALUE obj, attr_index_t index, ID id) VALUE val = rb_imemo_fields_ptr(fields_obj)[index]; if (UNLIKELY(!rb_ractor_main_p()) && !rb_ractor_shareable_p(val)) { - rb_raise(rb_eRactorIsolationError, + rb_ractor_isolation_violation( "can not get unshareable values from instance variables of classes/modules from non-main Ractors"); } @@ -3338,7 +3341,7 @@ rb_const_get_0(VALUE klass, ID id, int exclude, int recurse, int visibility) if (!UNDEF_P(c)) { if (UNLIKELY(!rb_ractor_main_p())) { if (!rb_ractor_shareable_p(c)) { - rb_raise(rb_eRactorIsolationError, "can not access non-shareable objects in constant %"PRIsVALUE"::%"PRIsVALUE" by non-main Ractor.", rb_class_path(found_in), rb_id2str(id)); + rb_ractor_isolation_violation("can not access non-shareable objects in constant %"PRIsVALUE"::%"PRIsVALUE" by non-main Ractor.", rb_class_path(found_in), rb_id2str(id)); } } return c; @@ -3847,7 +3850,7 @@ const_set(VALUE klass, ID id, VALUE val) } if (!rb_ractor_main_p() && !rb_ractor_shareable_p(val)) { - rb_raise(rb_eRactorIsolationError, "can not set constants with non-shareable objects by non-main Ractors"); + rb_ractor_isolation_violation("can not set constants with non-shareable objects by non-main Ractors"); } check_before_mod_set(klass, id, val, "constant"); diff --git a/version.c b/version.c index efffe8cdb3cc6c..233d18282f4a83 100644 --- a/version.c +++ b/version.c @@ -190,6 +190,11 @@ Init_version(void) int ruby_mn_threads_enabled; +/* Set at boot (see ruby_mn_threads_params) when RUBY_RACTOR_EXCLUSIVE is + * truthy: the M:N scheduler runs with a single shared native thread so at + * most one thread executes Ruby VM-wide. Used by Ractor.check_isolation advisory. */ +int ruby_ractor_exclusive_enabled; + #ifndef RB_DEFAULT_PARSER #define RB_DEFAULT_PARSER RB_DEFAULT_PARSER_PRISM #endif diff --git a/vm.c b/vm.c index 5454bff570e974..5ca19ed829fd95 100644 --- a/vm.c +++ b/vm.c @@ -1463,8 +1463,19 @@ collect_outer_variable_names(ID id, VALUE val, void *ptr) return ID_TABLE_CONTINUE; } +static void +proc_isolation_violation_str(VALUE message, bool warn) +{ + if (warn) { + rb_category_warn(RB_WARN_CATEGORY_RACTOR_ISOLATION, "%s", StringValueCStr(message)); + } + else { + rb_exc_raise(rb_exc_new_str(rb_eRactorIsolationError, message)); + } +} + static const rb_env_t * -env_copy(const VALUE *src_ep, VALUE read_only_variables) +env_copy(const VALUE *src_ep, VALUE read_only_variables, bool warn, bool *valid) { const rb_env_t *src_env = (rb_env_t *)VM_ENV_ENVVAL(src_ep); VM_ASSERT(src_env->ep == src_ep); @@ -1504,20 +1515,30 @@ env_copy(const VALUE *src_ep, VALUE read_only_variables) VALUE name = rb_id2str(id); VALUE msg = rb_sprintf("cannot make a shareable Proc because " "the outer variable '%" PRIsVALUE "' may be reassigned.", name); - rb_exc_raise(rb_exc_new_str(rb_eRactorIsolationError, msg)); + proc_isolation_violation_str(msg, warn); + *valid = false; } // check shareable VALUE v = src_env->env[j]; if (!rb_ractor_shareable_p(v)) { VALUE name = rb_id2str(id); - VALUE msg = rb_sprintf("cannot make a shareable Proc because it can refer" - " unshareable object %+" PRIsVALUE " from ", v); + VALUE msg; + if (warn) { + msg = rb_sprintf("cannot make a shareable Proc because it can refer " + "an unshareable object of class %+" PRIsVALUE " from ", + rb_class_of(v)); + } + else { + msg = rb_sprintf("cannot make a shareable Proc because it can refer" + " unshareable object %+" PRIsVALUE " from ", v); + } if (name) rb_str_catf(msg, "variable '%" PRIsVALUE "'", name); else rb_str_cat_cstr(msg, "a hidden variable"); - rb_exc_raise(rb_exc_new_str(rb_eRactorIsolationError, msg)); + proc_isolation_violation_str(msg, warn); + *valid = false; } RB_OBJ_WRITE((VALUE)copied_env, &env_body[j], v); rb_ary_delete_at(read_only_variables, i); @@ -1529,7 +1550,7 @@ env_copy(const VALUE *src_ep, VALUE read_only_variables) if (!VM_ENV_LOCAL_P(src_ep)) { const VALUE *prev_ep = VM_ENV_PREV_EP(src_env->ep); - const rb_env_t *new_prev_env = env_copy(prev_ep, read_only_variables); + const rb_env_t *new_prev_env = env_copy(prev_ep, read_only_variables, warn, valid); ep[VM_ENV_DATA_INDEX_SPECVAL] = VM_GUARDED_PREV_EP(new_prev_env->ep); RB_OBJ_WRITTEN(copied_env, Qundef, new_prev_env); VM_ENV_FLAGS_UNSET(ep, VM_ENV_FLAG_LOCAL); @@ -1538,21 +1559,26 @@ env_copy(const VALUE *src_ep, VALUE read_only_variables) ep[VM_ENV_DATA_INDEX_SPECVAL] = VM_BLOCK_HANDLER_NONE; } - RB_OBJ_SET_SHAREABLE((VALUE)copied_env); + if (*valid) { + RB_OBJ_SET_SHAREABLE((VALUE)copied_env); + } return copied_env; } -static void -proc_isolate_env(VALUE self, rb_proc_t *proc, VALUE read_only_variables) +static bool +proc_isolate_env(VALUE self, rb_proc_t *proc, VALUE read_only_variables, bool warn, bool valid) { const struct rb_captured_block *captured = &proc->block.as.captured; - const rb_env_t *env = env_copy(captured->ep, read_only_variables); + const rb_env_t *env = env_copy(captured->ep, read_only_variables, warn, &valid); + if (!valid) return false; + *((const VALUE **)&proc->block.as.captured.ep) = env->ep; RB_OBJ_WRITTEN(self, Qundef, env); + return true; } static VALUE -proc_shared_outer_variables(struct rb_id_table *outer_variables, bool isolate, const char *message) +proc_shared_outer_variables(struct rb_id_table *outer_variables, bool isolate, const char *message, bool warn, bool *valid) { struct collect_outer_variable_name_data data = { .isolate = isolate, @@ -1575,10 +1601,13 @@ proc_shared_outer_variables(struct rb_id_table *outer_variables, bool isolate, c } if (*sep == ',') rb_str_cat_cstr(str, ")"); rb_str_cat_cstr(str, data.yield ? " and uses 'yield'." : "."); - rb_exc_raise(rb_exc_new_str(rb_eRactorIsolationError, str)); + proc_isolation_violation_str(str, warn); + if (valid) *valid = false; } else if (data.yield) { - rb_raise(rb_eRactorIsolationError, "can not %s because it uses 'yield'.", message); + VALUE str = rb_sprintf("can not %s because it uses 'yield'.", message); + proc_isolation_violation_str(str, warn); + if (valid) *valid = false; } return data.read_only; @@ -1600,10 +1629,10 @@ rb_proc_isolate_bang(VALUE self, VALUE replace_self) } if (ISEQ_BODY(iseq)->outer_variables) { - proc_shared_outer_variables(ISEQ_BODY(iseq)->outer_variables, true, "isolate a Proc"); + proc_shared_outer_variables(ISEQ_BODY(iseq)->outer_variables, true, "isolate a Proc", false, NULL); } - proc_isolate_env(self, proc, Qfalse); + if (!proc_isolate_env(self, proc, Qfalse, false, true)) return self; proc->header.is_isolated = TRUE; RB_OBJ_WRITE(self, &proc->block.as.captured.self, Qnil); } @@ -1620,34 +1649,52 @@ rb_proc_isolate(VALUE self) return dst; } +/* Report the Proc-isolation checks performed by Ractor.new without mutating + * the Proc, so Ractor.check_isolation can execute the original closure. */ +void +rb_proc_check_isolation_warn(VALUE self) +{ + const rb_iseq_t *iseq = vm_proc_iseq(self); + + if (iseq) { + rb_proc_t *proc = (rb_proc_t *)RTYPEDDATA_DATA(self); + if (proc->block.type == block_type_iseq && ISEQ_BODY(iseq)->outer_variables) { + proc_shared_outer_variables(ISEQ_BODY(iseq)->outer_variables, true, "isolate a Proc", true, NULL); + } + } +} + VALUE rb_proc_ractor_make_shareable(VALUE self, VALUE replace_self) { const rb_iseq_t *iseq = vm_proc_iseq(self); + bool warn = rb_ractor_isolation_check_p(); if (iseq) { rb_proc_t *proc = (rb_proc_t *)RTYPEDDATA_DATA(self); if (proc->block.type != block_type_iseq) rb_raise(rb_eRuntimeError, "not supported yet"); - if (!UNDEF_P(replace_self)) { - RB_OBJ_WRITE(self, &proc->block.as.captured.self, replace_self); - } - - if (!rb_ractor_shareable_p(vm_block_self(&proc->block))) { - rb_raise(rb_eRactorIsolationError, - "Proc's self is not shareable: %" PRIsVALUE, - self); + bool valid = true; + VALUE proc_self = UNDEF_P(replace_self) ? vm_block_self(&proc->block) : replace_self; + if (!rb_ractor_shareable_p(proc_self)) { + VALUE message = rb_sprintf("Proc's self is not shareable: %" PRIsVALUE, self); + proc_isolation_violation_str(message, warn); + valid = false; } VALUE read_only_variables = Qfalse; if (ISEQ_BODY(iseq)->outer_variables) { read_only_variables = - proc_shared_outer_variables(ISEQ_BODY(iseq)->outer_variables, false, "make a Proc shareable"); + proc_shared_outer_variables(ISEQ_BODY(iseq)->outer_variables, false, + "make a Proc shareable", warn, &valid); } - proc_isolate_env(self, proc, read_only_variables); + if (!proc_isolate_env(self, proc, read_only_variables, warn, valid)) return self; + if (!UNDEF_P(replace_self)) { + RB_OBJ_WRITE(self, &proc->block.as.captured.self, replace_self); + } proc->header.is_isolated = TRUE; } else { @@ -1656,9 +1703,9 @@ rb_proc_ractor_make_shareable(VALUE self, VALUE replace_self) VALUE proc_self = vm_block_self(block); if (!rb_ractor_shareable_p(proc_self)) { - rb_raise(rb_eRactorIsolationError, - "Proc's self is not shareable: %" PRIsVALUE, - self); + VALUE message = rb_sprintf("Proc's self is not shareable: %" PRIsVALUE, self); + proc_isolation_violation_str(message, warn); + if (warn) return self; } } diff --git a/vm_core.h b/vm_core.h index 8843b7ac124ead..f6525219543de9 100644 --- a/vm_core.h +++ b/vm_core.h @@ -1394,6 +1394,7 @@ const rb_cref_t *rb_proc_refinements_cref_for_call(VALUE procval); RUBY_SYMBOL_EXPORT_BEGIN VALUE rb_proc_isolate(VALUE self); VALUE rb_proc_isolate_bang(VALUE self, VALUE replace_self); +void rb_proc_check_isolation_warn(VALUE self); VALUE rb_proc_ractor_make_shareable(VALUE proc, VALUE replace_self); RUBY_SYMBOL_EXPORT_END diff --git a/vm_insnhelper.c b/vm_insnhelper.c index 0542221f58d33e..78a69c27af4a60 100644 --- a/vm_insnhelper.c +++ b/vm_insnhelper.c @@ -1144,7 +1144,7 @@ vm_get_ev_const(rb_execution_context_t *ec, VALUE orig_klass, ID id, bool allow_ else { if (UNLIKELY(!rb_ractor_main_p())) { if (!rb_ractor_shareable_p(val)) { - rb_raise(rb_eRactorIsolationError, + rb_ractor_isolation_violation( "can not access non-shareable objects in constant %"PRIsVALUE"::%"PRIsVALUE" by non-main ractor.", rb_class_path(klass), rb_id2str(id)); } } @@ -1262,6 +1262,9 @@ vm_getivar(VALUE obj, ID id, const rb_iseq_t *iseq, IVC ic, const struct rb_call // and modules. So we can skip locking. // Second, other ractors need to check the shareability of the // values returned from the class ivars. + // + // Ractor.check_isolation also routes here so the isolation + // checks in the general path get a chance to fire. if (default_value == Qundef) { // defined? return rb_ivar_defined(obj, id) ? Qtrue : Qundef; @@ -1429,6 +1432,8 @@ static VALUE vm_setivar_class(VALUE obj, VALUE val, rb_setivar_cache cache) { if (UNLIKELY(!rb_ractor_main_p())) { + // Bail out of the inline cache fast path so the slow path can run + // the isolation check (also fires under Ractor.check_isolation). return Qundef; } @@ -3545,9 +3550,21 @@ vm_call_iseq_setup_tailcall(rb_execution_context_t *ec, rb_control_frame_t *cfp, static void ractor_unsafe_check(void) { - if (!rb_ractor_main_p()) { - rb_raise(rb_eRactorUnsafeError, "ractor unsafe method called from not main ractor"); + if (LIKELY(rb_ractor_main_p())) return; + + if (rb_ractor_isolation_check_p()) { + // Ractor.check_isolation: downgrade to a :ractor_isolation warning so + // the sweep can keep going. We deliberately route through the same + // category as the IsolationError downgrades because from the caller's + // point of view both mean "this code would not work in a Ractor". + rb_category_warn(RB_WARN_CATEGORY_RACTOR_ISOLATION, + "ractor unsafe method called from not main ractor"); + return; } + + // Real non-main Ractor: preserve the existing UnsafeError behaviour so + // user code that rescues Ractor::UnsafeError specifically keeps working. + rb_raise(rb_eRactorUnsafeError, "ractor unsafe method called from not main ractor"); } static VALUE @@ -4105,6 +4122,36 @@ vm_call_attrset(rb_execution_context_t *ec, rb_control_frame_t *cfp, struct rb_c return vm_call_attrset_direct(ec, cfp, calling->cc, calling->recv); } +// True if a bmethod's Proc may not be invoked from the current Ractor: it is +// not shareable and was defined in a different Ractor. +static inline bool +vm_bmethod_proc_uncallable_p(rb_execution_context_t *ec, const rb_callable_method_entry_t *cme, VALUE procv) +{ + return !RB_OBJ_SHAREABLE_P(procv) && + cme->def->body.bmethod.defined_ractor_id != rb_ec_ractor_id(ec); +} + +// A method defined with a genuinely non-shareable Proc (e.g. define_method with +// a Proc capturing unshareable state) can normally only be called from the +// Ractor that defined it; calling it elsewhere raises. Under +// Ractor.check_isolation we downgrade that to a :ractor_isolation warning and +// fall through to invoke it anyway. RUBY_RACTOR_EXCLUSIVE makes this +// race-free; without that scheduler mode the public wrapper emits an advisory. +// Continuing lets a real-Ractor sweep collect the violations that follow +// instead of dying on the first bmethod call. +static void +vm_bmethod_unshareable_proc_violation(rb_execution_context_t *ec, const rb_callable_method_entry_t *cme) +{ + if (rb_ractor_isolation_check_p()) { + rb_category_warn(RB_WARN_CATEGORY_RACTOR_ISOLATION, + "can not call method %"PRIsVALUE" defined with an un-shareable Proc from a different Ractor", + rb_id2str(cme->called_id)); + } + else { + rb_raise(rb_eRuntimeError, "defined with an un-shareable Proc in a different Ractor"); + } +} + static inline VALUE vm_call_bmethod_body(rb_execution_context_t *ec, struct rb_calling_info *calling, const VALUE *argv) { @@ -4114,9 +4161,8 @@ vm_call_bmethod_body(rb_execution_context_t *ec, struct rb_calling_info *calling const rb_callable_method_entry_t *cme = vm_cc_cme(cc); VALUE procv = cme->def->body.bmethod.proc; - if (!RB_OBJ_SHAREABLE_P(procv) && - cme->def->body.bmethod.defined_ractor_id != rb_ec_ractor_id(ec)) { - rb_raise(rb_eRuntimeError, "defined with an un-shareable Proc in a different Ractor"); + if (vm_bmethod_proc_uncallable_p(ec, cme, procv)) { + vm_bmethod_unshareable_proc_violation(ec, cme); } /* control block frame */ @@ -4137,9 +4183,8 @@ vm_call_iseq_bmethod(rb_execution_context_t *ec, rb_control_frame_t *cfp, struct const rb_callable_method_entry_t *cme = vm_cc_cme(cc); VALUE procv = cme->def->body.bmethod.proc; - if (!RB_OBJ_SHAREABLE_P(procv) && - cme->def->body.bmethod.defined_ractor_id != rb_ec_ractor_id(ec)) { - rb_raise(rb_eRuntimeError, "defined with an un-shareable Proc in a different Ractor"); + if (vm_bmethod_proc_uncallable_p(ec, cme, procv)) { + vm_bmethod_unshareable_proc_violation(ec, cme); } rb_proc_t *proc; From 62e1dd1b280ab7aeebce059133c9b398120ebb42 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Fri, 18 Sep 2026 13:27:00 +0400 Subject: [PATCH 2/7] Replace Ractor.check_isolation with RUBY_RACTOR_CHECK_ISOLATION Trigger the isolation check from a boolean environment variable read once at boot instead of from a method call, so an application can be swept for worker-Ractor incompatibilities without editing every Ractor.new call site. Under the variable every non-main Ractor behaves the way check_isolation did: the Proc is not isolated, arguments and the return value pass by reference, and violations are downgraded to :ractor_isolation warnings. Read the variable the way RUBY_RACTOR_EXCLUSIVE is read and relocate the one-shot advisory to boot, keeping its suppression under exclusive mode. Replace the per-Ractor isolation_check field with the process-wide flag. The three sites in thread.c run in the parent's context while creating the child, so they test the flag directly rather than the shared predicate, which evaluates the current Ractor and would never fire from the main one. Because the flag is process-wide it also covers nested Ractors, which previously reverted to raising, so the test asserting that is inverted and a companion pins that Ractor.new still raises when the variable is unset. --- error.c | 2 +- include/ruby/internal/error.h | 2 +- ractor.c | 56 ++++------- ractor.rb | 40 -------- ractor_core.h | 9 +- ractor_sync.c | 4 +- test/ruby/test_ractor.rb | 173 +++++++++++++++------------------- thread.c | 26 ++--- thread_sched.c | 16 ++++ version.c | 7 +- vm.c | 2 +- vm_insnhelper.c | 10 +- 12 files changed, 143 insertions(+), 204 deletions(-) diff --git a/error.c b/error.c index 097b5cdd4dba64..d3595beafd5cdd 100644 --- a/error.c +++ b/error.c @@ -227,7 +227,7 @@ rb_warning_category_enabled_p(rb_warning_category_t category) * * Shape variation limit * * +:ractor_isolation+ :: - * Ractor isolation violations reported by Ractor.check_isolation + * Ractor isolation violations reported under RUBY_RACTOR_CHECK_ISOLATION * (downgraded from Ractor::IsolationError exceptions to warnings). */ diff --git a/include/ruby/internal/error.h b/include/ruby/internal/error.h index 6127f9d180ffa2..3eec4e7599f4c4 100644 --- a/include/ruby/internal/error.h +++ b/include/ruby/internal/error.h @@ -56,7 +56,7 @@ typedef enum { /** Warning is for checking unused block strictly */ RB_WARN_CATEGORY_STRICT_UNUSED_BLOCK, - /** Warning is for Ractor isolation violations reported by Ractor.check_isolation. */ + /** Warning is for Ractor isolation violations reported under RUBY_RACTOR_CHECK_ISOLATION. */ RB_WARN_CATEGORY_RACTOR_ISOLATION, RB_WARN_CATEGORY_DEFAULT_BITS = ( diff --git a/ractor.c b/ractor.c index bfddb3c8b99c54..132a4efdb593d9 100644 --- a/ractor.c +++ b/ractor.c @@ -841,12 +841,11 @@ rb_ractor_main_setup(rb_vm_t *vm, rb_ractor_t *r, rb_thread_t *th) } static VALUE -ractor_create0(rb_execution_context_t *ec, VALUE self, VALUE loc, VALUE name, VALUE args, VALUE block, bool isolation_check) +ractor_create(rb_execution_context_t *ec, VALUE self, VALUE loc, VALUE name, VALUE args, VALUE block) { VALUE rv = ractor_alloc(self); rb_ractor_t *r = RACTOR_PTR(rv); ractor_init(r, name, loc); - r->isolation_check = isolation_check; r->pub.id = ractor_next_id(); RUBY_DEBUG_LOG("r:%u", r->pub.id); @@ -865,12 +864,6 @@ ractor_create0(rb_execution_context_t *ec, VALUE self, VALUE loc, VALUE name, VA return rv; } -static VALUE -ractor_create(rb_execution_context_t *ec, VALUE self, VALUE loc, VALUE name, VALUE args, VALUE block) -{ - return ractor_create0(ec, self, loc, name, args, block, false); -} - #if 0 static VALUE ractor_create_func(VALUE klass, VALUE loc, VALUE name, VALUE args, rb_block_call_func_t func) @@ -1957,7 +1950,7 @@ rb_ractor_ensure_shareable(VALUE obj, VALUE name) { if (!rb_ractor_shareable_p(obj)) { rb_ractor_isolation_violation("cannot assign unshareable object to %"PRIsVALUE, name); - // In check_isolation mode the violation only warned: return obj as-is + // In isolation-check mode the violation only warned: return obj as-is // so the caller can keep going. The caller's invariant ("this is now // shareable") will be wrong, which is exactly the bug we want surfaced. } @@ -4027,26 +4020,30 @@ rb_ractor_autoload_load(VALUE module, ID name) } // ============================================================================= -// Ractor.check_isolation { ... } +// RUBY_RACTOR_CHECK_ISOLATION (environment variable, read once at boot) // -// A development/debugging mode: the block runs in a genuine non-main Ractor, -// without isolating its Proc or copying its arguments. Violations are -// downgraded from Ractor::IsolationError to :ractor_isolation category warnings -// so the program can keep running and report more than the first violation. +// A development/debugging mode: isolation violations on non-main Ractors are +// downgraded from Ractor::IsolationError to :ractor_isolation category +// warnings so the program can keep running and report more than the first +// violation. The main Ractor is unaffected and keeps raising as usual. // -// As a side effect (matches Ractor semantics), the VM is switched into -// multi-ractor mode the first time check_isolation is enabled. Multi-ractor -// mode cannot be turned off again, so the VM keeps paying that overhead for -// the rest of the process lifetime. +// The mode only changes how violations are reported. Creating a Ractor still +// switches the VM into multi-ractor mode (ordinary Ractor.new semantics). +// Multi-ractor mode cannot be turned off again, so the VM keeps paying that +// overhead for the rest of the process lifetime. // ============================================================================= +/* Set at boot from the environment; see thread_sched.c and version.c. */ +extern int ruby_ractor_check_isolation_enabled; + bool rb_ractor_isolation_check_p(void) { + if (!ruby_ractor_check_isolation_enabled) return false; rb_execution_context_t *ec = rb_current_ec_noinline(); if (!ec) return false; rb_ractor_t *r = rb_ec_ractor_ptr(ec); - return r && r->isolation_check; + return r && r != rb_ec_vm_ptr(ec)->ractor.main_ractor; } void @@ -4071,25 +4068,4 @@ rb_ractor_isolation_violation(const char *fmt, ...) rb_ractor_isolation_violation_str(message); } -/* Set during native-thread scheduler initialization; see thread_sched.c. */ -extern int ruby_ractor_exclusive_enabled; - -static rb_atomic_t ractor_check_isolation_advisory_emitted; - -/* Return true to exactly one caller when nonexclusive mode needs its advisory. - * This state cannot live on Ractor itself: setting a class/module ivar from an - * ordinary non-main Ractor is itself an isolation violation. */ -static VALUE -ractor_check_isolation_warn_p(rb_execution_context_t *ec, VALUE self) -{ - if (ruby_ractor_exclusive_enabled) return Qfalse; - return RBOOL(ATOMIC_EXCHANGE(ractor_check_isolation_advisory_emitted, 1) == 0); -} - -static VALUE -ractor_check_isolation_create(rb_execution_context_t *ec, VALUE self, VALUE loc, VALUE name, VALUE args, VALUE block) -{ - return ractor_create0(ec, self, loc, name, args, block, true); -} - #include "ractor.rbinc" diff --git a/ractor.rb b/ractor.rb index 46aaac27d19b2f..e826e61b37655a 100644 --- a/ractor.rb +++ b/ractor.rb @@ -538,46 +538,6 @@ def self.main? } end - # call-seq: - # Ractor.check_isolation(*args, name: nil) {|*args| ... } -> result of block - # - # Runs the block in a genuine non-main \Ractor while downgrading isolation - # violations to +:ractor_isolation+ category warnings. Unlike +Ractor.new+, - # the block is not isolated and its arguments are passed by reference, so it - # can close over and inspect existing non-shareable application state. - # - # The block therefore observes production worker-Ractor behavior: - # +Ractor.main?+ is false and +Ractor.current+ is the newly created \Ractor. - # Its return value is delivered through +Ractor#value+, and exceptions are - # re-raised there as for an ordinary \Ractor. - # - # On builds with M:N scheduling, booting with +RUBY_RACTOR_EXCLUSIVE=1+ - # limits the scheduler to one shared native thread. This prevents simultaneous - # Ruby execution on shared native threads, but does not make the block atomic: - # blocking operations can hand the run slot to another \Ractor, and dedicated - # native threads are not covered. Without that mode, this method emits a - # one-time advisory because other \Ractors may run in parallel. - # - # Calling this method switches the VM into multi-Ractor mode permanently. - # Suppress isolation warnings with +Warning[:ractor_isolation] = false+ or - # +-W:no-ractor_isolation+. - def self.check_isolation(*args, name: nil, &block) - b = block # TODO: builtin bug - raise ArgumentError, "must be called with a block" unless block - - if Primitive.ractor_check_isolation_warn_p - Kernel.warn("Ractor.check_isolation: other Ractors can run in parallel " \ - "with the isolation-check Ractor. On builds with M:N scheduling, " \ - "RUBY_RACTOR_EXCLUSIVE=1 prevents simultaneous Ruby execution on " \ - "shared native threads.", uplevel: 1) - end - - loc = caller_locations(1, 1).first - loc = "#{loc.path}:#{loc.lineno}" - Primitive.ractor_check_isolation_create(loc, name, args, b).value - end - - # internal method def self._require feature # :nodoc: if main? diff --git a/ractor_core.h b/ractor_core.h index b39e3f5a06b923..6aba729b5822db 100644 --- a/ractor_core.h +++ b/ractor_core.h @@ -144,7 +144,6 @@ struct rb_ractor_struct { bool malloc_gc_disabled; bool main_ractor; - bool isolation_check; void *newobj_cache; /* This Ractor's objspace. The main Ractor receives the boot objspace from @@ -236,12 +235,14 @@ VALUE rb_ractor_autoload_load(VALUE space, ID id); VALUE rb_ractor_ensure_shareable(VALUE obj, VALUE name); st_table *rb_ractor_targeted_hooks(rb_ractor_t *cr); -/* True if the current Ractor was created by Ractor.check_isolation. */ +/* True if RUBY_RACTOR_CHECK_ISOLATION mode is enabled and the current Ractor + * is a non-main Ractor. */ bool rb_ractor_isolation_check_p(void); /* Report a Ractor isolation violation: - * - if Ractor.check_isolation is active on the current Ractor, emit a - * :ractor_isolation category warning and return; + * - if RUBY_RACTOR_CHECK_ISOLATION mode is enabled and the current Ractor + * is a non-main Ractor, emit a :ractor_isolation category warning and + * return; * - otherwise, raise Ractor::IsolationError (does not return). * * Use the printf-style overload for ad-hoc messages and the _str overload diff --git a/ractor_sync.c b/ractor_sync.c index 9c7eff30c34337..672163d826a0cc 100644 --- a/ractor_sync.c +++ b/ractor_sync.c @@ -1069,7 +1069,7 @@ ractor_prepare_payload(rb_execution_context_t *ec, VALUE obj, enum ractor_basket return obj; } else if (rb_ractor_isolation_check_p()) { - // Under Ractor.check_isolation, don't copy non-shareable messages. + // Under RUBY_RACTOR_CHECK_ISOLATION, don't copy non-shareable messages. // Copying can fail outright (e.g. Procs -> "can not copy Proc // object"), which would abort a real-Ractor sweep at the first // Ractor::Dispatch call. Exclusive mode (RUBY_RACTOR_EXCLUSIVE) @@ -1077,7 +1077,7 @@ ractor_prepare_payload(rb_execution_context_t *ec, VALUE obj, enum ractor_basket // original object by reference is safe; warn and continue. rb_category_warn(RB_WARN_CATEGORY_RACTOR_ISOLATION, "can not copy an unshareable %"PRIsVALUE" across Ractors; " - "passing by reference under Ractor.check_isolation", + "passing by reference under RUBY_RACTOR_CHECK_ISOLATION", rb_class_of(obj)); *ptype = basket_type_ref; return obj; diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index ceff55c171e788..f80c9531865e88 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -798,61 +798,55 @@ def test_io_is_not_shareable end end - def test_check_isolation_runs_in_a_non_main_ractor - assert_ractor(<<~'RUBY', ignore_stderr: true) - result = Ractor.check_isolation(name: "isolation check") do + def test_isolation_check_runs_in_a_non_main_ractor + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) + result = Ractor.new(name: "isolation check") do [Ractor.main?, Ractor.current == Ractor.main, Ractor.current.name] - end + end.value assert_equal [false, false, "isolation check"], result RUBY end - def test_check_isolation_returns_the_block_value_by_reference - assert_ractor(<<~'RUBY', ignore_stderr: true) + def test_isolation_check_returns_the_block_value_by_reference + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) obj = Object.new - assert_same obj, Ractor.check_isolation { obj } + assert_same obj, Ractor.new { obj }.value RUBY end - def test_check_isolation_passes_args_and_closes_over_outer_variables - assert_ractor(<<~'RUBY', ignore_stderr: true) + def test_isolation_check_passes_args_and_closes_over_outer_variables + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) outer = [1, 2, 3] arg = Object.new - returned_arg, returned_outer = Ractor.check_isolation(arg) do |a| + returned_arg, returned_outer = Ractor.new(arg) do |a| [a, outer] - end + end.value assert_same arg, returned_arg assert_same outer, returned_outer RUBY end - def test_check_isolation_handles_large_argument_lists_without_using_the_native_stack - assert_ractor(<<~'RUBY', ignore_stderr: true) + def test_isolation_check_handles_large_argument_lists_without_using_the_native_stack + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) marker = Object.new args = Array.new(200_000, marker) - length, first, last = Ractor.check_isolation(*args) do |*values| + length, first, last = Ractor.new(*args) do |*values| [values.length, values.first, values.last] - end + end.value assert_equal 200_000, length assert_same marker, first assert_same marker, last RUBY end - def test_check_isolation_requires_a_block - assert_ractor(<<~'RUBY') - assert_raise(ArgumentError) { Ractor.check_isolation } - RUBY - end - - def test_check_isolation_make_shareable_warns_and_continues_for_files - assert_ractor(<<~'RUBY', ignore_stderr: true) + def test_isolation_check_make_shareable_warns_and_continues_for_files + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) file = File.open(IO::NULL) begin - result = Ractor.check_isolation(file) do |f| + result = Ractor.new(file) do |f| Ractor.make_shareable(f) :completed - end + end.value assert_equal :completed, result refute Ractor.shareable?(file) ensure @@ -861,10 +855,10 @@ def test_check_isolation_make_shareable_warns_and_continues_for_files RUBY end - def test_check_isolation_warns_instead_of_raising + def test_isolation_check_warns_instead_of_raising # Warnings originate in the special Ractor, so capture them with a # shareable queue rather than replacing the main Ractor's $stderr. - assert_ractor(<<~'RUBY', ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) class CheckIsolationFixture @ivar = "ivar" @@cvar = [1, 2, 3] @@ -890,7 +884,7 @@ def warn(message, category: nil) require "etc" h = Hash.new(Mutex.new) - result = Ractor.check_isolation do + result = Ractor.new do CheckIsolationFixture.instance_variable_get(:@ivar) CheckIsolationFixture.class_variable_get(:@@cvar) 3.times { CheckIsolationFixture::MUTABLE } # exercise the constant cache @@ -900,7 +894,7 @@ def warn(message, category: nil) Etc.passwd Thread.new { CheckIsolationFixture::MUTABLE }.join :completed - end + end.value assert_equal :completed, result messages = [] @@ -916,8 +910,8 @@ def warn(message, category: nil) RUBY end - def test_check_isolation_warns_on_outer_variable_capture - assert_ractor(<<~'RUBY', ignore_stderr: true) + def test_isolation_check_warns_on_outer_variable_capture + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) captured = [] OUTER_VARIABLE_WARNINGS = Thread::Queue.new module CaptureOuterVariableWarnings @@ -931,7 +925,7 @@ def warn(message, category: nil) end Warning.singleton_class.prepend(CaptureOuterVariableWarnings) - result = Ractor.check_isolation { captured << :ran; captured } + result = Ractor.new { captured << :ran; captured }.value assert_same captured, result assert_equal [:ran], captured messages = [] @@ -941,12 +935,12 @@ def warn(message, category: nil) RUBY end - def test_check_isolation_does_not_mark_an_invalid_proc_shareable - assert_ractor(<<~'RUBY', ignore_stderr: true) + def test_isolation_check_does_not_mark_an_invalid_proc_shareable + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) captured = [] - callable = Ractor.check_isolation do + callable = Ractor.new do Ractor.shareable_proc { captured << :called; captured } - end + end.value refute Ractor.shareable?(callable) refute callable.frozen? @@ -955,8 +949,8 @@ def test_check_isolation_does_not_mark_an_invalid_proc_shareable RUBY end - def test_check_isolation_warns_and_executes_captured_define_method - assert_ractor(<<~'RUBY', ignore_stderr: true) + def test_isolation_check_warns_and_executes_captured_define_method + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) captured = [] klass = Class.new klass.define_method(:capture) { captured << :called; captured } @@ -977,7 +971,7 @@ def warn(message, category: nil) end Warning.singleton_class.prepend(CaptureBmethodWarnings) - result = Ractor.check_isolation(klass) { |k| k.new.capture } + result = Ractor.new(klass) { |k| k.new.capture }.value assert_same captured, result assert_equal [:called], captured messages = [] @@ -987,8 +981,8 @@ def warn(message, category: nil) RUBY end - def test_check_isolation_is_active_in_child_threads - assert_ractor(<<~'RUBY', ignore_stderr: true) + def test_isolation_check_is_active_in_child_threads + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) class CheckIsolationChildThreadFixture VALUE = [] end @@ -1009,9 +1003,9 @@ def warn(message, category: nil) end Warning.singleton_class.prepend(CaptureChildThreadWarnings) - value = Ractor.check_isolation do + value = Ractor.new do Thread.new { CheckIsolationChildThreadFixture::VALUE }.value - end + end.value assert_same CheckIsolationChildThreadFixture::VALUE, value messages = [] messages << CHILD_THREAD_WARNINGS.pop until CHILD_THREAD_WARNINGS.empty? @@ -1020,17 +1014,21 @@ def warn(message, category: nil) RUBY end - def test_ractor_new_still_enforces_isolation_after_check_isolation - assert_ractor(<<~'RUBY', ignore_stderr: true) - nested_result = Ractor.check_isolation do + def test_isolation_check_applies_to_nested_and_later_ractors + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) + nested, returned = Ractor.new do captured = Object.new - error = assert_raise(Ractor::IsolationError) do - Ractor.new { captured } - end - error.class - end - assert_equal Ractor::IsolationError, nested_result + [captured, Ractor.new { captured }.value] + end.value + assert_same nested, returned + captured = Object.new + assert_same captured, Ractor.new { captured }.value + RUBY + end + + def test_ractor_new_enforces_isolation_without_isolation_check_env + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => nil}]) captured = Object.new assert_raise(Ractor::IsolationError) do Ractor.new { captured } @@ -1038,9 +1036,9 @@ def test_ractor_new_still_enforces_isolation_after_check_isolation RUBY end - def test_check_isolation_warns_for_finalizers_on_foreign_objects + def test_isolation_check_warns_for_finalizers_on_foreign_objects omit 'per-Ractor objspace semantics of the default GC' unless GC.config[:implementation] == 'default' - assert_ractor(<<~'RUBY', ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) object = Object.new finalizer = proc {} FINALIZER_WARNINGS = Thread::Queue.new @@ -1060,10 +1058,10 @@ def warn(message, category: nil) end Warning.singleton_class.prepend(CaptureFinalizerWarnings) - defined, undefined = Ractor.check_isolation do + defined, undefined = Ractor.new do [ObjectSpace.define_finalizer(object, finalizer), ObjectSpace.undefine_finalizer(object)] - end + end.value assert_same finalizer, defined[1] assert_same object, undefined @@ -1075,67 +1073,46 @@ def warn(message, category: nil) RUBY end - def test_check_isolation_reraises_block_exceptions - assert_ractor(<<~'RUBY', ignore_stderr: true) + def test_isolation_check_reraises_block_exceptions + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) error = assert_raise(Ractor::RemoteError) do - Ractor.check_isolation { raise "boom" } + Ractor.new { raise "boom" }.value end assert_equal "boom", error.cause.message RUBY end - def test_check_isolation_allows_dispatch_to_main - assert_ractor(<<~'RUBY', ignore_stderr: true) + def test_isolation_check_allows_dispatch_to_main + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) main_port = Ractor::Port.new Thread.new do callable, reply = main_port.receive reply << callable.call end - value = Ractor.check_isolation do + value = Ractor.new do reply = Ractor::Port.new main_port << [Ractor.shareable_proc { 40 + 2 }, reply] reply.receive - end + end.value assert_equal 42, value RUBY end - def test_check_isolation_emits_nonexclusive_advisory_once - assert_ractor(<<~'RUBY') - assert_warning(/Ractor.check_isolation: other Ractors can run in parallel/) do - Ractor.check_isolation { :first } - end - assert_no_warning(/other Ractors can run in parallel/) do - Ractor.check_isolation { :second } - end - RUBY - end - - def test_check_isolation_first_called_from_an_ordinary_ractor - assert_ractor(<<~'RUBY') - ADVISORY_WARNINGS = Ractor::Port.new - module CaptureCheckIsolationAdvisory - def warn(message, **kwargs) - if message.include?("Ractor.check_isolation: other Ractors can run in parallel") - ADVISORY_WARNINGS << message - return nil - end - super - end - end - Warning.singleton_class.prepend(CaptureCheckIsolationAdvisory) - - result = Ractor.new { Ractor.check_isolation { :ok } }.value - - assert_equal :ok, result - assert_match(/Ractor.check_isolation: other Ractors can run in parallel/, - ADVISORY_WARNINGS.receive) - RUBY + def test_isolation_check_emits_nonexclusive_advisory_once_at_boot + advisory = /RUBY_RACTOR_CHECK_ISOLATION: other Ractors can run in parallel/ + # The advisory is an uncategorized warning, so -W:no-ractor_isolation must not hide it. + assert_in_out_err([{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}, "-W:no-ractor_isolation", "-e", ""]) do |_stdout, stderr| + assert_equal 1, stderr.grep(advisory).size, "expected the boot advisory exactly once, got: #{stderr.inspect}" + end + assert_in_out_err([{"RUBY_RACTOR_CHECK_ISOLATION" => "1", "RUBY_RACTOR_EXCLUSIVE" => "1"}, "-e", ""]) do |_stdout, stderr| + assert_empty stderr.grep(advisory) + end end - def test_check_isolation_blocks_other_ractors_in_exclusive_mode - assert_separately([{"RUBY_RACTOR_EXCLUSIVE" => "1"}, "-W:no-experimental"], <<~'RUBY', timeout: 30) + def test_isolation_check_blocks_other_ractors_in_exclusive_mode + assert_separately([{"RUBY_RACTOR_EXCLUSIVE" => "1", "RUBY_RACTOR_CHECK_ISOLATION" => "1"}, "-W:no-experimental"], + <<~'RUBY', timeout: 30, ignore_stderr: true) omit "M:N scheduling is not supported by this build" unless RUBY_DESCRIPTION.include?("+MN") Warning[:ractor_isolation] = false @@ -1153,12 +1130,12 @@ def test_check_isolation_blocks_other_ractors_in_exclusive_mode end assert_equal :ready, report.receive - start, finish = Ractor.check_isolation do + start, finish = Ractor.new do t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) x = 0 x += 1 while Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 < 1.0 [t0, Process.clock_gettime(Process::CLOCK_MONOTONIC)] - end + end.value stamps = [] loop do diff --git a/thread.c b/thread.c index f272bbe26a9b99..3b1d1b42b54dc5 100644 --- a/thread.c +++ b/thread.c @@ -154,6 +154,9 @@ MAYBE_UNUSED(static int consume_communication_pipe(int fd)); static rb_atomic_t system_working = 1; static rb_internal_thread_specific_key_t specific_key_count; +// set at boot from RUBY_RACTOR_CHECK_ISOLATION; defined in version.c +extern int ruby_ractor_check_isolation_enabled; + /********************************************************************************/ #define THREAD_SYSTEM_DEPENDENT_IMPLEMENTATION @@ -625,12 +628,12 @@ thread_do_start_proc(rb_thread_t *th) VALUE self = rb_ractor_self(th->ractor); th->thgroup = th->ractor->thgroup_default = rb_obj_alloc(cThGroup); - if (th->ractor->isolation_check) { - /* Isolation-check ractor (see thread_create_core): the block is not - * isolated and args were passed by reference as a real Array, so - * invoke the proc directly without going through the mailbox. Keep - * the proc's own self so closures over the enclosing scope keep - * working, mirroring the old inline Ractor.check_isolation block. */ + if (ruby_ractor_check_isolation_enabled) { + /* RUBY_RACTOR_CHECK_ISOLATION mode (see thread_create_core): the + * block is not isolated and args were passed by reference as a + * real Array, so invoke the proc directly without going through + * the mailbox. Keep the proc's own self so closures over the + * enclosing scope keep working. */ args_len = RARRAY_LENINT(args); if (args_len < 8) { args_ptr = ALLOCA_N(VALUE, args_len); @@ -960,10 +963,11 @@ thread_create_core(VALUE thval, struct thread_create_params *params) th->ec->ractor_id = rb_ractor_id(th->ractor); th->ractor->threads.main = th; th->invoke_arg.proc.kw_splat = rb_keyword_given_p(); - if (th->ractor->isolation_check) { - /* This is a real non-main Ractor, but the Proc and arguments stay - * intact and are passed by reference. Report the Proc-isolation - * errors Ractor.new would raise, then run the original closure. */ + if (ruby_ractor_check_isolation_enabled) { + /* RUBY_RACTOR_CHECK_ISOLATION mode: this is a real non-main + * Ractor, but the Proc and arguments stay intact and are passed + * by reference. Report the Proc-isolation errors Ractor.new would + * otherwise raise, then run the original closure. */ rb_proc_check_isolation_warn(params->proc); th->invoke_arg.proc.proc = params->proc; th->invoke_arg.proc.args = params->args; @@ -1019,7 +1023,7 @@ thread_create_core(VALUE thval, struct thread_create_params *params) EC_PUSH_TAG(ec); if ((state = EC_EXEC_TAG()) == TAG_NONE) { rb_ractor_setup_default_port(params->g); - if (!params->g->isolation_check) { + if (!ruby_ractor_check_isolation_enabled) { rb_ractor_send_parameters(ec, params->g, params->args); } } diff --git a/thread_sched.c b/thread_sched.c index b94683e04605f4..9b15df7c375f02 100644 --- a/thread_sched.c +++ b/thread_sched.c @@ -1794,6 +1794,7 @@ thread_sched_atfork(struct rb_thread_sched *sched) extern int ruby_mn_threads_enabled; extern int ruby_ractor_exclusive_enabled; +extern int ruby_ractor_check_isolation_enabled; static bool ractor_exclusive_env_p(void) @@ -1802,6 +1803,13 @@ ractor_exclusive_env_p(void) return cstr && atoi(cstr) > 0; } +static bool +ractor_check_isolation_env_p(void) +{ + const char *cstr = getenv("RUBY_RACTOR_CHECK_ISOLATION"); + return cstr && atoi(cstr) > 0; +} + void ruby_mn_threads_params(void) { @@ -1837,6 +1845,14 @@ ruby_mn_threads_params(void) } vm->ractor.sched.max_cpu = max_cpu; + + ruby_ractor_check_isolation_enabled = ractor_check_isolation_env_p(); + if (ruby_ractor_check_isolation_enabled && !ruby_ractor_exclusive_enabled) { + rb_warn("RUBY_RACTOR_CHECK_ISOLATION: other Ractors can run in parallel" + " with the isolation-check Ractor. On builds with M:N scheduling," + " RUBY_RACTOR_EXCLUSIVE=1 prevents simultaneous Ruby execution on" + " shared native threads."); + } } static void diff --git a/version.c b/version.c index 233d18282f4a83..78a285f22c764d 100644 --- a/version.c +++ b/version.c @@ -192,9 +192,14 @@ int ruby_mn_threads_enabled; /* Set at boot (see ruby_mn_threads_params) when RUBY_RACTOR_EXCLUSIVE is * truthy: the M:N scheduler runs with a single shared native thread so at - * most one thread executes Ruby VM-wide. Used by Ractor.check_isolation advisory. */ + * most one thread executes Ruby VM-wide. Used by the RUBY_RACTOR_CHECK_ISOLATION + * boot advisory. */ int ruby_ractor_exclusive_enabled; +/* Set at boot (see ruby_mn_threads_params) when RUBY_RACTOR_CHECK_ISOLATION is + * truthy: every non-main Ractor downgrades isolation violations to warnings. */ +int ruby_ractor_check_isolation_enabled; + #ifndef RB_DEFAULT_PARSER #define RB_DEFAULT_PARSER RB_DEFAULT_PARSER_PRISM #endif diff --git a/vm.c b/vm.c index 5ca19ed829fd95..6a55c8353f9429 100644 --- a/vm.c +++ b/vm.c @@ -1650,7 +1650,7 @@ rb_proc_isolate(VALUE self) } /* Report the Proc-isolation checks performed by Ractor.new without mutating - * the Proc, so Ractor.check_isolation can execute the original closure. */ + * the Proc, so RUBY_RACTOR_CHECK_ISOLATION mode can execute the original closure. */ void rb_proc_check_isolation_warn(VALUE self) { diff --git a/vm_insnhelper.c b/vm_insnhelper.c index 78a69c27af4a60..ec01f1e69c700b 100644 --- a/vm_insnhelper.c +++ b/vm_insnhelper.c @@ -1263,7 +1263,7 @@ vm_getivar(VALUE obj, ID id, const rb_iseq_t *iseq, IVC ic, const struct rb_call // Second, other ractors need to check the shareability of the // values returned from the class ivars. // - // Ractor.check_isolation also routes here so the isolation + // RUBY_RACTOR_CHECK_ISOLATION mode also routes here so the isolation // checks in the general path get a chance to fire. if (default_value == Qundef) { // defined? @@ -1433,7 +1433,7 @@ vm_setivar_class(VALUE obj, VALUE val, rb_setivar_cache cache) { if (UNLIKELY(!rb_ractor_main_p())) { // Bail out of the inline cache fast path so the slow path can run - // the isolation check (also fires under Ractor.check_isolation). + // the isolation check (also fires under RUBY_RACTOR_CHECK_ISOLATION). return Qundef; } @@ -3553,7 +3553,7 @@ ractor_unsafe_check(void) if (LIKELY(rb_ractor_main_p())) return; if (rb_ractor_isolation_check_p()) { - // Ractor.check_isolation: downgrade to a :ractor_isolation warning so + // RUBY_RACTOR_CHECK_ISOLATION: downgrade to a :ractor_isolation warning so // the sweep can keep going. We deliberately route through the same // category as the IsolationError downgrades because from the caller's // point of view both mean "this code would not work in a Ractor". @@ -4134,9 +4134,9 @@ vm_bmethod_proc_uncallable_p(rb_execution_context_t *ec, const rb_callable_metho // A method defined with a genuinely non-shareable Proc (e.g. define_method with // a Proc capturing unshareable state) can normally only be called from the // Ractor that defined it; calling it elsewhere raises. Under -// Ractor.check_isolation we downgrade that to a :ractor_isolation warning and +// RUBY_RACTOR_CHECK_ISOLATION we downgrade that to a :ractor_isolation warning and // fall through to invoke it anyway. RUBY_RACTOR_EXCLUSIVE makes this -// race-free; without that scheduler mode the public wrapper emits an advisory. +// race-free; without that scheduler mode a boot advisory is emitted. // Continuing lets a real-Ractor sweep collect the violations that follow // instead of dying on the first bmethod call. static void From 1ee685aa7194eaf7e9a3b23e9accb59dbf80bf43 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Fri, 18 Sep 2026 13:51:48 +0400 Subject: [PATCH 3/7] Do not fork from a non-main Ractor under RUBY_RACTOR_CHECK_ISOLATION Downgrading the isolation violation to a warning left rb_fork_ruby falling through into the fork it had just reported, because rb_raise is NORETURN and rb_ractor_isolation_violation is not. A sweep of an application that calls fork therefore forked, where the same application previously raised Ractor::IsolationError. fork keeps only the calling thread, so the child inherited a VM whose other Ractors' threads were gone while their objspaces were still mapped. Refuse the fork after warning and report it as a failed one: proc_fork_pid turns -1 into rb_sys_fail, rb_daemon returns -1, and the --help pager stops paging. --- process.c | 6 ++++++ test/ruby/test_ractor.rb | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/process.c b/process.c index e747f77b6a8be1..f1fcf123e7eecf 100644 --- a/process.c +++ b/process.c @@ -4110,6 +4110,12 @@ rb_fork_ruby(int *status) { if (UNLIKELY(!rb_ractor_main_p())) { rb_ractor_isolation_violation("can not fork from non-main Ractors"); + + /* Reached only when the violation warned instead of raising. fork keeps + * just the calling thread, so refuse it rather than hand the child a VM + * whose other Ractors are gone; every caller already handles -1. */ + errno = EPERM; + return -1; } struct rb_process_status child = {.status = 0}; diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index f80c9531865e88..3e519647d0886e 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -1036,6 +1036,28 @@ def test_ractor_new_enforces_isolation_without_isolation_check_env RUBY end + def test_isolation_check_warns_but_does_not_fork_from_a_ractor + omit 'fork is not supported' unless Process.respond_to?(:fork) + # Warned like any other violation, but the fork itself must not proceed. + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) + require 'tmpdir' + Dir.mktmpdir do |dir| + marker = File.join(dir, 'child-ran') + result = Ractor.new(marker) do |path| + begin + [:forked, fork { File.write(path, 'ran'); exit!(0) }] + rescue SystemCallError => e + [:refused, e] + end + end.value + + assert_equal :refused, result.first, "fork was not refused: #{result.inspect}" + assert_kind_of SystemCallError, result.last + refute File.exist?(marker), 'fork produced a child under RUBY_RACTOR_CHECK_ISOLATION' + end + RUBY + end + def test_isolation_check_warns_for_finalizers_on_foreign_objects omit 'per-Ractor objspace semantics of the default GC' unless GC.config[:implementation] == 'default' assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) From 0d5add42e0edc780b1b73c2cde98e22494348655 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Fri, 18 Sep 2026 14:07:13 +0400 Subject: [PATCH 4/7] Fix boot-advisory test on builds without M:N threads RUBY_RACTOR_EXCLUSIVE only engages when USE_MN_THREADS is set, so on a non-MN build the advisory correctly still prints and the unconditional zero-advisory assertion would fail. Branch on the +MN marker in RUBY_DESCRIPTION, asserting suppression where exclusive engages and advisory presence where it cannot. --- test/ruby/test_ractor.rb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index 3e519647d0886e..51a7be6cf0a0e8 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -1127,8 +1127,13 @@ def test_isolation_check_emits_nonexclusive_advisory_once_at_boot assert_in_out_err([{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}, "-W:no-ractor_isolation", "-e", ""]) do |_stdout, stderr| assert_equal 1, stderr.grep(advisory).size, "expected the boot advisory exactly once, got: #{stderr.inspect}" end - assert_in_out_err([{"RUBY_RACTOR_CHECK_ISOLATION" => "1", "RUBY_RACTOR_EXCLUSIVE" => "1"}, "-e", ""]) do |_stdout, stderr| - assert_empty stderr.grep(advisory) + assert_in_out_err([{"RUBY_RACTOR_CHECK_ISOLATION" => "1", "RUBY_RACTOR_EXCLUSIVE" => "1"}, "-e", "puts RUBY_DESCRIPTION"]) do |stdout, stderr| + if stdout.first&.include?("+MN") + assert_empty stderr.grep(advisory) + else + # Without M:N support exclusive mode does nothing, so the advisory still prints. + assert_equal 1, stderr.grep(advisory).size, "expected the advisory on a non-MN build, got: #{stderr.inspect}" + end end end From a4cc2372f77a570f8fdc62c7034cf0db1039e81d Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Fri, 18 Sep 2026 14:10:38 +0400 Subject: [PATCH 5/7] Explain check-mode guard in ractor_shareable_proc The fall-through to rb_proc_ractor_make_shareable re-reports the same violation, warned in check mode, so the upstream report is skipped there to avoid warning twice. Flagged as suspicious by two review passes; document it at the site. --- ractor.c | 1 + 1 file changed, 1 insertion(+) diff --git a/ractor.c b/ractor.c index 132a4efdb593d9..d48c1ea1fbe421 100644 --- a/ractor.c +++ b/ractor.c @@ -3803,6 +3803,7 @@ ractor_local_value_store_if_absent(rb_execution_context_t *ec, VALUE self, VALUE static VALUE ractor_shareable_proc(rb_execution_context_t *ec, VALUE replace_self, bool is_lambda) { + // in check mode, rb_proc_ractor_make_shareable below reports this violation if (!rb_ractor_shareable_p(replace_self) && !rb_ractor_isolation_check_p()) { rb_ractor_isolation_violation("self should be shareable: %" PRIsVALUE, replace_self); } From 9b136f3e347ffa614fed7f67d63acc82e940c06a Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Fri, 18 Sep 2026 14:20:30 +0400 Subject: [PATCH 6/7] Print the check-isolation advisory even under -W0 The advisory announces a mode that changes process behavior, so it must not be silenced by verbosity flags. Kernel.warn in the deleted method form printed under -W0; the relocated rb_warn did not. Use fprintf. --- test/ruby/test_ractor.rb | 4 ++-- thread_sched.c | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index 51a7be6cf0a0e8..9c7f7d4f520352 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -1123,8 +1123,8 @@ def test_isolation_check_allows_dispatch_to_main def test_isolation_check_emits_nonexclusive_advisory_once_at_boot advisory = /RUBY_RACTOR_CHECK_ISOLATION: other Ractors can run in parallel/ - # The advisory is an uncategorized warning, so -W:no-ractor_isolation must not hide it. - assert_in_out_err([{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}, "-W:no-ractor_isolation", "-e", ""]) do |_stdout, stderr| + # The mode announcement must survive both -W0 and -W:no-ractor_isolation. + assert_in_out_err([{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}, "-W0", "-W:no-ractor_isolation", "-e", ""]) do |_stdout, stderr| assert_equal 1, stderr.grep(advisory).size, "expected the boot advisory exactly once, got: #{stderr.inspect}" end assert_in_out_err([{"RUBY_RACTOR_CHECK_ISOLATION" => "1", "RUBY_RACTOR_EXCLUSIVE" => "1"}, "-e", "puts RUBY_DESCRIPTION"]) do |stdout, stderr| diff --git a/thread_sched.c b/thread_sched.c index 9b15df7c375f02..ee0cfa4fdb959b 100644 --- a/thread_sched.c +++ b/thread_sched.c @@ -1848,10 +1848,11 @@ ruby_mn_threads_params(void) ruby_ractor_check_isolation_enabled = ractor_check_isolation_env_p(); if (ruby_ractor_check_isolation_enabled && !ruby_ractor_exclusive_enabled) { - rb_warn("RUBY_RACTOR_CHECK_ISOLATION: other Ractors can run in parallel" + // fprintf, not rb_warn: the mode announcement must survive -W0 + fprintf(stderr, "warning: RUBY_RACTOR_CHECK_ISOLATION: other Ractors can run in parallel" " with the isolation-check Ractor. On builds with M:N scheduling," " RUBY_RACTOR_EXCLUSIVE=1 prevents simultaneous Ruby execution on" - " shared native threads."); + " shared native threads.\n"); } } From 346378628df4bef78bb8462528c5eec86276ac62 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Fri, 18 Sep 2026 14:30:41 +0400 Subject: [PATCH 7/7] Deduplicate check-mode isolation warnings by call site Under RUBY_RACTOR_CHECK_ISOLATION a hot violation warns on every hit, burying the sweep output. Key each warning on the violation's format string and Ruby call site, print the first, and count the rest into a one-line summary at process exit. The raising path is unchanged. --- ractor.c | 38 ++++++++++++++++++++++++++++++++++++++ ractor_core.h | 1 + test/ruby/test_ractor.rb | 23 +++++++++++++++++++++++ vm.c | 1 + 4 files changed, 63 insertions(+) diff --git a/ractor.c b/ractor.c index d48c1ea1fbe421..63dda6b1f8b969 100644 --- a/ractor.c +++ b/ractor.c @@ -4058,6 +4058,38 @@ rb_ractor_isolation_violation_str(VALUE message) rb_exc_raise(rb_exc_new_str(rb_eRactorIsolationError, message)); } +// One check-mode warning per (C site, Ruby site); keys are malloc'd, the table is VM-global. +static st_table *isolation_warn_tbl; +static unsigned long isolation_warn_suppressed; + +static bool +isolation_warn_first_p(const char *fmt) +{ + int line = 0; + const char *file = rb_source_location_cstr(&line); + VALUE loc = rb_sprintf("%p:%s:%d", (const void *)fmt, file ? file : "-", line); + char *key = strdup(RSTRING_PTR(loc)); + if (!key) return true; + + bool first; + RB_VM_LOCKING() { + if (!isolation_warn_tbl) isolation_warn_tbl = st_init_strtable(); + first = !st_insert(isolation_warn_tbl, (st_data_t)key, 0); + if (!first) isolation_warn_suppressed++; + } + if (!first) free(key); + return first; +} + +void +rb_ractor_isolation_warning_summary(void) +{ + if (isolation_warn_suppressed) { + fprintf(stderr, "RUBY_RACTOR_CHECK_ISOLATION: %lu repeated isolation warnings suppressed\n", + isolation_warn_suppressed); + } +} + void rb_ractor_isolation_violation(const char *fmt, ...) { @@ -4066,6 +4098,12 @@ rb_ractor_isolation_violation(const char *fmt, ...) VALUE message = rb_vsprintf(fmt, args); va_end(args); + if (rb_ractor_isolation_check_p() + && (NIL_P(ruby_verbose) || !rb_warning_category_enabled_p(RB_WARN_CATEGORY_RACTOR_ISOLATION) + || !isolation_warn_first_p(fmt))) { + return; + } + rb_ractor_isolation_violation_str(message); } diff --git a/ractor_core.h b/ractor_core.h index 6aba729b5822db..15bbb49ff913f2 100644 --- a/ractor_core.h +++ b/ractor_core.h @@ -250,6 +250,7 @@ bool rb_ractor_isolation_check_p(void); * calls). */ PRINTF_ARGS(void rb_ractor_isolation_violation(const char *fmt, ...), 1, 2); void rb_ractor_isolation_violation_str(VALUE message); +void rb_ractor_isolation_warning_summary(void); RUBY_SYMBOL_EXPORT_BEGIN void rb_ractor_finish_marking(bool full_mark); diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index 9c7f7d4f520352..31e0e8918a137f 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -1121,6 +1121,29 @@ def test_isolation_check_allows_dispatch_to_main RUBY end + def test_isolation_check_dedups_repeated_warnings + gvar_warning = /can not access global variable \$g/ + summary = /RUBY_RACTOR_CHECK_ISOLATION: (\d+) repeated isolation warnings suppressed/ + env = {"RUBY_RACTOR_CHECK_ISOLATION" => "1"} + + assert_in_out_err([env, "-e", "$g = 1; Ractor.new { 10_000.times { $g } }.value"]) do |_stdout, stderr| + assert_equal 1, stderr.grep(gvar_warning).size, "expected one warning, got: #{stderr.inspect}" + assert_equal ["9999"], stderr.filter_map {|l| l[summary, 1] } + end + + # each Ruby line warns once + assert_in_out_err([env, "-e", "$g = 1; Ractor.new {\n $g\n $g\n}.value"]) do |_stdout, stderr| + assert_equal 2, stderr.grep(gvar_warning).size, "expected two warnings, got: #{stderr.inspect}" + assert_empty stderr.grep(summary) + end + + # disabling the category suppresses the warnings and the summary + assert_in_out_err([env, "-W:no-ractor_isolation", "-e", "$g = 1; Ractor.new { 10.times { $g } }.value"]) do |_stdout, stderr| + assert_empty stderr.grep(gvar_warning) + assert_empty stderr.grep(summary) + end + end + def test_isolation_check_emits_nonexclusive_advisory_once_at_boot advisory = /RUBY_RACTOR_CHECK_ISOLATION: other Ractors can run in parallel/ # The mode announcement must survive both -W0 and -W:no-ractor_isolation. diff --git a/vm.c b/vm.c index 6a55c8353f9429..8a91ef4e7105a8 100644 --- a/vm.c +++ b/vm.c @@ -3571,6 +3571,7 @@ ruby_vm_destruct(rb_vm_t *vm) RUBY_FREE_ENTER("vm"); ruby_vm_during_cleanup = true; + rb_ractor_isolation_warning_summary(); rb_gc_stash_cleanup_objspace(); if (vm) {