From d43737d2a8a66c4eaae5035695ee585ef8dc452c Mon Sep 17 00:00:00 2001 From: Yusuke Endoh Date: Wed, 19 Aug 2026 18:40:23 +0900 Subject: [PATCH 01/12] Cache a template of the branch coverage result [Bug #22250] Coverage.peek_result rebuilt the nested branch coverage hash on every call, and hashing its array keys (Array#hash via the recursion guard) dominated the cost. Build { base_key => { target_key => counter_index } } once per file and cache it; each peek dups it and fills in the counters. 40k branch sites: 40 ms -> 3.5 ms per peek. The key arrays are now frozen and shared between results. Co-Authored-By: Claude Fable 5 --- ext/coverage/coverage.c | 94 +++++++++++++++++++++++++++++----- internal/hash.h | 2 +- test/coverage/test_coverage.rb | 38 +++++++++++++- 3 files changed, 118 insertions(+), 16 deletions(-) diff --git a/ext/coverage/coverage.c b/ext/coverage/coverage.c index 95688c309b0316..1b98ae163922dc 100644 --- a/ext/coverage/coverage.c +++ b/ext/coverage/coverage.c @@ -186,11 +186,16 @@ struct branch_coverage_result_builder int id; VALUE result; VALUE children; - VALUE counters; }; +/* + * Branch coverage result template, cached in branches[2]: + * { base_key => { target_key => counter_index } } + * Each peek dups it and replaces the indexes with the counters, which avoids + * hashing the array keys again and again. + */ static int -branch_coverage_ii(VALUE _key, VALUE branch, VALUE v) +branch_template_ii(VALUE _key, VALUE branch, VALUE v) { struct branch_coverage_result_builder *b = (struct branch_coverage_result_builder *) v; @@ -199,14 +204,16 @@ branch_coverage_ii(VALUE _key, VALUE branch, VALUE v) VALUE target_first_column = RARRAY_AREF(branch, 2); VALUE target_last_lineno = RARRAY_AREF(branch, 3); VALUE target_last_column = RARRAY_AREF(branch, 4); - long counter_idx = FIX2LONG(RARRAY_AREF(branch, 5)); - rb_hash_aset(b->children, rb_ary_new_from_args(6, target_label, LONG2FIX(b->id++), target_first_lineno, target_first_column, target_last_lineno, target_last_column), RARRAY_AREF(b->counters, counter_idx)); + VALUE counter_idx = RARRAY_AREF(branch, 5); + VALUE key = rb_ary_new_from_args(6, target_label, LONG2FIX(b->id++), target_first_lineno, target_first_column, target_last_lineno, target_last_column); + rb_ary_freeze(key); + rb_hash_aset(b->children, key, counter_idx); return ST_CONTINUE; } static int -branch_coverage_i(VALUE _key, VALUE branch_base, VALUE v) +branch_template_i(VALUE _key, VALUE branch_base, VALUE v) { struct branch_coverage_result_builder *b = (struct branch_coverage_result_builder *) v; @@ -217,26 +224,85 @@ branch_coverage_i(VALUE _key, VALUE branch_base, VALUE v) VALUE base_last_column = RARRAY_AREF(branch_base, 4); VALUE branches = RARRAY_AREF(branch_base, 5); VALUE children = rb_hash_new(); - rb_hash_aset(b->result, rb_ary_new_from_args(6, base_type, LONG2FIX(b->id++), base_first_lineno, base_first_column, base_last_lineno, base_last_column), children); + VALUE key = rb_ary_new_from_args(6, base_type, LONG2FIX(b->id++), base_first_lineno, base_first_column, base_last_lineno, base_last_column); + rb_ary_freeze(key); + rb_hash_aset(b->result, key, children); b->children = children; - rb_hash_foreach(branches, branch_coverage_ii, v); + rb_hash_foreach(branches, branch_template_ii, v); return ST_CONTINUE; } +/* returns [template, nbases, ntargets] */ static VALUE -branch_coverage(VALUE branches) +branch_template(VALUE branches) { VALUE structure = RARRAY_AREF(branches, 0); + VALUE counters = RARRAY_AREF(branches, 1); + long nbases = RHASH_SIZE(structure); + long ntargets = RARRAY_LEN(counters); + VALUE cache = RARRAY_LEN(branches) > 2 ? RARRAY_AREF(branches, 2) : Qnil; + + if (!NIL_P(cache) && + FIX2LONG(RARRAY_AREF(cache, 1)) == nbases && + FIX2LONG(RARRAY_AREF(cache, 2)) == ntargets) { + return RARRAY_AREF(cache, 0); + } + else { + struct branch_coverage_result_builder b; + b.id = 0; + b.result = rb_hash_new(); + rb_hash_foreach(structure, branch_template_i, (VALUE)&b); + cache = rb_ary_hidden_new(3); + rb_ary_push(cache, b.result); + rb_ary_push(cache, LONG2FIX(nbases)); + rb_ary_push(cache, LONG2FIX(ntargets)); + rb_ary_store(branches, 2, cache); + return b.result; + } +} + +static int +branch_fill_check(st_data_t key, st_data_t value, st_data_t argp, int error) +{ + return ST_REPLACE; +} + +/* children: {target_key => counter_index} -> {target_key => counter} */ +static int +branch_fill_counter(st_data_t *key, st_data_t *value, st_data_t argp, int existing) +{ + VALUE counters = (VALUE)argp; + *value = (st_data_t)RARRAY_AREF(counters, FIX2LONG((VALUE)*value)); + return ST_CONTINUE; +} - struct branch_coverage_result_builder b; - b.id = 0; - b.result = rb_hash_new(); - b.counters = RARRAY_AREF(branches, 1); +struct branch_fill_arg +{ + VALUE result; + VALUE counters; +}; - rb_hash_foreach(structure, branch_coverage_i, (VALUE)&b); +/* result: {base_key => children_template} -> {base_key => filled copy of children} */ +static int +branch_fill_children(st_data_t *key, st_data_t *value, st_data_t argp, int existing) +{ + struct branch_fill_arg *a = (struct branch_fill_arg *)argp; + VALUE children = rb_hash_dup((VALUE)*value); + rb_hash_stlike_foreach_with_replace(children, branch_fill_check, branch_fill_counter, (st_data_t)a->counters); + RB_OBJ_WRITE(a->result, value, children); + return ST_CONTINUE; +} - return b.result; +static VALUE +branch_coverage(VALUE branches) +{ + struct branch_fill_arg a; + VALUE template = branch_template(branches); + a.counters = RARRAY_AREF(branches, 1); + a.result = rb_hash_dup(template); + rb_hash_stlike_foreach_with_replace(a.result, branch_fill_check, branch_fill_children, (st_data_t)&a); + return a.result; } static void diff --git a/internal/hash.h b/internal/hash.h index 34fd659384e9f5..acc81978d9004b 100644 --- a/internal/hash.h +++ b/internal/hash.h @@ -84,7 +84,6 @@ VALUE rb_hash_rehash(VALUE hash); int rb_hash_add_new_element(VALUE hash, VALUE key, VALUE val); VALUE rb_hash_set_pair(VALUE hash, VALUE pair); int rb_hash_stlike_delete(VALUE hash, st_data_t *pkey, st_data_t *pval); -int rb_hash_stlike_foreach_with_replace(VALUE hash, st_foreach_check_callback_func *func, st_update_callback_func *replace, st_data_t arg); int rb_hash_stlike_update(VALUE hash, st_data_t key, st_update_callback_func *func, st_data_t arg); bool rb_hash_default_unredefined(VALUE hash); VALUE rb_hash_alloc_fixed_size(VALUE klass, st_index_t size); @@ -108,6 +107,7 @@ RUBY_SYMBOL_EXPORT_BEGIN VALUE rb_hash_delete_entry(VALUE hash, VALUE key); VALUE rb_ident_hash_new(void); int rb_hash_stlike_foreach(VALUE hash, st_foreach_callback_func *func, st_data_t arg); +int rb_hash_stlike_foreach_with_replace(VALUE hash, st_foreach_check_callback_func *func, st_update_callback_func *replace, st_data_t arg); RUBY_SYMBOL_EXPORT_END VALUE rb_hash_new_with_bulk_insert(long argc, const VALUE *argv); diff --git a/test/coverage/test_coverage.rb b/test/coverage/test_coverage.rb index 4a3b9690c09411..094c1061de9b6a 100644 --- a/test/coverage/test_coverage.rb +++ b/test/coverage/test_coverage.rb @@ -256,7 +256,7 @@ def test_eval_coverage end; end - def test_branch_coverage_for_eval_repeated +def test_branch_coverage_for_eval_repeated assert_in_out_err(["-W0", *ARGV], <<-"end;", ["2", "2", "[[0, 1], [0, 2]]"], []) Coverage.start(eval: true, branches: true) @@ -284,6 +284,42 @@ def foo(x) end; end + def test_peek_result_branches_after_eval_adds_branches + assert_in_out_err(ARGV, <<-"end;", ["1", "1", "2", "3", "1", "false"], []) + Coverage.start(eval: true, branches: true) + + sum = ->(r) { r.sum {|_, targets| targets.sum {|_, count| count } } } + + eval(<<-RUBY, TOPLEVEL_BINDING, "test.rb", 1) + def foo(x) + x ? 1 : 2 + end + RUBY + + foo(true) + r1 = Coverage.peek_result["test.rb"][:branches] + p r1.size + p sum[r1] + + # A later eval with the same path adds new branches to the same file, + # and the cached result template must be refreshed accordingly + eval(<<-RUBY, TOPLEVEL_BINDING, "test.rb", 10) + def bar(x) + x ? 1 : 2 + end + RUBY + + foo(true) + bar(false) + r2 = Coverage.peek_result["test.rb"][:branches] + p r2.size + p sum[r2] + # the earlier snapshot must not be affected + p sum[r1] + p r1.equal?(r2) + end; + end + def test_eval_negative_lineno assert_in_out_err(ARGV, <<-"end;", ["[1, 1, 1]"], []) Coverage.start(eval: true, lines: true) From c9e91014038b9116cb9a08a617856b6454f3ac86 Mon Sep 17 00:00:00 2001 From: Yusuke Endoh Date: Wed, 19 Aug 2026 18:40:24 +0900 Subject: [PATCH 02/12] Cache a template of the method coverage result [Bug #22250] 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 --- ext/coverage/coverage.c | 169 ++++++++++++++++++++++++++++----- internal/coverage.h | 1 + test/coverage/test_coverage.rb | 40 ++++++++ thread.c | 43 +++++---- 4 files changed, 213 insertions(+), 40 deletions(-) diff --git a/ext/coverage/coverage.c b/ext/coverage/coverage.c index 1b98ae163922dc..95c85ca4ab317d 100644 --- a/ext/coverage/coverage.c +++ b/ext/coverage/coverage.c @@ -21,6 +21,25 @@ static int current_mode; static VALUE cme2counter = Qnil; static VALUE me_set = Qnil; +/* + * Method coverage result template: + * method_tmpl_by_path: { path => { key => me or [me, ...] } } + * Each peek dups the per-path template and replaces the values with the + * call counts. me_set and cme2counter are append-only and iterate in + * insertion order, so only the entries after the ones already seen are added. + */ +static VALUE method_tmpl_by_path = Qnil; +static long method_tmpl_n_me_set = 0; +static long method_tmpl_n_cme2counter = 0; + +static void +method_template_reset(void) +{ + method_tmpl_by_path = Qnil; + method_tmpl_n_me_set = 0; + method_tmpl_n_cme2counter = 0; +} + /* * call-seq: Coverage.supported?(mode) -> true or false * @@ -120,6 +139,7 @@ rb_coverage_setup(int argc, VALUE *argv, VALUE klass) cme2counter = Qnil; me_set = Qnil; } + method_template_reset(); coverages = rb_get_coverages(); if (!RTEST(coverages)) { @@ -305,30 +325,130 @@ branch_coverage(VALUE branches) return a.result; } +struct method_template_add_arg { + long skip; + long i; + int check_me_set; /* skip the entries in me_set (they are already added) */ +}; + static void -method_coverage_i(const struct rb_coverage_method_data *method, void *data) +method_template_add(VALUE me) { - VALUE ncoverages = *(VALUE *)data; - VALUE ncoverage = rb_hash_aref(ncoverages, method->path); - - if (!NIL_P(ncoverage)) { - VALUE methods = rb_hash_aref(ncoverage, ID2SYM(rb_intern("methods"))); - VALUE key = rb_ary_new_from_args(6, method->owner, method->method_id, - method->first_lineno, method->first_column, - method->last_lineno, method->last_column); - VALUE rcount = method->count; - VALUE previous = rb_hash_aref(methods, key); - - if (NIL_P(rcount)) rcount = LONG2FIX(0); - if (NIL_P(previous)) previous = LONG2FIX(0); - if (!POSFIXABLE(FIX2LONG(rcount) + FIX2LONG(previous))) { - rcount = LONG2FIX(FIXNUM_MAX); - } - else { - rcount = LONG2FIX(FIX2LONG(rcount) + FIX2LONG(previous)); + struct rb_coverage_method_data d; + VALUE tmpl, key, mes; + + if (!rb_coverage_method_data_of(me, Qnil, &d)) return; + + tmpl = rb_hash_lookup(method_tmpl_by_path, d.path); + if (NIL_P(tmpl)) { + tmpl = rb_hash_new(); + rb_hash_aset(method_tmpl_by_path, d.path, tmpl); + } + key = rb_ary_new_from_args(6, d.owner, d.method_id, + d.first_lineno, d.first_column, + d.last_lineno, d.last_column); + rb_ary_freeze(key); + mes = rb_hash_lookup(tmpl, key); + if (NIL_P(mes)) { + rb_hash_aset(tmpl, key, me); + } + else if (RB_TYPE_P(mes, T_ARRAY)) { + rb_ary_push(mes, me); + } + else { + VALUE ary = rb_ary_hidden_new(2); + rb_ary_push(ary, mes); + rb_ary_push(ary, me); + rb_hash_aset(tmpl, key, ary); + } +} + +static int +method_template_add_i(VALUE me, VALUE value, VALUE data) +{ + struct method_template_add_arg *arg = (struct method_template_add_arg *)data; + if (arg->i++ >= arg->skip) { + if (arg->check_me_set && RTEST(rb_hash_lookup2(me_set, me, Qfalse))) return ST_CONTINUE; + method_template_add(me); + } + return ST_CONTINUE; +} + +static void +method_template_update(void) +{ + struct method_template_add_arg arg; + + if (NIL_P(method_tmpl_by_path)) { + method_tmpl_by_path = rb_hash_new(); + method_tmpl_n_me_set = 0; + method_tmpl_n_cme2counter = 0; + } + if (RTEST(me_set) && RHASH_SIZE(me_set) > (size_t)method_tmpl_n_me_set) { + arg.skip = method_tmpl_n_me_set; + arg.i = 0; + arg.check_me_set = 0; + rb_hash_foreach(me_set, method_template_add_i, (VALUE)&arg); + method_tmpl_n_me_set = arg.i; + } + if (RTEST(cme2counter) && RHASH_SIZE(cme2counter) > (size_t)method_tmpl_n_cme2counter) { + arg.skip = method_tmpl_n_cme2counter; + arg.i = 0; + arg.check_me_set = RTEST(me_set); + rb_hash_foreach(cme2counter, method_template_add_i, (VALUE)&arg); + method_tmpl_n_cme2counter = arg.i; + } +} + +static int +method_fill_check(st_data_t key, st_data_t value, st_data_t argp, int error) +{ + return ST_REPLACE; +} + +static long +method_call_count(VALUE me) +{ + VALUE c = rb_hash_lookup2(cme2counter, me, Qnil); + return FIXNUM_P(c) ? FIX2LONG(c) : 0; +} + +/* methods: {key => me or [me, ...]} -> {key => count}, where count is the + * sum of the call counts of all the method entries sharing the key + * (methods redefined at the same location) */ +static int +method_fill_count(st_data_t *key, st_data_t *value, st_data_t argp, int existing) +{ + VALUE mes = (VALUE)*value; + long count; + if (RB_TYPE_P(mes, T_ARRAY)) { + long i; + count = 0; + for (i = 0; i < RARRAY_LEN(mes); i++) { + count += method_call_count(RARRAY_AREF(mes, i)); + if (!POSFIXABLE(count)) count = FIXNUM_MAX; } - rb_hash_aset(methods, key, rcount); } + else { + count = method_call_count(mes); + } + *value = (st_data_t)LONG2FIX(count); + return ST_CONTINUE; +} + +static VALUE +method_coverage(VALUE path) +{ + VALUE tmpl = rb_hash_lookup(method_tmpl_by_path, path); + VALUE methods; + if (NIL_P(tmpl)) { + methods = rb_hash_new(); + } + else { + methods = rb_hash_dup(tmpl); + rb_hash_stlike_foreach_with_replace(methods, method_fill_check, method_fill_count, 0); + } + return methods; } static int @@ -360,7 +480,7 @@ coverage_peek_result_i(st_data_t key, st_data_t val, st_data_t h) } if (current_mode & COVERAGE_TARGET_METHODS) { - rb_hash_aset(h, ID2SYM(rb_intern("methods")), rb_hash_new()); + rb_hash_aset(h, ID2SYM(rb_intern("methods")), method_coverage(path)); } coverage = h; @@ -391,11 +511,10 @@ rb_coverage_peek_result(VALUE klass) rb_raise(rb_eRuntimeError, "coverage measurement is not enabled"); } - rb_hash_foreach(coverages, coverage_peek_result_i, ncoverages); - if (current_mode & COVERAGE_TARGET_METHODS) { - rb_coverage_each_method(method_coverage_i, &ncoverages); + method_template_update(); } + rb_hash_foreach(coverages, coverage_peek_result_i, ncoverages); rb_hash_freeze(ncoverages); return ncoverages; @@ -472,6 +591,7 @@ rb_coverage_result(int argc, VALUE *argv, VALUE klass) rb_reset_coverages(); cme2counter = Qnil; me_set = Qnil; + method_template_reset(); current_state = IDLE; } return ncoverages; @@ -737,4 +857,5 @@ Init_coverage(void) rb_define_module_function(rb_mCoverage, "running?", rb_coverage_running, 0); rb_global_variable(&cme2counter); rb_global_variable(&me_set); + rb_global_variable(&method_tmpl_by_path); } diff --git a/internal/coverage.h b/internal/coverage.h index 9c78661671dc23..f07f384dd06ada 100644 --- a/internal/coverage.h +++ b/internal/coverage.h @@ -41,6 +41,7 @@ void rb_reset_coverages(void); void rb_resume_coverages(void); void rb_suspend_coverages(void); void rb_coverage_each_method(rb_coverage_method_callback callback, void *data); +bool rb_coverage_method_data_of(VALUE me, VALUE count, struct rb_coverage_method_data *out); RUBY_SYMBOL_EXPORT_END diff --git a/test/coverage/test_coverage.rb b/test/coverage/test_coverage.rb index 094c1061de9b6a..73e3c0553b8c40 100644 --- a/test/coverage/test_coverage.rb +++ b/test/coverage/test_coverage.rb @@ -320,6 +320,46 @@ def bar(x) end; end + def test_peek_result_methods_after_eval_adds_methods + assert_in_out_err(["-W0", *ARGV], <<-"end;", ["1", "2", "1", "3", "1", "false"], []) + Coverage.start(eval: true, methods: true) + + eval(<<-RUBY, TOPLEVEL_BINDING, "test.rb", 1) + class Foo + def foo; end + end + RUBY + + Foo.new.foo + r1 = Coverage.peek_result["test.rb"][:methods] + p r1.size + + # A later eval with the same path adds new methods to the same file, + # and redefining a method at the same location shares the key + eval(<<-RUBY, TOPLEVEL_BINDING, "test.rb", 10) + class Foo + def bar; end + end + RUBY + eval(<<-RUBY, TOPLEVEL_BINDING, "test.rb", 1) + class Foo + def foo; end + end + RUBY + + Foo.new.foo + Foo.new.foo + Foo.new.bar + r2 = Coverage.peek_result["test.rb"][:methods] + p r2.size + # the earlier snapshot must not be affected + p r1.size + p r2[[Foo, :foo, 2, 8, 2, 20]] + p r2[[Foo, :bar, 11, 8, 11, 20]] + p r1.equal?(r2) + end; + end + def test_eval_negative_lineno assert_in_out_err(ARGV, <<-"end;", ["[1, 1, 1]"], []) Coverage.start(eval: true, lines: true) diff --git a/thread.c b/thread.c index 33d45a8c343c67..1cc89765ad1a43 100644 --- a/thread.c +++ b/thread.c @@ -6274,27 +6274,38 @@ struct method_coverage_arg { void *data; }; -static void -method_coverage_call(const rb_method_entry_t *me, VALUE count, - struct method_coverage_arg *arg) +/* Fills *out for the method entry `me_v` and returns true, or returns false + * if the method entry is not a subject of method coverage (aliases, + * complemented entries, and methods without a source location). */ +bool +rb_coverage_method_data_of(VALUE me_v, VALUE count, struct rb_coverage_method_data *out) { + const rb_method_entry_t *me = (const rb_method_entry_t *)me_v; VALUE location[5]; const rb_method_entry_t *resolved_me = rb_resolve_me_location(me, location); if (me != resolved_me || RB_TYPE_P(me->owner, T_ICLASS) || - FIX2LONG(location[1]) <= 0) return; - - struct rb_coverage_method_data method = { - .owner = me->owner, - .method_id = ID2SYM(me->def->original_id), - .path = location[0], - .first_lineno = location[1], - .first_column = location[2], - .last_lineno = location[3], - .last_column = location[4], - .count = count, - }; - arg->callback(&method, arg->data); + FIX2LONG(location[1]) <= 0) return false; + + out->owner = me->owner; + out->method_id = ID2SYM(me->def->original_id); + out->path = location[0]; + out->first_lineno = location[1]; + out->first_column = location[2]; + out->last_lineno = location[3]; + out->last_column = location[4]; + out->count = count; + return true; +} + +static void +method_coverage_call(const rb_method_entry_t *me, VALUE count, + struct method_coverage_arg *arg) +{ + struct rb_coverage_method_data method; + if (rb_coverage_method_data_of((VALUE)me, count, &method)) { + arg->callback(&method, arg->data); + } } static int From e1bce29aac8926ae4c1dfedd914ebe8e04e5cd7b Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Thu, 20 Aug 2026 19:16:35 +0000 Subject: [PATCH 03/12] Ractor: stop waiting a second for its own threads to finish 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. --- ractor.c | 1 + ractor_core.h | 3 +++ test/ruby/test_ractor.rb | 23 +++++++++++++++++++++++ thread.c | 6 +++++- 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/ractor.c b/ractor.c index 9a1ea4b61d631f..315501b48418a0 100644 --- a/ractor.c +++ b/ractor.c @@ -771,6 +771,7 @@ rb_ractor_living_threads_init(rb_ractor_t *r) ccan_list_head_init(&r->threads.set); r->threads.cnt = 0; r->threads.blocking_cnt = 0; + r->threads.terminating = false; } static void diff --git a/ractor_core.h b/ractor_core.h index 1649e69fd017d1..de60004e7e79e5 100644 --- a/ractor_core.h +++ b/ractor_core.h @@ -100,6 +100,9 @@ struct rb_ractor_struct { struct rb_thread_sched sched; rb_execution_context_t *running_ec; rb_thread_t *main; + + // `main` is in rb_thread_terminate_all(), waiting for the others to go + bool terminating; } threads; /* Postponed jobs targeted at this Ractor diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index 1b903a72ca20fb..476a1f8c2f1bd5 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -166,6 +166,29 @@ def test_default_thread_group refute_equal main_ractor_id, ractor_id end; end + def test_ractor_with_live_threads_terminates_without_waiting + assert_separately([], __FILE__, __LINE__, <<-'RUBY') + Warning[:experimental] = false + # A Ractor that ends while a thread of its own is still running used to sit out + # the one second poll in rb_thread_terminate_all(), once per Ractor. Measure + # against the same Ractors without a live thread, so that a busy machine, which + # makes both of them slow, does not decide this. + n = 5 + elapsed = ->(&blk) { + t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + n.times { blk.call } + Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 + } + + base = elapsed.call { assert_equal :done, Ractor.new { :done }.value } + live = elapsed.call { assert_equal :done, Ractor.new { Thread.new { sleep 10 }; :done }.value } + + # the bug costs a second per Ractor, so #{n} seconds here + assert_operator live, :<, base + 2.0, + "#{n} Ractors with a live thread took #{live}s, without one #{base}s" + RUBY + end + def test_class_instance_variables assert_ractor(<<~'RUBY') diff --git a/thread.c b/thread.c index 1cc89765ad1a43..78c91de8e7df90 100644 --- a/thread.c +++ b/thread.c @@ -482,6 +482,10 @@ rb_thread_terminate_all(rb_thread_t *th) /* unlock all locking mutexes */ rb_threadptr_unlock_all_locking_mutexes(th); + // tells the last sub-thread to wake this one out of the sleep below. Nothing + // clears it: no thread of this Ractor can run again once this returns. + cr->threads.terminating = true; + EC_PUSH_TAG(ec); if (EC_EXEC_TAG() == TAG_NONE) { retry: @@ -810,7 +814,7 @@ thread_start_func_2(rb_thread_t *th, VALUE *stack_start) (void *)th, th->locking_mutex); } - if (ractor_main_th->status == THREAD_KILLED && + if (th->ractor->threads.terminating && th->ractor->threads.cnt <= 2 /* main thread and this thread */) { /* I'm last thread. wake up main thread from rb_thread_terminate_all */ rb_threadptr_interrupt(ractor_main_th); From 2c9f247e8cf2fc1b5f585c700d6999996ff8744f Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 3 Aug 2026 11:00:53 +0900 Subject: [PATCH 04/12] [ruby/rubygems] Fetch Bundler metadata through Gem::Request instead of net-http-persistent Bundler's metadata transport was the last consumer of the vendored net-http-persistent. Hand out configured Gem::Net::HTTP connections from a small per-host pool compatible with Gem::Request's checkout/checkin interface, so metadata requests share RubyGems' request execution with .gem downloads. As a side effect, https_proxy is now honored for https sources, and a configured :no_proxy no longer falls back to the environment proxy. https://github.com/ruby/rubygems/commit/b6499513b9 Co-Authored-By: Claude Fable 5 --- lib/bundler/fetcher.rb | 49 +----- lib/bundler/fetcher/connection_pools.rb | 143 ++++++++++++++++++ lib/bundler/fetcher/downloader.rb | 47 ++++-- .../bundler/fetcher/downloader_spec.rb | 110 +++++++------- spec/bundler/bundler/fetcher_spec.rb | 26 ++-- 5 files changed, 247 insertions(+), 128 deletions(-) create mode 100644 lib/bundler/fetcher/connection_pools.rb diff --git a/lib/bundler/fetcher.rb b/lib/bundler/fetcher.rb index ce6f5fdcf402be..ecaca924205688 100644 --- a/lib/bundler/fetcher.rb +++ b/lib/bundler/fetcher.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require_relative "vendored_persistent" +require_relative "vendored_net_http" require_relative "vendored_timeout" require_relative "vendored_securerandom" require "zlib" @@ -10,6 +10,7 @@ module Bundler class Fetcher autoload :Base, File.expand_path("fetcher/base", __dir__) autoload :CompactIndex, File.expand_path("fetcher/compact_index", __dir__) + autoload :ConnectionPools, File.expand_path("fetcher/connection_pools", __dir__) autoload :Downloader, File.expand_path("fetcher/downloader", __dir__) autoload :Dependency, File.expand_path("fetcher/dependency", __dir__) autoload :Index, File.expand_path("fetcher/index", __dir__) @@ -137,7 +138,7 @@ def initialize(remote) @remote = remote Socket.do_not_reverse_lookup = true - connection # create persistent connection + connection # set up the connection pools eagerly so SSL support is checked upfront end def uri @@ -237,7 +238,7 @@ def user_agent end def http_proxy - return unless uri = connection.proxy_uri + return unless uri = connection.proxy_for(remote_uri) uri.to_s end @@ -307,28 +308,7 @@ def connection end end - con = Gem::Net::HTTP::Persistent.new name: "bundler", proxy: :ENV - if gem_proxy = Gem.configuration[:http_proxy] - con.proxy = Gem::URI.parse(gem_proxy) if gem_proxy != :no_proxy - end - - if remote_uri.scheme == "https" - con.verify_mode = (Bundler.settings[:ssl_verify_mode] || - OpenSSL::SSL::VERIFY_PEER) - con.cert_store = bundler_cert_store - end - - ssl_client_cert = Bundler.settings[:ssl_client_cert] || - (Gem.configuration.ssl_client_cert if - Gem.configuration.respond_to?(:ssl_client_cert)) - if ssl_client_cert - pem = File.read(ssl_client_cert) - con.cert = OpenSSL::X509::Certificate.new(pem) - con.key = OpenSSL::PKey.read(pem) - end - - con.read_timeout = Fetcher.api_timeout - con.open_timeout = Fetcher.api_timeout + con = ConnectionPools.new(size: Bundler.settings.processor_count, timeout: Fetcher.api_timeout) con.override_headers["User-Agent"] = user_agent con.override_headers["X-Gemfile-Source"] = @remote.original_uri.to_s if @remote.original_uri con @@ -341,25 +321,6 @@ def gemspec_cached_path(spec_file_name) paths.find {|path| File.file? path } end - def bundler_cert_store - store = OpenSSL::X509::Store.new - ssl_ca_cert = Bundler.settings[:ssl_ca_cert] || - (Gem.configuration.ssl_ca_cert if - Gem.configuration.respond_to?(:ssl_ca_cert)) - if ssl_ca_cert - if File.directory? ssl_ca_cert - store.add_path ssl_ca_cert - else - store.add_file ssl_ca_cert - end - else - store.set_default_paths - require "rubygems/request" - Gem::Request.get_cert_files.each {|c| store.add_file c } - end - store - end - def remote_uri @remote.uri end diff --git a/lib/bundler/fetcher/connection_pools.rb b/lib/bundler/fetcher/connection_pools.rb new file mode 100644 index 00000000000000..a2e72af74b8020 --- /dev/null +++ b/lib/bundler/fetcher/connection_pools.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "rubygems/request" +require "rubygems/remote_fetcher" + +module Bundler + class Fetcher + # Hands out pools of configured Gem::Net::HTTP connections, compatible + # with the checkout/checkin interface that Gem::Request expects. + # Connection policy (proxy, SSL, timeouts) follows Bundler settings + # first, falling back to RubyGems configuration. + class ConnectionPools + # A fixed-size pool of connections to a single host, sharing the + # interface of Gem::Request::HTTPPool. + class Pool + attr_reader :cert_files, :proxy_uri + + def initialize(connections, uri, proxy_uri, size) + @connections = connections + @uri = uri + @proxy_uri = proxy_uri + @cert_files = connections.cert_files + @queue = Thread::SizedQueue.new(size) + size.times { @queue.push(nil) } + end + + def checkout + @queue.pop || @connections.build_connection(@uri, @proxy_uri) + end + + def checkin(connection) + @queue.push(connection) + end + end + + attr_reader :override_headers, :cert_files + + def initialize(size:, timeout:) + @size = size + @timeout = timeout + @override_headers = {} + @cert_files = Gem::Request.get_cert_files + @pools = {} + @pool_mutex = Thread::Mutex.new + end + + # Performs a GET request through Gem::Request, which handles resetting + # and retrying stale connections, and returns the raw response. + def request(uri, headers = nil) + Gem::Request.new(uri, Gem::Net::HTTP::Get, nil, pool_for(uri)).fetch do |request| + @override_headers.each {|key, value| request[key] = value } + headers&.each {|key, value| request[key] = value } + end + end + + def pool_for(uri) + key = [uri.scheme, uri.hostname, uri.port] + @pool_mutex.synchronize do + @pools[key] ||= Pool.new(self, uri, proxy_for(uri), @size) + end + end + + # The proxy that will be used for +uri+, or nil. RubyGems configuration + # takes precedence over the environment, like Gem::RemoteFetcher. + # Unlike Gem::Request, an https URI falls back to `http_proxy` when no + # https-specific proxy is set, which is what the previous + # net-http-persistent based transport did. + def proxy_for(uri) + proxy = if config_proxy = Gem.configuration[:http_proxy] + Gem::Request.proxy_uri(config_proxy) + else + env_proxy_for(uri) + end + return unless proxy + return unless Gem::URI::Generic.use_proxy?(uri.hostname, nil, uri.port, no_proxy_env) + proxy + end + + def build_connection(uri, proxy_uri) # :nodoc: + args = [uri.hostname, uri.port] + args += if proxy_uri + [proxy_uri.hostname, proxy_uri.port, + Gem::UriFormatter.new(proxy_uri.user).unescape, + Gem::UriFormatter.new(proxy_uri.password).unescape] + else + [nil, nil] + end + + connection = Gem::Request::ConnectionPools.client.new(*args) + configure_ssl(connection) if uri.scheme == "https" + connection.open_timeout = @timeout + connection.read_timeout = @timeout + connection.start + connection + end + + private + + def env_proxy_for(uri) + proxy = Gem::Request.get_proxy_from_env(uri.scheme) + proxy = Gem::Request.get_proxy_from_env("http") if proxy == :no_proxy && uri.scheme == "https" + proxy == :no_proxy ? nil : proxy + end + + def no_proxy_env + ENV["no_proxy"] || ENV["NO_PROXY"] || "" + end + + def configure_ssl(connection) + connection.use_ssl = true + connection.verify_mode = Bundler.settings[:ssl_verify_mode] || OpenSSL::SSL::VERIFY_PEER + connection.cert_store = bundler_cert_store + + ssl_client_cert = Bundler.settings[:ssl_client_cert] || + (Gem.configuration.ssl_client_cert if + Gem.configuration.respond_to?(:ssl_client_cert)) + return unless ssl_client_cert + + pem = File.read(ssl_client_cert) + connection.cert = OpenSSL::X509::Certificate.new(pem) + connection.key = OpenSSL::PKey.read(pem) + end + + def bundler_cert_store + store = OpenSSL::X509::Store.new + ssl_ca_cert = Bundler.settings[:ssl_ca_cert] || + (Gem.configuration.ssl_ca_cert if + Gem.configuration.respond_to?(:ssl_ca_cert)) + if ssl_ca_cert + if File.directory? ssl_ca_cert + store.add_path ssl_ca_cert + else + store.add_file ssl_ca_cert + end + else + store.set_default_paths + cert_files.each {|c| store.add_file c } + end + store + end + end + end +end diff --git a/lib/bundler/fetcher/downloader.rb b/lib/bundler/fetcher/downloader.rb index 179eed83401f03..59e54c819de0bd 100644 --- a/lib/bundler/fetcher/downloader.rb +++ b/lib/bundler/fetcher/downloader.rb @@ -6,14 +6,19 @@ class Downloader HTTP_NON_RETRYABLE_ERRORS = [ SocketError, Errno::EADDRNOTAVAIL, + Errno::ECONNREFUSED, + Errno::EHOSTDOWN, + Errno::EHOSTUNREACH, Errno::ENETDOWN, Errno::ENETUNREACH, - Gem::Net::HTTP::Persistent::Error, - Errno::EHOSTUNREACH, ].freeze + # The vendored net-http raises Gem::Timeout::Error, but when Gem::Net is + # the real Net (hosts without a vendored net-http), timeouts are plain + # Timeout::Error subclasses instead. HTTP_RETRYABLE_ERRORS = [ Gem::Timeout::Error, + *(::Timeout::Error if defined?(::Timeout::Error)), EOFError, Errno::EINVAL, Errno::ECONNRESET, @@ -25,11 +30,11 @@ class Downloader Zlib::BufError, ].freeze - attr_reader :connection + attr_reader :connections attr_reader :redirect_limit - def initialize(connection, redirect_limit) - @connection = connection + def initialize(connections, redirect_limit) + @connections = connections @redirect_limit = redirect_limit end @@ -79,23 +84,25 @@ def request(uri, headers) filtered_uri = URICredentialsFilter.credential_filtered_uri(uri) Bundler.ui.debug "HTTP GET #{filtered_uri}" - req = Gem::Net::HTTP::Get.new uri.request_uri, headers - if uri.user - user = CGI.unescape(uri.user) - password = uri.password ? CGI.unescape(uri.password) : nil - req.basic_auth(user, password) + connections.request(uri, headers) + rescue Gem::RemoteFetcher::FetchError => e + Bundler.ui.trace e + + case e.message + when /certificate verify failed/ + raise CertificateFailureError.new(uri) + when /host is down|host down/i + raise network_down_error(uri, filtered_uri) + else + raise HTTPError, "Network error while fetching #{filtered_uri}" \ + " (#{e})" end - connection.request(uri, req) rescue OpenSSL::SSL::SSLError raise CertificateFailureError.new(uri) rescue *HTTP_NON_RETRYABLE_ERRORS => e Bundler.ui.trace e - host = uri.host - host_port = "#{host}:#{uri.port}" - host = host_port if filtered_uri.to_s.include?(host_port) - raise NetworkDownError, "Could not reach host #{host}. Check your network " \ - "connection and try again." + raise network_down_error(uri, filtered_uri) rescue *HTTP_RETRYABLE_ERRORS => e Bundler.ui.trace e @@ -105,6 +112,14 @@ def request(uri, headers) private + def network_down_error(uri, filtered_uri) + host = uri.host + host_port = "#{host}:#{uri.port}" + host = host_port if filtered_uri.to_s.include?(host_port) + NetworkDownError.new("Could not reach host #{host}. Check your network " \ + "connection and try again.") + end + def validate_uri_scheme!(uri) return if /\Ahttps?\z/.match?(uri.scheme) raise InvalidOption, diff --git a/spec/bundler/bundler/fetcher/downloader_spec.rb b/spec/bundler/bundler/fetcher/downloader_spec.rb index edf426328a4d9e..a3390ac5276fc0 100644 --- a/spec/bundler/bundler/fetcher/downloader_spec.rb +++ b/spec/bundler/bundler/fetcher/downloader_spec.rb @@ -1,12 +1,14 @@ # frozen_string_literal: true +require "rubygems/remote_fetcher" + RSpec.describe Bundler::Fetcher::Downloader do - let(:connection) { double(:connection) } + let(:connections) { double(:connections) } let(:redirect_limit) { 5 } let(:uri) { Gem::URI("http://www.uri-to-fetch.com/api/v2/endpoint") } let(:options) { double(:options) } - subject { described_class.new(connection, redirect_limit) } + subject { described_class.new(connections, redirect_limit) } describe "fetch" do let(:counter) { 0 } @@ -168,12 +170,10 @@ end describe "request" do - let(:net_http_get) { double(:net_http_get) } - let(:response) { double(:response) } + let(:response) { double(:response) } before do - allow(Gem::Net::HTTP::Get).to receive(:new).with("/api/v2/endpoint", options).and_return(net_http_get) - allow(connection).to receive(:request).with(uri, net_http_get).and_return(response) + allow(connections).to receive(:request).with(uri, options).and_return(response) end it "should log the HTTP GET request to debug" do @@ -181,55 +181,63 @@ subject.request(uri, options) end - context "when there is a user provided in the request" do - context "and there is also a password provided" do - context "that contains cgi escaped characters" do - let(:uri) { Gem::URI("http://username:password%24@www.uri-to-fetch.com/api/v2/endpoint") } + context "when there are credentials provided in the request" do + let(:uri) { Gem::URI("http://username:password@www.uri-to-fetch.com/api/v2/endpoint") } - it "should request basic authentication with the username and password, and log the HTTP GET request to debug, without the password" do - expect(net_http_get).to receive(:basic_auth).with("username", "password$") - expect(Bundler).to receive_message_chain(:ui, :debug).with("HTTP GET http://username@www.uri-to-fetch.com/api/v2/endpoint") - subject.request(uri, options) - end - end + it "should log the HTTP GET request to debug, without the password" do + expect(Bundler).to receive_message_chain(:ui, :debug).with("HTTP GET http://username@www.uri-to-fetch.com/api/v2/endpoint") + subject.request(uri, options) + end + end - context "that is all unescaped characters" do - let(:uri) { Gem::URI("http://username:password@www.uri-to-fetch.com/api/v2/endpoint") } - it "should request basic authentication with the username and proper cgi compliant password, and log the HTTP GET request to debug, without the password" do - expect(net_http_get).to receive(:basic_auth).with("username", "password") - expect(Bundler).to receive_message_chain(:ui, :debug).with("HTTP GET http://username@www.uri-to-fetch.com/api/v2/endpoint") - subject.request(uri, options) - end - end + context "when the request response causes a OpenSSL::SSL::SSLError" do + before { allow(connections).to receive(:request).with(uri, options) { raise OpenSSL::SSL::SSLError.new } } + + it "should raise a Bundler::Fetcher::CertificateFailureError" do + expect { subject.request(uri, options) }.to raise_error(Bundler::Fetcher::CertificateFailureError, + %r{Could not verify the SSL certificate for http://www.uri-to-fetch.com/api/v2/endpoint}) + end + end + + context "when the request response causes a Gem::RemoteFetcher::FetchError" do + let(:message) { "error about network" } + let(:error) { Gem::RemoteFetcher::FetchError.new(message, uri) } + + before do + allow(connections).to receive(:request).with(uri, options) { raise error } + end + + it "should raise a Bundler::HTTPError" do + expect { subject.request(uri, options) }.to raise_error(Bundler::HTTPError, + %r{Network error while fetching http://www\.uri-to-fetch\.com/api/v2/endpoint \(error about network}) end - context "and it's used as the authentication token" do - let(:uri) { Gem::URI("http://username@www.uri-to-fetch.com/api/v2/endpoint") } + context "when the error is about a failed certificate verification" do + let(:message) { "SSL_connect returned=1 errno=0 peeraddr=127.0.0.1:443 state=error: certificate verify failed" } - it "should request basic authentication with just the user, and log the HTTP GET request to debug, without the token" do - expect(net_http_get).to receive(:basic_auth).with("username", nil) - expect(Bundler).to receive_message_chain(:ui, :debug).with("HTTP GET http://www.uri-to-fetch.com/api/v2/endpoint") - subject.request(uri, options) + it "should raise a Bundler::Fetcher::CertificateFailureError" do + expect { subject.request(uri, options) }.to raise_error(Bundler::Fetcher::CertificateFailureError, + %r{Could not verify the SSL certificate for http://www.uri-to-fetch.com/api/v2/endpoint}) end end - context "and it's used as the authentication token, and contains cgi escaped characters" do - let(:uri) { Gem::URI("http://username%24@www.uri-to-fetch.com/api/v2/endpoint") } + context "when the error is about the host being down" do + let(:message) { "Host is down" } - it "should request basic authentication with the proper cgi compliant password user, and log the HTTP GET request to debug, without the token" do - expect(net_http_get).to receive(:basic_auth).with("username$", nil) - expect(Bundler).to receive_message_chain(:ui, :debug).with("HTTP GET http://www.uri-to-fetch.com/api/v2/endpoint") - subject.request(uri, options) + it "should raise a Bundler::Fetcher::NetworkDownError" do + expect { subject.request(uri, options) }.to raise_error(Bundler::Fetcher::NetworkDownError, + /Could not reach host www.uri-to-fetch.com/) end end - end - context "when the request response causes a OpenSSL::SSL::SSLError" do - before { allow(connection).to receive(:request).with(uri, net_http_get) { raise OpenSSL::SSL::SSLError.new } } + context "when there are credentials provided in the request" do + let(:uri) { Gem::URI("http://username:password@www.uri-to-fetch.com/api/v2/endpoint") } - it "should raise a LoadError about openssl" do - expect { subject.request(uri, options) }.to raise_error(Bundler::Fetcher::CertificateFailureError, - %r{Could not verify the SSL certificate for http://www.uri-to-fetch.com/api/v2/endpoint}) + it "should raise a Bundler::HTTPError that doesn't contain the password" do + expect { subject.request(uri, options) }.to raise_error(Bundler::HTTPError) do |error| + expect(error.message).not_to include("password") + end + end end end @@ -238,7 +246,7 @@ let(:error) { error_class.new(message) } before do - allow(connection).to receive(:request).with(uri, net_http_get) { raise error } + allow(connections).to receive(:request).with(uri, options) { raise error } end context "that it's retryable" do @@ -257,9 +265,6 @@ context "when there are credentials provided in the request" do let(:uri) { Gem::URI("http://username:password@www.uri-to-fetch.com/api/v2/endpoint") } - before do - allow(net_http_get).to receive(:basic_auth).with("username", "password") - end it "should raise a Bundler::HTTPError that doesn't contain the password" do expect { subject.request(uri, options) }.to raise_error(Bundler::HTTPError, @@ -268,19 +273,8 @@ end end - context "when error is about the host being down" do - let(:error_class) { Gem::Net::HTTP::Persistent::Error } - let(:message) { "host down: http://www.uri-to-fetch.com" } - - it "should raise a Bundler::Fetcher::NetworkDownError" do - expect { subject.request(uri, options) }.to raise_error(Bundler::Fetcher::NetworkDownError, - /Could not reach host www.uri-to-fetch.com/) - end - end - context "when error is about connection refused" do - let(:error_class) { Gem::Net::HTTP::Persistent::Error } - let(:message) { "connection refused down: http://www.uri-to-fetch.com" } + let(:error_class) { Errno::ECONNREFUSED } it "should raise a Bundler::Fetcher::NetworkDownError" do expect { subject.request(uri, options) }.to raise_error(Bundler::Fetcher::NetworkDownError, diff --git a/spec/bundler/bundler/fetcher_spec.rb b/spec/bundler/bundler/fetcher_spec.rb index 6891a7991de636..87a49ea6fc7865 100644 --- a/spec/bundler/bundler/fetcher_spec.rb +++ b/spec/bundler/bundler/fetcher_spec.rb @@ -78,18 +78,22 @@ end end it "consider no_proxy" do - with_env_vars("HTTP_PROXY" => "http://proxy-example4.com", "NO_PROXY" => ".example.com,.example.net") do - expect( - fetcher.send(:connection).no_proxy - ).to eq([".example.com", ".example.net"]) + with_env_vars("HTTP_PROXY" => "http://proxy-example4.com", "NO_PROXY" => "example.com,.example.net") do + expect(fetcher.http_proxy).to be_nil end end end + def configured_connection + http = Gem::Net::HTTP.new(uri.host, uri.port) + fetcher.send(:connection).send(:configure_ssl, http) + http + end + context "when no ssl configuration is set" do it "no cert" do - expect(fetcher.send(:connection).cert).to be_nil - expect(fetcher.send(:connection).key).to be_nil + expect(configured_connection.cert).to be_nil + expect(configured_connection.key).to be_nil end end @@ -106,8 +110,9 @@ FileUtils.rm File.join(Spec::Path.tmpdir, "cert") end it "use bundler configuration" do - expect(fetcher.send(:connection).cert).to eq("cert") - expect(fetcher.send(:connection).key).to eq("key") + connection = configured_connection + expect(connection.cert).to eq("cert") + expect(connection.key).to eq("key") end end @@ -126,8 +131,9 @@ expect(OpenSSL::X509::Store).to receive(:new).and_return(store) end it "use gem configuration" do - expect(fetcher.send(:connection).cert).to eq("cert") - expect(fetcher.send(:connection).key).to eq("key") + connection = configured_connection + expect(connection.cert).to eq("cert") + expect(connection.key).to eq("key") end end end From d893b71ad0940db4d005d11e66d1d5b185c816f7 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 3 Aug 2026 11:46:28 +0900 Subject: [PATCH 05/12] [ruby/rubygems] Drop vendored net-http-persistent and connection_pool Nothing references them since Bundler metadata fetching moved to Gem::Request. connection_pool was only vendored as a dependency of net-http-persistent, so it goes away too, along with their automatiek entries and patches. https://github.com/ruby/rubygems/commit/eaf49e1329 Co-Authored-By: Claude Fable 5 --- .../connection_pool/lib/connection_pool.rb | 185 --- .../lib/connection_pool/fork.rb | 40 - .../lib/connection_pool/timed_stack.rb | 236 ---- .../lib/connection_pool/version.rb | 3 - .../lib/connection_pool/wrapper.rb | 43 - .../lib/net/http/persistent.rb | 1159 ----------------- .../lib/net/http/persistent/connection.rb | 41 - .../lib/net/http/persistent/pool.rb | 65 - .../net/http/persistent/timed_stack_multi.rb | 89 -- lib/bundler/vendored_persistent.rb | 11 - .../fetcher/gem_remote_fetcher_spec.rb | 1 - .../installer/parallel_installer_spec.rb | 10 - spec/bundler/commands/ssl_spec.rb | 1 - .../install/gems/compact_index_spec.rb | 24 - .../install/gems/dependency_api_spec.rb | 24 - .../support/artifice/helpers/artifice.rb | 8 - 16 files changed, 1940 deletions(-) delete mode 100644 lib/bundler/vendor/connection_pool/lib/connection_pool.rb delete mode 100644 lib/bundler/vendor/connection_pool/lib/connection_pool/fork.rb delete mode 100644 lib/bundler/vendor/connection_pool/lib/connection_pool/timed_stack.rb delete mode 100644 lib/bundler/vendor/connection_pool/lib/connection_pool/version.rb delete mode 100644 lib/bundler/vendor/connection_pool/lib/connection_pool/wrapper.rb delete mode 100644 lib/bundler/vendor/net-http-persistent/lib/net/http/persistent.rb delete mode 100644 lib/bundler/vendor/net-http-persistent/lib/net/http/persistent/connection.rb delete mode 100644 lib/bundler/vendor/net-http-persistent/lib/net/http/persistent/pool.rb delete mode 100644 lib/bundler/vendor/net-http-persistent/lib/net/http/persistent/timed_stack_multi.rb delete mode 100644 lib/bundler/vendored_persistent.rb diff --git a/lib/bundler/vendor/connection_pool/lib/connection_pool.rb b/lib/bundler/vendor/connection_pool/lib/connection_pool.rb deleted file mode 100644 index c52694160800d7..00000000000000 --- a/lib/bundler/vendor/connection_pool/lib/connection_pool.rb +++ /dev/null @@ -1,185 +0,0 @@ -require_relative "../../../vendored_timeout" -require_relative "connection_pool/version" - -class Bundler::ConnectionPool - class Error < ::RuntimeError; end - - class PoolShuttingDownError < ::Bundler::ConnectionPool::Error; end - - class TimeoutError < ::Gem::Timeout::Error; end -end - -# Generic connection pool class for sharing a limited number of objects or network connections -# among many threads. Note: pool elements are lazily created. -# -# Example usage with block (faster): -# -# @pool = Bundler::ConnectionPool.new { Redis.new } -# @pool.with do |redis| -# redis.lpop('my-list') if redis.llen('my-list') > 0 -# end -# -# Using optional timeout override (for that single invocation) -# -# @pool.with(timeout: 2.0) do |redis| -# redis.lpop('my-list') if redis.llen('my-list') > 0 -# end -# -# Example usage replacing an existing connection (slower): -# -# $redis = Bundler::ConnectionPool.wrap { Redis.new } -# -# def do_work -# $redis.lpop('my-list') if $redis.llen('my-list') > 0 -# end -# -# Accepts the following options: -# - :size - number of connections to pool, defaults to 5 -# - :timeout - amount of time to wait for a connection if none currently available, defaults to 5 seconds -# - :auto_reload_after_fork - automatically drop all connections after fork, defaults to true -# -class Bundler::ConnectionPool - def self.wrap(**, &) - Wrapper.new(**, &) - end - - attr_reader :size - - def initialize(timeout: 5, size: 5, auto_reload_after_fork: true, name: nil, &) - raise ArgumentError, "Connection pool requires a block" unless block_given? - - @size = Integer(size) - @timeout = Float(timeout) - @available = TimedStack.new(size: @size, &) - @key = :"pool-#{@available.object_id}" - @key_count = :"pool-#{@available.object_id}-count" - @discard_key = :"pool-#{@available.object_id}-discard" - INSTANCES[self] = self if auto_reload_after_fork && INSTANCES - end - - def with(**) - # We need to manage exception handling manually here in order - # to work correctly with `Gem::Timeout.timeout` and `Thread#raise`. - # Otherwise an interrupted Thread can leak connections. - Thread.handle_interrupt(Exception => :never) do - conn = checkout(**) - begin - Thread.handle_interrupt(Exception => :immediate) do - yield conn - end - ensure - checkin - end - end - end - alias_method :then, :with - - ## - # Marks the current thread's checked-out connection for discard. - # - # When a connection is marked for discard, it will not be returned to the pool - # when checked in. Instead, the connection will be discarded. - # This is useful when a connection has become invalid or corrupted - # and should not be reused. - # - # Takes an optional block that will be called with the connection to be discarded. - # The block should perform any necessary clean-up on the connection. - # - # @yield [conn] - # @yieldparam conn [Object] The connection to be discarded. - # @yieldreturn [void] - # - # - # Note: This only affects the connection currently checked out by the calling thread. - # The connection will be discarded when +checkin+ is called. - # - # @return [void] - # - # @example - # pool.with do |conn| - # begin - # conn.execute("SELECT 1") - # rescue SomeConnectionError - # pool.discard_current_connection # Mark connection as bad - # raise - # end - # end - def discard_current_connection(&block) - ::Thread.current[@discard_key] = block || proc { |conn| conn } - end - - def checkout(timeout: @timeout, **) - if ::Thread.current[@key] - ::Thread.current[@key_count] += 1 - ::Thread.current[@key] - else - conn = @available.pop(timeout:, **) - ::Thread.current[@key] = conn - ::Thread.current[@key_count] = 1 - conn - end - end - - def checkin(force: false) - if ::Thread.current[@key] - if ::Thread.current[@key_count] == 1 || force - if ::Thread.current[@discard_key] - begin - @available.decrement_created - ::Thread.current[@discard_key].call(::Thread.current[@key]) - rescue - nil - ensure - ::Thread.current[@discard_key] = nil - end - else - @available.push(::Thread.current[@key]) - end - ::Thread.current[@key] = nil - ::Thread.current[@key_count] = nil - else - ::Thread.current[@key_count] -= 1 - end - elsif !force - raise Bundler::ConnectionPool::Error, "no connections are checked out" - end - - nil - end - - ## - # Shuts down the Bundler::ConnectionPool by passing each connection to +block+ and - # then removing it from the pool. Attempting to checkout a connection after - # shutdown will raise +Bundler::ConnectionPool::PoolShuttingDownError+. - def shutdown(&) - @available.shutdown(&) - end - - ## - # Reloads the Bundler::ConnectionPool by passing each connection to +block+ and then - # removing it the pool. Subsequent checkouts will create new connections as - # needed. - def reload(&) - @available.shutdown(reload: true, &) - end - - ## Reaps idle connections that have been idle for over +idle_seconds+. - # +idle_seconds+ defaults to 60. - def reap(idle_seconds: 60, &) - @available.reap(idle_seconds:, &) - end - - # Number of pool entries available for checkout at this instant. - def available - @available.length - end - - # Number of pool entries created and idle in the pool. - def idle - @available.idle - end -end - -require_relative "connection_pool/timed_stack" -require_relative "connection_pool/wrapper" -require_relative "connection_pool/fork" diff --git a/lib/bundler/vendor/connection_pool/lib/connection_pool/fork.rb b/lib/bundler/vendor/connection_pool/lib/connection_pool/fork.rb deleted file mode 100644 index 69683d16c26800..00000000000000 --- a/lib/bundler/vendor/connection_pool/lib/connection_pool/fork.rb +++ /dev/null @@ -1,40 +0,0 @@ -class Bundler::ConnectionPool - if Process.respond_to?(:fork) - INSTANCES = ObjectSpace::WeakMap.new - private_constant :INSTANCES - - def self.after_fork - INSTANCES.each_value do |pool| - # We're in after_fork, so we know all other threads are dead. - # All we need to do is ensure the main thread doesn't have a - # checked out connection - pool.checkin(force: true) - pool.reload do |connection| - # Unfortunately we don't know what method to call to close the connection, - # so we try the most common one. - connection.close if connection.respond_to?(:close) - end - end - nil - end - - module ForkTracker - def _fork - pid = super - if pid == 0 - Bundler::ConnectionPool.after_fork - end - pid - end - end - Process.singleton_class.prepend(ForkTracker) - else - # JRuby, et al - INSTANCES = nil - private_constant :INSTANCES - - def self.after_fork - # noop - end - end -end diff --git a/lib/bundler/vendor/connection_pool/lib/connection_pool/timed_stack.rb b/lib/bundler/vendor/connection_pool/lib/connection_pool/timed_stack.rb deleted file mode 100644 index d62a44159b5db7..00000000000000 --- a/lib/bundler/vendor/connection_pool/lib/connection_pool/timed_stack.rb +++ /dev/null @@ -1,236 +0,0 @@ -## -# The TimedStack manages a pool of homogeneous connections (or any resource -# you wish to manage). Connections are created lazily up to a given maximum -# number. -# -# Examples: -# -# ts = TimedStack.new(size: 1) { MyConnection.new } -# -# # fetch a connection -# conn = ts.pop -# -# # return a connection -# ts.push conn -# -# conn = ts.pop -# ts.pop timeout: 5 -# #=> raises Bundler::ConnectionPool::TimeoutError after 5 seconds -class Bundler::ConnectionPool::TimedStack - attr_reader :max - - ## - # Creates a new pool with +size+ connections that are created from the given - # +block+. - def initialize(size: 0, &block) - @create_block = block - @created = 0 - @que = [] - @max = size - @mutex = Thread::Mutex.new - @resource = Thread::ConditionVariable.new - @shutdown_block = nil - end - - ## - # Returns +obj+ to the stack. Additional kwargs are ignored in TimedStack but may be - # used by subclasses that extend TimedStack. - def push(obj, **) - @mutex.synchronize do - if @shutdown_block - @created -= 1 unless @created == 0 - @shutdown_block.call(obj) - else - store_connection obj, ** - end - - @resource.broadcast - end - end - alias_method :<<, :push - - ## - # Retrieves a connection from the stack. If a connection is available it is - # immediately returned. If no connection is available within the given - # timeout a Bundler::ConnectionPool::TimeoutError is raised. - # - # @option options [Float] :timeout (0.5) Wait this many seconds for an available entry - # @option options [Class] :exception (Bundler::ConnectionPool::TimeoutError) Exception class to raise - # if an entry was not available within the timeout period. Use `exception: false` to return nil. - # - # Other options may be used by subclasses that extend TimedStack. - def pop(timeout: 0.5, exception: Bundler::ConnectionPool::TimeoutError, **) - deadline = current_time + timeout - @mutex.synchronize do - loop do - raise Bundler::ConnectionPool::PoolShuttingDownError if @shutdown_block - if (conn = try_fetch_connection(**)) - return conn - end - - connection = try_create(**) - return connection if connection - - to_wait = deadline - current_time - if to_wait <= 0 - if exception - raise exception, "Waited #{timeout} sec, #{length}/#{@max} available" - else - return nil - end - end - @resource.wait(@mutex, to_wait) - end - end - end - - ## - # Shuts down the TimedStack by passing each connection to +block+ and then - # removing it from the pool. Attempting to checkout a connection after - # shutdown will raise +Bundler::ConnectionPool::PoolShuttingDownError+ unless - # +:reload+ is +true+. - def shutdown(reload: false, &block) - raise ArgumentError, "shutdown must receive a block" unless block - - @mutex.synchronize do - @shutdown_block = block - @resource.broadcast - - shutdown_connections - @shutdown_block = nil if reload - end - end - - ## - # Reaps connections that were checked in more than +idle_seconds+ ago. - def reap(idle_seconds:) - raise ArgumentError, "reap must receive a block" unless block_given? - raise ArgumentError, "idle_seconds must be a number" unless idle_seconds.is_a?(Numeric) - raise Bundler::ConnectionPool::PoolShuttingDownError if @shutdown_block - - count = idle - count.times do - conn = @mutex.synchronize do - raise Bundler::ConnectionPool::PoolShuttingDownError if @shutdown_block - reserve_idle_connection(idle_seconds) - end - break unless conn - - yield conn - end - end - - ## - # Returns +true+ if there are no available connections. - def empty? - (@created - @que.length) >= @max - end - - ## - # The number of connections available on the stack. - def length - @max - @created + @que.length - end - - ## - # The number of connections created and available on the stack. - def idle - @que.length - end - - ## - # Reduce the created count - def decrement_created - @created -= 1 unless @created == 0 - end - - private - - def current_time - Process.clock_gettime(Process::CLOCK_MONOTONIC) - end - - ## - # This is an extension point for TimedStack and is called with a mutex. - # - # This method must returns a connection from the stack if one exists. Allows - # subclasses with expensive match/search algorithms to avoid double-handling - # their stack. - def try_fetch_connection(**) - connection_stored?(**) && fetch_connection(**) - end - - ## - # This is an extension point for TimedStack and is called with a mutex. - # - # This method must returns true if a connection is available on the stack. - def connection_stored?(**) - !@que.empty? - end - - ## - # This is an extension point for TimedStack and is called with a mutex. - # - # This method must return a connection from the stack. - def fetch_connection(**) - @que.pop&.first - end - - ## - # This is an extension point for TimedStack and is called with a mutex. - # - # This method must shut down all connections on the stack. - def shutdown_connections(**) - while (conn = try_fetch_connection(**)) - @created -= 1 unless @created == 0 - @shutdown_block.call(conn) - end - end - - ## - # This is an extension point for TimedStack and is called with a mutex. - # - # This method returns the oldest idle connection if it has been idle for more than idle_seconds. - # This requires that the stack is kept in order of checked in time (oldest first). - def reserve_idle_connection(idle_seconds) - return unless idle_connections?(idle_seconds) - - @created -= 1 unless @created == 0 - - # Most active elements are at the tail of the array. - # Most idle will be at the head so `shift` rather than `pop`. - @que.shift.first - end - - ## - # This is an extension point for TimedStack and is called with a mutex. - # - # Returns true if the first connection in the stack has been idle for more than idle_seconds - def idle_connections?(idle_seconds) - return unless connection_stored? - # Most idle will be at the head so `first` - age = (current_time - @que.first.last) - age > idle_seconds - end - - ## - # This is an extension point for TimedStack and is called with a mutex. - # - # This method must return +obj+ to the stack. - def store_connection(obj, **) - @que.push [obj, current_time] - end - - ## - # This is an extension point for TimedStack and is called with a mutex. - # - # This method must create a connection if and only if the total number of - # connections allowed has not been met. - def try_create(**) - unless @created == @max - object = @create_block.call - @created += 1 - object - end - end -end diff --git a/lib/bundler/vendor/connection_pool/lib/connection_pool/version.rb b/lib/bundler/vendor/connection_pool/lib/connection_pool/version.rb deleted file mode 100644 index 509d5af2db81fd..00000000000000 --- a/lib/bundler/vendor/connection_pool/lib/connection_pool/version.rb +++ /dev/null @@ -1,3 +0,0 @@ -class Bundler::ConnectionPool - VERSION = "3.0.2" -end diff --git a/lib/bundler/vendor/connection_pool/lib/connection_pool/wrapper.rb b/lib/bundler/vendor/connection_pool/lib/connection_pool/wrapper.rb deleted file mode 100644 index d11d6c8eb4b129..00000000000000 --- a/lib/bundler/vendor/connection_pool/lib/connection_pool/wrapper.rb +++ /dev/null @@ -1,43 +0,0 @@ -class Bundler::ConnectionPool - class Wrapper < ::BasicObject - METHODS = [:with, :pool_shutdown, :wrapped_pool] - - def initialize(**options, &) - @pool = options.fetch(:pool) { ::Bundler::ConnectionPool.new(**options, &) } - end - - def wrapped_pool - @pool - end - - def with(**, &) - @pool.with(**, &) - end - - def pool_shutdown(&) - @pool.shutdown(&) - end - - def pool_size - @pool.size - end - - def pool_available - @pool.available - end - - def respond_to?(id, *, **) - METHODS.include?(id) || with { |c| c.respond_to?(id, *, **) } - end - - def respond_to_missing?(id, *, **) - with { |c| c.respond_to?(id, *, **) } - end - - def method_missing(name, *, **, &) - with do |connection| - connection.send(name, *, **, &) - end - end - end -end diff --git a/lib/bundler/vendor/net-http-persistent/lib/net/http/persistent.rb b/lib/bundler/vendor/net-http-persistent/lib/net/http/persistent.rb deleted file mode 100644 index 00f2bca14ad31f..00000000000000 --- a/lib/bundler/vendor/net-http-persistent/lib/net/http/persistent.rb +++ /dev/null @@ -1,1159 +0,0 @@ -require_relative '../../../../../vendored_net_http' -require_relative '../../../../../vendored_uri' -require 'cgi/escape' -require 'cgi/util' unless defined?(CGI::EscapeExt) -require_relative '../../../../connection_pool/lib/connection_pool' - -autoload :OpenSSL, 'openssl' - -## -# Persistent connections for Gem::Net::HTTP -# -# Gem::Net::HTTP::Persistent maintains persistent connections across all the -# servers you wish to talk to. For each host:port you communicate with a -# single persistent connection is created. -# -# Connections will be shared across threads through a connection pool to -# increase reuse of connections. -# -# You can shut down any remaining HTTP connections when done by calling -# #shutdown. -# -# Example: -# -# require 'bundler/vendor/net-http-persistent/lib/net/http/persistent' -# -# uri = Gem::URI 'http://example.com/awesome/web/service' -# -# http = Gem::Net::HTTP::Persistent.new -# -# # perform a GET -# response = http.request uri -# -# # or -# -# get = Gem::Net::HTTP::Get.new uri.request_uri -# response = http.request get -# -# # create a POST -# post_uri = uri + 'create' -# post = Gem::Net::HTTP::Post.new post_uri.path -# post.set_form_data 'some' => 'cool data' -# -# # perform the POST, the Gem::URI is always required -# response http.request post_uri, post -# -# âš  Note that for GET, HEAD and other requests that do not have a body, -# it uses Gem::URI#request_uri as default to send query params -# -# == TLS/SSL -# -# TLS connections are automatically created depending upon the scheme of the -# Gem::URI. TLS connections are automatically verified against the default -# certificate store for your computer. You can override this by changing -# verify_mode or by specifying an alternate cert_store. -# -# Here are the TLS settings, see the individual methods for documentation: -# -# #certificate :: This client's certificate -# #ca_file :: The certificate-authorities -# #ca_path :: Directory with certificate-authorities -# #cert_store :: An SSL certificate store -# #ciphers :: List of SSl ciphers allowed -# #extra_chain_cert :: Extra certificates to be added to the certificate chain -# #private_key :: The client's SSL private key -# #reuse_ssl_sessions :: Reuse a previously opened SSL session for a new -# connection -# #ssl_timeout :: Session lifetime -# #ssl_version :: Which specific SSL version to use -# #verify_callback :: For server certificate verification -# #verify_depth :: Depth of certificate verification -# #verify_mode :: How connections should be verified -# #verify_hostname :: Use hostname verification for server certificate -# during the handshake -# -# == Proxies -# -# A proxy can be set through #proxy= or at initialization time by providing a -# second argument to ::new. The proxy may be the Gem::URI of the proxy server or -# :ENV which will consult environment variables. -# -# See #proxy= and #proxy_from_env for details. -# -# == Headers -# -# Headers may be specified for use in every request. #headers are appended to -# any headers on the request. #override_headers replace existing headers on -# the request. -# -# The difference between the two can be seen in setting the User-Agent. Using -# http.headers['User-Agent'] = 'MyUserAgent' will send "Ruby, -# MyUserAgent" while http.override_headers['User-Agent'] = -# 'MyUserAgent' will send "MyUserAgent". -# -# == Tuning -# -# === Segregation -# -# Each Gem::Net::HTTP::Persistent instance has its own pool of connections. There -# is no sharing with other instances (as was true in earlier versions). -# -# === Idle Timeout -# -# If a connection hasn't been used for this number of seconds it will -# automatically be reset upon the next use to avoid attempting to send to a -# closed connection. The default value is 5 seconds. nil means no timeout. -# Set through #idle_timeout. -# -# Reducing this value may help avoid the "too many connection resets" error -# when sending non-idempotent requests while increasing this value will cause -# fewer round-trips. -# -# === Read Timeout -# -# The amount of time allowed between reading two chunks from the socket. Set -# through #read_timeout -# -# === Max Requests -# -# The number of requests that should be made before opening a new connection. -# Typically many keep-alive capable servers tune this to 100 or less, so the -# 101st request will fail with ECONNRESET. If unset (default), this value has -# no effect, if set, connections will be reset on the request after -# max_requests. -# -# === Open Timeout -# -# The amount of time to wait for a connection to be opened. Set through -# #open_timeout. -# -# === Socket Options -# -# Socket options may be set on newly-created connections. See #socket_options -# for details. -# -# === Connection Termination -# -# If you are done using the Gem::Net::HTTP::Persistent instance you may shut down -# all the connections in the current thread with #shutdown. This is not -# recommended for normal use, it should only be used when it will be several -# minutes before you make another HTTP request. -# -# If you are using multiple threads, call #shutdown in each thread when the -# thread is done making requests. If you don't call shutdown, that's OK. -# Ruby will automatically garbage collect and shutdown your HTTP connections -# when the thread terminates. - -class Gem::Net::HTTP::Persistent - - ## - # The beginning of Time - - EPOCH = Time.at 0 # :nodoc: - - ## - # Is OpenSSL available? This test works with autoload - - HAVE_OPENSSL = defined? OpenSSL::SSL # :nodoc: - - ## - # The default connection pool size is 1/4 the allowed open files - # (ulimit -n) or 256 if your OS does not support file handle - # limits (typically windows). - - if Process.const_defined? :RLIMIT_NOFILE - open_file_limits = Process.getrlimit(Process::RLIMIT_NOFILE) - - # Under JRuby on Windows Process responds to `getrlimit` but returns something that does not match docs - if open_file_limits.respond_to?(:first) - DEFAULT_POOL_SIZE = open_file_limits.first / 4 - else - DEFAULT_POOL_SIZE = 256 - end - else - DEFAULT_POOL_SIZE = 256 - end - - ## - # The version of Gem::Net::HTTP::Persistent you are using - - VERSION = '4.0.8' - - ## - # Error class for errors raised by Gem::Net::HTTP::Persistent. Various - # SystemCallErrors are re-raised with a human-readable message under this - # class. - - class Error < StandardError; end - - ## - # Use this method to detect the idle timeout of the host at +uri+. The - # value returned can be used to configure #idle_timeout. +max+ controls the - # maximum idle timeout to detect. - # - # After - # - # Idle timeout detection is performed by creating a connection then - # performing a HEAD request in a loop until the connection terminates - # waiting one additional second per loop. - # - # NOTE: This may not work on ruby > 1.9. - - def self.detect_idle_timeout uri, max = 10 - uri = Gem::URI uri unless Gem::URI::Generic === uri - uri += '/' - - req = Gem::Net::HTTP::Head.new uri.request_uri - - http = new 'net-http-persistent detect_idle_timeout' - - http.connection_for uri do |connection| - sleep_time = 0 - - http = connection.http - - loop do - response = http.request req - - $stderr.puts "HEAD #{uri} => #{response.code}" if $DEBUG - - unless Gem::Net::HTTPOK === response then - raise Error, "bad response code #{response.code} detecting idle timeout" - end - - break if sleep_time >= max - - sleep_time += 1 - - $stderr.puts "sleeping #{sleep_time}" if $DEBUG - sleep sleep_time - end - end - rescue - # ignore StandardErrors, we've probably found the idle timeout. - ensure - return sleep_time unless $! - end - - ## - # This client's OpenSSL::X509::Certificate - - attr_reader :certificate - - ## - # For Gem::Net::HTTP parity - - alias cert certificate - - ## - # An SSL certificate authority. Setting this will set verify_mode to - # VERIFY_PEER. - - attr_reader :ca_file - - ## - # A directory of SSL certificates to be used as certificate authorities. - # Setting this will set verify_mode to VERIFY_PEER. - - attr_reader :ca_path - - ## - # An SSL certificate store. Setting this will override the default - # certificate store. See verify_mode for more information. - - attr_reader :cert_store - - ## - # The ciphers allowed for SSL connections - - attr_reader :ciphers - - ## - # Extra certificates to be added to the certificate chain - - attr_reader :extra_chain_cert - - ## - # Sends debug_output to this IO via Gem::Net::HTTP#set_debug_output. - # - # Never use this method in production code, it causes a serious security - # hole. - - attr_accessor :debug_output - - ## - # Current connection generation - - attr_reader :generation # :nodoc: - - ## - # Headers that are added to every request using Gem::Net::HTTP#add_field - - attr_reader :headers - - ## - # Maps host:port to an HTTP version. This allows us to enable version - # specific features. - - attr_reader :http_versions - - ## - # Maximum time an unused connection can remain idle before being - # automatically closed. - - attr_accessor :idle_timeout - - ## - # Maximum number of requests on a connection before it is considered expired - # and automatically closed. - - attr_accessor :max_requests - - ## - # Number of retries to perform if a request fails. - # - # See also #max_retries=, Gem::Net::HTTP#max_retries=. - - attr_reader :max_retries - - ## - # The value sent in the Keep-Alive header. Defaults to 30. Not needed for - # HTTP/1.1 servers. - # - # This may not work correctly for HTTP/1.0 servers - # - # This method may be removed in a future version as RFC 2616 does not - # require this header. - - attr_accessor :keep_alive - - ## - # The name for this collection of persistent connections. - - attr_reader :name - - ## - # Seconds to wait until a connection is opened. See Gem::Net::HTTP#open_timeout - - attr_accessor :open_timeout - - ## - # Headers that are added to every request using Gem::Net::HTTP#[]= - - attr_reader :override_headers - - ## - # This client's SSL private key - - attr_reader :private_key - - ## - # For Gem::Net::HTTP parity - - alias key private_key - - ## - # The URL through which requests will be proxied - - attr_reader :proxy_uri - - ## - # List of host suffixes which will not be proxied - - attr_reader :no_proxy - - ## - # Test-only accessor for the connection pool - - attr_reader :pool # :nodoc: - - ## - # Seconds to wait until reading one block. See Gem::Net::HTTP#read_timeout - - attr_accessor :read_timeout - - ## - # Seconds to wait until writing one block. See Gem::Net::HTTP#write_timeout - - attr_accessor :write_timeout - - ## - # By default SSL sessions are reused to avoid extra SSL handshakes. Set - # this to false if you have problems communicating with an HTTPS server - # like: - # - # SSL_connect [...] read finished A: unexpected message (OpenSSL::SSL::SSLError) - - attr_accessor :reuse_ssl_sessions - - ## - # An array of options for Socket#setsockopt. - # - # By default the TCP_NODELAY option is set on sockets. - # - # To set additional options append them to this array: - # - # http.socket_options << [Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, 1] - - attr_reader :socket_options - - ## - # Current SSL connection generation - - attr_reader :ssl_generation # :nodoc: - - ## - # SSL session lifetime - - attr_reader :ssl_timeout - - ## - # SSL version to use. - # - # By default, the version will be negotiated automatically between client - # and server. Ruby 1.9 and newer only. Deprecated since Ruby 2.5. - - attr_reader :ssl_version - - ## - # Minimum SSL version to use, e.g. :TLS1_1 - # - # By default, the version will be negotiated automatically between client - # and server. Ruby 2.5 and newer only. - - attr_reader :min_version - - ## - # Maximum SSL version to use, e.g. :TLS1_2 - # - # By default, the version will be negotiated automatically between client - # and server. Ruby 2.5 and newer only. - - attr_reader :max_version - - ## - # Where this instance's last-use times live in the thread local variables - - attr_reader :timeout_key # :nodoc: - - ## - # SSL verification callback. Used when ca_file or ca_path is set. - - attr_reader :verify_callback - - ## - # Sets the depth of SSL certificate verification - - attr_reader :verify_depth - - ## - # HTTPS verify mode. Defaults to OpenSSL::SSL::VERIFY_PEER which verifies - # the server certificate. - # - # If no ca_file, ca_path or cert_store is set the default system certificate - # store is used. - # - # You can use +verify_mode+ to override any default values. - - attr_reader :verify_mode - - ## - # HTTPS verify_hostname. - # - # If a client sets this to true and enables SNI with SSLSocket#hostname=, - # the hostname verification on the server certificate is performed - # automatically during the handshake using - # OpenSSL::SSL.verify_certificate_identity(). - # - # You can set +verify_hostname+ as true to use hostname verification - # during the handshake. - # - # NOTE: This works with Ruby > 3.0. - - attr_reader :verify_hostname - - - ## - # Sets whether to ignore end-of-file when reading a response body - # with Content-Length headers. - - attr_accessor :ignore_eof - - ## - # Creates a new Gem::Net::HTTP::Persistent. - # - # Set a +name+ for fun. Your library name should be good enough, but this - # otherwise has no purpose. - # - # +proxy+ may be set to a Gem::URI::HTTP or :ENV to pick up proxy options from - # the environment. See proxy_from_env for details. - # - # In order to use a Gem::URI for the proxy you may need to do some extra work - # beyond Gem::URI parsing if the proxy requires a password: - # - # proxy = Gem::URI 'http://proxy.example' - # proxy.user = 'AzureDiamond' - # proxy.password = 'hunter2' - # - # Set +pool_size+ to limit the maximum number of connections allowed. - # Defaults to 1/4 the number of allowed file handles or 256 if your OS does - # not support a limit on allowed file handles. You can have no more than - # this many threads with active HTTP transactions. - - def initialize name: nil, proxy: nil, pool_size: DEFAULT_POOL_SIZE - @name = name - - @debug_output = nil - @proxy_uri = nil - @no_proxy = [] - @headers = {} - @override_headers = {} - @http_versions = {} - @keep_alive = 30 - @open_timeout = nil - @read_timeout = nil - @write_timeout = nil - @idle_timeout = 5 - @max_requests = nil - @max_retries = 1 - @socket_options = [] - @ssl_generation = 0 # incremented when SSL session variables change - @ignore_eof = nil - - @socket_options << [Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1] if - Socket.const_defined? :TCP_NODELAY - - @pool = Gem::Net::HTTP::Persistent::Pool.new size: pool_size do |http_args| - Gem::Net::HTTP::Persistent::Connection.new Gem::Net::HTTP, http_args, @ssl_generation - end - - @certificate = nil - @ca_file = nil - @ca_path = nil - @ciphers = nil - @private_key = nil - @ssl_timeout = nil - @ssl_version = nil - @min_version = nil - @max_version = nil - @verify_callback = nil - @verify_depth = nil - @verify_mode = nil - @verify_hostname = nil - @cert_store = nil - - @generation = 0 # incremented when proxy Gem::URI changes - - if HAVE_OPENSSL then - @verify_mode = OpenSSL::SSL::VERIFY_PEER - @reuse_ssl_sessions = OpenSSL::SSL.const_defined? :Session - end - - self.proxy = proxy if proxy - end - - ## - # Sets this client's OpenSSL::X509::Certificate - - def certificate= certificate - @certificate = certificate - - reconnect_ssl - end - - # For Gem::Net::HTTP parity - alias cert= certificate= - - ## - # Sets the SSL certificate authority file. - - def ca_file= file - @ca_file = file - - reconnect_ssl - end - - ## - # Sets the SSL certificate authority path. - - def ca_path= path - @ca_path = path - - reconnect_ssl - end - - ## - # Overrides the default SSL certificate store used for verifying - # connections. - - def cert_store= store - @cert_store = store - - reconnect_ssl - end - - ## - # The ciphers allowed for SSL connections - - def ciphers= ciphers - @ciphers = ciphers - - reconnect_ssl - end - - if Gem::Net::HTTP.method_defined?(:extra_chain_cert=) - ## - # Extra certificates to be added to the certificate chain. - # It is only supported starting from Gem::Net::HTTP version 0.1.1 - def extra_chain_cert= extra_chain_cert - @extra_chain_cert = extra_chain_cert - - reconnect_ssl - end - else - def extra_chain_cert= _extra_chain_cert - raise "extra_chain_cert= is not supported by this version of Gem::Net::HTTP" - end - end - - ## - # Creates a new connection for +uri+ - - def connection_for uri - use_ssl = uri.scheme.downcase == 'https' - - net_http_args = [uri.hostname, uri.port] - - # I'm unsure if uri.host or uri.hostname should be checked against - # the proxy bypass list. - if @proxy_uri and not proxy_bypass? uri.host, uri.port then - net_http_args.concat @proxy_args - else - net_http_args.concat [nil, nil, nil, nil] - end - - connection = @pool.checkout net_http_args - - begin - http = connection.http - - connection.ressl @ssl_generation if - connection.ssl_generation != @ssl_generation - - if not http.started? then - ssl http if use_ssl - start http - elsif expired? connection then - reset connection - end - - http.ignore_eof = @ignore_eof if @ignore_eof - http.keep_alive_timeout = @idle_timeout if @idle_timeout - http.max_retries = @max_retries if http.respond_to?(:max_retries=) - http.read_timeout = @read_timeout if @read_timeout - http.write_timeout = @write_timeout if - @write_timeout && http.respond_to?(:write_timeout=) - - return yield connection - rescue Errno::ECONNREFUSED - if http.proxy? - address = http.proxy_address - port = http.proxy_port - else - address = http.address - port = http.port - end - - raise Error, "connection refused: #{address}:#{port}" - rescue Errno::EHOSTDOWN - if http.proxy? - address = http.proxy_address - port = http.proxy_port - else - address = http.address - port = http.port - end - - raise Error, "host down: #{address}:#{port}" - ensure - @pool.checkin net_http_args - end - end - - ## - # CGI::escape wrapper - - def escape str - CGI.escape str if str - end - - ## - # CGI::unescape wrapper - - def unescape str - CGI.unescape str if str - end - - - ## - # Returns true if the connection should be reset due to an idle timeout, or - # maximum request count, false otherwise. - - def expired? connection - return true if @max_requests && connection.requests >= @max_requests - return false unless @idle_timeout - return true if @idle_timeout.zero? - - Time.now - connection.last_use > @idle_timeout - end - - ## - # Starts the Gem::Net::HTTP +connection+ - - def start http - http.set_debug_output @debug_output if @debug_output - http.open_timeout = @open_timeout if @open_timeout - - http.start - - socket = http.instance_variable_get :@socket - - if socket then # for fakeweb - @socket_options.each do |option| - socket.io.setsockopt(*option) - end - end - end - - ## - # Finishes the Gem::Net::HTTP +connection+ - - def finish connection - connection.finish - - connection.http.instance_variable_set :@last_communicated, nil - connection.http.instance_variable_set :@ssl_session, nil unless - @reuse_ssl_sessions - end - - ## - # Returns the HTTP protocol version for +uri+ - - def http_version uri - @http_versions["#{uri.hostname}:#{uri.port}"] - end - - ## - # Adds "http://" to the String +uri+ if it is missing. - - def normalize_uri uri - (uri =~ /^https?:/) ? uri : "http://#{uri}" - end - - ## - # Set the maximum number of retries for a request. - # - # Defaults to one retry. - # - # Set this to 0 to disable retries. - - def max_retries= retries - retries = retries.to_int - - raise ArgumentError, "max_retries must be positive" if retries < 0 - - @max_retries = retries - - reconnect - end - - ## - # Sets this client's SSL private key - - def private_key= key - @private_key = key - - reconnect_ssl - end - - # For Gem::Net::HTTP parity - alias key= private_key= - - ## - # Sets the proxy server. The +proxy+ may be the Gem::URI of the proxy server, - # the symbol +:ENV+ which will read the proxy from the environment or nil to - # disable use of a proxy. See #proxy_from_env for details on setting the - # proxy from the environment. - # - # If the proxy Gem::URI is set after requests have been made, the next request - # will shut-down and re-open all connections. - # - # The +no_proxy+ query parameter can be used to specify hosts which shouldn't - # be reached via proxy; if set it should be a comma separated list of - # hostname suffixes, optionally with +:port+ appended, for example - # example.com,some.host:8080. - - def proxy= proxy - @proxy_uri = case proxy - when :ENV then proxy_from_env - when Gem::URI::HTTP then proxy - when nil then # ignore - else raise ArgumentError, 'proxy must be :ENV or a Gem::URI::HTTP' - end - - @no_proxy.clear - - if @proxy_uri then - @proxy_args = [ - @proxy_uri.hostname, - @proxy_uri.port, - unescape(@proxy_uri.user), - unescape(@proxy_uri.password), - ] - - @proxy_connection_id = [nil, *@proxy_args].join ':' - - if @proxy_uri.query then - @no_proxy = Gem::URI.decode_www_form(@proxy_uri.query).filter_map { |k, v| v if k == 'no_proxy' }.join(',').downcase.split(',').map { |x| x.strip }.reject { |x| x.empty? } - end - end - - reconnect - reconnect_ssl - end - - ## - # Creates a Gem::URI for an HTTP proxy server from ENV variables. - # - # If +HTTP_PROXY+ is set a proxy will be returned. - # - # If +HTTP_PROXY_USER+ or +HTTP_PROXY_PASS+ are set the Gem::URI is given the - # indicated user and password unless HTTP_PROXY contains either of these in - # the Gem::URI. - # - # The +NO_PROXY+ ENV variable can be used to specify hosts which shouldn't - # be reached via proxy; if set it should be a comma separated list of - # hostname suffixes, optionally with +:port+ appended, for example - # example.com,some.host:8080. When set to * no proxy will - # be returned. - # - # For Windows users, lowercase ENV variables are preferred over uppercase ENV - # variables. - - def proxy_from_env - env_proxy = ENV['http_proxy'] || ENV['HTTP_PROXY'] - - return nil if env_proxy.nil? or env_proxy.empty? - - uri = Gem::URI normalize_uri env_proxy - - env_no_proxy = ENV['no_proxy'] || ENV['NO_PROXY'] - - # '*' is special case for always bypass - return nil if env_no_proxy == '*' - - if env_no_proxy then - uri.query = "no_proxy=#{escape(env_no_proxy)}" - end - - unless uri.user or uri.password then - uri.user = escape ENV['http_proxy_user'] || ENV['HTTP_PROXY_USER'] - uri.password = escape ENV['http_proxy_pass'] || ENV['HTTP_PROXY_PASS'] - end - - uri - end - - ## - # Returns true when proxy should by bypassed for host. - - def proxy_bypass? host, port - host = host.downcase - host_port = [host, port].join ':' - - @no_proxy.each do |name| - return true if host[-name.length, name.length] == name or - host_port[-name.length, name.length] == name - end - - false - end - - ## - # Forces reconnection of all HTTP connections, including TLS/SSL - # connections. - - def reconnect - @generation += 1 - end - - ## - # Forces reconnection of only TLS/SSL connections. - - def reconnect_ssl - @ssl_generation += 1 - end - - ## - # Finishes then restarts the Gem::Net::HTTP +connection+ - - def reset connection - http = connection.http - - finish connection - - start http - rescue Errno::ECONNREFUSED - e = Error.new "connection refused: #{http.address}:#{http.port}" - e.set_backtrace $@ - raise e - rescue Errno::EHOSTDOWN - e = Error.new "host down: #{http.address}:#{http.port}" - e.set_backtrace $@ - raise e - end - - ## - # Makes a request on +uri+. If +req+ is nil a Gem::Net::HTTP::Get is performed - # against +uri+. - # - # If a block is passed #request behaves like Gem::Net::HTTP#request (the body of - # the response will not have been read). - # - # +req+ must be a Gem::Net::HTTPGenericRequest subclass (see Gem::Net::HTTP for a list). - - def request uri, req = nil, &block - uri = Gem::URI uri - req = request_setup req || uri - response = nil - - connection_for uri do |connection| - http = connection.http - - begin - connection.requests += 1 - - response = http.request req, &block - - if req.connection_close? or - (response.http_version <= '1.0' and - not response.connection_keep_alive?) or - response.connection_close? then - finish connection - end - rescue Exception # make sure to close the connection when it was interrupted - finish connection - - raise - ensure - connection.last_use = Time.now - end - end - - @http_versions["#{uri.hostname}:#{uri.port}"] ||= response.http_version - - response - end - - ## - # Creates a GET request if +req_or_uri+ is a Gem::URI and adds headers to the - # request. - # - # Returns the request. - - def request_setup req_or_uri # :nodoc: - req = if req_or_uri.respond_to? 'request_uri' then - Gem::Net::HTTP::Get.new req_or_uri.request_uri - else - req_or_uri - end - - @headers.each do |pair| - req.add_field(*pair) - end - - @override_headers.each do |name, value| - req[name] = value - end - - unless req['Connection'] then - req.add_field 'Connection', 'keep-alive' - req.add_field 'Keep-Alive', @keep_alive - end - - req - end - - ## - # Shuts down all connections. Attempting to checkout a connection after - # shutdown will raise an error. - # - # *NOTE*: Calling shutdown for can be dangerous! - # - # If any thread is still using a connection it may cause an error! Call - # #shutdown when you are completely done making requests! - - def shutdown - @pool.shutdown { |http| http.finish } - end - - ## - # Discard all existing connections. Subsequent checkouts will create - # new connections as needed. - # - # If any thread is still using a connection it may cause an error! Call - # #reload when you are completely done making requests! - - def reload - @pool.reload { |http| http.finish } - end - - ## - # Enables SSL on +connection+ - - def ssl connection - connection.use_ssl = true - - connection.ciphers = @ciphers if @ciphers - connection.ssl_timeout = @ssl_timeout if @ssl_timeout - connection.ssl_version = @ssl_version if @ssl_version - connection.min_version = @min_version if @min_version - connection.max_version = @max_version if @max_version - - connection.verify_depth = @verify_depth - connection.verify_mode = @verify_mode - connection.verify_hostname = @verify_hostname if - @verify_hostname != nil && connection.respond_to?(:verify_hostname=) - - if OpenSSL::SSL::VERIFY_PEER == OpenSSL::SSL::VERIFY_NONE and - not Object.const_defined?(:I_KNOW_THAT_OPENSSL_VERIFY_PEER_EQUALS_VERIFY_NONE_IS_WRONG) then - warn <<-WARNING - !!!SECURITY WARNING!!! - -The SSL HTTP connection to: - - #{connection.address}:#{connection.port} - - !!!MAY NOT BE VERIFIED!!! - -On your platform your OpenSSL implementation is broken. - -There is no difference between the values of VERIFY_NONE and VERIFY_PEER. - -This means that attempting to verify the security of SSL connections may not -work. This exposes you to man-in-the-middle exploits, snooping on the -contents of your connection and other dangers to the security of your data. - -To disable this warning define the following constant at top-level in your -application: - - I_KNOW_THAT_OPENSSL_VERIFY_PEER_EQUALS_VERIFY_NONE_IS_WRONG = nil - - WARNING - end - - connection.ca_file = @ca_file if @ca_file - connection.ca_path = @ca_path if @ca_path - - if @ca_file or @ca_path then - connection.verify_mode = OpenSSL::SSL::VERIFY_PEER - connection.verify_callback = @verify_callback if @verify_callback - end - - if @certificate and @private_key then - connection.cert = @certificate - connection.key = @private_key - end - - if defined?(@extra_chain_cert) and @extra_chain_cert - connection.extra_chain_cert = @extra_chain_cert - end - - connection.cert_store = if @cert_store then - @cert_store - else - store = OpenSSL::X509::Store.new - store.set_default_paths - store - end - end - - ## - # SSL session lifetime - - def ssl_timeout= ssl_timeout - @ssl_timeout = ssl_timeout - - reconnect_ssl - end - - ## - # SSL version to use - - def ssl_version= ssl_version - @ssl_version = ssl_version - - reconnect_ssl - end - - ## - # Minimum SSL version to use - - def min_version= min_version - @min_version = min_version - - reconnect_ssl - end - - ## - # maximum SSL version to use - - def max_version= max_version - @max_version = max_version - - reconnect_ssl - end - - ## - # Sets the depth of SSL certificate verification - - def verify_depth= verify_depth - @verify_depth = verify_depth - - reconnect_ssl - end - - ## - # Sets the HTTPS verify mode. Defaults to OpenSSL::SSL::VERIFY_PEER. - # - # Setting this to VERIFY_NONE is a VERY BAD IDEA and should NEVER be used. - # Securely transfer the correct certificate and update the default - # certificate store or set the ca file instead. - - def verify_mode= verify_mode - @verify_mode = verify_mode - - reconnect_ssl - end - - ## - # Sets the HTTPS verify_hostname. - - def verify_hostname= verify_hostname - @verify_hostname = verify_hostname - - reconnect_ssl - end - - ## - # SSL verification callback. - - def verify_callback= callback - @verify_callback = callback - - reconnect_ssl - end -end - -require_relative 'persistent/connection' -require_relative 'persistent/pool' diff --git a/lib/bundler/vendor/net-http-persistent/lib/net/http/persistent/connection.rb b/lib/bundler/vendor/net-http-persistent/lib/net/http/persistent/connection.rb deleted file mode 100644 index 8b9ab5cc785b77..00000000000000 --- a/lib/bundler/vendor/net-http-persistent/lib/net/http/persistent/connection.rb +++ /dev/null @@ -1,41 +0,0 @@ -## -# A Gem::Net::HTTP connection wrapper that holds extra information for managing the -# connection's lifetime. - -class Gem::Net::HTTP::Persistent::Connection # :nodoc: - - attr_accessor :http - - attr_accessor :last_use - - attr_accessor :requests - - attr_accessor :ssl_generation - - def initialize http_class, http_args, ssl_generation - @http = http_class.new(*http_args) - @ssl_generation = ssl_generation - - reset - end - - def finish - @http.finish - rescue IOError - ensure - reset - end - alias_method :close, :finish - - def reset - @last_use = Gem::Net::HTTP::Persistent::EPOCH - @requests = 0 - end - - def ressl ssl_generation - @ssl_generation = ssl_generation - - finish - end - -end diff --git a/lib/bundler/vendor/net-http-persistent/lib/net/http/persistent/pool.rb b/lib/bundler/vendor/net-http-persistent/lib/net/http/persistent/pool.rb deleted file mode 100644 index 5e45ea77d1afde..00000000000000 --- a/lib/bundler/vendor/net-http-persistent/lib/net/http/persistent/pool.rb +++ /dev/null @@ -1,65 +0,0 @@ -class Gem::Net::HTTP::Persistent::Pool < Bundler::ConnectionPool # :nodoc: - - attr_reader :available # :nodoc: - attr_reader :key # :nodoc: - - def initialize(options = {}, &block) - super(**options, &block) - - @available = Gem::Net::HTTP::Persistent::TimedStackMulti.new(@size, &block) - @key = "current-#{@available.object_id}" - end - - def checkin net_http_args - if net_http_args.is_a?(Hash) && net_http_args.size == 1 && net_http_args[:force] - # Bundler::ConnectionPool 2.4+ calls `checkin(force: true)` after fork. - # When this happens, we should remove all connections from Thread.current - if stacks = Thread.current[@key] - stacks.each do |http_args, connections| - connections.each do |conn| - @available.push conn, connection_args: http_args - end - connections.clear - end - end - else - stack = Thread.current[@key][net_http_args] ||= [] - - raise Bundler::ConnectionPool::Error, 'no connections are checked out' if - stack.empty? - - conn = stack.pop - - if stack.empty? - @available.push conn, connection_args: net_http_args - - Thread.current[@key].delete(net_http_args) - Thread.current[@key] = nil if Thread.current[@key].empty? - end - end - nil - end - - def checkout net_http_args - stacks = Thread.current[@key] ||= {} - stack = stacks[net_http_args] ||= [] - - if stack.empty? then - conn = @available.pop connection_args: net_http_args - else - conn = stack.last - end - - stack.push conn - - conn - end - - def shutdown - Thread.current[@key] = nil - super - end -end - -require_relative 'timed_stack_multi' - diff --git a/lib/bundler/vendor/net-http-persistent/lib/net/http/persistent/timed_stack_multi.rb b/lib/bundler/vendor/net-http-persistent/lib/net/http/persistent/timed_stack_multi.rb deleted file mode 100644 index 57dfefdaae5098..00000000000000 --- a/lib/bundler/vendor/net-http-persistent/lib/net/http/persistent/timed_stack_multi.rb +++ /dev/null @@ -1,89 +0,0 @@ -class Gem::Net::HTTP::Persistent::TimedStackMulti < Bundler::ConnectionPool::TimedStack # :nodoc: - - ## - # Detects if Bundler::ConnectionPool 3.0+ is being used (needed for TimedStack subclass compatibility) - - CP_USES_KEYWORD_ARGS = Gem::Version.new(Bundler::ConnectionPool::VERSION) >= Gem::Version.new('3.0.0') # :nodoc: - - ## - # Returns a new hash that has arrays for keys - # - # Using a class method to limit the bindings referenced by the hash's - # default_proc - - def self.hash_of_arrays # :nodoc: - Hash.new { |h,k| h[k] = [] } - end - - def initialize(size = 0, &block) - if CP_USES_KEYWORD_ARGS - super(size: size, &block) - else - super(size, &block) - end - - @enqueued = 0 - @ques = self.class.hash_of_arrays - @lru = {} - @key = :"connection_args-#{object_id}" - end - - def empty? - (@created - @enqueued) >= @max - end - - def length - @max - @created + @enqueued - end - - private - - def connection_stored? options = {} # :nodoc: - !@ques[options[:connection_args]].empty? - end - - def fetch_connection options = {} # :nodoc: - connection_args = options[:connection_args] - - @enqueued -= 1 - lru_update connection_args - @ques[connection_args].pop - end - - def lru_update connection_args # :nodoc: - @lru.delete connection_args - @lru[connection_args] = true - end - - def shutdown_connections # :nodoc: - @ques.each_key do |key| - super connection_args: key - end - end - - def store_connection obj, options = {} # :nodoc: - @ques[options[:connection_args]].push obj - @enqueued += 1 - end - - def try_create options = {} # :nodoc: - connection_args = options[:connection_args] - - if @created >= @max && @enqueued >= 1 - oldest, = @lru.first - @lru.delete oldest - connection = @ques[oldest].pop - connection.close if connection.respond_to?(:close) - - @created -= 1 - end - - if @created < @max - @created += 1 - lru_update connection_args - return @create_block.call(connection_args) - end - end - -end - diff --git a/lib/bundler/vendored_persistent.rb b/lib/bundler/vendored_persistent.rb deleted file mode 100644 index ab985c267f252c..00000000000000 --- a/lib/bundler/vendored_persistent.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module Bundler - module Persistent - module Net - module HTTP - end - end - end -end -require_relative "vendor/net-http-persistent/lib/net/http/persistent" diff --git a/spec/bundler/bundler/fetcher/gem_remote_fetcher_spec.rb b/spec/bundler/bundler/fetcher/gem_remote_fetcher_spec.rb index a06cf0396c66cb..2619491972ee28 100644 --- a/spec/bundler/bundler/fetcher/gem_remote_fetcher_spec.rb +++ b/spec/bundler/bundler/fetcher/gem_remote_fetcher_spec.rb @@ -3,7 +3,6 @@ require "rubygems/remote_fetcher" require "bundler/fetcher/gem_remote_fetcher" require_relative "../../support/artifice/helpers/artifice" -require "bundler/vendored_persistent.rb" RSpec.describe Bundler::Fetcher::GemRemoteFetcher do describe "#initialize" do diff --git a/spec/bundler/bundler/installer/parallel_installer_spec.rb b/spec/bundler/bundler/installer/parallel_installer_spec.rb index bdfbbe6db34d2f..0169073375bd0a 100644 --- a/spec/bundler/bundler/installer/parallel_installer_spec.rb +++ b/spec/bundler/bundler/installer/parallel_installer_spec.rb @@ -8,11 +8,6 @@ RSpec.describe Bundler::ParallelInstaller do describe "priority queue" do before do - # Anchor the vendored Persistent classes on the real Gem::Net::HTTP - # before Artifice replaces it, see Artifice.activate_with. Requiring - # support/artifice/compact_index already activates Artifice, so this - # must come first. - require "bundler/vendored_persistent" require "support/artifice/compact_index" Artifice.activate_with(CompactIndexAPI) @@ -103,11 +98,6 @@ skip "This example does not work under a parent make jobserver" end - # Anchor the vendored Persistent classes on the real Gem::Net::HTTP - # before Artifice replaces it, see Artifice.activate_with. Requiring - # support/artifice/compact_index already activates Artifice, so this - # must come first. - require "bundler/vendored_persistent" require "support/artifice/compact_index" Artifice.activate_with(CompactIndexAPI) diff --git a/spec/bundler/commands/ssl_spec.rb b/spec/bundler/commands/ssl_spec.rb index 4220731b697073..54d11c44ceecee 100644 --- a/spec/bundler/commands/ssl_spec.rb +++ b/spec/bundler/commands/ssl_spec.rb @@ -4,7 +4,6 @@ require "bundler/cli/doctor" require "bundler/cli/doctor/ssl" require_relative "../support/artifice/helpers/artifice" -require "bundler/vendored_persistent.rb" RSpec.describe "bundle doctor ssl" do before(:each) do diff --git a/spec/bundler/install/gems/compact_index_spec.rb b/spec/bundler/install/gems/compact_index_spec.rb index 41d18280f24cfb..2de60ecee9f961 100644 --- a/spec/bundler/install/gems/compact_index_spec.rb +++ b/spec/bundler/install/gems/compact_index_spec.rb @@ -219,30 +219,6 @@ expect(the_bundle).to include_gems "myrack 1.0.0" end - it "handles host redirects without Gem::Net::HTTP::Persistent" do - gemfile <<-G - source "#{source_uri}" - gem "myrack" - G - - FileUtils.mkdir_p lib_path - File.open(lib_path("disable_net_http_persistent.rb"), "w") do |h| - h.write <<-H - module Kernel - alias require_without_disabled_net_http require - def require(*args) - raise LoadError, 'simulated' if args.first == 'openssl' && !caller.grep(/vendored_persistent/).empty? - require_without_disabled_net_http(*args) - end - end - H - end - - bundle :install, artifice: "compact_index_host_redirect", requires: [lib_path("disable_net_http_persistent.rb")] - expect(out).to_not match(/Too many redirects/) - expect(the_bundle).to include_gems "myrack 1.0.0" - end - it "times out when Bundler::Fetcher redirects too much" do gemfile <<-G source "#{source_uri}" diff --git a/spec/bundler/install/gems/dependency_api_spec.rb b/spec/bundler/install/gems/dependency_api_spec.rb index 9f28bfcebe79b3..e9a2cdf42c1586 100644 --- a/spec/bundler/install/gems/dependency_api_spec.rb +++ b/spec/bundler/install/gems/dependency_api_spec.rb @@ -195,30 +195,6 @@ expect(the_bundle).to include_gems "myrack 1.0.0" end - it "handles host redirects without Gem::Net::HTTP::Persistent" do - gemfile <<-G - source "#{source_uri}" - gem "myrack" - G - - FileUtils.mkdir_p lib_path - File.open(lib_path("disable_net_http_persistent.rb"), "w") do |h| - h.write <<-H - module Kernel - alias require_without_disabled_net_http require - def require(*args) - raise LoadError, 'simulated' if args.first == 'openssl' && !caller.grep(/vendored_persistent/).empty? - require_without_disabled_net_http(*args) - end - end - H - end - - bundle :install, artifice: "endpoint_host_redirect", requires: [lib_path("disable_net_http_persistent.rb")] - expect(out).to_not match(/Too many redirects/) - expect(the_bundle).to include_gems "myrack 1.0.0" - end - it "timeouts when Bundler::Fetcher redirects too much" do gemfile <<-G source "#{source_uri}" diff --git a/spec/bundler/support/artifice/helpers/artifice.rb b/spec/bundler/support/artifice/helpers/artifice.rb index 23501bdf8d5d60..61bac6231be1e7 100644 --- a/spec/bundler/support/artifice/helpers/artifice.rb +++ b/spec/bundler/support/artifice/helpers/artifice.rb @@ -9,14 +9,6 @@ module Artifice # Rack endpoint. # # @param [#call] endpoint A valid Rack endpoint - # In-process users that also deactivate must load bundler/vendored_persistent - # before activating. If it gets lazily required while Artifice is active, the - # vendored Persistent classes are defined under the Artifice replacement of - # Gem::Net::HTTP instead of the real one, and after deactivation - # Gem::Net::HTTP::Persistent becomes unresolvable, blowing up the - # connection_pool fork hook on any later Process.fork. Spawned bundler - # processes are unaffected: they never deactivate, and requiring it here - # would double-load bundler files in them through mismatched load paths. def self.activate_with(endpoint) require_relative "rack_request" From d21c1fdac4bbffb10b00ccd753197c97ea30eac7 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 3 Aug 2026 14:16:36 +0900 Subject: [PATCH 06/12] [ruby/rubygems] Keep skipped jobserver examples from clobbering global state The before hook skips before it saves the previous connection pool client and UI, but the after hook restored those unconditionally, leaving Gem::Request::ConnectionPools.client as nil for every later example. Only restore what was captured, matching the guard Artifice.deactivate uses. https://github.com/ruby/rubygems/commit/4cf080cb0a Co-Authored-By: Claude Fable 5 --- spec/bundler/bundler/installer/parallel_installer_spec.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/spec/bundler/bundler/installer/parallel_installer_spec.rb b/spec/bundler/bundler/installer/parallel_installer_spec.rb index 0169073375bd0a..20d39e885be3d8 100644 --- a/spec/bundler/bundler/installer/parallel_installer_spec.rb +++ b/spec/bundler/bundler/installer/parallel_installer_spec.rb @@ -132,9 +132,12 @@ Bundler.ui = Bundler::UI::Silent.new end + # The `before` hook can `skip` before it saves anything, so only restore + # what was actually captured. Otherwise every skipped example clobbers the + # globals with nil. after do - Bundler.ui = @old_ui - Gem::Request::ConnectionPools.client = @previous_client + Bundler.ui = @old_ui if @old_ui + Gem::Request::ConnectionPools.client = @previous_client if @previous_client Artifice.deactivate end From 0e5b888e1c355f3f728f2659f085820937dada48 Mon Sep 17 00:00:00 2001 From: Burdette Lamar Date: Fri, 21 Aug 2026 06:00:05 -0500 Subject: [PATCH 07/12] Harmonize atime doc- #18346 --- file.c | 107 +++++++++++++++++++++++++++++--------------- pathname_builtin.rb | 51 ++++++++++----------- 2 files changed, 98 insertions(+), 60 deletions(-) diff --git a/file.c b/file.c index 5ba0d104b4eeb4..ddea32d0e54710 100644 --- a/file.c +++ b/file.c @@ -1108,28 +1108,45 @@ static VALUE statx_birthtime(const rb_io_stat_data *st); /* * call-seq: - * atime -> new_time + * atime -> time * * Returns a new Time object containing the access time * of the object represented by +self+ * at the time +self+ was created; - * see {Snapshot}[rdoc-ref:File::Stat@Snapshot]: + * see {Snapshot}[rdoc-ref:File::Stat@Snapshot]. + * See {File System Timestamps}[rdoc-ref:file/timestamps.md]. + * + * Access time for a file is established when it is created, + * and may be updated when the file content is read: * * filepath = 't.tmp' - * File.write(filepath, 'foo') - * file = File.new(filepath, 'w') - * stat = File::Stat.new(filepath) - * file.atime # => 2026-03-31 16:26:39.5913207 -0500 - * stat.atime # => 2026-03-31 16:26:39.5913207 -0500 - * File.write(filepath, 'bar') - * file.atime # => 2026-03-31 16:27:01.4981624 -0500 # Changed by access. - * stat.atime # => 2026-03-31 16:26:39.5913207 -0500 # Unchanged by access. - * stat = File::Stat.new(filepath) - * stat.atime # => 2026-03-31 16:27:01.4981624 -0500 # New access time. + * File.exist?(filepath) # => false + * file = File.open(filepath, 'w+') # Create by writing; establishes access time. + * file.atime # => 2026-08-14 11:55:55.436283939 -0500 + * stat0 = File::Stat.new(filepath) # Take snapshot. + * stat0.atime # => 2026-08-14 11:55:55.436283939 -0500 + * file.read # Read file content; updates file access time. + * file.atime # => 2026-08-14 11:56:22.74241085 -0500 + * stat0.atime # => 2026-08-14 11:55:55.436283939 -0500 # Not updated. + * stat1 = File::Stat.new(filepath) # Take new snapshot. + * stat1.atime # => 2026-08-14 11:56:22.74241085 -0500 # Updated. + * # Clean up. * file.close * File.delete(filepath) * - * See {File System Timestamps}[rdoc-ref:file/timestamps.md]. + * Access time for a directory is established when it is created, + * and may be updated when its entries are read: + * + * dirpath = 'foo' + * File.exist?(dirpath) # => false + * FileUtils.cp_r('doc', 'foo') # Create directory by copying. + * File.atime(dirpath) # => 2026-08-15 14:10:04.832180372 -0500 + * stat = File::Stat.new(dirpath) + * stat.atime # => 2026-08-15 14:10:04.832180372 -0500 + * # Clean up. + * FileUtils.rm_rf(dirpath) + * dir.close + * */ static VALUE @@ -2524,25 +2541,42 @@ rb_file_s_ftype(VALUE klass, VALUE fname) /* * call-seq: - * File.atime(object) -> new_time + * File.atime(object) -> time * * Returns a new Time object containing the time of the most recent - * access (read or write) to the object, - * which may be a string filepath or dirpath, or a File or Dir object: + * access to the given +object+. + * See {File System Timestamps}[rdoc-ref:file/timestamps.md]. + * + * Access time for a file is established when it is created, + * and may be updated when the file content is read: * * filepath = 't.tmp' - * File.exist?(filepath) # => false - * File.atime(filepath) # Raises Errno::ENOENT. - * File.write(filepath, 'foo') - * File.atime(filepath) # => 2026-03-31 16:39:37.9290772 -0500 - * File.write(filepath, 'bar') - * File.atime(filepath) # => 2026-03-31 16:39:57.7710876 -0500 - * - * File.atime('.') # => 2026-03-31 16:47:49.0970483 -0500 + * File.exist?(filepath) # => false + * File.atime(filepath) # Raises Errno::ENOENT. + * File.write(filepath, 'foo') # Create by writing; establishes access time. + * File.atime(filepath) # => 2026-08-14 10:02:39.721407762 -0500 + * File.read(filepath) # Read file content; updates access time. + * File.atime(filepath) # => 2026-08-14 10:03:02.520494995 -0500 + * File.delete(filepath) # Clean up. + * + * Access time for a directory is established when it is created, + * and may updated when its entries are read: + * + * dirpath = 'foo' + * File.exist?(dirpath) # => false + * File.atime(dirpath) # Raises Errno::ENOENT. + * FileUtils.cp_r('doc', 'foo') # Create by copying; establishes access time. + * File.atime(dirpath) # => 2026-08-14 10:32:59.229951125 -0500 + * Dir.entries(dirpath) # Read directory entries; updates access time. + * File.atime(dirpath) # => 2026-08-14 10:33:05.679978581 -0500 + * FileUtils.rm_rf(dirpath) # Clean up. + * + * Argument +object+ may be a string path (as above), + * a File object, or a Dir object: + * * File.atime(File.new('README.md')) # => 2026-03-31 11:15:27.8215934 -0500 * File.atime(Dir.new('.')) # => 2026-03-31 12:39:45.5910591 -0500 * - * See {File System Timestamps}[rdoc-ref:file/timestamps.md]. */ static VALUE @@ -2560,22 +2594,25 @@ rb_file_s_atime(VALUE klass, VALUE fname) /* * call-seq: - * atime -> new_time + * atime -> time * * Returns a new Time object containing the time of the most recent - * access (read or write) to the file represented by +self+: + * access to +self+. + * See {File System Timestamps}[rdoc-ref:file/timestamps.md]. + * + * Access time for a file is established when it is created, + * and may be updated when the file content is read: * * filepath = 't.tmp' - * file = File.new(filepath, 'a+') - * file.atime # => 2026-03-31 17:11:27.7285397 -0500 - * file.write('foo') - * file.atime # => 2026-03-31 17:11:27.7285397 -0500 # Unchanged; not yet written. - * file.flush - * file.atime # => 2026-03-31 17:12:11.3408054 -0500 # Changed; now written. + * File.exist?(filepath) # => false + * file = File.open(filepath, 'w+') # Create by opening; establishes access time. + * file.atime # => 2026-08-14 11:15:48.422773736 -0500 + * file.read # Read file content; updates access time. + * file.atime # => 2026-08-14 11:16:10.697861103 -0500 + * # Clean up. * file.close - * File.delete(filename) + * File.delete(filepath) * - * See {File System Timestamps}[rdoc-ref:file/timestamps.md]. */ static VALUE diff --git a/pathname_builtin.rb b/pathname_builtin.rb index 0d359b4ed1fb1f..9deb0c860ef2d6 100644 --- a/pathname_builtin.rb +++ b/pathname_builtin.rb @@ -1357,35 +1357,36 @@ def binwrite(...) File.binwrite(@path, ...) end # :markup: markdown # # call-seq: - # atime -> new_time + # atime -> time # - # Returns a Time object containing the access time + # Returns a new Time object containing the access time # of the entry represented by `self`, as reported by the filesystem; - # see {File System Access Time}[rdoc-ref:file/timestamps.md@Access+Time]: + # see {File System Access Time}[rdoc-ref:file/timestamps.md@Access+Time]. + # + # For a file, the access time is established when the file is created, + # and may be updated with the file content is read: # # ```ruby - # # Pathname for a (non-existent) directory. - # dir_pn = Pathname('doc/foo') # => # - # # Create directory; establishes atime for directory. - # dir_pn.mkdir - # dir_pn.atime # => 2026-06-17 10:10:20.801115774 -0500 - # # Pathname for a (non-existent) file in the directory. - # file_pn = dir_pn.join('t.tmp') # => # - # # Create file; establishes atime for file, updates atime for directory. - # file_pn.write('foo') - # file_pn.atime # => 2026-06-17 10:11:40.987171568 -0500 - # dir_pn.atime # => 2026-06-17 10:11:40.96617277 -0500 - # # Write file; updates atime for file,but not directory. - # file_pn.write('bar') - # file_pn.atime # => 2026-06-17 10:13:22.062904563 -0500 - # dir_pn.atime # => 2026-06-17 10:11:40.96617277 -0500 - # # Read file; may update atime for file, but not directory. - # file_pn.read - # file_pn.atime # => 2026-06-17 10:13:22.062904563 -0500 - # dir_pn.atime # => 2026-06-17 10:11:40.96617277 -0500 - # # Clean up. - # file_pn.delete - # dir_pn.rmdir + # filepath = 't.tmp' + # pn = Pathname(filepath) + # pn.exist? # => false + # pn.write('foo') + # pn.atime # => 2026-08-15 14:30:28.624455747 -0500 + # pn.delete + # ``` + # + # For a directory, the access time is established when the directory is created, + # and may be updated when its entries are read: + # + # ```ruby + # dirpath = 'foo' + # pn = Pathname(dirpath) + # pn.exist? # => false + # FileUtils.cp_r('doc', 'foo') + # pn.atime # => 2026-08-15 14:36:20.139756073 -0500 + # pn.entries.take(3) # => [#, #, #] + # pn.atime # => 2026-08-15 14:36:32.262779081 -0500 + # pn.rmtree # Clean up. # ``` # def atime() File.atime(@path) end From d97ad82c3dda677a9c1f6b5163a17b4768495c84 Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Fri, 21 Aug 2026 12:10:43 +0000 Subject: [PATCH 08/12] Retry TestGc#test_stat_heap_constraints once The test reads GC.stat and GC.stat_heap separately and asserts that the per-heap values sum to the totals. Both come from the same counters (objspace_live_slots() is the sum of the per-heap live slots, and so on), so they only disagree when something allocated or freed between the two reads: TestGc#test_stat_heap_constraints [test/ruby/test_gc.rb:298]: <243163> expected but was <243164>. Running the pair in a loop with a background allocating thread reproduces it, 7 mismatches in 200,000 rounds; reading the pair again leaves 0. Disabling GC does not help (7 in 200,000): the reads disagree over an allocation, not over a collection. So retry the test once instead of leaving it flaky. An accounting bug fails the retry too, so this keeps what the test checks. Seen on YJIT macOS with --yjit-call-threshold=1: https://github.com/ruby/ruby/actions/runs/32472080033/job/96740787745 --- test/ruby/test_gc.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/ruby/test_gc.rb b/test/ruby/test_gc.rb index 84202c57ccf7f7..c28d1f496564cd 100644 --- a/test/ruby/test_gc.rb +++ b/test/ruby/test_gc.rb @@ -302,6 +302,13 @@ def test_stat_heap_constraints assert_equal stat[:heap_available_slots], stat_heap_sum[:heap_eden_slots] assert_equal stat[:total_allocated_objects], stat_heap_sum[:total_allocated_objects] assert_equal stat[:total_freed_objects], stat_heap_sum[:total_freed_objects] + rescue Test::Unit::AssertionFailedError + # GC.stat and GC.stat_heap are separate reads of the same counters, so + # anything allocated between them makes the two disagree. An accounting + # bug disagrees on the retry too. + raise if @retried + @retried = true + retry end def test_page_pool_stat_consistency From 1216f9527a8ecf6d14340508b681703a764ee17e Mon Sep 17 00:00:00 2001 From: Steven Webb Date: Fri, 21 Aug 2026 21:25:38 +0800 Subject: [PATCH 09/12] ZJIT: Exit infer_types early if no back traversals (#18358) ZJIT: infer_types early exit if no back traversals Function::infer_types repeatedly loops over all blocks and instructions in Reverse Post Order (RPO), progressively updating the types. The loop continues until a fixpoint is reached (no further type changes will occur). This patch improves performance by adding an early exit if no back edge traversals have occurred. If there were no back traversals the fixpoint has already been reached, and continuing to loop will not change the result. Note, I'm considering an edge that links a block back to itself as a back edge. The block can loop with different arguments, causing further type propagation. This patch reduces compile_hir_time from ~960ms to ~880ms. Before: % for i in $(seq 0 5); do WARMUP_ITRS=0 MIN_BENCH_ITRS=2 MIN_BENCH_TIME=0 ./ruby --zjit --zjit-stats ruby-bench/benchmarks/lobsters/benchmark.rb 2>&1 | grep compile_hir_time; done compile_hir_time: 949ms compile_hir_time: 962ms compile_hir_time: 975ms compile_hir_time: 976ms compile_hir_time: 952ms compile_hir_time: 951ms After: % for i in $(seq 0 5); do WARMUP_ITRS=0 MIN_BENCH_ITRS=2 MIN_BENCH_TIME=0 ./ruby --zjit --zjit-stats ruby-bench/benchmarks/lobsters/benchmark.rb 2>&1 | grep compile_hir_time; done compile_hir_time: 866ms compile_hir_time: 881ms compile_hir_time: 881ms compile_hir_time: 894ms compile_hir_time: 897ms compile_hir_time: 862ms --- zjit/src/hir.rs | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/zjit/src/hir.rs b/zjit/src/hir.rs index b49753635e2b13..2922e9002b8524 100644 --- a/zjit/src/hir.rs +++ b/zjit/src/hir.rs @@ -3676,12 +3676,33 @@ impl Function { let mut reachable = BlockSet::with_capacity(self.blocks.len()); reachable.insert(self.entries_block); - // Walk the graph, computing types until fixpoint + // Repeatedly walk the graph in RPO order, computing types until fixpoint. For each + // iteration over the CFG, track the following two attributes to detect the fixpoint: + // + // 1. if new types were inferred + // 2. if back edges were traversed + // + // For point (1), if no new types were inferred it means no new information is available. + // Further repetitions will not change the result. + // + // For point (2), if the RPO walk does not traverse a back edge, type information can only + // be propagated forwards. It follows that a node's type can only be inferred from its + // predecessors. RPO ordering ensures all of a node's predecessors have been processed; + // therefore, a single walk of the RPO ordering will reach the fixpoint. let rpo = self.reverse_post_order(); + // Map BlockId -> rpo index. Used to detect back edge traversal. If an edge targets a block + // with rpo index <= the current rpo index it's a back edge. Note that `rpo_order` must be + // of size `self.blocks.len()` to support all possible block IDs; however, `rpo` only + // includes reachable blocks. Any blocks not present in `rpo` default to `usize::MAX`. + let mut rpo_order = vec![usize::MAX; self.blocks.len()]; + for (idx, &block_id) in rpo.iter().enumerate() { + rpo_order[block_id.to_usize()] = idx; + } loop { let mut changed = false; + let mut traversed_back_edge = false; let mut num_instructions = 0; - for &block in &rpo { + for (rpo_index, &block) in rpo.iter().enumerate() { if !reachable.get(block) { continue; } for i in 0..self.blocks[block.to_usize()].insns.len() { let insn_id = self.blocks[block.to_usize()].insns[i]; @@ -3703,6 +3724,7 @@ impl Function { let param = self.blocks[if_true.target.to_usize()].params[idx]; changed |= set_type!(param, self.type_of(param).union(arg_type)); } + traversed_back_edge |= rpo_order[if_true.target.to_usize()] <= rpo_index; } if self.type_of(*val).could_be(Type::from_cbool(false)) { reachable.insert(if_false.target); @@ -3711,6 +3733,7 @@ impl Function { let param = self.blocks[if_false.target.to_usize()].params[idx]; changed |= set_type!(param, self.type_of(param).union(arg_type)); } + traversed_back_edge |= rpo_order[if_false.target.to_usize()] <= rpo_index; } continue; } @@ -3721,6 +3744,7 @@ impl Function { let param = self.blocks[target.to_usize()].params[idx]; changed |= set_type!(param, self.type_of(param).union(arg_type)); } + traversed_back_edge |= rpo_order[target.to_usize()] <= rpo_index; continue; } Insn::Entries { targets } => { @@ -3735,7 +3759,7 @@ impl Function { changed |= set_type!(insn_id, insn_type); } } - if !changed { + if !changed || !traversed_back_edge { self.num_instructions = num_instructions; break; } From d32793fe8ed02ecfc4a1f94b1100f1bbde35be26 Mon Sep 17 00:00:00 2001 From: Koichi Sasada Date: Thu, 20 Aug 2026 17:50:38 +0000 Subject: [PATCH 10/12] Ractor: let a wait for a message take a timeout Ractor::Port#receive, Ractor.receive and Ractor.select gain a `timeout:` keyword and return nil when it passes. The wait itself stays where it is: rb_ractor_sched_wait() keeps parking the thread in the thread scheduler, so an M:N thread still hands its native thread back instead of becoming dedicated. How the deadline is taken depends on which kind of thread waits: A dedicated native thread parks on its own condvar, so it takes the deadline there, the way native_cond_sleep() does. Nothing else is involved: the condvar it waits on is the one a send already signals. An M:N thread has no condvar of its own, so its deadline is armed on the timer thread as a timeout-only wheel entry, and the timer thread wakes it through thread_sched_to_ready_common(), which is exactly how rb_ractor_sched_wakeup() wakes it for a send. That gives such a waiter two wakers, so rb_ractor_sched_wakeup() now takes an armed timeout back before waking, skips a thread that a fired timeout already made runnable, and bumps the scheduler event serial so a timeout that has not fired cannot wake it a second time. `timeout: 0` is a poll: it never blocks, converts nothing and reads no clock, so it costs what a receive of a waiting message costs. It still delivers the incoming queue first, so a message another thread just sent is not missed. A timeout that expires leaves the waiter on the Ractor's waiter list, as a spurious wakeup does, and ractor_wait_receive() still delivers pending messages before it reports the timeout, so a message that arrived just before the deadline is not lost. Both kinds of wait exist on every pthread platform, including builds without the timer wheel (USE_MN_THREADS == 0), where every thread is dedicated. On win32 the wait is a condvar wait, which takes the timeout directly. --- ractor.c | 6 +-- ractor.rb | 37 ++++++++++---- ractor_core.h | 4 ++ ractor_sync.c | 100 +++++++++++++++++++++++++++++-------- test/ruby/test_ractor.rb | 60 ++++++++++++++++++++++ thread_pthread.c | 105 ++++++++++++++++++++++++++++++++------- thread_pthread_mn.c | 34 ++++++++++++- 7 files changed, 294 insertions(+), 52 deletions(-) diff --git a/ractor.c b/ractor.c index 315501b48418a0..1d9659e4aa1c85 100644 --- a/ractor.c +++ b/ractor.c @@ -894,7 +894,7 @@ void rb_ractor_receive_parameters(rb_execution_context_t *ec, rb_ractor_t *r, int len, VALUE *ptr) { for (int i=0; iport); + VALUE results = ractor_port_receive(ec, crr->port, Qnil); ractor_port_close(ec, crr->port); VALUE exc = rb_ary_pop(results); @@ -4001,7 +4001,7 @@ rb_ractor_autoload_load(VALUE module, ID name) rb_ractor_interrupt_exec(main_r, ractor_autoload_load_func, (void *)crr_obj, rb_interrupt_exec_flag_value_data); // wait for require done - VALUE results = ractor_port_receive(ec, crr->port); + VALUE results = ractor_port_receive(ec, crr->port, Qnil); ractor_port_close(ec, crr->port); VALUE exc = rb_ary_pop(results); diff --git a/ractor.rb b/ractor.rb index ee4a6fa73f3241..f0c9cc9aa6968b 100644 --- a/ractor.rb +++ b/ractor.rb @@ -265,10 +265,11 @@ def self.count # # call-seq: - # Ractor.select(*ractors_or_ports) -> [ractor or port, obj] + # Ractor.select(*ractors_or_ports, timeout: nil) -> [ractor or port, obj] or nil # # Blocks the current Thread until one of the given ports has received a message. Returns an # array of two elements where the first element is the Port and the second is the received object. + # With +timeout+ (in seconds) it returns +nil+ instead once the timeout passes. # This method can also accept Ractor objects themselves, and in that case will wait until one # has terminated and return a two-element array where the first element is the ractor and the # second is its termination value. @@ -307,7 +308,7 @@ def self.count # values << val # end # - def self.select(*ports) + def self.select(*ports, timeout: nil) raise ArgumentError, 'specify at least one Ractor::Port or Ractor' if ports.empty? monitors = {} # Ractor::Port => Ractor @@ -327,7 +328,10 @@ def self.select(*ports) end begin - result_port, obj = __builtin_ractor_select_internal(ports) + result = __builtin_ractor_select_internal(ports, timeout) + return nil if result.nil? # timed out + + result_port, obj = result if r = monitors[result_port] [r, r.value] @@ -345,11 +349,11 @@ def self.select(*ports) # # call-seq: - # Ractor.receive -> obj + # Ractor.receive(timeout: nil) -> obj or nil # # Receives a message from the current ractor's default port. - def self.receive - Ractor.current.default_port.receive + def self.receive(timeout: nil) + Ractor.current.default_port.receive(timeout: timeout) end class << self @@ -357,8 +361,8 @@ class << self end # same as Ractor.receive - private def receive - default_port.receive + private def receive(timeout: nil) + default_port.receive(timeout: timeout) end alias recv receive @@ -702,7 +706,7 @@ def self.shareable_lambda self: nil class Port # # call-seq: - # port.receive -> msg + # port.receive(timeout: nil) -> msg or nil # # Receives a message from the port (which was sent there by Port#send). Only the ractor # that created the port can receive messages this way. @@ -743,6 +747,17 @@ class Port # Still received only one # Received: message2 # + # With +timeout+ (in seconds) the method gives up waiting and returns +nil+ + # once it passes. A message that arrives just as the timeout expires is still + # returned; the timeout bounds the wait, it does not cut delivery off. + # + # port = Ractor::Port.new + # port.receive(timeout: 0.1) #=> nil + # port.receive(timeout: 0) #=> nil + # + # A +timeout+ of 0 never blocks and reads no clock: it takes a message if one + # is already there and returns +nil+ otherwise. + # # If the port is closed and there are no more messages in the message queue, # the method raises Ractor::ClosedError. # @@ -750,9 +765,9 @@ class Port # port.close # port.receive #=> raise Ractor::ClosedError # - def receive + def receive(timeout: nil) __builtin_cexpr! %q{ - ractor_port_receive(ec, self) + ractor_port_receive(ec, self, timeout) } end diff --git a/ractor_core.h b/ractor_core.h index de60004e7e79e5..99e3a30254a5cb 100644 --- a/ractor_core.h +++ b/ractor_core.h @@ -4,6 +4,7 @@ #include "vm_core.h" #include "id_table.h" #include "vm_debug.h" +#include "hrtime.h" #ifndef RACTOR_CHECK_MODE #define RACTOR_CHECK_MODE (VM_CHECK_MODE || RUBY_DEBUG) && (SIZEOF_UINT64_T == SIZEOF_VALUE) @@ -181,6 +182,9 @@ struct ractor_waiter { rb_thread_t *th; struct ccan_list_node node; rb_atomic_t event_serial; + + // absolute deadline for this wait, NULL when there is no timeout + const rb_hrtime_t *end; }; static inline VALUE diff --git a/ractor_sync.c b/ractor_sync.c index aafc7b0ccbe9a3..d8205775ba99ec 100644 --- a/ractor_sync.c +++ b/ractor_sync.c @@ -13,7 +13,7 @@ ractor_port_id(const struct ractor_port *rp) static VALUE rb_cRactorPort; -static VALUE ractor_receive(rb_execution_context_t *ec, const struct ractor_port *rp); +static VALUE ractor_receive(rb_execution_context_t *ec, const struct ractor_port *rp, const rb_hrtime_t *end); static VALUE ractor_send(rb_execution_context_t *ec, const struct ractor_port *rp, VALUE obj, VALUE move); static struct ractor_basket *ractor_basket_new_ref(VALUE shareable); static void ractor_send_basket(rb_execution_context_t *ec, const struct ractor_port *rp, struct ractor_basket *b, bool raise_on_error); @@ -149,8 +149,10 @@ ractor_port_p(VALUE self) return rb_typeddata_is_kind_of(self, &ractor_port_data_type); } +static const rb_hrtime_t *ractor_timeout_deadline(VALUE timeout, rb_hrtime_t *storage); + static VALUE -ractor_port_receive(rb_execution_context_t *ec, VALUE self) +ractor_port_receive(rb_execution_context_t *ec, VALUE self, VALUE timeout) { const struct ractor_port *rp = ractor_port_ptr_check(self); @@ -158,9 +160,14 @@ ractor_port_receive(rb_execution_context_t *ec, VALUE self) rb_raise(rb_eRactorError, "only allowed from the creator Ractor of this port"); } - VALUE v = ractor_receive(ec, rp); + rb_hrtime_t deadline; + const rb_hrtime_t *end = ractor_timeout_deadline(timeout, &deadline); + + VALUE v = ractor_receive(ec, rp, end); RB_GC_GUARD(self); - return v; + + // no message before the timeout + return UNDEF_P(v) ? Qnil : v; } static VALUE @@ -1294,13 +1301,22 @@ basket_type_name(enum ractor_basket_type type) #else // win32 static void -ractor_cond_wait(rb_ractor_t *r) +ractor_cond_wait(rb_ractor_t *r, const rb_hrtime_t *end) { #if RACTOR_CHECK_MODE > 0 VALUE locked_by = r->sync.locked_by; r->sync.locked_by = Qnil; #endif - rb_native_cond_wait(&r->sync.wakeup_cond, &r->sync.lock); + if (end) { + rb_hrtime_t now = rb_hrtime_now(); + rb_hrtime_t rel = *end > now ? *end - now : 0; + // the condvar takes msec: never round a live timeout down to 0 + unsigned long msec = (unsigned long)(rel / RB_HRTIME_PER_MSEC); + rb_native_cond_timedwait(&r->sync.wakeup_cond, &r->sync.lock, msec > 0 ? msec : 1); + } + else { + rb_native_cond_wait(&r->sync.wakeup_cond, &r->sync.lock); + } #if RACTOR_CHECK_MODE > 0 r->sync.locked_by = locked_by; @@ -1316,7 +1332,7 @@ ractor_wait_no_gvl(void *ptr) RACTOR_LOCK_SELF(cr); { if (waiter->wakeup_status == wakeup_none) { - ractor_cond_wait(cr); + ractor_cond_wait(cr, waiter->end); } } RACTOR_UNLOCK_SELF(cr); @@ -1406,14 +1422,16 @@ ubf_ractor_wait(void *ptr) rb_native_mutex_lock(&th->interrupt_lock); } +// Waits for an event on cr. `end` is an absolute deadline, NULL to wait forever. static enum ractor_wakeup_status -ractor_wait(rb_execution_context_t *ec, rb_ractor_t *cr) +ractor_wait(rb_execution_context_t *ec, rb_ractor_t *cr, const rb_hrtime_t *end) { rb_thread_t *th = rb_ec_thread_ptr(ec); struct ractor_waiter waiter = { .wakeup_status = wakeup_none, .th = th, + .end = end, }; RUBY_DEBUG_LOG("wait%s", ""); @@ -1481,19 +1499,30 @@ ractor_check_received(rb_ractor_t *cr, struct ractor_queue *messages) return received; } -static void -ractor_wait_receive(rb_execution_context_t *ec, rb_ractor_t *cr) +// Returns false if the deadline `end` passed with nothing to deliver. Incoming +// messages are delivered even then, so the caller retries its queue once more. +static bool +ractor_wait_receive(rb_execution_context_t *ec, rb_ractor_t *cr, const rb_hrtime_t *end) { struct ractor_queue messages; bool deliverred = false; + bool timedout = false; RACTOR_LOCK_SELF(cr); { if (ractor_check_received(cr, &messages)) { deliverred = true; } + else if (!end) { + ractor_wait(ec, cr, NULL); // no timeout: wait until a message arrives + } + else if (*end == 0) { + timedout = true; // `timeout: 0`: over without reading any clock + } else { - ractor_wait(ec, cr); + // only a wakeup nobody claimed can be the deadline, so only then look at + // the clock: a send or an interrupt says what woke this thread by itself + timedout = ractor_wait(ec, cr, end) == wakeup_none && rb_hrtime_now() >= *end; } } RACTOR_UNLOCK_SELF(cr); @@ -1506,6 +1535,8 @@ ractor_wait_receive(rb_execution_context_t *ec, rb_ractor_t *cr) ractor_queue_enq(cr, ractor_get_queue(cr, b->port_id, false), b); } } + + return !timedout; } static VALUE @@ -1533,8 +1564,12 @@ ractor_try_receive(rb_execution_context_t *ec, rb_ractor_t *cr, const struct rac } } +// Returns Qundef if the deadline passed first. It bounds how long this blocks; it +// does not cut delivery off. A message that lands while the timeout is being +// reported is still returned, as Thread::Queue#pop(timeout:) does. Either way +// nothing is lost: a basket only leaves the queue when it is returned. static VALUE -ractor_receive(rb_execution_context_t *ec, const struct ractor_port *rp) +ractor_receive(rb_execution_context_t *ec, const struct ractor_port *rp, const rb_hrtime_t *end) { rb_ractor_t *cr = rb_ec_ractor_ptr(ec); VM_ASSERT(cr == rp->r); @@ -1547,10 +1582,31 @@ ractor_receive(rb_execution_context_t *ec, const struct ractor_port *rp) if (v != Qundef) { return v; } - else { - ractor_wait_receive(ec, cr); + else if (!ractor_wait_receive(ec, cr, end)) { + return Qundef; + } + } +} + +// A timeout argument becomes an absolute deadline, or 0 for `timeout: 0`, which +// every wait reads as "do not wait". Returns NULL when there is no timeout. +static const rb_hrtime_t * +ractor_timeout_deadline(VALUE timeout, rb_hrtime_t *storage) +{ + if (NIL_P(timeout)) return NULL; + + if (!(FIXNUM_P(timeout) && FIX2LONG(timeout) == 0)) { + struct timeval tv = rb_time_interval(timeout); // raises on a negative timeout + rb_hrtime_t rel = rb_timeval2hrtime(&tv); + + if (rel > 0) { + *storage = rb_hrtime_add(rb_hrtime_now(), rel); + return storage; } } + + *storage = 0; + return storage; } // Ractor#send @@ -1816,7 +1872,7 @@ ractor_selector_wait_i(st_data_t key, st_data_t val, st_data_t data) } static VALUE -ractor_selector__wait(rb_execution_context_t *ec, VALUE selector) +ractor_selector__wait(rb_execution_context_t *ec, VALUE selector, const rb_hrtime_t *end) { rb_ractor_t *cr = rb_ec_ractor_ptr(ec); struct ractor_selector *s = RACTOR_SELECTOR_PTR(selector); @@ -1833,8 +1889,9 @@ ractor_selector__wait(rb_execution_context_t *ec, VALUE selector) if (data.found) { return rb_ary_new_from_args(2, data.rpv, data.v); } - - ractor_wait_receive(ec, cr); + else if (!ractor_wait_receive(ec, cr, end)) { + return Qnil; + } } } @@ -1847,7 +1904,7 @@ ractor_selector__wait(rb_execution_context_t *ec, VALUE selector) static VALUE ractor_selector_wait(VALUE selector) { - return ractor_selector__wait(GET_EC(), selector); + return ractor_selector__wait(GET_EC(), selector, NULL); } static VALUE @@ -1863,10 +1920,13 @@ ractor_selector_new(int argc, VALUE *ractors, VALUE klass) } static VALUE -ractor_select_internal(rb_execution_context_t *ec, VALUE self, VALUE ports) +ractor_select_internal(rb_execution_context_t *ec, VALUE self, VALUE ports, VALUE timeout) { + rb_hrtime_t deadline; + const rb_hrtime_t *end = ractor_timeout_deadline(timeout, &deadline); + VALUE selector = ractor_selector_new(RARRAY_LENINT(ports), (VALUE *)RARRAY_CONST_PTR(ports), rb_cRactorSelector); - VALUE result = ractor_selector__wait(ec, selector); + VALUE result = ractor_selector__wait(ec, selector, end); RB_GC_GUARD(selector); RB_GC_GUARD(ports); diff --git a/test/ruby/test_ractor.rb b/test/ruby/test_ractor.rb index 476a1f8c2f1bd5..0901757201740d 100644 --- a/test/ruby/test_ractor.rb +++ b/test/ruby/test_ractor.rb @@ -579,6 +579,66 @@ def test_port_new_under_gc_stress assert_equal 4, ports.size RUBY end + def test_port_receive_timeout + assert_separately([], __FILE__, __LINE__, <<-'RUBY') + Warning[:experimental] = false + port = Ractor::Port.new + + t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + assert_nil port.receive(timeout: 0.1) + assert_operator Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0, :>=, 0.1 + + # a message that is already there wins over the timeout + port << :a + assert_equal :a, port.receive(timeout: 10) + + # timeout: 0 polls + assert_nil port.receive(timeout: 0) + port << :b + assert_equal :b, port.receive(timeout: 0) + RUBY + end + + def test_receive_timeout_on_mn_thread + assert_separately([], __FILE__, __LINE__, <<-'RUBY') + Warning[:experimental] = false + # a Ractor's thread is an M:N thread: the timeout must not need a native thread + r = Ractor.new do + t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) + [Ractor.receive(timeout: 0.1), Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0] + end + v, elapsed = r.value + assert_nil v + assert_operator elapsed, :>=, 0.1 + RUBY + end + + def test_select_timeout + assert_separately([], __FILE__, __LINE__, <<-'RUBY') + Warning[:experimental] = false + p1, p2 = Ractor::Port.new, Ractor::Port.new + assert_nil Ractor.select(p1, p2, timeout: 0.1) + + p2 << :b + assert_equal [p2, :b], Ractor.select(p1, p2, timeout: 10) + RUBY + end + + def test_receive_timeout_racing_with_send + assert_separately([], __FILE__, __LINE__, <<-'RUBY') + Warning[:experimental] = false + # the timeout and a send aim at the same instant: both wake the waiter + results = [] + 300.times do + port = Ractor::Port.new + th = Thread.new(port) {|p| sleep 0.001; p << :msg } + results << port.receive(timeout: 0.001) + th.join + end + assert_empty results.uniq - [:msg, nil] + RUBY + end + # Moving a Hash that has Hash keys must not lose entries (regression guard for inserting a # key before its contents are filled in, which corrupts its hash value). diff --git a/thread_pthread.c b/thread_pthread.c index 2500e8998b8d48..4c5fdf74c9c8aa 100644 --- a/thread_pthread.c +++ b/thread_pthread.c @@ -334,6 +334,8 @@ static void ractor_sched_enq(rb_vm_t *vm, rb_ractor_t *r); static void timer_thread_wakeup(void); static void timer_thread_wakeup_locked(rb_vm_t *vm); static void timer_thread_wakeup_force(void); +static bool ractor_sched_timeout_arm(rb_thread_t *th, const rb_hrtime_t *rel); +static bool ractor_sched_timeout_disarm(rb_thread_t *th); static void thread_sched_switch(rb_thread_t *cth, rb_thread_t *next_th); static void ractor_sched_cancel_enq(rb_vm_t *vm, struct rb_thread_sched *sched); #if USE_MN_THREADS @@ -863,9 +865,9 @@ thread_sched_to_ready(struct rb_thread_sched *sched, rb_thread_t *th) thread_sched_unlock(sched, th); } -// wait until sched->running is `th`. +// wait until sched->running is `th`. `end` is an absolute deadline for a dedicated static void -thread_sched_wait_running_turn(struct rb_thread_sched *sched, rb_thread_t *th, bool can_direct_transfer) +thread_sched_wait_running_turn(struct rb_thread_sched *sched, rb_thread_t *th, bool can_direct_transfer, const rb_hrtime_t *end) { RUBY_DEBUG_LOG("th:%u", rb_th_serial(th)); @@ -922,10 +924,32 @@ thread_sched_wait_running_turn(struct rb_thread_sched *sched, rb_thread_t *th, b thread_sched_set_unlocked(sched, th); { RUBY_DEBUG_LOG("nt:%d cond:%p", th->nt->serial, &th->nt->cond.readyq); - rb_native_cond_wait(&th->nt->cond.readyq, &sched->lock_); + rb_nativethread_cond_t *cond = &th->nt->cond.readyq; + + if (end) { + rb_hrtime_t abs = *end; + + if (!condattr_monotonic) { + // the condvar counts in another clock: restate it there + rb_hrtime_t now = rb_hrtime_now(); + abs = native_cond_timeout(cond, *end > now ? *end - now : 0); + } + native_cond_timedwait(cond, &sched->lock_, &abs); + } + else { + rb_native_cond_wait(cond, &sched->lock_); + } } thread_sched_set_locked(sched, th); + if (end && rb_hrtime_now() >= *end && + sched->running != th && !th->sched.node.is_ready) { + // the deadline passed and nobody woke this thread: get back in + // line for the running turn, then wait for it without a deadline + thread_sched_to_ready_common(sched, th, false, false); + end = NULL; + } + if (sched->runnable_hot_th != NULL && sched->runnable_hot_th_waiting) { VM_ASSERT(sched->runnable_hot_th != th); // Give the hot thread a chance to preempt, if it's actively spinning. @@ -1012,7 +1036,7 @@ thread_sched_to_running_common(struct rb_thread_sched *sched, rb_thread_t *th) } // TODO: check SNT number - thread_sched_wait_running_turn(sched, th, false); + thread_sched_wait_running_turn(sched, th, false, NULL); } // waiting -> ready -> running @@ -1220,7 +1244,7 @@ thread_sched_to_waiting_until_wakeup(struct rb_thread_sched *sched, rb_thread_t bool can_direct_transfer = !th_has_dedicated_nt(th); // NOTE: th->status is set before and after this sleep outside of this function in `sleep_forever` thread_sched_wakeup_next_thread(sched, th, can_direct_transfer); - thread_sched_wait_running_turn(sched, th, can_direct_transfer); + thread_sched_wait_running_turn(sched, th, can_direct_transfer, NULL); } } thread_sched_unlock(sched, th); @@ -1242,7 +1266,7 @@ thread_sched_yield(struct rb_thread_sched *sched, rb_thread_t *th) thread_sched_wakeup_next_thread(sched, th, !th_has_dedicated_nt(th)); bool can_direct_transfer = !th_has_dedicated_nt(th); thread_sched_to_ready_common(sched, th, false, can_direct_transfer); - thread_sched_wait_running_turn(sched, th, can_direct_transfer); + thread_sched_wait_running_turn(sched, th, can_direct_transfer, NULL); th->status = THREAD_RUNNABLE; } else { @@ -1531,15 +1555,49 @@ rb_ractor_sched_wait(rb_execution_context_t *ec, rb_ractor_t *cr, rb_unblock_fun thread_sched_lock(sched, th); rb_ractor_unlock_self(cr); { - // setup sleep - bool can_direct_transfer = !th_has_dedicated_nt(th); - RB_VM_SAVE_MACHINE_CONTEXT(th); - th->status = THREAD_STOPPED_FOREVER; - RB_INTERNAL_THREAD_HOOK(RUBY_INTERNAL_THREAD_EVENT_SUSPENDED, th); - thread_sched_wakeup_next_thread(sched, th, can_direct_transfer); - // sleep - thread_sched_wait_running_turn(sched, th, can_direct_transfer); - th->status = THREAD_RUNNABLE; + // A dedicated native thread takes the deadline on the very condvar a wakeup + // signals. An M:N thread has no condvar of its own, so its deadline goes to + // the timer thread, which then wakes it the way rb_ractor_sched_wakeup() does. + bool dedicated = th_has_dedicated_nt(th); + const rb_hrtime_t *end_p = NULL; + bool armed = false, expired = false; + + if (waiter->end) { + if (dedicated) { + end_p = waiter->end; + } + else { + // the timer wheel takes a relative timeout + rb_hrtime_t now = rb_hrtime_now(); + rb_hrtime_t rel = *waiter->end > now ? *waiter->end - now : 0; + + armed = ractor_sched_timeout_arm(th, &rel); + expired = !armed; + } + } + + if (expired) { + RUBY_DEBUG_LOG("expired before sleep%s", ""); + } + else if (armed && th->sched.waiting_reason.flags == thread_sched_waiting_none) { + // the timer thread already took this thread out of the wheel; bump the + // serial so that it does not try to wake a thread that never slept + th->sched.event_serial++; + } + else { + // setup sleep + bool can_direct_transfer = !dedicated; + RB_VM_SAVE_MACHINE_CONTEXT(th); + th->status = THREAD_STOPPED_FOREVER; + RB_INTERNAL_THREAD_HOOK(RUBY_INTERNAL_THREAD_EVENT_SUSPENDED, th); + thread_sched_wakeup_next_thread(sched, th, can_direct_transfer); + // sleep + thread_sched_wait_running_turn(sched, th, can_direct_transfer, end_p); + th->status = THREAD_RUNNABLE; + + // whoever woke this thread took the timeout back first + VM_ASSERT(th->sched.waiting_reason.flags == thread_sched_waiting_none); + } } thread_sched_unlock(sched, th); rb_ractor_lock_self(cr); @@ -1561,7 +1619,20 @@ rb_ractor_sched_wakeup(rb_ractor_t *r, rb_thread_t *r_th) { if (r_th->status == THREAD_STOPPED_FOREVER) { RUBY_ATOMIC_ADD(r_th->unblock.event_serial, 1); - thread_sched_to_ready_common(sched, r_th, true, false); + + // r_th must not resume with a wheel entry left behind: take its timeout + // back, as ubf_event_waiting() does. Only r_th arms it, and it is + // parked here, so reading the flags without the timer lock is safe. + if (r_th->sched.waiting_reason.flags != thread_sched_waiting_none) { + ractor_sched_timeout_disarm(r_th); + } + + // a timeout that fired first may have made r_th runnable already: waking + // it twice would put it on the readyq twice + if (sched->running != r_th && !r_th->sched.node.is_ready) { + r_th->sched.event_serial++; // a timeout still armed must not wake it again + thread_sched_to_ready_common(sched, r_th, true, false); + } } } thread_sched_unlock(sched, r_th); @@ -2491,7 +2562,7 @@ nt_start(void *ptr) if (sched->running == th) { thread_sched_add_running_thread(sched, th); } - thread_sched_wait_running_turn(sched, th, false); + thread_sched_wait_running_turn(sched, th, false, NULL); } thread_sched_unlock(sched, th); diff --git a/thread_pthread_mn.c b/thread_pthread_mn.c index 938ac1001f4b38..d1bbd362c58039 100644 --- a/thread_pthread_mn.c +++ b/thread_pthread_mn.c @@ -364,6 +364,24 @@ enum timer_thread_register_result { static enum timer_thread_register_result timer_thread_register_waiting(rb_thread_t *th, int fd, enum thread_sched_waiting_flag flags, rb_hrtime_t *rel, uint32_t event_serial); +// Arm a timeout-only wake on the timer thread for a Ractor wait. Returns false +// if the deadline has already passed, in which case nothing was registered. +static bool +ractor_sched_timeout_arm(rb_thread_t *th, const rb_hrtime_t *rel) +{ + rb_hrtime_t rel_copy = *rel; + + return timer_thread_register_waiting(th, -1, thread_sched_waiting_timeout, &rel_copy, + ++th->sched.event_serial) == timer_thread_registered; +} + +// Returns true if the timeout was still armed, i.e. it did not fire. +static bool +ractor_sched_timeout_disarm(rb_thread_t *th) +{ + return timer_thread_cancel_waiting(th); +} + // return how the wait ended; see enum thread_sched_wait_result static enum thread_sched_wait_result thread_sched_wait_events(struct rb_thread_sched *sched, rb_thread_t *th, int fd, enum thread_sched_waiting_flag events, rb_hrtime_t *rel) @@ -414,7 +432,7 @@ thread_sched_wait_events(struct rb_thread_sched *sched, rb_thread_t *th, int fd, enum rb_thread_status prev_status = th->status; if (prev_status == THREAD_RUNNABLE) th->status = THREAD_STOPPED_FOREVER; thread_sched_wakeup_next_thread(sched, th, true); - thread_sched_wait_running_turn(sched, th, true); + thread_sched_wait_running_turn(sched, th, true, NULL); if (prev_status == THREAD_RUNNABLE) th->status = THREAD_RUNNABLE; RUBY_DEBUG_LOG("wakeup"); @@ -1660,6 +1678,20 @@ thread_sched_wait_events(struct rb_thread_sched *sched, rb_thread_t *th, int fd, rb_bug("unreachable"); } +// Without the wheel every thread is dedicated, so a Ractor wait takes its +// deadline on its own condvar and never reaches these. +static bool +ractor_sched_timeout_arm(rb_thread_t *th, const rb_hrtime_t *rel) +{ + rb_bug("unreachable"); +} + +static bool +ractor_sched_timeout_disarm(rb_thread_t *th) +{ + rb_bug("unreachable"); +} + static int timer_wheel_timeout(int timeout) { From e1fcca5c113f94275f2a3d500f1093f5a8c3859d Mon Sep 17 00:00:00 2001 From: John Hawthorn Date: Wed, 19 Aug 2026 15:50:27 -0700 Subject: [PATCH 11/12] Mark Process::Status as FROZEN_SHAREABLE --- process.c | 2 +- test/ruby/test_process.rb | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/process.c b/process.c index af6b95a1af7e37..a94b1b4fced775 100644 --- a/process.c +++ b/process.c @@ -591,7 +591,7 @@ static const rb_data_type_t rb_process_status_type = { .dfree = RUBY_DEFAULT_FREE, .dsize = NULL, }, - .flags = RUBY_TYPED_THREAD_SAFE_FREE | RUBY_TYPED_WB_PROTECTED | RUBY_TYPED_EMBEDDABLE, + .flags = RUBY_TYPED_THREAD_SAFE_FREE | RUBY_TYPED_WB_PROTECTED | RUBY_TYPED_EMBEDDABLE | RUBY_TYPED_FROZEN_SHAREABLE, }; static VALUE diff --git a/test/ruby/test_process.rb b/test/ruby/test_process.rb index 93b3cc7da7e8f2..c87fd8be327f92 100644 --- a/test/ruby/test_process.rb +++ b/test/ruby/test_process.rb @@ -1513,6 +1513,14 @@ def test_status end end + def test_status_frozen_shareable + with_tmpchdir do + s = run_in_child("exit 1") + assert_predicate(s, :frozen?) + assert(Ractor.shareable?(s), "a frozen Process::Status should be shareable") + end + end + def test_status_kill return unless Process.respond_to?(:kill) return unless Signal.list.include?("KILL") From c59c4d717a2e687972341eab1574196e97d7d7be Mon Sep 17 00:00:00 2001 From: Matt Valentine-House Date: Fri, 21 Aug 2026 16:32:10 +0100 Subject: [PATCH 12/12] Fix stale super cache on prepend after include Prepending a module to a module that already has includers backfills an origin iclass into each includer's ancestor chain using rb_prepend_module. The backfilled iclass is never added to a subclasses list, so rb_clear_method_cache can't reach it when one of the module's methods is later redefined. Calling super through a call site that has cached the backfilled iclass calls the old entry, even though it should be redefined. ```ruby module M; def foo; :m; end; end class D; include M; end M.prepend(Module.new { def foo; super; end }) D.new.foo # prime the super cache M.send(:define_method, :foo) { :hooked } D.new.foo # => got :m, expected :hooked ``` This commit makes sure that the backfilled iclass is registered in the subclasses list. --- class.c | 11 +++++++++++ test/ruby/test_super.rb | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/class.c b/class.c index a90a0f43de50ed..da22fd3583006a 100644 --- a/class.c +++ b/class.c @@ -1885,6 +1885,7 @@ rb_prepend_module(VALUE klass, VALUE module) if (subs_v) { struct rb_subclasses *subs = (struct rb_subclasses *)subs_v; VALUE *entries = rb_imemo_subclasses_entries(subs_v); + VALUE new_origins = 0; for (uint32_t i = 0; i < subs->count; i++) { const VALUE subclass = entries[i]; if (!subclass) continue; @@ -1900,10 +1901,20 @@ rb_prepend_module(VALUE klass, VALUE module) RCLASS_SET_INCLUDER(origin, RCLASS_INCLUDER(subclass)); RCLASS_WRITE_ORIGIN(subclass, origin); RICLASS_SET_ORIGIN_SHARED_MTBL(origin); + if (!new_origins) new_origins = rb_ary_hidden_new(1); + rb_ary_push(new_origins, origin); } include_modules_at(subclass, subclass, module, FALSE); } } + /* Register after the loop. Registering during it would visit the + * new iclass and prepend module into it a second time. */ + if (new_origins) { + for (long i = 0; i < RARRAY_LEN(new_origins); i++) { + rb_module_add_to_subclasses_list(klass, RARRAY_AREF(new_origins, i)); + } + } + RB_GC_GUARD(new_origins); } } } diff --git a/test/ruby/test_super.rb b/test/ruby/test_super.rb index 39594d74be38af..4a9ef1a2ea3418 100644 --- a/test/ruby/test_super.rb +++ b/test/ruby/test_super.rb @@ -667,6 +667,38 @@ def test assert_equal(:test, c.new.test) end + def test_super_with_prepended_module_after_include_method_caching + m = Module.new do + def test + :m + end + end + + c = Class.new { include m } + + m.prepend(Module.new do + def test + super + end + end) + + # prime the super call-site cache through the prepended module + assert_equal(:m, c.new.test) + + m.class_eval do + begin + verbose_bak, $VERBOSE = $VERBOSE, nil + def test + :redefined + end + ensure + $VERBOSE = verbose_bak + end + end + + assert_equal(:redefined, c.new.test) + end + class TestFor_super_with_modified_rest_parameter_base def foo *args args