Skip to content

[pull] master from ruby:master - #1330

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

[pull] master from ruby:master#1330
pull[bot] merged 14 commits into
turkdevops:masterfrom
ruby:master

Conversation

@pull

@pull pull Bot commented Aug 20, 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 : )

dependabot Bot and others added 14 commits August 20, 2026 11:33
Bumps the github-actions group with 1 update in the / directory: [taiki-e/install-action](https://github.com/taiki-e/install-action).


Updates `taiki-e/install-action` from 2.86.1 to 2.86.2
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](taiki-e/install-action@288e746...b6b84cf)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.86.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
An unshareable object that the native copier cannot handle is sent as a
Marshal dump.  That dump was a String in the sender's objspace, kept alive
by an in-flight pin until the receiver materialized it.  When the message
is never received the pin never lifts, so the page holding it stays
allocated for the life of the process: GC.start, GC.compact and
Port#close all leave it.  With per-Ractor GC that is one 64KB heap page
per sending Ractor, and it grows without bound.

Carry the dump in an xmalloc'd buffer instead, the way a move courier
already carries its payload (design_v2.md 4.5).  An in-flight payload
that is not a GC object needs no pin, so nothing of the sender's heap is
held while the message waits, and an unreceived message costs only the
buffer.

Measured with 3000 send-and-never-receive rounds, each from a Ractor that
then exits:

  payload          before              after
  Time             79.9 KB/Ractor      13.7 KB/Ractor   (1.00 -> 0.01 page)
  Set              79.5 KB/Ractor      13.3 KB/Ractor
  Random           83.6 KB/Ractor      15.7 KB/Ractor

13 KB/Ractor is what a Ractor that sends nothing at all retains, and that
part plateaus.  Send/receive throughput is unchanged (20k messages, n=9,
median us/message: Time 8.75 -> 8.79, Set 4.88 -> 4.84).

Natively copied payloads (String, Array, Hash, Object, Struct, MatchData)
still build a snapshot in the sender's objspace and still pin it; they
keep the old behaviour for now.

Marshal does not mark its source (mark_load_arg), and the basket is off
the queue while it materializes, so the rebuilt String is rooted from the
receiving frame's stack slot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Step 2 took the marshaled copy payload off-heap; this does the same for the
natively copied one, so an unreceived copy no longer pins a page of the
sender's heap for any payload type.

move_capture gains a copy mode: it reads the sources instead of taking them
apart (no husk, no buffer hand-over, no freeing the source's internals), and
copy_courier_supported_p decides up front whether the courier can carry the
whole graph.  MatchData, IO, any other T_DATA and a singleton class are
rejected there and keep using the on-heap snapshot path, which handles or
rejects them exactly as before.

Retention with 3000 send-and-never-receive rounds (KB per sending Ractor,
before -> after):

  String   79.5 -> 14.0     Array  79.5 -> 14.0
  Object   79.5 -> 14.0     Hash   79.5 -> 14.0

14 KB is what a Ractor that sends nothing retains, and that part plateaus.

A courier is an in-process format, so a shareable payload can be embedded as
the VALUE itself rather than costing a whole move_node: child slots now hold
either a node id or, with the top bit set, an index into a compact c->refs
array that the registry marks.  Without that, an array of immediates paid a
72-byte node, a hash lookup and an insert per element, and the message path
cost 2.5x what the native copier did (it skipped shareable children).

Message throughput (20k messages, n=9, interleaved, median us/message):

  100-element Integer array   5.1 -> 4.7
  4KB String                 16.6 -> 9.3     (one memcpy, not two copies)
  20-key Hash                12.0 -> 9.4
  nested Hash/Array/String   10.1 -> 7.2
  40B String, bare Object    unchanged

It also restores a behaviour master lost: a String or Array subclass now
survives the copy again (4.0.2 copies with #clone and keeps the class;
master's native copier builds a base-class object).  MyStr < String arrives
as MyStr, as it does under Marshal and #clone.

move_capture keeps testing the dedup table before shareability: move husks
each source as it goes, and a husk is a frozen field-less object that
rb_ractor_shareable_p answers true for, so the other order embedded the husk
instead of resolving a second occurrence to the first one's node
(bootstraptest/test_ractor.rb:661).  An immediate skips the lookup, since
only captured objects are ever inserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
With MatchData and an exception's backtrace on the courier, every copy
payload is off-heap: the courier carries the core types and anything else
travels as marshaled bytes.  The sender-resident snapshot is gone, and with
it all the machinery that kept one alive across a global GC.

MatchData: rb_match_move_dump takes a release_source flag, since only a move
takes the source's onig region and char_offset apart.

Backtrace: rb_backtrace_blob_dump / _load / _mark copy the frames into an
off-heap blob.  A frame only references shareable iseq and method-entry
imemos, so the blob can carry them as they are; it has no compaction update
hook, so the mark pins them (rb_gc_mark, not _movable).

Removed, all of it unreachable once no payload lives in the sender's heap:

  rb_ractor_t   pin_capture / _cnt / _capa, sending_basket,
                gen_fields_capturing, ractor_pin_capture_push
  basket        p.pinned / p.pinned_cnt, ractor_basket_repin_in_flight,
                rb_ractor_repin_in_flight and its queue walks
  receiver      struct ractor_materialize_frame, ec->materialize_frames and
                its mark loop, sync.materializing_copies,
                rb_ractor_materializing_p
  gc            rb_gc_pin_in_flight_message and the whole
                rb_gc_impl_pin_in_flight_message hook (default, mmtk, the
                modular function table), plus the two verifier relaxations
                that existed for a snapshot being materialized

shref bits stay: the write barrier still records them for other reasons.
ractor_copy_native_try and copy_enter stay too, since
Ractor.make_shareable(obj, copy: true) still deep-copies within one objspace.

Message cost, 20k messages per trial, 9 trials, median us per message:

                        4.0.2   master   here
  4KB String              3.8     19.4     9.4
  20-key Hash             3.3     11.9     7.5
  nested Hash/Array/Str   5.3      7.1     5.1
  100-elem Integer Array  4.4      5.7     6.9
  40B String, Object      same across all three

master is well behind 4.0.2 on multi-KB payloads; this recovers about half
of that and puts nested graphs back at 4.0.2's level.

Known issue, already in master and not introduced here: the courier's
registry mark does not traverse a shareable payload, so its children are
collected if a global GC runs while the message is in flight.  Unpatched
master crashes on both repros (a dynamic Symbol loses its fstr, a frozen
Array loses its elements) and 4.0.2 does not.  It only shows up for move
today; routing copy through the courier makes it reachable from a common
path, so it wants fixing before this ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Materializing a string node called rb_enc_str_new, which copies the bytes a
second time: once into the courier when the node was built, once out of it
when the message arrives.  Give the buffer to the String instead.

rb_str_new_owned takes an xmalloc'd buffer as a heap String's body, the way
rb_str_new_static does for a static one, except the String owns it and frees
it like any other.  The courier's node hands its pointer over and forgets it,
so courier_free no longer frees what the String now owns.

Message cost, 20k messages per trial, 9 trials, median us per message:

  payload    4.0.2   master   before   here
   1KB        2.97     4.11     3.4     3.10
   4KB        3.52     8.75     9.4     4.22
   8KB        4.26    12.39    12.0     5.08
  16KB        5.43    16.24    16.5     6.53

master pulls away from 4.0.2 as the payload grows; this tracks it within
about 20% across the range.  Small strings are embedded and never had a
buffer, so they are unchanged.

A heap String is freed by its size (STR_HEAP_SIZE = capa + terminator), so the
node carries the capacity of the allocation rather than the bytes in use, and
reserves the encoding's terminator length instead of a single NUL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A basket is rooted by whoever holds it, and nothing else: the port queue it
waits on, or -- while it is on no queue -- its holder's off_queue_baskets
list.  rb_ractor_sync grows that list and the basket a second ccan_list_node,
so it can sit on one or the other.

  send     alloc the basket, put it on the sender's list, then build the
           payload into it: rb_ractor_{move,copy}_courier_build publish the
           courier into the basket right after ZALLOC, so even a half-built
           one is rooted.  A move needs this -- it husks the sources as it
           captures them, so what the courier has collected loses its other
           root as the walk proceeds.
  enqueue  off the sender's list, onto the receiver's queue
  receive  off the queue, onto the receiver's list until the basket is freed

The publish is the handover of ownership too: when the capture raises, the
builder re-raises and leaves the courier where it is, and the basket frees it
exactly once as ractor_basket_new unwinds.  (The raise path was pointed out by
the Copilot review on #18392.)

Before this, a courier was the one payload ractor_basket_mark skipped: it
left the whole job to vm->ractor.move_courier_registry, which only a global
GC walks.  Between the send and the next global GC nothing rooted the
payload, and a shareable object reachable from the courier alone was
collected -- a dynamic Symbol loses its fstr, a frozen Array its elements,
and reading it crashes.  A shareable String survived only by being an
fstring, rooted elsewhere.  That is already broken in master for move; it
becomes reachable from an ordinary send once copy travels by courier.  Two
reproducers, both crashing unpatched master and fine on 4.0.2, now pass:

  ports = 5.times.map do |i|
    port = Ractor::Port.new
    r = Ractor.new(port, i) { |p, n| p.send([:"sym_#{n}"], move: true) }
    r.join
    port
  end
  5.times { GC.start }
  ports.each { |p| puts p.receive[0].inspect }

(the symbol has to be interpolated: a literal one is held by the iseq)  The
second sends Ractor.make_shareable([1, "child"]) the same way.

ractor_sync_mark walks the list next to the queues it already walks, so this
needs no new root pass, and it walks both in every collection.  "What a
courier holds is shareable, and only a global GC frees a shareable" does not
hold: pinned_roots_mark, which roots a shareable from its page bit, is
skipped once the process is back to a single Ractor
(rb_gc_single_objspace_p), and then an ordinary local GC frees one that
nothing else names.  A payload in flight is named by its basket and nothing
else.  The list needs no sync lock even so -- only its owner touches it,
unlike the queues a foreign sender writes.  The one caller that does skip it
is the single-objspace root pass, where ractor_mark_unshareable_parts has
just walked the same baskets through ractor_sync_mark.

This retires the registry, its lock, its fork re-initialization and
rb_ractor_move_courier_registry_mark.  Walking the registry in every
collection instead of the basket would not do: a receiver's local GC, which
materialize's own allocation can trigger, would then walk a courier another
Ractor is inside move_alloc_ref for -- observed as a crash.

move_alloc_node and move_alloc_ref grow by swapping in a fresh array rather
than realloc, for the same reason the nodes are initialized mark-safe: the
courier is a GC root while it is built, and a realloc can leave c->nodes
pointing at a block it has already freed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two costs on the courier path that the on-heap snapshot did not have.

The node and ref arrays were grown by doubling while the graph was captured,
and growing them is not a realloc: the courier is a GC root while it is being
built, so a realloc would leave the old pointer live across a window where it
may already have been freed.  Copying into a fresh array instead is correct
but not free, and it also made move_alloc_node/move_alloc_ref too big to
inline, which costs once per captured object.

Both walks that run before capture -- copy_courier_supported_p and
move_preflight -- already visit the whole graph, so let them count what
capture will allocate: one node per distinct unshareable object, one ref per
occurrence of a shareable one.  move_courier_reserve then sizes both arrays
once and the growth path never runs (verified: it does not fire for any of
the benchmark payloads).  It stays in place, out of line, in case a count
ever comes out short.

Materializing an array pushed its elements one at a time, through the
capacity check, although the length is known and the shell was allocated with
it.  Set the length once and write the slots.

Message cost, 20k messages per trial, 9 trials, median us per message,
measured against master:

  40B String   1.40 -> 1.04     4KB String  17.28 -> 8.66
  bare Object  1.15 -> 1.05     20-key Hash 12.35 -> 7.61
  nested       5.95 -> 5.48     100-elem Integer array 5.70 -> 6.46

The array of immediates is the one shape still behind master.  Copy walks the
graph twice there (the preflight, then the capture) where the on-heap snapshot
walked it once; dropping the preflight would mean capture bailing out on an
unsupported type and falling back to Marshal, which also gives up the sizing
above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The off-heap courier started out as the move path's own structure, so every
part of it was named move_*.  Copy now travels in the same structure and
through the same capture walk, with courier_build.copy selecting whether the
source is read or taken apart, which left the prefix claiming something
untrue: move_capture is what a copy goes through too.

Rename the shared machinery to courier_*: the struct and its build/
materialize/mark/free entry points, the node kinds, the capture and
materialize walks, and the array allocators.  The two builders become
rb_ractor_courier_build_copy and rb_ractor_courier_build_move so the pair
reads as one family.  What is still move-only keeps the move_ prefix --
move_preflight (copy has its own walk, copy_courier_supported_p) and
move_neutralize_source, which is exactly the step copy skips.  In re.c the
MatchData transfer helpers become rb_match_blob_*, matching the neighbouring
rb_backtrace_blob_* it sits beside in the same node.

No behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ractor_send_basket freed the basket only on the path that raises
Ractor::ClosedError.  With raise_on_error false the closed port leaves the
basket neither enqueued nor freed, and nothing else holds a pointer to it, so
it leaks.

The one caller that passes false is ractor_send_exit_tokens, so a monitor port
that is closed before the Ractor it watches exits leaks one basket per exit.
Counting live baskets over 400 rounds of

  mon = Ractor::Port.new
  r = Ractor.new { Ractor.receive }
  r.monitor(mon)
  mon.close
  r.send(1)
  r.join

gives 401 live before and 1 after; receiving the token instead of closing the
port was already flat, which is what the closed path should match.

Predates this branch -- the same code is in master.  It matters more here
because a basket now sits on its holder's off_queue_baskets while it is being
built: a caller that combined ractor_basket_new with raise_on_error false
would leave it on that list, marked in every collection, rather than only
leaking the allocation.  No caller does that today.

Found by the Copilot review on #18392.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
courier_alloc_node bumped c->count and then wrote the placeholder fields, so
for those few stores the mark walk over nodes[0, count) covered a slot that
had not been written yet.  courier_alloc_ref already had the other order and
says why; make the two match.

Not reachable as written: nothing between the bump and the stores allocates
or reaches a safepoint, so no collection can start there, and the growth
above it happens before the bump.  The order is what keeps that true without
having to check.

Found by the Copilot review on #18392.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rb_gc_finish_in_flight_gc is a one-line wrapper around rb_gc_impl_gc_rest,
and every other layer of the same call already says "gc rest": gc_rest in
gc/default/default.c, rb_gc_impl_gc_rest across the modular GC boundary.  The
wrapper exists only because ractor.c cannot call rb_gc_impl_* itself, so it
should not rename the operation on the way out.

"in flight" also means a message in transit elsewhere in the Ractor code
(rb_ractor_repin_in_flight, rb_ractor_mark_in_flight_for_single_objspace),
which is a second sense for the word in the subsystem where both GC cycles
and messages are the subject.

The comment above the definition described the one caller; the caller already
carries that, so drop it there and keep the reason where the call is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rb_ractor_mark_in_flight_for_single_objspace marked r->sync.legacy and
nothing else once the pin machinery went away, which is what
rb_ractor_mark_terminated_join_value next to it already does -- two functions
over the same field, and the surviving one names it correctly.  Call that one
from the single-objspace branch and drop the other.

It also pins, which the plain rb_gc_mark did not.  sync.legacy is a C-struct
slot with no compaction update hook anywhere, so pinning is the treatment the
field needs; the comment on rb_ractor_mark_terminated_join_value says so.

The name had stopped describing the function: "in flight" meant the sending
basket and the pinned copy snapshot it used to mark, and both are gone.  The
word is also the GC-cycle sense elsewhere in this code, which is why the
wrapper it sat next to is now rb_gc_rest.

Only the ractor.c translation unit used it -- ractor.c includes
ractor_sync.c -- so the ractor_core.h declaration goes too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The root scan marked r->sync.legacy for every zombie objspace, deliberately
"without depending on wrapper reachability".  That makes the value a GC root,
and a Ractor whose return value can reach the Ractor object roots itself:

  Ractor.new { Ractor.current }.join

Nothing else names either one, but the value is a root, the value reaches the
wrapper, so the wrapper never dies, so ractor_free never runs,
rb_gc_objspace_disown is never called, and the objspace stays in
zombie_objspaces for the life of the process.  The count never returning to
zero also holds rb_gc_single_objspace_p() false forever, which is the gate on
page_pool_reclaim, so the page pool stops returning arenas to the OS.  A
Ractor::Port, an array, an ivar -- any path from the value back to the wrapper
does it; 10000 rounds of the line above leave 10001 live Ractors.

The value is of use only to whoever can still call Ractor#value, and that
means holding the wrapper, so mark it as the wrapper's child instead.  A
self-referential pair is then ordinary garbage, and the transitive case (one
Ractor's value naming another) falls out of normal tracing rather than needing
a pass that iterates to a fixpoint.

Only during a global GC: it stops the world and marks every objspace together,
which is what lets the shareable wrapper reach an unshareable value.  A local
GC must not, and never did -- the zombie scan this replaces also ran only in a
global GC.

  300 rounds of the line above       10001 -> 1 live Ractors
  8000 rounds with a dropped message 2.3 GB -> 216 MB, and it now plateaus
                                     (287 MB at 20000, 288 MB at 40000)

Verified with RUBY_DEBUG=1: bootstraptest/test_ractor.rb, the GC and Ractor
unit tests, GC.verify_internal_consistency after holding 100 terminated
Ractors across GC.compact (their values, including self-references, all still
readable), and the same under GC.stress.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lvar_states only has 3 states, but an enum uses 4 bytes. This commit shrinks
lvar_states to use only 2 bits (0.25 bytes), which means we save 3.75 bytes
for every local variable in an iseq.

We can see that for this script:

    def foo
      a = 1
      b = 1
      c = 1
      d = 1
    end

    iseq = RubyVM::InstructionSequence.of(method(:foo))
    puts ObjectSpace.memsize_of(iseq)

It outputs 736 before this commit, and 721 now, which is 15 bytes saved
over the 4 local variables.
@pull pull Bot locked and limited conversation to collaborators Aug 20, 2026
@pull pull Bot added the ⤵️ pull label Aug 20, 2026
@pull
pull Bot merged commit 4012a96 into turkdevops:master Aug 20, 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.

2 participants