use std::collections::BTreeMap;
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::mpsc;
const MAX_FRAME_BYTES: u64 = 64 * 1024;
pub const FRAME_CHANNEL_DEPTH: usize = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProcessStream {
Stdout,
Stderr,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProcessFrame {
Output {
seq: u64,
stream: ProcessStream,
data: Vec<u8>,
},
Exit {
code: i32,
truncated: bool,
},
Failed { code: &'static str, message: String },
}
const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
pub fn spawn(program: &str, arguments: &[String]) -> std::io::Result<Command> {
let mut command = Command::new(program);
command
.args(arguments)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
command.process_group(0);
Ok(command)
}
async fn kill_all(child: &mut Child) {
#[cfg(unix)]
if let Some(pid) = child.id() {
unsafe {
libc::kill(-(pid as i32), libc::SIGKILL);
}
}
let _ = child.kill().await;
}
pub fn spawn_sandboxed(
program: &str,
arguments: &[String],
environment: &BTreeMap<String, String>,
) -> std::io::Result<Command> {
let mut command = spawn(program, arguments)?;
command
.env_clear()
.env("PATH", DEFAULT_PATH)
.envs(environment);
Ok(command)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Framing {
Lines,
Chunks,
}
pub async fn stream(
child: Child,
timeout: Duration,
output_cap: usize,
frames: mpsc::Sender<ProcessFrame>,
) {
stream_framed(
child,
timeout,
output_cap,
Framing::Lines,
frames,
None::<fn()>,
)
.await
}
pub async fn stream_framed(
mut child: Child,
timeout: Duration,
output_cap: usize,
framing: Framing,
frames: mpsc::Sender<ProcessFrame>,
exited: Option<impl FnOnce() + Send>,
) {
let mut exited = exited;
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let seq = AtomicU64::new(0);
let truncated = AtomicBool::new(false);
let read_error: Mutex<Option<String>> = Mutex::new(None);
let sink = Sink {
seq: &seq,
truncated: &truncated,
read_error: &read_error,
frames: &frames,
};
let pump = async {
let (stdout_connected, stderr_connected) = tokio::join!(
drain(stdout, ProcessStream::Stdout, framing, output_cap, &sink),
drain(stderr, ProcessStream::Stderr, framing, output_cap, &sink),
);
stdout_connected && stderr_connected
};
let mut connected = true;
let outcome = tokio::time::timeout(timeout, async {
tokio::select! {
biased;
() = frames.closed() => {
connected = false;
Ok(std::process::ExitStatus::default())
}
result = async {
connected = pump.await;
if exited.is_some() {
exited_unreaped(&child).await;
if let Some(exited) = exited.take() {
exited();
}
}
child.wait().await
} => result,
}
})
.await;
if !connected {
if let Some(exited) = exited.take() {
exited();
}
kill_all(&mut child).await;
return;
}
let truncated = truncated.load(Ordering::Relaxed);
let read_error = read_error.into_inner().expect("no panic holds this lock");
let terminal = match outcome {
Ok(Ok(_)) if read_error.is_some() => ProcessFrame::Failed {
code: "outputReadFailed",
message: read_error.expect("checked"),
},
Ok(Ok(status)) => ProcessFrame::Exit {
code: status.code().unwrap_or(-1),
truncated,
},
Ok(Err(error)) => ProcessFrame::Failed {
code: "waitFailed",
message: error.to_string(),
},
Err(_) => {
if let Some(exited) = exited.take() {
exited();
}
kill_all(&mut child).await;
ProcessFrame::Failed {
code: "timeoutExceeded",
message: format!("exceeded its {}ms timeout", timeout.as_millis()),
}
}
};
if let Some(exited) = exited.take() {
exited();
}
let _ = frames.send(terminal).await;
}
async fn exited_unreaped(child: &Child) {
#[cfg(unix)]
if let Some(pid) = child.id() {
#[cfg(target_os = "linux")]
let target = {
let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid as libc::pid_t, 0) };
if fd >= 0 {
let fd = unsafe {
<std::os::fd::OwnedFd as std::os::fd::FromRawFd>::from_raw_fd(fd as i32)
};
WaitTarget::PidFd(fd)
} else {
WaitTarget::Pid(pid)
}
};
#[cfg(not(target_os = "linux"))]
let target = WaitTarget::Pid(pid);
let _ = tokio::task::spawn_blocking(move || loop {
let (kind, id) = match &target {
#[cfg(target_os = "linux")]
WaitTarget::PidFd(fd) => (
libc::P_PIDFD,
std::os::fd::AsRawFd::as_raw_fd(fd) as libc::id_t,
),
WaitTarget::Pid(pid) => (libc::P_PID, *pid as libc::id_t),
};
let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
let result =
unsafe { libc::waitid(kind, id, &mut info, libc::WEXITED | libc::WNOWAIT) };
if result == 0
|| std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
{
return;
}
})
.await;
}
#[cfg(not(unix))]
let _ = child;
}
#[cfg(unix)]
enum WaitTarget {
#[cfg(target_os = "linux")]
PidFd(std::os::fd::OwnedFd),
Pid(u32),
}
struct Sink<'a> {
seq: &'a AtomicU64,
truncated: &'a AtomicBool,
read_error: &'a Mutex<Option<String>>,
frames: &'a mpsc::Sender<ProcessFrame>,
}
pub async fn run(child: Child, timeout: Duration, output_cap: usize) -> Vec<ProcessFrame> {
let (sender, mut receiver) = mpsc::channel(FRAME_CHANNEL_DEPTH);
let produce = stream(child, timeout, output_cap, sender);
let consume = async {
let mut frames = Vec::new();
while let Some(frame) = receiver.recv().await {
frames.push(frame);
}
frames
};
let (_, frames) = tokio::join!(produce, consume);
frames
}
async fn drain<R>(
stream: Option<R>,
which: ProcessStream,
framing: Framing,
output_cap: usize,
sink: &Sink<'_>,
) -> bool
where
R: tokio::io::AsyncRead + Unpin,
{
let Sink {
seq,
truncated,
read_error,
frames,
} = sink;
let Some(stream) = stream else {
return true;
};
let mut reader = BufReader::new(stream);
let mut kept = 0usize;
loop {
let mut line = Vec::new();
let mut bounded = (&mut reader).take(MAX_FRAME_BYTES);
let read = match framing {
Framing::Lines => bounded.read_until(b'\n', &mut line).await,
Framing::Chunks => {
line.resize(MAX_FRAME_BYTES as usize, 0);
let read = bounded.read(&mut line).await;
line.truncate(*read.as_ref().unwrap_or(&0));
read
}
};
match read {
Ok(0) => return true,
Err(error) => {
read_error
.lock()
.expect("no panic holds this lock")
.get_or_insert_with(|| error.to_string());
return true;
}
Ok(read) => {
let number = seq.fetch_add(1, Ordering::Relaxed);
if kept + read > output_cap {
truncated.store(true, Ordering::Relaxed);
continue;
}
kept += read;
if frames
.send(ProcessFrame::Output {
seq: number,
stream: which,
data: line,
})
.await
.is_err()
{
return false;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn child(command: &[&str]) -> Child {
let arguments: Vec<String> = command[1..].iter().map(|s| s.to_string()).collect();
spawn(command[0], &arguments)
.expect("command builds")
.spawn()
.expect("command spawns")
}
#[tokio::test]
async fn a_silent_command_is_killed_when_the_caller_stops_listening() {
let (sender, receiver) = mpsc::channel(4);
let running = tokio::spawn(stream(
child(&["/bin/sh", "-c", "sleep 60"]),
Duration::from_secs(60),
1024,
sender,
));
drop(receiver);
tokio::time::timeout(Duration::from_secs(10), running)
.await
.expect("dropping the receiver stops the command rather than waiting out its deadline")
.expect("the task does not panic");
}
fn terminal(frames: &[ProcessFrame]) -> &ProcessFrame {
frames.last().expect("there is always a terminal frame")
}
fn stdout_of(frames: &[ProcessFrame]) -> String {
let mut collected = Vec::new();
for frame in frames {
if let ProcessFrame::Output {
stream: ProcessStream::Stdout,
data,
..
} = frame
{
collected.extend_from_slice(data);
}
}
String::from_utf8_lossy(&collected).into_owned()
}
#[tokio::test]
#[cfg(unix)]
async fn a_deadline_kills_what_the_command_forked() {
let marker = std::env::temp_dir().join(format!("alien-forked-{}", std::process::id()));
let _ = std::fs::remove_file(&marker);
let script = format!(
"(echo x >> {m}; while true; do echo x >> {m} ; sleep 0.05; done) >/dev/null 2>&1 &\nsleep 30",
m = marker.display()
);
let command = spawn("/bin/sh", &["-c".to_string(), script])
.expect("command builds")
.spawn()
.expect("command spawns");
let frames = run(command, Duration::from_millis(1500), 1 << 20).await;
assert!(
matches!(
terminal(&frames),
ProcessFrame::Failed {
code: "timeoutExceeded",
..
}
),
"the command must hit its deadline: {frames:?}"
);
tokio::time::sleep(Duration::from_millis(300)).await;
let after_kill = std::fs::metadata(&marker).map(|m| m.len());
tokio::time::sleep(Duration::from_millis(300)).await;
let later = std::fs::metadata(&marker).map(|m| m.len());
let _ = std::fs::remove_file(&marker);
let after_kill = after_kill.expect("the forked process must have written before the kill");
let later = later.expect("the marker must still exist");
assert!(
after_kill > 0,
"the forked process wrote nothing, so this test proves nothing"
);
assert_eq!(
after_kill, later,
"a process the command forked outlived the deadline and is still writing"
);
}
#[tokio::test]
async fn a_sandboxed_command_does_not_inherit_the_ambient_environment() {
std::env::set_var("ALIEN_SANDBOX_LEAK_PROBE", "leaked");
let environment = BTreeMap::from([("PASSED_IN".to_string(), "yes".to_string())]);
let command = spawn_sandboxed(
"/bin/sh",
&[
"-c".to_string(),
"echo \"ambient=${ALIEN_SANDBOX_LEAK_PROBE:-absent} passed=${PASSED_IN:-absent}\""
.to_string(),
],
&environment,
)
.expect("command builds")
.spawn()
.expect("command spawns");
let frames = run(command, Duration::from_secs(10), 1 << 20).await;
assert!(
matches!(terminal(&frames), ProcessFrame::Exit { code: 0, .. }),
"the probe must actually run: {frames:?}"
);
assert_eq!(
stdout_of(&frames).trim(),
"ambient=absent passed=yes",
"the ambient environment must not cross into a caller's command"
);
}
#[tokio::test]
async fn output_is_followed_by_exactly_one_terminal_frame() {
let frames = run(
child(&["/bin/echo", "hello"]),
Duration::from_secs(10),
1 << 20,
)
.await;
assert!(matches!(
terminal(&frames),
ProcessFrame::Exit { code: 0, .. }
));
assert_eq!(
frames
.iter()
.filter(|frame| matches!(
frame,
ProcessFrame::Exit { .. } | ProcessFrame::Failed { .. }
))
.count(),
1
);
}
#[tokio::test]
async fn both_streams_share_one_monotonic_sequence() {
let frames = run(
child(&["/bin/sh", "-c", "echo out; echo err 1>&2; echo out2"]),
Duration::from_secs(10),
1 << 20,
)
.await;
let sequence: Vec<u64> = frames
.iter()
.filter_map(|frame| match frame {
ProcessFrame::Output { seq, .. } => Some(*seq),
_ => None,
})
.collect();
let mut sorted = sequence.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), sequence.len(), "no sequence number is reused");
assert!(
frames.iter().any(|frame| matches!(
frame,
ProcessFrame::Output {
stream: ProcessStream::Stderr,
..
}
)),
"stderr must be framed, not dropped"
);
}
#[tokio::test]
async fn a_deadline_ends_a_command_that_would_not() {
let frames = run(
child(&["/bin/sh", "-c", "sleep 30"]),
Duration::from_millis(200),
1 << 20,
)
.await;
assert!(matches!(
terminal(&frames),
ProcessFrame::Failed {
code: "timeoutExceeded",
..
}
));
}
#[tokio::test]
async fn output_past_the_cap_is_truncated_and_the_terminal_frame_says_so() {
let frames = run(
child(&[
"/bin/sh",
"-c",
"for i in 1 2 3 4 5 6 7 8 9 10; do echo aaaaaaaaaa; done",
]),
Duration::from_secs(10),
8,
)
.await;
assert!(matches!(
terminal(&frames),
ProcessFrame::Exit {
code: 0,
truncated: true
}
));
}
#[tokio::test]
async fn a_departed_caller_kills_the_process() {
let (sender, receiver) = mpsc::channel(FRAME_CHANNEL_DEPTH);
let produce = tokio::spawn(stream(
child(&["/bin/sh", "-c", "while true; do echo aaaaaaaa; done"]),
Duration::from_secs(30),
1 << 30,
sender,
));
tokio::time::sleep(Duration::from_millis(250)).await;
assert!(
!produce.is_finished(),
"an endless command must still be running, not drained into memory"
);
drop(receiver);
tokio::time::timeout(Duration::from_secs(10), produce)
.await
.expect("a command whose caller left must be killed, not left running")
.expect("the producing task must not panic");
}
#[tokio::test]
async fn frames_arrive_before_the_process_exits() {
let (sender, mut receiver) = mpsc::channel(FRAME_CHANNEL_DEPTH);
let produce = tokio::spawn(stream(
child(&["/bin/sh", "-c", "echo first; sleep 5; echo second"]),
Duration::from_secs(30),
1 << 20,
sender,
));
let first = tokio::time::timeout(Duration::from_secs(2), receiver.recv())
.await
.expect("the first frame must arrive long before the process exits")
.expect("a frame");
assert!(matches!(first, ProcessFrame::Output { .. }));
produce.abort();
}
fn interactive_child(script: &str) -> Child {
spawn("/bin/sh", &["-c".to_string(), script.to_string()])
.expect("command builds")
.stdin(Stdio::piped())
.spawn()
.expect("command spawns")
}
#[tokio::test]
async fn a_prompt_without_a_newline_arrives_while_the_program_waits() {
let mut child = interactive_child("printf '>>> '; read line; echo \"got $line\"");
let mut stdin = child.stdin.take().expect("stdin is piped");
let (sender, mut receiver) = mpsc::channel(FRAME_CHANNEL_DEPTH);
let produce = tokio::spawn(stream_framed(
child,
Duration::from_secs(30),
1 << 20,
Framing::Chunks,
sender,
None::<fn()>,
));
let prompt = tokio::time::timeout(Duration::from_secs(5), receiver.recv())
.await
.expect("the prompt must arrive while the program is still reading")
.expect("a frame");
assert!(
matches!(&prompt, ProcessFrame::Output { data, .. } if data == b">>> "),
"the prompt is delivered as written: {prompt:?}"
);
use tokio::io::AsyncWriteExt;
stdin.write_all(b"ping\n").await.expect("stdin accepts");
let mut frames = vec![prompt];
while let Some(frame) = receiver.recv().await {
frames.push(frame);
}
produce.await.expect("the producer does not panic");
assert_eq!(stdout_of(&frames), ">>> got ping\n");
assert!(matches!(
terminal(&frames),
ProcessFrame::Exit { code: 0, .. }
));
}
#[tokio::test]
async fn closing_stdin_ends_a_program_that_reads_to_eof() {
let mut child = interactive_child("cat");
let mut stdin = child.stdin.take().expect("stdin is piped");
let consume = tokio::spawn(run_framed(child, Duration::from_secs(30), Framing::Chunks));
use tokio::io::AsyncWriteExt;
stdin.write_all(b"one\ntwo").await.expect("stdin accepts");
drop(stdin);
let frames = tokio::time::timeout(Duration::from_secs(10), consume)
.await
.expect("EOF on stdin must end cat")
.expect("no panic");
assert_eq!(stdout_of(&frames), "one\ntwo");
assert!(matches!(
terminal(&frames),
ProcessFrame::Exit { code: 0, .. }
));
}
async fn run_framed(child: Child, timeout: Duration, framing: Framing) -> Vec<ProcessFrame> {
let (sender, mut receiver) = mpsc::channel(FRAME_CHANNEL_DEPTH);
let produce = stream_framed(child, timeout, 1 << 20, framing, sender, None::<fn()>);
let consume = async {
let mut frames = Vec::new();
while let Some(frame) = receiver.recv().await {
frames.push(frame);
}
frames
};
tokio::join!(produce, consume).1
}
#[tokio::test]
async fn exited_runs_before_the_terminal_frame_reaches_a_stalled_reader() {
let (sender, mut receiver) = mpsc::channel(1);
let reaped = std::sync::Arc::new(AtomicBool::new(false));
let flag = std::sync::Arc::clone(&reaped);
let produce = tokio::spawn(stream_framed(
child(&["/bin/sh", "-c", "echo x"]),
Duration::from_secs(30),
1 << 20,
Framing::Chunks,
sender,
Some(move || flag.store(true, Ordering::SeqCst)),
));
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
while !reaped.load(Ordering::SeqCst) {
assert!(
tokio::time::Instant::now() < deadline,
"the reap was not reported while the terminal frame waited on the reader"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(
!produce.is_finished(),
"the terminal frame is still waiting"
);
let mut frames = Vec::new();
while let Some(frame) = receiver.recv().await {
frames.push(frame);
}
assert!(matches!(
terminal(&frames),
ProcessFrame::Exit { code: 0, .. }
));
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn exited_still_runs_when_the_deadline_ends_its_wait() {
let child = child(&["/bin/sh", "-c", "exec >/dev/null 2>&1; exec sleep 30"]);
let pid = child.id().expect("a running child has a pid");
let (sender, receiver) = mpsc::channel(FRAME_CHANNEL_DEPTH);
let state = std::sync::Arc::new(Mutex::new(None));
let seen = std::sync::Arc::clone(&state);
let produce = stream_framed(
child,
Duration::from_millis(500),
1 << 20,
Framing::Chunks,
sender,
Some(move || {
*seen.lock().expect("no panic holds this lock") =
Some(std::path::Path::new(&format!("/proc/{pid}")).exists());
}),
);
let consume = async {
let mut receiver = receiver;
let mut frames = Vec::new();
while let Some(frame) = receiver.recv().await {
frames.push(frame);
}
frames
};
let (_, frames) = tokio::join!(produce, consume);
assert_eq!(
*state.lock().expect("no panic holds this lock"),
Some(true),
"the hook ran, and ran while the command still held its pid"
);
assert!(matches!(
terminal(&frames),
ProcessFrame::Failed {
code: "timeoutExceeded",
..
}
));
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn exited_runs_while_the_child_still_holds_its_pid() {
let child = child(&["/bin/sh", "-c", "exit 4"]);
let pid = child.id().expect("a running child has a pid");
let (sender, receiver) = mpsc::channel(FRAME_CHANNEL_DEPTH);
let state = std::sync::Arc::new(Mutex::new(None));
let seen = std::sync::Arc::clone(&state);
let produce = stream_framed(
child,
Duration::from_secs(30),
1 << 20,
Framing::Chunks,
sender,
Some(move || {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok();
let letter = stat.and_then(|stat| {
stat.rsplit_once(") ")
.and_then(|(_, rest)| rest.chars().next())
});
*seen.lock().expect("no panic holds this lock") = letter;
}),
);
let consume = async {
let mut receiver = receiver;
let mut frames = Vec::new();
while let Some(frame) = receiver.recv().await {
frames.push(frame);
}
frames
};
let (_, frames) = tokio::join!(produce, consume);
assert_eq!(
*state.lock().expect("no panic holds this lock"),
Some('Z'),
"when the hook ran the child had exited and was not yet reaped"
);
assert!(matches!(
terminal(&frames),
ProcessFrame::Exit { code: 4, .. }
));
assert!(
!std::path::Path::new(&format!("/proc/{pid}")).exists(),
"the child is reaped by the time the stream ends"
);
}
}