From 66d433e86eb5429969d4faff17743bed7aa27222 Mon Sep 17 00:00:00 2001 From: Ngan Pham Date: Wed, 4 Feb 2026 18:18:18 -0800 Subject: [PATCH] Clear gem specification cache after acquiring process lock When multiple `bundle install` processes run concurrently, a race condition can cause issues. The second process populates its `Gem::Specification.stubs` and `@installed_specs` caches before acquiring the ProcessLock. While waiting for the lock, the first process installs gems. After acquiring the lock, the second process uses its stale cache and may not see the newly installed gems. This fix clears the caches immediately after acquiring the process lock, ensuring that any gems installed by another process while waiting for the lock are properly detected. Similar to #8539 which addressed a related cache invalidation issue for the `bundle update` command. Fixes #8473 Co-Authored-By: Claude Opus 4.5 --- bundler/lib/bundler/installer.rb | 5 ++ bundler/lib/bundler/source/rubygems.rb | 5 ++ bundler/lib/bundler/source_list.rb | 4 ++ bundler/spec/bundler/source/rubygems_spec.rb | 30 +++++++++++ bundler/spec/bundler/source_list_spec.rb | 10 ++++ bundler/spec/install/process_lock_spec.rb | 56 ++++++++++++++++++++ 6 files changed, 110 insertions(+) diff --git a/bundler/lib/bundler/installer.rb b/bundler/lib/bundler/installer.rb index c5fd75431f41..20948fcbce4e 100644 --- a/bundler/lib/bundler/installer.rb +++ b/bundler/lib/bundler/installer.rb @@ -63,6 +63,11 @@ def run(options) Bundler.create_bundle_path ProcessLock.lock do + # Invalidate any stale gem specification cache from before we acquired the lock. + # Another process may have installed gems while we were waiting. + Gem::Specification.reset + @definition.sources.clear_cache + @definition.ensure_equivalent_gemfile_and_lockfile(options[:deployment]) if @definition.dependencies.empty? diff --git a/bundler/lib/bundler/source/rubygems.rb b/bundler/lib/bundler/source/rubygems.rb index e1e030ffc899..7a020fc1960d 100644 --- a/bundler/lib/bundler/source/rubygems.rb +++ b/bundler/lib/bundler/source/rubygems.rb @@ -320,6 +320,11 @@ def dependency_api_available? @allow_remote && api_fetchers.any? end + def clear_cache + @installed_specs = nil + @default_specs = nil + end + protected def remote_names diff --git a/bundler/lib/bundler/source_list.rb b/bundler/lib/bundler/source_list.rb index 38fa0972e64e..ac141299e5d0 100644 --- a/bundler/lib/bundler/source_list.rb +++ b/bundler/lib/bundler/source_list.rb @@ -136,6 +136,10 @@ def remote! all_sources.each(&:remote!) end + def clear_cache + rubygems_sources.each(&:clear_cache) + end + private def map_sources(replacement_sources) diff --git a/bundler/spec/bundler/source/rubygems_spec.rb b/bundler/spec/bundler/source/rubygems_spec.rb index dde4e4ed4769..516a85fafa1a 100644 --- a/bundler/spec/bundler/source/rubygems_spec.rb +++ b/bundler/spec/bundler/source/rubygems_spec.rb @@ -45,6 +45,36 @@ end end + describe "#clear_cache" do + it "clears the installed_specs cache" do + source = described_class.new + + # Access installed_specs to populate the cache + source.send(:installed_specs) + expect(source.instance_variable_get(:@installed_specs)).not_to be_nil + + # Expire the cache + source.clear_cache + + # Cache should be cleared + expect(source.instance_variable_get(:@installed_specs)).to be_nil + end + + it "clears the default_specs cache" do + source = described_class.new + + # Access default_specs to populate the cache + source.send(:default_specs) + expect(source.instance_variable_get(:@default_specs)).not_to be_nil + + # Expire the cache + source.clear_cache + + # Cache should be cleared + expect(source.instance_variable_get(:@default_specs)).to be_nil + end + end + describe "log debug information" do it "log the time spent downloading and installing a gem" do build_repo2 do diff --git a/bundler/spec/bundler/source_list_spec.rb b/bundler/spec/bundler/source_list_spec.rb index 6e0be6c92fcc..3ed58b867d96 100644 --- a/bundler/spec/bundler/source_list_spec.rb +++ b/bundler/spec/bundler/source_list_spec.rb @@ -442,6 +442,16 @@ end end + describe "#clear_cache" do + let(:rubygems_source) { source_list.add_rubygems_source("remotes" => ["https://rubygems.org"]) } + + it "calls #clear_cache on all rubygems sources" do + expect(rubygems_source).to receive(:clear_cache) + expect(source_list.global_rubygems_source).to receive(:clear_cache) + source_list.clear_cache + end + end + describe "implicit_global_source?" do context "when a global rubygem source provided" do it "returns a falsy value" do diff --git a/bundler/spec/install/process_lock_spec.rb b/bundler/spec/install/process_lock_spec.rb index 344caa3a9312..b096291d1a92 100644 --- a/bundler/spec/install/process_lock_spec.rb +++ b/bundler/spec/install/process_lock_spec.rb @@ -53,5 +53,61 @@ expect(processed).to eq true end end + + it "refreshes gem specification cache after waiting for lock" do + build_repo2 do + build_gem "myrack", "1.0.0" + end + + gemfile <<-G + source "https://gem.repo2" + gem "myrack" + G + + # First, install the gem so it's available + bundle "install" + expect(out).to include("Installing myrack") + + # Queue for thread-safe communication + lock_acquired = Queue.new + can_release_lock = Queue.new + install_output = Queue.new + + # Thread holds lock (simulating another bundle process that just finished installing) + thread = Thread.new do + Bundler::ProcessLock.lock(default_bundle_path) do + # Signal that we have the lock + lock_acquired << true + # Wait until main thread signals we can release + can_release_lock.pop + end + end + + # Wait for thread to acquire lock + lock_acquired.pop + + # Start another install in a thread - it will wait for the lock + install_thread = Thread.new do + bundle "install", verbose: true + install_output << out + end + + # Give subprocess time to start and begin waiting for lock + sleep 0.5 + + # Signal thread to release the lock + can_release_lock << true + + # Wait for both threads to complete + thread.join + install_thread.join + + second_install_out = install_output.pop + + expect(the_bundle).to include_gems "myrack 1.0.0" + # The second install should have refreshed its cache after acquiring + # the lock and seen that myrack was already installed + expect(second_install_out).to include("Using myrack") + end end end