use std::io::{Read, Write};
use std::os::unix::process::CommandExt as _;
use std::process::{Child, Command, Output, Stdio};
use std::sync::{mpsc, Arc, Mutex, PoisonError};
use std::time::{Duration, Instant};
const RUN_DEADLINE: Duration = Duration::from_secs(60);
const DRAIN_GRACE: Duration = Duration::from_secs(5);
static SPAWN_LOCK: Mutex<()> = Mutex::new(());
fn spawn_serialised(cmd: &mut Command) -> Child {
let _held = SPAWN_LOCK.lock().unwrap_or_else(PoisonError::into_inner);
cmd.spawn().expect("spawn child process")
}
fn output_of(cmd: &mut Command) -> Output {
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
spawn_serialised(cmd)
.wait_with_output()
.expect("collect child output")
}
fn bin() -> std::path::PathBuf {
let mut p = std::env::current_exe().expect("current exe");
p.pop(); p.pop(); p.push("trusty-memory");
p
}
fn run_piped(args: &[&str], data_dir: &std::path::Path) -> (String, String) {
let mut cmd = Command::new(bin());
cmd.args(args)
.env("TRUSTY_DATA_DIR_OVERRIDE", data_dir)
.stdin(Stdio::piped());
trusty_common::parent_death::exit_with_parent(&mut cmd);
run_bounded(cmd, RUN_DEADLINE)
}
fn run_bounded(mut cmd: Command, deadline: Duration) -> (String, String) {
cmd.process_group(0)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = spawn_serialised(&mut cmd);
let group = child.id();
drop(child.stdin.take());
let stdout = Drained::start(child.stdout.take().expect("piped stdout"));
let stderr = Drained::start(child.stderr.take().expect("piped stderr"));
let give_up = Instant::now() + deadline;
while !has_exited_unreaped(group) && Instant::now() < give_up {
std::thread::sleep(Duration::from_millis(50));
}
unsafe { libc::killpg(group as libc::pid_t, libc::SIGKILL) };
let _ = child.wait();
let collect_by = Instant::now() + DRAIN_GRACE;
(stdout.collect(collect_by), stderr.collect(collect_by))
}
fn has_exited_unreaped(pid: u32) -> bool {
let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
let rc = unsafe {
libc::waitid(
libc::P_PID,
pid as libc::id_t,
&mut info,
libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
)
};
rc == 0 && info.si_signo == libc::SIGCHLD
}
struct Drained {
buf: Arc<Mutex<Vec<u8>>>,
eof: mpsc::Receiver<()>,
}
impl Drained {
fn start(mut pipe: impl Read + Send + 'static) -> Self {
let buf = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&buf);
let (tx, eof) = mpsc::channel();
std::thread::spawn(move || {
let mut chunk = [0u8; 8192];
while let Ok(n @ 1..) = pipe.read(&mut chunk) {
sink.lock()
.unwrap_or_else(PoisonError::into_inner)
.extend_from_slice(&chunk[..n]);
}
let _ = tx.send(());
});
Self { buf, eof }
}
fn collect(self, by: Instant) -> String {
let _ = self
.eof
.recv_timeout(by.saturating_duration_since(Instant::now()));
let bytes = self.buf.lock().unwrap_or_else(PoisonError::into_inner);
String::from_utf8_lossy(&bytes).into_owned()
}
}
fn pid_alive(pid: u32) -> bool {
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
}
#[test]
fn run_bounded_returns_and_reaps_a_grandchild_holding_the_pipes() {
let tmp = tempfile::tempdir().expect("tempdir");
let pidfile = tmp.path().join("grandchild.pid");
let mut cmd = Command::new("/bin/sh");
cmd.args(["-c", "sleep 300 & echo $! > \"$1\"; exec sleep 300", "sh"])
.arg(&pidfile)
.stdin(Stdio::null());
let deadline = Duration::from_secs(2);
let bound = deadline + DRAIN_GRACE + Duration::from_secs(10);
let (tx, rx) = mpsc::channel();
let started = Instant::now();
std::thread::spawn(move || {
let _ = tx.send(run_bounded(cmd, deadline));
});
let returned = rx.recv_timeout(bound);
let grandchild: Option<u32> = std::fs::read_to_string(&pidfile)
.ok()
.and_then(|s| s.trim().parse().ok());
if returned.is_err() {
if let Some(pid) = grandchild {
unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
}
panic!(
"run_bounded did not return within {bound:?}: it is blocked on pipes a \
grandchild still holds (#8748)"
);
}
let elapsed = started.elapsed();
let pid = grandchild.expect("the sh child records its grandchild's pid");
let gone_by = Instant::now() + Duration::from_secs(5);
while pid_alive(pid) && Instant::now() < gone_by {
std::thread::sleep(Duration::from_millis(50));
}
let survived = pid_alive(pid);
if survived {
unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
}
assert!(
!survived,
"grandchild {pid} survived run_bounded (returned after {elapsed:?})"
);
}
#[test]
fn bare_serve_notice_absent_when_stdin_is_piped() {
let tmp = tempfile::tempdir().expect("tempdir");
let (stdout, stderr) = run_piped(&["serve"], tmp.path());
assert!(
!stderr.contains("waiting on stdin"),
"no notice for a piped (MCP client) stdin; stderr was: {stderr}"
);
assert!(
!stdout.contains("waiting on stdin"),
"the notice must NEVER reach stdout — it is the JSON-RPC channel"
);
}
#[test]
fn bare_serve_keeps_stdout_clean() {
let tmp = tempfile::tempdir().expect("tempdir");
let (stdout, _stderr) = run_piped(&["serve"], tmp.path());
for line in stdout.lines().filter(|l| !l.trim().is_empty()) {
assert!(
serde_json::from_str::<serde_json::Value>(line).is_ok(),
"stdout must carry only JSON-RPC; found non-JSON line: {line}"
);
}
}
#[test]
fn bare_serve_and_explicit_stdio_behave_alike() {
let tmp_a = tempfile::tempdir().expect("tempdir");
let tmp_b = tempfile::tempdir().expect("tempdir");
let (out_bare, _) = run_piped(&["serve"], tmp_a.path());
let (out_flag, _) = run_piped(&["serve", "--stdio"], tmp_b.path());
let json_lines = |s: &str| {
s.lines()
.filter(|l| !l.trim().is_empty())
.filter(|l| serde_json::from_str::<serde_json::Value>(l).is_ok())
.count()
};
assert_eq!(
json_lines(&out_bare),
json_lines(&out_flag),
"bare `serve` and `serve --stdio` must produce the same stdout shape"
);
}
#[test]
fn unknown_flag_is_still_rejected() {
let out = output_of(Command::new(bin()).args(["serve", "--definitely-not-a-flag"]));
assert!(!out.status.success(), "unknown flag must exit nonzero");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("unexpected argument") || stderr.contains("error"),
"expected a usage error, got: {stderr}"
);
}
#[test]
fn conflicting_transport_flags_still_rejected() {
for args in [
["serve", "--http", "--stdio"],
["serve", "--foreground", "--stdio"],
] {
let out = output_of(Command::new(bin()).args(args));
assert!(
!out.status.success(),
"{args:?} must be rejected as conflicting"
);
}
}
#[test]
fn help_documents_the_new_serve_semantics() {
let out = output_of(Command::new(bin()).args(["serve", "--help"]));
let help = String::from_utf8_lossy(&out.stdout);
assert!(
help.contains("stdio"),
"serve --help must describe the stdio default; got: {help}"
);
assert!(
help.contains("start"),
"serve --help must point at `start` for the daemon; got: {help}"
);
}
#[cfg(unix)]
#[test]
fn bare_serve_notice_present_when_stdin_is_a_tty() {
use std::os::unix::io::FromRawFd;
let tmp = tempfile::tempdir().expect("tempdir");
let mut master: libc::c_int = 0;
let mut slave: libc::c_int = 0;
let rc = {
let _held = SPAWN_LOCK.lock().unwrap_or_else(PoisonError::into_inner);
let rc = unsafe {
libc::openpty(
&mut master,
&mut slave,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
if rc == 0 {
unsafe {
libc::fcntl(master, libc::F_SETFD, libc::FD_CLOEXEC);
libc::fcntl(slave, libc::F_SETFD, libc::FD_CLOEXEC);
}
}
rc
};
assert_eq!(rc, 0, "openpty must succeed");
let child_stdin = unsafe { Stdio::from_raw_fd(slave) };
let mut cmd = Command::new(bin());
cmd.arg("serve")
.env("TRUSTY_DATA_DIR_OVERRIDE", tmp.path())
.stdin(child_stdin);
trusty_common::parent_death::exit_with_parent(&mut cmd);
let mut master_file = unsafe { std::fs::File::from_raw_fd(master) };
let ctrl_d = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(300));
let _ = master_file.write_all(&[0x04]);
let _ = master_file.flush();
master_file
});
let (stdout, stderr) = run_bounded(cmd, RUN_DEADLINE);
drop(ctrl_d.join());
assert!(
stderr.contains("waiting on stdin") && stderr.contains("start"),
"a human at a terminal must be told serve is stdio and start is the \
daemon verb; stderr was: {stderr}"
);
assert!(
!stdout.contains("waiting on stdin"),
"the notice must never reach stdout; stdout was: {stdout}"
);
}