Skip to content

Commit 114ad07

Browse files
nganclaude
authored andcommitted
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 <noreply@anthropic.com>
1 parent 103ca42 commit 114ad07

6 files changed

Lines changed: 110 additions & 0 deletions

File tree

bundler/lib/bundler/installer.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ def run(options)
6363
Bundler.create_bundle_path
6464

6565
ProcessLock.lock do
66+
# Invalidate any stale gem specification cache from before we acquired the lock.
67+
# Another process may have installed gems while we were waiting.
68+
Gem::Specification.reset
69+
@definition.sources.clear_cache
70+
6671
@definition.ensure_equivalent_gemfile_and_lockfile(options[:deployment])
6772

6873
if @definition.dependencies.empty?

bundler/lib/bundler/source/rubygems.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,11 @@ def dependency_api_available?
314314
@allow_remote && api_fetchers.any?
315315
end
316316

317+
def clear_cache
318+
@installed_specs = nil
319+
@default_specs = nil
320+
end
321+
317322
protected
318323

319324
def remote_names

bundler/lib/bundler/source_list.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ def remote!
136136
all_sources.each(&:remote!)
137137
end
138138

139+
def clear_cache
140+
rubygems_sources.each(&:clear_cache)
141+
end
142+
139143
private
140144

141145
def map_sources(replacement_sources)

spec/bundler/source/rubygems_spec.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,36 @@
4545
end
4646
end
4747

48+
describe "#clear_cache" do
49+
it "clears the installed_specs cache" do
50+
source = described_class.new
51+
52+
# Access installed_specs to populate the cache
53+
source.send(:installed_specs)
54+
expect(source.instance_variable_get(:@installed_specs)).not_to be_nil
55+
56+
# Expire the cache
57+
source.clear_cache
58+
59+
# Cache should be cleared
60+
expect(source.instance_variable_get(:@installed_specs)).to be_nil
61+
end
62+
63+
it "clears the default_specs cache" do
64+
source = described_class.new
65+
66+
# Access default_specs to populate the cache
67+
source.send(:default_specs)
68+
expect(source.instance_variable_get(:@default_specs)).not_to be_nil
69+
70+
# Expire the cache
71+
source.clear_cache
72+
73+
# Cache should be cleared
74+
expect(source.instance_variable_get(:@default_specs)).to be_nil
75+
end
76+
end
77+
4878
describe "log debug information" do
4979
it "log the time spent downloading and installing a gem" do
5080
build_repo2 do

spec/bundler/source_list_spec.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,16 @@
442442
end
443443
end
444444

445+
describe "#clear_cache" do
446+
let(:rubygems_source) { source_list.add_rubygems_source("remotes" => ["https://rubygems.org"]) }
447+
448+
it "calls #clear_cache on all rubygems sources" do
449+
expect(rubygems_source).to receive(:clear_cache)
450+
expect(source_list.global_rubygems_source).to receive(:clear_cache)
451+
source_list.clear_cache
452+
end
453+
end
454+
445455
describe "implicit_global_source?" do
446456
context "when a global rubygem source provided" do
447457
it "returns a falsy value" do

spec/install/process_lock_spec.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,61 @@
5353
expect(processed).to eq true
5454
end
5555
end
56+
57+
it "refreshes gem specification cache after waiting for lock" do
58+
build_repo2 do
59+
build_gem "myrack", "1.0.0"
60+
end
61+
62+
gemfile <<-G
63+
source "https://gem.repo2"
64+
gem "myrack"
65+
G
66+
67+
# First, install the gem so it's available
68+
bundle "install"
69+
expect(out).to include("Installing myrack")
70+
71+
# Queue for thread-safe communication
72+
lock_acquired = Queue.new
73+
can_release_lock = Queue.new
74+
install_output = Queue.new
75+
76+
# Thread holds lock (simulating another bundle process that just finished installing)
77+
thread = Thread.new do
78+
Bundler::ProcessLock.lock(default_bundle_path) do
79+
# Signal that we have the lock
80+
lock_acquired << true
81+
# Wait until main thread signals we can release
82+
can_release_lock.pop
83+
end
84+
end
85+
86+
# Wait for thread to acquire lock
87+
lock_acquired.pop
88+
89+
# Start another install in a thread - it will wait for the lock
90+
install_thread = Thread.new do
91+
bundle "install", verbose: true
92+
install_output << out
93+
end
94+
95+
# Give subprocess time to start and begin waiting for lock
96+
sleep 0.5
97+
98+
# Signal thread to release the lock
99+
can_release_lock << true
100+
101+
# Wait for both threads to complete
102+
thread.join
103+
install_thread.join
104+
105+
second_install_out = install_output.pop
106+
107+
expect(the_bundle).to include_gems "myrack 1.0.0"
108+
# The second install should have refreshed its cache after acquiring
109+
# the lock and seen that myrack was already installed
110+
expect(second_install_out).to include("Using myrack")
111+
end
56112
end
57113
end

0 commit comments

Comments
 (0)