//! Shared subprocess plumbing: spawn a child, optionally feed it stdin, wait up //! to a wall-clock budget, and reap the whole process group on a breach. //! //! Used by both the optional [`crate::MmdcEngine`] (which shells out to //! `mmdc`/headless-Chromium) and the pager's out-of-process render child (a //! short-lived re-exec of the pager that renders one diagram in isolation). The //! timeout is a *real* process kill, not a soft signal: a panic under //! `panic = "abort"` or a runaway render in the child is contained because the //! parent kills and reaps it. //! //! The caller builds the [`Command`] (stdio, env, and the sanctioned //! TTY/console detach via `kigi_tty_utils::detach_std_command`); this module only //! owns the spawn → feed-stdin → wait → reap lifecycle so neither call site //! re-implements process-group teardown. use std::process::{Child, Command}; use std::time::Duration; use wait_timeout::ChildExt; /// Why a child subprocess run did not complete successfully. #[derive(thiserror::Error, Debug)] pub enum SubprocessError { /// The child could not be spawned (binary missing, fork failure, …). #[error("could not spawn child process: {0}")] Spawn(std::io::Error), /// The child exceeded its wall-clock budget and was killed and reaped. #[error("child process timed out")] Timeout, /// The child ran to completion but exited non-zero. #[error("child process exited with {0}")] NonZeroExit(std::process::ExitStatus), /// Waiting on the child itself failed; the child was reaped defensively. #[error("waiting on child process failed: {0}")] Wait(std::io::Error), } /// Spawn `cmd`, optionally write `stdin_payload` to its stdin, wait up to /// `timeout`, and reap the process group on a breach. /// /// The caller must have configured `cmd` (stdio, env, detach). To pass /// `stdin_payload`, the caller must set `cmd.stdin(Stdio::piped())`; the payload /// is written from a scoped thread so a full pipe buffer can never deadlock the /// wait. When `stdin_payload` is `None` (or stdin is not piped), no writer runs. /// /// Returns `Ok(())` only on a zero-exit run; otherwise the matching /// [`SubprocessError`]. On timeout or a failed wait the child is killed and /// reaped: on Unix the whole process group is SIGKILLed, so grandchildren (e.g. /// an [`crate::MmdcEngine`]'s headless Chromium) are reaped too; on Windows only /// the direct child is killed — sufficient for the pager's render child (no /// grandchildren), but a Windows `MmdcEngine` could leak Chromium grandchildren /// (a Job Object is the follow-up there). pub fn run_with_timeout( mut cmd: Command, stdin_payload: Option<&[u8]>, timeout: Duration, ) -> Result<(), SubprocessError> { let mut child = spawn_with_etxtbsy_retry(&mut cmd).map_err(SubprocessError::Spawn)?; // Feed stdin from a scoped thread so a child that stops reading can't wedge // a `write_all` of a large (up to the source-size cap) payload and deadlock // the wait below. On timeout we kill the child, which EOF/EPIPEs the writer. let stdin = child.stdin.take(); // A payload with no piped stdin would be silently dropped (the caller forgot // `cmd.stdin(Stdio::piped())`). Both in-tree callers pipe correctly; flag the // foot-gun loudly in debug and at least log it in release. if stdin_payload.is_some() && stdin.is_none() { tracing::warn!( target: "mermaid", "run_with_timeout: stdin payload supplied but stdin is not piped; payload dropped" ); debug_assert!( false, "run_with_timeout: stdin_payload supplied but cmd.stdin is not piped (payload dropped)" ); } std::thread::scope(|scope| { if let (Some(mut sink), Some(payload)) = (stdin, stdin_payload) { scope.spawn(move || { use std::io::Write as _; // Errors are expected if the child exits/dies first; ignore them. let _ = sink.write_all(payload); // Dropping `sink` closes the pipe so the child observes EOF. }); } wait_and_reap(&mut child, timeout) }) } /// Spawn `cmd`, retrying briefly on `ETXTBSY` ("Text file busy"). /// /// On Linux, exec'ing a binary that another thread/process still holds open for /// writing fails with `ExecutableFileBusy`. A concurrent `Command::spawn` on /// another thread forks and inherits any write fd open at that instant; the fd /// is close-on-exec but only closes at the child's own `execve`, leaving a /// fork→execve window during which our `execve` of a freshly-written binary can /// race. It is transient and clears within milliseconds, so retry a few times /// with a short backoff. (No-op on the steady-state path; only the failing /// transient case changes behaviour.) fn spawn_with_etxtbsy_retry(cmd: &mut Command) -> std::io::Result { const MAX_ATTEMPTS: u32 = 5; let mut attempt = 0; loop { match cmd.spawn() { Ok(child) => return Ok(child), Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy && attempt + 1 < MAX_ATTEMPTS => { attempt += 1; std::thread::sleep(Duration::from_millis(20 * attempt as u64)); } Err(e) => return Err(e), } } } /// Wait for `child` up to `timeout`, tearing down its detached process group on /// every exit path (success, non-zero exit, timeout, wait failure) so a child /// that spawned grandchildren can't orphan them. fn wait_and_reap(child: &mut Child, timeout: Duration) -> Result<(), SubprocessError> { match child.wait_timeout(timeout) { // `wait_timeout` already reaped the direct child on these two branches, // but it was its own detached group leader: SIGKILL the group's pgid so // any grandchildren (e.g. an opt-in MmdcEngine's headless Chromium) are // torn down regardless of exit code. The pgid stays valid while a // grandchild is alive (the case that matters); for the grandchild-less // render child the leader is already gone, so killpg is a harmless no-op // (ESRCH). The full `reap()` is unneeded — the direct child is already // reaped, so `child.kill()`/`wait()` would be redundant. Ok(Some(status)) if status.success() => { reap_process_group(child); Ok(()) } Ok(Some(status)) => { reap_process_group(child); Err(SubprocessError::NonZeroExit(status)) } Ok(None) => { reap(child); Err(SubprocessError::Timeout) } // waitpid failed; the child may still be running, so reap it too — the // same teardown as the timeout branch (don't leak the child tree). Err(e) => { reap(child); Err(SubprocessError::Wait(e)) } } } /// Best-effort teardown of a spawned child: SIGKILL its process group (to reach /// any grandchildren, e.g. headless Chromium), then kill and reap the child. fn reap(child: &mut Child) { reap_process_group(child); let _ = child.kill(); let _ = child.wait(); } /// SIGKILL the child's process group so grandchildren are reaped, not just the /// direct child. /// /// `kigi_tty_utils::detach_std_command` runs `setsid` (EPERM fallback /// `setpgid(0,0)`), so the child is its own group leader and its pgid equals its /// pid. We send the signal directly because `kigi_tty_utils::ProcessGroup` only /// wraps tokio children. #[cfg(unix)] fn reap_process_group(child: &Child) { let pid = child.id() as libc::pid_t; // SAFETY: killpg with a valid pid + standard signal has no memory effects. unsafe { libc::killpg(pid, libc::SIGKILL); } } #[cfg(not(unix))] fn reap_process_group(_child: &Child) { // Group teardown via Job Objects is tokio-only here; the caller's // `child.kill()` still terminates the direct child process. } #[cfg(test)] mod tests { use super::*; use std::process::Stdio; use std::time::Instant; fn detached(mut cmd: Command) -> Command { cmd.stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); kigi_tty_utils::detach_std_command(&mut cmd); cmd } #[cfg(unix)] #[test] fn zero_exit_is_ok() { let cmd = detached(Command::new("true")); assert!(run_with_timeout(cmd, None, Duration::from_secs(5)).is_ok()); } #[cfg(unix)] #[test] fn nonzero_exit_is_reported() { let cmd = detached(Command::new("false")); let r = run_with_timeout(cmd, None, Duration::from_secs(5)); assert!( matches!(r, Err(SubprocessError::NonZeroExit(_))), "got {r:?}" ); } #[cfg(unix)] #[test] fn slow_command_times_out_quickly() { let mut cmd = Command::new("sleep"); cmd.arg("5"); let cmd = detached(cmd); let start = Instant::now(); let r = run_with_timeout(cmd, None, Duration::from_millis(150)); assert!(matches!(r, Err(SubprocessError::Timeout))); assert!( start.elapsed() < Duration::from_secs(2), "should return at the deadline, not wait the full 5s", ); } /// A large stdin payload (bigger than any OS pipe buffer) must be delivered /// through `run_with_timeout`'s own scoped writer without deadlocking the /// wait — the whole reason the writer is a scoped thread. We point `cat` at a /// file (its stdout) so we can prove every byte was consumed, and assert the /// call returns `Ok(())` promptly rather than hitting the timeout. #[cfg(unix)] #[test] fn large_stdin_payload_is_delivered_without_deadlock() { let payload = vec![b'x'; 256 * 1024]; let dir = tempfile::tempdir().expect("tempdir"); let sink = dir.path().join("drained"); let sink_file = std::fs::File::create(&sink).expect("create sink"); let mut cmd = Command::new("cat"); cmd.stdin(Stdio::piped()) .stdout(Stdio::from(sink_file)) .stderr(Stdio::null()); kigi_tty_utils::detach_std_command(&mut cmd); let start = Instant::now(); let r = run_with_timeout(cmd, Some(&payload), Duration::from_secs(10)); assert!( r.is_ok(), "draining a large stdin payload must succeed: {r:?}" ); assert!( start.elapsed() < Duration::from_secs(5), "must return after the drain, not after the full timeout", ); // `cat` copies all of stdin to the file → proves the whole payload was // both delivered and consumed via the real scoped writer. let drained = std::fs::metadata(&sink).expect("sink metadata").len(); assert_eq!( drained, payload.len() as u64, "all stdin bytes round-tripped through cat" ); } /// `reap` actually terminates the spawned process group. #[cfg(unix)] #[test] fn reap_terminates_the_process() { let mut cmd = Command::new("sleep"); cmd.arg("30"); let mut cmd = detached(cmd); let mut child = cmd.spawn().expect("spawn sleep"); let pid = child.id() as libc::pid_t; reap(&mut child); // After SIGKILL + wait, the pid no longer names a live process. assert_eq!(unsafe { libc::kill(pid, 0) }, -1); assert_eq!( std::io::Error::last_os_error().raw_os_error(), Some(libc::ESRCH), "process {pid} should be gone after reap", ); } #[test] fn missing_binary_is_spawn_error() { let cmd = Command::new("definitely-not-a-real-binary-9f8a7b6c5d4e"); let r = run_with_timeout(cmd, None, Duration::from_secs(5)); assert!(matches!(r, Err(SubprocessError::Spawn(_))), "got {r:?}"); } /// The dropped-stdin-payload guard fires when a caller passes a payload but /// forgets `cmd.stdin(Stdio::piped())`: the `debug_assert!` turns that silent /// foot-gun into a hard failure. Gated on `debug_assertions` because that is /// exactly when the assert is active (release keeps only the `warn`). `true` /// exits at once; `detached` sets stdin to null (not piped), so the payload /// would be dropped and the guard must catch it. #[cfg(all(unix, debug_assertions))] #[test] #[should_panic(expected = "stdin_payload supplied but cmd.stdin is not piped")] fn stdin_payload_without_piped_stdin_is_flagged() { let cmd = detached(Command::new("true")); let _ = run_with_timeout(cmd, Some(b"payload"), Duration::from_secs(5)); } }