use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use crate::acp::agent_proc;
use crate::acp::agent_proc::spawn_test_lock_async;
use crate::acp::client::AcpClient;
use crate::models::agent::{Agent, AgentEnvVar};
const SPAWN_TIMEOUT: Duration = Duration::from_secs(10);
const UNWIND_TIMEOUT: Duration = Duration::from_secs(5);
const KILL_TIMEOUT: Duration = Duration::from_millis(750);
const REAP_TIMEOUT: Duration = Duration::from_secs(5);
const FAKE_AGENT_LIFETIME: &str = "25";
const FAKE_AGENT_SCRIPT: &str = r#"#!/bin/sh
# test-only fake ACP agent(计划 D5)。响应 initialize(声明 loadSession,restore
# 路径也要能建连)与 session/new,之后按 FAKE_MODE 行动。
# hang 模式:模拟永不完成握手的 agent(坏二进制 / npx 冷启动超过探针 15s
# 预算)——不读不响应,驻留到被 kill(P2-3 回归测试的目标时序)。
if [ "$FAKE_MODE" = "hang" ]; then
exec sleep @LIFETIME@
fi
while IFS= read -r line; do
case "$line" in
*'"method":"initialize"'*)
id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([0-9a-f-][0-9a-f-]*\)".*/\1/p')
printf '{"jsonrpc":"2.0","id":"%s","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{}}}}\n' "$id"
# handshake 模式:响应 initialize 后立刻崩溃(session/new 永远等不到
# 响应)——建模「agent 死于握手期」,spawn 必须限时失败而不是挂死。
if [ "$FAKE_MODE" = "handshake" ]; then
exit 3
fi
;;
*'"method":"session/new"'*)
id=$(printf '%s' "$line" | sed -n 's/.*"id":"\([0-9a-f-][0-9a-f-]*\)".*/\1/p')
printf '{"jsonrpc":"2.0","id":"%s","result":{"sessionId":"fake-session"}}\n' "$id"
if [ "$FAKE_MODE" = "group" ]; then
sleep @LIFETIME@ &
echo $! > "$FAKE_GRANDCHILD_PID_FILE"
fi
if [ "$FAKE_MODE" = "exit" ]; then
while [ ! -f "$FAKE_EXIT_FILE" ]; do sleep 1; done
exit 3
fi
;;
*'"method":"session/prompt"'*)
# crash 模式:agent 在 turn 进行中崩溃——不回响应,直接退出。
if [ "$FAKE_MODE" = "crash" ]; then
exit 3
fi
;;
esac
done
exit 0
"#;
fn unique_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"omniterm-fake-agent-{tag}-{}-{}",
std::process::id(),
chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)
));
std::fs::create_dir_all(&dir).expect("create fake agent test dir");
dir
}
fn write_fake_agent(dir: &Path) -> PathBuf {
let script = dir.join("fake-agent.sh");
let content = FAKE_AGENT_SCRIPT.replace("@LIFETIME@", FAKE_AGENT_LIFETIME);
std::fs::write(&script, content).expect("write fake agent script");
script
}
fn agent_for(script: &Path, mode: &str, dir: &Path) -> Agent {
let env = vec![
AgentEnvVar { key: "FAKE_MODE".into(), value: mode.into() },
AgentEnvVar {
key: "FAKE_GRANDCHILD_PID_FILE".into(),
value: dir.join("grandchild.pid").to_string_lossy().to_string(),
},
AgentEnvVar {
key: "FAKE_EXIT_FILE".into(),
value: dir.join("exit.gate").to_string_lossy().to_string(),
},
];
Agent {
id: "fake-agent".into(),
display_name: "Fake ACP Agent".into(),
command: "/bin/sh".into(),
args: vec![script.to_string_lossy().to_string()],
env,
npm_package: None,
created_at: String::new(),
updated_at: String::new(),
}
}
async fn spawn_connect(agent: Agent, cwd: PathBuf) -> AcpClient {
tokio::time::timeout(SPAWN_TIMEOUT, AcpClient::spawn_and_connect(agent, cwd, &HashMap::new()))
.await
.expect("spawn 超时:fake agent 未在限时内完成 initialize/session/new")
.expect("spawn_and_connect 失败")
}
fn proc_state(pid: u32) -> Option<char> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let after_comm = stat.rsplit_once(')')?.1;
after_comm.split_whitespace().next().and_then(|s| s.chars().next())
}
fn proc_alive(pid: u32) -> bool {
matches!(proc_state(pid), Some(state) if state != 'Z')
}
fn proc_dead(pid: u32) -> bool {
!proc_alive(pid)
}
fn proc_reaped(pid: u32) -> bool {
proc_state(pid).is_none()
}
async fn wait_until(mut cond: impl FnMut() -> bool, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
if cond() {
return true;
}
let now = Instant::now();
if now >= deadline {
return cond();
}
tokio::time::sleep(Duration::from_millis(10).min(deadline - now)).await;
}
}
async fn wait_until_some<T>(mut f: impl FnMut() -> Option<T>, timeout: Duration) -> Option<T> {
let deadline = Instant::now() + timeout;
loop {
if let Some(v) = f() {
return Some(v);
}
let now = Instant::now();
if now >= deadline {
return f();
}
tokio::time::sleep(Duration::from_millis(10).min(deadline - now)).await;
}
}
#[tokio::test]
async fn agent_dying_mid_prompt_fails_prompt_and_shuts_down_clean() {
let _guard = spawn_test_lock_async().await;
let dir = unique_dir("crash");
let workspace = dir.join("ws");
std::fs::create_dir_all(&workspace).expect("create workspace");
let script = write_fake_agent(&dir);
let client = spawn_connect(agent_for(&script, "crash", &dir), workspace).await;
let pid = client.agent_pid().expect("D1:crash 模式也必须捕获 pid");
let prompt_result =
tokio::time::timeout(UNWIND_TIMEOUT, client.send_prompt("hi", vec![], vec![], vec![]))
.await
.expect("send_prompt 未在限时内返回(挂死)");
assert!(
prompt_result.is_err(),
"agent 死于 prompt 在途时 send_prompt 应失败,实际: {prompt_result:?}"
);
assert!(wait_until(|| proc_reaped(pid), UNWIND_TIMEOUT).await, "agent 死亡后未被 crate 回收");
assert!(
client.is_alive(),
"is_alive() 对已崩溃 agent 的误报存活是 crate 既有行为(见模块文档),\
此处 pin 住;若本断言失败说明 crate 行为已变,须复查 is_alive 判定与 \
backend.md 的描述"
);
client.shutdown().await;
assert!(wait_until(|| proc_reaped(pid), REAP_TIMEOUT).await, "shutdown 后 agent 仍残留");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn spawn_fails_promptly_when_agent_dies_during_handshake() {
let _guard = spawn_test_lock_async().await;
let dir = unique_dir("handshake");
let workspace = dir.join("ws");
std::fs::create_dir_all(&workspace).expect("create workspace");
let script = write_fake_agent(&dir);
let result = tokio::time::timeout(
SPAWN_TIMEOUT,
AcpClient::spawn_and_connect(
agent_for(&script, "handshake", &dir),
workspace,
&HashMap::new(),
),
)
.await
.expect("spawn 挂起:agent 死于 handshake 时 spawn_and_connect 未限时返回");
assert!(result.is_err(), "agent 死于 handshake 时 spawn_and_connect 应快速失败");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn agent_exits_without_traffic_shutdown_stays_clean() {
let _guard = spawn_test_lock_async().await;
let dir = unique_dir("exit");
let workspace = dir.join("ws");
std::fs::create_dir_all(&workspace).expect("create workspace");
let script = write_fake_agent(&dir);
let exit_gate = dir.join("exit.gate");
let client = spawn_connect(agent_for(&script, "exit", &dir), workspace).await;
let pid = client.agent_pid().expect("D1:exit 模式也必须捕获 pid");
assert!(proc_alive(pid), "放行前 agent 必须驻留");
std::fs::write(&exit_gate, b"go").expect("write exit gate");
assert!(wait_until(|| proc_reaped(pid), UNWIND_TIMEOUT).await, "agent 退出后未被 crate 回收");
client.shutdown().await;
assert!(wait_until(|| proc_reaped(pid), REAP_TIMEOUT).await, "shutdown 后 agent 仍残留");
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn shutdown_kills_live_agent_process_group_promptly() {
let _guard = spawn_test_lock_async().await;
let dir = unique_dir("live");
let workspace = dir.join("ws");
std::fs::create_dir_all(&workspace).expect("create workspace");
let script = write_fake_agent(&dir);
let client = spawn_connect(agent_for(&script, "live", &dir), workspace).await;
let pid = client.agent_pid().expect("D1:真实 spawn 路径必须捕获 agent pid");
assert!(proc_alive(pid), "shutdown 前 agent 必须存活");
client.shutdown().await;
assert!(
wait_until(|| proc_dead(pid), KILL_TIMEOUT).await,
"shutdown 后 agent 未在 {KILL_TIMEOUT:?} 内死亡(killpg 未生效或走了 1s 优雅宽限期)"
);
assert!(
wait_until(|| proc_reaped(pid), REAP_TIMEOUT).await,
"agent 死亡后未被回收(僵尸残留)"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn shutdown_kill_reaches_grandchild_in_same_process_group() {
let _guard = spawn_test_lock_async().await;
let dir = unique_dir("group");
let workspace = dir.join("ws");
std::fs::create_dir_all(&workspace).expect("create workspace");
let script = write_fake_agent(&dir);
let grandchild_pid_file = dir.join("grandchild.pid");
let client = spawn_connect(agent_for(&script, "group", &dir), workspace).await;
let leader = client.agent_pid().expect("D1:group 模式也必须捕获 leader pid");
let grandchild = wait_until_some(
|| {
std::fs::read_to_string(&grandchild_pid_file)
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
},
UNWIND_TIMEOUT,
)
.await
.expect("孙进程未在限时内登记 pid");
assert_ne!(grandchild, leader);
assert!(proc_alive(leader), "shutdown 前 leader 必须存活");
assert!(proc_alive(grandchild), "shutdown 前孙进程必须存活");
client.shutdown().await;
assert!(
wait_until(|| proc_dead(leader), KILL_TIMEOUT).await,
"shutdown 后 leader 未在 {KILL_TIMEOUT:?} 内死亡"
);
assert!(
wait_until(|| proc_dead(grandchild), KILL_TIMEOUT).await,
"shutdown 后孙进程未随进程组被击杀(D2 回归:只杀了单进程?)"
);
assert!(
wait_until(|| proc_reaped(leader), REAP_TIMEOUT).await,
"leader 死亡后未被回收(僵尸残留)"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn restore_path_captures_pid_and_shutdown_kills_agent() {
let _guard = spawn_test_lock_async().await;
let dir = unique_dir("restore");
let workspace = dir.join("ws");
std::fs::create_dir_all(&workspace).expect("create workspace");
let script = write_fake_agent(&dir);
let client = tokio::time::timeout(
SPAWN_TIMEOUT,
AcpClient::spawn_and_load(
agent_for(&script, "live", &dir),
workspace,
"fake-acp-session".to_string(),
&HashMap::new(),
),
)
.await
.expect("spawn_and_load 超时:fake agent 未响应 initialize")
.expect("spawn_and_load 失败");
assert!(client.supports_load_session(), "fake agent 已声明 loadSession 能力");
let pid = client.agent_pid().expect("D1:restore 路径必须捕获 agent pid");
assert!(proc_alive(pid), "shutdown 前 agent 必须存活");
client.shutdown().await;
assert!(
wait_until(|| proc_dead(pid), KILL_TIMEOUT).await,
"restore 路径 shutdown 后 agent 未在 {KILL_TIMEOUT:?} 内死亡"
);
assert!(
wait_until(|| proc_reaped(pid), REAP_TIMEOUT).await,
"restore 路径 agent 死亡后未被回收(僵尸残留)"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn spawn_timeout_during_handshake_does_not_leak_agent_process() {
let _guard = spawn_test_lock_async().await;
let dir = unique_dir("hang");
let workspace = dir.join("ws");
std::fs::create_dir_all(&workspace).expect("create workspace");
let script = write_fake_agent(&dir);
let children_before = agent_proc::snapshot_direct_children();
let result = tokio::time::timeout(
Duration::from_secs(1),
AcpClient::spawn_and_connect(
agent_for(&script, "hang", &dir),
workspace.clone(),
&HashMap::new(),
),
)
.await;
assert!(result.is_err(), "hang agent 必须让 spawn 限时失败而不是挂死");
let pid = agent_proc::resolve_child_pid(&children_before, &workspace)
.expect("应能 diff 出 hang agent 的 pid");
assert!(proc_alive(pid), "spawn 超时前 agent 必须还在跑(否则测了个空)");
assert!(
wait_until(|| proc_dead(pid), Duration::from_secs(4)).await,
"spawn 超时(调用方消失)后 agent 进程泄漏(P2-3 回归)"
);
assert!(
wait_until(|| proc_reaped(pid), REAP_TIMEOUT).await,
"hang agent 死亡后未被回收(僵尸残留)"
);
let _ = std::fs::remove_dir_all(&dir);
}