From 7e478e51b9bcc08ee418d4db1855b81ce006d4d3 Mon Sep 17 00:00:00 2001 From: Josh Heinrichs Date: Thu, 4 Jun 2026 16:49:33 -0600 Subject: [PATCH] Use env shebangs for RubyGems bin wrappers RubyGems bin wrappers hardcode the Ruby used at install time. With Nix-style Ruby installs, that path can later be garbage-collected or replaced, leaving wrappers like ruby-lsp and bundle pointing at a stale store path. Ruby LSP already activates the project Ruby environment before installing or launching the server, so install the bootstrap ruby-lsp gem and any missing Bundler versions with RubyGems' env-shebang option. This lets generated wrappers resolve ruby from the activated PATH instead. Bundler project binstubs already use env shebangs by default; this makes the RubyGems bin wrappers Ruby LSP relies on behave similarly. --- lib/ruby_lsp/setup_bundler.rb | 2 +- test/setup_bundler_test.rb | 32 +++++++++++++++++ vscode/src/test/suite/workspace.test.ts | 47 ++++++++++++++++++++++++- vscode/src/workspace.ts | 9 ++--- 4 files changed, 84 insertions(+), 6 deletions(-) diff --git a/lib/ruby_lsp/setup_bundler.rb b/lib/ruby_lsp/setup_bundler.rb index ad182aac8a..aa1c488d12 100644 --- a/lib/ruby_lsp/setup_bundler.rb +++ b/lib/ruby_lsp/setup_bundler.rb @@ -445,7 +445,7 @@ def install_bundler_if_needed requirement = Gem::Requirement.new(@bundler_version.to_s) return if Gem::Specification.any? { |s| s.name == "bundler" && requirement =~ s.version } - Gem.install("bundler", @bundler_version.to_s) + Gem.install("bundler", @bundler_version.to_s, env_shebang: true) end #: -> bool diff --git a/test/setup_bundler_test.rb b/test/setup_bundler_test.rb index 6eddde33a6..71d89205e9 100644 --- a/test/setup_bundler_test.rb +++ b/test/setup_bundler_test.rb @@ -655,6 +655,38 @@ def test_sets_bundler_version_to_avoid_reloads end end + def test_installs_missing_bundler_with_env_shebang + in_temp_dir do |dir| + File.write(File.join(dir, "Gemfile"), <<~GEMFILE) + source "https://rubygems.org" + GEMFILE + + File.write(File.join(dir, "Gemfile.lock"), <<~LOCKFILE) + GEM + remote: https://rubygems.org/ + specs: + + PLATFORMS + ruby + + DEPENDENCIES + + BUNDLED WITH + 999.999.999 + LOCKFILE + + capture_subprocess_io do + Bundler.with_unbundled_env do + compose = RubyLsp::SetupBundler.new(dir, launcher: true) + compose.expects(:run_bundle_install_directly) + Gem.expects(:install).with("bundler", "999.999.999", env_shebang: true) + + compose.setup! + end + end + end + end + def test_invoke_cli_calls_bundler_directly_for_install in_temp_dir do |dir| File.write(File.join(dir, "gems.rb"), <<~GEMFILE) diff --git a/vscode/src/test/suite/workspace.test.ts b/vscode/src/test/suite/workspace.test.ts index ab8fb9aa88..326f230698 100644 --- a/vscode/src/test/suite/workspace.test.ts +++ b/vscode/src/test/suite/workspace.test.ts @@ -8,9 +8,10 @@ import * as vscode from "vscode"; import { beforeEach, afterEach } from "mocha"; import { Workspace } from "../../workspace"; +import * as common from "../../common"; import { FAKE_TELEMETRY } from "./fakeTelemetry"; -import { createContext, FakeContext } from "./helpers"; +import { createContext, FakeContext, stubWorkspaceConfiguration } from "./helpers"; suite("Workspace", () => { let workspacePath: string; @@ -41,6 +42,50 @@ suite("Workspace", () => { context.dispose(); }); + test("installs ruby-lsp with env shebang", async () => { + stubWorkspaceConfiguration(sandbox, { rubyLsp: { bundleGemfile: "" } }); + sandbox.stub(common, "featureEnabled").returns(false); + + const execStub = sandbox.stub(common, "asyncExec"); + execStub.onFirstCall().resolves({ stdout: "", stderr: "" }); + execStub.onSecondCall().resolves({ stdout: "", stderr: "" }); + + await workspace.installOrUpdateServer(false); + + assert.strictEqual(execStub.firstCall.args[0], "gem list ruby-lsp language_server-protocol prism rbs"); + assert.strictEqual(execStub.secondCall.args[0], "gem install ruby-lsp --env-shebang"); + }); + + test("updates ruby-lsp with env shebang", async () => { + stubWorkspaceConfiguration(sandbox, { rubyLsp: { bundleGemfile: "" } }); + sandbox.stub(common, "featureEnabled").returns(false); + + const execStub = sandbox.stub(common, "asyncExec"); + execStub.onFirstCall().resolves({ + stdout: "ruby-lsp (0.1.0)\nlanguage_server-protocol (3.17.0)\nprism (1.2.0)\nrbs (3.0.0)\n", + stderr: "", + }); + execStub.onSecondCall().resolves({ stdout: "", stderr: "" }); + + await workspace.installOrUpdateServer(false); + + assert.strictEqual(execStub.firstCall.args[0], "gem list ruby-lsp language_server-protocol prism rbs"); + assert.strictEqual(execStub.secondCall.args[0], "gem update ruby-lsp --env-shebang"); + }); + + test("installs beta ruby-lsp with prerelease and env shebang flags", async () => { + stubWorkspaceConfiguration(sandbox, { rubyLsp: { bundleGemfile: "" } }); + sandbox.stub(common, "featureEnabled").returns(true); + + const execStub = sandbox.stub(common, "asyncExec"); + execStub.onFirstCall().resolves({ stdout: "", stderr: "" }); + execStub.onSecondCall().resolves({ stdout: "", stderr: "" }); + + await workspace.installOrUpdateServer(false); + + assert.strictEqual(execStub.secondCall.args[0], "gem install ruby-lsp --pre --env-shebang"); + }); + test("repeated rebase steps don't trigger multiple restarts", async () => { const gitDir = path.join(workspacePath, ".git"); fs.mkdirSync(gitDir); diff --git a/vscode/src/workspace.ts b/vscode/src/workspace.ts index 35355bea48..e916aa46d6 100644 --- a/vscode/src/workspace.ts +++ b/vscode/src/workspace.ts @@ -241,8 +241,8 @@ export class Workspace implements WorkspaceInterface { await this.lspClient?.dispose(); } - // Install or update the `ruby-lsp` gem globally with `gem install ruby-lsp` or `gem update ruby-lsp`. We only try to - // update on a daily basis, not every time the server boots + // Install or update the `ruby-lsp` gem globally with `gem install ruby-lsp --env-shebang` or + // `gem update ruby-lsp --env-shebang`. We only try to update on a daily basis, not every time the server boots async installOrUpdateServer(manualInvocation: boolean): Promise { // If there's a user configured custom bundle to run the LSP, then we do not perform auto-updates and let the user // manage that custom bundle themselves @@ -264,12 +264,13 @@ export class Workspace implements WorkspaceInterface { }); const preFlag = featureEnabled("betaServer") ? " --pre" : ""; + const gemFlags = `${preFlag} --env-shebang`; // If any of the Ruby LSP's dependencies are missing, we need to install them. For example, if the user runs `gem // uninstall prism`, then we must ensure it's installed or else rubygems will fail when trying to launch the // executable if (!dependencies.every((dep) => new RegExp(`${dep}\\s`).exec(stdout))) { - await asyncExec(`gem install ruby-lsp${preFlag}`, { + await asyncExec(`gem install ruby-lsp${gemFlags}`, { cwd: this.workspaceFolder.uri.fsPath, env: this.ruby.env, }); @@ -295,7 +296,7 @@ export class Workspace implements WorkspaceInterface { // If we haven't updated the gem in the last 24 hours or if the user manually asked for an update, update it if (manualInvocation || lastUpdatedAt === undefined || Date.now() - lastUpdatedAt > oneDayInMs) { try { - await asyncExec(`gem update ruby-lsp${preFlag}`, { + await asyncExec(`gem update ruby-lsp${gemFlags}`, { cwd: this.workspaceFolder.uri.fsPath, env: this.ruby.env, });