Skip to content

[pull] master from ruby:master - #1288

Merged
pull[bot] merged 27 commits into
turkdevops:masterfrom
ruby:master
Aug 7, 2026
Merged

[pull] master from ruby:master#1288
pull[bot] merged 27 commits into
turkdevops:masterfrom
ruby:master

Conversation

@pull

@pull pull Bot commented Aug 7, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

matzbot and others added 27 commits August 7, 2026 07:10
Give each Ractor its own objspace so an ordinary local GC runs without
the VM lock or a barrier.  A stop-the-world global GC does the
cross-objspace mark/sweep/compaction and drains the weak tables.

Includes objspace creation, absorption of a terminated Ractor's heap,
zombie objspace handling, the containment checks that keep a Ractor from
holding another objspace's unshareable object, and RGENGC_CHECK_MODE
support for the new invariants.

Co-authored-by: Claude <noreply@anthropic.com>
Implement send(move: true) with an off-heap courier that carries the
payload between objspaces, neutralizing the source into a
RactorMovedObject shell.  Handle absorbing a terminated Ractor's heap,
keeping a zombie owner alive while another Ractor still reads its value,
and per-Ractor ownership of the VM tables.

Co-authored-by: Claude <noreply@anthropic.com>
Keep the generic-ivar host-to-fields_obj mapping in one global table,
serializing mutators with a dedicated lock.  A local GC marks only the
values living in its own objspace; the global GC marks the values of
live keys in the weak pass that runs after marking.  This removes the
table-ownership transitions that used to happen on shareable promotion,
on send, and on absorb.
Order thread teardown against GC-time frees, route Fiber and Thread
frees from a local GC through the VM lock, and stop touching VM-global
fiber-pool and zombie-thread state without synchronization.

Co-authored-by: Claude <noreply@anthropic.com>
Make the VM-global GC roots per-Ractor so the main Ractor's ordinary
local GC runs lock-free.  Add the shareability and objspace-locality
checks to the write barrier, and split the VM-wide tables behind
finer-grained locks.

Co-authored-by: Claude <noreply@anthropic.com>
A single-objspace implementation has no pinning or shareable-reference
bits, no zombie objspaces, and no per-Ractor local GC: liveness is
decided by marking alone.  Give plain marking paths to everything the
per-Ractor GC otherwise keeps alive through C-struct root scans and
pinning (every Ractor's local roots, in-flight payloads, a terminated
Ractor's rb_gc_register_mark_object entries, and the main Ractor before
it joins vm->ractor.set), and connect during_global_gc_p to the mmtk
world-stopped state.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Adjust bootstraptest and test-all for the per-Ractor local GC, and add
regression tests for the Ractor lifecycle and cross-objspace cases.

Co-authored-by: Claude <noreply@anthropic.com>
…tor-local roots

Skipping test_enable leaves GC enabled for the rest of the suite, which
exposes two more tests:

(1) test_stress_and_stress= runs the whole assert_send_type type check
    with GC.stress on and does not finish within the CI budget (the
    leaked GC-off used to make it a no-op).  Once the rbs fix ships,
    upstream CI hits the same path.
(2) test_reachable_objects_from_root has assert_send_type eagerly build
    trace.inspect of the entire roots hash even on success.  Exposing
    per-Ractor roots makes that inspect huge and pushes peak memory into
    the gigabytes (NoMemoryError on the Windows CI).

Also skip GCSingletonTest#test_enable itself; the rbs-side fix is merged
but not yet released.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Ractor#value used to be repeatable: the successor could call it again
and get the same object back, because the value stayed reachable from
the dead Ractor's C struct (sync.legacy).  Keeping that slot valid
after the Ractor is gone is what forced the successor to root and pin
every Ractor it absorbed, through a VM-wide list guarded by its own
lock, with add, unlink and scan coming from three different threads.

