use std::collections::VecDeque;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use command_group::{AsyncCommandGroup, AsyncGroupChild};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{ChildStdin, ChildStdout, Command};
use tokio::sync::OnceCell;
use crate::error::AgentError;
const STDERR_TAIL_LINES: usize = 6;
const LOGIN_SHELL_TIMEOUT: Duration = Duration::from_secs(5);
pub(crate) struct Spawn {
pub exec_path: PathBuf,
pub args: Vec<String>,
pub cwd: PathBuf,
pub env: Vec<(String, String)>,
}
pub(crate) type SharedStdin = Arc<tokio::sync::Mutex<Option<ChildStdin>>>;
pub(crate) struct Child {
pub stdin: SharedStdin,
pub stdout: Option<ChildStdout>,
inner: AsyncGroupChild,
finished: bool,
stderr_tail: Arc<Mutex<VecDeque<String>>>,
stderr_task: Option<tokio::task::JoinHandle<()>>,
}
pub(crate) async fn retry_busy<T>(
mut spawn: impl FnMut() -> std::io::Result<T>,
) -> std::io::Result<T> {
let mut attempts = 0;
loop {
match spawn() {
Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy && attempts < 20 => {
attempts += 1;
tokio::time::sleep(Duration::from_millis(5)).await;
}
result => return result,
}
}
}
pub(crate) async fn spawn(spec: Spawn) -> Result<Child, AgentError> {
let path = compose_path(
&spec.exec_path,
std::env::var("PATH").ok().as_deref(),
login_shell_path().await.as_deref(),
);
let mut command = Command::new(&spec.exec_path);
command
.args(&spec.args)
.current_dir(&spec.cwd)
.env("PATH", path)
.envs(spec.env)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = retry_busy(|| command.group().kill_on_drop(true).spawn())
.await
.map_err(|e| AgentError::SpawnFailed(format!("{}: {e}", spec.exec_path.display())))?;
let stderr_tail = Arc::new(Mutex::new(VecDeque::new()));
let stderr_task = child.inner().stderr.take().map(|stderr| {
let tail = Arc::clone(&stderr_tail);
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
let mut tail = tail.lock().unwrap_or_else(|e| e.into_inner());
if tail.len() == STDERR_TAIL_LINES {
tail.pop_front();
}
tail.push_back(line);
}
})
});
Ok(Child {
stdin: Arc::new(tokio::sync::Mutex::new(child.inner().stdin.take())),
stdout: child.inner().stdout.take(),
finished: false,
inner: child,
stderr_tail,
stderr_task,
})
}
impl Child {
pub fn is_running(&mut self) -> bool {
matches!(self.inner.try_wait(), Ok(None))
}
pub fn stderr_tail(&self) -> String {
let tail = self.stderr_tail.lock().unwrap_or_else(|e| e.into_inner());
tail.iter().cloned().collect::<Vec<_>>().join("\n")
}
pub async fn exit_status(&mut self, grace: Duration) -> String {
if !self.is_running() {
self.kill_group();
}
let status = tokio::time::timeout(grace, self.inner.wait()).await;
self.finished = matches!(status, Ok(Ok(_)));
if let Some(task) = self.stderr_task.take() {
let _ = tokio::time::timeout(grace, task).await;
}
match status {
Ok(Ok(status)) => status_text(status),
_ => "unknown".into(),
}
}
pub async fn shutdown(&mut self, grace: Duration) {
if self.finished {
return;
}
self.request_exit(grace).await;
self.kill_group();
let _ = self.inner.wait().await;
self.finished = true;
if let Some(task) = self.stderr_task.take() {
let _ = tokio::time::timeout(grace, task).await;
}
}
}
fn status_text(status: std::process::ExitStatus) -> String {
match status.code() {
Some(code) if code >= 0 => format!("exit status: {code}"),
_ => status.to_string(),
}
}
impl Child {
fn kill_group(&mut self) {
let _ = self.inner.start_kill();
}
async fn request_exit(&mut self, grace: Duration) {
self.stdin.lock().await.take();
#[cfg(unix)]
{
use command_group::{Signal, UnixChildExt};
let _ = self.inner.signal(Signal::SIGTERM);
}
let _ = tokio::time::timeout(grace, self.inner.wait()).await;
}
}
impl Drop for Child {
fn drop(&mut self) {
if !self.finished {
self.kill_group();
}
}
}
fn compose_path(exec_path: &Path, own: Option<&str>, login: Option<&str>) -> OsString {
let mut seen = std::collections::HashSet::new();
let dirs = exec_path
.parent()
.map(Path::to_path_buf)
.into_iter()
.chain(split_path(own))
.chain(split_path(login))
.filter(|dir| !dir.as_os_str().is_empty() && seen.insert(dir.clone()));
std::env::join_paths(dirs).unwrap_or_default()
}
fn split_path(path: Option<&str>) -> impl Iterator<Item = PathBuf> + '_ {
std::env::split_paths(path.unwrap_or_default())
}
pub(crate) async fn login_shell_path() -> Option<String> {
static CACHE: OnceCell<Option<String>> = OnceCell::const_new();
CACHE.get_or_init(capture_login_shell_path).await.clone()
}
async fn capture_login_shell_path() -> Option<String> {
if cfg!(windows) || std::env::var("ANYAGENT_NO_LOGIN_SHELL").is_ok_and(|v| v == "1") {
return None;
}
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into());
for flags in ["-lic", "-lc"] {
if let Some(path) = shell_path(&shell, flags).await {
return Some(path);
}
}
None
}
async fn shell_path(shell: &str, flags: &str) -> Option<String> {
let output = tokio::time::timeout(
LOGIN_SHELL_TIMEOUT,
Command::new(shell)
.arg(flags)
.arg("echo __anyagent__$PATH")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true)
.output(),
)
.await
.ok()?
.ok()?;
String::from_utf8_lossy(&output.stdout)
.lines()
.find_map(|line| line.strip_prefix("__anyagent__"))
.filter(|path| !path.is_empty())
.map(str::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
fn node(script: &str) -> Spawn {
Spawn {
exec_path: PathBuf::from("node"),
args: vec!["-e".into(), script.into()],
cwd: std::env::temp_dir(),
env: Vec::new(),
}
}
#[cfg(unix)]
fn sh(script: &str) -> Spawn {
Spawn {
exec_path: PathBuf::from("/bin/sh"),
args: vec!["-c".into(), script.into()],
cwd: std::env::temp_dir(),
env: Vec::new(),
}
}
#[test]
fn compose_path_orders_and_dedupes() {
let joined = |dirs: [&str; 3]| std::env::join_paths(dirs).unwrap().into_string().unwrap();
let path = compose_path(
Path::new("/opt/agent/bin/claude"),
Some(&joined(["/usr/bin", "/opt/agent/bin", "/usr/bin"])),
Some(&joined(["/usr/bin", "/home/u/.volta/bin", "/usr/bin"])),
);
assert_eq!(
path,
OsString::from(joined(["/opt/agent/bin", "/usr/bin", "/home/u/.volta/bin"]))
);
}
#[tokio::test]
async fn shutdown_asks_with_stdin_eof() {
let mut child = spawn(node(
"process.on('SIGTERM', () => {}); process.stdin.on('end', () => process.exit(0)); \
process.stdin.resume(); setTimeout(() => {}, 30000)",
))
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
let start = std::time::Instant::now();
child.shutdown(Duration::from_secs(10)).await;
assert!(start.elapsed() < Duration::from_secs(5));
}
#[cfg(unix)]
#[tokio::test]
async fn shutdown_escalates_to_sigkill_within_grace() {
let mut child = spawn(sh("trap '' TERM; sleep 30")).await.unwrap();
let start = std::time::Instant::now();
child.shutdown(Duration::from_millis(200)).await;
assert!(start.elapsed() < Duration::from_secs(5));
}
#[cfg(unix)]
#[tokio::test]
async fn shutdown_lets_a_cooperative_child_exit_on_sigterm() {
let mut child = spawn(sh("sleep 30")).await.unwrap();
child.shutdown(Duration::from_secs(5)).await;
}
#[cfg(unix)]
#[tokio::test]
async fn shutdown_kills_a_worker_that_outlived_its_leader() {
let mut child = spawn(sh("trap '' TERM; sleep 30 & echo $!; exit 0"))
.await
.unwrap();
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
let worker = lines.next_line().await.unwrap().unwrap();
child.shutdown(Duration::from_millis(200)).await;
for _ in 0..50 {
let alive = Command::new("kill").args(["-0", &worker]).status().await;
if !alive.unwrap().success() {
return;
}
tokio::time::sleep(Duration::from_millis(40)).await;
}
panic!("worker {worker} survived shutdown");
}
#[tokio::test]
async fn stderr_tail_keeps_the_last_lines() {
let mut child = spawn(node(
"for (let i = 1; i <= 8; i++) console.error('line' + i)",
))
.await
.unwrap();
let status = child.exit_status(Duration::from_secs(5)).await;
assert_eq!(status, "exit status: 0");
assert_eq!(
child.stderr_tail(),
"line3\nline4\nline5\nline6\nline7\nline8"
);
}
}