Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View 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

View 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);
}
}

View 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"
);
}
}