From 653ebd578af4d32d7152cab094471f9c87964f69 Mon Sep 17 00:00:00 2001 From: Jacob Kjeldahl Date: Thu, 5 Nov 2026 15:38:10 +0100 Subject: [PATCH] fix(ruby): Ruby LSP no longer crash-loops on dead shims or a spaced gem home (#1534) Ruby LSP showed Crashed and restarted for two reasons. A version-manager shim for ruby-lsp exists for every Ruby that ever had the gem, so when the active Ruby lacked it the shim exited on every start while the IDE counted the server as installed. And the IDE-managed copy lived in a gem home under Application Support, whose space broke ruby-lsp's re-exec through Bundler. Shims that can't run the tool now count as missing. ruby-lsp is installed with the project's own Ruby (gem install in the project, so a version manager picks its Ruby). Its executable is looked up in that Ruby's gem bindir and user bin when they are off PATH (Homebrew), and those directories are added to the server's PATH. Co-authored-by: kjeldahl <19719+kjeldahl@users.noreply.github.com> --- crates/ide-core/src/tool_path.rs | 15 ++++++ crates/ide-host/src/ops/tools.rs | 6 ++- crates/ide-lsp/src/launch.rs | 87 +++++++++++++++++++++++++++++- crates/ide-sdk/src/apply.rs | 33 ++++++++++++ crates/ide-sdk/src/ruby.rs | 8 +++ crates/ide-tools/src/lib.rs | 93 +++++++++++++++++++++++++++++++- crates/ide-tools/src/registry.rs | 8 ++- crates/ide-tools/src/tests.rs | 45 ++++++++++++++++ 8 files changed, 289 insertions(+), 6 deletions(-) diff --git a/crates/ide-core/src/tool_path.rs b/crates/ide-core/src/tool_path.rs index 266f5bae0..bc25dd6d2 100644 --- a/crates/ide-core/src/tool_path.rs +++ b/crates/ide-core/src/tool_path.rs @@ -30,6 +30,21 @@ pub fn search_path() -> OsString { std::env::join_paths(dirs).unwrap_or_default() } +/// Ruby code printing where `gem install` puts executables: the Ruby's own `Gem.bindir`, which +/// Homebrew's Ruby keeps off `PATH`, and the `--user-install` one. +pub const RUBY_GEM_BIN_SCRIPT: &str = "puts Gem.bindir, File.join(Gem.user_dir, 'bin')"; + +/// The directories [`RUBY_GEM_BIN_SCRIPT`] printed that exist. +pub fn gem_bin_dirs(output: &str) -> Vec { + let mut dirs: Vec = Vec::new(); + for dir in output.lines().map(str::trim).map(PathBuf::from) { + if dir.is_absolute() && dir.is_dir() && !dirs.contains(&dir) { + dirs.push(dir); + } + } + dirs +} + /// Resolves a command on the shared path, or an explicit path relative to `root`. pub fn find_command(command: &str, search: &OsStr, root: &Path) -> Option { let path = Path::new(command); diff --git a/crates/ide-host/src/ops/tools.rs b/crates/ide-host/src/ops/tools.rs index b92399c2f..d3188b77a 100644 --- a/crates/ide-host/src/ops/tools.rs +++ b/crates/ide-host/src/ops/tools.rs @@ -24,7 +24,11 @@ fn tools(host: &Host, window: &str) -> IdeResult { .collect(); // Godot is usually an app bundle / Flatpak outside the search path. let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p)); - Ok(Tools::new(&data).with_custom_paths(custom).with_found_paths(found.into_iter().collect())) + let work_dir = (!root.as_os_str().is_empty()).then_some(root); + Ok(Tools::new(&data) + .with_custom_paths(custom) + .with_found_paths(found.into_iter().collect()) + .with_work_dir(work_dir)) } /// The language definitions plus the servers defined in the window's settings. diff --git a/crates/ide-lsp/src/launch.rs b/crates/ide-lsp/src/launch.rs index c8f68f0e0..e7ce586b5 100644 --- a/crates/ide-lsp/src/launch.rs +++ b/crates/ide-lsp/src/launch.rs @@ -4,7 +4,7 @@ use ide_core::CancellationToken; use ide_core::tool_path::search_path; use ide_jsonrpc::{Handler, Peer}; use ide_language::LanguageServerConfig; -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -100,7 +100,8 @@ impl Launcher for ProcessLauncher { // A plugin's server: the approved program, unchanged since. Some(pin) => Some(pin.verify().map_err(LaunchError::Failed)?.to_path_buf()), None => ide_core::tool_path::find_command(&config.command, &search, root) - .and_then(|b| resolve_rustup_proxy(&config.command, b)), + .and_then(|b| resolve_rustup_proxy(&config.command, b)) + .and_then(|b| reject_dead_shim(b, &search, root)), }; let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())]; env.extend(config.env.iter().filter(|(k, _)| *k != "PATH").map(|(k, v)| (k.clone(), v.clone()))); @@ -136,6 +137,60 @@ fn server_search_path(config: &LanguageServerConfig) -> OsString { config.env.get("PATH").map(OsString::from).unwrap_or_else(search_path) } +/// What a version manager's shim says when the active version has no such tool. +const DEAD_SHIM_MESSAGES: &[&str] = + &["no version is set for", "no preset version", "command not found", "exists in these", "is not installed"]; + +/// `~/.local/share/mise/shims/ruby-lsp` exists for every version of the runtime that ever +/// had the tool, but exits with an error when the version active in `root` lacks it. A +/// server that dies like that on every start is missing (an install hint), not crashing. +/// Anything but a clear "not installed" answer from the shim counts as working. +fn reject_dead_shim(binary: PathBuf, search: &OsStr, root: &Path) -> Option { + if !binary.parent().is_some_and(|d| d.file_name().is_some_and(|n| n == "shims")) { + return Some(binary); + } + let Ok(mut child) = ide_core::process::prepare(&mut std::process::Command::new(&binary)) + .arg("--version") + .current_dir(root) + .env("PATH", search) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .spawn() + else { + return Some(binary); + }; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(20)) + } + // Slow or unwaitable: let the real start decide. + _ => { + let _ = child.kill(); + let _ = child.wait(); + return Some(binary); + } + } + }; + if status.success() { + return Some(binary); + } + let mut stderr = String::new(); + if let Some(mut pipe) = child.stderr.take() { + let _ = std::io::Read::read_to_string(&mut pipe, &mut stderr); + } + let stderr = stderr.to_lowercase(); + if DEAD_SHIM_MESSAGES.iter().any(|m| stderr.contains(m)) { + tracing::info!("{} is a version-manager shim without the tool: {}", binary.display(), stderr.trim()); + None + } else { + Some(binary) + } +} + /// `~/.cargo/bin/` is usually a rustup proxy that fails when the active /// toolchain lacks the component (common for rust-analyzer). In that case use /// the newest installed toolchain that ships the binary; with none, the tool is @@ -219,6 +274,34 @@ mod tests { std::fs::remove_dir_all(&tmp).unwrap(); } + #[cfg(unix)] + #[test] + fn shims_without_the_tool_are_missing() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let shims = tmp.path().join("mise/shims"); + std::fs::create_dir_all(&shims).unwrap(); + let script = |name: &str, body: &str| { + let p = shims.join(name); + std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + p + }; + let search = OsString::from("/usr/bin:/bin"); + let dead = script("ruby-lsp", "echo 'mise ERROR No version is set for shim: ruby-lsp' >&2; exit 1"); + assert_eq!(reject_dead_shim(dead, &search, tmp.path()), None); + let works = script("good", "echo 1.0"); + assert_eq!(reject_dead_shim(works.clone(), &search, tmp.path()), Some(works)); + // A tool with no `--version` that fails for its own reasons still starts. + let odd = script("odd", "echo 'usage: odd' >&2; exit 2"); + assert_eq!(reject_dead_shim(odd.clone(), &search, tmp.path()), Some(odd)); + // Only shims are probed. + let plain = tmp.path().join("ruby-lsp"); + std::fs::write(&plain, "#!/bin/sh\necho 'No version is set for' >&2; exit 1\n").unwrap(); + std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!(reject_dead_shim(plain.clone(), &search, tmp.path()), Some(plain)); + } + #[test] fn marked_root_needs_a_marker() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 11888554c..2ee3a07e2 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -691,6 +691,13 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root path_dirs.push(dir); } + // Gems install to the Ruby's bindir, which is not always on `PATH` (Homebrew). + if name == "ruby-lsp" + && let Some(ruby) = ruby_interpreter(root, s) + { + path_dirs.extend(crate::ruby::gem_bin_dirs(&ruby, server_root)); + } + if !path_dirs.is_empty() { let dirs: Vec<&Path> = path_dirs.iter().map(PathBuf::as_path).collect(); cfg.env.insert("PATH".into(), path_with(&dirs)); @@ -1301,3 +1308,29 @@ mod maven_linkage_tests { assert!(serde_json::to_value(cfg.initialization_options).unwrap().get("projectConfigurations").is_none()); } } + +#[cfg(all(test, unix))] +mod ruby_lsp_tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + #[test] + fn ruby_lsp_is_found_in_the_rubys_gem_bindir_when_that_is_off_path() { + let tmp = tempfile::tempdir().unwrap(); + let bindir = tmp.path().join("gems/bin"); + std::fs::create_dir_all(&bindir).unwrap(); + // Homebrew's Ruby: `Gem.bindir` is not on `PATH`. + let ruby = tmp.path().join("ruby"); + std::fs::write(&ruby, format!("#!/bin/sh\necho '{}'\necho '/nonexistent/user/bin'\n", bindir.display())) + .unwrap(); + std::fs::set_permissions(&ruby, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut s = Settings::default(); + s.languages.ruby.interpreter = ruby.to_string_lossy().into_owned(); + let mut cfg = custom_server("ruby-lsp", &LanguageServerSettings::default()); + configure_server(&mut cfg, tmp.path(), tmp.path(), &s); + let path = cfg.env.get("PATH").expect("PATH set"); + let dirs: Vec = std::env::split_paths(path).collect(); + assert!(dirs.contains(&bindir), "{dirs:?}"); + assert!(!dirs.iter().any(|d| d.starts_with("/nonexistent")), "{dirs:?}"); + } +} diff --git a/crates/ide-sdk/src/ruby.rs b/crates/ide-sdk/src/ruby.rs index b7ce8df4b..c0bbed7ca 100644 --- a/crates/ide-sdk/src/ruby.rs +++ b/crates/ide-sdk/src/ruby.rs @@ -140,6 +140,14 @@ pub fn bin_dir_of(ruby: &Path) -> Option { ruby.parent().map(Path::to_path_buf) } +/// Where `ruby` installs gem executables (`Gem.bindir`, the `--user-install` one) that exist, asked +/// of the Ruby that version managers select in `root`: Homebrew's keeps its bindir off `PATH`. +pub fn gem_bin_dirs(ruby: &Path, root: &Path) -> Vec { + query_in(ruby, &["-e", ide_core::tool_path::RUBY_GEM_BIN_SCRIPT], Some(root), &[], QUERY_TIMEOUT) + .map(|out| ide_core::tool_path::gem_bin_dirs(&out)) + .unwrap_or_default() +} + type NamedRoots = Vec<(String, PathBuf)>; /// Standard-library / site directories (`ruby/3.3.0`) and the gems Bundler resolves diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 881393d6c..d0a8b65d5 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -154,7 +154,11 @@ fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option

{ Some(root.join(spec.name).join("venv").join(if platform.os == Os::Windows { "Scripts" } else { "bin" })) } - Method::Bundled { .. } | Method::Rustup { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => None, + Method::Bundled { .. } + | Method::Rustup { .. } + | Method::PipUser { .. } + | Method::PipTarget { .. } + | Method::GemActive { .. } => None, } } @@ -429,6 +433,9 @@ pub struct Tools { /// Executables found outside the search path (app bundles), by command. found: BTreeMap, version_timeout: Duration, + /// The project the tools are used for: tools that install into "the active runtime" (gems) run here, + /// so a version manager picks the project's Ruby. + work_dir: Option, } impl Tools { @@ -450,6 +457,7 @@ impl Tools { custom: BTreeMap::new(), found: BTreeMap::new(), version_timeout: Duration::from_secs(4), + work_dir: None, } } @@ -465,10 +473,25 @@ impl Tools { self } + /// Runs per-project installs (see [`registry::Method::GemActive`]) in `dir`. + pub fn with_work_dir(mut self, dir: Option) -> Self { + self.work_dir = dir.filter(|d| d.is_dir()); + self + } + pub fn root(&self) -> &Path { &self.root } + /// [`Tools::command`] in the project directory. + fn command_in_project(&self, program: impl AsRef) -> Command { + let mut cmd = self.command(program); + if let Some(dir) = &self.work_dir { + cmd.current_dir(dir); + } + cmd + } + fn which(&self, cmd: &str) -> Option { ide_core::tool_path::find_command(cmd, &self.system_path, &self.root) } @@ -614,7 +637,9 @@ impl Tools { "npm or pnpm is required: install Node.js (https://nodejs.org)", ), Method::Go { .. } => need(self.which("go").is_some(), "Go is required (https://go.dev/dl)"), - Method::Gem { .. } => need(self.which("gem").is_some(), "Ruby is required (https://www.ruby-lang.org)"), + Method::Gem { .. } | Method::GemActive { .. } => { + need(self.which("gem").is_some(), "Ruby is required (https://www.ruby-lang.org)") + } Method::DotnetTool { .. } => { need(self.which("dotnet").is_some(), ".NET SDK is required (https://dotnet.microsoft.com/download)") } @@ -772,6 +797,22 @@ impl Tools { _ => (ToolStatus::Missing, None, None), } } + Method::GemActive { gem } => { + // Installed for the Ruby active in the project, not merely a shim of some other version's. + let Some(gem_cmd) = self.which("gem") else { return (ToolStatus::Missing, None, None) }; + let mut c = self.command_in_project(&gem_cmd); + c.args(["list", "--exact", gem]); + match run_quiet(c, Duration::from_secs(10)) { + Some((true, out)) if out.contains(&format!("{gem} (")) => { + // Homebrew's Ruby keeps its gem bindir off PATH: ask the Ruby where it is. + match self.gem_executable(main) { + Some(path) => (ToolStatus::OnPath, Some(path), parse_version(&out)), + None => (ToolStatus::Missing, None, None), + } + } + _ => (ToolStatus::Missing, None, None), + } + } _ => match self.which(main) { Some(path) => { let version = self.query_version(spec, &path); @@ -868,6 +909,7 @@ impl Tools { Method::Npm { packages } => self.install_npm(packages, cancel, emit)?, Method::Go { module } => self.install_go(spec, module, cancel, emit)?, Method::Gem { gem } => self.install_gem(spec, gem, cancel, emit)?, + Method::GemActive { gem } => self.install_gem_active(gem, cancel, emit)?, Method::DotnetTool { package } => self.install_dotnet_tool(spec, package, cancel, emit)?, Method::PipVenv { package } => self.install_venv(spec, package, cancel, emit)?, Method::PipUser { package, .. } => self.install_pip_user(package, cancel, emit)?, @@ -953,6 +995,41 @@ impl Tools { self.write_manifest(spec.name, &Manifest { version, source: Some(module.to_string()) }) } + /// Where the project's Ruby installs gem executables (`Gem.bindir`, `Gem.user_dir`'s `bin`). + fn gem_bin_dirs(&self) -> Vec { + let Some(ruby) = self.which("ruby") else { return Vec::new() }; + let mut c = self.command_in_project(ruby); + c.args(["-e", ide_core::tool_path::RUBY_GEM_BIN_SCRIPT]); + match run_quiet(c, Duration::from_secs(10)) { + Some((true, out)) => ide_core::tool_path::gem_bin_dirs(&out), + _ => Vec::new(), + } + } + + /// An installed gem's executable: on the search path, else in one of the Ruby's gem bin directories. + fn gem_executable(&self, cmd: &str) -> Option { + self.which(cmd).or_else(|| self.gem_bin_dirs().iter().find_map(|d| self.exe_in(d, cmd))) + } + + /// `gem install` into the active Ruby, then makes version-manager shims see the new executable. + fn install_gem_active(&self, gem: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> { + let mut c = self.command_in_project(self.which("gem").expect("checked")); + c.args(["install", "--no-document", gem]); + run_streamed(c, cancel, emit)?; + // mise / asdf / rbenv list executables in shims written at install time; a gem installed + // afterwards has none until they are regenerated. Best effort: the tool may not be installed. + for (manager, args) in + [("mise", &["reshim"][..]), ("asdf", &["reshim", "ruby"][..]), ("rbenv", &["rehash"][..])] + { + if let Some(path) = self.which(manager) { + let mut c = self.command_in_project(path); + c.args(args); + let _ = run_quiet(c, Duration::from_secs(30)); + } + } + Ok(()) + } + fn install_gem(&self, spec: &ToolSpec, gem: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> { let dir = self.root.join(spec.name); let _ = std::fs::remove_dir_all(&dir); @@ -1310,6 +1387,18 @@ impl Tools { run_streamed(c, &cancel, &quiet)?; } } + Method::GemActive { gem } => { + if let Some(gem_cmd) = self.which("gem") { + let mut c = self.command_in_project(&gem_cmd); + c.args(["uninstall", "--all", "--executables", "--ignore-dependencies", gem]); + run_streamed(c, &cancel, &quiet)?; + } + // A copy an older version installed under the tools directory. + let dir = self.root.join(spec.name); + if dir.exists() { + std::fs::remove_dir_all(&dir)?; + } + } Method::Go { .. } | Method::Gem { .. } | Method::DotnetTool { .. } diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index 494482f76..50b503ca7 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -49,6 +49,11 @@ pub enum Method { PipTarget { package: &'static str, module: &'static str }, /// `gem install` into `//gems`, launched through `GEM_HOME` wrappers in `bin/`. Gem { gem: &'static str }, + /// `gem install ` into the active Ruby (the project's, installed from a project window), + /// launched through that Ruby's own executables. For tools that re-launch themselves under + /// Bundler (ruby-lsp), which breaks in a gem home under a path with spaces + /// (`~/Library/Application Support`: Bundler `exec`s a lone path string, and Ruby splits it). + GemActive { gem: &'static str }, /// `dotnet tool install --tool-path //tool `, launched /// through `DOTNET_ROOT` wrappers in `bin/`. DotnetTool { package: &'static str }, @@ -64,6 +69,7 @@ impl Method { Method::Npm { .. } => "npm", Method::Go { .. } => "go install", Method::Gem { .. } => "gem", + Method::GemActive { .. } => "gem (project Ruby)", Method::DotnetTool { .. } => "dotnet tool", Method::PipVenv { .. } => "pip (virtualenv)", Method::PipUser { .. } => "pip --user", @@ -940,7 +946,7 @@ pub static TOOLS: &[ToolSpec] = &[ display: "Ruby LSP (Shopify)", kind: LanguageServer, commands: &["ruby-lsp"], - method: Method::Gem { gem: "ruby-lsp" }, + method: Method::GemActive { gem: "ruby-lsp" }, fallback: None, version: V, homepage: "https://shopify.github.io/ruby-lsp", diff --git a/crates/ide-tools/src/tests.rs b/crates/ide-tools/src/tests.rs index de4ff670d..f3ab5530d 100644 --- a/crates/ide-tools/src/tests.rs +++ b/crates/ide-tools/src/tests.rs @@ -590,6 +590,51 @@ printf '#!/bin/sh\necho "$GEM_HOME"\n' > "$bin/solargraph"; /bin/chmod +x "$bin/ assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), env.root.join("solargraph/gems").to_string_lossy()); } +#[cfg(unix)] +#[test] +fn ruby_lsp_installs_into_the_project_ruby_not_a_private_gem_home() { + let env = Env::new(); + let project = env._tmp.path().join("project"); + fs::create_dir_all(&project).unwrap(); + let marker = env._tmp.path().join("installed"); + // Homebrew's Ruby: the gem bindir is not on the search path; only the Ruby knows it. + let bindir = env._tmp.path().join("gems/bin"); + fs::create_dir_all(&bindir).unwrap(); + env.script(&env.sys, "ruby", &format!("echo '{}'", bindir.display())); + // `gem list --exact ruby-lsp` knows the gem once `gem install ruby-lsp` ran, in the cwd it was given. + env.script( + &env.sys, + "gem", + &format!( + r#"case "$1" in +install) echo "$PWD $@" > '{m}'; printf '#!/bin/sh\n' > '{b}/ruby-lsp'; /bin/chmod +x '{b}/ruby-lsp' ;; +list) [ -f '{m}' ] && echo "ruby-lsp (0.26.11)" ;; +uninstall) /bin/rm -f '{m}' ;; +esac"#, + m = marker.display(), + b = bindir.display() + ), + ); + let tools = env.tools().with_work_dir(Some(project.clone())); + assert_eq!(find(&tools.list(&langs()), "ruby-lsp").status, ToolStatus::Missing); + let info = tools.install("ruby-lsp", &langs(), &CancellationToken::new(), &|_| {}).unwrap(); + assert_eq!(info.status, ToolStatus::OnPath); + assert_eq!(info.version.as_deref(), Some("0.26.11")); + // Found in the Ruby's own bindir, not reported as the `gem` command. + assert_eq!(info.path.as_deref(), Some(bindir.join("ruby-lsp").as_path())); + // Run in the project (so a version manager picks its Ruby), into the Ruby's own gem home. + let log = fs::read_to_string(&marker).unwrap(); + assert!(log.starts_with(&*project.canonicalize().unwrap().to_string_lossy()), "{log}"); + assert!(log.contains("install --no-document ruby-lsp") && !log.contains("--install-dir"), "{log}"); + // No private gem home, no wrapper directory on the search path. + assert!(!env.root.join("ruby-lsp").exists()); + // Uninstalling removes the gem, and an older managed copy. + fs::create_dir_all(env.root.join("ruby-lsp/gems")).unwrap(); + let info = tools.uninstall("ruby-lsp", &langs()).unwrap(); + assert_eq!(info.status, ToolStatus::Missing); + assert!(!env.root.join("ruby-lsp").exists()); +} + #[cfg(unix)] #[test] fn dotnet_tool_install_into_tool_path() {