Skip to content

[pull] master from ruby:master - #1311

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

[pull] master from ruby:master#1311
pull[bot] merged 20 commits into
turkdevops:masterfrom
ruby:master

Conversation

@pull

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

luke-gruber and others added 20 commits August 14, 2026 10:57
This patch allows the OS to free page and arena memory. This only happens in single objspace mode at the end of a major GC. There is still an empty_pages list per objspace, and this stays resident as before. Only pages previously munmapped pre-rlgc are madvised(DONTNEED)'d. We call these discarded pages. Once an arena (2MB) has all of its 32 pages (each 64K) discarded, we munmap the arena. At least 1 arena is kept mapped at all times.

The following fields are added to GC.stat for the default GC:

  page_pool_arenas // number of mapped arenas
  page_pool_arenas_freed // number of freed (unmapped) arenas
  page_pool_total_pages // total pages in all arenas (arena_count * 32 currently)
  page_pool_discarded_pages // number of madvise(DONTNEED) pages (they can still be mapped)

Future work:

* Reduce the amount of empty pages per objspace or get rid of objspace->empty_pages altogether and keep a certain amount of resident pages in the pool for all objspaces.

* Allow reclaiming memory when there are multiple Ractors.
ractor_sched_enq only signalled ractor.sched.cond.  The signal reaches
a parked shared native thread, and a running one revisits the queue in
ractor_sched_deq (under the same lock) before it can wait, so as long
as any shared native thread exists the entry is served.  With none --
each one dedicated to a blocking region or retired -- the signal is
lost, and if the timer thread is also in its untimed sleep
(timeslice_wait_inf), nothing serves the enqueued ractor: its runnable
thread never runs.  native_thread_check_and_create_shared, which would
repair the shortage, runs on the timer thread's timeout branch, which
is never reached again.

