diff --git a/src/common/process.rs b/src/common/process.rs index d9ed536..2bb6bb8 100644 --- a/src/common/process.rs +++ b/src/common/process.rs @@ -6,10 +6,19 @@ use std::process::{Command, Stdio}; /// /// The child gets its own session via `setsid(2)` and null stdio, so it /// survives the parent exiting and doesn't take a SIGKILL cascade when -/// pixelpass dies. The `Child` is dropped immediately — `std::process::Child::drop` -/// does not kill the process on Unix. +/// pixelpass dies. +/// +/// A detached reaper thread `wait()`s the child so it doesn't linger as a +/// `` zombie under a long-lived parent — the `--gui` front-end launches +/// players itself and lives for the whole session, and `std::process::Child` +/// (unlike tokio's) has no orphan reaping, so simply dropping the handle would +/// leak a zombie per closed player. If the parent exits while the player is +/// still up, the reaper thread dies with it but the `setsid`'d player survives +/// and is reaped by init. (A double-fork would also avoid the zombie, but +/// `fork(2)` followed by non-trivial work in this multithreaded process is +/// unsound — the reaper thread is the safe equivalent.) pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> { - unsafe { + let child = unsafe { Command::new(prog) .args(args) .stdin(Stdio::null()) @@ -19,7 +28,11 @@ pub fn spawn_detached(prog: &str, args: &[&str]) -> io::Result<()> { nix::unistd::setsid().ok(); Ok(()) }) - .spawn()?; - } + .spawn()? + }; + std::thread::spawn(move || { + let mut child = child; + let _ = child.wait(); + }); Ok(()) } diff --git a/src/host/wayland.rs b/src/host/wayland.rs index d41092b..f487a11 100644 --- a/src/host/wayland.rs +++ b/src/host/wayland.rs @@ -12,8 +12,7 @@ use ashpd::{ }, }; use nix::fcntl::{FcntlArg, FdFlag, fcntl}; -use nix::unistd::close; -use std::os::fd::{AsFd, IntoRawFd, OwnedFd, RawFd}; +use std::os::fd::{AsFd, AsRawFd, OwnedFd, RawFd}; use super::pipeline::{self, CaptureHandle}; use super::quality::EffectiveQuality; @@ -61,11 +60,14 @@ pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result Result Result<()> { accepted = listener.accept() => { let (tcp, peer) = accepted?; tracing::info!(%peer, "local viewer connected"); - crate::common::tunnel::bridge(quic_send, quic_recv, tcp).await + // Race the bridge against ctrl-c so a disconnect lands promptly + // mid-stream (mirrors the host's handle_peer). Without this, the + // cancel token is set but nothing checks it once the player has + // connected — ctrl-c is ignored until a second press, and a GUI + // "Disconnect" only takes effect via the child's SIGKILL backstop. + tokio::select! { + res = crate::common::tunnel::bridge(quic_send, quic_recv, tcp) => res, + _ = cancel.cancelled() => { + tracing::info!("ctrl-c received during stream — disconnecting"); + Ok(()) + } + } } _ = cancel.cancelled() => { tracing::info!("ctrl-c received before local viewer connected");