Skip to content

Commit 684bb30

Browse files
meganemuraclaude
andcommitted
Perform the install triggered by auto_install in a subprocess
When `auto_install` is enabled and gems are missing, Bundler installs them in the same process that then runs `Bundler.setup`. Installing requires `openssl` for HTTPS remotes, and no Gemfile requirement is in effect yet, so RubyGems activates the newest installed version, which is the default gem while the locked one is still missing. Activation can't be undone, so the `Bundler.setup` that follows raises a `Gem::LoadError` when the lockfile pins `openssl` to another version. Do the install in a forked child, like `bundler/inline` already does for the same reason, so the process that boots the application never activates gems that were only needed to install. Platforms without `fork` keep installing in process. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e5c8f5f commit 684bb30

3 files changed

Lines changed: 92 additions & 1 deletion

File tree

lib/bundler.rb

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ def auto_install
187187
rescue GemNotFound, GitError
188188
ui.info "Automatically installing missing gems."
189189
reset!
190-
CLI::Install.new({}).run
190+
auto_install_missing_gems
191191
reset!
192192
end
193193
end
@@ -605,6 +605,44 @@ def self_manager
605605

606606
private
607607

608+
# When possible we do the install in a subprocess because to install gems
609+
# we need to require some default gems like `openssl` (for HTTPS remotes)
610+
# which may later conflict with the Gemfile requirements. `bundler/inline`
611+
# re-resolves when that happens. We can't: the `Bundler.setup` that follows
612+
# must activate what the lockfile says.
613+
def auto_install_missing_gems
614+
do_install = -> { CLI::Install.new({}).run }
615+
616+
if Process.respond_to?(:fork)
617+
[$stdout, $stderr].each(&:flush) # don't let the fork inherit buffered output
618+
619+
_, status = Process.waitpid2(Process.fork do
620+
exit_status = 1
621+
622+
begin
623+
# Errors here never reach the parent's handler. Report them in the
624+
# child, and let the parent exit with the status. Required inside the
625+
# fork so the CLI's vendored Thor stays out of the parent.
626+
require_relative "bundler/friendly_errors"
627+
628+
with_friendly_errors(&do_install)
629+
exit_status = 0
630+
rescue SystemExit => e
631+
exit_status = e.status
632+
ensure
633+
# Skip `at_exit` handlers, they belong to the booting program.
634+
# `exit!` doesn't flush, so flush by hand.
635+
[$stdout, $stderr].each(&:flush)
636+
exit!(exit_status)
637+
end
638+
end)
639+
640+
exit(status.exitstatus || status.to_i) unless status.success?
641+
else
642+
do_install.call
643+
end
644+
end
645+
608646
def unbundle_env(env)
609647
if env.key?("BUNDLER_ORIG_MANPATH")
610648
env["MANPATH"] = env["BUNDLER_ORIG_MANPATH"]

spec/bundler/bundler_spec.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,36 @@
207207
end
208208
end
209209

210+
describe "#auto_install" do
211+
let(:install) { double("install") }
212+
213+
before do
214+
skip "requires Process.fork" unless Process.respond_to?(:fork)
215+
216+
definition = double("definition")
217+
allow(definition).to receive(:specs).and_raise(Bundler::GemNotFound)
218+
allow(Bundler).to receive(:definition).and_return(definition)
219+
allow(Bundler::CLI::Install).to receive(:new).with({}).and_return(install)
220+
end
221+
222+
it "installs in a subprocess, so that gems activated to install don't conflict with the Gemfile" do
223+
installer_pid = tmp("auto_install_pid")
224+
allow(install).to receive(:run) { File.write(installer_pid, Process.pid) }
225+
226+
Bundler.settings.temporary(auto_install: true) { Bundler.auto_install }
227+
228+
expect(installer_pid.read.to_i).not_to eq(Process.pid)
229+
end
230+
231+
it "exits with the status code of a failed install" do
232+
allow(install).to receive(:run).and_raise(Bundler::InstallError)
233+
234+
expect do
235+
Bundler.settings.temporary(auto_install: true) { Bundler.auto_install }
236+
end.to raise_error(SystemExit) {|error| expect(error.status).to eq(5) }
237+
end
238+
end
239+
210240
describe "#mkdir_p" do
211241
it "creates a folder at the given path" do
212242
install_gemfile <<-G

spec/runtime/setup_gems_spec.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,29 @@ def require(path)
792792
expect(out).to include("Installing myrack 1.0.0")
793793
end
794794

795+
it "performs an automatic bundle install of a default gem locked to another version" do
796+
build_repo4 do
797+
build_gem "psych", "999"
798+
build_gem "myrack", "1.0.0"
799+
end
800+
801+
gemfile <<-G
802+
source "https://gem.repo4"
803+
gem "psych"
804+
gem "myrack"
805+
G
806+
807+
bundle_config "auto_install 1"
808+
809+
ruby <<-RUBY, artifice: "compact_index"
810+
require 'bundler/setup'
811+
puts Gem.loaded_specs["psych"].version
812+
RUBY
813+
expect(err).to be_empty
814+
expect(out).to include("Installing psych 999")
815+
expect(out).to include("999")
816+
end
817+
795818
context "in a read-only filesystem" do
796819
before do
797820
gemfile <<-G

0 commit comments

Comments
 (0)