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 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 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..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); @@ -1099,22 +1112,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. */ - VALUE thval = Qundef; - rb_ractor_t *cr = GET_RACTOR(); - rb_execution_context_t *ec = GET_EC(); + volatile VALUE thval = Qundef; const bool multi_objspace = rb_gc_multi_objspace_p(); enum ruby_tag_type alloc_state = TAG_NONE; RB_VM_LOCKING() { @@ -1154,6 +1158,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 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) {