fix: clean up children on exit

This commit is contained in:
2026-08-20 09:36:11 +02:00
parent b01496fdd5
commit 75c687d82a
3 changed files with 68 additions and 4 deletions
Generated
+1
View File
@@ -9,6 +9,7 @@ dependencies = [
"anyhow",
"clap",
"gethostname",
"libc",
"ratatui",
"serde",
"serde_json",
+1
View File
@@ -24,6 +24,7 @@ assets = [
anyhow = "1.0.103"
clap = { version = "4.6.1", features = ["derive"] }
gethostname = "1.1.0"
libc = "0.2"
ratatui = "0.30.2"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
+66 -4
View File
@@ -1,17 +1,40 @@
use std::os::unix::process::{CommandExt, ExitStatusExt};
use std::path::Path;
use std::process::Command;
use std::sync::atomic::{AtomicI32, Ordering};
use anyhow::{Context, Result, bail};
use crate::config::LauncherEntry;
/// Run `python <entrypoint>` on the system with `env` added to its environment,
/// waiting for it to finish.
/// Process-group id of the currently running child, shared with the signal
/// handler. Zero when no child is running.
static CHILD_PGID: AtomicI32 = AtomicI32::new(0);
/// Forward termination to the launched process group so the whole tree exits.
///
/// Only calls the async-signal-safe `killpg`, so it is safe to run in a signal
/// handler.
extern "C" fn terminate_child(_signum: libc::c_int) {
let pgid = CHILD_PGID.load(Ordering::SeqCst);
if pgid > 0 {
unsafe { libc::killpg(pgid, libc::SIGTERM) };
}
}
/// Run `python <entrypoint>` on the system with `env` added to its environment.
///
/// Blocks until the process exits. The child runs in its own process group so
/// that an interrupt (Ctrl-C) — or normal completion — tears down the whole
/// process tree it spawned, rather than leaving anything running in the
/// background.
fn run(python: &Path, entrypoint: &Path, env: &[(String, String)]) -> Result<()> {
let status = Command::new(python)
let mut child = Command::new(python)
.arg(entrypoint)
.envs(env.to_owned())
.status()
// New process group led by the child, so we can signal the whole tree.
.process_group(0)
.spawn()
.with_context(|| {
format!(
"failed to launch {} {}",
@@ -20,6 +43,31 @@ fn run(python: &Path, entrypoint: &Path, env: &[(String, String)]) -> Result<()>
)
})?;
// The child is its own group leader, so the group id equals its pid.
let pgid = child.id() as libc::pid_t;
CHILD_PGID.store(pgid, Ordering::SeqCst);
set_signal_handler(terminate_child as *const () as usize);
// Block until the process exits, then make sure nothing it spawned is left
// behind in the group.
let status = child.wait().with_context(|| {
format!(
"failed to wait for {} {}",
python.display(),
entrypoint.display()
)
});
CHILD_PGID.store(0, Ordering::SeqCst);
set_signal_handler(libc::SIG_DFL);
unsafe { libc::killpg(pgid, libc::SIGTERM) };
let status = status?;
// A launch we terminated on Ctrl-C is an expected exit, not an error.
if matches!(status.signal(), Some(libc::SIGINT | libc::SIGTERM)) {
return Ok(());
}
if !status.success() {
bail!(
"{} {} exited with {status}",
@@ -31,6 +79,20 @@ fn run(python: &Path, entrypoint: &Path, env: &[(String, String)]) -> Result<()>
Ok(())
}
/// Point `SIGINT`/`SIGTERM` at `handler` (a handler address or `SIG_DFL`).
fn set_signal_handler(handler: libc::sighandler_t) {
for signum in [libc::SIGINT, libc::SIGTERM] {
unsafe {
let mut action: libc::sigaction = std::mem::zeroed();
action.sa_sigaction = handler;
libc::sigemptyset(&mut action.sa_mask);
// Restart interrupted syscalls so our own `wait` is not disturbed.
action.sa_flags = libc::SA_RESTART;
libc::sigaction(signum, &action, std::ptr::null_mut());
}
}
}
/// Run `python <entrypoint>` on the system with `env` added to its environment,
/// waiting for it to finish.
pub fn launch_with_env(python: &Path, entrypoint: &Path, env: &[(String, String)]) -> Result<()> {