Publish harness and TUI open-source
initial sync from the monorepo
This commit is contained in:
commit
c68e39f604
2734 changed files with 1437016 additions and 0 deletions
19
crates/codegen/xai-tty-utils/Cargo.toml
Normal file
19
crates/codegen/xai-tty-utils/Cargo.toml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "xai-tty-utils"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license = "Apache-2.0"
|
||||
description = "Lightweight process-spawning utilities for TTY safety — detach from controlling terminal, suppress interactive pagers, process-group lifecycle"
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true, features = ["process"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
nix = { workspace = true, features = ["signal", "process"] }
|
||||
libc = { workspace = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
938
crates/codegen/xai-tty-utils/src/lib.rs
Normal file
938
crates/codegen/xai-tty-utils/src/lib.rs
Normal file
|
|
@ -0,0 +1,938 @@
|
|||
//! Lightweight process-spawning utilities for TTY safety.
|
||||
//!
|
||||
//! When a TUI/pager/raw-mode terminal owns the parent process's controlling
|
||||
//! TTY, every child process must be detached — otherwise it (or its
|
||||
//! grandchildren: npm, git, pinentry, ssh-agent …) can open `/dev/tty`
|
||||
//! directly and spew mouse escape codes, capability-probe replies, or
|
||||
//! credential prompts onto the live screen.
|
||||
//!
|
||||
//! This crate provides the minimal surface needed to make subprocess spawning
|
||||
//! safe. It is intentionally tiny so that **every** crate in the workspace
|
||||
//! can depend on it without pulling in heavyweight libraries.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use xai_tty_utils::{detach_command, pager_env};
|
||||
//! use std::process::Stdio;
|
||||
//!
|
||||
//! let mut cmd = tokio::process::Command::new("git");
|
||||
//! cmd.args(["status", "--porcelain"]);
|
||||
//! detach_command(&mut cmd);
|
||||
//! cmd.stdin(Stdio::null());
|
||||
//! cmd.envs(pager_env());
|
||||
//! ```
|
||||
//!
|
||||
//! For `std::process::Command`:
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use xai_tty_utils::{detach_std_command, pager_env};
|
||||
//! use std::process::Stdio;
|
||||
//!
|
||||
//! let mut cmd = std::process::Command::new("git");
|
||||
//! cmd.args(["log", "--oneline"]);
|
||||
//! detach_std_command(&mut cmd);
|
||||
//! cmd.stdin(Stdio::null());
|
||||
//! cmd.envs(pager_env());
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
|
||||
mod process_scope;
|
||||
pub use process_scope::{ProcessScope, global_process_scope};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TTY detach — pre_exec building block
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Detach from the controlling TTY by starting a new session.
|
||||
///
|
||||
/// This prevents spawned subprocesses from opening `/dev/tty` and competing
|
||||
/// with the TUI for terminal input. `Stdio::null()` only redirects fd 0;
|
||||
/// programs like `ssh`, `ssh-add`, and interactive shells (`zsh -i`) bypass
|
||||
/// it by opening `/dev/tty` directly.
|
||||
///
|
||||
/// If `setsid()` fails with `EPERM` (the process is already a process group
|
||||
/// leader), falls back to `setpgid(0, 0)` which still gives process-group
|
||||
/// isolation even though the controlling terminal remains reachable.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// Must only be called inside a `pre_exec` hook (between `fork` and `exec`).
|
||||
/// Both `setsid()` and `setpgid()` are async-signal-safe (POSIX).
|
||||
#[cfg(unix)]
|
||||
pub fn detach_from_tty() -> io::Result<()> {
|
||||
use nix::errno::Errno;
|
||||
use nix::unistd::{Pid, setpgid, setsid};
|
||||
|
||||
match setsid() {
|
||||
Ok(_) => Ok(()),
|
||||
Err(Errno::EPERM) => {
|
||||
setpgid(Pid::from_raw(0), Pid::from_raw(0))
|
||||
.map_err(|e| io::Error::from_raw_os_error(e as i32))?;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(io::Error::from_raw_os_error(e as i32)),
|
||||
}
|
||||
}
|
||||
|
||||
/// No-op on non-Unix platforms.
|
||||
#[cfg(not(unix))]
|
||||
pub fn detach_from_tty() -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tokio::process::Command wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Detach a `tokio::process::Command` from the parent's controlling TTY/console.
|
||||
///
|
||||
/// - Unix: `pre_exec` hook calling `setsid` (EPERM fallback: `setpgid`).
|
||||
/// - Windows: `CREATE_NO_WINDOW`. Do NOT add `DETACHED_PROCESS` — it
|
||||
/// breaks stdio pipe inheritance for grandchildren (`cmd.exe` → `node`).
|
||||
pub fn detach_command(cmd: &mut tokio::process::Command) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// SAFETY: detach_from_tty only calls setsid/setpgid, both POSIX
|
||||
// async-signal-safe. Satisfies the pre_exec contract.
|
||||
unsafe {
|
||||
cmd.pre_exec(detach_from_tty);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows::Win32::System::Threading::CREATE_NO_WINDOW;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// std::process::Command wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Detach a `std::process::Command` from the parent's controlling TTY/console.
|
||||
///
|
||||
/// This is the `std` counterpart of [`detach_command`] (which only works with
|
||||
/// `tokio::process::Command`). Use this when you need to spawn via
|
||||
/// `std::process::Command` — e.g. in synchronous code or `spawn_blocking`.
|
||||
///
|
||||
/// - Unix: `pre_exec` hook calling `setsid` (EPERM fallback: `setpgid`).
|
||||
/// - Windows: `CREATE_NO_WINDOW`.
|
||||
pub fn detach_std_command(cmd: &mut std::process::Command) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
// SAFETY: detach_from_tty only calls setsid/setpgid, both POSIX
|
||||
// async-signal-safe. Satisfies the pre_exec contract.
|
||||
unsafe {
|
||||
cmd.pre_exec(detach_from_tty);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
use windows::Win32::System::Threading::CREATE_NO_WINDOW;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Process group lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Configure a command so the spawned child becomes the leader of a new
|
||||
/// process group.
|
||||
pub fn new_process_group(cmd: &mut tokio::process::Command) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
cmd.process_group(0);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP;
|
||||
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A Unix process-group id validated as safe to pass to `killpg`.
|
||||
///
|
||||
/// `killpg(pgid)` is `kill(-pgid)`, so a degenerate pgid escalates a scoped
|
||||
/// group-kill into a broadcast: `0` signals the *caller's own* group, `1`
|
||||
/// signals init, and the caller's own pgid would SIGKILL this very process and
|
||||
/// its whole tree. [`ProcessGroupId::new`] rejects all three, so holding one is
|
||||
/// a standing guarantee that `killpg` can only ever reach a real, foreign
|
||||
/// group — the highest-blast-radius primitive in process teardown is validated
|
||||
/// once, at enrollment, rather than re-checked at each call site.
|
||||
#[cfg(unix)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ProcessGroupId(u32);
|
||||
|
||||
#[cfg(unix)]
|
||||
impl ProcessGroupId {
|
||||
/// Validate a group-leader pid. Errors for pid `0` (the caller's own
|
||||
/// group), pid `1` (init), or the caller's own process group (signalling it
|
||||
/// would kill this very process). A child spawned into its own group
|
||||
/// (`setpgid`/`setsid`, e.g. via [`new_process_group`] or a `detach_*`
|
||||
/// helper) always has a leader pid `> 1` distinct from the caller's pgid, so
|
||||
/// a well-formed enrollment never trips this — it only catches a child that
|
||||
/// was never grouped, which would otherwise broadcast the kill.
|
||||
pub fn new(pid: u32) -> io::Result<Self> {
|
||||
if pid <= 1 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("refusing degenerate process-group id {pid} (0 = own group, 1 = init)"),
|
||||
));
|
||||
}
|
||||
// killpg_unix casts `pid as i32`; values > i32::MAX wrap to negative,
|
||||
// and killpg with a negative pgid returns EINVAL on Linux/macOS. Reject
|
||||
// here so the invariant is safe-by-construction, not safe-by-OS-quirk.
|
||||
if pid > i32::MAX as u32 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("process-group id {pid} exceeds i32::MAX; cannot be used with killpg"),
|
||||
));
|
||||
}
|
||||
if i64::from(pid) == i64::from(nix::unistd::getpgrp().as_raw()) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("refusing to killpg the caller's own process group ({pid})"),
|
||||
));
|
||||
}
|
||||
Ok(Self(pid))
|
||||
}
|
||||
|
||||
/// The validated raw process-group id.
|
||||
pub fn get(self) -> u32 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Process-tree teardown handle.
|
||||
///
|
||||
/// - Unix: holds the validated group-leader id ([`ProcessGroupId`]); dispatches
|
||||
/// to `killpg(pgid, signal)`.
|
||||
/// - Windows: holds a Job Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`.
|
||||
///
|
||||
/// **Drop semantics differ by platform.** On Windows, drop terminates every
|
||||
/// process in the job. On Unix, drop is a no-op — call [`kill`](Self::kill)
|
||||
/// or [`terminate`](Self::terminate) explicitly.
|
||||
pub struct ProcessGroup {
|
||||
/// `None` until a child is enrolled via [`Self::attach_pid`]; then `Some`
|
||||
/// holds a killpg-safe id. The pid is set once at attach and never
|
||||
/// auto-cleared — PID-reuse safety comes from the *owner* dropping this
|
||||
/// group (its `Arc`) at reap, after which nothing can `kill` it, not from
|
||||
/// this field resetting itself.
|
||||
#[cfg(unix)]
|
||||
leader: Option<ProcessGroupId>,
|
||||
#[cfg(windows)]
|
||||
job: windows::Win32::Foundation::HANDLE,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
unsafe impl Send for ProcessGroup {}
|
||||
#[cfg(windows)]
|
||||
unsafe impl Sync for ProcessGroup {}
|
||||
|
||||
impl ProcessGroup {
|
||||
pub fn new() -> io::Result<Self> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
Ok(Self { leader: None })
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::mem::{size_of, zeroed};
|
||||
use windows::Win32::Foundation::CloseHandle;
|
||||
use windows::Win32::System::JobObjects::{
|
||||
CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
|
||||
SetInformationJobObject,
|
||||
};
|
||||
use windows::core::PCWSTR;
|
||||
|
||||
let job = unsafe { CreateJobObjectW(None, PCWSTR::null()) }
|
||||
.map_err(|e| io::Error::other(format!("CreateJobObjectW: {e}")))?;
|
||||
|
||||
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
|
||||
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
|
||||
let result = unsafe {
|
||||
SetInformationJobObject(
|
||||
job,
|
||||
JobObjectExtendedLimitInformation,
|
||||
(&info as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(),
|
||||
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
)
|
||||
};
|
||||
if let Err(e) = result {
|
||||
let _ = unsafe { CloseHandle(job) };
|
||||
return Err(io::Error::other(format!("SetInformationJobObject: {e}")));
|
||||
}
|
||||
|
||||
Ok(Self { job })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn attach(&mut self, child: &tokio::process::Child) -> io::Result<()> {
|
||||
let pid = child
|
||||
.id()
|
||||
.ok_or_else(|| io::Error::other("ProcessGroup::attach: child has already exited"))?;
|
||||
self.attach_pid(pid)
|
||||
}
|
||||
|
||||
/// Attach a `std::process::Child` (rather than tokio's). The PID is read via
|
||||
/// `Child::id()`, which is valid until the child is reaped with `wait()`.
|
||||
pub fn attach_std(&mut self, child: &std::process::Child) -> io::Result<()> {
|
||||
self.attach_pid(child.id())
|
||||
}
|
||||
|
||||
/// Attach an already-spawned process by raw PID. The process must be (or
|
||||
/// lead) its own group/job — e.g. spawned via [`new_process_group`] (Unix
|
||||
/// `setpgid`) or a `detach_*` helper (Unix `setsid`) — otherwise `kill`
|
||||
/// would signal the wrong group.
|
||||
pub fn attach_pid(&mut self, pid: u32) -> io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
self.leader = Some(ProcessGroupId::new(pid)?);
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use windows::Win32::Foundation::CloseHandle;
|
||||
use windows::Win32::System::JobObjects::AssignProcessToJobObject;
|
||||
use windows::Win32::System::Threading::{
|
||||
OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE,
|
||||
};
|
||||
|
||||
let process_handle =
|
||||
unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, false, pid) }
|
||||
.map_err(|e| io::Error::other(format!("OpenProcess({pid}): {e}")))?;
|
||||
|
||||
let assign_result = unsafe { AssignProcessToJobObject(self.job, process_handle) };
|
||||
let _ = unsafe { CloseHandle(process_handle) };
|
||||
|
||||
assign_result
|
||||
.map_err(|e| io::Error::other(format!("AssignProcessToJobObject({pid}): {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn terminate(&self) -> io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
self.killpg_unix(nix::sys::signal::Signal::SIGTERM)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
self.terminate_job(1)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kill(&self) -> io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
self.killpg_unix(nix::sys::signal::Signal::SIGKILL)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
self.terminate_job(1)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn killpg_unix(&self, signal: nix::sys::signal::Signal) -> io::Result<()> {
|
||||
// `leader` is `None` until a child is enrolled, and a `ProcessGroupId`
|
||||
// is killpg-safe by construction (never 0/1/own-group), so this can only
|
||||
// ever signal a real, foreign group.
|
||||
let Some(leader) = self.leader else {
|
||||
return Ok(());
|
||||
};
|
||||
nix::sys::signal::killpg(nix::unistd::Pid::from_raw(leader.get() as i32), signal)
|
||||
.map_err(|e| io::Error::from_raw_os_error(e as i32))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn terminate_job(&self, exit_code: u32) -> io::Result<()> {
|
||||
use windows::Win32::System::JobObjects::TerminateJobObject;
|
||||
unsafe { TerminateJobObject(self.job, exit_code) }
|
||||
.map_err(|e| io::Error::other(format!("TerminateJobObject: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Drop for ProcessGroup {
|
||||
fn drop(&mut self) {
|
||||
let _ = unsafe { windows::Win32::Foundation::CloseHandle(self.job) };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Environment variable helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Returns environment variables that prevent CLI tools from launching any
|
||||
/// interactive program that would block waiting for user input — pagers,
|
||||
/// editors, credential prompts.
|
||||
pub fn pager_env() -> HashMap<String, String> {
|
||||
HashMap::from([
|
||||
("PAGER".to_string(), passthrough_pager().to_string()),
|
||||
("GIT_PAGER".to_string(), passthrough_pager().to_string()),
|
||||
("GH_PAGER".to_string(), passthrough_pager().to_string()),
|
||||
("MANPAGER".to_string(), passthrough_pager().to_string()),
|
||||
("AWS_PAGER".to_string(), String::new()),
|
||||
("SYSTEMD_PAGER".to_string(), passthrough_pager().to_string()),
|
||||
("GIT_EDITOR".to_string(), noop_cmd().to_string()),
|
||||
("GIT_SEQUENCE_EDITOR".to_string(), noop_cmd().to_string()),
|
||||
("GIT_TERMINAL_PROMPT".to_string(), "0".to_string()),
|
||||
// Empty (== unset for gpg) so gpg-agent/pinentry can't grab the TUI's tty; with setsid detach, signing fails cleanly instead of corrupting the screen.
|
||||
("GPG_TTY".to_string(), String::new()),
|
||||
])
|
||||
}
|
||||
|
||||
/// Environment variables set on every git command to suppress interactive prompts.
|
||||
pub const GIT_AUTH_SUPPRESSION_ENVS: [(&str, &str); 4] = [
|
||||
("GIT_TERMINAL_PROMPT", "0"),
|
||||
("GIT_ASKPASS", ""),
|
||||
("GIT_LFS_SKIP_SMUDGE", "1"),
|
||||
("GIT_SSH_COMMAND", "ssh -o BatchMode=yes"),
|
||||
];
|
||||
|
||||
/// Git command with auth/LFS/SSH prompt suppression and `--no-optional-locks`.
|
||||
///
|
||||
/// Respects `GIT_BIN_PATH` for hermetic git in Bazel test sandboxes.
|
||||
pub fn git_command() -> std::process::Command {
|
||||
let git = match std::env::var("GIT_BIN_PATH") {
|
||||
Ok(p) => {
|
||||
let p = std::path::PathBuf::from(p);
|
||||
if p.is_relative() {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join(&p)
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
} else {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
Err(_) => "git".to_string(),
|
||||
};
|
||||
let mut cmd = std::process::Command::new(&git);
|
||||
detach_std_command(&mut cmd);
|
||||
cmd.stdin(std::process::Stdio::null());
|
||||
cmd.envs(pager_env());
|
||||
for &(key, val) in &GIT_AUTH_SUPPRESSION_ENVS {
|
||||
cmd.env(key, val);
|
||||
}
|
||||
cmd.arg("--no-optional-locks");
|
||||
cmd
|
||||
}
|
||||
|
||||
fn passthrough_pager() -> &'static str {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
"cat"
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
fn noop_cmd() -> &'static str {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
"true"
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
r"C:\Windows\System32\cmd.exe /c exit 0"
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stderr redirection — shield TUI output from C-library noise
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The dup'd stderr fd that writes to the real terminal. Set once by
|
||||
/// [`redirect_native_stderr`]. Stored as [`OwnedFd`](std::os::unix::io::OwnedFd)
|
||||
/// for type safety; the [`OnceLock`](std::sync::OnceLock) keeps it alive for the
|
||||
/// entire process lifetime.
|
||||
#[cfg(unix)]
|
||||
static TUI_STDERR_FD: std::sync::OnceLock<std::os::unix::io::OwnedFd> = std::sync::OnceLock::new();
|
||||
|
||||
/// Redirect native stderr (fd 2) to `/dev/null` so that messages written
|
||||
/// directly to fd 2 by C libraries do not interleave with TUI escape
|
||||
/// sequences.
|
||||
///
|
||||
/// On macOS, `libsystem_malloc` writes `MallocStackLogging` diagnostics
|
||||
/// directly to fd 2 from C code (bypassing Rust's `stderr()` lock) when
|
||||
/// a `fork()`'d child process cannot turn off the parent's stack logging.
|
||||
/// The message originates from `turn_off_stack_logging()` in Apple's
|
||||
/// open-source `libmalloc`:
|
||||
/// <https://opensource.apple.com/source/libmalloc/>
|
||||
///
|
||||
/// After this call, all intentional terminal output should go through
|
||||
/// the `File` obtained from [`dup_tui_stderr`].
|
||||
///
|
||||
/// # Platform
|
||||
///
|
||||
/// Unix-only. On non-Unix platforms this is a no-op.
|
||||
///
|
||||
/// Must be called **very early**, before any threads are spawned, to avoid
|
||||
/// racing with other code writing to fd 2. The TUI's `spawn_writer_thread`
|
||||
/// should be called after this.
|
||||
pub fn redirect_native_stderr() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::io::Write;
|
||||
use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd};
|
||||
|
||||
// SAFETY: dup(2) is safe on fd 2 (stderr), which is always valid
|
||||
// at process start.
|
||||
let duped = unsafe { libc::dup(2) };
|
||||
if duped < 0 {
|
||||
return; // best-effort; don't crash
|
||||
}
|
||||
|
||||
// Store the duped fd (as OwnedFd) before redirecting.
|
||||
// SAFETY: `duped` is a freshly dup'd, valid file descriptor.
|
||||
let owned = unsafe { OwnedFd::from_raw_fd(duped) };
|
||||
let _ = TUI_STDERR_FD.set(owned);
|
||||
|
||||
// Flush any buffered stderr data before redirecting fd 2 to
|
||||
// /dev/null, so nothing is silently lost.
|
||||
let _ = std::io::stderr().flush();
|
||||
|
||||
// Open /dev/null and dup2 it onto fd 2.
|
||||
let devnull = match std::fs::OpenOptions::new().write(true).open("/dev/null") {
|
||||
Ok(f) => f,
|
||||
Err(_) => return,
|
||||
};
|
||||
// SAFETY: devnull.as_raw_fd() is valid; fd 2 is a valid target.
|
||||
unsafe { libc::dup2(devnull.as_raw_fd(), 2) };
|
||||
// devnull File is dropped here; fd 2 now points at /dev/null.
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new owned `File` that writes to the real terminal stderr.
|
||||
///
|
||||
/// This calls `dup(2)` on the saved fd to create an independently-owned
|
||||
/// file descriptor. Each caller gets their own fd that they can wrap in
|
||||
/// a `BufWriter`, pass to a thread, etc. Dropping the returned `File`
|
||||
/// closes only that caller's dup'd copy — the underlying terminal fd
|
||||
/// is never affected.
|
||||
///
|
||||
/// If [`redirect_native_stderr`] was not called, this dups normal fd 2.
|
||||
pub fn dup_tui_stderr() -> io::Result<std::fs::File> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::io::{AsRawFd, FromRawFd};
|
||||
let source_fd = TUI_STDERR_FD.get().map(|fd| fd.as_raw_fd()).unwrap_or(2);
|
||||
// SAFETY: source_fd is either the dup'd stderr (valid for the
|
||||
// process lifetime via OnceLock<OwnedFd>) or 2 (normal stderr).
|
||||
// dup() returns a new independently-owned fd.
|
||||
let new_fd = unsafe { libc::dup(source_fd) };
|
||||
if new_fd < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: new_fd is a freshly dup'd, owned file descriptor.
|
||||
Ok(unsafe { std::fs::File::from_raw_fd(new_fd) })
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// On Windows, `redirect_native_stderr` is a no-op, so fd 2 is
|
||||
// always the real stderr. We use `try_clone()` on a temporarily
|
||||
// created File to get an independently-owned handle via
|
||||
// `DuplicateHandle` — avoiding the `from_raw_handle` footgun
|
||||
// where `File` would take ownership of the process stderr handle
|
||||
// and close it on drop.
|
||||
use std::os::windows::io::{AsRawHandle, FromRawHandle};
|
||||
let stderr_handle = unsafe {
|
||||
windows::Win32::System::Console::GetStdHandle(
|
||||
windows::Win32::System::Console::STD_ERROR_HANDLE,
|
||||
)
|
||||
.map_err(|e| io::Error::other(format!("GetStdHandle: {e}")))?
|
||||
};
|
||||
// Create a temporary File from the raw handle, clone it (which
|
||||
// calls DuplicateHandle internally), then forget the original so
|
||||
// it doesn't close the process stderr handle.
|
||||
let temp = unsafe { std::fs::File::from_raw_handle(stderr_handle.0 as _) };
|
||||
let cloned = temp.try_clone();
|
||||
// Prevent `temp` from closing the process stderr handle.
|
||||
std::mem::forget(temp);
|
||||
cloned
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore fd 2 to point to the real terminal stderr.
|
||||
///
|
||||
/// Call this before exiting so that any final messages (panic output,
|
||||
/// tracing flushes) reach the user's terminal rather than `/dev/null`.
|
||||
pub fn restore_native_stderr() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::io::AsRawFd;
|
||||
if let Some(fd) = TUI_STDERR_FD.get() {
|
||||
// SAFETY: fd is the dup'd stderr (valid for process lifetime
|
||||
// via OnceLock<OwnedFd>). dup2 atomically replaces fd 2 with
|
||||
// a copy of fd.
|
||||
unsafe { libc::dup2(fd.as_raw_fd(), 2) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns `true` when running inside Windows Subsystem for Linux (WSL1/WSL2).
|
||||
/// Cached for process lifetime. Detection: the `WSL_DISTRO_NAME` / `WSL_INTEROP`
|
||||
/// env vars, with a `/proc/sys/kernel/osrelease` `microsoft`/`wsl` substring
|
||||
/// fallback that also catches WSL1 and env-stripped shells.
|
||||
pub fn is_wsl() -> bool {
|
||||
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*CACHE.get_or_init(detect_is_wsl)
|
||||
}
|
||||
|
||||
fn detect_is_wsl() -> bool {
|
||||
if !cfg!(target_os = "linux") {
|
||||
return false;
|
||||
}
|
||||
let env: HashMap<String, String> = std::env::vars().collect();
|
||||
let osrelease = std::fs::read_to_string("/proc/sys/kernel/osrelease").ok();
|
||||
is_wsl_from_inputs(&env, osrelease.as_deref())
|
||||
}
|
||||
|
||||
/// Pure helper so tests can drive the env and `/proc` contents directly.
|
||||
fn is_wsl_from_inputs(env: &HashMap<String, String>, osrelease: Option<&str>) -> bool {
|
||||
if env.contains_key("WSL_DISTRO_NAME") || env.contains_key("WSL_INTEROP") {
|
||||
return true;
|
||||
}
|
||||
osrelease.is_some_and(|s| {
|
||||
let lower = s.to_ascii_lowercase();
|
||||
lower.contains("microsoft") || lower.contains("wsl")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detach_command_does_not_panic() {
|
||||
let mut cmd = tokio::process::Command::new("echo");
|
||||
detach_command(&mut cmd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detach_std_command_does_not_panic() {
|
||||
let mut cmd = std::process::Command::new("echo");
|
||||
detach_std_command(&mut cmd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_process_group_does_not_panic() {
|
||||
let mut cmd = tokio::process::Command::new("echo");
|
||||
new_process_group(&mut cmd);
|
||||
}
|
||||
|
||||
fn wsl_env(pairs: &[(&str, &str)]) -> HashMap<String, String> {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_wsl_detects_wsl_distro_name() {
|
||||
assert!(is_wsl_from_inputs(
|
||||
&wsl_env(&[("WSL_DISTRO_NAME", "Ubuntu")]),
|
||||
None
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_wsl_detects_wsl_interop() {
|
||||
assert!(is_wsl_from_inputs(
|
||||
&wsl_env(&[("WSL_INTEROP", "/run/WSL/8_interop")]),
|
||||
None,
|
||||
));
|
||||
}
|
||||
|
||||
// osrelease fallback also catches env-stripped shells.
|
||||
#[test]
|
||||
fn is_wsl_detects_wsl2_osrelease() {
|
||||
assert!(is_wsl_from_inputs(
|
||||
&wsl_env(&[]),
|
||||
Some("5.15.167.4-microsoft-standard-WSL2\n"),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_wsl_detects_wsl1_osrelease() {
|
||||
assert!(is_wsl_from_inputs(
|
||||
&wsl_env(&[]),
|
||||
Some("4.4.0-19041-Microsoft\n")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_wsl_osrelease_match_is_case_insensitive() {
|
||||
assert!(is_wsl_from_inputs(
|
||||
&wsl_env(&[]),
|
||||
Some("WSLg-experimental\n")
|
||||
));
|
||||
assert!(is_wsl_from_inputs(
|
||||
&wsl_env(&[]),
|
||||
Some("Foo-MICROSOFT-bar\n")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_wsl_rejects_native_linux() {
|
||||
assert!(!is_wsl_from_inputs(
|
||||
&wsl_env(&[]),
|
||||
Some("6.5.0-21-generic\n")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_wsl_rejects_when_osrelease_missing() {
|
||||
assert!(!is_wsl_from_inputs(&wsl_env(&[]), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_wsl_env_var_match_is_exact_key_not_substring() {
|
||||
// A var whose name merely contains "WSL" must not trigger detection.
|
||||
assert!(!is_wsl_from_inputs(
|
||||
&wsl_env(&[("MY_WSLISH_VAR", "1"), ("OTHER", "wsl_in_value")]),
|
||||
Some("6.5.0-21-generic\n"),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pager_env_has_expected_keys() {
|
||||
let env = pager_env();
|
||||
assert!(env.contains_key("PAGER"));
|
||||
assert!(env.contains_key("GIT_PAGER"));
|
||||
assert!(env.contains_key("GH_PAGER"));
|
||||
assert!(env.contains_key("GIT_TERMINAL_PROMPT"));
|
||||
// Present and empty so gpg/pinentry cannot target the TUI tty.
|
||||
assert_eq!(env.get("GPG_TTY"), Some(&String::new()));
|
||||
}
|
||||
|
||||
// ── stderr redirect integration tests ────────────────────────
|
||||
//
|
||||
// The redirect/dup/restore cycle mutates process-global state
|
||||
// (fd 2 and a `OnceLock`), so the full flow runs in a subprocess
|
||||
// to avoid polluting other tests.
|
||||
|
||||
/// `dup_tui_stderr` returns a writable File even without prior redirect.
|
||||
#[test]
|
||||
fn dup_tui_stderr_without_redirect_returns_writable_file() {
|
||||
use std::io::Write;
|
||||
let mut f = dup_tui_stderr().expect("dup_tui_stderr should succeed");
|
||||
f.write_all(b"").expect("zero-length write should succeed");
|
||||
}
|
||||
|
||||
/// The `File` returned by `dup_tui_stderr` can write multi-byte UTF-8
|
||||
/// without truncation or byte-level corruption.
|
||||
///
|
||||
/// On Windows, a `File` wrapping a console handle uses `WriteFile`
|
||||
/// (byte-oriented, code-page-dependent) instead of `WriteConsoleW`
|
||||
/// (UTF-16, code-page-independent). This test verifies the bytes are
|
||||
/// at least accepted without error. Full Unicode correctness on Windows
|
||||
/// additionally requires `SetConsoleOutputCP(65001)` or using
|
||||
/// `std::io::stderr()` (which routes through `WriteConsoleW`).
|
||||
#[test]
|
||||
fn dup_tui_stderr_accepts_multibyte_utf8() {
|
||||
use std::io::Write;
|
||||
let mut f = dup_tui_stderr().expect("dup_tui_stderr should succeed");
|
||||
// Braille (3 bytes each), Powerline icon (3 bytes), emoji (4 bytes).
|
||||
let payload = "⣀⣾⠿⠛\u{e0a0}\u{1F600}";
|
||||
f.write_all(payload.as_bytes())
|
||||
.expect("multi-byte UTF-8 write should succeed");
|
||||
f.flush().expect("flush should succeed");
|
||||
}
|
||||
|
||||
/// Spawn a subprocess that exercises redirect → dup → restore.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stderr_redirect_roundtrip_subprocess() {
|
||||
let status = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.arg("--exact")
|
||||
.arg("tests::stderr_redirect_roundtrip_body")
|
||||
.env("__XAI_STDERR_REDIRECT_SUBPROCESS", "1")
|
||||
.status()
|
||||
.expect("failed to spawn test subprocess");
|
||||
assert!(status.success(), "subprocess integration test failed");
|
||||
}
|
||||
|
||||
/// The actual redirect/dup/restore test body (only runs as subprocess).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stderr_redirect_roundtrip_body() {
|
||||
if std::env::var("__XAI_STDERR_REDIRECT_SUBPROCESS").is_err() {
|
||||
return; // skip when not invoked as subprocess
|
||||
}
|
||||
|
||||
use std::io::Write;
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
// 1. After redirect, fd 2 points at /dev/null.
|
||||
redirect_native_stderr();
|
||||
let fd2_flags = unsafe { libc::fcntl(2, libc::F_GETFD) };
|
||||
assert!(fd2_flags >= 0, "fd 2 should be valid after redirect");
|
||||
|
||||
// Verify fd 2 is /dev/null by checking that writes succeed
|
||||
// but reading the inode shows it's a different file.
|
||||
let mut stat_fd2: libc::stat = unsafe { std::mem::zeroed() };
|
||||
let r = unsafe { libc::fstat(2, &mut stat_fd2) };
|
||||
assert_eq!(r, 0, "fstat(2) should succeed");
|
||||
|
||||
// 2. dup_tui_stderr returns a working fd to the real terminal.
|
||||
let mut tui = dup_tui_stderr().expect("dup_tui_stderr after redirect");
|
||||
assert!(tui.as_raw_fd() > 2, "dup'd fd should be > 2");
|
||||
tui.write_all(b"").expect("writing to dup'd fd should work");
|
||||
|
||||
// 3. A second call returns an independent fd.
|
||||
let tui2 = dup_tui_stderr().expect("second dup_tui_stderr");
|
||||
assert_ne!(
|
||||
tui.as_raw_fd(),
|
||||
tui2.as_raw_fd(),
|
||||
"each call should return a distinct fd"
|
||||
);
|
||||
drop(tui2);
|
||||
// First fd still works after second is dropped.
|
||||
tui.write_all(b"").expect("first fd survives second drop");
|
||||
|
||||
// 4. restore_native_stderr brings fd 2 back.
|
||||
restore_native_stderr();
|
||||
let fd2_after = unsafe { libc::fcntl(2, libc::F_GETFD) };
|
||||
assert!(fd2_after >= 0, "fd 2 should be valid after restore");
|
||||
|
||||
// Verify fd 2 is no longer /dev/null (inode changed).
|
||||
let mut stat_after: libc::stat = unsafe { std::mem::zeroed() };
|
||||
let r = unsafe { libc::fstat(2, &mut stat_after) };
|
||||
assert_eq!(r, 0, "fstat(2) after restore should succeed");
|
||||
assert_ne!(
|
||||
stat_fd2.st_ino, stat_after.st_ino,
|
||||
"fd 2 inode should differ after restore (no longer /dev/null)"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_group_kill_terminates_attached_child() {
|
||||
let mut cmd = if cfg!(windows) {
|
||||
let mut c = tokio::process::Command::new("cmd");
|
||||
c.args(["/C", "ping", "-n", "60", "127.0.0.1"]);
|
||||
c
|
||||
} else {
|
||||
let mut c = tokio::process::Command::new("sleep");
|
||||
c.arg("60");
|
||||
c
|
||||
};
|
||||
new_process_group(&mut cmd);
|
||||
|
||||
let mut group = ProcessGroup::new().expect("create ProcessGroup");
|
||||
#[allow(clippy::disallowed_methods)] // test: exercises ProcessGroup directly
|
||||
let mut child = cmd.spawn().expect("spawn child");
|
||||
group.attach(&child).expect("attach child to group");
|
||||
|
||||
group.kill().expect("kill ProcessGroup");
|
||||
|
||||
let status = tokio::time::timeout(std::time::Duration::from_secs(5), child.wait())
|
||||
.await
|
||||
.expect("child should exit within 5s of group kill")
|
||||
.expect("wait returns ok");
|
||||
assert!(
|
||||
!status.success(),
|
||||
"killed child should not exit successfully"
|
||||
);
|
||||
}
|
||||
|
||||
/// `kill()` must reap the WHOLE process group — including a GRANDCHILD the
|
||||
/// leader forks into the same group — not just the immediate leader. This is
|
||||
/// the `killpg` tree-kill property the LSP / MCP / terminal teardown relies
|
||||
/// on; the pre-fix code signalled only the direct child, orphaning
|
||||
/// grandchildren (e.g. a language server's own subprocesses).
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn process_group_kill_reaps_grandchild_tree() {
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
// Leader = sh; it backgrounds a grandchild in the SAME group (sh does
|
||||
// not `setsid` its children), prints the grandchild PID, then waits.
|
||||
let mut cmd = tokio::process::Command::new("sh");
|
||||
cmd.args(["-c", "sleep 60 & echo $!; wait"]);
|
||||
cmd.stdout(std::process::Stdio::piped());
|
||||
new_process_group(&mut cmd);
|
||||
|
||||
let mut group = ProcessGroup::new().expect("create ProcessGroup");
|
||||
#[allow(clippy::disallowed_methods)] // test: exercises ProcessGroup + grandchild kill
|
||||
let mut child = cmd.spawn().expect("spawn leader");
|
||||
group.attach(&child).expect("attach leader to group");
|
||||
|
||||
// Grandchild PID from the leader's stdout.
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
let mut line = String::new();
|
||||
BufReader::new(stdout)
|
||||
.read_line(&mut line)
|
||||
.await
|
||||
.expect("read grandchild pid");
|
||||
let gc_pid: i32 = line.trim().parse().expect("parse grandchild pid");
|
||||
|
||||
let alive =
|
||||
|pid: i32| nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), None).is_ok();
|
||||
assert!(
|
||||
alive(gc_pid),
|
||||
"grandchild should be running before the kill"
|
||||
);
|
||||
|
||||
group.kill().expect("kill ProcessGroup");
|
||||
|
||||
// Leader exits.
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), child.wait())
|
||||
.await
|
||||
.expect("leader should exit within 5s of group kill")
|
||||
.expect("wait ok");
|
||||
|
||||
// The grandchild (same group) must ALSO be reaped by the killpg — poll
|
||||
// until gone (orphan → reparented to init → reaped → ESRCH).
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
while alive(gc_pid) && std::time::Instant::now() < deadline {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
}
|
||||
assert!(
|
||||
!alive(gc_pid),
|
||||
"killpg must reap the WHOLE group incl. the grandchild (tree-kill), not just the \
|
||||
leader; grandchild pid {gc_pid} still alive after group kill"
|
||||
);
|
||||
}
|
||||
|
||||
/// [`ProcessGroupId`] rejects degenerate pids (0, 1, own group) at
|
||||
/// construction so `killpg` can never broadcast outside a child's group.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn process_group_id_rejects_degenerate_and_own_group() {
|
||||
assert!(
|
||||
ProcessGroupId::new(0).is_err(),
|
||||
"pid 0 = caller's own group — must be refused"
|
||||
);
|
||||
assert!(
|
||||
ProcessGroupId::new(1).is_err(),
|
||||
"pid 1 = init — must be refused"
|
||||
);
|
||||
let own = nix::unistd::getpgrp().as_raw() as u32;
|
||||
assert!(
|
||||
ProcessGroupId::new(own).is_err(),
|
||||
"the caller's own process group ({own}) must be refused"
|
||||
);
|
||||
// Pid above i32::MAX wraps on cast — must be refused.
|
||||
assert!(
|
||||
ProcessGroupId::new(u32::MAX).is_err(),
|
||||
"pid > i32::MAX must be refused (wrapping cast in killpg)"
|
||||
);
|
||||
// A pid that is clearly foreign succeeds.
|
||||
let foreign = if own == 2 { 3 } else { 2 };
|
||||
let id = ProcessGroupId::new(foreign).expect("foreign pgid should be accepted");
|
||||
assert_eq!(id.get(), foreign);
|
||||
}
|
||||
}
|
||||
307
crates/codegen/xai-tty-utils/src/process_scope.rs
Normal file
307
crates/codegen/xai-tty-utils/src/process_scope.rs
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
//! Kill-handle for a session's child-process trees (`Send + Sync`).
|
||||
//!
|
||||
//! If a session's worker thread wedges, its `Drop`s never run and child
|
||||
//! processes leak. A `ProcessScope` lives outside the worker so a supervisor
|
||||
//! can `kill_all()` one session's children without restarting the host.
|
||||
//!
|
||||
//! # Weak-keyed registry (PID-reuse safety)
|
||||
//!
|
||||
//! Each child enrolls as a [`ProcessGroup`]. The scope holds only `Weak`
|
||||
//! references; the spawn-site owner holds the strong `Arc`. On `kill_all`,
|
||||
//! only groups whose owner is still alive (i.e. un-reaped children) upgrade
|
||||
//! successfully — reaped children upgrade to `None` and are skipped. This
|
||||
//! prevents `killpg` from hitting a reused PID.
|
||||
//!
|
||||
//! # Residual
|
||||
//!
|
||||
//! `kill_all` SIGKILLs but doesn't `wait` (would race the live owner).
|
||||
//! A wedged owner's killed leader stays as a zombie until the host exits —
|
||||
//! bounded by wedge events, not sessions.
|
||||
|
||||
use std::io;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock, PoisonError, Weak};
|
||||
|
||||
use crate::{ProcessGroup, new_process_group};
|
||||
|
||||
/// A `Send + Sync` kill-handle for one unit's child-process trees. Cheap to
|
||||
/// clone (shares one inner via `Arc`). See the [module docs](self) for the
|
||||
/// ownership model and PID-reuse safety argument.
|
||||
#[derive(Clone)]
|
||||
pub struct ProcessScope {
|
||||
inner: Arc<ScopeInner>,
|
||||
}
|
||||
|
||||
struct ScopeInner {
|
||||
/// One `Weak` per enrolled child tree. The strong `Arc` lives at the spawn
|
||||
/// site for as long as that child is alive/owned; a dead `Weak` means the
|
||||
/// owner already reaped+dropped it, so killing it is neither needed nor safe.
|
||||
groups: Mutex<Vec<Weak<ProcessGroup>>>,
|
||||
/// Latched once [`kill_all`](ProcessScope::kill_all) has run. Nothing calls
|
||||
/// `kill_all` twice (the supervisor drops its handle after), so a child
|
||||
/// enrolled *after* close — a spawn that won the close/spawn race on a
|
||||
/// wedged actor — would otherwise never be reaped.
|
||||
/// [`register`](ProcessScope::register) checks this under the `groups` lock
|
||||
/// and kills such a child on the spot instead of enrolling a leaked `Weak`.
|
||||
closed: AtomicBool,
|
||||
}
|
||||
|
||||
impl ProcessScope {
|
||||
/// Create an empty scope. Infallible — group/job handles are created lazily
|
||||
/// as children are enrolled.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(ScopeInner {
|
||||
groups: Mutex::new(Vec::new()),
|
||||
closed: AtomicBool::new(false),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure `cmd` so its spawned child becomes the leader of a new process
|
||||
/// group / job. Call this **before** `cmd.spawn()`, then [`enroll`] the
|
||||
/// resulting child (or build the group yourself and [`register`] it).
|
||||
///
|
||||
/// [`enroll`]: Self::enroll
|
||||
/// [`register`]: Self::register
|
||||
pub fn prepare(&self, cmd: &mut tokio::process::Command) {
|
||||
new_process_group(cmd);
|
||||
}
|
||||
|
||||
/// Register an already-built process group so the scope can reap it if its
|
||||
/// owner wedges. The scope keeps only a [`Weak`]; **the caller MUST keep the
|
||||
/// `Arc` alive** for as long as the child is its responsibility. Dropping the
|
||||
/// last `Arc` (clean reap) makes this registration a silent no-op, which is
|
||||
/// what keeps [`kill_all`] PID-reuse-safe.
|
||||
///
|
||||
/// For spawn sites (e.g. the MCP child handle, the bash terminal) that
|
||||
/// already build their own [`ProcessGroup`] and own its lifecycle; sites that
|
||||
/// don't can use [`enroll`] instead.
|
||||
///
|
||||
/// [`kill_all`]: Self::kill_all
|
||||
/// [`enroll`]: Self::enroll
|
||||
pub fn register(&self, group: &Arc<ProcessGroup>) {
|
||||
let mut groups = self.lock();
|
||||
if self.inner.closed.load(Ordering::Relaxed) {
|
||||
// The scope was already reclaimed (`kill_all` ran) and won't run
|
||||
// again, so reap this just-spawned child now rather than enroll a
|
||||
// `Weak` that would leak. Closes the close/spawn race where a
|
||||
// (possibly wedged) actor's spawn lands after teardown. `killpg` is
|
||||
// non-blocking, so killing under the lock is fine and serializes
|
||||
// with a concurrent `kill_all`.
|
||||
let _ = group.kill();
|
||||
return;
|
||||
}
|
||||
groups.retain(|w| w.strong_count() > 0);
|
||||
groups.push(Arc::downgrade(group));
|
||||
}
|
||||
|
||||
/// Build a process group for an already-spawned `child` (which must have been
|
||||
/// configured via [`prepare`]), register a [`Weak`] into this scope, and
|
||||
/// return the owning `Arc` for the caller to hold. Errors only if the child
|
||||
/// already exited (nothing to attach) — not a leak.
|
||||
///
|
||||
/// [`prepare`]: Self::prepare
|
||||
#[must_use = "the returned Arc<ProcessGroup> must be kept alive or the scope cannot reap the child"]
|
||||
pub fn enroll(&self, child: &tokio::process::Child) -> io::Result<Arc<ProcessGroup>> {
|
||||
let mut group = ProcessGroup::new()?;
|
||||
group.attach(child)?;
|
||||
let group = Arc::new(group);
|
||||
self.register(&group);
|
||||
Ok(group)
|
||||
}
|
||||
|
||||
/// Convenience for simple sites: [`prepare`] + spawn + [`enroll`]. Returns
|
||||
/// the child together with the owning `Arc<ProcessGroup>`, which the caller
|
||||
/// must keep alive for the scope to be able to reap the child.
|
||||
///
|
||||
/// [`prepare`]: Self::prepare
|
||||
/// [`enroll`]: Self::enroll
|
||||
#[must_use = "the returned Arc<ProcessGroup> must be kept alive or the scope cannot reap the child"]
|
||||
pub fn spawn(
|
||||
&self,
|
||||
mut cmd: tokio::process::Command,
|
||||
) -> io::Result<(tokio::process::Child, Arc<ProcessGroup>)> {
|
||||
self.prepare(&mut cmd);
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
// ProcessScope::spawn is the enrollment primitive itself.
|
||||
let child = cmd.spawn()?;
|
||||
let group = self.enroll(&child)?;
|
||||
Ok((child, group))
|
||||
}
|
||||
|
||||
/// Idempotently kill every still-owned process tree (`killpg(SIGKILL)` /
|
||||
/// `TerminateJobObject`). Safe to call multiple times and from any thread.
|
||||
/// Groups whose owner already reaped+dropped them upgrade to `None` and are
|
||||
/// skipped — so this never `killpg`s a reused PID.
|
||||
pub fn kill_all(&self) {
|
||||
let mut groups = self.lock();
|
||||
for weak in groups.iter() {
|
||||
if let Some(group) = weak.upgrade() {
|
||||
// A failed kill means the group already exited (ESRCH) — benign,
|
||||
// and there is nothing actionable to log at this primitive layer.
|
||||
// Callers that care about the wedge-reclaim event log it there.
|
||||
let _ = group.kill();
|
||||
}
|
||||
}
|
||||
// Every weak has been handled above; clear the set.
|
||||
groups.clear();
|
||||
// Latch closed under the lock: a concurrent `register` either already
|
||||
// pushed (its group was just killed in the loop) or now sees `closed`
|
||||
// and kills its own child — nothing slips through after teardown.
|
||||
self.inner.closed.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Lock the group set, tolerating a poisoned mutex: the critical sections
|
||||
/// here are panic-free, and a best-effort reaper must still run even if some
|
||||
/// unrelated thread panicked while holding the lock.
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<Weak<ProcessGroup>>> {
|
||||
self.inner
|
||||
.groups
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Number of still-live enrolled groups: weaks whose owning `Arc` has not
|
||||
/// yet been dropped. This is exactly the set `kill_all` would `killpg`, so a
|
||||
/// spawn-site owner that has reaped its child (and dropped its `Arc`) is no
|
||||
/// longer counted — letting callers assert PID-reuse safety.
|
||||
pub fn live_count(&self) -> usize {
|
||||
self.lock().iter().filter(|w| w.strong_count() > 0).count()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProcessScope {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Process-global [`ProcessScope`] for reaping detached child trees at process
|
||||
/// exit. Spawn sites (e.g. the local terminal backend) enroll their children
|
||||
/// here, and the TUI exit paths call [`ProcessScope::kill_all`] on it so a
|
||||
/// `setsid`-detached background command cannot outlive the process that started
|
||||
/// it. `kill_all` latches the scope closed, so call it only when the process is
|
||||
/// genuinely exiting.
|
||||
pub fn global_process_scope() -> &'static ProcessScope {
|
||||
static GLOBAL: OnceLock<ProcessScope> = OnceLock::new();
|
||||
GLOBAL.get_or_init(ProcessScope::new)
|
||||
}
|
||||
|
||||
impl Drop for ScopeInner {
|
||||
fn drop(&mut self) {
|
||||
// RAII backstop: if the last scope handle drops without an explicit
|
||||
// `kill_all`, still reap any group whose owner is alive (a wedged
|
||||
// unit). Dead weaks (clean teardown) upgrade to None and are skipped,
|
||||
// so this stays PID-reuse-safe.
|
||||
let groups = self.groups.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
for weak in groups.iter() {
|
||||
if let Some(group) = weak.upgrade() {
|
||||
let _ = group.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
fn sleeper() -> tokio::process::Command {
|
||||
let mut c = tokio::process::Command::new("sleep");
|
||||
c.arg("1000");
|
||||
c
|
||||
}
|
||||
|
||||
/// `wait()` completing == the process actually died (and is reaped). If the
|
||||
/// kill failed, the `sleep 1000` would run on and `wait()` would time out.
|
||||
async fn died(child: &mut tokio::process::Child) -> bool {
|
||||
tokio::time::timeout(Duration::from_secs(3), child.wait())
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kill_all_reaps_every_enrolled_child() {
|
||||
let scope = ProcessScope::new();
|
||||
// The owner (here, the test) keeps the Arcs alive — as a live spawn site
|
||||
// would for as long as the child is running.
|
||||
let (mut c1, _g1) = scope.spawn(sleeper()).unwrap();
|
||||
let (mut c2, _g2) = scope.spawn(sleeper()).unwrap();
|
||||
assert_eq!(scope.live_count(), 2);
|
||||
|
||||
scope.kill_all();
|
||||
assert!(died(&mut c1).await, "child 1 must die after kill_all");
|
||||
assert!(died(&mut c2).await, "child 2 must die after kill_all");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kill_all_is_idempotent() {
|
||||
let scope = ProcessScope::new();
|
||||
let (mut c, _g) = scope.spawn(sleeper()).unwrap();
|
||||
scope.kill_all();
|
||||
scope.kill_all(); // second call must not panic / error
|
||||
assert!(died(&mut c).await);
|
||||
}
|
||||
|
||||
/// PID-reuse safety: once the owner drops its `Arc` (simulating a clean
|
||||
/// reap), the scope no longer references the group and `kill_all` is a no-op
|
||||
/// for it — it must NOT `killpg` a now-reapable/reused group id.
|
||||
#[tokio::test]
|
||||
async fn kill_all_skips_group_whose_owner_dropped() {
|
||||
let scope = ProcessScope::new();
|
||||
let (mut c, group) = scope.spawn(sleeper()).unwrap();
|
||||
assert_eq!(scope.live_count(), 1);
|
||||
|
||||
// Owner reaps + releases ownership.
|
||||
drop(group);
|
||||
assert_eq!(
|
||||
scope.live_count(),
|
||||
0,
|
||||
"dropping the owner's Arc must make the scope's weak dead"
|
||||
);
|
||||
|
||||
scope.kill_all(); // must be a no-op for the now-unowned group
|
||||
// The child was never killed by the scope; clean it up so the test
|
||||
// doesn't leak a real `sleep` process.
|
||||
let _ = c.start_kill();
|
||||
let _ = c.wait().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drop_reaps_children_while_owner_alive() {
|
||||
let scope = ProcessScope::new();
|
||||
// Owner Arc is held by the test, so the scope's weak is live; dropping
|
||||
// the scope's last handle must SIGKILL the still-owned group.
|
||||
let (mut c, _g) = scope.spawn(sleeper()).unwrap();
|
||||
drop(scope);
|
||||
assert!(
|
||||
died(&mut c).await,
|
||||
"dropping the scope must reap a still-owned enrolled child"
|
||||
);
|
||||
}
|
||||
|
||||
/// Close/spawn race: a child enrolled *after* `kill_all` must be killed on
|
||||
/// the spot, not leaked — `kill_all` won't run again to catch it.
|
||||
#[tokio::test]
|
||||
async fn register_after_kill_all_reaps_immediately() {
|
||||
let scope = ProcessScope::new();
|
||||
scope.kill_all(); // close the scope
|
||||
|
||||
let mut cmd = sleeper();
|
||||
scope.prepare(&mut cmd);
|
||||
#[allow(clippy::disallowed_methods)] // test: exercises enroll() after close
|
||||
let mut child = cmd.spawn().unwrap();
|
||||
let _group = scope.enroll(&child).unwrap(); // register() runs post-close
|
||||
assert_eq!(
|
||||
scope.live_count(),
|
||||
0,
|
||||
"a post-close register must not enroll the group"
|
||||
);
|
||||
assert!(
|
||||
died(&mut child).await,
|
||||
"a child registered after kill_all must be killed immediately"
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue