From d3d2b3d0b5b5a3561ba81f56157e7939ea61bd6a Mon Sep 17 00:00:00 2001 From: meganemura Date: Tue, 4 Aug 2026 23:14:09 +0900 Subject: [PATCH] Perform the install triggered by auto_install in a subprocess With `auto_install`, Bundler installs missing gems 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 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. Platforms without `fork` keep installing in process. Co-Authored-By: Claude Opus 5 (1M context) --- lib/bundler.rb | 40 ++++++++++++++++++++++++++++++++- spec/bundler/bundler_spec.rb | 30 +++++++++++++++++++++++++ spec/runtime/setup_gems_spec.rb | 23 +++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/lib/bundler.rb b/lib/bundler.rb index 4edb2600747d..e47715d9def6 100644 --- a/lib/bundler.rb +++ b/lib/bundler.rb @@ -187,7 +187,7 @@ def auto_install rescue GemNotFound, GitError ui.info "Automatically installing missing gems." reset! - CLI::Install.new({}).run + auto_install_missing_gems reset! end end @@ -605,6 +605,44 @@ def self_manager private + # When possible we do the install in a subprocess because to install gems + # we need to require some default gems like `openssl` (for HTTPS remotes) + # which may later conflict with the Gemfile requirements. `bundler/inline` + # re-resolves when that happens. We can't: the `Bundler.setup` that follows + # must activate what the lockfile says. + def auto_install_missing_gems + do_install = -> { CLI::Install.new({}).run } + + if Process.respond_to?(:fork) + [$stdout, $stderr].each(&:flush) # don't let the fork inherit buffered output + + _, status = Process.waitpid2(Process.fork do + exit_status = 1 + + begin + # Errors here never reach the parent's handler. Report them in the + # child, and let the parent exit with the status. Required inside the + # fork so the CLI's vendored Thor stays out of the parent. + require_relative "bundler/friendly_errors" + + with_friendly_errors(&do_install) + exit_status = 0 + rescue SystemExit => e + exit_status = e.status + ensure + # Skip `at_exit` handlers, they belong to the booting program. + # `exit!` doesn't flush, so flush by hand. + [$stdout, $stderr].each(&:flush) + exit!(exit_status) + end + end) + + exit(status.exitstatus || status.to_i) unless status.success? + else + do_install.call + end + end + def unbundle_env(env) if env.key?("BUNDLER_ORIG_MANPATH") env["MANPATH"] = env["BUNDLER_ORIG_MANPATH"] diff --git a/spec/bundler/bundler_spec.rb b/spec/bundler/bundler_spec.rb index bddcbdaef39c..83cca459a1e7 100644 --- a/spec/bundler/bundler_spec.rb +++ b/spec/bundler/bundler_spec.rb @@ -207,6 +207,36 @@ end end + describe "#auto_install" do + let(:install) { double("install") } + + before do + skip "requires Process.fork" unless Process.respond_to?(:fork) + + definition = double("definition") + allow(definition).to receive(:specs).and_raise(Bundler::GemNotFound) + allow(Bundler).to receive(:definition).and_return(definition) + allow(Bundler::CLI::Install).to receive(:new).with({}).and_return(install) + end + + it "installs in a subprocess, so that gems activated to install don't conflict with the Gemfile" do + installer_pid = tmp("auto_install_pid") + allow(install).to receive(:run) { File.write(installer_pid, Process.pid) } + + Bundler.settings.temporary(auto_install: true) { Bundler.auto_install } + + expect(installer_pid.read.to_i).not_to eq(Process.pid) + end + + it "exits with the status code of a failed install" do + allow(install).to receive(:run).and_raise(Bundler::InstallError) + + expect do + Bundler.settings.temporary(auto_install: true) { Bundler.auto_install } + end.to raise_error(SystemExit) {|error| expect(error.status).to eq(5) } + end + end + describe "#mkdir_p" do it "creates a folder at the given path" do install_gemfile <<-G diff --git a/spec/runtime/setup_gems_spec.rb b/spec/runtime/setup_gems_spec.rb index 1a8df525d3cb..08fec2ee1acb 100644 --- a/spec/runtime/setup_gems_spec.rb +++ b/spec/runtime/setup_gems_spec.rb @@ -792,6 +792,29 @@ def require(path) expect(out).to include("Installing myrack 1.0.0") end + it "performs an automatic bundle install of a default gem locked to another version" do + build_repo4 do + build_gem "psych", "999" + build_gem "myrack", "1.0.0" + end + + gemfile <<-G + source "https://gem.repo4" + gem "psych" + gem "myrack" + G + + bundle_config "auto_install 1" + + ruby <<-RUBY, artifice: "compact_index" + require 'bundler/setup' + puts Gem.loaded_specs["psych"].version + RUBY + expect(err).to be_empty + expect(out).to include("Installing psych 999") + expect(out).to include("999") + end + context "in a read-only filesystem" do before do gemfile <<-G