From 91023dbc74479c2f92f4f1bff21900f31e30e02c Mon Sep 17 00:00:00 2001 From: Bryan Woods Date: Fri, 5 Jun 2026 13:54:48 -0400 Subject: [PATCH 01/11] [ruby/rubygems] Preserve per-source cooldown when converging sources from the lockfile https://github.com/ruby/rubygems/commit/66dd16f025 --- lib/bundler/source/rubygems.rb | 2 +- lib/bundler/source_list.rb | 4 ++ spec/bundler/install/cooldown_spec.rb | 58 +++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/lib/bundler/source/rubygems.rb b/lib/bundler/source/rubygems.rb index ed864604fe1649..9109f399a7d71b 100644 --- a/lib/bundler/source/rubygems.rb +++ b/lib/bundler/source/rubygems.rb @@ -11,7 +11,7 @@ class Rubygems < Source API_REQUEST_SIZE = 100 REQUIRE_MUTEX = Mutex.new - attr_accessor :remotes + attr_accessor :remotes, :remote_cooldowns def initialize(options = {}) @options = options diff --git a/lib/bundler/source_list.rb b/lib/bundler/source_list.rb index ab7002d6e5b3d7..954efbb65fc14d 100644 --- a/lib/bundler/source_list.rb +++ b/lib/bundler/source_list.rb @@ -169,6 +169,10 @@ def replace_rubygems_source(replacement_sources, gemfile_source) # locked sources never include credentials so always prefer remotes from the gemfile replacement_source.remotes = gemfile_source.remotes + # cooldowns are only ever declared in the Gemfile, so carry them over + # along with the remotes they apply to + replacement_source.remote_cooldowns = gemfile_source.remote_cooldowns + yield replacement_source if block_given? replacement_source diff --git a/spec/bundler/install/cooldown_spec.rb b/spec/bundler/install/cooldown_spec.rb index bad7b7cf3472d7..5cdfe72284a764 100644 --- a/spec/bundler/install/cooldown_spec.rb +++ b/spec/bundler/install/cooldown_spec.rb @@ -143,6 +143,64 @@ expect(the_bundle).to include_gems("ripe_gem 1.0.0") end + it "applies per-source Gemfile cooldown on bundle update when a lockfile exists" do + # Converging the Gemfile sources with the lockfile sources used to drop + # the per-source cooldown, so it only ever worked on a first resolve + # without a lockfile. + gemfile <<-G + source "https://gem.repo3", cooldown: 7 + gem "ripe_gem" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + ripe_gem (1.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + ripe_gem + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "update ripe_gem", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("ripe_gem 1.0.0") + end + + it "applies per-source Gemfile cooldown to gems added after the lockfile was written" do + gemfile <<-G + source "https://gem.repo3", cooldown: 7 + gem "ripe_gem" + gem "child" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + ripe_gem (1.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + ripe_gem + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "install", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("ripe_gem 1.0.0", "child 1.0.0") + end + it "is overridden by CLI --cooldown when Gemfile sets a different per-source value" do gemfile <<-G source "https://gem.repo3", cooldown: 0 From 2c8002d58302e4fff51484826e1fd706cc2bfb19 Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Mon, 8 Jun 2026 10:55:20 +0900 Subject: [PATCH 02/11] IO::Buffer: Validate the buffer after argument conversion --- io_buffer.c | 12 +++++++++++- test/ruby/test_io_buffer.rb | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/io_buffer.c b/io_buffer.c index d9f50fc234c016..fa6c8c5d5ac38e 100644 --- a/io_buffer.c +++ b/io_buffer.c @@ -930,11 +930,17 @@ rb_io_buffer_get_bytes_for_writing(VALUE self, void **base, size_t *size) } static void -io_buffer_get_bytes_for_reading(struct rb_io_buffer *buffer, const void **base, size_t *size) +io_buffer_validate_for_reading(struct rb_io_buffer *buffer) { if (!io_buffer_validate(buffer)) { rb_raise(rb_eIOBufferInvalidatedError, "Buffer has been invalidated!"); } +} + +static void +io_buffer_get_bytes_for_reading(struct rb_io_buffer *buffer, const void **base, size_t *size) +{ + io_buffer_validate_for_reading(buffer); if (buffer->base) { *base = buffer->base; @@ -1548,6 +1554,8 @@ size_sum_is_bigger_than(size_t a, size_t b, size_t x) static inline void io_buffer_validate_range(struct rb_io_buffer *buffer, size_t offset, size_t length) { + io_buffer_validate_for_reading(buffer); + if (size_sum_is_bigger_than(offset, length, buffer->size)) { rb_raise(rb_eArgError, "Specified offset+length is bigger than the buffer size!"); } @@ -1752,6 +1760,8 @@ rb_io_buffer_resize(VALUE self, size_t size) struct rb_io_buffer *buffer = NULL; TypedData_Get_Struct(self, struct rb_io_buffer, &rb_io_buffer_type, buffer); + io_buffer_validate_for_reading(buffer); + if (buffer->flags & RB_IO_BUFFER_LOCKED) { rb_raise(rb_eIOBufferLockedError, "Cannot resize locked buffer!"); } diff --git a/test/ruby/test_io_buffer.rb b/test/ruby/test_io_buffer.rb index 327a3ece9c3398..bed80e64dc5d2c 100644 --- a/test/ruby/test_io_buffer.rb +++ b/test/ruby/test_io_buffer.rb @@ -242,6 +242,16 @@ def test_resize_zero_external end end + def test_resize_invalidated_slice + inner = IO::Buffer.new(IO::Buffer::PAGE_SIZE) + slice = inner.slice(0, 8) + inner.free + + assert_raise(IO::Buffer::InvalidatedError) do + slice.resize(16) + end + end + def test_compare_same_size buffer1 = IO::Buffer.new(1) assert_equal buffer1, buffer1 @@ -376,6 +386,17 @@ def test_get_string assert_raise_with_message(ArgumentError, /Offset can't be negative/) do buffer.get_string(-1) end + + encoding = Struct.new(:buffer) do + def to_str + buffer.free + "BINARY" + end + end.new(buffer.dup) + slice = encoding.buffer.slice(0, 8) + assert_raise(IO::Buffer::InvalidatedError) do + slice.get_string(0, 8, encoding) + end end def test_zero_length_get_string From 773e0c3a0f2ab2bd235c8d44cad1f999bfe2514b Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Mon, 8 Jun 2026 11:41:48 +0900 Subject: [PATCH 03/11] IO::Buffer: Validate the buffer after type argument conversion --- io_buffer.c | 113 ++++++++++++++++++++++++++++-------- test/ruby/test_io_buffer.rb | 20 +++++++ 2 files changed, 108 insertions(+), 25 deletions(-) diff --git a/io_buffer.c b/io_buffer.c index fa6c8c5d5ac38e..16407159b5c50f 100644 --- a/io_buffer.c +++ b/io_buffer.c @@ -899,8 +899,8 @@ rb_io_buffer_get_bytes(VALUE self, void **base, size_t *size) } // Internal function for accessing bytes for writing, wil -static inline void -io_buffer_get_bytes_for_writing(struct rb_io_buffer *buffer, void **base, size_t *size) +static void +io_buffer_validate_for_writing(struct rb_io_buffer *buffer) { if (buffer->flags & RB_IO_BUFFER_READONLY || (!NIL_P(buffer->source) && OBJ_FROZEN(buffer->source))) { @@ -910,6 +910,21 @@ io_buffer_get_bytes_for_writing(struct rb_io_buffer *buffer, void **base, size_t if (!io_buffer_validate(buffer)) { rb_raise(rb_eIOBufferInvalidatedError, "Buffer is invalid!"); } +} + +static struct rb_io_buffer * +get_io_buffer_for_writing(VALUE self) +{ + struct rb_io_buffer *buffer = NULL; + TypedData_Get_Struct(self, struct rb_io_buffer, &rb_io_buffer_type, buffer); + io_buffer_validate_for_writing(buffer); + return buffer; +} + +static inline void +io_buffer_get_bytes_for_writing(struct rb_io_buffer *buffer, void **base, size_t *size) +{ + io_buffer_validate_for_writing(buffer); if (buffer->base) { *base = buffer->base; @@ -1379,14 +1394,23 @@ io_buffer_readonly_p(VALUE self) return RBOOL(rb_io_buffer_readonly_p(self)); } -static void -io_buffer_lock(struct rb_io_buffer *buffer) +static int +io_buffer_try_lock(struct rb_io_buffer *buffer) { if (buffer->flags & RB_IO_BUFFER_LOCKED) { - rb_raise(rb_eIOBufferLockedError, "Buffer already locked!"); + return 0; } buffer->flags |= RB_IO_BUFFER_LOCKED; + return 1; +} + +static void +io_buffer_lock(struct rb_io_buffer *buffer) +{ + if (!io_buffer_try_lock(buffer)) { + rb_raise(rb_eIOBufferLockedError, "Buffer already locked!"); + } } VALUE @@ -1964,6 +1988,10 @@ ruby_swap128_int(rb_int128_t x) return conversion.int128; } +#define IO_BUFFER_VALIDATE_TYPE_FOR_WRITING(buffer, base, size, offset, type) \ + (io_buffer_get_bytes_for_writing(buffer, &(base), &(size)), \ + io_buffer_validate_type(size, offset, sizeof(type))) + #define IO_BUFFER_DECLARE_TYPE(name, type, endian, wrap, unwrap, swap) \ static ID RB_IO_BUFFER_DATA_TYPE_##name; \ \ @@ -1979,10 +2007,12 @@ io_buffer_read_##name(const void* base, size_t size, size_t *offset) \ } \ \ static void \ -io_buffer_write_##name(const void* base, size_t size, size_t *offset, VALUE _value) \ +io_buffer_write_##name(struct rb_io_buffer* buffer, size_t *offset, VALUE _value) \ { \ - io_buffer_validate_type(size, *offset, sizeof(type)); \ + void* base; size_t size; \ + IO_BUFFER_VALIDATE_TYPE_FOR_WRITING(buffer, base, size, *offset, type); \ type value = unwrap(_value); \ + IO_BUFFER_VALIDATE_TYPE_FOR_WRITING(buffer, base, size, *offset, type); \ if (endian != RB_IO_BUFFER_HOST_ENDIAN) value = swap(value); \ memcpy((char*)base + *offset, &value, sizeof(type)); \ *offset += sizeof(type); \ @@ -2357,9 +2387,10 @@ io_buffer_each_byte(int argc, VALUE *argv, VALUE self) } static inline void -rb_io_buffer_set_value(const void* base, size_t size, ID buffer_type, size_t *offset, VALUE value) +rb_io_buffer_set_value(struct rb_io_buffer *buffer, VALUE buffer_type, size_t *offset, VALUE value) { -#define IO_BUFFER_SET_VALUE(name) if (buffer_type == RB_IO_BUFFER_DATA_TYPE_##name) {io_buffer_write_##name(base, size, offset, value); return;} + ID type = RB_SYM2ID(buffer_type); +#define IO_BUFFER_SET_VALUE(name) if (type == RB_IO_BUFFER_DATA_TYPE_##name) {io_buffer_write_##name(buffer, offset, value); return;} IO_BUFFER_SET_VALUE(U8); IO_BUFFER_SET_VALUE(S8); @@ -2392,6 +2423,21 @@ rb_io_buffer_set_value(const void* base, size_t size, ID buffer_type, size_t *of rb_raise(rb_eArgError, "Invalid type name!"); } +struct io_buffer_set_value_arguments { + struct rb_io_buffer *buffer; + size_t offset; + VALUE type, value; +}; + +static VALUE +io_buffer_set_value_try(VALUE arguments) +{ + struct io_buffer_set_value_arguments *args = (void *)arguments; + size_t offset = args->offset; + rb_io_buffer_set_value(args->buffer, args->type, &offset, args->value); + return SIZET2NUM(offset); +} + /* * call-seq: set_value(type, offset, value) -> offset * @@ -2425,13 +2471,30 @@ rb_io_buffer_set_value(const void* base, size_t size, ID buffer_type, size_t *of static VALUE io_buffer_set_value(VALUE self, VALUE type, VALUE _offset, VALUE value) { - void *base; - size_t size; - size_t offset = io_buffer_extract_offset(_offset); + struct io_buffer_set_value_arguments arguments = { + .buffer = get_io_buffer_for_writing(self), + .offset = io_buffer_extract_offset(_offset), + .type = type, + .value = value, + }; + + if (!io_buffer_try_lock(arguments.buffer)) { + return io_buffer_set_value_try((VALUE)&arguments); + } + return rb_ensure(io_buffer_set_value_try, (VALUE)&arguments, rb_io_buffer_locked_ensure, self); +} - rb_io_buffer_get_bytes_for_writing(self, &base, &size); +static VALUE +io_buffer_set_values_try(VALUE arguments) +{ + struct io_buffer_set_value_arguments *args = (void *)arguments; + size_t offset = args->offset; - rb_io_buffer_set_value(base, size, RB_SYM2ID(type), &offset, value); + for (long i = 0; i < RARRAY_LEN(args->type); i++) { + VALUE type = rb_ary_entry(args->type, i); + VALUE value = rb_ary_entry(args->value, i); + rb_io_buffer_set_value(args->buffer, type, &offset, value); + } return SIZET2NUM(offset); } @@ -2453,31 +2516,31 @@ io_buffer_set_value(VALUE self, VALUE type, VALUE _offset, VALUE value) static VALUE io_buffer_set_values(VALUE self, VALUE buffer_types, VALUE _offset, VALUE values) { + struct io_buffer_set_value_arguments arguments = { + .buffer = get_io_buffer_for_writing(self), + }; + if (!RB_TYPE_P(buffer_types, T_ARRAY)) { rb_raise(rb_eArgError, "Argument buffer_types should be an array!"); } + arguments.type = buffer_types; + + arguments.offset = io_buffer_extract_offset(_offset); if (!RB_TYPE_P(values, T_ARRAY)) { rb_raise(rb_eArgError, "Argument values should be an array!"); } + arguments.value = values; if (RARRAY_LEN(buffer_types) != RARRAY_LEN(values)) { rb_raise(rb_eArgError, "Argument buffer_types and values should have the same length!"); } - size_t offset = io_buffer_extract_offset(_offset); - - void *base; - size_t size; - rb_io_buffer_get_bytes_for_writing(self, &base, &size); - - for (long i = 0; i < RARRAY_LEN(buffer_types); i++) { - VALUE type = rb_ary_entry(buffer_types, i); - VALUE value = rb_ary_entry(values, i); - rb_io_buffer_set_value(base, size, RB_SYM2ID(type), &offset, value); + if (!io_buffer_try_lock(arguments.buffer)) { + return io_buffer_set_values_try((VALUE)&arguments); } + return rb_ensure(io_buffer_set_values_try, (VALUE)&arguments, rb_io_buffer_locked_ensure, self); - return SIZET2NUM(offset); } static size_t IO_BUFFER_BLOCKING_SIZE = 1024*1024; diff --git a/test/ruby/test_io_buffer.rb b/test/ruby/test_io_buffer.rb index bed80e64dc5d2c..fdf99589ef8825 100644 --- a/test/ruby/test_io_buffer.rb +++ b/test/ruby/test_io_buffer.rb @@ -470,6 +470,26 @@ def test_get_set_values end end + def test_set_values_invalidated_slice + to_int = Struct.new(:buffer) do + def to_int + buffer.free + 0x41 + end + end + buffer = IO::Buffer.new(128) + slice = buffer.slice(0, 8) + value = to_int.new(buffer) + assert_raise(IO::Buffer::InvalidatedError) {slice.set_value(:U8, 0, value)} + + buffer = IO::Buffer.new(128) + slice = buffer.slice(0, 8) + value = to_int.new(buffer) + assert_raise(IO::Buffer::InvalidatedError) { + slice.set_values([:U8, :U8], 0, [0, value]) + } + end + def test_zero_length_get_set_values buffer = IO::Buffer.new(0) From 09e76c6cb715bd476188bab26dd6be471bb2649c Mon Sep 17 00:00:00 2001 From: Nobuyoshi Nakada Date: Mon, 8 Jun 2026 13:19:33 +0900 Subject: [PATCH 04/11] IO::Buffer: Avoid inadvertent ID creation --- io_buffer.c | 23 +++++++--- .../-ext-/symbol/test_inadvertent_creation.rb | 43 +++++++++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/io_buffer.c b/io_buffer.c index 16407159b5c50f..dc8f547a322096 100644 --- a/io_buffer.c +++ b/io_buffer.c @@ -2082,6 +2082,15 @@ io_buffer_buffer_type_size(ID buffer_type) rb_raise(rb_eArgError, "Invalid type name!"); } +static inline ID +io_buffer_type_id(VALUE name) +{ + Check_Type(name, T_SYMBOL); + if (!STATIC_SYM_P(name)) return 0; + return rb_sym2id(name); +} +#define TYPE_ID(name) io_buffer_type_id(name) + /* * call-seq: * size_of(buffer_type) -> byte size @@ -2098,12 +2107,12 @@ io_buffer_size_of(VALUE klass, VALUE buffer_type) if (RB_TYPE_P(buffer_type, T_ARRAY)) { size_t total = 0; for (long i = 0; i < RARRAY_LEN(buffer_type); i++) { - total += io_buffer_buffer_type_size(RB_SYM2ID(RARRAY_AREF(buffer_type, i))); + total += io_buffer_buffer_type_size(TYPE_ID(RARRAY_AREF(buffer_type, i))); } return SIZET2NUM(total); } else { - return SIZET2NUM(io_buffer_buffer_type_size(RB_SYM2ID(buffer_type))); + return SIZET2NUM(io_buffer_buffer_type_size(TYPE_ID(buffer_type))); } } @@ -2190,7 +2199,7 @@ io_buffer_get_value(VALUE self, VALUE type, VALUE _offset) rb_io_buffer_get_bytes_for_reading(self, &base, &size); - return rb_io_buffer_get_value(base, size, RB_SYM2ID(type), &offset); + return rb_io_buffer_get_value(base, size, TYPE_ID(type), &offset); } /* @@ -2220,7 +2229,7 @@ io_buffer_get_values(VALUE self, VALUE buffer_types, VALUE _offset) for (long i = 0; i < RARRAY_LEN(buffer_types); i++) { VALUE type = rb_ary_entry(buffer_types, i); - VALUE value = rb_io_buffer_get_value(base, size, RB_SYM2ID(type), &offset); + VALUE value = rb_io_buffer_get_value(base, size, TYPE_ID(type), &offset); rb_ary_push(array, value); } @@ -2289,7 +2298,7 @@ io_buffer_each(int argc, VALUE *argv, VALUE self) ID buffer_type; if (argc >= 1) { - buffer_type = RB_SYM2ID(argv[0]); + buffer_type = TYPE_ID(argv[0]); } else { buffer_type = RB_IO_BUFFER_DATA_TYPE_U8; @@ -2327,7 +2336,7 @@ io_buffer_values(int argc, VALUE *argv, VALUE self) ID buffer_type; if (argc >= 1) { - buffer_type = RB_SYM2ID(argv[0]); + buffer_type = TYPE_ID(argv[0]); } else { buffer_type = RB_IO_BUFFER_DATA_TYPE_U8; @@ -2389,7 +2398,7 @@ io_buffer_each_byte(int argc, VALUE *argv, VALUE self) static inline void rb_io_buffer_set_value(struct rb_io_buffer *buffer, VALUE buffer_type, size_t *offset, VALUE value) { - ID type = RB_SYM2ID(buffer_type); + ID type = TYPE_ID(buffer_type); #define IO_BUFFER_SET_VALUE(name) if (type == RB_IO_BUFFER_DATA_TYPE_##name) {io_buffer_write_##name(buffer, offset, value); return;} IO_BUFFER_SET_VALUE(U8); IO_BUFFER_SET_VALUE(S8); diff --git a/test/-ext-/symbol/test_inadvertent_creation.rb b/test/-ext-/symbol/test_inadvertent_creation.rb index 995e01ee157cad..44705e10c76598 100644 --- a/test/-ext-/symbol/test_inadvertent_creation.rb +++ b/test/-ext-/symbol/test_inadvertent_creation.rb @@ -489,5 +489,48 @@ def test_iv_get Bug::Symbol.iv_get(obj, name) end end + + def assert_io_buffer_no_immortal_symbol_created(buffer = IO::Buffer.new(128)) + assert_no_immortal_symbol_created("io_buffer") do |name| + yield buffer, name.to_sym + end + end + + def test_io_buffer_size_of_inadvertent_id_creation + assert_io_buffer_no_immortal_symbol_created(nil) do |_, name| + assert_raise(ArgumentError) {IO::Buffer.size_of(name)} + assert_raise(ArgumentError) {IO::Buffer.size_of([name])} + end + end + + def test_io_buffer_each_inadvertent_id_creation + assert_io_buffer_no_immortal_symbol_created do |buffer, name| + assert_raise(ArgumentError) {buffer.each(name, 0, 1) {}} + end + end + + def test_io_buffer_get_value_inadvertent_id_creation + assert_io_buffer_no_immortal_symbol_created do |buffer, name| + assert_raise(ArgumentError) {buffer.get_value(name, 0)} + end + end + + def test_io_buffer_get_values_inadvertent_id_creation + assert_io_buffer_no_immortal_symbol_created do |buffer, name| + assert_raise(ArgumentError) {buffer.get_values([name], 0)} + end + end + + def test_io_buffer_set_value_inadvertent_id_creation + assert_io_buffer_no_immortal_symbol_created do |buffer, name| + assert_raise(ArgumentError) {buffer.set_value(name, 0, 0)} + end + end + + def test_io_buffer_set_values_inadvertent_id_creation + assert_io_buffer_no_immortal_symbol_created do |buffer, name| + assert_raise(ArgumentError) {buffer.set_values([name], 0, [0])} + end + end end end From ca3d14672114cc5356f41378422000195f212cac Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 8 Jun 2026 16:49:23 +0900 Subject: [PATCH 05/11] [ruby/rubygems] Pin that cooldown is inactive without publish dates The legacy dependency API exposes no per-version publish dates, so the cooldown filter has nothing to compare against and silently does nothing. Document that limitation as a regression guard rather than a surprise. https://github.com/ruby/rubygems/commit/ce952c1b9f Co-Authored-By: Claude Opus 4.8 --- spec/bundler/install/cooldown_spec.rb | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/spec/bundler/install/cooldown_spec.rb b/spec/bundler/install/cooldown_spec.rb index 5cdfe72284a764..bf525288169649 100644 --- a/spec/bundler/install/cooldown_spec.rb +++ b/spec/bundler/install/cooldown_spec.rb @@ -488,4 +488,28 @@ expect(the_bundle).to include_gems("upgradable 3.0.0") end end + + context "with a source that does not provide publish dates" do + before do + build_repo3 do + build_gem "ripe_gem", "1.0.0" + build_gem "ripe_gem", "2.0.0" + end + end + + it "cannot apply cooldown and installs the latest version" do + # The legacy dependency API does not expose per-version publish dates, so + # the cooldown filter has nothing to compare against and is silently + # inactive. This pins that limitation; flip the expectation if publish + # dates ever become available over this endpoint. + gemfile <<-G + source "https://gem.repo3", cooldown: 7 + gem "ripe_gem" + G + + bundle "install", artifice: "endpoint" + + expect(the_bundle).to include_gems("ripe_gem 2.0.0") + end + end end From c428294f1d199af5e97411dca71909cfbefc8ec1 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 8 Jun 2026 17:25:25 +0900 Subject: [PATCH 06/11] [ruby/rubygems] Cover per-source cooldown across multiple sources A single converge can hold several rubygems sources, each keyed by its own remotes. A partial update re-converges the still-locked sources, the path that used to drop cooldown, so lock in that the cooldown stays attached to the source that declared it for both a top-level source and a gem-block source. https://github.com/ruby/rubygems/commit/2fcb2d70cc Co-Authored-By: Claude Opus 4.8 --- spec/bundler/install/cooldown_spec.rb | 96 +++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/spec/bundler/install/cooldown_spec.rb b/spec/bundler/install/cooldown_spec.rb index bf525288169649..547048ef3400df 100644 --- a/spec/bundler/install/cooldown_spec.rb +++ b/spec/bundler/install/cooldown_spec.rb @@ -487,6 +487,102 @@ expect(the_bundle).to include_gems("upgradable 3.0.0") end + + it "keeps a top-level source cooldown through a partial update with multiple sources" do + build_repo4 do + build_gem "solo_gem", "1.0.0" do |s| + s.date = Time.now.utc - (30 * 86_400) + end + build_gem "solo_gem", "2.0.0" do |s| + s.date = Time.now.utc - (1 * 86_400) + end + end + + gemfile <<-G + source "https://gem.repo3", cooldown: 7 + gem "ripe_gem" + source "https://gem.repo4" do + gem "solo_gem" + end + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + ripe_gem (1.0.0) + + GEM + remote: https://gem.repo4/ + specs: + solo_gem (1.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + ripe_gem + solo_gem! + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "update ripe_gem", artifice: "compact_index_cooldown" + + # A partial update converges the still-locked sources, the path that used + # to drop cooldown. repo3's cooldown must survive that even with a second + # source in the Gemfile, so its in-window 2.0.0 stays excluded. + expect(the_bundle).to include_gems("ripe_gem 1.0.0", "solo_gem 1.0.0") + end + + it "carries cooldown declared on a gem-block source" do + build_repo4 do + build_gem "solo_gem", "1.0.0" do |s| + s.date = Time.now.utc - (30 * 86_400) + end + build_gem "solo_gem", "2.0.0" do |s| + s.date = Time.now.utc - (1 * 86_400) + end + end + + gemfile <<-G + source "https://gem.repo3" + gem "ripe_gem" + source "https://gem.repo4", cooldown: 7 do + gem "solo_gem" + end + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + ripe_gem (1.0.0) + + GEM + remote: https://gem.repo4/ + specs: + solo_gem (1.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + ripe_gem + solo_gem! + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "update solo_gem", artifice: "compact_index_cooldown" + + # The cooldown lives on the gem-block source, which is also converged from + # the lockfile. A partial update of solo_gem must keep that cooldown, so + # its in-window 2.0.0 stays excluded. + expect(the_bundle).to include_gems("ripe_gem 1.0.0", "solo_gem 1.0.0") + end end context "with a source that does not provide publish dates" do From 7c9aeb90c9fab9c21b995ea3f770184f7dd956a0 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 8 Jun 2026 17:25:38 +0900 Subject: [PATCH 07/11] [ruby/rubygems] Cover per-source cooldown through bundle add bundle add re-resolves the Gemfile against the existing lockfile via the injector, which converges sources the same way install and update do. A gem added there must inherit the source's cooldown instead of grabbing an in-window release. https://github.com/ruby/rubygems/commit/cfed95d1dd Co-Authored-By: Claude Opus 4.8 --- spec/bundler/install/cooldown_spec.rb | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/spec/bundler/install/cooldown_spec.rb b/spec/bundler/install/cooldown_spec.rb index 547048ef3400df..480e3997975dab 100644 --- a/spec/bundler/install/cooldown_spec.rb +++ b/spec/bundler/install/cooldown_spec.rb @@ -583,6 +583,33 @@ # its in-window 2.0.0 stays excluded. expect(the_bundle).to include_gems("ripe_gem 1.0.0", "solo_gem 1.0.0") end + + it "applies per-source Gemfile cooldown to a gem added via bundle add" do + gemfile <<-G + source "https://gem.repo3", cooldown: 7 + gem "ripe_gem" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + ripe_gem (1.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + ripe_gem + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "add child", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("child 1.0.0") + end end context "with a source that does not provide publish dates" do From 55554bd73a7735745ad1508c5e8bb0c32fcc866b Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 8 Jun 2026 17:26:37 +0900 Subject: [PATCH 08/11] [ruby/rubygems] Cover per-source cooldown on bundle lock --update This is the exact path from the original report: regenerating the lockfile must not advance a gem into the cooldown window. Assert the written lockfile to guard the lock-only flow that install and update specs do not touch. https://github.com/ruby/rubygems/commit/293d7c7dc3 Co-Authored-By: Claude Opus 4.8 --- spec/bundler/install/cooldown_spec.rb | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/spec/bundler/install/cooldown_spec.rb b/spec/bundler/install/cooldown_spec.rb index 480e3997975dab..66c4b99231bb54 100644 --- a/spec/bundler/install/cooldown_spec.rb +++ b/spec/bundler/install/cooldown_spec.rb @@ -610,6 +610,34 @@ expect(the_bundle).to include_gems("child 1.0.0") end + + it "applies per-source Gemfile cooldown on bundle lock --update" do + gemfile <<-G + source "https://gem.repo3", cooldown: 7 + gem "ripe_gem" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + ripe_gem (1.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + ripe_gem + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "lock --update ripe_gem", artifice: "compact_index_cooldown" + + expect(lockfile).to include("ripe_gem (1.0.0)") + expect(lockfile).not_to include("ripe_gem (2.0.0)") + end end context "with a source that does not provide publish dates" do From d9a6ca69c5e7f4b4f9604bd62add7ec1ff2b894a Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 8 Jun 2026 17:27:05 +0900 Subject: [PATCH 09/11] [ruby/rubygems] Pin that frozen installs ignore cooldown A frozen install reads the lockfile rather than resolving, so cooldown never runs. Document that a version locked inside the window still installs, so the bypass stays intentional. https://github.com/ruby/rubygems/commit/bfc099bc26 Co-Authored-By: Claude Opus 4.8 --- spec/bundler/install/cooldown_spec.rb | 30 +++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/spec/bundler/install/cooldown_spec.rb b/spec/bundler/install/cooldown_spec.rb index 66c4b99231bb54..ada48ac5d48dde 100644 --- a/spec/bundler/install/cooldown_spec.rb +++ b/spec/bundler/install/cooldown_spec.rb @@ -638,6 +638,36 @@ expect(lockfile).to include("ripe_gem (1.0.0)") expect(lockfile).not_to include("ripe_gem (2.0.0)") end + + it "ignores cooldown and installs the locked version when frozen" do + # Frozen installs read the lockfile instead of resolving, so cooldown has + # no say. A version already locked inside the window must still install. + gemfile <<-G + source "https://gem.repo3", cooldown: 7 + gem "ripe_gem" + G + + lockfile <<-L + GEM + remote: https://gem.repo3/ + specs: + ripe_gem (2.0.0) + + PLATFORMS + #{lockfile_platforms} + + DEPENDENCIES + ripe_gem + + BUNDLED WITH + #{Bundler::VERSION} + L + + bundle "config set frozen true" + bundle "install", artifice: "compact_index_cooldown" + + expect(the_bundle).to include_gems("ripe_gem 2.0.0") + end end context "with a source that does not provide publish dates" do From fadf8f0ea07c94b16d99e369481c3f62614e9ebe Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 8 Jun 2026 17:28:24 +0900 Subject: [PATCH 10/11] [ruby/rubygems] Cover per-source cooldown behind a mirror A mirror rewrites the fetch URI while cooldown stays keyed by the URI declared in the Gemfile. Confirm the redirect to the serving mirror does not lose the cooldown. https://github.com/ruby/rubygems/commit/ea2bb164f1 Co-Authored-By: Claude Opus 4.8 --- spec/bundler/install/cooldown_spec.rb | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/spec/bundler/install/cooldown_spec.rb b/spec/bundler/install/cooldown_spec.rb index ada48ac5d48dde..e85267ff4121f1 100644 --- a/spec/bundler/install/cooldown_spec.rb +++ b/spec/bundler/install/cooldown_spec.rb @@ -668,6 +668,23 @@ expect(the_bundle).to include_gems("ripe_gem 2.0.0") end + + it "keys per-source cooldown by the declared URI even behind a mirror" do + # A mirror rewrites the fetch URI, but cooldown is recorded under the URI + # written in the Gemfile. The cooldown must still apply through the + # redirect to the mirror that actually serves the gems. + bundle "config set mirror.https://gem.repo2 https://gem.repo3" + + gemfile <<-G + source "https://gem.repo2", cooldown: 7 + gem "ripe_gem" + G + + bundle "install", artifice: "compact_index_cooldown", + env: { "BUNDLER_SPEC_GEM_REPO" => gem_repo3.to_s } + + expect(the_bundle).to include_gems("ripe_gem 1.0.0") + end end context "with a source that does not provide publish dates" do From 7ed00ca7a9d3b28330afd4451b8f1613ccd01d83 Mon Sep 17 00:00:00 2001 From: Hiroshi SHIBATA Date: Mon, 8 Jun 2026 17:53:34 +0900 Subject: [PATCH 11/11] [ruby/rubygems] Take one timestamp per multi-source repo build Stamping each solo_gem with its own Time.now.utc lets the two dates drift apart and matches neither the surrounding before block. Snapshot the time once so the cooldown window stays stable as thresholds tighten. https://github.com/ruby/rubygems/commit/69c6d876d4 Co-Authored-By: Claude Opus 4.8 --- spec/bundler/install/cooldown_spec.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/spec/bundler/install/cooldown_spec.rb b/spec/bundler/install/cooldown_spec.rb index e85267ff4121f1..01e87be663c1b8 100644 --- a/spec/bundler/install/cooldown_spec.rb +++ b/spec/bundler/install/cooldown_spec.rb @@ -489,12 +489,13 @@ end it "keeps a top-level source cooldown through a partial update with multiple sources" do + now = Time.now.utc build_repo4 do build_gem "solo_gem", "1.0.0" do |s| - s.date = Time.now.utc - (30 * 86_400) + s.date = now - (30 * 86_400) end build_gem "solo_gem", "2.0.0" do |s| - s.date = Time.now.utc - (1 * 86_400) + s.date = now - (1 * 86_400) end end @@ -537,12 +538,13 @@ end it "carries cooldown declared on a gem-block source" do + now = Time.now.utc build_repo4 do build_gem "solo_gem", "1.0.0" do |s| - s.date = Time.now.utc - (30 * 86_400) + s.date = now - (30 * 86_400) end build_gem "solo_gem", "2.0.0" do |s| - s.date = Time.now.utc - (1 * 86_400) + s.date = now - (1 * 86_400) end end