From 1691eab8a415635f6d74ddbfa2cb995867ae62f5 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Tue, 1 Sep 2026 14:47:43 -0400 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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(/ Date: Tue, 22 Sep 2026 10:04:13 +0400 Subject: [PATCH 14/21] Rename RUBY_RACTOR_CHECK_ISOLATION to RUBY_RACTOR_ISOLATION --- error.c | 2 +- gc.c | 4 +-- include/ruby/internal/error.h | 2 +- ractor.c | 10 +++---- ractor_core.h | 4 +-- ractor_sync.c | 2 +- test/ruby/test_ractor.rb | 50 +++++++++++++++++------------------ thread.c | 8 +++--- thread_sched.c | 14 +++++----- version.c | 4 +-- 10 files changed, 50 insertions(+), 50 deletions(-) diff --git a/error.c b/error.c index 1fed34c57b06ad..0d502de94841c1 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 under RUBY_RACTOR_CHECK_ISOLATION + * Ractor isolation violations reported under RUBY_RACTOR_ISOLATION * (downgraded from Ractor::IsolationError exceptions to warnings). */ diff --git a/gc.c b/gc.c index 21ea2f69b06836..7ca12158f8ad19 100644 --- a/gc.c +++ b/gc.c @@ -4206,13 +4206,13 @@ rb_gc_vm_ractor_count(void) return GET_VM()->ractor.cnt; } -extern int ruby_ractor_check_isolation_enabled; +extern int ruby_ractor_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; + return ruby_ractor_isolation_enabled != 0; } /* Called by a global cycle from inside the barrier. */ diff --git a/include/ruby/internal/error.h b/include/ruby/internal/error.h index 3eec4e7599f4c4..2acbadc5ee93d9 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 under RUBY_RACTOR_CHECK_ISOLATION. */ + /** Warning is for Ractor isolation violations reported under RUBY_RACTOR_ISOLATION. */ RB_WARN_CATEGORY_RACTOR_ISOLATION, RB_WARN_CATEGORY_DEFAULT_BITS = ( diff --git a/ractor.c b/ractor.c index 8d0add6140e043..4e44d487b4f56c 100644 --- a/ractor.c +++ b/ractor.c @@ -4244,15 +4244,15 @@ rb_ractor_autoload_load(VALUE module, ID name) } } -// RUBY_RACTOR_CHECK_ISOLATION: non-main Ractors warn on isolation violations +// RUBY_RACTOR_ISOLATION: non-main Ractors warn on isolation violations // instead of raising, so a sweep reports more than the first one. -extern int ruby_ractor_check_isolation_enabled; +extern int ruby_ractor_isolation_enabled; bool rb_ractor_isolation_check_p(void) { - if (!ruby_ractor_check_isolation_enabled) return false; + if (!ruby_ractor_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); @@ -4309,7 +4309,7 @@ rb_ractor_isolation_warn(VALUE message) int line; const char *file = isolation_source_location(&line); const char *cstr = StringValueCStr(message); - if (ruby_ractor_check_isolation_enabled < 2 && !isolation_warn_first_p(cstr, file, line)) return; + if (ruby_ractor_isolation_enabled < 2 && !isolation_warn_first_p(cstr, file, line)) return; rb_category_compile_warn(RB_WARN_CATEGORY_RACTOR_ISOLATION, file, line, "%s", cstr); } @@ -4318,7 +4318,7 @@ void rb_ractor_isolation_warning_summary(void) { if (isolation_warn_suppressed) { - fprintf(stderr, "RUBY_RACTOR_CHECK_ISOLATION: %lu repeated isolation warnings suppressed\n", + fprintf(stderr, "RUBY_RACTOR_ISOLATION: %lu repeated isolation warnings suppressed\n", isolation_warn_suppressed); } } diff --git a/ractor_core.h b/ractor_core.h index a5c3f8718e24a6..49b095656ff29f 100644 --- a/ractor_core.h +++ b/ractor_core.h @@ -373,7 +373,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; +extern int ruby_ractor_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 @@ -383,7 +383,7 @@ 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 (ruby_ractor_isolation_enabled) return obj; if (SPECIAL_CONST_P(obj) || RB_OBJ_SHAREABLE_P(obj)) return obj; if (UNLIKELY(rb_gc_obj_foreign_p(obj))) { diff --git a/ractor_sync.c b/ractor_sync.c index a2860216962553..e0c25b3f911b69 100644 --- a/ractor_sync.c +++ b/ractor_sync.c @@ -1115,7 +1115,7 @@ ractor_prepare_payload(rb_execution_context_t *ec, VALUE obj, enum ractor_basket // copying can fail outright (e.g. Procs), which would abort the sweep; // pass by reference instead rb_ractor_isolation_warn(rb_sprintf("can not copy an unshareable %"PRIsVALUE" across Ractors; " - "passing by reference under RUBY_RACTOR_CHECK_ISOLATION", + "passing by reference under RUBY_RACTOR_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 0e3f9dd01a62c8..2b74f5bce5c19a 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -1082,7 +1082,7 @@ def test_io_is_not_shareable end def test_isolation_check_runs_in_a_non_main_ractor - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) result = Ractor.new(name: "isolation check") do [Ractor.main?, Ractor.current == Ractor.main, Ractor.current.name] end.value @@ -1091,7 +1091,7 @@ def test_isolation_check_runs_in_a_non_main_ractor end def test_isolation_check_returns_the_block_value_by_reference - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) obj = Object.new assert_same obj, Ractor.new { obj }.value RUBY @@ -1099,7 +1099,7 @@ def test_isolation_check_returns_the_block_value_by_reference # 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) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_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 } @@ -1109,7 +1109,7 @@ def test_isolation_check_keeps_child_objects_reachable_from_main end def test_isolation_check_keeps_messages_alive_past_sender_exit - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) port = Ractor::Port.new Ractor.new(port) { |p| 500.times { |i| p << ["m#{i}", i] }; nil }.value GC.start @@ -1120,7 +1120,7 @@ def test_isolation_check_keeps_messages_alive_past_sender_exit end def test_isolation_check_passes_args_and_closes_over_outer_variables - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) outer = [1, 2, 3] arg = Object.new returned_arg, returned_outer = Ractor.new(arg) do |a| @@ -1132,7 +1132,7 @@ def test_isolation_check_passes_args_and_closes_over_outer_variables end 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) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) marker = Object.new args = Array.new(200_000, marker) length, first, last = Ractor.new(*args) do |*values| @@ -1145,7 +1145,7 @@ def test_isolation_check_handles_large_argument_lists_without_using_the_native_s end def test_isolation_check_make_shareable_warns_and_continues_for_files - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) file = File.open(IO::NULL) begin result = Ractor.new(file) do |f| @@ -1163,7 +1163,7 @@ def test_isolation_check_make_shareable_warns_and_continues_for_files 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', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) class CheckIsolationFixture @ivar = "ivar" @@cvar = [1, 2, 3] @@ -1216,7 +1216,7 @@ def warn(message, category: nil) end def test_isolation_check_warns_on_outer_variable_capture - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) captured = [] OUTER_VARIABLE_WARNINGS = Thread::Queue.new module CaptureOuterVariableWarnings @@ -1241,7 +1241,7 @@ def warn(message, category: nil) end def test_isolation_check_does_not_mark_an_invalid_proc_shareable - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) captured = [] callable = Ractor.new do Ractor.shareable_proc { captured << :called; captured } @@ -1255,7 +1255,7 @@ def test_isolation_check_does_not_mark_an_invalid_proc_shareable end def test_isolation_check_warns_and_executes_captured_define_method - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) captured = [] klass = Class.new klass.define_method(:capture) { captured << :called; captured } @@ -1287,7 +1287,7 @@ def warn(message, category: nil) end def test_isolation_check_is_active_in_child_threads - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) class CheckIsolationChildThreadFixture VALUE = [] end @@ -1320,7 +1320,7 @@ def warn(message, category: nil) end def test_isolation_check_applies_to_nested_and_later_ractors - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) nested, returned = Ractor.new do captured = Object.new [captured, Ractor.new { captured }.value] @@ -1333,7 +1333,7 @@ def test_isolation_check_applies_to_nested_and_later_ractors end def test_ractor_new_enforces_isolation_without_isolation_check_env - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => nil}]) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => nil}]) captured = Object.new assert_raise(Ractor::IsolationError) do Ractor.new { captured } @@ -1344,7 +1344,7 @@ def test_ractor_new_enforces_isolation_without_isolation_check_env 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) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) require 'tmpdir' Dir.mktmpdir do |dir| marker = File.join(dir, 'child-ran') @@ -1358,14 +1358,14 @@ def test_isolation_check_warns_but_does_not_fork_from_a_ractor 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' + refute File.exist?(marker), 'fork produced a child under RUBY_RACTOR_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) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) object = Object.new finalizer = proc {} FINALIZER_WARNINGS = Thread::Queue.new @@ -1401,7 +1401,7 @@ def warn(message, category: nil) end def test_isolation_check_reraises_block_exceptions - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) error = assert_raise(Ractor::RemoteError) do Ractor.new { raise "boom" }.value end @@ -1410,7 +1410,7 @@ def test_isolation_check_reraises_block_exceptions end def test_isolation_check_allows_dispatch_to_main - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}], ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) main_port = Ractor::Port.new Thread.new do callable, reply = main_port.receive @@ -1428,8 +1428,8 @@ def test_isolation_check_allows_dispatch_to_main 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"} + summary = /RUBY_RACTOR_ISOLATION: (\d+) repeated isolation warnings suppressed/ + env = {"RUBY_RACTOR_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}" @@ -1443,7 +1443,7 @@ def test_isolation_check_dedups_repeated_warnings 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_in_out_err([{"RUBY_RACTOR_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 @@ -1466,9 +1466,9 @@ def test_isolation_check_dedups_repeated_warnings end def test_isolation_check_serializes_ractors_or_warns_at_boot - advisory = /RUBY_RACTOR_CHECK_ISOLATION: this build has no M:N scheduling/ + advisory = /RUBY_RACTOR_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", + assert_in_out_err([{"RUBY_RACTOR_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. @@ -1480,7 +1480,7 @@ def test_isolation_check_serializes_ractors_or_warns_at_boot end def test_isolation_check_blocks_other_ractors - assert_separately([{"RUBY_RACTOR_CHECK_ISOLATION" => "1"}, "-W:no-experimental"], + assert_separately([{"RUBY_RACTOR_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") diff --git a/thread.c b/thread.c index e7964b1ada77f7..eabf1bab9f5352 100644 --- a/thread.c +++ b/thread.c @@ -156,7 +156,7 @@ 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; -extern int ruby_ractor_check_isolation_enabled; +extern int ruby_ractor_isolation_enabled; /********************************************************************************/ @@ -634,7 +634,7 @@ 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 (ruby_ractor_check_isolation_enabled) { + if (ruby_ractor_isolation_enabled) { // check mode: args is a real Array, not a mailbox count (see thread_create_core) args_len = RARRAY_LENINT(args); if (args_len < 8) { @@ -965,7 +965,7 @@ 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 (ruby_ractor_check_isolation_enabled) { + if (ruby_ractor_isolation_enabled) { // 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; @@ -1022,7 +1022,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 (!ruby_ractor_check_isolation_enabled) { + if (!ruby_ractor_isolation_enabled) { rb_ractor_send_parameters(ec, params->g, params->args); } } diff --git a/thread_sched.c b/thread_sched.c index ff992124999f6e..1ed28180277694 100644 --- a/thread_sched.c +++ b/thread_sched.c @@ -1813,12 +1813,12 @@ thread_sched_atfork(struct rb_thread_sched *sched) #endif extern int ruby_mn_threads_enabled; -extern int ruby_ractor_check_isolation_enabled; +extern int ruby_ractor_isolation_enabled; static int -ractor_check_isolation_env_level(void) +ractor_isolation_env_level(void) { - const char *cstr = getenv("RUBY_RACTOR_CHECK_ISOLATION"); + const char *cstr = getenv("RUBY_RACTOR_ISOLATION"); int level = cstr ? atoi(cstr) : 0; return level > 0 ? level : 0; } @@ -1830,8 +1830,8 @@ 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_level(); - bool exclusive = USE_MN_THREADS && ruby_ractor_check_isolation_enabled; + ruby_ractor_isolation_enabled = ractor_isolation_env_level(); + bool exclusive = USE_MN_THREADS && ruby_ractor_isolation_enabled; // RUBY_MN_THREADS: -1 = nothing is M:N, 0 = the default, 1 = the main // Ractor's threads too, 2 = the main thread as well (see @@ -1875,9 +1875,9 @@ ruby_mn_threads_params(void) } #endif - if (ruby_ractor_check_isolation_enabled && !exclusive) { + if (ruby_ractor_isolation_enabled && !exclusive) { // fprintf, not rb_warn: the mode announcement must survive -W0 - fprintf(stderr, "warning: RUBY_RACTOR_CHECK_ISOLATION: this build has no M:N" + fprintf(stderr, "warning: RUBY_RACTOR_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 bccb550f50430b..d95ecd94925416 100644 --- a/version.c +++ b/version.c @@ -190,10 +190,10 @@ Init_version(void) int ruby_mn_threads_enabled; -/* Set at boot (see ruby_mn_threads_params) to the RUBY_RACTOR_CHECK_ISOLATION +/* Set at boot (see ruby_mn_threads_params) to the RUBY_RACTOR_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; +int ruby_ractor_isolation_enabled; #ifndef RB_DEFAULT_PARSER #define RB_DEFAULT_PARSER RB_DEFAULT_PARSER_PRISM From fd52ef5b090feabad82e7f41560a670ac6369ef0 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Tue, 22 Sep 2026 10:21:23 +0400 Subject: [PATCH 15/21] Reduce isolation warning allocations and reuse Ractor helpers Skip formatting disabled isolation warnings and allocate deduplication keys only for new message/source combinations. Keep the source and message strings rooted while consulting the warning table. Reuse the ordinary thread argument path for isolation-check Ractors and share warning capture setup across the isolation tests. Add coverage for deduplication across messages and source files. Validated with the Ractor, Thread, and Ruby option suites: 254 tests, 3712 assertions, no failures or errors, and four skips. Also checked deduplication across forced GC compaction. --- ractor.c | 94 +++++++++++++++---- test/ruby/ractor_isolation_helper.rb | 27 ++++++ test/ruby/test_ractor.rb | 130 +++++++-------------------- thread.c | 68 +++++--------- 4 files changed, 160 insertions(+), 159 deletions(-) create mode 100644 test/ruby/ractor_isolation_helper.rb diff --git a/ractor.c b/ractor.c index 4e44d487b4f56c..8b2e605a8a03cb 100644 --- a/ractor.c +++ b/ractor.c @@ -4263,8 +4263,44 @@ rb_ractor_isolation_check_p(void) static st_table *isolation_warn_tbl; static unsigned long isolation_warn_suppressed; +struct isolation_warn_key { + const char *file; + const char *message; + int line; +}; + +static int +isolation_warn_key_cmp(st_data_t a, st_data_t b) +{ + const struct isolation_warn_key *key1 = (const void *)a; + const struct isolation_warn_key *key2 = (const void *)b; + return key1->line != key2->line || + strcmp(key1->file, key2->file) || strcmp(key1->message, key2->message); +} + +static st_index_t +isolation_warn_key_hash(st_data_t data) +{ + const struct isolation_warn_key *key = (const void *)data; + st_index_t hash = st_hash_start(key->line); + hash = st_hash(key->file, strlen(key->file), hash); + hash = st_hash(key->message, strlen(key->message), hash); + return st_hash_end(hash); +} + +static const struct st_hash_type isolation_warn_hash_type = { + isolation_warn_key_cmp, + isolation_warn_key_hash, +}; + +static bool +isolation_warnings_enabled_p(void) +{ + return !NIL_P(ruby_verbose) && rb_warning_category_enabled_p(RB_WARN_CATEGORY_RACTOR_ISOLATION); +} + // nearest Ruby frame outside , so Ractor.new and Port#<< report the app site -static const char * +static VALUE isolation_source_location(int *line) { const rb_execution_context_t *ec = GET_EC(); @@ -4275,43 +4311,63 @@ isolation_source_location(int *line) VALUE path = rb_iseq_path(CFP_ISEQ(cfp)); if (strncmp(" "1"}], ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") 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) @@ -1202,9 +1184,7 @@ def warn(message, category: nil) end.value assert_equal :completed, result - messages = [] - messages << ISOLATION_WARNINGS.pop until ISOLATION_WARNINGS.empty? - combined = messages.join("\n") + combined = RactorIsolationWarnings.drain.join("\n") assert_match(/instance variables of classes\/modules created by another Ractor/, combined) assert_match(/non-shareable class variable @@cvar/, combined) assert_match(/non-shareable objects in constant CheckIsolationFixture::MUTABLE/, combined) @@ -1216,27 +1196,14 @@ def warn(message, category: nil) end def test_isolation_check_warns_on_outer_variable_capture - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") 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.new { captured << :ran; captured }.value 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")) + RactorIsolationWarnings.drain.join("\n")) RUBY end @@ -1255,67 +1222,31 @@ def test_isolation_check_does_not_mark_an_invalid_proc_shareable end def test_isolation_check_warns_and_executes_captured_define_method - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") 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.new(klass) { |k| k.new.capture }.value 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")) + RactorIsolationWarnings.drain.join("\n")) RUBY end def test_isolation_check_is_active_in_child_threads - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") 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.new do Thread.new { CheckIsolationChildThreadFixture::VALUE }.value end.value 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")) + RactorIsolationWarnings.drain.join("\n")) RUBY end @@ -1365,25 +1296,9 @@ def test_isolation_check_warns_but_does_not_fork_from_a_ractor 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_ISOLATION" => "1"}], ignore_stderr: true) + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") 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.new do [ObjectSpace.define_finalizer(object, finalizer), @@ -1392,9 +1307,7 @@ def warn(message, category: nil) assert_same finalizer, defined[1] assert_same object, undefined - messages = [] - messages << FINALIZER_WARNINGS.pop until FINALIZER_WARNINGS.empty? - combined = messages.join("\n") + combined = RactorIsolationWarnings.drain.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 @@ -1465,6 +1378,29 @@ def test_isolation_check_dedups_repeated_warnings end end + def test_isolation_check_dedups_by_message_and_source + src = <<~'RUBY' + $g = 1 + $h = 2 + Ractor.new do + 2.times do + ["first.rb", "second.rb"].each do |path| + eval('$g; $h', binding, path, 7) + end + GC.start + end + end.value + RUBY + assert_in_out_err([{"RUBY_RACTOR_ISOLATION" => "1"}, "-W:no-experimental", "-e", src]) do |_stdout, stderr| + warnings = stderr.grep(/can not access global variable/) + expected = ["first.rb", "second.rb"].product(["$g", "$h"]).map do |path, name| + "#{path}:7: warning: can not access global variable #{name} from non-main Ractor" + end + assert_equal expected, warnings + assert_include stderr, "RUBY_RACTOR_ISOLATION: 4 repeated isolation warnings suppressed" + end + end + def test_isolation_check_serializes_ractors_or_warns_at_boot advisory = /RUBY_RACTOR_ISOLATION: this build has no M:N scheduling/ # The mode announcement must survive both -W0 and -W:no-ractor_isolation. diff --git a/thread.c b/thread.c index eabf1bab9f5352..0ded7c0e0f5061 100644 --- a/thread.c +++ b/thread.c @@ -631,66 +631,46 @@ thread_do_start_proc(rb_thread_t *th) vm_check_ints_blocking(th->ec); if (th->invoke_type == thread_invoke_type_ractor_proc) { - VALUE self = rb_ractor_self(th->ractor); th->thgroup = th->ractor->thgroup_default = rb_obj_alloc(cThGroup); - if (ruby_ractor_isolation_enabled) { - // 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); - MEMCPY((VALUE *)args_ptr, RARRAY_CONST_PTR(args), VALUE, args_len); - th->invoke_arg.proc.args = Qnil; - } - else { - args_ptr = RARRAY_CONST_PTR(args); - } + if (!ruby_ractor_isolation_enabled) { + VALUE self = rb_ractor_self(th->ractor); + args_len = FIX2INT(args); + args_ptr = ALLOCA_N(VALUE, args_len); + rb_ractor_receive_parameters(th->ec, th->ractor, args_len, (VALUE *)args_ptr); vm_check_ints_blocking(th->ec); - return rb_vm_invoke_proc( - th->ec, proc, + return rb_vm_invoke_proc_with_self( + th->ec, proc, self, args_len, args_ptr, th->invoke_arg.proc.kw_splat, VM_BLOCK_HANDLER_NONE, cref ); } + } - args_len = FIX2INT(args); + // Threads and check-mode Ractors both keep their arguments in an Array. + args_len = RARRAY_LENINT(args); + if (args_len < 8) { + /* free proc.args if the length is enough small */ args_ptr = ALLOCA_N(VALUE, args_len); - rb_ractor_receive_parameters(th->ec, th->ractor, args_len, (VALUE *)args_ptr); - vm_check_ints_blocking(th->ec); - - return rb_vm_invoke_proc_with_self( - th->ec, proc, self, - args_len, args_ptr, - th->invoke_arg.proc.kw_splat, - VM_BLOCK_HANDLER_NONE, - cref - ); + MEMCPY((VALUE *)args_ptr, RARRAY_CONST_PTR(args), VALUE, args_len); + th->invoke_arg.proc.args = Qnil; } else { - args_len = RARRAY_LENINT(args); - if (args_len < 8) { - /* free proc.args if the length is enough small */ - 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); - } + args_ptr = RARRAY_CONST_PTR(args); + } - vm_check_ints_blocking(th->ec); + 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 - ); - } + return rb_vm_invoke_proc( + th->ec, proc, + args_len, args_ptr, + th->invoke_arg.proc.kw_splat, + VM_BLOCK_HANDLER_NONE, + cref + ); } static VALUE From 8a7477d3edd4018ea3b65be55f28ad4d728cc7b2 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Tue, 22 Sep 2026 10:42:37 +0400 Subject: [PATCH 16/21] Prevent recursive isolation warnings and unify reporting --- cont.c | 2 + ractor.c | 47 ++++++++++----- test/ruby/ractor_isolation_helper.rb | 9 +-- test/ruby/test_ractor.rb | 86 ++++++++++++++++++++++++++++ vm_core.h | 1 + vm_insnhelper.c | 9 ++- 6 files changed, 127 insertions(+), 27 deletions(-) diff --git a/cont.c b/cont.c index c4d57d92ba1a0d..fd00f2c11c9d5e 100644 --- a/cont.c +++ b/cont.c @@ -2172,6 +2172,8 @@ fiber_t_alloc(VALUE fiber_value, unsigned int blocking) fiber->blocking = blocking; fiber->killed = 0; cont_init(&fiber->cont, th); + // A warning hook in the creator must not silence this new fiber. + fiber->cont.saved_ec.ractor_isolation_warning = false; fiber->cont.saved_ec.fiber_ptr = fiber; fiber->cont.saved_ec.serial = next_ec_serial(th->ractor); diff --git a/ractor.c b/ractor.c index 8b2e605a8a03cb..f7d43b30da6b01 100644 --- a/ractor.c +++ b/ractor.c @@ -1873,9 +1873,8 @@ 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)); + rb_ractor_isolation_violation("can not make shareable object of class %+"PRIsVALUE, + rb_class_of(obj)); return traverse_stop; } rb_raise(rb_eRactorError, @@ -1888,9 +1887,8 @@ make_shareable_check_shareable(VALUE 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)); + rb_ractor_isolation_violation("can not make shareable object of class %+"PRIsVALUE + " because it refers unshareable objects", rb_class_of(obj)); return traverse_stop; } else { @@ -1903,9 +1901,8 @@ make_shareable_check_shareable(VALUE obj) 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)); + rb_ractor_isolation_violation("can not make shareable object of class %+"PRIsVALUE, + rb_class_of(obj)); return traverse_stop; } else { @@ -4296,7 +4293,8 @@ static const struct st_hash_type isolation_warn_hash_type = { static bool isolation_warnings_enabled_p(void) { - return !NIL_P(ruby_verbose) && rb_warning_category_enabled_p(RB_WARN_CATEGORY_RACTOR_ISOLATION); + return !GET_EC()->ractor_isolation_warning && !NIL_P(ruby_verbose) && + rb_warning_category_enabled_p(RB_WARN_CATEGORY_RACTOR_ISOLATION); } // nearest Ruby frame outside , so Ractor.new and Port#<< report the app site @@ -4354,20 +4352,39 @@ isolation_warn_first_p(VALUE message, VALUE path, int line) return first; } -void -rb_ractor_isolation_warn(VALUE message) +static VALUE +isolation_warn_emit(VALUE message) { - if (!isolation_warnings_enabled_p()) return; - int line; VALUE path = isolation_source_location(&line); StringValueCStr(message); - if (ruby_ractor_isolation_enabled < 2 && !isolation_warn_first_p(message, path, line)) return; + if (ruby_ractor_isolation_enabled < 2 && !isolation_warn_first_p(message, path, line)) return Qnil; const char *file = NIL_P(path) ? NULL : RSTRING_PTR(path); rb_category_compile_warn(RB_WARN_CATEGORY_RACTOR_ISOLATION, file, line, "%s", RSTRING_PTR(message)); RB_GC_GUARD(message); RB_GC_GUARD(path); + return Qnil; +} + +static VALUE +isolation_warn_end(VALUE ec_ptr) +{ + ((rb_execution_context_t *)ec_ptr)->ractor_isolation_warning = false; + return Qnil; +} + +void +rb_ractor_isolation_warn(VALUE message) +{ + if (!isolation_warnings_enabled_p()) return; + + // A user-defined warning hook can itself violate isolation, including + // before entering its body when defined with define_singleton_method. + // Keep the guard fiber-local and restore it even if the hook raises. + rb_execution_context_t *ec = GET_EC(); + ec->ractor_isolation_warning = true; + rb_ensure(isolation_warn_emit, message, isolation_warn_end, (VALUE)ec); } void diff --git a/test/ruby/ractor_isolation_helper.rb b/test/ruby/ractor_isolation_helper.rb index 61da5c47a8e1aa..8a90b53183e153 100644 --- a/test/ruby/ractor_isolation_helper.rb +++ b/test/ruby/ractor_isolation_helper.rb @@ -5,13 +5,8 @@ module RactorIsolationWarnings QUEUE = Thread::Queue.new def warn(message, category: nil) - if category == :ractor_isolation && !Thread.current[:capturing_isolation_warning] - Thread.current[:capturing_isolation_warning] = true - begin - QUEUE << Ractor.make_shareable(message) - ensure - Thread.current[:capturing_isolation_warning] = false - end + if category == :ractor_isolation + QUEUE << Ractor.make_shareable(message) return nil end super diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index 88459e51b9c5eb..2fe56713e22c31 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -1207,6 +1207,92 @@ def test_isolation_check_warns_on_outer_variable_capture RUBY end + def test_isolation_check_handles_block_defined_warning_hooks + [1, 2].each do |level| + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => level.to_s}], ignore_stderr: true) + messages = [] + $warning_hook_global = 1 + $first_warning_global = 2 + $second_warning_global = 3 + Warning.define_singleton_method(:warn) do |message, category: nil| + next unless category == :ractor_isolation + $warning_hook_global + messages << message + raise "warning hook failed" if message.include?("$first_warning_global") + end + + result = Ractor.new do + begin + $first_warning_global + rescue RuntimeError => error + raise unless error.message == "warning hook failed" + end + $second_warning_global + :done + end.value + + assert_equal :done, result + assert_equal 2, messages.size + assert_match(/global variable \$first_warning_global/, messages[0]) + assert_match(/global variable \$second_warning_global/, messages[1]) + RUBY + end + end + + def test_isolation_check_deduplicates_all_warning_paths + source = <<~'RUBY' + require "etc" + klass = Class.new + klass.define_method(:call) { :done } + Ractor.new(klass) do |type| + file = File.open(IO::NULL) + 3.times { Ractor.make_shareable(file) } + file.close + 3.times { Etc.getlogin } + 3.times { type.new.call } + end.value + RUBY + + [1, 2].each do |level| + env = {"RUBY_RACTOR_ISOLATION" => level.to_s} + assert_in_out_err([env, "-W:no-experimental", "-e", source], success: true) do |_stdout, stderr| + expected = level == 1 ? 1 : 3 + assert_equal expected, stderr.grep(/^-e:6: warning: can not make shareable object/).size + assert_equal expected, stderr.grep(/^-e:8: warning: ractor unsafe method/).size + assert_equal expected, stderr.grep(/^-e:9: warning: can not call method call/).size + assert_empty stderr.grep(/ "2"}], ignore_stderr: true) + messages = [] + $suspended_warning_global = 1 + $other_fiber_global = 2 + $parent_fiber_global = 3 + Warning.define_singleton_method(:warn) do |message, category: nil| + next unless category == :ractor_isolation + messages << message + if message.include?("$suspended_warning_global") + Fiber.yield Fiber.new { $other_fiber_global } + end + end + + result = Ractor.new do + suspended = Fiber.new { $suspended_warning_global } + other = suspended.resume + [other.resume, $parent_fiber_global, suspended.resume] + end.value + + assert_equal [2, 3, 1], result + assert_equal 3, messages.size + assert_match(/global variable \$suspended_warning_global/, messages[0]) + assert_match(/global variable \$other_fiber_global/, messages[1]) + assert_match(/global variable \$parent_fiber_global/, messages[2]) + RUBY + end + def test_isolation_check_does_not_mark_an_invalid_proc_shareable assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) captured = [] diff --git a/vm_core.h b/vm_core.h index 510d2080fe8b05..2374f20ea4fe1b 100644 --- a/vm_core.h +++ b/vm_core.h @@ -1124,6 +1124,7 @@ struct rb_execution_context_struct { VALUE passed_block_handler; /* for rb_iterate */ uint8_t raised_flag; /* only 3 bits needed */ + bool ractor_isolation_warning; /* suppress violations inside Warning.warn */ /* n.b. only 7 bits needed, really: */ BITFIELD(enum method_missing_reason, method_missing_reason, 8); diff --git a/vm_insnhelper.c b/vm_insnhelper.c index 898ec401402c2c..9c992544c12f9c 100644 --- a/vm_insnhelper.c +++ b/vm_insnhelper.c @@ -3555,8 +3555,7 @@ ractor_unsafe_check(void) if (rb_ractor_isolation_check_p()) { // 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"); + rb_ractor_isolation_violation("ractor unsafe method called from not main ractor"); return; } @@ -4130,9 +4129,9 @@ 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)); + rb_ractor_isolation_violation( + "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"); From 4b54196538d68c801350c44699f62105b3c74f0a Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Tue, 22 Sep 2026 11:01:36 +0400 Subject: [PATCH 17/21] Fix isolation diagnostics for classes, Procs and constants Report class copy, attached-object and Proc instance-variable violations in diagnostic mode. Preserve requested Proc receivers without inheriting shareability, and prevent constant caches from bypassing isolation warnings. Add regression coverage for diagnostic and normal isolation behavior, including constant reads under YJIT and ZJIT. --- class.c | 15 ++- ractor.c | 2 +- test/ruby/test_ractor.rb | 230 +++++++++++++++++++++++++++++++++++++++ vm.c | 62 ++++++++--- vm_core.h | 1 + vm_insnhelper.c | 7 ++ 6 files changed, 293 insertions(+), 24 deletions(-) diff --git a/class.c b/class.c index fd0b1ab416c6d2..bc8c924014c8c4 100644 --- a/class.c +++ b/class.c @@ -1004,9 +1004,9 @@ init_copy_check_const_i(ID id, VALUE v, void *data) { const rb_const_entry_t *ce = (const rb_const_entry_t *)v; if (!UNDEF_P(ce->value) && !rb_ractor_shareable_p(ce->value)) { - rb_raise(rb_eRactorIsolationError, - "can not copy a class/module created by another Ractor because " - "constant %"PRIsVALUE" refers to an unshareable object", rb_id2str(id)); + rb_ractor_isolation_violation( + "can not copy a class/module created by another Ractor because " + "constant %"PRIsVALUE" refers to an unshareable object", rb_id2str(id)); } return ID_TABLE_CONTINUE; } @@ -1015,9 +1015,9 @@ static int init_copy_check_field_i(ID id, VALUE val, st_data_t arg) { if ((rb_is_instance_id(id) || rb_is_class_id(id)) && !rb_ractor_shareable_p(val)) { - rb_raise(rb_eRactorIsolationError, - "can not copy a class/module created by another Ractor because " - "variable %"PRIsVALUE" refers to an unshareable object", rb_id2str(id)); + rb_ractor_isolation_violation( + "can not copy a class/module created by another Ractor because " + "variable %"PRIsVALUE" refers to an unshareable object", rb_id2str(id)); } return ST_CONTINUE; } @@ -2442,8 +2442,7 @@ rb_class_attached_object(VALUE klass) if (rb_objspace_foreign_object_p(obj) && !RB_OBJ_SHAREABLE_P(obj)) { /* No klass in the message: naming a singleton class inspects the very object we * must not touch from here. */ - rb_raise(rb_eRactorIsolationError, - "can not get an unshareable attached object from another Ractor"); + rb_ractor_isolation_violation("can not get an unshareable attached object from another Ractor"); } return obj; diff --git a/ractor.c b/ractor.c index f7d43b30da6b01..fafc4ea03ef5ac 100644 --- a/ractor.c +++ b/ractor.c @@ -4029,7 +4029,7 @@ ractor_shareable_proc(rb_execution_context_t *ec, VALUE replace_self, bool is_la 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); + return rb_proc_ractor_make_shareable_copy(proc, replace_self); } // Ractor#require diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index 2fe56713e22c31..281ea9958aa0d7 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -1207,6 +1207,100 @@ def test_isolation_check_warns_on_outer_variable_capture RUBY end + def test_isolation_check_warns_and_copies_classes_and_modules + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") + originals = [Class.new, Module.new] + originals.each do |type| + type.const_set(:VALUE, []) + type.instance_variable_set(:@value, []) + type.class_variable_set(:@@value, []) + end + + results = Ractor.new(*originals) do |*types| + types.flat_map do |type| + [:dup, :clone].map do |operation| + copy = type.public_send(operation) + [!copy.equal?(type), + copy.const_get(:VALUE).equal?(type.const_get(:VALUE)), + copy.instance_variable_get(:@value).equal?(type.instance_variable_get(:@value)), + copy.class_variable_get(:@@value).equal?(type.class_variable_get(:@@value))] + end + end + end.value + + assert_equal [[true, true, true, true]] * 4, results + warnings = RactorIsolationWarnings.drain.join("\n") + assert_match(/can not copy a class\/module.*constant VALUE refers to an unshareable object/, warnings) + assert_match(/can not copy a class\/module.*variable @value refers to an unshareable object/, warnings) + assert_match(/can not copy a class\/module.*variable @@value refers to an unshareable object/, warnings) + RUBY + end + + def test_isolation_check_warns_and_returns_attached_objects + omit 'objspace per Ractor is how an object\'s owner is known' unless GC.config[:implementation] == 'default' + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") + object = Object.new + result = Ractor.new(object.singleton_class) { |type| type.attached_object }.value + assert_same object, result + assert_match(/can not get an unshareable attached object from another Ractor/, + RactorIsolationWarnings.drain.join("\n")) + RUBY + end + + def test_class_copy_enforces_isolation_without_isolation_check_env + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => nil}]) + [Class, Module].each do |factory| + [:constant, :ivar, :cvar].each do |storage| + type = factory.new + case storage + when :constant then type.const_set(:VALUE, []) + when :ivar then type.instance_variable_set(:@value, []) + when :cvar then type.class_variable_set(:@@value, []) + end + results = Ractor.new(type) do |original| + [:dup, :clone].map do |operation| + begin + original.public_send(operation) + :copied + rescue Ractor::IsolationError + :isolated + end + end + end.value + assert_equal [:isolated, :isolated], results + end + end + RUBY + end + + def test_isolation_check_warns_on_proc_instance_variables + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "2"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") + [nil, 42, []].each do |state| + callable = proc { :done } + callable.instance_variable_set(:@state, state) + assert_equal :done, Ractor.new(&callable).value + assert_same state, callable.instance_variable_get(:@state) + refute Ractor.shareable?(callable) + refute callable.frozen? + end + + warnings = RactorIsolationWarnings.drain.grep(/can not isolate a Proc because it has instance variables/) + assert_equal 3, warnings.size + RUBY + end + + def test_proc_instance_variables_enforce_isolation_without_isolation_check_env + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => nil}]) + [nil, 42, []].each do |state| + callable = proc { :done } + callable.instance_variable_set(:@state, state) + assert_raise_with_message(Ractor::IsolationError, /has instance variables/) do + Ractor.new(&callable) + end + end + RUBY + end + def test_isolation_check_handles_block_defined_warning_hooks [1, 2].each do |level| assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => level.to_s}], ignore_stderr: true) @@ -1307,6 +1401,88 @@ def test_isolation_check_does_not_mark_an_invalid_proc_shareable RUBY end + def test_isolation_check_preserves_invalid_proc_receivers + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") + results = Ractor.new do + [nil, :replacement, Object.new].flat_map do |receiver| + [:shareable_proc, :shareable_lambda].map do |kind| + captured = [] + callable = Ractor.public_send(kind, self: receiver) do |value| + captured << value + [self, captured] + end + [kind, receiver, captured, callable] + end + end + end.value + + results.each do |kind, receiver, captured, callable| + actual_self, actual_capture = callable.call(:called) + assert_same receiver, actual_self + assert_same captured, actual_capture + assert_equal [:called], captured + assert_equal kind == :shareable_lambda, callable.lambda? + refute Ractor.shareable?(callable) + refute callable.frozen? + end + assert_match(/cannot make a shareable Proc.*unshareable object of class Array/, + RactorIsolationWarnings.drain.join("\n")) + RUBY + end + + def test_shareable_proc_receivers_without_isolation_check_env + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => nil}]) + [:shareable_proc, :shareable_lambda].each do |kind| + original = Ractor.public_send(kind) { self } + [nil, :replacement].each do |receiver| + callables = [Ractor.public_send(kind, self: receiver) { self }, + Ractor.public_send(kind, self: receiver, &original)] + callables.each do |callable| + assert_same receiver, callable.call + assert Ractor.shareable?(callable) + assert callable.frozen? + assert_equal kind == :shareable_lambda, callable.lambda? + end + end + assert_nil original.call + + captured = [] + assert_raise(Ractor::IsolationError) do + Ractor.public_send(kind, self: :replacement) { captured } + end + assert_raise(Ractor::IsolationError) do + Ractor.public_send(kind, self: Object.new) { self } + end + end + RUBY + end + + def test_isolation_check_does_not_keep_shareability_when_rebinding_proc + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") + results = Ractor.new do + [:shareable_proc, :shareable_lambda].map do |kind| + original = Ractor.public_send(kind) { self } + receiver = Object.new + copy = Ractor.public_send(kind, self: receiver, &original) + [kind, original, receiver, copy] + end + end.value + + results.each do |kind, original, receiver, copy| + assert_same receiver, copy.call + assert_equal kind == :shareable_lambda, copy.lambda? + refute Ractor.shareable?(copy) + refute copy.frozen? + copy.instance_variable_set(:@state, :mutable) + assert_equal :mutable, copy.instance_variable_get(:@state) + assert Ractor.shareable?(original) + assert original.frozen? + assert_nil original.call + end + assert_match(/Proc's self is not shareable/, RactorIsolationWarnings.drain.join("\n")) + RUBY + end + def test_isolation_check_warns_and_executes_captured_define_method assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") captured = [] @@ -1487,6 +1663,60 @@ def test_isolation_check_dedups_by_message_and_source end end + def test_isolation_check_warns_on_repeated_constant_reads + source = <<~'RUBY' + module IsolationConstantFixture + VALUE = [] + def self.read + VALUE + end + end + # Populate the same cache in the main Ractor before using it in a child. + 10.times { IsolationConstantFixture.read } + Ractor.new { 10.times { IsolationConstantFixture.read } }.value + RUBY + assert_isolation_constant_warnings(source) + end + + def test_isolation_check_warns_after_reenabling_constant_warnings + source = <<~'RUBY' + module IsolationConstantFixture + VALUE = [] + def self.read + VALUE + end + end + Ractor.new do + Warning[:ractor_isolation] = false + 10.times { IsolationConstantFixture.read } + Warning[:ractor_isolation] = true + 10.times { IsolationConstantFixture.read } + end.value + RUBY + assert_isolation_constant_warnings(source) + end + + def assert_isolation_constant_warnings(source) + require_relative '../lib/jit_support' + options = [[]] + options << %w[--yjit --yjit-call-threshold=1] if JITSupport.yjit_supported? + options << %w[--zjit --zjit-call-threshold=1] if JITSupport.zjit_supported? + + options.each do |jit_options| + [1, 2].each do |level| + env = {"RUBY_RACTOR_ISOLATION" => level.to_s} + args = [env, *jit_options, "-W:no-experimental", "-e", source] + assert_in_out_err(args, success: true) do |_stdout, stderr| + warnings = stderr.grep(/non-shareable objects in constant IsolationConstantFixture::VALUE/) + assert_equal level == 1 ? 1 : 10, warnings.size, "#{jit_options.inspect}, level #{level}" + if level == 1 + assert_include stderr, "RUBY_RACTOR_ISOLATION: 9 repeated isolation warnings suppressed" + end + end + end + end + end + def test_isolation_check_serializes_ractors_or_warns_at_boot advisory = /RUBY_RACTOR_ISOLATION: this build has no M:N scheduling/ # The mode announcement must survive both -W0 and -W:no-ractor_isolation. diff --git a/vm.c b/vm.c index 02c7e852114e51..66f19ce3356917 100644 --- a/vm.c +++ b/vm.c @@ -1366,8 +1366,8 @@ proc_create(VALUE klass, const struct rb_block *block, int8_t is_from_method, in return procval; } -VALUE -rb_proc_dup_0(VALUE self) +static VALUE +proc_dup_0(VALUE self, bool preserve_shareability) { VALUE procval; rb_proc_t *src; @@ -1389,20 +1389,32 @@ rb_proc_dup_0(VALUE self) dst->header.is_refined = 1; } - if (RB_OBJ_SHAREABLE_P(self)) RB_OBJ_SET_SHAREABLE(procval); + if (preserve_shareability && RB_OBJ_SHAREABLE_P(self)) RB_OBJ_SET_SHAREABLE(procval); RB_GC_GUARD(self); /* for: body = rb_proc_dup(body) */ return procval; } VALUE -rb_proc_dup(VALUE self) +rb_proc_dup_0(VALUE self) { - VALUE procval = rb_proc_dup_0(self); + return proc_dup_0(self, true); +} + +static VALUE +proc_dup(VALUE self, bool preserve_shareability) +{ + VALUE procval = proc_dup_0(self, preserve_shareability); VALUE recipe = rb_proc_refinements_recipe(self); if (!NIL_P(recipe)) rb_proc_set_refinements_recipe(procval, recipe); return procval; } +VALUE +rb_proc_dup(VALUE self) +{ + return proc_dup(self, true); +} + /* Proc#refined: build a Proc that runs `iseq` with the refinements of * `recipe`, sharing self's environment. `iseq` is normally self's own block * iseq, which the copy replaces on the first call. */ @@ -1602,6 +1614,21 @@ proc_has_ivar_i(ID name, VALUE val, st_data_t arg) return ST_CONTINUE; } +static void +proc_check_isolation_ivars(VALUE self, bool warn) +{ + /* ivars are not traversed here, so their values may be unshareable */ + if (UNLIKELY(rb_obj_shape_has_ivars(self))) { + bool has_ivar = false; + rb_ivar_foreach(self, proc_has_ivar_i, (st_data_t)&has_ivar); + + if (has_ivar) { + proc_isolation_violation_str( + rb_str_new_cstr("can not isolate a Proc because it has instance variables"), warn); + } + } +} + static VALUE proc_shared_outer_variables(struct rb_id_table *outer_variables, bool isolate, const char *message, bool warn, bool *valid) { @@ -1662,15 +1689,7 @@ rb_proc_isolate_bang(VALUE self, VALUE replace_self) RB_OBJ_WRITE(self, &proc->block.as.captured.self, Qnil); } - /* ivars are not traversed here, so their values may be unshareable */ - if (UNLIKELY(rb_obj_shape_has_ivars(self))) { - bool has_ivar = false; - rb_ivar_foreach(self, proc_has_ivar_i, (st_data_t)&has_ivar); - - if (has_ivar) { - rb_raise(rb_eRactorIsolationError, "can not isolate a Proc because it has instance variables"); - } - } + proc_check_isolation_ivars(self, false); RB_OBJ_SET_SHAREABLE(self); return self; @@ -1696,6 +1715,7 @@ rb_proc_check_isolation_warn(VALUE self) proc_shared_outer_variables(ISEQ_BODY(iseq)->outer_variables, true, "isolate a Proc", true, NULL); } } + proc_check_isolation_ivars(self, true); } VALUE @@ -1725,10 +1745,13 @@ rb_proc_ractor_make_shareable(VALUE self, VALUE replace_self) "make a Proc shareable", warn, &valid); } - if (!proc_isolate_env(self, proc, read_only_variables, warn, valid)) return self; + valid = proc_isolate_env(self, proc, read_only_variables, warn, valid); if (!UNDEF_P(replace_self)) { RB_OBJ_WRITE(self, &proc->block.as.captured.self, replace_self); } + // Diagnostic fallback keeps the original closure and requested receiver, + // but must not mark an invalid Proc isolated or shareable. + if (!valid) return self; proc->header.is_isolated = TRUE; } else { @@ -1747,6 +1770,15 @@ rb_proc_ractor_make_shareable(VALUE self, VALUE replace_self) return self; } +VALUE +rb_proc_ractor_make_shareable_copy(VALUE self, VALUE replace_self) +{ + // Rebinding self can make a shareable Proc unshareable in diagnostic mode. + // The copy must only become shareable after its new receiver is validated. + VALUE copy = proc_dup(self, false); + return rb_proc_ractor_make_shareable(copy, replace_self); +} + VALUE rb_vm_make_proc_lambda(const rb_execution_context_t *ec, const struct rb_captured_block *captured, VALUE klass, int8_t is_lambda) { diff --git a/vm_core.h b/vm_core.h index 2374f20ea4fe1b..e2a12ec981a657 100644 --- a/vm_core.h +++ b/vm_core.h @@ -1402,6 +1402,7 @@ 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); +VALUE rb_proc_ractor_make_shareable_copy(VALUE proc, VALUE replace_self); RUBY_SYMBOL_EXPORT_END typedef struct { diff --git a/vm_insnhelper.c b/vm_insnhelper.c index 9c992544c12f9c..f132aa1f0f5996 100644 --- a/vm_insnhelper.c +++ b/vm_insnhelper.c @@ -6770,6 +6770,13 @@ vm_ic_update(const rb_iseq_t *iseq, IC ic, VALUE val, const VALUE *reg_ep, const return; } + // A diagnostic Ractor can read foreign unshareable constants. Caching them + // would bypass later warnings, including after the category is re-enabled. + if (rb_ractor_isolation_check_p() && !rb_ractor_shareable_p(val)) { + ic->entry = NULL; + return; + } + struct iseq_inline_constant_cache_entry *ice = SHAREABLE_IMEMO_NEW(struct iseq_inline_constant_cache_entry, imemo_constcache, 0); RB_OBJ_WRITE(ice, &ice->value, val); ice->ic_cref = vm_get_const_key_cref(reg_ep); From adf5510878b096e894ccfd4cc29d650c8fcb74b7 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Tue, 22 Sep 2026 11:13:26 +0400 Subject: [PATCH 18/21] Simplify RUBY_RACTOR_ISOLATION code for readability Make strict and warning-only Proc checks explicit, simplify warning control flow, and consolidate method-call isolation checks. Remove an unnecessary Proc-copy wrapper while preserving behavior. --- ractor.c | 38 ++++++++++-------- vm.c | 105 +++++++++++++++++++++++++----------------------- vm_insnhelper.c | 24 +++++------ 3 files changed, 86 insertions(+), 81 deletions(-) diff --git a/ractor.c b/ractor.c index fafc4ea03ef5ac..b65b9aeed7727c 100644 --- a/ractor.c +++ b/ractor.c @@ -4293,7 +4293,9 @@ static const struct st_hash_type isolation_warn_hash_type = { static bool isolation_warnings_enabled_p(void) { - return !GET_EC()->ractor_isolation_warning && !NIL_P(ruby_verbose) && + if (GET_EC()->ractor_isolation_warning) return false; + + return !NIL_P(ruby_verbose) && rb_warning_category_enabled_p(RB_WARN_CATEGORY_RACTOR_ISOLATION); } @@ -4302,24 +4304,24 @@ static VALUE isolation_source_location(int *line) { const rb_execution_context_t *ec = GET_EC(); - const rb_control_frame_t *cfp = ec->cfp; - - 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("cfp; + !RUBY_VM_CONTROL_FRAME_STACK_OVERFLOW_P(ec, cfp); + cfp = RUBY_VM_PREVIOUS_CONTROL_FRAME(cfp)) { + if (!VM_FRAME_RUBYFRAME_P(cfp) || !CFP_ISEQ(cfp)) continue; + + VALUE path = rb_iseq_path(CFP_ISEQ(cfp)); + if (strncmp("ractor_isolation_warning = false; return Qnil; @@ -4384,7 +4390,7 @@ rb_ractor_isolation_warn(VALUE message) // Keep the guard fiber-local and restore it even if the hook raises. rb_execution_context_t *ec = GET_EC(); ec->ractor_isolation_warning = true; - rb_ensure(isolation_warn_emit, message, isolation_warn_end, (VALUE)ec); + rb_ensure(isolation_warn_emit, message, isolation_warn_clear_guard, (VALUE)ec); } void diff --git a/vm.c b/vm.c index 66f19ce3356917..73698dbae1d87e 100644 --- a/vm.c +++ b/vm.c @@ -1400,21 +1400,15 @@ rb_proc_dup_0(VALUE self) return proc_dup_0(self, true); } -static VALUE -proc_dup(VALUE self, bool preserve_shareability) +VALUE +rb_proc_dup(VALUE self) { - VALUE procval = proc_dup_0(self, preserve_shareability); + VALUE procval = rb_proc_dup_0(self); VALUE recipe = rb_proc_refinements_recipe(self); if (!NIL_P(recipe)) rb_proc_set_refinements_recipe(procval, recipe); return procval; } -VALUE -rb_proc_dup(VALUE self) -{ - return proc_dup(self, true); -} - /* Proc#refined: build a Proc that runs `iseq` with the refinements of * `recipe`, sharing self's environment. `iseq` is normally self's own block * iseq, which the copy replaces on the first call. */ @@ -1483,10 +1477,15 @@ collect_outer_variable_names(ID id, VALUE val, void *ptr) return ID_TABLE_CONTINUE; } +enum proc_isolation_mode { + PROC_ISOLATION_STRICT, + PROC_ISOLATION_WARN_ONLY, +}; + static void -proc_isolation_violation_str(VALUE message, bool warn) +proc_isolation_violation_str(VALUE message, enum proc_isolation_mode mode) { - if (warn) { + if (mode == PROC_ISOLATION_WARN_ONLY) { rb_ractor_isolation_warn(message); } else { @@ -1495,7 +1494,7 @@ proc_isolation_violation_str(VALUE message, bool warn) } static const rb_env_t * -env_copy(const VALUE *src_ep, VALUE read_only_variables, bool warn, bool *valid) +env_copy(const VALUE *src_ep, VALUE read_only_variables, enum proc_isolation_mode mode, bool *can_isolate) { const rb_env_t *src_env = (rb_env_t *)VM_ENV_ENVVAL(src_ep); VM_ASSERT(src_env->ep == src_ep); @@ -1535,8 +1534,8 @@ env_copy(const VALUE *src_ep, VALUE read_only_variables, bool warn, bool *valid) VALUE name = rb_id2str(id); VALUE msg = rb_sprintf("cannot make a shareable Proc because " "the outer variable '%" PRIsVALUE "' may be reassigned.", name); - proc_isolation_violation_str(msg, warn); - *valid = false; + proc_isolation_violation_str(msg, mode); + *can_isolate = false; } // check shareable @@ -1544,7 +1543,7 @@ env_copy(const VALUE *src_ep, VALUE read_only_variables, bool warn, bool *valid) if (!rb_ractor_shareable_p(v)) { VALUE name = rb_id2str(id); VALUE msg; - if (warn) { + if (mode == PROC_ISOLATION_WARN_ONLY) { msg = rb_sprintf("cannot make a shareable Proc because it can refer " "an unshareable object of class %+" PRIsVALUE " from ", rb_class_of(v)); @@ -1557,8 +1556,8 @@ env_copy(const VALUE *src_ep, VALUE read_only_variables, bool warn, bool *valid) rb_str_catf(msg, "variable '%" PRIsVALUE "'", name); else rb_str_cat_cstr(msg, "a hidden variable"); - proc_isolation_violation_str(msg, warn); - *valid = false; + proc_isolation_violation_str(msg, mode); + *can_isolate = false; } RB_OBJ_WRITE((VALUE)copied_env, &env_body[j], v); rb_ary_delete_at(read_only_variables, i); @@ -1570,7 +1569,7 @@ env_copy(const VALUE *src_ep, VALUE read_only_variables, bool warn, bool *valid) 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, warn, valid); + const rb_env_t *new_prev_env = env_copy(prev_ep, read_only_variables, mode, can_isolate); 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); @@ -1586,18 +1585,18 @@ env_copy(const VALUE *src_ep, VALUE read_only_variables, bool warn, bool *valid) ep[VM_ENV_DATA_INDEX_SPECVAL] = VM_BLOCK_HANDLER_NONE; } - if (*valid) { + if (*can_isolate) { RB_OBJ_SET_SHAREABLE((VALUE)copied_env); } return copied_env; } static bool -proc_isolate_env(VALUE self, rb_proc_t *proc, VALUE read_only_variables, bool warn, bool valid) +proc_isolate_env(VALUE self, rb_proc_t *proc, VALUE read_only_variables, enum proc_isolation_mode mode, bool can_isolate) { const struct rb_captured_block *captured = &proc->block.as.captured; - const rb_env_t *env = env_copy(captured->ep, read_only_variables, warn, &valid); - if (!valid) return false; + const rb_env_t *env = env_copy(captured->ep, read_only_variables, mode, &can_isolate); + if (!can_isolate) return false; *((const VALUE **)&proc->block.as.captured.ep) = env->ep; RB_OBJ_WRITTEN(self, Qundef, env); @@ -1615,22 +1614,23 @@ proc_has_ivar_i(ID name, VALUE val, st_data_t arg) } static void -proc_check_isolation_ivars(VALUE self, bool warn) +proc_check_isolation_ivars(VALUE self, enum proc_isolation_mode mode) { + if (LIKELY(!rb_obj_shape_has_ivars(self))) return; + /* ivars are not traversed here, so their values may be unshareable */ - if (UNLIKELY(rb_obj_shape_has_ivars(self))) { - bool has_ivar = false; - rb_ivar_foreach(self, proc_has_ivar_i, (st_data_t)&has_ivar); + bool has_ivar = false; + rb_ivar_foreach(self, proc_has_ivar_i, (st_data_t)&has_ivar); - if (has_ivar) { - proc_isolation_violation_str( - rb_str_new_cstr("can not isolate a Proc because it has instance variables"), warn); - } + if (has_ivar) { + proc_isolation_violation_str( + rb_str_new_cstr("can not isolate a Proc because it has instance variables"), mode); } } static VALUE -proc_shared_outer_variables(struct rb_id_table *outer_variables, bool isolate, const char *message, bool warn, bool *valid) +proc_shared_outer_variables(struct rb_id_table *outer_variables, bool isolate, const char *message, + enum proc_isolation_mode mode, bool *can_isolate) { struct collect_outer_variable_name_data data = { .isolate = isolate, @@ -1653,13 +1653,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'." : "."); - proc_isolation_violation_str(str, warn); - if (valid) *valid = false; + proc_isolation_violation_str(str, mode); + if (can_isolate) *can_isolate = false; } else if (data.yield) { VALUE str = rb_sprintf("can not %s because it uses 'yield'.", message); - proc_isolation_violation_str(str, warn); - if (valid) *valid = false; + proc_isolation_violation_str(str, mode); + if (can_isolate) *can_isolate = false; } return data.read_only; @@ -1681,15 +1681,16 @@ 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", false, NULL); + proc_shared_outer_variables(ISEQ_BODY(iseq)->outer_variables, true, "isolate a Proc", + PROC_ISOLATION_STRICT, NULL); } - if (!proc_isolate_env(self, proc, Qfalse, false, true)) return self; + if (!proc_isolate_env(self, proc, Qfalse, PROC_ISOLATION_STRICT, true)) return self; proc->header.is_isolated = TRUE; RB_OBJ_WRITE(self, &proc->block.as.captured.self, Qnil); } - proc_check_isolation_ivars(self, false); + proc_check_isolation_ivars(self, PROC_ISOLATION_STRICT); RB_OBJ_SET_SHAREABLE(self); return self; @@ -1712,29 +1713,31 @@ rb_proc_check_isolation_warn(VALUE 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); + proc_shared_outer_variables(ISEQ_BODY(iseq)->outer_variables, true, "isolate a Proc", + PROC_ISOLATION_WARN_ONLY, NULL); } } - proc_check_isolation_ivars(self, true); + proc_check_isolation_ivars(self, PROC_ISOLATION_WARN_ONLY); } 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(); + enum proc_isolation_mode mode = rb_ractor_isolation_check_p() ? + PROC_ISOLATION_WARN_ONLY : PROC_ISOLATION_STRICT; 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"); - bool valid = true; + bool can_isolate = 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; + proc_isolation_violation_str(message, mode); + can_isolate = false; } VALUE read_only_variables = Qfalse; @@ -1742,16 +1745,16 @@ rb_proc_ractor_make_shareable(VALUE self, VALUE replace_self) if (ISEQ_BODY(iseq)->outer_variables) { read_only_variables = proc_shared_outer_variables(ISEQ_BODY(iseq)->outer_variables, false, - "make a Proc shareable", warn, &valid); + "make a Proc shareable", mode, &can_isolate); } - valid = proc_isolate_env(self, proc, read_only_variables, warn, valid); + can_isolate = proc_isolate_env(self, proc, read_only_variables, mode, can_isolate); if (!UNDEF_P(replace_self)) { RB_OBJ_WRITE(self, &proc->block.as.captured.self, replace_self); } // Diagnostic fallback keeps the original closure and requested receiver, // but must not mark an invalid Proc isolated or shareable. - if (!valid) return self; + if (!can_isolate) return self; proc->header.is_isolated = TRUE; } else { @@ -1761,8 +1764,8 @@ rb_proc_ractor_make_shareable(VALUE self, VALUE replace_self) VALUE proc_self = vm_block_self(block); 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); - if (warn) return self; + proc_isolation_violation_str(message, mode); + return self; } } @@ -1775,7 +1778,9 @@ rb_proc_ractor_make_shareable_copy(VALUE self, VALUE replace_self) { // Rebinding self can make a shareable Proc unshareable in diagnostic mode. // The copy must only become shareable after its new receiver is validated. - VALUE copy = proc_dup(self, false); + VALUE copy = proc_dup_0(self, false); + VALUE recipe = rb_proc_refinements_recipe(self); + if (!NIL_P(recipe)) rb_proc_set_refinements_recipe(copy, recipe); return rb_proc_ractor_make_shareable(copy, replace_self); } diff --git a/vm_insnhelper.c b/vm_insnhelper.c index f132aa1f0f5996..dd2b380f6840e3 100644 --- a/vm_insnhelper.c +++ b/vm_insnhelper.c @@ -4117,18 +4117,16 @@ 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); } -static inline bool -vm_bmethod_proc_uncallable_p(rb_execution_context_t *ec, const rb_callable_method_entry_t *cme, VALUE procv) +static inline void +vm_bmethod_check_ractor(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); -} + if (RB_OBJ_SHAREABLE_P(procv) || + cme->def->body.bmethod.defined_ractor_id == rb_ec_ractor_id(ec)) { + return; + } -// 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) -{ if (rb_ractor_isolation_check_p()) { + // Diagnostic mode invokes the method after reporting the violation. rb_ractor_isolation_violation( "can not call method %"PRIsVALUE" defined with an un-shareable Proc from a different Ractor", rb_id2str(cme->called_id)); @@ -4147,9 +4145,7 @@ 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 (vm_bmethod_proc_uncallable_p(ec, cme, procv)) { - vm_bmethod_unshareable_proc_violation(ec, cme); - } + vm_bmethod_check_ractor(ec, cme, procv); /* control block frame */ GetProcPtr(procv, proc); @@ -4169,9 +4165,7 @@ 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 (vm_bmethod_proc_uncallable_p(ec, cme, procv)) { - vm_bmethod_unshareable_proc_violation(ec, cme); - } + vm_bmethod_check_ractor(ec, cme, procv); rb_proc_t *proc; GetProcPtr(procv, proc); From 4ecb1db8975a568ee170cd455ca4919713ce1ba0 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Wed, 23 Sep 2026 15:36:29 +0400 Subject: [PATCH 19/21] Address code review by Luke Gruber --- gc.c | 5 +- gc/default/default.c | 6 +- ractor.c | 24 +- ractor_core.h | 11 +- ractor_sync.c | 4 +- test/ruby/test_ractor.rb | 689 -------------------- test/ruby/test_ractor_isolation_check.rb | 778 +++++++++++++++++++++++ thread.c | 6 +- thread_sched.c | 4 +- variable.c | 14 +- vm.c | 8 +- vm_insnhelper.c | 4 +- 12 files changed, 826 insertions(+), 727 deletions(-) create mode 100644 test/ruby/test_ractor_isolation_check.rb diff --git a/gc.c b/gc.c index 7ca12158f8ad19..993a8fdd4cff99 100644 --- a/gc.c +++ b/gc.c @@ -2151,14 +2151,13 @@ undefine_final(VALUE os, VALUE obj) VALUE rb_undefine_finalizer(VALUE obj) { - rb_check_frozen(obj); - - if (rb_gc_obj_foreign_p(obj)) { + if (rb_objspace_foreign_object_p(obj)) { rb_ractor_isolation_violation( "can not undefine a finalizer of an object of another Ractor"); return obj; } + rb_check_frozen(obj); rb_gc_impl_undefine_finalizer(rb_gc_get_objspace(), obj); return obj; diff --git a/gc/default/default.c b/gc/default/default.c index d61b47b836b0fc..78b12096d176f5 100644 --- a/gc/default/default.c +++ b/gc/default/default.c @@ -6926,11 +6926,13 @@ check_children_i(const VALUE child, void *ptr) * invisible to both local GCs. The exception is a box's top_self, which every * thread's th->top_self points at and which is VM-permanent. Skipped during a * global GC: it clears every shref bit, so the shref exemption would not fire, - * and its unified exact stop-the-world mark makes the invariant itself moot. */ + * and its unified exact stop-the-world mark makes the invariant itself moot. + * Isolation checking also allows these edges and uses only global GC. */ if (!data->parent_shareable && child != rb_gc_vm_top_self() && !MARKED_IN_BITMAP(GET_HEAP_SHAREABLE_BITS(child), child) && !MARKED_IN_BITMAP(GET_HEAP_SHREF_BITS(child), child) && + !rb_gc_vm_global_gc_only_p() && !rb_gc_impl_during_global_gc_p(data->objspace) && !global_objspace->during_absorb) { fprintf(stderr, "check_children_i: containment violation: " @@ -7004,6 +7006,8 @@ root_scope_check_i(const char *category, VALUE obj, void *ptr) return; } + /* Isolation checking permits foreign unshareable roots and uses only global GC. */ + if (rb_gc_vm_global_gc_only_p()) return; if (GET_HEAP_OBJSPACE(obj) == data->objspace) return; if (MARKED_IN_BITMAP(GET_HEAP_SHAREABLE_BITS(obj), obj)) return; if (MARKED_IN_BITMAP(GET_HEAP_SHREF_BITS(obj), obj)) return; diff --git a/ractor.c b/ractor.c index b65b9aeed7727c..37549f5ddf8c6d 100644 --- a/ractor.c +++ b/ractor.c @@ -4242,14 +4242,11 @@ rb_ractor_autoload_load(VALUE module, ID name) } // RUBY_RACTOR_ISOLATION: non-main Ractors warn on isolation violations -// instead of raising, so a sweep reports more than the first one. - -extern int ruby_ractor_isolation_enabled; +// instead of raising, so the isolation check can report further violations. bool -rb_ractor_isolation_check_p(void) +rb_ractor_isolation_check_p_slowpath(void) { - if (!ruby_ractor_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); @@ -4339,16 +4336,13 @@ isolation_warn_first_occurrence_p(VALUE message, VALUE path, int line) // Own copies of both strings outside Ruby's heap for this VM-wide table. size_t file_size = strlen(lookup.file) + 1; size_t message_size = strlen(lookup.message) + 1; - struct isolation_warn_key *key = malloc(sizeof(*key) + file_size + message_size); - if (key) { - char *file = (char *)(key + 1); - char *text = file + file_size; - memcpy(file, lookup.file, file_size); - memcpy(text, lookup.message, message_size); - *key = (struct isolation_warn_key){file, text, line}; - st_add_direct(isolation_warn_tbl, (st_data_t)key, 0); - } - // If allocation fails, still report the warning. + struct isolation_warn_key *key = ruby_xmalloc(sizeof(*key) + file_size + message_size); + char *file = (char *)(key + 1); + char *text = file + file_size; + memcpy(file, lookup.file, file_size); + memcpy(text, lookup.message, message_size); + *key = (struct isolation_warn_key){file, text, line}; + st_add_direct(isolation_warn_tbl, (st_data_t)key, 0); } } RB_GC_GUARD(message); diff --git a/ractor_core.h b/ractor_core.h index 49b095656ff29f..993ef8d761229a 100644 --- a/ractor_core.h +++ b/ractor_core.h @@ -237,7 +237,8 @@ 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); -bool rb_ractor_isolation_check_p(void); +extern int ruby_ractor_isolation_enabled; +bool rb_ractor_isolation_check_p_slowpath(void); /* Warns and returns in check mode, raises Ractor::IsolationError otherwise. */ PRINTF_ARGS(void rb_ractor_isolation_violation(const char *fmt, ...), 1, 2); @@ -268,6 +269,13 @@ rb_ractor_main_p(void) } } +static inline bool +rb_ractor_isolation_check_p(void) +{ + if (!ruby_ractor_isolation_enabled) return false; + return rb_ractor_isolation_check_p_slowpath(); +} + static inline bool rb_ractor_status_p(rb_ractor_t *r, enum ractor_status status) { @@ -373,7 +381,6 @@ 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_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 diff --git a/ractor_sync.c b/ractor_sync.c index e0c25b3f911b69..a3d0dfec31e3d9 100644 --- a/ractor_sync.c +++ b/ractor_sync.c @@ -1112,8 +1112,8 @@ ractor_prepare_payload(rb_execution_context_t *ec, VALUE obj, enum ractor_basket return obj; } else if (rb_ractor_isolation_check_p()) { - // copying can fail outright (e.g. Procs), which would abort the sweep; - // pass by reference instead + // Copying can fail (e.g. for Procs). Pass by reference so the + // isolation check can continue reporting violations. rb_ractor_isolation_warn(rb_sprintf("can not copy an unshareable %"PRIsVALUE" across Ractors; " "passing by reference under RUBY_RACTOR_ISOLATION", rb_class_of(obj))); diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index 281ea9958aa0d7..a270d31d92ba5c 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -1081,695 +1081,6 @@ def test_io_is_not_shareable end end - def test_isolation_check_runs_in_a_non_main_ractor - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) - result = Ractor.new(name: "isolation check") do - [Ractor.main?, Ractor.current == Ractor.main, Ractor.current.name] - end.value - assert_equal [false, false, "isolation check"], result - RUBY - end - - def test_isolation_check_returns_the_block_value_by_reference - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) - obj = Object.new - assert_same obj, Ractor.new { obj }.value - 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_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_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_ISOLATION" => "1"}], ignore_stderr: true) - outer = [1, 2, 3] - arg = Object.new - returned_arg, returned_outer = Ractor.new(arg) do |a| - [a, outer] - end.value - assert_same arg, returned_arg - assert_same outer, returned_outer - RUBY - end - - def test_isolation_check_handles_large_argument_lists_without_using_the_native_stack - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) - marker = Object.new - args = Array.new(200_000, marker) - length, first, last = Ractor.new(*args) do |*values| - [values.length, values.first, values.last] - end.value - assert_equal 200_000, length - assert_same marker, first - assert_same marker, last - RUBY - end - - def test_isolation_check_make_shareable_warns_and_continues_for_files - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) - file = File.open(IO::NULL) - begin - result = Ractor.new(file) do |f| - Ractor.make_shareable(f) - :completed - end.value - assert_equal :completed, result - refute Ractor.shareable?(file) - ensure - file.close - end - RUBY - end - - def test_isolation_check_warns_instead_of_raising - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") - class CheckIsolationFixture - @ivar = "ivar" - @@cvar = [1, 2, 3] - MUTABLE = "mutable" - end - $check_isolation_global = "global" - require "etc" - - h = Hash.new(Mutex.new) - result = Ractor.new 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.value - assert_equal :completed, result - - combined = RactorIsolationWarnings.drain.join("\n") - assert_match(/instance variables of classes\/modules created by another Ractor/, 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_isolation_check_warns_on_outer_variable_capture - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") - captured = [] - - result = Ractor.new { captured << :ran; captured }.value - assert_same captured, result - assert_equal [:ran], captured - assert_match(/can not isolate a Proc because it accesses outer variables \(captured\)/, - RactorIsolationWarnings.drain.join("\n")) - RUBY - end - - def test_isolation_check_warns_and_copies_classes_and_modules - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") - originals = [Class.new, Module.new] - originals.each do |type| - type.const_set(:VALUE, []) - type.instance_variable_set(:@value, []) - type.class_variable_set(:@@value, []) - end - - results = Ractor.new(*originals) do |*types| - types.flat_map do |type| - [:dup, :clone].map do |operation| - copy = type.public_send(operation) - [!copy.equal?(type), - copy.const_get(:VALUE).equal?(type.const_get(:VALUE)), - copy.instance_variable_get(:@value).equal?(type.instance_variable_get(:@value)), - copy.class_variable_get(:@@value).equal?(type.class_variable_get(:@@value))] - end - end - end.value - - assert_equal [[true, true, true, true]] * 4, results - warnings = RactorIsolationWarnings.drain.join("\n") - assert_match(/can not copy a class\/module.*constant VALUE refers to an unshareable object/, warnings) - assert_match(/can not copy a class\/module.*variable @value refers to an unshareable object/, warnings) - assert_match(/can not copy a class\/module.*variable @@value refers to an unshareable object/, warnings) - RUBY - end - - def test_isolation_check_warns_and_returns_attached_objects - omit 'objspace per Ractor is how an object\'s owner is known' unless GC.config[:implementation] == 'default' - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") - object = Object.new - result = Ractor.new(object.singleton_class) { |type| type.attached_object }.value - assert_same object, result - assert_match(/can not get an unshareable attached object from another Ractor/, - RactorIsolationWarnings.drain.join("\n")) - RUBY - end - - def test_class_copy_enforces_isolation_without_isolation_check_env - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => nil}]) - [Class, Module].each do |factory| - [:constant, :ivar, :cvar].each do |storage| - type = factory.new - case storage - when :constant then type.const_set(:VALUE, []) - when :ivar then type.instance_variable_set(:@value, []) - when :cvar then type.class_variable_set(:@@value, []) - end - results = Ractor.new(type) do |original| - [:dup, :clone].map do |operation| - begin - original.public_send(operation) - :copied - rescue Ractor::IsolationError - :isolated - end - end - end.value - assert_equal [:isolated, :isolated], results - end - end - RUBY - end - - def test_isolation_check_warns_on_proc_instance_variables - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "2"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") - [nil, 42, []].each do |state| - callable = proc { :done } - callable.instance_variable_set(:@state, state) - assert_equal :done, Ractor.new(&callable).value - assert_same state, callable.instance_variable_get(:@state) - refute Ractor.shareable?(callable) - refute callable.frozen? - end - - warnings = RactorIsolationWarnings.drain.grep(/can not isolate a Proc because it has instance variables/) - assert_equal 3, warnings.size - RUBY - end - - def test_proc_instance_variables_enforce_isolation_without_isolation_check_env - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => nil}]) - [nil, 42, []].each do |state| - callable = proc { :done } - callable.instance_variable_set(:@state, state) - assert_raise_with_message(Ractor::IsolationError, /has instance variables/) do - Ractor.new(&callable) - end - end - RUBY - end - - def test_isolation_check_handles_block_defined_warning_hooks - [1, 2].each do |level| - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => level.to_s}], ignore_stderr: true) - messages = [] - $warning_hook_global = 1 - $first_warning_global = 2 - $second_warning_global = 3 - Warning.define_singleton_method(:warn) do |message, category: nil| - next unless category == :ractor_isolation - $warning_hook_global - messages << message - raise "warning hook failed" if message.include?("$first_warning_global") - end - - result = Ractor.new do - begin - $first_warning_global - rescue RuntimeError => error - raise unless error.message == "warning hook failed" - end - $second_warning_global - :done - end.value - - assert_equal :done, result - assert_equal 2, messages.size - assert_match(/global variable \$first_warning_global/, messages[0]) - assert_match(/global variable \$second_warning_global/, messages[1]) - RUBY - end - end - - def test_isolation_check_deduplicates_all_warning_paths - source = <<~'RUBY' - require "etc" - klass = Class.new - klass.define_method(:call) { :done } - Ractor.new(klass) do |type| - file = File.open(IO::NULL) - 3.times { Ractor.make_shareable(file) } - file.close - 3.times { Etc.getlogin } - 3.times { type.new.call } - end.value - RUBY - - [1, 2].each do |level| - env = {"RUBY_RACTOR_ISOLATION" => level.to_s} - assert_in_out_err([env, "-W:no-experimental", "-e", source], success: true) do |_stdout, stderr| - expected = level == 1 ? 1 : 3 - assert_equal expected, stderr.grep(/^-e:6: warning: can not make shareable object/).size - assert_equal expected, stderr.grep(/^-e:8: warning: ractor unsafe method/).size - assert_equal expected, stderr.grep(/^-e:9: warning: can not call method call/).size - assert_empty stderr.grep(/ "2"}], ignore_stderr: true) - messages = [] - $suspended_warning_global = 1 - $other_fiber_global = 2 - $parent_fiber_global = 3 - Warning.define_singleton_method(:warn) do |message, category: nil| - next unless category == :ractor_isolation - messages << message - if message.include?("$suspended_warning_global") - Fiber.yield Fiber.new { $other_fiber_global } - end - end - - result = Ractor.new do - suspended = Fiber.new { $suspended_warning_global } - other = suspended.resume - [other.resume, $parent_fiber_global, suspended.resume] - end.value - - assert_equal [2, 3, 1], result - assert_equal 3, messages.size - assert_match(/global variable \$suspended_warning_global/, messages[0]) - assert_match(/global variable \$other_fiber_global/, messages[1]) - assert_match(/global variable \$parent_fiber_global/, messages[2]) - RUBY - end - - def test_isolation_check_does_not_mark_an_invalid_proc_shareable - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) - captured = [] - callable = Ractor.new do - Ractor.shareable_proc { captured << :called; captured } - end.value - - refute Ractor.shareable?(callable) - refute callable.frozen? - assert_same captured, callable.call - assert_equal [:called], captured - RUBY - end - - def test_isolation_check_preserves_invalid_proc_receivers - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") - results = Ractor.new do - [nil, :replacement, Object.new].flat_map do |receiver| - [:shareable_proc, :shareable_lambda].map do |kind| - captured = [] - callable = Ractor.public_send(kind, self: receiver) do |value| - captured << value - [self, captured] - end - [kind, receiver, captured, callable] - end - end - end.value - - results.each do |kind, receiver, captured, callable| - actual_self, actual_capture = callable.call(:called) - assert_same receiver, actual_self - assert_same captured, actual_capture - assert_equal [:called], captured - assert_equal kind == :shareable_lambda, callable.lambda? - refute Ractor.shareable?(callable) - refute callable.frozen? - end - assert_match(/cannot make a shareable Proc.*unshareable object of class Array/, - RactorIsolationWarnings.drain.join("\n")) - RUBY - end - - def test_shareable_proc_receivers_without_isolation_check_env - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => nil}]) - [:shareable_proc, :shareable_lambda].each do |kind| - original = Ractor.public_send(kind) { self } - [nil, :replacement].each do |receiver| - callables = [Ractor.public_send(kind, self: receiver) { self }, - Ractor.public_send(kind, self: receiver, &original)] - callables.each do |callable| - assert_same receiver, callable.call - assert Ractor.shareable?(callable) - assert callable.frozen? - assert_equal kind == :shareable_lambda, callable.lambda? - end - end - assert_nil original.call - - captured = [] - assert_raise(Ractor::IsolationError) do - Ractor.public_send(kind, self: :replacement) { captured } - end - assert_raise(Ractor::IsolationError) do - Ractor.public_send(kind, self: Object.new) { self } - end - end - RUBY - end - - def test_isolation_check_does_not_keep_shareability_when_rebinding_proc - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") - results = Ractor.new do - [:shareable_proc, :shareable_lambda].map do |kind| - original = Ractor.public_send(kind) { self } - receiver = Object.new - copy = Ractor.public_send(kind, self: receiver, &original) - [kind, original, receiver, copy] - end - end.value - - results.each do |kind, original, receiver, copy| - assert_same receiver, copy.call - assert_equal kind == :shareable_lambda, copy.lambda? - refute Ractor.shareable?(copy) - refute copy.frozen? - copy.instance_variable_set(:@state, :mutable) - assert_equal :mutable, copy.instance_variable_get(:@state) - assert Ractor.shareable?(original) - assert original.frozen? - assert_nil original.call - end - assert_match(/Proc's self is not shareable/, RactorIsolationWarnings.drain.join("\n")) - RUBY - end - - def test_isolation_check_warns_and_executes_captured_define_method - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") - captured = [] - klass = Class.new - klass.define_method(:capture) { captured << :called; captured } - - result = Ractor.new(klass) { |k| k.new.capture }.value - assert_same captured, result - assert_equal [:called], captured - assert_match(/can not call method capture defined with an un-shareable Proc/, - RactorIsolationWarnings.drain.join("\n")) - RUBY - end - - def test_isolation_check_is_active_in_child_threads - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") - class CheckIsolationChildThreadFixture - VALUE = [] - end - - value = Ractor.new do - Thread.new { CheckIsolationChildThreadFixture::VALUE }.value - end.value - assert_same CheckIsolationChildThreadFixture::VALUE, value - assert_match(/non-shareable objects in constant CheckIsolationChildThreadFixture::VALUE/, - RactorIsolationWarnings.drain.join("\n")) - RUBY - end - - def test_isolation_check_applies_to_nested_and_later_ractors - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) - nested, returned = Ractor.new do - captured = Object.new - [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_ISOLATION" => nil}]) - captured = Object.new - assert_raise(Ractor::IsolationError) do - Ractor.new { captured } - end - 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_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_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_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") - object = Object.new - finalizer = proc {} - - defined, undefined = Ractor.new do - [ObjectSpace.define_finalizer(object, finalizer), - ObjectSpace.undefine_finalizer(object)] - end.value - assert_same finalizer, defined[1] - assert_same object, undefined - - combined = RactorIsolationWarnings.drain.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_isolation_check_reraises_block_exceptions - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) - error = assert_raise(Ractor::RemoteError) do - Ractor.new { raise "boom" }.value - end - assert_equal "boom", error.cause.message - RUBY - end - - def test_isolation_check_allows_dispatch_to_main - assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) - main_port = Ractor::Port.new - Thread.new do - callable, reply = main_port.receive - reply << callable.call - end - - value = Ractor.new do - reply = Ractor::Port.new - main_port << [Ractor.shareable_proc { 40 + 2 }, reply] - reply.receive - end.value - assert_equal 42, value - RUBY - end - - def test_isolation_check_dedups_repeated_warnings - gvar_warning = /can not access global variable \$g/ - summary = /RUBY_RACTOR_ISOLATION: (\d+) repeated isolation warnings suppressed/ - env = {"RUBY_RACTOR_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 - - # level 2 reports every hit - assert_in_out_err([{"RUBY_RACTOR_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) - assert_empty stderr.grep(summary) - end - - # warnings raised inside 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(/ "1"}, "-W:no-experimental", "-e", src]) do |_stdout, stderr| - warnings = stderr.grep(/can not access global variable/) - expected = ["first.rb", "second.rb"].product(["$g", "$h"]).map do |path, name| - "#{path}:7: warning: can not access global variable #{name} from non-main Ractor" - end - assert_equal expected, warnings - assert_include stderr, "RUBY_RACTOR_ISOLATION: 4 repeated isolation warnings suppressed" - end - end - - def test_isolation_check_warns_on_repeated_constant_reads - source = <<~'RUBY' - module IsolationConstantFixture - VALUE = [] - def self.read - VALUE - end - end - # Populate the same cache in the main Ractor before using it in a child. - 10.times { IsolationConstantFixture.read } - Ractor.new { 10.times { IsolationConstantFixture.read } }.value - RUBY - assert_isolation_constant_warnings(source) - end - - def test_isolation_check_warns_after_reenabling_constant_warnings - source = <<~'RUBY' - module IsolationConstantFixture - VALUE = [] - def self.read - VALUE - end - end - Ractor.new do - Warning[:ractor_isolation] = false - 10.times { IsolationConstantFixture.read } - Warning[:ractor_isolation] = true - 10.times { IsolationConstantFixture.read } - end.value - RUBY - assert_isolation_constant_warnings(source) - end - - def assert_isolation_constant_warnings(source) - require_relative '../lib/jit_support' - options = [[]] - options << %w[--yjit --yjit-call-threshold=1] if JITSupport.yjit_supported? - options << %w[--zjit --zjit-call-threshold=1] if JITSupport.zjit_supported? - - options.each do |jit_options| - [1, 2].each do |level| - env = {"RUBY_RACTOR_ISOLATION" => level.to_s} - args = [env, *jit_options, "-W:no-experimental", "-e", source] - assert_in_out_err(args, success: true) do |_stdout, stderr| - warnings = stderr.grep(/non-shareable objects in constant IsolationConstantFixture::VALUE/) - assert_equal level == 1 ? 1 : 10, warnings.size, "#{jit_options.inspect}, level #{level}" - if level == 1 - assert_include stderr, "RUBY_RACTOR_ISOLATION: 9 repeated isolation warnings suppressed" - end - end - end - end - end - - def test_isolation_check_serializes_ractors_or_warns_at_boot - advisory = /RUBY_RACTOR_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_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 - 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 - assert_separately([{"RUBY_RACTOR_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 - 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.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.value - - 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 the 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_ractor_isolation_check.rb b/test/ruby/test_ractor_isolation_check.rb new file mode 100644 index 00000000000000..93789fa052f6aa --- /dev/null +++ b/test/ruby/test_ractor_isolation_check.rb @@ -0,0 +1,778 @@ +# frozen_string_literal: false +require 'test/unit' + +class TestRactorIsolationCheck < Test::Unit::TestCase + def test_isolation_check_runs_in_a_non_main_ractor + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) + result = Ractor.new(name: "isolation check") do + [Ractor.main?, Ractor.current == Ractor.main, Ractor.current.name] + end.value + assert_equal [false, false, "isolation check"], result + RUBY + end + + def test_isolation_check_preserves_ractor_block_receiver + [1, 2].each do |level| + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => level.to_s}], ignore_stderr: true) + ractor = Ractor.new(name: "isolation check") { [self, name, receive, recv] } + ractor.send(:first).send(:second) + assert_equal [ractor, "isolation check", :first, :second], ractor.value + + captured = Object.new + block = proc { |arg| [self, captured, arg] } + ractor = Ractor.new(:argument, &block) + assert_equal [ractor, captured, :argument], ractor.value + assert_equal [self, captured, :local], block.call(:local) + RUBY + end + end + + def test_isolation_check_returns_the_block_value_by_reference + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) + obj = Object.new + assert_same obj, Ractor.new { obj }.value + 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_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_allows_gc_internal_consistency_verification + [1, 2].each do |level| + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => level.to_s}], ignore_stderr: true) + result = Ractor.new do + before = GC.verify_internal_consistency + GC.start + [before, GC.verify_internal_consistency] + end.value + + assert_equal [nil, nil], result + assert_nil GC.verify_internal_consistency + GC.start + assert_nil GC.verify_internal_consistency + RUBY + end + end + + def test_isolation_check_keeps_messages_alive_past_sender_exit + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_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_ISOLATION" => "1"}], ignore_stderr: true) + outer = [1, 2, 3] + arg = Object.new + returned_arg, returned_outer = Ractor.new(arg) do |a| + [a, outer] + end.value + assert_same arg, returned_arg + assert_same outer, returned_outer + RUBY + end + + def test_isolation_check_handles_large_argument_lists_without_using_the_native_stack + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) + marker = Object.new + args = Array.new(200_000, marker) + length, first, last = Ractor.new(*args) do |*values| + [values.length, values.first, values.last] + end.value + assert_equal 200_000, length + assert_same marker, first + assert_same marker, last + RUBY + end + + def test_isolation_check_make_shareable_warns_and_continues_for_files + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) + file = File.open(IO::NULL) + begin + result = Ractor.new(file) do |f| + Ractor.make_shareable(f) + :completed + end.value + assert_equal :completed, result + refute Ractor.shareable?(file) + ensure + file.close + end + RUBY + end + + def test_isolation_check_warns_instead_of_raising + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") + class CheckIsolationFixture + @ivar = "ivar" + @@cvar = [1, 2, 3] + MUTABLE = "mutable" + end + $check_isolation_global = "global" + require "etc" + + h = Hash.new(Mutex.new) + result = Ractor.new 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.value + assert_equal :completed, result + + combined = RactorIsolationWarnings.drain.join("\n") + assert_match(/instance variables of classes\/modules created by another Ractor/, 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_isolation_check_warns_on_outer_variable_capture + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") + captured = [] + + result = Ractor.new { captured << :ran; captured }.value + assert_same captured, result + assert_equal [:ran], captured + assert_match(/can not isolate a Proc because it accesses outer variables \(captured\)/, + RactorIsolationWarnings.drain.join("\n")) + RUBY + end + + def test_isolation_check_warns_and_copies_classes_and_modules + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") + originals = [Class.new, Module.new] + originals.each do |type| + type.const_set(:VALUE, []) + type.instance_variable_set(:@value, []) + type.class_variable_set(:@@value, []) + end + + results = Ractor.new(*originals) do |*types| + types.flat_map do |type| + [:dup, :clone].map do |operation| + copy = type.public_send(operation) + [!copy.equal?(type), + copy.const_get(:VALUE).equal?(type.const_get(:VALUE)), + copy.instance_variable_get(:@value).equal?(type.instance_variable_get(:@value)), + copy.class_variable_get(:@@value).equal?(type.class_variable_get(:@@value))] + end + end + end.value + + assert_equal [[true, true, true, true]] * 4, results + warnings = RactorIsolationWarnings.drain.join("\n") + assert_match(/can not copy a class\/module.*constant VALUE refers to an unshareable object/, warnings) + assert_match(/can not copy a class\/module.*variable @value refers to an unshareable object/, warnings) + assert_match(/can not copy a class\/module.*variable @@value refers to an unshareable object/, warnings) + RUBY + end + + def test_isolation_check_warns_and_returns_attached_objects + omit 'objspace per Ractor is how an object\'s owner is known' unless GC.config[:implementation] == 'default' + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") + object = Object.new + result = Ractor.new(object.singleton_class) { |type| type.attached_object }.value + assert_same object, result + assert_match(/can not get an unshareable attached object from another Ractor/, + RactorIsolationWarnings.drain.join("\n")) + RUBY + end + + def test_class_copy_enforces_isolation_without_isolation_check_env + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => nil}]) + [Class, Module].each do |factory| + [:constant, :ivar, :cvar].each do |storage| + type = factory.new + case storage + when :constant then type.const_set(:VALUE, []) + when :ivar then type.instance_variable_set(:@value, []) + when :cvar then type.class_variable_set(:@@value, []) + end + results = Ractor.new(type) do |original| + [:dup, :clone].map do |operation| + begin + original.public_send(operation) + :copied + rescue Ractor::IsolationError + :isolated + end + end + end.value + assert_equal [:isolated, :isolated], results + end + end + RUBY + end + + def test_isolation_check_warns_on_proc_instance_variables + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "2"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") + [nil, 42, []].each do |state| + callable = proc { :done } + callable.instance_variable_set(:@state, state) + assert_equal :done, Ractor.new(&callable).value + assert_same state, callable.instance_variable_get(:@state) + refute Ractor.shareable?(callable) + refute callable.frozen? + end + + warnings = RactorIsolationWarnings.drain.grep(/can not isolate a Proc because it has instance variables/) + assert_equal 3, warnings.size + RUBY + end + + def test_proc_instance_variables_enforce_isolation_without_isolation_check_env + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => nil}]) + [nil, 42, []].each do |state| + callable = proc { :done } + callable.instance_variable_set(:@state, state) + assert_raise_with_message(Ractor::IsolationError, /has instance variables/) do + Ractor.new(&callable) + end + end + RUBY + end + + def test_isolation_check_handles_block_defined_warning_hooks + [1, 2].each do |level| + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => level.to_s}], ignore_stderr: true) + messages = [] + $warning_hook_global = 1 + $first_warning_global = 2 + $second_warning_global = 3 + Warning.define_singleton_method(:warn) do |message, category: nil| + next unless category == :ractor_isolation + $warning_hook_global + messages << message + raise "warning hook failed" if message.include?("$first_warning_global") + end + + result = Ractor.new do + begin + $first_warning_global + rescue RuntimeError => error + raise unless error.message == "warning hook failed" + end + $second_warning_global + :done + end.value + + assert_equal :done, result + assert_equal 2, messages.size + assert_match(/global variable \$first_warning_global/, messages[0]) + assert_match(/global variable \$second_warning_global/, messages[1]) + RUBY + end + end + + def test_isolation_check_deduplicates_all_warning_paths + source = <<~'RUBY' + require "etc" + klass = Class.new + klass.define_method(:call) { :done } + Ractor.new(klass) do |type| + file = File.open(IO::NULL) + 3.times { Ractor.make_shareable(file) } + file.close + 3.times { Etc.getlogin } + 3.times { type.new.call } + end.value + RUBY + + [1, 2].each do |level| + env = {"RUBY_RACTOR_ISOLATION" => level.to_s} + assert_in_out_err([env, "-W:no-experimental", "-e", source], success: true) do |_stdout, stderr| + expected = level == 1 ? 1 : 3 + assert_equal expected, stderr.grep(/^-e:6: warning: can not make shareable object/).size + assert_equal expected, stderr.grep(/^-e:8: warning: ractor unsafe method/).size + assert_equal expected, stderr.grep(/^-e:9: warning: can not call method call/).size + assert_empty stderr.grep(/ "2"}], ignore_stderr: true) + messages = [] + $suspended_warning_global = 1 + $other_fiber_global = 2 + $parent_fiber_global = 3 + Warning.define_singleton_method(:warn) do |message, category: nil| + next unless category == :ractor_isolation + messages << message + if message.include?("$suspended_warning_global") + Fiber.yield Fiber.new { $other_fiber_global } + end + end + + result = Ractor.new do + suspended = Fiber.new { $suspended_warning_global } + other = suspended.resume + [other.resume, $parent_fiber_global, suspended.resume] + end.value + + assert_equal [2, 3, 1], result + assert_equal 3, messages.size + assert_match(/global variable \$suspended_warning_global/, messages[0]) + assert_match(/global variable \$other_fiber_global/, messages[1]) + assert_match(/global variable \$parent_fiber_global/, messages[2]) + RUBY + end + + def test_isolation_check_does_not_mark_an_invalid_proc_shareable + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) + captured = [] + callable = Ractor.new do + Ractor.shareable_proc { captured << :called; captured } + end.value + + refute Ractor.shareable?(callable) + refute callable.frozen? + assert_same captured, callable.call + assert_equal [:called], captured + RUBY + end + + def test_isolation_check_preserves_invalid_proc_receivers + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") + results = Ractor.new do + [nil, :replacement, Object.new].flat_map do |receiver| + [:shareable_proc, :shareable_lambda].map do |kind| + captured = [] + callable = Ractor.public_send(kind, self: receiver) do |value| + captured << value + [self, captured] + end + [kind, receiver, captured, callable] + end + end + end.value + + results.each do |kind, receiver, captured, callable| + actual_self, actual_capture = callable.call(:called) + assert_same receiver, actual_self + assert_same captured, actual_capture + assert_equal [:called], captured + assert_equal kind == :shareable_lambda, callable.lambda? + refute Ractor.shareable?(callable) + refute callable.frozen? + end + assert_match(/cannot make a shareable Proc.*unshareable object of class Array/, + RactorIsolationWarnings.drain.join("\n")) + RUBY + end + + def test_shareable_proc_receivers_without_isolation_check_env + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => nil}]) + [:shareable_proc, :shareable_lambda].each do |kind| + original = Ractor.public_send(kind) { self } + [nil, :replacement].each do |receiver| + callables = [Ractor.public_send(kind, self: receiver) { self }, + Ractor.public_send(kind, self: receiver, &original)] + callables.each do |callable| + assert_same receiver, callable.call + assert Ractor.shareable?(callable) + assert callable.frozen? + assert_equal kind == :shareable_lambda, callable.lambda? + end + end + assert_nil original.call + + captured = [] + assert_raise(Ractor::IsolationError) do + Ractor.public_send(kind, self: :replacement) { captured } + end + assert_raise(Ractor::IsolationError) do + Ractor.public_send(kind, self: Object.new) { self } + end + end + + callable = Object.new.instance_eval { proc { self } } + error = assert_raise(Ractor::IsolationError) { Ractor.make_shareable(callable) } + assert_equal "Proc's self is not shareable: #{callable}", error.message + RUBY + end + + def test_isolation_check_does_not_keep_shareability_when_rebinding_proc + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") + results = Ractor.new do + [:shareable_proc, :shareable_lambda].map do |kind| + original = Ractor.public_send(kind) { self } + receiver = Object.new + copy = Ractor.public_send(kind, self: receiver, &original) + [kind, original, receiver, copy] + end + end.value + + results.each do |kind, original, receiver, copy| + assert_same receiver, copy.call + assert_equal kind == :shareable_lambda, copy.lambda? + refute Ractor.shareable?(copy) + refute copy.frozen? + copy.instance_variable_set(:@state, :mutable) + assert_equal :mutable, copy.instance_variable_get(:@state) + assert Ractor.shareable?(original) + assert original.frozen? + assert_nil original.call + end + assert_match(/Proc's self is not shareable/, RactorIsolationWarnings.drain.join("\n")) + RUBY + end + + def test_isolation_check_warns_and_executes_captured_define_method + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") + captured = [] + klass = Class.new + klass.define_method(:capture) { captured << :called; captured } + + result = Ractor.new(klass) { |k| k.new.capture }.value + assert_same captured, result + assert_equal [:called], captured + assert_match(/can not call method capture defined with an un-shareable Proc/, + RactorIsolationWarnings.drain.join("\n")) + RUBY + end + + def test_isolation_check_is_active_in_child_threads + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") + class CheckIsolationChildThreadFixture + VALUE = [] + end + + value = Ractor.new do + Thread.new { CheckIsolationChildThreadFixture::VALUE }.value + end.value + assert_same CheckIsolationChildThreadFixture::VALUE, value + assert_match(/non-shareable objects in constant CheckIsolationChildThreadFixture::VALUE/, + RactorIsolationWarnings.drain.join("\n")) + RUBY + end + + def test_isolation_check_applies_to_nested_and_later_ractors + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) + nested, returned = Ractor.new do + captured = Object.new + [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_ISOLATION" => nil}]) + captured = Object.new + assert_raise(Ractor::IsolationError) do + Ractor.new { captured } + end + 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_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_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_ISOLATION" => "1"}], ignore_stderr: true, require_relative: "ractor_isolation_helper") + object = Object.new + finalizer = proc {} + + defined, undefined = Ractor.new do + [ObjectSpace.define_finalizer(object, finalizer), + ObjectSpace.undefine_finalizer(object)] + end.value + assert_same finalizer, defined[1] + assert_same object, undefined + + combined = RactorIsolationWarnings.drain.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_isolation_check_warns_when_undefining_finalizers_on_frozen_foreign_objects + omit 'per-Ractor objspace semantics of the default GC' unless GC.config[:implementation] == 'default' + [1, 2].each do |level| + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => level.to_s}], ignore_stderr: true, require_relative: "ractor_isolation_helper") + object = Object.new.freeze + result = Ractor.new(object) { |obj| ObjectSpace.undefine_finalizer(obj) }.value + assert_same object, result + assert_match(/can not undefine a finalizer of an object of another Ractor/, + RactorIsolationWarnings.drain.join("\n")) + + [object, nil, true, false, 1, 1.5, :symbol].each do |obj| + assert_raise(FrozenError) { ObjectSpace.undefine_finalizer(obj) } + end + RUBY + end + end + + def test_isolation_check_reraises_block_exceptions + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) + error = assert_raise(Ractor::RemoteError) do + Ractor.new { raise "boom" }.value + end + assert_equal "boom", error.cause.message + RUBY + end + + def test_isolation_check_allows_dispatch_to_main + assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) + main_port = Ractor::Port.new + Thread.new do + callable, reply = main_port.receive + reply << callable.call + end + + value = Ractor.new do + reply = Ractor::Port.new + main_port << [Ractor.shareable_proc { 40 + 2 }, reply] + reply.receive + end.value + assert_equal 42, value + RUBY + end + + def test_isolation_check_dedups_repeated_warnings + gvar_warning = /can not access global variable \$g/ + summary = /RUBY_RACTOR_ISOLATION: (\d+) repeated isolation warnings suppressed/ + env = {"RUBY_RACTOR_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 + + # level 2 reports every hit + assert_in_out_err([{"RUBY_RACTOR_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) + assert_empty stderr.grep(summary) + end + + # warnings raised inside 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(/ level.to_s}, "-W:no-experimental", "-e", source, kind] + assert_in_out_err(args, "", [], warnings, success: true) + end + end + + def test_isolation_check_dedups_by_message_and_source + src = <<~'RUBY' + $g = 1 + $h = 2 + Ractor.new do + 2.times do + ["first.rb", "second.rb"].each do |path| + eval('$g; $h', binding, path, 7) + end + GC.start + end + end.value + RUBY + assert_in_out_err([{"RUBY_RACTOR_ISOLATION" => "1"}, "-W:no-experimental", "-e", src]) do |_stdout, stderr| + warnings = stderr.grep(/can not access global variable/) + expected = ["first.rb", "second.rb"].product(["$g", "$h"]).map do |path, name| + "#{path}:7: warning: can not access global variable #{name} from non-main Ractor" + end + assert_equal expected, warnings + assert_include stderr, "RUBY_RACTOR_ISOLATION: 4 repeated isolation warnings suppressed" + end + end + + def test_isolation_check_warns_on_repeated_constant_reads + source = <<~'RUBY' + module IsolationConstantFixture + VALUE = [] + def self.read + VALUE + end + end + # Populate the same cache in the main Ractor before using it in a child. + 10.times { IsolationConstantFixture.read } + Ractor.new { 10.times { IsolationConstantFixture.read } }.value + RUBY + assert_isolation_constant_warnings(source) + end + + def test_isolation_check_warns_after_reenabling_constant_warnings + source = <<~'RUBY' + module IsolationConstantFixture + VALUE = [] + def self.read + VALUE + end + end + Ractor.new do + Warning[:ractor_isolation] = false + 10.times { IsolationConstantFixture.read } + Warning[:ractor_isolation] = true + 10.times { IsolationConstantFixture.read } + end.value + RUBY + assert_isolation_constant_warnings(source) + end + + def assert_isolation_constant_warnings(source) + require_relative '../lib/jit_support' + options = [[]] + options << %w[--yjit --yjit-call-threshold=1] if JITSupport.yjit_supported? + options << %w[--zjit --zjit-call-threshold=1] if JITSupport.zjit_supported? + + options.each do |jit_options| + [1, 2].each do |level| + env = {"RUBY_RACTOR_ISOLATION" => level.to_s} + args = [env, *jit_options, "-W:no-experimental", "-e", source] + assert_in_out_err(args, success: true) do |_stdout, stderr| + warnings = stderr.grep(/non-shareable objects in constant IsolationConstantFixture::VALUE/) + assert_equal level == 1 ? 1 : 10, warnings.size, "#{jit_options.inspect}, level #{level}" + if level == 1 + assert_include stderr, "RUBY_RACTOR_ISOLATION: 9 repeated isolation warnings suppressed" + end + end + end + end + end + + def test_isolation_check_serializes_ractors_or_warns_at_boot + advisory = /RUBY_RACTOR_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_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 + 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_shares_native_thread_with_main + [1, 2].product([nil, "-1", "0", "1", "2"]).each do |level, mn_threads| + env = {"RUBY_RACTOR_ISOLATION" => level.to_s, "RUBY_MN_THREADS" => mn_threads, "RUBY_MAX_CPU" => "4"} + assert_ractor(<<~'RUBY', args: [env], ignore_stderr: true) + omit "M:N scheduling is not supported by this build" unless RUBY_DESCRIPTION.include?("+MN") + omit "native_thread_id is not supported" unless Thread.current.respond_to?(:native_thread_id) + + main_id = Thread.current.native_thread_id + child_id = Ractor.new { Thread.current.native_thread_id }.value + refute_nil main_id + assert_equal main_id, child_id + RUBY + end + end + + def test_isolation_check_blocks_other_ractors + assert_separately([{"RUBY_RACTOR_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 + 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.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.value + + 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 the isolation check, observed #{during} ticks" + RUBY + end +end diff --git a/thread.c b/thread.c index 0ded7c0e0f5061..1915a259458247 100644 --- a/thread.c +++ b/thread.c @@ -664,8 +664,10 @@ thread_do_start_proc(rb_thread_t *th) vm_check_ints_blocking(th->ec); - return rb_vm_invoke_proc( - th->ec, proc, + VALUE self = th->invoke_type == thread_invoke_type_ractor_proc ? + rb_ractor_self(th->ractor) : vm_block_self(&proc->block); + return rb_vm_invoke_proc_with_self( + th->ec, proc, self, args_len, args_ptr, th->invoke_arg.proc.kw_splat, VM_BLOCK_HANDLER_NONE, diff --git a/thread_sched.c b/thread_sched.c index 1ed28180277694..47efa4c532e9e7 100644 --- a/thread_sched.c +++ b/thread_sched.c @@ -1839,8 +1839,8 @@ ruby_mn_threads_params(void) // here, so it is set rather than defaulted. const char *mn_threads_cstr = getenv("RUBY_MN_THREADS"); int mn_threads = (USE_MN_THREADS && mn_threads_cstr) ? atoi(mn_threads_cstr) : 0; - if (exclusive && mn_threads < 1) { - mn_threads = 1; + if (exclusive && mn_threads < 2) { + mn_threads = 2; } mn_threads_mode = mn_threads; diff --git a/variable.c b/variable.c index 7f1b9798450e66..2924c0cfbbbfe9 100644 --- a/variable.c +++ b/variable.c @@ -656,10 +656,10 @@ global_entry_lookup(ID id, bool create_entry, bool *isolation_error) } static struct rb_global_entry* -rb_find_global_entry(ID id) +rb_global_entry(ID id) { bool isolation_error; - struct rb_global_entry *entry = global_entry_lookup(id, false, &isolation_error); + struct rb_global_entry *entry = global_entry_lookup(id, true, &isolation_error); if (isolation_error) global_entry_isolation_error(id); @@ -669,29 +669,29 @@ rb_find_global_entry(ID id) void rb_gvar_ractor_local(const char *name) { - struct rb_global_entry *entry = rb_find_global_entry(rb_intern(name)); + struct rb_global_entry *entry = rb_global_entry(rb_intern(name)); entry->ractor_local = true; } void rb_gvar_box_ready(const char *name) { - struct rb_global_entry *entry = rb_find_global_entry(rb_intern(name)); + struct rb_global_entry *entry = rb_global_entry(rb_intern(name)); entry->var->box_ready = true; } void rb_gvar_box_dynamic(const char *name) { - struct rb_global_entry *entry = rb_find_global_entry(rb_intern(name)); + struct rb_global_entry *entry = rb_global_entry(rb_intern(name)); entry->var->box_dynamic = true; } static struct rb_global_entry* -rb_global_entry(ID id) +rb_find_global_entry(ID id) { bool isolation_error; - struct rb_global_entry *entry = global_entry_lookup(id, true, &isolation_error); + struct rb_global_entry *entry = global_entry_lookup(id, false, &isolation_error); if (isolation_error) global_entry_isolation_error(id); diff --git a/vm.c b/vm.c index 73698dbae1d87e..dfb9a3b0fb67a1 100644 --- a/vm.c +++ b/vm.c @@ -1735,7 +1735,9 @@ rb_proc_ractor_make_shareable(VALUE self, VALUE replace_self) bool can_isolate = 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); + VALUE message = mode == PROC_ISOLATION_WARN_ONLY ? + rb_str_new_cstr("Proc's self is not shareable") : + rb_sprintf("Proc's self is not shareable: %" PRIsVALUE, self); proc_isolation_violation_str(message, mode); can_isolate = false; } @@ -1763,7 +1765,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)) { - VALUE message = rb_sprintf("Proc's self is not shareable: %" PRIsVALUE, self); + VALUE message = mode == PROC_ISOLATION_WARN_ONLY ? + rb_str_new_cstr("Proc's self is not shareable") : + rb_sprintf("Proc's self is not shareable: %" PRIsVALUE, self); proc_isolation_violation_str(message, mode); return self; } diff --git a/vm_insnhelper.c b/vm_insnhelper.c index dd2b380f6840e3..a1f4c78dc890ff 100644 --- a/vm_insnhelper.c +++ b/vm_insnhelper.c @@ -4120,8 +4120,8 @@ vm_call_attrset(rb_execution_context_t *ec, rb_control_frame_t *cfp, struct rb_c static inline void vm_bmethod_check_ractor(rb_execution_context_t *ec, const rb_callable_method_entry_t *cme, VALUE procv) { - if (RB_OBJ_SHAREABLE_P(procv) || - cme->def->body.bmethod.defined_ractor_id == rb_ec_ractor_id(ec)) { + if (RB_LIKELY(RB_OBJ_SHAREABLE_P(procv) || + cme->def->body.bmethod.defined_ractor_id == rb_ec_ractor_id(ec))) { return; } From 60d30756efd5543e399d85e233eebe57af72b0e4 Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Wed, 23 Sep 2026 17:53:27 +0400 Subject: [PATCH 20/21] Fix isolation warning test on builds without M:N threading --- test/ruby/test_ractor_isolation_check.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/ruby/test_ractor_isolation_check.rb b/test/ruby/test_ractor_isolation_check.rb index 93789fa052f6aa..b489b98d5eb92e 100644 --- a/test/ruby/test_ractor_isolation_check.rb +++ b/test/ruby/test_ractor_isolation_check.rb @@ -627,7 +627,13 @@ def test_isolation_check_dedups_proc_self_warnings warnings = ["-e:2: warning: Proc's self is not shareable"] * (level == 1 ? 1 : 3) warnings << "RUBY_RACTOR_ISOLATION: 2 repeated isolation warnings suppressed" if level == 1 args = [{"RUBY_RACTOR_ISOLATION" => level.to_s}, "-W:no-experimental", "-e", source, kind] - assert_in_out_err(args, "", [], warnings, success: true) + assert_in_out_err(args) do |stdout, stderr, status| + assert_predicate status, :success? + assert_empty stdout + # The startup advisory on non-M:N builds is checked separately. + stderr.reject! { |line| line.start_with?("warning: RUBY_RACTOR_ISOLATION: this build has no M:N scheduling,") } + assert_equal warnings, stderr + end end end From fcb05ee7e5febc0a2667909c655e793828d3a60e Mon Sep 17 00:00:00 2001 From: Yaroslav Markin Date: Wed, 30 Sep 2026 09:36:12 +0400 Subject: [PATCH 21/21] Run explicit GC.start globally under RUBY_RACTOR_ISOLATION --- gc/default/default.c | 3 ++ test/ruby/test_ractor_isolation_check.rb | 35 ++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/gc/default/default.c b/gc/default/default.c index 0cfdb289b4201d..8e6706dc2f1279 100644 --- a/gc/default/default.c +++ b/gc/default/default.c @@ -10256,6 +10256,9 @@ rb_gc_impl_start(void *objspace_ptr, bool full_mark, bool immediate_mark, bool i global = false; } + /* Isolation checking never collects one objspace alone (see gc_need_global_p). */ + if (rb_gc_vm_global_gc_only_p()) global = true; + if (global && !rb_gc_single_objspace_p()) { /* A mid-cycle driver is settled by gc_start_global itself: it aborts the partial * mark and finishes the lazy sweep, so the dead slots are T_NONE before the diff --git a/test/ruby/test_ractor_isolation_check.rb b/test/ruby/test_ractor_isolation_check.rb index b489b98d5eb92e..e7b2671a282245 100644 --- a/test/ruby/test_ractor_isolation_check.rb +++ b/test/ruby/test_ractor_isolation_check.rb @@ -73,6 +73,41 @@ def test_isolation_check_keeps_messages_alive_past_sender_exit RUBY end + EXPLICIT_GC_VARIANTS = [{}, {global: false}, {full_mark: false}, {immediate_mark: false}, {immediate_sweep: false}] + + # an explicit GC.start must not collect one objspace alone either + def test_isolation_check_runs_explicit_gc_start_globally + omit "no GC.stat(:global_gc_count)" unless GC.stat.key?(:global_gc_count) + assert_ractor(<<~"RUBY", args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) + variants = #{EXPLICIT_GC_VARIANTS.inspect} + in_child = Ractor.new(variants) do |vs| + vs.map { |kw| before = GC.stat(:global_gc_count); GC.start(**kw); GC.stat(:global_gc_count) - before } + end.value + assert_equal [1] * variants.size, in_child.map { _1.clamp(0, 1) }, variants.inspect + + r = Ractor.new { Ractor.receive } + in_main = variants.map { |kw| before = GC.stat(:global_gc_count); GC.start(**kw); GC.stat(:global_gc_count) - before } + r.send(:done).value + assert_equal [1] * variants.size, in_main.map { _1.clamp(0, 1) }, variants.inspect + RUBY + end + + def test_isolation_check_keeps_child_objects_alive_across_explicit_gc_start + EXPLICIT_GC_VARIANTS.each do |kw| + assert_ractor(<<~"RUBY", args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) + sink = [] + Ractor.new(sink) do |s| + 20_000.times { |i| s << "payload-\#{i}-" + "x" * 32 } + GC.start(**#{kw.inspect}) + 20_000.times { |i| "reuse-\#{i}-" + "y" * 32 } + nil + end.value + bad = sink.each_with_index.count { |s, i| !(String === s) || !s.start_with?("payload-\#{i}-") } + assert_equal 0, bad, #{kw.inspect.dump} + RUBY + end + end + def test_isolation_check_passes_args_and_closes_over_outer_variables assert_ractor(<<~'RUBY', args: [{"RUBY_RACTOR_ISOLATION" => "1"}], ignore_stderr: true) outer = [1, 2, 3]