debugger: Make sure debuggees are killed when quitting Zed (#32186)

Closes #31373 

We kill the DAP process in our `on_app_quit` handler, but the debuggee
might not be killed. Try to make this more reliable by making the DAP
process its own process group leader, and killing that entire process
group when quitting Zed.

I also considered going through the normal DAP shutdown sequence here,
but that seems dicey in a quit handler. There's also the DAP
`ProcessEvent` but it seems we can't rely on that as e.g. the JS DAP
doesn't send it.

Release Notes:

- Debugger Beta: Fixed debuggee processes not getting cleaned up when
quitting Zed.
This commit is contained in:
Cole Miller 2025-06-11 01:23:38 -04:00 committed by GitHub
parent fc4ca346be
commit 6c0ea88f5b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 88 additions and 32 deletions

1
Cargo.lock generated
View file

@ -4027,6 +4027,7 @@ dependencies = [
"gpui",
"http_client",
"language",
"libc",
"log",
"node_runtime",
"parking_lot",

View file

@ -51,6 +51,9 @@ telemetry.workspace = true
util.workspace = true
workspace-hack.workspace = true
[target.'cfg(not(windows))'.dependencies]
libc.workspace = true
[dev-dependencies]
async-pipe.workspace = true
gpui = { workspace = true, features = ["test-support"] }

View file

@ -12,7 +12,6 @@ use smol::{
io::{AsyncBufReadExt as _, AsyncWriteExt, BufReader},
lock::Mutex,
net::{TcpListener, TcpStream},
process::Child,
};
use std::{
collections::HashMap,
@ -22,7 +21,7 @@ use std::{
time::Duration,
};
use task::TcpArgumentsTemplate;
use util::{ConnectionResult, ResultExt as _};
use util::ConnectionResult;
use crate::{adapters::DebugAdapterBinary, debugger_settings::DebuggerSettings};
@ -102,7 +101,7 @@ impl Transport {
}
}
async fn kill(&self) -> Result<()> {
async fn kill(&self) {
match self {
Transport::Stdio(stdio_transport) => stdio_transport.kill().await,
Transport::Tcp(tcp_transport) => tcp_transport.kill().await,
@ -537,7 +536,7 @@ impl TransportDelegate {
current_requests.clear();
pending_requests.clear();
let _ = self.transport.kill().await.log_err();
self.transport.kill().await;
drop(current_requests);
drop(pending_requests);
@ -598,8 +597,6 @@ impl TcpTransport {
let port = connection_args.port;
let mut command = util::command::new_std_command(&binary.command);
util::set_pre_exec_to_start_new_session(&mut command);
let mut command = smol::process::Command::from(command);
if let Some(cwd) = &binary.cwd {
command.current_dir(cwd);
@ -608,14 +605,7 @@ impl TcpTransport {
command.args(&binary.arguments);
command.envs(&binary.envs);
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut process = command
.spawn()
let mut process = Child::spawn(command, Stdio::null())
.with_context(|| "failed to start debug adapter.")?;
let address = SocketAddrV4::new(host, port);
@ -635,7 +625,7 @@ impl TcpTransport {
Ok(stream) => return Ok((process, stream.split())),
Err(_) => {
if let Ok(Some(_)) = process.try_status() {
let output = process.output().await?;
let output = process.into_inner().output().await?;
let output = if output.stderr.is_empty() {
String::from_utf8_lossy(&output.stdout).to_string()
} else {
@ -679,10 +669,15 @@ impl TcpTransport {
true
}
async fn kill(&self) -> Result<()> {
self.process.lock().await.kill()?;
async fn kill(&self) {
let mut process = self.process.lock().await;
Child::kill(&mut process);
}
}
Ok(())
impl Drop for TcpTransport {
fn drop(&mut self) {
self.process.get_mut().kill();
}
}
@ -694,8 +689,6 @@ impl StdioTransport {
#[allow(dead_code, reason = "This is used in non test builds of Zed")]
async fn start(binary: &DebugAdapterBinary, _: AsyncApp) -> Result<(TransportPipe, Self)> {
let mut command = util::command::new_std_command(&binary.command);
util::set_pre_exec_to_start_new_session(&mut command);
let mut command = smol::process::Command::from(command);
if let Some(cwd) = &binary.cwd {
command.current_dir(cwd);
@ -704,13 +697,7 @@ impl StdioTransport {
command.args(&binary.arguments);
command.envs(&binary.envs);
command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut process = command.spawn().with_context(|| {
let mut process = Child::spawn(command, Stdio::piped()).with_context(|| {
format!(
"failed to spawn command `{} {}`.",
binary.command,
@ -751,9 +738,15 @@ impl StdioTransport {
false
}
async fn kill(&self) -> Result<()> {
self.process.lock().await.kill()?;
Ok(())
async fn kill(&self) {
let mut process = self.process.lock().await;
Child::kill(&mut process);
}
}
impl Drop for StdioTransport {
fn drop(&mut self) {
self.process.get_mut().kill();
}
}
@ -927,7 +920,66 @@ impl FakeTransport {
false
}
async fn kill(&self) -> Result<()> {
Ok(())
async fn kill(&self) {}
}
struct Child {
process: smol::process::Child,
}
impl std::ops::Deref for Child {
type Target = smol::process::Child;
fn deref(&self) -> &Self::Target {
&self.process
}
}
impl std::ops::DerefMut for Child {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.process
}
}
impl Child {
fn into_inner(self) -> smol::process::Child {
self.process
}
#[cfg(not(windows))]
fn spawn(mut command: std::process::Command, stdin: Stdio) -> Result<Self> {
util::set_pre_exec_to_start_new_session(&mut command);
let process = smol::process::Command::from(command)
.stdin(stdin)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
Ok(Self { process })
}
#[cfg(windows)]
fn spawn(command: std::process::Command, stdin: Stdio) -> Result<Self> {
// TODO(windows): create a job object and add the child process handle to it,
// see https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects
let process = smol::process::Command::from(command)
.stdin(stdin)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
Ok(Self { process })
}
#[cfg(not(windows))]
fn kill(&mut self) {
let pid = self.process.id();
unsafe {
libc::killpg(pid as i32, libc::SIGKILL);
}
}
#[cfg(windows)]
fn kill(&mut self) {
// TODO(windows): terminate the job object in kill
let _ = self.process.kill();
}
}