npm puts a Unix shell script with the bare name (codex-acp, claude-code-acp, gemini) beside the .cmd shim. The lookup tried the bare name first, so every npm-installed agent failed with "not a valid Win32 application (os error 193)". Windows now only considers .exe/.cmd/.bat, and a command saved with the bare path runs the shim beside it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
322 lines
13 KiB
Rust
322 lines
13 KiB
Rust
//! The agent as a child process (desktop only): its stdout is read on a thread and handed to the
|
|
//! panel line by line, its stdin written on another so a slow agent never blocks a frame.
|
|
//!
|
|
//! Agents start helpers of their own (Node adapters start the CLI they wrap, which starts the MCP
|
|
//! servers it was given), so stopping only the program we started would leave those running. The
|
|
//! agent therefore gets a process group of its own on Unix (on Windows, `taskkill /T` walks its
|
|
//! tree), and [`AgentProcess::shutdown`] ends the whole group: stdin is closed first (an ACP agent
|
|
//! ends at end of input), then the group is asked to stop, then stopped. Everything is addressed
|
|
//! by the ids of the processes we started, never by name.
|
|
|
|
use std::io::{BufRead, BufReader, Read, Write};
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::sync::mpsc::{Receiver, Sender, channel};
|
|
use std::time::{Duration, Instant};
|
|
|
|
/// Longest line read from the agent; a longer one is dropped.
|
|
const MAX_LINE: usize = 8 << 20;
|
|
/// Lines of the agent's stderr kept to explain a failed start.
|
|
const STDERR_LINES: usize = 40;
|
|
/// How long an agent gets to end by itself once its input is closed, and then once asked to stop.
|
|
const GRACE: Duration = Duration::from_millis(1200);
|
|
|
|
pub enum FromAgent {
|
|
Line(String),
|
|
/// stdout closed: the agent ended. Its last stderr lines, for the panel.
|
|
Exited(Vec<String>),
|
|
}
|
|
|
|
pub struct AgentProcess {
|
|
child: ProcessHandle,
|
|
to_agent: Option<Sender<String>>,
|
|
pub from_agent: Receiver<FromAgent>,
|
|
}
|
|
|
|
/// Ends the process from another thread than the one that talks to it.
|
|
#[derive(Clone)]
|
|
pub struct ProcessHandle(std::sync::Arc<std::sync::Mutex<Option<Child>>>);
|
|
|
|
impl ProcessHandle {
|
|
pub fn pid(&self) -> Option<u32> {
|
|
self.0.lock().ok().and_then(|c| c.as_ref().map(Child::id))
|
|
}
|
|
/// End it and everything it started; `wait`: block until done.
|
|
pub fn shutdown(&self, wait: bool) {
|
|
let Some(child) = self.0.lock().ok().and_then(|mut c| c.take()) else { return };
|
|
if wait {
|
|
end_tree(child);
|
|
} else {
|
|
std::thread::spawn(move || end_tree(child));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// On Windows, npm installs `.cmd` shims, which only `cmd.exe` runs. A command saved as the bare
|
|
/// npm path (a shell script there) runs the shim beside it.
|
|
fn command_for(program: &str) -> Command {
|
|
let shim = if cfg!(windows) { super::discover::runnable_sibling(std::path::Path::new(program)) } else { None };
|
|
let program = shim.as_deref().and_then(|p| p.to_str()).unwrap_or(program);
|
|
let lower = program.to_ascii_lowercase();
|
|
if cfg!(windows) && (lower.ends_with(".cmd") || lower.ends_with(".bat")) {
|
|
let mut c = Command::new("cmd");
|
|
c.arg("/C").arg(program);
|
|
c
|
|
} else {
|
|
Command::new(program)
|
|
}
|
|
}
|
|
|
|
/// Start the agent in a process group of its own (Unix) or without a console window (Windows).
|
|
fn detach_group(cmd: &mut Command) {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::process::CommandExt;
|
|
cmd.process_group(0);
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
use std::os::windows::process::CommandExt;
|
|
// CREATE_NO_WINDOW: no console flashes up for a console agent.
|
|
cmd.creation_flags(0x0800_0000);
|
|
}
|
|
}
|
|
|
|
/// Signal the agent's whole process group (`signal`: `TERM` or `KILL`). The group's id is the
|
|
/// agent's own process id, as it was started with `process_group(0)`.
|
|
#[cfg(unix)]
|
|
fn signal_group(pgid: u32, signal: &str) {
|
|
if pgid <= 1 {
|
|
return;
|
|
}
|
|
let mut c = Command::new("kill");
|
|
c.args(["-s", signal]);
|
|
// procps' kill reads `-123` as an option without `--`; BSD kill takes it as is.
|
|
if cfg!(target_os = "linux") {
|
|
c.arg("--");
|
|
}
|
|
let _ = c.arg(format!("-{pgid}")).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).status();
|
|
}
|
|
|
|
/// Windows: end the agent and every process it started.
|
|
#[cfg(windows)]
|
|
fn kill_tree(pid: u32) {
|
|
use std::os::windows::process::CommandExt;
|
|
let _ = Command::new("taskkill")
|
|
.args(["/T", "/F", "/PID", &pid.to_string()])
|
|
.creation_flags(0x0800_0000)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status();
|
|
}
|
|
|
|
/// Wait up to `limit` for the child to end; whether it did.
|
|
fn wait_for(child: &mut Child, limit: Duration) -> bool {
|
|
let start = Instant::now();
|
|
loop {
|
|
match child.try_wait() {
|
|
Ok(Some(_)) => return true,
|
|
Ok(None) if start.elapsed() < limit => std::thread::sleep(Duration::from_millis(20)),
|
|
Ok(None) => return false,
|
|
Err(_) => return true,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// End the agent and everything it started (see the module note). Blocks for at most about
|
|
/// three seconds.
|
|
fn end_tree(mut child: Child) {
|
|
let pid = child.id();
|
|
// Its input is already closed: a well-behaved agent ends now and stops its own helpers.
|
|
let ended = wait_for(&mut child, GRACE);
|
|
#[cfg(unix)]
|
|
{
|
|
// Helpers can outlive the agent: the group is signalled whether or not it ended. The
|
|
// group id stays reserved while any member lives, so it can't name someone else's.
|
|
signal_group(pid, "TERM");
|
|
if !ended {
|
|
wait_for(&mut child, GRACE);
|
|
}
|
|
signal_group(pid, "KILL");
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
if !ended {
|
|
kill_tree(pid);
|
|
}
|
|
}
|
|
#[cfg(not(any(unix, windows)))]
|
|
let _ = (pid, ended);
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
}
|
|
|
|
impl AgentProcess {
|
|
/// Start `argv` (program first) in `cwd`; `wake` is called whenever a line arrives.
|
|
/// `env_remove`: variables the agent must not inherit.
|
|
pub fn spawn(
|
|
argv: &[String],
|
|
cwd: &std::path::Path,
|
|
env_remove: &[&str],
|
|
wake: impl Fn() + Send + Sync + 'static,
|
|
) -> Result<AgentProcess, String> {
|
|
let Some((program, args)) = argv.split_first() else { return Err("no agent command".into()) };
|
|
let mut cmd = command_for(program);
|
|
cmd.args(args).current_dir(cwd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
|
for v in env_remove {
|
|
cmd.env_remove(v);
|
|
}
|
|
detach_group(&mut cmd);
|
|
let mut child = cmd.spawn().map_err(|e| format!("{program}: {e}"))?;
|
|
let (Some(mut stdin), Some(stdout), Some(stderr)) = (child.stdin.take(), child.stdout.take(), child.stderr.take()) else {
|
|
end_tree(child);
|
|
return Err("the agent's pipes could not be opened".into());
|
|
};
|
|
let (to_agent, rx) = channel::<String>();
|
|
std::thread::spawn(move || {
|
|
for line in rx {
|
|
if stdin.write_all(line.as_bytes()).and_then(|()| stdin.write_all(b"\n")).and_then(|()| stdin.flush()).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
// The sender is gone: stdin closes here, which is how an ACP agent learns to end.
|
|
});
|
|
let tail = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<String>::new()));
|
|
let tail_w = tail.clone();
|
|
std::thread::spawn(move || {
|
|
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
|
|
if let Ok(mut t) = tail_w.lock() {
|
|
if t.len() >= STDERR_LINES {
|
|
t.pop_front();
|
|
}
|
|
t.push_back(line.chars().take(400).collect());
|
|
}
|
|
}
|
|
});
|
|
let (tx, from_agent) = channel::<FromAgent>();
|
|
let wake = std::sync::Arc::new(wake);
|
|
std::thread::spawn(move || {
|
|
let mut reader = BufReader::new(stdout);
|
|
loop {
|
|
let mut buf = Vec::new();
|
|
match (&mut reader).take(MAX_LINE as u64 + 1).read_until(b'\n', &mut buf) {
|
|
Ok(0) | Err(_) => break,
|
|
Ok(_) if buf.len() > MAX_LINE => {
|
|
// Skip the rest of an oversized line.
|
|
let mut rest = Vec::new();
|
|
if reader.read_until(b'\n', &mut rest).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
Ok(_) => {
|
|
if tx.send(FromAgent::Line(String::from_utf8_lossy(&buf).into_owned())).is_err() {
|
|
return;
|
|
}
|
|
wake();
|
|
}
|
|
}
|
|
}
|
|
// Give stderr a moment to deliver the agent's last words.
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
let lines = tail.lock().map(|t| t.iter().cloned().collect()).unwrap_or_default();
|
|
let _ = tx.send(FromAgent::Exited(lines));
|
|
wake();
|
|
});
|
|
Ok(AgentProcess { child: ProcessHandle(std::sync::Arc::new(std::sync::Mutex::new(Some(child)))), to_agent: Some(to_agent), from_agent })
|
|
}
|
|
|
|
pub fn send(&self, msg: &serde_json::Value) {
|
|
if let Some(t) = &self.to_agent {
|
|
let _ = t.send(msg.to_string());
|
|
}
|
|
}
|
|
|
|
pub fn pid(&self) -> Option<u32> {
|
|
self.child.pid()
|
|
}
|
|
|
|
pub fn handle(&self) -> ProcessHandle {
|
|
self.child.clone()
|
|
}
|
|
|
|
/// End the agent and its helpers. `wait`: block until they are gone (quitting the app);
|
|
/// otherwise the ending runs on a thread of its own and this returns at once.
|
|
pub fn shutdown(&mut self, wait: bool) {
|
|
self.to_agent = None;
|
|
self.child.shutdown(wait);
|
|
}
|
|
}
|
|
|
|
impl Drop for AgentProcess {
|
|
fn drop(&mut self) {
|
|
self.shutdown(false);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[cfg(unix)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn alive(pid: u32) -> bool {
|
|
// `kill -0` checks that the process exists without touching it. A zombie still counts
|
|
// on Linux, so /proc's state is checked there too.
|
|
#[cfg(target_os = "linux")]
|
|
if let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) {
|
|
return !stat.rsplit(')').next().is_some_and(|s| s.trim_start().starts_with('Z'));
|
|
}
|
|
Command::new("kill").args(["-0", &pid.to_string()]).stderr(Stdio::null()).status().is_ok_and(|s| s.success())
|
|
}
|
|
|
|
/// Start a fake agent that leaves helpers behind, read the helpers' ids from it, end it.
|
|
fn helpers_after_shutdown(script: &str) -> Vec<u32> {
|
|
let dir = std::env::temp_dir();
|
|
let argv = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
|
|
let mut p = AgentProcess::spawn(&argv, &dir, &[], || {}).unwrap();
|
|
let mut pids = vec![p.pid().unwrap()];
|
|
while pids.len() < 3 {
|
|
match p.from_agent.recv_timeout(Duration::from_secs(10)).unwrap() {
|
|
FromAgent::Line(l) => pids.push(l.trim().parse().unwrap()),
|
|
FromAgent::Exited(tail) => panic!("the fake agent ended early: {tail:?}"),
|
|
}
|
|
}
|
|
assert!(pids.iter().all(|&pid| alive(pid)), "all running before the shutdown: {pids:?}");
|
|
p.shutdown(true);
|
|
// Orphaned helpers are reaped by init a moment after they end.
|
|
let start = Instant::now();
|
|
while pids.iter().any(|&pid| alive(pid)) && start.elapsed() < Duration::from_secs(5) {
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
pids.into_iter().filter(|&pid| alive(pid)).collect()
|
|
}
|
|
|
|
/// No orphans: an agent that started helpers (as Node adapters start the CLI they wrap and
|
|
/// its MCP servers) is ended with all of them, whether it ends politely at end of input or
|
|
/// ignores both its input and the request to stop.
|
|
#[test]
|
|
fn stopping_the_agent_leaves_no_helpers_running() {
|
|
// A polite agent: ends when its input closes; its helpers would live on by themselves.
|
|
let polite = "sleep 300 & echo $!; sleep 300 & echo $!; cat >/dev/null";
|
|
assert_eq!(helpers_after_shutdown(polite), Vec::<u32>::new(), "polite agent");
|
|
// A stubborn one: ignores end of input and TERM (so do its helpers).
|
|
let stubborn = "trap '' TERM; (trap '' TERM; sleep 300) & echo $!; (trap '' TERM; sleep 300) & echo $!; while :; do sleep 1; done";
|
|
assert_eq!(helpers_after_shutdown(stubborn), Vec::<u32>::new(), "stubborn agent");
|
|
}
|
|
|
|
/// Dropping the process (closing the panel's connection any other way) ends it too.
|
|
#[test]
|
|
fn dropping_the_process_ends_its_group() {
|
|
let argv = vec!["sh".to_string(), "-c".to_string(), "sleep 300 & echo $!; wait".to_string()];
|
|
let p = AgentProcess::spawn(&argv, &std::env::temp_dir(), &[], || {}).unwrap();
|
|
let leader = p.pid().unwrap();
|
|
let FromAgent::Line(l) = p.from_agent.recv_timeout(Duration::from_secs(10)).unwrap() else { panic!("no helper id") };
|
|
let helper: u32 = l.trim().parse().unwrap();
|
|
drop(p);
|
|
let start = Instant::now();
|
|
while (alive(helper) || alive(leader)) && start.elapsed() < Duration::from_secs(8) {
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
}
|
|
assert!(!alive(helper) && !alive(leader), "leader {leader} or helper {helper} still running");
|
|
}
|
|
}
|