Wake the timer thread under the same lock its timeout decision is made
under, from the two transitions that can strand an entry: an enqueue
that finds snt_cnt == 0, and the blocking-region entry that dedicates
the last shared native thread away while the queue is non-empty.
Both are off the ordinary path, and timer_thread_wakeup_locked writes
to the communication pipe only when the timer thread sleeps untimed,
so the common case costs nothing.  [Bug #21504]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
native_thread_create_shared called thread_sched_to_ready before trying
to widen the shared pool, so when pthread_create failed the creator's
failure path (marking the thread killed, removing it from the living
set, rb_ractor_cancel_creation) ran against a thread that an already
parked shared native thread could pick up and run to its end
concurrently -- including rb_ractor_postmortem_free of the very
rb_thread_t the creator is still writing to.  Observed under
RLIMIT_NPROC exhaustion as
  ractor.c: rb_ractor_cancel_creation: r->status_ == ractor_blocking
  ractor.c: ractor_check_blocking: cr->threads.cnt >= cr->threads.blocking_cnt + 1
i.e. the living-set removal ran twice.

Widen the pool first and make the thread ready only after that
succeeded; the failure path then handles a thread that never became
runnable, which is what it assumes.  A never-started coroutine's stack
and context are already freed by rb_threadptr_sched_free.

Also roll snt_cnt back when native_thread_create0 fails, and free the
allocated rb_native_thread.  The count stayed raised before, so
native_thread_check_and_create_shared concluded forever that the pool
was wide enough and never tried again: permanent starvation instead of
a retry on the next timer tick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
waitfd_to_waiting_flag maps RB_WAITFD_PRI to no thread_sched_waiting_*
event, so the M:N scheduler registered nothing for it yet parked the
thread: a timeout-less IO#wait(IO::PRIORITY) on an M:N thread slept
forever (a VM_ASSERT in thread_io_wait_events on debug builds).
Exclude it from thread_io_mn_schedulable; poll(2) on the blocking path
handles POLLPRI.

The test lives in test/ruby/test_ractor.rb rather than bootstraptest
because it needs the socket extension, which btest runs without.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The M:N wait set th->status to THREAD_STOPPED_FOREVER around the park
and to THREAD_RUNNABLE after it, unconditionally.  A dedicated thread's
native_cond_sleep leaves status alone: sleep_hrtime sets THREAD_STOPPED,
re-sleeps while it stays so (SLEEP_SPURIOUS_CHECK), and only a waker
sets THREAD_RUNNABLE.  Rewriting it to THREAD_RUNNABLE made every
spurious wakeup end the sleep: an interrupt deferred by
Thread.handle_interrupt cut sleep(n) short on an M:N thread.

Touch status only when the thread entered as THREAD_RUNNABLE (an io
wait), which shows "sleep" while parked as a dedicated thread's
blocking region does; a sleeper's status now belongs to its caller on
both kinds of native thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
rb_nogvl resolved RUBY_UBF_IO / RUBY_UBF_PROCESS to ubf_select before
looking at RB_NOGVL_UBF_ASYNC_SAFE, so a caller combining the two had
ubf_select invoked from the signal handler path
(rb_thread_wakeup_timer_thread), where its ubf_list_lock and ractor
scheduler lock can self-deadlock.  No in-tree caller does this, but the
API accepted it.  Honour the flag only for a caller-provided ubf; the
sentinels fall back to the ordinary deferred invocation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In the forked child:

- Do not pthread_mutex_destroy timer_th.waiting_lock.  glibc returns
  EBUSY (which rb_native_mutex_destroy turns into rb_bug_errno) for a
  mutex that another ractor's M:N thread held at the fork moment.
  Reinitializing it, as the code already does, is the whole story.

- Reinitialize nt_machine_stack_lock, whose holder at the fork moment
  does not exist in the child; the next machine-stack alloc or free
  would deadlock on it.

- Forget timer_th.event_fd on the kqueue backend instead of leaving the
  stale number around.  A kqueue is not inherited across fork, so it
  must not be closed either: the number may name a reused fd by then.

- Reset dnt_cnt in thread_sched_atfork next to snt_cnt: only the
  forking thread's native thread survives.  Its consumer is only a
  debug log, but the log lied after fork.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A retiring shared thread -- ractor_sched_deq giving itself back, or a
thread rejoining a full pool (nt->retiring) -- just returned from
nt_start, leaking its rb_native_thread: the struct, the coroutine
context, the condvars and the sigaltstack.  This was the old "how to
free nt and nt->altstack?" TODO, and retiring made it recurring: every
pool shrink leaked one.

Both exits drop the nt from the counts before leaving the loop, so
nothing can reference it; the exiting thread frees it itself, disarming
its sigaltstack registration first so a late signal cannot run on the
freed block.

Verified with SNT_IDLE_RETIRE=0 (retire on the first idle wakeup): 168
retire/free pairs in a bursty ractor workload and a clean btest run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
None of these is reachable with default settings; each is a mine for a
configuration or platform that steps on it.

- timer_thread_set_timeout: a sleeper's deadline further away than
  INT_MAX ms truncated to a negative timeout, i.e. an untimed
  epoll_wait, and the sleeper was never woken.  Clamp to INT_MAX.

- nt_thread_stack_size: the size sum went through int, so stack sizes
  totalling over 2GB went negative (stack_count 0, and nt_alloc_stack
  retries mapping 512MB chunks forever).  Compute in size_t.  Also
  page-align the VM stack and machine stack areas: the configured
  sizes align only to 4KB, and on 16KB/64KB-page systems the guard
  page and the MAP_FIXED machine stack must land on page boundaries.

- nt_alloc_stack: an mprotect/mmap failure had already consumed the
  slot (uninitialized_stack_count--); restore it.

- nt_free_stack: prev_free_chunk == NULL cannot double as the "not on
  the free list" test -- the list's tail also has it NULL, and
  re-pushing the tail self-cycled the list.  Track membership in its
  own flag.

- ruby_stack_overflowed_p: the __APPLE__ branch runs when th == NULL
  yet read th->nt->thread_id; ask pthread_self() instead.

- rb_thread_create_timer_thread: rb_bug on pthread_create failure
  instead of silently running without a timer thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- verify_waiting_list compared w->data.timeout with itself, so the
  sorted-order assert was vacuously true and the waiting list's O(n)
  insert has been unverified even on debug builds.

- timer_thread_register_waiting repeated the rel && *rel > 0 check that
  the branch right above it had just done.

- Remove THREAD_BLOCKING_YIELD: no user, and it calls
  thread_sched_to_waiting with a signature that predates the
  yield_immediately parameter, so any new user fails to compile.

- native_thread_check_and_create_shared took ractor.sched.lock through
  the raw mutex API, bypassing the lock-owner bookkeeping every other
  taker goes through (ractor_sched_lock).

- The stack chunk layout comment claimed "0th page is Redzone"; the
  header has occupied the head pages since 407dd02.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
thread_io_wait() sent every fd wait with a timeout to the blocking
region (ppoll on a dedicated native thread), even under M:N.  A
pattern like IO#wait_readable(0.5) -- WEBrick's keepalive loop, or
any flag-polling wait -- therefore cost a native thread handoff per
call: ~0.7ms to reacquire the running slot after each ppoll under
load, serializing servers to a fraction of the 1:1 throughput.

Register such waits with the timer thread instead, as fd+timeout
combined waiting.  That registration path had no caller until now,
and its expiry path timer_thread_deq_wakeup() left the waiter on its
fd's waiter list with the fd still armed; unregister it there the
same way the cancel path does.

A zero timeout keeps using ppoll: it is a plain probe and needs no
parking.

WEBrick c=256: MN 2.3k -> 6.0k rps (hello, now above 1:1's 5.7k),
0.37k -> 1.5k rps (50 memcached accesses per request).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…is enabled

It still needs to take the VM lock occasionally to protect certain global data structures during
marking but it doesn't have to take the lock the entire time. The problem with a long-held VM lock
is that it increases the chance that other Ractors will block on it (and increases the amount of time
they will be blocked).

Note: It still takes the VM lock around the sweep of every page but I'll work on that in a separate PR.
```
/github/workspace/src/ext/digest/blake3/blake3_dispatch.c:108:5: error: '_Atomic' is a C11 extension [-Werror,-Wc11-extensions]
      108 |     ATOMIC_INT g_cpu_features = UNDEFINED;
          |     ^
/github/workspace/src/ext/digest/blake3/blake3_dispatch.c:34:20: note: expanded from macro 'ATOMIC_INT'
       34 | #define ATOMIC_INT _Atomic int
          |                    ^
1 error generated.
```

ruby/digest@505c2d159f
The distributed files do not include `extconf.h` generated by
`create_header`.

ruby/digest@d3bcdccba3
Mainly, to dodge warnings on i686:

    ../src/zjit.h: In function ‘ZJIT_STACK_MAP_BASE_PTR_STACK_SIZE’:
    ../src/zjit.h:83:18: warning: right shift count >= width of type [-Wshift-count-overflow]
       83 |     return entry >> ZJIT_STACK_MAP_BASE_PTR_SIZE_SHIFT;
          |                  ^~
Since frozen ar_table can occupy smaller slots,
shrinking the table on freeze potentially allow the
Hash to be demoted to an even smaller slot.
page_pool_reclaim() advises (madvise) cold page bodies only when the OS
page size is smaller than HEAP_PAGE_SIZE, because the first OS page of a
body holds the in-body freelist link and the arena tag and must be
spared.  On systems whose page size is >= HEAP_PAGE_SIZE (64KiB pages on
aarch64, e.g. the oci-aarch64 CI machine) Step A therefore never
increments page_pool.advised_count -- but Step B still subtracted
PAGE_POOL_ARENA_BODIES (32) from it for every munmap'd arena, so the
counter went negative.

advised_count is an int reported through GC.stat as
page_pool_discarded_pages with SIZET2NUM(), so a negative value is
converted to a huge unsigned value and GC.stat has to allocate a Bignum
for it.  That allocation happens after GC.stat has already read the slot
counters, so any pair of "read stats, then read them again another way"
observes one extra live object, which is exactly what these two tests do
(both fail by exactly 1 in the same direction).

Skip the Step B adjustment when the platform cannot advise at all.  When
it can, every body of an unmapped arena is on its cold freelist and was
just advised by Step A, so subtracting 32 stays exact.

Verified by simulating a 64KiB page size (forcing
page_pool.os_page_size = HEAP_PAGE_SIZE) on x86_64: before the fix
GC.stat[:page_pool_discarded_pages] reads 18446744073709551456 after
arenas are unmapped and the test_stat comparison is off by 1; after the
fix it reads 0 and test/ruby/test_gc.rb passes both with and without the
simulation.

Error:
  1) Failure:
  TestGc#test_stat_heap_constraints [test/ruby/test_gc.rb:298]:
  <357949> expected but was
  <357950>.

  2) Failure:
  TestGc#test_stat [test/ruby/test_gc.rb:178]:
  <356712> expected but was
  <356711>.

  (5 consecutive master@oci-aarch64 runs since 9ebb977)

CI: https://ci.rvm.jp/results/master@oci-aarch64/6449795
Log: https://ci.rvm.jp/logfiles/brlog.master.20260814-161514

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pull pull Bot locked and limited conversation to collaborators Aug 15, 2026
@pull pull Bot added the ⤵️ pull label Aug 15, 2026
@pull
pull Bot merged commit 6cc5c92 into turkdevops:master Aug 15, 2026
1 of 3 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.

5 participants