From 6b71dd210feaf67d996e8dbbc9b122495cd33918 Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Sun, 16 Aug 2026 02:39:28 +0000 Subject: [PATCH 1/5] btest/test_thread.rb: make the NPROC test CPU-count independent The test added by 6f176c8925 warmed two ractors before dropping RLIMIT_NPROC, assuming every subsequent Ractor.new would attempt to spawn a shared native thread and raise ThreadError. However, native_thread_check_and_create_shared widens the pool only while snt_cnt < max_cpu, so on 2-vCPU CI machines (rubyci ubuntu-no-yjit, ubuntu2204) the two warm ractors already saturated the pool: pthread_create was never attempted, nothing raised, and the test failed with "0 of 20 raised". Warm a single ractor instead. snt_cnt (1) then stays below max_cpu on any host with two or more CPUs, so every attempt deterministically reaches the failing pthread_create, while one parked shared native thread is still around to exercise the publish-before-widen race the test guards against. On a single-CPU host widening can never be attempted, so accept zero raises as well; a mixed count still fails, which is what a broken snt_cnt rollback would produce. Verified with RUBY_MAX_CPU=1/2/unset: the new test passes 5/5 in each configuration on master, and still segfaults 5/5 on the pre-fix code (3c66369561), so the original regression remains covered. Co-Authored-By: Claude Fable 5 --- bootstraptest/test_thread.rb | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/bootstraptest/test_thread.rb b/bootstraptest/test_thread.rb index 321247256d3980..e78c3f3bb963ca 100644 --- a/bootstraptest/test_thread.rb +++ b/bootstraptest/test_thread.rb @@ -627,8 +627,12 @@ def inspect if !can_limit 'ok' # cannot make thread creation fail on this platform; nothing to test else - warm = 2.times.map { Ractor.new { nil until Ractor.receive == :quit } } - sleep 0.3 # the pool now has shared native threads parked for the warm ractors + # One warm ractor parks one shared native thread in the pool. Exactly one: + # the pool is widened only while snt_cnt < max_cpu, so with two parked + # threads a 2-CPU host would never attempt pthread_create below and the + # rlimit would go unnoticed. + warm = Ractor.new { nil until Ractor.receive == :quit } + sleep 0.3 # the pool now has a shared native thread parked for the warm ractor Process.setrlimit(:NPROC, 1) # RLIMIT_NPROC binds neither root (CI containers) nor macOS threads; # probe that thread creation actually fails before asserting on it. @@ -651,10 +655,13 @@ def inspect end end sleep 0.5 # a wrongly-published thread would be served and die about now - errs == 20 ? 'ok' : "#{errs} of 20 raised" + # On a single-CPU host the pool is already at max_cpu, widening is never + # attempted and nothing raises; everywhere else every attempt must fail. + # A mixed count means a failed attempt was not rolled back cleanly. + (errs == 20 || errs == 0) ? 'ok' : "#{errs} of 20 raised" end - warm.each { |r| r.send(:quit) } - warm.each(&:value) + warm.send(:quit) + warm.value GC.start result end From 9e01974ae77aeebc00223de6a5fe4c382b46c885 Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Sat, 15 Aug 2026 17:20:06 +0900 Subject: [PATCH 2/5] Store variables which might be clobbered --- ractor_sync.c | 2 +- thread.c | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ractor_sync.c b/ractor_sync.c index 91fde7b73ed604..13cbc6ad3f136a 100644 --- a/ractor_sync.c +++ b/ractor_sync.c @@ -1070,7 +1070,7 @@ ractor_basket_new(rb_execution_context_t *ec, VALUE obj, enum ractor_basket_type /* A copy payload's preparation can raise (an uncopyable object), so it runs before * the basket is allocated and cannot leak one; the move branch allocates first, * since an alloc raise must not orphan an already built courier. */ - VALUE v = Qfalse; + volatile VALUE v = Qfalse; bool marshaled = false; struct rb_ractor_move_courier *courier = NULL; diff --git a/thread.c b/thread.c index 803a2ac96ffe6b..673f42076b0954 100644 --- a/thread.c +++ b/thread.c @@ -1112,7 +1112,7 @@ rb_thread_create_ractor(rb_ractor_t *r, VALUE args, VALUE proc) /* Allocate the child's main Thread and root Fiber wrappers directly in the child's * objspace, so the thread is built of objects it owns. Whole-VM walks read * cr->objspace: swap it under the VM lock, unobservable to others. */ - VALUE thval = Qundef; + volatile VALUE thval = Qundef; rb_ractor_t *cr = GET_RACTOR(); rb_execution_context_t *ec = GET_EC(); const bool multi_objspace = rb_gc_multi_objspace_p(); From 2567607d0feb3e9ea76fe637f367e81294325e15 Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Sat, 15 Aug 2026 20:27:48 +0900 Subject: [PATCH 3/5] Avoid internal compiler error: SSA corruption on s390x --- thread.c | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/thread.c b/thread.c index 673f42076b0954..66e51f90fe6d8e 100644 --- a/thread.c +++ b/thread.c @@ -1099,22 +1099,13 @@ rb_thread_create(VALUE (*fn)(void *), void *arg) return thread_create_core(rb_thread_alloc(rb_cThread), ¶ms); } -VALUE -rb_thread_create_ractor(rb_ractor_t *r, VALUE args, VALUE proc) +static VALUE +create_ractor_alloc_thread(rb_ractor_t *r, rb_ractor_t *cr, rb_execution_context_t *ec) { - struct thread_create_params params = { - .type = thread_invoke_type_ractor_proc, - .g = r, - .args = args, - .proc = proc, - }; - /* Allocate the child's main Thread and root Fiber wrappers directly in the child's * objspace, so the thread is built of objects it owns. Whole-VM walks read * cr->objspace: swap it under the VM lock, unobservable to others. */ volatile VALUE thval = Qundef; - rb_ractor_t *cr = GET_RACTOR(); - rb_execution_context_t *ec = GET_EC(); const bool multi_objspace = rb_gc_multi_objspace_p(); enum ruby_tag_type alloc_state = TAG_NONE; RB_VM_LOCKING() { @@ -1154,6 +1145,23 @@ rb_thread_create_ractor(rb_ractor_t *r, VALUE args, VALUE proc) } EC_JUMP_TAG(ec, alloc_state); } + return thval; +} + +VALUE +rb_thread_create_ractor(rb_ractor_t *r, VALUE args, VALUE proc) +{ + struct thread_create_params params = { + .type = thread_invoke_type_ractor_proc, + .g = r, + .args = args, + .proc = proc, + }; + + rb_ractor_t *cr = GET_RACTOR(); + rb_execution_context_t *ec = GET_EC(); + + VALUE thval = create_ractor_alloc_thread(r, cr, ec); /* Creation can still fail before vm_insert_ractor (an IsolationError, say), and a * left-over cover would enumerate the dead child's objspace twice and dangle after From ab454af573614ab5521db4be8f22107914dc9f55 Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Tue, 11 Aug 2026 22:04:23 +0900 Subject: [PATCH 4/5] Share bundled gem caches across runners Reuse downloaded files and source repositories across runner platforms to reduce network-dependent fetches. --- .github/actions/setup/directories/action.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/actions/setup/directories/action.yml b/.github/actions/setup/directories/action.yml index ec9c44f8b452ac..457466ae78d65d 100644 --- a/.github/actions/setup/directories/action.yml +++ b/.github/actions/setup/directories/action.yml @@ -114,10 +114,20 @@ runs: fetch-depth: ${{ inputs.fetch-depth }} persist-credentials: false + - id: gems-key + shell: bash + run: | + echo "hash=$hash" >> "$GITHUB_OUTPUT" + env: + hash: ${{ hashFiles(format('{0}/gems/bundled_gems', inputs.srcdir)) }} + - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ inputs.srcdir }}/.downloaded-cache - key: ${{ runner.os }}-${{ runner.arch }}-downloaded-cache + key: downloaded-cache-${{ steps.gems-key.outputs.hash }} + restore-keys: | + downloaded-cache- + ${{ runner.os }}-${{ runner.arch }}-downloaded-cache # Cache cloned bundled gem sources so a transient DNS/network failure # while cloning from github.com (e.g. "Could not resolve host") does not @@ -130,8 +140,9 @@ runs: uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ inputs.srcdir }}/gems/src - key: ${{ runner.os }}-${{ runner.arch }}-bundled-gems-src-${{ hashFiles(format('{0}/gems/bundled_gems', inputs.srcdir)) }} + key: bundled-gems-src-${{ steps.gems-key.outputs.hash }} restore-keys: | + bundled-gems-src- ${{ runner.os }}-${{ runner.arch }}-bundled-gems-src- - if: steps.which.outputs.autoreconf From 973c45fcb3eb56df4f13d6aa54499e6ccb02809a Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Sun, 16 Aug 2026 04:38:55 +0000 Subject: [PATCH 5/5] Ractor: let ruby_vm_destruct wait for the postmortem epilogue 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) --- thread.c | 13 +++++++++++++ thread_none.c | 13 +++++++++++++ thread_pthread.c | 17 +++++++++++++++++ thread_win32.c | 13 +++++++++++++ 4 files changed, 56 insertions(+) diff --git a/thread.c b/thread.c index 66e51f90fe6d8e..924b3ece132280 100644 --- a/thread.c +++ b/thread.c @@ -842,12 +842,25 @@ thread_start_func_2(rb_thread_t *th, VALUE *stack_start) #endif if (th->invoke_type == thread_invoke_type_ractor_proc) { + // The postmortem epilogue below runs after this Ractor is unlinked and no + // longer counted, with the GVL already released, and it frees through + // VM-global state (the jit_cont list and its mutex, the fiber pool, the + // main objspace's malloc accounting). Nothing else holds the main Ractor + // back at that point, so count it like a coroutine epilogue: then + // ruby_vm_destruct waits for it (rb_thread_sched_wait_winding) instead of + // tearing that state down underneath. th is freed by the epilogue, so + // keep the VM pointer. + rb_vm_t *const vm = th->vm; + rb_thread_sched_winding_begin(vm); + // after rb_ractor_living_threads_remove() // GC will happen anytime and this ractor can be collected (and destroy GVL). // So gvl_release() should be before it. thread_sched_to_dead(TH_SCHED(th), th); rb_ractor_living_threads_remove(th->ractor, th); rb_ractor_postmortem_free(&pf); + + rb_thread_sched_winding_end(vm); } else { rb_ractor_living_threads_remove(th->ractor, th); diff --git a/thread_none.c b/thread_none.c index 1767099e775474..1a3ff8bcd2cd39 100644 --- a/thread_none.c +++ b/thread_none.c @@ -339,6 +339,19 @@ rb_thread_event_hooks_registered_p(void) #endif /* THREAD_SYSTEM_DEPENDENT_IMPLEMENTATION */ +void +rb_thread_sched_winding_begin(rb_vm_t *vm) +{ + // nothing to count: rb_thread_sched_wait_winding below never waits + (void)vm; +} + +void +rb_thread_sched_winding_end(rb_vm_t *vm) +{ + (void)vm; +} + void rb_thread_sched_wait_winding(rb_vm_t *vm) { diff --git a/thread_pthread.c b/thread_pthread.c index 38135ce30b4a4b..4480d2ff90f131 100644 --- a/thread_pthread.c +++ b/thread_pthread.c @@ -1340,6 +1340,23 @@ grq_size(rb_vm_t *vm, rb_ractor_t *cr) } #endif +// A native thread enters/leaves an epilogue that outlives its Ractor: from +// the increment until the decrement, ruby_vm_destruct waits for it below. +// The increment must happen while the VM still counts the thread's Ractor, +// so that the two never look absent at the same time. +void +rb_thread_sched_winding_begin(rb_vm_t *vm) +{ + RUBY_ATOMIC_INC(vm->ractor.sched.winding_cnt); +} + +void +rb_thread_sched_winding_end(rb_vm_t *vm) +{ + VM_ASSERT(RUBY_ATOMIC_LOAD(vm->ractor.sched.winding_cnt) > 0); + RUBY_ATOMIC_DEC(vm->ractor.sched.winding_cnt); +} + // ruby_vm_destruct: wait until no native thread is between a coroutine // epilogue and its reclaim -- past that point the reclaim frees through the // (about to be destroyed) objspace and reads the (about to be unset) VM. diff --git a/thread_win32.c b/thread_win32.c index 7d6bf6362892b2..a7163fa8066acf 100644 --- a/thread_win32.c +++ b/thread_win32.c @@ -1020,6 +1020,19 @@ rb_thread_malloc_stack_set(rb_thread_t *th, void *stack, size_t stack_size) #endif /* THREAD_SYSTEM_DEPENDENT_IMPLEMENTATION */ +void +rb_thread_sched_winding_begin(rb_vm_t *vm) +{ + // nothing to count: rb_thread_sched_wait_winding below never waits + (void)vm; +} + +void +rb_thread_sched_winding_end(rb_vm_t *vm) +{ + (void)vm; +} + void rb_thread_sched_wait_winding(rb_vm_t *vm) {