Files
Kigi-CLI/crates/codegen/kigi-tty-utils/src/lib.rs
T
雷电芽衣 4512df79e6 fix(tui): terminal escapes ride the writer queue and teardown is bounded
A terminal that stopped draining the pty parked the writer thread inside
its tty write while it held the stderr lock; the next inline stderr write
from the event loop (mouse toggle, notification, kitty clear) deadlocked
the loop, and /quit hung on the unbounded writer join and the teardown
writes.

EscapeWriter queues out-of-band escapes on the frame writer: the mouse
capture toggle, notifications (title, progress, protocol), the auth
cursor, gboom keyboard flags, the wrap clipboard request, and the kitty
clears (queued per agent view and merged into the frame's post-flush,
nested subagent views included). The $EDITOR/$PAGER handoff aborts when
the writer does not drain. WriterThread::join_within and the teardown
run under a 2 s grace; after a stall fd 2 is silenced, the exit hints,
relaunch and update prints are skipped, and the panic hook keeps only its
log report.

Ported from upstream 1.0.19 (grok-build EscapeWriter, WriterJoin,
run_bounded_teardown), extended to the remaining inline writes.

Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
2026-09-13 09:08:09 -04:00

975 lines
36 KiB
Rust

//! 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 kigi_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 kigi_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 mod runtime;
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
/// Bound on waiting for an already-killed child (or its pipe readers) before
/// abandoning it.
///
/// A kill normally makes `child.wait()` resolve in milliseconds, but a child
/// wedged in an uninterruptible kernel syscall (D-state — e.g. a read on a
/// hard NFS mount whose server stopped responding) only observes the signal
/// when that syscall returns, which can be effectively never. Callers that
/// must not block (tool futures, turn loops) wait at most this long, then
/// abandon the corpse to the runtime's orphan reaper.
pub const KILL_REAP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
/// Reap an already-killed child, waiting at most `bound` (usually
/// [`KILL_REAP_TIMEOUT`]).
///
/// Returns the exit status when the child was reaped in time. `None` covers
/// both failure shapes — the bound expired (see [`KILL_REAP_TIMEOUT`]) and
/// `wait()` itself erred (e.g. the child was already reaped elsewhere) — the
/// caller's obligation is identical in either case: the kill signal is
/// already delivered, there is no status to report, and the corpse is left
/// to tokio's orphan reaper. Callers should log the `None` case.
pub async fn reap_killed_bounded(
child: &mut tokio::process::Child,
bound: std::time::Duration,
) -> Option<std::process::ExitStatus> {
match tokio::time::timeout(bound, child.wait()).await {
Ok(res) => res.ok(),
Err(_elapsed) => None,
}
}
/// 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 {
// best-effort; don't crash
return;
}
// 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::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
}
}
/// Point fd 2 at `/dev/null` for the rest of the process: the terminal stopped reading, so any further stderr write would block.
pub fn silence_native_stderr() {
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let Ok(devnull) = std::fs::OpenOptions::new().write(true).open("/dev/null") else {
return;
};
// SAFETY: devnull is a valid descriptor; fd 2 is a valid target.
unsafe { libc::dup2(devnull.as_raw_fd(), 2) };
}
}
/// 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() {
// skip when not invoked as subprocess
return;
}
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");
// test: exercises ProcessGroup directly
#[allow(clippy::disallowed_methods)]
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");
// test: exercises ProcessGroup + grandchild kill
#[allow(clippy::disallowed_methods)]
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);
}
}