diff --git a/NEWS.md b/NEWS.md index cf19a5393fd219..bede495de79303 100644 --- a/NEWS.md +++ b/NEWS.md @@ -225,6 +225,7 @@ They are still available on rubygems.org and can be installed with * 1.9.2 to [v1.9.3][win32ole-v1.9.3] * irb 1.18.0 * 1.16.0 to [v1.17.0][irb-v1.17.0], [v1.18.0][irb-v1.18.0] +* reline 0.7.0 ### RubyGems and Bundler @@ -302,6 +303,25 @@ The following APIs, which have been deprecated for many years, are removed. A lot of work has gone into making Ractors more stable, performant, and usable. These improvements bring Ractor implementation closer to leaving experimental status. +* The default GC now runs **per Ractor**: each Ractor collects its own heap + on its own thread without stopping the others, and a stop-the-world + collection only runs when it is really needed (explicit full `GC.start`, + shareable-object growth, reclaiming dead Ractors' heaps). Allocation-heavy + Ractor programs now scale like forked processes. + + Visible behavior changes: + + * `Ractor#value` returns the value only once; a second call raises + `Ractor::Error`. + * `GC.disable`/`GC.enable` act as per-Ractor holds on a process-wide + switch: one Ractor's `GC.enable` no longer overrides another Ractor's + `GC.disable`. + * `ObjectSpace.each_object` enumerates the calling Ractor's own objects + plus other Ractors' shareable objects (`ObjectSpace.dump_all` still + covers everything). + * `ObjectSpace.define_finalizer` on another Ractor's object raises + `Ractor::IsolationError`. + ## JIT [Feature #8948]: https://bugs.ruby-lang.org/issues/8948 diff --git a/ast.c b/ast.c index 11cce897274be5..721302c0304370 100644 --- a/ast.c +++ b/ast.c @@ -259,8 +259,42 @@ rb_ast_node_source_location(VALUE source, VALUE path, int first_lineno, return true; } +static VALUE +ast_node_find(rb_execution_context_t *ec, VALUE self, VALUE root, VALUE node_id) +{ + return node_find(root, NUM2INT(node_id)); +} + +static VALUE +ast_node_source_hash(rb_execution_context_t *ec, VALUE self, VALUE node) +{ + struct ASTNodeData *data; + TypedData_Get_Struct(node, struct ASTNodeData, &rb_node_type, data); + + rb_ast_t *ast = rb_ruby_ast_data_get(data->ast_value); + if (!ast->body.has_source_hash) return Qnil; + return ULL2NUM(ast->body.source_hash); +} + extern VALUE rb_e_script; +static VALUE +iseq_compiled_by_prism_p(rb_execution_context_t *ec, VALUE self) +{ + return RBOOL(ISEQ_BODY(rb_iseqw_to_iseq(self))->prism); +} + +static VALUE +source_hash_of(rb_execution_context_t *ec, VALUE self, VALUE str) +{ + StringValue(str); + + rb_source_hash_state_t state; + rb_source_hash_init(&state); + rb_source_hash_update(&state, (const uint8_t *)RSTRING_PTR(str), (size_t)RSTRING_LEN(str)); + return ULL2NUM(rb_source_hash_finalize(&state)); +} + static VALUE node_id_for_backtrace_location(rb_execution_context_t *ec, VALUE module, VALUE location) { @@ -278,6 +312,18 @@ node_id_for_backtrace_location(rb_execution_context_t *ec, VALUE module, VALUE l return INT2NUM(node_id); } +static VALUE +iseq_of_backtrace_location(rb_execution_context_t *ec, VALUE module, VALUE location) +{ + if (!rb_frame_info_p(location)) { + rb_raise(rb_eTypeError, "Thread::Backtrace::Location object expected"); + } + + const rb_iseq_t *iseq = rb_get_iseq_from_frame_info(location); + if (!iseq) return Qnil; + return rb_iseqw_new(iseq); +} + static VALUE ast_s_of(rb_execution_context_t *ec, VALUE module, VALUE body, VALUE keep_script_lines, VALUE error_tolerant, VALUE keep_tokens) { diff --git a/ast.rb b/ast.rb index 5785c77bb5eadf..6151ab5c50ae8f 100644 --- a/ast.rb +++ b/ast.rb @@ -332,3 +332,179 @@ def inspect end end end + +class RubyVM::InstructionSequence + # call-seq: + # iseq.syntax_tree -> Prism::Node | RubyVM::AbstractSyntaxTree::Node | nil + # + # Returns the AST node that this instruction sequence was compiled from, + # by re-parsing the source with the same parser that compiled it: a + # Prism::Node if it was compiled by prism, or a + # RubyVM::AbstractSyntaxTree::Node if it was compiled by parse.y. + # + # Returns +nil+ whenever the node cannot be retrieved reliably. For + # example: the source is not available (such as eval'ed code without + # RubyVM.keep_script_lines enabled), or the source file has been modified + # since it was compiled. + # + # When a prism gem other than the default gem is loaded, a warning is + # emitted in verbose mode. In that case, the loaded prism may parse the + # source differently from the parser that compiled the instruction + # sequence, and the returned node may not correspond to the code that + # was actually executed. + # + # This method is experimental and might change without notice. + def syntax_tree + source_hash = self.source_hash + return nil unless source_hash + + # When the source is kept in memory (RubyVM.keep_script_lines), use it + # instead of the file. This also works for eval'ed code. + if (lines = script_lines) + source = lines.join + else + path = absolute_path + return nil unless path && File.file?(path) + end + + node_id = self.node_id + if Primitive.iseq_compiled_by_prism_p + require "prism" + + # Only the default gem prism is the same parser as the one built into + # the interpreter. Another prism gem is still likely to parse the + # source in the same way, so continue with a warning. + if $VERBOSE && (spec = defined?(Gem) && Gem.loaded_specs["prism"]) && !spec.default_gem? + warn "syntax_tree: a prism gem other than the default gem is loaded; " \ + "the result may not correspond exactly to the compiled code" + end + + begin + result = source ? Prism.parse(source, version: "current") : Prism.parse_file(path, version: "current") + rescue ArgumentError + # The loaded prism does not know the grammar of the running Ruby. + return nil + end + return nil unless result.success? + + # Hash exactly the bytes that prism parsed. The data section after an + # __END__ marker is not part of the code, so the hash covers the source + # only up to the end of the __END__ line. + code = result.source.source + if (data_loc = result.data_loc) && (eol = code.byteindex("\n", data_loc.start_offset)) + code = code.byteslice(0, eol + 1) + end + return nil unless Primitive.source_hash_of(code) == source_hash + + root = result.value + else + begin + root = source ? RubyVM::AbstractSyntaxTree.parse(source, keep_script_lines: true) : + RubyVM::AbstractSyntaxTree.parse_file(path, keep_script_lines: true) + rescue SyntaxError + # The source has been modified into invalid Ruby. + return nil + end + return nil unless Primitive.ast_node_source_hash(root) == source_hash + + return Primitive.ast_node_find(root, node_id) + end + + return root if root.node_id == node_id + + queue = [root] + while (node = queue.shift) + node.compact_child_nodes.each do |child| + if child.node_id == node_id + # A block iseq refers to the block node itself. Return the outer + # node that owns the block (a CallNode, SuperNode, or + # ForwardingSuperNode) instead. + return child.type == :block_node ? node : child + end + queue << child + end + end + + nil + end +end + +class Proc + # call-seq: + # prc.syntax_tree -> Prism::Node | RubyVM::AbstractSyntaxTree::Node | nil + # + # Returns the AST node that this proc was compiled from. See + # RubyVM::InstructionSequence#syntax_tree for details and for when +nil+ + # is returned. + # + # This method is experimental and might change without notice. + def syntax_tree + RubyVM::InstructionSequence.of(self)&.syntax_tree + end +end + +class Method + # call-seq: + # meth.syntax_tree -> Prism::Node | RubyVM::AbstractSyntaxTree::Node | nil + # + # Returns the AST node that this method was compiled from. Returns + # +nil+ for methods not written in Ruby. See + # RubyVM::InstructionSequence#syntax_tree for other cases where +nil+ is + # returned. + # + # This method is experimental and might change without notice. + def syntax_tree + RubyVM::InstructionSequence.of(self)&.syntax_tree + end +end + +class UnboundMethod + # call-seq: + # meth.syntax_tree -> Prism::Node | RubyVM::AbstractSyntaxTree::Node | nil + # + # Returns the AST node that this method was compiled from. Returns + # +nil+ for methods not written in Ruby. See + # RubyVM::InstructionSequence#syntax_tree for other cases where +nil+ is + # returned. + # + # This method is experimental and might change without notice. + def syntax_tree + RubyVM::InstructionSequence.of(self)&.syntax_tree + end +end + +class Thread::Backtrace::Location + # call-seq: + # location.syntax_tree -> Prism::Node | RubyVM::AbstractSyntaxTree::Node | nil + # + # Returns the AST node at this location, by re-parsing the source file. See + # RubyVM::InstructionSequence#syntax_tree for when +nil+ is returned. + # + # This method is experimental and might change without notice. + def syntax_tree + iseq = Primitive.iseq_of_backtrace_location(self) + return nil unless iseq + + node_id = Primitive.node_id_for_backtrace_location(self) + return nil unless node_id + + scope = iseq.syntax_tree + return nil unless scope + + if scope.is_a?(RubyVM::AbstractSyntaxTree::Node) + return Primitive.ast_node_find(scope, node_id) + end + + return scope if scope.node_id == node_id + + queue = [scope] + while (node = queue.shift) + node.compact_child_nodes.each do |child| + return child if child.node_id == node_id + queue << child + end + end + + nil + end +end diff --git a/bootstraptest/test_ractor.rb b/bootstraptest/test_ractor.rb index 0207773e8843cf..dd6531bc48138e 100644 --- a/bootstraptest/test_ractor.rb +++ b/bootstraptest/test_ractor.rb @@ -506,8 +506,9 @@ def test n obj.object_id == r.value } -# To copy the object, now Marshal#dump is used -assert_match /can't clone unshareable instance of Thread/, %q{ +# Copying an object uses the native copier or Marshal#dump; it never calls the +# user-visible #clone. +assert_match /can not copy Thread object/, %q{ obj = Thread.new{} begin r = Ractor.new obj do |msg| @@ -1270,12 +1271,14 @@ class C counts.inspect } -# ObjectSpace.each_object can not handle unshareable objects with Ractors -assert_equal '0', %q{ +# ObjectSpace.each_object enumerates the calling Ractor's own objects (unshareable ones +# included) and other Ractors' shareable objects, but never their unshareable ones. +assert_equal 'true', %q{ Ractor.new{ - n = 0 - ObjectSpace.each_object{|o| n += 1 unless Ractor.shareable?(o)} - n + own = Object.new + seen = false + ObjectSpace.each_object{|o| seen = true if o.equal?(own)} + seen }.value } @@ -2477,8 +2480,8 @@ def initialize(a) ret == [1, 2, ret.object_id] } -# Only one Ractor can call Ractor#value -assert_equal '[["Only the successor ractor can take a value", 9], ["ok", 2]]', %q{ +# Only one Ractor can call Ractor#value, and only once +assert_equal '[["Only the successor ractor can take a value", 9], ["The value was already taken", 1], ["ok", 1]]', %q{ r = Ractor.new do 'ok' end @@ -2489,7 +2492,7 @@ def initialize(a) Ractor.new r do |r| begin Ractor.main << r.value - Ractor.main << r.value # this ractor can get same result + Ractor.main << r.value # the value is taken only once rescue Ractor::Error => e Ractor.main << e.message end @@ -2734,39 +2737,114 @@ def foo(a:, b:, c:) = super(a: a, b: b, c: c) r.value } -# Ractor::Port.allocate leaves the owner Ractor NULL, so every method reachable -# from Ruby must reject such a port instead of dereferencing it. [Bug #22214] +# A Ractor creation that fails (IsolationError) after the child objspace exists must clean up +# the creator's cover for it; otherwise a later global GC enumerates the dead child's objspace +# twice and reads the freed shell. assert_equal 'ok', %q{ - port = Ractor::Port.allocate - - messages = [ - -> { port.send(1) }, - -> { port << 1 }, - -> { port.receive }, - -> { port.close }, - -> { port.closed? }, - -> { port.inspect }, - -> { Ractor::Port.new.__send__(:initialize_copy, port) }, - -> { Ractor.new { :x }.monitor(port) }, - -> { Ractor.new { :x }.unmonitor(port) }, - -> { Ractor.select(port) }, - ].map do |blk| + x = 42 # capturing an outer local makes Ractor.new raise IsolationError + worker = Ractor.new { loop { break if Ractor.receive == :quit } } + begin + Ractor.new { x } + raise "isolation error did not fire" + rescue Ractor::IsolationError + end + 10.times { GC.start; 500.times { Object.new } } + worker.send(:quit) + worker.value + 100.times do |i| begin - blk.call - 'not raised' - rescue TypeError => e - e.message + Ractor.new { x } + raise "isolation error did not fire" + rescue Ractor::IsolationError + end + if (i % 20).zero? + Ractor.new { :ok }.value + GC.start end end + GC.start + :ok +} - messages.uniq == ['uninitialized Ractor::Port'] ? :ok : messages +# Moving a CoW shared-root string (a frozen root with an unshareable ivar) must not steal the +# root's buffer; that would leave the remaining sharers reading freed memory. +assert_equal 'ok', %q{ + 30.times do + r = Ractor.new do + v = Ractor.receive + v.bytesize + :done + end + f = "x" * 4096 + f.instance_variable_set(:@x, []) # unshareable ivar: moved rather than passed through + f.freeze + g = f.dup # shares f's buffer, making f a shared root + h = f[10, 3000] # a long substring shares the buffer too + r.send(f, move: true) + r.value + GC.start + 10.times { "z" * 4096 } + raise "sharer corrupted" unless g == "x" * 4096 && h == "x" * 3000 + end + :ok } -assert_equal 'uninitialized MyPort', %q{ - class MyPort < Ractor::Port; end - begin - MyPort.allocate.closed? - rescue TypeError => e - e.message +# Same for an array: a frozen array is a shared root without carrying the shared root flag, +# so its buffer belongs to the sharers and the move must not free it. +assert_equal 'ok', %q{ + 30.times do + r = Ractor.new do + v = Ractor.receive + v.size + :done + end + a = (1..100).to_a + a.instance_variable_set(:@x, []) # unshareable ivar: moved rather than passed through + a.freeze + b = a.dup # shares a's buffer, making a a shared root + c = a[10, 80] # a subseq shares the buffer too + r.send(a, move: true) + r.value + GC.start + 10.times { (1..100).to_a } + raise "sharer corrupted" unless b == (1..100).to_a && c == (11..90).to_a + end + :ok +} + +# Moving a String/Array/Hash subclass (an unshareable ivar sends it down the move path) must +# preserve the class rather than degrading it to the base class. +assert_equal '["MyStr", "MyAry", "MyHash"]', %q{ + class MyStr < String; end + class MyAry < Array; end + class MyHash < Hash; end + r = Ractor.new do + 3.times.map { Ractor.receive.class.name } + end + [MyStr.new("x"), (MyAry.new << 1), (h=MyHash.new; h[:a]=1; h)].each do |o| + o.instance_variable_set(:@x, []) # unshareable ivar sends it down the move path + r.send(o, move: true) + end + r.value.inspect +} + +# Moving an object with a singleton class must keep its singleton methods and reattach the +# rebuilt singleton class to the new object; otherwise it keeps pointing at the original the +# sender's attach invalidated. Covers T_OBJECT, String and Struct. +assert_equal '[[:obj, true], [:str, true], [:strct, true]]', %q{ + o = Object.new + def o.m; :obj end + s = +"str" + def s.m; :str end + st = Struct.new(:a).new(1) + def st.m; :strct end + r = Ractor.new do + 3.times.map do + v = Ractor.receive + GC.start + [v.m, v.method(:m).owner.attached_object.equal?(v)] + end end + [o, s, st].each { |x| r.send(x, move: true) } + r.value.inspect } diff --git a/cont.c b/cont.c index 016cc8f10f2924..5840c5b5d952bf 100644 --- a/cont.c +++ b/cont.c @@ -889,6 +889,19 @@ fiber_pool_stack_release(struct fiber_pool_stack * stack) if (DEBUG) fprintf(stderr, "fiber_pool_stack_release: %p used=%"PRIuSIZE"\n", stack->base, stack->pool->used); + /* Serialize pool access against other Ractors' acquires: a per-Ractor GC sweep can + * free a fiber without the VM lock. Releases are rare, so take it NO_BARRIER, + * never joining a forming global barrier. + * + * Two callers must not take it. VM destruct's free-at-exit walk is single-threaded + * and its thread structs are already freed, so looking the current Ractor up would + * read freed memory. A single objspace impl (mmtk) frees on its own GC thread, + * which has no execution context to look one up from at all -- and it stops the + * world, so nothing races us there. */ + unsigned int lev = 0; + const bool lock_here = !ruby_vm_during_cleanup && rb_current_execution_context(false) != NULL; + if (lock_here) RB_VM_LOCK_ENTER_LEV_NB(&lev); + // Copy the stack details into the vacancy area: vacancy->stack = *stack; // After this point, be careful about updating/using state in stack, since it's copied to the vacancy area. @@ -919,6 +932,8 @@ fiber_pool_stack_release(struct fiber_pool_stack * stack) fiber_pool_stack_free(&vacancy->stack); } #endif + + if (lock_here) RB_VM_LOCK_LEAVE_LEV_NB(&lev); } static inline void @@ -1034,11 +1049,9 @@ fiber_stack_release(rb_fiber_t * fiber) static void fiber_stack_release_locked(rb_fiber_t *fiber) { - if (!ruby_vm_during_cleanup) { - // We can't try to acquire the VM lock here because MMTK calls free in its own native thread which has no ec. - // This assertion will fail on MMTK but we currently don't have CI for debug releases of MMTK, so we can assert for now. - ASSERT_vm_locking_with_barrier(); - } + /* Called from GC finalization. With per-Ractor objspaces the sweep runs with + * no barrier and no VM lock, so the side that returns stacks to the pool + * (fiber_pool_stack_release) takes the lock. Do not assert the VM lock here. */ fiber_stack_release(fiber); } @@ -1307,12 +1320,11 @@ fiber_memsize(const void *ptr) const rb_fiber_t *fiber = ptr; size_t size = sizeof(*fiber); const rb_execution_context_t *saved_ec = &fiber->cont.saved_ec; - const rb_thread_t *th = rb_ec_thread_ptr(saved_ec); - /* - * vm.c::thread_memsize already counts th->ec->local_storage - */ - if (saved_ec->local_storage && fiber != th->root_fiber) { + /* thread_memsize in vm.c already accounts for a root fiber's local_storage. + * first_proc != 0 picks the non-root fibers without dereferencing the thread + * (equivalent to fiber != th->root_fiber). */ + if (saved_ec->local_storage && fiber->first_proc != 0) { size += rb_id_table_memsize(saved_ec->local_storage); size += rb_obj_memsize_of(saved_ec->storage); } diff --git a/ext/io/console/console.c b/ext/io/console/console.c index 05a4d684d5873a..80944aaef4eecf 100644 --- a/ext/io/console/console.c +++ b/ext/io/console/console.c @@ -2102,8 +2102,21 @@ InitVM_console(void) } { /* :nodoc: */ - cConmode = rb_define_class_under(rb_cIO, "ConsoleMode", rb_cObject); - rb_define_const(cConmode, "VERSION", rb_obj_freeze(rb_str_new_cstr(IO_CONSOLE_VERSION))); + VALUE mConsole = rb_define_module_under(rb_cIO, "Console"); + VALUE version = rb_obj_freeze(rb_str_new_cstr(IO_CONSOLE_VERSION)); + ID cid, deprecate_constant = rb_intern_const("deprecate_constant"); + rb_define_const(mConsole, "VERSION", version); + /* :nodoc: */ + cConmode = rb_define_class_under(mConsole, "Mode", rb_cObject); + + /* old internal names; do not use */ + cid = rb_intern_const("ConsoleMode"); + rb_const_set(rb_cIO, cid, cConmode); + rb_funcall(rb_cIO, deprecate_constant, 1, ID2SYM(cid)); + cid = rb_intern_const("VERSION"); + rb_const_set(cConmode, cid, version); + rb_funcall(cConmode, deprecate_constant, 1, ID2SYM(cid)); + rb_define_alloc_func(cConmode, conmode_alloc); rb_undef_method(cConmode, "initialize"); rb_define_method(cConmode, "initialize_copy", conmode_init_copy, 1); diff --git a/ext/json/parser/parser.c b/ext/json/parser/parser.c index e577c5883f39e1..1443095b67bb53 100644 --- a/ext/json/parser/parser.c +++ b/ext/json/parser/parser.c @@ -1082,7 +1082,8 @@ NOINLINE(static) VALUE json_string_unescape(JSON_ParserState *state, JSON_Parser return result; } -#define MAX_FAST_INTEGER_SIZE 18 +#define MAX_FAST_INTEGER_SIZE 19 +#define MAX_FAST_UINT64_SIZE 20 #define MAX_NUMBER_STACK_BUFFER 128 typedef VALUE (*json_number_decode_func_t)(const char *ptr); @@ -1117,11 +1118,30 @@ NOINLINE(static) VALUE json_decode_large_integer(const char *start, long len) static inline VALUE json_decode_integer(uint64_t mantissa, int mantissa_digits, bool negative, const char *start, const char *end) { - if (RB_LIKELY(mantissa_digits < MAX_FAST_INTEGER_SIZE)) { - if (negative) { + if (RB_LIKELY(mantissa_digits <= MAX_FAST_INTEGER_SIZE)) { + if (RB_LIKELY(!negative)) { + return UINT64T2NUM(mantissa); + } + + // For a negative number 19 digits in length, we only get half of the range, + // so ensure this negative number is less than INT64_MAX. + // + // Note: This does miss INT64_MIN as it's value is one past INT64_MAX + // when converted to a uint64_t. It will still be parsed correctly by + // falling through to json_decode_large_integer. + if (RB_LIKELY(mantissa <= (uint64_t)INT64_MAX)) { return INT64T2NUM(-((int64_t)mantissa)); } - return UINT64T2NUM(mantissa); + } + + if (!negative && mantissa_digits == MAX_FAST_UINT64_SIZE) { + // Not all 20 digit integers can be safely represented by a uint64_t but + // some can. The memcmp with uint64_max is safe as we've rejected leading + // zeros and we have guaranteed we're comparing it with a 20 digit number. + static const char uint64_max[] = "18446744073709551615"; + if (memcmp(end - MAX_FAST_UINT64_SIZE, uint64_max, MAX_FAST_UINT64_SIZE) <= 0) { + return UINT64T2NUM(mantissa); + } } return json_decode_large_integer(start, end - start); diff --git a/gc.c b/gc.c index 0664ae6ccfd046..af259e06bb599c 100644 --- a/gc.c +++ b/gc.c @@ -151,20 +151,6 @@ rb_gc_vm_unlock(unsigned int lev, const char *file, int line) rb_vm_lock_leave(&lev, file, line); } -unsigned int -rb_gc_cr_lock(const char *file, int line) -{ - unsigned int lev; - rb_vm_lock_enter_cr(GET_RACTOR(), &lev, file, line); - return lev; -} - -void -rb_gc_cr_unlock(unsigned int lev, const char *file, int line) -{ - rb_vm_lock_leave_cr(GET_RACTOR(), &lev, file, line); -} - unsigned int rb_gc_vm_lock_no_barrier(const char *file, int line) { @@ -248,30 +234,31 @@ rb_gc_event_hook(VALUE obj, rb_event_flag_t event) #endif } -void * -rb_gc_get_objspace(void) -{ - return GET_VM()->gc.objspace; -} +/* VM destruct's free-at-exit walk can free the thread and Ractor structs first, so + * resolving through the current Ractor would use freed memory; return the objspace + * stashed before the walk started. */ void -rb_gc_ractor_newobj_cache_foreach(void (*func)(void *cache, void *data), void *data) -{ - rb_ractor_t *r = NULL; - if (RB_LIKELY(ruby_single_main_ractor)) { - GC_ASSERT( - ccan_list_empty(&GET_VM()->ractor.set) || - (ccan_list_top(&GET_VM()->ractor.set, rb_ractor_t, vmlr_node) == ruby_single_main_ractor && - ccan_list_tail(&GET_VM()->ractor.set, rb_ractor_t, vmlr_node) == ruby_single_main_ractor) - ); +rb_gc_stash_cleanup_objspace(void) +{ + GET_VM()->gc.cleanup_objspace = rb_gc_get_objspace(); +} - func(ruby_single_main_ractor->newobj_cache, data); +void * +rb_gc_get_objspace(void) +{ + if (RB_UNLIKELY(ruby_vm_during_cleanup) && GET_VM()->gc.cleanup_objspace) { + return GET_VM()->gc.cleanup_objspace; } - else { - ccan_list_for_each(&GET_VM()->ractor.set, r, vmlr_node) { - func(r->newobj_cache, data); - } + rb_ractor_t *cr = rb_current_ractor_raw(false); + if (cr == NULL) { + /* A thread with no current Ractor (a GVL-less native thread freeing in + * thread_sched_reclaim, say) uses the main Ractor's objspace. */ + return GET_VM()->ractor.main_ractor->objspace; } + /* A live current Ractor always has an objspace. */ + RUBY_ASSERT(cr->objspace != NULL); + return cr->objspace; } void @@ -328,6 +315,30 @@ rb_gc_set_pending_interrupt(void) ec->interrupt_mask |= PENDING_INTERRUPT_MASK; } +/* Schedule an objspace's deferred finalizers. A global GC sweeps other Ractors' + * objspaces too, so target the owning Ractor rather than the sweeping driver. For an + * objspace with no live owner the untargeted fallback is only a wake-up: a zombie's + * entries move to the inheriting objspace in the absorb, which re-triggers there. */ +void +rb_gc_trigger_finalize_deferred(void *objspace, rb_postponed_job_handle_t pjob) +{ + rb_ractor_t *const cr = rb_current_ractor_raw(false); + if (cr == NULL || cr->objspace != objspace) { + /* Only a global GC (stop-the-world) or an absorb settle (under the VM lock) + * defers another objspace's finalizers, so ractor.set is stable here. */ + ASSERT_vm_locking(); + rb_vm_t *vm = GET_VM(); + rb_ractor_t *r; + ccan_list_for_each(&vm->ractor.set, r, vmlr_node) { + if (r->objspace == objspace) { + rb_postponed_job_trigger_for_ractor(pjob, r->pub.self); + return; + } + } + } + rb_postponed_job_trigger(pjob); +} + void rb_gc_unset_pending_interrupt(void) { @@ -392,7 +403,7 @@ void rb_vm_update_references(void *ptr); #define unless_objspace(objspace) \ void *objspace; \ rb_vm_t *unless_objspace_vm = GET_VM(); \ - if (unless_objspace_vm) objspace = unless_objspace_vm->gc.objspace; \ + if (unless_objspace_vm) objspace = rb_gc_get_objspace(); \ else /* return; or objspace will be warned uninitialized */ #define RMOVED(obj) ((struct RMoved *)(obj)) @@ -588,6 +599,16 @@ rb_gc_guarded_ptr_val(volatile VALUE *ptr, VALUE val) static const char *obj_type_name(VALUE obj); +/* A forking parent can hold registered_globals.lock (every Ractor's root scan takes + * it); inheriting it locked would make the child's first GC wait forever, so rebuild + * it, like the generic_fields lock. */ +void +rb_gc_atfork_global_locks(void) +{ + rb_vm_t *vm = GET_VM(); + rb_native_mutex_initialize(&vm->gc.registered_globals.lock); +} + #include "gc/default/default.c" #if USE_MODULAR_GC && !defined(HAVE_DLOPEN) @@ -600,6 +621,7 @@ typedef struct gc_function_map { void *(*objspace_alloc)(void); void (*objspace_init)(void *objspace_ptr); void *(*ractor_cache_alloc)(void *objspace_ptr, void *ractor); + void (*objspace_retire_gc)(void *objspace_ptr); void (*set_params)(void *objspace_ptr); void (*init)(void); // Shutdown @@ -613,6 +635,15 @@ typedef struct gc_function_map { void (*gc_enable)(void *objspace_ptr); void (*gc_disable)(void *objspace_ptr, bool finish_current_gc); bool (*gc_enabled_p)(void *objspace_ptr); + bool (*user_gc_disabled_set)(void *objspace_ptr, bool disable); + bool (*user_gc_disabled_p)(void *objspace_ptr); + bool (*multi_objspace_p)(void); + bool (*during_global_gc_p)(void *objspace_ptr); + bool (*obj_foreign_p)(void *objspace_ptr, VALUE obj); + bool (*shref_marked_p)(void *objspace_ptr, VALUE obj); + size_t (*heap_page_count)(void *objspace_ptr); + void (*objspace_absorb)(void *dst_ptr, void *src_ptr); + void (*gc_rest)(void *objspace_ptr); VALUE (*config_get)(void *objpace_ptr); void (*config_set)(void *objspace_ptr, VALUE hash); void (*stress_set)(void *objspace_ptr, VALUE flag); @@ -647,8 +678,12 @@ typedef struct gc_function_map { void (*writebarrier)(void *objspace_ptr, VALUE a, VALUE b); void (*writebarrier_unprotect)(void *objspace_ptr, VALUE obj); void (*writebarrier_remember)(void *objspace_ptr, VALUE obj); + void (*obj_became_shareable)(void *objspace_ptr, VALUE obj); + void (*pin_in_flight_message)(void *objspace_ptr, VALUE obj); // Heap walking void (*each_objects)(void *objspace_ptr, int (*callback)(void *, void *, size_t, void *), void *data); + void (*each_objects_shareable)(void *objspace_ptr, int (*callback)(void *, void *, size_t, void *), void *data); + void (*each_objects_foreign)(void *objspace_ptr, int (*callback)(void *, void *, size_t, void *), void *data); void (*each_object)(void *objspace_ptr, void (*func)(VALUE obj, void *data), void *data); // Finalizers void (*make_zombie)(void *objspace_ptr, VALUE obj, void (*dfree)(void *), void *data); @@ -780,6 +815,7 @@ ruby_modular_gc_init(void) load_modular_gc_func(objspace_alloc); load_modular_gc_func(objspace_init); load_modular_gc_func(ractor_cache_alloc); + load_modular_gc_func(objspace_retire_gc); load_modular_gc_func(set_params); load_modular_gc_func(init); // Shutdown @@ -793,6 +829,15 @@ ruby_modular_gc_init(void) load_modular_gc_func(gc_enable); load_modular_gc_func(gc_disable); load_modular_gc_func(gc_enabled_p); + load_modular_gc_func(user_gc_disabled_set); + load_modular_gc_func(user_gc_disabled_p); + load_modular_gc_func(multi_objspace_p); + load_modular_gc_func(during_global_gc_p); + load_modular_gc_func(obj_foreign_p); + load_modular_gc_func(shref_marked_p); + load_modular_gc_func(heap_page_count); + load_modular_gc_func(objspace_absorb); + load_modular_gc_func(gc_rest); load_modular_gc_func(config_set); load_modular_gc_func(config_get); load_modular_gc_func(stress_set); @@ -827,8 +872,12 @@ ruby_modular_gc_init(void) load_modular_gc_func(writebarrier); load_modular_gc_func(writebarrier_unprotect); load_modular_gc_func(writebarrier_remember); + load_modular_gc_func(obj_became_shareable); + load_modular_gc_func(pin_in_flight_message); // Heap walking load_modular_gc_func(each_objects); + load_modular_gc_func(each_objects_shareable); + load_modular_gc_func(each_objects_foreign); load_modular_gc_func(each_object); // Finalizers load_modular_gc_func(make_zombie); @@ -869,6 +918,7 @@ ruby_modular_gc_init(void) # define rb_gc_impl_objspace_alloc rb_gc_functions.objspace_alloc # define rb_gc_impl_objspace_init rb_gc_functions.objspace_init # define rb_gc_impl_ractor_cache_alloc rb_gc_functions.ractor_cache_alloc +# define rb_gc_impl_objspace_retire_gc rb_gc_functions.objspace_retire_gc # define rb_gc_impl_set_params rb_gc_functions.set_params # define rb_gc_impl_init rb_gc_functions.init // Shutdown @@ -882,6 +932,15 @@ ruby_modular_gc_init(void) # define rb_gc_impl_gc_enable rb_gc_functions.gc_enable # define rb_gc_impl_gc_disable rb_gc_functions.gc_disable # define rb_gc_impl_gc_enabled_p rb_gc_functions.gc_enabled_p +# define rb_gc_impl_user_gc_disabled_set rb_gc_functions.user_gc_disabled_set +# define rb_gc_impl_user_gc_disabled_p rb_gc_functions.user_gc_disabled_p +# define rb_gc_impl_multi_objspace_p rb_gc_functions.multi_objspace_p +# define rb_gc_impl_during_global_gc_p rb_gc_functions.during_global_gc_p +# define rb_gc_impl_obj_foreign_p rb_gc_functions.obj_foreign_p +# define rb_gc_impl_shref_marked_p rb_gc_functions.shref_marked_p +# define rb_gc_impl_heap_page_count rb_gc_functions.heap_page_count +# define rb_gc_impl_objspace_absorb rb_gc_functions.objspace_absorb +# define rb_gc_impl_gc_rest rb_gc_functions.gc_rest # define rb_gc_impl_config_get rb_gc_functions.config_get # define rb_gc_impl_config_set rb_gc_functions.config_set # define rb_gc_impl_stress_set rb_gc_functions.stress_set @@ -916,8 +975,12 @@ ruby_modular_gc_init(void) # define rb_gc_impl_writebarrier rb_gc_functions.writebarrier # define rb_gc_impl_writebarrier_unprotect rb_gc_functions.writebarrier_unprotect # define rb_gc_impl_writebarrier_remember rb_gc_functions.writebarrier_remember +# define rb_gc_impl_obj_became_shareable rb_gc_functions.obj_became_shareable +# define rb_gc_impl_pin_in_flight_message rb_gc_functions.pin_in_flight_message // Heap walking # define rb_gc_impl_each_objects rb_gc_functions.each_objects +# define rb_gc_impl_each_objects_shareable rb_gc_functions.each_objects_shareable +# define rb_gc_impl_each_objects_foreign rb_gc_functions.each_objects_foreign # define rb_gc_impl_each_object rb_gc_functions.each_object // Finalizers # define rb_gc_impl_make_zombie rb_gc_functions.make_zombie @@ -957,21 +1020,50 @@ asan_death_callback(void) static VALUE initial_stress = Qfalse; -void * -rb_objspace_alloc(void) +void +rb_gc_init_objspaces(void) { #if USE_MODULAR_GC ruby_modular_gc_init(); #endif + rb_vm_t *vm = ruby_current_vm_ptr; + void *objspace = rb_gc_impl_objspace_alloc(); - ruby_current_vm_ptr->gc.objspace = objspace; + RUBY_ASSERT(vm->ractor.main_ractor != NULL); + vm->ractor.main_ractor->objspace = objspace; rb_gc_impl_objspace_init(objspace); rb_gc_impl_stress_set(objspace, initial_stress); #ifdef RUBY_ASAN_ENABLED __sanitizer_set_death_callback(asan_death_callback); #endif +} + +/* Stays true once the process has gone multi-Ractor (rb_multi_ractor_p goes back to + * false when the other Ractors finish). Used by verification that spans generation + * state built while multiple Ractors ran. */ +static bool gc_ever_multi_ractor = false; + +bool +rb_gc_ever_multi_ractor_p(void) +{ + if (!gc_ever_multi_ractor && rb_multi_ractor_p()) gc_ever_multi_ractor = true; + return gc_ever_multi_ractor; +} + +/* Allocate the objspace of a new non-main Ractor. Called on the creating Ractor's + * thread, before the new Ractor starts running. */ +void * +rb_gc_objspace_alloc(void) +{ + gc_ever_multi_ractor = true; + if (!rb_gc_impl_multi_objspace_p()) { + /* One objspace shared by every Ractor. */ + return rb_gc_get_objspace(); + } + void *objspace = rb_gc_impl_objspace_alloc(); + rb_gc_impl_objspace_init(objspace); return objspace; } @@ -1020,11 +1112,11 @@ gc_newobj_hook(VALUE obj) * to trigger a GC right after an object has been allocated because * they perform initialization for the object and assume that the * GC does not trigger before then. */ - bool gc_disabled = RTEST(rb_gc_disable_no_rest()); + bool gc_disabled = RTEST(rb_gc_local_disable_no_rest()); { rb_gc_event_hook(obj, RUBY_INTERNAL_EVENT_NEWOBJ); } - if (!gc_disabled) rb_gc_enable(); + if (!gc_disabled) rb_gc_local_enable(); } RB_GC_VM_UNLOCK_NO_BARRIER(lev); } @@ -1034,17 +1126,14 @@ rb_newobj(rb_execution_context_t *ec, VALUE klass, VALUE flags, shape_id_t shape { GC_ASSERT((flags & FL_WB_PROTECTED) == 0); rb_ractor_t *cr = rb_ec_ractor_ptr(ec); + /* Use cr->objspace directly: rb_gc_get_objspace() would look cr up through TLS + * on every allocation. */ size_t actual_alloc_size; - VALUE obj = rb_gc_impl_new_obj(rb_gc_get_objspace(), cr->newobj_cache, klass, flags, wb_protected, size, &actual_alloc_size); + VALUE obj = rb_gc_impl_new_obj(cr->objspace, cr->newobj_cache, klass, flags, wb_protected, size, &actual_alloc_size); GC_ASSERT(actual_alloc_size >= size); shape_id = rb_shape_transition_slot_size(shape_id, actual_alloc_size); -#if RACTOR_CHECK_MODE - void rb_ractor_setup_belonging(VALUE obj); - rb_ractor_setup_belonging(obj); -#endif - RBASIC_SET_FULL_SHAPE_ID_NO_CHECKS(obj, shape_id); gc_validate_pc(obj); @@ -1416,6 +1505,14 @@ rb_gc_obj_needs_cleanup_p(VALUE obj) return rb_gc_imemo_needs_cleanup_p(obj); } + /* A host with generic fields must drop its table entry when it is freed. The + * process-wide table holds every Ractor's entries, so a sweep cannot bulk-wipe it; + * this per-object cleanup carries the correctness. */ + shape_id_t shape_id = RBASIC_SHAPE_ID(obj); + if (rb_shape_has_fields(shape_id) && rb_shape_layout(shape_id) == SHAPE_ID_LAYOUT_OTHER) { + return true; + } + switch (flags & RUBY_T_MASK) { case T_FLOAT: case T_RATIONAL: @@ -1745,7 +1842,15 @@ rb_gc_obj_free(void *objspace, VALUE obj) void rb_objspace_set_event_hook(const rb_event_flag_t event) { - rb_gc_impl_set_event_hook(rb_gc_get_objspace(), event); + /* Only the main objspace may enable the FREEOBJ hook: it runs user callbacks from + * inside the sweep, which is unsafe in a non-main Ractor's lock-free local GC. + * Extending it VM-wide is future work. */ + rb_event_flag_t e = event; + const rb_ractor_t *const cr = rb_current_ractor_raw(false); + if (cr != NULL && cr != GET_VM()->ractor.main_ractor) { + e &= ~RUBY_INTERNAL_EVENT_FREEOBJ; + } + rb_gc_impl_set_event_hook(rb_gc_get_objspace(), e); } static int @@ -1804,10 +1909,36 @@ os_obj_of_i(void *vstart, void *vend, size_t stride, void *data) for (; v != (VALUE)vend; v += stride) { if (!internal_object_p(v)) { if (!oes->of || rb_obj_is_kind_of(v, oes->of)) { - if (!rb_multi_ractor_p() || rb_ractor_shareable_p(v)) { - rb_yield(v); - oes->num++; - } + rb_yield(v); + oes->num++; + } + } + } + + return 0; +} + +/* Like os_obj_of_i but collects into an array: foreign shareable objects are walked + * under the barrier, where yielding is unsafe (see os_obj_of). Pure C, allocates no + * object (rb_ary_push only grows the buffer), so it reaches no safepoint. */ +struct os_shareable_collect_struct { + VALUE of; + VALUE buffer; +}; + +static int +os_shareable_collect_i(void *vstart, void *vend, size_t stride, void *data) +{ + struct os_shareable_collect_struct *ocs = (struct os_shareable_collect_struct *)data; + + VALUE v = (VALUE)vstart; + for (; v != (VALUE)vend; v += stride) { + /* We walk a foreign Ractor's objspace, so collect only shareable objects. The + * walk already filters on shareable_bits; check again so an unshareable object + * can never be exposed. */ + if (rb_ractor_shareable_p(v) && !internal_object_p(v)) { + if (!ocs->of || rb_obj_is_kind_of(v, ocs->of)) { + rb_ary_push(ocs->buffer, v); } } } @@ -1815,6 +1946,9 @@ os_obj_of_i(void *vstart, void *vend, size_t stride, void *data) return 0; } +static void rb_gc_critical_disable(void); +static void rb_gc_critical_enable(void); + static VALUE os_obj_of(VALUE of) { @@ -1822,7 +1956,43 @@ os_obj_of(VALUE of) oes.num = 0; oes.of = of; - rb_objspace_each_objects(os_obj_of_i, &oes); + + /* Phase 1: our own Ractor's objspace, yielding every object directly with no + * barrier. The walk snapshots the page list and tolerates pages being freed + * concurrently, so no VM lock is needed and the block may allocate, GC or block. */ + rb_gc_impl_each_objects(rb_gc_get_objspace(), os_obj_of_i, &oes); + + /* Phase 2 (multi-Ractor): other live Ractors' shareable objects, readable only + * under the barrier (where a user block must not run), so collect them in pure C + * with GC disabled and yield after the barrier is released. */ + if (rb_multi_ractor_p()) { + struct os_shareable_collect_struct ocs; + ocs.of = of; + ocs.buffer = rb_ary_new(); + + rb_gc_critical_disable(); + RB_VM_LOCKING() { + rb_vm_barrier(); + + void *self = rb_gc_get_objspace(); + rb_vm_t *vm = GET_VM(); + rb_ractor_t *r; + ccan_list_for_each(&vm->ractor.set, r, vmlr_node) { + if (r->objspace && r->objspace != self) { + rb_gc_impl_each_objects_shareable(r->objspace, os_shareable_collect_i, &ocs); + } + } + } + rb_gc_critical_enable(); + + long len = RARRAY_LEN(ocs.buffer); + for (long i = 0; i < len; i++) { + rb_yield(RARRAY_AREF(ocs.buffer, i)); + oes.num++; + } + RB_GC_GUARD(ocs.buffer); + } + return SIZET2NUM(oes.num); } @@ -2162,6 +2332,13 @@ rb_gc_obj_free_vm_weak_references(VALUE obj) { ASSUME(!RB_SPECIAL_CONST_P(obj)); + /* Drop a generic-fields entry when its host's slot is freed. The table is + * process-wide, so no sweep bulk-wipes it; a stale entry would let the global GC's + * weak pass (or a reader after the slot is reused) walk a freed page. */ + if (rb_obj_gen_fields_p(obj)) { + rb_free_generic_ivar(obj); + } + switch (BUILTIN_TYPE(obj)) { case T_STRING: if (FL_TEST_RAW(obj, RSTRING_FSTR)) { @@ -2616,12 +2793,19 @@ ruby_stack_check(void) /* ==================== Marking ==================== */ -/* The traversal mark redirect is per-Ractor, except on a modular GC where - * marking can run on worker threads with no current EC and it lives in the VM. - * GC_MARK_FUNC_DATA_SLOTP() points at the active slot; a non-modular build pays - * nothing extra over a plain GET_VM() (one GET_RACTOR(), no NULL check). */ +/* The traversal mark redirect is per-Ractor so a real GC never observes a + * foreign traversal's redirect (a VM-global slot would divert another Ractor's + * concurrent GC mark into obj_traverse recursion). Only threads with no + * current Ractor (modular GC's marking worker threads) fall back to the VM + * slot, which no setter writes, so they always take the real mark path. */ #if USE_MODULAR_GC -# define GC_MARK_FUNC_DATA_SLOTP() (&GET_VM()->gc.mark_func_data) +static inline struct gc_mark_func_data_struct ** +gc_mark_func_data_slotp(void) +{ + rb_ractor_t *const cr = rb_current_ractor_raw(false); + return cr != NULL ? &cr->mark_func_data : &GET_VM()->gc.mark_func_data; +} +# define GC_MARK_FUNC_DATA_SLOTP() gc_mark_func_data_slotp() #else # define GC_MARK_FUNC_DATA_SLOTP() (&GET_RACTOR()->mark_func_data) #endif @@ -2630,7 +2814,7 @@ ruby_stack_check(void) if (!RB_SPECIAL_CONST_P(obj)) { \ struct gc_mark_func_data_struct **mfdp = GC_MARK_FUNC_DATA_SLOTP(); \ struct gc_mark_func_data_struct *mark_func_data = *mfdp; \ - void *objspace = GET_VM()->gc.objspace; \ + void *objspace = rb_gc_get_objspace(); \ if (LIKELY(mark_func_data == NULL)) { \ GC_ASSERT(rb_gc_impl_during_gc_p(objspace)); \ (func)(objspace, (obj_or_ptr)); \ @@ -2821,10 +3005,9 @@ mark_const_entry_i(VALUE value, void *objspace) { const rb_const_entry_t *ce = (const rb_const_entry_t *)value; - if (!rb_gc_checking_shareable()) { - gc_mark_internal(ce->value); - gc_mark_internal(ce->file); // TODO: ce->file should be shareable? - } + gc_mark_internal(ce->value); + gc_mark_internal(ce->file); // TODO: ce->file should be shareable? + return ID_TABLE_CONTINUE; } @@ -3042,38 +3225,129 @@ rb_gc_mark_roots(void *objspace, const char **categoryp) if (categoryp) *categoryp = category; \ } while (0) - MARK_CHECKPOINT("vm"); - rb_vm_mark(vm); + /* A single-objspace impl (mmtk) only has stop-the-world global GCs and no + * per-mutator root scan, so always walk every Ractor's local roots here. */ + const bool global_gc = rb_gc_impl_during_global_gc_p(objspace) || + !rb_gc_impl_multi_objspace_p(); + + /* Mark the current Ractor's roots from its C structs (a local GC must not depend on + * heap wrapper traversal). A global GC does the same for every Ractor and re-pins + * the in-flight payloads whose shrefs its clear pass dropped. */ + MARK_CHECKPOINT("ractor"); + if (global_gc) { + rb_ractor_t *r; + ccan_list_for_each(&vm->ractor.set, r, vmlr_node) { + rb_ractor_mark_local_roots(r); + rb_ractor_repin_in_flight(r); + } - MARK_CHECKPOINT("end_proc"); - rb_mark_end_proc(); + /* Early in boot (before rb_ractor_main_setup) main is not in vm->ractor.set + * yet; do not drop its registered_marks in a single-objspace boot GC. */ + if (vm->ractor.cnt == 0 && vm->ractor.main_ractor) { + rb_ractor_mark_local_roots(vm->ractor.main_ractor); + } + /* A Ractor that terminated (left vm->ractor.set) but whose struct is not freed + * still owns rb_gc_register_mark_object pins. Keep them alive until + * ractor_free hands them to main; an orphan (owner == NULL) was moved above. */ + for (size_t i = 0; i < vm->gc.zombie_objspaces_count; i++) { + rb_ractor_t *owner = vm->gc.zombie_objspaces[i].owner; + if (owner) { + rb_gc_mark_vm_stack_values((long)owner->registered_marks_cnt, + owner->registered_marks); + /* Keep a terminated Ractor's join value (read by Ractor#value) alive + * without depending on wrapper reachability. Threads are not walked. */ + rb_ractor_mark_terminated_join_value(owner); + } + } - MARK_CHECKPOINT("global_tbl"); - rb_gc_mark_global_tbl(); + /* Single-objspace impl: keep terminated-but-not-freed Ractors' + * rb_gc_register_mark_object entries alive without depending on wrapper + * reachability. With multiple objspaces zombie_objspaces covers this. */ + if (!rb_gc_impl_multi_objspace_p()) { + rb_ractor_t *tr; + rb_native_mutex_lock(&vm->gc.registered_globals.lock); + ccan_list_for_each(&vm->ractor.terminated_set, tr, vmlr_node) { + rb_gc_mark_vm_stack_values((long)tr->registered_marks_cnt, + tr->registered_marks); + } + rb_native_mutex_unlock(&vm->gc.registered_globals.lock); + } + } + else { + rb_ractor_mark_local_roots(rb_ec_ractor_ptr(ec)); + } + + /* rb_gc_register_address slots live in one VM-wide list: *addr can later hold + * another objspace's value, so every Ractor's GC scans all slots conservatively, + * marking only its own residents. */ + MARK_CHECKPOINT("registered_globals"); + rb_native_mutex_lock(&vm->gc.registered_globals.lock); + for (size_t i = 0; i < vm->gc.registered_globals.addrs_cnt; i++) { + rb_gc_mark_maybe(*vm->gc.registered_globals.addrs[i]); + } + rb_native_mutex_unlock(&vm->gc.registered_globals.lock); + + /* Trap handlers live in the VM-global vm->trap_list.cmd[], a fixed array of aligned + * VALUEs (signal.c uses ACCESS_ONCE): a racing walk reads either the old or the new + * handler, both alive, so no lock. */ + MARK_CHECKPOINT("trap_list"); + rb_gc_mark_values(RUBY_NSIG, vm->trap_list.cmd); + + /* VM-global roots belong to the main Ractor's objspace, since the boot objects + * live there. A non-main Ractor's local GC skips them; a global GC walks all. */ + if (global_gc || objspace == vm->ractor.main_ractor->objspace) { + /* Only the main Ractor can register at_exit/END procs (a non-main one gets an + * IsolationError), and end_procs is a lock-free linked list, so only main -- + * the thread that registers, or a stop-the-world global GC, walks it. */ + MARK_CHECKPOINT("end_proc"); + rb_mark_end_proc(); + + MARK_CHECKPOINT("vm"); + /* rb_vm_mark walks VM-global weak tables that other Ractors rewrite under the + * VM lock, so main's otherwise lock-free local GC takes a no-barrier VM lock + * for this stretch; under a global GC the barrier already protects it. */ + const bool vm_mark_needs_lock = rb_multi_ractor_p() && !global_gc; + unsigned int vm_mark_lock_lev = 0; + if (vm_mark_needs_lock) vm_mark_lock_lev = RB_GC_VM_LOCK_NO_BARRIER(); + rb_vm_mark(vm); + if (vm_mark_needs_lock) RB_GC_VM_UNLOCK_NO_BARRIER(vm_mark_lock_lev); + + if (global_gc) { + /* Mark and pin the shareable REFs of in-flight (off-heap) move couriers, + * covering the transient window between queue and materialize frame. Only + * a global GC frees shareable objects, so only it needs this pass. */ + MARK_CHECKPOINT("move_couriers"); + void rb_ractor_move_courier_registry_mark(void); + rb_ractor_move_courier_registry_mark(); + } + + MARK_CHECKPOINT("global_tbl"); + rb_gc_mark_global_tbl(); #if USE_YJIT - void rb_yjit_root_mark(void); // in Rust + void rb_yjit_root_mark(void); // in Rust - if (rb_yjit_enabled_p) { - MARK_CHECKPOINT("YJIT"); - rb_yjit_root_mark(); - } + if (rb_yjit_enabled_p) { + MARK_CHECKPOINT("YJIT"); + rb_yjit_root_mark(); + } #endif #if USE_ZJIT - void rb_zjit_root_mark(void); - if (rb_zjit_enabled_p) { - MARK_CHECKPOINT("ZJIT"); - rb_zjit_root_mark(); - } + void rb_zjit_root_mark(void); + if (rb_zjit_enabled_p) { + MARK_CHECKPOINT("ZJIT"); + rb_zjit_root_mark(); + } #endif + MARK_CHECKPOINT("global_symbols"); + rb_sym_global_symbols_mark_and_move(); + } + MARK_CHECKPOINT("machine_context"); mark_current_machine_context(ec); - MARK_CHECKPOINT("global_symbols"); - rb_sym_global_symbols_mark_and_move(); - MARK_CHECKPOINT("finish"); #undef MARK_CHECKPOINT @@ -3095,11 +3369,8 @@ gc_mark_classext_module(rb_classext_t *ext, bool prime, VALUE box_value, void *a } mark_m_tbl(objspace, RCLASSEXT_M_TBL(ext)); - if (!rb_gc_checking_shareable()) { - // unshareable - gc_mark_internal(RCLASSEXT_FIELDS_OBJ(ext)); - gc_mark_internal(RCLASSEXT_CVC_TBL(ext)); - } + gc_mark_internal(RCLASSEXT_FIELDS_OBJ(ext)); + gc_mark_internal(RCLASSEXT_CVC_TBL(ext)); if (!RCLASSEXT_SHARED_CONST_TBL(ext) && RCLASSEXT_CONST_TBL(ext)) { mark_const_tbl(objspace, RCLASSEXT_CONST_TBL(ext)); @@ -3194,8 +3465,7 @@ rb_gc_mark_children(void *objspace, VALUE obj) switch (BUILTIN_TYPE(obj)) { case T_CLASS: - if (FL_TEST_RAW(obj, FL_SINGLETON) && - !rb_gc_checking_shareable()) { + if (FL_TEST_RAW(obj, FL_SINGLETON)) { gc_mark_internal(RCLASS_ATTACHED_OBJECT(obj)); } // Continue to the shared T_CLASS/T_MODULE @@ -3434,6 +3704,24 @@ rb_gc_writebarrier_remember(VALUE obj) rb_gc_impl_writebarrier_remember(rb_gc_get_objspace(), obj); } +/* obj became shareable after it was created (FL_SHAREABLE was set). Tell the GC so it + * updates the per-page shareable bitmap. */ +void +rb_gc_obj_became_shareable(VALUE obj) +{ + rb_gc_impl_obj_became_shareable(rb_gc_get_objspace(), obj); +} + +/* Pin an in-flight message payload in its owner's (the sender's) objspace, so the + * sender's local GC keeps it alive while it sits in a queue the sender does not walk. */ +void +rb_gc_pin_in_flight_message(VALUE obj) +{ + if (RB_SPECIAL_CONST_P(obj)) return; + + rb_gc_impl_pin_in_flight_message(rb_gc_get_objspace(), obj); +} + void rb_gc_copy_attributes(VALUE dest, VALUE obj) { @@ -3511,49 +3799,39 @@ rb_gc_register_address(VALUE *addr) { rb_vm_t *vm = GET_VM(); - VALUE obj = *addr; - - RB_VM_LOCKING() { - if (vm->global_object_list_size == vm->global_object_list_capa) { - size_t new_capa = vm->global_object_list_capa ? vm->global_object_list_capa * 2 : 64; - SIZED_REALLOC_N(vm->global_object_list, VALUE *, new_capa, vm->global_object_list_capa); - vm->global_object_list_capa = new_capa; - } - - vm->global_object_list[vm->global_object_list_size++] = addr; + rb_native_mutex_lock(&vm->gc.registered_globals.lock); + if (vm->gc.registered_globals.addrs_cnt == vm->gc.registered_globals.addrs_capa) { + size_t nc = vm->gc.registered_globals.addrs_capa ? vm->gc.registered_globals.addrs_capa * 2 : 64; + VALUE **p = realloc(vm->gc.registered_globals.addrs, nc * sizeof(VALUE *)); + if (!p) rb_bug("rb_gc_register_address: out of memory"); + vm->gc.registered_globals.addrs = p; + vm->gc.registered_globals.addrs_capa = nc; } + vm->gc.registered_globals.addrs[vm->gc.registered_globals.addrs_cnt++] = addr; + rb_native_mutex_unlock(&vm->gc.registered_globals.lock); - /* - * Because some C extensions have assignment-then-register bugs, - * we guard `obj` here so that it would not get swept defensively. - */ - RB_GC_GUARD(obj); - if (0 && !SPECIAL_CONST_P(obj)) { - rb_warn("Object is assigned to registering address already: %"PRIsVALUE, - rb_obj_class(obj)); - rb_print_backtrace(stderr); - } + /* Some C extensions register before assigning, so protect obj from GC here. */ + RB_GC_GUARD(*addr); } void rb_gc_unregister_address(VALUE *addr) { rb_vm_t *vm = GET_VM(); - RB_VM_LOCKING() { - size_t index; - for (index = 0; index < vm->global_object_list_size; index++) { - if (addr == vm->global_object_list[index]) { - MEMMOVE( - &vm->global_object_list[index], - &vm->global_object_list[index + 1], - VALUE *, - vm->global_object_list_size - index - 1 - ); - vm->global_object_list_size--; - break; - } + + /* One VM-wide list, so a register and unregister from different Ractors (Init on + * main, dfree elsewhere) still pair up. Silently a no-op when not found: upstream + * tolerates a double unregister too. */ + rb_native_mutex_lock(&vm->gc.registered_globals.lock); + for (size_t i = 0; i < vm->gc.registered_globals.addrs_cnt; i++) { + if (vm->gc.registered_globals.addrs[i] == addr) { + MEMMOVE(&vm->gc.registered_globals.addrs[i], &vm->gc.registered_globals.addrs[i + 1], + VALUE *, vm->gc.registered_globals.addrs_cnt - i - 1); + vm->gc.registered_globals.addrs_cnt--; + break; } } + rb_native_mutex_unlock(&vm->gc.registered_globals.lock); } void @@ -3619,7 +3897,391 @@ rb_objspace_each_objects(int (*callback)(void *, void *, size_t, void *), void * { RB_VM_LOCKING() { rb_vm_barrier(); - rb_gc_impl_each_objects(rb_gc_get_objspace(), callback, data); + + void *self = rb_gc_get_objspace(); + rb_gc_impl_each_objects(self, callback, data); + + /* Like upstream, cover every object in the process: walk the other live + * Ractors' objspaces too, under the VM lock and barrier, with a pure-C callback. + * A foreign objspace's stopped lazy sweep is not settled; the walk skips its + * dead objects. */ + rb_vm_t *vm = GET_VM(); + rb_ractor_t *r; + ccan_list_for_each(&vm->ractor.set, r, vmlr_node) { + if (r->objspace && r->objspace != self) { + rb_gc_impl_each_objects_foreign(r->objspace, callback, data); + } + } + } +} + + + +/* Enumerate every objspace: live Ractors' plus uninherited zombies. Callers hold the + * VM lock (reading another objspace also needs the barrier). Missing even one leaves + * stale mark bits behind for the global GC. */ +void +rb_gc_vm_each_objspace(void (*func)(void *objspace, void *data), void *data) +{ + ASSERT_vm_locking(); + + rb_vm_t *vm = GET_VM(); + rb_ractor_t *r; + ccan_list_for_each(&vm->ractor.set, r, vmlr_node) { + if (r->objspace) { + func(r->objspace, data); + } + /* A child being created is not in the set yet but its objspace already holds + * the Thread/Fiber wrappers; enumerate it through its creator so a global GC + * cannot miss it and mark into an objspace it never cleared. */ + if (r->creating_child_objspace) { + func(r->creating_child_objspace, data); + } + } + for (size_t i = 0; i < vm->gc.zombie_objspaces_count; i++) { + func(vm->gc.zombie_objspaces[i].objspace, data); + } +} + +/* Merging an ownerless zombie objspace (its Ractor object was collected) into main + * runs as a postponed job targeted at main, at main's next safepoint; never inside + * the GC cycle that discovered the orphan. */ + +static void gc_orphan_merge_job(void *unused); + +/* Grown with plain realloc: rb_gc_objspace_disown pushes from inside a global GC + * sweep, where the accounting allocator is not allowed. This table is VM-lifetime + * metadata with at most a few dozen entries. */ +static void +zombie_objspaces_push(rb_vm_t *vm, void *objspace, void **owner_slot, struct rb_ractor_struct *owner) +{ + if (vm->gc.zombie_objspaces_count == vm->gc.zombie_objspaces_capa) { + size_t new_capa = vm->gc.zombie_objspaces_capa ? vm->gc.zombie_objspaces_capa * 2 : 16; + struct rb_objspace_zombie *grown = + realloc(vm->gc.zombie_objspaces, new_capa * sizeof(struct rb_objspace_zombie)); + if (grown == NULL) rb_bug("zombie_objspaces_push: out of memory"); + vm->gc.zombie_objspaces = grown; + vm->gc.zombie_objspaces_capa = new_capa; + } + size_t pages = rb_gc_impl_heap_page_count(objspace); + vm->gc.zombie_objspaces[vm->gc.zombie_objspaces_count++] = (struct rb_objspace_zombie){ + .objspace = objspace, + .owner_slot = owner_slot, + .owner = owner, + .pages = pages, + }; + vm->gc.zombie_total_pages += pages; +} + +/* Called for a Ractor that terminated without being joined. Its objspace loses its + * owning thread, but its pages still hold shareable objects other Ractors can reach, + * so keep it enumerable until inheritance merges it. The owning r->objspace slot stays + * until the inheriting path takes the objspace and clears it. */ +/* Reserve the handle of the orphan-merge job if it is not registered yet. Shared by + * every retire and disown path; a second preregister is idempotent (the same func and + * data are deduplicated). */ +static void +gc_orphan_merge_pjob_ensure(void) +{ + if (GET_VM()->gc.orphan_merge_pjob == POSTPONED_JOB_HANDLE_INVALID) { + GET_VM()->gc.orphan_merge_pjob = rb_postponed_job_preregister(0, gc_orphan_merge_job, NULL); + if (GET_VM()->gc.orphan_merge_pjob == POSTPONED_JOB_HANDLE_INVALID) { + rb_bug("Could not preregister postponed job for GC"); + } + } +} + +/* A terminating Ractor runs the last local GC of its own objspace; own thread only. */ +void +rb_gc_objspace_retire_gc(void) +{ + rb_gc_impl_objspace_retire_gc(rb_gc_get_objspace()); +} + +void +rb_gc_objspace_retire(void **objspace_slot) +{ + rb_vm_t *vm = GET_VM(); + + if (!rb_gc_impl_multi_objspace_p()) { + /* It only aliased the shared objspace, so just drop it. */ + *objspace_slot = NULL; + return; + } + + /* Return the hold if the Ractor exits with GC disabled: otherwise nobody can + * enable it again and GC stays off. */ + if (rb_gc_impl_user_gc_disabled_set(*objspace_slot, false)) { + RUBY_ATOMIC_DEC(vm->gc.disable_holders); + } + + RB_VM_LOCKING() { + gc_orphan_merge_pjob_ensure(); + /* owner_slot is always &r->objspace of the retiring Ractor. owner is recorded so a + * root scan can still reach the dead Ractor's registered_marks pins and its join + * value; rb_gc_objspace_disown clears it when the zombie becomes an orphan. */ + struct rb_ractor_struct *owner = + (struct rb_ractor_struct *)((char *)objspace_slot - offsetof(rb_ractor_t, objspace)); + zombie_objspaces_push(vm, *objspace_slot, objspace_slot, owner); + } +} + +/* The owning Ractor object was collected, so nobody can join any more: drop the owner + * slot in zombie_objspaces and hand the merge to main. Called from ractor_free (inside + * a sweep), where the accounting allocator is unavailable; the table itself is stable. */ +void +rb_gc_objspace_disown(void *objspace) +{ + if (!rb_gc_impl_multi_objspace_p()) return; + rb_vm_t *vm = GET_VM(); + bool found = false; + + for (size_t i = 0; i < vm->gc.zombie_objspaces_count; i++) { + if (vm->gc.zombie_objspaces[i].objspace == objspace) { + vm->gc.zombie_objspaces[i].owner_slot = NULL; + /* The Ractor struct is being freed, so drop owner too: nothing may read its + * registered_marks or join value after this. */ + vm->gc.zombie_objspaces[i].owner = NULL; + found = true; + break; + } + } + if (!found) { + zombie_objspaces_push(vm, objspace, NULL, NULL); + } + + /* The trigger is wait-free (an atomic bit plus an interrupt flag), so it is safe + * inside a sweep, and it also covers a Ractor that never started. */ + gc_orphan_merge_pjob_ensure(); + rb_postponed_job_trigger_for_ractor(GET_VM()->gc.orphan_merge_pjob, vm->ractor.main_ractor->pub.self); +} + +/* Is a global (stop-the-world) GC cycle running? Only its driver runs during one, so + * asking through the current objspace is exact. */ +bool +rb_gc_during_global_gc_p(void) +{ + return rb_gc_impl_during_global_gc_p(rb_gc_get_objspace()); +} + +static void +rb_gc_vm_forget_zombie(void *objspace) +{ + rb_vm_t *vm = GET_VM(); + size_t n = vm->gc.zombie_objspaces_count; + for (size_t i = 0; i < n; i++) { + if (vm->gc.zombie_objspaces[i].objspace == objspace) { + vm->gc.zombie_total_pages -= vm->gc.zombie_objspaces[i].pages; + vm->gc.zombie_objspaces[i] = vm->gc.zombie_objspaces[n - 1]; + vm->gc.zombie_objspaces_count = n - 1; + break; + } + } +} + +/* Total zombie pages, deciding whether to start a global GC. An upper bound between + * global cycles (each re-measures under the barrier), so a stale value cannot + * re-trigger; a lock-free read at worst fires one cycle early or late. */ +size_t +rb_gc_vm_zombie_total_pages(void) +{ + return GET_VM()->gc.zombie_total_pages; +} + +/* Number of live Ractors, for the heap growth heuristic (r_mul); a racy read is fine. */ +unsigned int +rb_gc_vm_ractor_count(void) +{ + return GET_VM()->ractor.cnt; +} + +/* Called by a global cycle from inside the barrier. */ +void +rb_gc_vm_refresh_zombie_pages(void) +{ + rb_vm_t *vm = GET_VM(); + size_t total = 0; + for (size_t i = 0; i < vm->gc.zombie_objspaces_count; i++) { + size_t pages = rb_gc_impl_heap_page_count(vm->gc.zombie_objspaces[i].objspace); + vm->gc.zombie_objspaces[i].pages = pages; + total += pages; + } + vm->gc.zombie_total_pages = total; +} + +/* Incremental marking only runs single-objspace; vm_insert_ractor0 calls this just + * before a second Ractor becomes visible so any cycle in progress finishes; a settle + * cannot resume, nor inheritance extend, another objspace's partial mark. */ +void +rb_gc_finish_in_flight_gc(void) +{ + rb_gc_impl_gc_rest(rb_gc_get_objspace()); +} + +/* True while a zombie is being absorbed. The zombie's count is decremented before the + * merge (see absorb below), so in that window its live objects still exist even though + * the process looks single-objspace. */ +static int gc_absorbing_zombie = 0; + +/* True once a zombie objspace was absorbed since the last global GC: until the unified + * mark runs, a single-objspace local mark can miss absorbed shareable objects (a cc in + * a class's cc_table, say), so stop treating the process as single until then. */ +static bool gc_absorbed_since_global_gc = false; + +void +rb_gc_reset_absorbed_since_global_gc(void) +{ + gc_absorbed_since_global_gc = false; +} + +/* True when the process holds exactly one objspace (one live Ractor, no zombies) and + * nothing was absorbed since the last global GC. Only then is a local GC the whole + * world and the multi-objspace guards can be skipped. The child-creation window (the + * child objspace exists while cnt is still 1) and both absorb windows, during (count + * already decremented, merge unfinished) and after (merged, next global GC pending) -- + * count as multi: treating them as single would let a GC skip guards such as shareable + * pinning and collect a live cc. */ +/* False when the impl only supports one objspace (mmtk and friends); the VM then makes + * its per-Ractor objspace machinery (retire, absorb, creation cover) a no-op. */ +bool +rb_gc_multi_objspace_p(void) +{ + return rb_gc_impl_multi_objspace_p(); +} + +/* Does obj belong to another Ractor's objspace rather than the current one? Always + * false for a single-objspace impl, which cannot tell owners apart. */ +bool +rb_gc_obj_foreign_p(VALUE obj) +{ + return rb_gc_impl_obj_foreign_p(rb_gc_get_objspace(), obj); +} + +bool +rb_gc_single_objspace_p(void) +{ + if (!rb_gc_impl_multi_objspace_p()) return true; + rb_vm_t *vm = GET_VM(); + return vm->ractor.cnt == 1 && vm->gc.zombie_objspaces_count == 0 && gc_absorbing_zombie == 0 && + !gc_absorbed_since_global_gc && + (vm->ractor.main_ractor == NULL || + vm->ractor.main_ractor->creating_child_objspace == NULL); +} + +/* Inherit a dead Ractor's objspace into the calling Ractor. Going through the owner + * slot clears it and releases the objspace in one VM-lock section; the merge runs with + * the inheritor's GC disabled (moving the finalizer st table could trigger it). */ +static void +objspace_absorb_merge(void *dst, void *src) +{ + ASSERT_vm_locking(); + rb_gc_impl_objspace_absorb(dst, src); + gc_absorbed_since_global_gc = true; +} + +void +rb_gc_objspace_absorb_into_current(void **objspace_slot) +{ + if (!rb_gc_impl_multi_objspace_p()) { + *objspace_slot = NULL; + return; + } + RB_VM_LOCKING() { + void *objspace = *objspace_slot; + if (objspace != NULL) { + *objspace_slot = NULL; + gc_absorbing_zombie++; + rb_gc_vm_forget_zombie(objspace); + objspace_absorb_merge(rb_gc_get_objspace(), objspace); + gc_absorbing_zombie--; + } + } +} + +/* Merge every ownerless zombie objspace (no owner slot, i.e. the Ractor object was + * collected) into the current Ractor's objspace. Runs as a postponed job on the main + * Ractor's thread; the VM teardown path calls it directly. */ +static void +objspace_absorb_disowned_zombies(void) +{ + rb_vm_t *vm = GET_VM(); + + RB_VM_LOCKING() { + size_t i = 0; + while (i < vm->gc.zombie_objspaces_count) { + if (vm->gc.zombie_objspaces[i].owner_slot == NULL) { + void *zombie = vm->gc.zombie_objspaces[i].objspace; + /* Remove via forget, which also subtracts the entry's pages from + * zombie_total_pages; a hand-written swap-remove would leave a phantom + * total that keeps starting stop-the-world global cycles. */ + gc_absorbing_zombie++; + rb_gc_vm_forget_zombie(zombie); + objspace_absorb_merge(rb_gc_get_objspace(), zombie); + gc_absorbing_zombie--; + } + else { + i++; + } + } + } +} + +static void +gc_orphan_merge_job(void *unused) +{ + (void)unused; + objspace_absorb_disowned_zombies(); +} + +/* Re-target a pending orphan merge after fork. The job may target the parent's main + * Ractor, whose per-Ractor trigger mask is not inherited unless that Ractor forked. + * Called on the child side. */ +/* Only main survives a fork, so rebuild the counter from main's own hold alone. */ +void +rb_gc_disable_holders_atfork(void) +{ + RUBY_ATOMIC_SET(GET_VM()->gc.disable_holders, + rb_gc_impl_user_gc_disabled_p(rb_gc_get_objspace()) ? 1 : 0); +} + +void +rb_gc_zombie_objspaces_atfork(void) +{ + rb_vm_t *vm = GET_VM(); + + for (size_t i = 0; i < vm->gc.zombie_objspaces_count; i++) { + if (vm->gc.zombie_objspaces[i].owner_slot == NULL) { + rb_postponed_job_trigger_for_ractor(GET_VM()->gc.orphan_merge_pjob, vm->ractor.main_ractor->pub.self); + break; + } + } +} + +/* VM teardown, right after every other Ractor was killed: merge all uninherited + * objspaces into main so at-exit processing covers every object and dead Ractors' + * deferred finalizers run on main. The owner slot also covers collected wrappers. */ +void +rb_gc_objspace_absorb_all_zombies(void) +{ + rb_vm_t *vm = GET_VM(); + + /* Entries whose Ractor object is already gone, i.e. the pending merge job itself, + * which we run synchronously here. */ + objspace_absorb_disowned_zombies(); + + while (vm->gc.zombie_objspaces_count > 0) { + size_t before = vm->gc.zombie_objspaces_count; + GC_ASSERT(vm->gc.zombie_objspaces[0].owner_slot != NULL); + /* Move the rb_gc_register_mark_object pins before the merge, so the objects + * pinned in the owner's objspace do not lose their root in its sweep. */ + rb_ractor_t *owner = vm->gc.zombie_objspaces[0].owner; + if (owner) { + rb_ractor_absorb_registered_marks(GET_RACTOR(), owner); + } + rb_gc_objspace_absorb_into_current(vm->gc.zombie_objspaces[0].owner_slot); + if (vm->gc.zombie_objspaces_count >= before) { + rb_bug("rb_gc_objspace_absorb_all_zombies: zombie list did not shrink"); + } } } @@ -3917,6 +4579,13 @@ struct global_vm_table_foreach_data { vm_table_update_callback_func update_callback; void *data; bool weak_only; + /* The generic_fields table being walked, so compaction can re-insert a moved key + * into it (rb_generic_fields_tables_foreach hands the table to the callback). */ + struct st_table *gen_fields_current_tbl; + /* Re-inserting a moved key adds an entry, which can rehash and break the running + * iterator, so collect them and insert after the walk (raw realloc: we are in GC). */ + struct gen_fields_deferred_insert { st_data_t k, v; } *gf_deferred; + size_t gf_deferred_cnt, gf_deferred_capa; }; static int @@ -4014,15 +4683,36 @@ vm_weak_table_gen_fields_foreach(st_data_t key, st_data_t value, st_data_t data) iter_data->update_callback(&new_value, iter_data->data); break; + case ST_DELETE: + /* Leftover entry of a moved host: even if the key is alive, nobody can + * read these fields once fields_obj is unreachable, so clean up as if the + * key had died. */ + RBASIC_SET_SHAPE_ID((VALUE)key, ROOT_SHAPE_ID); + return ST_DELETE; + default: rb_bug("vm_weak_table_gen_fields_foreach: return value %d not supported", ivar_ret); } } - if (key != new_key || value != new_value) { + if (key != new_key) { + /* Inserting the new key adds an entry and may rehash, so defer it. */ + if (iter_data->gf_deferred_cnt == iter_data->gf_deferred_capa) { + size_t nc = iter_data->gf_deferred_capa ? iter_data->gf_deferred_capa * 2 : 64; + struct gen_fields_deferred_insert *p = + realloc(iter_data->gf_deferred, nc * sizeof(*p)); + if (!p) rb_bug("vm_weak_table_gen_fields_foreach: out of memory"); + iter_data->gf_deferred = p; + iter_data->gf_deferred_capa = nc; + } + iter_data->gf_deferred[iter_data->gf_deferred_cnt++] = + (struct gen_fields_deferred_insert){ .k = (st_data_t)new_key, .v = (st_data_t)new_value }; + } + else if (value != new_value) { DURING_GC_COULD_MALLOC_REGION_START(); { - st_insert(rb_generic_fields_tbl_get(), (st_data_t)new_key, new_value); + /* Updating an existing key's value adds no entry and cannot rehash. */ + st_insert(iter_data->gen_fields_current_tbl, (st_data_t)new_key, new_value); } DURING_GC_COULD_MALLOC_REGION_END(); } @@ -4055,14 +4745,26 @@ void rb_fstring_foreach_with_replace(int (*callback)(VALUE *str, void *data), vo bool rb_gc_vm_weak_table_essential_p(enum rb_gc_vm_weak_tables table) { + /* No bulk cleanup: the generic_fields table is process-wide, so a local GC must not + * wipe other Ractors' live entries. They are dropped per freed object instead + * (rb_gc_obj_free_vm_weak_references), and dead keys drain in the global GC's weak pass. */ switch (table) { - case RB_GC_VM_GENERIC_FIELDS_TABLE: - return true; default: return false; } } +/* Callback of rb_generic_fields_tables_foreach: walk one generic_fields table with the + * gen_fields foreach used by compaction, recording the current table in foreach_data so + * a moved key is re-inserted into the right one. */ +static void +vm_weak_table_gen_fields_tbl_cb(struct st_table *tbl, void *arg) +{ + struct global_vm_table_foreach_data *foreach_data = (struct global_vm_table_foreach_data *)arg; + foreach_data->gen_fields_current_tbl = tbl; + st_foreach(tbl, vm_weak_table_gen_fields_foreach, (st_data_t)foreach_data); +} + void rb_gc_vm_weak_table_foreach(vm_table_foreach_callback_func callback, vm_table_update_callback_func update_callback, @@ -4106,13 +4808,26 @@ rb_gc_vm_weak_table_foreach(vm_table_foreach_callback_func callback, break; } case RB_GC_VM_GENERIC_FIELDS_TABLE: { - st_table *generic_fields_tbl = rb_generic_fields_tbl_get(); - if (generic_fields_tbl) { - st_foreach( - generic_fields_tbl, - vm_weak_table_gen_fields_foreach, - (st_data_t)&foreach_data - ); + /* There is one table. A global GC walks it without a lock under the + * stop-the-world barrier; a local compaction holds the barrier VM lock taken in + * gc_enter, so foreign keys cannot move and fall through the moved check. The + * table's mutex (taken by shared_table_foreach) excludes mutator inserts. */ + if (rb_gc_during_global_gc_p()) { + rb_generic_fields_tables_foreach(vm_weak_table_gen_fields_tbl_cb, (void *)&foreach_data); + } + else if (!weak_only) { + rb_generic_fields_shared_table_foreach(vm_weak_table_gen_fields_tbl_cb, (void *)&foreach_data); + } + if (foreach_data.gf_deferred != NULL) { + DURING_GC_COULD_MALLOC_REGION_START(); + { + for (size_t i = 0; i < foreach_data.gf_deferred_cnt; i++) { + struct gen_fields_deferred_insert *const d = &foreach_data.gf_deferred[i]; + st_insert(foreach_data.gen_fields_current_tbl, d->k, d->v); + } + } + DURING_GC_COULD_MALLOC_REGION_END(); + free(foreach_data.gf_deferred); } break; } @@ -4130,6 +4845,76 @@ rb_gc_vm_weak_table_foreach(vm_table_foreach_callback_func callback, } } +/* The global GC's weak pass over the generic_fields table; under the barrier, so the + * walk needs no lock. */ +struct gf_mark_foreach_ctx { + int (*cb)(VALUE key, VALUE val, void *arg); + void *arg; +}; + +static int +gf_mark_foreach_i(st_data_t key, st_data_t val, st_data_t data) +{ + struct gf_mark_foreach_ctx *ctx = (struct gf_mark_foreach_ctx *)data; + return ctx->cb((VALUE)key, (VALUE)val, ctx->arg); +} + +static void +gf_mark_foreach_table_cb(struct st_table *tbl, void *arg) +{ + st_foreach(tbl, gf_mark_foreach_i, (st_data_t)arg); +} + +void +rb_gc_vm_generic_fields_mark_foreach(int (*cb)(VALUE key, VALUE val, void *arg), void *arg) +{ + struct gf_mark_foreach_ctx ctx = { cb, arg }; + rb_generic_fields_tables_foreach(gf_mark_foreach_table_cb, &ctx); +} + +struct gf_drain_ctx { + bool (*is_dead)(VALUE key); +}; + +static int +gf_drain_i(st_data_t key, st_data_t val, st_data_t data) +{ + struct gf_drain_ctx *ctx = (struct gf_drain_ctx *)data; + if (ctx->is_dead((VALUE)key)) { + /* The weak pass only drains dead keys' entries, never touching the key itself: + * after the global GC settled another objspace's lazy sweep the key may already + * be freed (poisoned), and writing a shape there would be a use-after-poison. */ + return ST_DELETE; + } + return ST_CONTINUE; +} + +static void +gf_drain_table_cb(struct st_table *tbl, void *arg) +{ + st_foreach(tbl, gf_drain_i, (st_data_t)arg); +} + +void +rb_gc_vm_generic_fields_drain_dead(bool (*is_dead)(VALUE key)) +{ + struct gf_drain_ctx ctx = { is_dead }; + rb_generic_fields_tables_foreach(gf_drain_table_cb, &ctx); +} + +/* A wrapper exported from gc.c so a modular build's gc-impl can call it. */ +bool +rb_gc_current_ractor_materializing_p(void) +{ + return rb_ractor_materializing_p(); +} + +VALUE +rb_gc_vm_top_self(void) +{ + return rb_vm_top_self(); +} + void rb_gc_update_vm_references(void *objspace) { @@ -4474,54 +5259,106 @@ rb_gc_initial_stress_set(VALUE flag) initial_stress = flag; } +/* Add or drop a GC-disable holder (vm->gc.disable_holders; see vm_core.h). critical + * is the anonymous holder used by internal sections that must not be interrupted by a + * GC, such as collecting under the barrier. */ + +static void +rb_gc_critical_disable(void) +{ + rb_gc_impl_gc_rest(rb_gc_get_objspace()); + RUBY_ATOMIC_INC(GET_VM()->gc.disable_holders); +} + +static void +rb_gc_critical_enable(void) +{ + RUBY_ATOMIC_DEC(GET_VM()->gc.disable_holders); +} + +bool +rb_gc_gc_disabled_global_p(void) +{ + return RUBY_ATOMIC_LOAD(GET_VM()->gc.disable_holders) != 0; +} + +/* GC.disable/enable set and clear this objspace's flag and only move the holder count + * when the flag actually changes. The returned previous state is this objspace's. */ +static bool +gc_ractor_disable_set(bool disable) +{ + const bool was = rb_gc_impl_user_gc_disabled_set(rb_gc_get_objspace(), disable); + if (was != disable) { + if (disable) { + RUBY_ATOMIC_INC(GET_VM()->gc.disable_holders); + } + else { + RUBY_ATOMIC_DEC(GET_VM()->gc.disable_holders); + } + } + return was; +} + VALUE rb_gc_enable(void) { - return rb_objspace_gc_enable(rb_gc_get_objspace()); + return RBOOL(gc_ractor_disable_set(false)); } VALUE -rb_objspace_gc_enable(void *objspace) +rb_gc_disable_no_rest(void) { - bool disabled = !rb_gc_impl_gc_enabled_p(objspace); - rb_gc_impl_gc_enable(objspace); - return RBOOL(disabled); + return RBOOL(gc_ractor_disable_set(true)); } -static VALUE -gc_enable(rb_execution_context_t *ec, VALUE _) +VALUE +rb_gc_disable(void) { - return rb_gc_enable(); + const bool was_disabled = gc_ractor_disable_set(true); + if (!was_disabled) { + rb_gc_impl_gc_rest(rb_gc_get_objspace()); + } + return RBOOL(was_disabled); } -static VALUE -gc_disable_no_rest(void *objspace) +VALUE +rb_objspace_gc_enable(void *objspace) { bool disabled = !rb_gc_impl_gc_enabled_p(objspace); - rb_gc_impl_gc_disable(objspace, false); + rb_gc_impl_gc_enable(objspace); return RBOOL(disabled); } VALUE -rb_gc_disable_no_rest(void) +rb_objspace_gc_disable(void *objspace) { - return gc_disable_no_rest(rb_gc_get_objspace()); + bool disabled = !rb_gc_impl_gc_enabled_p(objspace); + rb_gc_impl_gc_disable(objspace, true); + return RBOOL(disabled); } VALUE -rb_gc_disable(void) +rb_gc_local_enable(void) { - return rb_objspace_gc_disable(rb_gc_get_objspace()); + return rb_objspace_gc_enable(rb_gc_get_objspace()); } + VALUE -rb_objspace_gc_disable(void *objspace) +rb_gc_local_disable_no_rest(void) { + void *objspace = rb_gc_get_objspace(); bool disabled = !rb_gc_impl_gc_enabled_p(objspace); - rb_gc_impl_gc_disable(objspace, true); + rb_gc_impl_gc_disable(objspace, false); return RBOOL(disabled); } +static VALUE +gc_enable(rb_execution_context_t *ec, VALUE _) +{ + return rb_gc_enable(); +} + static VALUE gc_disable(rb_execution_context_t *ec, VALUE _) { @@ -4574,8 +5411,6 @@ rb_objspace_reachable_objects_from_root(void (func)(const char *category, VALUE, { if (rb_gc_impl_during_gc_p(rb_gc_get_objspace())) rb_bug("rb_gc_impl_objspace_reachable_objects_from_root() is not supported while during GC"); - rb_vm_t *vm = GET_VM(); - struct root_objects_data data = { .func = func, .data = passing_data, @@ -4590,7 +5425,7 @@ rb_objspace_reachable_objects_from_root(void (func)(const char *category, VALUE, *mfdp = &mfd; rb_gc_save_machine_context(); - rb_gc_mark_roots(vm->gc.objspace, &data.category); + rb_gc_mark_roots(rb_gc_get_objspace(), &data.category); *mfdp = prev_mfd; } @@ -5540,45 +6375,54 @@ check_shareable_i(const VALUE child, void *ptr) struct check_shareable_data *data = (struct check_shareable_data *)ptr; if (!rb_gc_obj_shareable_p(child)) { + /* A shareable object may reference an unshareable one only if the write barrier + * recorded the edge in the target's shref bit (keeping it alive past its owner's + * local GC). Root-like exceptions (Ractor private fields, cref, JIT) are hidden + * while checking_shareable is set. */ + if (rb_gc_impl_shref_marked_p(rb_gc_get_objspace(), child)) { + return; + } + fprintf(stderr, "(a) "); rb_gc_rp(data->parent); fprintf(stderr, "(b) "); rb_gc_rp(child); - fprintf(stderr, "check_shareable_i: shareable (a) -> unshareable (b)\n"); + fprintf(stderr, "check_shareable_i: shareable (a) -> unshareable (b) without a shref record\n"); data->err_count++; rb_bug("!! violate shareable constraint !!"); } } -static bool gc_checking_shareable = false; - -static void -gc_verify_shareable(void *objspace, VALUE obj, void *data) -{ - // while gc_checking_shareable is true, - // other Ractors should not run the GC, until the flag is not local. - // TODO: remove VM locking if the flag is Ractor local - - unsigned int lev = RB_GC_VM_LOCK(); - { - gc_checking_shareable = true; - rb_objspace_reachable_objects_from(obj, check_shareable_i, (void *)data); - gc_checking_shareable = false; - } - RB_GC_VM_UNLOCK(lev); -} - -// TODO: only one level (non-recursive) +/* List obj's direct children one level deep through the traversal API and check the + * shareable constraint: a shareable object's child is either shareable or an + * unshareable one with a recorded shref. The "verification walk in progress" marker + * lives in the per-Ractor mark_func_data slot: a process-global flag would make the + * lock-free local GC of an unrelated Ractor hit the mark gate too, skip marking a live + * object's children (its fields imemo, say) and let the sweep collect them. (Upstream + * could use a global flag, since its GC always runs under the VM lock.) The slot is + * private to this Ractor and the walk is synchronous, so no lock is needed. */ void rb_gc_verify_shareable(VALUE obj) { - rb_objspace_t *objspace = rb_gc_get_objspace(); struct check_shareable_data data = { .parent = obj, .err_count = 0, }; - gc_verify_shareable(objspace, obj, &data); + + if (!RB_SPECIAL_CONST_P(obj)) { + struct gc_mark_func_data_struct **mfdp = GC_MARK_FUNC_DATA_SLOTP(); + struct gc_mark_func_data_struct *prev_mfd = *mfdp; + struct gc_mark_func_data_struct mfd = { + .mark_func = check_shareable_i, + .data = &data, + .checking_shareable = true, + }; + + *mfdp = &mfd; + rb_gc_mark_children(rb_gc_get_objspace(), obj); + *mfdp = prev_mfd; + } if (data.err_count > 0) { rb_bug("rb_gc_verify_shareable"); @@ -5588,7 +6432,8 @@ rb_gc_verify_shareable(VALUE obj) bool rb_gc_checking_shareable(void) { - return gc_checking_shareable; + const struct gc_mark_func_data_struct *mfd = *GC_MARK_FUNC_DATA_SLOTP(); + return mfd && mfd->checking_shareable; } /* diff --git a/gc/default/default.c b/gc/default/default.c index 14d7df606c144d..559598ed1ebe27 100644 --- a/gc/default/default.c +++ b/gc/default/default.c @@ -6,6 +6,7 @@ #ifndef _WIN32 # include # include +# include # ifdef HAVE_SYS_PRCTL_H # include # endif @@ -18,6 +19,7 @@ #ifdef BUILDING_MODULAR_GC # define nlz_int64(x) (x == 0 ? 64 : (unsigned int)__builtin_clzll((unsigned long long)x)) +# define rb_popcount_intptr(x) ((unsigned int)__builtin_popcountll((unsigned long long)(x))) #else # include "internal/bits.h" #endif @@ -34,6 +36,8 @@ #include "darray.h" #include "gc/gc.h" #include "gc/gc_impl.h" +#include "yjit.h" +#include "zjit.h" #ifdef BUILDING_MODULAR_GC /* hrtime.h transitively includes internal/time.h -> internal/bits.h, which are @@ -243,20 +247,6 @@ static RB_THREAD_LOCAL_SPECIFIER int malloc_increase_local; SLOT(32) SLOT(64) SLOT(128) SLOT(256) SLOT(512) #endif -typedef struct ractor_newobj_heap_cache { - uintptr_t cursor; - uintptr_t cursor_end; - struct free_region *next_region; - struct heap_page *using_page; - uintptr_t region_end; - size_t allocated_objects_count; -} rb_ractor_newobj_heap_cache_t; - -typedef struct ractor_newobj_cache { - size_t incremental_mark_step_allocated_slots; - rb_ractor_newobj_heap_cache_t heap_caches[HEAP_COUNT]; -} rb_ractor_newobj_cache_t; - typedef struct { size_t heap_init_bytes; size_t heap_free_slots; @@ -527,6 +517,14 @@ typedef struct rb_heap_struct { size_t freed_slots; size_t empty_slots; + /* Bump-pointer allocation state; only this objspace's owner thread writes it. */ + struct { + uintptr_t alloc_cursor; + uintptr_t alloc_cursor_end; + struct free_region *alloc_next_region; + struct heap_page *alloc_using_page; + } newobj; + struct heap_page *free_pages; struct ccan_list_head pages; struct heap_page *sweeping_page; /* iterator for .pages */ @@ -595,11 +593,14 @@ typedef struct rb_objspace { unsigned int mode : 2; unsigned int immediate_sweep : 1; unsigned int dont_gc : 1; + /* A user hold from GC.disable (kept in vm->gc.disable_holders); owner thread only. */ + unsigned int user_gc_disabled : 1; unsigned int dont_incremental : 1; unsigned int during_gc : 1; + unsigned int during_global_gc : 1; unsigned int during_compacting : 1; + unsigned int gc_lock_barrier : 1; unsigned int during_reference_updating : 1; - unsigned int gc_stressful: 1; unsigned int during_minor_gc : 1; unsigned int during_incremental_marking : 1; unsigned int measure_gc : 1; @@ -618,6 +619,17 @@ typedef struct rb_objspace { mark_stack_t mark_stack; size_t marked_slots; + /* Moved out of the per-Ractor newobj cache: allocation state is per objspace. */ + size_t incremental_mark_step_allocated_slots; + + /* Inputs of the global GC trigger, all owned by this objspace's thread. + * shareable_objects is the live population of shareable objects; exceeding the + * limit requests a global GC. */ + size_t shareable_objects; + size_t shareable_objects_limit; + /* Whether the last mark ran the pinned walk; the sweep asserts on it. */ + unsigned char last_cycle_pinned; + struct { rb_darray(struct heap_page *) sorted; @@ -698,7 +710,6 @@ typedef struct rb_objspace { size_t weak_references_count; } profile; - VALUE gc_stress_mode; struct { bool parent_object_old_p; @@ -744,7 +755,6 @@ typedef struct rb_objspace { rb_darray(VALUE) weak_references; rb_postponed_job_handle_t finalize_deferred_pjob; - unsigned long live_ractor_cache_count; int sweeping_heap_count; @@ -753,20 +763,104 @@ typedef struct rb_objspace { struct rb_gc_vm_context vm_context; } rb_objspace_t; +/* The one VM-global GC structure; for now it only holds the page pool. Page bodies are + * carved out of large mmap arenas and reused via a process-wide freelist (per-page + * mmap/munmap would serialize on the kernel's mmap_lock). Leaf lock: no alloc, no GC. */ +typedef struct rb_global_objspace { + struct { + rb_nativethread_lock_t lock; + struct heap_page_body *freelist; /* bodies to reuse; the next pointer lives in the body */ + /* List of mmap'd memory regions (arenas) for page bodies. */ + struct page_arena { + struct page_arena *next; + char *start; /* usable area, HEAP_PAGE_ALIGN aligned */ + size_t size; /* usable bytes (a multiple of HEAP_PAGE_SIZE) */ + } *arenas; /* every arena, newest first */ + char *arena_cursor; /* first body not yet carved out of the newest arena */ + char *arena_end; + } page_pool; + + /* Zombie pages left after the last global cycle (roughly the live data). Updated + * under the barrier; readers (gc_need_global_p) may be racy. */ + size_t zombie_pages_survivors; + + /* An objspace merge (objspace_absorb) is running: suppress the cross-objspace + * verifier while the graph is in flux. Written by the absorbing thread, read by + * verification with the world stopped. */ + bool during_absorb; + + /* main's objspace, for gc_enter's locking policy. main_ractor->objspace is swapped + * during Ractor creation; this stable pointer decides the same way at both ends of a + * GC. Set at boot, re-pointed in a forked child. */ + rb_objspace_t *main_objspace; + + /* GC.stress is process-global (upstream semantics). Written by GC.stress= in any + * Ractor (rare) and read on every Ractor's alloc and GC path; it is diagnostic, so + * plain store/load with last-writer-wins is fine. */ + bool gc_stressful; + VALUE gc_stress_mode; + + /* Global GC driver state. compacting is true during the move phase: reference + * updates are deferred until all forwarding exists, so a cross-objspace reference is + * rewritten exactly once. objspaces is the snapshot being collected. */ + struct { + bool compacting; + struct rb_objspace **objspaces; + size_t n_objspaces, objspaces_capa; + } global_gc; + + /* Index of every objspace's heap pages, ordered by body address. Writers (page + * alloc/free) serialize on page_pool.lock; the only reader is a stop-the-world global + * GC, so reads need no lock. A local GC uses its own heap_pages.sorted. */ + struct { + struct heap_page **pages; + size_t n_pages, capa; + uintptr_t lomem, himem; + } page_index; +} rb_global_objspace_t; + +static rb_global_objspace_t rb_global_objspace_instance; +static rb_global_objspace_t *global_objspace = NULL; + +/* The floor keeps a global GC from running as soon as a few shareable objects appear; + * the factor follows the rule used for the old-generation limit. */ +#define SHAREABLE_OBJECTS_LIMIT_MIN (1 << 16) +#define SHAREABLE_OBJECTS_LIMIT_FACTOR 2.0 +/* Start a global GC once terminated, uninherited Ractors hold this many heap pages. A + * small Ractor's objspace is about 13 pages, so discarding many of them still stays + * below it, while a single fat zombie crosses it. */ +#define ZOMBIE_PAGES_TRIGGER 256 + +static void objspace_absorb(rb_objspace_t *dst, rb_objspace_t *src); + + +static struct heap_page_body *page_pool_acquire(void); +static void page_pool_release(struct heap_page_body *body); + +static void +global_objspace_init(void) +{ + if (global_objspace == NULL) { + rb_global_objspace_t *g = &rb_global_objspace_instance; + rb_native_mutex_initialize(&g->page_pool.lock); + g->page_pool.freelist = NULL; + g->page_pool.arenas = NULL; + g->page_pool.arena_cursor = NULL; + g->page_pool.arena_end = NULL; + global_objspace = g; + } +} + + #ifndef HEAP_PAGE_ALIGN_LOG /* default tiny heap size: 64KiB */ #define HEAP_PAGE_ALIGN_LOG 16 #endif -#if RB_GC_OBJ_HAS_SUFFIX || GC_DEBUG +#if GC_DEBUG struct rvalue_overhead { -# if RB_GC_OBJ_HAS_SUFFIX - struct rb_gc_obj_suffix suffix; -# endif -# if GC_DEBUG const char *file; int line; -# endif }; // Make sure that RVALUE_OVERHEAD aligns to sizeof(VALUE) @@ -896,14 +990,23 @@ struct heap_page { unsigned short free_slots; unsigned short final_slots; unsigned short pinned_slots; + /* Page state flags. A bitfield is safe: the only writers are the owning Ractor + * (GVL) and the global GC driver (stop-the-world), never together. has_shareable / + * has_shref hint that the page holds at least one such bit, for re-scanning. */ struct { unsigned int before_sweep : 1; unsigned int has_remembered_objects : 1; unsigned int has_uncollectible_wb_unprotected_objects : 1; + unsigned int has_shref_objects : 1; + unsigned int has_shareable_objects : 1; } flags; rb_heap_t *heap; + /* The objspace owning this page, so any object's owner is one load away + * (GET_HEAP_OBJSPACE). Rewritten only when a page changes owner (inheritance). */ + rb_objspace_t *objspace; + struct heap_page *free_next; struct heap_page_body *body; struct ccan_list_node page_node; @@ -916,6 +1019,13 @@ struct heap_page { bits_t remembered_bits[HEAP_PAGE_BITMAP_LIMIT]; + /* Two extra bits per object. shareable_bits: what a local sweep must never free + * (only a global GC decides a shareable object is dead); set at creation and by + * rb_gc_impl_obj_became_shareable. shref_bits: an unshareable object referenced + * from a shareable one, a local GC root; the write barrier maintains it. */ + bits_t shareable_bits[HEAP_PAGE_BITMAP_LIMIT]; + bits_t shref_bits[HEAP_PAGE_BITMAP_LIMIT]; + /* If set, the object is not movable */ bits_t pinned_bits[HEAP_PAGE_BITMAP_LIMIT]; bits_t age_bits[HEAP_PAGE_BITMAP_LIMIT * RVALUE_AGE_BIT_COUNT]; @@ -990,6 +1100,38 @@ slot_index_for_offset(size_t offset, uint64_t reciprocal) #define GET_HEAP_UNCOLLECTIBLE_BITS(x) (&GET_HEAP_PAGE(x)->uncollectible_bits[0]) #define GET_HEAP_WB_UNPROTECTED_BITS(x) (&GET_HEAP_PAGE(x)->wb_unprotected_bits[0]) #define GET_HEAP_MARKING_BITS(x) (&GET_HEAP_PAGE(x)->marking_bits[0]) +#define GET_HEAP_SHAREABLE_BITS(x) (&GET_HEAP_PAGE(x)->shareable_bits[0]) +#define GET_HEAP_SHREF_BITS(x) (&GET_HEAP_PAGE(x)->shref_bits[0]) +#define GET_HEAP_OBJSPACE(x) (GET_HEAP_PAGE(x)->objspace) + +/* obj lives on a page of another objspace, not the current one (i.e. it is foreign). */ +static inline bool +gc_foreign_object_p(const rb_objspace_t *objspace, VALUE obj) +{ + return RB_UNLIKELY(GET_HEAP_OBJSPACE(obj) != objspace); +} + +/* Foreign and not inside a stop-the-world global GC. While true, a local GC must not + * touch obj's per-object GC state (mark, pin, remember bits): its owner handles that, + * or the global GC does with everyone stopped. */ +static inline bool +gc_skip_foreign_object_p(const rb_objspace_t *objspace, VALUE obj) +{ + return gc_foreign_object_p(objspace, obj) && !objspace->flags.during_global_gc; +} + +/* Record obj as shareable on its owning page (bit, page flag and population counter). + * Shared by born-shareable objects and make_shareable. The writer is the owner thread, + * so plain bit operations suffice. */ +static inline void +gc_page_add_shareable(struct heap_page *page, VALUE obj) +{ + GC_ASSERT(page == GET_HEAP_PAGE(obj)); + GC_ASSERT(RB_FL_TEST_RAW(obj, RUBY_FL_SHAREABLE)); + _MARK_IN_BITMAP(page->shareable_bits, page, obj); + page->flags.has_shareable_objects = TRUE; + page->objspace->shareable_objects++; +} static int RVALUE_AGE_GET(VALUE obj) @@ -1134,8 +1276,8 @@ gc_malloc_counters_snapshot(rb_objspace_t *objspace, struct gc_malloc_bytes *c) #define during_gc objspace->flags.during_gc #define finalizing objspace->atomic_flags.finalizing #define finalizer_table objspace->finalizer_table -#define ruby_gc_stressful objspace->flags.gc_stressful -#define ruby_gc_stress_mode objspace->gc_stress_mode +#define ruby_gc_stressful global_objspace->gc_stressful +#define ruby_gc_stress_mode global_objspace->gc_stress_mode #if GC_DEBUG_STRESS_TO_CLASS #define stress_to_class objspace->stress_to_class #define set_stress_to_class(c) (stress_to_class = (c)) @@ -1278,13 +1420,24 @@ static void init_mark_stack(mark_stack_t *stack); static int garbage_collect(rb_objspace_t *, unsigned int reason); static int gc_start(rb_objspace_t *objspace, unsigned int reason); +static int gc_start_body(rb_objspace_t *objspace, unsigned int reason, bool allow_global); static void gc_rest(rb_objspace_t *objspace); +/* GC cycle events (ENTER, EXIT, START, END_MARK, END_SWEEP) fire only if the objspace's + * own Ractor enabled them, so a concurrent local GC never walks the VM-global hook list + * while another Ractor mutates it. NEWOBJ and FREEOBJ were already restricted. */ +#define gc_event_hook(objspace, event) do { \ + if (RB_UNLIKELY((objspace)->hook_events & (event))) { \ + rb_gc_event_hook(0, (event)); \ + } \ +} while (0) + enum gc_enter_event { gc_enter_event_start, gc_enter_event_continue, gc_enter_event_rest, gc_enter_event_finalizer, + gc_enter_event_global, }; static inline void gc_enter(rb_objspace_t *objspace, enum gc_enter_event event, unsigned int *lock_lev); @@ -1486,36 +1639,61 @@ RVALUE_UNCOLLECTIBLE(rb_objspace_t *objspace, VALUE obj) #define RVALUE_PAGE_MARKING(page, obj) MARKED_IN_BITMAP((page)->marking_bits, (obj)) static int rgengc_remember(rb_objspace_t *objspace, VALUE obj); -static void rgengc_mark_and_rememberset_clear(rb_objspace_t *objspace, rb_heap_t *heap); +static void gc_bitmaps_clear(rb_objspace_t *objspace, rb_heap_t *heap, bool clear_shref); static void rgengc_rememberset_mark(rb_objspace_t *objspace, rb_heap_t *heap); +static bool verify_pointer_in_any_heap_p(const void *ptr); /* cross-objspace ownership test */ static int check_rvalue_consistency_force(rb_objspace_t *objspace, const VALUE obj, int terminate) { int err = 0; - int lev = RB_GC_VM_LOCK_NO_BARRIER(); + /* Under a global GC the barrier stops every Ractor, so the cross-objspace walk + * below is safe without the VM lock. Sweeping an ownerless zombie objspace also + * leaves GET_RACTOR() NULL, and taking the lock here would dereference it. */ + const bool world_stopped = objspace->flags.during_global_gc; + /* The VM lock protects the cross-objspace walk while other Ractors run and + * reallocate their heaps. Not taken while this objspace is in GC: pages are stable + * then, the cross-objspace walk needs the world stopped anyway, and the Ractor lock + * may already be held (Ractor -> VM order inversion). A global GC holds the barrier + * and needs no lock. */ + const bool take_vm_lock = !world_stopped && !during_gc; + unsigned int lev = 0; + if (take_vm_lock) lev = RB_GC_VM_LOCK_NO_BARRIER(); { if (SPECIAL_CONST_P(obj)) { fprintf(stderr, "check_rvalue_consistency: %p is a special const.\n", (void *)obj); err++; } else if (!is_pointer_to_heap(objspace, (void *)obj)) { - struct heap_page *empty_page = objspace->empty_pages; - while (empty_page) { - if ((uintptr_t)empty_page->body <= (uintptr_t)obj && - (uintptr_t)obj < (uintptr_t)empty_page->body + HEAP_PAGE_SIZE) { - GC_ASSERT(heap_page_in_global_empty_pages_pool(objspace, empty_page)); - fprintf(stderr, "check_rvalue_consistency: %p is in an empty page (%p).\n", - (void *)obj, (void *)empty_page); - err++; - goto skip; + /* obj may be a legitimate cross-objspace reference (a shareable object, an + * in-flight shref payload); it is a non-object only if no objspace's heap + * holds it. A foreign object's mark/age/remembered bits belong to its + * owner and reading them would race its local GC: skip per-object checks. */ + if (!world_stopped) { + /* A mid-local-GC verify holds no barrier, so other Ractors reallocate + * heap_pages.sorted under verify_pointer_in_any_heap_p's page_index + * read. Accept foreign pointers here; the global GC's world-stopped + * verify does the full existence check. */ + } + else if (!verify_pointer_in_any_heap_p((void *)obj)) { + struct heap_page *empty_page = objspace->empty_pages; + while (empty_page) { + if ((uintptr_t)empty_page->body <= (uintptr_t)obj && + (uintptr_t)obj < (uintptr_t)empty_page->body + HEAP_PAGE_SIZE) { + GC_ASSERT(heap_page_in_global_empty_pages_pool(objspace, empty_page)); + fprintf(stderr, "check_rvalue_consistency: %p is in an empty page (%p).\n", + (void *)obj, (void *)empty_page); + err++; + goto skip; + } + empty_page = empty_page->free_next; } + fprintf(stderr, "check_rvalue_consistency: %p is not a Ruby object.\n", (void *)obj); + err++; + skip: + ; } - fprintf(stderr, "check_rvalue_consistency: %p is not a Ruby object.\n", (void *)obj); - err++; - skip: - ; } else { const int wb_unprotected_bit = RVALUE_WB_UNPROTECTED_BITMAP(obj) != 0; @@ -1538,7 +1716,9 @@ check_rvalue_consistency_force(rb_objspace_t *objspace, const VALUE obj, int ter err++; } - if (BUILTIN_TYPE(obj) != T_DATA) { + /* Do not run the memsize probe once an inconsistency (a T_NONE, say) was + * found: an rb_bug inside the probe would lose the real diagnosis. */ + if (err == 0 && BUILTIN_TYPE(obj) != T_DATA) { rb_obj_memsize_of((VALUE)obj); } @@ -1584,7 +1764,7 @@ check_rvalue_consistency_force(rb_objspace_t *objspace, const VALUE obj, int ter } } } - RB_GC_VM_UNLOCK_NO_BARRIER(lev); + if (take_vm_lock) RB_GC_VM_UNLOCK_NO_BARRIER(lev); if (err > 0 && terminate) { rb_bug("check_rvalue_consistency_force: there is %d errors.", err); @@ -1632,7 +1812,10 @@ static inline void RVALUE_PAGE_OLD_UNCOLLECTIBLE_SET(rb_objspace_t *objspace, struct heap_page *page, VALUE obj) { MARK_IN_BITMAP(&page->uncollectible_bits[0], obj); - objspace->rgengc.old_objects++; + /* Count a promotion in the object's own objspace: a global GC ages every objspace's + * slots from the driver, and counting them there would skew the other objspaces' + * old_objects and with it their major GC frequency. */ + page->objspace->rgengc.old_objects++; #if RGENGC_PROFILE >= 2 objspace->profile.total_promoted_count++; @@ -1689,14 +1872,16 @@ RVALUE_DEMOTE(rb_objspace_t *objspace, VALUE obj) GC_ASSERT(RVALUE_OLD_P(objspace, obj)); if (!is_incremental_marking(objspace) && RVALUE_REMEMBERED(objspace, obj)) { - CLEAR_IN_BITMAP(GET_HEAP_PAGE(obj)->remembered_bits, obj); + struct heap_page *page = GET_HEAP_PAGE(obj); + _CLEAR_IN_BITMAP(page->remembered_bits, page, obj); } CLEAR_IN_BITMAP(GET_HEAP_UNCOLLECTIBLE_BITS(obj), obj); RVALUE_AGE_RESET(obj); if (RVALUE_MARKED(objspace, obj)) { - objspace->rgengc.old_objects--; + /* symmetric with RVALUE_PAGE_OLD_UNCOLLECTIBLE_SET */ + GET_HEAP_PAGE(obj)->objspace->rgengc.old_objects--; } check_rvalue_consistency(objspace, obj); @@ -1714,6 +1899,22 @@ RVALUE_WHITE_P(rb_objspace_t *objspace, VALUE obj) return !RVALUE_MARKED(objspace, obj); } +bool +rb_gc_impl_user_gc_disabled_set(void *objspace_ptr, bool disable) +{ + rb_objspace_t *objspace = objspace_ptr; + const bool was = objspace->flags.user_gc_disabled; + objspace->flags.user_gc_disabled = disable; + return was; +} + +bool +rb_gc_impl_user_gc_disabled_p(void *objspace_ptr) +{ + rb_objspace_t *objspace = objspace_ptr; + return objspace->flags.user_gc_disabled; +} + bool rb_gc_impl_gc_enabled_p(void *objspace_ptr) { @@ -1741,6 +1942,15 @@ rb_gc_impl_gc_disable(void *objspace_ptr, bool finish_current_gc) dont_gc_on(); } +/* Finish an incremental mark or lazy sweep in progress without changing the enabled + * state. gc.c uses it to settle the only objspace just before the process goes + * multi-objspace. */ +void +rb_gc_impl_gc_rest(void *objspace_ptr) +{ + gc_rest(objspace_ptr); +} + /* --------------------------- ObjectSpace ----------------------------- */ @@ -1755,6 +1965,9 @@ void rb_gc_impl_set_event_hook(void *objspace_ptr, const rb_event_flag_t event) { rb_objspace_t *objspace = objspace_ptr; + /* FREEOBJ is main-objspace only (rb_objspace_set_event_hook masks it elsewhere). */ + GC_ASSERT(!(event & RUBY_INTERNAL_EVENT_FREEOBJ) || + objspace == global_objspace->main_objspace); objspace->hook_events = event & RUBY_INTERNAL_EVENT_OBJSPACE_MASK; } @@ -1791,6 +2004,15 @@ rb_gc_impl_garbage_object_p(void *objspace_ptr, VALUE ptr) { rb_objspace_t *objspace = objspace_ptr; + /* A foreign object is a live leaf: reading its type or mark bit would race the + * owner's local GC, so outside a global GC's barrier never report it as garbage. + * The fstring/symbol weak-set lookups do reach across objspaces, but those objects + * are born shareable and only a stop-the-world global GC collects them, so "not + * garbage" is correct. */ + if (gc_skip_foreign_object_p(objspace, ptr)) { + return false; + } + /* Asking whether a freed (T_NONE), moved (T_MOVED), or finalized (T_ZOMBIE) * object is garbage gives an unreliable answer: the slot may since have been * reused for an unrelated object. A reference to one of these is stale and a @@ -1841,6 +2063,11 @@ heap_page_add_free_region(rb_objspace_t *objspace, struct heap_page *page, VALUE asan_unlock_freelist(page); + /* Keep a freed slot from carrying its old shareable and shref bits into the next + * object born there. */ + CLEAR_IN_BITMAP(GET_HEAP_SHAREABLE_BITS(obj), obj); + CLEAR_IN_BITMAP(GET_HEAP_SHREF_BITS(obj), obj); + struct free_region *region = (struct free_region *)obj; region->flags = 0; region->end = (uintptr_t)obj + page->slot_size; @@ -1955,22 +2182,67 @@ heap_page_body_free(struct heap_page_body *page_body) { GC_ASSERT((uintptr_t)page_body % HEAP_PAGE_ALIGN == 0); - if (HEAP_PAGE_ALLOC_USE_MMAP) { -#ifdef HAVE_MMAP - GC_ASSERT(HEAP_PAGE_SIZE % sysconf(_SC_PAGE_SIZE) == 0); - if (munmap(page_body, HEAP_PAGE_SIZE)) { - rb_bug("heap_page_body_free: munmap failed"); - } -#endif + page_pool_release(page_body); +} + +/* Insert into page_index. Writers serialize on page_pool.lock; lomem and himem are a + * monotonically growing over-approximation used for a quick reject. */ +static void +global_page_index_insert(struct heap_page *page) +{ + rb_global_objspace_t *g = global_objspace; + uintptr_t body = (uintptr_t)page->body; + + rb_native_mutex_lock(&g->page_pool.lock); + if (g->page_index.n_pages == g->page_index.capa) { + size_t new_capa = g->page_index.capa ? g->page_index.capa * 2 : 128; + struct heap_page **grown = realloc(g->page_index.pages, new_capa * sizeof(*grown)); + if (grown == NULL) rb_bug("global_page_index_insert: realloc failed"); + g->page_index.pages = grown; + g->page_index.capa = new_capa; } - else { - gc_aligned_free(page_body, HEAP_PAGE_SIZE); + size_t lo = 0, hi = g->page_index.n_pages; + while (lo < hi) { + size_t mid = (lo + hi) / 2; + if ((uintptr_t)g->page_index.pages[mid]->body < body) lo = mid + 1; + else hi = mid; } + memmove(&g->page_index.pages[lo + 1], &g->page_index.pages[lo], + (g->page_index.n_pages - lo) * sizeof(struct heap_page *)); + g->page_index.pages[lo] = page; + g->page_index.n_pages++; + + uintptr_t start = body + sizeof(struct heap_page_header); + uintptr_t end = body + HEAP_PAGE_SIZE; + if (g->page_index.lomem == 0 || g->page_index.lomem > start) g->page_index.lomem = start; + if (g->page_index.himem < end) g->page_index.himem = end; + rb_native_mutex_unlock(&g->page_pool.lock); +} + +static void +global_page_index_remove(const struct heap_page *page) +{ + rb_global_objspace_t *g = global_objspace; + uintptr_t body = (uintptr_t)page->body; + + rb_native_mutex_lock(&g->page_pool.lock); + size_t lo = 0, hi = g->page_index.n_pages; + while (lo < hi) { + size_t mid = (lo + hi) / 2; + if ((uintptr_t)g->page_index.pages[mid]->body < body) lo = mid + 1; + else hi = mid; + } + GC_ASSERT(lo < g->page_index.n_pages && g->page_index.pages[lo] == page); + memmove(&g->page_index.pages[lo], &g->page_index.pages[lo + 1], + (g->page_index.n_pages - lo - 1) * sizeof(struct heap_page *)); + g->page_index.n_pages--; + rb_native_mutex_unlock(&g->page_pool.lock); } static void heap_page_free(rb_objspace_t *objspace, struct heap_page *page) { + global_page_index_remove(page); objspace->heap_pages.freed_pages++; heap_page_body_free(page->body); free(page); @@ -2009,15 +2281,22 @@ heap_pages_free_unused_pages(rb_objspace_t *objspace) rb_darray_pop(objspace->heap_pages.sorted, i - j); GC_ASSERT(rb_darray_size(objspace->heap_pages.sorted) == j); - struct heap_page *hipage = rb_darray_get(objspace->heap_pages.sorted, rb_darray_size(objspace->heap_pages.sorted) - 1); - uintptr_t himem = (uintptr_t)hipage->body + HEAP_PAGE_SIZE; - GC_ASSERT(himem <= heap_pages_himem); - heap_pages_himem = himem; + /* A retire GC can free every page, so an empty objspace is legitimate. */ + if (j > 0) { + struct heap_page *hipage = rb_darray_get(objspace->heap_pages.sorted, rb_darray_size(objspace->heap_pages.sorted) - 1); + uintptr_t himem = (uintptr_t)hipage->body + HEAP_PAGE_SIZE; + GC_ASSERT(himem <= heap_pages_himem); + heap_pages_himem = himem; - struct heap_page *lopage = rb_darray_get(objspace->heap_pages.sorted, 0); - uintptr_t lomem = (uintptr_t)lopage->body + sizeof(struct heap_page_header); - GC_ASSERT(lomem >= heap_pages_lomem); - heap_pages_lomem = lomem; + struct heap_page *lopage = rb_darray_get(objspace->heap_pages.sorted, 0); + uintptr_t lomem = (uintptr_t)lopage->body + sizeof(struct heap_page_header); + GC_ASSERT(lomem >= heap_pages_lomem); + heap_pages_lomem = lomem; + } + else { + heap_pages_lomem = 0; + heap_pages_himem = 0; + } } } @@ -2055,61 +2334,140 @@ gc_aligned_malloc(size_t alignment, size_t size) return res; } -static struct heap_page_body * -heap_page_body_allocate(void) -{ - struct heap_page_body *page_body; +/* The page pool (global_objspace->page_pool): heap page bodies are carved out of large + * arenas and reused through the pool's freelist. */ + +#define PAGE_POOL_ARENA_SIZE (HEAP_PAGE_SIZE * 32) /* 2MiB with 64KiB pages */ - if (HEAP_PAGE_ALLOC_USE_MMAP) { #ifdef HAVE_MMAP - GC_ASSERT(HEAP_PAGE_ALIGN % sysconf(_SC_PAGE_SIZE) == 0); +/* mmap a new arena to carve from. Called with the pool lock held, at which point the + * previous arena is always fully carved. */ +static bool +page_pool_add_arena(rb_global_objspace_t *g) +{ + GC_ASSERT(HEAP_PAGE_ALIGN % sysconf(_SC_PAGE_SIZE) == 0); - size_t mmap_size = HEAP_PAGE_ALIGN + HEAP_PAGE_SIZE; - char *ptr = mmap(NULL, mmap_size, - PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); - if (ptr == MAP_FAILED) { - return NULL; - } + size_t mmap_size = PAGE_POOL_ARENA_SIZE + HEAP_PAGE_ALIGN; + char *ptr = mmap(NULL, mmap_size, + PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (ptr == MAP_FAILED) { + return false; + } - // If we are building `default.c` as part of the ruby executable, we - // may just call `ruby_annotate_mmap`. But if we are building - // `default.c` as a shared library, we will not have access to private - // symbols, and we have to either call prctl directly or make our own - // wrapper. + // If we are building `default.c` as part of the ruby executable, we + // may just call `ruby_annotate_mmap`. But if we are building + // `default.c` as a shared library, we will not have access to private + // symbols, and we have to either call prctl directly or make our own + // wrapper. #if defined(HAVE_SYS_PRCTL_H) && defined(PR_SET_VMA) && defined(PR_SET_VMA_ANON_NAME) - prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME, ptr, mmap_size, "Ruby:GC:default:heap_page_body_allocate"); - errno = 0; + prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME, ptr, mmap_size, "Ruby:GC:default:page_pool_arena"); + errno = 0; #endif - char *aligned = ptr + HEAP_PAGE_ALIGN; - aligned -= ((VALUE)aligned & (HEAP_PAGE_ALIGN - 1)); - GC_ASSERT(aligned > ptr); - GC_ASSERT(aligned <= ptr + HEAP_PAGE_ALIGN); + /* Trim the unaligned head and tail so the usable area is HEAP_PAGE_ALIGN aligned. */ + char *aligned = ptr + HEAP_PAGE_ALIGN; + aligned -= ((uintptr_t)aligned & (HEAP_PAGE_ALIGN - 1)); + GC_ASSERT(aligned > ptr); + GC_ASSERT(aligned <= ptr + HEAP_PAGE_ALIGN); - size_t start_out_of_range_size = aligned - ptr; - GC_ASSERT(start_out_of_range_size % sysconf(_SC_PAGE_SIZE) == 0); - if (start_out_of_range_size > 0) { - if (munmap(ptr, start_out_of_range_size)) { - rb_bug("heap_page_body_allocate: munmap failed for start"); - } + size_t start_out_of_range_size = aligned - ptr; + GC_ASSERT(start_out_of_range_size % sysconf(_SC_PAGE_SIZE) == 0); + if (start_out_of_range_size > 0) { + if (munmap(ptr, start_out_of_range_size)) { + rb_bug("page_pool_add_arena: munmap failed for start"); } + } - size_t end_out_of_range_size = HEAP_PAGE_ALIGN - start_out_of_range_size; - GC_ASSERT(end_out_of_range_size % sysconf(_SC_PAGE_SIZE) == 0); - if (end_out_of_range_size > 0) { - if (munmap(aligned + HEAP_PAGE_SIZE, end_out_of_range_size)) { - rb_bug("heap_page_body_allocate: munmap failed for end"); - } + size_t end_out_of_range_size = HEAP_PAGE_ALIGN - start_out_of_range_size; + GC_ASSERT(end_out_of_range_size % sysconf(_SC_PAGE_SIZE) == 0); + if (end_out_of_range_size > 0) { + if (munmap(aligned + PAGE_POOL_ARENA_SIZE, end_out_of_range_size)) { + rb_bug("page_pool_add_arena: munmap failed for end"); + } + } + + struct page_arena *arena = calloc1(sizeof(struct page_arena)); + if (arena == NULL) { + if (munmap(aligned, PAGE_POOL_ARENA_SIZE)) { + rb_bug("page_pool_add_arena: munmap failed for arena"); + } + return false; + } + arena->start = aligned; + arena->size = PAGE_POOL_ARENA_SIZE; + arena->next = g->page_pool.arenas; + g->page_pool.arenas = arena; + + g->page_pool.arena_cursor = aligned; + g->page_pool.arena_end = aligned + PAGE_POOL_ARENA_SIZE; + + return true; +} +#endif + +static struct heap_page_body * +page_pool_acquire(void) +{ + struct heap_page_body *body = NULL; + + if (HEAP_PAGE_ALLOC_USE_MMAP) { +#ifdef HAVE_MMAP + rb_global_objspace_t *g = global_objspace; + + rb_native_mutex_lock(&g->page_pool.lock); + if (g->page_pool.freelist != NULL) { + body = g->page_pool.freelist; + asan_unpoison_memory_region(body, sizeof(struct heap_page_body *), false); + g->page_pool.freelist = *(struct heap_page_body **)body; + } + else if (g->page_pool.arena_cursor != g->page_pool.arena_end || + page_pool_add_arena(g)) { + GC_ASSERT(g->page_pool.arena_cursor + HEAP_PAGE_SIZE <= g->page_pool.arena_end); + body = (struct heap_page_body *)g->page_pool.arena_cursor; + g->page_pool.arena_cursor += HEAP_PAGE_SIZE; } + rb_native_mutex_unlock(&g->page_pool.lock); - page_body = (struct heap_page_body *)aligned; + if (body != NULL) { + asan_unpoison_memory_region(body, HEAP_PAGE_SIZE, false); + } #endif } else { - page_body = gc_aligned_malloc(HEAP_PAGE_ALIGN, HEAP_PAGE_SIZE); + body = gc_aligned_malloc(HEAP_PAGE_ALIGN, HEAP_PAGE_SIZE); } - GC_ASSERT((uintptr_t)page_body % HEAP_PAGE_ALIGN == 0); + return body; +} + +static void +page_pool_release(struct heap_page_body *body) +{ + if (HEAP_PAGE_ALLOC_USE_MMAP) { +#ifdef HAVE_MMAP + rb_global_objspace_t *g = global_objspace; + + rb_native_mutex_lock(&g->page_pool.lock); + /* A body in the empty-pages pool stays fully poisoned (see gc_sweep_page), so + * unpoison its head before linking it into the pool freelist. */ + asan_unpoison_memory_region(body, sizeof(struct heap_page_body *), false); + *(struct heap_page_body **)body = g->page_pool.freelist; + g->page_pool.freelist = body; + asan_poison_memory_region(body, HEAP_PAGE_SIZE); + rb_native_mutex_unlock(&g->page_pool.lock); +#endif + } + else { + gc_aligned_free(body, HEAP_PAGE_SIZE); + } +} + +static struct heap_page_body * +heap_page_body_allocate(void) +{ + struct heap_page_body *page_body = page_pool_acquire(); + + GC_ASSERT(page_body == NULL || (uintptr_t)page_body % HEAP_PAGE_ALIGN == 0); return page_body; } @@ -2126,6 +2484,10 @@ heap_page_resurrect(rb_objspace_t *objspace) objspace->empty_pages_count--; page = objspace->empty_pages; objspace->empty_pages = page->free_next; + /* Clear the flags left over from emptying the page before reusing it, or the + * shareable and shref scans would keep walking an empty bitmap forever. */ + page->flags.has_shareable_objects = FALSE; + page->flags.has_shref_objects = FALSE; } return page; @@ -2173,9 +2535,12 @@ heap_page_allocate(rb_objspace_t *objspace) page->body = page_body; page_body->header.page = page; + page->objspace = objspace; objspace->heap_pages.allocated_pages++; + global_page_index_insert(page); + return page; } @@ -2393,6 +2758,14 @@ newobj_init(VALUE klass, VALUE flags, int wb_protected, rb_objspace_t *objspace, RBASIC(obj)->shape_id = 0; #endif + if (RB_UNLIKELY(flags & RUBY_FL_SHAREABLE)) { + /* A born-shareable object must be WB protected: the shref and remembered-set + * rules for shareable objects assume the write barrier. A local GC roots + * shareable objects from this bit (pinned_roots_mark). */ + GC_ASSERT(wb_protected); + gc_page_add_shareable(GET_HEAP_PAGE(obj), obj); + } + #if RGENGC_CHECK_MODE int lev = RB_GC_VM_LOCK_NO_BARRIER(); { @@ -2464,86 +2837,57 @@ rb_gc_impl_size_allocatable_p(size_t size) return size <= rb_gc_impl_max_allocation_size(); } -static inline void -gc_bump_flush_alloc_count(rb_ractor_newobj_heap_cache_t *heap_cache, rb_heap_t *heap) -{ - if (heap_cache->allocated_objects_count > 0) { - RUBY_ATOMIC_SIZE_ADD(heap->total_allocated_objects, heap_cache->allocated_objects_count); - heap_cache->allocated_objects_count = 0; - } -} - -static void -ractor_cache_flush_count(rb_objspace_t *objspace, rb_ractor_newobj_cache_t *gc_cache) -{ - for (int heap_idx = 0; heap_idx < HEAP_COUNT; heap_idx++) { - gc_bump_flush_alloc_count(&gc_cache->heap_caches[heap_idx], &heaps[heap_idx]); - } -} - -static inline void -ractor_cache_open_window(rb_objspace_t *objspace, rb_ractor_newobj_heap_cache_t *heap_cache, - size_t heap_idx) -{ - uintptr_t end = heap_cache->region_end; - - if (RB_UNLIKELY(is_incremental_marking(objspace))) { - uintptr_t window_end = heap_cache->cursor + INCREMENTAL_MARK_STEP_ALLOCATIONS * pool_slot_sizes[heap_idx]; - if (window_end < end) end = window_end; - } - - heap_cache->cursor_end = end; -} - static inline bool -ractor_cache_advance_region(rb_objspace_t *objspace, rb_ractor_newobj_heap_cache_t *heap_cache, - size_t heap_idx) +heap_advance_region(rb_heap_t *heap) { - gc_bump_flush_alloc_count(heap_cache, &heaps[heap_idx]); - - struct free_region *region = heap_cache->next_region; + struct free_region *region = heap->newobj.alloc_next_region; if (region == NULL) { return false; } rb_asan_unpoison_object((VALUE)region, false); GC_ASSERT(RB_TYPE_P((VALUE)region, T_NONE)); - heap_cache->cursor = (uintptr_t)region; - heap_cache->region_end = region->end; - heap_cache->next_region = region->next; + heap->newobj.alloc_cursor = (uintptr_t)region; + heap->newobj.alloc_cursor_end = region->end; + heap->newobj.alloc_next_region = region->next; rb_asan_poison_object((VALUE)region); - ractor_cache_open_window(objspace, heap_cache, heap_idx); - return true; } -static inline VALUE -ractor_cache_allocate_slot(rb_objspace_t *objspace, rb_ractor_newobj_cache_t *gc_cache, - size_t heap_idx) +/* The whole region is ours until the next refill, so charge it to the step now. */ +static inline void +heap_charge_region(rb_objspace_t *objspace, const rb_heap_t *heap, size_t heap_idx) { - rb_ractor_newobj_heap_cache_t *heap_cache = &gc_cache->heap_caches[heap_idx]; - size_t slot_size = pool_slot_sizes[heap_idx]; + objspace->incremental_mark_step_allocated_slots += + (heap->newobj.alloc_cursor_end - heap->newobj.alloc_cursor) / pool_slot_sizes[heap_idx]; +} - uintptr_t cursor = heap_cache->cursor; - if (RB_UNLIKELY(cursor + slot_size > heap_cache->cursor_end)) { - if (RB_UNLIKELY(is_incremental_marking(objspace))) { - return Qfalse; - } +static inline VALUE +heap_alloc_slot(rb_objspace_t *objspace, size_t heap_idx) +{ + rb_heap_t *heap = &heaps[heap_idx]; - if (!ractor_cache_advance_region(objspace, heap_cache, heap_idx)) { + uintptr_t cursor = heap->newobj.alloc_cursor; + if (RB_UNLIKELY(cursor >= heap->newobj.alloc_cursor_end)) { + /* Marking owes us a step before the next region, and newobj_refill runs it. */ + if (RB_UNLIKELY(is_incremental_marking(objspace)) || + heap_advance_region(heap) == false) { return Qfalse; } - cursor = heap_cache->cursor; + cursor = heap->newobj.alloc_cursor; } VALUE obj = (VALUE)cursor; rb_asan_unpoison_object(obj, true); - heap_cache->cursor = cursor + slot_size; - heap_cache->allocated_objects_count++; + heap->newobj.alloc_cursor = cursor + pool_slot_sizes[heap_idx]; + + /* Single writer (the owning Ractor under the GVL), so a plain increment is enough. */ + heap->total_allocated_objects++; #if RGENGC_CHECK_MODE GC_ASSERT(rb_gc_impl_obj_slot_size(obj) == heap_slot_size(heap_idx)); + // zero clear MEMZERO((char *)obj, char, heap_slot_size(heap_idx)); #endif return obj; @@ -2569,30 +2913,27 @@ heap_next_free_page(rb_objspace_t *objspace, rb_heap_t *heap) } static inline void -ractor_cache_set_page(rb_objspace_t *objspace, rb_ractor_newobj_cache_t *gc_cache, size_t heap_idx, - struct heap_page *page) +heap_set_alloc_page(rb_objspace_t *objspace, size_t heap_idx, struct heap_page *page) { - gc_report(3, objspace, "ractor_set_cache: Using page %p\n", (void *)page->body); + gc_report(3, objspace, "heap_set_alloc_page: Using page %p\n", (void *)page->body); - rb_ractor_newobj_heap_cache_t *heap_cache = &gc_cache->heap_caches[heap_idx]; + rb_heap_t *heap = &heaps[heap_idx]; - GC_ASSERT(heap_cache->cursor + pool_slot_sizes[heap_idx] > heap_cache->cursor_end); - GC_ASSERT(heap_cache->next_region == NULL); + GC_ASSERT(heap->newobj.alloc_cursor >= heap->newobj.alloc_cursor_end); + GC_ASSERT(heap->newobj.alloc_next_region == NULL); GC_ASSERT(page->free_slots != 0); GC_ASSERT(page->free_region != NULL); - heap_cache->using_page = page; + heap->newobj.alloc_using_page = page; struct free_region *region = page->free_region; rb_asan_unpoison_object((VALUE)region, false); GC_ASSERT(RB_TYPE_P((VALUE)region, T_NONE)); - heap_cache->cursor = (uintptr_t)region; - heap_cache->region_end = region->end; - heap_cache->next_region = region->next; + heap->newobj.alloc_cursor = (uintptr_t)region; + heap->newobj.alloc_cursor_end = region->end; + heap->newobj.alloc_next_region = region->next; rb_asan_poison_object((VALUE)region); - ractor_cache_open_window(objspace, heap_cache, heap_idx); - page->free_slots = 0; page->free_region = NULL; } @@ -2600,6 +2941,13 @@ ractor_cache_set_page(rb_objspace_t *objspace, rb_ractor_newobj_cache_t *gc_cach static void init_size_to_heap_idx(void) { + /* Process-wide and immutable, so build it once at boot. A rebuild in a later + * objspace_init would write the same values but race other threads' lock-free + * allocation-fastpath reads. */ + static bool initialized = false; + if (initialized) return; + initialized = true; + for (size_t i = 0; i < sizeof(size_to_heap_idx); i++) { size_t effective = i * 8 + RVALUE_OVERHEAD; uint8_t idx; @@ -2633,103 +2981,46 @@ bool rb_gc_impl_zjit_new_obj_fastpath(void *objspace_ptr, size_t alloc_size, VALUE flags, VALUE klass, struct rb_gc_zjit_fastpath *fastpath) { -#if USE_ZJIT - size_t heap_idx = 0; - size_t slot_size = 0; - for (; pool_slot_sizes[heap_idx] != 0; heap_idx++) { - if (alloc_size <= pool_slot_sizes[heap_idx]) { - slot_size = pool_slot_sizes[heap_idx]; - break; - } - } - if (slot_size == 0) return false; - - size_t base = offsetof(rb_ractor_newobj_cache_t, heap_caches) + - heap_idx * sizeof(rb_ractor_newobj_heap_cache_t); - - struct rb_gc_zjit_default_new_obj_fastpath default_fastpath = { - base + offsetof(rb_ractor_newobj_heap_cache_t, cursor), - base + offsetof(rb_ractor_newobj_heap_cache_t, cursor_end), - slot_size, - flags, - klass - }; - - memset(fastpath, 0, sizeof(*fastpath)); - fastpath->kind = RB_GC_ZJIT_FASTPATH_DEFAULT; - memcpy(fastpath->data.words, &default_fastpath, sizeof(default_fastpath)); - - return true; -#else + /* Bump-pointer allocation state lives in the per-objspace heaps, but ZJIT's inline + * fastpath assumes a separate cache structure; report "no fastpath" (as gc/wbcheck + * does). The heaps are single-writer, so one could be offered later. */ return false; -#endif } -NOINLINE(static VALUE newobj_bump_pointer_miss(rb_objspace_t *objspace, rb_ractor_newobj_cache_t *gc_cache, size_t heap_idx, bool vm_locked)); +NOINLINE(static VALUE newobj_refill(rb_objspace_t *objspace, size_t heap_idx)); static VALUE -newobj_bump_pointer_miss(rb_objspace_t *objspace, rb_ractor_newobj_cache_t *gc_cache, size_t heap_idx, bool vm_locked) +newobj_refill(rb_objspace_t *objspace, size_t heap_idx) { - rb_ractor_newobj_cache_t *cache = gc_cache; - rb_ractor_newobj_heap_cache_t *heap_cache = &cache->heap_caches[heap_idx]; rb_heap_t *heap = &heaps[heap_idx]; VALUE obj = Qfalse; - unsigned int lev = 0; - bool unlock_vm = false; - - if (!vm_locked) { - lev = RB_GC_CR_LOCK(); - unlock_vm = true; - } - - { - if (RB_UNLIKELY(during_gc || ruby_gc_stressful)) { - if (during_gc) { - dont_gc_on(); - during_gc = 0; - if (rb_memerror_reentered()) { - rb_memerror(); - } - rb_bug("object allocation during garbage collection phase"); - } - } - - if (is_incremental_marking(objspace)) { - cache->incremental_mark_step_allocated_slots += heap_cache->allocated_objects_count; - gc_bump_flush_alloc_count(heap_cache, heap); - - if (cache->incremental_mark_step_allocated_slots >= INCREMENTAL_MARK_STEP_ALLOCATIONS) { - gc_continue(objspace, heap); - cache->incremental_mark_step_allocated_slots = 0; - } - - if (heap_cache->cursor + pool_slot_sizes[heap_idx] <= heap_cache->region_end) { - ractor_cache_open_window(objspace, heap_cache, heap_idx); - obj = ractor_cache_allocate_slot(objspace, gc_cache, heap_idx); - } - } - - if (obj == Qfalse) { - if (ractor_cache_advance_region(objspace, heap_cache, heap_idx)) { - obj = ractor_cache_allocate_slot(objspace, gc_cache, heap_idx); - } - } - - if (obj == Qfalse) { - struct heap_page *page = heap_next_free_page(objspace, heap); - ractor_cache_set_page(objspace, gc_cache, heap_idx, page); - - obj = ractor_cache_allocate_slot(objspace, gc_cache, heap_idx); + /* No lock: a heap is single-writer (its owner thread, serialized by the GVL inside + * the Ractor), the page pool has its own mutex, and a GC started from here takes + * whatever gc_enter needs. */ + if (is_incremental_marking(objspace)) { + /* The fast path sends us here at every region, which is far more often than the + * step size, so step only once the regions add up to it. */ + if (objspace->incremental_mark_step_allocated_slots >= INCREMENTAL_MARK_STEP_ALLOCATIONS) { + gc_continue(objspace, heap); + objspace->incremental_mark_step_allocated_slots = 0; } - if (RB_UNLIKELY(ruby_gc_stressful)) { - heap_cache->cursor_end = heap_cache->cursor; + // Move on to the region the fast path refused to take + if (heap_advance_region(heap)) { + heap_charge_region(objspace, heap, heap_idx); + obj = heap_alloc_slot(objspace, heap_idx); } } - if (unlock_vm) { - RB_GC_CR_UNLOCK(lev); + if (obj == Qfalse) { + // Get next free page (possibly running GC) + struct heap_page *page = heap_next_free_page(objspace, heap); + heap_set_alloc_page(objspace, heap_idx, page); + heap_charge_region(objspace, heap, heap_idx); + + // Retry allocation after moving to new page + obj = heap_alloc_slot(objspace, heap_idx); } if (RB_UNLIKELY(obj == Qfalse)) { @@ -2739,56 +3030,65 @@ newobj_bump_pointer_miss(rb_objspace_t *objspace, rb_ractor_newobj_cache_t *gc_c } static VALUE -newobj_alloc(rb_objspace_t *objspace, rb_ractor_newobj_cache_t *gc_cache, size_t heap_idx, bool vm_locked) +newobj_alloc(rb_objspace_t *objspace, size_t heap_idx) { - if (RB_UNLIKELY(ruby_gc_stressful)) { - if (!garbage_collect(objspace, GPR_FLAG_NEWOBJ)) { - rb_memerror(); - } - } - - VALUE obj = ractor_cache_allocate_slot(objspace, gc_cache, heap_idx); + /* The objspace belongs to the current Ractor and is single-writer, so the fast path + * needs no lock. Stress GC runs in the caller's slow path, before newobj_alloc. */ + VALUE obj = heap_alloc_slot(objspace, heap_idx); if (RB_UNLIKELY(obj == Qfalse)) { - obj = newobj_bump_pointer_miss(objspace, gc_cache, heap_idx, vm_locked); + obj = newobj_refill(objspace, heap_idx); } return obj; } -ALWAYS_INLINE(static VALUE newobj_slowpath(VALUE klass, VALUE flags, rb_objspace_t *objspace, rb_ractor_newobj_cache_t *gc_cache, int wb_protected, size_t heap_idx)); +ALWAYS_INLINE(static VALUE newobj_slowpath(VALUE klass, VALUE flags, rb_objspace_t *objspace, int wb_protected, size_t heap_idx)); static inline VALUE -newobj_slowpath(VALUE klass, VALUE flags, rb_objspace_t *objspace, rb_ractor_newobj_cache_t *gc_cache, int wb_protected, size_t heap_idx) +newobj_slowpath(VALUE klass, VALUE flags, rb_objspace_t *objspace, int wb_protected, size_t heap_idx) { VALUE obj; - unsigned int lev; - lev = RB_GC_CR_LOCK(); - { - obj = newobj_alloc(objspace, gc_cache, heap_idx, true); - newobj_init(klass, flags, wb_protected, objspace, obj); + /* No lock (see newobj_refill); during_gc and the stress flag are this objspace's own state. */ + if (RB_UNLIKELY(during_gc || ruby_gc_stressful)) { + if (during_gc) { + dont_gc_on(); + during_gc = 0; + if (rb_memerror_reentered()) { + rb_memerror(); + } + rb_bug("object allocation during garbage collection phase"); + } + + if (ruby_gc_stressful) { + if (!garbage_collect(objspace, GPR_FLAG_NEWOBJ)) { + rb_memerror(); + } + } } - RB_GC_CR_UNLOCK(lev); + + obj = newobj_alloc(objspace, heap_idx); + newobj_init(klass, flags, wb_protected, objspace, obj); return obj; } NOINLINE(static VALUE newobj_slowpath_wb_protected(VALUE klass, VALUE flags, - rb_objspace_t *objspace, rb_ractor_newobj_cache_t *gc_cache, size_t heap_idx)); + rb_objspace_t *objspace, size_t heap_idx)); NOINLINE(static VALUE newobj_slowpath_wb_unprotected(VALUE klass, VALUE flags, - rb_objspace_t *objspace, rb_ractor_newobj_cache_t *gc_cache, size_t heap_idx)); + rb_objspace_t *objspace, size_t heap_idx)); static VALUE -newobj_slowpath_wb_protected(VALUE klass, VALUE flags, rb_objspace_t *objspace, rb_ractor_newobj_cache_t *gc_cache, size_t heap_idx) +newobj_slowpath_wb_protected(VALUE klass, VALUE flags, rb_objspace_t *objspace, size_t heap_idx) { - return newobj_slowpath(klass, flags, objspace, gc_cache, TRUE, heap_idx); + return newobj_slowpath(klass, flags, objspace, TRUE, heap_idx); } static VALUE -newobj_slowpath_wb_unprotected(VALUE klass, VALUE flags, rb_objspace_t *objspace, rb_ractor_newobj_cache_t *gc_cache, size_t heap_idx) +newobj_slowpath_wb_unprotected(VALUE klass, VALUE flags, rb_objspace_t *objspace, size_t heap_idx) { - return newobj_slowpath(klass, flags, objspace, gc_cache, FALSE, heap_idx); + return newobj_slowpath(klass, flags, objspace, FALSE, heap_idx); } VALUE @@ -2797,6 +3097,10 @@ rb_gc_impl_new_obj(void *objspace_ptr, void *cache_ptr, VALUE klass, VALUE flags VALUE obj; rb_objspace_t *objspace = objspace_ptr; + /* There is no per-Ractor cache; the argument stays for ABI compatibility with other + * GC implementations such as MMTk. */ + (void)cache_ptr; + RB_DEBUG_COUNTER_INC(obj_newobj); (void)RB_DEBUG_COUNTER_INC_IF(obj_newobj_wb_unprotected, !wb_protected); @@ -2809,19 +3113,17 @@ rb_gc_impl_new_obj(void *objspace_ptr, void *cache_ptr, VALUE klass, VALUE flags size_t heap_idx = heap_idx_for_size(alloc_size); *actual_alloc_size = heap_slot_size((unsigned char)heap_idx); - rb_ractor_newobj_cache_t *gc_cache = (rb_ractor_newobj_cache_t *)cache_ptr; - if (!RB_UNLIKELY(during_gc || ruby_gc_stressful) && wb_protected) { - obj = newobj_alloc(objspace, gc_cache, heap_idx, false); + obj = newobj_alloc(objspace, heap_idx); newobj_init(klass, flags, wb_protected, objspace, obj); } else { RB_DEBUG_COUNTER_INC(obj_newobj_slowpath); obj = wb_protected ? - newobj_slowpath_wb_protected(klass, flags, objspace, gc_cache, heap_idx) : - newobj_slowpath_wb_unprotected(klass, flags, objspace, gc_cache, heap_idx); + newobj_slowpath_wb_protected(klass, flags, objspace, heap_idx) : + newobj_slowpath_wb_unprotected(klass, flags, objspace, heap_idx); } return obj; @@ -2951,6 +3253,16 @@ struct each_obj_data { rb_objspace_t *objspace; bool reenable_incremental; + /* Visit only the pages that hold shareable objects, so a foreign Ractor's objspace + * can be walked for its shareable objects alone, without touching the rest of its + * isolated heap. */ + bool shareable_only; + + /* Set when walking a foreign objspace without settling its stopped lazy sweep + * (settling would run the owner's obj_free and dfree on this thread). Objects the + * sweep is about to free are skipped: on an unswept page, unmarked means dead. */ + bool skip_unswept_dead; + each_obj_callback *each_obj_callback; each_page_callback *each_page_callback; void *data; @@ -3026,13 +3338,69 @@ objspace_each_objects_try(VALUE arg) uintptr_t pstart = (uintptr_t)page->start; uintptr_t pend = pstart + (page->total_slots * heap->slot_size); - if (data->each_obj_callback && - (*data->each_obj_callback)((void *)pstart, (void *)pend, heap->slot_size, data->data)) { - break; + if (data->shareable_only) { + /* Hand shareable objects to the callback one slot at a time, not the + * whole page: walking a foreign Ractor's objspace must never expose its + * unshareable objects, which the caller cannot inspect safely. */ + if (page->flags.has_shareable_objects) { + /* This walk runs over a foreign objspace under the barrier and + * must not settle the owner's stopped lazy sweep: settling would run + * the owner's obj_free and dfree on this thread with this Ractor's + * identity (wrong per-Ractor tables, a foreign T_DATA dfree). So no + * gc_rest, and objects the sweep is about to free are skipped: on an + * unswept page unmarked means dead and its shareable bit merely has + * not been bulk-cleared yet. Passing one to the callback would + * resurrect it, handing out a reference the owner's sweep frees as + * soon as the barrier lifts. */ + const bool page_unswept = is_lazy_sweeping(objspace) && page->flags.before_sweep; + int planes = CEILDIV(page->total_slots, BITS_BITLENGTH); + uintptr_t base = pstart; + bool stop = false; + for (int j = 0; j < planes && !stop; j++) { + bits_t bits = page->shareable_bits[j]; + uintptr_t slot = base; + while (bits) { + if ((bits & 1) && data->each_obj_callback && + !(page_unswept && !RVALUE_MARKED(objspace, (VALUE)slot)) && + (*data->each_obj_callback)((void *)slot, (void *)(slot + heap->slot_size), + heap->slot_size, data->data)) { + stop = true; + break; + } + slot += heap->slot_size; + bits >>= 1; + } + base += BITS_BITLENGTH * heap->slot_size; + } + if (stop) break; + } } - if (data->each_page_callback && - (*data->each_page_callback)(page, data->data)) { - break; + else if (data->skip_unswept_dead && + is_lazy_sweeping(objspace) && page->flags.before_sweep) { + /* A foreign page pending sweep: hand out the live objects one slot at a + * time and skip the unmarked (dead) ones the owner's sweep frees as soon + * as the barrier lifts. */ + bool stop = false; + for (uintptr_t slot = pstart; slot < pend; slot += heap->slot_size) { + if (!RVALUE_MARKED(objspace, (VALUE)slot)) continue; + if (data->each_obj_callback && + (*data->each_obj_callback)((void *)slot, (void *)(slot + heap->slot_size), + heap->slot_size, data->data)) { + stop = true; + break; + } + } + if (stop) break; + } + else { + if (data->each_obj_callback && + (*data->each_obj_callback)((void *)pstart, (void *)pend, heap->slot_size, data->data)) { + break; + } + if (data->each_page_callback && + (*data->each_page_callback)(page, data->data)) { + break; + } } page = ccan_list_next(&heap->pages, page, page_node); @@ -3080,6 +3448,45 @@ rb_gc_impl_each_objects(void *objspace_ptr, each_obj_callback *callback, void *d objspace_each_objects(objspace_ptr, callback, data, TRUE); } +/* Like rb_gc_impl_each_objects but visiting only pages that hold shareable objects, to + * reach a foreign Ractor's shareable objects without walking the rest of its heap. */ +void +rb_gc_impl_each_objects_shareable(void *objspace_ptr, each_obj_callback *callback, void *data) +{ + struct each_obj_data each_obj_data = { + .objspace = objspace_ptr, + .shareable_only = true, + .each_obj_callback = callback, + .each_page_callback = NULL, + .data = data, + }; + /* Not the protected variant: this objspace belongs to another Ractor (the caller + * holds the barrier). The protected path calls gc_rest, which would run the owner's + * stopped lazy sweep (its obj_free and dfree) on the walking thread with the + * walker's Ractor identity (wrong per-Ractor tables, a foreign T_DATA dfree). The + * owner is stopped and its page list is stable, and the walk itself skips dead, + * unswept objects (the shareable_only branch of objspace_each_objects_try). The + * walker's own incremental GC state is untouched, since this is not its objspace. */ + objspace_each_exec(FALSE, &each_obj_data); +} + +/* Walk every object of a foreign Ractor's objspace, unshareable ones included. Only for + * callers that hold the barrier and whose callback is pure C (a heap dump, memory + * accounting). As in the shareable walk above, the owner's stopped lazy sweep is not + * settled and dead, unswept objects are skipped by the walk (skip_unswept_dead). */ +void +rb_gc_impl_each_objects_foreign(void *objspace_ptr, each_obj_callback *callback, void *data) +{ + struct each_obj_data each_obj_data = { + .objspace = objspace_ptr, + .skip_unswept_dead = true, + .each_obj_callback = callback, + .each_page_callback = NULL, + .data = data, + }; + objspace_each_exec(FALSE, &each_obj_data); +} + #if GC_CAN_COMPILE_COMPACTION static void objspace_each_pages(rb_objspace_t *objspace, each_page_callback *callback, void *data, bool protected) @@ -3103,6 +3510,14 @@ rb_gc_impl_define_finalizer(void *objspace_ptr, VALUE obj, VALUE block) GC_ASSERT(!OBJ_FROZEN(obj)); + /* Registering, storing and running finalizers all belong to the object's own + * objspace, so refuse to define one on another Ractor's object (even a shareable + * one): it would land in a table the owner's sweep never consults. */ + if (GET_HEAP_OBJSPACE(obj) != objspace) { + rb_raise(rb_eRactorIsolationError, + "can not define a finalizer for an object of another Ractor"); + } + RBASIC(obj)->flags |= FL_FINALIZE; unsigned int lev = RB_GC_VM_LOCK(); @@ -3147,6 +3562,12 @@ rb_gc_impl_undefine_finalizer(void *objspace_ptr, VALUE obj) GC_ASSERT(!OBJ_FROZEN(obj)); + /* Symmetric with define. */ + if (GET_HEAP_OBJSPACE(obj) != objspace) { + rb_raise(rb_eRactorIsolationError, + "can not undefine a finalizer of an object of another Ractor"); + } + st_data_t data = obj; int lev = RB_GC_VM_LOCK(); @@ -3159,11 +3580,15 @@ rb_gc_impl_undefine_finalizer(void *objspace_ptr, VALUE obj) void rb_gc_impl_copy_finalizer(void *objspace_ptr, VALUE dest, VALUE obj) { + /* Finalizers do not cross objspaces: a copy of another Ractor's object starts with + * none (guards the public rb_gc_copy_finalizer C API; no in-tree caller crosses). + * A same-objspace copy behaves as before. Table accessed under the VM lock. */ rb_objspace_t *objspace = objspace_ptr; VALUE table; st_data_t data; if (!FL_TEST(obj, FL_FINALIZE)) return; + if (GET_HEAP_OBJSPACE(obj) != objspace) return; int lev = RB_GC_VM_LOCK(); if (RB_LIKELY(st_lookup(finalizer_table, obj, &data))) { @@ -3263,7 +3688,10 @@ finalize_deferred(rb_objspace_t *objspace) static void gc_finalize_deferred(void *dmy) { - rb_objspace_t *objspace = dmy; + /* One postponed job is shared by every objspace: the preregistration table only + * holds about 32 entries and Ractors are created continuously. A deferred finalizer + * belongs to the objspace of the thread that ran the job, i.e. the current one. */ + rb_objspace_t *objspace = rb_gc_get_objspace(); if (RUBY_ATOMIC_EXCHANGE(finalizing, 1)) return; finalize_deferred(objspace); @@ -3273,8 +3701,10 @@ gc_finalize_deferred(void *dmy) static void gc_finalize_deferred_register(rb_objspace_t *objspace) { - /* will enqueue a call to gc_finalize_deferred */ - rb_postponed_job_trigger(objspace->finalize_deferred_pjob); + /* Enqueue gc_finalize_deferred on this objspace's owning Ractor. A global GC can + * defer a foreign objspace's finalizers, and those must run on their owner rather + * than on the driver. */ + rb_gc_trigger_finalize_deferred(objspace, objspace->finalize_deferred_pjob); } static int pop_mark_stack(mark_stack_t *stack, VALUE *data); @@ -3308,7 +3738,7 @@ gc_abort(void *objspace_ptr) for (int i = 0; i < HEAP_COUNT; i++) { rb_heap_t *heap = &heaps[i]; - rgengc_mark_and_rememberset_clear(objspace, heap); + gc_bitmaps_clear(objspace, heap, false); } gc_mode_set(objspace, gc_mode_none); @@ -3610,6 +4040,8 @@ gc_unprotect_pages(rb_objspace_t *objspace, rb_heap_t *heap) } static void gc_update_references(rb_objspace_t *objspace); +static void gc_update_references_heap(rb_objspace_t *objspace); +static void gc_update_references_global(rb_objspace_t *objspace); #if GC_CAN_COMPILE_COMPACTION static void invalidate_moved_page(rb_objspace_t *objspace, struct heap_page *page); #endif @@ -3798,9 +4230,18 @@ gc_compact_finish(rb_objspace_t *objspace) gc_unprotect_pages(objspace, heap); } - uninstall_handlers(); + if (!global_objspace->global_gc.compacting) uninstall_handlers(); - gc_update_references(objspace); + if (global_objspace->global_gc.compacting) { + /* In a compacting global GC this updates only this objspace's heap references; + * gc_start_global sets during_reference_updating on every objspace (the + * move-or-mark decision reads it via rb_gc_get_objspace()) and runs the + * non-idempotent VM-global side (gc_update_references_global) once at the end. */ + gc_update_references_heap(objspace); + } + else { + gc_update_references(objspace); + } objspace->profile.compact_count++; for (int i = 0; i < HEAP_COUNT; i++) { @@ -3814,7 +4255,7 @@ gc_compact_finish(rb_objspace_t *objspace) gc_profile_record *record = gc_prof_record(objspace); record->moved_objects = objspace->rcompactor.total_moved - record->moved_objects; } - objspace->flags.during_compacting = FALSE; + if (!global_objspace->global_gc.compacting) objspace->flags.during_compacting = FALSE; } struct gc_sweep_context { @@ -3822,6 +4263,9 @@ struct gc_sweep_context { int final_slots; int freed_slots; int empty_slots; + /* Hoisted out of the per-slot pinned-free assert: too expensive for the sweep loop + * as an external call. */ + unsigned char check_pinned_free; struct free_region *free_region; }; @@ -3832,6 +4276,10 @@ gc_sweep_register_free_slot(rb_objspace_t *objspace, struct heap_page *page, str rb_asan_unpoison_object(p, false); ((struct RBasic *)p)->flags = 0; + /* Keep a freed slot from carrying its old shareable and shref bits into the next + * object born there; the actual clear happens per bitmap word at the end of + * gc_sweep_page rather than per slot. */ + struct free_region *existing_region = ctx->free_region; if (existing_region) rb_asan_unpoison_object((VALUE)existing_region, false); @@ -3885,6 +4333,20 @@ gc_sweep_plane(rb_objspace_t *objspace, rb_heap_t *heap, uintptr_t p, bits_t bit break; default: +#if RGENGC_CHECK_MODE + /* A local GC must never free a pinned slot; a global GC may (its exact + * mark collects dead shareable objects). Reading the bits here is + * CHECK-only and still valid: the bulk clear runs after the free loop. */ + if (ctx->check_pinned_free && + (MARKED_IN_BITMAP(GET_HEAP_SHAREABLE_BITS(vp), vp) || + MARKED_IN_BITMAP(GET_HEAP_SHREF_BITS(vp), vp))) { + rb_bug("page_sweep: freeing pinned slot %s (shareable=%d shref=%d single_now=%d)", + rb_obj_info(vp), + (int)!!MARKED_IN_BITMAP(GET_HEAP_SHAREABLE_BITS(vp), vp), + (int)!!MARKED_IN_BITMAP(GET_HEAP_SHREF_BITS(vp), vp), + (int)rb_gc_single_objspace_p()); + } +#endif #if RGENGC_CHECK_MODE if (!is_full_marking(objspace)) { if (RVALUE_OLD_P(objspace, vp)) rb_bug("page_sweep: %p - old while minor GC.", (void *)p); @@ -3976,6 +4438,17 @@ gc_sweep_page(rb_objspace_t *objspace, rb_heap_t *heap, struct gc_sweep_context } } + /* main's local GC is lock-free, but freeing a shareable object referenced from a + * VM-global weak table (rb_gc_obj_free_vm_weak_references: ci_table, fstring, symbol, + * cme) mutates that table, so wrap the page's free loop in a no-barrier VM lock. + * Under a global GC the barrier already protects those tables, and a compacting + * local GC holds the barrier VM lock from gc_enter, so this nests harmlessly. A + * non-main Ractor's local GC never frees such objects and does not take it. */ + const bool sweep_needs_vm_lock = + objspace == global_objspace->main_objspace && rb_gc_multi_ractor_p() && !objspace->flags.during_global_gc; + unsigned int sweep_lock_lev = 0; + if (sweep_needs_vm_lock) sweep_lock_lev = RB_GC_VM_LOCK_NO_BARRIER(); + for (int i = 0; i < bitmap_plane_count; i++) { bitset = ~bits[i]; if (bitset) { @@ -3984,6 +4457,21 @@ gc_sweep_page(rb_objspace_t *objspace, rb_heap_t *heap, struct gc_sweep_context p += BITS_BITLENGTH * slot_size; } + if (sweep_needs_vm_lock) RB_GC_VM_UNLOCK_NO_BARRIER(sweep_lock_lev); + + /* Bulk-clear the freed slots' shareable and shref bits before the freelist is + * published, so a reused slot is clean. Freed slots are exactly the unmarked ones, + * so `bits &= mark_bits` keeps live shareable objects (which must stay pinned) and + * drops the rest. Pages with neither bit are skipped. */ + if (sweep_page->flags.has_shareable_objects || sweep_page->flags.has_shref_objects) { + bits_t *shareable_bits = sweep_page->shareable_bits; + bits_t *shref_bits = sweep_page->shref_bits; + for (int i = 0; i < bitmap_plane_count; i++) { + shareable_bits[i] &= bits[i]; + shref_bits[i] &= bits[i]; + } + } + asan_unlock_freelist(sweep_page); sweep_page->free_region = ctx->free_region; asan_lock_freelist(sweep_page); @@ -4056,7 +4544,13 @@ gc_mode_transition(rb_objspace_t *objspace, enum gc_mode mode) #if RGENGC_CHECK_MODE enum gc_mode prev_mode = gc_mode(objspace); switch (prev_mode) { - case gc_mode_none: GC_ASSERT(mode == gc_mode_marking); break; + case gc_mode_none: + /* A global GC marks every objspace as one heap (mark_roots on the driver), so an + * individual objspace's mode stays `none` during that mark; the sweep inside the + * barrier then makes the legitimate none -> sweeping transition. */ + GC_ASSERT(mode == gc_mode_marking || + (objspace->flags.during_global_gc && mode == gc_mode_sweeping)); + break; case gc_mode_marking: GC_ASSERT(mode == gc_mode_sweeping); break; case gc_mode_sweeping: GC_ASSERT(mode == gc_mode_none || mode == gc_mode_compacting); break; case gc_mode_compacting: GC_ASSERT(mode == gc_mode_none); break; @@ -4067,16 +4561,16 @@ gc_mode_transition(rb_objspace_t *objspace, enum gc_mode mode) } static void -heap_page_flush_cache_regions(struct heap_page *page, rb_ractor_newobj_heap_cache_t *heap_cache) +heap_page_flush_alloc_regions(struct heap_page *page, rb_heap_t *heap) { - struct free_region *chain = heap_cache->next_region; + struct free_region *chain = heap->newobj.alloc_next_region; - if (heap_cache->cursor < heap_cache->region_end) { - VALUE start = (VALUE)heap_cache->cursor; + if (heap->newobj.alloc_cursor < heap->newobj.alloc_cursor_end) { + VALUE start = (VALUE)heap->newobj.alloc_cursor; rb_asan_unpoison_object(start, false); struct free_region *remnant = (struct free_region *)start; remnant->flags = 0; - remnant->end = heap_cache->region_end; + remnant->end = heap->newobj.alloc_cursor_end; remnant->next = chain; rb_asan_poison_object(start); chain = remnant; @@ -4126,44 +4620,27 @@ static void gc_sort_heap_by_compare_func(rb_objspace_t *objspace, gc_compact_com static int compare_pinned_slots(const void *left, const void *right, void *d); #endif +/* Return the current allocation page and freelist to their pages, so the sweeper sees a + * consistent heap. */ static void -gc_ractor_newobj_cache_clear(void *c, void *data) +heap_alloc_state_clear(rb_objspace_t *objspace) { - rb_objspace_t *objspace = data; - rb_ractor_newobj_cache_t *gc_cache = c; - rb_ractor_newobj_cache_t *newobj_cache = gc_cache; - - newobj_cache->incremental_mark_step_allocated_slots = 0; + objspace->incremental_mark_step_allocated_slots = 0; for (size_t heap_idx = 0; heap_idx < HEAP_COUNT; heap_idx++) { - rb_ractor_newobj_heap_cache_t *cache = &newobj_cache->heap_caches[heap_idx]; - rb_heap_t *heap = &heaps[heap_idx]; - gc_bump_flush_alloc_count(cache, heap); - struct heap_page *page = cache->using_page; - RUBY_DEBUG_LOG("ractor using_page:%p cursor:%p", (void *)page, (void *)cache->cursor); + struct heap_page *page = heap->newobj.alloc_using_page; + RUBY_DEBUG_LOG("heap alloc_using_page:%p cursor:%p", (void *)page, (void *)heap->newobj.alloc_cursor); if (page) { - heap_page_flush_cache_regions(page, cache); + heap_page_flush_alloc_regions(page, heap); } - cache->using_page = NULL; - cache->next_region = NULL; - cache->region_end = 0; - cache->cursor = 0; - cache->cursor_end = 0; - } -} - -static void -gc_ractor_newobj_cache_exhaust(void *c, void *data) -{ - rb_ractor_newobj_cache_t *gc_cache = c; - - for (size_t heap_idx = 0; heap_idx < HEAP_COUNT; heap_idx++) { - rb_ractor_newobj_heap_cache_t *heap_cache = &gc_cache->heap_caches[heap_idx]; - heap_cache->cursor_end = heap_cache->cursor; + heap->newobj.alloc_using_page = NULL; + heap->newobj.alloc_cursor = 0; + heap->newobj.alloc_cursor_end = 0; + heap->newobj.alloc_next_region = NULL; } } @@ -4239,18 +4716,27 @@ gc_sweep_start(rb_objspace_t *objspace) objspace->rincgc.pooled_slots = 0; if (RB_UNLIKELY(objspace->hook_events & RUBY_INTERNAL_EVENT_FREEOBJ)) { + /* FREEOBJ is never enabled outside the main objspace + * (rb_objspace_set_event_hook), so this hook, which runs user callbacks, + * cannot fire during a non-main Ractor's lock-free local sweep. */ + GC_ASSERT(objspace == global_objspace->main_objspace); gc_sweep_freeobj_hooks(objspace); } - for (int table = 0; table < RB_GC_VM_WEAK_TABLE_COUNT; table++) { - if (!rb_gc_vm_weak_table_essential_p(table)) continue; - rb_gc_vm_weak_table_foreach( - gc_sweep_weak_table_i, - NULL, - objspace, - true, - table - ); + /* Clean the VM-global tables. Under a global GC every objspace's sweep passes here, + * but there is one table per VM and the decision is a (page-relative) mark bit, so + * repeating it is idempotent waste: gc_start_global does it once before sweeping. */ + if (!objspace->flags.during_global_gc) { + for (int table = 0; table < RB_GC_VM_WEAK_TABLE_COUNT; table++) { + if (!rb_gc_vm_weak_table_essential_p(table)) continue; + rb_gc_vm_weak_table_foreach( + gc_sweep_weak_table_i, + NULL, + objspace, + true, + table + ); + } } #if GC_CAN_COMPILE_COMPACTION @@ -4274,7 +4760,7 @@ gc_sweep_start(rb_objspace_t *objspace) } } - rb_gc_ractor_newobj_cache_foreach(gc_ractor_newobj_cache_clear, objspace); + heap_alloc_state_clear(objspace); } static void @@ -4348,12 +4834,8 @@ gc_sweep_finish(rb_objspace_t *objspace) } } - rb_gc_event_hook(0, RUBY_INTERNAL_EVENT_GC_END_SWEEP); + gc_event_hook(objspace, RUBY_INTERNAL_EVENT_GC_END_SWEEP); gc_mode_transition(objspace, gc_mode_none); - -#if RGENGC_CHECK_MODE >= 2 - gc_verify_internal_consistency(objspace); -#endif } static int @@ -4371,6 +4853,12 @@ gc_sweep_step(rb_objspace_t *objspace, rb_heap_t *heap) gc_prof_sweep_timer_start(objspace); #endif + /* Per-slot pinned-free assert (gc_sweep_context): check only when this cycle's mark + * ran the pinned walk. The current world state would misfire: a single-world + * cycle leaves dead shareable objects unmarked and its sweep can straddle the switch + * to multi-objspace. A global GC's exact mark does not pin, so it is excluded. */ + const unsigned char check_pinned_free = objspace->last_cycle_pinned; + do { RUBY_DEBUG_LOG("sweep_page:%p", (void *)sweep_page); @@ -4379,6 +4867,7 @@ gc_sweep_step(rb_objspace_t *objspace, rb_heap_t *heap) .final_slots = 0, .freed_slots = 0, .empty_slots = 0, + .check_pinned_free = check_pinned_free, }; gc_sweep_page(objspace, heap, &ctx); int free_slots = ctx.freed_slots + ctx.empty_slots; @@ -4455,6 +4944,13 @@ gc_sweep_rest(rb_objspace_t *objspace) gc_sweep_step(objspace, heap); } } + + /* An objspace with no live pages never runs gc_sweep_step and so never reaches + * gc_sweep_finish, leaving mode at sweeping or compacting until the next cycle's + * gc_sweep_start asserts. If every heap is swept out, settle it to none here. */ + if (gc_mode(objspace) != gc_mode_none && !has_sweeping_pages(objspace)) { + gc_sweep_finish(objspace); + } } static void @@ -4518,12 +5014,22 @@ gc_sweep_step_for_malloc(rb_objspace_t *objspace) gc_exit(objspace, gc_enter_event_continue, &lock_lev); } +static bool gc_global_pointer_to_heap_p(const void *ptr); + VALUE rb_gc_impl_location(void *objspace_ptr, VALUE value) { + rb_objspace_t *objspace = objspace_ptr; VALUE destination; - GC_ASSERT(is_pointer_to_heap(objspace_ptr, (void *)value)); + /* A local (single-objspace) compaction never moves another objspace's objects, so + * leave foreign references alone. A compacting global GC moves objects everywhere + * under the barrier, so there every objspace's heap is searched for forwarding. */ + if (RB_UNLIKELY(objspace->flags.during_global_gc) + ? !gc_global_pointer_to_heap_p((void *)value) + : !is_pointer_to_heap(objspace_ptr, (void *)value)) { + return value; + } asan_unpoisoning_object(value) { if (BUILTIN_TYPE(value) == T_MOVED) { @@ -4627,7 +5133,8 @@ gc_compact_start(rb_objspace_t *objspace) memset(objspace->rcompactor.moved_down_count_table, 0, T_MASK * sizeof(size_t)); /* Set up read barrier for pages containing MOVED objects */ - install_handlers(); + /* A compacting global GC installs the read barrier once for every objspace. */ + if (!global_objspace->global_gc.compacting) install_handlers(); } static void gc_sweep_compact(rb_objspace_t *objspace); @@ -4973,6 +5480,27 @@ gc_mark(rb_objspace_t *objspace, VALUE obj) GC_ASSERT(during_gc); GC_ASSERT(!objspace->flags.during_reference_updating); + /* Never step into another objspace: a foreign object is a live leaf whose liveness + * belongs to its owner, so touching its bitmaps here would be unsound. A global GC + * lifts this: everyone is stopped and the bits live on the object's own page. */ + if (gc_skip_foreign_object_p(objspace, obj)) { + return; + } + + if (RB_UNLIKELY(objspace->flags.during_global_gc)) { + /* Recompute the shref of every shareable -> unshareable edge, within and across + * objspaces: the clear pass dropped all shref bits and the write barrier + * maintains them from here on. */ + VALUE parent = objspace->rgengc.parent_object; + if (!UNDEF_P(parent) && parent != Qfalse && + RB_FL_TEST_RAW(parent, RUBY_FL_SHAREABLE) && + !RB_FL_TEST_RAW(obj, RUBY_FL_SHAREABLE)) { + struct heap_page *page = GET_HEAP_PAGE(obj); + _MARK_IN_BITMAP(page->shref_bits, page, obj); + page->flags.has_shref_objects = TRUE; + } + } + rgengc_check_relation(objspace, obj); if (!gc_mark_set(objspace, obj)) return; /* already marked */ @@ -4992,6 +5520,10 @@ static inline void gc_pin(rb_objspace_t *objspace, VALUE obj) { GC_ASSERT(!SPECIAL_CONST_P(obj)); + + /* Never write a foreign page's pinned bit (a global GC may: everyone is stopped). */ + if (gc_skip_foreign_object_p(objspace, obj)) return; + if (RB_UNLIKELY(objspace->flags.during_compacting)) { if (RB_LIKELY(during_gc)) { if (!RVALUE_PINNED(objspace, obj)) { @@ -5045,6 +5577,30 @@ rb_gc_impl_mark_and_pin(void *objspace_ptr, VALUE obj) gc_mark_and_pin(objspace, obj); } +/* A word scanned conservatively by a global GC can point into any objspace, so ownership + * is decided against the driver's snapshot of every objspace (the bits then land on the + * owner's page through gc_mark and gc_pin). */ +static bool +gc_global_pointer_to_heap_p(const void *ptr) +{ + const rb_global_objspace_t *g = global_objspace; + uintptr_t p = (uintptr_t)ptr; + + if (p < g->page_index.lomem || p > g->page_index.himem) return false; + if (p % sizeof(VALUE) != 0) return false; + + struct heap_page **res = bsearch(ptr, g->page_index.pages, g->page_index.n_pages, + sizeof(struct heap_page *), ptr_in_page_body_p); + if (res == NULL) return false; + + struct heap_page *page = *res; + if (heap_page_in_global_empty_pages_pool(page->objspace, page)) return false; + if (p < page->start) return false; + if (p >= page->start + (page->total_slots * page->slot_size)) return false; + if ((p - page->start) % page->slot_size != 0) return false; + return true; +} + void rb_gc_impl_mark_maybe(void *objspace_ptr, VALUE obj) { @@ -5052,7 +5608,9 @@ rb_gc_impl_mark_maybe(void *objspace_ptr, VALUE obj) (void)VALGRIND_MAKE_MEM_DEFINED(&obj, sizeof(obj)); - if (is_pointer_to_heap(objspace, (void *)obj)) { + if (RB_UNLIKELY(objspace->flags.during_global_gc) + ? gc_global_pointer_to_heap_p((void *)obj) + : is_pointer_to_heap(objspace, (void *)obj)) { asan_unpoisoning_object(obj) { /* Garbage can live on the stack, so do not mark or pin */ switch (BUILTIN_TYPE(obj)) { @@ -5097,6 +5655,8 @@ gc_mark_set_parent_invalid(rb_objspace_t *objspace) asan_poison_memory_region(&objspace->rgengc.parent_object_old_p, sizeof(objspace->rgengc.parent_object_old_p)); } +static void pinned_roots_mark(rb_objspace_t *objspace, rb_heap_t *heap); + static void mark_roots(rb_objspace_t *objspace, const char **categoryp) { @@ -5104,10 +5664,25 @@ mark_roots(rb_objspace_t *objspace, const char **categoryp) if (categoryp) *categoryp = category; \ } while (0) + /* Pinning shareable objects and shrefs runs at the end of marking (gc_marks_finish), + * not here: after the full walk it only has to touch what ordinary marking missed, + * which is both cheap and a useful retention metric. */ + MARK_CHECKPOINT("objspace"); gc_mark_set_parent_raw(objspace, Qundef, false); - if (finalizer_table != NULL) { + if (objspace->flags.during_global_gc) { + /* Pin the finalizer tables of every objspace, zombies included. + * (finalizer_table is a macro over the local "objspace".) */ + rb_objspace_t *const driver = objspace; + for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { + rb_objspace_t *objspace = global_objspace->global_gc.objspaces[i]; + if (finalizer_table != NULL) { + st_foreach(finalizer_table, pin_value, (st_data_t)driver); + } + } + } + else if (finalizer_table != NULL) { st_foreach(finalizer_table, pin_value, (st_data_t)objspace); } @@ -5464,15 +6039,21 @@ gc_marks_check(rb_objspace_t *objspace, st_foreach_callback_func *checker_func, struct verify_internal_consistency_struct { rb_objspace_t *objspace; + /* True only while the world is stopped: a GC.verify holding the VM lock and barrier, + * or a global GC. Cross-objspace checks (walking every objspace's pages) are sound + * only then. */ + bool world_stopped; int err_count; size_t live_object_count; size_t zombie_object_count; VALUE parent; + bool parent_shareable; size_t old_object_count; size_t remembered_shady_count; }; + static void check_generation_i(const VALUE child, void *ptr) { @@ -5481,10 +6062,36 @@ check_generation_i(const VALUE child, void *ptr) if (RGENGC_CHECK_MODE) GC_ASSERT(RVALUE_OLD_P(data->objspace, parent)); + /* A cross-objspace edge is kept alive by the shareable/shref mechanism and is not + * tracked in this objspace's remembered set. */ + if (GET_HEAP_OBJSPACE(child) != data->objspace) return; + + /* Once the process goes multi-Ractor, the shareable world is managed by pinning and + * shrefs rather than by the remembered set: the pinned walk at the end of a mark + * re-marks every shareable object (and its shref'd children) each local cycle, and a + * global GC rebuilds the generation state. So the generational old->young invariant + * does not hold when either endpoint is shareable: an old constcache, cc_table or + * interned string pointing at a core class that is young after a global GC is the + * typical false positive. That state outlives the return to a single Ractor until + * the next major (an old shareable singleton class pointing at a young + * attached_object, say), so the test uses rb_gc_ever_multi_ractor_p(), which stays + * true forever once multiple Ractors existed. A program that never goes multi keeps + * the strict check, and ASAN catches what is left. */ + if (rb_gc_ever_multi_ractor_p() && + (MARKED_IN_BITMAP(GET_HEAP_SHAREABLE_BITS(parent), parent) || + MARKED_IN_BITMAP(GET_HEAP_SHAREABLE_BITS(child), child))) { + return; + } + if (!RVALUE_OLD_P(data->objspace, child)) { + /* A young shareable child is pinned and kept alive by the local GC (only a + * global GC collects it), so it survives even when the old parent does not + * remember it. It is outside the generational remembered set, so exclude it + * from the old->young check. */ if (!RVALUE_REMEMBERED(data->objspace, parent) && !RVALUE_REMEMBERED(data->objspace, child) && - !RVALUE_UNCOLLECTIBLE(data->objspace, child)) { + !RVALUE_UNCOLLECTIBLE(data->objspace, child) && + !RB_FL_TEST_RAW(child, RUBY_FL_SHAREABLE)) { fprintf(stderr, "verify_internal_consistency_reachable_i: WB miss (O->Y) %s -> %s\n", rb_obj_info(parent), rb_obj_info(child)); data->err_count++; } @@ -5508,11 +6115,60 @@ static void check_children_i(const VALUE child, void *ptr) { struct verify_internal_consistency_struct *data = (struct verify_internal_consistency_struct *)ptr; - if (check_rvalue_consistency_force(data->objspace, child, FALSE) != 0) { - fprintf(stderr, "check_children_i: %s has error (referenced from %s)", - rb_obj_info(child), rb_obj_info(data->parent)); - data->err_count++; + /* Fast path: a child in this objspace (99.99% of all edges). */ + if (RB_LIKELY(is_pointer_to_heap(data->objspace, (void *)child))) { + if (check_rvalue_consistency_force(data->objspace, child, FALSE) != 0) { + fprintf(stderr, "check_children_i: %s has error (referenced from %s)\n", + rb_obj_info(child), rb_obj_info(data->parent)); + data->err_count++; + } + return; + } + + /* The remaining cross-objspace check (verify_pointer_in_any_heap_p) walks every + * objspace's pages, sound only with the world stopped: mid-local-GC other Ractors + * change page structures concurrently. The next world-stopped verify re-checks. */ + if (!data->world_stopped) return; + + /* A non-heap child reaches this callback only when a stale field was followed by a + * plain rb_gc_mark (the dmark of a live but unreachable wrapper, say). Report it and + * keep going rather than aborting. */ + if (!verify_pointer_in_any_heap_p((void *)child)) { + /* The graph is in flux mid-merge, so a transient non-heap edge is expected; it + * is re-checked after the merge. */ + if (global_objspace->during_absorb) return; + fprintf(stderr, "VERIFY-NOTE: non-heap child %p (from %s)\n", + (void *)child, rb_obj_info(data->parent)); + return; + } + + if (GET_HEAP_OBJSPACE(child) != data->objspace) { + /* A legal cross-objspace edge either starts at a shareable object or is recorded + * in the child's shref bit (an in-flight send or move payload kept alive across + * its owner's local GC; root_scope_check_i honours the same record). An + * unshareable parent holding an unrecorded foreign unshareable child would be + * 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 and keeps in-flight payloads alive by + * re-pinning, so the shref exemption would not fire, and its unified exact + * stop-the-world mark makes the invariant itself moot. */ + 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_impl_during_global_gc_p(data->objspace) && + !rb_gc_current_ractor_materializing_p() && + !global_objspace->during_absorb) { + fprintf(stderr, "check_children_i: containment violation: " + "unshareable %s (objspace %p) -> foreign unshareable %s (objspace %p)\n", + rb_obj_info(data->parent), (void *)data->objspace, + rb_obj_info(child), (void *)GET_HEAP_OBJSPACE(child)); + data->err_count++; + } + + /* The remaining per-objspace sanity rules belong to the owner. */ + return; } } @@ -5532,6 +6188,65 @@ gc_slot_live_object_p(rb_objspace_t *objspace, VALUE obj) } } +/* Verifier only: does ptr point at a live slot in any objspace? The caller holds the VM + * lock and the barrier, so page_index is stable. */ +static bool +verify_pointer_in_any_heap_p(const void *ptr) +{ + return gc_global_pointer_to_heap_p(ptr); +} + +/* An exact root of the calling Ractor may only point at a shareable object, its own + * objspace, or an in-flight payload with a recorded shref. Exempt: the conservative + * machine scan (stale slots) and the VM-global containers that are cross-rooted by + * design (every objspace scans them; the marker skips foreign entries). */ +static void +root_scope_check_i(const char *category, VALUE obj, void *ptr) +{ + struct verify_internal_consistency_struct *data = ptr; + + if (RB_SPECIAL_CONST_P(obj)) return; + /* This check walks every objspace (verify_pointer_in_any_heap_p), so it is sound + * only with the world stopped; a mid-local-GC verify races with other Ractors' + * lock-free allocation. */ + if (!data->world_stopped) return; + /* Mid-merge the VM-global root tables still point at the unmerged source (transient + * non-heap or foreign roots); re-checked after the merge. */ + if (global_objspace->during_absorb) return; + if (strcmp(category, "machine_context") == 0 || + strcmp(category, "vm_registered_objects") == 0 || + strcmp(category, "end_proc") == 0 || + strcmp(category, "trap_list") == 0 || + /* Every Ractor's root scan walks the one VM-wide registered-globals list (a slot + * can hold another objspace's value); rb_gc_mark_maybe filters to its own + * objspace, so a foreign entry here is by design, not a leak. */ + strcmp(category, "registered_globals") == 0) { + return; + } + + if (!verify_pointer_in_any_heap_p((void *)obj)) { + fprintf(stderr, "root_scope_check_i: root category \"%s\" names a non-heap pointer %p\n", + category, (void *)obj); + data->err_count++; + 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; + if (obj == rb_gc_vm_top_self()) return; /* VM-permanent (see check_children_i) */ + /* A sender-resident snapshot being materialized by a receive is rooted through + * sync.materializing_copies: a foreign-unshareable root that is valid only while + * the copy runs (see check_children_i). */ + if (rb_gc_current_ractor_materializing_p()) return; + + fprintf(stderr, "root_scope_check_i: root category \"%s\" names a foreign " + "unshareable without a shref record: %s (owner %p, self %p)\n", + category, rb_obj_info(obj), + (void *)GET_HEAP_OBJSPACE(obj), (void *)data->objspace); + data->err_count++; +} + static int verify_internal_consistency_i(void *page_start, void *page_end, size_t stride, struct verify_internal_consistency_struct *data) @@ -5541,10 +6256,28 @@ verify_internal_consistency_i(void *page_start, void *page_end, size_t stride, for (obj = (VALUE)page_start; obj != (VALUE)page_end; obj += stride) { asan_unpoisoning_object(obj) { + bool sh_bit = MARKED_IN_BITMAP(GET_HEAP_SHAREABLE_BITS(obj), obj) != 0; + bool sr_bit = MARKED_IN_BITMAP(GET_HEAP_SHREF_BITS(obj), obj) != 0; + if (gc_slot_live_object_p(objspace, obj)) { /* count objects */ data->live_object_count++; data->parent = obj; + data->parent_shareable = sh_bit; + + /* Bitmap invariants: a page's shareable bit matches FL_SHAREABLE + * exactly, and a shref record only ever points at an unshareable + * object. */ + if (sh_bit != !!RB_FL_TEST_RAW(obj, RUBY_FL_SHAREABLE)) { + fprintf(stderr, "verify_internal_consistency_i: shareable bit %d " + "disagrees with FL_SHAREABLE on %s\n", (int)sh_bit, rb_obj_info(obj)); + data->err_count++; + } + if (sr_bit && sh_bit) { + fprintf(stderr, "verify_internal_consistency_i: shref bit on a shareable: %s\n", + rb_obj_info(obj)); + data->err_count++; + } /* Normally, we don't expect T_MOVED objects to be in the heap. * But they can stay alive on the stack, */ @@ -5576,6 +6309,15 @@ verify_internal_consistency_i(void *page_start, void *page_end, size_t stride, } } else { + /* A freed slot must not carry its old pin bit into the next object born + * there (a dead object not swept yet legitimately keeps it until the + * sweep arrives). */ + if (BUILTIN_TYPE(obj) == T_NONE && (sh_bit || sr_bit)) { + fprintf(stderr, "verify_internal_consistency_i: T_NONE slot carries " + "shareable=%d shref=%d bits\n", (int)sh_bit, (int)sr_bit); + data->err_count++; + } + if (BUILTIN_TYPE(obj) == T_ZOMBIE) { data->zombie_object_count++; @@ -5700,11 +6442,12 @@ gc_verify_heap_pages(rb_objspace_t *objspace) } static void -gc_verify_internal_consistency_(rb_objspace_t *objspace) +gc_verify_internal_consistency_(rb_objspace_t *objspace, bool world_stopped) { struct verify_internal_consistency_struct data = {0}; data.objspace = objspace; + data.world_stopped = world_stopped; gc_report(5, objspace, "gc_verify_internal_consistency: start\n"); /* check relations */ @@ -5718,6 +6461,14 @@ gc_verify_internal_consistency_(rb_objspace_t *objspace) verify_internal_consistency_i((void *)start, (void *)end, slot_size, &data); } + /* Check the calling Ractor's root scoping (only when verifying the current + * objspace). Skipped during a global GC, which deliberately spans every Ractor's + * roots and legitimately reaches foreign objects: containment does not apply. */ + if (!rb_gc_single_objspace_p() && objspace == rb_gc_get_objspace() && + !rb_gc_impl_during_global_gc_p(objspace)) { + rb_objspace_reachable_objects_from_root(root_scope_check_i, &data); + } + if (data.err_count != 0) { #if RGENGC_CHECK_MODE >= 5 objspace->rgengc.error_count = data.err_count; @@ -5732,8 +6483,6 @@ gc_verify_internal_consistency_(rb_objspace_t *objspace) /* check counters */ - ractor_cache_flush_count(objspace, rb_gc_get_ractor_newobj_cache()); - if (!is_lazy_sweeping(objspace) && !finalizing && !rb_gc_multi_ractor_p()) { @@ -5783,21 +6532,62 @@ gc_verify_internal_consistency_(rb_objspace_t *objspace) gc_report(5, objspace, "gc_verify_internal_consistency: OK\n"); } +/* The `during_gc` macro expands a bare identifier to `objspace->flags.during_gc`, so a + * foreign objspace's flag cannot be written directly; these helpers reach it through the + * `objspace` argument. */ +static inline unsigned int +gc_during_gc_get(const rb_objspace_t *objspace) +{ + return during_gc; +} + +static inline void +gc_during_gc_set(rb_objspace_t *objspace, unsigned int v) +{ + during_gc = v; +} + +/* Run the check with during_gc cleared in both the verified objspace and the current + * Ractor's: rb_objspace_reachable_objects_from() decides on rb_gc_get_objspace(), and + * under a global GC the driver verifies foreign objspaces, so the driver's during_gc + * needs clearing too (a no-op when cur == objspace). */ +static void +gc_verify_internal_consistency_body(rb_objspace_t *objspace, bool world_stopped) +{ + const unsigned int prev_during_gc = during_gc; + during_gc = FALSE; // stop gc here + + rb_objspace_t *const cur = rb_gc_get_objspace(); + const unsigned int prev_cur_during_gc = (cur != objspace) ? gc_during_gc_get(cur) : 0; + if (cur != objspace) gc_during_gc_set(cur, FALSE); + { + gc_verify_internal_consistency_(objspace, world_stopped); + } + if (cur != objspace) gc_during_gc_set(cur, prev_cur_during_gc); + during_gc = prev_during_gc; +} + static void gc_verify_internal_consistency(void *objspace_ptr) { rb_objspace_t *objspace = objspace_ptr; + /* Called mid-GC, take neither the VM lock nor the barrier: waiting would join a + * pending global barrier mid-collection (a GC must never take the VM lock) and let + * the global GC sweep the heap this mark is walking. The barrier is unnecessary + * anyway; the objspace is single-writer, this verify runs on its owner thread, and + * the global driver that sets during_gc everywhere already holds both. */ + if (during_gc) { + /* The world is stopped only when the global GC's driver runs this while holding + * the barrier; a non-main Ractor's local GC does not stop other Ractors. */ + gc_verify_internal_consistency_body(objspace, rb_gc_impl_during_global_gc_p(objspace)); + return; + } + unsigned int lev = RB_GC_VM_LOCK(); { rb_gc_vm_barrier(); // stop other ractors - - unsigned int prev_during_gc = during_gc; - during_gc = FALSE; // stop gc here - { - gc_verify_internal_consistency_(objspace); - } - during_gc = prev_during_gc; + gc_verify_internal_consistency_body(objspace, true); // holding the barrier, so walking every objspace is sound } RB_GC_VM_UNLOCK(lev); } @@ -5830,7 +6620,8 @@ gc_remember_unprotected(rb_objspace_t *objspace, VALUE obj) if (!MARKED_IN_BITMAP(uncollectible_bits, obj)) { page->flags.has_uncollectible_wb_unprotected_objects = TRUE; MARK_IN_BITMAP(uncollectible_bits, obj); - objspace->rgengc.uncollectible_wb_unprotected_objects++; + /* Like RVALUE_PAGE_OLD_UNCOLLECTIBLE_SET, count it in the object's own objspace. */ + page->objspace->rgengc.uncollectible_wb_unprotected_objects++; #if RGENGC_PROFILE > 0 objspace->profile.total_remembered_shady_object_count++; @@ -5897,6 +6688,10 @@ rb_gc_impl_handle_weak_references_alive_p(void *objspace_ptr, VALUE obj) { rb_objspace_t *objspace = objspace_ptr; + /* A local GC cannot decide a foreign object's liveness, so treat it as live; its + * owner or the global GC decides (a global GC's unified mark is exact). */ + if (gc_skip_foreign_object_p(objspace, obj)) return true; + bool marked = RVALUE_MARKED(objspace, obj); if (marked) { @@ -5956,11 +6751,23 @@ gc_marks_finish(rb_objspace_t *objspace) } } - gc_update_weak_references(objspace); + /* Pin the shareable objects and shrefs ordinary marking missed: a local GC must free + * neither (another objspace may hold them). Running after the full walk makes the + * pin count a retention metric: an upper bound on the garbage only a global GC can + * reclaim. A global GC's exact mark does not pin. (The allrefs comparison of + * RGENGC_CHECK_MODE >= 4 does not model these pins; it reports false positives.) */ + objspace->last_cycle_pinned = 0; + if (!rb_gc_single_objspace_p() && !objspace->flags.during_global_gc) { + objspace->last_cycle_pinned = 1; + gc_mark_set_parent_raw(objspace, Qundef, false); + for (int i = 0; i < HEAP_COUNT; i++) { + pinned_roots_mark(objspace, &heaps[i]); + } + /* And everything they keep alive. */ + gc_mark_stacked_objects_all(objspace); + } -#if RGENGC_CHECK_MODE >= 2 - gc_verify_internal_consistency(objspace); -#endif + gc_update_weak_references(objspace); #if RGENGC_CHECK_MODE >= 4 during_gc = FALSE; @@ -5969,7 +6776,8 @@ gc_marks_finish(rb_objspace_t *objspace) #endif { - const unsigned long r_mul = objspace->live_ractor_cache_count > 8 ? 8 : objspace->live_ractor_cache_count; // upto 8 + const unsigned long ractor_cnt = rb_gc_vm_ractor_count(); + const unsigned long r_mul = ractor_cnt > 8 ? 8 : ractor_cnt; // upto 8 size_t total_slots = objspace_available_slots(objspace); size_t sweep_slots = total_slots - objspace->marked_slots; /* will be swept slots */ @@ -6048,7 +6856,7 @@ gc_marks_finish(rb_objspace_t *objspace) // TODO: refactor so we don't need to call this rb_ractor_finish_marking(); - rb_gc_event_hook(0, RUBY_INTERNAL_EVENT_GC_END_MARK); + gc_event_hook(objspace, RUBY_INTERNAL_EVENT_GC_END_MARK); } static bool @@ -6182,13 +6990,14 @@ gc_compact_all_compacted_p(rb_objspace_t *objspace) return true; } +/* Compaction's move phase: relocate this objspace's movable objects and leave T_MOVED + * forwarding behind without updating references yet. A global GC calls this for every + * objspace before updating any of them (two phases), so a cross-objspace reference to a + * moved object is rewritten exactly once, after all forwarding exists. */ static void -gc_sweep_compact(rb_objspace_t *objspace) +gc_compact_relocate(rb_objspace_t *objspace) { gc_compact_start(objspace); -#if RGENGC_CHECK_MODE >= 2 - gc_verify_internal_consistency(objspace); -#endif while (!gc_compact_all_compacted_p(objspace)) { for (int i = 0; i < HEAP_COUNT; i++) { @@ -6212,12 +7021,17 @@ gc_sweep_compact(rb_objspace_t *objspace) heap->compact_cursor = ccan_list_prev(&heap->pages, heap->compact_cursor, page_node); } } +} - gc_compact_finish(objspace); - -#if RGENGC_CHECK_MODE >= 2 - gc_verify_internal_consistency(objspace); -#endif +static void +gc_sweep_compact(rb_objspace_t *objspace) +{ + gc_compact_relocate(objspace); + /* A compacting global GC defers the finish (reference update) to the second phase, + * after every objspace has been relocated. */ + if (!global_objspace->global_gc.compacting) { + gc_compact_finish(objspace); + } } static void @@ -6279,6 +7093,84 @@ gc_marks_continue(rb_objspace_t *objspace, rb_heap_t *heap) return marking_finished; } +/* Mark the following as roots of this objspace. + * - Every shareable object: another objspace may hold the only reference, invisible to a + * local GC. Marking them rather than skipping them in the sweep preserves the + * generational invariants (a pinned object ages and gets promoted like any live one). + * Only a global GC decides that a shareable object is dead. + * - Every shref (an unshareable object referenced from a shareable one): the referring + * shareable object can live in another objspace or in an in-flight message queue. The + * write barrier maintains them. + * Skipped while the VM has a single Ractor: a local GC is then a whole-world GC and + * shareable objects may die normally. */ +static void +pinned_roots_mark(rb_objspace_t *objspace, rb_heap_t *heap) +{ + struct heap_page *page = NULL; + + /* Runs before mark_roots, so rgengc_check_relation sees a valid (absent) parent rather + * than the poison left by the previous GC. */ + gc_mark_set_parent_raw(objspace, Qundef, false); + + /* A local GC never frees or traverses a shareable object, and keeps its unshareable + * children alive through their shref bits, so: + * - a shareable object only gets its mark bit set (like an old object), which keeps + * the sweep off it, and is not traversed; + * - a shref is marked and traversed, like a remembered old->young target: without + * that, the referring shareable object is never walked and it would look + * unreachable. + * Objects can become shareable between GCs, so this pass scans the bitmaps in every + * mark (gc_marks_finish) instead of maintaining a pin set across the sweep. */ + ccan_list_for_each(&heap->pages, page, page_node) { + if (!(page->flags.has_shareable_objects | page->flags.has_shref_objects)) continue; + + uintptr_t p = page->start; + short slot_size = page->slot_size; + int total_slots = page->total_slots; + int bitmap_plane_count = CEILDIV(total_slots, BITS_BITLENGTH); + + for (int j = 0; j < bitmap_plane_count; j++) { + bits_t sr_bits = page->shref_bits[j]; + /* Only the pins ordinary marking left unmarked need work here: an already + * marked object (reached by traversal, or pre-marked because it is old) is a + * no-op in gc_mark_set, so skip visiting it. */ + bits_t bitset = (page->shareable_bits[j] | sr_bits) & ~page->mark_bits[j]; + uintptr_t pp = p; + while (bitset) { + if (bitset & 1) { + VALUE obj = (VALUE)pp; + asan_unpoisoning_object(obj) { + switch (BUILTIN_TYPE(obj)) { + case T_NONE: + case T_ZOMBIE: + case T_MOVED: + /* A dead slot (a zombie awaiting its finalizer) is not a root. */ + break; + default: + gc_report(2, objspace, "pinned_roots_mark: mark %s\n", rb_obj_info(obj)); + if (sr_bits & 1) { + gc_mark(objspace, obj); /* shref: root + traverse */ + } + else if (gc_mark_set(objspace, obj)) { + gc_aging(objspace, obj); /* shareable: mark, no traverse */ + /* Pin as well when compaction runs alongside: if a shareable + * object moved, the C-struct slots of other Ractors (a + * port in sync, say) are not updated and go stale. */ + gc_pin(objspace, obj); + } + break; + } + } + } + pp += slot_size; + bitset >>= 1; + sr_bits >>= 1; + } + p += BITS_BITLENGTH * slot_size; + } + } +} + static void gc_marks_start(rb_objspace_t *objspace, int full_mark) { @@ -6295,7 +7187,7 @@ gc_marks_start(rb_objspace_t *objspace, int full_mark) "objspace->rincgc.step_slots: %"PRIdSIZE", \n", objspace->marked_slots, objspace->rincgc.pooled_slots, objspace->rincgc.step_slots); objspace->flags.during_minor_gc = FALSE; - if (ruby_enable_autocompact) { + if (ruby_enable_autocompact && rb_gc_single_objspace_p()) { objspace->flags.during_compacting |= TRUE; } objspace->profile.major_gc_count++; @@ -6306,7 +7198,7 @@ gc_marks_start(rb_objspace_t *objspace, int full_mark) for (int i = 0; i < HEAP_COUNT; i++) { rb_heap_t *heap = &heaps[i]; - rgengc_mark_and_rememberset_clear(objspace, heap); + gc_bitmaps_clear(objspace, heap, false); heap_move_pooled_pages_to_free_pages(heap); if (objspace->flags.during_compacting) { @@ -6331,10 +7223,6 @@ gc_marks_start(rb_objspace_t *objspace, int full_mark) mark_roots(objspace, NULL); - if (is_incremental_marking(objspace)) { - rb_gc_ractor_newobj_cache_foreach(gc_ractor_newobj_cache_exhaust, NULL); - } - gc_report(1, objspace, "gc_marks_start: (%s) end, stack in %"PRIdSIZE"\n", full_mark ? "full" : "minor", mark_stack_size(&objspace->mark_stack)); } @@ -6406,14 +7294,14 @@ rgengc_remembersetbits_set(rb_objspace_t *objspace, VALUE obj) struct heap_page *page = GET_HEAP_PAGE(obj); bits_t *bits = &page->remembered_bits[0]; - if (MARKED_IN_BITMAP(bits, obj)) { - return FALSE; - } - else { - page->flags.has_remembered_objects = TRUE; - MARK_IN_BITMAP(bits, obj); - return TRUE; - } + /* remembered_bits writers are always serialized: the write barrier only remembers a + * local a (under its Ractor's GVL) and a global GC writes from the driver alone. + * Set the bit before the page flag so a page pending re-scan stays in + * rememberset_mark. */ + const bool newly = !_MARKED_IN_BITMAP(bits, page, obj); + _MARK_IN_BITMAP(bits, page, obj); + page->flags.has_remembered_objects = TRUE; + return newly ? TRUE : FALSE; } /* wb, etc */ @@ -6497,11 +7385,17 @@ rgengc_rememberset_mark(rb_objspace_t *objspace, rb_heap_t *heap) else if (page->flags.has_remembered_objects) has_old++; else if (page->flags.has_uncollectible_wb_unprotected_objects) has_shady++; #endif + /* Clear has_remembered_objects before draining the bits. A concurrent + * lock-free write barrier (another Ractor remembering a shareable object on + * this page) sets the bit first and the flag second, so clearing the flag first + * keeps the page scheduled for re-scan even if that set interleaves. The + * per-word drain is an atomic read-and-clear, so an interleaved set is not lost + * (it lands in the zeroed word). */ + page->flags.has_remembered_objects = FALSE; for (j=0; j < (size_t)bitmap_plane_count; j++) { - bits[j] = remembered_bits[j] | (uncollectible_bits[j] & wb_unprotected_bits[j]); - remembered_bits[j] = 0; + bits[j] = RUBY_ATOMIC_SIZE_EXCHANGE(*(volatile size_t *)&remembered_bits[j], 0) + | (uncollectible_bits[j] & wb_unprotected_bits[j]); } - page->flags.has_remembered_objects = FALSE; for (j=0; j < (size_t)bitmap_plane_count; j++) { bitset = bits[j]; @@ -6523,7 +7417,7 @@ rgengc_rememberset_mark(rb_objspace_t *objspace, rb_heap_t *heap) } static void -rgengc_mark_and_rememberset_clear(rb_objspace_t *objspace, rb_heap_t *heap) +gc_bitmaps_clear(rb_objspace_t *objspace, rb_heap_t *heap, bool clear_shref) { struct heap_page *page = 0; @@ -6531,10 +7425,19 @@ rgengc_mark_and_rememberset_clear(rb_objspace_t *objspace, rb_heap_t *heap) memset(&page->mark_bits[0], 0, HEAP_PAGE_BITMAP_SIZE); memset(&page->uncollectible_bits[0], 0, HEAP_PAGE_BITMAP_SIZE); memset(&page->marking_bits[0], 0, HEAP_PAGE_BITMAP_SIZE); + /* A plain memset can lose a concurrent remember, but only a shareable object can + * be remembered from another Ractor's thread, and pinned_roots_mark re-marks + * those every local cycle, and this clear precedes a major that re-scans all. */ memset(&page->remembered_bits[0], 0, HEAP_PAGE_BITMAP_SIZE); memset(&page->pinned_bits[0], 0, HEAP_PAGE_BITMAP_SIZE); page->flags.has_uncollectible_wb_unprotected_objects = FALSE; page->flags.has_remembered_objects = FALSE; + /* A shref is a local GC's root, so only a stop-the-world global GC may clear them: + * its unified mark re-derives them from every shareable -> unshareable edge. */ + if (clear_shref) { + memset(&page->shref_bits[0], 0, HEAP_PAGE_BITMAP_SIZE); + page->flags.has_shref_objects = FALSE; + } } } @@ -6551,13 +7454,11 @@ gc_writebarrier_generational(VALUE a, VALUE b, rb_objspace_t *objspace) if (is_incremental_marking(objspace)) rb_bug("gc_writebarrier_generational: called while incremental marking: %s -> %s", rb_obj_info(a), rb_obj_info(b)); } - /* mark `a' and remember (default behavior) */ + /* Mark and remember a (the default behaviour). + * No lock: setting a remembered bit is atomic (rgengc_remembersetbits_set), and that is + * the only place a concurrent local GC or another Ractor's write barrier can race. */ if (!RVALUE_REMEMBERED(objspace, a)) { - int lev = RB_GC_VM_LOCK_NO_BARRIER(); - { - rgengc_remember(objspace, a); - } - RB_GC_VM_UNLOCK_NO_BARRIER(lev); + rgengc_remember(objspace, a); gc_report(1, objspace, "gc_writebarrier_generational: %s (remembered) -> %s\n", rb_obj_info(a), rb_obj_info(b)); } @@ -6623,9 +7524,27 @@ rb_gc_impl_writebarrier(void *objspace_ptr, VALUE a, VALUE b) GC_ASSERT(RB_BUILTIN_TYPE(b) != T_MOVED); GC_ASSERT(RB_BUILTIN_TYPE(b) != T_ZOMBIE); + /* A shareable object now references an unshareable one: record b as a shref so its + * owner's local GC roots it (the parent may live in another objspace, untraversed + * there). Only b's owner stores this, on its own page: a plain store suffices. */ + if (RB_UNLIKELY(RB_FL_TEST_RAW(a, RUBY_FL_SHAREABLE)) && + !RB_FL_TEST_RAW(b, RUBY_FL_SHAREABLE)) { + struct heap_page *bpage = GET_HEAP_PAGE(b); + if (!_MARKED_IN_BITMAP(bpage->shref_bits, bpage, b)) { + _MARK_IN_BITMAP(bpage->shref_bits, bpage, b); + bpage->flags.has_shref_objects = TRUE; + } + } + retry: if (!is_incremental_marking(objspace)) { - if (!RVALUE_OLD_P(objspace, a) || RVALUE_OLD_P(objspace, b)) { + /* The generational barrier covers old->young edges within one objspace only; a + * foreign a or b has age bits another objspace mutates, unsafe to read, so check + * locality first when multi-Ractor (a foreign a is shareable and the shref above + * already keeps b alive). With a single Ractor nothing is foreign. */ + if ((rb_gc_multi_ractor_p() && + (GET_HEAP_OBJSPACE(a) != objspace || GET_HEAP_OBJSPACE(b) != objspace)) || + !RVALUE_OLD_P(objspace, a) || RVALUE_OLD_P(objspace, b)) { // do nothing } else { @@ -6633,29 +7552,66 @@ rb_gc_impl_writebarrier(void *objspace_ptr, VALUE a, VALUE b) } } else { - bool retry = false; - /* slow path */ - int lev = RB_GC_VM_LOCK_NO_BARRIER(); - { - if (is_incremental_marking(objspace)) { - gc_writebarrier_incremental(a, b, objspace); - } - else { - retry = true; - } + /* Slow path, no lock: incremental marking only runs while the process has a single + * objspace, so the owning Ractor's GVL already serializes this barrier against its + * own GC. */ + if (is_incremental_marking(objspace)) { + gc_writebarrier_incremental(a, b, objspace); + } + else { + goto retry; } - RB_GC_VM_UNLOCK_NO_BARRIER(lev); - - if (retry) goto retry; } return; } +void +rb_gc_impl_obj_became_shareable(void *objspace_ptr, VALUE obj) +{ + /* An object becomes shareable on its owner thread, so this page update is + * single-writer. */ + struct heap_page *page = GET_HEAP_PAGE(obj); + if (_MARKED_IN_BITMAP(page->shareable_bits, page, obj)) return; + gc_page_add_shareable(page, obj); + + /* The shref bits recorded while the object was unshareable are now covered by the + * shareable pin, and a shref only points at an unshareable object. The owner thread is + * the only writer, so a plain clear is enough. */ + if (_MARKED_IN_BITMAP(page->shref_bits, page, obj)) { + _CLEAR_IN_BITMAP(page->shref_bits, page, obj); + } +} + +void +rb_gc_impl_pin_in_flight_message(void *objspace_ptr, VALUE obj) +{ + if (RB_FL_TEST_RAW(obj, RUBY_FL_SHAREABLE)) return; /* pinned anyway */ + + /* The payload's pages belong to the sender, so a plain store is enough. */ + struct heap_page *page = GET_HEAP_PAGE(obj); + if (!_MARKED_IN_BITMAP(page->shref_bits, page, obj)) { + _MARK_IN_BITMAP(page->shref_bits, page, obj); + page->flags.has_shref_objects = TRUE; + } + /* A shref bit only makes the object a root for the next local GC; it does not affect an + * in-progress global compaction's move decision (pinned_bits). Moving a payload node + * would break the address-keyed maps, dedup tables and pin lists, so pin it as well. */ + rb_objspace_t *objspace = objspace_ptr; + if (objspace->flags.during_global_gc) { + gc_pin(objspace, obj); + } +} + void rb_gc_impl_writebarrier_unprotect(void *objspace_ptr, VALUE obj) { rb_objspace_t *objspace = objspace_ptr; + /* A shareable object is never WB-unprotected. Keeping shrefs correct relies on every + * store into s->u going through the write barrier, which keeps wb_unprotected_bits + * single-writer (only the owner thread can unprotect its own unshareable objects). */ + GC_ASSERT(!RB_FL_TEST_RAW(obj, RUBY_FL_SHAREABLE)); + if (RVALUE_WB_UNPROTECTED(objspace, obj)) { return; } @@ -6663,29 +7619,28 @@ rb_gc_impl_writebarrier_unprotect(void *objspace_ptr, VALUE obj) gc_report(2, objspace, "rb_gc_writebarrier_unprotect: %s %s\n", rb_obj_info(obj), RVALUE_REMEMBERED(objspace, obj) ? " (already remembered)" : ""); - unsigned int lev = RB_GC_VM_LOCK_NO_BARRIER(); - { - if (RVALUE_OLD_P(objspace, obj)) { - gc_report(1, objspace, "rb_gc_writebarrier_unprotect: %s\n", rb_obj_info(obj)); - RVALUE_DEMOTE(objspace, obj); - gc_mark_set(objspace, obj); - gc_remember_unprotected(objspace, obj); + /* No lock: per the assert obj is our own unshareable, so these bits + * (wb_unprotected, uncollectible, age) are single-writer on an owned page, and + * RVALUE_DEMOTE's remembered-bit clear is atomic against word-sharing writers. */ + if (RVALUE_OLD_P(objspace, obj)) { + gc_report(1, objspace, "rb_gc_writebarrier_unprotect: %s\n", rb_obj_info(obj)); + RVALUE_DEMOTE(objspace, obj); + gc_mark_set(objspace, obj); + gc_remember_unprotected(objspace, obj); #if RGENGC_PROFILE - objspace->profile.total_shade_operation_count++; + objspace->profile.total_shade_operation_count++; #if RGENGC_PROFILE >= 2 - objspace->profile.shade_operation_count_types[BUILTIN_TYPE(obj)]++; + objspace->profile.shade_operation_count_types[BUILTIN_TYPE(obj)]++; #endif /* RGENGC_PROFILE >= 2 */ #endif /* RGENGC_PROFILE */ - } - else { - RVALUE_AGE_RESET(obj); - } - - RB_DEBUG_COUNTER_INC(obj_wb_unprotect); - MARK_IN_BITMAP(GET_HEAP_WB_UNPROTECTED_BITS(obj), obj); } - RB_GC_VM_UNLOCK_NO_BARRIER(lev); + else { + RVALUE_AGE_RESET(obj); + } + + RB_DEBUG_COUNTER_INC(obj_wb_unprotect); + MARK_IN_BITMAP(GET_HEAP_WB_UNPROTECTED_BITS(obj), obj); } } @@ -6713,19 +7668,16 @@ rb_gc_impl_writebarrier_remember(void *objspace_ptr, VALUE obj) gc_report(1, objspace, "rb_gc_writebarrier_remember: %s\n", rb_obj_info(obj)); - if (is_incremental_marking(objspace) || RVALUE_OLD_P(objspace, obj)) { - int lev = RB_GC_VM_LOCK_NO_BARRIER(); - { - if (is_incremental_marking(objspace)) { - if (RVALUE_BLACK_P(objspace, obj)) { - gc_grey(objspace, obj); - } - } - else if (RVALUE_OLD_P(objspace, obj)) { - rgengc_remember(objspace, obj); - } + /* No lock, for the same reason as rb_gc_impl_writebarrier: remembering is an atomic + * bitmap set, and the incremental branch only runs with a single objspace, where the + * Ractor's GVL serializes it against its own GC. */ + if (is_incremental_marking(objspace)) { + if (RVALUE_BLACK_P(objspace, obj)) { + gc_grey(objspace, obj); } - RB_GC_VM_UNLOCK_NO_BARRIER(lev); + } + else if (RVALUE_OLD_P(objspace, obj)) { + rgengc_remember(objspace, obj); } } @@ -6788,24 +7740,31 @@ rb_gc_impl_object_metadata(void *objspace_ptr, VALUE obj) void * rb_gc_impl_ractor_cache_alloc(void *objspace_ptr, void *ractor) { - rb_objspace_t *objspace = objspace_ptr; - - objspace->live_ractor_cache_count++; - - rb_ractor_newobj_cache_t *gc_cache = calloc1(sizeof(rb_ractor_newobj_cache_t)); + /* No cache needed: allocation happens in a per-Ractor objspace. */ + return NULL; +} - return gc_cache; +void +rb_gc_impl_ractor_cache_free(void *objspace_ptr, void *cache) +{ + GC_ASSERT(cache == NULL); } +/* The terminating Ractor's final local GC, on its own thread: roots are minimal, so the + * mark is tiny, and it reclaims what the joining side would otherwise inherit. Never + * promotes to a global GC (that would STW on every Ractor death); empty pages go + * straight back to the page pool. */ void -rb_gc_impl_ractor_cache_free(void *objspace_ptr, void *cache_ptr) +rb_gc_impl_objspace_retire_gc(void *objspace_ptr) { rb_objspace_t *objspace = objspace_ptr; - rb_ractor_newobj_cache_t *gc_cache = cache_ptr; - objspace->live_ractor_cache_count--; - gc_ractor_newobj_cache_clear(gc_cache, objspace); - free(gc_cache); + gc_rest(objspace); + gc_start_body(objspace, GPR_FLAG_FULL_MARK | GPR_FLAG_IMMEDIATE_MARK | GPR_FLAG_IMMEDIATE_SWEEP, + false); + + heap_pages_freeable_pages = objspace->empty_pages_count; + heap_pages_free_unused_pages(objspace); } static void @@ -6822,7 +7781,7 @@ heap_ready_to_gc(rb_objspace_t *objspace, rb_heap_t *heap) static int ready_to_gc(rb_objspace_t *objspace) { - if (dont_gc_val() || during_gc) { + if (rb_gc_gc_disabled_global_p() || dont_gc_val() || during_gc) { for (int i = 0; i < HEAP_COUNT; i++) { rb_heap_t *heap = &heaps[i]; heap_ready_to_gc(objspace, heap); @@ -6909,54 +7868,73 @@ gc_reset_malloc_info(rb_objspace_t *objspace, bool full_mark) #endif } +static void gc_start_global(rb_objspace_t *driver, bool compact); + +/* Decide whether this collection has to be global. A local GC can reclaim neither + * shareable objects nor zombie objspaces, so once those grow past their limits only a + * global GC makes progress. All inputs belong to this objspace. */ +static bool +gc_need_global_p(rb_objspace_t *objspace) +{ + if (rb_gc_single_objspace_p()) return false; + 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. + * Otherwise one live-heavy unjoined zombie turns every GC stop-the-world forever. */ + { + size_t zp = rb_gc_vm_zombie_total_pages(); + size_t base = global_objspace->zombie_pages_survivors < zp ? global_objspace->zombie_pages_survivors : zp; + if (zp - base >= ZOMBIE_PAGES_TRIGGER) return true; + } + return false; +} + static int garbage_collect(rb_objspace_t *objspace, unsigned int reason) { int ret; - int lev = RB_GC_VM_LOCK(); - { #if GC_PROFILE_MORE_DETAIL - objspace->profile.prepare_time = getrusage_time(); + objspace->profile.prepare_time = getrusage_time(); #endif - gc_rest(objspace); + gc_rest(objspace); #if GC_PROFILE_MORE_DETAIL - objspace->profile.prepare_time = getrusage_time() - objspace->profile.prepare_time; + objspace->profile.prepare_time = getrusage_time() - objspace->profile.prepare_time; #endif - ret = gc_start(objspace, reason); - } - RB_GC_VM_UNLOCK(lev); + ret = gc_start(objspace, reason); return ret; } static int -gc_start(rb_objspace_t *objspace, unsigned int reason) +gc_start_body(rb_objspace_t *objspace, unsigned int reason, bool allow_global) { unsigned int do_full_mark = !!(reason & GPR_FLAG_FULL_MARK); if (!rb_darray_size(objspace->heap_pages.sorted)) return TRUE; /* heap is not ready */ if (!(reason & GPR_FLAG_METHOD) && !ready_to_gc(objspace)) return TRUE; /* GC is not allowed */ + /* Every local GC entry asks whether a global cycle is needed instead, including the + * allocation slow path, or an allocation-driven workload slips past every threshold + * (only a global cycle reclaims dead shareable objects and zombie pages). The + * exception is the retire GC, which never promotes: a Ractor's death must not STW. */ + if (allow_global && gc_need_global_p(objspace)) { + gc_start_global(objspace, false); + return TRUE; + } + rb_gc_initialize_vm_context(&objspace->vm_context); GC_ASSERT(gc_mode(objspace) == gc_mode_none, "gc_mode is %s\n", gc_mode_name(gc_mode(objspace))); GC_ASSERT(!is_lazy_sweeping(objspace)); GC_ASSERT(!is_incremental_marking(objspace)); - unsigned int lock_lev; - gc_enter(objspace, gc_enter_event_start, &lock_lev); - /* reason may be clobbered, later, so keep set immediate_sweep here */ objspace->flags.immediate_sweep = !!(reason & GPR_FLAG_IMMEDIATE_SWEEP); -#if RGENGC_CHECK_MODE >= 2 - gc_verify_internal_consistency(objspace); -#endif - if (ruby_gc_stressful) { int flag = FIXNUM_P(ruby_gc_stress_mode) ? FIX2INT(ruby_gc_stress_mode) : 0; @@ -6984,15 +7962,22 @@ gc_start(rb_objspace_t *objspace, unsigned int reason) if (objspace->flags.dont_incremental || reason & GPR_FLAG_IMMEDIATE_MARK || - ruby_gc_stressful) { + ruby_gc_stressful || + /* No incremental marking while multiple objspaces exist: between steps another + * Ractor can create and share objects behind this objspace's already-scanned + * roots. */ + !rb_gc_single_objspace_p()) { objspace->flags.during_incremental_marking = FALSE; } else { objspace->flags.during_incremental_marking = do_full_mark; } - /* Explicitly enable compaction (GC.compact) */ - if (do_full_mark && ruby_enable_autocompact) { + /* Compaction on the local GC path (autocompact) runs only with a single objspace: + * without the stop-the-world barrier, moving objects would break cross-objspace + * references. With multiple objspaces GC.compact and autocompact go through the + * compacting global GC instead (rb_gc_impl_start -> gc_start_global). */ + if (do_full_mark && ruby_enable_autocompact && rb_gc_single_objspace_p()) { objspace->flags.during_compacting = TRUE; #if RGENGC_CHECK_MODE objspace->rcompactor.compare_func = ruby_autocompact_compare_func; @@ -7000,6 +7985,12 @@ gc_start(rb_objspace_t *objspace, unsigned int reason) } else { objspace->flags.during_compacting = !!(reason & GPR_FLAG_COMPACT); + /* The local path was chosen with a single objspace, but another Ractor can be + * born before this point; local compaction would then move shareable objects and + * leave other Ractors' C-struct slots stale, so give up. */ + if (objspace->flags.during_compacting && !rb_gc_single_objspace_p()) { + objspace->flags.during_compacting = FALSE; + } } if (!GC_ENABLE_LAZY_SWEEP || objspace->flags.dont_incremental) { @@ -7008,6 +7999,10 @@ gc_start(rb_objspace_t *objspace, unsigned int reason) if (objspace->flags.immediate_sweep) reason |= GPR_FLAG_IMMEDIATE_SWEEP; + /* Enter after during_compacting is decided: gc_local_gc_holds_vm_lock reads it. */ + unsigned int lock_lev; + gc_enter(objspace, gc_enter_event_start, &lock_lev); + gc_report(1, objspace, "gc_start(reason: %x) => %u, %d, %d\n", reason, do_full_mark, !is_incremental_marking(objspace), objspace->flags.immediate_sweep); @@ -7040,7 +8035,7 @@ gc_start(rb_objspace_t *objspace, unsigned int reason) gc_prof_setup_new_record(objspace, reason); gc_reset_malloc_info(objspace, do_full_mark); - rb_gc_event_hook(0, RUBY_INTERNAL_EVENT_GC_START); + gc_event_hook(objspace, RUBY_INTERNAL_EVENT_GC_START); GC_ASSERT(during_gc); @@ -7053,9 +8048,22 @@ gc_start(rb_objspace_t *objspace, unsigned int reason) gc_prof_timer_stop(objspace); gc_exit(objspace, gc_enter_event_start, &lock_lev); + + /* Verify after the GC, at a real safepoint with during_gc cleared: mid-GC it would + * call rb_objspace_reachable_objects_from, whose barrier VM lock would join another + * Ractor's global GC barrier and let it collect on this half-collected heap. */ +#if RGENGC_CHECK_MODE >= 2 + gc_verify_internal_consistency(objspace); +#endif return TRUE; } +static int +gc_start(rb_objspace_t *objspace, unsigned int reason) +{ + return gc_start_body(objspace, reason, true); +} + static void gc_rest(rb_objspace_t *objspace) { @@ -7063,8 +8071,6 @@ gc_rest(rb_objspace_t *objspace) unsigned int lock_lev; gc_enter(objspace, gc_enter_event_rest, &lock_lev); - if (RGENGC_CHECK_MODE >= 2) gc_verify_internal_consistency(objspace); - if (is_incremental_marking(objspace)) { gc_marking_enter(objspace); gc_marks_rest(objspace); @@ -7080,6 +8086,8 @@ gc_rest(rb_objspace_t *objspace) } gc_exit(objspace, gc_enter_event_rest, &lock_lev); + + if (RGENGC_CHECK_MODE >= 2) gc_verify_internal_consistency(objspace); /* after GC, see gc_start */ } } @@ -7170,6 +8178,7 @@ gc_enter_event_cstr(enum gc_enter_event event) case gc_enter_event_continue: return "continue"; case gc_enter_event_rest: return "rest"; case gc_enter_event_finalizer: return "finalizer"; + case gc_enter_event_global: return "global"; } return NULL; } @@ -7182,6 +8191,7 @@ gc_enter_count(enum gc_enter_event event) case gc_enter_event_continue: RB_DEBUG_COUNTER_INC(gc_enter_continue); break; case gc_enter_event_rest: RB_DEBUG_COUNTER_INC(gc_enter_rest); break; case gc_enter_event_finalizer: RB_DEBUG_COUNTER_INC(gc_enter_finalizer); break; + case gc_enter_event_global: RB_DEBUG_COUNTER_INC(gc_enter_start); break; } } @@ -7211,10 +8221,48 @@ gc_clock_end(struct timespec *ts) return 0; } +/* Whether a non-global local GC holds the no-barrier VM lock for its whole run. Main's + * ordinary local GC is lock-free; only its compaction or an enabled JIT holds it (see the + * comment in the function body). */ +static inline bool +gc_local_gc_holds_vm_lock(const rb_objspace_t *objspace) +{ + /* Main's local GC is lock-free at the gc_enter level (bounded no-barrier windows + * cover the VM-global weak tables; compaction takes its barrier lock separately). + * What DOES hold the lock for the whole GC is an enabled JIT: marking reaches + * rb_yjit_iseq_mark / rb_zjit_iseq_mark through shareable iseq payloads, which must + * exclude another Ractor's concurrent compile (rb_iseq_mark_and_move asserts it). */ + return objspace == global_objspace->main_objspace && + (objspace->flags.during_compacting || rb_yjit_enabled_p || rb_zjit_enabled_p); +} + static inline void gc_enter(rb_objspace_t *objspace, enum gc_enter_event event, unsigned int *lock_lev) { - *lock_lev = RB_GC_VM_LOCK(); + /* A local GC runs on its owner thread and takes neither the VM lock nor a barrier: + * containment makes the heap single-writer (only a stop-the-world global GC writes pages + * across objspaces). There are two exceptions. + * + * - A global GC stops the world (VM lock + barrier). A GC has no safepoints and a + * thread only joins after gc_exit, so the barrier implicitly waits for every in-flight + * local GC. + * - Main objspace's local GC also walks VM-global roots (rb_vm_mark) that change under + * the VM lock, so it takes the lock without raising a barrier. Non-main objspaces run + * as they are. A thread waiting for the VM lock here joins a pending global barrier + * *before* starting its own GC, never in the middle of one. + * + * Hence a GC must never take the VM lock from inside itself: the waiter would join a + * pending barrier mid-collection and expose its half-collected heap to the global GC. + * Shared structures the GC paths touch use their own native mutexes (registered + * globals, generic fields) or the page-pool lock. + * + * Under RGENGC_CHECK_MODE a non-main local GC also takes the no-barrier VM lock + * (gc_local_gc_holds_vm_lock): mid-collection verification iterates every objspace + * (rb_gc_vm_each_objspace needs the lock), and holding it for the whole GC keeps a global + * GC from interrupting and clearing this objspace's during_gc mid-mark. The lock is + * taken at a safepoint rather than mid-collection, so it cannot join a pending barrier + * halfway. Production (CHECK_MODE off) stays lock-free. */ + *lock_lev = 0; RUBY_DTRACE_GC_HOOK(ENTER, event); @@ -7226,18 +8274,35 @@ gc_enter(rb_objspace_t *objspace, enum gc_enter_event event, unsigned int *lock_ objspace->profile.gc_pause_start_time = rb_hrtime_now(); break; case gc_enter_event_finalizer: + case gc_enter_event_global: break; } } - switch (event) { - case gc_enter_event_rest: - case gc_enter_event_start: - case gc_enter_event_continue: + case gc_enter_event_global: + *lock_lev = RB_GC_VM_LOCK(); // stop other ractors rb_gc_vm_barrier(); break; + case gc_enter_event_finalizer: + /* Shutdown finalizers read VM-global tables (fstring, symbol) and free T_DATA that + * is not thread-safe, so take the no-barrier VM lock. */ + *lock_lev = RB_GC_VM_LOCK_NO_BARRIER(); + break; default: + objspace->flags.gc_lock_barrier = FALSE; + if (objspace->flags.during_compacting) { + /* Compaction relocates objects and rewrites every Ractor's JIT and global + * references, so it stops the world with a barrier VM lock. rb_gc_vm_barrier is + * a reentrant no-op with a single Ractor, so an inner barrier request during the + * move folds into this one and gc_exit ends it. */ + *lock_lev = RB_GC_VM_LOCK(); + rb_gc_vm_barrier(); + objspace->flags.gc_lock_barrier = TRUE; + } + else if (gc_local_gc_holds_vm_lock(objspace)) { + *lock_lev = RB_GC_VM_LOCK_NO_BARRIER(); + } break; } @@ -7257,7 +8322,7 @@ gc_enter(rb_objspace_t *objspace, enum gc_enter_event event, unsigned int *lock_ gc_report(1, objspace, "gc_enter: %s [%s]\n", gc_enter_event_cstr(event), gc_current_status(objspace)); gc_record(objspace, 0, gc_enter_event_cstr(event)); - rb_gc_event_hook(0, RUBY_INTERNAL_EVENT_GC_ENTER); + gc_event_hook(objspace, RUBY_INTERNAL_EVENT_GC_ENTER); } static inline void @@ -7267,7 +8332,7 @@ gc_exit(rb_objspace_t *objspace, enum gc_enter_event event, unsigned int *lock_l RUBY_DTRACE_GC_HOOK(EXIT, event); - rb_gc_event_hook(0, RUBY_INTERNAL_EVENT_GC_EXIT); + gc_event_hook(objspace, RUBY_INTERNAL_EVENT_GC_EXIT); if (objspace->profile.gc_pause_start_time) { if (gc_prof_enabled(objspace)) { @@ -7290,7 +8355,25 @@ gc_exit(rb_objspace_t *objspace, enum gc_enter_event event, unsigned int *lock_l gc_report(1, objspace, "gc_exit: %s [%s]\n", gc_enter_event_cstr(event), gc_current_status(objspace)); during_gc = FALSE; - RB_GC_VM_UNLOCK(*lock_lev); + switch (event) { + case gc_enter_event_global: + RB_GC_VM_UNLOCK(*lock_lev); + break; + case gc_enter_event_finalizer: + RB_GC_VM_UNLOCK_NO_BARRIER(*lock_lev); + break; + default: + if (*lock_lev != 0) { + if (objspace->flags.gc_lock_barrier) { + objspace->flags.gc_lock_barrier = FALSE; + RB_GC_VM_UNLOCK(*lock_lev); + } + else { + RB_GC_VM_UNLOCK_NO_BARRIER(*lock_lev); + } + } + break; + } } #ifndef MEASURE_GC @@ -7382,7 +8465,7 @@ int ruby_thread_has_gvl_p(void); static int garbage_collect_with_gvl(rb_objspace_t *objspace, unsigned int reason) { - if (dont_gc_val()) { + if (rb_gc_gc_disabled_global_p() || dont_gc_val()) { return TRUE; } else if (!ruby_native_thread_p()) { @@ -7426,6 +8509,602 @@ gc_set_candidate_object_i(void *vstart, void *vend, size_t stride, void *data) return 0; } +bool +rb_gc_impl_multi_objspace_p(void) +{ + return true; +} + +bool +rb_gc_impl_during_global_gc_p(void *objspace_ptr) +{ + rb_objspace_t *objspace = objspace_ptr; + return objspace->flags.during_global_gc != 0; +} + +bool +rb_gc_impl_obj_foreign_p(void *objspace_ptr, VALUE obj) +{ + return gc_foreign_object_p(objspace_ptr, obj); +} + + +/* Whether obj is recorded as an unshareable object referenced from a shareable one. For + * the verifier: a shareable -> unshareable edge is only accepted if the write barrier + * recorded it here. */ +bool +rb_gc_impl_shref_marked_p(void *objspace_ptr, VALUE obj) +{ + return MARKED_IN_BITMAP(GET_HEAP_SHREF_BITS(obj), obj) != 0; +} + +/* The objspace's current page count (used for the zombie_objspaces page accounting). */ +size_t +rb_gc_impl_heap_page_count(void *objspace_ptr) +{ + rb_objspace_t *objspace = objspace_ptr; + return rb_darray_size(objspace->heap_pages.sorted); +} + +static void +gc_global_objspaces_i(void *os, void *data) +{ + if (global_objspace->global_gc.n_objspaces == global_objspace->global_gc.objspaces_capa) { + size_t new_capa = global_objspace->global_gc.objspaces_capa ? global_objspace->global_gc.objspaces_capa * 2 : 16; + struct rb_objspace **new_list = realloc(global_objspace->global_gc.objspaces, new_capa * sizeof(*new_list)); + if (new_list == NULL) rb_bug("gc_global_objspaces_i: realloc failed"); + global_objspace->global_gc.objspaces = new_list; + global_objspace->global_gc.objspaces_capa = new_capa; + } + global_objspace->global_gc.objspaces[global_objspace->global_gc.n_objspaces++] = os; +} + +/* Re-snapshot every objspace this cycle covers, zombies included. The objspaces/capa + * buffer is reused from the previous cycle. */ +static void +gc_global_snapshot_objspaces(void) +{ + global_objspace->global_gc.n_objspaces = 0; + rb_gc_vm_each_objspace(gc_global_objspaces_i, NULL); + +#if RGENGC_CHECK_MODE + /* Check that the incrementally maintained page_index agrees with the per-objspace + * sorted arrays. */ + size_t total = 0; + for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { + total += rb_darray_size(global_objspace->global_gc.objspaces[i]->heap_pages.sorted); + } + GC_ASSERT(total == global_objspace->page_index.n_pages); +#endif +} + +/* Global GC: stop every Ractor and clear/mark/sweep all objspaces as one heap. It is the + * only collector that can free shareable objects and decide cross-objspace reachability + * precisely. */ +/* The global GC's generic_fields weak pass, after the unified mark fixpoint, before the + * sweep. Per-object rb_mark_generic_ivar is a no-op during a global GC (the driver has + * GET_RACTOR() != owner); the whole table is swept here instead. Weak-KEY: mark the val + * (fields_obj, a strong child) only for a live key, drain dead keys' entries. Marking a + * val can make another key live, so repeat to a fixpoint. */ +struct genfields_mark_arg { + rb_objspace_t *objspace; + bool progress; +}; + +static int +genfields_mark_i(VALUE key, VALUE val, void *arg) +{ + struct genfields_mark_arg *a = (struct genfields_mark_arg *)arg; + if (RB_SPECIAL_CONST_P(val) || !RVALUE_MARKED_BITMAP(key)) { + return ST_CONTINUE; + } + /* Record the old(key)->young(val) edge with the host (key) as parent, even when val + * is already marked: a conservative machine-stack scan can mark a fresh fields_obj + * parentless before this pass, and branching on the mark bit would leave the key + * unremembered, so the next minor GC misses the young val ("WB miss (O->Y)"). + * gc_mark runs rgengc_check_relation before its already-marked return: call always. */ + bool newly = !RVALUE_MARKED_BITMAP(val); + gc_mark_set_parent(a->objspace, key); + gc_mark(a->objspace, val); + if (newly) a->progress = true; + return ST_CONTINUE; +} + +static bool +genfields_dead_p(VALUE key) +{ + return RVALUE_MARKED_BITMAP(key) == 0; +} + +static void +gc_global_mark_generic_fields(rb_objspace_t *driver) +{ + struct genfields_mark_arg arg = { driver, false }; + do { + arg.progress = false; + /* Each entry's mark sets parent=key (genfields_mark_i) so the generational WB is + * recorded correctly. gc_mark_stacked_objects_all sets its own per-object parent, + * so restore the invalid parent (the poison contract) before calling it. */ + rb_gc_vm_generic_fields_mark_foreach(genfields_mark_i, &arg); + gc_mark_set_parent_invalid(driver); + if (arg.progress) { + gc_mark_stacked_objects_all(driver); + } + } while (arg.progress); + + rb_gc_vm_generic_fields_drain_dead(genfields_dead_p); +} + +/* Two Ractors choosing a global GC at once are serialized by the barrier in gc_enter and + * simply run two cycles back to back. The second is wasted work, not an error. */ +static void +gc_start_global(rb_objspace_t *driver, bool compact) +{ + unsigned int lock_lev; + gc_enter(driver, gc_enter_event_global, &lock_lev); + + /* A global GC is a collection of the driver's objspace too, and its profile.count + * below says so, so report it like a local one. The driver is the objspace whose + * count moves, which is the one a hook reading GC.stat would compare against. */ + gc_event_hook(driver, RUBY_INTERNAL_EVENT_GC_START); + + GC_ASSERT(is_mark_stack_empty(&driver->mark_stack)); + + gc_global_snapshot_objspaces(); + + /* Mark every objspace as in a global GC before step 3 settles the lazy sweeps: the + * settle frees other objspaces' garbage on the driver thread, and + * rb_free_generic_ivar must see "global GC in progress" to defer generic_fields + * removal to the weak-pass drain. */ + for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { + global_objspace->global_gc.objspaces[i]->flags.during_global_gc = TRUE; + } + + /* step 3: settle every lazy sweep so the mark bits' meaning is fixed before the clear + * below. (during_gc is a macro over the local "objspace".) rb_gc_get_ec() resolves + * through objspace->vm_context during a GC, so initialize it for all: the driver + * thread runs every objspace's phases. */ + for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { + rb_objspace_t *objspace = global_objspace->global_gc.objspaces[i]; + /* No objspace can be mid-incremental-mark here: that only runs single-objspace + * and vm_insert_ractor0 settles it on the transition. Clearing flags in step 5 + * under a live gray stack would break the owner's GC state machine. */ + GC_ASSERT(!is_incremental_marking(objspace)); + GC_ASSERT(is_mark_stack_empty(&objspace->mark_stack)); + rb_gc_initialize_vm_context(&objspace->vm_context); + if (objspace != driver) during_gc = TRUE; + gc_sweep_rest(objspace); + } + + /* step 5: clear every objspace's mark bits, remembered sets, generation counters and + * shrefs (missing even one leaves a stale mark bit and a UAF). (heaps is a macro over + * the local "objspace".) */ + for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { + rb_objspace_t *objspace = global_objspace->global_gc.objspaces[i]; + objspace->flags.during_minor_gc = FALSE; + objspace->flags.during_incremental_marking = FALSE; + /* The unified mark is precise and does not pin, so the per-objspace sweep below must + * not re-check against a stale local cycle. */ + objspace->last_cycle_pinned = 0; + objspace->rgengc.uncollectible_wb_unprotected_objects = 0; + objspace->rgengc.old_objects = 0; + objspace->rgengc.last_major_gc = objspace->profile.count; + objspace->marked_slots = 0; + for (int h = 0; h < HEAP_COUNT; h++) { + rb_heap_t *heap = &heaps[h]; + gc_bitmaps_clear(objspace, heap, true); + heap_move_pooled_pages_to_free_pages(heap); + } + } + driver->profile.major_gc_count++; + + /* Enable compaction in every objspace before the mark: the unified conservative root + * scan then pins machine-stack referents (gc_pin only pins while during_compacting) + * and step 9's sweep relocates the rest. global_gc.compacting defers the + * reference-update phase to phase 2 below (two phases, safe across objspaces). */ + global_objspace->global_gc.compacting = compact; + if (compact) { + for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { + rb_objspace_t *objspace = global_objspace->global_gc.objspaces[i]; + objspace->flags.during_compacting = TRUE; + /* A global GC skips gc_marks_start, which is what resets pinned_slots for a + * compacting local GC, so reset it here. step 5 cleared pinned_bits; the + * conservative mark re-pins machine-stack referents. */ + for (int h = 0; h < HEAP_COUNT; h++) { + struct heap_page *page = NULL; + ccan_list_for_each(&heaps[h].pages, page, page_node) { + page->pinned_slots = 0; + } + } + } + } + + /* steps 6-7: every Ractor's roots (gc.c walks them all and re-pins in-flight payloads), + * then one unified precise mark. */ + mark_roots(driver, NULL); + gc_mark_stacked_objects_all(driver); + + /* Run the generic_fields weak pass after the mark fixpoint: mark the vals (fields_obj) + * of live keys and drain the entries of dead ones. The per-object rb_mark_generic_ivar + * is a no-op during a global GC, so this is the only path that marks generic_fields. */ + gc_global_mark_generic_fields(driver); + + gc_event_hook(driver, RUBY_INTERNAL_EVENT_GC_END_MARK); + + /* step 8 */ + gc_update_weak_references(driver); + + /* This cycle's root pass over every Ractor has swept the deleted ractor-local keys out of + * each storage. Free the key structs while still inside the barrier (a local GC never + * can; see rb_ractor_finish_marking). */ + rb_ractor_finish_marking(); + + /* Clean the VM-global weak tables once, before sweeping any objspace (gc_sweep_start + * skips it during a global GC; the decision comes from the objspace-independent unified + * mark). */ + for (int table = 0; table < RB_GC_VM_WEAK_TABLE_COUNT; table++) { + if (!rb_gc_vm_weak_table_essential_p(table)) continue; + rb_gc_vm_weak_table_foreach(gc_sweep_weak_table_i, NULL, driver, true, table); + } + + /* step 9: sweep every objspace inside the barrier, not lazily. Dead shareable objects + * are reclaimed here and emptied pages go back to the pool. */ + if (!compact) { + for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { + rb_objspace_t *os = global_objspace->global_gc.objspaces[i]; + unsigned int prev_immediate = os->flags.immediate_sweep; + os->flags.immediate_sweep = TRUE; + gc_sweep(os); + os->flags.immediate_sweep = prev_immediate; + } + } + else { + /* The move -> update-references -> free flow runs as three passes across ALL + * objspaces, not per objspace: (a) updating references must see every objspace's + * forwarding (a reference can point at a moved foreign object), and (b) freeing + * source pages must wait until everyone is updated (or another objspace's update + * reads a freed T_MOVED). The read barrier is installed once for all passes. */ + install_handlers(); + + /* pass 1 (move): relocate every objspace and leave T_MOVED forwarding behind. */ + for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { + rb_objspace_t *os = global_objspace->global_gc.objspaces[i]; + gc_sweeping_enter(os); + gc_sweep_start(os); /* mode -> sweeping, order the heap for compaction */ + gc_compact_relocate(os); /* mode -> compacting, move */ + } + + /* pass 2 (update): all forwarding now exists, so update every objspace's + * references (cross-objspace ones resolve too); gc_compact_finish also unprotects + * pages and clears during_compacting. The move-or-mark decision reads + * rb_gc_get_objspace()'s during_reference_updating: set it on every objspace. */ + for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { + global_objspace->global_gc.objspaces[i]->flags.during_reference_updating = TRUE; + } + rb_gc_before_updating_jit_code(); + for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { + gc_compact_finish(global_objspace->global_gc.objspaces[i]); + } + /* The VM-global / weak-table side of the reference update runs once (each objspace's + * heap side already ran in gc_compact_finish above). */ + gc_update_references_global(driver); + rb_gc_after_updating_jit_code(); + for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { + global_objspace->global_gc.objspaces[i]->flags.during_reference_updating = FALSE; + global_objspace->global_gc.objspaces[i]->flags.during_compacting = FALSE; + } + global_objspace->global_gc.compacting = false; + uninstall_handlers(); + + /* pass 3 (free): page-sweep every objspace, freeing dead objects and the source pages + * that are now empty. during_compacting is already cleared, so the sweep treats + * T_MOVED as usual. */ + for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { + rb_objspace_t *os = global_objspace->global_gc.objspaces[i]; + gc_sweep_rest(os); + gc_sweeping_exit(os); + } + } + global_objspace->global_gc.compacting = false; + + /* A global GC never calls gc_marks_finish, which budgets heap growth + * (allocatable_bytes). An objspace still full after the global sweep (materializing + * a large received copy, say) has no free pages, no empty pages, budget 0, and its next + * allocation would hit newobj_refill's "cannot create a new page after a major GC". + * Give every objspace stuck like that the growth budget gc_marks_finish would. */ + for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { + rb_objspace_t *objspace = global_objspace->global_gc.objspaces[i]; + if (objspace->heap_pages.allocatable_bytes != 0 || objspace->empty_pages_count != 0) { + continue; + } + bool stuck = false; + for (int h = 0; h < HEAP_COUNT; h++) { + if (heaps[h].free_pages == NULL) { stuck = true; break; } + } + if (stuck) { + heap_allocatable_bytes_expand(objspace, NULL, 0, + objspace_available_slots(objspace), heaps[0].slot_size); + } + } + + /* Recount the surviving shareable objects (the sweep already folded the dead ones out of + * shareable_bits) and reset each trigger limit. */ + for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { + rb_objspace_t *objspace = global_objspace->global_gc.objspaces[i]; + size_t survivors = 0; + for (int h = 0; h < HEAP_COUNT; h++) { + struct heap_page *page = NULL; + ccan_list_for_each(&heaps[h].pages, page, page_node) { + if (!page->flags.has_shareable_objects) continue; + for (int j = 0; j < HEAP_PAGE_BITMAP_LIMIT; j++) { + survivors += rb_popcount_intptr(page->shareable_bits[j]); + } + } + } + objspace->shareable_objects = survivors; + size_t new_limit = (size_t)(survivors * SHAREABLE_OBJECTS_LIMIT_FACTOR); + if (new_limit < SHAREABLE_OBJECTS_LIMIT_MIN) new_limit = SHAREABLE_OBJECTS_LIMIT_MIN; + objspace->shareable_objects_limit = new_limit; + } + driver->profile.count++; + + /* step 10 */ + for (size_t i = 0; i < global_objspace->global_gc.n_objspaces; i++) { + rb_objspace_t *objspace = global_objspace->global_gc.objspaces[i]; + objspace->flags.during_global_gc = FALSE; + if (objspace != driver) during_gc = FALSE; + } + + /* The unified mark re-established the reachability of absorbed shareable objects, so a + * single objspace's local mark is trustworthy again (pinning can be skipped until the + * next absorb). */ + rb_gc_reset_absorbed_since_global_gc(); + + /* Re-measure the zombie_objspaces table now that the garbage is gone; entries are stable + * inside the barrier. Without this, the page trigger above keeps firing on the stale + * numbers left when a joinable (slotted) zombie retires without any pass merging it. */ + rb_gc_vm_refresh_zombie_pages(); + global_objspace->zombie_pages_survivors = rb_gc_vm_zombie_total_pages(); + + /* If the sweep above collected an unjoined Ractor object, ractor_free disowned its + * zombie_objspaces entry and posted the merge to main as a postponed job; the objspace + * stays enumerable until main absorbs it at its next safepoint. */ + + gc_exit(driver, gc_enter_event_global, &lock_lev); +} + +static int +absorb_finalizer_i(st_data_t key, st_data_t val, st_data_t data) +{ + rb_objspace_t *objspace = (rb_objspace_t *)data; + st_insert(finalizer_table, key, val); + return ST_CONTINUE; +} + +/* Merge a dead Ractor's objspace into dst under the VM lock. src has no owner thread and + * dst is the calling thread's own objspace (join/value) or main with everyone stopped + * (global GC), so single-writer holds throughout. Pages move whole (their bits describe + * objects, not the objspace), and dst's next collection is forced full to rebuild the + * generational state. */ +static void +objspace_absorb(rb_objspace_t *dst, rb_objspace_t *src) +{ + GC_ASSERT(dst != src); + + /* Suppress the cross-objspace verifier checks while the graph is in flux (see + * global_objspace->during_absorb). */ + const bool prev_absorb = global_objspace->during_absorb; + global_objspace->during_absorb = true; + + /* Settle dst first: adding pages under a walking lazy-sweep cursor, or into a + * half-marked incremental heap, would sweep the merged pages with src's stale mark + * bits and free live objects. (Normally settled already: vm_insert_ractor0's settle + * means no objspace is incremental while a zombie waits to be absorbed.) */ + gc_rest(dst); + + /* Settle src: no lazy sweep and no in-progress allocation page. */ + { + rb_objspace_t *objspace = src; + during_gc = TRUE; + gc_sweep_rest(objspace); + during_gc = FALSE; + heap_alloc_state_clear(objspace); + /* gc_sweep_finish leaves swept pages "pooled" for a coming incremental mark; src + * never runs one (it is about to be merged), so return them to its free list now, + * restoring the pooled_pages == NULL the page merge below assumes (mirrors + * gc_start_global step 3). */ + for (int h = 0; h < HEAP_COUNT; h++) { + heap_move_pooled_pages_to_free_pages(&heaps[h]); + } + } + + /* From here the merge must not run dst's GC: the finalizer st_insert below can cross + * the malloc-accounting threshold, and a GC then would sweep src's detached finalizer + * procs, reachable only from this C frame, into dangling VALUEs. Page/darray moves + * allocate nothing (_without_gc), so disabling costs nothing and makes the splice + * atomic. (The two settles above deliberately collect: they stay outside.) */ + const bool dst_gc_was_enabled = rb_gc_impl_gc_enabled_p(dst); + if (dst_gc_was_enabled) rb_gc_impl_gc_disable(dst, false); + + /* Hand over the pages size pool by size pool. ("heaps" is a macro over the local + * objspace, so the arrays are taken through scoped locals.) */ + rb_heap_t *dst_heaps; + rb_heap_t *src_heaps; + { + rb_objspace_t *objspace = dst; + dst_heaps = heaps; + } + { + rb_objspace_t *objspace = src; + src_heaps = heaps; + } + for (int h = 0; h < HEAP_COUNT; h++) { + rb_heap_t *dheap = &dst_heaps[h]; + rb_heap_t *sheap = &src_heaps[h]; + struct heap_page *page = NULL; + + GC_ASSERT(sheap->sweeping_page == NULL); + GC_ASSERT(sheap->pooled_pages == NULL); + + ccan_list_for_each(&sheap->pages, page, page_node) { + page->objspace = dst; + page->heap = dheap; + } + ccan_list_append_list(&dheap->pages, &sheap->pages); + + /* Append the free-page chain to the tail. */ + if (sheap->free_pages) { + struct heap_page **tail = &dheap->free_pages; + while (*tail) tail = &(*tail)->free_next; + *tail = sheap->free_pages; + sheap->free_pages = NULL; + } + + dheap->total_pages += sheap->total_pages; + dheap->total_slots += sheap->total_slots; + dheap->total_allocated_pages += sheap->total_allocated_pages; + dheap->total_allocated_objects += sheap->total_allocated_objects; + dheap->total_freed_objects += sheap->total_freed_objects; + dheap->final_slots_count += sheap->final_slots_count; + } + + /* The objspace-wide page bookkeeping. */ + { + rb_objspace_t *objspace = dst; /* for the heap_pages_* macros */ + struct heap_page *page = NULL; + size_t srcn = rb_darray_size(src->heap_pages.sorted); + for (size_t i = 0; i < srcn; i++) { + page = rb_darray_get(src->heap_pages.sorted, i); + /* Residents of the empty pool (no live objects) are returned to page_pool rather + * than inherited; dst's allocation demand is cheaply met from the shared pool's + * free list. */ + if (heap_page_in_global_empty_pages_pool(src, page)) { + heap_page_free(src, page); + continue; + } + uintptr_t body = (uintptr_t)page->body; + uintptr_t start = body + sizeof(struct heap_page_header); + uintptr_t end = body + HEAP_PAGE_SIZE; + + /* Keep the array ordered by page BODY address: heap_page_for_ptr bsearches + * body ranges, and a detached empty page has start == 0, so ordering by + * page->start would miss live pages (a global GC would then fail to mark a + * registered root and sweep it). */ + size_t lo = 0; + size_t hi = rb_darray_size(objspace->heap_pages.sorted); + while (lo < hi) { + size_t mid = (lo + hi) / 2; + struct heap_page *mid_page = rb_darray_get(objspace->heap_pages.sorted, mid); + if ((uintptr_t)mid_page->body < body) lo = mid + 1; + else hi = mid; + } + rb_darray_insert_without_gc(&objspace->heap_pages.sorted, hi, page); + + if (heap_pages_lomem == 0 || heap_pages_lomem > start) heap_pages_lomem = start; + if (heap_pages_himem < end) heap_pages_himem = end; + } + objspace->heap_pages.allocated_pages += src->heap_pages.allocated_pages; + objspace->heap_pages.freed_pages += src->heap_pages.freed_pages; + rb_darray_free_without_gc(src->heap_pages.sorted); + src->heap_pages.sorted = NULL; + /* The empty_pages chain's structs were freed in the loop above. */ + src->empty_pages = NULL; + src->empty_pages_count = 0; + } + + /* Finalizers: move the table's entries, and the dead Ractor's deferred zombies are run + * by dst's thread from now on. */ + { + st_table *src_finalizers; + { + rb_objspace_t *objspace = src; + src_finalizers = finalizer_table; + finalizer_table = NULL; + } + if (src_finalizers) { + rb_objspace_t *objspace = dst; + if (finalizer_table == NULL) { + finalizer_table = src_finalizers; + } + else { + st_foreach(src_finalizers, absorb_finalizer_i, (st_data_t)dst); + st_free_table(src_finalizers); + } + } + } + { + VALUE src_deferred = RUBY_ATOMIC_VALUE_EXCHANGE(src->heap_pages.deferred_final, 0); + if (src_deferred) { + VALUE tail_obj = src_deferred; + while (RZOMBIE(tail_obj)->next) tail_obj = RZOMBIE(tail_obj)->next; + VALUE prev; + do { + prev = dst->heap_pages.deferred_final; + RZOMBIE(tail_obj)->next = prev; + } while (RUBY_ATOMIC_VALUE_CAS(dst->heap_pages.deferred_final, prev, src_deferred) != prev); + /* No owner was left to run these zombies (register's owner walk misses a dead + * Ractor). dst runs this merge, so schedule dst's job here; otherwise they wait + * until dst's next GC. */ + rb_postponed_job_trigger(dst->finalize_deferred_pjob); + } + } + + /* Counters inherited by dst. */ + dst->rgengc.old_objects += src->rgengc.old_objects; + dst->rgengc.uncollectible_wb_unprotected_objects += src->rgengc.uncollectible_wb_unprotected_objects; + dst->shareable_objects += src->shareable_objects; + + /* Merged pages carry src's mark/age state, so dst rebuilds its view at the next + * collection. */ + dst->rgengc.need_major_gc |= GPR_FLAG_MAJOR_BY_FORCE; + + /* src's outstanding malloc pressure moves with the xmalloc'd buffers. Later frees are + * charged to dst, so without this transfer dst underestimates its own heap and delays + * GCs. dst is live, so take its counter lock where gc_counter_add is not atomic. */ + { + int64_t inc = gc_malloc_counters_increase(src, &src->malloc_counters.counters); +#if RGENGC_ESTIMATE_OLDMALLOC + int64_t oldinc = gc_malloc_counters_increase(src, &src->malloc_counters.oldcounters); +#endif + MALLOC_COUNTERS_LOCK(dst); + if (inc > 0) gc_counter_add(&dst->malloc_counters.counters.malloc, (size_t)inc); +#if RGENGC_ESTIMATE_OLDMALLOC + if (oldinc > 0) gc_counter_add(&dst->malloc_counters.oldcounters.malloc, (size_t)oldinc); +#endif + MALLOC_COUNTERS_UNLOCK(dst); + } + + /* Free the shell (as rb_gc_impl_objspace_free does). */ + free(src->profile.records); + free_stack_chunks(&src->mark_stack); + mark_stack_free_cache(&src->mark_stack); + GC_ASSERT(rb_darray_size(src->weak_references) == 0); + rb_darray_free_without_gc(src->weak_references); +#ifdef MALLOC_COUNTERS_NEED_LOCK + rb_native_mutex_destroy(&src->malloc_counters.lock); +#endif + free(src); + + if (dst_gc_was_enabled) rb_gc_impl_gc_enable(dst); + + /* Return the empty pages inheritance piled up in dst (mostly from the dead Ractor's + * teardown material) to the pool with no budget. An empty page is by definition safe to + * release, and re-acquiring one from the pool is cheap. */ + { + rb_objspace_t *objspace = dst; + heap_pages_freeable_pages = objspace->empty_pages_count; + heap_pages_free_unused_pages(objspace); + } + + global_objspace->during_absorb = prev_absorb; +} + +void +rb_gc_impl_objspace_absorb(void *dst_ptr, void *src_ptr) +{ + objspace_absorb(dst_ptr, src_ptr); +} + void rb_gc_impl_start(void *objspace_ptr, bool full_mark, bool immediate_mark, bool immediate_sweep, bool compact) { @@ -7438,6 +9117,10 @@ rb_gc_impl_start(void *objspace_ptr, bool full_mark, bool immediate_mark, bool i int full_marking_p = gc_config_full_mark_val; gc_config_full_mark_set(TRUE); + /* With multiple objspaces the global GC's barrier makes relocation and the two-phase + * reference update safe across all of them (gc_start_global with compact=true below); + * single-objspace compaction takes the usual local path (gc_start w/ during_compacting). */ + /* For now, compact implies full mark / sweep, so ignore other flags */ if (compact) { GC_ASSERT(GC_COMPACTION_SUPPORTED); @@ -7450,9 +9133,17 @@ rb_gc_impl_start(void *objspace_ptr, bool full_mark, bool immediate_mark, bool i if (!immediate_sweep) reason &= ~GPR_FLAG_IMMEDIATE_SWEEP; } - garbage_collect(objspace, reason); - gc_finalize_deferred(objspace); + /* An explicit full GC.start with multiple objspaces runs a global GC, the only + * collector that reclaims shareable and cross-objspace garbage. It stops the world, + * so auto_compact is honoured here too (mirroring full mark x autocompact locally). */ + if (!rb_gc_single_objspace_p() && (reason & GPR_FLAG_FULL_MARK)) { + gc_start_global(objspace, compact || ruby_enable_autocompact); + } + else { + garbage_collect(objspace, reason); + } + gc_finalize_deferred(objspace); gc_config_full_mark_set(full_marking_p); } @@ -7566,6 +9257,11 @@ gc_move(rb_objspace_t *objspace, VALUE src, VALUE dest, struct heap_page *src_pa wb_unprotected = RVALUE_WB_UNPROTECTED(objspace, src); uncollectible = RVALUE_UNCOLLECTIBLE(objspace, src); bool remembered = RVALUE_REMEMBERED(objspace, src); + /* Pin bits travel with the object. Losing one during single-objspace compaction would + * silently unpin it once the process goes multi-objspace, letting a local GC free a method + * entry or shref target that another Ractor references. */ + bool shareable = MARKED_IN_BITMAP(GET_HEAP_SHAREABLE_BITS(src), src) != 0; + bool shref = MARKED_IN_BITMAP(GET_HEAP_SHREF_BITS(src), src) != 0; age = RVALUE_AGE_GET(src); /* Clear bits for eventual T_MOVED */ @@ -7573,6 +9269,8 @@ gc_move(rb_objspace_t *objspace, VALUE src, VALUE dest, struct heap_page *src_pa CLEAR_IN_BITMAP(GET_HEAP_WB_UNPROTECTED_BITS(src), src); CLEAR_IN_BITMAP(GET_HEAP_UNCOLLECTIBLE_BITS(src), src); CLEAR_IN_BITMAP(GET_HEAP_PAGE(src)->remembered_bits, src); + CLEAR_IN_BITMAP(GET_HEAP_SHAREABLE_BITS(src), src); + CLEAR_IN_BITMAP(GET_HEAP_SHREF_BITS(src), src); /* Move the object */ memcpy((void *)dest, (void *)src, MIN(src_slot_size, slot_size)); @@ -7620,6 +9318,22 @@ gc_move(rb_objspace_t *objspace, VALUE src, VALUE dest, struct heap_page *src_pa CLEAR_IN_BITMAP(GET_HEAP_UNCOLLECTIBLE_BITS(dest), dest); } + if (shareable) { + MARK_IN_BITMAP(GET_HEAP_SHAREABLE_BITS(dest), dest); + GET_HEAP_PAGE(dest)->flags.has_shareable_objects = TRUE; + } + else { + CLEAR_IN_BITMAP(GET_HEAP_SHAREABLE_BITS(dest), dest); + } + + if (shref) { + MARK_IN_BITMAP(GET_HEAP_SHREF_BITS(dest), dest); + GET_HEAP_PAGE(dest)->flags.has_shref_objects = TRUE; + } + else { + CLEAR_IN_BITMAP(GET_HEAP_SHREF_BITS(dest), dest); + } + RVALUE_AGE_SET(dest, age); /* A re-embedded object (rb_gc_obj_changed_slot_size) references its @@ -7772,13 +9486,12 @@ gc_update_references_weak_table_replace_i(VALUE *obj, void *data) return ST_CONTINUE; } +/* The per-objspace side of the reference update: walk this objspace's heap objects and rewrite + * moved references (following T_MOVED forwarding across objspaces). A compacting global GC + * runs this for every objspace. */ static void -gc_update_references(rb_objspace_t *objspace) +gc_update_references_heap(rb_objspace_t *objspace) { - objspace->flags.during_reference_updating = true; - - rb_gc_before_updating_jit_code(); - struct heap_page *page = NULL; for (int i = 0; i < HEAP_COUNT; i++) { @@ -7798,7 +9511,14 @@ gc_update_references(rb_objspace_t *objspace) } } } +} +/* The VM-global side of the reference update (finalizer table, every Ractor's VM roots, + * weak tables). Process-wide, so a compacting global GC runs it once after every heap + * side: rb_gc_update_vm_references and the weak tables' mark_and_move are not idempotent. */ +static void +gc_update_references_global(rb_objspace_t *objspace) +{ gc_update_table_refs(finalizer_table); rb_gc_update_vm_references((void *)objspace); @@ -7812,6 +9532,17 @@ gc_update_references(rb_objspace_t *objspace) table ); } +} + +static void +gc_update_references(rb_objspace_t *objspace) +{ + objspace->flags.during_reference_updating = true; + + rb_gc_before_updating_jit_code(); + + gc_update_references_heap(objspace); + gc_update_references_global(objspace); rb_gc_after_updating_jit_code(); @@ -8171,7 +9902,6 @@ rb_gc_impl_stat(void *objspace_ptr, VALUE hash_or_sym) setup_gc_stat_symbols(); - ractor_cache_flush_count(objspace, rb_gc_get_ractor_newobj_cache()); malloc_increase_local_flush(objspace); if (RB_TYPE_P(hash_or_sym, T_HASH)) { @@ -8230,7 +9960,6 @@ rb_gc_impl_stat(void *objspace_ptr, VALUE hash_or_sym) SET(oldmalloc_increase_bytes_limit, objspace->rgengc.oldmalloc_increase_limit); #endif - ractor_cache_flush_count(objspace, rb_gc_get_ractor_newobj_cache()); SET(total_allocated_objects, total_allocated_objects(objspace)); SET(total_freed_objects, total_freed_objects(objspace)); SET(heap_available_slots, objspace_available_slots(objspace)); @@ -8344,8 +10073,6 @@ rb_gc_impl_stat_heap(void *objspace_ptr, VALUE heap_name, VALUE hash_or_sym) { rb_objspace_t *objspace = objspace_ptr; - ractor_cache_flush_count(objspace, rb_gc_get_ractor_newobj_cache()); - setup_gc_stat_heap_symbols(); if (NIL_P(heap_name)) { @@ -8434,21 +10161,14 @@ rb_gc_impl_config_set(void *objspace_ptr, VALUE hash) VALUE rb_gc_impl_stress_get(void *objspace_ptr) { - rb_objspace_t *objspace = objspace_ptr; return ruby_gc_stress_mode; } void rb_gc_impl_stress_set(void *objspace_ptr, VALUE flag) { - rb_objspace_t *objspace = objspace_ptr; - - objspace->flags.gc_stressful = RTEST(flag); - objspace->gc_stress_mode = flag; - - if (objspace->flags.gc_stressful) { - rb_gc_ractor_newobj_cache_foreach(gc_ractor_newobj_cache_exhaust, NULL); - } + global_objspace->gc_stressful = RTEST(flag); + global_objspace->gc_stress_mode = flag; } static int @@ -8754,7 +10474,7 @@ objspace_malloc_increase_body(rb_objspace_t *objspace, void *mem, size_t new_siz if (type == MEMOP_TYPE_MALLOC && gc_allowed) { retry: - if (malloc_increase > malloc_limit && ruby_native_thread_p() && !dont_gc_val()) { + if (malloc_increase > malloc_limit && ruby_native_thread_p() && !dont_gc_val() && !rb_gc_gc_disabled_global_p()) { if (ruby_thread_has_gvl_p() && is_lazy_sweeping(objspace)) { gc_sweep_step_for_malloc(objspace); /* sweeping frees may reduce malloc_increase */ goto retry; @@ -10151,6 +11871,14 @@ gc_verify_compaction_references(int argc, VALUE* argv, VALUE self) rb_objspace_t *objspace = rb_gc_get_objspace(); + /* This verification machinery (heap expansion, toward_empty page ordering, the + * moved-reference walk) is built for a single objspace, so with several demote it + * to a plain full GC. Plain GC.compact does compact them via the global GC. */ + if (!rb_gc_single_objspace_p()) { + rb_gc_impl_start(objspace, true, true, true, false); + return gc_compact_stats(self); + } + /* Clear the heap. */ rb_gc_impl_start(objspace, true, true, true, false); @@ -10313,7 +12041,9 @@ rb_gc_impl_after_fork(void *objspace_ptr, rb_pid_t pid) objspace->fork_vm_lock_lev = 0; if (pid == 0) { /* child process */ - rb_gc_ractor_newobj_cache_foreach(gc_ractor_newobj_cache_clear, objspace); + heap_alloc_state_clear(objspace); + /* The forking Ractor becomes the child process's main Ractor. */ + global_objspace->main_objspace = objspace; } } @@ -10374,6 +12104,8 @@ rb_gcdebug_remove_stress_to_class(int argc, VALUE *argv, VALUE self) void * rb_gc_impl_objspace_alloc(void) { + global_objspace_init(); + rb_objspace_t *objspace = calloc1(sizeof(rb_objspace_t)); return objspace; @@ -10388,10 +12120,12 @@ rb_gc_impl_objspace_init(void *objspace_ptr) objspace->flags.measure_gc = true; malloc_limit = gc_params.malloc_limit_min; + objspace->shareable_objects_limit = SHAREABLE_OBJECTS_LIMIT_MIN; #ifdef MALLOC_COUNTERS_NEED_LOCK rb_native_mutex_initialize(&objspace->malloc_counters.lock); #endif - objspace->finalize_deferred_pjob = rb_postponed_job_preregister(0, gc_finalize_deferred, objspace); + /* Shared by every objspace. preregister deduplicates on (func, data). */ + objspace->finalize_deferred_pjob = rb_postponed_job_preregister(0, gc_finalize_deferred, NULL); if (objspace->finalize_deferred_pjob == POSTPONED_JOB_HANDLE_INVALID) { rb_bug("Could not preregister postponed job for GC"); } @@ -10409,19 +12143,27 @@ rb_gc_impl_objspace_init(void *objspace_ptr) ccan_list_head_init(&heap->pages); } - init_size_to_heap_idx(); + if (global_objspace->main_objspace == NULL) { + /* Single-threaded at boot and the first objspace is main's: compute process-wide + * constants once here. A later objspace_init rewriting them, even with equal + * values, would race other threads' lock-free reads. */ + global_objspace->main_objspace = objspace; - rb_darray_make_without_gc(&objspace->heap_pages.sorted, 0); - rb_darray_make_without_gc(&objspace->weak_references, 0); + init_size_to_heap_idx(); #if defined(INIT_HEAP_PAGE_ALLOC_USE_MMAP) - /* Need to determine if we can use mmap at runtime. */ - heap_page_alloc_use_mmap = INIT_HEAP_PAGE_ALLOC_USE_MMAP; + /* Need to determine if we can use mmap at runtime. */ + heap_page_alloc_use_mmap = INIT_HEAP_PAGE_ALLOC_USE_MMAP; #endif + gc_params.heap_init_bytes = GC_HEAP_INIT_BYTES; + } + + rb_darray_make_without_gc(&objspace->heap_pages.sorted, 0); + rb_darray_make_without_gc(&objspace->weak_references, 0); + #if RGENGC_ESTIMATE_OLDMALLOC objspace->rgengc.oldmalloc_increase_limit = gc_params.oldmalloc_limit_min; #endif - gc_params.heap_init_bytes = GC_HEAP_INIT_BYTES; init_mark_stack(&objspace->mark_stack); diff --git a/gc/gc.h b/gc/gc.h index 7ab14af1a58fc4..d5f32df780ff37 100644 --- a/gc/gc.h +++ b/gc/gc.h @@ -13,6 +13,7 @@ #include "ruby/assert.h" #include "ruby/thread_native.h" +#include "ruby/debug.h" #ifndef VM_CHECK_MODE # define VM_CHECK_MODE RUBY_DEBUG @@ -23,20 +24,6 @@ # define RACTOR_CHECK_MODE (VM_CHECK_MODE || RUBY_DEBUG) && (SIZEOF_UINT64_T == SIZEOF_VALUE) #endif -#if RACTOR_CHECK_MODE -void rb_ractor_setup_belonging(VALUE obj); - -struct rb_gc_obj_suffix { - uint32_t _ractor_belonging_id; -}; - -# define RB_GC_OBJ_HAS_SUFFIX 1 -# define RB_GC_OBJ_SUFFIX_SIZE (sizeof(struct rb_gc_obj_suffix)) -#else -# define RB_GC_OBJ_HAS_SUFFIX 0 -# define RB_GC_OBJ_SUFFIX_SIZE 0 -#endif - struct rb_gc_vm_context { struct rb_execution_context_struct *ec; }; @@ -55,8 +42,6 @@ enum rb_gc_vm_weak_tables { #define RB_GC_VM_LOCK() rb_gc_vm_lock(__FILE__, __LINE__) #define RB_GC_VM_UNLOCK(lev) rb_gc_vm_unlock(lev, __FILE__, __LINE__) -#define RB_GC_CR_LOCK() rb_gc_cr_lock(__FILE__, __LINE__) -#define RB_GC_CR_UNLOCK(lev) rb_gc_cr_unlock(lev, __FILE__, __LINE__) #define RB_GC_VM_LOCK_NO_BARRIER() rb_gc_vm_lock_no_barrier(__FILE__, __LINE__) #define RB_GC_VM_UNLOCK_NO_BARRIER(lev) rb_gc_vm_unlock_no_barrier(lev, __FILE__, __LINE__) @@ -85,28 +70,47 @@ void rb_gc_verify_shareable(VALUE); MODULAR_GC_FN unsigned int rb_gc_vm_lock(const char *file, int line); MODULAR_GC_FN void rb_gc_vm_unlock(unsigned int lev, const char *file, int line); -MODULAR_GC_FN unsigned int rb_gc_cr_lock(const char *file, int line); -MODULAR_GC_FN void rb_gc_cr_unlock(unsigned int lev, const char *file, int line); MODULAR_GC_FN unsigned int rb_gc_vm_lock_no_barrier(const char *file, int line); MODULAR_GC_FN void rb_gc_vm_unlock_no_barrier(unsigned int lev, const char *file, int line); 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 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). */ +bool rb_gc_single_objspace_p(void); +/* Clear the "absorbed" flag once a global GC finishes (see rb_gc_single_objspace_p). + * No MODULAR_GC_FN, for the same reason as above. */ +void rb_gc_reset_absorbed_since_global_gc(void); MODULAR_GC_FN size_t rb_gc_obj_optimal_size(VALUE obj); MODULAR_GC_FN void rb_gc_mark_children(void *objspace, VALUE obj); MODULAR_GC_FN bool rb_gc_vm_weak_table_essential_p(enum rb_gc_vm_weak_tables table); MODULAR_GC_FN void rb_gc_vm_weak_table_foreach(vm_table_foreach_callback_func callback, vm_table_update_callback_func update_callback, void *data, bool weak_only, enum rb_gc_vm_weak_tables table); +/* The global GC's weak pass over generic_fields (called from a gc-impl). */ +MODULAR_GC_FN void rb_gc_vm_generic_fields_mark_foreach(int (*cb)(VALUE key, VALUE val, void *arg), void *arg); +MODULAR_GC_FN void rb_gc_vm_generic_fields_drain_dead(bool (*is_dead)(VALUE key)); +/* Exemptions for the shareable containment verifier (called from a gc-impl). */ +MODULAR_GC_FN bool rb_gc_current_ractor_materializing_p(void); +MODULAR_GC_FN VALUE rb_gc_vm_top_self(void); MODULAR_GC_FN void rb_gc_update_object_references(void *objspace, VALUE obj); MODULAR_GC_FN void rb_gc_update_vm_references(void *objspace); MODULAR_GC_FN void rb_gc_event_hook(VALUE obj, rb_event_flag_t event); MODULAR_GC_FN void *rb_gc_get_objspace(void); MODULAR_GC_FN void rb_gc_run_obj_finalizer(VALUE objid, long count, VALUE (*callback)(long i, void *data), void *data); MODULAR_GC_FN void rb_gc_set_pending_interrupt(void); +MODULAR_GC_FN void rb_gc_trigger_finalize_deferred(void *objspace, rb_postponed_job_handle_t pjob); MODULAR_GC_FN void rb_gc_unset_pending_interrupt(void); MODULAR_GC_FN void rb_gc_obj_free_vm_weak_references(VALUE obj); MODULAR_GC_FN bool rb_gc_obj_free(void *objspace, VALUE obj); MODULAR_GC_FN void rb_gc_save_machine_context(void); MODULAR_GC_FN void rb_gc_mark_roots(void *objspace, const char **categoryp); -MODULAR_GC_FN void rb_gc_ractor_newobj_cache_foreach(void (*func)(void *cache, void *data), void *data); MODULAR_GC_FN bool rb_gc_multi_ractor_p(void); +MODULAR_GC_FN bool rb_gc_ever_multi_ractor_p(void); +/* Process-wide GC disable flag (GC.disable / rb_gc_disable). Every GC trigger in + * an impl checks it, so disabling stops automatic GC in every Ractor. The + * per-objspace switch is objspace->flags.dont_gc. */ +MODULAR_GC_FN bool rb_gc_gc_disabled_global_p(void); MODULAR_GC_FN bool rb_gc_shutdown_call_finalizer_p(VALUE obj); MODULAR_GC_FN void rb_gc_obj_changed_slot_size(VALUE obj, size_t slot_size); MODULAR_GC_FN void rb_gc_prepare_heap_process_object(VALUE obj); diff --git a/gc/gc_impl.h b/gc/gc_impl.h index 95f7a37267658f..6a3d0fcee39987 100644 --- a/gc/gc_impl.h +++ b/gc/gc_impl.h @@ -55,6 +55,7 @@ struct rb_gc_object_metadata_entry { GC_IMPL_FN void *rb_gc_impl_objspace_alloc(void); GC_IMPL_FN void rb_gc_impl_objspace_init(void *objspace_ptr); GC_IMPL_FN void *rb_gc_impl_ractor_cache_alloc(void *objspace_ptr, void *ractor); +GC_IMPL_FN void rb_gc_impl_objspace_retire_gc(void *objspace_ptr); GC_IMPL_FN void rb_gc_impl_set_params(void *objspace_ptr); GC_IMPL_FN void rb_gc_impl_init(void); // Shutdown @@ -68,6 +69,9 @@ GC_IMPL_FN void rb_gc_impl_prepare_heap(void *objspace_ptr); GC_IMPL_FN void rb_gc_impl_gc_enable(void *objspace_ptr); GC_IMPL_FN void rb_gc_impl_gc_disable(void *objspace_ptr, bool finish_current_gc); GC_IMPL_FN bool rb_gc_impl_gc_enabled_p(void *objspace_ptr); +GC_IMPL_FN bool rb_gc_impl_user_gc_disabled_set(void *objspace_ptr, bool disable); +GC_IMPL_FN bool rb_gc_impl_user_gc_disabled_p(void *objspace_ptr); +GC_IMPL_FN void rb_gc_impl_gc_rest(void *objspace_ptr); GC_IMPL_FN void rb_gc_impl_stress_set(void *objspace_ptr, VALUE flag); GC_IMPL_FN VALUE rb_gc_impl_stress_get(void *objspace_ptr); GC_IMPL_FN VALUE rb_gc_impl_config_get(void *objspace_ptr); @@ -123,8 +127,22 @@ GC_IMPL_FN VALUE rb_gc_impl_location(void *objspace_ptr, VALUE value); GC_IMPL_FN void rb_gc_impl_writebarrier(void *objspace_ptr, VALUE a, VALUE b); GC_IMPL_FN void rb_gc_impl_writebarrier_unprotect(void *objspace_ptr, VALUE obj); GC_IMPL_FN void rb_gc_impl_writebarrier_remember(void *objspace_ptr, VALUE obj); +GC_IMPL_FN void rb_gc_impl_obj_became_shareable(void *objspace_ptr, VALUE obj); +GC_IMPL_FN void rb_gc_impl_pin_in_flight_message(void *objspace_ptr, VALUE obj); // Heap walking GC_IMPL_FN void rb_gc_impl_each_objects(void *objspace_ptr, int (*callback)(void *, void *, size_t, void *), void *data); +GC_IMPL_FN void rb_gc_impl_each_objects_shareable(void *objspace_ptr, int (*callback)(void *, void *, size_t, void *), void *data); +GC_IMPL_FN void rb_gc_impl_each_objects_foreign(void *objspace_ptr, int (*callback)(void *, void *, size_t, void *), void *data); +/* Whether the impl supports multiple per-Ractor objspaces. When false the VM shares a single + * objspace and passes the per-Ractor objspace machinery (retire, absorb, ...) straight through. */ +GC_IMPL_FN bool rb_gc_impl_multi_objspace_p(void); +GC_IMPL_FN bool rb_gc_impl_during_global_gc_p(void *objspace_ptr); +/* Whether obj is owned by an objspace other than objspace_ptr. Always false for a single + * objspace impl. */ +GC_IMPL_FN bool rb_gc_impl_obj_foreign_p(void *objspace_ptr, VALUE obj); +GC_IMPL_FN bool rb_gc_impl_shref_marked_p(void *objspace_ptr, VALUE obj); +GC_IMPL_FN size_t rb_gc_impl_heap_page_count(void *objspace_ptr); +GC_IMPL_FN void rb_gc_impl_objspace_absorb(void *dst_ptr, void *src_ptr); GC_IMPL_FN void rb_gc_impl_each_object(void *objspace_ptr, void (*func)(VALUE obj, void *data), void *data); // Finalizers GC_IMPL_FN void rb_gc_impl_make_zombie(void *objspace_ptr, VALUE obj, void (*dfree)(void *), void *data); diff --git a/gc/mmtk/mmtk.c b/gc/mmtk/mmtk.c index d7228c43becbcf..4bf0866fefc00d 100644 --- a/gc/mmtk/mmtk.c +++ b/gc/mmtk/mmtk.c @@ -22,6 +22,7 @@ #endif struct objspace { + bool user_gc_disabled; bool measure_gc_time; bool gc_stress; @@ -572,16 +573,30 @@ rb_mmtk_builder_init(void) return builder; } + void * rb_gc_impl_objspace_alloc(void) { - MMTk_Builder *builder = rb_mmtk_builder_init(); - MMTk_RubyBindingOptions binding_options = { - .suffix_size = RB_GC_OBJ_SUFFIX_SIZE, - }; - mmtk_init_binding(builder, &binding_options, &ruby_upcalls); + /* One heap, binding and objspace per process (multi_objspace_p=false). The VM + * shares the same objspace across all Ractors, so a re-entry returns the same + * instance. */ + static struct objspace *the_objspace = NULL; + if (the_objspace == NULL) { + MMTk_Builder *builder = rb_mmtk_builder_init(); + MMTk_RubyBindingOptions binding_options = { + .suffix_size = 0, + }; + mmtk_init_binding(builder, &binding_options, &ruby_upcalls); + the_objspace = calloc(1, sizeof(struct objspace)); + } + + return the_objspace; +} - return calloc(1, sizeof(struct objspace)); +bool +rb_gc_impl_multi_objspace_p(void) +{ + return false; } static void gc_run_finalizers(void *data); @@ -591,6 +606,10 @@ rb_gc_impl_objspace_init(void *objspace_ptr) { struct objspace *objspace = objspace_ptr; + /* The objspace is a singleton (see rb_gc_impl_objspace_alloc). A re-init must + * not clobber finalizer_table or ractor_caches. */ + if (objspace->finalizer_table != NULL) return; + objspace->measure_gc_time = true; objspace->finalizer_table = st_init_numtable(); @@ -612,7 +631,7 @@ rb_gc_impl_objspace_init(void *objspace_ptr) void rb_gc_impl_objspace_free(void *objspace_ptr) { - free(objspace_ptr); + /* The objspace is a process-lifetime singleton. */ } void * @@ -633,6 +652,12 @@ rb_gc_impl_ractor_cache_alloc(void *objspace_ptr, void *ractor) return cache; } +void +rb_gc_impl_objspace_retire_gc(void *objspace_ptr) +{ + /* A single objspace needs no per-Ractor GC at teardown. */ +} + void rb_gc_impl_ractor_cache_free(void *objspace_ptr, void *cache_ptr) { @@ -683,11 +708,11 @@ bool rb_gc_impl_zjit_new_obj_fastpath(void *objspace_ptr, size_t alloc_size, VALUE flags, VALUE klass, struct rb_gc_zjit_fastpath *fastpath) { -#if USE_ZJIT && RB_GC_OBJ_SUFFIX_SIZE == 0 +#if USE_ZJIT struct objspace *objspace = objspace_ptr; - size_t total_size = rb_mmtk_align_obj_size(alloc_size + sizeof(VALUE) + RB_GC_OBJ_SUFFIX_SIZE); - size_t object_size = total_size - sizeof(VALUE) - RB_GC_OBJ_SUFFIX_SIZE; + size_t total_size = rb_mmtk_align_obj_size(alloc_size + sizeof(VALUE)); + size_t object_size = total_size - sizeof(VALUE); size_t value_size_shift = sizeof(VALUE) == 8 ? 3 : 2; if (total_size > objspace->max_non_los_default_alloc_bytes) return false; @@ -808,6 +833,22 @@ rb_gc_impl_gc_disable(void *objspace_ptr, bool finish_current_gc) mmtk_set_gc_enabled(false); } +bool +rb_gc_impl_user_gc_disabled_set(void *objspace_ptr, bool disable) +{ + struct objspace *objspace = objspace_ptr; + const bool was = objspace->user_gc_disabled; + objspace->user_gc_disabled = disable; + return was; +} + +bool +rb_gc_impl_user_gc_disabled_p(void *objspace_ptr) +{ + struct objspace *objspace = objspace_ptr; + return objspace->user_gc_disabled; +} + bool rb_gc_impl_gc_enabled_p(void *objspace_ptr) { @@ -978,9 +1019,9 @@ rb_gc_impl_new_obj(void *objspace_ptr, void *cache_ptr, VALUE klass, VALUE flags rb_bug("rb_gc_impl_new_obj: allocation size out of range (size=%"PRIuSIZE")", alloc_size); } - // Layout: [hidden size header (sizeof(VALUE))][payload (alloc_size)][suffix (RB_GC_OBJ_SUFFIX_SIZE)] - size_t total_size = rb_mmtk_align_obj_size(alloc_size + sizeof(VALUE) + RB_GC_OBJ_SUFFIX_SIZE); - size_t object_size = total_size - sizeof(VALUE) - RB_GC_OBJ_SUFFIX_SIZE; + // Layout: [hidden size header (sizeof(VALUE))][payload (alloc_size)] + size_t total_size = rb_mmtk_align_obj_size(alloc_size + sizeof(VALUE)); + size_t object_size = total_size - sizeof(VALUE); MMTk_AllocationSemantics semantics = total_size > objspace->max_non_los_default_alloc_bytes ? MMTK_ALLOCATION_SEMANTICS_LOS : MMTK_ALLOCATION_SEMANTICS_DEFAULT; @@ -1037,7 +1078,7 @@ rb_gc_impl_size_slot_size(void *objspace_ptr, size_t size) rb_bug("rb_gc_impl_size_slot_size: size too large (size=%"PRIuSIZE")", size); } - return rb_mmtk_align_obj_size(size + sizeof(VALUE) + RB_GC_OBJ_SUFFIX_SIZE) - sizeof(VALUE) - RB_GC_OBJ_SUFFIX_SIZE; + return rb_mmtk_align_obj_size(size + sizeof(VALUE)) - sizeof(VALUE); } bool @@ -1205,6 +1246,18 @@ rb_gc_impl_writebarrier_unprotect(void *objspace_ptr, VALUE obj) mmtk_register_wb_unprotected_object((MMTk_ObjectReference)obj); } +void +rb_gc_impl_obj_became_shareable(void *objspace_ptr, VALUE obj) +{ + /* MMTk has no per-page shareable bits. */ +} + +void +rb_gc_impl_pin_in_flight_message(void *objspace_ptr, VALUE obj) +{ + /* With a single objspace there is nothing to pin. */ +} + void rb_gc_impl_writebarrier_remember(void *objspace_ptr, VALUE obj) { @@ -1745,3 +1798,78 @@ rb_gc_impl_active_gc_name(void) { return "mmtk"; } + +bool +rb_gc_impl_during_global_gc_p(void *objspace_ptr) +{ + /* An mmtk GC is always a stop-the-world global GC. Helpers used during marking + * (ractor_sync_mark and friends) read this to tell whether every mutator has + * stopped. */ + struct objspace *objspace = objspace_ptr; + return objspace->world_stopped; +} + +bool +rb_gc_impl_obj_foreign_p(void *objspace_ptr, VALUE obj) +{ + /* With a single objspace every object is our own. */ + return false; +} + +bool +rb_gc_impl_shref_marked_p(void *objspace_ptr, VALUE obj) +{ + /* With a single objspace there is no cross-objspace pinning to track. */ + return false; +} + +size_t +rb_gc_impl_heap_page_count(void *objspace_ptr) +{ + /* With a single objspace zombie_objspaces is always empty. */ + return 0; +} + +void +rb_gc_impl_objspace_absorb(void *dst_ptr, void *src_ptr) +{ + /* A single objspace. */ +} + +void +rb_gc_impl_gc_rest(void *objspace_ptr) +{ + /* An mmtk GC completes stop-the-world: there is no in-progress incremental + * mark or lazy sweep state. */ +} + +struct each_objects_shareable_data { + int (*func)(void *, void *, size_t, void *); + void *data; +}; + +static int +each_objects_shareable_i(void *start, void *end, size_t stride, void *d) +{ + struct each_objects_shareable_data *data = d; + for (VALUE obj = (VALUE)start; obj < (VALUE)end; obj += stride) { + if (RB_FL_TEST_RAW(obj, RUBY_FL_SHAREABLE)) { + int ret = data->func((void *)obj, (void *)(obj + stride), stride, data->data); + if (ret) return ret; + } + } + return 0; +} + +void +rb_gc_impl_each_objects_shareable(void *objspace_ptr, int (*func)(void *, void *, size_t, void *), void *data) +{ + struct each_objects_shareable_data d = { func, data }; + rb_gc_impl_each_objects(objspace_ptr, each_objects_shareable_i, &d); +} + +void +rb_gc_impl_each_objects_foreign(void *objspace_ptr, int (*func)(void *, void *, size_t, void *), void *data) +{ + /* With a single objspace no object lives in a foreign objspace. */ +} diff --git a/gems/bundled_gems b/gems/bundled_gems index c10d746627ceb8..d071b22c4fd34e 100644 --- a/gems/bundled_gems +++ b/gems/bundled_gems @@ -40,7 +40,7 @@ logger 1.7.0 https://github.com/ruby/logger rdoc 8.0.0 https://github.com/ruby/rdoc win32ole 1.9.3 https://github.com/ruby/win32ole irb 1.18.0 https://github.com/ruby/irb ac42eaaa88e6399384c1a56cc948c4b2528a9cc2 -reline 0.6.3 https://github.com/ruby/reline +reline 0.7.0 https://github.com/ruby/reline readline 0.0.4 https://github.com/ruby/readline fiddle 1.1.8 https://github.com/ruby/fiddle tsort 0.2.0 https://github.com/ruby/tsort diff --git a/imemo.c b/imemo.c index 5fb5766a55eb96..44b8bd67073b50 100644 --- a/imemo.c +++ b/imemo.c @@ -368,9 +368,7 @@ mark_and_move_method_entry(rb_method_entry_t *ment, bool reference_updating) rb_gc_mark_and_move(&def->body.attr.location); break; case VM_METHOD_TYPE_BMETHOD: - if (!rb_gc_checking_shareable()) { - rb_gc_mark_and_move(&def->body.bmethod.proc); - } + rb_gc_mark_and_move(&def->body.bmethod.proc); break; case VM_METHOD_TYPE_ALIAS: rb_gc_mark_and_move_ptr(&def->body.alias.original_me); @@ -444,10 +442,7 @@ rb_imemo_mark_and_move(VALUE obj, bool reference_updating) case imemo_constcache: { struct iseq_inline_constant_cache_entry *ice = (struct iseq_inline_constant_cache_entry *)obj; - if ((ice->flags & IMEMO_CONST_CACHE_SHAREABLE) || - !rb_gc_checking_shareable()) { - rb_gc_mark_and_move(&ice->value); - } + rb_gc_mark_and_move(&ice->value); break; } @@ -575,25 +570,23 @@ rb_imemo_mark_and_move(VALUE obj, bool reference_updating) case imemo_fields: { rb_gc_mark_and_move((VALUE *)&RBASIC(obj)->klass); - if (!rb_gc_checking_shareable()) { - // imemo_fields can refer unshareable objects - // even if the imemo_fields is shareable. - - if (rb_obj_shape_complex_p(obj)) { - st_table *tbl = rb_imemo_fields_complex_tbl(obj); - if (reference_updating) { - rb_gc_ref_update_table_values_only(tbl); - } - else { - rb_mark_tbl_no_pin(tbl); - } + /* A shareable imemo_fields (a class/module's fields) can reference unshareable values + * too. The write barrier records those as shrefs, so the shareable constraint check + * walks here. */ + if (rb_obj_shape_complex_p(obj)) { + st_table *tbl = rb_imemo_fields_complex_tbl(obj); + if (reference_updating) { + rb_gc_ref_update_table_values_only(tbl); } else { - VALUE *fields = rb_imemo_fields_ptr(obj); - attr_index_t len = RSHAPE_LEN(RBASIC_SHAPE_ID(obj)); - for (attr_index_t i = 0; i < len; i++) { - rb_gc_mark_and_move(&fields[i]); - } + rb_mark_tbl_no_pin(tbl); + } + } + else { + VALUE *fields = rb_imemo_fields_ptr(obj); + attr_index_t len = RSHAPE_LEN(RBASIC_SHAPE_ID(obj)); + for (attr_index_t i = 0; i < len; i++) { + rb_gc_mark_and_move(&fields[i]); } } break; diff --git a/internal/gc.h b/internal/gc.h index 706610f35eec4c..29c86f28060839 100644 --- a/internal/gc.h +++ b/internal/gc.h @@ -175,12 +175,15 @@ struct rb_gc_object_metadata_entry { * need to temporarily disable the GC to allow the malloc to happen. * Allocating memory during GC is a bad idea, so use this only when absolutely * necessary. */ +/* Only re-entrant GC of the current objspace needs suppressing (the malloc happens inside this + * Ractor), so use the local disable. The during-GC malloc guard reads the per-objspace dont_gc + * flag rather than a process-wide one. */ #define DURING_GC_COULD_MALLOC_REGION_START() \ assert(rb_during_gc()); \ - VALUE _already_disabled = rb_gc_disable_no_rest() + VALUE _already_disabled = rb_gc_local_disable_no_rest() #define DURING_GC_COULD_MALLOC_REGION_END() \ - if (_already_disabled == Qfalse) rb_gc_enable() + if (_already_disabled == Qfalse) rb_gc_local_enable() /* gc.c */ RUBY_ATTR_MALLOC void *ruby_mimmalloc(size_t size); @@ -240,9 +243,15 @@ void rb_objspace_each_objects( int (*callback)(void *start, void *end, size_t stride, void *data), void *data); + size_t rb_gc_obj_slot_size(VALUE obj); VALUE rb_gc_disable_no_rest(void); +/* Local GC disable/enable covering only the current Ractor's objspace. Unlike rb_gc_disable*, + * which became process-wide, this suppresses GC in one's own objspace only. Exported because + * DURING_GC_COULD_MALLOC_REGION above expands in bundled extensions. */ +VALUE rb_gc_local_enable(void); +VALUE rb_gc_local_disable_no_rest(void); #define RB_GC_MAX_NAME_LEN 20 @@ -288,6 +297,23 @@ rb_obj_atomic_write( int rb_ec_stack_check(struct rb_execution_context_struct *ec); void rb_gc_writebarrier_remember(VALUE obj); +void rb_gc_obj_became_shareable(VALUE obj); +void rb_gc_pin_in_flight_message(VALUE obj); +bool rb_gc_multi_objspace_p(void); +bool rb_gc_obj_foreign_p(VALUE obj); +void *rb_gc_objspace_alloc(void); +void rb_gc_objspace_retire_gc(void); +void rb_gc_objspace_retire(void **objspace_slot); +void rb_gc_objspace_absorb_into_current(void **objspace_slot); +void rb_gc_objspace_absorb_all_zombies(void); +void rb_gc_objspace_disown(void *objspace); +void rb_gc_zombie_objspaces_atfork(void); +void rb_gc_disable_holders_atfork(void); +void rb_gc_atfork_global_locks(void); +void rb_gc_stash_cleanup_objspace(void); +void rb_gc_finish_in_flight_gc(void); +bool rb_gc_during_global_gc_p(void); +bool rb_gc_single_objspace_p(void); const char *rb_obj_info(VALUE obj); void ruby_annotate_mmap(const void *addr, unsigned long size, const char *name); diff --git a/internal/ractor.h b/internal/ractor.h index a65907a05a7cf2..74ba88034a8b89 100644 --- a/internal/ractor.h +++ b/internal/ractor.h @@ -4,7 +4,6 @@ void rb_ractor_ensure_main_ractor(const char *msg); RUBY_SYMBOL_EXPORT_BEGIN -void rb_ractor_setup_belonging(VALUE obj); RUBY_SYMBOL_EXPORT_END #endif /* INTERNAL_RACTOR_H */ diff --git a/internal/re.h b/internal/re.h index ab9fd57889bcb0..0d4bc43ad40acb 100644 --- a/internal/re.h +++ b/internal/re.h @@ -64,6 +64,12 @@ VALUE rb_reg_check_preprocess(VALUE); long rb_reg_search0(VALUE, VALUE, long, int, int, VALUE *); VALUE rb_reg_match_p(VALUE re, VALUE str, long pos); VALUE rb_reg_regsub_match(VALUE str, VALUE src, VALUE match); +VALUE rb_match_init_copy(VALUE copy, VALUE orig); +/* MatchData transfer for the move courier (ractor.c). */ +void *rb_match_move_dump(VALUE match, VALUE *regexp_out, VALUE *str_out, int *num_regs_out); +VALUE rb_match_move_alloc(VALUE klass, int num_regs); +void rb_match_move_load(VALUE match, VALUE regexp, VALUE str, int num_regs, const void *blob); +void rb_match_move_free(void *blob); bool rb_reg_start_with_p(VALUE re, VALUE str); VALUE rb_reg_hash(VALUE re); VALUE rb_reg_equal(VALUE re1, VALUE re2); diff --git a/internal/thread.h b/internal/thread.h index 77226dafdbebda..32e8589e93513f 100644 --- a/internal/thread.h +++ b/internal/thread.h @@ -98,4 +98,8 @@ void rb_ractor_interrupt_exec(struct rb_ractor_struct *target_r, void rb_threadptr_interrupt_exec_task_mark(struct rb_thread_struct *th); +/* Mark the roots of the heap objects a thread owns, excluding ec and fiber. Used by + * thread_mark and by the Ractor's local-root marking (rb_ractor_mark_local_roots). */ +void rb_thread_mark_owned_roots(struct rb_thread_struct *th); + #endif /* INTERNAL_THREAD_H */ diff --git a/internal/variable.h b/internal/variable.h index 53d991c2be131b..47d4c86090f49b 100644 --- a/internal/variable.h +++ b/internal/variable.h @@ -55,6 +55,13 @@ attr_index_t rb_ivar_set_index(VALUE obj, ID id, VALUE val); attr_index_t rb_obj_field_set(VALUE obj, shape_id_t target_shape_id, ID field_name, VALUE val); VALUE rb_ivar_get_at(VALUE obj, attr_index_t index, ID id); VALUE rb_ivar_get_at_no_ractor_check(VALUE obj, attr_index_t index); +void rb_generic_fields_lock_atfork(void); +void rb_imemo_fields_record_shrefs(VALUE fields_obj); + +/* Call cb(tbl, arg) for the single global generic_fields table. Used by the global GC's weak + * pass and by compaction's reference update (gc.c). */ +void rb_generic_fields_tables_foreach(void (*cb)(struct st_table *tbl, void *arg), void *arg); +void rb_generic_fields_shared_table_foreach(void (*cb)(struct st_table *tbl, void *arg), void *arg); RUBY_SYMBOL_EXPORT_BEGIN /* variable.c (export) */ diff --git a/internal/vm.h b/internal/vm.h index 99e956bdc143f2..1820a4e69f2d2a 100644 --- a/internal/vm.h +++ b/internal/vm.h @@ -114,6 +114,7 @@ VALUE rb_vm_backtrace_locations(int argc, const VALUE * argv, struct rb_executio VALUE rb_make_backtrace(void); void rb_backtrace_print_as_bugreport(FILE*); int rb_backtrace_p(VALUE obj); +VALUE rb_backtrace_dup(VALUE btobj); VALUE rb_backtrace_to_str_ary(VALUE obj); VALUE rb_backtrace_to_location_ary(VALUE obj); VALUE rb_location_ary_to_backtrace(VALUE ary); diff --git a/iseq.c b/iseq.c index a51ad14439233e..f7c0602bcd233f 100644 --- a/iseq.c +++ b/iseq.c @@ -40,6 +40,7 @@ #include "iseq.h" #include "ruby/util.h" #include "vm_core.h" +#include "vm_sync.h" #include "ractor_core.h" #include "vm_callinfo.h" #include "yjit.h" @@ -420,22 +421,68 @@ rb_iseq_mark_and_move(rb_iseq_t *iseq, bool reference_updating) } } - if (reference_updating) { -#if USE_YJIT - rb_yjit_iseq_update_references(iseq); +#if USE_YJIT || USE_ZJIT + /* The JIT payload's critical section is the VM lock (racing other Ractors' + * compile/invalidate; yjit/zjit assert it). A lock-free local GC also reaches + * here, so take it without joining a barrier. mmtk marks on a GC worker with no + * EC, where the lock cannot be taken, nor needed: stop-the-world. */ + const bool jit_payload_lock_p = rb_gc_multi_objspace_p(); + bool jit_payload_p = false; +# if USE_YJIT + if (body->yjit_payload != NULL) jit_payload_p = true; +# endif +# if USE_ZJIT + if (body->zjit_payload != NULL) jit_payload_p = true; +# endif #endif -#if USE_ZJIT - rb_zjit_iseq_update_references(body->zjit_payload); + if (reference_updating) { +#if USE_YJIT || USE_ZJIT + if (jit_payload_p) { + if (jit_payload_lock_p) { + RB_VM_LOCKING_NO_BARRIER() { +# if USE_YJIT + rb_yjit_iseq_update_references(iseq); +# endif +# if USE_ZJIT + rb_zjit_iseq_update_references(body->zjit_payload); +# endif + } + } + else { +# if USE_YJIT + rb_yjit_iseq_update_references(iseq); +# endif +# if USE_ZJIT + rb_zjit_iseq_update_references(body->zjit_payload); +# endif + } + } #endif } else { // TODO: check jit payload if (!rb_gc_checking_shareable()) { -#if USE_YJIT - rb_yjit_iseq_mark(body->yjit_payload); -#endif -#if USE_ZJIT - rb_zjit_iseq_mark(body->zjit_payload); +#if USE_YJIT || USE_ZJIT + if (jit_payload_p) { + if (jit_payload_lock_p) { + RB_VM_LOCKING_NO_BARRIER() { +# if USE_YJIT + rb_yjit_iseq_mark(body->yjit_payload); +# endif +# if USE_ZJIT + rb_zjit_iseq_mark(body->zjit_payload); +# endif + } + } + else { +# if USE_YJIT + rb_yjit_iseq_mark(body->yjit_payload); +# endif +# if USE_ZJIT + rb_zjit_iseq_mark(body->zjit_payload); +# endif + } + } #endif } } @@ -4552,6 +4599,24 @@ iseqw_script_lines(VALUE self) return ISEQ_BODY(iseq)->variable.script_lines; } +/* Returns the hash of the source this iseq was compiled from, or nil if it + * is unavailable. */ +static VALUE +iseqw_source_hash(VALUE self) +{ + const rb_iseq_t *iseq = iseqw_check(self); + if (!ISEQ_BODY(iseq)->has_source_hash) return Qnil; + return ULL2NUM(ISEQ_BODY(iseq)->source_hash); +} + +/* Returns the node id of the AST node this iseq corresponds to. */ +static VALUE +iseqw_node_id(VALUE self) +{ + const rb_iseq_t *iseq = iseqw_check(self); + return INT2NUM(ISEQ_BODY(iseq)->location.node_id); +} + /* * Document-class: RubyVM::InstructionSequence * @@ -4623,6 +4688,8 @@ Init_ISeq(void) // script lines rb_define_method(rb_cISeq, "script_lines", iseqw_script_lines, 0); + rb_define_method(rb_cISeq, "source_hash", iseqw_source_hash, 0); + rb_define_method(rb_cISeq, "node_id", iseqw_node_id, 0); rb_undef_method(CLASS_OF(rb_cISeq), "translate"); rb_undef_method(CLASS_OF(rb_cISeq), "load_iseq"); diff --git a/ractor.c b/ractor.c index 15132415bf9c4b..f8fbe3e380d2e9 100644 --- a/ractor.c +++ b/ractor.c @@ -10,17 +10,26 @@ #include "ractor_core.h" #include "internal/array.h" #include "internal/complex.h" +#include "internal/cont.h" #include "internal/error.h" #include "internal/gc.h" #include "internal/hash.h" #include "internal/object.h" +#include "internal/array.h" +#include "internal/string.h" +#include "internal/variable.h" +#include "eval_intern.h" +#include "internal/io.h" #include "internal/ractor.h" #include "internal/rational.h" +#include "internal/re.h" #include "internal/struct.h" #include "internal/st.h" -#include "internal/string.h" #include "internal/thread.h" +#include "internal/vm.h" +#include "ruby/encoding.h" #include "variable.h" +#include "shape.h" #include "yjit.h" #include "zjit.h" @@ -228,47 +237,134 @@ mark_targeted_hook_list(st_data_t key, st_data_t value, st_data_t _arg) return ST_CONTINUE; } +static void +ractor_mark_unshareable_parts(rb_ractor_t *r) +{ + /* A single VALUE slot written by the owner in one word, so any GC reads it safely. + * Its target belongs to another Ractor, so containment makes a foreign marker skip + * it. */ + rb_gc_mark(r->r_stdin); + rb_gc_mark(r->r_stdout); + rb_gc_mark(r->r_stderr); + rb_gc_mark(r->verbose); + rb_gc_mark(r->debug); + + // mark the received messages (the structures the owner mutates guard themselves) + ractor_sync_mark(r); + + /* Structures the owner mutates while running follow. Only the root scan calls + * this: a local GC for itself, a global GC for the whole set under the barrier. A + * terminated Ractor has left the set; zombie_objspaces covers it instead. */ + VM_ASSERT(r == rb_current_ractor_raw(false) || rb_gc_during_global_gc_p()); + VM_ASSERT(!rb_ractor_status_p(r, ractor_terminated)); + + rb_hook_list_mark(&r->pub.hooks); + if (r->pub.targeted_hooks.num_entries) { + st_foreach(&r->pub.targeted_hooks, mark_targeted_hook_list, 0); + } + + if (r->threads.cnt > 0) { + rb_thread_t *th = 0; + ccan_list_for_each(&r->threads.set, th, lt_node) { + VM_ASSERT(th != NULL); + rb_gc_mark(th->self); + /* Mark the EC directly: the stack must stay alive even in windows where + * the Thread wrapper's own mark has not been traversed yet (mid-creation, + * teardown). */ + if (th->ec) rb_execution_context_mark(th->ec); + + /* A thread's ec lives inside the root fiber struct and is freed with that + * fiber's wrapper object, so keep the fiber wrappers alive from here too. */ + if (th->root_fiber) { + VALUE root_fiber_self = rb_fiberptr_self(th->root_fiber); + if (root_fiber_self) rb_gc_mark(root_fiber_self); + } + if (th->ec && th->ec->fiber_ptr) { + VALUE fiber_self = rb_fiberptr_self(th->ec->fiber_ptr); + if (fiber_self) rb_gc_mark(fiber_self); + } + + /* Root the thread's remaining possessions directly as well; thgroup in + * particular has no other root. */ + rb_thread_mark_owned_roots(th); + } + } + + ractor_local_storage_mark(r); +} + static void ractor_mark(void *ptr) { rb_ractor_t *r = (rb_ractor_t *)ptr; - bool checking_shareable = rb_gc_checking_shareable(); + /* Only the wrapper's direct references: following an unshareable object from the + * shareable wrapper would break the shref rule. Unshareable roots are marked by the + * root scan (rb_ractor_mark_local_roots); zombie_objspaces covers the terminated. */ rb_gc_mark(r->loc); rb_gc_mark(r->name); + /* The default port is shareable, so following it breaks no rule. Other Ractors + * still send/value through it after termination, and once a terminated Ractor left + * both the set and zombie_objspaces (orphan-merged) this marker is its only cover. */ + rb_gc_mark(r->sync.default_port_value); + /* A single-objspace impl (mmtk) has no zombie_objspaces and no pin/shref bits, so + * the root scan cannot reach a terminated Ractor's legacy value, queue or in-flight + * payloads; and no shref rule forbids following them from the wrapper. */ + if (!rb_gc_multi_objspace_p()) { + ractor_mark_unshareable_parts(r); + rb_ractor_mark_in_flight_for_single_objspace(r); + } +} + +/* Mark the GC roots reachable from Ractor r's C structs. A local GC cannot rely on the + * heap Ractor and Thread wrapper objects, which may live in another objspace, so this + * Ractor's own possessions are rooted directly from here. */ +void +rb_ractor_mark_local_roots(rb_ractor_t *r) +{ + rb_gc_mark(r->loc); + rb_gc_mark(r->name); + ractor_mark_unshareable_parts(r); - if (!checking_shareable) { - // may unshareable objects - - /* objects this Ractor pinned via rb_gc_register_mark_object (the - * pin_array_list wrapper itself is an unshareable internal object; - * updated in ractor_update_references) */ - if (r->mark_object_ary) rb_gc_mark_movable(r->mark_object_ary); - - rb_gc_mark(r->r_stdin); - rb_gc_mark(r->r_stdout); - rb_gc_mark(r->r_stderr); - rb_gc_mark(r->verbose); - rb_gc_mark(r->debug); - - // mark received messages - ractor_sync_mark(r); + /* This Ractor's rb_gc_register_mark_object pins, treated conservatively: a local GC + * marks only its own residents and leaves foreign or shareable entries to their + * owner or to the global GC. */ + rb_gc_mark_vm_stack_values((long)r->registered_marks_cnt, r->registered_marks); - rb_hook_list_mark(&r->pub.hooks); - if (r->pub.targeted_hooks.num_entries) { - st_foreach(&r->pub.targeted_hooks, mark_targeted_hook_list, 0); - } +} - if (r->threads.cnt > 0) { - rb_thread_t *th = 0; - ccan_list_for_each(&r->threads.set, th, lt_node) { - VM_ASSERT(th != NULL); - rb_gc_mark(th->self); - } - } +/* Mark and pin a terminated, unfreed Ractor's return value (legacy); the global GC + * calls this via zombie_objspaces. Pinned because compaction does not update C-struct + * slots. The default port is covered by the mutual wrapper/port marking instead. */ +void +rb_ractor_mark_terminated_join_value(rb_ractor_t *r) +{ + VALUE slots[] = { + r->sync.legacy, + }; + rb_gc_mark_vm_stack_values((long)numberof(slots), slots); +} - ractor_local_storage_mark(r); +/* Move src's rb_gc_register_mark_object pins to dst. Called before merging src's + * objspace into dst, so a pinned object never loses its root in between. An absorb can + * run during a GC sweep, so plain realloc keeps it from re-entering GC. */ +void +rb_ractor_absorb_registered_marks(rb_ractor_t *dst, rb_ractor_t *src) +{ + if (src->registered_marks_cnt == 0) return; + size_t need = dst->registered_marks_cnt + src->registered_marks_cnt; + if (need > dst->registered_marks_capa) { + size_t nc = dst->registered_marks_capa ? dst->registered_marks_capa : 64; + while (nc < need) nc *= 2; + VALUE *p = realloc(dst->registered_marks, nc * sizeof(VALUE)); + if (!p) rb_bug("rb_ractor_absorb_registered_marks: out of memory"); + dst->registered_marks = p; + dst->registered_marks_capa = nc; } + MEMCPY(dst->registered_marks + dst->registered_marks_cnt, + src->registered_marks, VALUE, src->registered_marks_cnt); + dst->registered_marks_cnt = need; + src->registered_marks_cnt = 0; } static int @@ -290,6 +386,7 @@ ractor_free(void *ptr) { rb_ractor_t *r = (rb_ractor_t *)ptr; RUBY_DEBUG_LOG("free r:%d", rb_ractor_id(r)); + free_targeted_hooks(&r->pub.targeted_hooks); rb_native_mutex_destroy(&r->sync.lock); #ifdef RUBY_THREAD_WIN32_H @@ -306,7 +403,38 @@ ractor_free(void *ptr) r->newobj_cache = NULL; } + /* Died unjoined and the handle is collected: nobody can inherit it. We are in a + * sweep under the global GC barrier, so disown the zombie_objspaces entry and post + * the merge to main. main itself only gets here in the free-at-exit walk: leave it + * and its objspace to VM destruct. */ + if (r->objspace && !r->main_ractor) { + rb_gc_objspace_disown(r->objspace); + r->objspace = NULL; + } + ractor_sync_free(r); + + if (r->in_terminated_set) { + rb_native_mutex_lock(&GET_VM()->gc.registered_globals.lock); + ccan_list_del(&r->vmlr_node); + r->in_terminated_set = false; + rb_native_mutex_unlock(&GET_VM()->gc.registered_globals.lock); + } + + /* An orphan (unjoined) Ractor hands its rb_gc_register_mark_object pins to main + * before its objspace is absorbed; the join path does the same for the joiner. + * Both happen before the objspace merge, so no window has unmoved registrations. */ + if (!r->main_ractor) { + rb_ractor_absorb_registered_marks(GET_VM()->ractor.main_ractor, r); + } + free(r->registered_marks); + r->registered_marks = NULL; + r->registered_marks_cnt = r->registered_marks_capa = 0; + + free(r->pin_capture); + r->pin_capture = NULL; + r->pin_capture_cnt = r->pin_capture_capa = 0; + if (!r->main_ractor) { SIZED_FREE(r); } @@ -324,11 +452,8 @@ ractor_memsize(const void *ptr) static void ractor_update_references(void *ptr) { - rb_ractor_t *r = (rb_ractor_t *)ptr; - /* the registered mark objects list is marked movable in ractor_mark */ - if (r->mark_object_ary) { - r->mark_object_ary = rb_gc_location(r->mark_object_ary); - } + /* registered_marks are pinned (marked by rb_gc_mark_vm_stack_values), so + * compaction does not need to update them. */ } static const rb_data_type_t ractor_data_type = { @@ -364,19 +489,6 @@ RACTOR_PTR(VALUE self) #define MAIN_RACTOR_ID 1 static rb_atomic_t ractor_last_id = MAIN_RACTOR_ID; -#if RACTOR_CHECK_MODE > 0 -uint32_t -rb_ractor_current_id(void) -{ - if (GET_THREAD()->ractor == NULL) { - return 1; // main ractor - } - else { - return rb_ractor_id(GET_RACTOR()); - } -} -#endif - #include "ractor_sync.c" // creation/termination @@ -397,6 +509,12 @@ vm_insert_ractor0(rb_vm_t *vm, rb_ractor_t *r, bool single_ractor_mode) RUBY_DEBUG_LOG("r:%u ractor.cnt:%u++", r->pub.id, vm->ractor.cnt); VM_ASSERT(single_ractor_mode || RB_VM_LOCKED_P()); + /* Just before the process goes multi-objspace. Incremental marking only runs in a + * single-objspace world, so finish any cycle in progress before the count changes. */ + if (vm->ractor.cnt == 1) { + rb_gc_finish_in_flight_gc(); + } + ccan_list_add_tail(&vm->ractor.set, &r->vmlr_node); vm->ractor.cnt++; @@ -431,6 +549,13 @@ vm_insert_ractor(rb_vm_t *vm, rb_ractor_t *r) { vm_insert_ractor0(vm, r, false); vm_ractor_blocking_cnt_inc(vm, r, __FILE__, __LINE__); + /* The child is in the set and enumerated on its own now, so drop the cover + * through its creator and avoid enumerating it twice. Cleared under the + * same VM lock that added it, so no whole-VM walk sees both. */ + rb_ractor_t *cur = rb_current_ractor_raw(false); + if (cur && cur->creating_child_objspace == r->objspace) { + cur->creating_child_objspace = NULL; + } } RB_VM_UNLOCK(); } @@ -445,6 +570,13 @@ vm_insert_ractor(rb_vm_t *vm, rb_ractor_t *r) cancel_single_ractor_mode(); vm_insert_ractor0(vm, r, true); vm_ractor_blocking_cnt_inc(vm, r, __FILE__, __LINE__); + /* As in the multi-Ractor branch: the child joined the set, so drop the + * creator's cover, or a global GC enumerates the child's objspace twice and + * sweeps its live main Thread and root Fiber. */ + rb_ractor_t *cur = rb_current_ractor_raw(false); + if (cur && cur->creating_child_objspace == r->objspace) { + cur->creating_child_objspace = NULL; + } } } } @@ -458,23 +590,39 @@ vm_remove_ractor(rb_vm_t *vm, rb_ractor_t *cr) RB_VM_LOCK(); { - /* keep this Ractor's registered mark objects alive under the main Ractor */ - if (cr->mark_object_ary) rb_vm_ractor_migrate_mark_objects(vm->ractor.main_ractor, cr); - RUBY_DEBUG_LOG("ractor.cnt:%u-- terminate_waiting:%d", vm->ractor.cnt, vm->ractor.sync.terminate_waiting); VM_ASSERT(vm->ractor.cnt > 0); ccan_list_del(&cr->vmlr_node); + /* A single-objspace impl has no zombie_objspaces, so nothing roots the + * registered_marks of a Ractor that left the set; track it in a separate list + * until ractor_free. */ + if (!rb_gc_multi_objspace_p()) { + rb_native_mutex_lock(&vm->gc.registered_globals.lock); + ccan_list_add(&vm->ractor.terminated_set, &cr->vmlr_node); + cr->in_terminated_set = true; + rb_native_mutex_unlock(&vm->gc.registered_globals.lock); + } + if (vm->ractor.cnt <= 2 && vm->ractor.sync.terminate_waiting) { rb_native_cond_signal(&vm->ractor.sync.terminate_cond); } - vm->ractor.cnt--; rb_gc_ractor_cache_free(cr->newobj_cache); cr->newobj_cache = NULL; + /* The objspace loses its owning thread: keep it enumerable until inheritance + * merges it. Register in zombie_objspaces BEFORE decrementing cnt: other + * Ractors read rb_gc_single_objspace_p lock-free, and the other order opens a + * cnt==1-no-zombie window where a GC skips shareable pinning and collects + * objects (a cc, say) reachable only through this objspace. */ + if (cr->objspace) { + rb_gc_objspace_retire(&cr->objspace); + } + vm->ractor.cnt--; + ractor_status_set(cr, ractor_terminated); } RB_VM_UNLOCK(); @@ -486,6 +634,7 @@ ractor_alloc(VALUE klass) rb_ractor_t *r; VALUE rv = TypedData_Make_Struct(klass, rb_ractor_t, &ractor_data_type, r); FL_SET_RAW(rv, RUBY_FL_SHAREABLE); + rb_gc_obj_became_shareable(rv); r->pub.self = rv; r->next_ec_serial = 1; VM_ASSERT(ractor_status_p(r, ractor_created)); @@ -505,7 +654,8 @@ rb_ractor_t * rb_ractor_main_alloc(void) { rb_ractor_t *r = &_main_ractor; - r->newobj_cache = rb_gc_ractor_cache_alloc(r); + /* The main Ractor is allocated before its objspace exists, so its newobj cache is + * created later in Init_BareVM, once rb_gc_init_objspaces has set r->objspace. */ ruby_single_main_ractor = r; return r; @@ -520,6 +670,17 @@ rb_ractor_atfork(rb_vm_t *vm, rb_thread_t *th) // initialize as a main ractor vm->ractor.cnt = 0; vm->ractor.blocking_cnt = 0; + /* Another thread may have held the lock at fork, so rebuild it in the child (the + * same reason generic_fields_lock is re-initialized at fork). The registry's list + * head is left alone: the nodes of surviving couriers are still linked into it. */ + rb_native_mutex_initialize(&vm->ractor.move_courier_registry_lock); + /* Only main survives a fork: the holds of dead Ractors and of critical sections are + * gone, leaving main's own disable. */ + rb_gc_disable_holders_atfork(); + /* Only the main Ractor survives a fork, so drop the creation cover. The set was + * just emptied by rb_vm_living_threads_init, and zombie_objspaces still holds the + * non-main objspaces that terminate_atfork parked there for the orphan merge. */ + th->ractor->creating_child_objspace = NULL; ruby_single_main_ractor = th->ractor; th->ractor->status_ = ractor_created; @@ -536,6 +697,11 @@ rb_ractor_terminate_atfork(rb_vm_t *vm, rb_ractor_t *r) rb_gc_ractor_cache_free(r->newobj_cache); r->newobj_cache = NULL; r->status_ = ractor_terminated; + /* In a forked child every other Ractor is terminated-unjoined, so keep its objspace + * enumerable until a join or a global GC merges it. */ + if (r->objspace) { + rb_gc_objspace_retire(&r->objspace); + } ractor_sync_terminate_atfork(vm, r); } #endif @@ -554,6 +720,10 @@ static void ractor_init(rb_ractor_t *r, VALUE name, VALUE loc) { ractor_sync_init(r); + r->gen_fields_capturing = false; + r->pin_capture = NULL; + r->pin_capture_cnt = r->pin_capture_capa = 0; + r->sending_basket = NULL; st_init_existing_numtable_with_size(&r->pub.targeted_hooks, 0); r->pub.hooks.type = hook_list_type_ractor_local; @@ -583,9 +753,11 @@ rb_ractor_main_setup(rb_vm_t *vm, rb_ractor_t *r, rb_thread_t *th) { VALUE rv = r->pub.self = TypedData_Wrap_Struct(rb_cRactor, &ractor_data_type, r); FL_SET_RAW(r->pub.self, RUBY_FL_SHAREABLE); + rb_gc_obj_became_shareable(r->pub.self); ractor_init(r, Qnil, Qnil); r->threads.main = th; rb_ractor_living_threads_insert(r, th); + rb_ractor_setup_default_port(r); RB_GC_GUARD(rv); } @@ -604,6 +776,10 @@ ractor_create(rb_execution_context_t *ec, VALUE self, VALUE loc, VALUE name, VAL r->verbose = cr->verbose; r->debug = cr->debug; + /* Every Ractor has an objspace, and it must exist before its thread runs: the + * first allocation goes there through rb_gc_get_objspace. */ + r->objspace = rb_gc_objspace_alloc(); + rb_thread_create_ractor(r, args, block); RB_GC_GUARD(rv); @@ -780,6 +956,44 @@ ractor_check_blocking(rb_ractor_t *cr, unsigned int remained_thread_cnt, const c } +/* Remove a child that never started (send_parameters failed during creation). The + * creator calls this (rb_ractor_living_threads_remove assumes the current Ractor); + * leaving the set and disowning the objspace share one VM-lock section, no window. */ +void +rb_ractor_cancel_creation(rb_ractor_t *r, rb_thread_t *th) +{ + RACTOR_LOCK(r); + { + ccan_list_del(&th->lt_node); + r->threads.cnt--; + } + RACTOR_UNLOCK(r); + + RB_VM_LOCK(); + { + rb_vm_t *vm = th->vm; + VM_ASSERT(vm->ractor.cnt > 1); + ccan_list_del(&r->vmlr_node); + vm->ractor.cnt--; + /* Give back the blocking count vm_insert_ractor took at insert time. A child + * that never ran has no chance to decrement it, and without this the + * blocking_cnt <= cnt invariant breaks on the next insert. */ + VM_ASSERT(r->status_ == ractor_blocking); + VM_ASSERT(vm->ractor.blocking_cnt > 0); + vm->ractor.blocking_cnt--; + + rb_gc_ractor_cache_free(r->newobj_cache); + r->newobj_cache = NULL; + + if (r->objspace) { + rb_gc_objspace_disown(r->objspace); + r->objspace = NULL; + } + r->status_ = ractor_terminated; + } + RB_VM_UNLOCK(); +} + void rb_ractor_living_threads_remove(rb_ractor_t *cr, rb_thread_t *th) { @@ -936,6 +1150,10 @@ rb_ractor_terminate_all(void) } } RB_VM_UNLOCK(); + + /* Every other Ractor is dead. main inherits all uninherited objspaces, so the + * remaining at-exit work (finalizers, IO flush, free-at-exit) sees every object. */ + rb_gc_objspace_absorb_all_zombies(); } rb_execution_context_t * @@ -1188,13 +1406,29 @@ rb_ractor_targeted_hooks(rb_ractor_t *cr) static void rb_obj_set_shareable_no_assert(VALUE obj) { - FL_SET_RAW(obj, FL_SHAREABLE); + /* make_shareable_check_shareable refuses an IO, because the traversal cannot reach + * the VALUE members inside its fptr. */ + VM_ASSERT(!RB_TYPE_P(obj, T_FILE)); - if (rb_obj_gen_fields_p(obj)) { + FL_SET_RAW(obj, FL_SHAREABLE); + rb_gc_obj_became_shareable(obj); + + /* A T_OBJECT can have a fields imemo too (too_complex and friends), and an imemo + * born while its owner was unshareable stays unshareable + * (imemo_fields_complex_from_obj), so align it here. */ + if (rb_obj_gen_fields_p(obj) || BUILTIN_TYPE(obj) == T_OBJECT) { + /* obj is shareable already, so rb_obj_fields_no_ractor_check finds the right + * table. Make the fields imemo itself shareable and record shrefs for the + * hidden field values the traversal never reaches. */ VALUE fields = rb_obj_fields_no_ractor_check(obj); if (imemo_type_p(fields, imemo_fields)) { // no recursive mark FL_SET_RAW(fields, FL_SHAREABLE); + rb_gc_obj_became_shareable(fields); + // Field values the traversal never reaches (hidden internal ivars, say) + // can stay unshareable, so record their shrefs to keep the shareable + // fields imemo's edges correct. + rb_imemo_fields_record_shrefs(fields); } } } @@ -1355,7 +1589,6 @@ obj_traverse_i(VALUE obj, struct obj_traverse_data *data) case T_FLOAT: case T_BIGNUM: case T_REGEXP: - case T_FILE: case T_SYMBOL: break; @@ -1627,6 +1860,8 @@ rb_ractor_make_shareable(VALUE obj) return obj; } +static VALUE ractor_copy(VALUE obj); // defined below + VALUE rb_ractor_make_shareable_copy(VALUE obj) { @@ -1698,41 +1933,12 @@ rb_ractor_shareable_p_continue(VALUE obj) } } -#if RACTOR_CHECK_MODE > 0 -void -rb_ractor_setup_belonging(VALUE obj) -{ - rb_ractor_setup_belonging_to(obj, rb_ractor_current_id()); -} - -static enum obj_traverse_iterator_result -reset_belonging_enter(VALUE obj) -{ - if (rb_ractor_shareable_p(obj)) { - return traverse_skip; - } - else { - rb_ractor_setup_belonging(obj); - return traverse_cont; - } -} -#endif - static enum obj_traverse_iterator_result null_leave(VALUE obj) { return traverse_cont; } -static VALUE -ractor_reset_belonging(VALUE obj) -{ -#if RACTOR_CHECK_MODE > 0 - rb_obj_traverse(obj, reset_belonging_enter, null_leave, NULL); -#endif - return obj; -} - /// traverse and replace function @@ -1749,8 +1955,11 @@ struct obj_traverse_replace_data { rb_obj_traverse_replace_enter_func enter_func; rb_obj_traverse_replace_leave_func leave_func; + /* old -> new map, a plain st_table: an OLD key may live in another Ractor's + * objspace and must not become a GC edge here (marking a freed foreign key is a + * UAF). Keys compare by address; replacements stay alive via rec_keepalive. */ st_table *rec; - VALUE rec_hash; + VALUE rec_keepalive; VALUE replacement; bool move; @@ -1823,8 +2032,8 @@ static struct st_table * obj_traverse_replace_rec(struct obj_traverse_replace_data *data) { if (UNLIKELY(!data->rec)) { - data->rec_hash = rb_ident_hash_new(); - data->rec = RHASH_ST_TABLE(data->rec_hash); + data->rec = st_init_numtable(); + data->rec_keepalive = rb_ary_hidden_new(0); } return data->rec; } @@ -1859,7 +2068,10 @@ obj_traverse_replace_i(VALUE obj, struct obj_traverse_replace_data *data) return 0; } - if (UNLIKELY(data->rec && st_lookup(data->rec, (st_data_t)obj, &replacement))) { + /* Dedup before enter_func, so a revisited shared/cyclic node reuses its recorded + * replacement; otherwise the copy path would build a wasteful temporary holding a + * containment-breaking cross-objspace edge. */ + if (UNLIKELY(st_lookup(obj_traverse_replace_rec(data), (st_data_t)obj, &replacement))) { data->replacement = (VALUE)replacement; return 0; } @@ -1872,8 +2084,9 @@ obj_traverse_replace_i(VALUE obj, struct obj_traverse_replace_data *data) replacement = (st_data_t)data->replacement; st_insert(obj_traverse_replace_rec(data), (st_data_t)obj, replacement); - RB_OBJ_WRITTEN(data->rec_hash, Qundef, obj); - RB_OBJ_WRITTEN(data->rec_hash, Qundef, replacement); + if (!RB_SPECIAL_CONST_P((VALUE)replacement)) { + rb_ary_push(data->rec_keepalive, (VALUE)replacement); + } if (!data->move) { obj = replacement; @@ -2061,11 +2274,19 @@ rb_obj_traverse_replace(VALUE obj, .enter_func = enter_func, .leave_func = leave_func, .rec = NULL, + .rec_keepalive = Qfalse, .replacement = Qundef, .move = move, }; - if (obj_traverse_replace_i(obj, &data)) { + int stopped = obj_traverse_replace_i(obj, &data); + + /* The enter and leave functions report failure with traverse_stop rather than by + * raising, so this is the only place the table is freed. */ + if (data.rec) st_free_table(data.rec); + RB_GC_GUARD(data.rec_keepalive); + + if (stopped) { return Qundef; } else { @@ -2073,63 +2294,139 @@ rb_obj_traverse_replace(VALUE obj, } } -static const bool wb_protected_types[RUBY_T_MASK] = { - [T_OBJECT] = true, - [T_HASH] = true, - [T_ARRAY] = true, - [T_STRING] = true, - [T_STRUCT] = true, - [T_COMPLEX] = true, - [T_REGEXP] = true, - [T_MATCH] = true, - [T_FLOAT] = true, - [T_RATIONAL] = true, +/* Move courier: serializes the payload of Ractor#send(move: true) into an xmalloc'd + * structure that belongs to no objspace, so no sender GC can mark, sweep, compact or + * race with it. A node array with id references handles sharing and cycles, and the + * receiver rebuilds it in its own objspace in two passes. */ + +enum move_node_kind { + MOVE_KIND_REF, /* an immediate or a shareable object: carried by value */ + MOVE_KIND_STRING, + MOVE_KIND_ARRAY, + MOVE_KIND_HASH, + MOVE_KIND_OBJECT, + MOVE_KIND_STRUCT, + MOVE_KIND_MATCH, + MOVE_KIND_IO, }; -static enum obj_traverse_iterator_result -move_enter(VALUE obj, struct obj_traverse_replace_data *data) +struct move_node { + enum move_node_kind kind; + bool frozen; + /* The instance and generic ivars every non-REF node can have (a String or Array + * can hold generic ivars too) */ + uint32_t niv; + ID *iv_ids; /* owned by the courier */ + uint32_t *iv_vals; /* owned by the courier; node ids */ + union { + VALUE ref; + struct { char *ptr; long len; int encidx; VALUE klass; } str; /* the courier owns ptr */ + struct { long len; uint32_t *elems; VALUE klass; } ary; /* the courier owns elems */ + struct { long size; uint32_t *kv; uint32_t ifnone_id; bool compare_by_id; bool proc_default; VALUE klass; } hash; /* owns kv (2*size) */ + struct { VALUE klass; } obj; + struct { long len; uint32_t *elems; VALUE klass; } strct; /* owns elems */ + struct { uint32_t regexp_id, str_id; int num_regs; void *regs; VALUE klass; } match; /* owns regs */ + struct { + struct rb_io *fptr; /* carried by pointer (it owns the fd) */ + VALUE klass; + /* The sender-side VALUE members of fptr travel as ordinary child nodes: + * capture detaches them from fptr (see the T_FILE case) and rebuild writes + * them back into the receiving shell with RB_OBJ_WRITE. */ + uint32_t pathv_id, ecopts_id, wc_pre_ecopts_id, wc_asciicompat_id, timeout_id; + } io; + } u; +}; + +struct rb_ractor_move_courier { + struct move_node *nodes; + uint32_t count; + uint32_t capa; + uint32_t root; + struct ccan_list_node reg_node; /* in-flight courier registry (a GC root while it lives) */ +}; + +/* VM-global list of move couriers in flight (vm->ractor.move_courier_registry). A + * courier is off-heap and carries shareable REFs as raw pointers; in some windows only + * a transient (a stack-local message queue, say) reaches it, so a global GC could + * collect the REFs. Registered from build to free, marked and pinned by the global + * GC's root pass (only a global GC frees shareable objects, so only it needs this). + * add/remove run concurrently and take the lock; stop-the-world marking does not, which + * is sound only because add/remove contain no safepoint (none may be added: a mark + * could then see a half-linked list across the barrier). */ + +static void +move_courier_registry_add(struct rb_ractor_move_courier *c) { - if (rb_ractor_shareable_p(obj)) { - data->replacement = obj; - return traverse_skip; - } - else { - VALUE type = RB_BUILTIN_TYPE(obj); - size_t slot_size = rb_obj_shape_slot_size(obj); - VALUE moved = rb_newobj(GET_EC(), 0, type, RBASIC_SHAPE_ID(obj), wb_protected_types[type], slot_size); - MEMZERO(((struct RBasic *)moved) + 1, char, slot_size - sizeof(struct RBasic)); - data->replacement = (VALUE)moved; - return traverse_cont; - } + rb_native_mutex_lock(&GET_VM()->ractor.move_courier_registry_lock); + ccan_list_add(&GET_VM()->ractor.move_courier_registry, &c->reg_node); + rb_native_mutex_unlock(&GET_VM()->ractor.move_courier_registry_lock); } -static enum obj_traverse_iterator_result -move_leave(VALUE obj, struct obj_traverse_replace_data *data) -{ - // Copy flags - VALUE ignored_flags = RUBY_FL_PROMOTED; - RBASIC(data->replacement)->flags = (RBASIC(obj)->flags & ~ignored_flags) | (RBASIC(data->replacement)->flags & ignored_flags); - // Copy contents without the flags - memcpy( - (char *)data->replacement + sizeof(VALUE), - (char *)obj + sizeof(VALUE), - rb_obj_shape_slot_size(obj) - sizeof(VALUE) - ); - - // We've copied obj's references to the replacement - rb_gc_writebarrier_remember(data->replacement); - - void rb_replace_generic_ivar(VALUE clone, VALUE obj); // variable.c - if (UNLIKELY(rb_obj_gen_fields_p(obj))) { - rb_replace_generic_ivar(data->replacement, obj); +static void +move_courier_registry_remove(struct rb_ractor_move_courier *c) +{ + rb_native_mutex_lock(&GET_VM()->ractor.move_courier_registry_lock); + ccan_list_del(&c->reg_node); + rb_native_mutex_unlock(&GET_VM()->ractor.move_courier_registry_lock); +} + +void rb_ractor_move_courier_mark(struct rb_ractor_move_courier *c); + +/* Called from the global GC's root pass; stop-the-world, so no lock. */ +void +rb_ractor_move_courier_registry_mark(void) +{ + struct rb_ractor_move_courier *c; + ccan_list_for_each(&GET_VM()->ractor.move_courier_registry, c, reg_node) { + rb_ractor_move_courier_mark(c); } +} - VALUE flags = T_OBJECT | FL_FREEZE | (RBASIC(obj)->flags & FL_PROMOTED); - shape_id_t shape_id = (RBASIC_SHAPE_ID(obj) & SHAPE_ID_CAPACITY_MASK) | ROOT_SHAPE_ID | SHAPE_ID_LAYOUT_ROBJECT | SHAPE_ID_FL_FROZEN; +struct move_build { + struct rb_ractor_move_courier *c; + st_table *seen; /* src VALUE -> (node id + 1) */ +}; + +static uint32_t move_capture(struct move_build *b, VALUE obj); + +static uint32_t +move_alloc_node(struct rb_ractor_move_courier *c) +{ + if (c->count == c->capa) { + c->capa = c->capa ? c->capa * 2 : 8; + REALLOC_N(c->nodes, struct move_node, c->capa); + } + uint32_t id = c->count++; + /* Initialize to a harmless REF/Qnil so the courier mark (a GC root while sending) + * is safe even mid-construction; a captured node overwrites it later. */ + c->nodes[id].kind = MOVE_KIND_REF; + c->nodes[id].frozen = false; + c->nodes[id].niv = 0; + c->nodes[id].iv_ids = NULL; + c->nodes[id].iv_vals = NULL; + c->nodes[id].u.ref = Qnil; + return id; +} - // A copy-on-write sharer reads its payload straight out of an embedded root's slot - // (String#dup of a frozen string, Array#[] of a frozen array), and it outlives the - // move, so that body has to survive as it is. +/* Turn a moved source into a valid RactorMovedObject without passing through flags==0, + * so a concurrent foreign marker always sees either the original object or the shell. */ +static void +move_neutralize_source(VALUE obj) +{ + /* The shell stays in the original slot: keep the capacity bits, give it a frozen + * field-less ROBJECT shape (read before the flags are overwritten). The old body is + * then never read as ivars and compaction's slot-size check still holds. */ + shape_id_t shape_id = (RBASIC_SHAPE_ID(obj) & SHAPE_ID_CAPACITY_MASK) | + ROOT_SHAPE_ID | SHAPE_ID_LAYOUT_ROBJECT | SHAPE_ID_FL_FROZEN; + + /* A non-T_OBJECT host (a String with ivars, say) must drop its generic_fields + * entry: obj stops being a host below and its fields_obj is collected, so a stale + * entry would let the global GC walk a freed value. */ + rb_free_generic_ivar(obj); + + /* A copy-on-write sharer reads its payload straight out of an embedded root's slot + * (String#dup of a frozen string, Array#[] of a frozen array), and it outlives the + * move, so that body has to survive as it is. */ bool wipe_body = true; switch (BUILTIN_TYPE(obj)) { case T_STRING: @@ -2142,62 +2439,685 @@ move_leave(VALUE obj, struct obj_traverse_replace_data *data) break; } - // Avoid mutations using bind_call, etc. + VALUE flags = T_OBJECT | FL_FREEZE | (RBASIC(obj)->flags & FL_PROMOTED); + /* Read the slot size before the header is rewritten. */ size_t slot_size = rb_gc_obj_slot_size(obj); - MEMZERO((char *)obj, char, sizeof(struct RBasic)); - RBASIC(obj)->flags = flags; RBASIC_SET_CLASS_RAW(obj, rb_cRactorMovedObject); + RBASIC(obj)->flags = flags; + RBASIC_SET_FULL_SHAPE_ID(obj, shape_id); - // Wipe the old body too. The husk has no fields, so nothing reads it as ivars, - // but C code that held the object from before the move still reads it with its - // old type (an Array iteration in progress, the RMatch capa behind $~): a zeroed - // body makes those reads see an empty object instead of stale internals. + /* Wipe the old body. The shell has no fields, so nothing reads it as ivars, but + * C code holding the object from before the move still reads it with its old type + * (a running Array iteration, the RMatch capa of a $~ entry): a zeroed body makes + * those reads see an empty object instead of stale internals. */ if (wipe_body) { MEMZERO((char *)obj + sizeof(struct RBasic), char, slot_size - sizeof(struct RBasic)); } +} - // The husk keeps its original (larger) slot, so give it a field-less shape - // sized to that slot; otherwise compaction's slot_size == shape_slot_size - // invariant is violated. - RBASIC_SET_FULL_SHAPE_ID(obj, shape_id); - return traverse_cont; +struct move_hash_ctx { + struct move_build *b; + uint32_t *kv; + long i; +}; + +static int +move_capture_hash_i(st_data_t key, st_data_t val, st_data_t arg) +{ + struct move_hash_ctx *hc = (struct move_hash_ctx *)arg; + uint32_t kid = move_capture(hc->b, (VALUE)key); + uint32_t vid = move_capture(hc->b, (VALUE)val); + hc->kv[hc->i++] = kid; + hc->kv[hc->i++] = vid; + return ST_CONTINUE; } -static VALUE -ractor_move(VALUE obj) +struct move_obj_ctx { + struct move_build *b; + ID *ids; + uint32_t *vals; + long n; + long capa; +}; + +static int +move_capture_ivar_i(ID name, VALUE val, st_data_t arg) +{ + struct move_obj_ctx *oc = (struct move_obj_ctx *)arg; + if (oc->n == oc->capa) { + oc->capa = oc->capa ? oc->capa * 2 : 4; + REALLOC_N(oc->ids, ID, oc->capa); + REALLOC_N(oc->vals, uint32_t, oc->capa); + } + uint32_t vid = move_capture(oc->b, val); + oc->ids[oc->n] = name; + oc->vals[oc->n] = vid; + oc->n++; + return ST_CONTINUE; +} + +/* Capture obj's instance and generic ivars as node ids, recursing into the values. + * Handles both a T_OBJECT's inline ivars and the generic ivars of a String, Array and + * so on. */ +static void +move_capture_ivars(struct move_build *b, VALUE obj, uint32_t id) { - VALUE val = rb_obj_traverse_replace(obj, move_enter, move_leave, true); - if (!UNDEF_P(val)) { - return val; + struct move_obj_ctx oc = { b, NULL, NULL, 0, 0 }; + rb_ivar_foreach_buffered(obj, move_capture_ivar_i, (st_data_t)&oc); + b->c->nodes[id].niv = (uint32_t)oc.n; + b->c->nodes[id].iv_ids = oc.ids; + b->c->nodes[id].iv_vals = oc.vals; +} + +/* Capture obj into the courier, recurse into its children, return its node id. The id + * is registered before recursing (a cycle back resolves to the same node); node fields + * are written after (recursion can realloc c->nodes); the source is neutralized exactly + * once after the switch. */ +static uint32_t +move_capture(struct move_build *b, VALUE obj) +{ + st_data_t existing; + if (st_lookup(b->seen, (st_data_t)obj, &existing)) { + return (uint32_t)existing - 1; } - else { - rb_raise(rb_eRactorError, "can not move the object"); + + uint32_t id = move_alloc_node(b->c); + st_insert(b->seen, (st_data_t)obj, (st_data_t)(uintptr_t)(id + 1)); + + if (RB_SPECIAL_CONST_P(obj) || rb_ractor_shareable_p(obj)) { + b->c->nodes[id].kind = MOVE_KIND_REF; + b->c->nodes[id].frozen = false; + b->c->nodes[id].niv = 0; + b->c->nodes[id].iv_ids = NULL; + b->c->nodes[id].iv_vals = NULL; + b->c->nodes[id].u.ref = obj; + return id; + } + + /* Reject an unmovable object before anything is mutated. */ + if (BUILTIN_TYPE(obj) == T_FILE && RFILE(obj)->fptr == NULL) { + rb_raise(rb_eRactorError, "can not move an uninitialized IO"); + } + + bool frozen = OBJ_FROZEN(obj); + b->c->nodes[id].frozen = frozen; + move_capture_ivars(b, obj, id); /* shared: instance and generic ivars */ + + switch (BUILTIN_TYPE(obj)) { + case T_STRING: { + /* Give the source its own buffer (drop sharing, copy a static STR_NOFREE one). + * Safe even when frozen: it changes ownership, not content. Afterwards a string + * is embedded, owns a private heap buffer, or is a shared ROOT (a no-op). */ + rb_str_make_independent(obj); + long len = RSTRING_LEN(obj); + int encidx = ENCODING_GET(obj); + char *ptr; + if (!STR_EMBED_P(obj) && rb_str_reembeddable_p(obj)) { + /* Owns a private heap buffer: carry the pointer over (zero-copy) and leave + * the source as a shell that does not free it. */ + ptr = RSTRING(obj)->as.heap.ptr; + } + else { + /* Embedded or a shared root: copy the bytes into a courier-owned buffer. + * Taking a root's buffer would dangle its copy-on-write children, so leave + * it (the same reason T_ARRAY excludes ARY_SHARED_ROOT_P below). */ + ptr = ALLOC_N(char, len + 1); + if (len) memcpy(ptr, RSTRING_PTR(obj), len); + ptr[len] = '\0'; + } + b->c->nodes[id].kind = MOVE_KIND_STRING; + b->c->nodes[id].u.str.klass = RBASIC_CLASS(obj); + b->c->nodes[id].u.str.ptr = ptr; + b->c->nodes[id].u.str.len = len; + b->c->nodes[id].u.str.encidx = encidx; + break; + } + + case T_ARRAY: { + long len = RARRAY_LEN(obj); + uint32_t *elems = len ? ALLOC_N(uint32_t, len) : NULL; + for (long i = 0; i < len; i++) { + elems[i] = move_capture(b, RARRAY_AREF(obj, i)); + } + b->c->nodes[id].kind = MOVE_KIND_ARRAY; + b->c->nodes[id].u.ary.klass = RBASIC_CLASS(obj); + b->c->nodes[id].u.ary.len = len; + b->c->nodes[id].u.ary.elems = elems; + /* Free the source's heap buffer now that the children were read, but only when it + * is private: a sharer's belongs to its root, a root's to its sharers -- and a + * frozen array is a root without carrying the flag. */ + if (!ARY_EMBED_P(obj) && !ARY_SHARED_P(obj) && !ARY_SHARED_ROOT_P(obj) && !OBJ_FROZEN(obj)) { + ruby_xfree((void *)RARRAY_CONST_PTR(obj)); + } + break; + } + + case T_HASH: { + uint32_t ifnone_id = move_capture(b, RHASH_IFNONE(obj)); + long size = RHASH_SIZE(obj); + uint32_t *kv = size ? ALLOC_N(uint32_t, size * 2) : NULL; + struct move_hash_ctx hc = { b, kv, 0 }; + rb_hash_stlike_foreach(obj, move_capture_hash_i, (st_data_t)&hc); + b->c->nodes[id].kind = MOVE_KIND_HASH; + b->c->nodes[id].u.hash.klass = RBASIC_CLASS(obj); + b->c->nodes[id].u.hash.size = size; + b->c->nodes[id].u.hash.kv = kv; + b->c->nodes[id].u.hash.ifnone_id = ifnone_id; + b->c->nodes[id].u.hash.compare_by_id = RTEST(rb_hash_compare_by_id_p(obj)); + b->c->nodes[id].u.hash.proc_default = FL_TEST_RAW(obj, RHASH_PROC_DEFAULT) != 0; + /* Free the source's st-table internals (an ar table lives in the slot) */ + rb_hash_free(obj); + break; + } + + case T_OBJECT: + b->c->nodes[id].kind = MOVE_KIND_OBJECT; + /* Keep the real class: even a singleton class is shareable, so a cross-objspace + * reference is safe. rebuild re-attaches it after allocating with a + * non-singleton class. */ + b->c->nodes[id].u.obj.klass = RBASIC_CLASS(obj); + break; + + case T_STRUCT: { + long len = RSTRUCT_LEN(obj); + uint32_t *elems = len ? ALLOC_N(uint32_t, len) : NULL; + for (long i = 0; i < len; i++) { + elems[i] = move_capture(b, RSTRUCT_GET(obj, (int)i)); + } + b->c->nodes[id].kind = MOVE_KIND_STRUCT; + b->c->nodes[id].u.strct.len = len; + b->c->nodes[id].u.strct.elems = elems; + b->c->nodes[id].u.strct.klass = RBASIC_CLASS(obj); + /* Free the source's private heap buffer (an embedded struct has none) */ + if (RSTRUCT_EMBED_LEN(obj) == 0) { + ruby_xfree((void *)RSTRUCT_CONST_PTR(obj)); + } + break; + } + + case T_MATCH: { + /* The regexp and the matched string travel as ordinary children; re.c dumps the + * registers (freeing the source's onig and char_offset). */ + VALUE re, st; + int nregs; + void *regs = rb_match_move_dump(obj, &re, &st, &nregs); + uint32_t rid = move_capture(b, re); + uint32_t sid = move_capture(b, st); + b->c->nodes[id].kind = MOVE_KIND_MATCH; + b->c->nodes[id].u.match.regexp_id = rid; + b->c->nodes[id].u.match.str_id = sid; + b->c->nodes[id].u.match.num_regs = nregs; + b->c->nodes[id].u.match.regs = regs; + b->c->nodes[id].u.match.klass = RBASIC_CLASS(obj); + break; + } + + case T_FILE: + { + /* Carry the whole fptr (fd included) by pointer; the source shell does not + * close it. fptr's VALUE members lose their root once the source is T_MOVED, + * so capture them as ordinary child nodes, detached; rebuild writes them back. */ + struct rb_io *fptr = RFILE(obj)->fptr; + VM_ASSERT(!RTEST(fptr->tied_io_for_writing) && !RTEST(fptr->wakeup_mutex)); + uint32_t pathv_id = move_capture(b, fptr->pathv); + uint32_t ecopts_id = move_capture(b, fptr->encs.ecopts); + uint32_t wc_pre_id = move_capture(b, fptr->writeconv_pre_ecopts); + uint32_t wc_ac_id = move_capture(b, fptr->writeconv_asciicompat); + uint32_t timeout_id = move_capture(b, fptr->timeout); + fptr->self = Qnil; /* it points at the moved-from T_MOVED; attach rebuilds it */ + fptr->pathv = Qnil; + fptr->encs.ecopts = Qnil; + fptr->writeconv_pre_ecopts = Qnil; + fptr->writeconv_asciicompat = Qnil; + fptr->timeout = Qnil; + fptr->write_lock = Qnil; + fptr->wakeup_mutex = Qnil; + fptr->tied_io_for_writing = 0; /* io.c tests it as a C boolean, so 0 rather than Qnil */ + b->c->nodes[id].kind = MOVE_KIND_IO; + b->c->nodes[id].u.io.fptr = fptr; + b->c->nodes[id].u.io.klass = RBASIC_CLASS(obj); + b->c->nodes[id].u.io.pathv_id = pathv_id; + b->c->nodes[id].u.io.ecopts_id = ecopts_id; + b->c->nodes[id].u.io.wc_pre_ecopts_id = wc_pre_id; + b->c->nodes[id].u.io.wc_asciicompat_id = wc_ac_id; + b->c->nodes[id].u.io.timeout_id = timeout_id; + break; + } + + default: + rb_raise(rb_eRactorError, "can not move a %"PRIsVALUE" object", + rb_class_name(rb_obj_class(obj))); } + + move_neutralize_source(obj); + return id; } -static VALUE -ractor_call_clone_try(VALUE obj) +static void move_preflight(VALUE obj, st_table *seen); + +static int +move_preflight_ivar_i(ID name, VALUE val, st_data_t arg) { - return rb_funcall(obj, idClone, 0); + move_preflight(val, (st_table *)arg); + return ST_CONTINUE; } -static VALUE -ractor_call_clone_rescue(VALUE obj, VALUE exc) +static int +move_preflight_hash_i(st_data_t key, st_data_t val, st_data_t arg) +{ + move_preflight((VALUE)key, (st_table *)arg); + move_preflight((VALUE)val, (st_table *)arg); + return ST_CONTINUE; +} + +/* A read-only pre-walk of move_capture's decision tree. Capture turns sources into + * T_MOVED as it goes, so an unmovable object midway would leave the graph broken beyond + * repair; every "can not move" error is raised here, before anything is mutated. */ +static void +move_preflight(VALUE obj, st_table *seen) +{ + if (RB_SPECIAL_CONST_P(obj) || rb_ractor_shareable_p(obj)) return; + if (st_lookup(seen, (st_data_t)obj, NULL)) return; /* cycle */ + st_insert(seen, (st_data_t)obj, 0); + + switch (BUILTIN_TYPE(obj)) { + case T_STRING: + case T_OBJECT: + break; /* children are ivars only (below) */ + case T_MATCH: + break; /* child = Regexp (shareable) + String */ + case T_ARRAY: + for (long i = 0; i < RARRAY_LEN(obj); i++) { + move_preflight(RARRAY_AREF(obj, i), seen); + } + break; + case T_HASH: + rb_hash_stlike_foreach(obj, move_preflight_hash_i, (st_data_t)seen); + move_preflight(RHASH_IFNONE(obj), seen); + break; + case T_STRUCT: + for (long i = 0; i < RSTRUCT_LEN(obj); i++) { + move_preflight(RSTRUCT_GET(obj, (int)i), seen); + } + break; + case T_FILE: { + struct rb_io *fptr = RFILE(obj)->fptr; + if (fptr == NULL) { + rb_raise(rb_eRactorError, "can not move an uninitialized IO"); + } + if (RTEST(fptr->tied_io_for_writing)) { + /* A popen("r+") pair: moving one side would dangle the tied writer on the + * sender. */ + rb_raise(rb_eRactorError, "can not move an IO tied to a writer IO"); + } + if (RTEST(fptr->wakeup_mutex)) { + /* A close is in progress: a thread is blocked on this IO. */ + rb_raise(rb_eRactorError, "can not move an IO that is being closed"); + } + move_preflight(fptr->pathv, seen); + move_preflight(fptr->encs.ecopts, seen); + move_preflight(fptr->writeconv_pre_ecopts, seen); + move_preflight(fptr->writeconv_asciicompat, seen); + move_preflight(fptr->timeout, seen); + break; + } + default: + rb_raise(rb_eRactorError, "can not move a %"PRIsVALUE" object", + rb_class_name(rb_obj_class(obj))); + } + + rb_ivar_foreach(obj, move_preflight_ivar_i, (st_data_t)seen); +} + +/* Build a move courier from obj and turn every captured source into a + * RactorMovedObject (move semantics). Returns the xmalloc'd courier. */ +struct rb_ractor_move_courier * +rb_ractor_move_courier_build(VALUE obj) +{ + /* Two phases, preflight then commit, so an unmovable object is raised from the + * read-only walk while the graph is still intact. */ + { + st_table *pf_seen = st_init_numtable(); + enum ruby_tag_type state; + rb_execution_context_t *ec = GET_EC(); + EC_PUSH_TAG(ec); + if ((state = EC_EXEC_TAG()) == TAG_NONE) { + move_preflight(obj, pf_seen); + } + EC_POP_TAG(); + st_free_table(pf_seen); + if (state != TAG_NONE) EC_JUMP_TAG(ec, state); + } + + struct rb_ractor_move_courier *c = ZALLOC(struct rb_ractor_move_courier); + struct move_build b = { c, st_init_numtable() }; + + /* Between send and materialization the courier's shareable REFs pass through + * windows where nothing else roots them; register it for its whole lifetime so the + * registry root pass marks and pins them. Registering before the sources become + * T_MOVED is safe: partial nodes are initialized mark-safe. */ + move_courier_registry_add(c); + + enum ruby_tag_type state; + rb_execution_context_t *ec = GET_EC(); + EC_PUSH_TAG(ec); + if ((state = EC_EXEC_TAG()) == TAG_NONE) { + c->root = move_capture(&b, obj); + } + EC_POP_TAG(); + st_free_table(b.seen); + if (state != TAG_NONE) { + /* move_capture raised (an unmovable type, an interrupt). Remove the courier + * from the registry and free it before re-raising; the partial nodes are + * mark-safe and safe to free. */ + rb_ractor_move_courier_free(c); + EC_JUMP_TAG(ec, state); + } + return c; +} + +/* Shells are created with the base/real class, so re-attach the original subclass or + * singleton class (classes are shareable; the reference is safe). A singleton's + * attached object still points at the sender's source: re-attach it to the shell. */ +static void +move_apply_moved_klass(VALUE shell, VALUE klass) +{ + if (klass != RBASIC_CLASS(shell)) { + RBASIC_SET_CLASS(shell, klass); + } + if (RB_UNLIKELY(FL_TEST_RAW(klass, FL_SINGLETON))) { + rb_singleton_class_attached(klass, shell); + } +} + +/* Rebuild the courier's graph in the current Ractor's objspace and return its root. + * Two passes (allocate shells, then fill) break reference cycles. */ +VALUE +rb_ractor_move_courier_materialize(struct rb_ractor_move_courier *c) +{ + /* A hidden Array roots every shell, keeping them alive while the allocations that + * build the rest of the graph (which can start this Ractor's GC) run. */ + VALUE shells = rb_ary_hidden_new(c->count); + + for (uint32_t i = 0; i < c->count; i++) { + struct move_node *n = &c->nodes[i]; + VALUE shell; + switch (n->kind) { + case MOVE_KIND_REF: + shell = n->u.ref; + break; + case MOVE_KIND_STRING: + shell = rb_enc_str_new(n->u.str.ptr, n->u.str.len, rb_enc_from_index(n->u.str.encidx)); + move_apply_moved_klass(shell, n->u.str.klass); + break; + case MOVE_KIND_ARRAY: + shell = rb_ary_new_capa(n->u.ary.len); + move_apply_moved_klass(shell, n->u.ary.klass); + break; + case MOVE_KIND_HASH: + shell = n->u.hash.compare_by_id ? rb_ident_hash_new() : rb_hash_new(); + move_apply_moved_klass(shell, n->u.hash.klass); + break; + case MOVE_KIND_OBJECT: + /* A singleton class cannot allocate, so make an instance of the real class + * and re-attach it afterwards */ + shell = rb_obj_alloc(rb_class_real(n->u.obj.klass)); + move_apply_moved_klass(shell, n->u.obj.klass); + break; + case MOVE_KIND_STRUCT: + shell = rb_obj_alloc(rb_class_real(n->u.strct.klass)); + move_apply_moved_klass(shell, n->u.strct.klass); + break; + case MOVE_KIND_MATCH: + shell = rb_match_move_alloc(rb_class_real(n->u.match.klass), n->u.match.num_regs); + move_apply_moved_klass(shell, n->u.match.klass); + break; + case MOVE_KIND_IO: + shell = rb_obj_alloc(rb_class_real(n->u.io.klass)); + move_apply_moved_klass(shell, n->u.io.klass); + RFILE(shell)->fptr = n->u.io.fptr; + n->u.io.fptr->self = shell; + n->u.io.fptr = NULL; /* consumed: the new IO owns it now */ + break; + default: + rb_bug("rb_ractor_move_courier_materialize: bad node kind"); + } + rb_ary_push(shells, shell); + } + + for (uint32_t i = 0; i < c->count; i++) { + struct move_node *n = &c->nodes[i]; + VALUE shell = RARRAY_AREF(shells, i); + switch (n->kind) { + case MOVE_KIND_ARRAY: + for (long j = 0; j < n->u.ary.len; j++) { + rb_ary_push(shell, RARRAY_AREF(shells, n->u.ary.elems[j])); + } + break; + case MOVE_KIND_HASH: + /* Entry insertion is deferred to a third pass: insertion calls the key's + * #hash / #eql?, and a content-based #hash would collide on every key while + * the graph is still empty, collapsing entries. */ + break; + case MOVE_KIND_STRUCT: + for (long j = 0; j < n->u.strct.len; j++) { + RSTRUCT_SET(shell, (int)j, RARRAY_AREF(shells, n->u.strct.elems[j])); + } + break; + case MOVE_KIND_MATCH: + rb_match_move_load(shell, RARRAY_AREF(shells, n->u.match.regexp_id), + RARRAY_AREF(shells, n->u.match.str_id), + n->u.match.num_regs, n->u.match.regs); + break; + case MOVE_KIND_IO: { + /* Write the rebuilt VALUE members back into fptr (capture detached them). + * write_lock and wakeup_mutex stay nil; io.c recreates them lazily. */ + struct rb_io *fptr = RFILE(shell)->fptr; + RB_OBJ_WRITE(shell, &fptr->pathv, RARRAY_AREF(shells, n->u.io.pathv_id)); + RB_OBJ_WRITE(shell, &fptr->encs.ecopts, RARRAY_AREF(shells, n->u.io.ecopts_id)); + RB_OBJ_WRITE(shell, &fptr->writeconv_pre_ecopts, RARRAY_AREF(shells, n->u.io.wc_pre_ecopts_id)); + RB_OBJ_WRITE(shell, &fptr->writeconv_asciicompat, RARRAY_AREF(shells, n->u.io.wc_asciicompat_id)); + RB_OBJ_WRITE(shell, &fptr->timeout, RARRAY_AREF(shells, n->u.io.timeout_id)); + break; + } + default: + break; + } + /* Restore instance and generic ivars (any non-REF node can have them) */ + for (uint32_t j = 0; j < n->niv; j++) { + rb_ivar_set(shell, n->iv_ids[j], RARRAY_AREF(shells, n->iv_vals[j])); + } + } + + /* Insert hash entries only once every shell is filled. Ids are assigned + * depth-first (children larger), so inserting in reverse settles nested hash keys + * inside-out (a #hash cycling through itself is out of scope). */ + for (uint32_t i = c->count; i > 0; i--) { + struct move_node *n = &c->nodes[i - 1]; + if (n->kind != MOVE_KIND_HASH) continue; + VALUE shell = RARRAY_AREF(shells, i - 1); + for (long j = 0; j < n->u.hash.size; j++) { + rb_hash_aset(shell, RARRAY_AREF(shells, n->u.hash.kv[2 * j]), + RARRAY_AREF(shells, n->u.hash.kv[2 * j + 1])); + } + /* Restore the default value and default proc (before freezing) */ + VALUE ifnone = RARRAY_AREF(shells, n->u.hash.ifnone_id); + if (n->u.hash.proc_default) { + rb_hash_set_default_proc(shell, ifnone); + } + else if (ifnone != Qnil) { + rb_hash_set_default(shell, ifnone); + } + } + + /* Freeze after filling, so frozen containers and strings can be built too. */ + for (uint32_t i = 0; i < c->count; i++) { + VALUE shell = RARRAY_AREF(shells, i); + if (c->nodes[i].frozen && !RB_SPECIAL_CONST_P(shell)) { + rb_obj_freeze(shell); + } + } + + VALUE root = c->count ? RARRAY_AREF(shells, c->root) : Qnil; + RB_GC_GUARD(shells); + return root; +} + +void +rb_ractor_move_courier_free(struct rb_ractor_move_courier *c) +{ + for (uint32_t i = 0; i < c->count; i++) { + struct move_node *n = &c->nodes[i]; + ruby_xfree(n->iv_ids); + ruby_xfree(n->iv_vals); + switch (n->kind) { + case MOVE_KIND_STRING: + ruby_xfree(n->u.str.ptr); + break; + case MOVE_KIND_ARRAY: + ruby_xfree(n->u.ary.elems); + break; + case MOVE_KIND_HASH: + ruby_xfree(n->u.hash.kv); + break; + case MOVE_KIND_STRUCT: + ruby_xfree(n->u.strct.elems); + break; + case MOVE_KIND_MATCH: + rb_match_move_free(n->u.match.regs); + break; + case MOVE_KIND_IO: + /* A delivered IO left fptr == NULL (the rebuilt IO owns it). An + * undelivered one still owns the fd and its source is already a + * RactorMovedObject nobody can close: close it here, not leak it. */ + if (n->u.io.fptr) { + rb_io_fptr_finalize(n->u.io.fptr); + n->u.io.fptr = NULL; + } + break; + default: + break; + } + } + move_courier_registry_remove(c); + ruby_xfree(c->nodes); + ruby_xfree(c); +} + +/* Mark the only VALUEs a courier holds: shareable objects and immediates (REF) and the + * classes of its objects. All of them are shareable, so marking cannot race, and the + * global GC keeps them reachable through the courier. */ +void +rb_ractor_move_courier_mark(struct rb_ractor_move_courier *c) { - rb_raise(rb_eRactorError, "can't clone unshareable instance of %"PRIsVALUE, rb_class_of(obj)); - UNREACHABLE_RETURN(Qnil); + if (!c) return; + for (uint32_t i = 0; i < c->count; i++) { + struct move_node *n = &c->nodes[i]; + if (n->kind == MOVE_KIND_REF) { + rb_gc_mark(n->u.ref); + } + else if (n->kind == MOVE_KIND_OBJECT) { + rb_gc_mark(n->u.obj.klass); + } + else if (n->kind == MOVE_KIND_STRUCT) { + rb_gc_mark(n->u.strct.klass); + } + else if (n->kind == MOVE_KIND_MATCH) { + rb_gc_mark(n->u.match.klass); + } + else if (n->kind == MOVE_KIND_IO) { + rb_gc_mark(n->u.io.klass); + } + else if (n->kind == MOVE_KIND_STRING) { + rb_gc_mark(n->u.str.klass); + } + else if (n->kind == MOVE_KIND_ARRAY) { + rb_gc_mark(n->u.ary.klass); + } + else if (n->kind == MOVE_KIND_HASH) { + rb_gc_mark(n->u.hash.klass); + } + } } +/* The message copy traversal never calls #clone or #initialize_clone. Core container + * types get a native shallow copy here (the traversal then rewrites the children inside + * the copy); any other unshareable type falls back to a full Marshal round trip. */ static VALUE -ractor_obj_clone(VALUE obj) +ractor_native_shallow_copy(VALUE obj) { - VALUE clone = rb_rescue(ractor_call_clone_try, obj, ractor_call_clone_rescue, obj); + VALUE copy; + + /* An object with a singleton class cannot be copied natively; fall back to Marshal + * so it reports a proper error. */ + VALUE klass = RBASIC_CLASS(obj); + if (klass == 0 || FL_TEST_RAW(klass, FL_SINGLETON)) { + return Qundef; + } + + switch (BUILTIN_TYPE(obj)) { + case T_OBJECT: + copy = rb_obj_alloc(rb_obj_class(obj)); + rb_obj_copy_ivar(copy, obj); + break; + case T_STRING: + copy = rb_enc_str_new(RSTRING_PTR(obj), RSTRING_LEN(obj), rb_enc_get(obj)); + break; + case T_ARRAY: + copy = rb_ary_new_from_values(RARRAY_LEN(obj), RARRAY_CONST_PTR(obj)); + break; + case T_HASH: + copy = rb_hash_dup(obj); + break; + case T_STRUCT: + copy = rb_obj_alloc(rb_obj_class(obj)); + rb_struct_init_copy(copy, obj); + break; + case T_MATCH: + copy = rb_obj_alloc(rb_obj_class(obj)); + rb_match_init_copy(copy, obj); + break; + case T_DATA: + /* Keep a copied exception from carrying a raw pointer to the sender's backtrace + * across objspaces */ + if (rb_backtrace_p(obj)) { + copy = rb_backtrace_dup(obj); + break; + } + return Qundef; + default: + return Qundef; + } - if (obj == clone) { - rb_raise(rb_eRactorError, "#clone returned self"); + /* A non-T_OBJECT host keeps its ivars in the generic fields table: copy them. + * T_HASH is excluded: rb_hash_dup already ran rb_copy_generic_ivar, and a second + * call asserts in rb_shape_rebuild (the first gave the copy an ivar shape). */ + if (BUILTIN_TYPE(obj) != T_OBJECT && BUILTIN_TYPE(obj) != T_HASH && + UNLIKELY(rb_obj_gen_fields_p(obj))) { + rb_copy_generic_ivar(copy, obj); } - return clone; + /* The traversal rewrites the children inside the copy with raw stores, so the frozen + * bit can be set now: by the time leave runs the original is out of sight. */ + if (OBJ_FROZEN(obj)) { + RB_FL_SET_RAW(copy, RUBY_FL_FREEZE); + } + return copy; +} + +/* Add a node of the snapshot under construction to the pin list and pin it now. */ +static void +ractor_pin_capture_push(rb_ractor_t *cr, VALUE v) +{ + if (cr->pin_capture_cnt == cr->pin_capture_capa) { + size_t nc = cr->pin_capture_capa ? cr->pin_capture_capa * 2 : 16; + VALUE *p = realloc(cr->pin_capture, nc * sizeof(VALUE)); + if (!p) rb_bug("ractor_pin_capture_push: out of memory"); + cr->pin_capture = p; + cr->pin_capture_capa = nc; + } + cr->pin_capture[cr->pin_capture_cnt++] = v; + rb_gc_pin_in_flight_message(v); } static enum obj_traverse_iterator_result @@ -2208,7 +3128,20 @@ copy_enter(VALUE obj, struct obj_traverse_replace_data *data) return traverse_skip; } else { - data->replacement = ractor_obj_clone(obj); + VALUE copy = ractor_native_shallow_copy(obj); + if (UNDEF_P(copy)) return traverse_stop; /* no native copy for this type */ + data->replacement = copy; + /* Collect every node into the pin list as the snapshot is built: the global + * GC's re-pin must cover all nodes, not just the root (moving one breaks the + * address-keyed dedup table). fields_obj is not included: the global + * generic_fields table reaches it and compaction updates that. */ + rb_ractor_t *cr = GET_RACTOR(); + if (cr->gen_fields_capturing) { + /* Pin from birth (shref bit, plus the pin bit during a global compaction). + * rb_ractor_repin_in_flight re-pins via cr->pin_capture, so the cover runs + * unbroken from construction through enqueue to materialization. */ + ractor_pin_capture_push(cr, copy); + } return traverse_cont; } } @@ -2219,16 +3152,26 @@ copy_leave(VALUE obj, struct obj_traverse_replace_data *data) return traverse_cont; } +/* Native deep copy of obj's graph. Returns Qundef when it contains a type the native + * copier does not support, and the caller falls back to Marshal. */ +static VALUE +ractor_copy_native_try(VALUE obj) +{ + return rb_obj_traverse_replace(obj, copy_enter, copy_leave, false); +} + +/* Deep copy within one objspace (Ractor.make_shareable(obj, copy: true)): native first, + * then a whole-graph Marshal round trip. */ static VALUE ractor_copy(VALUE obj) { - VALUE val = rb_obj_traverse_replace(obj, copy_enter, copy_leave, false); - if (!UNDEF_P(val)) { - return val; - } - else { - rb_raise(rb_eRactorError, "can not copy the object"); + VALUE copy = ractor_copy_native_try(obj); + if (UNDEF_P(copy)) { + copy = rb_marshal_load(rb_rescue2(ractor_marshal_dump_body, obj, + ractor_marshal_dump_rescue, obj, + rb_eTypeError, (VALUE)0)); } + return copy; } // Ractor local storage @@ -2244,6 +3187,21 @@ static struct freed_ractor_local_keys_struct { rb_ractor_local_key_t *keys; } freed_ractor_local_keys; +/* Purge deleted ractor-local keys from the storage tables and run their free hooks. */ +static void +ractor_local_keys_purge(st_table *local_storage) +{ + for (int i=0; itype->free) { + (*key->type->free)((void *)val); + } + } +} + + static int ractor_local_storage_mark_i(st_data_t key, st_data_t val, st_data_t dmy) { @@ -2265,13 +3223,11 @@ ractor_local_storage_mark(rb_ractor_t *r) if (r->local_storage) { st_foreach(r->local_storage, ractor_local_storage_mark_i, 0); - for (int i=0; ilocal_storage, &k, &val) && - (key = (rb_ractor_local_key_t)k)->type->free) { - (*key->type->free)((void *)val); - } + /* A deleted key is purged from every Ractor's storage in one collection, which + * then frees its struct. Only a collection that visits every Ractor with no + * other marker running can do that: a global GC, or a single objspace. */ + if (rb_gc_single_objspace_p() || rb_gc_during_global_gc_p()) { + ractor_local_keys_purge(r->local_storage); } } @@ -2443,6 +3399,23 @@ rb_ractor_local_storage_ptr_set(rb_ractor_local_key_t key, void *ptr) void rb_ractor_finish_marking(void) { + /* A freed key's struct may only be released by a collection that purged every + * Ractor's storage with no other marker running: a global GC, or a single objspace. + * A local GC also reaches here (gc_marks_finish) and must do nothing. */ + if (!(rb_gc_single_objspace_p() || rb_gc_during_global_gc_p())) { + return; + } + + /* The root scan's purge never reaches a zombie's storage (not in the set; + * zombie_objspaces only marks the join slot): purge here, under the barrier, before + * the struct is freed, or a later ractor_free reads a freed key. */ + rb_vm_t *vm = GET_VM(); + for (size_t zi = 0; zi < vm->gc.zombie_objspaces_count; zi++) { + rb_ractor_t *owner = vm->gc.zombie_objspaces[zi].owner; + if (owner == NULL || owner->local_storage == NULL) continue; + ractor_local_keys_purge(owner->local_storage); + } + for (int i=0; i [1, 2] (unshareable object) + # r.value #=> Ractor::Error # # Ractor.new(r){|r| r.value} #=> Ractor::Error # diff --git a/ractor_core.h b/ractor_core.h index ce43ca9e91943c..e8dc599a69ce59 100644 --- a/ractor_core.h +++ b/ractor_core.h @@ -12,6 +12,9 @@ // experimental flag because it is not sure it is the common pattern #define RUBY_TYPED_FROZEN_SHAREABLE_NO_REC RUBY_FL_FINALIZE +/* An in-flight move payload, serialized off-heap (defined in ractor.c). */ +struct rb_ractor_move_courier; + struct rb_ractor_sync { // ractor lock rb_nativethread_lock_t lock; @@ -42,6 +45,22 @@ struct rb_ractor_sync { rb_ractor_t *successor; VALUE legacy; bool legacy_exc; + bool legacy_taken; /* Ractor#value already returned the value */ + + /* Number of receives currently materializing a copy (only the owner's threads + * update it, under the GVL). */ + int materializing_copies; +}; + +struct ractor_basket; + +/* One in-flight copy payload being rebuilt (lives on the receiver's machine + * stack) */ +struct ractor_materialize_frame { + VALUE snapshot; /* the sender-side snapshot */ + const VALUE *pinned; /* pin list of every snapshot node (owned by the basket) */ + size_t pinned_cnt; + struct ractor_materialize_frame *prev; }; // created @@ -69,16 +88,16 @@ struct rb_ractor_struct { struct rb_ractor_pub pub; struct rb_ractor_sync sync; - /* objects pinned via rb_gc_register_mark_object; this Ractor owns them and - * marks them, and hands them to the main Ractor when it terminates. */ - VALUE mark_object_ary; + /* rb_gc_register_mark_object pins, per Ractor: the owner marks them (live via + * rb_ractor_mark_local_roots, unmerged zombie via the zombie scan) and a merge moves + * them to the survivor. Raw malloc, so a merge during sweep cannot re-enter GC. */ + VALUE *registered_marks; + size_t registered_marks_cnt, registered_marks_capa; -#if !USE_MODULAR_GC /* traversal-API mark redirect (NULL outside a traversal). Per Ractor so a - * concurrent traversal on another Ractor is never observed. A modular GC - * keeps this in the VM instead (vm->gc.mark_func_data). */ + * concurrent traversal on another Ractor is never observed. A modular GC's + * Ractor-less marking worker threads read vm->gc.mark_func_data instead. */ struct gc_mark_func_data_struct *mark_func_data; -#endif // thread management struct { @@ -105,6 +124,7 @@ struct rb_ractor_struct { enum ractor_status status_; struct ccan_list_node vmlr_node; + bool in_terminated_set; /* vmlr_node is on vm->ractor.terminated_set */ // ractor local data @@ -123,8 +143,46 @@ struct rb_ractor_struct { bool malloc_gc_disabled; bool main_ractor; void *newobj_cache; + + /* This Ractor's objspace. The main Ractor receives the boot objspace from + * rb_gc_init_objspaces; a non-main Ractor shares the main one until it gets its + * own (while this is NULL). */ + void *objspace; + + /* A child Ractor's objspace is populated (Thread/Fiber wrappers) before it joins + * vm->ractor.set, so a whole-VM walk would miss it. Park it here from wrapper + * allocation until vm_insert_ractor clears it (under the VM lock) so the global GC + * still enumerates it. */ + void *creating_child_objspace; + + /* True while Ractor#send builds a native copy snapshot; copy_enter then collects + * every snapshot node into pin_capture below. Owner thread only. */ + bool gen_fields_capturing; + + /* Pin list collecting every node while a copy snapshot is built (basket_new + * hands it over to the basket). A global GC clears every shref, so the re-pin + * has to cover all nodes, not just the root. */ + VALUE *pin_capture; + size_t pin_capture_cnt, pin_capture_capa; + /* The in-flight copy basket between basket_new and the enqueue, so the re-pin + * covers that window too */ + struct ractor_basket *sending_basket; }; // rb_ractor_t is defined in vm_core.h +/* Mark the GC roots held in Ractor r's C structs (from the root scan in gc.c). */ +void rb_ractor_mark_local_roots(rb_ractor_t *r); +void rb_ractor_mark_terminated_join_value(rb_ractor_t *r); +void rb_ractor_repin_in_flight(rb_ractor_t *r); +void rb_ractor_mark_in_flight_for_single_objspace(rb_ractor_t *r); +/* True while the current Ractor is materializing an arriving copy (see the + * definition in ractor_sync.c). */ +bool rb_ractor_materializing_p(void); + +/* Move src's registered_marks to dst and leave src empty (on join or when an orphan + * is absorbed). An absorb can run during a GC sweep, so the implementation uses raw + * realloc (ractor.c). */ +void rb_ractor_absorb_registered_marks(rb_ractor_t *dst, rb_ractor_t *src); + enum ractor_wakeup_status { wakeup_none, wakeup_by_send, @@ -147,12 +205,12 @@ rb_ractor_self(const rb_ractor_t *r) rb_ractor_t *rb_ractor_main_alloc(void); void rb_ractor_main_setup(rb_vm_t *vm, rb_ractor_t *main_ractor, rb_thread_t *main_thread); -void rb_vm_ractor_migrate_mark_objects(rb_ractor_t *dst, rb_ractor_t *src); void rb_ractor_atexit(rb_execution_context_t *ec, VALUE result); void rb_ractor_atexit_exception(rb_execution_context_t *ec); void rb_ractor_teardown(rb_execution_context_t *ec); void rb_ractor_receive_parameters(rb_execution_context_t *ec, rb_ractor_t *g, int len, VALUE *ptr); void rb_ractor_send_parameters(rb_execution_context_t *ec, rb_ractor_t *g, VALUE args); +void rb_ractor_setup_default_port(rb_ractor_t *r); VALUE rb_thread_create_ractor(rb_ractor_t *g, VALUE args, VALUE proc); // defined in thread.c @@ -163,6 +221,7 @@ bool rb_ractor_p(VALUE rv); void rb_ractor_living_threads_init(rb_ractor_t *r); void rb_ractor_living_threads_insert(rb_ractor_t *r, rb_thread_t *th); void rb_ractor_living_threads_remove(rb_ractor_t *r, rb_thread_t *th); +void rb_ractor_cancel_creation(rb_ractor_t *r, rb_thread_t *th); void rb_ractor_blocking_threads_inc(rb_ractor_t *r, const char *file, int line); // TODO: file, line only for RUBY_DEBUG_LOG void rb_ractor_blocking_threads_dec(rb_ractor_t *r, const char *file, int line); // TODO: file, line only for RUBY_DEBUG_LOG @@ -303,50 +362,21 @@ rb_ractor_targeted_hooks_cnt(rb_ractor_t *cr) } #if RACTOR_CHECK_MODE > 0 -# define RACTOR_BELONGING_ID(obj) (*(uint32_t *)(((uintptr_t)(obj)) + rb_gc_obj_slot_size(obj))) - -uint32_t rb_ractor_current_id(void); - -static inline void -rb_ractor_setup_belonging_to(VALUE obj, uint32_t rid) -{ - RACTOR_BELONGING_ID(obj) = rid; -} - -static inline uint32_t -rb_ractor_belonging(VALUE obj) -{ - if (SPECIAL_CONST_P(obj) || RB_OBJ_SHAREABLE_P(obj)) { - return 0; - } - else { - return RACTOR_BELONGING_ID(obj); - } -} extern bool rb_ractor_ignore_belonging_flag; +/* 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 + * but its owner is a containment violation. */ static inline VALUE rb_ractor_confirm_belonging(VALUE obj) { if (rb_ractor_ignore_belonging_flag) return obj; + if (SPECIAL_CONST_P(obj) || RB_OBJ_SHAREABLE_P(obj)) return obj; - uint32_t id = rb_ractor_belonging(obj); - - if (id == 0) { - if (UNLIKELY(!rb_ractor_shareable_p(obj))) { - rp(obj); - rb_bug("id == 0 but not shareable"); - } - } - else if (UNLIKELY(id != rb_ractor_current_id())) { - if (rb_ractor_shareable_p(obj)) { - // ok - } - else { - rp(obj); - rb_bug("rb_ractor_confirm_belonging object-ractor id:%u, current-ractor id:%u", id, rb_ractor_current_id()); - } + if (UNLIKELY(rb_gc_obj_foreign_p(obj))) { + rp(obj); + rb_bug("rb_ractor_confirm_belonging: unshareable object of another Ractor's objspace"); } return obj; } diff --git a/ractor_sync.c b/ractor_sync.c index b48aad9f756bd0..00926a5c88e3f6 100644 --- a/ractor_sync.c +++ b/ractor_sync.c @@ -18,6 +18,11 @@ static VALUE ractor_send(rb_execution_context_t *ec, const struct ractor_port *r static VALUE ractor_try_send(rb_execution_context_t *ec, const struct ractor_port *rp, VALUE obj, VALUE move); static void ractor_add_port(rb_ractor_t *r, st_data_t id); +// The off-heap courier used for moves. It is defined in ractor.c. +struct rb_ractor_move_courier *rb_ractor_move_courier_build(VALUE obj); +VALUE rb_ractor_move_courier_materialize(struct rb_ractor_move_courier *c); +void rb_ractor_move_courier_free(struct rb_ractor_move_courier *c); + static void ractor_port_mark(void *ptr) { @@ -219,6 +224,18 @@ struct ractor_basket { struct { VALUE v; bool exception; + /* True when v held a type the native copier does not support and became a + * Marshal byte String. The receiver rebuilds it with Marshal.load instead + * of walking it natively. */ + bool marshaled; + /* The off-heap (xmalloc) courier of a basket_type_move. A move basket does + * not use v. */ + struct rb_ractor_move_courier *move_courier; + /* Every node of a native copy snapshot, collected while building it (raw + * malloc). The global GC's re-pin walks this list, since traversing the graph + * in-GC would need generic-ivar lookups. NULL: only the root (p.v) is pinned. */ + VALUE *pinned; + size_t pinned_cnt; } p; // payload struct ccan_list_node node; @@ -241,12 +258,31 @@ ractor_basket_none_p(const struct ractor_basket *b) static void ractor_basket_mark(const struct ractor_basket *b) { - rb_gc_mark(b->p.v); + /* A move courier lives off-heap, and the shareable REFs it carries are marked and + * pinned as a global GC root by the in-flight registry (ractor.c). Nothing to do + * here. */ + if (b->type != basket_type_move) { + rb_gc_mark(b->p.v); + } } static void ractor_basket_free(struct ractor_basket *b) { + /* A basket that dies before being enqueued clears the sender's re-pin slot; a + * free by the Ractor tearing the queue down does not match and is a no-op. */ + rb_ractor_t *cr = rb_current_ractor_raw(false); + if (cr != NULL && cr->sending_basket == b) { + cr->sending_basket = NULL; + } + free(b->p.pinned); + b->p.pinned = NULL; + b->p.pinned_cnt = 0; + if (b->type == basket_type_move && b->p.move_courier) { + /* A move courier that was never consumed (a queue being torn down, say). */ + rb_ractor_move_courier_free(b->p.move_courier); + b->p.move_courier = NULL; + } SIZED_FREE(b); } @@ -546,6 +582,9 @@ struct ractor_monitor { struct ccan_list_node node; }; +/* Mark the Ractors monitoring r. ractor_notify_exit sends the exit token through each + * entry's port, so the monitoring Ractor's struct must outlive r, and its wrapper is + * what keeps it alive. */ static void ractor_mark_monitors(rb_ractor_t *r) { @@ -636,6 +675,12 @@ ractor_notify_exit(rb_execution_context_t *ec, rb_ractor_t *cr, VALUE legacy, bo VM_ASSERT(!UNDEF_P(legacy)); VM_ASSERT(cr->sync.legacy == Qundef); + /* Last local GC before termination, in ordinary execution context: collect here + * what the joiner would otherwise inherit, and return empty pages to the pool. */ + if (cr != GET_VM()->ractor.main_ractor) { + rb_gc_objspace_retire_gc(); + } + RACTOR_LOCK_SELF(cr); { ractor_free_all_ports(cr); @@ -677,14 +722,99 @@ ractor_mark_ports_i(st_data_t key, st_data_t val, st_data_t data) static void ractor_sync_mark(rb_ractor_t *r) { + /* The owner rewrites the queues, the port table and the monitor list under its sync + * lock, so only the owner itself or the stopped world may walk them. */ + const bool world_stopped = rb_gc_during_global_gc_p(); + VM_ASSERT(world_stopped || r == rb_current_ractor_raw(false)); + rb_gc_mark(r->sync.default_port_value); + /* (A copy snapshot being materialized is not marked here: each EC's frame + * chain roots it in rb_execution_context_mark, which also re-pins it.) */ + /* Until the value is absorbed this is its only reliable root (Qundef while the + * Ractor still runs); after Ractor#value returns it, the Ruby side roots it. */ + rb_gc_mark(r->sync.legacy); + + /* ractor_sync_init builds the rest, and a root scan reaches the main Ractor before + * that: ports is what tells the two apart (the lock and the list heads are still + * zeroed, and walking those crashes). Lock out foreign senders while walking them + * (self-lock: not recursive, and a held Ractor lock disables malloc-GC, so no GC + * nests); a stopped world needs no lock. */ if (r->sync.ports) { - ractor_queue_mark(r->sync.recv_queue); - st_foreach(r->sync.ports, ractor_mark_ports_i, 0); + if (!world_stopped) RACTOR_LOCK_SELF(r); + { + ractor_queue_mark(r->sync.recv_queue); + st_foreach(r->sync.ports, ractor_mark_ports_i, 0); + ractor_mark_monitors(r); + } + if (!world_stopped) RACTOR_UNLOCK_SELF(r); } +} - ractor_mark_monitors(r); +/* Re-pin a copy basket's payload: the root and every collected node. */ +static void +ractor_basket_repin_in_flight(const struct ractor_basket *b) +{ + if (b->type != basket_type_copy) return; + rb_gc_pin_in_flight_message(b->p.v); + for (size_t i = 0; i < b->p.pinned_cnt; i++) { + rb_gc_pin_in_flight_message(b->p.pinned[i]); + } +} + +static void +ractor_queue_repin_in_flight(const struct ractor_queue *rq) +{ + const struct ractor_basket *b; + ccan_list_for_each(&rq->set, b, node) { + /* A move basket carries an off-heap courier, so it has no shref to re-pin; + * ractor_basket_mark marks the shareable VALUEs it carries instead. */ + ractor_basket_repin_in_flight(b); + } +} + +static int +ractor_repin_ports_i(st_data_t key, st_data_t val, st_data_t data) +{ + ractor_queue_repin_in_flight((struct ractor_queue *)val); + return ST_CONTINUE; +} + +/* A global GC clears every shref bit, so all in-flight payloads have to be re-pinned + * before the unified mark. Runs on the driver, under the barrier. */ +void +rb_ractor_repin_in_flight(rb_ractor_t *r) +{ + if (r->sync.ports) { + ractor_queue_repin_in_flight(r->sync.recv_queue); + st_foreach(r->sync.ports, ractor_repin_ports_i, 0); + } + /* Baskets already built but not enqueued yet (in flight on the send path). */ + if (r->sending_basket != NULL) { + ractor_basket_repin_in_flight(r->sending_basket); + } + /* A snapshot still being built (from prepare_payload's walk until it moves into + * the basket). */ + for (size_t i = 0; i < r->pin_capture_cnt; i++) { + rb_gc_pin_in_flight_message(r->pin_capture[i]); + } + /* Snapshots being materialized are re-pinned from the EC frame chains instead + * (rb_execution_context_mark, which also covers a suspended fiber's EC). */ +} + +/* A single-objspace impl (mmtk) has no pin or shref bits and no zombie_objspaces, so + * plain marking from the wrapper keeps these alive; the default GC covers the same set + * with its pins and its zombie scan. */ +void +rb_ractor_mark_in_flight_for_single_objspace(rb_ractor_t *r) +{ + rb_gc_mark(r->sync.legacy); + if (r->sending_basket != NULL) { + ractor_basket_mark(r->sending_basket); + } + for (size_t i = 0; i < r->pin_capture_cnt; i++) { + rb_gc_mark(r->pin_capture[i]); + } } static int @@ -740,17 +870,32 @@ ractor_sync_init(rb_ractor_t *r) // ports r->sync.ports = st_init_numtable(); - r->sync.default_port_value = ractor_port_new(r); - FL_SET_RAW(r->sync.default_port_value, RUBY_FL_SHAREABLE); // only default ports are shareable + /* ractor_setup_default_port creates it only after the Ractor joins + * vm->ractor.set, so a global GC cannot free the rootless port in between. */ + r->sync.default_port_value = Qfalse; // legacy r->sync.legacy = Qundef; + // no receive is rebuilding a payload yet + r->sync.materializing_copies = 0; + #ifndef RUBY_THREAD_PTHREAD_H rb_native_cond_initialize(&r->sync.wakeup_cond); #endif } +/* Create the default port. Call only after the Ractor joined vm->ractor.set, so the + * root scan can mark the shareable port from creation onwards. */ +void +rb_ractor_setup_default_port(rb_ractor_t *r) +{ + VM_ASSERT(r->sync.default_port_value == Qfalse); + r->sync.default_port_value = ractor_port_new(r); + FL_SET_RAW(r->sync.default_port_value, RUBY_FL_SHAREABLE); // only default ports are shareable + rb_gc_obj_became_shareable(r->sync.default_port_value); +} + // Ractor#value static rb_ractor_t * @@ -764,8 +909,6 @@ ractor_set_successor_once(rb_ractor_t *r, rb_ractor_t *cr) return r->sync.successor; } -static VALUE ractor_reset_belonging(VALUE obj); - static VALUE ractor_make_remote_exception(VALUE cause, VALUE sender) { @@ -783,37 +926,118 @@ ractor_value(rb_execution_context_t *ec, VALUE self) rb_ractor_t *sr = ractor_set_successor_once(r, cr); if (sr == cr) { - ractor_reset_belonging(r->sync.legacy); + if (r->sync.legacy_taken) { + rb_raise(rb_eRactorError, "The value was already taken"); + } + + /* The value is returned by reference: inherit the dead Ractor's objspace first, + * making it our own object (containment without a copy). Wait for + * ractor_terminated: a monitor-port wakeup arrives before the dying thread + * finishes teardown (vm_remove_ractor still touches the objspace). */ + while (!rb_ractor_status_p(r, ractor_terminated)) { + rb_thread_schedule(); + } + + /* The wait above yields the GVL, so another thread of this Ractor can take the + * value first: re-check. */ + if (r->sync.legacy_taken) { + rb_raise(rb_eRactorError, "The value was already taken"); + } + + /* Move r's rb_gc_register_mark_object pins to the joiner before the merge + * below sweeps r's objspace, or the objects pinned there lose their root. */ + rb_ractor_absorb_registered_marks(GET_RACTOR(), r); + + rb_gc_objspace_absorb_into_current(&r->objspace); + + /* Keep legacy alive in a C local until it is returned: after the absorb only + * the C struct reaches it, so let the conservative machine-stack mark find it. */ + volatile VALUE legacy_keep = r->sync.legacy; + + /* A dead Ractor's local storage is unreachable from Ruby (Ractor#[] only works + * from inside), so let the values die and keep ractor_mark and ractor_free from + * walking a stale table later. */ + ractor_local_storage_free(r); + r->local_storage = NULL; + r->idkey_local_storage = NULL; + + /* The value is returned to the caller and rooted from Ruby afterwards. Drop it + * from the C struct: keeping it would leave a C-only reference into the + * successor's objspace, needing marking and a pin against compaction. */ + VALUE legacy = r->sync.legacy; + r->sync.legacy = Qnil; + r->sync.legacy_taken = true; + RB_GC_GUARD(legacy_keep); if (r->sync.legacy_exc) { - rb_exc_raise(ractor_make_remote_exception(r->sync.legacy, self)); + rb_exc_raise(ractor_make_remote_exception(legacy, self)); } - return r->sync.legacy; + return legacy; } else { rb_raise(rb_eRactorError, "Only the successor ractor can take a value"); } } -static VALUE ractor_move(VALUE obj); // in this file -static VALUE ractor_copy(VALUE obj); // in this file +static VALUE ractor_copy_native_try(VALUE obj); // in ractor.c + +static VALUE +ractor_marshal_dump_body(VALUE obj) +{ + return rb_marshal_dump(obj, Qnil); +} + +static VALUE +ractor_marshal_dump_rescue(VALUE obj, VALUE errinfo) +{ + rb_raise(rb_eRactorError, "can not copy %"PRIsVALUE" object.", rb_class_of(obj)); + UNREACHABLE_RETURN(Qnil); +} static VALUE -ractor_prepare_payload(rb_execution_context_t *ec, VALUE obj, enum ractor_basket_type *ptype) +ractor_prepare_payload(rb_execution_context_t *ec, VALUE obj, enum ractor_basket_type *ptype, bool *pmarshaled) { switch (*ptype) { case basket_type_ref: return obj; - case basket_type_move: - return ractor_move(obj); default: if (rb_ractor_shareable_p(obj)) { *ptype = basket_type_ref; return obj; } else { + /* Snapshot the object on the sender side without calling the user-visible + * #clone: core types are deep-copied natively and anything else is + * marshaled here, so its user hooks run on the sender. */ *ptype = basket_type_copy; - return ractor_copy(obj); + /* During a native copy, copy_enter collects every snapshot node into the + * pin list that covers construction, enqueue and materialization. */ + rb_ractor_t *cr = rb_ec_ractor_ptr(ec); + VM_ASSERT(!cr->gen_fields_capturing); + cr->gen_fields_capturing = true; + VALUE snapshot = Qundef; + /* A native copy can raise (allocation, async interrupt). Leaving the + * capturing flag set would fail the next send's assert and leak a stale + * pin_capture list into that basket. */ + enum ruby_tag_type state; + EC_PUSH_TAG(ec); + if ((state = EC_EXEC_TAG()) == TAG_NONE) { + snapshot = ractor_copy_native_try(obj); + } + EC_POP_TAG(); + cr->gen_fields_capturing = false; + if (state != TAG_NONE) { + cr->pin_capture_cnt = 0; + EC_JUMP_TAG(ec, state); + } + if (UNDEF_P(snapshot)) { + cr->pin_capture_cnt = 0; + snapshot = rb_rescue2(ractor_marshal_dump_body, obj, + ractor_marshal_dump_rescue, obj, + rb_eTypeError, (VALUE)0); + *pmarshaled = true; + } + return snapshot; } } } @@ -821,25 +1045,188 @@ ractor_prepare_payload(rb_execution_context_t *ec, VALUE obj, enum ractor_basket static struct ractor_basket * ractor_basket_new(rb_execution_context_t *ec, VALUE obj, enum ractor_basket_type type, bool exc) { - VALUE v = ractor_prepare_payload(ec, obj, &type); + /* A copy payload's preparation can raise (an uncopyable object), so it runs before + * the basket is allocated and cannot leak one; the move branch allocates first, + * since an alloc raise must not orphan an already built courier. */ + VALUE v = Qfalse; + bool marshaled = false; + struct rb_ractor_move_courier *courier = NULL; + + struct ractor_basket *b; + if (type == basket_type_move) { + /* Allocate the basket first: its xmalloc can raise NoMemoryError, and a courier + * already built (sources destroyed, registry entry live) would be orphaned. */ + b = ractor_basket_alloc(); + enum ruby_tag_type state; + EC_PUSH_TAG(ec); + if ((state = EC_EXEC_TAG()) == TAG_NONE) { + /* Serialize the graph into an off-heap courier; the sources become + * RactorMovedObject. While in flight there is no GC object left for the + * sender's GC to mark, sweep or move. */ + courier = rb_ractor_move_courier_build(obj); + } + EC_POP_TAG(); + if (state != TAG_NONE) { + SIZED_FREE(b); + EC_JUMP_TAG(ec, state); + } + } + else { + v = ractor_prepare_payload(ec, obj, &type, &marshaled); + enum ruby_tag_type state; + EC_PUSH_TAG(ec); + if ((state = EC_EXEC_TAG()) == TAG_NONE) { + b = ractor_basket_alloc(); + } + EC_POP_TAG(); + if (state != TAG_NONE) { + /* Drop the pin list, or every global GC re-pins the dead snapshot from it + * forever (rb_ractor_repin_in_flight walks it unconditionally). The nodes + * stay shref-pinned only until the next global GC clears the bits. */ + rb_ractor_t *cr = rb_ec_ractor_ptr(ec); + free(cr->pin_capture); + cr->pin_capture = NULL; + cr->pin_capture_cnt = cr->pin_capture_capa = 0; + EC_JUMP_TAG(ec, state); + } + /* copy_enter pinned every node at construction with cr->pin_capture as the + * re-pin source; hand it to the basket only after basket_alloc (which may GC) + * so the cover never lapses. A marshaled String is pinned here, after the + * alloc, so an alloc raise leaves no stale pin. */ + if (type == basket_type_copy && marshaled) { + rb_gc_pin_in_flight_message(v); + } + } - struct ractor_basket *b = ractor_basket_alloc(); b->type = type; - b->p.v = v; b->p.exception = exc; + b->p.v = v; + b->p.marshaled = marshaled; + b->p.move_courier = courier; + b->p.pinned = NULL; + b->p.pinned_cnt = 0; + if (type == basket_type_copy) { + /* Hand the pin list to the basket, moving the re-pin cover from + * cr->pin_capture to cr->sending_basket with no safepoint in between. */ + rb_ractor_t *cr = rb_ec_ractor_ptr(ec); + b->p.pinned = cr->pin_capture; + b->p.pinned_cnt = cr->pin_capture_cnt; + VM_ASSERT(cr->sending_basket == NULL); + cr->sending_basket = b; + cr->pin_capture = NULL; + cr->pin_capture_cnt = cr->pin_capture_capa = 0; + } return b; } +/* True while this Ractor materializes an arriving copy: the half-built result + * legitimately points at the sender-resident (pinned) snapshot, so a local GC's + * verifier must not report containment violations, and the copy's own allocations can + * start that GC. */ +bool +rb_ractor_materializing_p(void) +{ + const rb_ractor_t *cr = rb_current_ractor_raw(false); + if (cr == NULL) return false; + /* Only a COPY materialization sets this: move shells reference other shells in + * this objspace, never the sender's graph. The count is per Ractor, so a fiber + * switch keeps it exact. */ + return cr->sync.materializing_copies > 0; +} + static VALUE ractor_basket_value(struct ractor_basket *b) { switch (b->type) { case basket_type_ref: break; - case basket_type_copy: - case basket_type_move: - ractor_reset_belonging(b->p.v); + case basket_type_copy: { + /* Materialize the sender's snapshot into the receiving Ractor's objspace. + * Passing the sender-resident graph by reference would create an unshareable + * cross-objspace edge that neither local GC can follow. The snapshot stays + * pinned in the sender's objspace and becomes garbage there once this copy + * finishes. Marshal.load allocates through this Ractor's normal newobj and + * write-barrier paths. + * + * Rebuilding can raise (marshal load hooks and autoload run user code and an + * async interrupt can arrive anywhere), and those hooks can run a nested + * Ractor.receive. The frame is pushed on the machine stack and popped under a + * TAG, so the chain never leaks a dead materialization or drops an outer one. */ + rb_execution_context_t *ec = rb_current_ec_noinline(); + rb_ractor_t *cr = rb_ec_ractor_ptr(ec); + struct ractor_materialize_frame frame = { + .snapshot = b->p.v, .pinned = b->p.pinned, .pinned_cnt = b->p.pinned_cnt, + .prev = ec->materialize_frames, + }; + ec->materialize_frames = &frame; + cr->sync.materializing_copies++; + VALUE result = Qundef; + enum ruby_tag_type state; + EC_PUSH_TAG(ec); + if ((state = EC_EXEC_TAG()) == TAG_NONE) { + if (b->p.marshaled) { + result = rb_marshal_load(b->p.v); + } + else { + result = ractor_copy_native_try(b->p.v); + if (UNDEF_P(result)) rb_bug("ractor_basket_value: native snapshot not natively copyable"); + } + } + EC_POP_TAG(); + ec->materialize_frames = frame.prev; + cr->sync.materializing_copies--; + /* rb_copy_generic_ivar left the sender-resident snapshot host and fields_obj in + * this EC's gen_fields_cache; the snapshot is garbage on the sender now, and a + * stale cache hit on a reused address would deref a freed foreign fields_obj. + * Invalidate (the raise path resets it the same way). */ + ec->gen_fields_cache.obj = Qundef; + ec->gen_fields_cache.fields_obj = Qundef; + if (state != TAG_NONE) { + /* The basket left the queue and has no other owner, and a raise skips + * accept, so free it here before propagating. */ + ractor_basket_free(b); + EC_JUMP_TAG(ec, state); + } + /* keep rooting result from the stack after the frame is popped */ + b->p.v = result; + RB_GC_GUARD(result); + break; + } + case basket_type_move: { + /* Rebuild the moved graph from the off-heap courier into this Ractor's + * objspace. The sources are already RactorMovedObject (set when the courier + * was built), so move's snapshot semantics hold. The courier is xmalloc'd + * rather than a GC object, so the sender's concurrent local GC never touches + * it; the VALUEs it carries are shareable or immediates, marked and pinned as + * a global GC root by the in-flight registry (ractor.c). + * + * Rebuilding can raise here too (rb_hash_aset on a moved key with a custom + * #hash runs user code, and an async interrupt can arrive). On a raise the + * courier is still owned by the basket, whose teardown frees it. */ + rb_execution_context_t *ec = rb_current_ec_noinline(); + struct rb_ractor_move_courier *courier = b->p.move_courier; + /* Keep the materialized graph on the machine stack (result): it is the only + * root until it reaches the caller. courier_free below runs a long loop, and + * only the malloc'd basket's p.v holding it would give a concurrent global GC a + * wide window. */ + VALUE result = Qundef; + enum ruby_tag_type state; + EC_PUSH_TAG(ec); + if ((state = EC_EXEC_TAG()) == TAG_NONE) { + result = rb_ractor_move_courier_materialize(courier); + } + EC_POP_TAG(); + if (state != TAG_NONE) { + /* An unconsumed courier stays in b->p.move_courier; basket_free frees it. */ + ractor_basket_free(b); + EC_JUMP_TAG(ec, state); + } + rb_ractor_move_courier_free(courier); + b->p.move_courier = NULL; + b->p.v = result; + RB_GC_GUARD(result); break; + } default: VM_ASSERT(0); // unreachable } @@ -1193,6 +1580,15 @@ ractor_send_basket(rb_execution_context_t *ec, const struct ractor_port *rp, str else { b->port_id = ractor_port_id(rp); ractor_queue_enq(rp->r, rp->r->sync.recv_queue, b); + /* From basket_new to the enqueue the sender's sending_basket slot covers + * the re-pin; from here the queue walk does, so drop the slot (no safepoint + * or malloc-triggered GC inside the lock, so the cover never lapses). */ + if (b->type == basket_type_copy) { + rb_ractor_t *scr = rb_current_ractor_raw(false); + if (scr != NULL && scr->sending_basket == b) { + scr->sending_basket = NULL; + } + } } } RACTOR_UNLOCK(rp->r); diff --git a/re.c b/re.c index 3fbe774b789a05..3e4114976e952d 100644 --- a/re.c +++ b/re.c @@ -1080,6 +1080,70 @@ match_set_regs(VALUE match, int num_regs, const OnigPosition *beg, const OnigPos rm->num_regs = num_regs; } +/* Helpers for carrying a MatchData to another objspace via Ractor#send(move:). The match's + * registers are written out to an onig-independent blob so the original malloc'd area can be + * freed, leaving an empty shell behind, and rebuilt from the blob on the receiving side. */ +void * +rb_match_move_dump(VALUE match, VALUE *regexp_out, VALUE *str_out, int *num_regs_out) +{ + struct RMatch *rm = RMATCH(match); + int n = rm->num_regs; + *regexp_out = rm->regexp; + *str_out = rm->str; + *num_regs_out = n; + + OnigPosition *blob = ALLOC_N(OnigPosition, n ? 2 * n : 1); + const OnigPosition *beg = RMATCH_BEG_PTR(match); + const OnigPosition *end = RMATCH_END_PTR(match); + for (int i = 0; i < n; i++) { + blob[2 * i] = beg[i]; + blob[2 * i + 1] = end[i]; + } + + if (FL_TEST_RAW(match, RMATCH_ONIG)) { + onig_region_free(&rm->as.onig, 0); + memset(&rm->as.onig, 0, sizeof(rm->as.onig)); + FL_UNSET_RAW(match, RMATCH_ONIG); + } + if (rm->char_offset) { + ruby_xfree(rm->char_offset); + rm->char_offset = NULL; + rm->char_offset_num_allocated = 0; + } + return blob; +} + +VALUE +rb_match_move_alloc(VALUE klass, int num_regs) +{ + return match_alloc_n(klass, num_regs); +} + +void +rb_match_move_load(VALUE match, VALUE regexp, VALUE str, int num_regs, const void *blob_) +{ + const OnigPosition *blob = blob_; + struct RMatch *rm = RMATCH(match); + RB_OBJ_WRITE(match, &rm->str, str); + RB_OBJ_WRITE(match, &rm->regexp, regexp); + + OnigPosition *beg = ALLOC_N(OnigPosition, num_regs ? num_regs : 1); + OnigPosition *end = ALLOC_N(OnigPosition, num_regs ? num_regs : 1); + for (int i = 0; i < num_regs; i++) { + beg[i] = blob[2 * i]; + end[i] = blob[2 * i + 1]; + } + match_set_regs(match, num_regs, beg, end); + ruby_xfree(beg); + ruby_xfree(end); +} + +void +rb_match_move_free(void *blob) +{ + ruby_xfree(blob); +} + typedef struct { long byte_pos; long char_pos; @@ -1176,8 +1240,8 @@ match_check(VALUE match) } /* :nodoc: */ -static VALUE -match_init_copy(VALUE obj, VALUE orig) +VALUE +rb_match_init_copy(VALUE obj, VALUE orig) { struct RMatch *rm = RMATCH(obj); @@ -5087,7 +5151,7 @@ Init_Regexp(void) rb_undef_method(CLASS_OF(rb_cMatch), "new"); rb_undef_method(CLASS_OF(rb_cMatch), "allocate"); - rb_define_method(rb_cMatch, "initialize_copy", match_init_copy, 1); + rb_define_method(rb_cMatch, "initialize_copy", rb_match_init_copy, 1); rb_define_method(rb_cMatch, "regexp", match_regexp, 0); rb_define_method(rb_cMatch, "names", match_names, 0); rb_define_method(rb_cMatch, "size", match_size, 0); diff --git a/signal.c b/signal.c index 6ec02a5fe8ad3a..77778dc9531081 100644 --- a/signal.c +++ b/signal.c @@ -762,7 +762,7 @@ rb_get_next_signal(void) #if defined SIGSEGV || defined SIGBUS || defined SIGILL || defined SIGFPE static const char *received_signal; # define clear_received_signal() do { \ - if (GET_VM() != NULL) rb_gc_enable(); \ + if (GET_VM() != NULL) rb_gc_local_enable(); \ received_signal = 0; \ } while (0) #else @@ -1046,7 +1046,7 @@ check_reserved_signal_(const char *name, size_t name_len, int signo) } if (GET_VM() != NULL) { - rb_gc_disable_no_rest(); + rb_gc_local_disable_no_rest(); } } #endif diff --git a/spec/ruby/core/argf/read_nonblock_spec.rb b/spec/ruby/core/argf/read_nonblock_spec.rb index 5c6bd52d805b17..044f970bf3fe56 100644 --- a/spec/ruby/core/argf/read_nonblock_spec.rb +++ b/spec/ruby/core/argf/read_nonblock_spec.rb @@ -49,6 +49,14 @@ stdin.should == @chunk1 end + it "raises an ArgumentError if exception: is not true or false" do + argf ['-'] do + -> { @argf.read_nonblock(4, exception: 0) }.should.raise(ArgumentError, /expected true or false/) + -> { @argf.read_nonblock(4, exception: nil) }.should.raise(ArgumentError, /expected true or false/) + -> { @argf.read_nonblock(4, exception: 'false') }.should.raise(ArgumentError, /expected true or false/) + end + end + context "with STDIN" do before do @r, @w = IO.pipe diff --git a/spec/ruby/core/array/pack/shared/encodings.rb b/spec/ruby/core/array/pack/shared/encodings.rb index 0b5a5cc8a01815..34e738c377c1de 100644 --- a/spec/ruby/core/array/pack/shared/encodings.rb +++ b/spec/ruby/core/array/pack/shared/encodings.rb @@ -13,4 +13,8 @@ obj.should_receive(:to_str).and_return(1) -> { [obj].pack(pack_format) }.should.raise(TypeError) end + + it "accepts nil" do + [nil].pack(pack_format).should == "\x00" + end end diff --git a/spec/ruby/core/array/pack/shared/unicode.rb b/spec/ruby/core/array/pack/shared/unicode.rb index 58ba8a8b233f0a..77504b2987b28c 100644 --- a/spec/ruby/core/array/pack/shared/unicode.rb +++ b/spec/ruby/core/array/pack/shared/unicode.rb @@ -93,4 +93,8 @@ [[0x10FFFF].pack("U"), Encoding::UTF_8] ].should be_computed_by(:encoding) end + + it "raises a TypeError when passed nil" do + -> { [nil].pack("U") }.should.raise(TypeError) + end end diff --git a/spec/ruby/core/dir/chdir_spec.rb b/spec/ruby/core/dir/chdir_spec.rb index 2dc598e2a9562d..ec0537b8ecad85 100644 --- a/spec/ruby/core/dir/chdir_spec.rb +++ b/spec/ruby/core/dir/chdir_spec.rb @@ -65,6 +65,26 @@ def to_str; DirSpecs.mock_dir; end Dir.pwd.should == @original end + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + dir = tmp("dir_chdir_\u{3042}") + non_utf8_dir = dir.encode(Encoding::Windows_31J) + + begin + mkdir_p(dir) + original = Dir.pwd + begin + Dir.chdir(non_utf8_dir).should == 0 + Dir.pwd.should == dir + ensure + Dir.chdir(original) + end + ensure + rm_r dir + end + end + end + it "returns the value of the block when a block is given" do Dir.chdir(@original) { :block_value }.should == :block_value end @@ -123,6 +143,23 @@ def to_str; DirSpecs.mock_dir; end Dir.pwd.should == @original end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters when given a block" do + dir = tmp("dir_chdir_\u{3042}") + non_utf8_dir = dir.encode(Encoding::Windows_31J) + + begin + mkdir_p(dir) + current_dir = nil + Dir.chdir(non_utf8_dir) { current_dir = Dir.pwd } + current_dir.should == dir + Dir.pwd.should == @original + ensure + rm_r dir + end + end + end end describe "Dir#chdir" do diff --git a/spec/ruby/core/dir/delete_spec.rb b/spec/ruby/core/dir/delete_spec.rb index 2dbd461c945d41..4743f15b1c7e9c 100644 --- a/spec/ruby/core/dir/delete_spec.rb +++ b/spec/ruby/core/dir/delete_spec.rb @@ -61,4 +61,19 @@ end end end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + dir = tmp("dir_delete_\u{3042}") + non_utf8_dir = dir.encode(Encoding::Windows_31J) + + begin + mkdir_p(dir) + Dir.delete(non_utf8_dir).should == 0 + File.directory?(dir).should == false + ensure + rm_r dir + end + end + end end diff --git a/spec/ruby/core/dir/element_reference_spec.rb b/spec/ruby/core/dir/element_reference_spec.rb index 092114bed44e76..f8a75f80eb0d47 100644 --- a/spec/ruby/core/dir/element_reference_spec.rb +++ b/spec/ruby/core/dir/element_reference_spec.rb @@ -30,4 +30,30 @@ Dir[pat1, pat2].should == %w[file_one.ext file_two.ext] end + + it "preserves the encoding of the path" do + pattern1 = "file_one.ext".encode(Encoding::EUC_JP) + pattern2 = "file_two.ext".encode(Encoding::EUC_JP) + results = Dir[pattern1, pattern2] + results.map(&:encoding).should == [Encoding::EUC_JP, Encoding::EUC_JP] + end + + platform_is :darwin do + it "accepts multiple patterns in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + dir = tmp("dir_glob_\u{3042}") + utf8_file = File.join(dir, "file.txt") + non_utf8_pattern = File.join(dir, "*.txt").encode(Encoding::Windows_31J) + + begin + mkdir_p(dir) + touch(utf8_file) + Dir[non_utf8_pattern, non_utf8_pattern].should == [ + utf8_file.encode(Encoding::Windows_31J), + utf8_file.encode(Encoding::Windows_31J) + ] + ensure + rm_r dir + end + end + end end diff --git a/spec/ruby/core/dir/empty_spec.rb b/spec/ruby/core/dir/empty_spec.rb index 3b6b2bac85336d..5f7013c0377c3b 100644 --- a/spec/ruby/core/dir/empty_spec.rb +++ b/spec/ruby/core/dir/empty_spec.rb @@ -28,4 +28,18 @@ it "raises ENOENT for nonexistent directories" do -> { Dir.empty? tmp("nonexistent") }.should.raise(Errno::ENOENT) end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + dir = tmp("dir_empty_\u{3042}") + non_utf8_dir = dir.encode(Encoding::Windows_31J) + + begin + mkdir_p(dir) + Dir.empty?(non_utf8_dir).should == true + ensure + rm_r dir + end + end + end end diff --git a/spec/ruby/core/dir/entries_spec.rb b/spec/ruby/core/dir/entries_spec.rb index f3ca49b26d4898..2abc6a962e4abe 100644 --- a/spec/ruby/core/dir/entries_spec.rb +++ b/spec/ruby/core/dir/entries_spec.rb @@ -67,4 +67,18 @@ it "raises a SystemCallError if called with a nonexistent directory" do -> { Dir.entries DirSpecs.nonexistent }.should.raise(SystemCallError) end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + dir = tmp("dir_entries_\u{3042}") + non_utf8_dir = dir.encode(Encoding::Windows_31J) + + begin + mkdir_p(dir) + Dir.entries(non_utf8_dir).should.include?(".".encode(Encoding::Windows_31J)) + ensure + rm_r dir + end + end + end end diff --git a/spec/ruby/core/dir/foreach_spec.rb b/spec/ruby/core/dir/foreach_spec.rb index 2a2265a0298009..60fb5e35dc5a2e 100644 --- a/spec/ruby/core/dir/foreach_spec.rb +++ b/spec/ruby/core/dir/foreach_spec.rb @@ -65,4 +65,18 @@ end end end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + dir = tmp("dir_foreach_\u{3042}") + non_utf8_dir = dir.encode(Encoding::Windows_31J) + + begin + mkdir_p(dir) + Dir.foreach(non_utf8_dir).to_a.should.include?(".".encode(Encoding::Windows_31J)) + ensure + rm_r dir + end + end + end end diff --git a/spec/ruby/core/dir/glob_spec.rb b/spec/ruby/core/dir/glob_spec.rb index 9e81feb15fc1cd..f6bf132dd4cea0 100644 --- a/spec/ruby/core/dir/glob_spec.rb +++ b/spec/ruby/core/dir/glob_spec.rb @@ -67,6 +67,14 @@ Dir.glob('**', File::FNM_DOTMATCH).sort.should == DirSpecs.expected_glob_paths end + it "accepts the flags keyword argument as an alternative to the positional argument" do + Dir.glob('**', flags: File::FNM_DOTMATCH).sort.should == DirSpecs.expected_glob_paths + end + + it "prefers the keyword argument over the positional flag argument" do + Dir.glob('**', :ignored, flags: File::FNM_DOTMATCH).sort.should == DirSpecs.expected_glob_paths + end + it "recursively matches any subdirectories except './' or '../' with '**/' from the current directory and option File::FNM_DOTMATCH" do expected = %w[ .dotsubdir/ @@ -359,4 +367,9 @@ Dir.glob('**/*/nondotfile').sort.should == expected end end + + it "preserves the encoding of the path" do + pattern = "file_one.ext".encode(Encoding::EUC_JP) + Dir.glob(pattern).first.encoding.should == Encoding::EUC_JP + end end diff --git a/spec/ruby/core/dir/mkdir_spec.rb b/spec/ruby/core/dir/mkdir_spec.rb index 37513e417ad059..99a3297c670e40 100644 --- a/spec/ruby/core/dir/mkdir_spec.rb +++ b/spec/ruby/core/dir/mkdir_spec.rb @@ -81,6 +81,20 @@ it "raises Errno::EEXIST if the argument points to the existing file" do -> { Dir.mkdir("#{DirSpecs.mock_dir}/file_one.ext") }.should.raise(Errno::EEXIST) end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + dir = tmp("dir_mkdir_\u{3042}") + non_utf8_dir = dir.encode(Encoding::Windows_31J) + + begin + Dir.mkdir(non_utf8_dir).should == 0 + File.directory?(dir).should == true + ensure + rm_r dir + end + end + end end # The permissions flag are not supported on Windows as stated in documentation: diff --git a/spec/ruby/core/dir/shared/chroot.rb b/spec/ruby/core/dir/shared/chroot.rb index e4e61037996aa8..e6956e88831950 100644 --- a/spec/ruby/core/dir/shared/chroot.rb +++ b/spec/ruby/core/dir/shared/chroot.rb @@ -41,4 +41,18 @@ p.should_receive(:to_path).and_return(@real_root) Dir.send(@method, p) end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + dir = tmp("dir_chroot_\u{3042}") + non_utf8_dir = dir.encode(Encoding::Windows_31J) + + begin + mkdir_p(dir) + Dir.chroot(non_utf8_dir).should == 0 + ensure + rm_r dir + end + end + end end diff --git a/spec/ruby/core/dir/shared/glob.rb b/spec/ruby/core/dir/shared/glob.rb index 86aa105259a0cb..468858ed6c72c5 100644 --- a/spec/ruby/core/dir/shared/glob.rb +++ b/spec/ruby/core/dir/shared/glob.rb @@ -27,6 +27,22 @@ -> {Dir.send(@method, "file_o*\0file_t*")}.should.raise ArgumentError, /nul-separated/ end + platform_is :darwin do + it "accepts a pattern in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + dir = tmp("dir_glob_\u{3042}") + utf8_file = File.join(dir, "file.txt") + non_utf8_pattern = File.join(dir, "*.txt").encode(Encoding::Windows_31J) + + begin + mkdir_p(dir) + touch(utf8_file) + Dir.send(@method, non_utf8_pattern).should == [utf8_file.encode(Encoding::Windows_31J)] + ensure + rm_r dir + end + end + end + it "result is sorted by default" do result = Dir.send(@method, '*') result.should == result.sort @@ -305,6 +321,21 @@ subdir_two/nondotfile.ext] end + it "matches when ** is at start of pattern and next segment is at the base of the current directory" do + Dir.send(@method, "**/subdir_one/nondotfile").should == ["subdir_one/nondotfile"] + Dir.send(@method, "**/subdir_{one,two}/nondotfile").should == ["subdir_one/nondotfile", "subdir_two/nondotfile"] + end + + it "matches when ** is at start of pattern and following segments are at different depths in the directory structure" do + Dir.send(@method, "**/{structure,subdir_one}/{bar,nondotfile}").should == ["deeply/nested/directory/structure/bar", "subdir_one/nondotfile"] + end + + it "matches when ** is at start of pattern and followed by non-glob segments" do + Dir.send(@method, "**/deeply").should == ["deeply"] + Dir.send(@method, "**/deeply/nested").should == ["deeply/nested"] + Dir.send(@method, "**/deeply/nested/.dotfile.ext").should == ["deeply/nested/.dotfile.ext"] + end + it "ignores matching through directories that doesn't exist" do Dir.send(@method, "deeply/notthere/blah*/whatever").should == [] end @@ -396,8 +427,12 @@ @mock_dir = File.expand_path tmp('dir_glob_mock') %w[ - a/x/b/y/e - a/x/b/y/b/z/e + a/file.txt + a/1/file.txt + a/1/b/file.txt + a/1/b/2/file.txt + a/1/b/2/b/file.txt + a/1/b/2/b/3/file.txt ].each do |path| file = File.join @mock_dir, path mkdir_p File.dirname(file) @@ -414,28 +449,53 @@ it "matches multiple recursives" do expected = %w[ - a/x/b/y/b/z/e - a/x/b/y/e + a/1/b/2/b/3/file.txt + a/1/b/2/b/file.txt + a/1/b/2/file.txt + a/1/b/file.txt + ] + + Dir.send(@method, 'a/**/b/**/file.txt').uniq.sort.should == expected + end + + it "matches multiple recursives when a recursive is at start of pattern" do + expected = %w[ + a/1/b/2/b/3/file.txt + a/1/b/2/b/file.txt + a/1/b/2/file.txt + a/1/b/file.txt + a/1/file.txt + a/file.txt ] - Dir.send(@method, 'a/**/b/**/e').uniq.sort.should == expected + Dir.send(@method, '**/a/**/file.txt').uniq.sort.should == expected + + expected.pop + Dir.send(@method, '**/1/**/file.txt').uniq.sort.should == expected + + expected.pop + Dir.send(@method, '**/b/**/file.txt').uniq.sort.should == expected end platform_is_not :windows do it "ignores symlinks" do - file = File.join @mock_dir, 'b/z/e' - link = File.join @mock_dir, 'a/y' + file = File.join @mock_dir, 'b/3/file.txt' + link = File.join @mock_dir, 'a/2' mkdir_p File.dirname(file) touch file File.symlink(File.dirname(file), link) expected = %w[ - a/x/b/y/b/z/e - a/x/b/y/e + a/1/b/2/b/3/file.txt + a/1/b/2/b/file.txt + a/1/b/2/file.txt + a/1/b/file.txt + a/1/file.txt + a/file.txt ] - Dir.send(@method, 'a/**/e').uniq.sort.should == expected + Dir.send(@method, 'a/**/file.txt').uniq.sort.should == expected end end end diff --git a/spec/ruby/core/enumerable/compact_spec.rb b/spec/ruby/core/enumerable/compact_spec.rb index 1895430c4ed23f..a3bd4b686726d9 100644 --- a/spec/ruby/core/enumerable/compact_spec.rb +++ b/spec/ruby/core/enumerable/compact_spec.rb @@ -2,6 +2,18 @@ require_relative 'fixtures/classes' describe "Enumerable#compact" do + describe "value packing of source yields" do + it "packs a multi-argument source yield into an Array" do + e = Enumerator.new { |y| y.yield 1, 2 } + e.compact.should == [[1, 2]] + end + + it "removes a zero-argument source yield like nil" do + e = Enumerator.new { |y| y.yield; y.yield :v } + e.compact.should == [:v] + end + end + it 'returns array without nil elements' do arr = EnumerableSpecs::Numerous.new(nil, 1, 2, nil, true) arr.compact.should == [1, 2, true] diff --git a/spec/ruby/core/enumerable/drop_spec.rb b/spec/ruby/core/enumerable/drop_spec.rb index 8d95f464b35637..18d8a9822fb4f4 100644 --- a/spec/ruby/core/enumerable/drop_spec.rb +++ b/spec/ruby/core/enumerable/drop_spec.rb @@ -1,7 +1,15 @@ require_relative '../../spec_helper' require_relative 'fixtures/classes' +require_relative 'shared/value_packing' describe "Enumerable#drop" do + describe "value packing of source yields" do + before :each do + @take = -> e { e.drop(0) } + end + it_behaves_like :enumerable_value_packing, nil + end + before :each do @enum = EnumerableSpecs::Numerous.new(3, 2, 1, :go) end diff --git a/spec/ruby/core/enumerable/drop_while_spec.rb b/spec/ruby/core/enumerable/drop_while_spec.rb index 4b4fdf2d4f7871..3d5bd06dc56abd 100644 --- a/spec/ruby/core/enumerable/drop_while_spec.rb +++ b/spec/ruby/core/enumerable/drop_while_spec.rb @@ -1,8 +1,16 @@ require_relative '../../spec_helper' require_relative 'fixtures/classes' require_relative 'shared/enumerable_enumeratorized' +require_relative 'shared/value_packing' describe "Enumerable#drop_while" do + describe "value packing of source yields" do + before :each do + @take = -> e { e.drop_while { false } } + end + it_behaves_like :enumerable_value_packing, nil + end + before :each do @enum = EnumerableSpecs::Numerous.new(3, 2, 1, :go) end diff --git a/spec/ruby/core/enumerable/reject_spec.rb b/spec/ruby/core/enumerable/reject_spec.rb index 31e89f5b0e5ead..7bd32fe4dfdc77 100644 --- a/spec/ruby/core/enumerable/reject_spec.rb +++ b/spec/ruby/core/enumerable/reject_spec.rb @@ -1,8 +1,16 @@ require_relative '../../spec_helper' require_relative 'fixtures/classes' require_relative 'shared/enumerable_enumeratorized' +require_relative 'shared/value_packing' describe "Enumerable#reject" do + describe "value packing of source yields" do + before :each do + @take = -> e { e.reject { false } } + end + it_behaves_like :enumerable_value_packing, nil + end + it "returns an array of the elements for which block is false" do EnumerableSpecs::Numerous.new.reject { |i| i > 3 }.should == [2, 3, 1] entries = (1..10).to_a diff --git a/spec/ruby/core/enumerable/select_spec.rb b/spec/ruby/core/enumerable/select_spec.rb index a53c228a447420..35e344c5842966 100644 --- a/spec/ruby/core/enumerable/select_spec.rb +++ b/spec/ruby/core/enumerable/select_spec.rb @@ -1,8 +1,16 @@ require_relative '../../spec_helper' require_relative 'fixtures/classes' require_relative 'shared/enumerable_enumeratorized' +require_relative 'shared/value_packing' describe "Enumerable#select" do + describe "value packing of source yields" do + before :each do + @take = -> e { e.select { true } } + end + it_behaves_like :enumerable_value_packing, nil + end + before :each do ScratchPad.record [] @elements = (1..10).to_a diff --git a/spec/ruby/core/enumerable/shared/enumeratorized.rb b/spec/ruby/core/enumerable/shared/enumeratorized.rb index 05d27b578326f2..41a34451e0945a 100644 --- a/spec/ruby/core/enumerable/shared/enumeratorized.rb +++ b/spec/ruby/core/enumerable/shared/enumeratorized.rb @@ -26,7 +26,6 @@ @object.cycle(2).size.should == @object.size * 2 @object.cycle(7).size.should == @object.size * 7 @object.cycle(0).size.should == 0 - @empty_object.cycle(2).size.should == 0 end it "should be zero when the argument passed is 0 or less" do @@ -36,6 +35,12 @@ it "should be Float::INFINITY when no argument is passed" do @object.cycle.size.should == Float::INFINITY end + + it "should be zero for an empty object" do + @empty_object.cycle.size.should == 0 + @empty_object.cycle(0).size.should == 0 + @empty_object.cycle(2).size.should == 0 + end end end end diff --git a/spec/ruby/core/enumerable/take_while_spec.rb b/spec/ruby/core/enumerable/take_while_spec.rb index 918bfc897d3bca..51951b10711f9b 100644 --- a/spec/ruby/core/enumerable/take_while_spec.rb +++ b/spec/ruby/core/enumerable/take_while_spec.rb @@ -1,8 +1,16 @@ require_relative '../../spec_helper' require_relative 'fixtures/classes' require_relative 'shared/enumerable_enumeratorized' +require_relative 'shared/value_packing' describe "Enumerable#take_while" do + describe "value packing of source yields" do + before :each do + @take = -> e { e.take_while { true } } + end + it_behaves_like :enumerable_value_packing, nil + end + before :each do @enum = EnumerableSpecs::Numerous.new(3, 2, 1, :go) end diff --git a/spec/ruby/core/enumerable/uniq_spec.rb b/spec/ruby/core/enumerable/uniq_spec.rb index a1ed44796fe3ba..e2999b8690fc83 100644 --- a/spec/ruby/core/enumerable/uniq_spec.rb +++ b/spec/ruby/core/enumerable/uniq_spec.rb @@ -1,7 +1,15 @@ require_relative '../../spec_helper' require_relative 'fixtures/classes' +require_relative 'shared/value_packing' describe 'Enumerable#uniq' do + describe "value packing of source yields" do + before :each do + @take = -> e { e.uniq } + end + it_behaves_like :enumerable_value_packing, nil + end + it 'returns an array that contains only unique elements' do [0, 1, 2, 3].to_enum.uniq { |n| n.even? }.should == [0, 1] end diff --git a/spec/ruby/core/enumerator/lazy/compact_spec.rb b/spec/ruby/core/enumerator/lazy/compact_spec.rb index 7305e1c9c4b7fa..baa80acd3fe9ae 100644 --- a/spec/ruby/core/enumerator/lazy/compact_spec.rb +++ b/spec/ruby/core/enumerator/lazy/compact_spec.rb @@ -2,6 +2,23 @@ require_relative 'fixtures/classes' describe "Enumerator::Lazy#compact" do + # Cannot use shared/value_packing.rb examples: the packed nil is removed by #compact. + describe "value packing of source yields" do + it "packs a multi-argument source yield into an Array" do + e = Enumerator.new { |y| y.yield 1, 2 } + args = nil + e.lazy.compact.each { |*a| args = a } + args.should == [[1, 2]] + end + + it "removes a zero-argument source yield like nil" do + e = Enumerator.new { |y| y.yield; y.yield :v } + collected = [] + e.lazy.compact.each { |*a| collected << a } + collected.should == [[:v]] + end + end + it 'returns array without nil elements' do arr = [1, nil, 3, false, 5].to_enum.lazy.compact arr.should.instance_of?(Enumerator::Lazy) diff --git a/spec/ruby/core/enumerator/lazy/drop_spec.rb b/spec/ruby/core/enumerator/lazy/drop_spec.rb index 95ac7e9ecce610..099dc3265a4239 100644 --- a/spec/ruby/core/enumerator/lazy/drop_spec.rb +++ b/spec/ruby/core/enumerator/lazy/drop_spec.rb @@ -2,8 +2,16 @@ require_relative '../../../spec_helper' require_relative 'fixtures/classes' +require_relative '../../enumerable/shared/value_packing' describe "Enumerator::Lazy#drop" do + describe "value packing of source yields (matches Enumerable#drop)" do + before :each do + @take = -> e { e.lazy.drop(0) } + end + it_behaves_like :enumerable_value_packing, nil + end + before :each do @yieldsmixed = EnumeratorLazySpecs::YieldsMixed.new.to_enum.lazy @eventsmixed = EnumeratorLazySpecs::EventsMixed.new.to_enum.lazy @@ -34,6 +42,14 @@ end end + describe "when the returned lazy enumerator is evaluated by .force" do + it "return same value when called twice" do + lazy = [0, 1].lazy.drop(1) + lazy.force.should == [1] + lazy.force.should == [1] + end + end + describe "on a nested Lazy" do it "sets difference of given count with old size to new size" do Enumerator::Lazy.new(Object.new, 100) {}.drop(20).drop(50).size.should == 30 diff --git a/spec/ruby/core/enumerator/lazy/drop_while_spec.rb b/spec/ruby/core/enumerator/lazy/drop_while_spec.rb index 65f3007dec2b27..e9862ba028c09f 100644 --- a/spec/ruby/core/enumerator/lazy/drop_while_spec.rb +++ b/spec/ruby/core/enumerator/lazy/drop_while_spec.rb @@ -2,8 +2,16 @@ require_relative '../../../spec_helper' require_relative 'fixtures/classes' +require_relative '../../enumerable/shared/value_packing' describe "Enumerator::Lazy#drop_while" do + describe "value packing of source yields (matches Enumerable#drop_while)" do + before :each do + @take = -> e { e.lazy.drop_while { false } } + end + it_behaves_like :enumerable_value_packing, nil + end + before :each do @yieldsmixed = EnumeratorLazySpecs::YieldsMixed.new.to_enum.lazy @eventsmixed = EnumeratorLazySpecs::EventsMixed.new.to_enum.lazy @@ -43,6 +51,14 @@ -> { @yieldsmixed.drop_while }.should.raise(ArgumentError) end + describe "when the returned lazy enumerator is evaluated by .force" do + it "return same value when called twice" do + lazy = [0, 1, 2, 3].lazy.drop_while { |v| v < 2 } + lazy.force.should == [2, 3] + lazy.force.should == [2, 3] + end + end + describe "on a nested Lazy" do it "sets #size to nil" do Enumerator::Lazy.new(Object.new, 100) {}.take(20).drop_while { |v| v }.size.should == nil diff --git a/spec/ruby/core/enumerator/lazy/reject_spec.rb b/spec/ruby/core/enumerator/lazy/reject_spec.rb index 374d4df14ed000..23c882b1d5b84f 100644 --- a/spec/ruby/core/enumerator/lazy/reject_spec.rb +++ b/spec/ruby/core/enumerator/lazy/reject_spec.rb @@ -2,8 +2,16 @@ require_relative '../../../spec_helper' require_relative 'fixtures/classes' +require_relative '../../enumerable/shared/value_packing' describe "Enumerator::Lazy#reject" do + describe "value packing of source yields (matches Enumerable#reject)" do + before :each do + @take = -> e { e.lazy.reject { false } } + end + it_behaves_like :enumerable_value_packing, nil + end + before :each do @yieldsmixed = EnumeratorLazySpecs::YieldsMixed.new.to_enum.lazy @eventsmixed = EnumeratorLazySpecs::EventsMixed.new.to_enum.lazy diff --git a/spec/ruby/core/enumerator/lazy/select_spec.rb b/spec/ruby/core/enumerator/lazy/select_spec.rb index 29c8f1bd8096f8..5d6b68cff2e737 100644 --- a/spec/ruby/core/enumerator/lazy/select_spec.rb +++ b/spec/ruby/core/enumerator/lazy/select_spec.rb @@ -1,7 +1,15 @@ require_relative '../../../spec_helper' require_relative 'fixtures/classes' +require_relative '../../enumerable/shared/value_packing' describe "Enumerator::Lazy#select" do + describe "value packing of source yields (matches Enumerable#select)" do + before :each do + @take = -> e { e.lazy.select { true } } + end + it_behaves_like :enumerable_value_packing, nil + end + before :each do @yieldsmixed = EnumeratorLazySpecs::YieldsMixed.new.to_enum.lazy @eventsmixed = EnumeratorLazySpecs::EventsMixed.new.to_enum.lazy diff --git a/spec/ruby/core/enumerator/lazy/take_spec.rb b/spec/ruby/core/enumerator/lazy/take_spec.rb index 2dd5b939e2b4b0..9976c587a9e12a 100644 --- a/spec/ruby/core/enumerator/lazy/take_spec.rb +++ b/spec/ruby/core/enumerator/lazy/take_spec.rb @@ -49,6 +49,12 @@ @eventsmixed.take(0).force ScratchPad.recorded.should == [] end + + it "return same value when called twice" do + lazy = [0, 1].lazy.take(1) + lazy.force.should == [0] + lazy.force.should == [0] + end end describe "on a nested Lazy" do diff --git a/spec/ruby/core/enumerator/lazy/take_while_spec.rb b/spec/ruby/core/enumerator/lazy/take_while_spec.rb index c369712c56c208..1dc62b242318d9 100644 --- a/spec/ruby/core/enumerator/lazy/take_while_spec.rb +++ b/spec/ruby/core/enumerator/lazy/take_while_spec.rb @@ -2,8 +2,16 @@ require_relative '../../../spec_helper' require_relative 'fixtures/classes' +require_relative '../../enumerable/shared/value_packing' describe "Enumerator::Lazy#take_while" do + describe "value packing of source yields (matches Enumerable#take_while)" do + before :each do + @take = -> e { e.lazy.take_while { true } } + end + it_behaves_like :enumerable_value_packing, nil + end + before :each do @yieldsmixed = EnumeratorLazySpecs::YieldsMixed.new.to_enum.lazy @eventsmixed = EnumeratorLazySpecs::EventsMixed.new.to_enum.lazy diff --git a/spec/ruby/core/enumerator/lazy/uniq_spec.rb b/spec/ruby/core/enumerator/lazy/uniq_spec.rb index d30ed8df2f270f..d928cb06ebf819 100644 --- a/spec/ruby/core/enumerator/lazy/uniq_spec.rb +++ b/spec/ruby/core/enumerator/lazy/uniq_spec.rb @@ -1,7 +1,15 @@ require_relative '../../../spec_helper' require_relative 'fixtures/classes' +require_relative '../../enumerable/shared/value_packing' describe 'Enumerator::Lazy#uniq' do + describe "value packing of source yields (matches Enumerable#uniq)" do + before :each do + @take = -> e { e.lazy.uniq } + end + it_behaves_like :enumerable_value_packing, nil + end + context 'without block' do before :each do @lazy = [0, 1, 0, 1].to_enum.lazy.uniq diff --git a/spec/ruby/core/enumerator/lazy/with_index_spec.rb b/spec/ruby/core/enumerator/lazy/with_index_spec.rb index 2e983fd3b15701..513d0317bac2ad 100644 --- a/spec/ruby/core/enumerator/lazy/with_index_spec.rb +++ b/spec/ruby/core/enumerator/lazy/with_index_spec.rb @@ -4,6 +4,29 @@ require_relative 'fixtures/classes' describe "Enumerator::Lazy#with_index" do + describe "value packing of source yields" do + it "pairs a packed Array with the index for a multi-argument source yield" do + e = Enumerator.new { |y| y.yield 1, 2 } + args = nil + e.lazy.with_index.each { |*a| args = a } + args.should == [[[1, 2], 0]] + end + + it "pairs nil with the index for a zero-argument source yield" do + e = Enumerator.new { |y| y.yield } + args = nil + e.lazy.with_index.each { |*a| args = a } + args.should == [[nil, 0]] + end + + it "calls the block with the packed value and the index" do + e = Enumerator.new { |y| y.yield 1, 2 } + seen = [] + e.lazy.with_index { |v, i| seen << [v, i] }.force + seen.should == [[[1, 2], 0]] + end + end + it "enumerates with an index" do (0..Float::INFINITY).lazy.with_index.map { |i, idx| [i, idx] }.first(3).should == [[0, 0], [1, 1], [2, 2]] end diff --git a/spec/ruby/core/enumerator/lazy/zip_spec.rb b/spec/ruby/core/enumerator/lazy/zip_spec.rb index 9f612542d71f5b..e7e5dfca45471a 100644 --- a/spec/ruby/core/enumerator/lazy/zip_spec.rb +++ b/spec/ruby/core/enumerator/lazy/zip_spec.rb @@ -47,6 +47,14 @@ -> { @yieldsmixed.zip [], Object.new, [] }.should.raise(TypeError) end + describe "when the returned lazy enumerator is evaluated by .force" do + it "return same value when called twice" do + lazy = [0, 1].lazy.zip([2, 3]) + lazy.force.should == [[0, 2], [1, 3]] + lazy.force.should == [[0, 2], [1, 3]] + end + end + describe "on a nested Lazy" do it "keeps size" do Enumerator::Lazy.new(Object.new, 100) {}.map {}.zip([], []).size.should == 100 diff --git a/spec/ruby/core/env/each_key_spec.rb b/spec/ruby/core/env/each_key_spec.rb index ed55beac7c1460..77a3bb7d82b0ee 100644 --- a/spec/ruby/core/env/each_key_spec.rb +++ b/spec/ruby/core/env/each_key_spec.rb @@ -1,6 +1,5 @@ require_relative '../../spec_helper' require_relative '../enumerable/shared/enumeratorized' -require_relative 'fixtures/common' describe "ENV.each_key" do @@ -25,9 +24,30 @@ enum.to_a.should == ENV.keys end - it "returns keys in the locale encoding" do - ENV.each_key do |key| - key.encoding.should == ENVSpecs.encoding + platform_is_not :windows do + it "returns keys in the locale encoding" do + ENV.each_key do |key| + key.encoding.should == Encoding.find('locale') + end + end + end + + # https://bugs.ruby-lang.org/issues/20958 + platform_is :windows do + ruby_version_is ""..."4.1" do + it "returns keys in the locale encoding" do + ENV.each_key do |key| + key.encoding.should == Encoding.find('locale') + end + end + end + + ruby_version_is "4.1" do + it "returns the keys in UTF-8" do + ENV.each_key do |key| + key.encoding.should == Encoding::UTF_8 + end + end end end diff --git a/spec/ruby/core/env/fixtures/common.rb b/spec/ruby/core/env/fixtures/common.rb index 8d5057614d8f6d..48fe7ac146ad86 100644 --- a/spec/ruby/core/env/fixtures/common.rb +++ b/spec/ruby/core/env/fixtures/common.rb @@ -1,9 +1,7 @@ module ENVSpecs def self.encoding - locale = Encoding.find('locale') - if ruby_version_is '3' and platform_is :windows - locale = Encoding::UTF_8 - end - locale + return Encoding::UTF_8 if platform_is :windows + + Encoding.find('locale') end end diff --git a/spec/ruby/core/env/keys_spec.rb b/spec/ruby/core/env/keys_spec.rb index c66d44fafd189e..0efe841ccfdd5f 100644 --- a/spec/ruby/core/env/keys_spec.rb +++ b/spec/ruby/core/env/keys_spec.rb @@ -1,5 +1,4 @@ require_relative '../../spec_helper' -require_relative 'fixtures/common' describe "ENV.keys" do @@ -7,9 +6,30 @@ ENV.keys.should == ENV.to_hash.keys end - it "returns the keys in the locale encoding" do - ENV.keys.each do |key| - key.encoding.should == ENVSpecs.encoding + platform_is_not :windows do + it "returns the keys in the locale encoding" do + ENV.keys.each do |key| + key.encoding.should == Encoding.find('locale') + end + end + end + + # https://bugs.ruby-lang.org/issues/20958 + platform_is :windows do + ruby_version_is ""..."4.1" do + it "returns the keys in the locale encoding" do + ENV.keys.each do |key| + key.encoding.should == Encoding.find('locale') + end + end + end + + ruby_version_is "4.1" do + it "returns the keys in UTF-8" do + ENV.keys.each do |key| + key.encoding.should == Encoding::UTF_8 + end + end end end end diff --git a/spec/ruby/core/exception/detailed_message_spec.rb b/spec/ruby/core/exception/detailed_message_spec.rb index 9df164a1cf393b..6f81d3771b3ded 100644 --- a/spec/ruby/core/exception/detailed_message_spec.rb +++ b/spec/ruby/core/exception/detailed_message_spec.rb @@ -44,6 +44,11 @@ def exception.detailed_message(**) StandardError.new("").detailed_message(highlight: true).should == "\e[1;4mStandardError\e[m" end + it "raises an ArgumentError if exception: is not true or false" do + -> { StandardError.new("").detailed_message(highlight: 0) }.should.raise ArgumentError, /expected true or false/ + -> { StandardError.new("").detailed_message(highlight: 'false') }.should.raise ArgumentError, /expected true or false/ + end + it "allows and ignores other keyword arguments" do RuntimeError.new("new error").detailed_message(foo: true).should == "new error (RuntimeError)" end diff --git a/spec/ruby/core/exception/full_message_spec.rb b/spec/ruby/core/exception/full_message_spec.rb index 5a5e0a2b3a0090..d4d2c0940f6bfa 100644 --- a/spec/ruby/core/exception/full_message_spec.rb +++ b/spec/ruby/core/exception/full_message_spec.rb @@ -38,6 +38,11 @@ e.full_message(order: :bottom, highlight: false).should =~ /b.rb:2.*a.rb:1/m end + it "raises an ArgumentError if exception: is not true or false" do + -> { StandardError.new("").full_message(highlight: 0) }.should.raise ArgumentError, /expected true or false/ + -> { StandardError.new("").full_message(highlight: 'false') }.should.raise ArgumentError, /expected true or false/ + end + it "shows the caller if the exception has no backtrace" do e = RuntimeError.new("Some runtime error") e.backtrace.should == nil diff --git a/spec/ruby/core/file/absolute_path_spec.rb b/spec/ruby/core/file/absolute_path_spec.rb index fc12985a75b734..8029183df399f5 100644 --- a/spec/ruby/core/file/absolute_path_spec.rb +++ b/spec/ruby/core/file/absolute_path_spec.rb @@ -91,4 +91,10 @@ it "calls #to_path on its argument" do File.absolute_path(mock_to_path(@abs)).should == @abs end + + it "preserves the encoding of the path" do + path = "foo/bar".encode(Encoding::EUC_JP) + File.absolute_path(path).encoding.should == Encoding::EUC_JP + File.absolute_path(path, "dir".encode(Encoding::EUC_JP)).encoding.should == Encoding::EUC_JP + end end diff --git a/spec/ruby/core/file/atime_spec.rb b/spec/ruby/core/file/atime_spec.rb index af9393bef496b0..a01657087d979d 100644 --- a/spec/ruby/core/file/atime_spec.rb +++ b/spec/ruby/core/file/atime_spec.rb @@ -41,6 +41,22 @@ it "accepts an object that has a #to_path method" do File.atime(mock_to_path(@file)) end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_atime_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + File.atime(non_utf8_path).should.is_a?(Time) + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end describe "File#atime" do diff --git a/spec/ruby/core/file/basename_spec.rb b/spec/ruby/core/file/basename_spec.rb index 77afe5c22fbf4e..bb83d77c96272c 100644 --- a/spec/ruby/core/file/basename_spec.rb +++ b/spec/ruby/core/file/basename_spec.rb @@ -180,12 +180,6 @@ File.basename('/path/Офис.m4a').should == "Офис.m4a" end - it "returns the basename with the same encoding as the original" do - basename = File.basename('C:/Users/Scuby Pagrubý'.encode(Encoding::Windows_1250)) - basename.should == 'Scuby Pagrubý'.encode(Encoding::Windows_1250) - basename.encoding.should == Encoding::Windows_1250 - end - it "returns a new unfrozen String" do exts = [nil, '.rb', '.*', '.txt'] ['foo.rb','//', '/test/', 'test'].each do |example| @@ -202,4 +196,10 @@ end end + it "preserves the encoding of the path" do + path = "foo.bar".encode(Encoding::EUC_JP) + File.basename(path).encoding.should == Encoding::EUC_JP + File.basename(path, ".bar").encoding.should == Encoding::EUC_JP + File.basename(path, ".bar".encode(Encoding::EUC_JP)).encoding.should == Encoding::EUC_JP + end end diff --git a/spec/ruby/core/file/birthtime_spec.rb b/spec/ruby/core/file/birthtime_spec.rb index f439970c308483..25ee55b833e2e3 100644 --- a/spec/ruby/core/file/birthtime_spec.rb +++ b/spec/ruby/core/file/birthtime_spec.rb @@ -35,6 +35,24 @@ e.message.should.start_with?(*not_implemented_messages) end + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_birthtime_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + File.birthtime(non_utf8_path).should.is_a?(Time) + ensure + rm_r utf8_path + rm_r non_utf8_path + end + rescue NotImplementedError => e + e.message.should.start_with?(*not_implemented_messages) + end + end + platform_is :linux do guard -> { File.directory?('/proc') } do it "raises NotImplementedError for a filesystem that does not support birthtime" do diff --git a/spec/ruby/core/file/chmod_spec.rb b/spec/ruby/core/file/chmod_spec.rb index e0fd10ceb1e004..2b40777e149d7d 100644 --- a/spec/ruby/core/file/chmod_spec.rb +++ b/spec/ruby/core/file/chmod_spec.rb @@ -182,4 +182,20 @@ File.stat(@file).mode.should == 33261 end end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_chmod_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + File.chmod(0755, non_utf8_path).should == 1 + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end diff --git a/spec/ruby/core/file/chown_spec.rb b/spec/ruby/core/file/chown_spec.rb index 3353aafc700e75..eedea1c44943b8 100644 --- a/spec/ruby/core/file/chown_spec.rb +++ b/spec/ruby/core/file/chown_spec.rb @@ -75,6 +75,22 @@ it "accepts an object that has a #to_path method" do File.chown(nil, nil, mock_to_path(@fname)).should == 1 end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_chown_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + File.chown(nil, nil, non_utf8_path).should == 1 + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end describe "File#chown" do diff --git a/spec/ruby/core/file/ctime_spec.rb b/spec/ruby/core/file/ctime_spec.rb index 25058fe6820a24..d22be734a6ff5a 100644 --- a/spec/ruby/core/file/ctime_spec.rb +++ b/spec/ruby/core/file/ctime_spec.rb @@ -35,6 +35,22 @@ it "raises an Errno::ENOENT exception if the file is not found" do -> { File.ctime('bogus') }.should.raise(Errno::ENOENT) end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_ctime_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + File.ctime(non_utf8_path).should.is_a?(Time) + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end describe "File#ctime" do diff --git a/spec/ruby/core/file/delete_spec.rb b/spec/ruby/core/file/delete_spec.rb index 7149b8a37d9250..01ee42fb0b1e96 100644 --- a/spec/ruby/core/file/delete_spec.rb +++ b/spec/ruby/core/file/delete_spec.rb @@ -50,6 +50,22 @@ File.delete(mock_to_path(@file1)).should == 1 end + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_delete_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + File.delete(non_utf8_path).should == 1 + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end + platform_is :windows do it "allows deleting an open file with File::SHARE_DELETE" do path = tmp("share_delete.txt") diff --git a/spec/ruby/core/file/dirname_spec.rb b/spec/ruby/core/file/dirname_spec.rb index 855148a6844739..4cd55267ba1397 100644 --- a/spec/ruby/core/file/dirname_spec.rb +++ b/spec/ruby/core/file/dirname_spec.rb @@ -167,4 +167,9 @@ def object.to_int; 2; end File.dirname("C:/foo/bar//").should == "C:/foo" end end + + it "preserves the encoding of the path" do + path = "foo/bar".encode(Encoding::EUC_JP) + File.dirname(path).encoding.should == Encoding::EUC_JP + end end diff --git a/spec/ruby/core/file/expand_path_spec.rb b/spec/ruby/core/file/expand_path_spec.rb index 160494f1453fe0..50a652ead16523 100644 --- a/spec/ruby/core/file/expand_path_spec.rb +++ b/spec/ruby/core/file/expand_path_spec.rb @@ -134,7 +134,7 @@ end end - it "returns a String in the same encoding as the argument" do + it "preserves the encoding of the path" do Encoding.default_external = Encoding::SHIFT_JIS path = "./a".dup.force_encoding Encoding::CP1251 diff --git a/spec/ruby/core/file/extname_spec.rb b/spec/ruby/core/file/extname_spec.rb index 995d0ea31a0910..e9a72d389a736e 100644 --- a/spec/ruby/core/file/extname_spec.rb +++ b/spec/ruby/core/file/extname_spec.rb @@ -73,4 +73,8 @@ File.extname('Имя.m4a').should == ".m4a" end + it "preserves the encoding of the path" do + path = "foo.bar".encode(Encoding::EUC_JP) + File.extname(path).encoding.should == Encoding::EUC_JP + end end diff --git a/spec/ruby/core/file/ftype_spec.rb b/spec/ruby/core/file/ftype_spec.rb index ab9f76b79b8fb2..3ce490735ea5ff 100644 --- a/spec/ruby/core/file/ftype_spec.rb +++ b/spec/ruby/core/file/ftype_spec.rb @@ -79,4 +79,20 @@ end end end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_ftype_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + File.ftype(non_utf8_path).should == 'file' + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end diff --git a/spec/ruby/core/file/inspect_spec.rb b/spec/ruby/core/file/inspect_spec.rb deleted file mode 100644 index fe87429e8dcc65..00000000000000 --- a/spec/ruby/core/file/inspect_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -require_relative '../../spec_helper' - -describe "File#inspect" do - before :each do - @name = tmp("file_inspect.txt") - @file = File.open @name, "w" - end - - after :each do - @file.close unless @file.closed? - rm_r @name - end - - it "returns a String" do - @file.inspect.should.instance_of?(String) - end -end diff --git a/spec/ruby/core/file/join_spec.rb b/spec/ruby/core/file/join_spec.rb index 0f0911ea310941..dd543d331a49a2 100644 --- a/spec/ruby/core/file/join_spec.rb +++ b/spec/ruby/core/file/join_spec.rb @@ -145,4 +145,10 @@ e.message.should == 'string contains null byte' } end + + it "preserves the encoding of the path" do + path1 = "foo".encode(Encoding::EUC_JP) + path2 = "bar".encode(Encoding::EUC_JP) + File.join(path1, path2).encoding.should == Encoding::EUC_JP + end end diff --git a/spec/ruby/core/file/lchmod_spec.rb b/spec/ruby/core/file/lchmod_spec.rb index 3c44374983c416..8df424f0a1226f 100644 --- a/spec/ruby/core/file/lchmod_spec.rb +++ b/spec/ruby/core/file/lchmod_spec.rb @@ -28,5 +28,21 @@ File.stat(@lname).should_not.readable? File.stat(@lname).should.writable? end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_lchmod_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + File.lchmod(0755, non_utf8_path).should == 1 + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end end diff --git a/spec/ruby/core/file/lchown_spec.rb b/spec/ruby/core/file/lchown_spec.rb index 8d95d287ba7b87..0b4581904d43b8 100644 --- a/spec/ruby/core/file/lchown_spec.rb +++ b/spec/ruby/core/file/lchown_spec.rb @@ -55,5 +55,21 @@ File.lchown(nil, nil, @lname, @lname).should == 2 end end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_lchown_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + File.lchown(nil, nil, non_utf8_path).should == 1 + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end end diff --git a/spec/ruby/core/file/link_spec.rb b/spec/ruby/core/file/link_spec.rb index 768ee4b0face92..6ab63a8a6f8f72 100644 --- a/spec/ruby/core/file/link_spec.rb +++ b/spec/ruby/core/file/link_spec.rb @@ -35,5 +35,24 @@ -> { File.link(@file, nil) }.should.raise(TypeError) -> { File.link(@file, 1) }.should.raise(TypeError) end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_file = tmp("file_link_file_utf8_path_\u{3042}.txt") + utf8_link = tmp("file_link_link_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_file = utf8_file.encode(Encoding::Windows_31J) + non_utf8_link = utf8_link.encode(Encoding::Windows_31J) + + begin + touch(utf8_file) + File.link(non_utf8_file, non_utf8_link).should == 0 + File.should.exist?(utf8_link) + ensure + rm_r utf8_file, utf8_link + rm_r non_utf8_file, non_utf8_link + end + end + end end end diff --git a/spec/ruby/core/file/mkfifo_spec.rb b/spec/ruby/core/file/mkfifo_spec.rb index ce4a67fe310300..8c352fbbd24608 100644 --- a/spec/ruby/core/file/mkfifo_spec.rb +++ b/spec/ruby/core/file/mkfifo_spec.rb @@ -47,5 +47,20 @@ it "returns 0 after creating the FIFO file" do File.mkfifo(@path).should == 0 end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_mkfifo_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + File.mkfifo(non_utf8_path).should == 0 + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end end diff --git a/spec/ruby/core/file/mtime_spec.rb b/spec/ruby/core/file/mtime_spec.rb index 2e28695d977ab1..f41697ab94bb32 100644 --- a/spec/ruby/core/file/mtime_spec.rb +++ b/spec/ruby/core/file/mtime_spec.rb @@ -36,6 +36,22 @@ it "raises an Errno::ENOENT exception if the file is not found" do -> { File.mtime('bogus') }.should.raise(Errno::ENOENT) end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_mtime_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + File.mtime(non_utf8_path).should.is_a?(Time) + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end describe "File#mtime" do diff --git a/spec/ruby/core/file/new_spec.rb b/spec/ruby/core/file/new_spec.rb index 4cd2cb5dcb99c3..b46b80f4918a94 100644 --- a/spec/ruby/core/file/new_spec.rb +++ b/spec/ruby/core/file/new_spec.rb @@ -37,6 +37,24 @@ File.should.exist?(@file) end + platform_is :darwin do + it "returns a new File when given a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_new_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + @fh = File.new(non_utf8_path, "w") + @fh.should.is_a?(File) + File.should.exist?(utf8_path) + ensure + @fh.close if @fh and not @fh.closed? + rm_r utf8_path + rm_r non_utf8_path + end + end + end + it "creates the file and returns writable descriptor when called with 'w' mode and r-o permissions" do # it should be possible to write to such a file via returned descriptor, # even though the file permissions are r-r-r. diff --git a/spec/ruby/core/file/open_spec.rb b/spec/ruby/core/file/open_spec.rb index 7318c3163672bd..212d1b6d3a6473 100644 --- a/spec/ruby/core/file/open_spec.rb +++ b/spec/ruby/core/file/open_spec.rb @@ -72,6 +72,24 @@ File.should.exist?(@unicode_path) end + platform_is :darwin do + it "opens a file when given a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_open_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + @fh = File.open(non_utf8_path, "w") + @fh.should.is_a?(File) + File.should.exist?(utf8_path) + ensure + @fh.close if @fh and not @fh.closed? + rm_r utf8_path + rm_r non_utf8_path + end + end + end + it "opens a file when called with a block" do File.open(@file) { |fh| } File.should.exist?(@file) @@ -223,7 +241,7 @@ # Check the grants associated to the different open modes combinations. it "raises an ArgumentError exception when call with an unknown mode" do - -> { File.open(@file, "q") }.should.raise(ArgumentError) + -> { File.open(@file, "q") }.should.raise(ArgumentError, "invalid access mode q") end it "can read in a block when call open with RDONLY mode" do @@ -240,13 +258,13 @@ it "raises an IO exception when write in a block opened with RDONLY mode" do File.open(@file, File::RDONLY) do |f| - -> { f.puts "writing ..." }.should.raise(IOError) + -> { f.puts "writing ..." }.should.raise(IOError, "not opened for writing") end end it "raises an IO exception when write in a block opened with 'r' mode" do File.open(@file, "r") do |f| - -> { f.puts "writing ..." }.should.raise(IOError) + -> { f.puts "writing ..." }.should.raise(IOError, "not opened for writing") end end @@ -261,7 +279,7 @@ File.open(@file, File::WRONLY|File::RDONLY ) do |f| f.gets.should == nil end - }.should.raise(IOError) + }.should.raise(IOError, "not opened for reading") end it "can write in a block when call open with WRONLY mode" do @@ -278,39 +296,39 @@ it "raises an IOError when read in a block opened with WRONLY mode" do File.open(@file, File::WRONLY) do |f| - -> { f.gets }.should.raise(IOError) + -> { f.gets }.should.raise(IOError, "not opened for reading") end end it "raises an IOError when read in a block opened with 'w' mode" do File.open(@file, "w") do |f| - -> { f.gets }.should.raise(IOError) + -> { f.gets }.should.raise(IOError, "not opened for reading") end end it "raises an IOError when read in a block opened with 'a' mode" do File.open(@file, "a") do |f| - -> { f.gets }.should.raise(IOError) + -> { f.gets }.should.raise(IOError, "not opened for reading") end end it "raises an IOError when read in a block opened with 'a' mode" do File.open(@file, "a") do |f| f.puts("writing").should == nil - -> { f.gets }.should.raise(IOError) + -> { f.gets }.should.raise(IOError, "not opened for reading") end end it "raises an IOError when read in a block opened with 'a' mode" do File.open(@file, File::WRONLY|File::APPEND ) do |f| - -> { f.gets }.should.raise(IOError) + -> { f.gets }.should.raise(IOError, "not opened for reading") end end it "raises an IOError when read in a block opened with File::WRONLY|File::APPEND mode" do File.open(@file, File::WRONLY|File::APPEND ) do |f| f.puts("writing").should == nil - -> { f.gets }.should.raise(IOError) + -> { f.gets }.should.raise(IOError, "not opened for reading") end end @@ -319,7 +337,7 @@ File.open(@file, File::RDONLY|File::APPEND ) do |f| f.puts("writing") end - }.should.raise(IOError) + }.should.raise(IOError, "not opened for writing") end it "can read and write in a block when call open with RDWR mode" do @@ -336,7 +354,7 @@ File.open(@file, File::EXCL) do |f| f.puts("writing").should == nil end - }.should.raise(IOError) + }.should.raise(IOError, "not opened for writing") end it "can read in a block when call open with File::EXCL mode" do @@ -386,7 +404,7 @@ File.open(@file, File::RDONLY|File::APPEND) do |f| f.puts("writing").should == nil end - }.should.raise(IOError) + }.should.raise(IOError, "not opened for writing") end platform_is_not :openbsd, :windows do @@ -420,7 +438,7 @@ File.open(@file, File::TRUNC) do |f| f.puts("writing") end - }.should.raise(IOError) + }.should.raise(IOError, "not opened for writing") end it "raises an Errno::EEXIST if the file exists when open with File::RDONLY|File::TRUNC" do @@ -428,7 +446,7 @@ File.open(@file, File::RDONLY|File::TRUNC) do |f| f.puts("writing").should == nil end - }.should.raise(IOError) + }.should.raise(IOError, "not opened for writing") end end @@ -553,11 +571,11 @@ end it "raises an ArgumentError if passed the wrong number of arguments" do - -> { File.open(@file, File::CREAT, 0755, 'test') }.should.raise(ArgumentError) + -> { File.open(@file, File::CREAT, 0755, 'test') }.should.raise(ArgumentError, "wrong number of arguments (given 4, expected 1..3)") end it "raises an ArgumentError if passed an invalid string for mode" do - -> { File.open(@file, 'fake') }.should.raise(ArgumentError) + -> { File.open(@file, 'fake') }.should.raise(ArgumentError, "invalid access mode fake") end it "defaults external_encoding to BINARY for binary modes" do diff --git a/spec/ruby/core/file/path_spec.rb b/spec/ruby/core/file/path_spec.rb index f3b9b56dbe1bfa..24918e413958d7 100644 --- a/spec/ruby/core/file/path_spec.rb +++ b/spec/ruby/core/file/path_spec.rb @@ -79,4 +79,9 @@ path.should_receive(:to_path).and_return("abc".encode(Encoding::UTF_32BE)) -> { File.path(path) }.should.raise Encoding::CompatibilityError end + + it "preserves the encoding of the path" do + path = "abc".encode(Encoding::EUC_JP) + File.path(path).encoding.should == Encoding::EUC_JP + end end diff --git a/spec/ruby/core/file/readlink_spec.rb b/spec/ruby/core/file/readlink_spec.rb index 568692b9b62f93..8f9b6e589ba83b 100644 --- a/spec/ruby/core/file/readlink_spec.rb +++ b/spec/ruby/core/file/readlink_spec.rb @@ -82,5 +82,23 @@ File.readlink(@link).should == @file end end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_file = tmp("file_readlink_file_utf8_path_\u{3042}.txt") + utf8_link = tmp("file_readlink_link_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_file = utf8_file.encode(Encoding::Windows_31J) + non_utf8_link = utf8_link.encode(Encoding::Windows_31J) + + begin + File.symlink(utf8_file, utf8_link) + File.readlink(non_utf8_link).should == utf8_file + ensure + rm_r utf8_file, utf8_link + rm_r non_utf8_file, non_utf8_link + end + end + end end end diff --git a/spec/ruby/core/file/realdirpath_spec.rb b/spec/ruby/core/file/realdirpath_spec.rb index ecf1e0c6d94e33..5ef21b4ed51097 100644 --- a/spec/ruby/core/file/realdirpath_spec.rb +++ b/spec/ruby/core/file/realdirpath_spec.rb @@ -79,6 +79,23 @@ it "raises Errno::ENOENT if the symlink points to an absent directory" do -> { File.realdirpath(@fake_link_to_fake_dir) }.should.raise(Errno::ENOENT) end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + dir = tmp("file_realdirpath_dir_\u{3042}") + utf8_file = File.join(dir, "file.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_file = utf8_file.encode(Encoding::Windows_31J) + + begin + mkdir_p(dir) + touch(utf8_file) + File.realdirpath(non_utf8_file).should == File.realdirpath(utf8_file).encode(Encoding::Windows_31J) + ensure + rm_r dir + end + end + end end end @@ -102,3 +119,26 @@ end end end + +describe "File.realdirpath" do + it "preserves the encoding of the path" do + path = __FILE__.encode(Encoding::EUC_JP) + File.realdirpath(path).encoding.should == Encoding::EUC_JP + dir = File.dirname(__FILE__).encode(Encoding::EUC_JP) + File.realdirpath(File.basename(path), dir).encoding.should == Encoding::EUC_JP + end + + platform_is_not :windows do + it "retains the encoding of the resolved path when encoding conversion fails" do + dir = tmp("realdirpath_あ") + mkdir_p(dir) + begin + resolved = File.realdirpath(".".encode(Encoding::ISO_8859_1), dir) + resolved.encoding.should == dir.encoding + resolved.should.include?(dir) + ensure + rm_r dir + end + end + end +end diff --git a/spec/ruby/core/file/realpath_spec.rb b/spec/ruby/core/file/realpath_spec.rb index ccb981eff16630..553dce3ff698dd 100644 --- a/spec/ruby/core/file/realpath_spec.rb +++ b/spec/ruby/core/file/realpath_spec.rb @@ -77,6 +77,23 @@ path.should_receive(:to_path).and_return(__FILE__) File.realpath(path).should == File.realpath(__FILE__ ) end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + dir = tmp("file_realpath_dir_\u{3042}") + utf8_file = File.join(dir, "file.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_file = utf8_file.encode(Encoding::Windows_31J) + + begin + mkdir_p(dir) + touch(utf8_file) + File.realpath(non_utf8_file).should == File.realpath(utf8_file).encode(Encoding::Windows_31J) + ensure + rm_r dir + end + end + end end end @@ -96,3 +113,26 @@ end end end + +describe "File.realpath" do + it "preserves the encoding of the path" do + path = __FILE__.encode(Encoding::EUC_JP) + File.realpath(path).encoding.should == Encoding::EUC_JP + dir = File.dirname(__FILE__).encode(Encoding::EUC_JP) + File.realpath(File.basename(path), dir).encoding.should == Encoding::EUC_JP + end + + platform_is_not :windows do + it "forces the encoding of the path when encoding conversion fails" do + dir = tmp("realpath_あ") + mkdir_p(dir) + begin + resolved = File.realpath(".".encode(Encoding::ISO_8859_1), dir) + resolved.encoding.should == Encoding::ISO_8859_1 + resolved.b.should.include?(dir.b) + ensure + rm_r dir + end + end + end +end diff --git a/spec/ruby/core/file/rename_spec.rb b/spec/ruby/core/file/rename_spec.rb index 70ea669a68ccaf..fced298526b77c 100644 --- a/spec/ruby/core/file/rename_spec.rb +++ b/spec/ruby/core/file/rename_spec.rb @@ -34,4 +34,24 @@ it "raises a TypeError if not passed String types" do -> { File.rename(1, 2) }.should.raise(TypeError) end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_old = tmp("file_rename_old_utf8_path_\u{3042}.txt") + utf8_new = tmp("file_rename_new_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_old = utf8_old.encode(Encoding::Windows_31J) + non_utf8_new = utf8_new.encode(Encoding::Windows_31J) + + begin + touch(utf8_old) + File.rename(non_utf8_old, non_utf8_new).should == 0 + File.should.exist?(utf8_new) + File.should_not.exist?(utf8_old) + ensure + rm_r utf8_old, utf8_new + rm_r non_utf8_old, non_utf8_new + end + end + end end diff --git a/spec/ruby/core/file/shared/stat.rb b/spec/ruby/core/file/shared/stat.rb index 879a7f11ffc5e7..dd106c1edbb67b 100644 --- a/spec/ruby/core/file/shared/stat.rb +++ b/spec/ruby/core/file/shared/stat.rb @@ -24,6 +24,22 @@ File.send(@method, mock_to_path(@file)) end + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_lstat_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + File.send(@method, non_utf8_path).should.is_a?(File::Stat) + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end + it "raises an Errno::ENOENT if the file does not exist" do -> { File.send(@method, "fake_file") diff --git a/spec/ruby/core/file/shared/update_time.rb b/spec/ruby/core/file/shared/update_time.rb index 3fe7266a00e2e7..856ac0841b64bb 100644 --- a/spec/ruby/core/file/shared/update_time.rb +++ b/spec/ruby/core/file/shared/update_time.rb @@ -53,6 +53,22 @@ File.send(@method, @atime, @mtime, mock_to_path(@file1), mock_to_path(@file2)) end + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_lutime_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + File.send(@method, @atime, @mtime, non_utf8_path).should == 1 + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end + it "accepts numeric atime and mtime arguments" do if @time_is_float File.send(@method, @atime.to_f, @mtime.to_f, @file1, @file2) diff --git a/spec/ruby/core/file/split_spec.rb b/spec/ruby/core/file/split_spec.rb index e989a6b86edb66..8875de15b0b0bc 100644 --- a/spec/ruby/core/file/split_spec.rb +++ b/spec/ruby/core/file/split_spec.rb @@ -61,4 +61,11 @@ it "accepts an object that has a #to_path method" do File.split(mock_to_path("")).should == [".", ""] end + + it "preserves the encoding of the path" do + path = "/foo/bar".encode(Encoding::EUC_JP) + dir, file = File.split(path) + dir.encoding.should == Encoding::EUC_JP + file.encoding.should == Encoding::EUC_JP + end end diff --git a/spec/ruby/core/file/stat/new_spec.rb b/spec/ruby/core/file/stat/new_spec.rb index b8c3600028491c..5dea8cd5ab0024 100644 --- a/spec/ruby/core/file/stat/new_spec.rb +++ b/spec/ruby/core/file/stat/new_spec.rb @@ -29,4 +29,20 @@ p.should_receive(:to_path).and_return @file File::Stat.new p end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_stat_new_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + File::Stat.new(non_utf8_path).should.is_a?(File::Stat) + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end diff --git a/spec/ruby/core/file/symlink_spec.rb b/spec/ruby/core/file/symlink_spec.rb index 4ceeb28c8473ee..9ed57db267ee4f 100644 --- a/spec/ruby/core/file/symlink_spec.rb +++ b/spec/ruby/core/file/symlink_spec.rb @@ -45,6 +45,24 @@ -> { File.symlink(@file, 1) }.should.raise(TypeError) -> { File.symlink(1, 1) }.should.raise(TypeError) end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_file = tmp("file_symlink_file_utf8_path_\u{3042}.txt") + utf8_link = tmp("file_symlink_link_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_file = utf8_file.encode(Encoding::Windows_31J) + non_utf8_link = utf8_link.encode(Encoding::Windows_31J) + + begin + touch(utf8_file) + File.symlink(non_utf8_file, non_utf8_link).should == 0 + ensure + rm_r utf8_file, utf8_link + rm_r non_utf8_file, non_utf8_link + end + end + end end end diff --git a/spec/ruby/core/file/truncate_spec.rb b/spec/ruby/core/file/truncate_spec.rb index 5f37f341554d6f..4d88038eb23cea 100644 --- a/spec/ruby/core/file/truncate_spec.rb +++ b/spec/ruby/core/file/truncate_spec.rb @@ -82,6 +82,22 @@ it "accepts an object that has a #to_path method" do File.truncate(mock_to_path(@name), 0).should == 0 end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_truncate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + File.truncate(non_utf8_path, 0).should == 0 + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end diff --git a/spec/ruby/core/hash/transform_keys_spec.rb b/spec/ruby/core/hash/transform_keys_spec.rb index d37a2b86163be3..f9c13e894d210f 100644 --- a/spec/ruby/core/hash/transform_keys_spec.rb +++ b/spec/ruby/core/hash/transform_keys_spec.rb @@ -55,6 +55,10 @@ @hash.transform_keys({ a: :A }, &:to_s).should == { A: 1, 'b' => 2, 'c' => 3 } end + it "raises TypeError when the given mapping is nil" do + -> { @hash.transform_keys(nil) }.should.raise(TypeError) + end + it "does not retain the default value" do h = Hash.new(1) h.transform_keys(&:succ).default.should == nil @@ -123,6 +127,10 @@ @hash.should == { A: 1, B: 2, C: 3, D: 4 } end + it "raises TypeError when the given mapping is nil" do + -> { @hash.transform_keys!(nil) }.should.raise(TypeError) + end + describe "on frozen instance" do before :each do @hash.freeze diff --git a/spec/ruby/core/io/binmode_spec.rb b/spec/ruby/core/io/binmode_spec.rb index 8117229c91409c..cd5475a90377a1 100644 --- a/spec/ruby/core/io/binmode_spec.rb +++ b/spec/ruby/core/io/binmode_spec.rb @@ -31,6 +31,142 @@ @io.binmode @io.internal_encoding.should == nil end + + it "disables newline conversion for #read" do + data = "line1\r\nline2\r\n" + + @io = new_io(@name, "wb") + @io.write(data) + @io.close + + @io = new_io(@name, "rt") + @io.set_encoding("utf-8:ISO-8859-1", newline: :universal) + @io.binmode + @io.read.should == data + end + + it "disables newline conversion for #gets" do + data = "line1\r\nline2\r\n" + + @io = new_io(@name, "wb") + @io.write(data) + @io.close + + @io = new_io(@name, "rt") + @io.set_encoding("utf-8:ISO-8859-1", newline: :universal) + @io.binmode + @io.gets.should == "line1\r\n" + @io.gets.should == "line2\r\n" + end + + it "disables newline conversion for #readline" do + data = "line1\r\nline2\r\n" + + @io = new_io(@name, "wb") + @io.write(data) + @io.close + + @io = new_io(@name, "rt") + @io.set_encoding("utf-8:ISO-8859-1", newline: :universal) + @io.binmode + @io.readline.should == "line1\r\n" + @io.readline.should == "line2\r\n" + end + + it "disables newline conversion for #readlines" do + data = "line1\r\nline2\r\n" + + @io = new_io(@name, "wb") + @io.write(data) + @io.close + + @io = new_io(@name, "rt") + @io.set_encoding("utf-8:ISO-8859-1", newline: :universal) + @io.binmode + @io.readlines.should == ["line1\r\n", "line2\r\n"] + end + + it "disables newline conversion for #each" do + data = "line1\r\nline2\r\n" + + @io = new_io(@name, "wb") + @io.write(data) + @io.close + + @io = new_io(@name, "rt") + @io.set_encoding("utf-8:ISO-8859-1", newline: :universal) + @io.binmode + @io.each.to_a.should == ["line1\r\n", "line2\r\n"] + end + + it "disables newline conversion for #each_line" do + data = "line1\r\nline2\r\n" + + @io = new_io(@name, "wb") + @io.write(data) + @io.close + + @io = new_io(@name, "rt") + @io.set_encoding("utf-8:ISO-8859-1", newline: :universal) + @io.binmode + @io.each_line.to_a.should == ["line1\r\n", "line2\r\n"] + end + + it "disables newline conversion for #getc" do + data = "line1\r\n" + + @io = new_io(@name, "wb") + @io.write(data) + @io.close + + @io = new_io(@name, "rt") + @io.set_encoding("utf-8:ISO-8859-1", newline: :universal) + @io.binmode + 5.times { @io.getc } + @io.getc.should == "\r" + @io.getc.should == "\n" + end + + it "disables newline conversion for #readchar" do + data = "line1\r\n" + + @io = new_io(@name, "wb") + @io.write(data) + @io.close + + @io = new_io(@name, "rt") + @io.set_encoding("utf-8:ISO-8859-1", newline: :universal) + @io.binmode + 5.times { @io.readchar } + @io.readchar.should == "\r" + @io.readchar.should == "\n" + end + + it "disables newline conversion for #each_char" do + data = "line1\r\n" + + @io = new_io(@name, "wb") + @io.write(data) + @io.close + + @io = new_io(@name, "rt") + @io.set_encoding("utf-8:ISO-8859-1", newline: :universal) + @io.binmode + @io.each_char.to_a.should == ["l", "i", "n", "e", "1", "\r", "\n"] + end + + it "disables newline conversion for #each_codepoint" do + data = "line1\r\n" + + @io = new_io(@name, "wb") + @io.write(data) + @io.close + + @io = new_io(@name, "rt") + @io.set_encoding("utf-8:ISO-8859-1", newline: :universal) + @io.binmode + @io.each_codepoint.to_a.should == [108, 105, 110, 101, 49, 13, 10] + end end describe "IO#binmode?" do diff --git a/spec/ruby/core/io/binread_spec.rb b/spec/ruby/core/io/binread_spec.rb index 200fa05abfb853..4daf89b786989f 100644 --- a/spec/ruby/core/io/binread_spec.rb +++ b/spec/ruby/core/io/binread_spec.rb @@ -67,4 +67,20 @@ end end end + + platform_is :darwin do + it "reads a file when given a path string in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("io_binread_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + File.write(utf8_path, "ok") + IO.binread(non_utf8_path).should == "ok".b + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end diff --git a/spec/ruby/core/io/copy_stream_spec.rb b/spec/ruby/core/io/copy_stream_spec.rb index 31383f9b0f154f..3b9b0f482c2193 100644 --- a/spec/ruby/core/io/copy_stream_spec.rb +++ b/spec/ruby/core/io/copy_stream_spec.rb @@ -19,6 +19,29 @@ File.read(@to_name).should == "Line one" end + it "copies nothing when given 0 bytes length to read" do + IO.copy_stream(@object.from, @to_name, 0).should == 0 + File.read(@to_name).should == "" + end + + it "calls #to_int to convert length" do + length = mock("length") + length.should_receive(:to_int).and_return(8) + IO.copy_stream(@object.from, @to_name, length).should == 8 + File.read(@to_name).should == "Line one" + end + + it "raises a TypeError if #to_int does not return an Integer" do + length = mock("length") + length.should_receive(:to_int).and_return("8") + -> { IO.copy_stream(@object.from, @to_name, length) }.should.raise(TypeError) + end + + it "raises a TypeError if passed an object that does not respond to #to_int" do + length = mock("length") + -> { IO.copy_stream(@object.from, @to_name, length) }.should.raise(TypeError) + end + it "calls #to_path to convert on object to a file name" do obj = mock("io_copy_stream_to") obj.should_receive(:to_path).and_return(@to_name) @@ -33,6 +56,22 @@ -> { IO.copy_stream(@object.from, obj) }.should.raise(TypeError) end + + platform_is :darwin do + it "writes to a file when given a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("io_read_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + IO.copy_stream(@object.from, non_utf8_path) + File.read(utf8_path).should == @content + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end describe :io_copy_stream_to_file_with_offset, shared: true do @@ -41,6 +80,29 @@ IO.copy_stream(@object.from, @to_name, 8, 4).should == 8 File.read(@to_name).should == " one\n\nLi" end + + it "copies nothing when given 0 bytes length to read" do + IO.copy_stream(@object.from, @to_name, 0, 4).should == 0 + File.read(@to_name).should == "" + end + + it "calls #to_int to convert the offset" do + offset = mock("offset") + offset.should_receive(:to_int).and_return(4) + IO.copy_stream(@object.from, @to_name, 8, offset).should == 8 + File.read(@to_name).should == " one\n\nLi" + end + + it "raises a TypeError if #to_int does not return an Integer" do + offset = mock("offset") + offset.should_receive(:to_int).and_return("4") + -> { IO.copy_stream(@object.from, @to_name, 8, offset) }.should.raise(TypeError) + end + + it "raises a TypeError if passed an object that does not respond to #to_int" do + offset = mock("offset") + -> { IO.copy_stream(@object.from, @to_name, 8, offset) }.should.raise(TypeError) + end end end @@ -86,6 +148,29 @@ IO.copy_stream(@object.from, @to_io, 8).should == 8 File.read(@to_name).should == "Line one" end + + it "copies nothing when given 0 bytes length to read" do + IO.copy_stream(@object.from, @to_io, 0).should == 0 + File.read(@to_name).should == "" + end + + it "calls #to_int to convert length" do + length = mock("length") + length.should_receive(:to_int).and_return(8) + IO.copy_stream(@object.from, @to_io, length).should == 8 + File.read(@to_name).should == "Line one" + end + + it "raises a TypeError if #to_int does not return an Integer" do + length = mock("length") + length.should_receive(:to_int).and_return("8") + -> { IO.copy_stream(@object.from, @to_io, length) }.should.raise(TypeError) + end + + it "raises a TypeError if passed an object that does not respond to #to_int" do + length = mock("length") + -> { IO.copy_stream(@object.from, @to_io, length) }.should.raise(TypeError) + end end describe :io_copy_stream_to_io_with_offset, shared: true do @@ -94,6 +179,29 @@ IO.copy_stream(@object.from, @to_io, 8, 4).should == 8 File.read(@to_name).should == " one\n\nLi" end + + it "copies nothing when given 0 bytes length to read" do + IO.copy_stream(@object.from, @to_io, 0, 4).should == 0 + File.read(@to_name).should == "" + end + + it "calls #to_int to convert the offset" do + offset = mock("offset") + offset.should_receive(:to_int).and_return(4) + IO.copy_stream(@object.from, @to_io, 8, offset).should == 8 + File.read(@to_name).should == " one\n\nLi" + end + + it "raises a TypeError if #to_int does not return an Integer" do + offset = mock("offset") + offset.should_receive(:to_int).and_return("4") + -> { IO.copy_stream(@object.from, @to_io, 8, offset) }.should.raise(TypeError) + end + + it "raises a TypeError if passed an object that does not respond to #to_int" do + offset = mock("offset") + -> { IO.copy_stream(@object.from, @to_io, 8, offset) }.should.raise(TypeError) + end end end @@ -209,6 +317,23 @@ -> { IO.copy_stream(obj, @to_name) }.should.raise(TypeError) end + platform_is :darwin do + it "reads a file when given a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("io_read_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + File.write(utf8_path, @content) + IO.copy_stream(non_utf8_path, @to_name) + File.read(@to_name).should == @content + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end + describe "to a file name" do it_behaves_like :io_copy_stream_to_file, nil, IOSpecs::CopyStream it_behaves_like :io_copy_stream_to_file_with_offset, nil, IOSpecs::CopyStream @@ -305,8 +430,9 @@ from = mock("io_copy_stream_to_object_zero_length_read") to = mock("io_copy_stream_to_object_zero_length_write") from.should_not_receive(:read) + from.should_not_receive(:readpartial) to.should_not_receive(:write) - IO.copy_stream(from, to, 0) + IO.copy_stream(from, to, 0).should == 0 end end @@ -349,3 +475,31 @@ end end end + +describe "IO.copy_stream" do + context "given length" do + it "calls #read/#readpartial with remaining bytes count" do + input = +"abcdefghijklmnopqrstuvwxyz" + read_maxlens = [] + from = Object.new + from.define_singleton_method(:read) do |maxlen, buf = nil| + read_maxlens << maxlen + bytes_to_read = read_maxlens.size == 1 ? 5 : maxlen + bytes = input.slice!(0, bytes_to_read) + buf.replace(bytes) if buf + bytes + end + + output = +"" + to = Object.new + to.define_singleton_method(:write) do |bytes| + output << bytes + bytes.bytesize + end + + IO.copy_stream(from, to, 12).should == 12 + read_maxlens.should == [12, 7] + output.should == "abcdefghijkl" + end + end +end diff --git a/spec/ruby/core/io/getbyte_spec.rb b/spec/ruby/core/io/getbyte_spec.rb index 668d81519c4724..afe84cf67064ca 100644 --- a/spec/ruby/core/io/getbyte_spec.rb +++ b/spec/ruby/core/io/getbyte_spec.rb @@ -25,6 +25,13 @@ it "raises an IOError on closed stream" do -> { IOSpecs.closed_io.getbyte }.should.raise(IOError) end + + it "reads after ungetc without character conversion" do + @io.set_encoding("utf-8") + c = @io.getc + @io.ungetc(c) + @io.getbyte.should == 86 + end end describe "IO#getbyte" do diff --git a/spec/ruby/core/io/inspect_spec.rb b/spec/ruby/core/io/inspect_spec.rb index 37dc459f22c0b6..7db8fdaac6db22 100644 --- a/spec/ruby/core/io/inspect_spec.rb +++ b/spec/ruby/core/io/inspect_spec.rb @@ -1,20 +1,40 @@ require_relative '../../spec_helper' describe "IO#inspect" do + before :each do + @path = tmp("foo") + end + after :each do - @r.close if @r && !@r.closed? - @w.close if @w && !@w.closed? + File.delete(@path) if File.exist?(@path) + end + + it "contains the file descriptor number if no path is given" do + fd = new_fd(@path) + io = IO.open(fd) + io.inspect.should == "#" + + io.close + io.inspect.should == "#" + ensure + io&.close end - it "contains the file descriptor number" do - @r, @w = IO.pipe - @r.inspect.should.include?("fd #{@r.fileno}") + it "contains the path if a path is given" do + fd = new_fd(@path) + io = IO.open(fd, path: @path) + io.inspect.should == "#" + + io.close + io.inspect.should == "#" + ensure + io&.close end - it "contains \"(closed)\" if the stream is closed" do - @r, @w = IO.pipe - @r.close - @r.inspect.should.include?("(closed)") + it "contains the subclass in its result" do + File.open(@path, "w") do |file| + file.inspect.should == "#" + end end it "reports IO as its Method object's owner" do diff --git a/spec/ruby/core/io/internal_encoding_spec.rb b/spec/ruby/core/io/internal_encoding_spec.rb index 9963a93f332a0f..4bdfbaabbea992 100644 --- a/spec/ruby/core/io/internal_encoding_spec.rb +++ b/spec/ruby/core/io/internal_encoding_spec.rb @@ -93,6 +93,11 @@ @io = new_io @name, "#{@object}:binary" @io.internal_encoding.should == nil end + + it "returns nil when the external encoding is BINARY and internal encoding is set" do + @io = new_io @name, "#{@object}:binary:ibm437" + @io.internal_encoding.should == nil + end end end diff --git a/spec/ruby/core/io/path_spec.rb b/spec/ruby/core/io/path_spec.rb index 798adb2163279f..8eef9cd854c767 100644 --- a/spec/ruby/core/io/path_spec.rb +++ b/spec/ruby/core/io/path_spec.rb @@ -9,4 +9,16 @@ ensure File.unlink(path) end + + it "is set for STDIN" do + STDIN.path.should == "" + end + + it "is set for STDOUT" do + STDOUT.path.should == "" + end + + it "is set for STDERR" do + STDERR.path.should == "" + end end diff --git a/spec/ruby/core/io/popen_spec.rb b/spec/ruby/core/io/popen_spec.rb index b5747bf255435e..dafb8f08696ecb 100644 --- a/spec/ruby/core/io/popen_spec.rb +++ b/spec/ruby/core/io/popen_spec.rb @@ -284,4 +284,23 @@ end end end + + describe "options validation" do + it "raises an ArgumentError if :unsetenv_others option is not a boolean or nil" do + -> { IO.popen(["true", unsetenv_others: 1]) }.should.raise(ArgumentError, /expected true or false/) + -> { IO.popen(["true", unsetenv_others: "true"]) }.should.raise(ArgumentError, /expected true or false/) + end + + it "raises an ArgumentError if :close_others option is not a boolean or nil" do + -> { IO.popen(["true", close_others: 1]) }.should.raise(ArgumentError, /expected true or false/) + -> { IO.popen(["true", close_others: "true"]) }.should.raise(ArgumentError, /expected true or false/) + end + + platform_is :windows do + it "raises an ArgumentError if :new_pgroup option is not a boolean or nil" do + -> { IO.popen(["true", new_pgroup: 1]) }.should.raise(ArgumentError, /expected true or false/) + -> { IO.popen(["true", new_pgroup: "true"]) }.should.raise(ArgumentError, /expected true or false/) + end + end + end end diff --git a/spec/ruby/core/io/pwrite_spec.rb b/spec/ruby/core/io/pwrite_spec.rb index c318d551bc3e13..5fcb503fc15f6b 100644 --- a/spec/ruby/core/io/pwrite_spec.rb +++ b/spec/ruby/core/io/pwrite_spec.rb @@ -59,6 +59,12 @@ }.should.raise(NoMethodError, /undefined method [`']to_s'/) end + it "raises a Errno::EINVAL if the offset is invalid" do + -> { + @file.pwrite("foo", -3) + }.should.raise(Errno::EINVAL) + end + it "raises a TypeError if the offset cannot be converted to an Integer" do -> { @file.pwrite("foo", Object.new) diff --git a/spec/ruby/core/io/read_nonblock_spec.rb b/spec/ruby/core/io/read_nonblock_spec.rb index 511cf03263c497..bd36b04582e2cf 100644 --- a/spec/ruby/core/io/read_nonblock_spec.rb +++ b/spec/ruby/core/io/read_nonblock_spec.rb @@ -22,6 +22,12 @@ } end + it "raises an ArgumentError if exception: is not true or false" do + -> { @read.read_nonblock(5, exception: 0) }.should.raise ArgumentError, /expected true or false/ + -> { @read.read_nonblock(5, exception: nil) }.should.raise ArgumentError, /expected true or false/ + -> { @read.read_nonblock(5, exception: 'false') }.should.raise ArgumentError, /expected true or false/ + end + context "when exception option is set to false" do context "when there is no data" do it "returns :wait_readable" do @@ -66,16 +72,6 @@ @read.read_nonblock(3).should == "bar" end - it "raises an exception after ungetc with data in the buffer and character conversion enabled" do - @write.write("foobar") - @read.set_encoding( - 'utf-8', universal_newline: true - ) - c = @read.getc - @read.ungetc(c) - -> { @read.read_nonblock(3).should == "foo" }.should.raise(IOError) - end - it "returns less data if that is all that is available" do @write << "hello" @read.read_nonblock(10).should == "hello" @@ -137,6 +133,14 @@ -> { @read.read_nonblock(5) }.should.raise(EOFError) end + ruby_bug "#18421", ""..."3.0.4" do + it "clears and returns the given buffer if the length argument is 0" do + buffer = String.new("existing content") + @read.read_nonblock(0, buffer).should == buffer + buffer.should == "" + end + end + it "preserves the encoding of the given buffer" do buffer = ''.encode(Encoding::ISO_8859_1) @write.write("abc") diff --git a/spec/ruby/core/io/read_spec.rb b/spec/ruby/core/io/read_spec.rb index 0c165814c74194..85b3acf81383bb 100644 --- a/spec/ruby/core/io/read_spec.rb +++ b/spec/ruby/core/io/read_spec.rb @@ -45,11 +45,11 @@ end it "raises an IOError if the options Hash specifies write mode" do - -> { IO.read(@fname, 3, 0, mode: "w") }.should.raise(IOError) + -> { IO.read(@fname, 3, 0, mode: "w") }.should.raise(IOError, "not opened for reading") end it "raises an IOError if the options Hash specifies append only mode" do - -> { IO.read(@fname, mode: "a") }.should.raise(IOError) + -> { IO.read(@fname, mode: "a") }.should.raise(IOError, "not opened for reading") end it "reads the file if the options Hash includes read mode" do @@ -64,9 +64,6 @@ IO.read(@fname, mode: "a+").should == @contents end - platform_is_not :windows do - end - it "disregards other options if :open_args is given" do string = IO.read(@fname,mode: "w", encoding: Encoding::UTF_32LE, open_args: ["r", encoding: Encoding::UTF_8]) string.encoding.should == Encoding::UTF_8 @@ -119,16 +116,16 @@ end it "raises a TypeError when not passed a String type" do - -> { IO.read nil }.should.raise(TypeError) + -> { IO.read nil }.should raise_consistent_error(TypeError, "no implicit conversion of nil into String") end it "raises an ArgumentError when not passed a valid length" do - -> { IO.read @fname, -1 }.should.raise(ArgumentError) + -> { IO.read @fname, -1 }.should.raise(ArgumentError, "negative length -1 given") end it "raises an ArgumentError when not passed a valid offset" do - -> { IO.read @fname, 0, -1 }.should.raise(ArgumentError) - -> { IO.read @fname, -1, -1 }.should.raise(ArgumentError) + -> { IO.read @fname, 0, -1 }.should.raise(ArgumentError, "negative offset -1 given") + -> { IO.read @fname, -1, -1 }.should.raise(ArgumentError, "negative offset -1 given") end it "uses the external encoding specified via the :external_encoding option" do @@ -148,6 +145,22 @@ IO.read(@fname).should.empty? end end + + platform_is :darwin do + it "reads a file when given a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("io_read_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + File.write(utf8_path, "ok") + IO.read(non_utf8_path).should == "ok" + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end describe "IO.read from a pipe" do @@ -283,31 +296,34 @@ end it "raises an ArgumentError when not passed a valid length" do - -> { @io.read(-1) }.should.raise(ArgumentError) + -> { @io.read(-1) }.should.raise(ArgumentError, "negative length -1 given") end it "clears the output buffer if there is nothing to read" do - @io.pos = 10 - buf = +'non-empty string' - + @io.pos = 10 @io.read(10, buf).should == nil buf.should == '' buf = +'non-empty string' - + @io.pos = 10 @io.read(nil, buf).should == "" buf.should == '' buf = +'non-empty string' - + @io.pos = 10 @io.read(0, buf).should == "" buf.should == '' end + it "returns the empty string when there is nothing to read and lenght=0 is given" do + @io.read(11) + @io.read(0).should == "" + end + it "raise FrozenError if the output buffer is frozen" do @io.read -> { @io.read(0, 'frozen-string'.freeze) }.should.raise(FrozenError) @@ -436,11 +452,11 @@ end it "raises IOError on closed stream" do - -> { IOSpecs.closed_io.read }.should.raise(IOError) + -> { IOSpecs.closed_io.read }.should.raise(IOError, "closed stream") end it "raises ArgumentError when length is less than 0" do - -> { @io.read(-1) }.should.raise(ArgumentError) + -> { @io.read(-1) }.should.raise(ArgumentError, "negative length -1 given") end platform_is_not :windows do @@ -672,6 +688,12 @@ @io.read.encoding.should.equal?(Encoding::EUC_JP) end + it "reads after ungetc" do + c = @io.getc + @io.ungetc(c) + @io.read(2).should == [164, 162].pack('C*').force_encoding(Encoding::BINARY) + end + it_behaves_like :io_read_size_internal_encoding, nil end diff --git a/spec/ruby/core/io/readbyte_spec.rb b/spec/ruby/core/io/readbyte_spec.rb index 07da1da919652f..d8ac7dea309a93 100644 --- a/spec/ruby/core/io/readbyte_spec.rb +++ b/spec/ruby/core/io/readbyte_spec.rb @@ -21,4 +21,11 @@ @io.readbyte end.should.raise EOFError end + + it "reads after ungetc without character conversion" do + @io.set_encoding("utf-8") + c = @io.getc + @io.ungetc(c) + @io.readbyte.should == ?r.getbyte(0) + end end diff --git a/spec/ruby/core/io/reopen_spec.rb b/spec/ruby/core/io/reopen_spec.rb index 3b972d8978e391..758793f0e08628 100644 --- a/spec/ruby/core/io/reopen_spec.rb +++ b/spec/ruby/core/io/reopen_spec.rb @@ -57,6 +57,10 @@ @io.close -> { @io.reopen(STDOUT) }.should.raise(IOError) end + + it "raises ArgumentError when too many arguments are given" do + -> { @io.reopen(@other_name, "r", "excess argument") }.should.raise(ArgumentError) + end end describe "IO#reopen with a String" do @@ -116,6 +120,41 @@ obj.should_receive(:to_path).and_return(@other_name) @io.reopen(obj) end + + platform_is :darwin do + it "opens a file when given a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("io_reopen_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + File.write(utf8_path, "ok") + @io = new_io @other_name, "r" + @io.reopen(non_utf8_path, "r") + @io.read.should == "ok" + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + + it "opens a file when given a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters when called on a closed stream" do + utf8_path = tmp("io_reopen_utf8_path_closed_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + File.write(utf8_path, "ok") + @io = new_io @other_name, "r" + @io.close + @io.reopen(non_utf8_path, "r") + @io.read.should == "ok" + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end describe "IO#reopen with a String" do @@ -170,6 +209,53 @@ @io.reopen(@other_name) File.should.exist?(@other_name) end + + it "opens the file in read mode if the IO is read-only" do + touch(@name) { |f| f.write "original data" } + touch(@other_name) { |f| f.write "new data" } + @io = new_io @name, "r" + + @io.reopen(@other_name) + -> { @io.write("overwrite content") }.should.raise(IOError) + @io.read.should == "new data" + end + + it "opens the file in write mode if the IO is write-only" do + touch(@name) { |f| f.write "original data" } + touch(@other_name) { |f| f.write "new data" } + @io = new_io @name, "w" + + @io.reopen(@other_name) + -> { @io.read }.should.raise(IOError) + @io.write("overwrite content").should == 17 + @io.close + File.read(@other_name).should == "overwrite content" + end + + it "opens the file in read-write mode if the IO is read-write" do + touch(@name) { |f| f.write "original data" } + touch(@other_name) { |f| f.write "new data" } + @io = new_io @name, "r+" + + @io.reopen(@other_name) + @io.read.should == "new data" + @io.rewind + @io.write("overwrite content").should == 17 + @io.close + File.read(@other_name).should == "overwrite content" + end + + it "opens the file in append mode if the IO appends" do + touch(@name) { |f| f.write "original data" } + touch(@other_name) { |f| f.write "new data" } + @io = new_io @name, "a" + + @io.reopen(@other_name) + -> { @io.read }.should.raise(IOError) + @io.write("overwrite content").should == 17 + @io.close + File.read(@other_name).should == "new dataoverwrite content" + end end describe "IO#reopen with a String" do diff --git a/spec/ruby/core/io/seek_spec.rb b/spec/ruby/core/io/seek_spec.rb index d6e553bae2ce58..9d42e3a5c727f2 100644 --- a/spec/ruby/core/io/seek_spec.rb +++ b/spec/ruby/core/io/seek_spec.rb @@ -77,6 +77,19 @@ value[-1].should == @io.read[0] end + ruby_bug "#20919", "" ... "3.4" do + it "clears the character buffer" do + @io.ungetc("a") + @io.seek(1, IO::SEEK_SET) + @io.getc.should == "o" + + @io.set_encoding(Encoding::UTF_8, Encoding::UTF_16LE) + @io.ungetc("a".encode(Encoding::UTF_16LE)) + @io.seek(1, IO::SEEK_SET) + @io.getc.should == "o".encode(Encoding::UTF_16LE) + end + end + platform_is :darwin do it "supports seek offsets greater than 2^32" do begin diff --git a/spec/ruby/core/io/set_encoding_spec.rb b/spec/ruby/core/io/set_encoding_spec.rb index 237251de5b4bc0..27f5a4103c29b3 100644 --- a/spec/ruby/core/io/set_encoding_spec.rb +++ b/spec/ruby/core/io/set_encoding_spec.rb @@ -1,5 +1,94 @@ require_relative '../../spec_helper' +describe :io_set_encoding_common, shared: true do + describe "when Encoding.default_external is not binary and Encoding.default_internal is not nil" do + it "sets the encodings to the current Encoding defaults" do + @io = new_io @name, "#{@object}" + + Encoding.default_external = Encoding::ISO_8859_1 + Encoding.default_internal = Encoding::ISO_8859_2 + + @io.set_encoding nil, nil + + @io.external_encoding.should.equal?(Encoding::ISO_8859_1) + @io.internal_encoding.should.equal?(Encoding::ISO_8859_2) + end + + it "prevents the encodings from changing when Encoding defaults are changed" do + @io = new_io @name, "#{@object}" + + Encoding.default_external = Encoding::ISO_8859_1 + Encoding.default_internal = Encoding::ISO_8859_2 + + @io.set_encoding nil, nil + + Encoding.default_external = Encoding::IBM437 + Encoding.default_internal = Encoding::IBM866 + + @io.external_encoding.should.equal?(Encoding::ISO_8859_1) + @io.internal_encoding.should.equal?(Encoding::ISO_8859_2) + end + end +end + +describe :io_set_encoding_readonly, shared: true do + describe "when Encoding.default_external is binary" do + it "prevents the #internal_encoding from changing when Encoding.default_internal is changed" do + @io = new_io @name, "#{@object}" + + Encoding.default_external = Encoding::ASCII_8BIT + Encoding.default_internal = Encoding::ISO_8859_2 + + @io.set_encoding nil, nil + + Encoding.default_internal = Encoding::IBM437 + + @io.internal_encoding.should == nil + end + + it "allows the #external_encoding to change when Encoding.default_external is changed" do + @io = new_io @name, "#{@object}" + + Encoding.default_external = Encoding::ASCII_8BIT + Encoding.default_internal = Encoding::ISO_8859_2 + + @io.set_encoding nil, nil + + Encoding.default_external = Encoding::IBM437 + + @io.external_encoding.should.equal?(Encoding::IBM437) + end + end + + describe "when Encoding.default_internal is nil" do + it "prevents the #internal_encoding from changing when Encoding.default_internal is changed" do + @io = new_io @name, "#{@object}" + + Encoding.default_external = Encoding::ISO_8859_1 + Encoding.default_internal = nil + + @io.set_encoding nil, nil + + Encoding.default_internal = Encoding::IBM437 + + @io.internal_encoding.should == nil + end + + it "allows the #external_encoding to change when Encoding.default_external is changed" do + @io = new_io @name, "#{@object}" + + Encoding.default_external = Encoding::ISO_8859_1 + Encoding.default_internal = nil + + @io.set_encoding nil, nil + + Encoding.default_external = Encoding::IBM437 + + @io.external_encoding.should.equal?(Encoding::IBM437) + end + end +end + describe :io_set_encoding_write, shared: true do it "sets the encodings to nil when they were set previously" do @io = new_io @name, "#{@object}:ibm437:ibm866" @@ -32,18 +121,6 @@ @io.external_encoding.should == nil @io.internal_encoding.should == nil end - - it "sets the encodings to the current Encoding defaults" do - @io = new_io @name, @object - - Encoding.default_external = Encoding::IBM437 - Encoding.default_internal = Encoding::IBM866 - - @io.set_encoding nil, nil - - @io.external_encoding.should == Encoding::IBM437 - @io.internal_encoding.should == Encoding::IBM866 - end end describe "IO#set_encoding when passed nil, nil" do @@ -68,63 +145,44 @@ end describe "with 'r' mode" do - it "sets the encodings to the current Encoding defaults" do - @io = new_io @name, "r" + it_behaves_like :io_set_encoding_common, nil, "r" - Encoding.default_external = Encoding::IBM437 - Encoding.default_internal = Encoding::IBM866 - - @io.set_encoding nil, nil - @io.external_encoding.should.equal?(Encoding::IBM437) - @io.internal_encoding.should.equal?(Encoding::IBM866) - end - - it "prevents the #internal_encoding from changing when Encoding.default_internal is changed" do - @io = new_io @name, "r" - @io.set_encoding nil, nil - - Encoding.default_internal = Encoding::IBM437 - - @io.internal_encoding.should == nil - end - - it "allows the #external_encoding to change when Encoding.default_external is changed" do - @io = new_io @name, "r" - @io.set_encoding nil, nil - - Encoding.default_external = Encoding::IBM437 - - @io.external_encoding.should.equal?(Encoding::IBM437) - end + it_behaves_like :io_set_encoding_readonly, nil, "r" end describe "with 'rb' mode" do - it "returns Encoding.default_external" do - @io = new_io @name, "rb" - @io.external_encoding.should.equal?(Encoding::BINARY) + it_behaves_like :io_set_encoding_common, nil, "rb" - @io.set_encoding nil, nil - @io.external_encoding.should.equal?(Encoding.default_external) - end + it_behaves_like :io_set_encoding_readonly, nil, "rb" end describe "with 'r+' mode" do + it_behaves_like :io_set_encoding_common, nil, "r+" + it_behaves_like :io_set_encoding_write, nil, "r+" end describe "with 'w' mode" do + it_behaves_like :io_set_encoding_common, nil, "w" + it_behaves_like :io_set_encoding_write, nil, "w" end describe "with 'w+' mode" do + it_behaves_like :io_set_encoding_common, nil, "w+" + it_behaves_like :io_set_encoding_write, nil, "w+" end describe "with 'a' mode" do + it_behaves_like :io_set_encoding_common, nil, "a" + it_behaves_like :io_set_encoding_write, nil, "a" end describe "with 'a+' mode" do + it_behaves_like :io_set_encoding_common, nil, "a+" + it_behaves_like :io_set_encoding_write, nil, "a+" end @@ -203,6 +261,23 @@ @io.internal_encoding.should == Encoding::UTF_16BE end + it "sets the internal encoding to nil when passed '-' as the second argument" do + default_internal = Encoding.default_internal + Encoding.default_internal = Encoding::UTF_16BE + + @io.set_encoding("utf-8", "-") + @io.external_encoding.should == Encoding::UTF_8 + @io.internal_encoding.should == nil + ensure + Encoding.default_internal = default_internal + end + + it "sets the internal encoding to nil when external encoding is BINARY" do + @io.set_encoding(Encoding::BINARY, Encoding::IBM437) + @io.external_encoding.should == Encoding::BINARY + @io.internal_encoding.should == nil + end + it "calls #to_str to convert an abject to a String" do obj = mock("io_set_encoding") obj.should_receive(:to_str).and_return("utf-8:utf-16be") @@ -235,4 +310,103 @@ it "raises ArgumentError when too many arguments are given" do -> { @io.set_encoding(1, 2, 3) }.should.raise(ArgumentError) end + + it "raises ArgumentError when argument is not ASCII compatible" do + -> { @io.set_encoding("utf-8".encode(Encoding::UTF_16BE)) }.should.raise(ArgumentError) + end + + it "raises TypeError when the first argument is nil and the second is not nil" do + -> { @io.set_encoding(nil, Encoding::UTF_8) }.should.raise(TypeError) + end + + it "raises ArgumentError when newline decorator has invalid value" do + -> { + @io.set_encoding("utf-8", newline: :invalid) + }.should.raise(ArgumentError, "unexpected value for newline option: invalid") + -> { + @io.set_encoding("utf-8", newline: "invalid") + }.should.raise(ArgumentError, "unexpected value for newline option") + end + + it "raises ArgumentError when ASCII incompatible encoding is used with readable stream without binmode or character conversion" do + default_external = Encoding.default_external + + io = new_io @name, "r" + + -> { + io.set_encoding(Encoding::UTF_16BE) + }.should.raise(ArgumentError, "ASCII incompatible encoding needs binmode") + + Encoding.default_external = Encoding::UTF_16BE + + -> { + io.set_encoding(nil) + }.should.raise(ArgumentError, "ASCII incompatible encoding needs binmode") + ensure + io.close + Encoding.default_external = default_external + end + + it "sets the external encoding when ASCII incompatible encoding is used with binmode" do + io = new_io @name, "r" + io.binmode + + io.set_encoding(Encoding::UTF_16BE) + io.external_encoding.should == Encoding::UTF_16BE + + default_external = Encoding.default_external + Encoding.default_external = Encoding::UTF_16BE + + io.set_encoding(nil) + io.external_encoding.should == Encoding::UTF_16BE + ensure + io.close + Encoding.default_external = default_external + end + + it "sets the external and internal encodings when ASCII incompatible encoding is used with character conversion" do + io = new_io @name, "r" + + io.set_encoding(Encoding::UTF_16BE, Encoding::UTF_16LE) + io.external_encoding.should == Encoding::UTF_16BE + io.internal_encoding.should == Encoding::UTF_16LE + ensure + io.close + end + + it "sets the external encoding when ASCII incompatible encoding is used with a write-only stream" do + default_external = Encoding.default_external + + @io.set_encoding(Encoding::UTF_16BE) + @io.external_encoding.should == Encoding::UTF_16BE + + Encoding.default_external = Encoding::UTF_16BE + + @io.set_encoding(nil) + @io.external_encoding.should == nil + ensure + Encoding.default_external = default_external + end + + it "raises ArgumentError when newline decorator provided in binary mode" do + @io.binmode + -> { + @io.set_encoding("utf-8", newline: :lf) + }.should.raise(ArgumentError, "newline decorator with binary mode") + end + + it "sets the internal encoding to Encoding.default_internal when the second argument is nil" do + default_internal = Encoding.default_internal + Encoding.default_internal = Encoding::UTF_16BE + + @io.set_encoding(Encoding::UTF_8, nil) + @io.internal_encoding.should == Encoding::UTF_16BE + ensure + Encoding.default_internal = default_internal + end + + it "raises Argument error when given 2 arguments and an encoding name containing a null byte" do + -> { @io.set_encoding(Encoding::UTF_8, "null\0byte") }.should.raise(ArgumentError) + -> { @io.set_encoding("null\0byte", Encoding::UTF_8) }.should.raise(ArgumentError) + end end diff --git a/spec/ruby/core/io/shared/binwrite.rb b/spec/ruby/core/io/shared/binwrite.rb index 64793b19365a39..17f8e7fbf0152f 100644 --- a/spec/ruby/core/io/shared/binwrite.rb +++ b/spec/ruby/core/io/shared/binwrite.rb @@ -88,4 +88,20 @@ IO.send(@method, @filename, "hello, world!", **{}) File.read(@filename).should == "hello, world!" end + + platform_is :darwin do + it "writes to a file when given a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("io_binwrite_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + IO.send(@method, non_utf8_path, "ok").should == 2 + File.read(utf8_path).should == "ok" + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end diff --git a/spec/ruby/core/io/shared/readlines.rb b/spec/ruby/core/io/shared/readlines.rb index f54fccc2e3fe8f..16e25c8d958503 100644 --- a/spec/ruby/core/io/shared/readlines.rb +++ b/spec/ruby/core/io/shared/readlines.rb @@ -22,6 +22,23 @@ result = IO.send(@method, @name, chomp: true, &@object) (result ? result : ScratchPad.recorded).should == IOSpecs.lines_without_newline_characters end + + platform_is :darwin do + it "reads a file when given a path string in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("io_foreach_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + File.write(utf8_path, "ok\nline2") + result = IO.send(@method, non_utf8_path, &@object) + (result ? result : ScratchPad.recorded).should == ["ok\n", "line2"] + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end describe :io_readlines_options_19, shared: true do diff --git a/spec/ruby/core/io/stat_spec.rb b/spec/ruby/core/io/stat_spec.rb index f9fc232ee002ff..5a9ea34a7c84be 100644 --- a/spec/ruby/core/io/stat_spec.rb +++ b/spec/ruby/core/io/stat_spec.rb @@ -12,7 +12,7 @@ end it "raises IOError on closed stream" do - -> { IOSpecs.closed_io.stat }.should.raise(IOError) + -> { IOSpecs.closed_io.stat }.should.raise(IOError, "closed stream") end it "returns a File::Stat object for the stream" do diff --git a/spec/ruby/core/io/sysopen_spec.rb b/spec/ruby/core/io/sysopen_spec.rb index 325d51ed23e3d6..3859699e894780 100644 --- a/spec/ruby/core/io/sysopen_spec.rb +++ b/spec/ruby/core/io/sysopen_spec.rb @@ -47,4 +47,21 @@ @fd = IO.sysopen(@filename, nil, nil) @fd.should_not.equal?(0) end + + platform_is :darwin do + it "returns a file descriptor for a given path when a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("io_sysopen_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + @fd = IO.sysopen(non_utf8_path, "w") + @fd.should.is_a?(Integer) + File.should.exist?(utf8_path) + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end diff --git a/spec/ruby/core/io/sysread_spec.rb b/spec/ruby/core/io/sysread_spec.rb index 078e97934a7668..4b9aba46a23628 100644 --- a/spec/ruby/core/io/sysread_spec.rb +++ b/spec/ruby/core/io/sysread_spec.rb @@ -52,7 +52,9 @@ it "raises an error when called after buffered reads" do @file.readline - -> { @file.sysread(5) }.should.raise(IOError) + -> do + @file.sysread(5) + end.should.raise(IOError, "sysread for buffered IO") end it "reads normally even when called immediately after a buffered IO#read" do diff --git a/spec/ruby/core/io/sysseek_spec.rb b/spec/ruby/core/io/sysseek_spec.rb index e513d189aec0ff..db03e925031f39 100644 --- a/spec/ruby/core/io/sysseek_spec.rb +++ b/spec/ruby/core/io/sysseek_spec.rb @@ -28,7 +28,9 @@ it "raises an error when called after buffered reads" do @io.readline - -> { @io.sysseek(-5, IO::SEEK_CUR) }.should.raise(IOError) + -> do + @io.sysseek(-5, IO::SEEK_CUR) + end.should.raise(IOError, "sysseek for buffered IO") end it "seeks normally even when called immediately after a buffered IO#read" do diff --git a/spec/ruby/core/io/syswrite_spec.rb b/spec/ruby/core/io/syswrite_spec.rb index 8bf61a27c3e475..e82866c6116552 100644 --- a/spec/ruby/core/io/syswrite_spec.rb +++ b/spec/ruby/core/io/syswrite_spec.rb @@ -60,19 +60,22 @@ end describe "IO#syswrite on a pipe" do - it "returns the written bytes if the fd is in nonblock mode and write would block" do + before do require 'io/nonblock' - r, w = IO.pipe - begin - w.nonblock = true - larger_than_pipe_capacity = 2 * 1024 * 1024 - written = w.syswrite("a"*larger_than_pipe_capacity) - written.should > 0 - written.should < larger_than_pipe_capacity - ensure - w.close - r.close - end + @read, @write = IO.pipe + end + + after do + @read.close + @write.close + end + + it "returns the written bytes if the fd is in nonblock mode and write would block" do + @write.nonblock = true + larger_than_pipe_capacity = 2 * 1024 * 1024 + written = @write.syswrite("a"*larger_than_pipe_capacity) + written.should > 0 + written.should < larger_than_pipe_capacity end end diff --git a/spec/ruby/core/io/ungetc_spec.rb b/spec/ruby/core/io/ungetc_spec.rb index 413ea2aa71c65d..e027632dc9e3e2 100644 --- a/spec/ruby/core/io/ungetc_spec.rb +++ b/spec/ruby/core/io/ungetc_spec.rb @@ -97,12 +97,6 @@ @io.pos.should == pos - 1 end - it "makes subsequent unbuffered operations to raise IOError" do - @io.getc - @io.ungetc(100) - -> { @io.sysread(1) }.should.raise(IOError) - end - it "raises TypeError if passed nil" do @io.getc.should == ?V proc{@io.ungetc(nil)}.should raise_consistent_error(TypeError, /no implicit conversion of nil into String/) diff --git a/spec/ruby/core/io/write_nonblock_spec.rb b/spec/ruby/core/io/write_nonblock_spec.rb index a6bd43c0581bb6..6671de5184241f 100644 --- a/spec/ruby/core/io/write_nonblock_spec.rb +++ b/spec/ruby/core/io/write_nonblock_spec.rb @@ -77,6 +77,12 @@ } end + it "raises an ArgumentError if exception: is not true or false" do + -> { @write.write_nonblock("a", exception: 0) }.should.raise ArgumentError, /expected true or false/ + -> { @write.write_nonblock("a", exception: nil) }.should.raise ArgumentError, /expected true or false/ + -> { @write.write_nonblock("a", exception: 'false') }.should.raise ArgumentError, /expected true or false/ + end + context "when exception option is set to false" do it "returns :wait_writable when the operation would block" do loop { diff --git a/spec/ruby/core/io/write_spec.rb b/spec/ruby/core/io/write_spec.rb index 469f9a89ee316b..a7023464abbc9e 100644 --- a/spec/ruby/core/io/write_spec.rb +++ b/spec/ruby/core/io/write_spec.rb @@ -140,6 +140,36 @@ File.binread(@filename).bytes.should == [0x61, 0x00, 0x00, 0x00, 0xC4, 0x85] end end + + it "writes binary data if encoding is ASCII-8BIT" do + File.open(@filename, "w:ascii-8bit") do |file| + file.write('Hëllö'.encode('ISO-8859-1')) + end + ë = ([235].pack('U')).encode('ISO-8859-1') + ö = ([246].pack('U')).encode('ISO-8859-1') + res = "H#{ë}ll#{ö}" + File.binread(@filename).should == res.b + end + + it "writes binary data with newline conversion if no encoding is given" do + File.open(@filename, "w", newline: :crlf) do |file| + file.write("Hëllö\n".encode('ISO-8859-1')) + end + ë = ([235].pack('U')).encode('ISO-8859-1') + ö = ([246].pack('U')).encode('ISO-8859-1') + res = "H#{ë}ll#{ö}\r\n" + File.binread(@filename).should == res.b + end + + it "writes binary data with newline conversion if encoding is ASCII-8BIT" do + File.open(@filename, "w:ascii-8bit", newline: :crlf) do |file| + file.write("Hëllö\n".encode('ISO-8859-1')) + end + ë = ([235].pack('U')).encode('ISO-8859-1') + ö = ([246].pack('U')).encode('ISO-8859-1') + res = "H#{ë}ll#{ö}\r\n" + File.binread(@filename).should == res.b + end end describe "IO.write" do diff --git a/spec/ruby/core/kernel/Complex_spec.rb b/spec/ruby/core/kernel/Complex_spec.rb index a3887953de2a05..800fbca2c23522 100644 --- a/spec/ruby/core/kernel/Complex_spec.rb +++ b/spec/ruby/core/kernel/Complex_spec.rb @@ -274,6 +274,12 @@ it "freezes its result" do Complex(1).frozen?.should == true end + + it "raises an ArgumentError if exception: is not true or false" do + -> { Complex(1, exception: 0) }.should.raise ArgumentError, /expected true or false/ + -> { Complex(1, exception: nil) }.should.raise ArgumentError, /expected true or false/ + -> { Complex(1, exception: 'false') }.should.raise ArgumentError, /expected true or false/ + end end describe "Kernel.Complex" do diff --git a/spec/ruby/core/kernel/Float_spec.rb b/spec/ruby/core/kernel/Float_spec.rb index 4c5d783095cfca..fda2423e09ca40 100644 --- a/spec/ruby/core/kernel/Float_spec.rb +++ b/spec/ruby/core/kernel/Float_spec.rb @@ -384,6 +384,12 @@ def to_f() 1.2 end -> { Float(c) }.should.raise(RangeError) end + it "raises an ArgumentError if exception: is not true or false" do + -> { Float(1, exception: 0) }.should.raise ArgumentError, /expected true or false/ + -> { Float(1, exception: nil) }.should.raise ArgumentError, /expected true or false/ + -> { Float(1, exception: 'false') }.should.raise ArgumentError, /expected true or false/ + end + describe "when passed exception: false" do describe "and valid input" do it "returns a Float number" do diff --git a/spec/ruby/core/kernel/Integer_spec.rb b/spec/ruby/core/kernel/Integer_spec.rb index 1c216ec54fcdab..90d7b7ac292313 100644 --- a/spec/ruby/core/kernel/Integer_spec.rb +++ b/spec/ruby/core/kernel/Integer_spec.rb @@ -124,6 +124,12 @@ -> { Integer(infinity_value) }.should.raise(FloatDomainError) end + it "raises an ArgumentError if exception: is not true or false" do + -> { Integer(1, exception: 0) }.should.raise ArgumentError, /expected true or false/ + -> { Integer(1, exception: nil) }.should.raise ArgumentError, /expected true or false/ + -> { Integer(1, exception: 'false') }.should.raise ArgumentError, /expected true or false/ + end + describe "when passed exception: false" do describe "and to_i returns a value that is not an Integer" do it "swallows an error" do diff --git a/spec/ruby/core/kernel/Rational_spec.rb b/spec/ruby/core/kernel/Rational_spec.rb index 7ffa314a535267..d39effd025a533 100644 --- a/spec/ruby/core/kernel/Rational_spec.rb +++ b/spec/ruby/core/kernel/Rational_spec.rb @@ -234,6 +234,12 @@ def obj.to_int; raise; end it "freezes its result" do Rational(1).frozen?.should == true end + + it "raises an ArgumentError if exception: is not true or false" do + -> { Rational(1, exception: 0) }.should.raise ArgumentError, /expected true or false/ + -> { Rational(1, exception: nil) }.should.raise ArgumentError, /expected true or false/ + -> { Rational(1, exception: 'false') }.should.raise ArgumentError, /expected true or false/ + end end describe "Kernel.Rational" do diff --git a/spec/ruby/core/kernel/p_spec.rb b/spec/ruby/core/kernel/p_spec.rb index 9abacbfdb32999..3b8bdf0e06cf12 100644 --- a/spec/ruby/core/kernel/p_spec.rb +++ b/spec/ruby/core/kernel/p_spec.rb @@ -69,11 +69,32 @@ end it "prints nothing if no argument is given" do - -> { p }.should output("") + -> { p.should == nil }.should output("") end it "prints nothing if called splatting an empty Array" do - -> { p(*[]) }.should output("") + -> { p(*[]).should == nil }.should output("") + end + + it "returns the argument if a single argument is given" do + o = mock("Inspector Gadget") + o.should_receive(:inspect).any_number_of_times.and_return "Next time, Gadget, NEXT TIME!" + + -> { p(o).should.equal?(o) }.should output("Next time, Gadget, NEXT TIME!\n") + end + + it "returns the argument if called splatting a single-element Array" do + o = mock("Inspector Gadget") + o.should_receive(:inspect).any_number_of_times.and_return "Next time, Gadget, NEXT TIME!" + + -> { p(*[o]).should.equal?(o) }.should output("Next time, Gadget, NEXT TIME!\n") + end + + it "returns an Array of the arguments if multiple arguments are given" do + o = mock("Inspector Gadget") + o.should_receive(:inspect).any_number_of_times.and_return "Next time, Gadget, NEXT TIME!" + + -> { p(o, o).should == [o, o] }.should output("Next time, Gadget, NEXT TIME!\nNext time, Gadget, NEXT TIME!\n") end # Not sure how to spec this, but wanted to note the behavior here diff --git a/spec/ruby/core/kernel/raise_spec.rb b/spec/ruby/core/kernel/raise_spec.rb index c1642d5b43163a..7fde0cca3972ec 100644 --- a/spec/ruby/core/kernel/raise_spec.rb +++ b/spec/ruby/core/kernel/raise_spec.rb @@ -38,14 +38,26 @@ class << public_raiser ScratchPad.recorded.should == nil end + new_raisers = [ + -> { raise "Error" }, + -> { raise RuntimeError, "Error" }, + -> { raise RuntimeError, "Error", [] } + ] + + re_raisers_with_cause = [ + -> e1, e2 {raise e1, cause: e2}, + -> e1, e2 {raise e1, "New message", cause: e2}, + -> e1, e2 {raise e1, "New message", [], cause: e2} + ] + it "re-raises a previously rescued exception without overwriting the cause" do - begin + check = -> second_raiser do begin begin begin raise "Error 1" rescue => e1 - raise "Error 2" + second_raiser.call end rescue => e2 raise "Error 3" @@ -57,56 +69,61 @@ class << public_raiser rescue => e e.cause.should == e1 end + + new_raisers.each(&check) end it "re-raises a previously rescued exception with overwriting the cause when it's explicitly specified with :cause option" do - e4 = RuntimeError.new("Error 4") - - begin + check = -> ((raiser, re_raiser_with_cause)) do # rubocop:disable Style/StabbyLambdaParentheses + e4 = RuntimeError.new("Error 4") begin begin begin raise "Error 1" rescue => e1 - raise "Error 2" + raiser.call end rescue => e2 raise "Error 3" end rescue e2.cause.should == e1 - raise e2, cause: e4 + re_raiser_with_cause.call(e2, e4) end rescue => e e.cause.should == e4 end + + new_raisers.product(re_raisers_with_cause).each(&check) end - it "re-raises a previously rescued exception without overwriting the cause when it's explicitly specified with :cause option and has nil value" do - begin + it "re-raises a previously rescued exception without overwriting the cause when it's explicitly specified with a :cause option that has nil value" do + check = -> ((raiser, re_raiser_with_cause)) do # rubocop:disable Style/StabbyLambdaParentheses begin begin begin raise "Error 1" rescue => e1 - raise "Error 2" + raiser.call end rescue => e2 raise "Error 3" end rescue e2.cause.should == e1 - raise e2, cause: nil + re_raiser_with_cause.call(e2, nil) end rescue => e e.cause.should == e1 end + + new_raisers.product(re_raisers_with_cause).each(&check) end it "re-raises a previously rescued exception without setting a cause implicitly" do - begin + check = -> raiser do begin - raise "Error 1" + raiser.call rescue => e1 raise end @@ -114,12 +131,14 @@ class << public_raiser e.should == e1 e.cause.should == nil end + + new_raisers.each(&check) end it "re-raises a previously rescued exception that has a cause without setting a cause implicitly" do - begin + check = -> raiser do begin - raise "Error 1" + raiser.call rescue => e1 begin raise "Error 2" @@ -131,12 +150,14 @@ class << public_raiser e.should == e2 e.cause.should == e1 end + + new_raisers.each(&check) end - it "re-raises a previously rescued exception that doesn't have a cause and isn't a cause of any other exception with setting a cause implicitly" do - begin + it "raises a new exception with two outer rescues while setting the cause implicitly to the innermost rescued exception" do + check = -> raiser do begin - raise "Error 1" + raiser.call rescue => e1 begin raise "Error 2" @@ -148,12 +169,14 @@ class << public_raiser e.message.should == "Error 3" e.cause.should == e2 end + + new_raisers.each(&check) end it "re-raises a previously rescued exception that doesn't have a cause and is a cause of other exception without setting a cause implicitly" do - begin + check = -> raiser do begin - raise "Error 1" + raiser.call rescue => e1 begin raise "Error 2" @@ -167,12 +190,14 @@ class << public_raiser e.should == e1 e.cause.should == nil end + + new_raisers.each(&check) end it "re-raises a previously rescued exception that doesn't have a cause and is a cause of other exception (that wasn't raised explicitly) without setting a cause implicitly" do - begin + check = -> raiser do begin - raise "Error 1" + raiser.call rescue => e1 begin foo # raises NameError @@ -186,12 +211,14 @@ class << public_raiser e.should == e1 e.cause.should == nil end + + new_raisers.each(&check) end it "re-raises a previously rescued exception that has a cause but isn't a cause of any other exception without setting a cause implicitly" do - begin + check = -> raiser do begin - raise "Error 1" + raiser.call rescue => e1 begin raise "Error 2" @@ -209,6 +236,8 @@ class << public_raiser e.should == e2 e.cause.should == e1 end + + new_raisers.each(&check) end end diff --git a/spec/ruby/core/kernel/shared/sprintf.rb b/spec/ruby/core/kernel/shared/sprintf.rb index 955d8ca36ebdea..bebde63fc1e8db 100644 --- a/spec/ruby/core/kernel/shared/sprintf.rb +++ b/spec/ruby/core/kernel/shared/sprintf.rb @@ -1023,7 +1023,11 @@ def obj.to_str it "respects Hash#default when there is no set key" do @method.call("%{foo}", Hash.new(123)).should == "123" - @method.call("%{foo}", Hash.new { 123 }).should == "123" + hash_default_proc = Hash.new do |*args| + args.should == [hash_default_proc, :foo] + 123 + end + @method.call("%{foo}", hash_default_proc).should == "123" end it "raises KeyError when Hash#default returns nil" do diff --git a/spec/ruby/core/kernel/system_spec.rb b/spec/ruby/core/kernel/system_spec.rb index 0f55fc938fd4d0..dc667227037cee 100644 --- a/spec/ruby/core/kernel/system_spec.rb +++ b/spec/ruby/core/kernel/system_spec.rb @@ -37,6 +37,11 @@ -> { system('feature_14386', exception: true) }.should.raise(Errno::ENOENT) end + it "raises an ArgumentError if :exception option is not a boolean or nil" do + -> { system("true", exception: 1) }.should.raise(ArgumentError, /expected true or false/) + -> { system("true", exception: "true") }.should.raise(ArgumentError, /expected true or false/) + end + it "returns nil when command execution fails" do system("sad").should == nil diff --git a/spec/ruby/core/module/module_function_spec.rb b/spec/ruby/core/module/module_function_spec.rb index 41bd152608b1f2..4465df325d9e6c 100644 --- a/spec/ruby/core/module/module_function_spec.rb +++ b/spec/ruby/core/module/module_function_spec.rb @@ -94,7 +94,46 @@ def test() "hello" end module_function :test end - m.public_methods.map {|me| me.to_s }.include?('test').should == true + m.public_methods(false).should.include?(:test) + end + + it "makes the new Module method public even if the instance method is private" do + m = Module.new do + private + def test() end + module_function :test + end + + m.public_methods(false).should.include?(:test) + m.private_instance_methods(false).should.include?(:test) + end + + it "makes the new Module method public even if the instance method is protected" do + m = Module.new do + protected + def test() end + module_function :test + end + + m.public_methods(false).should.include?(:test) + m.private_instance_methods(false).should.include?(:test) + end + + it "makes initialize, initialize_copy, initialize_clone, initialize_dup, and respond_to_missing? public Module methods" do + m = Module.new do + def initialize() end + def initialize_copy() end + def initialize_clone() end + def initialize_dup() end + def respond_to_missing?() end + + module_function :initialize, :initialize_copy, :initialize_clone, :initialize_dup, :respond_to_missing? + end + + [:initialize, :initialize_copy, :initialize_clone, :initialize_dup, :respond_to_missing?].each do |method| + m.public_methods(false).should.include?(method) + m.private_instance_methods(false).should.include?(method) + end end it "tries to convert the given names to strings using to_str" do @@ -242,6 +281,22 @@ def test3() end m.respond_to?(:test3).should == true end + it "makes the initialize-related and respond_to_missing? Module methods public when defined after toggle" do + m = Module.new do + module_function + def initialize() end + def initialize_copy() end + def initialize_clone() end + def initialize_dup() end + def respond_to_missing?() end + end + + [:initialize, :initialize_copy, :initialize_clone, :initialize_dup, :respond_to_missing?].each do |method| + m.public_methods(false).should.include?(method) + m.private_instance_methods(false).should.include?(method) + end + end + it "does not affect module_evaled method definitions also if outside the eval itself" do m = Module.new do module_function @@ -309,7 +364,7 @@ def test2() end m.respond_to?(:test2).should == true end - context "methods are defined with define_method" do + context "when defining methods using define_method" do context "passed a block" do it "makes any subsequently defined methods module functions with the normal semantics" do m = Module.new do @@ -355,4 +410,51 @@ def test1; end end end end + + context "when defining methods using define_method" do + it "makes the initialize-related and respond_to_missing? module methods public" do + m = Module.new do + module_function + + define_method :initialize do; end + define_method :initialize_copy do; end + define_method :initialize_clone do; end + define_method :initialize_dup do; end + define_method :respond_to_missing? do; end + end + + [:initialize, :initialize_copy, :initialize_clone, :initialize_dup, :respond_to_missing?].each do |method| + m.public_methods(false).should.include?(method) + m.private_instance_methods(false).should.include?(method) + end + end + end + + context "when defining methods using alias" do + it "does not create module functions for the aliased method" do + m = Module.new do + def test; end + + module_function + alias test_alias test + end + + m.should_not.respond_to?(:test) + m.should_not.respond_to?(:test_alias) + end + end + + context "when defining methods using alias_method" do + it "does not create module functions for the aliased method" do + m = Module.new do + def test; end + + module_function + alias_method :test_alias, :test + end + + m.should_not.respond_to?(:test) + m.should_not.respond_to?(:test_alias) + end + end end diff --git a/spec/ruby/core/module/ruby2_keywords_spec.rb b/spec/ruby/core/module/ruby2_keywords_spec.rb index e3926429784013..f8ac6cd69805d6 100644 --- a/spec/ruby/core/module/ruby2_keywords_spec.rb +++ b/spec/ruby/core/module/ruby2_keywords_spec.rb @@ -132,6 +132,21 @@ def proc_call(*args) Hash.ruby2_keywords_hash?(marked).should == true end + it "does not copy or unmark the Hash when it is passed directly as a positional argument" do + obj = Object.new + def obj.single(arg) + arg + end + + h = { a: 1 } + marked = mark(**h).last + Hash.ruby2_keywords_hash?(marked).should == true + + after_usage = obj.single(marked) + after_usage.should.equal?(marked) + Hash.ruby2_keywords_hash?(after_usage).should == true + end + it "applies to the underlying method and applies across aliasing" do obj = Object.new diff --git a/spec/ruby/core/proc/clone_spec.rb b/spec/ruby/core/proc/clone_spec.rb index aee4873e0994ec..fa8ecbee046c59 100644 --- a/spec/ruby/core/proc/clone_spec.rb +++ b/spec/ruby/core/proc/clone_spec.rb @@ -5,6 +5,15 @@ describe "Proc#clone" do it_behaves_like :proc_dup, :clone + it "copies the singleton class" do + obj = proc { } + def obj.tag; :tag; end + + clone = obj.clone + clone.should.respond_to?(:tag) + clone.tag.should == :tag + end + ruby_bug "cloning a frozen proc is broken on Ruby 3.3", ""..."3.4" do it "preserves frozen status" do proc = Proc.new { } diff --git a/spec/ruby/core/proc/dup_spec.rb b/spec/ruby/core/proc/dup_spec.rb index 8604389422210b..7bd586f63cc128 100644 --- a/spec/ruby/core/proc/dup_spec.rb +++ b/spec/ruby/core/proc/dup_spec.rb @@ -5,6 +5,13 @@ describe "Proc#dup" do it_behaves_like :proc_dup, :dup + it "does not copy the singleton class" do + obj = proc { } + def obj.tag; end + + obj.dup.should_not.respond_to?(:tag) + end + it "resets frozen status" do proc = Proc.new { } proc.freeze diff --git a/spec/ruby/core/proc/fixtures/source_location.rb b/spec/ruby/core/proc/fixtures/source_location.rb index e7a1bf5a17e565..59e4a18b6f3409 100644 --- a/spec/ruby/core/proc/fixtures/source_location.rb +++ b/spec/ruby/core/proc/fixtures/source_location.rb @@ -11,7 +11,7 @@ def self.my_lambda end def self.my_block_lambda - lambda { 42 } + lambda { 42 } # rubocop:disable Style/Lambda end MY_PROC_NEW_LINE = __LINE__ + 2 diff --git a/spec/ruby/core/process/daemon_spec.rb b/spec/ruby/core/process/daemon_spec.rb index 7198dfa6eead66..9325029124ee46 100644 --- a/spec/ruby/core/process/daemon_spec.rb +++ b/spec/ruby/core/process/daemon_spec.rb @@ -90,6 +90,16 @@ @daemon.invoke("stay_in_dir", [true]).should == @invoke_dir end + it "raises ArgumentError if the first argument is not a boolean or nil" do + -> { Process.daemon(1) }.should.raise(ArgumentError, /expected true or false/) + -> { Process.daemon("true") }.should.raise(ArgumentError, /expected true or false/) + end + + it "raises ArgumentError if the second argument is not a boolean or nil" do + -> { Process.daemon(true, 1) }.should.raise(ArgumentError, /expected true or false/) + -> { Process.daemon(true, "true") }.should.raise(ArgumentError, /expected true or false/) + end + describe "when the second argument is not given" do it_behaves_like :process_daemon_keep_stdio_open_false, nil, [false] end diff --git a/spec/ruby/core/process/egid_spec.rb b/spec/ruby/core/process/egid_spec.rb index 69c86bebe22384..a2904eaf8d4818 100644 --- a/spec/ruby/core/process/egid_spec.rb +++ b/spec/ruby/core/process/egid_spec.rb @@ -1,4 +1,5 @@ require_relative '../../spec_helper' +require_relative 'fixtures/common' describe "Process.egid" do it "returns the effective group ID for this process" do @@ -33,11 +34,13 @@ as_user do it "raises Errno::ERPERM if run by a non superuser trying to set the root group id" do + skip "Codex sandbox returns EINVAL instead of EPERM for uid/gid permission changes" if ProcessSpecs.codex_sandbox? -> { Process.egid = 0 }.should.raise(Errno::EPERM) end platform_is :linux do it "raises Errno::ERPERM if run by a non superuser trying to set the group id from group name" do + skip "Codex sandbox returns EINVAL instead of EPERM for uid/gid permission changes" if ProcessSpecs.codex_sandbox? -> { Process.egid = "root" }.should.raise(Errno::EPERM) end end diff --git a/spec/ruby/core/process/euid_spec.rb b/spec/ruby/core/process/euid_spec.rb index da76f06e597941..c5283900858038 100644 --- a/spec/ruby/core/process/euid_spec.rb +++ b/spec/ruby/core/process/euid_spec.rb @@ -1,4 +1,5 @@ require_relative '../../spec_helper' +require_relative 'fixtures/common' describe "Process.euid" do it "returns the effective user ID for this process" do @@ -33,10 +34,12 @@ as_user do it "raises Errno::ERPERM if run by a non superuser trying to set the superuser id" do + skip "Codex sandbox returns EINVAL instead of EPERM for uid/gid permission changes" if ProcessSpecs.codex_sandbox? -> { Process.euid = 0 }.should.raise(Errno::EPERM) end it "raises Errno::ERPERM if run by a non superuser trying to set the superuser id from username" do + skip "Codex sandbox returns EINVAL instead of EPERM for uid/gid permission changes" if ProcessSpecs.codex_sandbox? -> { Process.euid = "root" }.should.raise(Errno::EPERM) end end diff --git a/spec/ruby/core/process/exec_spec.rb b/spec/ruby/core/process/exec_spec.rb index a48d461b026d8c..da9b4d38f9096b 100644 --- a/spec/ruby/core/process/exec_spec.rb +++ b/spec/ruby/core/process/exec_spec.rb @@ -238,4 +238,23 @@ end end end + + describe "options validation" do + it "raises an ArgumentError if :unsetenv_others option is not a boolean or nil" do + -> { Process.exec("true", unsetenv_others: 1) }.should.raise(ArgumentError, /expected true or false/) + -> { Process.exec("true", unsetenv_others: "true") }.should.raise(ArgumentError, /expected true or false/) + end + + it "raises an ArgumentError if :close_others option is not a boolean or nil" do + -> { Process.exec("true", close_others: 1) }.should.raise(ArgumentError, /expected true or false/) + -> { Process.exec("true", close_others: "true") }.should.raise(ArgumentError, /expected true or false/) + end + + platform_is :windows do + it "raises an ArgumentError if :new_pgroup option is not a boolean or nil" do + -> { Process.exec("true", new_pgroup: 1) }.should.raise(ArgumentError, /expected true or false/) + -> { Process.exec("true", new_pgroup: "true") }.should.raise(ArgumentError, /expected true or false/) + end + end + end end diff --git a/spec/ruby/core/process/fixtures/common.rb b/spec/ruby/core/process/fixtures/common.rb index f49513d262f299..5356401495164f 100644 --- a/spec/ruby/core/process/fixtures/common.rb +++ b/spec/ruby/core/process/fixtures/common.rb @@ -1,4 +1,9 @@ module ProcessSpecs + # See https://github.com/openai/codex/issues/34617 + def self.codex_sandbox? + ENV["CODEX_CI"] == "1" + end + def self.use_system_ruby(context) if defined?(MSpecScript::SYSTEM_RUBY) context.send(:before, :all) do diff --git a/spec/ruby/core/process/kill_spec.rb b/spec/ruby/core/process/kill_spec.rb index 885c2bf2b79530..835c03f17e1383 100644 --- a/spec/ruby/core/process/kill_spec.rb +++ b/spec/ruby/core/process/kill_spec.rb @@ -38,6 +38,43 @@ end platform_is_not :windows do + describe "Process.kill" do + it "runs a registered signal handler immediately if called with the current process PID on the main Thread" do + backtrace = nil + old = trap(:SIGTERM) { backtrace = caller(0) } + begin + Process.kill(:SIGTERM, Process.pid) + backtrace.should.is_a?(Array) + backtrace[0].should.include?(__FILE__) + backtrace.join.should =~ /in ('Process[.#]kill'|`kill')/ + ensure + trap(:SIGTERM, old) + end + end + + it "runs a registered signal handler later on the main Thread if called with the current process PID on a non-main Thread" do + backtrace = nil + old = trap(:SIGTERM) { + backtrace = caller(0) + Thread.current.should == Thread.main + } + begin + # a way to detect it's a backtrace of the main thread + caller(0).join.should.include?("
") + + Thread.new do + caller(0).join.should_not.include?("
") + Process.kill(:SIGTERM, Process.pid) + end.join + + Thread.pass until backtrace + backtrace.join.should.include?("
") # the signal handler was run on the main thread + ensure + trap(:SIGTERM, old) + end + end + end + describe "Process.kill" do ProcessSpecs.use_system_ruby(self) diff --git a/spec/ruby/core/process/spawn_spec.rb b/spec/ruby/core/process/spawn_spec.rb index 8f005f8dee1f78..95573f02c43420 100644 --- a/spec/ruby/core/process/spawn_spec.rb +++ b/spec/ruby/core/process/spawn_spec.rb @@ -773,6 +773,23 @@ def child_pids(pid) -> { Process.spawn("echo", nonesuch: :foo) }.should.raise(ArgumentError) end + it "raises an ArgumentError if :unsetenv_others option is not a boolean or nil" do + -> { Process.spawn("true", unsetenv_others: 1) }.should.raise(ArgumentError, /expected true or false/) + -> { Process.spawn("true", unsetenv_others: "true") }.should.raise(ArgumentError, /expected true or false/) + end + + it "raises an ArgumentError if :close_others option is not a boolean or nil" do + -> { Process.spawn("true", close_others: 1) }.should.raise(ArgumentError, /expected true or false/) + -> { Process.spawn("true", close_others: "true") }.should.raise(ArgumentError, /expected true or false/) + end + + platform_is :windows do + it "raises an ArgumentError if :new_pgroup option is not a boolean or nil" do + -> { Process.spawn("true", new_pgroup: 1) }.should.raise(ArgumentError, /expected true or false/) + -> { Process.spawn("true", new_pgroup: "true") }.should.raise(ArgumentError, /expected true or false/) + end + end + platform_is_not :windows, :aix do describe "with Integer option keys" do before :each do diff --git a/spec/ruby/core/process/uid_spec.rb b/spec/ruby/core/process/uid_spec.rb index 1e218ef4fe53ab..5193402eb4a9a1 100644 --- a/spec/ruby/core/process/uid_spec.rb +++ b/spec/ruby/core/process/uid_spec.rb @@ -1,4 +1,5 @@ require_relative '../../spec_helper' +require_relative 'fixtures/common' describe "Process.uid" do platform_is_not :windows do @@ -25,10 +26,12 @@ as_user do it "raises Errno::ERPERM if run by a non privileged user trying to set the superuser id" do + skip "Codex sandbox returns EINVAL instead of EPERM for uid/gid permission changes" if ProcessSpecs.codex_sandbox? -> { (Process.uid = 0)}.should.raise(Errno::EPERM) end it "raises Errno::ERPERM if run by a non privileged user trying to set the superuser id from username" do + skip "Codex sandbox returns EINVAL instead of EPERM for uid/gid permission changes" if ProcessSpecs.codex_sandbox? -> { Process.uid = "root" }.should.raise(Errno::EPERM) end end diff --git a/spec/ruby/core/set/classify_spec.rb b/spec/ruby/core/set/classify_spec.rb index a225ab7cbb93ea..1cfacf3576c897 100644 --- a/spec/ruby/core/set/classify_spec.rb +++ b/spec/ruby/core/set/classify_spec.rb @@ -23,4 +23,10 @@ classified = @set.classify { |x| x.length } classified.should == { 3 => Set["one", "two"], 4 => Set["four"], 5 => Set["three"] } end + + it "does not retain compare_by_identity flag" do + set = Set["one", "two"].compare_by_identity + classified = set.classify { |x| x.length } + classified.values.each { |s| s.compare_by_identity?.should == false } + end end diff --git a/spec/ruby/core/set/divide_spec.rb b/spec/ruby/core/set/divide_spec.rb index 409a22df756e55..e00e4616e19fb1 100644 --- a/spec/ruby/core/set/divide_spec.rb +++ b/spec/ruby/core/set/divide_spec.rb @@ -17,6 +17,12 @@ ret.should.is_a?(Enumerator) ret.each(&:even?).should == Set[Set[1, 3], Set[2, 4]] end + + it "does not retain compare_by_identity flag" do + set = Set["one", "two"].compare_by_identity + res = set.divide { |x| x.length } + res.each { |s| s.compare_by_identity?.should == false } + end end describe "Set#divide when passed a block with an arity of 2" do diff --git a/spec/ruby/core/set/exclusion_spec.rb b/spec/ruby/core/set/exclusion_spec.rb index 52ee34fe786a49..5457e6e3deda8a 100644 --- a/spec/ruby/core/set/exclusion_spec.rb +++ b/spec/ruby/core/set/exclusion_spec.rb @@ -14,4 +14,19 @@ -> { @set ^ 3 }.should.raise(ArgumentError) -> { @set ^ Object.new }.should.raise(ArgumentError) end + + ruby_version_is ""..."4.0" do + it "does not retain compare_by_identity flag" do + @set.compare_by_identity + (@set ^ Set[3, 4, 5]).compare_by_identity?.should == false + (@set ^ [3, 4, 5]).compare_by_identity?.should == false + end + end + ruby_version_is "4.0" do + it "retains compare_by_identity flag" do + @set.compare_by_identity + (@set ^ Set[3, 4, 5]).compare_by_identity?.should == true + (@set ^ [3, 4, 5]).compare_by_identity?.should == true + end + end end diff --git a/spec/ruby/core/set/flatten_spec.rb b/spec/ruby/core/set/flatten_spec.rb index ca6323fac8b747..65c63a9b7f3554 100644 --- a/spec/ruby/core/set/flatten_spec.rb +++ b/spec/ruby/core/set/flatten_spec.rb @@ -23,6 +23,11 @@ end end end + + it "does not retain compare_by_identity flag" do + set = Set[1, 2, Set[3, 4]].compare_by_identity + set.flatten.compare_by_identity?.should == false + end end describe "Set#flatten!" do @@ -46,4 +51,16 @@ (set = Set[]) << set -> { set.flatten! }.should.raise(ArgumentError) end + + it "does not retain compare_by_identity flag when flattening elements" do + set = Set[1, 2, Set[3, 4]].compare_by_identity + set.flatten! + set.compare_by_identity?.should == false + end + + it "retains compare_by_identity flag if no elements are flattened" do + set = Set[1, 2].compare_by_identity + set.flatten! + set.compare_by_identity?.should == true + end end diff --git a/spec/ruby/core/set/intersection_spec.rb b/spec/ruby/core/set/intersection_spec.rb index c14e1f62ad30f7..3551fe701893a2 100644 --- a/spec/ruby/core/set/intersection_spec.rb +++ b/spec/ruby/core/set/intersection_spec.rb @@ -20,4 +20,10 @@ -> { @set & 1 }.should.raise(ArgumentError) -> { @set & Object.new }.should.raise(ArgumentError) end + + it "does not retain compare_by_identity flag" do + @set.compare_by_identity + (@set & Set[:b, :c, :d, :e]).compare_by_identity?.should == false + (@set & [:b, :c, :d]).compare_by_identity?.should == false + end end diff --git a/spec/ruby/core/set/map_spec.rb b/spec/ruby/core/set/map_spec.rb index fd04a8bde17af2..6f6959feebd4a2 100644 --- a/spec/ruby/core/set/map_spec.rb +++ b/spec/ruby/core/set/map_spec.rb @@ -19,4 +19,10 @@ @set.map! { |x| x * 2 } @set.should == Set[2, 4, 6, 8, 10] end + + it "does not retain compare_by_identity flag" do + @set.compare_by_identity + @set.map! { |x| x * 2 } + @set.compare_by_identity?.should == false + end end diff --git a/spec/ruby/core/set/merge_spec.rb b/spec/ruby/core/set/merge_spec.rb index a2c1a7e7069a01..c7f2cd90bfde21 100644 --- a/spec/ruby/core/set/merge_spec.rb +++ b/spec/ruby/core/set/merge_spec.rb @@ -26,4 +26,14 @@ it "accepts multiple arguments" do Set[:a, :b].merge(Set[:b, :c], [:d]).should == Set[:a, :b, :c, :d] end + + it "retains compare_by_identity flag" do + set = Set[1, 2].compare_by_identity + set.merge([3, 4]) + set.compare_by_identity?.should == true + + set2 = Set[1, 2].compare_by_identity + set2.merge(Set[3, 4]) + set2.compare_by_identity?.should == true + end end diff --git a/spec/ruby/core/set/minus_spec.rb b/spec/ruby/core/set/minus_spec.rb index 8574708559ad96..4e3387138b5c55 100644 --- a/spec/ruby/core/set/minus_spec.rb +++ b/spec/ruby/core/set/minus_spec.rb @@ -14,4 +14,10 @@ -> { @set - 1 }.should.raise(ArgumentError) -> { @set - Object.new }.should.raise(ArgumentError) end + + it "retains compare_by_identity flag" do + @set.compare_by_identity + (@set - Set[:a, :b]).compare_by_identity?.should == true + (@set - [:a, :b]).compare_by_identity?.should == true + end end diff --git a/spec/ruby/core/set/replace_spec.rb b/spec/ruby/core/set/replace_spec.rb index 2a51a024dcb27a..bd3a3273413f8d 100644 --- a/spec/ruby/core/set/replace_spec.rb +++ b/spec/ruby/core/set/replace_spec.rb @@ -21,4 +21,22 @@ it "accepts any enumerable as other" do @set.replace([1, 2, 3]).should == Set[1, 2, 3] end + + it "transfers compare_by_identity flag of the argument if it is a Set" do + set1 = Set[:a].compare_by_identity + set2 = Set[1, 2] + set1.replace(set2) + set1.compare_by_identity?.should == false + + set3 = Set[:a] + set4 = Set[1, 2].compare_by_identity + set3.replace(set4) + set3.compare_by_identity?.should == true + end + + it "retains compare_by_identity flag if the argument is a non-Set Enumerable" do + set1 = Set[:a].compare_by_identity + set1.replace([1, 2]) + set1.compare_by_identity?.should == true + end end diff --git a/spec/ruby/core/set/union_spec.rb b/spec/ruby/core/set/union_spec.rb index 206535aae21265..5b15d89a3ee20e 100644 --- a/spec/ruby/core/set/union_spec.rb +++ b/spec/ruby/core/set/union_spec.rb @@ -20,4 +20,10 @@ -> { @set | 1 }.should.raise(ArgumentError) -> { @set | Object.new }.should.raise(ArgumentError) end + + it "retains compare_by_identity flag" do + @set.compare_by_identity + (@set | Set[:b, :d, :e]).compare_by_identity?.should == true + (@set | [:b, :d, :e]).compare_by_identity?.should == true + end end diff --git a/spec/ruby/core/signal/trap_spec.rb b/spec/ruby/core/signal/trap_spec.rb index 5d3105fee8d297..8ab24a3a466eff 100644 --- a/spec/ruby/core/signal/trap_spec.rb +++ b/spec/ruby/core/signal/trap_spec.rb @@ -240,6 +240,10 @@ -> { Signal.trap obj, @proc }.should.raise(ArgumentError, /bad signal type/) end + it "raises ArgumentError when passed negative signal name" do + -> { Signal.trap("-HUP") { } }.should.raise(ArgumentError, "negative signal name: -HUP") + end + it "raises ArgumentError when passed unknown signal" do -> { Signal.trap(300) { } }.should.raise(ArgumentError, "invalid signal number (300)") -> { Signal.trap("USR10") { } }.should.raise(ArgumentError, /\Aunsupported signal [`']SIGUSR10'\z/) diff --git a/spec/ruby/core/tracepoint/trace_spec.rb b/spec/ruby/core/tracepoint/trace_spec.rb index 167f594bb922f2..c1bf36400233c4 100644 --- a/spec/ruby/core/tracepoint/trace_spec.rb +++ b/spec/ruby/core/tracepoint/trace_spec.rb @@ -7,4 +7,18 @@ trace.should.enabled? trace.disable end + + it 'clears $! when invoking the trace block' do + exception_in_trace = nil + trace = TracePoint.trace(:raise) do |tp| + exception_in_trace = $! + end + begin + raise + rescue => e + exception_in_trace.should == nil + $!.should == e + end + trace.disable + end end diff --git a/spec/ruby/language/class_spec.rb b/spec/ruby/language/class_spec.rb index 7ea485751447b6..6a98cef4593247 100644 --- a/spec/ruby/language/class_spec.rb +++ b/spec/ruby/language/class_spec.rb @@ -1,12 +1,6 @@ require_relative '../spec_helper' require_relative '../fixtures/class' -ClassSpecsNumber = 12 - -module ClassSpecs - Number = 12 -end - describe "The class keyword" do it "creates a new class with semicolon" do class ClassSpecsKeywordWithSemicolon; end @@ -43,17 +37,27 @@ class ClassSpecsKeywordWithSemicolon; end end it "raises TypeError if constant given as class name exists and is not a Module" do + ClassSpecsNumber = 123 -> { - class ClassSpecsNumber - end - }.should.raise(TypeError, /\AClassSpecsNumber is not a class/) + class ClassSpecsNumber; end + }.should.raise(TypeError, <<~MSG.strip) + ClassSpecsNumber is not a class + #{__FILE__}:#{__LINE__ - 5}: previous definition of ClassSpecsNumber was here + MSG + ensure + Object.send(:remove_const, :ClassSpecsNumber) end it "raises TypeError if constant given as class name exists and is a Module but not a Class" do + module ClassSpecsModule; end -> { - class ClassSpecs - end - }.should.raise(TypeError, /\AClassSpecs is not a class/) + class ClassSpecsModule; end + }.should.raise(TypeError, <<~MSG.strip) + ClassSpecsModule is not a class + #{__FILE__}:#{__LINE__ - 5}: previous definition of ClassSpecsModule was here + MSG + ensure + Object.send(:remove_const, :ClassSpecsModule) end # test case known to be detecting bugs (JRuby, MRI) @@ -61,19 +65,27 @@ class ClassSpecs -> { class nil::Foo end - }.should.raise(TypeError) + }.should.raise(TypeError, "nil is not a class/module") end it "raises TypeError if any constant qualifying the class is not a Module" do + ClassSpecsNumber = 123 + module ClassSpecsNested + Number = 123 + end + -> { - class ClassSpecs::Number::MyClass + class ClassSpecsNested::Number::MyClass end - }.should.raise(TypeError) + }.should.raise(TypeError, "123 is not a class/module") -> { class ClassSpecsNumber::MyClass end - }.should.raise(TypeError) + }.should.raise(TypeError, "123 is not a class/module") + ensure + Object.send(:remove_const, :ClassSpecsNumber) + Object.send(:remove_const, :ClassSpecsNested) end it "inherits from Object by default" do @@ -87,7 +99,7 @@ class SuperclassResetToSubclass < L -> { class SuperclassResetToSubclass < M end - }.should.raise(TypeError, /superclass mismatch/) + }.should.raise(TypeError, "superclass mismatch for class SuperclassResetToSubclass") end end @@ -100,7 +112,7 @@ class SuperclassReopenedBasicObject < A -> { class SuperclassReopenedBasicObject < BasicObject end - }.should.raise(TypeError, /superclass mismatch/) + }.should.raise(TypeError, "superclass mismatch for class SuperclassReopenedBasicObject") SuperclassReopenedBasicObject.superclass.should == A end end @@ -115,7 +127,7 @@ class SuperclassReopenedObject < A -> { class SuperclassReopenedObject < Object end - }.should.raise(TypeError, /superclass mismatch/) + }.should.raise(TypeError, "superclass mismatch for class SuperclassReopenedObject") SuperclassReopenedObject.superclass.should == A end end @@ -140,7 +152,7 @@ class NoSuperclassSet -> { class NoSuperclassSet < String end - }.should.raise(TypeError, /superclass mismatch/) + }.should.raise(TypeError, "superclass mismatch for class NoSuperclassSet") end end @@ -149,7 +161,7 @@ class NoSuperclassSet < String -> { class ShouldNotWork < self; end - }.should.raise(TypeError) + }.should.raise(TypeError, "superclass must be an instance of Class (given an instance of MSpecEnv)") end it "first evaluates the superclass before checking if the class already exists" do @@ -168,7 +180,9 @@ class SuperclassEvaluatedFirst < remove_const(:SuperclassEvaluatedFirst) it "raises a TypeError if inheriting from a metaclass" do obj = mock("metaclass super") meta = obj.singleton_class - -> { class ClassSpecs::MetaclassSuper < meta; end }.should.raise(TypeError) + -> { + class ClassSpecs::MetaclassSuper < meta; end + }.should.raise(TypeError, "can't make subclass of singleton class") end it "allows the declaration of class variables in the body" do @@ -298,18 +312,16 @@ def self.get_class_name it "raises a TypeError when trying to extend numbers" do -> { - eval <<-CODE - class << 1 - def xyz - self - end + class << 1 + def xyz + self end - CODE - }.should.raise(TypeError) + end + }.should.raise(TypeError, "can't define singleton") end it "raises a TypeError when trying to extend non-Class" do - error_msg = /superclass must be a.* Class/ + error_msg = /superclass must be an instance of Class \(given an instance of .*\)/ -> { class TestClass < ""; end }.should.raise(TypeError, error_msg) -> { class TestClass < 1; end }.should.raise(TypeError, error_msg) -> { class TestClass < :symbol; end }.should.raise(TypeError, error_msg) @@ -341,7 +353,7 @@ def xyz end it "raises a TypeError when superclasses mismatch" do - -> { class ClassSpecs::A < Array; end }.should.raise(TypeError) + -> { class ClassSpecs::A < Array; end }.should.raise(TypeError, "superclass mismatch for class A") end it "adds new methods to subclasses" do diff --git a/spec/ruby/language/fixtures/module.rb b/spec/ruby/language/fixtures/module.rb index 75eee7779172a6..e852e44d366945 100644 --- a/spec/ruby/language/fixtures/module.rb +++ b/spec/ruby/language/fixtures/module.rb @@ -1,8 +1,5 @@ module ModuleSpecs module Modules - class Klass - end - A = "Module" B = 1 C = nil diff --git a/spec/ruby/language/for_spec.rb b/spec/ruby/language/for_spec.rb index b0f3aef40566de..b8a0c9debd3ccb 100644 --- a/spec/ruby/language/for_spec.rb +++ b/spec/ruby/language/for_spec.rb @@ -40,6 +40,26 @@ end end + it "iterates over a list of arrays and destructures with a multi-assignment" do + for (i, j, k) in [[1,2,3]] + [i, j, k].should == [1, 2, 3] + end + + for i, (j, k) in [[1,[2,3]]] + [i, j, k].should == [1, 2, 3] + end + + # Prism-related bug + # https://github.com/ruby/prism/pull/4156 + ruby_version_is "4.1" do + eval <<~RUBY + for (i, j), k in [[[1,2],3]] + [i, j, k].should == [1, 2, 3] + end + RUBY + end + end + it "iterates over an Hash passing each key-value pair to the block" do k = 0 l = 0 diff --git a/spec/ruby/language/module_spec.rb b/spec/ruby/language/module_spec.rb index 2f22e383d54c30..1b974f1b64f32c 100644 --- a/spec/ruby/language/module_spec.rb +++ b/spec/ruby/language/module_spec.rb @@ -62,9 +62,15 @@ module IncludedModule; end end it "raises a TypeError if the constant is a Class" do + class Klass; end -> do - module ModuleSpecs::Modules::Klass; end - end.should.raise(TypeError) + module Klass; end + end.should.raise(TypeError, <<~MSG.strip) + Klass is not a module + #{__FILE__}:#{__LINE__ - 5}: previous definition of Klass was here + MSG + ensure + Object.send(:remove_const, :Klass) end it "raises a TypeError if the constant is a String" do diff --git a/spec/ruby/language/pattern_matching_spec.rb b/spec/ruby/language/pattern_matching_spec.rb index a24500c9fd0b16..783eada94928c0 100644 --- a/spec/ruby/language/pattern_matching_spec.rb +++ b/spec/ruby/language/pattern_matching_spec.rb @@ -893,6 +893,17 @@ def obj.deconstruct }.should.raise(SyntaxError, /duplicated key name/) end + it "raises NoMatchingPatternKeyError if the key does not match" do + kwargs = {a: 1} + -> do + case kwargs + in {b: 2} + end + end.should.raise(NoMatchingPatternKeyError, message: "key not found: :b") + + NoMatchingPatternKeyError.superclass.should == NoMatchingPatternError + end + it "matches an object with #deconstruct_keys method which returns a Hash with equal keys and each value in Hash matches value in pattern" do obj = Object.new diff --git a/spec/ruby/optional/capi/class_spec.rb b/spec/ruby/optional/capi/class_spec.rb index 1486ab6d7fa7ea..ba7826aee74bb8 100644 --- a/spec/ruby/optional/capi/class_spec.rb +++ b/spec/ruby/optional/capi/class_spec.rb @@ -132,10 +132,25 @@ obj.kwargs.should == {} end - it "raises TypeError if the last argument is not a Hash" do + it "coerces the last argument to a hash by calling #to_hash" do + h = mock('to_hash') + h.should_receive(:to_hash).and_return(kw: 2) + obj = @s.rb_class_new_instance_kw([h], CApiClassSpecs::KeywordAlloc) + obj.kwargs.should == {kw: 2} + end + + it "raises a TypeError if the last argument does not respond to #to_hash" do -> { @s.rb_class_new_instance_kw([42], CApiClassSpecs::KeywordAlloc) - }.should.raise(TypeError, 'no implicit conversion of Integer into Hash') + }.should raise_consistent_error(TypeError, 'no implicit conversion of Integer into Hash') + end + + it "raises a TypeError if #to_hash does not return a hash" do + h = mock('to_hash') + h.should_receive(:to_hash).and_return(42) + -> { + @s.rb_class_new_instance_kw([h], CApiClassSpecs::KeywordAlloc) + }.should raise_consistent_error(TypeError, "can't convert MockObject into Hash (MockObject#to_hash gives Integer)") end end @@ -205,6 +220,150 @@ obj = CApiClassSpecs::SubSub.new obj.call_super_method.should == :super_method end + + it "passes block argument as is" do + @s.define_call_super_method CApiClassSpecs::Sub, "call_super_method_block" + obj = CApiClassSpecs::Sub.new + obj.call_super_method_block { :block_val }.should == :block_val + end + + it "calls #method_missing if there is no super method and #method_missing is defined" do + @s.define_call_super_method CApiClassSpecs::Sub, "non_existent_method" + obj = CApiClassSpecs::Sub.new + def obj.method_missing(name, *args) + [name, args] + end + obj.non_existent_method(1, 2).should == [:non_existent_method, [1, 2]] + end + + it "raises a NoMethodError if there is no super method and no #method_missing defined" do + @s.define_call_super_method CApiClassSpecs::Sub, "non_existent_method" + obj = CApiClassSpecs::Sub.new + -> { obj.non_existent_method }.should.raise(NoMethodError) + end + end + + describe "rb_call_super_kw" do + it "calls the method in the superclass" do + @s.define_call_super_kw_method CApiClassSpecs::SubKw, "call_super_method", :RB_NO_KEYWORDS + obj = CApiClassSpecs::SubKw.new + obj.call_super_method.should == :super_method + + @s.define_call_super_kw_method CApiClassSpecs::SubKw, "call_super_method", :RB_PASS_KEYWORDS + obj = CApiClassSpecs::SubKw.new + obj.call_super_method({a: 3}).should == :super_method + end + + it "calls the method in the superclass with correct self" do + @s.define_call_super_kw_method CApiClassSpecs::SubKw, "call_super_method_self", :RB_NO_KEYWORDS + obj = CApiClassSpecs::SubKw.new + obj.call_super_method_self.should.equal? obj + + @s.define_call_super_kw_method CApiClassSpecs::SubKw, "call_super_method_self", :RB_PASS_KEYWORDS + obj = CApiClassSpecs::SubKw.new + obj.call_super_method_self({a: 1}).should.equal? obj + end + + it "passes the last argument as a positional parameter when called with RB_NO_KEYWORDS" do + @s.define_call_super_kw_method CApiClassSpecs::SubKw, "call_super_method_args", :RB_NO_KEYWORDS + obj = CApiClassSpecs::SubKw.new + obj.call_super_method_args(1, 2, {a: 3}).should == [[1, 2, {a: 3}], {}] + end + + it "passes the last argument as keyword arguments when called with RB_PASS_KEYWORDS" do + @s.define_call_super_kw_method CApiClassSpecs::SubKw, "call_super_method_args", :RB_PASS_KEYWORDS + obj = CApiClassSpecs::SubKw.new + obj.call_super_method_args(1, 2, {a: 3}).should == [[1, 2], {a: 3}] + end + + it "passes the last argument as keyword arguments when called with RB_PASS_CALLED_KEYWORDS and with keyword arguments" do + @s.define_call_super_kw_method CApiClassSpecs::SubKw, "call_super_method_args", :RB_PASS_CALLED_KEYWORDS + obj = CApiClassSpecs::SubKw.new + obj.call_super_method_args(1, 2, a: 3).should == [[1, 2], {a: 3}] + end + + it "passes the last argument as a positional parameter when called with RB_PASS_CALLED_KEYWORDS and with a positional Hash" do + @s.define_call_super_kw_method CApiClassSpecs::SubKw, "call_super_method_args", :RB_PASS_CALLED_KEYWORDS + obj = CApiClassSpecs::SubKw.new + hash_obj = {a: 3} + obj.call_super_method_args(1, 2, hash_obj).should == [[1, 2, {a: 3}], {}] + end + + it "passes block argument as is" do + @s.define_call_super_kw_method CApiClassSpecs::SubKw, "call_super_method_block", :RB_NO_KEYWORDS + obj = CApiClassSpecs::SubKw.new + obj.call_super_method_block { :block_val }.should == :block_val + + @s.define_call_super_kw_method CApiClassSpecs::SubKw, "call_super_method_block", :RB_PASS_KEYWORDS + obj = CApiClassSpecs::SubKw.new + obj.call_super_method_block({a: 3}) { :block_val }.should == :block_val + end + + it "calls #method_missing if there is no super method and #method_missing is defined" do + @s.define_call_super_kw_method CApiClassSpecs::SubKw, "non_existent_method", :RB_NO_KEYWORDS + obj = CApiClassSpecs::SubKw.new + def obj.method_missing(name, *args) + [name, args] + end + obj.non_existent_method(1, 2).should == [:non_existent_method, [1, 2]] + + @s.define_call_super_kw_method CApiClassSpecs::SubKw, "non_existent_method", :RB_PASS_KEYWORDS + obj = CApiClassSpecs::SubKw.new + def obj.method_missing(name, *args) + [name, args] + end + obj.non_existent_method(1, 2, {a: 3}).should == [:non_existent_method, [1, 2, {a: 3}]] + end + + it "raises a NoMethodError if there is no super method and no #method_missing defined" do + @s.define_call_super_kw_method CApiClassSpecs::SubKw, "non_existent_method", :RB_NO_KEYWORDS + obj = CApiClassSpecs::SubKw.new + -> { obj.non_existent_method }.should.raise(NoMethodError) + + @s.define_call_super_kw_method CApiClassSpecs::SubKw, "non_existent_method", :RB_PASS_KEYWORDS + obj = CApiClassSpecs::SubKw.new + -> { obj.non_existent_method({a: 3}) }.should.raise(NoMethodError) + end + + it "tolerates giving no positional or keyword arguments when called with RB_PASS_KEYWORDS" do + @s.define_call_super_kw_method CApiClassSpecs::SubKw, "call_super_method_args", :RB_PASS_KEYWORDS + obj = CApiClassSpecs::SubKw.new + obj.call_super_method_args.should == [[], {}] + end + + it "tolerates giving {} as the last positional argument when called with RB_PASS_KEYWORDS" do + @s.define_call_super_kw_method CApiClassSpecs::SubKw, "call_super_method_args", :RB_PASS_KEYWORDS + obj = CApiClassSpecs::SubKw.new + obj.call_super_method_args({}).should == [[], {}] + end + + it "coerces the last argument to a hash by calling #to_hash when called with RB_PASS_KEYWORDS" do + @s.define_call_super_kw_method CApiClassSpecs::SubKw, "call_super_method_args", :RB_PASS_KEYWORDS + obj = CApiClassSpecs::SubKw.new + h = mock('to_hash') + h.should_receive(:to_hash).and_return({a: 3}) + obj.call_super_method_args(1, 2, h).should == [[1, 2], {a: 3}] + end + + it "raises a TypeError if the last argument does not respond to #to_hash when called with RB_PASS_KEYWORDS" do + @s.define_call_super_kw_method CApiClassSpecs::SubKw, "call_super_method_args", :RB_PASS_KEYWORDS + obj = CApiClassSpecs::SubKw.new + + -> { + obj.call_super_method_args(1, 2, 3) + }.should raise_consistent_error(TypeError, 'no implicit conversion of Integer into Hash') + end + + it "raises a TypeError if #to_hash does not return a hash when called with RB_PASS_KEYWORDS" do + @s.define_call_super_kw_method CApiClassSpecs::SubKw, "call_super_method_args", :RB_PASS_KEYWORDS + obj = CApiClassSpecs::SubKw.new + h = mock('to_hash') + h.should_receive(:to_hash).and_return(42) + + -> { + obj.call_super_method_args(1, 2, h) + }.should raise_consistent_error(TypeError, "can't convert MockObject into Hash (MockObject#to_hash gives Integer)") + end end describe "rb_class2name" do @@ -514,4 +673,12 @@ def obj.some_method() end @s.rb_class_get_superclass(Module.new).should == false end end + + describe "a constant defined in C" do + it "raises TypeError if constant given as class name exists and is a Number" do + -> { + class CApiClassSpecs::CONST_DEFINED_IN_NATIVE_CODE; end + }.should.raise(TypeError, /CONST_DEFINED_IN_NATIVE_CODE is not a class/) + end + end end diff --git a/spec/ruby/optional/capi/ext/class_spec.c b/spec/ruby/optional/capi/ext/class_spec.c index 0722ea5915571e..1e858f93ac6ff8 100644 --- a/spec/ruby/optional/capi/ext/class_spec.c +++ b/spec/ruby/optional/capi/ext/class_spec.c @@ -8,12 +8,38 @@ extern "C" { #endif -static VALUE class_spec_call_super_method(VALUE self) { - return rb_call_super(0, 0); +static VALUE class_spec_call_super_method(int argc, VALUE *argv, VALUE self) { + return rb_call_super(argc, argv); } static VALUE class_spec_define_call_super_method(VALUE self, VALUE obj, VALUE str_name) { - rb_define_method(obj, RSTRING_PTR(str_name), class_spec_call_super_method, 0); + rb_define_method(obj, RSTRING_PTR(str_name), class_spec_call_super_method, -1); + return Qnil; +} + +static VALUE class_spec_call_super_kw_no_keywords(int argc, VALUE *argv, VALUE self) { + return rb_call_super_kw(argc, argv, RB_NO_KEYWORDS); +} + +static VALUE class_spec_call_super_kw_pass_keywords(int argc, VALUE *argv, VALUE self) { + return rb_call_super_kw(argc, argv, RB_PASS_KEYWORDS); +} + +static VALUE class_spec_call_super_kw_pass_called_keywords(int argc, VALUE *argv, VALUE self) { + return rb_call_super_kw(argc, argv, RB_PASS_CALLED_KEYWORDS); +} + +static VALUE class_spec_define_call_super_kw_method(VALUE self, VALUE obj, VALUE str_name, VALUE kw_splat) { + ID id = rb_to_id(kw_splat); + if (id == rb_intern("RB_NO_KEYWORDS")) { + rb_define_method(obj, RSTRING_PTR(str_name), class_spec_call_super_kw_no_keywords, -1); + } else if (id == rb_intern("RB_PASS_KEYWORDS")) { + rb_define_method(obj, RSTRING_PTR(str_name), class_spec_call_super_kw_pass_keywords, -1); + } else if (id == rb_intern("RB_PASS_CALLED_KEYWORDS")) { + rb_define_method(obj, RSTRING_PTR(str_name), class_spec_call_super_kw_pass_called_keywords, -1); + } else { + rb_raise(rb_eArgError, "invalid kw_splat value"); + } return Qnil; } @@ -151,7 +177,9 @@ static VALUE class_spec_prepend_module(VALUE self, VALUE klass, VALUE module) { void Init_class_spec(void) { VALUE cls = rb_define_class("CApiClassSpecs", rb_cObject); + rb_define_const(cls, "CONST_DEFINED_IN_NATIVE_CODE", INT2NUM(42)); rb_define_method(cls, "define_call_super_method", class_spec_define_call_super_method, 2); + rb_define_method(cls, "define_call_super_kw_method", class_spec_define_call_super_kw_method, 3); rb_define_method(cls, "rb_class_path", class_spec_rb_class_path, 1); rb_define_method(cls, "rb_class_name", class_spec_rb_class_name, 1); rb_define_method(cls, "rb_class2name", class_spec_rb_class2name, 1); diff --git a/spec/ruby/optional/capi/ext/module_spec.c b/spec/ruby/optional/capi/ext/module_spec.c index 12bcf999835ee8..5facac1419f302 100644 --- a/spec/ruby/optional/capi/ext/module_spec.c +++ b/spec/ruby/optional/capi/ext/module_spec.c @@ -34,6 +34,11 @@ static VALUE module_specs_const_set(VALUE self, VALUE klass, VALUE name, VALUE v return Qnil; } +static VALUE module_specs_rb_deprecate_constant(VALUE self, VALUE cls, VALUE str_name) { + rb_deprecate_constant(cls, RSTRING_PTR(str_name)); + return Qnil; +} + static VALUE module_specs_rb_define_alias(VALUE self, VALUE obj, VALUE new_name, VALUE old_name) { @@ -143,6 +148,8 @@ void Init_module_spec(void) { rb_define_method(cls, "rb_const_get_at", module_specs_const_get_at, 2); rb_define_method(cls, "rb_const_get_from", module_specs_const_get_from, 2); rb_define_method(cls, "rb_const_set", module_specs_const_set, 3); + rb_define_method(cls, "rb_deprecate_constant", module_specs_rb_deprecate_constant, 2); + rb_define_method(cls, "rb_define_alias", module_specs_rb_define_alias, 3); rb_define_method(cls, "rb_alias", module_specs_rb_alias, 3); rb_define_method(cls, "rb_define_module", module_specs_rb_define_module, 1); diff --git a/spec/ruby/optional/capi/ext/process_spec.c b/spec/ruby/optional/capi/ext/process_spec.c index 328186d928efc7..36323d6b415b3e 100644 --- a/spec/ruby/optional/capi/ext/process_spec.c +++ b/spec/ruby/optional/capi/ext/process_spec.c @@ -13,11 +13,14 @@ static VALUE process_spec_rb_process_status_for(VALUE self, VALUE pid, #endif void Init_process_spec(void) { - VALUE cls = rb_define_class("CApiProcessSpecs", rb_cObject); #ifdef RUBY_VERSION_IS_4_1 + VALUE cls = rb_define_class("CApiProcessSpecs", rb_cObject); + rb_define_method(cls, "rb_process_status_for", process_spec_rb_process_status_for, 3); +#else + rb_define_class("CApiProcessSpecs", rb_cObject); #endif } diff --git a/spec/ruby/optional/capi/fixtures/class.rb b/spec/ruby/optional/capi/fixtures/class.rb index b463e3b4c322b1..23aec29e8954e9 100644 --- a/spec/ruby/optional/capi/fixtures/class.rb +++ b/spec/ruby/optional/capi/fixtures/class.rb @@ -64,6 +64,10 @@ class Super def call_super_method :super_method end + + def call_super_method_block + yield + end end class Sub < Super @@ -91,6 +95,28 @@ def call_super_method class SubSelf < SuperSelf end + # rb_call_super_kw + class SuperKw + def call_super_method(*, **) + :super_method + end + + def call_super_method_self(*, **) + self + end + + def call_super_method_args(*args, **kwargs) + [args, kwargs] + end + + def call_super_method_block(*, **) + yield + end + end + + class SubKw < SuperKw + end + class A C = 1 autoload :D, File.expand_path('../path_to_class.rb', __FILE__) diff --git a/spec/ruby/optional/capi/io_spec.rb b/spec/ruby/optional/capi/io_spec.rb index b7517f7f322ff6..ce8ba6cb5da155 100644 --- a/spec/ruby/optional/capi/io_spec.rb +++ b/spec/ruby/optional/capi/io_spec.rb @@ -302,14 +302,18 @@ end describe "rb_io_maybe_wait_writable" do - it "returns mask for events if operation was interrupted" do + it "returns IO::WRITABLE immediately if given errno is EINTR" do @o.rb_io_maybe_wait_writable(Errno::EINTR::Errno, @w_io, nil).should == IO::WRITABLE end - it "returns 0 if there is no error condition" do + it "returns 0 if there is given no error" do @o.rb_io_maybe_wait_writable(0, @w_io, nil).should == 0 end + it "returns 0 if given errno is neither EINTR nor EAGAIN" do + @o.rb_io_maybe_wait_writable(Errno::EBADF::Errno, @w_io, nil).should == 0 + end + it "raises an IOError if the IO is closed" do @w_io.close -> { @o.rb_io_maybe_wait_writable(0, @w_io, nil) }.should.raise(IOError, "closed stream") @@ -323,8 +327,9 @@ platform_is_not :windows do it "raises a IO::TimeoutError if the timeout elapses" do IOSpec.exhaust_write_buffer(@w_io) - -> { @o.rb_io_maybe_wait_writable(Errno::EAGAIN::Errno, @w_io, 0) }. - should.raise(IO::TimeoutError, "Timed out waiting for IO to become writable!") + -> { + @o.rb_io_maybe_wait_writable(Errno::EAGAIN::Errno, @w_io, 0) + }.should.raise(IO::TimeoutError, "Timed out waiting for IO to become writable!") end end @@ -339,8 +344,9 @@ r_sock.close_write w_sock.close_read IOSpec.exhaust_write_buffer(w_sock) - -> { @o.rb_io_maybe_wait_writable(Errno::EAGAIN::Errno, w_sock, 0) }. - should.raise(IO::TimeoutError, "Timed out waiting for IO to become writable!") + -> { + @o.rb_io_maybe_wait_writable(Errno::EAGAIN::Errno, w_sock, 0) + }.should.raise(IO::TimeoutError, "Timed out waiting for IO to become writable!") ensure r_sock.close unless r_sock.closed? w_sock.close unless w_sock.closed? @@ -349,20 +355,58 @@ end end - it "can be interrupted" do - IOSpec.exhaust_write_buffer(@w_io) - start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + platform_is_not :windows do + it "can be interrupted" do + IOSpec.exhaust_write_buffer(@w_io) + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t = Thread.new do - @o.rb_io_maybe_wait_writable(0, @w_io, 10) + t = Thread.new do + @o.rb_io_maybe_wait_writable(Errno::EAGAIN::Errno, @w_io, 10) + + # ensure the call was blocking and was really interrupted + flunk "not reached" + end + + Thread.pass until t.stop? + t.kill + t.join + + finish = Process.clock_gettime(Process::CLOCK_MONOTONIC) + (finish - start).should < 9 end + end - Thread.pass until t.stop? - t.kill - t.join + platform_is :windows do + # Windows select/poll wrapper (rb_w32_select) treats write descriptors of non-sockets + # (such as pipe writers) as always writable. Thus it immediately returns IO::WRITABLE + # instead of timing out or blocking. So use sockets instead. + it "can be interrupted" do + require 'socket' + r_sock, w_sock = Socket.pair(Socket::AF_INET, Socket::SOCK_STREAM, 0) + begin + r_sock.close_write + w_sock.close_read + IOSpec.exhaust_write_buffer(w_sock) + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + t = Thread.new do + @o.rb_io_maybe_wait_writable(Errno::EAGAIN::Errno, w_sock, 10) + + # ensure the call was blocking and was really interrupted + flunk "not reached" + end - finish = Process.clock_gettime(Process::CLOCK_MONOTONIC) - (finish - start).should < 9 + Thread.pass until t.stop? + t.kill + t.join + + finish = Process.clock_gettime(Process::CLOCK_MONOTONIC) + (finish - start).should < 9 + ensure + r_sock.close unless r_sock.closed? + w_sock.close unless w_sock.closed? + end + end end end @@ -417,14 +461,18 @@ end describe "rb_io_maybe_wait_readable" do - it "returns mask for events if operation was interrupted" do + it "returns IO::READABLE immediately if given errno is EINTR" do @o.rb_io_maybe_wait_readable(Errno::EINTR::Errno, @r_io, nil, false).should == IO::READABLE end - it "returns 0 if there is no error condition" do + it "returns 0 if there is given no error" do @o.rb_io_maybe_wait_readable(0, @r_io, nil, false).should == 0 end + it "returns 0 if given errno is neither EINTR nor EAGAIN" do + @o.rb_io_maybe_wait_readable(Errno::EBADF::Errno, @r_io, nil, false).should == 0 + end + it "blocks until the io is readable and returns events that actually occurred" do @o.instance_variable_set :@write_data, false thr = Thread.new do @@ -442,7 +490,10 @@ start = Process.clock_gettime(Process::CLOCK_MONOTONIC) t = Thread.new do - @o.rb_io_maybe_wait_readable(0, @r_io, 10, false) + @o.rb_io_maybe_wait_readable(Errno::EAGAIN::Errno, @r_io, 10, false) + + # ensure the call was blocking and was really interrupted + flunk "not reached" end Thread.pass until t.stop? @@ -463,9 +514,10 @@ end ruby_version_is "3.4" do - it "raises a IO::TimeoutError if the timeout elapses" do - -> { @o.rb_io_maybe_wait_readable(Errno::EAGAIN::Errno, @r_io, 0, false) }. - should.raise(IO::TimeoutError, "Timed out waiting for IO to become readable!") + it "raises a IO::TimeoutError if given errno is EAGAIN and the timeout elapses" do + -> { + @o.rb_io_maybe_wait_readable(Errno::EAGAIN::Errno, @r_io, 0, false) + }.should.raise(IO::TimeoutError, "Timed out waiting for IO to become readable!") end end end @@ -542,7 +594,10 @@ start = Process.clock_gettime(Process::CLOCK_MONOTONIC) t = Thread.new do - @o.rb_io_maybe_wait(0, @r_io, IO::READABLE, 10) + @o.rb_io_maybe_wait(Errno::EAGAIN::Errno, @r_io, IO::READABLE, 10) + + # ensure the call was blocking and was really interrupted + flunk "not reached" end Thread.pass until t.stop? @@ -553,20 +608,58 @@ (finish - start).should < 9 end - it "can be interrupted when waiting for WRITABLE event" do - IOSpec.exhaust_write_buffer(@w_io) - start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + platform_is_not :windows do + it "can be interrupted when waiting for WRITABLE event" do + IOSpec.exhaust_write_buffer(@w_io) + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t = Thread.new do - @o.rb_io_maybe_wait(0, @w_io, IO::WRITABLE, 10) + t = Thread.new do + @o.rb_io_maybe_wait(Errno::EAGAIN::Errno, @w_io, IO::WRITABLE, 10) + + # ensure the call was blocking and was really interrupted + flunk "not reached" + end + + Thread.pass until t.stop? + t.kill + t.join + + finish = Process.clock_gettime(Process::CLOCK_MONOTONIC) + (finish - start).should < 9 end + end - Thread.pass until t.stop? - t.kill - t.join + platform_is :windows do + # Windows select/poll wrapper (rb_w32_select) treats write descriptors of non-sockets + # (such as pipe writers) as always writable. Thus it immediately returns IO::WRITABLE + # instead of timing out or blocking. So use sockets instead. + it "can be interrupted when waiting for WRITABLE event" do + require 'socket' + r_sock, w_sock = Socket.pair(Socket::AF_INET, Socket::SOCK_STREAM, 0) + begin + r_sock.close_write + w_sock.close_read + IOSpec.exhaust_write_buffer(w_sock) + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) - finish = Process.clock_gettime(Process::CLOCK_MONOTONIC) - (finish - start).should < 9 + t = Thread.new do + @o.rb_io_maybe_wait(Errno::EAGAIN::Errno, w_sock, IO::WRITABLE, 10) + + # ensure the call was blocking and was really interrupted + flunk "not reached" + end + + Thread.pass until t.stop? + t.kill + t.join + + finish = Process.clock_gettime(Process::CLOCK_MONOTONIC) + (finish - start).should < 9 + ensure + r_sock.close unless r_sock.closed? + w_sock.close unless w_sock.closed? + end + end end end diff --git a/spec/ruby/optional/capi/regexp_spec.rb b/spec/ruby/optional/capi/regexp_spec.rb index f233b5e3b3ce02..0a7ec4b170de43 100644 --- a/spec/ruby/optional/capi/regexp_spec.rb +++ b/spec/ruby/optional/capi/regexp_spec.rb @@ -17,12 +17,16 @@ end it "returns a Regexp with the given options" do - @p.a_re("a", 0).options == 0 + @p.a_re("a", 0).options.should == 0 @p.a_re("a", Regexp::IGNORECASE).options.should == Regexp::IGNORECASE @p.a_re("a", Regexp::EXTENDED).options.should == Regexp::EXTENDED @p.a_re("a", Regexp::EXTENDED | Regexp::IGNORECASE).options.should == Regexp::EXTENDED | Regexp::IGNORECASE @p.a_re("a", Regexp::MULTILINE).options.should == Regexp::MULTILINE end + + it "returns a Regexp that equals an equivalent Regexp literal" do + @p.a_re("^[0-9]", 0).should == /^[0-9]/ + end end describe "rb_reg_nth_match" do diff --git a/spec/ruby/optional/capi/util_spec.rb b/spec/ruby/optional/capi/util_spec.rb index dd3cbb654982cf..3d6d6df07ab4ad 100644 --- a/spec/ruby/optional/capi/util_spec.rb +++ b/spec/ruby/optional/capi/util_spec.rb @@ -197,6 +197,18 @@ h.should == {:a => 7, :c => 12} end + it "raises an error if given hash is nil and required arguments are specified" do + h = nil + -> { @o.rb_get_kwargs(h, [:a], 1, 0) }.should.raise(ArgumentError, "missing keyword: :a") + h.should == nil + end + + it "raises an error if given hash is nil and multiple required arguments are specified" do + h = nil + -> { @o.rb_get_kwargs(h, [:a, :b], 2, 0) }.should.raise(ArgumentError, "missing keywords: :a, :b") + h.should == nil + end + it "does not raise an error for an optional argument not in the hash" do h = { :a => 7, :b => 5 } @o.rb_get_kwargs(h, [:b, :a, :c], 2, 1).should == [5, 7] diff --git a/spec/ruby/shared/file/blockdev.rb b/spec/ruby/shared/file/blockdev.rb index b0b3ea040a346d..0ed083bb13bf02 100644 --- a/spec/ruby/shared/file/blockdev.rb +++ b/spec/ruby/shared/file/blockdev.rb @@ -6,4 +6,20 @@ it "accepts an object that has a #to_path method" do @object.send(@method, mock_to_path(tmp(""))).should == false end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + @object.send(@method, non_utf8_path).should == false + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end diff --git a/spec/ruby/shared/file/chardev.rb b/spec/ruby/shared/file/chardev.rb index 8a7a89fd05f58b..5f1b25b6a1289b 100644 --- a/spec/ruby/shared/file/chardev.rb +++ b/spec/ruby/shared/file/chardev.rb @@ -6,4 +6,20 @@ it "accepts an object that has a #to_path method" do @object.send(@method, mock_to_path(tmp(""))).should == false end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + @object.send(@method, non_utf8_path).should == false + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end diff --git a/spec/ruby/shared/file/directory.rb b/spec/ruby/shared/file/directory.rb index 84f8f1a958b332..16b733ccf66f46 100644 --- a/spec/ruby/shared/file/directory.rb +++ b/spec/ruby/shared/file/directory.rb @@ -31,6 +31,28 @@ it "raises a TypeError when passed nil" do -> { @object.send(@method, nil) }.should.raise(TypeError) end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + @object.send(@method, non_utf8_path).should == false + + rm_r utf8_path + rm_r non_utf8_path + + mkdir_p utf8_path + @object.send(@method, non_utf8_path).should == true + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end describe :file_directory_io, shared: true do diff --git a/spec/ruby/shared/file/executable.rb b/spec/ruby/shared/file/executable.rb index 0fc65cf8669fa4..0e8ffd1133f5d2 100644 --- a/spec/ruby/shared/file/executable.rb +++ b/spec/ruby/shared/file/executable.rb @@ -40,6 +40,25 @@ -> { @object.send(@method, false) }.should.raise(TypeError) end + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + @object.send(@method, non_utf8_path).should == false + + File.chmod(0755, utf8_path) + @object.send(@method, non_utf8_path).should == true + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end + platform_is_not :windows do as_superuser do context "when run by a superuser" do diff --git a/spec/ruby/shared/file/executable_real.rb b/spec/ruby/shared/file/executable_real.rb index 90b7a41ba76413..2183c2cb08d0c1 100644 --- a/spec/ruby/shared/file/executable_real.rb +++ b/spec/ruby/shared/file/executable_real.rb @@ -38,6 +38,25 @@ -> { @object.send(@method, false) }.should.raise(TypeError) end + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + @object.send(@method, non_utf8_path).should == false + + File.chmod(0755, utf8_path) + @object.send(@method, non_utf8_path).should == true + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end + platform_is_not :windows do as_real_superuser do context "when run by a real superuser" do diff --git a/spec/ruby/shared/file/exist.rb b/spec/ruby/shared/file/exist.rb index 5075fa74b9b1fe..72a2ad3cb3b418 100644 --- a/spec/ruby/shared/file/exist.rb +++ b/spec/ruby/shared/file/exist.rb @@ -16,4 +16,22 @@ it "accepts an object that has a #to_path method" do @object.send(@method, mock_to_path(__FILE__)).should == true end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + @object.send(@method, non_utf8_path).should == false + + touch(utf8_path) + @object.send(@method, non_utf8_path).should == true + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end diff --git a/spec/ruby/shared/file/file.rb b/spec/ruby/shared/file/file.rb index 18477cff554ebe..861f9954c2c4f1 100644 --- a/spec/ruby/shared/file/file.rb +++ b/spec/ruby/shared/file/file.rb @@ -27,6 +27,22 @@ @object.send(@method, mock_to_path(@file)).should == true end + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + @object.send(@method, non_utf8_path).should == true + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end + platform_is_not :windows do it "returns true if the null device exists and is a regular file." do @object.send(@method, @null).should == false # May fail on MS Windows diff --git a/spec/ruby/shared/file/grpowned.rb b/spec/ruby/shared/file/grpowned.rb index 07a5a69e1adb8b..9f2c75b4ee644f 100644 --- a/spec/ruby/shared/file/grpowned.rb +++ b/spec/ruby/shared/file/grpowned.rb @@ -1,3 +1,5 @@ +require_relative '../../core/process/fixtures/common' + describe :file_grpowned, shared: true do before :each do @file = tmp('i_exist') @@ -18,7 +20,25 @@ @object.send(@method, mock_to_path(@file)).should == true end + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + File.chown(nil, Process.gid, utf8_path) rescue nil + @object.send(@method, non_utf8_path).should == true + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end + it 'takes non primary groups into account' do + skip "Codex sandbox returns EINVAL instead of EPERM for uid/gid permission changes" if ProcessSpecs.codex_sandbox? group = (Process.groups - [Process.egid]).first if group diff --git a/spec/ruby/shared/file/owned.rb b/spec/ruby/shared/file/owned.rb index 4a08a4ed89d30c..cc30b4904dcfb7 100644 --- a/spec/ruby/shared/file/owned.rb +++ b/spec/ruby/shared/file/owned.rb @@ -1,3 +1,19 @@ describe :file_owned, shared: true do it "accepts an object that has a #to_path method" + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + @object.send(@method, non_utf8_path).should == true + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end diff --git a/spec/ruby/shared/file/pipe.rb b/spec/ruby/shared/file/pipe.rb index 7e150b916768e7..43d44d72bb921b 100644 --- a/spec/ruby/shared/file/pipe.rb +++ b/spec/ruby/shared/file/pipe.rb @@ -1,3 +1,19 @@ describe :file_pipe, shared: true do it "accepts an object that has a #to_path method" + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + File.mkfifo(utf8_path) + @object.send(@method, non_utf8_path).should == true + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end diff --git a/spec/ruby/shared/file/readable.rb b/spec/ruby/shared/file/readable.rb index 7b45e23e3607e1..0aba305eb6ca1b 100644 --- a/spec/ruby/shared/file/readable.rb +++ b/spec/ruby/shared/file/readable.rb @@ -25,6 +25,22 @@ @object.send(@method, mock_to_path(@file2)).should == true end + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + @object.send(@method, non_utf8_path).should == true + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end + platform_is_not :windows do as_superuser do context "when run by a superuser" do diff --git a/spec/ruby/shared/file/readable_real.rb b/spec/ruby/shared/file/readable_real.rb index 32d38bc7a23fb5..b249e5e307c718 100644 --- a/spec/ruby/shared/file/readable_real.rb +++ b/spec/ruby/shared/file/readable_real.rb @@ -15,6 +15,22 @@ File.open(@file,'w') { @object.send(@method, mock_to_path(@file)).should == true } end + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + @object.send(@method, non_utf8_path).should == true + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end + platform_is_not :windows do as_real_superuser do context "when run by a real superuser" do diff --git a/spec/ruby/shared/file/setgid.rb b/spec/ruby/shared/file/setgid.rb index 98937958323eeb..3b32ef5454475b 100644 --- a/spec/ruby/shared/file/setgid.rb +++ b/spec/ruby/shared/file/setgid.rb @@ -1,2 +1,18 @@ describe :file_setgid, shared: true do + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + system "chmod g+s #{utf8_path}" + @object.send(@method, non_utf8_path).should == true + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end diff --git a/spec/ruby/shared/file/setuid.rb b/spec/ruby/shared/file/setuid.rb index 6401674a94aeb0..ef08dd142bccdf 100644 --- a/spec/ruby/shared/file/setuid.rb +++ b/spec/ruby/shared/file/setuid.rb @@ -1,2 +1,18 @@ describe :file_setuid, shared: true do + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + system "chmod u+s #{utf8_path}" + @object.send(@method, non_utf8_path).should == true + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end diff --git a/spec/ruby/shared/file/size.rb b/spec/ruby/shared/file/size.rb index fa198ed23263f7..7958fc96a71240 100644 --- a/spec/ruby/shared/file/size.rb +++ b/spec/ruby/shared/file/size.rb @@ -22,6 +22,22 @@ it "accepts an object that has a #to_path method" do @object.send(@method, mock_to_path(@exists)).should == 8 end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_size_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + File.write(utf8_path, "ok") + @object.send(@method, non_utf8_path).should == 2 + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end describe :file_size_to_io, shared: true do diff --git a/spec/ruby/shared/file/socket.rb b/spec/ruby/shared/file/socket.rb index ef6c482d1cf297..b519c34d7ab8de 100644 --- a/spec/ruby/shared/file/socket.rb +++ b/spec/ruby/shared/file/socket.rb @@ -30,4 +30,21 @@ def obj.to_path @object.send(@method, obj).should == false end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + server = UNIXServer.new(utf8_path) + + begin + @object.send(@method, non_utf8_path).should == true + ensure + server.close + rm_r utf8_path + rm_r non_utf8_path + end + end + end end diff --git a/spec/ruby/shared/file/sticky.rb b/spec/ruby/shared/file/sticky.rb index e07fa22fd7d1a3..f3ea3e1bdbea8d 100644 --- a/spec/ruby/shared/file/sticky.rb +++ b/spec/ruby/shared/file/sticky.rb @@ -18,6 +18,25 @@ end it "accepts an object that has a #to_path method" + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + @object.send(@method, non_utf8_path).should == false + + system "chmod +t #{utf8_path}" + @object.send(@method, non_utf8_path).should == true + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end describe :file_sticky_missing, shared: true do diff --git a/spec/ruby/shared/file/symlink.rb b/spec/ruby/shared/file/symlink.rb index d1c1dc94df56f0..49d2dca41cae0a 100644 --- a/spec/ruby/shared/file/symlink.rb +++ b/spec/ruby/shared/file/symlink.rb @@ -22,6 +22,22 @@ @object.send(@method, mock_to_path(@link)).should == true end end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + File.symlink(@file, utf8_path) + + begin + @object.send(@method, non_utf8_path).should == true + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end describe :file_symlink_nonexistent, shared: true do diff --git a/spec/ruby/shared/file/world_readable.rb b/spec/ruby/shared/file/world_readable.rb index c8946366ad86b9..38aa6fd5e7ce7f 100644 --- a/spec/ruby/shared/file/world_readable.rb +++ b/spec/ruby/shared/file/world_readable.rb @@ -46,4 +46,21 @@ it "coerces the argument with #to_path" do @object.world_readable?(mock_to_path(@file)) end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + File.chmod(0644, utf8_path) + @object.send(@method, non_utf8_path).should.instance_of?(Integer) + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end diff --git a/spec/ruby/shared/file/world_writable.rb b/spec/ruby/shared/file/world_writable.rb index fcff09636e22a0..99cb8cb8f78d95 100644 --- a/spec/ruby/shared/file/world_writable.rb +++ b/spec/ruby/shared/file/world_writable.rb @@ -46,4 +46,21 @@ it "coerces the argument with #to_path" do @object.world_writable?(mock_to_path(@file)) end + + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + File.chmod(0777, utf8_path) + @object.send(@method, non_utf8_path).should.instance_of?(Integer) + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end end diff --git a/spec/ruby/shared/file/writable.rb b/spec/ruby/shared/file/writable.rb index 65ea2c1781a6d7..666420d407c7ed 100644 --- a/spec/ruby/shared/file/writable.rb +++ b/spec/ruby/shared/file/writable.rb @@ -20,6 +20,22 @@ File.open(@file,'w') { @object.send(@method, mock_to_path(@file)).should == true } end + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + @object.send(@method, non_utf8_path).should == true + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end + platform_is_not :windows do as_superuser do context "when run by a superuser" do diff --git a/spec/ruby/shared/file/writable_real.rb b/spec/ruby/shared/file/writable_real.rb index 4602996187b1c2..7c350af4c3b210 100644 --- a/spec/ruby/shared/file/writable_real.rb +++ b/spec/ruby/shared/file/writable_real.rb @@ -15,6 +15,22 @@ File.open(@file,'w') { @object.send(@method, mock_to_path(@file)).should == true } end + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + @object.send(@method, non_utf8_path).should == true + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end + it "raises an ArgumentError if not passed one argument" do -> { File.writable_real? }.should.raise(ArgumentError) end diff --git a/spec/ruby/shared/file/zero.rb b/spec/ruby/shared/file/zero.rb index 94285c14c57248..df1d59bdc4779d 100644 --- a/spec/ruby/shared/file/zero.rb +++ b/spec/ruby/shared/file/zero.rb @@ -26,6 +26,25 @@ @object.send(@method, mock_to_path(@zero_file)).should == true end + platform_is :darwin do + it "accepts a path in a non-UTF-8, ASCII-compatible encoding containing non-ASCII characters" do + utf8_path = tmp("file_predicate_utf8_path_\u{3042}.txt") + # Can fail with UndefinedConversionError if tmp path has non-Shift_JIS chars (e.g. Emojis, Hangul, Cyrillic, accented letters) + non_utf8_path = utf8_path.encode(Encoding::Windows_31J) + + begin + touch(utf8_path) + @object.send(@method, non_utf8_path).should == true + + File.write(utf8_path, "ok") + @object.send(@method, non_utf8_path).should == false + ensure + rm_r utf8_path + rm_r non_utf8_path + end + end + end + platform_is :windows do it "returns true for NUL" do @object.send(@method, 'NUL').should == true diff --git a/test/-ext-/eval/test_iseq_load.rb b/test/-ext-/eval/test_iseq_load.rb index 927e263377a45d..cdc7393455e906 100644 --- a/test/-ext-/eval/test_iseq_load.rb +++ b/test/-ext-/eval/test_iseq_load.rb @@ -4,7 +4,12 @@ class IseqLoadTest < Test::Unit::TestCase def test_rb_iseq_load_from_binary - binary = RubyVM::InstructionSequence.compile('1 + 1').to_binary + binary = begin + RubyVM::InstructionSequence.compile('1 + 1').to_binary + rescue RuntimeError => e + omit e.message if /compile with coverage/ =~ e.message + raise + end assert_equal 2, rb_iseq_load_from_binary(binary).eval end end diff --git a/test/io/console/test_io_console.rb b/test/io/console/test_io_console.rb index 83cb4e709656f1..9f5911e878c595 100644 --- a/test/io/console/test_io_console.rb +++ b/test/io/console/test_io_console.rb @@ -8,6 +8,14 @@ class TestIO_Console < Test::Unit::TestCase HOST_OS = RbConfig::CONFIG['host_os'] + + def test_version + assert_kind_of(String, IO::Console::VERSION) + EnvUtil.suppress_warning do + assert_same(IO::Console::VERSION, IO::Console::Mode::VERSION) + end + end + private def host_os?(os) HOST_OS =~ os end @@ -274,7 +282,10 @@ def test_console_mode helper {|m, s| begin original = s.console_mode - assert_kind_of(IO::ConsoleMode, original) + assert_kind_of(IO::Console::Mode, original) + EnvUtil.suppress_warning do + assert_same(IO::Console::Mode, IO.const_get(:ConsoleMode)) + end noecho = original.dup noecho.echo = false diff --git a/test/json/json_parser_test.rb b/test/json/json_parser_test.rb index 2f79b87cc028e6..f75741fd6221af 100644 --- a/test/json/json_parser_test.rb +++ b/test/json/json_parser_test.rb @@ -132,6 +132,37 @@ def test_parse_numbers capture_output { assert_equal(Float::INFINITY, parse("23456789012E666")) } end + INTEGER_FAST_PATH_BOUNDARIES = [ + "999999999999999999", # 18 digits + "1000000000000000000", # narrowest 19 digits + "9999999999999999999", # widest 19 digits, still inside uint64_t + "-999999999999999999", + "-9223372036854775807", # INT64_MAX, the widest negatable accumulator + "-9223372036854775808", # INT64_MIN, one past what negating a uint64_t covers + "-9223372036854775809", + "-9999999999999999999", # widest negative 19 digits + "10000000000000000000", # narrowest 20 digits + "18446744073709551614", + "18446744073709551615", # UINT64_MAX exactly, the last value the range check admits + "18446744073709551616", # 2**64, first value it must reject + "18446744073709551617", + "19999999999999999999", + "99999999999999999999", # widest 20 digits + "-18446744073709551615", # negatives never take the 20 digit path + "-18446744073709551616", + "-99999999999999999999", + "100000000000000000000", # 21 digits, always a bignum + "-100000000000000000000", + ].freeze + + def test_parse_integer_boundaries + INTEGER_FAST_PATH_BOUNDARIES.each do |literal| + expected = Integer(literal, 10) + + assert_equal(expected, parse(literal)) + end + end + def test_parse_bignum bignum = Integer('1234567890' * 10) assert_equal(bignum, JSON.parse(bignum.to_s)) diff --git a/test/json/resumable_parser_test.rb b/test/json/resumable_parser_test.rb index 11a5d2eb5fa153..0b8e1356086f77 100644 --- a/test/json/resumable_parser_test.rb +++ b/test/json/resumable_parser_test.rb @@ -192,6 +192,27 @@ def test_large_numbers_split_across_feeds_are_decoded_correctly end end + def test_integer_boundaries_split_across_feeds + [ + '9999999999999999999', # widest 19 digits + '-9223372036854775808', # INT64_MIN + '18446744073709551615', # UINT64_MAX exactly + '18446744073709551616', # 2**64, wraps the accumulator to 0 + '99999999999999999999', # widest 20 digits + '-18446744073709551616', + '100000000000000000000', # 21 digits + ].each do |literal| + doc = "#{literal} " + parser = new_parser + value = nil + doc.each_char do |char| + parser << char + value = parser.value if parser.parse + end + assert_equal Integer(literal, 10), value, doc.inspect + end + end + def test_nul_byte_is_a_syntax_error # A NUL byte in a structural position must raise, not stall forever waiting for more input # (peek() returns 0 both at EOS and for a literal NUL byte). diff --git a/test/objspace/test_ractor.rb b/test/objspace/test_ractor.rb index fb6432a8272595..40eabd07937ed2 100644 --- a/test/objspace/test_ractor.rb +++ b/test/objspace/test_ractor.rb @@ -13,6 +13,27 @@ def test_tracing_does_not_crash RUBY end + # dump_all / memsize_of_all cover every Ractor's objspace, including other Ractors' + # unshareable objects + def test_dump_all_covers_all_ractors + assert_ractor(<<~'RUBY', require: 'objspace') + ready = Ractor::Port.new + ch = Ractor.new(ready) do |port| + marker = +"DUMP_ALL_MARKER_FOREIGN" + port << :built + Ractor.receive + marker.size + end + ready.receive + + dump = ObjectSpace.dump_all(output: :string) + assert_include dump, "DUMP_ALL_MARKER_FOREIGN" + + ch.send(:go) + ch.value + RUBY + end + def test_undefine_finalizer assert_ractor(<<~'RUBY', timeout: 20, require: 'objspace', signal: :SEGV) def fin diff --git a/test/ruby/test_allocation.rb b/test/ruby/test_allocation.rb index 90d7c04f9b0a2b..7047eddec70112 100644 --- a/test/ruby/test_allocation.rb +++ b/test/ruby/test_allocation.rb @@ -6,6 +6,10 @@ def setup # The namespace changes on i686 platform triggers a bug to allocate objects unexpectedly. # For now, skip these tests only on i686 pend if RUBY_PLATFORM =~ /^i686/ + # Allocations are measured as the difference in ObjectSpace.count_objects' live counts, so + # a GC during the measurement makes it negative. mmtk's initial heap is small enough that + # a GC in the middle is the norm. + omit 'live-count deltas need an idle GC' unless GC.config[:implementation] == 'default' end def munge_checks(checks) diff --git a/test/ruby/test_iseq.rb b/test/ruby/test_iseq.rb index 0a06d01b08a1d4..9d7001fa61067d 100644 --- a/test/ruby/test_iseq.rb +++ b/test/ruby/test_iseq.rb @@ -976,7 +976,7 @@ def obj.test obj RUBY - binary = iseq.to_binary # [Bug # 21370] + binary = iseq_to_binary(iseq) # [Bug # 21370] roundtripped_iseq = RubyVM::InstructionSequence.load_from_binary(binary) object = roundtripped_iseq.eval assert_equal 1, object.test diff --git a/test/ruby/test_proc_syntax_tree.rb b/test/ruby/test_proc_syntax_tree.rb new file mode 100644 index 00000000000000..d6f25f04e4a281 --- /dev/null +++ b/test/ruby/test_proc_syntax_tree.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true +require "test/unit" +require "tmpdir" + +class TestProcSyntaxTree < Test::Unit::TestCase + PRISM = RubyVM::InstructionSequence.compile("").to_a[4][:parser] == :prism + + def with_loaded_file(source) + Dir.mktmpdir do |dir| + path = File.join(dir, "target.rb") + File.write(path, source) + load path + yield path + end + end + + def test_method_ast + with_loaded_file("def proc_ast_test_method = :ok\n") do + node = method(:proc_ast_test_method).syntax_tree + if PRISM + assert_equal :def_node, node.type + assert_equal "def proc_ast_test_method = :ok", node.slice + else + assert_equal :DEFN, node.type + end + ensure + Object.remove_method(:proc_ast_test_method) + end + end + + def test_proc_ast + with_loaded_file("PROC_AST_TEST_PROC = proc { :ok }\n") do + node = PROC_AST_TEST_PROC.syntax_tree + if PRISM + assert_equal :call_node, node.type + assert_equal "proc { :ok }", node.slice + else + assert_equal :ITER, node.type + end + ensure + Object.send(:remove_const, :PROC_AST_TEST_PROC) + end + end + + def test_returns_nil_when_source_is_modified + with_loaded_file("def proc_ast_test_modified = :ok\n") do |path| + File.write(path, "def proc_ast_test_modified = :changed\n") + assert_nil method(:proc_ast_test_modified).syntax_tree + + File.write(path, "def proc_ast_test_modified = (\n") + assert_nil method(:proc_ast_test_modified).syntax_tree + ensure + Object.remove_method(:proc_ast_test_modified) + end + end + + def test_ignores_the_data_section + with_loaded_file("def proc_ast_test_data = :ok\n__END__\noriginal\n") do |path| + File.write(path, "def proc_ast_test_data = :ok\n__END__\nchanged\n") + refute_nil method(:proc_ast_test_data).syntax_tree + ensure + Object.remove_method(:proc_ast_test_data) + end + end + + def test_eval_with_keep_script_lines + assert_separately([], "#{<<~"begin;"}\n#{<<~'end;'}") + begin; + RubyVM.keep_script_lines = true + eval("def proc_ast_test_eval_ksl = :ok\nPROC_AST_TEST_KSL = proc { :ok }\n", binding, "(eval-ksl)", 1) + + refute_nil method(:proc_ast_test_eval_ksl).syntax_tree + refute_nil PROC_AST_TEST_KSL.syntax_tree + end; + end + + def test_returns_nil_for_eval + assert_nil eval("proc { :ok }").syntax_tree + end + + def test_returns_nil_for_c_method + assert_nil method(:puts).syntax_tree + end + + def test_source_hash_survives_binary_round_trip + with_loaded_file("def proc_ast_test_binary = :ok\n") do |path| + iseq = RubyVM::InstructionSequence.compile_file(path) + loaded = RubyVM::InstructionSequence.load_from_binary(iseq.to_binary) + + assert_equal iseq.source_hash, loaded.source_hash + assert_equal (PRISM ? :program_node : :SCOPE), loaded.syntax_tree.type + end + end + + def test_source_hash_in_to_a + iseq = RubyVM::InstructionSequence.compile("x = 1") + assert_equal iseq.source_hash, iseq.to_a[4][:source_hash] + end + + def test_backtrace_location_syntax_tree + with_loaded_file("def proc_ast_test_loc = caller_locations(0, 1).first\n") do + node = proc_ast_test_loc.syntax_tree + if PRISM + assert_equal :call_node, node.type + assert_equal "caller_locations(0, 1)", node.slice + else + assert_equal :FCALL, node.type + end + ensure + Object.remove_method(:proc_ast_test_loc) + end + end + + def test_parse_y_syntax_tree + assert_separately(%w[--parser=parse.y], "#{<<~"begin;"}\n#{<<~'end;'}") + begin; + require "tmpdir" + Dir.mktmpdir do |dir| + path = File.join(dir, "target.rb") + File.write(path, "def proc_ast_test_parse_y = :ok\n") + load path + + node = method(:proc_ast_test_parse_y).syntax_tree + assert_equal RubyVM::AbstractSyntaxTree::Node, node.class + assert_equal :DEFN, node.type + + File.write(path, "def proc_ast_test_parse_y = :changed\n") + assert_nil method(:proc_ast_test_parse_y).syntax_tree + + File.write(path, "def proc_ast_test_parse_y = (\n") + assert_nil method(:proc_ast_test_parse_y).syntax_tree + end + end; + end +end diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index f56b7aed8d18a3..d1c90aae46e6e1 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -141,6 +141,8 @@ def test_sending_exception_with_array_backtrace end def test_sending_object_with_broken_clone + # Copying a message does not call the user-visible #clone, so a broken #clone cannot + # break sending; the singleton class that defining #clone creates makes it uncopyable. assert_ractor(<<~'RUBY') o = Object.new def o.clone @@ -150,7 +152,7 @@ def o.clone error = assert_raise Ractor::Error do ractor.send(o) end - assert_match "#clone returned self", error.message + assert_match "can not copy", error.message RUBY end @@ -419,18 +421,196 @@ def test_detailed_message_in_ractor RUBY end - def test_ractor_vm_once_dispatch - assert_ractor(<<~'RUBY', args: ["-W0"], timeout: 30) - vals = 10.times.map do - Ractor.new { - a = nil - /#{sleep 0.1; a = "set"}/o - a - } - end.map(&:value) - vals.compact! - assert_equal 1, vals.size - assert_equal "set", vals.first + # With per-Ractor GC, registering, storing and running a finalizer all belong to the + # object's Ractor, so defining one on another Ractor's object (shareable included) is + # rejected. + def test_define_finalizer_on_foreign_object + omit 'per-Ractor objspace semantics of the default GC' unless GC.config[:implementation] == 'default' + assert_separately([], __FILE__, __LINE__, <<-'RUBY') + Warning[:experimental] = false + r = Ractor.new do + results = [] + own = Object.new + ObjectSpace.define_finalizer(own, proc {}) + results << :own_ok + begin + ObjectSpace.define_finalizer(String, proc {}) # main's class + results << :define_did_not_raise + rescue Ractor::IsolationError + results << :define_raised + end + begin + ObjectSpace.undefine_finalizer(String) + results << :undefine_did_not_raise + rescue Ractor::IsolationError + results << :undefine_raised + end + results + end + assert_equal [:own_ok, :define_raised, :undefine_raised], r.value + GC.verify_internal_consistency + RUBY + end + + # ObjectSpace.each_object enumerates every object in the calling Ractor's own objspace plus + # the shareable objects of other live Ractors (never their unshareable ones). + def test_each_object_own_all_and_foreign_shareables + omit 'per-Ractor objspace semantics of the default GC' unless GC.config[:implementation] == 'default' + assert_separately([], __FILE__, __LINE__, <<-'RUBY') + Warning[:experimental] = false + class Marker; end + main_un = 5.times.map { Marker.new } + main_sh = 3.times.map { Ractor.make_shareable(Marker.new) } + ready = Ractor::Port.new + ch = Ractor.new(ready) do |ready_port| + un = 7.times.map { Marker.new } # unshareable: must not be visible + sh = 4.times.map { Ractor.make_shareable(Marker.new) } + ready_port << :built + Ractor.receive # keep this objspace alive + [un.size, sh.size] + end + ready.receive # the child finished building markers + + seen = 0 + ObjectSpace.each_object(Marker) { seen += 1 } + # own 8 (5 unshareable + 3 shareable) + the child's 4 shareable + assert_equal 12, seen + + ch.send(:go) + ch.value + # keep the roots alive across the scan + assert_equal 5, main_un.size + assert_equal 3, main_sh.size + RUBY + end + + # A Ractor.new that fails with IsolationError (stillborn) must still clean up the + # half-created objspace (regression guard for double enumeration / use-after-free). + def test_stillborn_ractor_gc + assert_ractor(<<~'RUBY', timeout: 60) + x = 42 # capturing an outer local makes Ractor.new raise IsolationError + worker = Ractor.new { loop { break if Ractor.receive == :quit } } + assert_raise(Ractor::IsolationError) { Ractor.new { x } } + 10.times { GC.start; 500.times { Object.new } } + GC.verify_internal_consistency + worker.send(:quit) + worker.value + 100.times do |i| + assert_raise(Ractor::IsolationError) { Ractor.new { x } } + if (i % 20).zero? + Ractor.new { :ok }.value + GC.start + end + end + GC.start + GC.verify_internal_consistency + RUBY + end + + # Moving a CoW shared-root String must not steal its buffer (regression guard for the + # remaining sharers reading freed memory). + def test_move_shared_root_string_keeps_buffer + assert_ractor(<<~'RUBY', timeout: 60) + 10.times do + r = Ractor.new { Ractor.receive.bytesize; :done } + f = "x" * 4096 + f.instance_variable_set(:@x, []) # unshareable ivar => moved, not passed by reference + f.freeze + g = f.dup # shares f's buffer -> f is a shared root + h = f[10, 3000] # a long substring shares the buffer too + r.send(f, move: true) + r.value + GC.start + 10.times { "z" * 4096 } + assert_equal "x" * 4096, g + assert_equal "x" * 3000, h + end + RUBY + end + + # Ractor::Port.new must not deadlock under GC.stress (regression guard for a stress GC + # triggered by malloc while the ractor lock is held). + def test_port_new_under_gc_stress + assert_ractor(<<~'RUBY', timeout: 90) + GC.stress = true + ports = 4.times.map { Ractor::Port.new } + GC.stress = false + assert_equal 4, ports.size + RUBY + end + + # Moving a Hash that has Hash keys must not lose entries (regression guard for inserting a + # key before its contents are filled in, which corrupts its hash value). + def test_move_hash_with_hash_keys + assert_ractor(<<~'RUBY', timeout: 60) + k1 = { a: 1 }; k2 = { b: 2 } + h = { k1 => :v1, k2 => :v2, { c: { d: 3 } } => :v3 } + r = Ractor.new { Ractor.receive } + r.send(h, move: true) + m = r.value + assert_equal 3, m.size + assert_equal :v1, m[{ a: 1 }] + assert_equal :v2, m[{ b: 2 }] + assert_equal :v3, m[{ c: { d: 3 } }] + RUBY + end + + # A copy send's in-flight snapshot must not be moved by GC.compact (the global + # generic_fields entries and the dedup table are keyed by address; YJIT reproduced this + # deterministically). + def test_copy_genivar_snapshot_survives_compact + omit 'GC.compact is unimplemented' unless GC.config[:implementation] == 'default' + assert_ractor(<<~'RUBY', timeout: 60, args: [{ "RUBY_YJIT_ENABLE" => "1" }]) + port = Ractor::Port.new + w = Ractor.new(port) do |po| + mm = Ractor.receive + res = mm.map { |ss| [ss, ss.frozen?, ss.instance_variable_get(:@sku)] } + po.send(res) + end + items = 4.times.map do |i| + s = +"item-#{i}" + s.instance_variable_set(:@sku, "SKU#{1000 + i}") + s.freeze + end + w.send(items) + GC.compact + res = port.receive + res.each_with_index do |(txt, fz, sku), i| + assert_equal "item-#{i}", txt + assert fz + assert_equal "SKU#{1000 + i}", sku + end + RUBY + end + + # A monitor entry holds a port of the monitoring Ractor, and the exit token is sent + # through it, so that Ractor's wrapper must stay alive while the entry exists. + def test_monitor_keeps_the_monitoring_ractor_alive + assert_ractor(<<~'RUBY', timeout: 60) + long = Ractor.new { Ractor.receive } + Ractor.new(long) { |l| l.monitor(Ractor::Port.new) }.value + GC.start # used to collect the monitoring Ractor's wrapper + 3000.times { Object.new } + GC.start(full_mark: true) + long.send(:bye) + assert_equal :bye, long.value + RUBY + end + + # move must preserve the class of a String/Array/Hash subclass. + def test_move_preserves_subclass + assert_ractor(<<~'RUBY', timeout: 60) + class MyStr < String; end + class MyArr < Array; end + class MyHash < Hash; end + s = MyStr.new("hello"); a = MyArr.new([1, 2]); h = MyHash.new; h[:k] = 1 + r = Ractor.new { 3.times.map { Ractor.receive } } + r.send(s, move: true); r.send(a, move: true); r.send(h, move: true) + rs, ra, rh = r.value + assert_equal [MyStr, MyArr, MyHash], [rs.class, ra.class, rh.class] + assert_equal "hello", rs + assert_equal [1, 2], ra + assert_equal 1, rh[:k] RUBY end diff --git a/thread.c b/thread.c index 68a16e4a1f2489..eb77f22128e577 100644 --- a/thread.c +++ b/thread.c @@ -701,10 +701,10 @@ thread_start_func_2(rb_thread_t *th, VALUE *stack_start) r->r_stdout = rb_io_prep_stdout(); r->r_stderr = rb_io_prep_stderr(); - /* Build the interrupt queue and mask stack here, on the new Ractor's - * own main thread, instead of carrying over the ones the creating - * thread made. The mask stack starts empty so a new Ractor does not - * inherit the creating thread's Thread.handle_interrupt state. */ + /* Left 0 at creation (building them then would put them in the parent's + * objspace), so build them here out of objects this Ractor owns. The mask + * stack starts empty: inheriting it would reference the parent's + * unshareable mask Hash. */ th->pending_interrupt_queue = rb_ary_hidden_new(0); th->pending_interrupt_mask_stack = rb_ary_hidden_new(0); } @@ -898,9 +898,6 @@ thread_create_core(VALUE thval, struct thread_create_params *params) break; case thread_invoke_type_ractor_proc: -#if RACTOR_CHECK_MODE > 0 - rb_ractor_setup_belonging_to(thval, rb_ractor_id(params->g)); -#endif th->invoke_type = thread_invoke_type_ractor_proc; th->ractor = params->g; th->ec->ractor_id = rb_ractor_id(th->ractor); @@ -908,7 +905,6 @@ thread_create_core(VALUE thval, struct thread_create_params *params) 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(); - rb_ractor_send_parameters(ec, params->g, params->args); break; case thread_invoke_type_func: @@ -925,11 +921,16 @@ thread_create_core(VALUE thval, struct thread_create_params *params) th->thgroup = current_th->thgroup; if (th->invoke_type == thread_invoke_type_ractor_proc) { - /* A new Ractor's main thread builds these on start - * (thread_start_func_2); leave them unset until then. */ + /* Left 0: the child's main thread builds this in its own objspace at start + * (thread_start_func_2). Built here it would sit rootless in the parent's + * objspace, freed by the parent's local GC before the child starts. */ th->pending_interrupt_queue = 0; th->pending_interrupt_mask_stack = 0; th->pending_interrupt_queue_checked = 0; + /* Same for the thread group: the parent's lives in the parent's objspace, and + * keeping it would point the child's Thread wrapper at a foreign unshareable + * object with no shref. Left 0 until thread_do_start_proc builds it. */ + th->thgroup = 0; } else { th->pending_interrupt_queue = rb_ary_hidden_new(0); @@ -942,11 +943,38 @@ thread_create_core(VALUE thval, struct thread_create_params *params) rb_ractor_living_threads_insert(th->ractor, th); + if (th->invoke_type == thread_invoke_type_ractor_proc) { + /* Create the default port and send the arguments only after the child joined + * vm->ractor.set, so a global GC in between still marks the port in its root + * scan. If either raises (an uncopyable argument, NoMemoryError), undo the + * membership: left in place it would make terminate_all wait forever. */ + enum ruby_tag_type state; + 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); + } + EC_POP_TAG(); + if (state != TAG_NONE) { + th->status = THREAD_KILLED; + rb_ractor_cancel_creation(params->g, th); + EC_JUMP_TAG(ec, state); + } + } + /* kick thread */ err = native_thread_create(th); if (err) { th->status = THREAD_KILLED; - rb_ractor_living_threads_remove(th->ractor, th); + if (th->invoke_type == thread_invoke_type_ractor_proc) { + /* A child Ractor's main thread: the creator runs this, so the ordinary + * removal (which assumes the current Ractor and would run the Ractor exit + * protocol) does not apply. Undo the creation like the send-failure path. */ + rb_ractor_cancel_creation(th->ractor, th); + } + else { + rb_ractor_living_threads_remove(th->ractor, th); + } rb_raise(rb_eThreadError, "can't create Thread: %s", strerror(err)); } return thval; @@ -1075,7 +1103,77 @@ rb_thread_create_ractor(rb_ractor_t *r, VALUE args, VALUE proc) .args = args, .proc = proc, }; - return thread_create_core(rb_thread_alloc(rb_cThread), ¶ms); + + /* Allocate the child's main Thread and root Fiber wrappers directly in the child's + * objspace, so the thread is built of objects it owns. Whole-VM walks read + * cr->objspace: swap it under the VM lock, unobservable to others. */ + VALUE thval = Qundef; + rb_ractor_t *cr = GET_RACTOR(); + rb_execution_context_t *ec = GET_EC(); + const bool multi_objspace = rb_gc_multi_objspace_p(); + enum ruby_tag_type alloc_state = TAG_NONE; + RB_VM_LOCKING() { + void *const parent_objspace = cr->objspace; + if (multi_objspace) cr->objspace = r->objspace; + /* The wrapper allocations must not re-enter GC: while cr->objspace points at + * the child, the creator's own objspace is invisible to every walk, so a global + * GC would skip it and leave stale mark bits (a UAF). Single allocations; + * suppressing GC costs only a little growth. */ + VALUE gc_was_disabled = rb_gc_local_disable_no_rest(); + /* The alloc can raise NoMemoryError; a longjmp here would skip both the unlock + * of RB_VM_LOCKING and the objspace restore, so catch and rethrow outside. */ + EC_PUSH_TAG(ec); + if ((alloc_state = EC_EXEC_TAG()) == TAG_NONE) { + thval = rb_thread_alloc(rb_cThread); + } + EC_POP_TAG(); + if (gc_was_disabled == Qfalse) rb_gc_local_enable(); + if (multi_objspace) cr->objspace = parent_objspace; + /* The child's objspace holds the wrappers but is not in vm->ractor.set yet: + * keep it enumerable until vm_insert_ractor clears this under the VM lock. One + * slot suffices: the GVL is never released between set and clear and one + * Ractor creates children serially, so no overwrite (asserted: releasing the + * GVL here in the future would break it). */ + if (alloc_state == TAG_NONE && multi_objspace) { + RUBY_ASSERT(cr->creating_child_objspace == NULL); + cr->creating_child_objspace = r->objspace; + } + } + if (alloc_state != TAG_NONE) { + /* No cover was set; park the child objspace for the orphan merge and re-raise. */ + RB_VM_LOCKING() { + if (r->objspace) { + rb_gc_objspace_disown(r->objspace); + r->objspace = NULL; + } + } + EC_JUMP_TAG(ec, alloc_state); + } + + /* Creation can still fail before vm_insert_ractor (an IsolationError, say), and a + * left-over cover would enumerate the dead child's objspace twice and dangle after + * the merge: on failure hand the objspace to zombie_objspaces under the VM lock, + * drop the cover, NULL r->objspace. */ + enum ruby_tag_type state; + VALUE thret = Qundef; + EC_PUSH_TAG(ec); + if ((state = EC_EXEC_TAG()) == TAG_NONE) { + thret = thread_create_core(thval, ¶ms); + } + EC_POP_TAG(); + if (state != TAG_NONE) { + RB_VM_LOCKING() { + if (cr->creating_child_objspace == r->objspace) { + cr->creating_child_objspace = NULL; + } + if (r->objspace) { + rb_gc_objspace_disown(r->objspace); + r->objspace = NULL; + } + } + EC_JUMP_TAG(ec, state); + } + return thret; } @@ -5086,6 +5184,9 @@ rb_thread_atfork_internal(rb_thread_t *th, void (*atfork)(rb_thread_t *, const r rb_native_mutex_initialize(&th->interrupt_lock); rb_native_mutex_initialize(&vm->once_lock); rb_native_cond_initialize(&vm->once_cond); + rb_gc_zombie_objspaces_atfork(); + rb_gc_atfork_global_locks(); + rb_generic_fields_lock_atfork(); ccan_list_head_init(&th->interrupt_exec_tasks); vm->fork_gen++; diff --git a/tool/rbs_skip_tests b/tool/rbs_skip_tests index 54259cfdf424f8..35e5d431bf8be9 100644 --- a/tool/rbs_skip_tests +++ b/tool/rbs_skip_tests @@ -60,3 +60,9 @@ test_instance_method_generic(RDocPluginParserTest) test_instance_method_with_block(RDocPluginParserTest) test_method_alias_decl_1(RDocPluginParserTest) test_method_alias_decl_2(RDocPluginParserTest) + +## Restore-state bug in the test itself (fixed in ruby/rbs after 4.0.3) + +test_enable(GCSingletonTest) leaves GC disabled for the rest of the process (GC.enable return value misread); every later stdlib test then runs with GC off and the process peaks multi-GB (NoMemoryError on Windows CI) +test_stress_and_stress=(GCSingletonTest) runs assert_send_type with GC.stress enabled; the type check allocates heavily and cannot finish in CI time (was hidden while test_enable left GC disabled) +test_reachable_objects_from_root(ObjectSpaceTest) assert_send_type eagerly builds trace.inspect of the whole roots hash; with per-Ractor GC roots the inspect is huge and the process peaks multi-GB (NoMemoryError on Windows CI) diff --git a/variable.c b/variable.c index a0f24c0868dd55..aef6405d8e91f2 100644 --- a/variable.c +++ b/variable.c @@ -25,6 +25,7 @@ #include "internal/compilers.h" #include "internal/error.h" #include "internal/eval.h" +#include "eval_intern.h" #include "internal/hash.h" #include "internal/object.h" #include "internal/gc.h" @@ -66,9 +67,22 @@ static void setup_const_entry(rb_const_entry_t *, VALUE, VALUE, rb_const_flag_t) static VALUE rb_const_search(VALUE klass, ID id, int exclude, int recurse, int visibility, VALUE *found_in); static st_table *generic_fields_tbl_; +/* Mutex guarding the single global generic_fields table (all hosts, every Ractor). A + * dedicated mutex (vm->ractor.generic_fields_lock) because a local GC's marking reads + * the table and must not wait for the VM lock: joining a barrier mid-mark would expose + * a half-collected heap. The global GC's weak pass cleans the table under the barrier, + * lock-free. Sections that may allocate disable GC first: no self-re-entry. */ + typedef int rb_ivar_foreach_callback_func(ID key, VALUE val, st_data_t arg); static void rb_field_foreach(VALUE obj, rb_ivar_foreach_callback_func *func, st_data_t arg, bool ivar_only); +void +rb_generic_fields_lock_atfork(void) +{ + /* Another thread may have held it at fork time, so rebuild it in the child. */ + rb_native_mutex_initialize(&GET_VM()->ractor.generic_fields_lock); +} + void Init_var_tables(void) { @@ -1238,38 +1252,67 @@ ivar_ractor_check(VALUE obj, ID id) } } -static inline struct st_table * -generic_fields_tbl_no_ractor_check(void) +struct st_table * +rb_generic_fields_tbl_get(void) { - ASSERT_vm_locking(); - return generic_fields_tbl_; } -struct st_table * -rb_generic_fields_tbl_get(void) +/* generic_fields is one global table. Leaf lock discipline: under gf_lock, take no + * other lock, do not allocate, and create no safepoint. In single-Ractor mode the + * GVL already serializes everything, so no lock is taken. */ +static inline void +gf_lock(void) { - return generic_fields_tbl_; + if (rb_multi_ractor_p()) { + rb_native_mutex_lock(&GET_VM()->ractor.generic_fields_lock); + } +} + +static inline void +gf_unlock(void) +{ + if (rb_multi_ractor_p()) { + rb_native_mutex_unlock(&GET_VM()->ractor.generic_fields_lock); + } } void rb_mark_generic_ivar(VALUE obj) { - VALUE data; - // Bypass ASSERT_vm_locking() check because marking may happen concurrently with mmtk - if (st_lookup(generic_fields_tbl_, (st_data_t)obj, (st_data_t *)&data)) { + /* Under a multi-objspace global GC (stop-the-world) there is no per-object + * lookup: after marking, rb_gc_vm_generic_fields_mark_foreach marks the values of + * the live keys. A single-objspace impl (mmtk) has no such pass, so mark here. */ + if (rb_gc_during_global_gc_p() && rb_gc_multi_objspace_p()) { + return; + } + + /* Per-object marking for a local GC or for compaction (single objspace). gf_lock + * excludes writers in other Ractors. */ + VALUE data = 0; + gf_lock(); + st_lookup(generic_fields_tbl_, (st_data_t)obj, (st_data_t *)&data); + gf_unlock(); + if (data) { rb_gc_mark_movable(data); } } +/* Look up obj's generic fields in the single global table. A snapshot host being + * materialized (which lives in the sender's objspace) is in the same table, so the + * receiving side can look it up directly. */ VALUE rb_obj_fields_generic_uncached(VALUE obj) { VALUE fields_obj = 0; - RB_VM_LOCKING() { - if (!st_lookup(generic_fields_tbl_, (st_data_t)obj, (st_data_t *)&fields_obj)) { - rb_bug("Object is missing entry in generic_fields_tbl"); - } + int found = 0; + + gf_lock(); + found = st_lookup(generic_fields_tbl_, (st_data_t)obj, (st_data_t *)&fields_obj); + gf_unlock(); + + if (!found) { + rb_bug("Object is missing entry in generic_fields_tbl"); } return fields_obj; } @@ -1356,10 +1399,21 @@ rb_free_generic_ivar(VALUE obj) ec->gen_fields_cache.obj = Qundef; ec->gen_fields_cache.fields_obj = Qundef; } - RB_VM_LOCKING() { - if (!st_delete(generic_fields_tbl_no_ractor_check(), &key, &value)) { - rb_bug("Object is missing entry in generic_fields_tbl"); - } + /* A write from the mutator or from a local GC sweep (the host's + * obj_free), taking the table's mutex; never from a global GC sweep + * (the during_global_gc guard below). */ + if (rb_gc_during_global_gc_p() || ruby_vm_during_cleanup) { + /* Leave dead keys to the weak pass's drain (same reasoning as the + * skip in rb_mark_generic_ivar); VM destruct's free-at-exit walk + * discards the whole table, needing no per-entry removal either. */ + break; + } + int deleted = 0; + gf_lock(); + deleted = st_delete(generic_fields_tbl_, &key, &value); + gf_unlock(); + if (!deleted) { + rb_bug("Object is missing entry in generic_fields_tbl"); } } } @@ -1398,9 +1452,22 @@ rb_obj_set_fields(VALUE obj, VALUE fields_obj, ID field_name, VALUE original_fie default: { - RB_VM_LOCKING() { + /* st_insert may malloc: disable this Ractor's GC first, or our own + * local GC's marking takes gf_lock again and self-deadlocks. Growing + * can still raise NoMemoryError, and leaking gf_lock hangs every later + * generic-fields access: unwind through a tag. */ + bool gc_disabled = RTEST(rb_gc_local_disable_no_rest()); + rb_execution_context_t *insert_ec = GET_EC(); + enum ruby_tag_type state; + gf_lock(); + EC_PUSH_TAG(insert_ec); + if ((state = EC_EXEC_TAG()) == TAG_NONE) { st_insert(generic_fields_tbl_, (st_data_t)obj, (st_data_t)fields_obj); } + EC_POP_TAG(); + gf_unlock(); + if (!gc_disabled) rb_gc_local_enable(); + if (state != TAG_NONE) EC_JUMP_TAG(insert_ec, state); RB_OBJ_WRITTEN(obj, original_fields_obj, fields_obj); rb_execution_context_t *ec = GET_EC(); @@ -1704,6 +1771,27 @@ imemo_fields_complex_from_obj_i(ID key, VALUE val, st_data_t arg) return ST_CONTINUE; } +static int +imemo_fields_shref_i(ID key, VALUE val, st_data_t arg) +{ + VALUE fields_obj = (VALUE)arg; + /* The fields_obj became shareable while this field value stayed unshareable (a + * hidden [path, line] ivar, say, which make_shareable's traversal never reaches): + * record a shref so the shareable -> unshareable edge is tracked. */ + if (!SPECIAL_CONST_P(val) && !RB_OBJ_SHAREABLE_P(val)) { + rb_gc_writebarrier(fields_obj, val); + } + return ST_CONTINUE; +} + +/* Record shrefs for the values that are still unshareable in a fields imemo that has + * just been promoted to shareable. */ +void +rb_imemo_fields_record_shrefs(VALUE fields_obj) +{ + rb_field_foreach(fields_obj, imemo_fields_shref_i, (st_data_t)fields_obj, false); +} + static VALUE imemo_fields_complex_from_obj(VALUE owner, VALUE source, shape_id_t shape_id, bool ivar_only, int extra_capa) { @@ -2204,18 +2292,27 @@ rb_copy_generic_ivar(VALUE dest, VALUE obj) } } +/* Reference updating for compaction: walk the generic_fields table under the lock, + * from a local GC's update phase, because moving a host in our own objspace leaves the + * table's keys and values stale. This only updates; it never decides liveness. */ void -rb_replace_generic_ivar(VALUE clone, VALUE obj) +rb_generic_fields_shared_table_foreach(void (*cb)(struct st_table *tbl, void *arg), void *arg) { - RB_VM_LOCKING() { - st_data_t fields_tbl, obj_data = (st_data_t)obj; - if (st_delete(generic_fields_tbl_, &obj_data, &fields_tbl)) { - st_insert(generic_fields_tbl_, (st_data_t)clone, fields_tbl); - RB_OBJ_WRITTEN(clone, Qundef, fields_tbl); - } - else { - rb_bug("unreachable"); - } + rb_native_mutex_lock(&GET_VM()->ractor.generic_fields_lock); + if (generic_fields_tbl_ != NULL) { + cb(generic_fields_tbl_, arg); + } + rb_native_mutex_unlock(&GET_VM()->ractor.generic_fields_lock); +} + +/* Call cb(tbl, arg) for the single global generic_fields table. Used by the global + * GC's weak pass and by compaction's reference update; both run under the barrier, so + * the walk needs no lock. */ +void +rb_generic_fields_tables_foreach(void (*cb)(struct st_table *tbl, void *arg), void *arg) +{ + if (generic_fields_tbl_ != NULL) { + cb(generic_fields_tbl_, arg); } } diff --git a/vm.c b/vm.c index 1be43c67a0afb4..11adb95ad2bedd 100644 --- a/vm.c +++ b/vm.c @@ -44,6 +44,7 @@ #include "vm_core.h" #include "vm_callinfo.h" #include "vm_debug.h" +#include "ruby/debug.h" #include "vm_exec.h" #include "vm_insnhelper.h" #include "ractor_core.h" @@ -337,7 +338,17 @@ vm_cref_new0(VALUE klass, rb_method_visibility_t visi, int module_func, rb_cref_ VM_ASSERT(singleton || klass); rb_cref_t *cref = SHAREABLE_IMEMO_NEW(rb_cref_t, imemo_cref, refinements); - cref->klass_or_self = klass; + /* A cref is born shareable, so possibly-unshareable children (a singleton cref's + * self, `using`'s refinements hash) go through the write barrier to record a shref; + * a plain store would let the owner's local GC collect the child under the pinned + * cref. next is always a cref (shareable): plain store. */ + if (!SPECIAL_CONST_P(refinements)) RB_OBJ_WRITTEN(cref, Qundef, refinements); + if (klass) { + RB_OBJ_WRITE(cref, &cref->klass_or_self, klass); + } + else { + cref->klass_or_self = 0; + } cref->next = use_prev_prev ? CREF_NEXT(prev_cref) : prev_cref; *((rb_scope_visibility_t *)&cref->scope_visi) = scope_visi; @@ -3437,10 +3448,6 @@ rb_vm_mark(void *ptr) rb_gc_mark(rb_ractor_self(r)); } - for (size_t index = 0; index < vm->global_object_list_size; index++) { - rb_gc_mark_maybe(*vm->global_object_list[index]); - } - rb_gc_mark_movable(vm->self); if (vm->root_box) { @@ -3450,22 +3457,12 @@ rb_vm_mark(void *ptr) rb_box_entry_mark(vm->main_box); } - /* The main Ractor's registered mark objects (rb_gc_register_mark_object) - * are process-lifetime pins. Mark them here as well as from ractor_mark - * so they stay live before the main Ractor joins vm->ractor.set, e.g. - * during early boot under GC.stress. */ - if (vm->ractor.main_ractor && vm->ractor.main_ractor->mark_object_ary) { - rb_gc_mark_movable(vm->ractor.main_ractor->mark_object_ary); - } - rb_gc_mark_movable(vm->orig_progname); rb_gc_mark_movable(vm->coverages); rb_gc_mark_movable(vm->cme2counter); rb_gc_mark_movable(vm->me_set); rb_gc_mark_movable(vm->cc_refinement_set); - rb_gc_mark_values(RUBY_NSIG, vm->trap_list.cmd); - rb_hook_list_mark(&vm->global_hooks); rb_id_table_foreach_values(&vm->negative_cme_table, vm_mark_negative_cme, NULL); @@ -3513,6 +3510,7 @@ ruby_vm_destruct(rb_vm_t *vm) RUBY_FREE_ENTER("vm"); ruby_vm_during_cleanup = true; + rb_gc_stash_cleanup_objspace(); if (vm) { rb_thread_t *th = vm->ractor.main_thread; @@ -3551,15 +3549,13 @@ ruby_vm_destruct(rb_vm_t *vm) thread_free(th); } - struct rb_objspace *objspace = vm->gc.objspace; + void *objspace = vm->ractor.main_ractor ? vm->ractor.main_ractor->objspace : NULL; rb_vm_living_threads_init(vm); ruby_vm_run_at_exit_hooks(vm); st_free_embedded_table(&vm->ci_table); RB_ALTSTACK_FREE(vm->main_altstack); - SIZED_FREE_N(vm->global_object_list, vm->global_object_list_capa); - if (objspace) { if (rb_free_at_exit) { rb_objspace_free_objects(objspace); @@ -3645,7 +3641,6 @@ vm_memsize(const void *ptr) vm_memsize_builtin_function_table(vm->builtin_function_table) + (rb_id_table_memsize(&vm->negative_cme_table) - sizeof(struct rb_id_table)) + (rb_st_memsize(&vm->overloaded_cme_table) - sizeof(struct st_table)) + - (vm->global_object_list_capa * sizeof(*vm->global_object_list)) + vm_memsize_constant_cache() ); @@ -3901,6 +3896,23 @@ rb_execution_context_mark(const rb_execution_context_t *ec) rb_gc_mark(ec->local_storage_recursive_hash_for_trace); rb_gc_mark(ec->private_const_reference); + /* Snapshots of copy receives being materialized; off the queue, this is their only + * root. A snapshot is sender-resident, skipped as foreign by our local GC; the + * global GC marks it and re-pins its shrefs (its clear pass dropped all). Move + * couriers are covered by the in-flight registry instead (ractor.c). */ + for (const struct ractor_materialize_frame *f = ec->materialize_frames; f != NULL; f = f->prev) { + rb_gc_mark(f->snapshot); + if (f->snapshot && !RB_SPECIAL_CONST_P(f->snapshot) && rb_gc_during_global_gc_p()) { + /* Every node, not just the root: if compaction moved a snapshot node, + * the address-keyed generic_fields entries and the dedup table would + * break. */ + rb_gc_pin_in_flight_message(f->snapshot); + for (size_t i = 0; i < f->pinned_cnt; i++) { + rb_gc_pin_in_flight_message(f->pinned[i]); + } + } + } + rb_gc_mark_movable(ec->storage); } @@ -3918,17 +3930,12 @@ thread_compact(void *ptr) th->self = rb_gc_location(th->self); } -static void -thread_mark(void *ptr) +/* Mark the heap objects a thread owns (the caller handles ec and fiber). Split + * out of thread_mark so that a local GC can root them straight from the Ractor's + * local roots (rb_ractor_mark_local_roots). */ +void +rb_thread_mark_owned_roots(rb_thread_t *th) { - rb_thread_t *th = ptr; - RUBY_MARK_ENTER("thread"); - - // ec is null when setting up the thread in rb_threadptr_root_fiber_setup - if (th->ec) { - rb_fiber_mark_self(th->ec->fiber_ptr); - } - /* mark ruby objects */ switch (th->invoke_type) { case thread_invoke_type_proc: @@ -3943,23 +3950,40 @@ thread_mark(void *ptr) break; } - rb_gc_mark(rb_ractor_self(th->ractor)); rb_gc_mark(th->thgroup); rb_gc_mark(th->value); rb_gc_mark(th->pending_interrupt_queue); rb_gc_mark(th->pending_interrupt_mask_stack); rb_gc_mark(th->top_self); rb_gc_mark(th->top_wrapper); - if (th->root_fiber) rb_fiber_mark_self(th->root_fiber); - - RUBY_ASSERT(th->ec == NULL || th->ec == rb_fiberptr_get_ec(th->ec->fiber_ptr)); rb_gc_mark(th->last_status); rb_gc_mark(th->locking_mutex); rb_gc_mark(th->name); - rb_gc_mark(th->scheduler); rb_threadptr_interrupt_exec_task_mark(th); +} + +static void +thread_mark(void *ptr) +{ + rb_thread_t *th = ptr; + RUBY_MARK_ENTER("thread"); + + // ec is null when setting up the thread in rb_threadptr_root_fiber_setup + if (th->ec) { + rb_fiber_mark_self(th->ec->fiber_ptr); + } + + /* A live thread wrapper keeps its Ractor object alive (and through its dfree the + * rb_ractor_t), so an inherited Thread keeps a dead Ractor alive just as it does + * upstream. */ + if (th->ractor) rb_gc_mark(rb_ractor_self(th->ractor)); + if (th->root_fiber) rb_fiber_mark_self(th->root_fiber); + + RUBY_ASSERT(th->ec == NULL || th->ec == rb_fiberptr_get_ec(th->ec->fiber_ptr)); + + rb_thread_mark_owned_roots(th); RUBY_MARK_LEAVE("thread"); } @@ -4720,7 +4744,7 @@ Init_VM(void) rb_define_global_const("TOPLEVEL_BINDING", rb_binding_new()); #ifdef _WIN32 - rb_objspace_gc_enable(vm->gc.objspace); + rb_objspace_gc_enable(vm->ractor.main_ractor->objspace); #endif } vm_init_redefined_flag(); @@ -4768,7 +4792,11 @@ Init_BareVM(void) vm_init2(vm); ruby_current_vm_ptr = vm; - rb_objspace_alloc(); + /* The boot objspace belongs to the main Ractor, so the main Ractor has to exist + * before rb_gc_init_objspaces allocates it. */ + vm->ractor.main_ractor = rb_ractor_main_alloc(); + rb_gc_init_objspaces(); + vm->ractor.main_ractor->newobj_cache = rb_gc_ractor_cache_alloc(vm->ractor.main_ractor); rb_id_table_init(&vm->negative_cme_table, 16); st_init_existing_numtable_with_size(&vm->overloaded_cme_table, 0); st_init_existing_strtable_with_size(&vm->static_ext_inits, 0); @@ -4777,7 +4805,7 @@ Init_BareVM(void) // setup main thread th->nt = ZALLOC(struct rb_native_thread); - th->ractor = vm->ractor.main_ractor = rb_ractor_main_alloc(); + th->ractor = vm->ractor.main_ractor; Init_native_thread(th); rb_jit_cont_init(); th_init(th, 0, vm); @@ -4790,6 +4818,11 @@ Init_BareVM(void) // setup ractor system rb_native_mutex_initialize(&vm->ractor.sync.lock); rb_native_cond_initialize(&vm->ractor.sync.terminate_cond); + rb_native_mutex_initialize(&vm->ractor.generic_fields_lock); + rb_native_mutex_initialize(&vm->ractor.move_courier_registry_lock); + ccan_list_head_init(&vm->ractor.move_courier_registry); + rb_native_mutex_initialize(&vm->gc.registered_globals.lock); + vm->gc.orphan_merge_pjob = POSTPONED_JOB_HANDLE_INVALID; vm_opt_method_def_table = st_init_numtable(); vm_opt_mid_table = st_init_numtable(); @@ -4812,82 +4845,6 @@ ruby_init_stack(void *addr) #endif -#ifndef MARK_OBJECT_ARY_BUCKET_SIZE -#define MARK_OBJECT_ARY_BUCKET_SIZE 1024 -#endif - -struct pin_array_list { - VALUE next; - long len; - VALUE *array; -}; - -static void -pin_array_list_mark(void *data) -{ - struct pin_array_list *array = (struct pin_array_list *)data; - rb_gc_mark_movable(array->next); - - rb_gc_mark_vm_stack_values(array->len, array->array); -} - -static void -pin_array_list_free(void *data) -{ - struct pin_array_list *array = (struct pin_array_list *)data; - xfree(array->array); -} - -static size_t -pin_array_list_memsize(const void *data) -{ - return sizeof(struct pin_array_list) + (MARK_OBJECT_ARY_BUCKET_SIZE * sizeof(VALUE)); -} - -static void -pin_array_list_update_references(void *data) -{ - struct pin_array_list *array = (struct pin_array_list *)data; - array->next = rb_gc_location(array->next); -} - -static const rb_data_type_t pin_array_list_type = { - .wrap_struct_name = "VM/pin_array_list", - .function = { - .dmark = pin_array_list_mark, - .dfree = pin_array_list_free, - .dsize = pin_array_list_memsize, - .dcompact = pin_array_list_update_references, - }, - .flags = RUBY_TYPED_THREAD_SAFE_FREE | RUBY_TYPED_WB_PROTECTED | RUBY_TYPED_EMBEDDABLE, -}; - -static VALUE -pin_array_list_new(VALUE next) -{ - struct pin_array_list *array_list; - VALUE obj = TypedData_Make_Struct(0, struct pin_array_list, &pin_array_list_type, array_list); - RB_OBJ_WRITE(obj, &array_list->next, next); - array_list->array = ALLOC_N(VALUE, MARK_OBJECT_ARY_BUCKET_SIZE); - return obj; -} - -static VALUE -pin_array_list_append(VALUE obj, VALUE item) -{ - struct pin_array_list *array_list; - TypedData_Get_Struct(obj, struct pin_array_list, &pin_array_list_type, array_list); - - if (array_list->len >= MARK_OBJECT_ARY_BUCKET_SIZE) { - obj = pin_array_list_new(obj); - TypedData_Get_Struct(obj, struct pin_array_list, &pin_array_list_type, array_list); - } - - RB_OBJ_WRITE(obj, &array_list->array[array_list->len], item); - array_list->len++; - return obj; -} - void rb_vm_register_global_object(VALUE obj) { @@ -4907,36 +4864,19 @@ rb_vm_register_global_object(VALUE obj) default: break; } - RB_VM_LOCKING() { - rb_ractor_t *cr = GET_RACTOR(); - if (!cr->mark_object_ary) cr->mark_object_ary = pin_array_list_new(Qnil); - VALUE list = cr->mark_object_ary; - VALUE head = pin_array_list_append(list, obj); - if (head != list) { - cr->mark_object_ary = head; - } - RB_GC_GUARD(obj); - } -} - -/* Hand src's registered mark objects to dst (used when a Ractor terminates: - * these are process-lifetime pins, so the main Ractor keeps them alive). */ -void -rb_vm_ractor_migrate_mark_objects(rb_ractor_t *dst, rb_ractor_t *src) -{ - ASSERT_vm_locking(); - VALUE list = src->mark_object_ary; - while (!NIL_P(list) && list) { - struct pin_array_list *array_list; - TypedData_Get_Struct(list, struct pin_array_list, &pin_array_list_type, array_list); - for (long i = 0; i < array_list->len; i++) { - if (!dst->mark_object_ary) dst->mark_object_ary = pin_array_list_new(Qnil); - VALUE head = pin_array_list_append(dst->mark_object_ary, array_list->array[i]); - if (head != dst->mark_object_ary) dst->mark_object_ary = head; - } - list = array_list->next; + /* Register in the current Ractor's own pin list (a raw array). No lock: only the + * owner appends and only the owner's GC marks it; the merge that inherits a list + * runs stop-the-world. */ + rb_ractor_t *cr = GET_RACTOR(); + if (cr->registered_marks_cnt == cr->registered_marks_capa) { + size_t nc = cr->registered_marks_capa ? cr->registered_marks_capa * 2 : 64; + VALUE *p = realloc(cr->registered_marks, nc * sizeof(VALUE)); + if (!p) rb_bug("rb_vm_register_global_object: out of memory"); + cr->registered_marks = p; + cr->registered_marks_capa = nc; } - src->mark_object_ary = 0; + cr->registered_marks[cr->registered_marks_cnt++] = obj; + RB_GC_GUARD(obj); } VALUE rb_cc_refinement_set_create(void); @@ -4945,9 +4885,6 @@ void Init_vm_objects(void) { rb_vm_t *vm = GET_VM(); - - /* mark object arrays are per-Ractor (rb_ractor_t.mark_object_ary), - * lazily created on first rb_gc_register_mark_object */ st_init_existing_table_with_size(&vm->ci_table, &vm_ci_hashtype, 0); vm->cc_refinement_set = rb_cc_refinement_set_create(); } diff --git a/vm_backtrace.c b/vm_backtrace.c index 91ca7f3f0188f7..de952e3090d06b 100644 --- a/vm_backtrace.c +++ b/vm_backtrace.c @@ -846,6 +846,26 @@ backtrace_alloc_capa(long num_frames, rb_backtrace_t **backtrace) return btobj; } +/* Duplicate the backtrace so an exception copy carries no raw pointer to the sender's. + * A frame only references shareable iseq / method-entry imemos, so duplicating is safe; + * the lazily built strings and location array are regenerated on the receiving side. */ +VALUE +rb_backtrace_dup(VALUE btobj) +{ + rb_backtrace_t *src, *dst; + TypedData_Get_Struct(btobj, rb_backtrace_t, &backtrace_data_type, src); + + VALUE dupobj = backtrace_alloc_capa(src->backtrace_size, &dst); + dst->backtrace_size = src->backtrace_size; + MEMCPY(dst->backtrace, src->backtrace, rb_backtrace_location_t, src->backtrace_size); + for (int i = 0; i < dst->backtrace_size; i++) { + const rb_backtrace_location_t *fi = &dst->backtrace[i]; + if (fi->cme) RB_OBJ_WRITTEN(dupobj, Qundef, (VALUE)fi->cme); + if (fi->iseq) RB_OBJ_WRITTEN(dupobj, Qundef, (VALUE)fi->iseq); + } + return dupobj; +} + static long backtrace_size(const rb_execution_context_t *ec) diff --git a/vm_core.h b/vm_core.h index e82227aa6414eb..a3ce5a4738d737 100644 --- a/vm_core.h +++ b/vm_core.h @@ -662,7 +662,7 @@ typedef struct rb_at_exit_list { struct rb_at_exit_list *next; } rb_at_exit_list; -void *rb_objspace_alloc(void); +void rb_gc_init_objspaces(void); void rb_objspace_free(void *objspace); void rb_objspace_call_finalizer(void); @@ -687,11 +687,15 @@ typedef const struct rb_builtin_function *RB_BUILTIN; /* The mark redirect used by the object-traversal APIs * (rb_objspace_reachable_objects_from etc.). It is installed while a traversal * runs and is NULL during a real GC. Storage is per-Ractor - * (rb_ractor_t.mark_func_data), except on a modular GC where it lives in the VM - * (rb_vm_struct's gc sub-struct; see gc.c). */ + * (rb_ractor_t.mark_func_data); on a modular GC, threads without a current + * Ractor fall back to rb_vm_struct's gc sub-struct (see gc.c). */ struct gc_mark_func_data_struct { void *data; void (*mark_func)(VALUE v, void *data); + /* Marker set while a shareable-verification walk runs (read by + * rb_gc_checking_shareable). The slot is per-Ractor, so it only affects the + * walk of the Ractor doing the verification. */ + bool checking_shareable; }; typedef struct rb_vm_struct { @@ -699,6 +703,10 @@ typedef struct rb_vm_struct { struct { struct ccan_list_head set; + /* For a single-objspace impl (mmtk): Ractors between termination and + * ractor_free. The global root scan keeps marking their + * registered_marks. */ + struct ccan_list_head terminated_set; unsigned int cnt; unsigned int blocking_cnt; @@ -724,6 +732,13 @@ typedef struct rb_vm_struct { #endif } sync; + /* VM-wide locks for the Ractor transfer/inheritance machinery, plus the + * registry of in-flight move couriers. All of them are leaf locks: no + * safepoint inside a critical section. */ + rb_nativethread_lock_t generic_fields_lock; /* the shared generic-fields table in variable.c */ + struct ccan_list_head move_courier_registry; /* couriers in flight (ractor.c); the global GC marks them */ + rb_nativethread_lock_t move_courier_registry_lock; + #ifdef RUBY_THREAD_PTHREAD_H // ractor scheduling struct { @@ -778,9 +793,6 @@ typedef struct rb_vm_struct { unsigned int thread_ignore_deadlock: 1; /* object management */ - VALUE **global_object_list; - size_t global_object_list_size; - size_t global_object_list_capa; const VALUE special_exceptions[ruby_special_error_count]; /* Ruby Box */ @@ -816,14 +828,56 @@ typedef struct rb_vm_struct { int coverage_mode; struct { - struct rb_objspace *objspace; + /* The VM only points at rb_global_objspace, the process-wide GC data such as + * the page pool. Each Ractor owns its own rb_objspace through r->objspace, + * and the boot objspace belongs to the main Ractor. */ + struct rb_global_objspace *global_objspace; + /* Objspaces of terminated, not-yet-inherited Ractors. No mutator runs in + * them; a global GC sweeps them under the barrier (missing one leaves stale + * mark bits = UAF), inheritance merges them under the VM lock. owner_slot is + * the dead Ractor's r->objspace, cleared when inherited. */ + struct rb_objspace_zombie { + void *objspace; + void **owner_slot; + /* The terminated Ractor owning this zombie; a root scan reaches its + * rb_gc_register_mark_object pins and join value through it. NULL for an + * orphan, whose Ractor struct is gone and has neither any more. */ + struct rb_ractor_struct *owner; + /* Heap pages this zombie holds: measured when it retires and refreshed + * under the barrier of each global cycle. The total below stays exactly + * in sync, entry by entry. */ + size_t pages; + } *zombie_objspaces; + size_t zombie_objspaces_count; + size_t zombie_objspaces_capa; + /* Sum of .pages over zombie_objspaces. Between global cycles it is an upper + * bound: a zombie's heap never grows and only shrinks at a global cycle. */ + size_t zombie_total_pages; + #if USE_MODULAR_GC - /* A modular GC (e.g. MMTk) may mark on worker threads that have no - * current EC, so the traversal mark redirect must be reachable without - * a Ractor and lives here. Otherwise it is per-Ractor - * (rb_ractor_t.mark_func_data). */ struct gc_mark_func_data_struct *mark_func_data; #endif + /* One VM-wide list for rb_gc_register_address: a slot can later hold another + * objspace's value, so it is not split per Ractor and every Ractor's GC scans it + * conservatively. Leaf lock; register/unregister are cold paths. */ + struct { + rb_nativethread_lock_t lock; + VALUE **addrs; /* rb_gc_register_address: mark_maybe on *addr */ + size_t addrs_cnt, addrs_capa; + } registered_globals; + + /* Holders keeping GC disabled (atomic): Ractors that called GC.disable (at + * most one hold each) plus short internal critical sections. One holder stops + * GC everywhere; GC.enable releases only the caller's own hold, never + * overriding another Ractor's disable. */ + rb_atomic_t disable_holders; + /* Handle of the postponed job that merges an orphan objspace into the main + * one (rb_postponed_job_handle_t; POSTPONED_JOB_HANDLE_INVALID when not + * registered). */ + unsigned int orphan_merge_pjob; + /* Used to resolve the objspace during VM teardown (the cleanup path of + * rb_gc_get_objspace). */ + void *cleanup_objspace; } gc; rb_at_exit_list *at_exit; @@ -1068,6 +1122,8 @@ struct rb_waiting_list { struct rb_fiber_struct *fiber; }; +struct ractor_materialize_frame; + struct rb_execution_context_struct { /* execution information */ VALUE *vm_stack; /* must free, must mark */ @@ -1119,6 +1175,11 @@ struct rb_execution_context_struct { VALUE fields_obj; } gen_fields_cache; + /* Chain of receive frames being materialized on this EC (LIFO; the frames live + * on the C stack). A thread or fiber switch cannot corrupt it, since each EC's + * chain only contains that EC's own nesting. */ + struct ractor_materialize_frame *materialize_frames; + /* for GC */ struct { VALUE *stack_start; @@ -2043,6 +2104,7 @@ rb_vm_living_threads_init(rb_vm_t *vm) { ccan_list_head_init(&vm->workqueue); ccan_list_head_init(&vm->ractor.set); + ccan_list_head_init(&vm->ractor.terminated_set); } typedef int rb_backtrace_iter_func(void *, VALUE, int, VALUE); diff --git a/vm_insnhelper.c b/vm_insnhelper.c index 43bdfadb9354dd..488f4f10aeabef 100644 --- a/vm_insnhelper.c +++ b/vm_insnhelper.c @@ -514,10 +514,23 @@ NOINLINE(static void vm_env_write_slowpath(const VALUE *ep, int index, VALUE v)) static void vm_env_write_slowpath(const VALUE *ep, int index, VALUE v) { - /* remember env value forcely */ - rb_gc_writebarrier_remember(VM_ENV_ENVVAL(ep)); - VM_FORCE_WRITE(&ep[index], v); - VM_ENV_FLAGS_UNSET(ep, VM_ENV_FLAG_WB_REQUIRED); + const VALUE envval = VM_ENV_ENVVAL(ep); + + if (RB_FL_TEST_RAW(envval, RUBY_FL_SHAREABLE)) { + /* Writing to a SHAREABLE env (an isolated proc) can create a shareable -> + * unshareable edge; only a full barrier sets the shref bit keeping v alive + * through its owner's local GC. WB_REQUIRED stays: later writes need it too. */ + if (!SPECIAL_CONST_P(v)) { + rb_gc_writebarrier(envval, v); + } + VM_FORCE_WRITE(&ep[index], v); + } + else { + /* remember env value forcely */ + rb_gc_writebarrier_remember(envval); + VM_FORCE_WRITE(&ep[index], v); + VM_ENV_FLAGS_UNSET(ep, VM_ENV_FLAG_WB_REQUIRED); + } RB_DEBUG_COUNTER_INC(lvar_set_slowpath); } @@ -582,12 +595,32 @@ vm_svar_valid_p(VALUE svar) } #endif +/* Should this frame's special variables live in the env's svar slot? A SHAREABLE env + * (isolated proc) can run in several Ractors at once, making svar shared mutable state + * leaking $~/$_ across Ractors: keep them per-EC instead. */ +static inline bool +lep_svar_in_env_p(const rb_execution_context_t *ec, const VALUE *lep) +{ + if (!lep) return false; + if (ec == NULL) return true; + if (ec->root_lep == lep) return false; + /* lep may be a stale on-stack ep of a frame whose env already escaped: lep[0] + * still holds the imemo_env, so flags is not a FIXNUM and VM_ENV_ESCAPED_P + * asserts. That is not a live shareable proc's env, so fall back to in-env. */ + if (FIXNUM_P(lep[VM_ENV_DATA_INDEX_FLAGS]) && + VM_ENV_ESCAPED_P(lep) && + RB_FL_TEST_RAW(VM_ENV_ENVVAL(lep), RUBY_FL_SHAREABLE)) { + return false; + } + return true; +} + static inline struct vm_svar * lep_svar(const rb_execution_context_t *ec, const VALUE *lep) { VALUE svar; - if (lep && (ec == NULL || ec->root_lep != lep)) { + if (lep_svar_in_env_p(ec, lep)) { svar = lep[VM_ENV_DATA_INDEX_ME_CREF]; } else { @@ -604,7 +637,7 @@ lep_svar_write(const rb_execution_context_t *ec, const VALUE *lep, const struct { VM_ASSERT(vm_svar_valid_p((VALUE)svar)); - if (lep && (ec == NULL || ec->root_lep != lep)) { + if (lep_svar_in_env_p(ec, lep)) { vm_env_write(lep, VM_ENV_DATA_INDEX_ME_CREF, (VALUE)svar); } else { diff --git a/vm_method.c b/vm_method.c index ac992db8909802..47d15cb02b81fe 100644 --- a/vm_method.c +++ b/vm_method.c @@ -30,18 +30,14 @@ mark_cc_entry_i(VALUE ccs_ptr, void *data) VM_ASSERT(vm_ccs_p(ccs)); if (METHOD_ENTRY_INVALIDATED(ccs->cme)) { - /* Before detaching the CCs from this class, we need to invalidate the cc - * since we will no longer be marking the cme on their behalf. - */ + /* Never prune from a GC, only mark. A cc table walk (dup and friends) can trigger a + * GC from an allocation midway, so an xfree+DELETE while marking would derail the + * walking iterator into freed ccs. The mutator cleans up, under the VM lock. */ + rb_gc_mark_movable((VALUE)ccs->cme); for (int i = 0; i < ccs->len; i++) { - const struct rb_callcache *cc = ccs->entries[i].cc; - if (cc->klass == Qundef) continue; // already invalidated - VM_ASSERT(cc->klass == Qundef || vm_cc_check_cme(cc, ccs->cme)); - VM_ASSERT(!vm_cc_super_p(cc) && !vm_cc_refinement_p(cc)); - vm_cc_invalidate(cc); + rb_gc_mark_movable((VALUE)ccs->entries[i].cc); } - ruby_xfree_sized(ccs, vm_ccs_alloc_size(ccs->capa)); - return ID_TABLE_DELETE; + return ID_TABLE_CONTINUE; } else { rb_gc_mark_movable((VALUE)ccs->cme); diff --git a/vm_trace.c b/vm_trace.c index f2c3727e4e9708..78ba3e24fd5996 100644 --- a/vm_trace.c +++ b/vm_trace.c @@ -129,8 +129,12 @@ update_global_event_hooks(rb_hook_list_t *list, rb_event_flag_t prev_events, rb_ rb_execution_context_t *ec = rb_current_execution_context(false); unsigned int lev; - // Can't enter VM lock during freeing of ractor hook list on MMTK, where ec == NULL. - if (ec) { + // Lock only with a current Ractor. ec is NULL in MMTk's hook-list free; a global + // GC's sweep frees dead Ractors' hook lists with GET_RACTOR() == NULL (locking = + // NULL deref, and the barrier already excludes); in VM destruct's free-at-exit walk + // the thread structs are freed first (GET_RACTOR() = UAF) and it is single-threaded. + const bool vm_locked_here = ec && !ruby_vm_during_cleanup && GET_RACTOR() != NULL; + if (vm_locked_here) { RB_VM_LOCK_ENTER_LEV(&lev); rb_vm_barrier(); } @@ -185,7 +189,7 @@ update_global_event_hooks(rb_hook_list_t *list, rb_event_flag_t prev_events, rb_ rb_zjit_tracing_invalidate_all(); } - if (ec) { + if (vm_locked_here) { RB_VM_LOCK_LEAVE_LEV(&lev); } } @@ -2022,9 +2026,11 @@ rb_postponed_job_flush(rb_vm_t *vm) RUBY_VM_SET_POSTPONED_JOB_INTERRUPT(GET_EC()); } /* likewise with any remaining-to-be-executed bits of the preregistered postponed - * job table */ + * job table. A merged bit can carry a Ractor-directed job that must not run on another + * Ractor (rb_postponed_job_trigger_for_ractor), so re-post it to this Ractor's own mask + * rather than to the global bitset. */ if (triggered_bits) { - RUBY_ATOMIC_OR(pjq->triggered_bitset, triggered_bits); + RUBY_ATOMIC_OR(rb_ec_ractor_ptr(ec)->postponed_job_triggered_bits, triggered_bits); RUBY_VM_SET_POSTPONED_JOB_INTERRUPT(GET_EC()); } }