Take the value out of the struct on the first call and raise
Ractor::Error afterwards.  The value is then rooted by the caller like
any other return value, so the list, its lock and the pinning go away.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
RACTOR_CHECK_MODE stamped every object with the id of the Ractor that
allocated it (a 4 byte suffix on every slot), and re-stamped whole object
graphs whenever a value crossed a Ractor boundary.  Per-Ractor objspaces
already record the owner: an object's page knows its objspace, so
rb_gc_obj_foreign_p() answers "is this another Ractor's object" with one
load, and the id, its suffix and the re-stamping traversals all go away.

Deriving it is also more accurate.  The stamped id was never rewritten
when a zombie objspace was absorbed, which is what ractor_reset_belonging()
had to paper over by re-stamping received and inherited values.  Page
ownership moves with the absorb, so nothing is left stale.

thread.c stamped the child's main Thread wrapper with the child's id
because upstream allocates that wrapper in the parent's objspace.  This
branch already allocates it in the child's objspace, so the site goes too.

With ractor_reset_belonging() gone, no rb_obj_traverse() caller has an
enter function that descends into arbitrary types: make_shareable raises
on T_FILE and shareable_p stops there, so obj_traverse_i() can no longer
reach T_FILE and its case goes as well.

GC impls that keep a single objspace (mmtk) cannot tell owners apart and
report every object as their own, so the check degrades to a no-op there;
in exchange, dropping the suffix re-enables mmtk's ZJIT allocation
fastpath.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
A move courier carries an IO by taking over its rb_io, so the source IO is
already a RactorMovedObject and nobody else can close the descriptor.  When
the courier is freed without being delivered -- Ractor#send raising because
the port is closed is enough -- the branch for MOVE_KIND_IO did nothing and
the fd was leaked.  Sending 20 IOs to a closed Ractor leaked 20 descriptors.

Finalize the rb_io instead, which closes the fd and frees the buffers.  A
delivered IO already left fptr == NULL, so it is untouched.

Found by an automated review of the branch.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rewriting the atfork block to reinitialize the GC's locks dropped the two
lines that reinitialize vm->once_lock and vm->once_cond.  They are only
initialized in Init_Thread_Mutex now, so a child of a fork that happened
while another thread held once_lock inherits it locked, and the first
vm_once_wait_no_gvl in the child waits forever.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding a generic-fields entry takes the VM-wide generic_fields_lock and then
calls st_insert, which mallocs when the table grows.  With this Ractor's GC
disabled the allocation cannot fall back to a collection, so it can raise
NoMemoryError -- and the raise skipped both gf_unlock() and the matching
rb_gc_local_enable().  A leaked generic_fields_lock hangs every later access
to generic fields, including the marking of any Ractor's local GC, so an
otherwise recoverable NoMemoryError turned into a deadlock.  Unwind through
a tag instead.

While here, drop three things that no longer match the code: a basket
payload comment describing a generic-ivar map field that was removed, the
unused st_table placeholder left in ractor_basket_new, and two comments in
variable.c still describing the per-Ractor generic-fields tables that the
single global table replaced.

This commit also carries the mechanical translation of the Japanese comments
in these two files, which was in progress in the same working tree.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verified from an independent review's findings:

- Ractor#value: the legacy_taken check ran only before the wait for
  ractor_terminated, which yields the GVL, so a second thread of the same
  Ractor calling #value returned Qnil instead of raising "The value was
  already taken".  Re-check after the wait.
- basket_new: allocating the basket after building a move courier meant a
  NoMemoryError from the alloc orphaned the courier (sources already
  destroyed, registry entry alive forever).  Allocate the basket first and
  free it if the courier build raises.
- Ractor creation: rb_thread_alloc can raise NoMemoryError inside
  RB_VM_LOCKING, whose unlock a longjmp skips -- and it would also leave
  cr->objspace pointing at the child.  Catch inside the lock, restore, and
  re-raise outside.
- thread_start: rb_ractor_setup_default_port ran outside the tag that
  cancels creation, so a raise left an unstartable Ractor in the set and
  terminate_all waited forever.  Move it inside.
