Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
931 lines
34 KiB
Rust
931 lines
34 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 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 {
|
|
// 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::{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() {
|
|
// 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);
|
|
}
|
|
}
|