From 1691eab8a415635f6d74ddbfa2cb995867ae62f5 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Tue, 1 Sep 2026 14:47:43 -0400 Subject: [PATCH 01/13] 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 02/13] 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 03/13] 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 04/13] 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 05/13] 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 06/13] 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 07/13] 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) { From 8a6f7a5222b1e6e99746ef1326f06a98b61e97ed Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Mon, 21 Sep 2026 15:12:33 +0400 Subject: [PATCH 08/13] Serialize Ractors under RUBY_RACTOR_CHECK_ISOLATION, drop RUBY_RACTOR_EXCLUSIVE The requirements for the isolation-checking mode have two halves: setting the environment variable turns every Ractor::IsolationError into a warning, and it puts all Ractors on the same GVL so the by-reference sharing that check mode permits is not also a data race. Until now the branch delivered only the first half on its own. The second half required a separate RUBY_RACTOR_EXCLUSIVE=1, and a boot advisory nagged whenever it was absent. The second variable existed because the mode used to be entered at runtime through Ractor.check_isolation. A method call cannot retroactively pin the scheduler once the VM has gone multi-Ractor, so serialization had to be requested up front by other means. With the method gone and the mode read in ruby_mn_threads_params, that constraint no longer holds: boot precedes the first Ractor, so check mode can arrange the scheduler itself. Derive the exclusive flag from ruby_ractor_check_isolation_enabled and leave the existing mechanism (force M:N on, clamp max_cpu to 1) exactly as it was. This removes ractor_exclusive_env_p, the ruby_ractor_exclusive_enabled global and its extern; no reference to RUBY_RACTOR_EXCLUSIVE remains. The variable was never documented or upstreamed and had been carried between unmerged branches, so nothing outside this branch depends on it. The boot advisory changes meaning. It used to key on the absence of the env var, which was wrong in both directions: it fired when the user had already serialized by hand with RUBY_MN_THREADS=1 RUBY_MAX_CPU=1, and it recommended a variable that did nothing on builds without M:N. It now fires only when serialization genuinely could not be arranged, that is on a build compiled without M:N support, and says so. Measured with two CPU-bound non-main Ractors: with the variable set they run back to back with zero overlap; unset they run fully in parallel. The default path is unchanged. Note that max_cpu = 1 serializes M:N-scheduled threads only; Ractors that take dedicated native threads can still overlap, and this does nothing about cross-objspace GC. It reduces the concurrent mutation risk of by-reference sharing rather than eliminating it. Tests: the advisory test now asserts absence on M:N builds and presence otherwise; the serialization test needs only RUBY_RACTOR_CHECK_ISOLATION=1. Two comments that pointed at the removed variable are reworded. --- ractor_sync.c | 5 ++--- test/ruby/test_ractor.rb | 18 ++++++++---------- thread_sched.c | 25 +++++++++---------------- version.c | 6 ------ vm_insnhelper.c | 4 ++-- 5 files changed, 21 insertions(+), 37 deletions(-) diff --git a/ractor_sync.c b/ractor_sync.c index 672163d826a0cc..cb25ecdb0eb812 100644 --- a/ractor_sync.c +++ b/ractor_sync.c @@ -1072,9 +1072,8 @@ ractor_prepare_payload(rb_execution_context_t *ec, VALUE obj, enum ractor_basket // 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) - // guarantees no other Ractor runs concurrently, so passing the - // original object by reference is safe; warn and continue. + // Ractor::Dispatch call. Check mode serializes Ractors, 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 RUBY_RACTOR_CHECK_ISOLATION", diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index 31e0e8918a137f..925d125ef46ce8 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -1144,24 +1144,22 @@ def test_isolation_check_dedups_repeated_warnings end end - def test_isolation_check_emits_nonexclusive_advisory_once_at_boot - advisory = /RUBY_RACTOR_CHECK_ISOLATION: other Ractors can run in parallel/ + def test_isolation_check_serializes_ractors_or_warns_at_boot + advisory = /RUBY_RACTOR_CHECK_ISOLATION: this build has no M:N scheduling/ # 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| + assert_in_out_err([{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}, "-W0", "-W:no-ractor_isolation", + "-e", "puts RUBY_DESCRIPTION"]) do |stdout, stderr| if stdout.first&.include?("+MN") + # Check mode turns on M:N and pins it to one CPU, so nothing to advise. 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 - def test_isolation_check_blocks_other_ractors_in_exclusive_mode - assert_separately([{"RUBY_RACTOR_EXCLUSIVE" => "1", "RUBY_RACTOR_CHECK_ISOLATION" => "1"}, "-W:no-experimental"], + def test_isolation_check_blocks_other_ractors + assert_separately([{"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") @@ -1195,7 +1193,7 @@ def test_isolation_check_blocks_other_ractors_in_exclusive_mode 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" + "expected no other Ractor to run during the isolation check, observed #{during} ticks" RUBY end diff --git a/thread_sched.c b/thread_sched.c index ee0cfa4fdb959b..60e93ce32a58ce 100644 --- a/thread_sched.c +++ b/thread_sched.c @@ -1793,16 +1793,8 @@ thread_sched_atfork(struct rb_thread_sched *sched) #endif 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) -{ - const char *cstr = getenv("RUBY_RACTOR_EXCLUSIVE"); - return cstr && atoi(cstr) > 0; -} - static bool ractor_check_isolation_env_p(void) { @@ -1815,7 +1807,11 @@ 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(); + + // Boot precedes the first Ractor, so isolation checking can serialize + // Ractors itself rather than ask for a second environment variable. + ruby_ractor_check_isolation_enabled = ractor_check_isolation_env_p(); + bool exclusive = USE_MN_THREADS && ruby_ractor_check_isolation_enabled; const char *mn_threads_cstr = getenv("RUBY_MN_THREADS"); bool enable_mn_threads = false; @@ -1841,18 +1837,15 @@ ruby_mn_threads_params(void) * 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; - ruby_ractor_check_isolation_enabled = ractor_check_isolation_env_p(); - if (ruby_ractor_check_isolation_enabled && !ruby_ractor_exclusive_enabled) { + if (ruby_ractor_check_isolation_enabled && !exclusive) { // 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.\n"); + fprintf(stderr, "warning: RUBY_RACTOR_CHECK_ISOLATION: this build has no M:N" + " scheduling, so other Ractors can run in parallel with the" + " isolation-check Ractor.\n"); } } diff --git a/version.c b/version.c index 78a285f22c764d..d8041cc180a119 100644 --- a/version.c +++ b/version.c @@ -190,12 +190,6 @@ 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 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; diff --git a/vm_insnhelper.c b/vm_insnhelper.c index ec01f1e69c700b..e2d532c88d6c01 100644 --- a/vm_insnhelper.c +++ b/vm_insnhelper.c @@ -4135,8 +4135,8 @@ vm_bmethod_proc_uncallable_p(rb_execution_context_t *ec, const rb_callable_metho // a Proc capturing unshareable state) can normally only be called from the // Ractor that defined it; calling it elsewhere raises. Under // 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 a boot advisory is emitted. +// fall through to invoke it anyway. Check mode serializes Ractors, which makes +// this race-free; on builds without M:N 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 3b0fea31d905e596431dd254370a79a571f43d87 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Mon, 21 Sep 2026 15:25:44 +0400 Subject: [PATCH 09/13] Trim check-isolation comments to what the code cannot say The branch carried about three times the comment density of the code around it. Most of the excess fell into three patterns: restating the mode's rationale at every call site, noting that check-mode Ractors "also route here" through guards that already cover every non-main Ractor, and explaining why unchanged code was left unchanged. Two externs were the only commented externs in their files, and the mode description was repeated in ractor.c, ractor_core.h and version.c. Keep one description at the definition site, cut each remaining block to the single fact the code does not show, and drop the rest. No behavioral change. --- process.c | 4 +--- ractor.c | 20 +++----------------- ractor_core.h | 12 +----------- ractor_sync.c | 7 ++----- thread.c | 12 ++---------- thread_sched.c | 3 +-- vm.c | 3 +-- vm_insnhelper.c | 24 +++--------------------- 8 files changed, 14 insertions(+), 71 deletions(-) diff --git a/process.c b/process.c index f1fcf123e7eecf..e1308117349b91 100644 --- a/process.c +++ b/process.c @@ -4111,9 +4111,7 @@ 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. */ + // only reached in check mode; fork would drop every other Ractor's thread errno = EPERM; return -1; } diff --git a/ractor.c b/ractor.c index 63dda6b1f8b969..0e626d5fd8a06f 100644 --- a/ractor.c +++ b/ractor.c @@ -1950,9 +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 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. + // in check mode this only warned; the caller's "shareable" invariant is knowingly broken } return obj; } @@ -4020,21 +4018,9 @@ rb_ractor_autoload_load(VALUE module, ID name) } } -// ============================================================================= -// RUBY_RACTOR_CHECK_ISOLATION (environment variable, read once at boot) -// -// 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. -// -// 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. -// ============================================================================= +// RUBY_RACTOR_CHECK_ISOLATION: non-main Ractors warn on isolation violations +// instead of raising, so a sweep reports more than the first one. -/* Set at boot from the environment; see thread_sched.c and version.c. */ extern int ruby_ractor_check_isolation_enabled; bool diff --git a/ractor_core.h b/ractor_core.h index 15bbb49ff913f2..b979765be0a961 100644 --- a/ractor_core.h +++ b/ractor_core.h @@ -235,19 +235,9 @@ 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 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 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 - * when the message is already constructed (e.g. via several rb_str_catf - * calls). */ +/* Warns and returns in check mode, raises Ractor::IsolationError otherwise. */ 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); diff --git a/ractor_sync.c b/ractor_sync.c index cb25ecdb0eb812..eefb51cd3b7ce8 100644 --- a/ractor_sync.c +++ b/ractor_sync.c @@ -1069,11 +1069,8 @@ ractor_prepare_payload(rb_execution_context_t *ec, VALUE obj, enum ractor_basket return obj; } else if (rb_ractor_isolation_check_p()) { - // 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. Check mode serializes Ractors, so passing - // the original object by reference is safe; warn and continue. + // copying can fail outright (e.g. Procs), which would abort the sweep; + // pass by reference instead rb_category_warn(RB_WARN_CATEGORY_RACTOR_ISOLATION, "can not copy an unshareable %"PRIsVALUE" across Ractors; " "passing by reference under RUBY_RACTOR_CHECK_ISOLATION", diff --git a/thread.c b/thread.c index 3b1d1b42b54dc5..65bed611ac7444 100644 --- a/thread.c +++ b/thread.c @@ -154,7 +154,6 @@ 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; /********************************************************************************/ @@ -629,11 +628,7 @@ thread_do_start_proc(rb_thread_t *th) th->thgroup = th->ractor->thgroup_default = rb_obj_alloc(cThGroup); 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. */ + // check mode: args is a real Array, not a mailbox count (see thread_create_core) args_len = RARRAY_LENINT(args); if (args_len < 8) { args_ptr = ALLOCA_N(VALUE, args_len); @@ -964,10 +959,7 @@ thread_create_core(VALUE thval, struct thread_create_params *params) th->ractor->threads.main = th; th->invoke_arg.proc.kw_splat = rb_keyword_given_p(); 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. */ + // check mode: keep the Proc and args by reference, warn instead of isolating rb_proc_check_isolation_warn(params->proc); th->invoke_arg.proc.proc = params->proc; th->invoke_arg.proc.args = params->args; diff --git a/thread_sched.c b/thread_sched.c index 60e93ce32a58ce..9c05b308413ba5 100644 --- a/thread_sched.c +++ b/thread_sched.c @@ -1808,8 +1808,7 @@ ruby_mn_threads_params(void) rb_vm_t *vm = GET_VM(); rb_ractor_t *main_ractor = GET_RACTOR(); - // Boot precedes the first Ractor, so isolation checking can serialize - // Ractors itself rather than ask for a second environment variable. + // boot precedes the first Ractor, so check mode can pin the scheduler itself ruby_ractor_check_isolation_enabled = ractor_check_isolation_env_p(); bool exclusive = USE_MN_THREADS && ruby_ractor_check_isolation_enabled; diff --git a/vm.c b/vm.c index 8a91ef4e7105a8..56ed4d491baa33 100644 --- a/vm.c +++ b/vm.c @@ -1649,8 +1649,7 @@ rb_proc_isolate(VALUE self) return dst; } -/* Report the Proc-isolation checks performed by Ractor.new without mutating - * the Proc, so RUBY_RACTOR_CHECK_ISOLATION mode can execute the original closure. */ +/* The checks of rb_proc_isolate_bang, reported as warnings, without mutating the Proc. */ void rb_proc_check_isolation_warn(VALUE self) { diff --git a/vm_insnhelper.c b/vm_insnhelper.c index e2d532c88d6c01..1500073c1f7b1b 100644 --- a/vm_insnhelper.c +++ b/vm_insnhelper.c @@ -1262,9 +1262,6 @@ 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. - // - // 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? return rb_ivar_defined(obj, id) ? Qtrue : Qundef; @@ -1432,8 +1429,7 @@ 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 RUBY_RACTOR_CHECK_ISOLATION). + // leave the inline cache so the slow path runs the isolation check return Qundef; } @@ -3553,17 +3549,12 @@ ractor_unsafe_check(void) if (LIKELY(rb_ractor_main_p())) return; if (rb_ractor_isolation_check_p()) { - // 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". + // same category as IsolationError: to the caller both mean "not Ractor-safe" 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"); } @@ -4122,8 +4113,6 @@ 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) { @@ -4131,14 +4120,7 @@ vm_bmethod_proc_uncallable_p(rb_execution_context_t *ec, const rb_callable_metho 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 -// RUBY_RACTOR_CHECK_ISOLATION we downgrade that to a :ractor_isolation warning and -// fall through to invoke it anyway. Check mode serializes Ractors, which makes -// this race-free; on builds without M:N a boot advisory is emitted. -// Continuing lets a real-Ractor sweep collect the violations that follow -// instead of dying on the first bmethod call. +// in check mode the bmethod is invoked anyway after the warning static void vm_bmethod_unshareable_proc_violation(rb_execution_context_t *ec, const rb_callable_method_entry_t *cme) { From 0e9d95dd0c2f37abffc2a3ebbcf321ffd11c71de Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Mon, 21 Sep 2026 16:20:33 +0400 Subject: [PATCH 10/13] Skip the debug belonging assertion under RUBY_RACTOR_CHECK_ISOLATION rb_ractor_confirm_belonging (RACTOR_CHECK_MODE, i.e. RUBY_DEBUG or VM_CHECK_MODE builds) aborts when an unshareable object of another Ractor's objspace is pushed on the VM stack. Check mode does exactly that on purpose: Procs are not isolated and arguments and return values travel by reference. Five test_ractor tests failed on every Ubuntu CI job for that reason while passing on the non-debug macOS and Windows jobs. --- ractor_core.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ractor_core.h b/ractor_core.h index 3610078f6bba56..f3408630b14b29 100644 --- a/ractor_core.h +++ b/ractor_core.h @@ -371,6 +371,7 @@ rb_ractor_targeted_hooks_cnt(rb_ractor_t *cr) #if RACTOR_CHECK_MODE > 0 extern bool rb_ractor_ignore_belonging_flag; +extern int ruby_ractor_check_isolation_enabled; /* An object's owning Ractor is decided by the objspace its page belongs to * (rb_gc_obj_foreign_p). Putting an unshareable object on the VM stack of anyone @@ -379,6 +380,8 @@ static inline VALUE rb_ractor_confirm_belonging(VALUE obj) { if (rb_ractor_ignore_belonging_flag) return obj; + // check mode passes unshareable objects by reference on purpose + if (ruby_ractor_check_isolation_enabled) return obj; if (SPECIAL_CONST_P(obj) || RB_OBJ_SHAREABLE_P(obj)) return obj; if (UNLIKELY(rb_gc_obj_foreign_p(obj))) { From 22e095fdeec17f2e5506417bb3b29f89ff9b6768 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Mon, 21 Sep 2026 18:35:30 +0400 Subject: [PATCH 11/13] Add RUBY_RACTOR_CHECK_ISOLATION=2 to report every violation Level 1 keeps the per-site dedup and exit summary for reading stderr by eye. Level 2 emits every hit so an external collector can count them and record where each was first seen. --- ractor.c | 4 ++-- test/ruby/test_ractor.rb | 6 ++++++ thread_sched.c | 9 +++++---- version.c | 5 +++-- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/ractor.c b/ractor.c index 2ebf30cb85a778..1314e893193718 100644 --- a/ractor.c +++ b/ractor.c @@ -4270,7 +4270,7 @@ 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. +// Level 1 warns once 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; @@ -4312,7 +4312,7 @@ rb_ractor_isolation_violation(const char *fmt, ...) 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))) { + || (ruby_ractor_check_isolation_enabled < 2 && !isolation_warn_first_p(fmt)))) { return; } diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index 4f30b241c74b83..274622ee12a544 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -1420,6 +1420,12 @@ def test_isolation_check_dedups_repeated_warnings assert_empty stderr.grep(summary) end + # level 2 reports every hit + assert_in_out_err([{"RUBY_RACTOR_CHECK_ISOLATION" => "2"}, "-e", "$g = 1; Ractor.new { 100.times { $g } }.value"]) do |_stdout, stderr| + assert_equal 100, stderr.grep(gvar_warning).size, "expected 100 warnings, got: #{stderr.size} lines" + 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) diff --git a/thread_sched.c b/thread_sched.c index f50ed2cd177c59..ff992124999f6e 100644 --- a/thread_sched.c +++ b/thread_sched.c @@ -1815,11 +1815,12 @@ thread_sched_atfork(struct rb_thread_sched *sched) extern int ruby_mn_threads_enabled; extern int ruby_ractor_check_isolation_enabled; -static bool -ractor_check_isolation_env_p(void) +static int +ractor_check_isolation_env_level(void) { const char *cstr = getenv("RUBY_RACTOR_CHECK_ISOLATION"); - return cstr && atoi(cstr) > 0; + int level = cstr ? atoi(cstr) : 0; + return level > 0 ? level : 0; } void @@ -1829,7 +1830,7 @@ ruby_mn_threads_params(void) rb_ractor_t *main_ractor = GET_RACTOR(); // boot precedes the first Ractor, so check mode can pin the scheduler itself - ruby_ractor_check_isolation_enabled = ractor_check_isolation_env_p(); + ruby_ractor_check_isolation_enabled = ractor_check_isolation_env_level(); bool exclusive = USE_MN_THREADS && ruby_ractor_check_isolation_enabled; // RUBY_MN_THREADS: -1 = nothing is M:N, 0 = the default, 1 = the main diff --git a/version.c b/version.c index d8041cc180a119..bccb550f50430b 100644 --- a/version.c +++ b/version.c @@ -190,8 +190,9 @@ Init_version(void) int ruby_mn_threads_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. */ +/* Set at boot (see ruby_mn_threads_params) to the RUBY_RACTOR_CHECK_ISOLATION + * level: every non-main Ractor downgrades isolation violations to warnings, + * deduplicated per site at 1 and reported on every hit at 2. */ int ruby_ractor_check_isolation_enabled; #ifndef RB_DEFAULT_PARSER From 79a2e965653e7885f3683e2b12d71a278dbafca7 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Mon, 21 Sep 2026 18:35:31 +0400 Subject: [PATCH 12/13] Run every GC as a global cycle under RUBY_RACTOR_CHECK_ISOLATION Check mode passes arguments, block values and messages between Ractors by reference, so a Ractor's heap can be reachable only from another Ractor's roots. A local cycle cannot see those edges and frees live objects. Treat the process as one logical heap: promote every collection to a global cycle and skip the dying Ractor's local retire GC, leaving its pages for the next global sweep. --- gc.c | 9 +++++++++ gc/default/default.c | 7 +++++++ gc/gc.h | 1 + test/ruby/test_ractor.rb | 22 ++++++++++++++++++++++ 4 files changed, 39 insertions(+) diff --git a/gc.c b/gc.c index adc0740491100c..21ea2f69b06836 100644 --- a/gc.c +++ b/gc.c @@ -4206,6 +4206,15 @@ rb_gc_vm_ractor_count(void) return GET_VM()->ractor.cnt; } +extern int ruby_ractor_check_isolation_enabled; + +/* Check mode passes objects between Ractors by reference; only a global cycle sees those edges. */ +bool +rb_gc_vm_global_gc_only_p(void) +{ + return ruby_ractor_check_isolation_enabled != 0; +} + /* Called by a global cycle from inside the barrier. */ void rb_gc_vm_refresh_zombie_pages(void) diff --git a/gc/default/default.c b/gc/default/default.c index 8ecd74297457af..d61b47b836b0fc 100644 --- a/gc/default/default.c +++ b/gc/default/default.c @@ -8541,6 +8541,12 @@ rb_gc_impl_objspace_retire_gc(void *objspace_ptr) { rb_objspace_t *objspace = objspace_ptr; + /* Other Ractors may hold this heap's objects by reference; the next global cycle sweeps it. */ + if (rb_gc_vm_global_gc_only_p()) { + gc_rest(objspace); + return; + } + /* The dying thread's stack is already torn down here, so the root scan must skip * its machine context (rb_gc_mark_roots). */ objspace->flags.during_postmortem = 1; @@ -8696,6 +8702,7 @@ static bool gc_need_global_p(rb_objspace_t *objspace) { if (rb_gc_single_objspace_p()) return false; + if (rb_gc_vm_global_gc_only_p()) return true; if (objspace->shareable_objects > objspace->shareable_objects_limit) return true; /* A zombie's garbage only a global cycle reclaims, but what survived the last one * is live data, so retrigger only once TRIGGER more pages accumulate on top of it. diff --git a/gc/gc.h b/gc/gc.h index 3360744bb02ab4..396e7234d196f0 100644 --- a/gc/gc.h +++ b/gc/gc.h @@ -76,6 +76,7 @@ MODULAR_GC_FN void rb_gc_vm_barrier(void); MODULAR_GC_FN void rb_gc_vm_each_objspace(void (*func)(void *objspace, void *data), void *data); MODULAR_GC_FN size_t rb_gc_vm_zombie_total_pages(void); MODULAR_GC_FN unsigned int rb_gc_vm_ractor_count(void); +MODULAR_GC_FN bool rb_gc_vm_global_gc_only_p(void); MODULAR_GC_FN void rb_gc_vm_refresh_zombie_pages(void); /* No MODULAR_GC_FN: the VM side (ractor.c) calls this too, so it needs external * linkage even in a non-modular build (see internal/gc.h). */ diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index 274622ee12a544..de6d303b66a803 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -1097,6 +1097,28 @@ def test_isolation_check_returns_the_block_value_by_reference RUBY end + # check mode runs every GC globally: objects held by reference across Ractors must survive + def test_isolation_check_keeps_child_objects_reachable_from_main + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) + acc = [] + Ractor.new(acc) { |a| 2000.times { |i| a << "s#{i}" * 4 }; GC.start; 2000.times { |i| a << "t#{i}" }; nil }.value + expected = 2000.times.sum { |i| ("s#{i}" * 4).size } + 2000.times.sum { |i| "t#{i}".size } + assert_equal 4000, acc.size + assert_equal expected, acc.sum(&:size) + RUBY + end + + def test_isolation_check_keeps_messages_alive_past_sender_exit + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) + port = Ractor::Port.new + Ractor.new(port) { |p| 500.times { |i| p << ["m#{i}", i] }; nil }.value + GC.start + sum = 0 + 500.times { m, i = port.receive; sum += m.size + i } + assert_equal 500.times.sum { |i| "m#{i}".size + i }, sum + RUBY + end + 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] From 4a251b6ccb7fbc6bdd8adf778459f5466af8076a Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Mon, 21 Sep 2026 20:19:32 +0400 Subject: [PATCH 13/13] Deduplicate and locate isolation warnings raised inside internal frames The outer-variable Proc warning and the by-reference copy warning called rb_category_warn directly, so level 1 printed them on every hit and reported as the site. Route every warning through one emitter that keys the dedup on the message and the nearest non-internal Ruby frame, and reports that frame. --- ractor.c | 68 +++++++++++++++++++++++++++------------- ractor_core.h | 2 ++ ractor_sync.c | 7 ++--- test/ruby/test_ractor.rb | 10 ++++++ vm.c | 2 +- 5 files changed, 63 insertions(+), 26 deletions(-) diff --git a/ractor.c b/ractor.c index 1314e893193718..8d0add6140e043 100644 --- a/ractor.c +++ b/ractor.c @@ -4259,27 +4259,35 @@ rb_ractor_isolation_check_p(void) return r && r != rb_ec_vm_ptr(ec)->ractor.main_ractor; } -void -rb_ractor_isolation_violation_str(VALUE message) +// Level 1 warns once per (message, Ruby site); keys are malloc'd, the table is VM-global. +static st_table *isolation_warn_tbl; +static unsigned long isolation_warn_suppressed; + +// nearest Ruby frame outside , so Ractor.new and Port#<< report the app site +static const char * +isolation_source_location(int *line) { - if (rb_ractor_isolation_check_p()) { - rb_category_warn(RB_WARN_CATEGORY_RACTOR_ISOLATION, "%s", StringValueCStr(message)); - return; - } + const rb_execution_context_t *ec = GET_EC(); + const rb_control_frame_t *cfp = ec->cfp; - rb_exc_raise(rb_exc_new_str(rb_eRactorIsolationError, message)); + while (!RUBY_VM_CONTROL_FRAME_STACK_OVERFLOW_P(ec, cfp)) { + if (VM_FRAME_RUBYFRAME_P(cfp) && CFP_ISEQ(cfp)) { + VALUE path = rb_iseq_path(CFP_ISEQ(cfp)); + if (strncmp(" dedup too and name the caller's line + src = "x = [1]\nport = Ractor::Port.new\n3.times { Ractor.new(port) { |pt| 5.times { pt << [x] } }.value }" + assert_in_out_err([env, "-W:no-experimental", "-e", src]) do |_stdout, stderr| + assert_equal ["-e:3: warning: can not isolate a Proc because it accesses outer variables (x)."], + stderr.grep(/isolate a Proc/) + assert_equal 1, stderr.grep(/^-e:3: warning: can not copy an unshareable Array/).size, stderr.inspect + assert_empty stderr.grep(/