- thread_start: native_thread_create failure for a child Ractor called
  rb_ractor_living_threads_remove, which asserts the current Ractor and
  runs the Ractor-exit protocol from the wrong thread; cancel the creation
  instead, like the send-failure path.

btest 2054 PASS, test_ractor.rb 38 tests / 364 assertions PASS; the #value
race is covered by a new deterministic check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
After a native copy succeeds, ractor_basket_alloc can still raise
NoMemoryError before cr->pin_capture is handed to the basket.  The stale
list was then re-pinned by every global GC's root pass
(rb_ractor_repin_in_flight walks it unconditionally), keeping the failed
send's snapshot alive until the next copy send replaced the list.  Free
the list on that path; the nodes stay shref-pinned only until the next
global GC clears the bits.

Found by an independent review of the previous raise-path fixes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A monitor entry holds a port of the monitoring Ractor and the exit token is
sent through it, so the monitoring Ractor's struct has to outlive the
monitored one: its wrapper is the reference that keeps it alive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tegers.

When this code was refactored in 9c4db31, the mantissa_digits captured
only the length of the mantissa and the negative sign was captured
separately. Additionally, the paths diverged between signed and
unsigned values. However, MAX_FAST_INTEGER_SIZE stayed at 18 which limited
the range of uint64_t and int64_t that took the fast path.

This change expands the range of uint64_t and int64_t's that can be
returned by the fast paths without falling back to
json_decode_large_number.

ruby/json@0238abcaf3
RubyVM::InstructionSequence#to_binary raises "should not compile with
coverage" while coverage measurement is enabled. Most to_binary tests
in test_iseq.rb go through the iseq_to_binary helper, which omits the
test in that case, but these two call to_binary directly. This makes
`make check COVERAGE=true` fail deterministically, which has kept the
ruby/actions coverage workflow red on every scheduled run.

Use the helper in test_serialize_anonymous_outer_variables, and guard
test_rb_iseq_load_from_binary with the same idiom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
heap_alloc_slot tested is_incremental_marking() and bumped a counter on
every allocation, to bring the mutator out to newobj_refill every
INCREMENTAL_MARK_STEP_ALLOCATIONS slots.  Test it in the branch that has
already run out of the region instead, so that the fast path is back to
the single comparison it makes anyway.

A free region holds about twenty slots, which is far more often than the
step size, so newobj_refill charges the region it takes to
incremental_mark_step_allocated_slots and steps once the regions add up
to INCREMENTAL_MARK_STEP_ALLOCATIONS.

Allocating 40M empty arrays in one Ractor, instructions retired:

    before  14.784G
    after   14.170G

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Isolate console modes and version information from `IO` into a common
namespace.

ruby/io-console@62890333ac
Returns the AST node that the proc or method was compiled from, by
re-parsing the source ([Feature #21795]). Returns nil if the node
cannot be retrieved reliably: the stored source hash guarantees that
the re-parsed source is likely the same code before the node is looked
up by its node id. For a proc defined by a block, the outer node that
owns the block (such as the method call with the block) is returned
rather than the block node itself.

The source is re-parsed by the same parser that compiled it. For
prism, the default gem prism is expected, because it is the same
parser as the one built into the interpreter; when another prism gem
is loaded, a warning is emitted in verbose mode since it may parse the
source differently. `version: "current"` is passed so that the source
is parsed with the grammar of the running Ruby.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Returns the AST node at the location: the node of the surrounding
method or block is retrieved and validated in the same way as
Proc#syntax_tree, and then the node at the program counter is searched
within it ([Feature #21795]).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pull pull Bot locked and limited conversation to collaborators Aug 7, 2026
@pull pull Bot added the ⤵️ pull label Aug 7, 2026
@pull
pull Bot merged commit 6408f14 into turkdevops:master Aug 7, 2026
0 of 2 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants