#![cfg(unix)]
#[path = "support/phase2/mod.rs"]
mod phase2;
use std::process::{Command, Stdio};
use std::time::Duration;
use agent_engine::daemon::{registry, EXIT_DAEMON_LOST};
use agent_engine::session::socket_transport::SocketTransport;
use agent_engine::session::wire::*;
use agent_engine::session::*;
use phase2::*;
use serial_test::serial;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_synaps")
}
async fn spawn_daemon(guard: &HomeGuard, url: &str) -> (std::process::Child, registry::DaemonPaths) {
let run = guard.base_dir().join("run");
std::fs::create_dir_all(&run).unwrap();
let paths = registry::daemon_paths_in(&run, None);
let child = Command::new(bin())
.args(["daemon", "--foreground"])
.env("HOME", guard.home.path())
.env("SYNAPS_BASE_DIR", guard.base_dir())
.env("SYNAPS_RUNTIME_DIR", &run)
.env("SYNAPS_DAEMON", "1")
.env("SYNAPS_ANTHROPIC_BASE_URL", url)
.env("SYNAPS_NO_BOOT_FX", "1")
.env_remove("SYNAPS_DAEMON_RELOAD_STATE")
.env_remove("SYNAPS_DAEMON_LOCK_FD")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.spawn()
.expect("spawn daemon");
let t0 = std::time::Instant::now();
loop {
if SocketTransport::ping(&paths.sock).await.is_ok() {
break;
}
assert!(
t0.elapsed() < Duration::from_secs(20),
"daemon never answered"
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
(child, paths)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn daemon_sigkill_line_client_exits_nonzero() {
let guard = HomeGuard::new();
let (url, _hits, _bodies) = spawn_stub(Script::Sse(ANTHROPIC_SSE)).await;
let (mut daemon, paths) = spawn_daemon(&guard, &url).await;
let conn = SocketTransport::connect(&paths.sock, Hello::new(ClientKind::Test))
.await
.unwrap();
let (t, _snap) = SocketTransport::attach(
conn,
Attach::Create {
config: SessionConfig {
cwd: Some(guard.home.path().to_path_buf()),
env: None,
model_override: Some("claude-sonnet-4-5".into()),
..Default::default()
},
mode: AttachMode::Mirror,
},
)
.await
.unwrap();
let session_id = t.session_id().clone();
t.detach().await;
let mut client = Command::new(bin())
.args(["attach", session_id.as_str()])
.env("HOME", guard.home.path())
.env("SYNAPS_BASE_DIR", guard.base_dir())
.env("SYNAPS_RUNTIME_DIR", guard.base_dir().join("run"))
.env("SYNAPS_DAEMON", "1")
.env("SYNAPS_ANTHROPIC_BASE_URL", &url)
.env("SYNAPS_NO_BOOT_FX", "1")
.env("SYNAPS_TUI_ATTACH_RECONNECT_SECS", "3")
.env_remove("SYNAPS_DAEMON_RELOAD_STATE")
.env_remove("SYNAPS_DAEMON_LOCK_FD")
.stdin(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn attach client");
tokio::time::sleep(Duration::from_secs(2)).await;
let client_stdin = client.stdin.take();
daemon.kill().expect("kill daemon");
let _ = daemon.wait();
tokio::time::sleep(Duration::from_millis(500)).await;
drop(client_stdin);
let output = tokio::time::timeout(Duration::from_secs(15), async {
tokio::task::spawn_blocking(move || client.wait_with_output().expect("client output"))
.await
.unwrap()
})
.await
.expect("client should exit within budget");
let code = output.status.code().unwrap_or(-1);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
assert_eq!(
code, EXIT_DAEMON_LOST,
"expected exit code {EXIT_DAEMON_LOST}, got {code}\nstdout: {stdout}\nstderr: {stderr}"
);
assert!(
stderr.contains("lost the daemon"),
"stderr should say 'lost the daemon': {stderr}"
);
assert!(
stderr.contains(session_id.as_str()),
"stderr should mention session id: {stderr}"
);
assert!(
stdout.contains("daemon connection lost"),
"stdout should show 'daemon connection lost': {stdout}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn daemon_sigkill_socket_transport_reports_crash() {
let guard = HomeGuard::new();
let (url, _hits, _bodies) = spawn_stub(Script::Sse(ANTHROPIC_SSE)).await;
let (mut daemon, paths) = spawn_daemon(&guard, &url).await;
let conn = SocketTransport::connect(&paths.sock, Hello::new(ClientKind::Test))
.await
.unwrap();
let (mut t, _snap) = SocketTransport::attach(
conn,
Attach::Create {
config: SessionConfig {
cwd: Some(guard.home.path().to_path_buf()),
env: None,
..Default::default()
},
mode: AttachMode::Mirror,
},
)
.await
.unwrap();
let _ = tokio::time::timeout(Duration::from_secs(5), t.next_event()).await;
daemon.kill().expect("kill daemon");
let _ = daemon.wait();
let ev = tokio::time::timeout(Duration::from_secs(5), t.next_event())
.await
.expect("should return quickly");
assert!(ev.is_none(), "expected None after daemon death, got {ev:?}");
assert!(
!t.is_reload_pending(),
"should NOT be a reload — it was a crash"
);
let r = t.reconnect_once(AttachMode::Mirror).await;
assert!(r.is_err(), "reconnect_once should fail: {r:?}");
}
#[test]
fn exit_daemon_lost_is_distinct() {
assert_eq!(EXIT_DAEMON_LOST, 4);
assert_ne!(EXIT_DAEMON_LOST, agent_engine::daemon::EXIT_REFUSED);
assert_ne!(EXIT_DAEMON_LOST, agent_engine::daemon::EXIT_VERSION);
}
#[test]
fn daemon_lost_message_format() {
let msg = format!(
"synaps: lost the daemon (pid {}) and could not reconnect within {} s \
— session {}; resume with synaps --attach {} / --continue {}",
42, 60, "20260919-123456-abcd", "20260919-123456-abcd", "20260919-123456-abcd",
);
assert!(msg.contains("lost the daemon (pid 42)"));
assert!(msg.contains("60 s"));
assert!(msg.contains("20260919-123456-abcd"));
assert!(msg.contains("--attach"));
assert!(msg.contains("--continue"));
}