From 78b23df7b5da24df6b707f11db4bb72cda9a3864 Mon Sep 17 00:00:00 2001 From: misswell Date: Mon, 2 Nov 2026 20:15:51 +0800 Subject: [PATCH] fix(host): close a project's background processes with it (#852) Closing a project only cancelled its agents and notebooks; its window's requests, run processes, terminal shells and debug sessions kept running until app exit, and the language server shutdown was a detached task a closing app could drop (orphaned jdtls). Closing now cancels the window's requests, kills its run processes and shells, stops its debug sessions and waits for the language servers to shut down; other windows are untouched. Terminals are keyed by owner window so they can be closed per window. Co-authored-by: misswell <28730815+misswell@users.noreply.github.com> --- crates/ide-dap/src/lib.rs | 10 +++++ crates/ide-host/src/lib.rs | 23 +++++++++- crates/ide-host/src/ops/term.rs | 3 +- crates/ide-host/src/trust.rs | 4 +- crates/ide-host/tests/close_project.rs | 54 ++++++++++++++++++++++++ crates/ide-host/tests/terminal_images.rs | 1 + crates/ide-terminal/src/lib.rs | 16 ++++++- 7 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 crates/ide-host/tests/close_project.rs diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 638ec8d38..5b8786a14 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -1389,6 +1389,16 @@ impl DebugManager { }) } + /// Stops every session `key` started: the window that opened the project is closing. + pub async fn stop_key(&self, key: &str) { + let ids: Vec = self.sessions(Some(key)).into_iter().map(|s| s.id).collect(); + for id in ids { + if let Err(e) = self.stop(id).await { + tracing::debug!("debug session {id:?} did not stop: {e}"); + } + } + } + /// Stops a session and its child sessions. pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> { let session = self.session(s)?; diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 66e21c9b7..d5fef1e5f 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -176,12 +176,21 @@ impl Host { } project.agents.shutdown(); project.notebooks.stop_admission(); + // What the closing window started goes with it — the builds and searches it + // asked for, its runs, its shells and its debug sessions — and the language + // servers finish shutting down before the window is gone, as IDEA closes a + // project's processes. + for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() { + self.cancel_request(request); + } + self.processes.kill_project(&project.root); + self.terminals.kill_key(self.owner_window(window)); self.shutdown_project_workers_blocking( vec![project.notebooks.clone()], project.kotlin_bridge.iter().cloned().collect(), + Some(project.lsp.clone()), + Some((self.debugger.clone(), window.to_owned())), ); - let lsp = project.lsp.clone(); - self.runtime.spawn(async move { lsp.shutdown_all().await }); } } @@ -191,6 +200,8 @@ impl Host { &self, notebooks: Vec>, bridges: Vec>, + lsp: Option>, + debug: Option<(Arc, String)>, ) { for bridge in &bridges { bridge.stop(); @@ -208,6 +219,12 @@ impl Host { for service in notebooks { service.shutdown().await; } + if let Some(lsp) = lsp { + lsp.shutdown_all().await; + } + if let Some((debug, key)) = debug { + debug.stop_key(&key).await; + } }); }); if let Err(error) = worker.join() { @@ -229,6 +246,8 @@ impl Host { self.shutdown_project_workers_blocking( projects.iter().map(|project| project.notebooks.clone()).collect(), projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(), + None, + None, ); } diff --git a/crates/ide-host/src/ops/term.rs b/crates/ide-host/src/ops/term.rs index c7a284c9f..02a340266 100644 --- a/crates/ide-host/src/ops/term.rs +++ b/crates/ide-host/src/ops/term.rs @@ -40,7 +40,8 @@ pub fn spawn( .ok() .filter(|p| p.is_trusted()) .and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), &shell)); - let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?; + let key = host.owner_window(window).to_owned(); + let info = host.terminals.spawn(options, key, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?; if let Some(line) = activation { let _ = host.terminals.write(info.id, &format!(" {line}\r")); } diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 7e8522e10..5fd072bba 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -135,9 +135,7 @@ impl crate::Host { for request in requests { self.cancel_request(request); } - for session in self.debugger.sessions(Some(&window)) { - let _ = self.debugger.stop(session.id).await; - } + self.debugger.stop_key(&window).await; } } project.lsp.reconfigure().await; diff --git a/crates/ide-host/tests/close_project.rs b/crates/ide-host/tests/close_project.rs new file mode 100644 index 000000000..e0b07cd11 --- /dev/null +++ b/crates/ide-host/tests/close_project.rs @@ -0,0 +1,54 @@ +//! Closing a project takes the background processes of its window with it, as IDEA does, +//! and leaves the other windows' processes alone. +#![cfg(unix)] + +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops}; +use std::sync::Arc; + +fn options(root: &std::path::Path) -> ide_terminal::TerminalOptions { + ide_terminal::TerminalOptions { + cwd: root.to_path_buf().into(), + // A fixed shell: the test must not depend on the login shell of this machine. + shell: Some("/bin/sh".into()), + args: vec![], + env: Default::default(), + cols: 80, + rows: 24, + shell_integration: false, + } +} + +#[test] +fn closing_a_project_closes_its_terminals_and_keeps_other_windows() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap().join("project"); + std::fs::create_dir_all(&root).unwrap(); + let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap(); + runtime.block_on(async { + let host = Arc::new(Host::new(HostConfig { + config_dir: tmp.path().join("config"), + data_dir: tmp.path().join("data"), + local_data_dir: tmp.path().join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })); + host.trust.decide(&root, true, false).unwrap(); + host.open_project("main", &root).unwrap(); + let project_shell = + ops::term::spawn(&host, "main", options(&root), Arc::new(|_| {})).expect("project terminal"); + let other_shell = ops::term::spawn(&host, "other", options(&root), Arc::new(|_| {})).expect("other terminal"); + assert_eq!(host.terminals.list().len(), 2); + + host.close_project("main"); + + let left: Vec<_> = host.terminals.list().into_iter().map(|t| t.id).collect(); + assert!(!left.contains(&project_shell.id), "the closing window's shell is gone"); + assert!(left.contains(&other_shell.id), "another window keeps its shell"); + host.terminals.kill_key("other"); + }); +} diff --git a/crates/ide-host/tests/terminal_images.rs b/crates/ide-host/tests/terminal_images.rs index 13605357a..8bb4a3e11 100644 --- a/crates/ide-host/tests/terminal_images.rs +++ b/crates/ide-host/tests/terminal_images.rs @@ -35,6 +35,7 @@ async fn storage_requires_a_registered_terminal() { rows: 24, shell_integration: false, }, + "main".into(), Arc::new(NullSink), Box::new(|_| {}), ) diff --git a/crates/ide-terminal/src/lib.rs b/crates/ide-terminal/src/lib.rs index 82df70cde..21ae833cf 100644 --- a/crates/ide-terminal/src/lib.rs +++ b/crates/ide-terminal/src/lib.rs @@ -53,6 +53,8 @@ pub struct TerminalInfo { } struct Session { + /// The window that opened it: closing that window's project closes its terminals. + key: String, info: TerminalInfo, master: Mutex>, writer: Mutex>, @@ -83,6 +85,7 @@ impl TerminalManager { pub fn spawn( &self, options: TerminalOptions, + key: String, sink: SharedSink, on_output: Box, ) -> IdeResult { @@ -121,6 +124,7 @@ impl TerminalManager { let info = TerminalInfo { id, title: shell_title(&shell), pid: child.process_id() }; let exited = Arc::new(AtomicBool::new(false)); let session = Arc::new(Session { + key, info: info.clone(), master: Mutex::new(pair.master), writer: Mutex::new(writer), @@ -160,6 +164,15 @@ impl TerminalManager { .map_err(|e| IdeError::internal(format!("resize failed: {e}"))) } + /// Closes the terminals `key` opened: the project of that window is closing. + pub fn kill_key(&self, key: &str) { + let ids: Vec = + self.sessions.lock().iter().filter(|(_, s)| s.key == key).map(|(id, _)| *id).collect(); + for id in ids { + let _ = self.kill(id); + } + } + pub fn kill(&self, id: TerminalId) -> IdeResult<()> { let session = self.sessions.lock().remove(&id).ok_or_else(|| not_found(id))?; terminate(&session); @@ -636,6 +649,7 @@ mod tests { .manager .spawn( opts, + "test".into(), h.sink.clone(), Box::new(move |s| { let _ = tx.send(s); @@ -793,7 +807,7 @@ mod tests { let h = harness(); let (other_sink, other_events) = channel_sink(); let (mine, _rx) = spawn_collect(&h, sh("exit 1")); - let theirs = h.manager.spawn(sh("exit 2"), other_sink, Box::new(|_| {})).expect("spawn"); + let theirs = h.manager.spawn(sh("exit 2"), "other".into(), other_sink, Box::new(|_| {})).expect("spawn"); match h.events.recv_timeout(Duration::from_secs(10)).expect("exit event") { IdeEvent::TerminalExited { terminal, code } => assert_eq!((terminal, code), (mine.id, Some(1))), e => panic!("unexpected {e:?}"),