pub const DISABLE_ENV: &str = "TM_DISABLE_SPAWN_DISCLAIM";
pub fn disclaimed_output(program: &str, args: &[String]) -> std::io::Result<std::process::Output> {
#[cfg(target_os = "macos")]
{
if std::env::var_os(DISABLE_ENV).is_none() {
return macos::spawn_capture_disclaimed(program, args);
}
}
std::process::Command::new(program).args(args).output()
}
pub fn disclaimed_status(
cmd: &mut std::process::Command,
) -> std::io::Result<std::process::ExitStatus> {
#[cfg(target_os = "macos")]
{
if std::env::var_os(DISABLE_ENV).is_none() {
return macos::spawn_status_inherit_disclaimed(cmd);
}
}
cmd.status()
}
pub fn disclaimed_spawn_detached(program: &str, args: &[&str]) -> std::io::Result<()> {
#[cfg(target_os = "macos")]
{
if std::env::var_os(DISABLE_ENV).is_none() {
let mut cmd = std::process::Command::new(program);
cmd.args(args);
return macos::spawn_detached_disclaimed(&mut cmd);
}
}
std::process::Command::new(program)
.args(args)
.spawn()
.map(|_| ())
}
mod stderr_piped;
pub use stderr_piped::{StderrPipedSpawn, disclaimed_stderr_piped_spawn};
mod stdout_piped;
pub use stdout_piped::{StdoutPipedSpawn, disclaimed_stdout_piped_spawn};
pub type DynChildStdin = Box<dyn tokio::io::AsyncWrite + Unpin + Send>;
pub type DynChildStdout = Box<dyn tokio::io::AsyncRead + Unpin + Send>;
pub enum ChildHandle {
Native(tokio::process::Child),
#[cfg(target_os = "macos")]
Disclaimed {
pid: libc::pid_t,
reaper: Option<tokio::task::JoinHandle<std::io::Result<std::process::ExitStatus>>>,
_stderr: std::os::fd::OwnedFd,
},
}
impl ChildHandle {
pub fn start_kill(&mut self) -> std::io::Result<()> {
match self {
ChildHandle::Native(child) => child.start_kill(),
#[cfg(target_os = "macos")]
ChildHandle::Disclaimed { pid, reaper, .. } => {
if reaper.is_none() {
return Ok(());
}
if unsafe { libc::kill(*pid, libc::SIGKILL) } == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
}
}
pub async fn wait(&mut self) -> std::io::Result<std::process::ExitStatus> {
match self {
ChildHandle::Native(child) => child.wait().await,
#[cfg(target_os = "macos")]
ChildHandle::Disclaimed { reaper, .. } => {
let handle = reaper
.take()
.expect("ChildHandle::wait called more than once");
match handle.await {
Ok(result) => result,
Err(e) => Err(std::io::Error::other(e)),
}
}
}
}
}
pub struct PipedSpawn {
pub handle: ChildHandle,
pub stdin: DynChildStdin,
pub stdout: DynChildStdout,
}
pub fn disclaimed_piped_spawn(cmd: std::process::Command) -> std::io::Result<PipedSpawn> {
#[cfg(target_os = "macos")]
{
if std::env::var_os(DISABLE_ENV).is_none() {
return macos::spawn_piped_disclaimed(cmd);
}
}
spawn_piped_native(cmd)
}
fn spawn_piped_native(cmd: std::process::Command) -> std::io::Result<PipedSpawn> {
let mut child = tokio::process::Command::from(cmd).spawn()?;
let stdin = child
.stdin
.take()
.ok_or_else(|| std::io::Error::other("child stdin pipe was not opened"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| std::io::Error::other("child stdout pipe was not opened"))?;
Ok(PipedSpawn {
handle: ChildHandle::Native(child),
stdin: Box::new(stdin),
stdout: Box::new(stdout),
})
}
mod pane;
pub use pane::{PANE_DISCLAIM_SUBCOMMAND, disclaim_pane_command};
pub fn disclaim_spi_available() -> bool {
#[cfg(target_os = "macos")]
{
macos::resolve_disclaim_fn().is_some()
}
#[cfg(not(target_os = "macos"))]
{
false
}
}
pub fn disclaim_disabled_by_env() -> bool {
std::env::var_os(DISABLE_ENV).is_some()
}
#[cfg(target_os = "macos")]
mod macos;
#[cfg(all(test, target_os = "macos"))]
mod tests {
use super::*;
#[test]
#[serial_test::serial]
fn disclaimed_output_captures_stdout() {
let out = disclaimed_output("/bin/echo", &["hello world".to_string()]).unwrap();
assert!(out.status.success());
assert_eq!(String::from_utf8_lossy(&out.stdout), "hello world\n");
assert!(out.stderr.is_empty());
}
#[test]
#[serial_test::serial]
fn disclaimed_output_captures_stderr_and_nonzero_exit() {
let out = disclaimed_output(
"/bin/sh",
&["-c".to_string(), "echo boom >&2; exit 3".to_string()],
)
.unwrap();
assert!(!out.status.success());
assert_eq!(out.status.code(), Some(3));
assert_eq!(String::from_utf8_lossy(&out.stderr), "boom\n");
assert!(out.stdout.is_empty());
}
#[test]
#[serial_test::serial]
fn disclaimed_output_saturates_stdout_and_stderr_without_deadlock() {
let out = disclaimed_output(
"/bin/sh",
&[
"-c".to_string(),
"head -c 2097152 /dev/zero | tr '\\0' 'x' & \
head -c 2097152 /dev/zero | tr '\\0' 'y' >&2; \
wait"
.to_string(),
],
)
.unwrap();
assert!(out.status.success());
assert_eq!(out.stdout.len(), 2_097_152);
assert!(out.stdout.iter().all(|&b| b == b'x'));
assert_eq!(out.stderr.len(), 2_097_152);
assert!(out.stderr.iter().all(|&b| b == b'y'));
}
#[test]
#[serial_test::serial]
fn disclaimed_output_reports_spawn_error_for_missing_binary() {
let err = disclaimed_output("/nonexistent/definitely-not-a-real-binary-2819", &[])
.expect_err("spawning a missing binary must error, not hang or panic");
assert!(matches!(
err.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::Other
));
}
#[test]
#[serial_test::serial]
fn disable_env_forces_plain_path_still_captures() {
unsafe { std::env::set_var(DISABLE_ENV, "1") };
let out = disclaimed_output("/bin/echo", &["valve".to_string()]).unwrap();
unsafe { std::env::remove_var(DISABLE_ENV) };
assert!(out.status.success());
assert_eq!(String::from_utf8_lossy(&out.stdout), "valve\n");
}
#[test]
#[serial_test::serial]
fn disclaimed_status_inherits_and_reports_exit_code() {
let mut cmd = std::process::Command::new("/bin/sh");
cmd.args(["-c", "exit 7"]);
let status = disclaimed_status(&mut cmd).unwrap();
assert_eq!(status.code(), Some(7));
}
#[test]
#[serial_test::serial]
fn disclaimed_status_applies_cwd_and_env_override() {
let tmp = tempfile::TempDir::new().unwrap();
let tmp_path = std::fs::canonicalize(tmp.path()).unwrap();
let mut cmd = std::process::Command::new("/bin/sh");
cmd.current_dir(&tmp_path);
cmd.env("SPAWN_DISCLAIM_TEST_VAR_2997", "hello-2997");
cmd.args([
"-c",
"printf '%s' \"$SPAWN_DISCLAIM_TEST_VAR_2997\" > out.txt && pwd > cwd.txt",
]);
let status = disclaimed_status(&mut cmd).unwrap();
assert!(status.success());
let out_contents = std::fs::read_to_string(tmp_path.join("out.txt")).unwrap();
assert_eq!(
out_contents, "hello-2997",
"expected the Command's env override to reach the child"
);
let cwd_contents = std::fs::read_to_string(tmp_path.join("cwd.txt")).unwrap();
assert_eq!(
std::fs::canonicalize(cwd_contents.trim()).unwrap(),
tmp_path,
"expected the Command's current_dir to reach the child via chdir_np"
);
}
#[test]
#[serial_test::serial]
fn disclaimed_status_reports_spawn_error_for_missing_binary() {
let mut cmd = std::process::Command::new("/nonexistent/definitely-not-a-real-binary-2997");
let err = disclaimed_status(&mut cmd)
.expect_err("spawning a missing binary must error, not hang or panic");
assert!(matches!(
err.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::Other
));
}
#[test]
#[serial_test::serial]
fn disclaimed_status_disable_env_forces_plain_path() {
unsafe { std::env::set_var(DISABLE_ENV, "1") };
let mut cmd = std::process::Command::new("/bin/sh");
cmd.args(["-c", "exit 3"]);
let status = disclaimed_status(&mut cmd).unwrap();
unsafe { std::env::remove_var(DISABLE_ENV) };
assert_eq!(status.code(), Some(3));
}
#[tokio::test]
#[serial_test::serial]
async fn disclaimed_second_kill_after_wait_is_noop() {
let mut cmd = std::process::Command::new("/bin/sleep");
cmd.arg("30");
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut spawned = macos::spawn_piped_disclaimed(cmd).unwrap();
spawned.handle.start_kill().unwrap();
spawned.handle.wait().await.unwrap();
match &spawned.handle {
ChildHandle::Disclaimed { reaper, .. } => {
assert!(reaper.is_none(), "wait() must consume the reaper handle");
}
ChildHandle::Native(_) => {
unreachable!("macos::spawn_piped_disclaimed always returns Disclaimed")
}
}
assert!(spawned.handle.start_kill().is_ok());
}
#[test]
#[serial_test::serial]
fn disclaimed_spawn_detached_returns_ok_for_true() {
assert!(disclaimed_spawn_detached("/usr/bin/true", &[]).is_ok());
}
#[test]
#[serial_test::serial]
fn disclaimed_spawn_detached_reports_spawn_error_for_missing_binary() {
let err = disclaimed_spawn_detached("/nonexistent/definitely-not-a-real-binary-3126", &[])
.expect_err("spawning a missing binary must error, not hang or panic");
assert!(matches!(
err.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::Other
));
}
#[test]
#[serial_test::serial]
fn disclaimed_spawn_detached_disable_env_forces_plain_path() {
unsafe { std::env::set_var(DISABLE_ENV, "1") };
let result = disclaimed_spawn_detached("/usr/bin/true", &[]);
unsafe { std::env::remove_var(DISABLE_ENV) };
assert!(result.is_ok());
}
}
#[cfg(test)]
mod piped_native_tests {
use super::*;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
#[tokio::test]
#[serial_test::serial]
async fn disclaimed_piped_spawn_native_path_round_trips() {
if cfg!(target_os = "windows") {
return; }
unsafe { std::env::set_var(DISABLE_ENV, "1") };
let mut cmd = std::process::Command::new("/bin/cat");
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut spawned = disclaimed_piped_spawn(cmd).unwrap();
unsafe { std::env::remove_var(DISABLE_ENV) };
spawned.stdin.write_all(b"native path\n").await.unwrap();
drop(spawned.stdin);
let mut reader = BufReader::new(spawned.stdout);
let mut line = String::new();
reader.read_line(&mut line).await.unwrap();
assert_eq!(line, "native path\n");
let status = spawned.handle.wait().await.unwrap();
assert!(status.success());
}
}