fix: reap opener processes instead of leaving them as zombies (#1231)

Opening a file, link, URL or config.json, revealing a path, and the custom
link_file_command all spawned `open` / `xdg-open` / `explorer` and dropped
the Child. Dropping a Child never waits on it, so every one of these clicks
left a defunct process under tty7-app for as long as the GUI ran.

Add `core::proc::spawn_reaped`, which spawns the command and waits on it
from a short-lived thread, and route all six fire-and-forget spawns through
it.
This commit is contained in:
l0ng-ai
2026-10-27 21:18:21 +08:00
committed by GitHub
parent 2d21c55e6c
commit 62b7409cb4
5 changed files with 53 additions and 10 deletions
+39
View File
@@ -67,6 +67,21 @@ pub fn output_within(cmd: &mut Command, timeout: Duration) -> io::Result<Output>
})
}
/// Starts a command nobody will read back from — an opener like `open` or
/// `xdg-open` — and reaps it once it exits. Dropping a `Child` never waits on
/// it, so each fire-and-forget spawn would otherwise sit as a zombie under
/// this process until it quits.
pub fn spawn_reaped(cmd: &mut Command) -> io::Result<u32> {
let mut child = cmd.spawn()?;
let pid = child.id();
std::thread::Builder::new()
.name("reap-child".into())
.spawn(move || {
let _ = child.wait();
})?;
Ok(pid)
}
fn drain<R: Read + Send + 'static>(pipe: Option<R>) -> Vec<u8> {
let mut buf = Vec::new();
if let Some(mut pipe) = pipe {
@@ -103,6 +118,30 @@ mod tests {
);
}
#[test]
fn a_reaped_spawn_leaves_no_zombie_behind() {
let pid = spawn_reaped(&mut Command::new("true")).expect("true runs");
// WNOWAIT only peeks, so this never collects the child itself: the
// pid stops being ours (ECHILD) only once the reaper has waited on it.
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
let rc = unsafe {
libc::waitid(
libc::P_PID,
pid as libc::id_t,
&mut info,
libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
)
};
if rc == -1 && io::Error::last_os_error().raw_os_error() == Some(libc::ECHILD) {
break;
}
assert!(Instant::now() < deadline, "the child was never reaped");
std::thread::sleep(REAP_POLL);
}
}
#[test]
fn output_larger_than_a_pipe_buffer_does_not_deadlock() {
let mut cmd = Command::new("sh");
+2 -1
View File
@@ -975,7 +975,8 @@ pub fn open_url(url: &str) {
} else {
"xdg-open"
};
if let Err(e) = std::process::Command::new(opener).arg(url).spawn() {
if let Err(e) = tty7_core::core::proc::spawn_reaped(std::process::Command::new(opener).arg(url))
{
log::warn!("failed to open {url}: {e}");
}
}
+3 -3
View File
@@ -8442,7 +8442,7 @@ pub(crate) fn open_file_path(path: &std::path::Path) -> std::io::Result<()> {
} else {
"xdg-open"
};
std::process::Command::new(opener).arg(path).spawn()?;
tty7_core::core::proc::spawn_reaped(std::process::Command::new(opener).arg(path))?;
Ok(())
}
@@ -8476,7 +8476,7 @@ pub(crate) fn reveal_file_path(path: &std::path::Path) -> std::io::Result<()> {
c.arg(path.parent().unwrap_or(path));
c
};
command.spawn()?;
tty7_core::core::proc::spawn_reaped(&mut command)?;
Ok(())
}
@@ -8496,7 +8496,7 @@ fn run_file_command(
"link_file_command expanded to nothing",
));
};
std::process::Command::new(program).args(args).spawn()?;
tty7_core::core::proc::spawn_reaped(std::process::Command::new(program).args(args))?;
Ok(())
}
+3 -1
View File
@@ -9146,7 +9146,9 @@ impl Tty7App {
} else {
"xdg-open"
};
if let Err(e) = std::process::Command::new(opener).arg(&path).spawn() {
if let Err(e) =
tty7_core::core::proc::spawn_reaped(std::process::Command::new(opener).arg(&path))
{
log::warn!("failed to open {}: {e}", path.display());
}
}
+6 -5
View File
@@ -734,11 +734,12 @@ fn opens_in_browser(path: &Path) -> bool {
fn open_in_browser(path: &Path) -> std::io::Result<()> {
#[cfg(target_os = "macos")]
if let Some(bundle) = default_browser_bundle_id() {
std::process::Command::new("open")
.arg("-b")
.arg(bundle)
.arg(path)
.spawn()?;
tty7_core::core::proc::spawn_reaped(
std::process::Command::new("open")
.arg("-b")
.arg(bundle)
.arg(path),
)?;
return Ok(());
}
crate::terminal::view::open_file_path(path)