diff --git a/crates/ide-dap/src/adapter.rs b/crates/ide-dap/src/adapter.rs index 9c0431ec6..d517510e9 100644 --- a/crates/ide-dap/src/adapter.rs +++ b/crates/ide-dap/src/adapter.rs @@ -374,8 +374,8 @@ pub struct AdapterIo { pub type ProgramOutput = tokio::sync::mpsc::UnboundedReceiver<(&'static str, String)>; type ProgramSink = tokio::sync::mpsc::UnboundedSender<(&'static str, String)>; -/// Delve's debuggee inherits its stdio; no DAP output event carries what the program prints. -const SHARES_STDIO: &str = "delve"; +/// These debuggees inherit adapter stdio; no DAP event carries their output. +const SHARES_STDIO: &[&str] = &["delve", "rdbg"]; const DELVE_BANNER: &str = "DAP server listening at:"; pub type LaunchFuture = Pin> + Send>>; @@ -477,6 +477,9 @@ impl AdapterLauncher for ProcessLauncher { cmd.env("PATH", path); } ide_core::process::prepare(cmd.as_std_mut()); + if spec.adapter == "rdbg" { + ide_core::process::own_group(cmd.as_std_mut()); + } cmd.args(&args) .envs(spec.env.iter().map(|(k, v)| (k, v))) .current_dir(&spec.cwd) @@ -486,7 +489,7 @@ impl AdapterLauncher for ProcessLauncher { .kill_on_drop(true); let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?; - let (program_sink, program_output) = if spec.adapter == SHARES_STDIO { + let (program_sink, program_output) = if SHARES_STDIO.contains(&spec.adapter.as_str()) { let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); (Some(tx), Some(rx)) } else { @@ -516,6 +519,11 @@ impl AdapterLauncher for ProcessLauncher { let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await { Ok(s) => s, Err(e) => { + if spec.adapter == "rdbg" + && let Some(pid) = child.id() + { + ide_core::process::kill_tree_of(pid); + } let _ = child.start_kill(); let tail = stderr.lock().join("\n"); return Err(if tail.is_empty() { diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 1336de244..8b335d520 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -310,6 +310,9 @@ pub struct DebugManager { /// In a Bundler project it runs from the bundle, which must have `debug`: the debuggee then /// loads that version, and the gems rdbg needs (irb is not a default gem from Ruby 3.5). fn rdbg_command_line(spec: &mut AdapterSpec, config: &LaunchConfig, cwd: &Path) -> IdeResult<()> { + if config.request != "launch" { + return Ok(()); + } if spec.command == "rdbg" && let Some(gemfile) = bundler_gemfile(cwd, &config.env) { @@ -324,6 +327,11 @@ fn rdbg_command_line(spec: &mut AdapterSpec, config: &LaunchConfig, cwd: &Path) } spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone()))); spec.args.push("--".into()); + // With only a command, Ruby's exec treats it as shell text. Ruby entry points + // without arguments go through -S so spaces and metacharacters stay literal. + if config.program.is_some() && config.args.is_empty() { + spec.args.extend(["ruby", "-S", "--"].map(str::to_owned)); + } spec.args.extend(config.program.iter().cloned()); spec.args.extend(config.args.iter().cloned()); Ok(()) @@ -331,9 +339,13 @@ fn rdbg_command_line(spec: &mut AdapterSpec, config: &LaunchConfig, cwd: &Path) /// The Gemfile Bundler uses in `dir`: `BUNDLE_GEMFILE`, else the closest `Gemfile` / `gems.rb` from `dir` up. fn bundler_gemfile(dir: &Path, env: &BTreeMap) -> Option { - match env.get("BUNDLE_GEMFILE").filter(|g| !g.is_empty()) { + let gemfile = env + .get("BUNDLE_GEMFILE") + .cloned() + .or_else(|| ide_core::process::host_var_os("BUNDLE_GEMFILE").map(|v| v.to_string_lossy().into_owned())); + match gemfile.filter(|g| !g.is_empty()) { Some(gemfile) => Some(dir.join(gemfile)), - None => dir.ancestors().flat_map(|d| [d.join("Gemfile"), d.join("gems.rb")]).find(|g| g.is_file()), + None => dir.ancestors().flat_map(|d| [d.join("gems.rb"), d.join("Gemfile")]).find(|g| g.is_file()), } } @@ -842,14 +854,23 @@ impl DebugManager { let stderr = stderr.clone(); Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line))) }; + let rdbg_pid = (adapter_name == "rdbg" && request == "launch") + .then(|| io.child.as_ref().and_then(tokio::process::Child::id)) + .flatten(); let session = Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child)); + // rdbg execs the debuggee in its own process and sends no DAP process event. + if let Some(pid) = rdbg_pid { + session.debuggee_pid.store(i64::from(pid), Ordering::Release); + } if let Some(mut output) = io.program_output { + session.output_drained.send_replace(false); let session = session.clone(); tokio::spawn(async move { while let Some((category, output)) = output.recv().await { session.emit(ide_core::event::DebugEvent::Output { category: category.into(), output }); } + session.output_drained.send_replace(true); }); } self.sessions.lock().insert(id, session.clone()); @@ -1051,7 +1072,9 @@ impl DebugManager { // the console (`terminated`), as IDEA does with the process it owns. let pid = s.debuggee_pid.load(Ordering::Acquire); let own_terminate = terminate && s.request == "launch" && pid > 0; - let graceful = terminate && s.request == "launch" && s.supports("supportsTerminateRequest"); + // rdbg's terminate exits the Ruby process without terminating its children. + let graceful = + terminate && s.request == "launch" && s.adapter != "rdbg" && s.supports("supportsTerminateRequest"); if !s.conn.is_closed() { if graceful { let _ = s.conn.request_timeout("terminate", json!({ "restart": false }), SHUTDOWN_TIMEOUT).await; diff --git a/crates/ide-dap/src/session.rs b/crates/ide-dap/src/session.rs index bc9c81946..fe2d6f8ec 100644 --- a/crates/ide-dap/src/session.rs +++ b/crates/ide-dap/src/session.rs @@ -48,6 +48,7 @@ pub(crate) struct Session { /// Set once `configurationDone` was sent; breakpoint updates go out live after that. pub configured: AtomicBool, pub initialized: watch::Sender, + pub output_drained: watch::Sender, pub terminated: AtomicBool, /// Set by a second Stop: the one waiting for the program kills it instead. pub stop_force: AtomicBool, @@ -103,6 +104,7 @@ impl Session { program: Mutex::new(None), configured: AtomicBool::new(false), initialized: watch::channel(false).0, + output_drained: watch::channel(true).0, terminated: AtomicBool::new(false), stop_force: AtomicBool::new(false), debuggee_pid: AtomicI64::new(0), @@ -275,6 +277,11 @@ impl Session { pub fn kill_processes(&self) { self.kotlin_relay.lock().take(); if let Some(mut c) = self.adapter_process.lock().take() { + if self.adapter == "rdbg" + && let Some(pid) = c.id() + { + ide_core::process::kill_tree_of(pid); + } let _ = c.start_kill(); } for mut c in std::mem::take(&mut *self.debuggees.lock()) { @@ -448,6 +455,11 @@ pub(crate) async fn event_loop( Incoming::Closed => break, } } + // rdbg closes DAP before Ruby's remaining at_exit handlers print the test summary. + if session.adapter == "rdbg" && session.request == "launch" { + let mut drained = session.output_drained.subscribe(); + let _ = tokio::time::timeout(DEBUGGEE_EXIT_WAIT, drained.wait_for(|done| *done)).await; + } if let Some(mgr) = manager.upgrade() { mgr.session_closed(&session); } diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index f7dd8859b..c0ff9304f 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -1398,7 +1398,7 @@ fn rdbg_runs_the_debuggee_after_its_options_with_the_configuration_environment() let config = LaunchConfig { adapter: "rdbg".into(), program: Some("ruby".into()), - args: ["-Ilib", "app.rb", "--verbose"].map(String::from).to_vec(), + args: ["-Ilib", "app with spaces.rb", "$(touch sentinel); literal"].map(String::from).to_vec(), env: BTreeMap::from([("RAILS_ENV".into(), "test".into())]), extra_json: None, ..config() @@ -1406,7 +1406,10 @@ fn rdbg_runs_the_debuggee_after_its_options_with_the_configuration_environment() let mut spec = adapter::resolve(&rdbg, dir.path().into()); rdbg_command_line(&mut spec, &config, dir.path()).unwrap(); assert_eq!(spec.command, "rdbg"); - assert_eq!(spec.args, ["-O", "--port", "${port}", "-c", "--", "ruby", "-Ilib", "app.rb", "--verbose"]); + assert_eq!( + spec.args, + ["-O", "--port", "${port}", "-c", "--", "ruby", "-Ilib", "app with spaces.rb", "$(touch sentinel); literal"] + ); assert!(spec.env.contains(&("RAILS_ENV".into(), "test".into()))); } @@ -1440,6 +1443,41 @@ fn rdbg_runs_from_a_bundle_that_has_debug_and_refuses_one_without() { } } +#[test] +fn rdbg_commands_without_arguments_are_ruby_entry_points_not_shell_text() { + let dir = tempfile::tempdir().unwrap(); + let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap(); + for program in ["rspec", "/app with spaces/bin/rake", "$(touch sentinel); literal"] { + let config = + LaunchConfig { adapter: "rdbg".into(), program: Some(program.into()), args: Vec::new(), ..config() }; + let mut spec = adapter::resolve(&rdbg, dir.path().into()); + rdbg_command_line(&mut spec, &config, dir.path()).unwrap(); + assert_eq!(&spec.args[4..], ["--", "ruby", "-S", "--", program]); + } +} + +#[test] +fn rdbg_remote_attach_does_not_check_the_local_bundle_or_append_a_program() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("Gemfile"), "").unwrap(); + let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap(); + let config = LaunchConfig { adapter: "rdbg".into(), request: "attach".into(), ..config() }; + let mut spec = adapter::resolve(&rdbg, dir.path().into()); + let args = spec.args.clone(); + rdbg_command_line(&mut spec, &config, dir.path()).unwrap(); + assert_eq!(spec.command, "rdbg"); + assert_eq!(spec.args, args); +} + +#[test] +fn bundler_prefers_gems_rb_when_both_gemfiles_exist() { + let dir = tempfile::tempdir().unwrap(); + for name in ["Gemfile", "gems.rb"] { + std::fs::write(dir.path().join(name), "").unwrap(); + } + assert_eq!(bundler_gemfile(dir.path(), &BTreeMap::new()), Some(dir.path().join("gems.rb"))); +} + #[test] fn bundler_gemfile_and_lockfile_follow_bundle_gemfile_and_gems_rb() { let dir = tempfile::tempdir().unwrap(); @@ -1673,3 +1711,64 @@ fn delve_leftovers_are_removed_without_touching_other_files() { } std::fs::remove_dir_all(&dir).unwrap(); } + +#[cfg(unix)] +#[tokio::test] +async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() { + struct Launcher; + impl AdapterLauncher for Launcher { + fn launch(&self, _spec: AdapterSpec, _reuse: Option) -> LaunchFuture { + Box::pin(async { + let (client, server) = tokio::io::duplex(1 << 16); + tokio::spawn(fake_adapter(server, Arc::default(), Script::default())); + let mut command = tokio::process::Command::new("/bin/sh"); + ide_core::process::own_group(command.as_std_mut()); + let mut child = command + .args(["-c", "sleep 1; printf 'test summary\\n'"]) + .stdout(std::process::Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let mut stdout = child.stdout.take().unwrap(); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + let mut text = String::new(); + tokio::io::AsyncReadExt::read_to_string(&mut stdout, &mut text).await.unwrap(); + let _ = tx.send(("stdout", text)); + }); + let (reader, writer) = tokio::io::split(client); + Ok(AdapterIo { + reader: Box::new(reader), + writer: Box::new(writer), + child: Some(child), + port: None, + stderr: Arc::default(), + program_output: Some(rx), + }) + }) + } + } + let dir = tempfile::tempdir().unwrap(); + let manager = DebugManager::with_launcher(Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(Launcher)); + let events = Arc::new(Mutex::new(Vec::new())); + let collected = events.clone(); + let owner = SessionOwner::new("rdbg-test", Arc::new(move |e| collected.lock().push(e))); + let id = manager + .start( + LaunchConfig { adapter: "rdbg".into(), cwd: Some(dir.path().into()), extra_json: None, ..config() }, + owner, + ) + .await + .unwrap(); + manager.session(id).unwrap().conn.close(); + tokio::time::timeout(Duration::from_secs(5), async { + while !manager.sessions(None).is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + assert!(events.lock().iter().any(|e| matches!(e, + IdeEvent::Debug { event: DebugEvent::Output { output, .. }, .. } if output == "test summary\n" + ))); +} diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index 383cf6fde..0b61b0daa 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -76,7 +76,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide } let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default(); let mut ruby_tools = Vec::new(); - if config.adapter == "rdbg" { + if config.adapter == "rdbg" && config.request == "launch" { let project_root = host.project(window).map(|p| p.root.clone()).unwrap_or_else(|_| root.clone()); let (dir, settings) = (root.clone(), settings.clone()); (config, ruby_tools) = host diff --git a/crates/ide-sdk/src/ruby.rs b/crates/ide-sdk/src/ruby.rs index afb44fb5a..877a86018 100644 --- a/crates/ide-sdk/src/ruby.rs +++ b/crates/ide-sdk/src/ruby.rs @@ -163,7 +163,12 @@ pub fn debug_environment( let mut gem_bins: Option> = None; let mut find = |name: &str| { ide_core::process::find_executable(name, bin.as_os_str()).or_else(|| { - let dirs = gem_bins.get_or_insert_with(|| gem_bin_dirs(&ruby, dir)); + let dirs = gem_bins.get_or_insert_with(|| { + let vars: Vec<_> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + query_in(&ruby, &["-e", ide_core::tool_path::RUBY_GEM_BIN_SCRIPT], Some(dir), &vars, QUERY_TIMEOUT) + .map(|out| ide_core::tool_path::gem_bin_dirs(&out)) + .unwrap_or_default() + }); ide_core::process::find_executable(name, &std::env::join_paths(dirs.iter()).ok()?) }) }; @@ -251,7 +256,10 @@ mod tests { } // Homebrew's Ruby installs gem executables outside its `bin`. let scripts = [ - (bin.join("ruby"), format!("#!/bin/sh\necho '{}'\n", gem_bin.display())), + ( + bin.join("ruby"), + format!("#!/bin/sh\necho \"${{GEM_HOME:-{}}}/bin\"\n", dir.path().join("gems").display()), + ), (bin.join("bundle"), String::new()), (gem_bin.join("rdbg"), String::new()), ]; @@ -266,6 +274,15 @@ mod tests { let tools = debug_environment(dir.path(), dir.path(), &s, &mut env); assert_eq!(tools, [("bundle", bin.join("bundle")), ("rdbg", gem_bin.join("rdbg"))]); assert_eq!(std::env::split_paths(&env["PATH"]).next(), Some(bin.clone())); + let custom_home = dir.path().join("custom gems"); + std::fs::create_dir_all(custom_home.join("bin")).unwrap(); + let custom_rdbg = custom_home.join("bin/rdbg"); + std::fs::write(&custom_rdbg, "").unwrap(); + std::fs::set_permissions(&custom_rdbg, std::fs::Permissions::from_mode(0o755)).unwrap(); + let mut env = + std::collections::BTreeMap::from([("GEM_HOME".into(), custom_home.to_string_lossy().into_owned())]); + let tools = debug_environment(dir.path(), dir.path(), &s, &mut env); + assert_eq!(tools, [("bundle", bin.join("bundle")), ("rdbg", custom_rdbg)]); // The configuration's own PATH stays. let mut env = std::collections::BTreeMap::from([("PATH".to_owned(), "/custom".to_owned())]); debug_environment(dir.path(), dir.path(), &s, &mut env); diff --git a/src/features/debugger/__tests__/startFailure.test.ts b/src/features/debugger/__tests__/startFailure.test.ts index 43ac10ad7..859492a89 100644 --- a/src/features/debugger/__tests__/startFailure.test.ts +++ b/src/features/debugger/__tests__/startFailure.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from "vitest"; +import { _resetI18nForTests, setLocale } from "@/platform/i18n"; import { IpcError, type LaunchConfig } from "@/platform/ipc"; import { provideNotificationService } from "@/workbench/contributions"; import { startFailureMessage } from "../startFailure"; @@ -21,6 +22,21 @@ describe("debug start failures", () => { expect(startFailureMessage(new IpcError({ kind: "invalidInput", message }))).toBe(PORT_IN_USE); }); + it("translates the missing Ruby debug gem instruction", async () => { + const message = + 'the bundle of /app with spaces/Gemfile has no `debug` gem. Add `gem "debug"` to the Gemfile and run `bundle install`'; + try { + await setLocale("en"); + expect(startFailureMessage(new Error(message))).toBe(message); + await setLocale("zh"); + expect(startFailureMessage(new Error(message))).toBe( + '/app with spaces/Gemfile 的 bundle 中没有 `debug` gem。请在 Gemfile 中添加 `gem "debug"`,然后运行 `bundle install`', + ); + } finally { + _resetI18nForTests(); + } + }); + it("keeps other failures as they are", () => { expect(startFailureMessage(new Error("launch failed: Attach requests are not supported"))).toBe( "launch failed: Attach requests are not supported", diff --git a/src/features/debugger/startFailure.ts b/src/features/debugger/startFailure.ts index 497bb94b1..dd9304194 100644 --- a/src/features/debugger/startFailure.ts +++ b/src/features/debugger/startFailure.ts @@ -6,9 +6,14 @@ import { tx } from "@/platform/i18n"; const PORT_IN_USE = /\bEADDRINUSE\b.*:(\d+)\s*$/; +const RUBY_DEBUG_MISSING = + /^the bundle of (.+) has no `debug` gem\. Add `gem "debug"` to the Gemfile and run `bundle install`$/; export function startFailureMessage(e: unknown): string { const message = e instanceof Error ? e.message : String(e); + const gemfile = RUBY_DEBUG_MISSING.exec(message)?.[1]; + if (gemfile) + return tx`the bundle of ${gemfile} has no \`debug\` gem. Add \`gem "debug"\` to the Gemfile and run \`bundle install\``; const port = PORT_IN_USE.exec(message)?.[1]; return port ? tx`Port ${port} is already in use: another IDE or debug session may be listening on it. Stop it or choose another port.` diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 45cb292ca..c5d4989a1 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -537,5 +537,6 @@ "Select Maven toolchains.xml": "Select Maven toolchains.xml", "dev,integration": "dev,integration", "Comma-separated profiles (-P), combined with Maven tool window choices; !id excludes a profile. A run configuration’s own -P replaces these profiles.": "Comma-separated profiles (-P), combined with Maven tool window choices; !id excludes a profile. A run configuration’s own -P replaces these profiles.", - "Active profiles": "Active profiles" + "Active profiles": "Active profiles", + "the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`": "the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`" } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index d3a893947..095f90431 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6873,5 +6873,6 @@ "Passed as -t; selects the JDKs Maven compiles with.": "以 -t 传入,用于选择 Maven 编译使用的 JDK。", "Select Maven toolchains.xml": "选择 Maven toolchains.xml", "dev,integration": "开发,集成", - "Comma-separated profiles (-P), combined with Maven tool window choices; !id excludes a profile. A run configuration’s own -P replaces these profiles.": "以逗号分隔的 profiles(-P),与 Maven 工具窗口中的选择合并;!id 排除指定 profile。运行配置自身的 -P 会替代这些 profiles。" + "Comma-separated profiles (-P), combined with Maven tool window choices; !id excludes a profile. A run configuration’s own -P replaces these profiles.": "以逗号分隔的 profiles(-P),与 Maven 工具窗口中的选择合并;!id 排除指定 profile。运行配置自身的 -P 会替代这些 profiles。", + "the bundle of {gemfile} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`": "{gemfile} 的 bundle 中没有 `debug` gem。请在 Gemfile 中添加 `gem \"debug\"`,然后运行 `bundle install`" }