From 655b3c539fc9009f79e41b63daaf2a49a29b444a Mon Sep 17 00:00:00 2001 From: meilin Date: Wed, 4 Nov 2026 11:30:46 +0800 Subject: [PATCH] windows compatibility fix --- src/ruby.rs | 292 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 284 insertions(+), 8 deletions(-) diff --git a/src/ruby.rs b/src/ruby.rs index 8a53c0e..7b52b92 100644 --- a/src/ruby.rs +++ b/src/ruby.rs @@ -43,6 +43,102 @@ struct RubyDebugConfig { cwd: Option, } +/// Zed's default grace period for a TCP debug adapter to start listening is +/// 2000ms (`DebuggerSettings::timeout`). Booting Ruby on Windows routinely +/// takes ~1.9s, and ~2.8s when it goes through `bundle exec`, so the default +/// loses that race and the user sees "unable to connect to the debug service". +/// Widening the timeout only affects the failing path; a successful start +/// still connects as soon as the socket is up. +const WINDOWS_DAP_CONNECT_TIMEOUT_MS: u64 = 30_000; + +/// Renders the host of a TCP debug connection the way `rdbg` expects it. +/// +/// `TcpArguments::host` carries an IPv4 address as a big-endian `u32`, so +/// formatting it directly yields `--host=2130706433`. `rdbg` passes that to +/// name resolution, which fails with "getaddrinfo: no such host" and exits +/// before the adapter ever listens - which Zed reports as a connect timeout. +fn format_debug_host(host: u32) -> String { + std::net::Ipv4Addr::from(host).to_string() +} + +/// Applies `overrides` on top of `envs`, replacing values in place instead of +/// duplicating keys, so later (more specific) sources win. +fn merge_envs( + mut envs: Vec<(String, String)>, + overrides: impl IntoIterator, +) -> Vec<(String, String)> { + for (key, value) in overrides { + match envs.iter_mut().find(|(existing, _)| *existing == key) { + Some(entry) => entry.1 = value, + None => envs.push((key, value)), + } + } + envs +} + +/// Returns the extensionless Ruby script behind a Windows batch shim. +/// +/// RubyGems writes both files next to each other (`bin/rdbg` and +/// `bin/rdbg.bat`) and the shim locates the script through `%~dpn0`, i.e. its +/// own path without the extension. Returns `None` for anything that is not a +/// `.bat`/`.cmd` shim. +/// +/// This splits the string by hand instead of using `std::path`: the extension +/// runs as a WASI module, where `Path` follows POSIX rules even when the host +/// is Windows, so `Path::extension` would treat `C:\...\rdbg.bat` as a single +/// file name and never see the `.bat` suffix. +fn gem_script_path(shim: &str) -> Option { + let last_separator = shim.rfind(|character| character == '/' || character == '\\'); + let (directory, file_name) = match last_separator { + Some(index) => shim.split_at(index + 1), + None => ("", shim), + }; + let (stem, extension) = file_name.rsplit_once('.')?; + if stem.is_empty() { + return None; + } + if !extension.eq_ignore_ascii_case("bat") && !extension.eq_ignore_ascii_case("cmd") { + return None; + } + Some(format!("{directory}{stem}")) +} + +/// Runs the Ruby script behind `shim` with `ruby` as interpreter, keeping the +/// arguments the shim used to receive. +fn ruby_interpreter_command( + shim: &str, + ruby: String, + arguments: Vec, +) -> (String, Vec) { + match gem_script_path(shim) { + Some(script) => { + let mut script_arguments = Vec::with_capacity(arguments.len() + 1); + script_arguments.push(script); + script_arguments.extend(arguments); + (ruby, script_arguments) + } + // Not a batch shim: nothing to unwrap, keep the direct launch. + None => (shim.to_string(), arguments), + } +} + +/// Decides how the adapter process is spawned. +/// +/// Only Windows rewrites the launch so the Ruby script runs under an explicit +/// interpreter; every other platform gets back the exact command it resolved +/// (shebang launchers, version-manager shims, ...) with unchanged arguments. +fn resolve_launcher( + is_windows: bool, + command: String, + arguments: Vec, + ruby: Option, +) -> (String, Vec) { + match (is_windows, ruby) { + (true, Some(ruby)) => ruby_interpreter_command(&command, ruby, arguments), + _ => (command, arguments), + } +} + impl zed::Extension for RubyExtension { fn new() -> Self { Self::default() @@ -142,13 +238,25 @@ impl zed::Extension for RubyExtension { _: Option, worktree: &Worktree, ) -> Result { - let (command, mut arguments) = { - let shell_env = worktree.shell_env(); - let env_vars: Vec<(&str, &str)> = shell_env - .iter() - .map(|(key, value)| (key.as_str(), value.as_str())) - .collect(); + let is_windows = matches!(zed::current_platform().0, zed::Os::Windows); + + let shell_env = worktree.shell_env(); + let env_vars: Vec<(&str, &str)> = shell_env + .iter() + .map(|(key, value)| (key.as_str(), value.as_str())) + .collect(); + // On Windows the adapter process only inherits the environment we hand + // to Zed on top of Zed's own process environment, so start from the + // user's shell environment - the same source the language servers use. + // That keeps `ruby` and `GEM_PATH` resolvable inside the adapter, which + // the `.bat`/`.cmd` shims implicitly rely on. + let mut launcher_envs: Vec<(String, String)> = Vec::new(); + if is_windows { + launcher_envs.extend(shell_env.iter().cloned()); + } + + let (command, arguments) = { let bundler = Bundler::new(PathBuf::from(worktree.root_path()), RealCommandExecutor); if bundler.installed_gem_version("debug", &env_vars).is_ok() { let bundle = worktree.which("bundle").ok_or_else(|| { @@ -174,16 +282,44 @@ impl zed::Extension for RubyExtension { let rdbg = gemset .gem_bin_path("rdbg") .map_err(|e| format!("{:#}", e))?; + if is_windows { + // The gemset is only reachable through `GEM_PATH` (and its + // `bin` through `PATH`); without them the spawned adapter + // cannot require `debug`. + launcher_envs = merge_envs(launcher_envs, gemset.env().iter().cloned()); + } (rdbg, Vec::new()) } }; + // Windows resolves gem executables to `.bat`/`.cmd` shims. Their wrapper + // re-resolves an interpreter on its own (from `PATH` for `--install-dir` + // gemsets, from the sibling `ruby.exe` for system installs) and adds a + // `cmd.exe` layer. Run the extensionless script with the interpreter we + // resolved instead. Other platforms keep their shebang launchers. + let ruby = if is_windows { + worktree.which("ruby") + } else { + None + }; + let (command, mut arguments) = resolve_launcher(is_windows, command, arguments, ruby); + let tcp_connection = config.tcp_connection.unwrap_or(TcpArgumentsTemplate { port: None, host: None, timeout: None, }); + let configured_timeout = tcp_connection.timeout; let mut connection = resolve_tcp_template(tcp_connection)?; + if is_windows && configured_timeout.is_none() { + // Zed's default grace period for a TCP DAP server is 2000ms + // (`DebuggerSettings::timeout`). Starting Ruby on Windows routinely + // takes ~1.9s and ~2.8s through `bundle exec`, so the adapter loses + // that race and surfaces as "Connection to TCP DAP timeout", i.e. + // "unable to connect to the debug service". The longer timeout only + // widens the failing path; a successful start is unaffected. + connection.timeout = Some(WINDOWS_DAP_CONNECT_TIMEOUT_MS); + } let mut configuration: serde_json::Value = serde_json::from_str(&config.config) .map_err(|e| format!("`config` is not a valid JSON: {e:#}"))?; if let Some(configuration) = configuration.as_object_mut() { @@ -201,7 +337,7 @@ impl zed::Extension for RubyExtension { .map(|ip_addr| ip_addr.into()) .map_err(|_| format!("Invalid host '{host}' specified via RUBY_DEBUG_HOST"))?; } else { - arguments.push(format!("--host={}", connection.host)); + arguments.push(format!("--host={}", format_debug_host(connection.host))); } if let Some(port) = ruby_config.env.get("RUBY_DEBUG_PORT") { @@ -253,7 +389,7 @@ impl zed::Extension for RubyExtension { arguments, connection: Some(connection), cwd: ruby_config.cwd.or(Some(worktree.root_path())), - envs: ruby_config.env.into_iter().collect(), + envs: merge_envs(launcher_envs, ruby_config.env), request_args: StartDebuggingRequestArguments { configuration: configuration.to_string(), request: request_type, @@ -380,3 +516,143 @@ impl zed::Extension for RubyExtension { } zed_extension_api::register_extension!(RubyExtension); + +#[cfg(test)] +mod tests { + use super::*; + + fn args(items: &[&str]) -> Vec { + items.iter().map(ToString::to_string).collect() + } + + #[test] + fn test_format_debug_host_renders_dotted_quad() { + // `TcpArguments.host` for 127.0.0.1 as a big-endian u32. + assert_eq!(format_debug_host(2_130_706_433), "127.0.0.1"); + assert_eq!(format_debug_host(0), "0.0.0.0"); + assert_eq!(format_debug_host(u32::MAX), "255.255.255.255"); + assert_eq!( + format_debug_host(u32::from(std::net::Ipv4Addr::new(192, 168, 1, 10))), + "192.168.1.10" + ); + } + + #[test] + fn test_gem_script_path_strips_windows_shim_extensions() { + assert_eq!( + gem_script_path(r"C:\Ruby34-x64\bin\rdbg.bat").unwrap(), + r"C:\Ruby34-x64\bin\rdbg" + ); + assert_eq!( + gem_script_path(r"C:\Ruby34-x64\bin\bundle.cmd").unwrap(), + r"C:\Ruby34-x64\bin\bundle" + ); + assert_eq!( + gem_script_path("C:/Ruby User/zed/gems/abc/bin/rdbg.BAT").unwrap(), + "C:/Ruby User/zed/gems/abc/bin/rdbg" + ); + // Mixed separators must not confuse the split. + assert_eq!( + gem_script_path("C:/Ruby User/bin\\rdbg.bat").unwrap(), + "C:/Ruby User/bin\\rdbg" + ); + assert_eq!(gem_script_path("rdbg.bat").unwrap(), "rdbg"); + } + + #[test] + fn test_gem_script_path_ignores_non_batch_paths() { + // POSIX launchers and real executables are spawned as-is. + assert_eq!(gem_script_path("/usr/local/bin/rdbg"), None); + assert_eq!(gem_script_path(r"C:\Ruby34-x64\bin\ruby.exe"), None); + assert_eq!(gem_script_path("rdbg"), None); + } + + #[test] + fn test_resolve_launcher_is_a_noop_off_windows() { + let (command, arguments) = resolve_launcher( + false, + "/home/user/.rbenv/shims/ruby".to_string(), + args(&["--host=127.0.0.1", "--port=1234"]), + Some("/home/user/.rbenv/shims/ruby".to_string()), + ); + assert_eq!(command, "/home/user/.rbenv/shims/ruby"); + assert_eq!(arguments, args(&["--host=127.0.0.1", "--port=1234"])); + } + + #[test] + fn test_resolve_launcher_is_a_noop_on_windows_without_ruby() { + let (command, arguments) = resolve_launcher( + true, + r"C:\Ruby34-x64\bin\rdbg.bat".to_string(), + args(&["--open"]), + None, + ); + assert_eq!(command, r"C:\Ruby34-x64\bin\rdbg.bat"); + assert_eq!(arguments, args(&["--open"])); + } + + #[test] + fn test_resolve_launcher_runs_script_with_explicit_ruby() { + let (command, arguments) = resolve_launcher( + true, + r"C:\Ruby34-x64\bin\rdbg.bat".to_string(), + args(&["--host=127.0.0.1", "--open"]), + Some(r"C:\Ruby34-x64\bin\ruby.exe".to_string()), + ); + assert_eq!(command, r"C:\Ruby34-x64\bin\ruby.exe"); + assert_eq!( + arguments, + args(&[r"C:\Ruby34-x64\bin\rdbg", "--host=127.0.0.1", "--open"]) + ); + } + + #[test] + fn test_resolve_launcher_keeps_bundle_exec_arguments() { + let (command, arguments) = resolve_launcher( + true, + r"C:\Ruby34-x64\bin\bundle.bat".to_string(), + args(&["exec", "rdbg", "--open"]), + Some(r"C:\Ruby34-x64\bin\ruby.exe".to_string()), + ); + assert_eq!(command, r"C:\Ruby34-x64\bin\ruby.exe"); + assert_eq!( + arguments, + args(&[r"C:\Ruby34-x64\bin\bundle", "exec", "rdbg", "--open"]) + ); + } + + #[test] + fn test_merge_envs_keeps_base_order_and_appends_overrides() { + let base = vec![ + ("PATH".to_string(), "C:/Ruby/bin".to_string()), + ("HOME".to_string(), "C:/Users/x".to_string()), + ]; + let merged = merge_envs( + base, + vec![ + ("GEM_HOME".to_string(), "C:/gems".to_string()), + ("PATH".to_string(), "C:/gems/bin".to_string()), + ], + ); + + assert_eq!( + merged, + vec![ + ("PATH".to_string(), "C:/gems/bin".to_string()), + ("HOME".to_string(), "C:/Users/x".to_string()), + ("GEM_HOME".to_string(), "C:/gems".to_string()), + ] + ); + } + + #[test] + fn test_merge_envs_with_empty_base_is_the_user_env() { + let mut user_env = HashMap::new(); + user_env.insert("RUBY_DEBUG_PORT".to_string(), "1234".to_string()); + + assert_eq!( + merge_envs(Vec::new(), user_env), + vec![("RUBY_DEBUG_PORT".to_string(), "1234".to_string())] + ); + } +}