diff --git a/Cargo.lock b/Cargo.lock index d04863312..90fc585db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4216,6 +4216,7 @@ dependencies = [ "ide-core", "ide-jsonrpc", "ide-language", + "libc", "memchr", "parking_lot", "portable-pty", diff --git a/crates/ide-dap/Cargo.toml b/crates/ide-dap/Cargo.toml index 757915cca..b37151c9d 100644 --- a/crates/ide-dap/Cargo.toml +++ b/crates/ide-dap/Cargo.toml @@ -20,6 +20,9 @@ memchr = "2" portable-pty.workspace = true tracing.workspace = true +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [dev-dependencies] tracing-subscriber.workspace = true tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros"] } diff --git a/crates/ide-dap/src/lib.rs b/crates/ide-dap/src/lib.rs index 524c10bea..ad1121178 100644 --- a/crates/ide-dap/src/lib.rs +++ b/crates/ide-dap/src/lib.rs @@ -301,6 +301,8 @@ pub struct DebugManager { exception_choices: Mutex>, function_breakpoints: Mutex>, data_breakpoints: Mutex>, + /// In-flight stops, by session: a second Stop sets `stop_force`, the waiting one kills. + stopping: Mutex>>, } /// Whether the debugged project depends on the Flutter SDK, so it must go @@ -587,6 +589,7 @@ impl DebugManager { exception_choices: Mutex::default(), function_breakpoints: Mutex::default(), data_breakpoints: Mutex::default(), + stopping: Mutex::default(), }) } @@ -994,15 +997,62 @@ impl DebugManager { /// Ends a session: `terminate` (when asked and supported) → `disconnect` → kill. pub(crate) async fn shutdown(&self, s: &Arc, terminate: bool) { + // java-debug runs the program itself and pipes its stdout over DAP. A `disconnect` + // ends that DAP session, so every line the program writes while shutting down is lost. + // Ask the program to exit ourselves and keep the adapter attached until it has drained + // 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"); if !s.conn.is_closed() { - if terminate && s.request == "launch" && s.supports("supportsTerminateRequest") { + if graceful { let _ = s.conn.request_timeout("terminate", json!({ "restart": false }), SHUTDOWN_TIMEOUT).await; } + if !graceful && !own_terminate { + let _ = s + .conn + .request_timeout( + "disconnect", + json!({ "restart": false, "terminateDebuggee": s.request == "launch" }), + SHUTDOWN_TIMEOUT, + ) + .await; + } + } + if own_terminate && !graceful { + // Signal before anything else: waiting on a request the adapter may not answer + // would delay the shutdown the console is supposed to show. + crate::session::signal_debuggee(s, false); + } + if graceful { + // java-debug sends `terminated` only after it has drained the program's console + // (it waits up to 5 s for the output pump). Close on the process id alone and the + // last shutdown lines are dropped, so wait for that event here. + let deadline = std::time::Instant::now() + crate::session::DEBUGGEE_EXIT_WAIT; + while !s.terminated.load(Ordering::Acquire) && std::time::Instant::now() < deadline { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + } + // An adapter answers `disconnect` before the program it launched has exited, and that + // program keeps its port until it is gone. Reporting the session as terminated then + // lets Run start again into an occupied port, so wait for the program first — with the + // connection still open, so a second Stop can find the session and kill it. + crate::session::wait_for_debuggee(s, terminate).await; + if terminate && s.request == "launch" { + // The adapter reports `terminated` only after it has drained the program's console + // (java-debug waits up to 5 s for the pump): closing before that drops the last + // shutdown lines from the console. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(6); + while !s.terminated.load(Ordering::Acquire) && !s.conn.is_closed() && std::time::Instant::now() < deadline { + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + } + if graceful && !s.conn.is_closed() { let _ = s .conn .request_timeout( "disconnect", - json!({ "restart": false, "terminateDebuggee": s.request == "launch" }), + json!({ "restart": false, "terminateDebuggee": false }), SHUTDOWN_TIMEOUT, ) .await; @@ -1577,10 +1627,40 @@ impl DebugManager { } } - /// Stops a session and its child sessions. + /// Stops every debug session started from `root` or below — closing a project stops the + /// programs it launched even when the session's owner key does not name the window doing + /// the closing (window groups, auxiliary windows). + pub async fn stop_under(&self, root: &Path) { + let ids: Vec = + self.live_sessions().into_iter().filter(|s| s.spec.cwd.starts_with(root)).map(|s| s.id).collect(); + tracing::info!(root = %root.display(), sessions = ids.len(), "closing project: stopping debug sessions"); + 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. A second Stop while the first one waits kills + /// the program instead of waiting for it — even after the adapter dropped the session. pub async fn stop(&self, s: DebugSessionId) -> IdeResult<()> { + { + let in_flight = self.stopping.lock(); + if let Some(session) = in_flight.get(&s) { + session.stop_force.store(true, Ordering::Release); + return Ok(()); + } + } let session = self.session(s)?; - let children: Vec> = self.live_sessions().into_iter().filter(|c| c.parent == Some(s)).collect(); + self.stopping.lock().insert(s, session.clone()); + let result = self.stop_tree(session).await; + self.stopping.lock().remove(&s); + result + } + + async fn stop_tree(&self, session: Arc) -> IdeResult<()> { + let children: Vec> = + self.live_sessions().into_iter().filter(|c| c.parent == Some(session.id)).collect(); for child in children { self.shutdown(&child, true).await; } diff --git a/crates/ide-dap/src/session.rs b/crates/ide-dap/src/session.rs index e7356fbbb..bc9c81946 100644 --- a/crates/ide-dap/src/session.rs +++ b/crates/ide-dap/src/session.rs @@ -13,7 +13,7 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::Arc; use std::sync::Weak; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; use std::time::Duration; use tokio::io::{AsyncRead, AsyncReadExt}; use tokio::process::Child; @@ -49,6 +49,14 @@ pub(crate) struct Session { pub configured: AtomicBool, pub initialized: watch::Sender, pub terminated: AtomicBool, + /// Set by a second Stop: the one waiting for the program kills it instead. + pub stop_force: AtomicBool, + /// `systemProcessId` from the adapter's `process` event: the program being debugged. + pub debuggee_pid: AtomicI64, + /// Pids signalled for the current stop: the reported pid and everything it started (an + /// adapter may report the launcher shell, whose child is the program). + #[cfg_attr(not(unix), allow(dead_code))] + pub stop_pids: parking_lot::Mutex>, /// Breakpoints replaced for run to cursor / force stepping, restored on the next stop. pub pending_restore: Mutex, /// Adapter breakpoint id → (file, index in the stored list). @@ -96,6 +104,9 @@ impl Session { configured: AtomicBool::new(false), initialized: watch::channel(false).0, terminated: AtomicBool::new(false), + stop_force: AtomicBool::new(false), + debuggee_pid: AtomicI64::new(0), + stop_pids: parking_lot::Mutex::new(Vec::new()), pending_restore: Mutex::default(), breakpoint_ids: Mutex::new(HashMap::new()), resolved_paths: Mutex::new(HashMap::new()), @@ -275,6 +286,108 @@ impl Session { } } +/// How long Stop waits for a launched program to exit before reporting the session ended. +pub(crate) const DEBUGGEE_EXIT_WAIT: Duration = Duration::from_secs(10); + +/// The program and everything it started: an adapter may report the launcher shell, whose +/// child is the debugged program, so stopping only the reported pid leaves the program alive. +#[cfg(unix)] +fn process_tree(pid: i32) -> Vec { + use std::collections::HashMap; + let Ok(out) = std::process::Command::new("ps").args(["-A", "-o", "pid=,ppid="]).output() else { + return vec![pid]; + }; + let mut children: HashMap> = HashMap::new(); + for line in String::from_utf8_lossy(&out.stdout).lines() { + let mut it = line.split_whitespace(); + let (Some(p), Some(parent)) = (it.next(), it.next()) else { continue }; + let (Ok(p), Ok(parent)) = (p.parse::(), parent.parse::()) else { continue }; + children.entry(parent).or_default().push(p); + } + let mut tree = vec![pid]; + let mut at = 0; + while at < tree.len() { + if let Some(kids) = children.get(&tree[at]) { + tree.extend(kids.iter().copied()); + } + at += 1; + } + tree +} + +/// Asks the program itself to exit (`force` = SIGKILL) without ending the adapter session: +/// its output keeps flowing over DAP while it shuts down. The first call remembers the pid +/// tree, because the reported pid may die before its program does. +pub(crate) fn signal_debuggee(s: &Arc, force: bool) { + let pid = s.debuggee_pid.load(Ordering::Acquire); + if pid <= 0 { + return; + } + #[cfg(unix)] + { + let mut known = s.stop_pids.lock(); + if known.is_empty() { + *known = process_tree(pid as i32); + } + let signal = if force { libc::SIGKILL } else { libc::SIGTERM }; + // Children first: the shell that started the program would otherwise reparent it. + for p in known.iter().rev() { + // SAFETY: plain syscall on the processes this session started. + unsafe { + libc::kill(*p, signal); + } + } + } + #[cfg(windows)] + { + let mut cmd = std::process::Command::new("taskkill"); + cmd.args(["/PID", &pid.to_string(), "/T"]); + if force { + cmd.arg("/F"); + } + let _ = cmd.status(); + } + #[cfg(not(any(unix, windows)))] + let _ = (pid, force); +} + +/// Waits until the program a `launch` session started is gone; a second Stop kills it. +pub(crate) async fn wait_for_debuggee(s: &Arc, terminate: bool) { + if !terminate || s.request != "launch" { + return; + } + let pid = s.debuggee_pid.load(Ordering::Acquire); + if pid <= 0 { + return; + } + tracing::info!(session = s.id.0, pid, "stop: waiting for the debugged program to exit"); + #[cfg(unix)] + { + use std::time::Instant; + let targets = { + let known = s.stop_pids.lock(); + if known.is_empty() { process_tree(pid as i32) } else { known.clone() } + }; + let alive = || targets.iter().any(|p| unsafe { libc::kill(*p, 0) } == 0); + let deadline = Instant::now() + DEBUGGEE_EXIT_WAIT; + while alive() { + if s.stop_force.swap(false, Ordering::AcqRel) { + signal_debuggee(s, true); + } + if Instant::now() >= deadline { + // The program ignored the terminate: do not leave it running. + signal_debuggee(s, true); + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + #[cfg(not(unix))] + { + let _ = pid; + } +} + // java-debug and Windows netcoredbg need native separators for source lookup. // Keep DAP path mode, but normalize Windows drive and UNC paths. fn is_windows_source_path(path: &str) -> bool { @@ -370,8 +483,18 @@ async fn on_event(mgr: &Arc, s: &Arc, event: &str, mut bo return; } let output = str_("output").unwrap_or_default(); + if tracing::enabled!(tracing::Level::DEBUG) && mgr.stopping.lock().contains_key(&s.id) { + tracing::debug!(session = ?s.id, bytes = output.len(), "output during stop"); + } s.emit(DebugEvent::Output { category, output }); } + // The program the adapter started: Stop must wait for it, not for the adapter's reply. + // `process` is the DAP event; java-debug announces the same thing as `processid`. + "process" | "processid" => { + if let Some(pid) = int("systemProcessId").or_else(|| int("processId")).filter(|p| *p > 0) { + s.debuggee_pid.store(pid, Ordering::Release); + } + } "thread" => s.emit(DebugEvent::Thread { thread_id: int("threadId").unwrap_or(0), started: body.get("reason").and_then(Value::as_str) != Some("exited"), @@ -390,12 +513,18 @@ async fn on_event(mgr: &Arc, s: &Arc, event: &str, mut bo } "exited" => {} "terminated" => { - if !s.terminated.swap(true, Ordering::AcqRel) { - s.emit(DebugEvent::Terminated); - } + let first = !s.terminated.swap(true, Ordering::AcqRel); let mgr = mgr.clone(); let s = s.clone(); - tokio::spawn(async move { mgr.shutdown(&s, false).await }); + // Waiting here would stop reading the adapter: it keeps sending the program's last + // output while the program exits. Wait off the reader task, then report the end. + tokio::spawn(async move { + if first { + wait_for_debuggee(&s, true).await; + s.emit(DebugEvent::Terminated); + } + mgr.shutdown(&s, false).await; + }); } "capabilities" => { if let Some(new) = body.get("capabilities").and_then(Value::as_object) { diff --git a/crates/ide-dap/src/tests.rs b/crates/ide-dap/src/tests.rs index 2f728a9f3..05fc98ed0 100644 --- a/crates/ide-dap/src/tests.rs +++ b/crates/ide-dap/src/tests.rs @@ -5,11 +5,16 @@ use crate::adapter::{AdapterIo, LaunchFuture}; use ide_core::IdeEvent; use ide_core::event::DebugEvent; use ide_jsonrpc::framing; +#[cfg(unix)] +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; +#[cfg(unix)] +use std::time::Instant; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; type Log = Arc>>; -#[derive(Clone, Default)] +#[derive(Clone)] struct Script { /// Send a `runInTerminal` reverse request when `launch` arrives. run_in_terminal: Option>, @@ -17,10 +22,35 @@ struct Script { start_debugging: bool, ignore_configuration_done: bool, source_path: Option, + /// Report this process id in the adapter's `process` event (the program being debugged). + debuggee_pid: Option, + /// java-debug's spelling of the same event. + java_process_id: bool, + /// Send one more output event right after the `terminated` event (a program still logging + /// while it shuts down). + output_after_terminate: bool, + /// Advertise `supportsTerminateRequest` (java-debug does not, so Stop signals the program). + supports_terminate: bool, /// Answer `setBreakpoints` only once this many are waiting (a client that waits for each never gets one). hold_set_breakpoints: usize, } +impl Default for Script { + fn default() -> Self { + Self { + run_in_terminal: None, + start_debugging: false, + ignore_configuration_done: false, + source_path: None, + debuggee_pid: None, + java_process_id: false, + output_after_terminate: false, + supports_terminate: true, + hold_set_breakpoints: 0, + } + } +} + struct FakeLauncher { log: Log, script: Script, @@ -77,7 +107,7 @@ async fn fake_adapter(stream json!({ "supportsConfigurationDoneRequest": true, "supportsCompletionsRequest": true, - "supportsTerminateRequest": true, + "supportsTerminateRequest": script.supports_terminate, "supportsSetVariable": true, "supportsFunctionBreakpoints": true, "supportsDataBreakpoints": true, @@ -155,6 +185,14 @@ async fn fake_adapter(stream if let Some(l) = pending_launch.take() { send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true})); } + if let Some(pid) = script.debuggee_pid { + let event = if script.java_process_id { + json!({"type": "event", "event": "processid", "body": {"processId": pid, "shellProcessId": pid}}) + } else { + json!({"type": "event", "event": "process", "body": {"name": "prog", "systemProcessId": pid}}) + }; + send!(event); + } send!(json!({"type": "event", "event": "output", "body": {"category": "stdout", "output": "hello\n"}})); send!( json!({"type": "event", "event": "stopped", "body": {"reason": "breakpoint", "threadId": 1, "allThreadsStopped": true}}) @@ -208,6 +246,11 @@ async fn fake_adapter(stream "terminate" => { ok!(msg, Value::Null); send!(json!({"type": "event", "event": "terminated"})); + if script.output_after_terminate { + send!( + json!({"type": "event", "event": "output", "body": {"category": "stdout", "output": "late\n"}}) + ); + } } "disconnect" => { ok!(msg, Value::Null); @@ -1348,6 +1391,201 @@ fn flutter_projects_are_found_from_the_program_or_the_working_directory() { std::fs::remove_dir_all(&root).unwrap(); } +#[cfg(unix)] +#[tokio::test] +async fn stop_waits_for_the_launched_program_to_exit() { + // The adapter answers `disconnect` at once; the program it started is still alive and + // holds its port for a while. Stop must not report the session as over before it exits. + // Reaped by a waiting thread: a zombie still "exists" for `kill(pid, 0)`, unlike the + // program an adapter starts and reaps. + // Ignores SIGTERM: Stop must wait for the program, not assume the signal ended it. + let mut child = std::process::Command::new("python3") + .args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(1)"]) + .spawn() + .unwrap(); + let pid = child.id(); + std::thread::spawn(move || { + let _ = child.wait(); + }); + let h = harness(Script { debuggee_pid: Some(pid), ..Default::default() }); + let id = h.start(config()).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await; + for _ in 0..100 { + if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + tokio::time::sleep(Duration::from_millis(200)).await; // let the program install its trap + let started = Instant::now(); + h.manager.stop(id).await.unwrap(); + let elapsed = started.elapsed(); + assert!(elapsed >= Duration::from_millis(800), "stop returned after {elapsed:?}, before the program exited"); + assert!(elapsed < Duration::from_secs(5), "stop waited {elapsed:?} for a program that ended after a second"); + h.wait_event(|e| matches!(e, DebugEvent::Terminated)).await; +} + +#[cfg(unix)] +#[tokio::test] +async fn a_second_stop_kills_the_program_instead_of_waiting() { + let mut child = std::process::Command::new("python3") + .args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(30)"]) + .spawn() + .unwrap(); + let pid = child.id(); + let exited = Arc::new(AtomicBool::new(false)); + { + let exited = exited.clone(); + std::thread::spawn(move || { + let _ = child.wait(); + exited.store(true, Ordering::Release); + }); + } + let h = harness(Script { debuggee_pid: Some(pid), ..Default::default() }); + let id = h.start(config()).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await; + for _ in 0..100 { + if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + tokio::time::sleep(Duration::from_millis(400)).await; + let first = tokio::spawn({ + let manager = h.manager.clone(); + async move { manager.stop(id).await } + }); + tokio::time::sleep(Duration::from_millis(300)).await; + assert!(!exited.load(Ordering::Acquire), "the program is still alive while the first Stop waits"); + let started = Instant::now(); + h.manager.stop(id).await.unwrap(); + let first_result = tokio::time::timeout(Duration::from_secs(5), first).await.expect("the first Stop returns"); + first_result.unwrap().unwrap(); + assert!(started.elapsed() < Duration::from_secs(2), "a second Stop does not wait out the program"); + for _ in 0..100 { + if exited.load(Ordering::Acquire) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(exited.load(Ordering::Acquire), "the second Stop killed the program"); +} + +#[cfg(unix)] +#[tokio::test] +async fn stop_waits_for_the_program_when_the_adapter_uses_javas_processid_event() { + // Ignores SIGTERM: Stop must wait for the program, not assume the signal ended it. + let mut child = std::process::Command::new("python3") + .args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(1)"]) + .spawn() + .unwrap(); + let pid = child.id(); + std::thread::spawn(move || { + let _ = child.wait(); + }); + let h = harness(Script { debuggee_pid: Some(pid), java_process_id: true, ..Default::default() }); + let id = h.start(config()).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await; + for _ in 0..100 { + if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + tokio::time::sleep(Duration::from_millis(200)).await; + let started = Instant::now(); + h.manager.stop(id).await.unwrap(); + assert!( + started.elapsed() >= Duration::from_millis(800), + "stop returned after {:?}, before the program exited", + started.elapsed() + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn output_keeps_arriving_while_a_stopped_program_is_exiting() { + // Ignores SIGTERM: Stop must wait for the program, not assume the signal ended it. + let mut child = std::process::Command::new("python3") + .args(["-c", "import signal,time; signal.signal(signal.SIGTERM, signal.SIG_IGN); time.sleep(1)"]) + .spawn() + .unwrap(); + let pid = child.id(); + std::thread::spawn(move || { + let _ = child.wait(); + }); + let h = harness(Script { debuggee_pid: Some(pid), output_after_terminate: true, ..Default::default() }); + let id = h.start(config()).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await; + for _ in 0..100 { + if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(pid)) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let started = Instant::now(); + let stop = tokio::spawn({ + let manager = h.manager.clone(); + async move { manager.stop(id).await } + }); + // The last line must reach the console while Stop still waits for the program. + h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output == "late\n")).await; + assert!(started.elapsed() < Duration::from_millis(800), "output arrived only after the program was gone"); + stop.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn closing_a_project_stops_the_debug_sessions_it_started() { + let h = harness(Script::default()); + let id = h.start(config()).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await; + // The session's cwd is /p, whatever key it was started under. + h.manager.stop_under(Path::new("/p")).await; + h.wait_event(|e| matches!(e, DebugEvent::Terminated)).await; + assert!(h.manager.sessions(None).is_empty(), "session {id:?} is gone"); +} + +#[cfg(unix)] +#[tokio::test] +async fn stop_kills_the_program_started_by_the_reported_launcher() { + use std::io::BufRead; + // java-debug reports the launcher shell; the JVM is its child. Stopping only the reported + // pid orphans the program, which keeps running (and keeps its ports). + let mut launcher = std::process::Command::new("sh") + .args(["-c", "python3 -c 'import time; time.sleep(30)' & echo $!; wait"]) + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let launcher_pid = launcher.id(); + let mut line = String::new(); + std::io::BufReader::new(launcher.stdout.take().unwrap()).read_line(&mut line).unwrap(); + let program: i32 = line.trim().parse().expect("child pid"); + // Reap the launcher: a zombie still "exists" for `kill(pid, 0)`, unlike the shell jdtls reaps. + std::thread::spawn(move || { + let _ = launcher.wait(); + }); + + let h = harness(Script { + debuggee_pid: Some(launcher_pid), + supports_terminate: false, // java-debug does not advertise it + ..Default::default() + }); + let id = h.start(config()).await.unwrap(); + h.wait_event(|e| matches!(e, DebugEvent::Initialized)).await; + for _ in 0..100 { + if h.manager.session(id).map(|s| s.debuggee_pid.load(Ordering::Acquire)).ok() == Some(i64::from(launcher_pid)) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + h.manager.stop(id).await.unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while unsafe { libc::kill(program, 0) } == 0 { + assert!(Instant::now() < deadline, "the program the launcher started survived stop"); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + #[test] fn delve_leftovers_are_removed_without_touching_other_files() { let dir = std::env::temp_dir().join(format!("openidea-delve-bin-{}", std::process::id())); diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 2dad9a5a9..56147fcf5 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -248,7 +248,7 @@ impl Host { vec![project.notebooks.clone()], project.kotlin_bridge.iter().cloned().collect(), Some(project.lsp.clone()), - Some((self.debugger.clone(), window.to_owned())), + Some((self.debugger.clone(), window.to_owned(), project.root.clone())), Some(project), )) } @@ -261,7 +261,7 @@ impl Host { notebooks: Vec>, bridges: Vec>, lsp: Option>, - debug: Option<(Arc, String)>, + debug: Option<(Arc, String, std::path::PathBuf)>, retired: Option>, ) -> std::thread::JoinHandle<()> { for bridge in &bridges { @@ -286,8 +286,9 @@ impl Host { if let Some(lsp) = lsp { lsp.shutdown_all().await; } - if let Some((debug, key)) = debug { + if let Some((debug, key, root)) = debug { debug.stop_key(&key).await; + debug.stop_under(&root).await; } }); if let Some(retired) = retired { diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 9deb76d46..d796884b7 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -633,15 +633,18 @@ impl ProcessManager { self.stop_with_grace(id, STOP_GRACE) } - /// [`Self::stop`] with an explicit grace period before the kill. A configuration's - /// stop command runs first; signals follow only if the process outlives it. + /// [`Self::stop`] with an explicit grace period for the configured stop command. The + /// first stop only asks the process tree to exit (stop command, then `SIGINT`) and waits; + /// a second stop kills it. pub fn stop_with_grace(&self, id: RunId, grace: Duration) -> IdeResult<()> { let entry = self.entry(id)?; - if entry.exited.load(Ordering::Acquire) { - return Ok(()); - } let already_stopping = entry.stopping.swap(true, Ordering::AcqRel); let Some(pid) = *entry.pid.lock() else { return Ok(()) }; + // A run that ended by itself can leave members of its group behind: a Stop must + // still be able to reach them. + if entry.exited.load(Ordering::Acquire) && !group_alive(pid, &entry) { + return Ok(()); + } if already_stopping { // Second stop request: don't wait out the grace period. kill_tree(pid, true); @@ -671,9 +674,11 @@ impl ProcessManager { } kill_tree(pid, false); } - if !exits_within(grace) { - tracing::debug!(id = %entry.id, pid, "process ignored interrupt; killing"); - kill_tree(pid, true); + // A first Stop only asks the process tree to exit, as IDEA does: it waits for + // the shutdown to finish (a Spring Boot app can take seconds) and a second Stop + // is what kills. Killing on a timer here cut shutdown logs off with SIGKILL. + while group_alive(pid, &entry) { + std::thread::sleep(Duration::from_millis(50)); } }) .map_err(|e| IdeError::internal(format!("failed to start stop thread: {e}")))?; @@ -1061,6 +1066,18 @@ fn spawn_pump( if let Some(pty) = &entry.pty { pty.lock().take(); } + // A stopped run is over when the whole process group is gone, not when the child we + // spawned exits: a shell or `mvn` can end while the application it started keeps its + // port, and reporting "finished" then would let Run start into an occupied port. + // A second Stop kills the group, which ends this wait. + #[cfg(unix)] + if entry.stopping.load(Ordering::Acquire) + && let Some(pid) = *entry.pid.lock() + { + while group_alive(pid, &entry) { + std::thread::sleep(Duration::from_millis(50)); + } + } entry.exited.store(true, Ordering::Release); tracing::debug!(id = %entry.id, ?code, "run exited"); sink.emit(IdeEvent::RunExited { run: entry.id, code }); diff --git a/crates/ide-run/tests/process.rs b/crates/ide-run/tests/process.rs index 21524e4f4..20619efe6 100644 --- a/crates/ide-run/tests/process.rs +++ b/crates/ide-run/tests/process.rs @@ -204,6 +204,9 @@ fn stop_kills_whole_process_group() { let grandchild: i32 = out.trim().parse().expect("pid"); assert!(alive(grandchild)); + // The first Stop only asks the group to exit (and waits); the second one kills it. + h.pm.stop(id).unwrap(); + std::thread::sleep(Duration::from_millis(200)); h.pm.stop(id).unwrap(); wait_exit(&h, id, Duration::from_secs(10)); let deadline = Instant::now() + Duration::from_secs(8); diff --git a/crates/ide-run/tests/stop_group.rs b/crates/ide-run/tests/stop_group.rs new file mode 100644 index 000000000..8b26ff945 --- /dev/null +++ b/crates/ide-run/tests/stop_group.rs @@ -0,0 +1,158 @@ +//! A stopped run reports its exit only once the whole process group is gone, so the next +//! start cannot meet a port the previous process still holds. +#![cfg(unix)] + +use ide_core::{IdeEvent, RunId, SharedSink}; +use ide_run::{ProcessManager, RunConfig, StartOptions}; +use parking_lot::Mutex; +use std::collections::BTreeMap; +use std::sync::Arc; +use std::sync::mpsc::{self, Receiver}; +use std::time::{Duration, Instant}; + +fn channel_sink() -> (SharedSink, Receiver) { + let (tx, events) = mpsc::channel(); + let tx = Mutex::new(tx); + let sink: SharedSink = Arc::new(move |e: IdeEvent| { + let _ = tx.lock().send(e); + }); + (sink, events) +} + +fn wait_exit(events: &Receiver, id: RunId, timeout: Duration) -> Option { + let deadline = Instant::now() + timeout; + loop { + let left = deadline.saturating_duration_since(Instant::now()); + match events.recv_timeout(left) { + Ok(IdeEvent::RunExited { run, code }) if run == id => return code, + Ok(_) => {} + Err(_) => return None, + } + } +} + +#[test] +fn a_stopped_run_keeps_its_port_until_the_group_is_gone() { + let tmp = tempfile::tempdir().unwrap(); + let (sink, events) = channel_sink(); + let pm = ProcessManager::new(); + // The `mvn`-starts-a-JVM shape: the direct child dies on SIGINT while the process it + // started ignores it and keeps its socket open for a moment. + let script = tmp.path().join("hold.py"); + std::fs::write( + &script, + "import signal, socket, sys, time\n\ + signal.signal(signal.SIGINT, signal.SIG_IGN)\n\ + s = socket.socket(); s.bind(('127.0.0.1', 0)); s.listen()\n\ + open(sys.argv[1], 'w').write(str(s.getsockname()[1]))\n\ + time.sleep(2)\n", + ) + .unwrap(); + let port_file = tmp.path().join("port"); + let config = RunConfig { + id: "boot".into(), + name: "boot".into(), + kind: "shell".into(), + command: "/bin/sh".into(), + args: vec!["-c".into(), format!("python3 {} {} & sleep 60", script.display(), port_file.display())], + cwd: None, + env: BTreeMap::new(), + detected: false, + is_test: false, + debug_adapter: None, + allow_parallel: false, + extras: Default::default(), + }; + let info = pm.start(tmp.path(), &config, &StartOptions::default(), sink, Box::new(|_| {})).expect("start"); + let deadline = Instant::now() + Duration::from_secs(10); + while std::fs::read_to_string(&port_file).is_err() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + let port: u16 = std::fs::read_to_string(&port_file).expect("port").trim().parse().unwrap(); + + let stopped = Instant::now(); + pm.stop(info.id).unwrap(); + let code = wait_exit(&events, info.id, Duration::from_secs(20)); + let elapsed = stopped.elapsed(); + assert!(code.is_some(), "the run reports its exit"); + assert!(elapsed >= Duration::from_millis(1500), "exit came {elapsed:?} after Stop, before the port was free"); + assert!( + std::net::TcpStream::connect(("127.0.0.1", port)).is_err(), + "the port is still bound when the run reports finished" + ); +} + +#[test] +fn a_first_stop_does_not_kill_a_program_that_is_still_shutting_down() { + let tmp = tempfile::tempdir().unwrap(); + let (sink, events) = channel_sink(); + let pm = ProcessManager::new(); + // Ignores SIGINT, then exits by itself after 2 s, like a slow Spring Boot shutdown: a first + // Stop waits for it instead of killing it (exit code 137). + let script = tmp.path().join("slow.py"); + std::fs::write( + &script, + "import signal, sys, time\n\ + signal.signal(signal.SIGINT, signal.SIG_IGN)\n\ + print('ready', flush=True)\n\ + time.sleep(2)\n", + ) + .unwrap(); + let config = RunConfig { + id: "slow".into(), + name: "slow".into(), + kind: "shell".into(), + command: "python3".into(), + args: vec![script.display().to_string()], + cwd: None, + env: BTreeMap::new(), + detected: false, + is_test: false, + debug_adapter: None, + allow_parallel: false, + extras: Default::default(), + }; + let info = pm.start(tmp.path(), &config, &StartOptions::default(), sink, Box::new(|_| {})).expect("start"); + std::thread::sleep(Duration::from_millis(500)); // let the program install its handler + let start = Instant::now(); + pm.stop(info.id).unwrap(); + let code = wait_exit(&events, info.id, Duration::from_secs(10)); + assert_eq!(code, Some(0), "the program exited by itself, not by SIGKILL"); + assert!(start.elapsed() >= Duration::from_millis(1500), "stop waited for the shutdown, not a timer"); +} + +#[test] +fn a_second_stop_kills_a_program_that_ignores_the_first() { + let tmp = tempfile::tempdir().unwrap(); + let (sink, events) = channel_sink(); + let pm = ProcessManager::new(); + let script = tmp.path().join("stuck.py"); + std::fs::write( + &script, + "import signal, time\n\ + signal.signal(signal.SIGINT, signal.SIG_IGN)\n\ + time.sleep(60)\n", + ) + .unwrap(); + let config = RunConfig { + id: "stuck".into(), + name: "stuck".into(), + kind: "shell".into(), + command: "python3".into(), + args: vec![script.display().to_string()], + cwd: None, + env: BTreeMap::new(), + detected: false, + is_test: false, + debug_adapter: None, + allow_parallel: false, + extras: Default::default(), + }; + let info = pm.start(tmp.path(), &config, &StartOptions::default(), sink, Box::new(|_| {})).expect("start"); + std::thread::sleep(Duration::from_millis(500)); + pm.stop(info.id).unwrap(); + std::thread::sleep(Duration::from_millis(200)); + pm.stop(info.id).unwrap(); + let code = wait_exit(&events, info.id, Duration::from_secs(10)); + assert_eq!(code, Some(137), "the second Stop SIGKILLs"); +} diff --git a/src/features/app/Termination.module.css b/src/features/app/Termination.module.css new file mode 100644 index 000000000..e7b375824 --- /dev/null +++ b/src/features/app/Termination.module.css @@ -0,0 +1,12 @@ +.body { + display: flex; + flex-direction: column; + gap: 10px; + min-width: 360px; + padding: 2px 16px 12px; +} + +.text { + color: var(--fg-secondary); + font-size: 13px; +} diff --git a/src/features/app/__tests__/closeGuard.test.ts b/src/features/app/__tests__/closeGuard.test.ts index c703c1b9c..23e5952bc 100644 --- a/src/features/app/__tests__/closeGuard.test.ts +++ b/src/features/app/__tests__/closeGuard.test.ts @@ -60,6 +60,22 @@ describe("decideClose", () => { expect(asked).toHaveLength(0); }); + it("stops the processes gracefully and waits for them before closing", async () => { + const { d } = deps([{ button: "terminate", checked: false }]); + d.terminate = vi.fn(async () => {}); + d.waitForProcesses = vi.fn(async () => {}); + await expect(decideClose({ ...base, confirmTerminate: true, processes: ["app"] }, d)).resolves.toBe(true); + expect(d.terminate).toHaveBeenCalledWith(false); + expect(d.waitForProcesses).toHaveBeenCalledOnce(); + }); + + it("does not stop anything when the terminate prompt is cancelled", async () => { + const { d } = deps([{ button: "cancel", checked: false }]); + d.terminate = vi.fn(async () => {}); + await expect(decideClose({ ...base, confirmTerminate: true, processes: ["app"] }, d)).resolves.toBe(false); + expect(d.terminate).not.toHaveBeenCalled(); + }); + it("asks before terminating a running process", async () => { const { d, asked } = deps([{ button: "terminate", checked: false }]); await expect(decideClose({ ...base, confirmTerminate: true, processes: ["app"] }, d)).resolves.toBe(true); diff --git a/src/features/app/closeGuard.ts b/src/features/app/closeGuard.ts index dff728230..084fb5244 100644 --- a/src/features/app/closeGuard.ts +++ b/src/features/app/closeGuard.ts @@ -16,8 +16,10 @@ import { maybeEditorService, notifications, runningProcesses as liveProcesses, + stopRunningProcesses, type EditorCloseSession, } from "@/workbench/contributions"; +import { beginTermination, endTermination } from "./termination"; import { ask, type Ask } from "@/workbench/messages"; import { AUX_CLOSED, auxiliaryWindow, closeDocumentWindows, type AuxCloseResult } from "@/platform/auxWindow"; @@ -50,6 +52,10 @@ export interface CloseDeps { disableConfirmTerminate: () => Promise; /** Running Run/Debug process names in this window. */ runningProcesses: () => Promise; + /** Stops them, gracefully first and SIGKILL when asked, as IDEA's close does. */ + terminate?: (kill: boolean) => Promise; + /** Resolves once no process of this window is left. */ + waitForProcesses?: () => Promise; onSaveFailed: (e: unknown) => void; /** Stops custom document edits/execution before reading their final dirty state. */ prepareClose?: () => Promise; @@ -67,6 +73,7 @@ export async function decideClose(input: CloseInput, deps: CloseDeps): Promise {}); } + let terminateNames: readonly string[] = []; if (input.confirmTerminate && input.processes.length > 0) { const names = input.processes; const r = await deps.ask({ @@ -84,6 +91,8 @@ export async function decideClose(input: CloseInput, deps: CloseDeps): Promise {}); + // Terminated after the save question below, so cancelling that keeps the processes alive. + terminateNames = names; } let session: EditorCloseSession | undefined; let authorized = false; @@ -104,6 +113,9 @@ export async function decideClose(input: CloseInput, deps: CloseDeps): Promise 0) await terminateForClose(terminateNames, deps); session?.commit(); authorized = true; return true; @@ -130,6 +142,10 @@ const defaultDeps: CloseDeps = { if (r.status === "ok") useProject.setState({ settings: r.data }); }, runningProcesses: async () => (await liveProcesses()).map((p) => p.name), + terminate: (kill) => stopRunningProcesses(kill), + waitForProcesses: async () => { + while ((await liveProcesses()).length > 0) await new Promise((r) => setTimeout(r, 100)); + }, onSaveFailed: (e) => notifications().notify({ level: "error", @@ -138,6 +154,20 @@ const defaultDeps: CloseDeps = { }), }; +/** The close waits for the processes, with the progress panel and a Kill escape hatch. */ +async function terminateForClose(names: readonly string[], deps: CloseDeps): Promise { + if (!deps.terminate) return; + beginTermination(names, () => { + void deps.terminate?.(true).catch(() => undefined); + }); + try { + await deps.terminate(false); + await deps.waitForProcesses?.(); + } finally { + endTermination(); + } +} + /** Process names this close would terminate; an unavailable feature list never blocks closing. */ async function processNames(deps: CloseDeps): Promise { return [...new Set(await deps.runningProcesses().catch(() => []))].filter(Boolean); diff --git a/src/features/app/index.ts b/src/features/app/index.ts index d86eef2cb..abc6b5667 100644 --- a/src/features/app/index.ts +++ b/src/features/app/index.ts @@ -7,6 +7,7 @@ import { AboutOverlay } from "./AboutDialog"; import { appActions, updateActions } from "./appActions"; import { checkForUpdatesOnStartup } from "./updates"; import { installCloseGuard } from "./closeGuard"; +import { TerminationOverlay } from "./termination"; import { InvalidateCachesOverlay, registerInvalidateCachesActions } from "./InvalidateCaches"; import { buildInfo } from "./host"; import { KeymapReferenceOverlay } from "./KeymapReference"; @@ -34,6 +35,7 @@ export function activate(): () => void { registerOverlay({ id: "app.keymapReference", component: KeymapReferenceOverlay }), registerOverlay({ id: "app.invalidateCaches", component: InvalidateCachesOverlay }), registerOverlay({ id: "app.newProject", component: NewProjectOverlay }), + registerOverlay({ id: "app.termination", component: TerminationOverlay }), registerStatusItem({ id: "app.lightEdit", align: "left", diff --git a/src/features/app/termination.tsx b/src/features/app/termination.tsx new file mode 100644 index 000000000..42bde8774 --- /dev/null +++ b/src/features/app/termination.tsx @@ -0,0 +1,44 @@ +/** IDEA's "Terminating 'X'" progress: a graceful stop in flight, with a Kill escape hatch. */ +import { create } from "zustand"; +import { Dialog, ProgressBar } from "@/ui"; +import { tx } from "@/platform/i18n"; +import styles from "./Termination.module.css"; + +interface TerminationState { + names: readonly string[]; + /** Set while the window waits for the processes to disappear. */ + active: boolean; + kill: () => void; +} + +const useTermination = create(() => ({ names: [], active: false, kill: () => {} })); + +/** Shows the progress panel; call [`endTermination`] once every process is gone. */ +export function beginTermination(names: readonly string[], kill: () => void) { + useTermination.setState({ names, active: true, kill }); +} + +export function endTermination() { + useTermination.setState({ names: [], active: false, kill: () => {} }); +} + +export function TerminationOverlay() { + const { names, active, kill } = useTermination(); + if (!active) return null; + const title = names.length > 1 ? tx`Terminating ${names.length} processes` : tx`Terminating '${names[0] ?? ""}'`; + return ( + {}} + title={title} + width={420} + buttons={[{ label: tx`Kill process`, role: "default", onClick: kill }]} + > +
+ +
{tx`Waiting for process detach`}
+
+
+ ); +} diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index 2d619c5d4..877e3e30d 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -34,7 +34,7 @@ import { flashTaskbarForBreakpoint, stopTaskbarFlash } from "./taskbarFlash"; import { missingAdapter } from "./missingAdapter"; import { startFailureMessage } from "./startFailure"; -export type SessionStatus = "starting" | "running" | "paused" | "terminated"; +export type SessionStatus = "starting" | "running" | "paused" | "stopping" | "terminated"; export interface DebugSession { id: DebugSessionId; @@ -328,6 +328,7 @@ export function publishContext() { configId: session.runConfig?.id ?? null, name: session.runConfig?.name ?? session.name, startedAt: session.startedAt ?? 0, + status: session.status, paused: !!paused, rerun: session.runConfig || session.launch ? () => void rerunSession(session.id) : null, stop: () => void stopSession(session.id), @@ -663,6 +664,12 @@ export async function stopSession(id: DebugSessionId, options: { stopProcess?: b markTerminated(root.id); return; } + // Between the Stop press and the adapter's terminated event the session is shutting down: + // keep it live (Run stays disabled) and let the toolbar show the spinner. + for (const sid of new Set([root.id, s.id])) { + const target = useDebug.getState().sessions[sid]; + if (target && isLive(target)) patch(sid, { status: "stopping" }); + } try { await call(commands.debugStop(root.id)); } catch (e) { diff --git a/src/features/run/Run.module.css b/src/features/run/Run.module.css index 7bf99d8fd..890faec31 100644 --- a/src/features/run/Run.module.css +++ b/src/features/run/Run.module.css @@ -284,6 +284,16 @@ .widgetFilledStop:hover:not(:disabled) { background: var(--danger); } +.widgetStopping { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + line-height: 0; + background: var(--warning); + color: var(--fg-on-accent); +} .widgetFilled:hover:not(:disabled) { filter: brightness(0.94); } diff --git a/src/features/run/RunView.tsx b/src/features/run/RunView.tsx index 0d6ddac4a..994e87c39 100644 --- a/src/features/run/RunView.tsx +++ b/src/features/run/RunView.tsx @@ -27,6 +27,7 @@ import { useRun, writeTerminal, type RunSession, + isLive, } from "./store"; import { TestTreeView, formatDuration } from "./TestTreeView"; import { StackTracePanel, closeStackTrace, useStackTraces, type StackTraceTab } from "./stackTrace"; @@ -46,7 +47,7 @@ function failed(s: RunSession): boolean { * configuration's own icon (Services keeps the same model, see services.tsx). */ export function sessionIcon(s: RunSession): string { - if (s.status === "running" || s.status === "starting") return s.config.isTest ? "check" : "run"; + if (isLive(s)) return s.config.isTest ? "check" : "run"; if (failed(s)) return "error"; // Ended (exit or stop): the configuration's own icon — the green arrow stays "still running". return configIcon(s.config); @@ -74,7 +75,7 @@ export function SessionPanel({ const softWrap = wrapOverride ?? defaultWrap; const [selectedTest, setSelectedTest] = useState(null); const [findOpen, setFindOpen] = useState(false); - const running = session.status === "running" || session.status === "starting"; + const running = isLive(session); const cwd = runDirectory(session.config.cwd); // Selecting a test with captured output shows just that output (like IDEA). @@ -170,7 +171,7 @@ export function SessionPanel({ void stopSession(session.id))} diff --git a/src/features/run/RunWidget.test.tsx b/src/features/run/RunWidget.test.tsx index 858fa3b3e..8fe01e078 100644 --- a/src/features/run/RunWidget.test.tsx +++ b/src/features/run/RunWidget.test.tsx @@ -98,6 +98,12 @@ it("targets the selected debug session while npm runs, through buttons and short expect(useRun.getState().sessions[0]?.stopped).toBeUndefined(); }); +it("turns Stop into Kill process while the process is shutting down", () => { + useRun.setState({ sessions: [{ ...run(app), status: "stopping" }] }); + render(); + expect(screen.getByRole("button", { name: "Kill process" })).toBeTruthy(); +}); + it("lists every running process with Stop All for the Stop shortcut, like IDEA", async () => { const session = debug(app); useRun.setState({ sessions: [run(npm, 3)] }); diff --git a/src/features/run/RunWidget.tsx b/src/features/run/RunWidget.tsx index 2254b24b8..7fb7bb621 100644 --- a/src/features/run/RunWidget.tsx +++ b/src/features/run/RunWidget.tsx @@ -1,13 +1,13 @@ import { useRef, useState } from "react"; import clsx from "clsx"; -import { BarButton, Icon, IconButton, Menu, type MenuItem } from "@/ui"; +import { BarButton, Icon, IconButton, Menu, Spinner, type MenuItem } from "@/ui"; import { executeAction } from "@/platform/actions"; import { useTx } from "@/platform/i18n"; import { shortcutLabel } from "@/platform/keymap"; import { runExecutors, useRunExecutors, useDebugRunSessions } from "@/workbench/contributions"; import { configIcon } from "./configs"; import { useRemoteLabel } from "./remote"; -import { CURRENT_FILE, useRun } from "./store"; +import { CURRENT_FILE, useRun, isLive } from "./store"; import { compoundTargets, selectedTarget } from "./sessionTargets"; import { isDebugOnly } from "./types"; import styles from "./Run.module.css"; @@ -24,13 +24,17 @@ export function RunWidget() { const currentFile = selectedId === CURRENT_FILE; // Remote JVM Debug and the like: only Debug applies, as in IDEA. const debugOnly = !!selected && isDebugOnly(selected); - const running = sessions.filter((s) => s.status === "running" || s.status === "starting"); + const running = sessions.filter((s) => isLive(s)); const debug = useDebugRunSessions((s) => s.sessions); const compoundRunning = compoundTargets(selected, running, debug); const target = selectedTarget(selectedId, running, debug); const debugging = !!target?.debug; const active = !!target || compoundRunning.length > 0; const count = running.length + debug.length; + const stopping = running.some((s) => s.status === "stopping") || debug.some((s) => s.status === "stopping"); + const targetStopping = + running.some((s) => s.status === "stopping" && s.config.id === selectedId) || + debug.some((s) => s.status === "stopping" && s.configId === selectedId); const stopName = compoundRunning.length ? selected?.name : (target?.name ?? running[0]?.config.name ?? debug[0]?.name); @@ -100,7 +104,7 @@ export function RunWidget() { className={clsx(styles.widgetButton, styles.widgetFilled, styles.widgetFilledRun)} title={tx`Rerun '${selected?.name ?? target?.name ?? ""}'`} shortcut={shortcutLabel("run.run")} - disabled={!!target && !target.rerun} + disabled={!!target && (!target.rerun || targetStopping)} onClick={() => void executeAction("run.run")} /> @@ -136,17 +140,30 @@ export function RunWidget() { /> )} - {count > 0 && ( - void executeAction("run.stop", "widget")} - /> - )} + {count > 0 && + (stopping ? ( + // Between Stop and the exit: a spinner says the process is shutting down, and the + // button kills it on the next click (IDEA's Stop turns into Kill process). + + ) : ( + void executeAction("run.stop", "widget")} + /> + ))} { it("waits for clean shutdown before restarting a debug server", async () => { await updateTomcat(session, "restart"); expect(mocks.stop).toHaveBeenCalledWith("run1"); - expect(mocks.wait).toHaveBeenCalledWith(["run1"]); + expect(mocks.wait).toHaveBeenCalledWith(["run1"], null); expect(mocks.wait.mock.invocationCallOrder[0]).toBeLessThan(mocks.action.mock.invocationCallOrder[0]!); expect(mocks.action).toHaveBeenCalledWith("run.debugConfig", session.config); }); diff --git a/src/features/run/applicationUpdate.ts b/src/features/run/applicationUpdate.ts index 02c52a011..6b8482a86 100644 --- a/src/features/run/applicationUpdate.ts +++ b/src/features/run/applicationUpdate.ts @@ -78,7 +78,7 @@ export async function updateApplication(session: UpdateSession, mode: string): P await executeAction("run.debugConfig", config); } else if (debugging) { await stopSession(session.id); - await waitForExit([session.id]); + await waitForExit([session.id], null); await executeAction("run.debugConfig", config); } else await startRun(config, session.id); return; diff --git a/src/features/run/index.ts b/src/features/run/index.ts index c6c4d3e37..2b8fd024b 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -77,6 +77,7 @@ import { stopSession, trackRecentConfigurations, useRun, + isLive, } from "./store"; import { compoundTargets, selectedTarget } from "./sessionTargets"; import { tx } from "@/platform/i18n"; @@ -241,7 +242,22 @@ function stopAction(arg?: unknown) { })), ...menuGroupItems("stopProcess", { paths: [] }), ].filter((i): i is MenuItem & { run: () => void } => "run" in i && !!i.run); - if (items.length === 0) return; + // A Debug launch has no Run process: with nothing else to stop, the title-bar Stop reaches + // the debug session itself (the Debug window need not be focused). + if (items.length === 0) { + if (debug.length === 0) return; + if (debug.length === 1) { + debug[0]!.stop(); + return; + } + const anchor = document.querySelector("[data-run-chooser]")?.getBoundingClientRect(); + const at = anchor ? { x: anchor.left, y: anchor.bottom + 4 } : { x: window.innerWidth / 2 - 120, y: 44 }; + showContextMenu(at, [ + { kind: "header", label: "Stop Process" }, + ...debug.map((s): MenuItem => ({ label: s.name, verbatim: true, icon: "debug", run: () => s.stop() })), + ]); + return; + } if (items.length === 1) { items[0]!.run(); return; @@ -264,7 +280,7 @@ function rerunActive() { } function useRunningCount(): number | null { - return useRun((s) => s.sessions.filter((x) => x.status === "running" || x.status === "starting").length || null); + return useRun((s) => s.sessions.filter(isLive).length || null); } const CONFIG_FILES = @@ -304,6 +320,20 @@ export function activate(): () => void { serviceSessions(useRun.getState().sessions, useDebugRunSessions.getState().serviceSessions) .filter(liveServiceSession) .map((s) => ({ name: s.config.name })), + // A closing window asks first, then shows the progress while these shut down; `kill` + // is the second Stop that SIGKILLs instead of waiting. + stop: (kill) => { + // The first call asks to stop; with `kill` the second one reaches the backend while + // the session is already stopping, which turns it into SIGKILL. + for (const s of useRun.getState().sessions.filter(isLive)) { + void stopSession(s.id); + if (kill) void stopSession(s.id); + } + for (const d of useDebugRunSessions.getState().sessions) { + d.stop(); + if (kill) d.stop(); + } + }, }), onWorkflowSettingsChange((w) => w.console, applyConsoleSettings), registerExternalTools(), diff --git a/src/features/run/serviceSessions.test.ts b/src/features/run/serviceSessions.test.ts index 4bac8b315..35ee54ac6 100644 --- a/src/features/run/serviceSessions.test.ts +++ b/src/features/run/serviceSessions.test.ts @@ -11,6 +11,8 @@ const { stopRun, waitForExit, getSession } = vi.hoisted(() => ({ })); vi.mock("./store", () => ({ stopSession: stopRun, + // The real predicate: a stopping run is still live. + isLive: (s: RunSession) => s.status === "running" || s.status === "starting" || s.status === "stopping", waitForExit, useRun: { getState: () => ({ sessions: [getSession()].filter(Boolean) }) }, })); diff --git a/src/features/run/serviceSessions.ts b/src/features/run/serviceSessions.ts index 49f217b7c..743efebc5 100644 --- a/src/features/run/serviceSessions.ts +++ b/src/features/run/serviceSessions.ts @@ -1,6 +1,6 @@ import type { RunConfig } from "@/platform/ipc"; import type { DebugServiceSession } from "@/workbench/contributions"; -import { stopSession, useRun, waitForExit, type RunSession } from "./store"; +import { stopSession, useRun, waitForExit, type RunSession, isLive } from "./store"; /** One execution: a Run process and its attached debuggers, or a direct debug launch. */ export interface ServiceSession { @@ -10,7 +10,7 @@ export interface ServiceSession { debug: DebugServiceSession[]; } -const running = (s: RunSession) => s.status === "running" || s.status === "starting"; +const running = (s: RunSession) => isLive(s); const debugging = (s: DebugServiceSession) => s.status !== "terminated"; export function serviceSessions(runs: readonly RunSession[], debug: readonly DebugServiceSession[]): ServiceSession[] { diff --git a/src/features/run/services.tsx b/src/features/run/services.tsx index 88f700554..7c27f6ea3 100644 --- a/src/features/run/services.tsx +++ b/src/features/run/services.tsx @@ -38,6 +38,7 @@ import { stopSession, useRun, type RunSession, + isLive, } from "./store"; import { currentServiceSession, @@ -208,7 +209,7 @@ export async function cleanupRemovedProject(removedRoot: string): Promise // --------------------------------------------------------------------------- // Tree -const busy = (s: RunSession) => s.status === "running" || s.status === "starting"; +const busy = (s: RunSession) => isLive(s); export function sessionStatus(s: RunSession | undefined): string | undefined { if (!s) return undefined; diff --git a/src/features/run/sessionTargets.ts b/src/features/run/sessionTargets.ts index 367a9e37f..6c3c1c46e 100644 --- a/src/features/run/sessionTargets.ts +++ b/src/features/run/sessionTargets.ts @@ -1,13 +1,13 @@ import { compoundChildren } from "./configs"; import type { RunConfig } from "@/platform/ipc"; import type { DebugRunSession } from "@/workbench/contributions"; -import { rerunSession, sessionsOf, stopSession, useRun, type RunSession } from "./store"; +import { rerunSession, sessionsOf, stopSession, useRun, type RunSession, isLive } from "./store"; /** Selection, never tool-window focus or an unrelated process, determines the primary target. */ export function selectedTarget(selectedId: string | null, runs: RunSession[], debug: DebugRunSession[]) { const candidates = [ ...runs - .filter((s) => (s.status === "running" || s.status === "starting") && s.config.id === selectedId) + .filter((s) => isLive(s) && s.config.id === selectedId) .map((s) => ({ name: s.config.name, startedAt: s.startedAt, diff --git a/src/features/run/stopStatus.test.ts b/src/features/run/stopStatus.test.ts new file mode 100644 index 000000000..08d03f82e --- /dev/null +++ b/src/features/run/stopStatus.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RunConfig } from "@/platform/ipc"; + +const runStop = vi.hoisted(() => vi.fn(async () => ({ status: "ok" as const, data: null }))); +vi.mock("@/platform/ipc", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + isTauri: () => true, + commands: { ...real.commands, runStop }, + call: async (p: Promise<{ status: "ok"; data: unknown }>) => (await p).data, + }; +}); +vi.mock("@/workbench/contributions", async (importOriginal) => { + const real = await importOriginal(); + return { ...real, notifications: () => ({ notify: () => undefined }) }; +}); + +import { isLive, onRunExited, stopSession, useRun, type RunSession } from "./store"; + +const config: RunConfig = { + id: "app", + name: "app", + kind: "spring-boot", + command: "java", + args: [], + cwd: null, + env: {}, + detected: false, + isTest: false, + debugAdapter: null, +}; + +const session: RunSession = { + id: "s1", + config, + runId: 7 as RunSession["runId"], + status: "running", + exitCode: null, + startedAt: 0, + endedAt: null, + stdinClosed: false, +}; + +beforeEach(() => { + runStop.mockClear(); + useRun.setState({ sessions: [session], activeSession: "s1" }); +}); + +describe("Stop keeps the tab live until the process is gone", () => { + it("a stopping run counts as live", () => { + for (const status of ["starting", "running", "stopping"] as const) expect(isLive({ status }), status).toBe(true); + for (const status of ["finished", "failed"] as const) expect(isLive({ status }), status).toBe(false); + }); + + it("stays stopping after the stop request and only finishes on the exit event", async () => { + await stopSession("s1"); + expect(runStop).toHaveBeenCalledWith(session.runId); + expect(useRun.getState().sessions[0]).toMatchObject({ status: "stopping", stopped: true }); + // The button stays Stop while the process shuts down: Run must not start again yet. + expect(isLive(useRun.getState().sessions[0]!)).toBe(true); + + onRunExited(session.runId!, 130); + await new Promise((r) => setTimeout(r, 10)); + expect(useRun.getState().sessions[0]?.status).toBe("finished"); + expect(isLive(useRun.getState().sessions[0]!)).toBe(false); + }); + + it("a second Stop while stopping asks the server to kill it", async () => { + await stopSession("s1"); + await stopSession("s1"); + expect(runStop).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 8e5162405..97ccc3374 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -63,7 +63,12 @@ import { tx } from "@/platform/i18n"; /** The permanent "Current File" pseudo-configuration of the chooser (runs the active file's context). */ export const CURRENT_FILE = "current-file"; -export type SessionStatus = "starting" | "running" | "finished" | "failed"; +export type SessionStatus = "starting" | "running" | "stopping" | "finished" | "failed"; + +/** Whether the process is alive: starting, running, or shutting down after Stop. */ +export function isLive(s: { status: SessionStatus }): boolean { + return s.status === "starting" || s.status === "running" || s.status === "stopping"; +} export interface RunSession { id: string; @@ -385,9 +390,7 @@ export function adoptTemporary(config: RunConfig): RunConfig { return existing; } let temporary = [...state.temporary.filter((id) => id !== config.id), config.id]; - const busy = new Set( - state.sessions.filter((x) => x.status === "running" || x.status === "starting").map((x) => x.config.id), - ); + const busy = new Set(state.sessions.filter(isLive).map((x) => x.config.id)); for (const session of useDebugRunSessions.getState().sessions) if (session.configId) busy.add(session.configId); while (temporary.length > MAX_TEMPORARY) { const evict = temporary.find((id) => id !== config.id && !busy.has(id)) ?? temporary[0]!; @@ -669,7 +672,7 @@ export async function startRun( } if (!options.launch && !options.prepared && !(await prepareLaunch(config, options.chain))) return null; const state = useRun.getState(); - const busy = (s: RunSession) => s.status === "running" || s.status === "starting"; + const busy = (s: RunSession) => isLive(s); let session = reuse ? state.sessions.find((s) => s.id === reuse) : undefined; // As in IDEA, a configuration that doesn't allow parallel // runs asks before stopping its running instances; rerunning a tab restarts it without asking. @@ -690,8 +693,9 @@ export async function startRun( await stopSession(session.id); stopping.push(session.id); } - // The new process starts once the old one is gone (it may still hold a port or a lock). - await waitForExit(stopping); + // The new process starts once the old one is gone (it may still hold a port or a lock). As in + // IDEA there is no time limit: a first Stop waits for the shutdown, a second one kills it. + await waitForExit(stopping, null); session ??= state.sessions.find((s) => s.config.id === config.id && !busy(s)); try { @@ -916,7 +920,13 @@ const pendingExits = new Map(); function finishSession(sessionId: string, code: number | null) { const rt = runtimes.get(sessionId); const session = getSession(sessionId); - if (!rt || !session) return; + if (!session) return; + if (!rt) { + // Nothing to flush: the run's output runtime is gone already. The exit is still the + // authority on the process being over, so the tab must not stay "stopping" for ever. + patchSession(sessionId, { status: "finished", exitCode: code, endedAt: Date.now() }); + return; + } // Output still on the channel lands before the exit is applied. setTimeout(() => { void (async () => { @@ -1036,11 +1046,14 @@ export async function stopSession(sessionId: string): Promise { await commands.cancelRequest(request.id); return; } - if (session.runId === null || session.status !== "running") return; - patchSession(sessionId, { stopped: true }); + if (session.runId === null || !isLive(session)) return; + // Stopping keeps the process live (IDEA: the Stop button stays until the process is gone); + // a second Stop meanwhile skips the grace period and kills it. + patchSession(sessionId, { stopped: true, status: "stopping" }); try { await call(commands.runStop(session.runId)); } catch (e) { + patchSession(sessionId, { status: "running" }); notifications().notify({ group: "run", level: "error", @@ -1050,12 +1063,12 @@ export async function stopSession(sessionId: string): Promise { } } -/** Resolves once the given sessions' processes exited (or after `timeoutMs`). */ -export function waitForExit(sessionIds: readonly string[], timeoutMs = 10_000): Promise { +/** Resolves once the given sessions' processes exited (or after `timeoutMs`, unless `null`). */ +export function waitForExit(sessionIds: readonly string[], timeoutMs: number | null = 10_000): Promise { const live = () => sessionIds.some((id) => { const s = getSession(id); - return !!s && s.runId !== null && s.status === "running"; + return !!s && s.runId !== null && isLive(s); }); if (!live()) return Promise.resolve(); return new Promise((resolve) => { @@ -1064,7 +1077,7 @@ export function waitForExit(sessionIds: readonly string[], timeoutMs = 10_000): unsubscribe(); resolve(); }; - const timer = setTimeout(done, timeoutMs); + const timer = timeoutMs === null ? undefined : setTimeout(done, timeoutMs); const unsubscribe = useRun.subscribe(() => { if (!live()) done(); }); @@ -1121,7 +1134,7 @@ export function failedTests(sessionId: string): string[][] { /** "Rerun Failed Tests": reruns only the failed tests in the same tab. Returns false when unsupported. */ export function rerunFailed(sessionId: string): boolean { const session = getSession(sessionId); - if (!session || session.status === "running" || session.status === "starting") return false; + if (!session || isLive(session)) return false; const base = baseConfigOf(session); const derived = rerunFailedConfig(base, failedTests(sessionId)); if (!derived) return false; @@ -1222,7 +1235,7 @@ export function clearConsole(sessionId: string) { export async function closeSessionTab(sessionId: string): Promise { const session = getSession(sessionId); if (!session) return false; - const running = session.status === "running" || session.status === "starting"; + const running = isLive(session); if (running && workflowSettings().run.confirmTerminate && !(await confirmTerminate(session.config.name))) return false; closeSession(sessionId); @@ -1232,7 +1245,7 @@ export async function closeSessionTab(sessionId: string): Promise { export function closeSession(sessionId: string) { const session = getSession(sessionId); if (!session) return; - if (session.status === "running" || session.status === "starting") void stopSession(sessionId); + if (isLive(session)) void stopSession(sessionId); const rt = runtimes.get(sessionId); if (rt) cancelFlush(rt); runtimes.delete(sessionId); @@ -1248,7 +1261,7 @@ export function closeSession(sessionId: string) { } export function runningSessions(): RunSession[] { - return useRun.getState().sessions.filter((s) => s.status === "running" || s.status === "starting"); + return useRun.getState().sessions.filter(isLive); } /** Stops everything and forgets sessions (project closed). */ diff --git a/src/features/settings/pages/execution.tsx b/src/features/settings/pages/execution.tsx index eaae115b6..1a438e942 100644 --- a/src/features/settings/pages/execution.tsx +++ b/src/features/settings/pages/execution.tsx @@ -314,7 +314,7 @@ export const EXECUTION_PAGES: PageSpec[] = [ integer: true, step: 500, unit: "ms", - hint: "After Stop, the process gets this long to exit after the interrupt before it is killed.", + hint: "After a configuration's stop command, the process gets this long to exit before it is interrupted. Press Stop again to kill a process that doesn't exit.", }, ], }, diff --git a/src/platform/locales/text/es.json b/src/platform/locales/text/es.json index e1274fb80..442e2260f 100644 --- a/src/platform/locales/text/es.json +++ b/src/platform/locales/text/es.json @@ -216,7 +216,6 @@ "Additional arguments": "Argumentos adicionales", "Adjust colors for red-green vision deficiency": "Ajustar colores para deficiencia visual rojo-verde", "After Replace": "Después de reemplazar", - "After Stop, the process gets this long to exit after the interrupt before it is killed.": "Después de Detener, el proceso dispone de este tiempo para terminar tras la interrupción antes de ser forzado a cerrar.", "Align Changes": "Alinear cambios", "All": "Todo", "All Places": "Todos los lugares", diff --git a/src/platform/locales/text/fr.json b/src/platform/locales/text/fr.json index 990e299ae..057fe0ee9 100644 --- a/src/platform/locales/text/fr.json +++ b/src/platform/locales/text/fr.json @@ -164,7 +164,6 @@ "Additional arguments": "Arguments supplémentaires", "Adjust colors for red-green vision deficiency": "Ajuster les couleurs pour le daltonisme rouge-vert", "After Replace": "Après remplacement", - "After Stop, the process gets this long to exit after the interrupt before it is killed.": "Après un arrêt, délai laissé au processus pour se terminer après l'interruption avant d'être tué.", "Align Changes": "Aligner les modifications", "All": "Tout", "All Places": "Tous les emplacements", diff --git a/src/platform/locales/text/ko.json b/src/platform/locales/text/ko.json index d061836d4..89da00abd 100644 --- a/src/platform/locales/text/ko.json +++ b/src/platform/locales/text/ko.json @@ -256,7 +256,6 @@ "Additional arguments": "추가 인수", "Adjust colors for red-green vision deficiency": "적록 색각 이상자를 위한 색상 조정", "After Replace": "바꾼 후", - "After Stop, the process gets this long to exit after the interrupt before it is killed.": "중지 후 프로세스가 강제 종료되기 전까지 인터럽트 후 종료할 수 있는 시간입니다.", "Align Changes": "변경 사항 정렬", "All": "모두", "All Places": "모든 위치", diff --git a/src/platform/locales/text/pl.json b/src/platform/locales/text/pl.json index 3435a68c4..ecc1d63ee 100644 --- a/src/platform/locales/text/pl.json +++ b/src/platform/locales/text/pl.json @@ -208,7 +208,6 @@ "Additional arguments": "Dodatkowe argumenty", "Adjust colors for red-green vision deficiency": "Dostosuj kolory dla zaburzeń rozpoznawania barwy czerwonej i zielonej", "After Replace": "Po zastąpieniu", - "After Stop, the process gets this long to exit after the interrupt before it is killed.": "Po zatrzymaniu proces ma tyle czasu na zakończenie po przerwaniu, zanim zostanie wymuszone jego zamknięcie.", "Align Changes": "Wyrównaj zmiany", "All": "Wszystko", "All Places": "Wszystkie miejsca", diff --git a/src/platform/locales/text/pt.json b/src/platform/locales/text/pt.json index 5f01749db..5db18741e 100644 --- a/src/platform/locales/text/pt.json +++ b/src/platform/locales/text/pt.json @@ -208,7 +208,6 @@ "Additional arguments": "Argumentos adicionais", "Adjust colors for red-green vision deficiency": "Ajustar cores para daltonismo vermelho-verde", "After Replace": "Após substituição", - "After Stop, the process gets this long to exit after the interrupt before it is killed.": "Após a parada, o processo tem esse tempo para sair após a interrupção antes de ser encerrado à força.", "Align Changes": "Alinhar alterações", "All": "Tudo", "All Places": "Todos os locais", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 0d87f187e..c70435fb7 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -259,7 +259,7 @@ "Additional arguments": "附加实参", "Adjust colors for red-green vision deficiency": "针对红绿色觉缺陷调整颜色", "After Replace": "替换后", - "After Stop, the process gets this long to exit after the interrupt before it is killed.": "停止后,进程在收到中断信号后有这么长时间退出,超时将被强制终止。", + "After a configuration's stop command, the process gets this long to exit before it is interrupted. Press Stop again to kill a process that doesn't exit.": "配置了停止命令时,进程在停止命令执行后有这么长时间退出,超时后发送中断信号。进程不退出时,再按一次停止可强制终止。", "Align Changes": "对齐更改", "All": "全部", "All Places": "所有位置", @@ -4188,6 +4188,12 @@ "Stop ({count} running)": "停止({count} 个正在运行)", "Rerun '{name}'": "重新运行 '{name}'", "Stop '{name}'": "停止 '{name}'", + "Terminating {length} processes": "正在终止 {length} 个进程", + "Terminating '{names}'": "正在终止 '{names}'", + "Kill process": "强杀进程", + "Terminating": "正在终止", + "Waiting for process detach": "等待进程退出", + "Stopping… (click to kill)": "正在停止…(点击强杀)", "Comparison Failure: {name}": "比较失败:{name}", "Process '{name}' Is Running": "进程 '{name}' 正在运行", "'{name}' is not allowed to run in parallel.\nWould you like to stop {count} running instances?": "'{name}' 不允许并行运行。\n是否停止 {count} 个正在运行的实例?", diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index e1f03e0e8..4b359b438 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -1468,6 +1468,8 @@ export interface DebugRunSession { configId: string | null; name: string; startedAt: number; + /** Set while the session shuts down after Stop. */ + status?: "starting" | "running" | "paused" | "stopping" | "terminated"; paused: boolean; rerun: (() => void) | null; stop: () => void; @@ -1479,7 +1481,7 @@ export interface DebugServiceSession { id: number; config: RunConfig; startedAt: number; - status: "starting" | "running" | "paused" | "terminated"; + status: "starting" | "running" | "paused" | "stopping" | "terminated"; processId: number | null; /** The debuggee's HTTP port announced on its console (not the JDWP attach port), if known. */ httpPort?: number; @@ -1508,6 +1510,11 @@ export interface RunningProcessesProvider { id: string; /** Processes this feature is running in the window right now. */ running(): RunningProcess[] | Promise; + /** + * Stops them before a close: graceful first (stop command / SIGINT, as IDEA does), the + * whole set again when `kill` asks for SIGKILL. + */ + stop?(kill: boolean): Promise | void; } const runningProcessesProviders = new Map(); @@ -1519,6 +1526,11 @@ export function registerRunningProcessesProvider(p: RunningProcessesProvider): ( }; } +/** Asks every provider to stop its processes: graceful, or killed when `kill` is set. */ +export async function stopRunningProcesses(kill: boolean): Promise { + await Promise.all([...runningProcessesProviders.values()].map((p) => p.stop?.(kill))); +} + /** Live processes of every provider, one entry per name. */ export async function runningProcesses(): Promise { const all = (await Promise.all([...runningProcessesProviders.values()].map((p) => p.running()))).flat(); diff --git a/src/workbench/runningProcesses.test.ts b/src/workbench/runningProcesses.test.ts index 8a53c7b22..ffb346ee6 100644 --- a/src/workbench/runningProcesses.test.ts +++ b/src/workbench/runningProcesses.test.ts @@ -1,5 +1,5 @@ import { expect, it, vi } from "vitest"; -import { registerRunningProcessesProvider, runningProcesses } from "./contributions"; +import { registerRunningProcessesProvider, runningProcesses, stopRunningProcesses } from "./contributions"; it("collects live processes from every provider, one entry per name", async () => { const first = vi.fn(async () => [{ name: "app" }, { name: "worker" }]); @@ -33,3 +33,17 @@ it("keeps a replacement provider registered when its predecessor is disposed", a } expect(await runningProcesses()).toEqual([]); }); + +it("asks every provider to stop, gracefully and then killed", async () => { + const stop = vi.fn(async () => {}); + const dispose = registerRunningProcessesProvider({ id: "test.stop", running: () => [{ name: "app" }], stop }); + try { + await stopRunningProcesses(false); + expect(stop).toHaveBeenLastCalledWith(false); + await stopRunningProcesses(true); + expect(stop).toHaveBeenLastCalledWith(true); + expect(stop).toHaveBeenCalledTimes(2); + } finally { + dispose(); + } +});