diff --git a/lib/ruby_lsp/setup_bundler.rb b/lib/ruby_lsp/setup_bundler.rb index 90563f868..55f5fe059 100644 --- a/lib/ruby_lsp/setup_bundler.rb +++ b/lib/ruby_lsp/setup_bundler.rb @@ -19,6 +19,7 @@ class SetupBundler extend T::Sig class BundleNotLocked < StandardError; end + class BundleInstallFailure < StandardError; end FOUR_HOURS = T.let(4 * 60 * 60, Integer) @@ -49,6 +50,7 @@ def initialize(project_path, **options) @last_updated_path = T.let(@custom_dir + "last_updated", Pathname) @dependencies = T.let(load_dependencies, T::Hash[String, T.untyped]) + @retry = T.let(false, T::Boolean) end # Sets up the custom bundle and returns the `BUNDLE_GEMFILE`, `BUNDLE_PATH` and `BUNDLE_APP_CONFIG` that should be @@ -224,7 +226,17 @@ def run_bundle_install(bundle_gemfile = @gemfile) # Add bundle update $stderr.puts("Ruby LSP> Running bundle install for the custom bundle. This may take a while...") $stderr.puts("Ruby LSP> Command: #{command}") - system(env, command) + + # Try to run the bundle install or update command. If that fails, it normally means that the custom lockfile is in + # a bad state that no longer reflects the top level one. In that case, we can remove the whole directory, try + # another time and give up if it fails again + if !system(env, command) && !@retry && @custom_dir.exist? + @retry = true + @custom_dir.rmtree + $stderr.puts("Ruby LSP> Running bundle install failed. Trying to re-generate the custom bundle from scratch") + return setup! + end + [bundle_gemfile.to_s, expanded_path, env["BUNDLE_APP_CONFIG"]] end diff --git a/test/setup_bundler_test.rb b/test/setup_bundler_test.rb index cd2e85776..3daeae8be 100644 --- a/test/setup_bundler_test.rb +++ b/test/setup_bundler_test.rb @@ -6,14 +6,14 @@ class SetupBundlerTest < Minitest::Test def test_does_nothing_if_both_ruby_lsp_and_debug_are_in_the_bundle - Object.any_instance.expects(:system).with(bundle_env, "(bundle check || bundle install) 1>&2") + Object.any_instance.expects(:system).with(bundle_env, "(bundle check || bundle install) 1>&2").returns(true) Bundler::LockfileParser.any_instance.expects(:dependencies).returns({ "ruby-lsp" => true, "debug" => true }) run_script refute_path_exists(".ruby-lsp") end def test_does_nothing_if_both_ruby_lsp_and_debug_are_in_the_bundle2 - Object.any_instance.expects(:system).with(bundle_env, "(bundle check || bundle install) 1>&2") + Object.any_instance.expects(:system).with(bundle_env, "(bundle check || bundle install) 1>&2").returns(true) Bundler::LockfileParser.any_instance.expects(:dependencies).returns({ "ruby-lsp" => true, "rails" => true, @@ -35,7 +35,7 @@ def test_removes_ruby_lsp_folder_if_both_gems_were_added_to_the_bundle end def test_in_a_rails_app_does_nothing_if_ruby_lsp_and_ruby_lsp_rails_and_debug_are_in_the_bundle - Object.any_instance.expects(:system).with(bundle_env, "(bundle check || bundle install) 1>&2") + Object.any_instance.expects(:system).with(bundle_env, "(bundle check || bundle install) 1>&2").returns(true) Bundler::LockfileParser.any_instance.expects(:dependencies) .returns({ "ruby-lsp" => true, "ruby-lsp-rails" => true, "debug" => true }) run_script @@ -45,7 +45,7 @@ def test_in_a_rails_app_does_nothing_if_ruby_lsp_and_ruby_lsp_rails_and_debug_ar end def test_in_a_rails_app_removes_ruby_lsp_folder_if_all_gems_were_added_to_the_bundle - Object.any_instance.expects(:system).with(bundle_env, "(bundle check || bundle install) 1>&2") + Object.any_instance.expects(:system).with(bundle_env, "(bundle check || bundle install) 1>&2").returns(true) Bundler::LockfileParser.any_instance.expects(:dependencies) .returns({ "ruby-lsp" => true, "ruby-lsp-rails" => true, "debug" => true }) FileUtils.mkdir(".ruby-lsp") @@ -56,7 +56,10 @@ def test_in_a_rails_app_removes_ruby_lsp_folder_if_all_gems_were_added_to_the_bu end def test_creates_custom_bundle - Object.any_instance.expects(:system).with(bundle_env(".ruby-lsp/Gemfile"), "(bundle check || bundle install) 1>&2") + Object.any_instance.expects(:system).with( + bundle_env(".ruby-lsp/Gemfile"), + "(bundle check || bundle install) 1>&2", + ).returns(true) Bundler::LockfileParser.any_instance.expects(:dependencies).returns({}).at_least_once run_script @@ -72,7 +75,10 @@ def test_creates_custom_bundle end def test_creates_custom_bundle_for_a_rails_app - Object.any_instance.expects(:system).with(bundle_env(".ruby-lsp/Gemfile"), "(bundle check || bundle install) 1>&2") + Object.any_instance.expects(:system).with( + bundle_env(".ruby-lsp/Gemfile"), + "(bundle check || bundle install) 1>&2", + ).returns(true) Bundler::LockfileParser.any_instance.expects(:dependencies).returns({ "rails" => true }).at_least_once run_script @@ -128,7 +134,7 @@ def test_changing_lockfile_causes_custom_bundle_to_be_rebuilt Object.any_instance.expects(:system).with( bundle_env(".ruby-lsp/Gemfile"), "(bundle check || bundle install) 1>&2", - ) + ).returns(true) Bundler.with_unbundled_env do run_script end @@ -160,7 +166,7 @@ def test_does_not_copy_gemfile_lock_when_not_modified Object.any_instance.expects(:system).with( bundle_env(".ruby-lsp/Gemfile"), "((bundle check && bundle update ruby-lsp debug) || bundle install) 1>&2", - ) + ).returns(true) FileUtils.expects(:cp).never @@ -197,7 +203,7 @@ def test_does_only_updates_every_4_hours Object.any_instance.expects(:system).with( bundle_env(".ruby-lsp/Gemfile"), "(bundle check || bundle install) 1>&2", - ) + ).returns(true) Bundler.with_unbundled_env do # Run the script again without having the lockfile modified @@ -210,7 +216,10 @@ def test_does_only_updates_every_4_hours def test_uses_absolute_bundle_path_for_bundle_install Bundler.settings.set_global("path", "vendor/bundle") - Object.any_instance.expects(:system).with(bundle_env(".ruby-lsp/Gemfile"), "(bundle check || bundle install) 1>&2") + Object.any_instance.expects(:system).with( + bundle_env(".ruby-lsp/Gemfile"), + "(bundle check || bundle install) 1>&2", + ).returns(true) Bundler::LockfileParser.any_instance.expects(:dependencies).returns({}).at_least_once run_script(expected_path: File.expand_path("vendor/bundle", Dir.pwd)) ensure @@ -227,7 +236,7 @@ def test_creates_custom_bundle_if_no_gemfile Object.any_instance.expects(:system).with( bundle_env(bundle_gemfile.to_s), "(bundle check || bundle install) 1>&2", - ) + ).returns(true) Bundler.with_unbundled_env do run_script @@ -289,7 +298,7 @@ def test_does_nothing_if_both_ruby_lsp_and_debug_are_gemspec_dependencies FileUtils.touch(File.join(dir, "Gemfile.lock")) Bundler.with_unbundled_env do - Object.any_instance.expects(:system).with(bundle_env, "(bundle check || bundle install) 1>&2") + Object.any_instance.expects(:system).with(bundle_env, "(bundle check || bundle install) 1>&2").returns(true) Bundler::LockfileParser.any_instance.expects(:dependencies).returns({}) run_script end @@ -306,7 +315,7 @@ def test_creates_custom_bundle_with_specified_branch Object.any_instance.expects(:system).with( bundle_env(bundle_gemfile.to_s), "(bundle check || bundle install) 1>&2", - ) + ).returns(true) Bundler.with_unbundled_env do run_script(branch: "test-branch") @@ -342,7 +351,7 @@ def test_install_prerelease_versions_if_experimental_is_true Object.any_instance.expects(:system).with( bundle_env(".ruby-lsp/Gemfile"), "((bundle check && bundle update ruby-lsp debug --pre) || bundle install) 1>&2", - ) + ).returns(true) Bundler.with_unbundled_env do run_script(experimental: true) @@ -361,7 +370,7 @@ def test_returns_bundle_app_config_if_there_is_local_config Object.any_instance.expects(:system).with( bundle_env(bundle_gemfile.to_s), "(bundle check || bundle install) 1>&2", - ) + ).returns(true) run_script end @@ -490,7 +499,7 @@ def test_ruby_lsp_rails_is_automatically_included_in_rails_apps Object.any_instance.expects(:system).with( bundle_env(".ruby-lsp/Gemfile"), "(bundle check || bundle install) 1>&2", - ) + ).returns(true) Bundler.with_unbundled_env do run_script end @@ -501,6 +510,73 @@ def test_ruby_lsp_rails_is_automatically_included_in_rails_apps end end + def test_recovers_from_stale_lockfiles + Dir.mktmpdir do |dir| + custom_dir = File.join(dir, ".ruby-lsp") + FileUtils.mkdir_p(custom_dir) + + Dir.chdir(dir) do + # Write the main Gemfile and lockfile with valid versions + File.write(File.join(dir, "Gemfile"), <<~GEMFILE) + source "https://rubygems.org" + gem "stringio" + GEMFILE + + lockfile_contents = <<~LOCKFILE + GEM + remote: https://rubygems.org/ + specs: + stringio (3.1.0) + + PLATFORMS + arm64-darwin-23 + ruby + + DEPENDENCIES + stringio + + BUNDLED WITH + 2.5.7 + LOCKFILE + File.write(File.join(dir, "Gemfile.lock"), lockfile_contents) + + # Write the lockfile hash based on the valid file + File.write(File.join(custom_dir, "main_lockfile_hash"), Digest::SHA256.hexdigest(lockfile_contents)) + + # Write the custom bundle's lockfile using a fake version that doesn't exist to force bundle install to fail + File.write(File.join(custom_dir, "Gemfile"), <<~GEMFILE) + source "https://rubygems.org" + gem "stringio" + GEMFILE + File.write(File.join(custom_dir, "Gemfile.lock"), <<~LOCKFILE) + GEM + remote: https://rubygems.org/ + specs: + stringio (999.1.555) + + PLATFORMS + arm64-darwin-23 + ruby + + DEPENDENCIES + stringio + + BUNDLED WITH + 2.5.7 + LOCKFILE + + Bundler.with_unbundled_env do + run_script + end + + # Verify that the script recovered and re-generated the custom bundle from scratch + assert_path_exists(".ruby-lsp/Gemfile") + assert_path_exists(".ruby-lsp/Gemfile.lock") + refute_match("999.1.555", File.read(".ruby-lsp/Gemfile.lock")) + end + end + end + private # This method runs the script and then immediately unloads it. This allows us to make assertions against the effects