fix(run): keep Stop in effect until the process is really gone (#1763)

Stop flipped the session back to Run as soon as the direct child exited, but a shell, Maven or the debug adapter can exit while the application it started still holds its port, so the next Run failed with "port already in use". The console also went quiet on a debug Stop because the DAP session was disconnected before java-debug drained the program's output, and closing a window killed running programs with SIGKILL at once.

Run and Debug sessions now stay in a stopping state from the Stop press until the whole process group (or the debuggee's pid tree) is gone. As in IntelliJ IDEA, the first Stop only interrupts and waits, and a second Stop kills; the Stop button turns into Kill process meanwhile. A rerun or restart waits for the old process without a time limit, and the termination time limit setting now only bounds a configuration's stop command. Debug launches record the program pid from the adapter's process/processid event, signal its pid tree and keep the adapter attached until the program exits; closing a project also stops debug sessions under its root. Closing a window with running programs shows a Terminating panel with Kill process and closes once they are gone.

Co-authored-by: misswell <[email protected]>
Цей коміт міститься в:
misswellтаmisswell authored and GitHub committed 2026-11-07 10:15:30 +08:00
1 джерело 7965d171f0
коміт 438ab158ad
37 files changed
+999 -77

No files matched your search

Створено
+1
Переглянути файл
@@ -4216,6 +4216,7 @@ dependencies = [
"ide-core",
"ide-jsonrpc",
"ide-language",
"libc",
"memchr",
"parking_lot",
"portable-pty",
+3
Переглянути файл
@@ -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"] }
+84 -4
Переглянути файл
@@ -301,6 +301,8 @@ pub struct DebugManager {
exception_choices: Mutex<BTreeMap<String, bool>>,
function_breakpoints: Mutex<Vec<FunctionBreakpoint>>,
data_breakpoints: Mutex<Vec<DataBreakpoint>>,
/// In-flight stops, by session: a second Stop sets `stop_force`, the waiting one kills.
stopping: Mutex<HashMap<DebugSessionId, Arc<Session>>>,
}
/// 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<Session>, 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<DebugSessionId> =
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<Arc<Session>> = 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<Session>) -> IdeResult<()> {
let children: Vec<Arc<Session>> =
self.live_sessions().into_iter().filter(|c| c.parent == Some(session.id)).collect();
for child in children {
self.shutdown(&child, true).await;
}
+134 -5
Переглянути файл
@@ -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<bool>,
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<Vec<i32>>,
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
pub pending_restore: Mutex<PendingRestore>,
/// 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<i32> {
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<i32, Vec<i32>> = 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::<i32>(), parent.parse::<i32>()) 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<Session>, 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<Session>, 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<DebugManager>, s: &Arc<Session>, 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<DebugManager>, s: &Arc<Session>, 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) {
+240 -2
Переглянути файл
@@ -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<Mutex<Vec<Value>>>;
#[derive(Clone, Default)]
#[derive(Clone)]
struct Script {
/// Send a `runInTerminal` reverse request when `launch` arrives.
run_in_terminal: Option<Vec<String>>,
@@ -17,10 +22,35 @@ struct Script {
start_debugging: bool,
ignore_configuration_done: bool,
source_path: Option<String>,
/// Report this process id in the adapter's `process` event (the program being debugged).
debuggee_pid: Option<u32>,
/// 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<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(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<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(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<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(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()));
+4 -3
Переглянути файл
@@ -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<Arc<notebook::NotebookService>>,
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
lsp: Option<Arc<ide_lsp::LspManager>>,
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
debug: Option<(Arc<ide_dap::DebugManager>, String, std::path::PathBuf)>,
retired: Option<Arc<Project>>,
) -> 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 {
+25 -8
Переглянути файл
@@ -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 });
+3
Переглянути файл
@@ -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);
+158
Переглянути файл
@@ -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<IdeEvent>) {
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<IdeEvent>, id: RunId, timeout: Duration) -> Option<i32> {
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");
}
+12
Переглянути файл
@@ -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;
}
+16
Переглянути файл
@@ -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);
+30
Переглянути файл
@@ -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<void>;
/** Running Run/Debug process names in this window. */
runningProcesses: () => Promise<readonly string[]>;
/** Stops them, gracefully first and SIGKILL when asked, as IDEA's close does. */
terminate?: (kill: boolean) => Promise<void>;
/** Resolves once no process of this window is left. */
waitForProcesses?: () => Promise<void>;
onSaveFailed: (e: unknown) => void;
/** Stops custom document edits/execution before reading their final dirty state. */
prepareClose?: () => Promise<EditorCloseSession | undefined>;
@@ -67,6 +73,7 @@ export async function decideClose(input: CloseInput, deps: CloseDeps): Promise<b
if (r.button !== "exit") return false;
if (r.checked) await deps.disableConfirmExit().catch(() => {});
}
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<b
});
if (r.button !== "terminate") return false;
if (r.checked) await deps.disableConfirmTerminate().catch(() => {});
// 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<b
else if (r.button === "discard") await session?.discard();
else return false;
}
// IDEA asks, then shows "Terminating 'X'" while the graceful shutdown runs; a Kill button
// there kills the processes and the close finishes once none is left.
if (terminateNames.length > 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<void> {
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<string[]> {
return [...new Set(await deps.runningProcesses().catch(() => []))].filter(Boolean);
+2
Переглянути файл
@@ -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",
+44
Переглянути файл
@@ -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<TerminationState>(() => ({ 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 (
<Dialog
open
// The close is in flight: Escape must not dismiss the only progress the window shows.
onClose={() => {}}
title={title}
width={420}
buttons={[{ label: tx`Kill process`, role: "default", onClick: kill }]}
>
<div className={styles.body}>
<ProgressBar aria-label={tx`Terminating`} />
<div className={styles.text}>{tx`Waiting for process detach`}</div>
</div>
</Dialog>
);
}
+8 -1
Переглянути файл
@@ -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) {
+10
Переглянути файл
@@ -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);
}
+4 -3
Переглянути файл
@@ -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<string | null>(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({
<IconButton
icon="stop"
iconSize={13}
title="Stop"
title={session.status === "stopping" ? tx`Stopping… (click to kill)` : "Stop"}
tone={running || onStop ? "danger" : "default"}
disabled={!running && !onStop}
onClick={onStop ?? (() => void stopSession(session.id))}
+6
Переглянути файл
@@ -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(<RunWidget />);
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)] });
+32 -15
Переглянути файл
@@ -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 && (
<IconButton
icon="stop-filled"
size="large"
iconSize={16}
className={clsx(styles.widgetButton, styles.widgetFilled, styles.widgetFilledStop)}
title={active || count === 1 ? tx`Stop '${stopName ?? ""}'` : tx`Stop (${count} running)`}
shortcut={shortcutLabel("run.stop")}
onClick={() => 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).
<button
type="button"
className={clsx(styles.widgetButton, styles.widgetFilled, styles.widgetFilledStop, styles.widgetStopping)}
title={tx`Kill process`}
aria-label={tx`Kill process`}
onClick={() => void executeAction("run.stop", "widget")}
>
<Spinner size={14} />
</button>
) : (
<IconButton
icon="stop-filled"
size="large"
iconSize={16}
className={clsx(styles.widgetButton, styles.widgetFilled, styles.widgetFilledStop)}
title={active || count === 1 ? tx`Stop '${stopName ?? ""}'` : tx`Stop (${count} running)`}
shortcut={shortcutLabel("run.stop")}
onClick={() => void executeAction("run.stop", "widget")}
/>
))}
<IconButton
ref={moreRef}
icon="more"
+1 -1
Переглянути файл
@@ -102,7 +102,7 @@ describe("Tomcat application updates", () => {
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);
});
+1 -1
Переглянути файл
@@ -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;
+32 -2
Переглянути файл
@@ -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(),
+2
Переглянути файл
@@ -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) }) },
}));
+2 -2
Переглянути файл
@@ -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[] {
+2 -1
Переглянути файл
@@ -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<void>
// ---------------------------------------------------------------------------
// 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;
+2 -2
Переглянути файл
@@ -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,
+74
Переглянути файл
@@ -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<typeof import("@/platform/ipc")>();
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<typeof import("@/workbench/contributions")>();
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);
});
});
+31 -18
Переглянути файл
@@ -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<RunId, number | null>();
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<void> {
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<void> {
}
}
/** Resolves once the given sessions' processes exited (or after `timeoutMs`). */
export function waitForExit(sessionIds: readonly string[], timeoutMs = 10_000): Promise<void> {
/** Resolves once the given sessions' processes exited (or after `timeoutMs`, unless `null`). */
export function waitForExit(sessionIds: readonly string[], timeoutMs: number | null = 10_000): Promise<void> {
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<boolean> {
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<boolean> {
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). */
+1 -1
Переглянути файл
@@ -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.",
},
],
},
-1
Переглянути файл
@@ -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",
-1
Переглянути файл
@@ -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",
-1
Переглянути файл
@@ -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": "모든 위치",
-1
Переглянути файл
@@ -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",
-1
Переглянути файл
@@ -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",
+7 -1
Переглянути файл
@@ -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} 个正在运行的实例?",
+13 -1
Переглянути файл
@@ -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<RunningProcess[]>;
/**
* 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> | void;
}
const runningProcessesProviders = new Map<string, RunningProcessesProvider>();
@@ -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<void> {
await Promise.all([...runningProcessesProviders.values()].map((p) => p.stop?.(kill)));
}
/** Live processes of every provider, one entry per name. */
export async function runningProcesses(): Promise<RunningProcess[]> {
const all = (await Promise.all([...runningProcessesProviders.values()].map((p) => p.running()))).flat();
+15 -1
Переглянути файл
@@ -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();
}
});