diff --git a/class.c b/class.c index 6cde1e16f13e52..48ef765ae17130 100644 --- a/class.c +++ b/class.c @@ -686,9 +686,8 @@ void rb_class_owner_check(VALUE klass) { if (UNLIKELY(!rb_class_owned_p(klass))) { - rb_raise(rb_eRactorIsolationError, - "can not modify %"PRIsVALUE" because it is created by another Ractor", - class_owner_name(klass)); + rb_ractor_isolation_violation("can not modify %"PRIsVALUE" because it is created by another Ractor", + class_owner_name(klass)); } } @@ -1009,9 +1008,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; } @@ -1020,9 +1019,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; } @@ -2447,8 +2446,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/cont.c b/cont.c index 9c29d509290f1c..9b46d0b7cceaf1 100644 --- a/cont.c +++ b/cont.c @@ -2317,6 +2317,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/error.c b/error.c index 500a27619f8a4d..0a9017ce53c3ff 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 under RUBY_RACTOR_ISOLATION + * (downgraded from Ractor::IsolationError exceptions to warnings). */ static VALUE @@ -3926,6 +3931,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(); @@ -3939,6 +3945,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); @@ -3946,6 +3953,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 f618f4569c452f..c7d255b335ca18 100644 --- a/gc.c +++ b/gc.c @@ -2176,8 +2176,13 @@ undefine_final(VALUE os, VALUE obj) VALUE rb_undefine_finalizer(VALUE obj) { - rb_check_frozen(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; @@ -2298,7 +2303,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); @@ -4393,6 +4404,15 @@ rb_gc_vm_ractor_count(void) return GET_VM()->ractor.cnt; } +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_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 ecf924d707c3c8..8e6706dc2f1279 100644 --- a/gc/default/default.c +++ b/gc/default/default.c @@ -7005,11 +7005,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: " @@ -7079,6 +7081,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; @@ -8668,6 +8672,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; @@ -8821,6 +8831,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; /* A Ractor's death must not stop the world, so the retire GC stays local. */ if (objspace->flags.during_postmortem) return false; if (objspace->shareable_objects > objspace->shareable_objects_limit) return true; @@ -10245,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/gc/gc.h b/gc/gc.h index c6b92dfd7f2955..b0c25f1df97915 100644 --- a/gc/gc.h +++ b/gc/gc.h @@ -82,6 +82,7 @@ MODULAR_GC_FN bool rb_gc_registered_addr_owned_by_registrant_p(VALUE *addr, void MODULAR_GC_FN bool rb_gc_vm_zombie_objspace_p(void *objspace); 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/include/ruby/internal/error.h b/include/ruby/internal/error.h index 5bf82bfe7d632e..2acbadc5ee93d9 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 under RUBY_RACTOR_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 98ab7a34d8f433..c233462259291a 100644 --- a/process.c +++ b/process.c @@ -4062,7 +4062,11 @@ 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"); + + // only reached in check mode; fork would drop every other Ractor's thread + errno = EPERM; + return -1; } struct rb_process_status child = {.status = 0}; diff --git a/ractor.c b/ractor.c index c68b5f9d0e9664..d07c1711be3527 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" @@ -1914,6 +1915,11 @@ 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_ractor_isolation_violation("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); } @@ -1923,6 +1929,11 @@ make_shareable_check_shareable(VALUE obj) RB_OBJ_SET_SHAREABLE(obj); return traverse_skip; } + else if (rb_ractor_isolation_check_p()) { + 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 { rb_raise(rb_eRactorError, "can not make shareable object for %+"PRIsVALUE" because it refers unshareable objects", obj); @@ -1930,7 +1941,12 @@ 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_ractor_isolation_violation("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); @@ -1994,9 +2010,8 @@ 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 mode this only warned; the caller's "shareable" invariant is knowingly broken } return obj; } @@ -2005,7 +2020,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); } } @@ -4052,13 +4067,12 @@ 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); + // 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); } + VALUE proc = is_lambda ? rb_block_lambda() : rb_block_proc(); + return rb_proc_ractor_make_shareable_copy(proc, replace_self); } // Ractor#require @@ -4270,6 +4284,185 @@ rb_ractor_autoload_load(VALUE module, ID name) } } +// RUBY_RACTOR_ISOLATION: non-main Ractors warn on isolation violations +// instead of raising, so the isolation check can report further violations. + +bool +rb_ractor_isolation_check_p_slowpath(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 != rb_ec_vm_ptr(ec)->ractor.main_ractor; +} + +// 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; + +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) +{ + if (GET_EC()->ractor_isolation_warning) return false; + + 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 VALUE +isolation_source_location(int *line) +{ + const rb_execution_context_t *ec = GET_EC(); + + for (const rb_control_frame_t *cfp = ec->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; +} + +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_clear_guard, (VALUE)ec); +} + +void +rb_ractor_isolation_warning_summary(void) +{ + if (isolation_warn_suppressed) { + fprintf(stderr, "RUBY_RACTOR_ISOLATION: %lu repeated isolation warnings suppressed\n", + isolation_warn_suppressed); + } +} + +void +rb_ractor_isolation_violation_str(VALUE message) +{ + if (rb_ractor_isolation_check_p()) { + rb_ractor_isolation_warn(message); + return; + } + + rb_exc_raise(rb_exc_new_str(rb_eRactorIsolationError, message)); +} + +void +rb_ractor_isolation_violation(const char *fmt, ...) +{ + if (rb_ractor_isolation_check_p() && !isolation_warnings_enabled_p()) return; + + va_list args; + va_start(args, fmt); + VALUE message = rb_vsprintf(fmt, args); + va_end(args); + + rb_ractor_isolation_violation_str(message); +} + VALUE rb_builtin_shareable_proc(rb_execution_context_t *ec, VALUE self, VALUE arg_self) { diff --git a/ractor_core.h b/ractor_core.h index ecf28f1827279f..d9e504fa620367 100644 --- a/ractor_core.h +++ b/ractor_core.h @@ -263,6 +263,16 @@ 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); +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); +void rb_ractor_isolation_violation_str(VALUE message); +/* Always warns (deduplicated at level 1); for sites that run in the parent Ractor. */ +void rb_ractor_isolation_warn(VALUE message); +void rb_ractor_isolation_warning_summary(void); + RUBY_SYMBOL_EXPORT_BEGIN void rb_ractor_finish_marking(bool full_mark); @@ -285,6 +295,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) { @@ -398,6 +415,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_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 067bfe53e14d40..cb166d81738a8d 100644 --- a/ractor_sync.c +++ b/ractor_sync.c @@ -1118,6 +1118,15 @@ 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()) { + // 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))); + *ptype = basket_type_ref; + return obj; + } else { /* Snapshot the object on the sender side without calling the user-visible * #clone. The courier is off-heap, so an in-flight payload is never a GC diff --git a/ruby.c b/ruby.c index e1164da05c3b13..7acaff4c4d70f8 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/ractor_isolation_helper.rb b/test/ruby/ractor_isolation_helper.rb new file mode 100644 index 00000000000000..8a90b53183e153 --- /dev/null +++ b/test/ruby/ractor_isolation_helper.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module RactorIsolationWarnings + # A shareable queue receives warnings from the Ractor and its child threads. + QUEUE = Thread::Queue.new + + def warn(message, category: nil) + if category == :ractor_isolation + QUEUE << Ractor.make_shareable(message) + return nil + end + super + end + + def self.drain + messages = [] + messages << QUEUE.pop until QUEUE.empty? + messages + end +end + +Warning.singleton_class.prepend(RactorIsolationWarnings) diff --git a/test/ruby/test_ractor_isolation_check.rb b/test/ruby/test_ractor_isolation_check.rb new file mode 100644 index 00000000000000..e7b2671a282245 --- /dev/null +++ b/test/ruby/test_ractor_isolation_check.rb @@ -0,0 +1,819 @@ +# 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 + + 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] + 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) 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 + + 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/test/ruby/test_rubyoptions.rb b/test/ruby/test_rubyoptions.rb index 2df2cf6417c914..4d300131bbf50a 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 41f15f7f726e42..0206049fc1436f 100644 --- a/thread.c +++ b/thread.c @@ -156,6 +156,8 @@ 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_isolation_enabled; + /********************************************************************************/ #define THREAD_SYSTEM_DEPENDENT_IMPLEMENTATION @@ -629,45 +631,48 @@ 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); - VM_ASSERT(FIXNUM_P(args)); - 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); + 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_with_self( + th->ec, proc, self, + args_len, args_ptr, + th->invoke_arg.proc.kw_splat, + VM_BLOCK_HANDLER_NONE, + cref + ); + } + } - 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 - ); + // 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); + 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 - ); - } + 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, + cref + ); } static VALUE @@ -941,9 +946,17 @@ 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 (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; + 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: @@ -991,7 +1004,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 (!ruby_ractor_isolation_enabled) { + rb_ractor_send_parameters(ec, params->g, params->args); + } } EC_POP_TAG(); if (state != TAG_NONE) { @@ -1222,7 +1237,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 b73eb1fcf6dc5a..47efa4c532e9e7 100644 --- a/thread_sched.c +++ b/thread_sched.c @@ -1813,6 +1813,15 @@ thread_sched_atfork(struct rb_thread_sched *sched) #endif extern int ruby_mn_threads_enabled; +extern int ruby_ractor_isolation_enabled; + +static int +ractor_isolation_env_level(void) +{ + const char *cstr = getenv("RUBY_RACTOR_ISOLATION"); + int level = cstr ? atoi(cstr) : 0; + return level > 0 ? level : 0; +} void ruby_mn_threads_params(void) @@ -1820,12 +1829,19 @@ ruby_mn_threads_params(void) rb_vm_t *vm = GET_VM(); rb_ractor_t *main_ractor = GET_RACTOR(); + // boot precedes the first Ractor, so check mode can pin the scheduler itself + 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 // thread_sched_main_to_shared). The main Ractor's sched already exists // 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 < 2) { + mn_threads = 2; + } mn_threads_mode = mn_threads; if (mn_threads > 0) { @@ -1845,6 +1861,12 @@ 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; + } + vm->ractor.sched.max_cpu = max_cpu; #if USE_MN_THREADS @@ -1852,6 +1874,13 @@ ruby_mn_threads_params(void) thread_sched_main_to_shared(GET_THREAD()); } #endif + + if (ruby_ractor_isolation_enabled && !exclusive) { + // fprintf, not rb_warn: the mode announcement must survive -W0 + 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"); + } } static void diff --git a/variable.c b/variable.c index 4d9ac0c4db6e35..778557e991bc3a 100644 --- a/variable.c +++ b/variable.c @@ -606,16 +606,15 @@ rb_gvar_undef_compactor(void *var) { } -NORETURN(static void global_entry_isolation_error(ID id)); - static void global_entry_isolation_error(ID id) { - 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)); } -/* Sets *isolation_error when the caller must raise; the caller has to do that - * once it no longer holds the VM lock. */ +/* Sets *isolation_error when the caller must report it; the caller has to do + * that once it no longer holds the VM lock. In check mode the report only + * warns, so the entry is still created and the caller carries on with it. */ static struct rb_global_entry* global_entry_lookup(ID id, bool create_entry, bool *isolation_error) { @@ -633,7 +632,7 @@ global_entry_lookup(ID id, bool create_entry, bool *isolation_error) *isolation_error = UNLIKELY(!rb_ractor_main_p()) && (!entry || !entry->ractor_local); - if (!entry && create_entry && !*isolation_error) { + if (!entry && create_entry && (!*isolation_error || rb_ractor_isolation_check_p())) { struct rb_global_variable *var = ALLOC(struct rb_global_variable); entry = ALLOC(struct rb_global_entry); entry->id = id; @@ -659,10 +658,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); @@ -672,29 +671,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); @@ -1070,7 +1069,7 @@ rb_gvar_set(ID id, VALUE val) RB_VM_LOCKING() { entry = global_entry_lookup(id, true, &isolation_error); - if (!isolation_error && gvar_use_box_tbl(box, entry)) { + if ((!isolation_error || rb_ractor_isolation_check_p()) && gvar_use_box_tbl(box, entry)) { use_box_tbl = true; rb_hash_aset(box->gvar_tbl, rb_id2sym(entry->var->id), val); retval = val; @@ -1108,7 +1107,7 @@ rb_gvar_get(ID id) // TODO: use lock-free rb_id_table when it's available for use (doesn't yet exist) entry = global_entry_lookup(id, true, &isolation_error); - if (!isolation_error) { + if (!isolation_error || rb_ractor_isolation_check_p()) { var = entry->var; if (gvar_use_box_tbl(box, entry)) { @@ -1202,7 +1201,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 */ @@ -1237,7 +1236,7 @@ rb_alias_variable(ID name1, ID name2) bool tracer_error = false; 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() { @@ -1276,7 +1275,7 @@ class_ivar_set_ractor_check(VALUE klass, ID id) { if (rb_is_instance_id(id) && // check only normal ivars UNLIKELY(!rb_class_owned_p(klass))) { - rb_raise(rb_eRactorIsolationError, "can not set instance variables of classes/modules created by another Ractor"); + rb_ractor_isolation_violation("can not set instance variables of classes/modules created by another Ractor"); } } @@ -1286,9 +1285,10 @@ static void cvar_set_ractor_check(VALUE klass, ID id) { if (UNLIKELY(!rb_class_owned_p(klass))) { - rb_raise(rb_eRactorIsolationError, + // rb_class_path, not klass: a user to_s could recurse into this check + rb_ractor_isolation_violation( "can not set class variable %"PRIsVALUE" of %"PRIsVALUE", which was created by another Ractor", - rb_id2str(id), klass); + rb_id2str(id), rb_class_path(klass)); } } @@ -1296,9 +1296,9 @@ static void cvar_read_ractor_check(VALUE klass, ID id, VALUE val) { if (UNLIKELY(!rb_class_owned_p(klass)) && !rb_ractor_shareable_p(val)) { - rb_raise(rb_eRactorIsolationError, + rb_ractor_isolation_violation( "can not read non-shareable class variable %"PRIsVALUE" of %"PRIsVALUE", which was created by another Ractor", - rb_id2str(id), klass); + rb_id2str(id), rb_class_path(klass)); } } @@ -1624,12 +1624,11 @@ rb_ivar_lookup(VALUE obj, ID id, VALUE undef) if (is_class && val != undef && rb_is_instance_id(id)) { if (UNLIKELY(!rb_class_owned_p(obj)) && !rb_ractor_shareable_p(val)) { - rb_raise( - rb_eRactorIsolationError, + rb_ractor_isolation_violation( "can not get unshareable values from instance variables of classes/modules " "created by another Ractor (%"PRIsVALUE" from %"PRIsVALUE")", rb_id2str(id), - obj + rb_class_path(obj) ); } } @@ -1661,7 +1660,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_class_owned_p(obj)) && !rb_ractor_shareable_p(val)) { - rb_raise(rb_eRactorIsolationError, + rb_ractor_isolation_violation( "can not get unshareable values from instance variables of classes/modules created by another Ractor"); } @@ -3414,7 +3413,7 @@ rb_const_get_0(VALUE klass, ID id, int exclude, int recurse, int visibility) if (!UNDEF_P(c)) { if (UNLIKELY(!rb_class_owned_p(found_in))) { if (!rb_ractor_shareable_p(c)) { - rb_raise(rb_eRactorIsolationError, "can not access non-shareable objects in constant %"PRIsVALUE"::%"PRIsVALUE" of a class/module created by another Ractor.", rb_class_path(found_in), rb_id2str(id)); + rb_ractor_isolation_violation("can not access non-shareable objects in constant %"PRIsVALUE"::%"PRIsVALUE" of a class/module created by another Ractor.", rb_class_path(found_in), rb_id2str(id)); } } return c; @@ -3924,7 +3923,7 @@ const_set(VALUE klass, ID id, VALUE val) } if (UNLIKELY(!rb_class_owned_p(klass))) { - rb_raise(rb_eRactorIsolationError, "can not set constants of classes/modules created by another Ractor"); + rb_ractor_isolation_violation("can not set constants of classes/modules created by another Ractor"); } check_before_mod_set(klass, id, val, "constant"); diff --git a/version.c b/version.c index efffe8cdb3cc6c..d95ecd94925416 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) 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_isolation_enabled; + #ifndef RB_DEFAULT_PARSER #define RB_DEFAULT_PARSER RB_DEFAULT_PARSER_PRISM #endif diff --git a/vm.c b/vm.c index 726479a49f3ac9..a34f637717dd64 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,11 +1389,17 @@ 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_0(VALUE self) +{ + return proc_dup_0(self, true); +} + VALUE rb_proc_dup(VALUE self) { @@ -1471,8 +1477,24 @@ 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, enum proc_isolation_mode mode) +{ + if (mode == PROC_ISOLATION_WARN_ONLY) { + rb_ractor_isolation_warn(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, 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); @@ -1512,20 +1534,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, mode); + *can_isolate = 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 (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)); + } + 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, mode); + *can_isolate = false; } RB_OBJ_WRITE((VALUE)copied_env, &env_body[j], v); rb_ary_delete_at(read_only_variables, i); @@ -1537,7 +1569,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, 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); @@ -1553,17 +1585,22 @@ 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 (*can_isolate) { + 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, 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); + 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); + return true; } static int @@ -1576,8 +1613,24 @@ proc_has_ivar_i(ID name, VALUE val, st_data_t arg) return ST_CONTINUE; } +static void +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 */ + 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"), mode); + } +} + 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, + enum proc_isolation_mode mode, bool *can_isolate) { struct collect_outer_variable_name_data data = { .isolate = isolate, @@ -1600,10 +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'." : "."); - rb_exc_raise(rb_exc_new_str(rb_eRactorIsolationError, str)); + proc_isolation_violation_str(str, mode); + if (can_isolate) *can_isolate = 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, mode); + if (can_isolate) *can_isolate = false; } return data.read_only; @@ -1625,23 +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"); + proc_shared_outer_variables(ISEQ_BODY(iseq)->outer_variables, true, "isolate a Proc", + PROC_ISOLATION_STRICT, NULL); } - proc_isolate_env(self, proc, Qfalse); + 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); } - /* 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, PROC_ISOLATION_STRICT); RB_OBJ_SET_SHAREABLE(self); return self; @@ -1655,34 +1704,59 @@ rb_proc_isolate(VALUE self) return dst; } +/* The checks of rb_proc_isolate_bang, reported as warnings, without mutating the Proc. */ +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", + PROC_ISOLATION_WARN_ONLY, NULL); + } + } + 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); + 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"); - 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 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 = 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; } 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", mode, &can_isolate); } - proc_isolate_env(self, proc, read_only_variables); + 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 (!can_isolate) return self; proc->header.is_isolated = TRUE; } else { @@ -1691,9 +1765,11 @@ 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 = 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; } } @@ -1701,6 +1777,17 @@ 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_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); +} + VALUE rb_vm_make_proc_lambda(const rb_execution_context_t *ec, const struct rb_captured_block *captured, VALUE klass, int8_t is_lambda) { @@ -3593,6 +3680,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) { diff --git a/vm_core.h b/vm_core.h index 40422f902c12a8..5fcde872a2042c 100644 --- a/vm_core.h +++ b/vm_core.h @@ -1119,6 +1119,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); @@ -1394,7 +1395,9 @@ 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); +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 581952e65716ce..a3ec35b3753a52 100644 --- a/vm_insnhelper.c +++ b/vm_insnhelper.c @@ -1145,7 +1145,7 @@ vm_get_ev_const(rb_execution_context_t *ec, VALUE orig_klass, ID id, bool allow_ else { if (UNLIKELY(!rb_class_owned_p(klass))) { if (!rb_ractor_shareable_p(val)) { - rb_raise(rb_eRactorIsolationError, + rb_ractor_isolation_violation( "can not access non-shareable objects in constant %"PRIsVALUE"::%"PRIsVALUE" of a class/module created by another Ractor.", rb_class_path(klass), rb_id2str(id)); } } @@ -1430,6 +1430,7 @@ static VALUE vm_setivar_class(VALUE obj, VALUE val, rb_setivar_cache cache) { if (UNLIKELY(!rb_class_owned_p(obj))) { + // leave the inline cache so the slow path runs the isolation check return Qundef; } @@ -3551,9 +3552,15 @@ 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()) { + // same category as IsolationError: to the caller both mean "not Ractor-safe" + rb_ractor_isolation_violation("ractor unsafe method called from not main ractor"); + return; } + + rb_raise(rb_eRactorUnsafeError, "ractor unsafe method called from not main ractor"); } static VALUE @@ -4111,6 +4118,25 @@ 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 void +vm_bmethod_check_ractor(rb_execution_context_t *ec, const rb_callable_method_entry_t *cme, VALUE procv) +{ + if (RB_LIKELY(RB_OBJ_SHAREABLE_P(procv) || + cme->def->body.bmethod.defined_ractor_id == rb_ec_ractor_id(ec))) { + return; + } + + 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)); + } + 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) { @@ -4120,10 +4146,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 (!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"); - } + vm_bmethod_check_ractor(ec, cme, procv); /* control block frame */ GetProcPtr(procv, proc); @@ -4143,10 +4166,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 (!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"); - } + vm_bmethod_check_ractor(ec, cme, procv); rb_proc_t *proc; GetProcPtr(procv, proc); @@ -6745,6 +6765,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);