thread_sched_wait_events() returned thread_sched_wait_event when an
interrupt was already pending at entry, without looking at the fd, and
thread_io_wait() reported that as readiness: under RUBY_MN_THREADS=2,
IO#wait_readable on a pipe nobody wrote to returned truthy when a trap
arrived just before the wait.
A wakeup for a masked interrupt (a trap arriving inside a trap handler)
was reported as a timeout, so wait_readable(0.3) in a trap handler
returned nil at once.
Add thread_sched_wait_interrupted for both "stopped for an interrupt"
cases. thread_io_wait() now runs the whole wait under one tag, handles
the interrupt and waits again on the M:N scheduler until its deadline,
and treats a wakeup without an event before the deadline the same way.
At the deadline it probes the fd once. rb_thread_io_blocking_call()
retries func for every M:N outcome but unhandled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Same as the previous commit, for the select(2) path used by IO.select
(and by thread_io_wait without poll). When an interrupt was pending,
select was skipped with result 0, which wait_retryable takes as a
timeout, so IO.select([io], nil, nil, 0) could return nil for an io at
EOF. With another thread looping on Thread.pass, 137-153 of about 10^6
such calls returned nil; 0 with this change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
wait_for_single_fd_blocking_region skips ppoll when an interrupt is
already pending, but left result 0, which wait_retryable reads as a
timeout. With a zero timeout the deadline has passed, so it does not
retry: IO#wait_readable(0) returns nil even for a socket at EOF. This
happens about once per 10^4 probes whenever another thread is around
(timer interrupt).
Net::HTTP#begin_transport probes a kept-alive connection with
wait_readable(0) && eof?, so it reused a connection the server had
already closed and the next non-idempotent request raised EOFError
(spec/ruby/library/net-http/http/request_spec.rb:71 on trunk_asan).
Report the skip as EINTR so the probe polls again after the interrupt
is handled. The same pattern remains in do_select.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
errno was cleared only once before the retry loop. Under M:N threads, the
thread may move to another native thread while it waits for the fd, and
errno is per native thread. If func then succeeded without touching errno,
a stale ETIMEDOUT left by another thread raised IO::TimeoutError on an IO
without a timeout, and the data that func had read was lost.
8.times.map { Ractor.new {
Thread.new { r, = IO.pipe; r.timeout = 0.0001; 30000.times { r.read(1) rescue nil } }
r, w = IO.pipe
Thread.new { 10000.times { w << "x"; sleep 0.0001 } }
10000.times { r.readpartial(1) }
} }.each(&:join)
#=> Blocking operation timed out! (IO::TimeoutError)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Interrupts are checked at arbitrary points, including while a jump
(break/next/redo/return/raise/throw) is being propagated, and the payload
of that jump lives in ec->errinfo.
Handling an interrupt can run Ruby code: rb_signal_exec() runs a trap
handler and threadptr_interrupt_exec_exec() runs interrupt_exec tasks.
Neither preserves ec->errinfo, and any rescue or ensure clause that code
enters sets it to nil.
Thread.handle_interrupt checks for interrupts after its block returns but
before re-raising the jump that left the block. If a signal handler runs
in that window the throw data is replaced with nil and the VM dereferences
nil as a vm_throw_data:
THROW_DATA_CATCH_FRAME (obj=0x4) at vm_insnhelper.h:217
vm_exec_handle_exception (ec=..., errinfo=4) at vm.c:2880
vm_exec_loop / rb_vm_exec at vm.c:2829 / vm.c:2812
invoke_iseq_block_from_c / rb_yield / rb_ary_each
rb_postponed_job_flush() and rb_exec_event_hooks() already save and restore
ec->errinfo around the Ruby code they run; do the same for the rest of
rb_threadptr_execute_interrupts(). The paths that set errinfo on purpose
(rb_exc_raise, rb_threadptr_to_kill) jump out of the function and so do not
reach the restore.
Argument counts stay `int` or `long`. The int range checks in proc.c and compile.c now depend on SIZEOF_VALUE so that they are kept once rb_len_t widens.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The registered_globals list made every Ractor's GC scan every slot under
a shared lock. This commit splits it into per-Ractor lists.
We also maintain a global registry that references every Ractor that has
ever registered so that unregistering from a Ractor other than the one
that registered can look up the owning list.
When a Ractor is joined, the dead Ractor's slots are moved to the
joining Ractor's heap before the objspace merge happens.
This change tightens up the requirements for values stored in a
registered address.
Previously, Every Ractor's local GC scanned the shared list. So if a
slot held an unshareable object living in a different Ractor's heap,
that object was always kept alive regardless.
After this change, only the registering Ractor's GC scans its list. So if a
slot holds another Ractor's unshareable object, the owning Ractor's
local GC cannot see that reference, so it can collect the object while
the registered slot still points at it.
Storing such an object was already a Ractor containment violation. It
was just hidden by the shared scan.
[Feature #22277]
The size assumed that `fd_array` follows a 4-byte `fd_count`, but on x64 SOCKET is 8 bytes and `fd_array` starts at offset 8. Filling the last slot of a set grown to 128, 192, ... sockets wrote 4 bytes past the allocation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Now fibers can be collected by multiple ractors at once when those Ractors
are running local GCs. Fibers are created in the Ractor that owns them, so
if multiple Ractors have unmarked fibers during local GC sweeping, we can
now collect them concurrently instead of deferring their collection to a
postponed job. Each fiber pool is protected by its own native mutex.
In the future, maybe fiber pools will be ractor-local, but we have to
experiment with it first and see if it's enough of a performance win to
justify the change. We also want to minimize resource acquisition for each
Ractor as they're supposed to be lighweight.
Save `timeout` in a volatile pointer before `EC_EXEC_TAG` to avoid
GCC 15.3.0's `-Wclobbered` warning on aarch64-linux.
```
../src/thread.c: In function ‘thread_io_wait’:
../src/thread.c:4915:87: warning: argument ‘timeout’ might be clobbered by ‘longjmp’ or ‘vfork’ [-Wclobbered]
4915 | thread_io_wait(rb_thread_t *th, struct rb_io *io, int fd, int events, struct timeval *timeout)
| ~~~~~~~~~~~~~~~~^~~~~~~
```
eb38fb670b added it as a thread-safe counterpart to postponed_job, for
MJIT to enqueue work from its own thread [Bug #15316]. MJIT and RJIT are
gone and nothing enqueues anything any more: rb_workqueue_register() has
no caller, no declaration in any header, and hidden visibility, so an
extension cannot reach it either. What is left is rb_postponed_job_flush()
taking workqueue_lock on every call to steal a list that is always empty.
rb_postponed_job_flush() no longer needs the VM: the preregistered job
table it drains is a file-static. Drop the argument.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cont.c takes jit_cont_lock whenever an execution context is created or
freed. cont_free() does it from a Ractor reclaiming its own dead root
fiber, outside the VM lock, so fork() can land there. The child then
inherits the lock held by a thread that no longer exists, and aborts in
rb_jit_cont_finish():
[BUG] pthread_mutex_destroy: Device or resource busy (EBUSY)
rb_thread_atfork_internal() already reinitializes the other locks another
thread may have held at the fork; jit_cont_lock was missing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A Ractor id must stay unique for the life of the process, but the 32 bit
counter wraps after ~4e9 Ractors and comes back onto ids that are still in
use. That is not out of reach: a few thousand Ractor.new/s is days, not
years. The wrapped value is 0 too, which some callers read as "the main
Ractor".
There is no portable 64 bit atomic, so RUBY_ATOMIC_FETCH_ADD gives way to the
VM lock, which Ractor.new acquires a few frames later anyway. That lock is a
no-op until there is a second Ractor, which is exactly when the GVL alone
already serializes the counter, so ractor_next_id asserts the same condition
vm_insert_ractor0 does.
rb_serial_t, to match ec->ractor_id and bmethod's defined_ractor_id. Neither
struct grows: rb_ractor_pub had 4 bytes of padding after id.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
thread_sched_wait_events() decided "timed out" from
waiting_reason.data.result == 0, which only says that no event fired. A
thread woken by an interrupt lands there too, so an interrupted wait was
reported as thread_sched_wait_timeout. rb_thread_io_wait() then left
revents at 0 and IO#wait_readable returned nil even though the fd had
become readable.
Only RUBY_MN_THREADS=2 could hit it, because that is what puts the main
thread on the M:N io path; =1 and =-1 keep the 1:1 path, which retries
around EINTR and reports the fd correctly.
Hand an interrupted wait back to the caller's blocking path instead, and
charge the time the M:N wait already spent against the timeout so the
deadline stays the one the caller asked for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
create_ractor_alloc_thread() pointed cr->objspace at the child's objspace while
it allocated the child's Thread and root Fiber wrappers, so that they would be
made of objects the child owns. That slot is read without the VM lock by
threads that hold no GVL -- a Ractor's postmortem epilogue, which clears the
TLS EC before freeing the dead thread, thread_sched_reclaim, and any thread
that released the GVL inside rb_nogvl -- to decide which objspace to charge a
free to. One of them could therefore be sent to the child's objspace; a
stillborn child (IsolationError) is then disowned and freed by the orphan merge
job, and the free landed in released memory (ASAN heap-use-after-free at the
malloc accounting in ruby_xfree_sized, seen once in 209 CI runs).
The window allocates exactly two objects, so hand the objspace down to them
instead: rb_newobj_in_objspace() and the two TypedData entry points built on
it carry it to thread_alloc() and fiber_alloc_in(). cr->objspace never moves,
so no other thread can observe anything, and rb_gc_get_objspace() is unchanged.
GC suppression around the wrappers now names the child's objspace too, which is
the one the allocations go to; resolving it through the current Ractor would
suppress the creator's instead and let a cycle collect the half-built child.
The creation cover goes up before the first allocation rather than after the
last one, and the allocation-failure path parks the objspace in the same
VM-lock section, so a global GC can never find it neither covered nor a zombie.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`RUBY_MN_THREADS=1` only affects threads created after it is read: the
main thread keeps a native thread of its own and is woken through its
condvar, so handing control to the main thread costs an order of
magnitude more than handing it between any two other threads (6.4us
against 0.56us for a Queue round trip here).
With `RUBY_MN_THREADS=2` the running main thread is turned into an M:N
thread in place (thread_sched_main_to_shared), rather than spawning a
thread for the program body and leaving the main native thread idle:
* the process stack becomes the main thread's coroutine context
(coroutine_initialize_main), the way nt_start's own stack is a shared
thread's context;
* the main native thread joins the shared pool with a stack of its own
for its scheduling loop (nt_loop_co), which starts on the first
transfer into it -- the main thread's first park. From then on any
shared native thread may resume the main thread, and this one serves
any Ractor.
nt_start's shared loop is split out as nt_shared_loop so that both
entries share it. The main native thread never retires, its loop being
on a stack only it could free: ractor_sched_deq takes a can_retire flag
and native_thread_dedicated_dec always lets it rejoin.
The wait in rb_ractor_terminate_all had to give its native thread back
first, or exit hung with the Ractors it waits for having nothing to run
on; that is the commit before this one. The VM barrier waits natively
in the same way and has the same exposure, and is not touched here,
being no more reachable under `=2` than before.
Several places assumed the main thread owns the process's initial native
thread:
* native_thread_init_stack set the process stack's range for any thread
starting on that native thread, over the pool stack an M:N thread
already owns; the case is now keyed on th->sched.context.
* thread_sched_switch passed to_dead for a thread whose status is
THREAD_KILLED, which the main thread already is while
rb_ractor_terminate_all parks it. A thread ends only through
co_start's epilogue transfer.
* thread_sched_atfork left the forker's nt->running_th and nt->retiring
as the parent had them; retire eligibility is decided against the
process's main native thread, refreshed there.
* rb_thread_free_native_thread (RUBY_FREE_AT_EXIT) destroyed the native
thread hosting the main thread even when shared, along with the
altstack registered on the thread running it.
RUBY_FREE_AT_EXIT now leaves more behind: the main native thread and
its context and stack, since a thread is parked in its loop until the
process ends, and the main thread's own coroutine context, since the
free-at-exit path does not reach rb_threadptr_sched_free. Leak-checker
baselines move by that much.
The OS thread name is no longer set from the Ruby thread for M:N
threads. One shared native thread runs many Ruby threads over its life,
so the name described whichever one happened to start on it; under `=2`
that renamed the process itself, the thread group leader's comm being
what ps and pkill show. Thread#name= was already skipped for M:N
threads for the same reason.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Windows carried a second, much weaker scheduler: a single Win32 mutex as
the GVL, vm->clock for preemption, its own VM barrier over
vm->ractor.sync.barrier_*, its own Ractor wait/wakeup on a per-Ractor
condvar, no ready queue and no time slice. Keeping two made the rest of
the interpreter branch on which one was compiled in, and left Ractors on
Windows without real scheduling.
Move the scheduler out of thread_pthread.c into thread_sched.c and let
both platforms use it. thread.c now includes thread_sched.c, which
declares the scheduler <-> platform contract, includes THREAD_IMPL_SRC,
and then builds the scheduler on the primitives that file provides.
thread_pthread.c keeps only those primitives: synchronization, thread
local storage, native thread creation and stacks, the communication pipe,
the timer thread backend, the fork lock and the event hooks.
The data structures the scheduler works on move to a new thread_sched.h,
included from both platform headers, so struct rb_thread_sched,
struct rb_native_thread and struct rb_thread_sched_item are now shared.
thread_pthread_mn.c becomes thread_sched_mn.c: it is the M:N scheduler,
not a pthread detail. Most of it -- the coroutine threads, the native
thread stack pool, the timer wheel and the fd -> waiters map -- is
platform independent; only the readiness backend is not, and that section
is now marked as the seam where an epoll/kqueue split (or an IOCP backend,
which is what Windows would need for M:N) would go.
What Windows had to add is small: native_thread_interrupt(),
native_thread_default_max_cpu(), timer_thread_polling(),
timer_thread_wakeup_force(), RB_NATIVE_COND_MONOTONIC_P() and
RB_NATIVE_MUTEX_TRYLOCK_DETECTS_SELF() -- the last because a
CRITICAL_SECTION is recursive, so trylock cannot back a "somebody holds
this" assertion. It sets USE_MN_THREADS to 0 and supplies the stubs
thread_sched_mn.c provides elsewhere, so every thread there is dedicated,
as it already was.
With one scheduler the platform branches go away: all 16 uses of
RUBY_THREAD_PTHREAD_H / RUBY_THREAD_WIN32_H are removed from vm_core.h,
ractor.c, ractor_core.h, ractor_sync.c, vm_sync.c, vm.c and thread.c.
ractor_check_blocking() went with them -- it only ever fed the win32
scheduler's vm->ractor.blocking_cnt -- and so did USE_VM_CLOCK, now that
the common time slice drives preemption on Windows too.
Verified on both platforms. Linux: 11 rounds of make test-all (36236
tests) and btest (2066 tests) with no failures, plus 474 runs of a
scheduler and Ractor stress soak. Windows: no test fails that does not
also fail on the same tree without this change, btest passes, and two
40-minute soaks of 438 and 460 runs are clean. Ractors now run in
parallel there -- 3.6x on four Ractors, where before they could not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A dedicated native thread used to sleep in two stages: native_cond_sleep()
parked it on nt->cond.intr inside a blocking region, and on wakeup it went
back to the scheduler to wait for its running turn on nt->cond.readyq. The
thread scheduler's turn wait takes an absolute deadline since d32793fe8e, so
the sleep can happen right there: native_sleep() now parks every dedicated
thread in thread_sched_to_waiting_until_wakeup(), with a deadline when it
has one, and an M:N thread keeps using the timer wheel. The second condvar,
its ubf and native_cond_sleep() go away, and with them the union/struct
dance in struct rb_native_thread for platforms whose condvars remember
their mutex: the one condvar left always pairs with sched->lock_.
Two things keep the handoff as fast as the old path:
The turn wait trusts ETIMEDOUT to say the deadline passed, instead of
reading the clock on every wakeup; on clocksources where clock_gettime is a
real syscall that read was the single biggest cost of a timed wakeup.
ubf_waiting() wakes the target even when the running turn is taken, knowing
it re-parks at once. The old two-stage sleep did the same thing by its
shape, and it is worth doing on purpose: the woken thread's futex wakeup
runs on another core in parallel with the running thread, so by the time
the turn is handed over it is off the handoff path. Measured on a
queue-with-timeout ping-pong, the handoff costs what the two-stage sleep
cost; without the early wake it was three times slower.
The ubf must also leave alone a thread whose deadline already put it back
in the ready queue: waking it a second time would double-enqueue it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The timer thread delivers expiry and fd wakeups in batches: it collects
{thread, serial} pairs under timer_th.waiting_lock, releases the lock (the
scheduler lock a wakeup takes must not nest inside it), and then wakes each
thread. Unlinking an entry is what releases its thread, so from that moment
the thread can be woken by somebody else, exit and be freed while the batch
still holds a bare pointer to it. Waking it then dereferences a freed
thread, and crashes when the Ractor holding it was torn down:
[BUG] Segmentation fault at 0x0000000000000138
timer_thread_check_timeout -> timer_thread_wakeup_thread
-> rb_native_mutex_lock(&TH_SCHED(th)->lock_) # th->ractor is NULL
The serial captured in the batch does not help, and can even match again: a
thread struct reused from the freed one starts counting event serials from
zero, so its first timed wait matches serial 1 held in a stale batch entry,
and the timer wakes a thread whose wheel entry is still armed:
Assertion Failed: thread_sched_wait_running_turn:
th->sched.waiting_reason.flags == thread_sched_waiting_none
Both reproduce on a loop of Ractors that die while one of their threads sits
in a timed receive, in ~15 rounds of 400. They only became reachable when
e1bce29aac cut a dying Ractor's teardown from one second to well under a
millisecond: the batch window used to be dwarfed by the teardown time.
Holding waiting_lock across the wakes would close the window but deadlock:
arming a timer takes the scheduler lock and then waiting_lock, so a wake
taking the scheduler lock under waiting_lock inverts the order. So mark the
threads instead. When the timer thread publishes a batch it sets
in_wake_batch on each thread, under waiting_lock; when it has woken them all
it clears the marks and broadcasts. A dying thread checks its own mark and
waits on the cond until it clears, fencing twice:
Once when it leaves the scheduler for good (thread_sched_to_dead, and the
coroutine epilogue in thread_start_func_2). This is the fence that matters
for a dying Ractor: a stale wakeup reaches the Ractor through TH_SCHED(),
and the rb_ractor_t can be collected as soon as the Ractor is unlinked, so
the wait must happen while it is still alive. Nothing re-arms the thread
after this point, so no later batch can name it.
Once more when the rb_thread_t itself is freed (rb_threadptr_sched_free), as
a backstop for frees that do not come through a thread's own exit.
The wait is bounded by one batch of at most 16 wakeups, and the fence takes
only waiting_lock, which the timer thread never holds while it wakes, so the
two cannot deadlock.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Ractor whose block ends while one of its own threads is still running took
a full second to terminate: rb_thread_terminate_all() sleeps for a second per
round and relies on the last sub-thread to wake it, but that wakeup was sent
only when the Ractor's main thread already had THREAD_KILLED, which
thread_start_func_2 sets after rb_thread_terminate_all() returns. For the
main Ractor rb_ec_cleanup() sets it beforehand, which is why only Ractors
were affected.
Setting THREAD_KILLED earlier is not an option here: thread_sched_switch()
reads it as to_dead, promising the M:N scheduler that the coroutine is never
resumed, and this thread still parks and resumes inside the wait. So mark
the wait itself instead: rb_thread_terminate_all() sets threads.terminating
before it waits, and the exiting thread wakes `main` on that. Nothing clears
it afterwards, because no thread of that Ractor runs again; the Ractor that
survives a fork gets it reset along with the rest of the thread set.
Likewise for method coverage: walking me_set and cme2counter and hashing
the array keys on every peek dominated the cost. Maintain
{ path => { key => me or [me, ...] } } incrementally (both hashes are
append-only) and fill in the counts from cme2counter on each peek.
Extract rb_coverage_method_data_of() from thread.c for that.
10k methods: 24 ms -> 0.7 ms per peek. rack's test suite with SimpleCov
per-test tracking (lines+branches+methods): 30 s -> 8.4 s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The branch coverage structure was keyed by the address of the AST node,
which is needed to let multiple compilations of the same node (e.g., an
ensure clause) share one entry [Bug #16967]. However, ASTs are freed
after compilation, so when code is eval'ed against the same path, a
reused node address could merge unrelated branches into one entry,
nondeterministically.
Key the structure by [source_hash, node_id, first_lineno] instead.
This is deterministic: different code eval'ed at the same path always
gets separate entries, and re-evaluating the very same code at the same
path and line accumulates the counters, like line coverage does.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Since "Ractor: the dying thread collects its own objspace post-mortem"
(e768a87a17), a dying Ractor thread runs rb_ractor_postmortem_free()
after thread_sched_to_dead() (its GVL is gone) and after
rb_ractor_living_threads_remove() -> vm_remove_ractor(), which unlinks
the Ractor, decrements vm->ractor.cnt and signals the terminate waiter.
From that point nothing holds the main Ractor back, but the epilogue
still frees through VM-global state: rb_fiber_free_body() -> cont_free()
takes jit_cont_lock and returns the stack to the shared fiber pool, and
both frees update the main objspace's malloc accounting.
The coroutine epilogue has the same hazard and is covered:
coroutine_thread_terminated() bumps vm->ractor.sched.winding_cnt and
ruby_vm_destruct() spins in rb_thread_sched_wait_winding() until the
reclaim is done. The non-coroutine path -- the ordinary
Ractor.new-spawned dedicated thread -- was not counted, so a main
Ractor that exits while the dying thread is still in the epilogue tears
that state down underneath it.
Count the epilogue in winding_cnt as well. The increment happens before
the Ractor stops being counted, so there is no window where the VM sees
neither the Ractor nor the epilogue. vm->ractor.sched exists only on
the pthread backend, so the count gets the same shape its waiter has:
real work in thread_pthread.c, a no-op in thread_win32.c and
thread_none.c, where rb_thread_sched_wait_winding never waits either.
Reproduced deterministically by delaying the epilogue (50ms) and the
process exit (400ms) on top of e768a87a17: 6/6 runs of
`Ractor.new{ 1 }.value` died with
[BUG] pthread_mutex_lock: Invalid argument (EINVAL)
rb_native_mutex_lock (thread_pthread.c:128)
jit_cont_free (cont.c:1419)
cont_free (cont.c:1185)
rb_ractor_postmortem_free (ractor.c:683)
thread_start_func_2 (thread.c:851)
i.e. the epilogue locking jit_cont_lock after rb_jit_cont_finish()
destroyed it. With this change the same harness is clean 6/6, and
test/ruby/test_ractor.rb, test/objspace/test_ractor.rb, test_thread.rb
and the strscan/digest/io-wait/io-console Ractor tests pass. Without
the injected delay the window is never entered on x86_64 (0 hits in 15
runs of those test files, instrumented), which fits the failures being
confined to the slow emulated runners.
This is the most likely cause of the heap corruption CI has been
reporting since e768a87a17 landed (2026-08-10 18:33 UTC): every failing
case is a child process that runs one Ractor and exits.
Error:
TestIOWaitInRactor#test_ractor [test/io/wait/test_ractor.rb:9]:
pid 68552 exit 0
| free(): corrupted unsorted chunks
1. [2/2] Assertion for "stderr"
| <[]> expected but was
| <["free(): corrupted unsorted chunks"]>.
(also TestStringScannerRactor#test_ractor, TestDigestRactor::*,
TestIOConsoleInRactor#test_ractor -- 16 of the 63 master runs since
2026-08-10, none in the 87 runs before it)
TestObjSpaceRactor#test_copy_finalizer [test/objspace/test_ractor.rb:57]:
==96541==ERROR: AddressSanitizer: heap-use-after-free on address 0x520000000088
WRITE of size 8 at 0x520000000088 thread T5
CI: https://github.com/ruby/ruby/actions/runs/31886577248/job/95016642538
CI: https://github.com/ruby/ruby/actions/runs/31427322788 (ASAN, x86_64)
Co-Authored-By: Claude Opus 5 (1M context) <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>
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>
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>
timer_thread_register_waiting() asks the kernel whether the fd is ready
before registering it, so that an already-ready fd costs one poll(2)
instead of a registration, a sleep, a detection and a wakeup. That is
what makes IO#wait_readable cheap.
rb_thread_io_blocking_call() reaches it only after the operation itself
returned EAGAIN, so for that caller the question has just been answered:
the poll cannot say anything but "not ready", and it runs on every wait.
Pass the caller's knowledge down. thread_sched_waiting_io_force, which
already suppresses the check, was so far only used for the timer thread's
own pipe.
Reaching the retry path proves the fd was nonblocking at that moment --
a blocking descriptor would have returned data rather than EAGAIN -- so
this needs no assumption about O_NONBLOCK, which nothing guarantees. If
data arrives between the EAGAIN and the registration, epoll is
level-triggered and reports it immediately.
A microbenchmark of 3000 reads that each have to wait goes from 2999
poll(2) calls to none. The saving is real but small: at ~39k waits/s it
is on the order of 1-2% of the server's CPU, which is below the run to
run variation of the 10k-connection benchmark, so no throughput or
latency difference is claimed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two threads waiting on one fd -- a reader and a writer on the same socket
is the ordinary shape -- made the second one see a lie.
timer_thread_register_waiting() returned false both when the fd was
already ready and when it could not be registered at all (epoll_ctl
giving EEXIST because another thread holds the registration, or EBADF /
EPERM). thread_sched_wait_events() turned "could not register" into
timedout = false, thread_io_wait_events() turned that into "the event
happened", and thread_io_wait() then fabricated fds[0].revents = events.
So IO#wait_readable returned readable for an fd nothing had written to,
and a blocking read behind another waiter spun at 100% CPU
(read -> EAGAIN -> poll -> epoll_ctl -> EEXIST -> retry) until the fd
really became ready.
Distinguish the three outcomes by type rather than packing them into a
bool, at each of the three layers, so that "not registered" can no longer
arrive as readiness. A caller that gets it falls back to its ordinary
blocking path.
Then stop producing that case at all: keep the waiters of an fd in a
table indexed by the fd (POSIX hands out the lowest free descriptor, so
fds stay dense) and arm the backend with the union of what they ask for,
so a second waiter extends the registration instead of colliding with it.
Entries live in fixed chunks because growing the table must not move a
live list head.
Events now carry the fd and the generation of its table entry instead of
a thread pointer. A stale event -- one epoll_wait had already queued
when the fd was disarmed, possibly closed and reopened since -- resolves
to a table lookup under the lock and is dropped on the generation check,
where before it dereferenced a possibly freed rb_thread_t. Managing the
union also means kqueue deletes the filters it registered rather than
only the one that fired, so a registration can no longer be left behind.
Waiters are unlinked and their results recorded while waiting_lock is
held, but woken after releasing it: the scheduler lock is always taken
before waiting_lock, and capturing event_serial under the lock is what
keeps a thread that has since moved on from being woken, as the timeout
path already does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ractor#join waits on a monitor port, and the exit token was sent from
ractor_notify_exit as the Ractor's block ends. Ractor#value then has to wait
for ractor_terminated on its own -- vm_remove_ractor retires the objspace the
joiner is about to absorb -- and it does so by spinning on rb_thread_schedule().
Now that the dying thread collects its own objspace post-mortem, that spin
covers a whole local GC: the joiner burns a core for as long as the child's
final collection takes, which grows with the child's heap.
Split the notification: the value is published at atexit as before, but the
tokens go out after the collection, from rb_ractor_postmortem() so that the
order of the two steps is stated in one place. What the joiner spins over is
then only the scheduler handoff and vm_remove_ractor, which is constant.
The send cannot go through ractor_basket_new there: thread_cleanup_func has
already dropped the EC's VM stack, and EC_PUSH_TAG reads ec->cfp when ZJIT is
built in. A token is a static Symbol, so ractor_basket_new_ref builds its
basket without a tag or any payload preparation.
Post-join spin, by the number of objects the child allocated:
0: 13us -> 3us
10000: 961us -> 3us
100000: 5211us -> 199us (the remainder is the absorb itself)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every Ractor spawn eagerly built three IO objects (its $stdin, $stdout
and $stderr) under the VM lock in the setup section, though most
Ractors never touch stdio. Build them in rb_ractor_stdin and friends
on first access instead: the accessor runs in the owning Ractor, so
the objects still land in its own objspace. The slots stay 0 until
then -- marking 0 is a no-op, and the setters are unchanged.
A Ractor that never uses stdio now leaves nothing for its final
collection to finalize, which composes with the previous two commits:
the death of Ractor.new { :ok } releases every page it allocated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The retire GC at notify_exit could not reclaim the terminating
thread's scaffolding -- the Thread and Fiber wrappers, the ThreadGroup,
the Ractor-local stdio, whatever the machine stack still references --
because at that point they were all still rooted. Those ~11 objects
kept ~2 heap pages alive per dead Ractor, merged into the inheritor to
wait for its next major GC, so both a spawn-and-join loop and a
fire-and-forget loop grew the heap by two pages per Ractor with
nothing reclaiming them.
Instead, run the final collection at the tail of thread_start_func_2,
just before the scheduler handoff. The thread still holds the GVL, so
the collection runs lock-free like any local GC (a concurrent global
GC waits for its safepoint), and vm_remove_ractor keeps its light
lock-only section. r->postmortem makes rb_ractor_mark_local_roots
root only what must survive the death -- the join value
(r->sync.legacy) and the rb_gc_register_mark_object pins -- and the
zombie_objspaces entry recorded later measures exactly the pages the
joiner will inherit. While the Ractor is still in vm->ractor.set,
rb_vm_mark and ractor_mark keep the Ractor object itself and its
default port alive as before; everything they mark is foreign to or
pinned by this collection.
Two of the swept wrappers are the dying thread's own: its Thread (the
rest of the collection resolves GET_EC through ec->thread_ptr) and its
root Fiber (whose struct embeds that ec). thread_free and fiber_free
recognize "the struct I am standing on" (ec == the current ec), keep
the struct, and leave an in-band mark (th->self = 0; cont.self = 0,
which already means "no wrapper" on the never-wrapped path). Found as
a 1/30 heap corruption, pinned by ASAN. The collecting thread
captures those two pointers right after the collection, while its GVL
still excludes any global GC, and frees exactly the captured structs
at its very last step -- after vm_remove_ractor unlinks the Ractor,
anything it did not sweep may already be freed by a concurrent global
GC, so the epilogue reads no shared memory at all. Those frees would
resolve their objspace through the TLS ec -- which sits inside the
fiber struct being freed and leads to the retired Ractor -- so
rb_ractor_postmortem_free severs the TLS ec first (the MN epilogue
already had) and they fall back to the main objspace.
While a thread-event hook is registered the postmortem flag stays off
and the collection keeps the ordinary retire roots: callbacks may
retain any Ractor's Thread object (rb_internal_thread_event_hook_t),
and such references are invisible to the reduced roots. The check
takes the hook rwlock, so it happens-after any completed registration;
a hook registered after it gets no event from this thread --
rb_thread_execute_hooks skips a thread whose wrapper is gone
(th->self == 0) -- so it never receives what the collection swept.
On platforms without hook support (win32, none) the check reports
false.
The collection runs after thread_cleanup_func, so the dying thread's
machine stack is already torn down: the root scan skips its machine
context (rb_gc_impl_during_postmortem_p), which holds nothing the
reduced roots do not already cover and faults when scanned on some
platforms (windows-arm).
Co-Authored-By: Claude Fable 5 <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>
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>
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>
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>
Since `prc.refined(*ms).refined(*ns)` is behaviorally equivalent to
`prc.refined(*ms, *ns)`, the copy of the block is deferred until
the first call and the memo is shared by these behaviorally
equivalent Procs. A Proc that is never called is no longer copied
at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An alias resolves to a different method entry, so it is filtered out when
the result is aggregated anyway. Avoid retaining it in me_set.
Co-Authored-By: Erik Berlin <sferik@gmail.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Method coverage rebuilt its result by walking the heap for method
entries, so a shadowed or removed method that was never called could be
reclaimed by GC and vanish from the result depending on GC timing.
Record each defined method entry in me_set as it is defined, and compute
the result from me_set and cme2counter instead of walking the heap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>