#![cfg(unix)]
#[path = "support/phase2/mod.rs"]
mod phase2;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::Duration;
use agent_engine::daemon::registry;
use agent_engine::session::socket_transport::SocketTransport;
use agent_engine::session::wire::*;
use agent_engine::session::*;
use phase2::*;
use serial_test::serial;
struct DaemonProc {
child: Child,
paths: registry::DaemonPaths,
}
impl Drop for DaemonProc {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_synaps")
}
async fn spawn_daemon(guard: &HomeGuard, url: &str) -> DaemonProc {
spawn_daemon_env(guard, url, &[]).await
}
async fn spawn_daemon_env(guard: &HomeGuard, url: &str, extra: &[(&str, &str)]) -> DaemonProc {
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"])
.envs(extra.iter().copied())
.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 d = DaemonProc { child, paths };
let t0 = std::time::Instant::now();
loop {
if SocketTransport::ping(&d.paths.sock).await.is_ok() {
break;
}
assert!(t0.elapsed() < Duration::from_secs(20), "daemon never answered");
tokio::time::sleep(Duration::from_millis(100)).await;
}
d
}
async fn attach_create(paths: ®istry::DaemonPaths, cwd: &Path) -> (SocketTransport, AttachSnapshot) {
let conn = SocketTransport::connect(&paths.sock, Hello::new(ClientKind::Test)).await.unwrap();
SocketTransport::attach(
conn,
Attach::Create {
config: SessionConfig {
cwd: Some(cwd.to_path_buf()),
env: None,
model_override: Some("claude-sonnet-4-5".into()),
..Default::default()
},
mode: AttachMode::Mirror,
},
)
.await
.unwrap()
}
async fn one_turn(t: &mut SocketTransport, text: &str) -> Vec<agent_engine::SharedMessage> {
t.send_from_self(SessionCommand::Submit { text: text.into(), attachments: vec![] }).await.unwrap();
let mut msgs = None;
loop {
let env = tokio::time::timeout(Duration::from_secs(20), t.next_event()).await.expect("turn hung").expect("alive");
match env.event {
SessionEventWire::Conversation(c) => msgs = Some(c.api_messages),
SessionEventWire::Idle => break,
SessionEventWire::Refused { reason, .. } => panic!("refused: {reason}"),
_ => {}
}
}
msgs.expect("conversation after the turn")
}
fn fake_older_binary(dir: &Path) -> PathBuf {
let p = dir.join("synaps-old");
std::fs::write(
&p,
"#!/bin/sh\nif [ \"$2\" = \"--print-version\" ]; then echo '{\"binary_version\":\"0.0.1\",\"protocol_version\":3}'; exit 0; fi\nexit 1\n",
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
p
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn reload_keeps_pid_sessions_and_reconnects_clients() {
let guard = HomeGuard::new();
let (url, _hits, _) = spawn_stub(Script::Sse(ANTHROPIC_SSE)).await;
let d = spawn_daemon(&guard, &url).await;
let pid_before = d.child.id();
let info = registry::read_daemon_json(&d.paths).expect("daemon.json");
assert_eq!(info.pid, pid_before);
assert_eq!(info.generation, 1);
assert_eq!(info.exe.as_deref().map(|p| p.canonicalize().unwrap()), Some(Path::new(bin()).canonicalize().unwrap()));
let (mut t, snap) = attach_create(&d.paths, guard.home.path()).await;
assert_eq!(t.welcome.generation, 1);
assert_eq!(t.welcome.daemon_build, env!("SYNAPS_BUILD_ID"), "daemon reports its build id");
let same = agent_engine::session::transport::BinaryId { version: binary_version(), build: env!("SYNAPS_BUILD_ID").into() };
let daemon_id = agent_engine::session::transport::BinaryId::of(&t.welcome);
assert_eq!(agent_engine::session::transport::version_skew_notice(&same, &daemon_id, "s"), None);
if !daemon_id.build.is_empty() {
let other = agent_engine::session::transport::BinaryId { build: "0000000".into(), ..same };
assert!(agent_engine::session::transport::version_skew_notice(&other, &daemon_id, "s").is_some(), "another build of the same version is flagged");
}
assert_eq!(snap.input_owner, Some(t.client_id()), "first mirror owns");
let sid = t.session_id().clone();
let before = one_turn(&mut t, "hello before reload").await;
assert_eq!(before.len(), 2, "user + assistant");
let old = fake_older_binary(guard.home.path());
let r = SocketTransport::reload(&d.paths.sock, true, None, Some(old)).await;
assert!(matches!(r, Err(TransportError::Refused(ref m)) if m.contains("older")), "{r:?}");
assert!(registry::is_alive(&d.paths));
assert_eq!(registry::read_daemon_json(&d.paths).unwrap().generation, 1);
assert!(SocketTransport::ping(&d.paths.sock).await.is_ok());
let gen = SocketTransport::reload(&d.paths.sock, true, None, None).await.expect("reload accepted");
assert_eq!(gen, 2);
let mut saw_reloading = false;
while let Some(env) =
tokio::time::timeout(Duration::from_secs(10), t.next_event()).await.expect("announce")
{
if let SessionEventWire::Reloading { generation, retry_after_ms } = env.event {
assert_eq!(generation, 2);
assert!(retry_after_ms > 0);
saw_reloading = true;
}
}
assert!(saw_reloading);
assert!(matches!(t.last_error(), Some(TransportError::Reloading { generation: 2 })));
let snap = tokio::time::timeout(Duration::from_secs(15), t.reconnect(AttachMode::Mirror))
.await
.expect("reconnect budget")
.expect("reconnected");
assert_eq!(t.welcome.generation, 2);
assert_eq!(t.welcome.pid, pid_before, "same pid after execv");
assert_eq!(t.session_id(), &sid, "session id survives (same journal)");
assert_eq!(
serde_json::to_string(&snap.conversation.api_messages).unwrap(),
serde_json::to_string(&before).unwrap(),
"conversation survived the reload"
);
assert_eq!(snap.input_owner, Some(t.client_id()), "was_owner → Takeover → owner again");
let info = registry::read_daemon_json(&d.paths).unwrap();
assert_eq!(info.pid, pid_before);
assert_eq!(info.generation, 2);
assert!(registry::is_alive(&d.paths), "flock adopted, never released");
assert!(!agent_engine::daemon::reload::reload_state_path(&d.paths).exists(), "reload-state consumed");
let after = one_turn(&mut t, "hello after reload").await;
assert_eq!(after.len(), 4);
t.detach().await;
SocketTransport::shutdown(&d.paths.sock, false).await.unwrap();
let t0 = std::time::Instant::now();
while registry::is_alive(&d.paths) && t0.elapsed() < Duration::from_secs(10) {
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert!(!registry::is_alive(&d.paths));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn reload_with_turn_in_flight_checkpoints_and_keeps_the_partial_turn() {
let guard = HomeGuard::new();
let (url, _hits, _) = spawn_stub(Script::Endless(ANTHROPIC_SSE_PREFIX)).await;
let d = spawn_daemon(&guard, &url).await;
let pid_before = d.child.id();
let (mut t, _snap) = attach_create(&d.paths, guard.home.path()).await;
let sid = t.session_id().clone();
t.send_from_self(SessionCommand::Submit { text: "never finishes".into(), attachments: vec![] }).await.unwrap();
loop {
let env = tokio::time::timeout(Duration::from_secs(20), t.next_event()).await.unwrap().unwrap();
if matches!(env.event, SessionEventWire::Stream(agent_engine::StreamEvent::Llm(agent_engine::LlmEvent::Text(_)))) {
break;
}
}
let gen = SocketTransport::reload(&d.paths.sock, true, None, None).await.expect("reload accepted");
assert_eq!(gen, 2);
let mut notices = Vec::new();
let mut aborted = false;
while let Some(env) = tokio::time::timeout(Duration::from_secs(10), t.next_event()).await.expect("announce") {
match env.event {
SessionEventWire::SystemNotice(n) => notices.push(n),
SessionEventWire::Aborted { .. } => aborted = true,
_ => {}
}
}
assert!(aborted, "typed Aborted expected; notices: {notices:?}");
assert!(notices.iter().any(|n| n.contains("daemon reloading")), "{notices:?}");
let snap = tokio::time::timeout(Duration::from_secs(15), t.reconnect(AttachMode::Mirror)).await.unwrap().expect("reconnected");
assert_eq!(t.welcome.pid, pid_before);
assert_eq!(t.session_id(), &sid);
assert!(!snap.streaming, "the turn was checkpointed, not resumed");
assert!(snap.conversation.abort_context.is_none());
let msgs = &snap.conversation.api_messages;
assert_eq!(msgs.len(), 3, "{msgs:#?}");
assert_eq!(msgs[0]["content"], "never finishes");
assert_eq!(msgs[1]["role"], "assistant");
assert_eq!(msgs[1]["content"][0]["text"], "partial");
assert_eq!(
msgs[2]["content"],
agent_engine::engine::interrupt::InterruptReason::Restart.marker()
);
t.detach().await;
SocketTransport::shutdown(&d.paths.sock, true).await.unwrap();
}
async fn wait_listed_lifecycle(paths: ®istry::DaemonPaths, sid: &SessionId, want: SessionLifecycle, for_: Duration) -> bool {
let t0 = std::time::Instant::now();
loop {
let metas = SocketTransport::sessions(&paths.sock).await.unwrap_or_default();
if metas.iter().any(|m| &m.id == sid && m.lifecycle == want) {
return true;
}
if t0.elapsed() > for_ {
return false;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
async fn listed_pins(paths: ®istry::DaemonPaths, a: &SessionId, b: &SessionId) -> (bool, bool) {
let metas = SocketTransport::sessions(&paths.sock).await.expect("sessions");
let pinned = |id: &SessionId| metas.iter().find(|m| &m.id == id).map(|m| m.pinned).expect("listed");
(pinned(a), pinned(b))
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn reload_preserves_model_keep_warm_settings_and_parked() {
let guard = HomeGuard::new();
let plugin = install_slow_start_plugin(&guard);
let (url, _hits, _) = spawn_stub(Script::Sse(ANTHROPIC_SSE)).await;
let d = spawn_daemon_env(&guard, &url, &[("SYNAPS_DAEMON_PARK_GRACE_SECS", "0")]).await;
let pid_before = d.child.id();
let (mut a, snap) = attach_create(&d.paths, guard.home.path()).await;
assert_eq!(snap.view.model, "claude-sonnet-4-5");
let a_id = a.session_id().clone();
one_turn(&mut a, "hello").await;
a.send_from_self(SessionCommand::Set { id: 1, setting: SessionSetting::Model { model: "claude-opus-4-6".into() } }).await.unwrap();
a.send_from_self(SessionCommand::Set { id: 2, setting: SessionSetting::ContextWindow { tokens: Some(1_000_000) } }).await.unwrap();
a.send_from_self(SessionCommand::Set { id: 3, setting: SessionSetting::SystemPrompt { text: "reload-me".into() } }).await.unwrap();
a.send_from_self(SessionCommand::KeepWarm { on: true }).await.unwrap();
let mut seen = 0;
while seen < 3 {
let env = tokio::time::timeout(Duration::from_secs(10), a.next_event()).await.unwrap().unwrap();
if let SessionEventWire::SettingChanged(_) = env.event {
seen += 1;
}
}
a.detach().await;
let (mut b, _) = attach_create(&d.paths, guard.home.path()).await;
let b_id = b.session_id().clone();
one_turn(&mut b, "park me").await;
b.detach().await;
assert!(wait_listed_lifecycle(&d.paths, &b_id, SessionLifecycle::Parked, Duration::from_secs(10)).await, "B parks");
tokio::time::sleep(Duration::from_millis(500)).await;
assert!(wait_listed_lifecycle(&d.paths, &a_id, SessionLifecycle::Live, Duration::from_secs(1)).await, "A pinned stays Live");
assert_eq!(listed_pins(&d.paths, &a_id, &b_id).await, (true, false), "the listing shows A's pin");
let gen = SocketTransport::reload(&d.paths.sock, true, None, None).await.expect("reload accepted");
assert_eq!(gen, 2);
let t0 = std::time::Instant::now();
loop {
if registry::read_daemon_json(&d.paths).is_some_and(|i| i.generation == 2)
&& SocketTransport::ping(&d.paths.sock).await.is_ok()
{
break;
}
assert!(t0.elapsed() < Duration::from_secs(20), "new image never answered");
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert_eq!(registry::read_daemon_json(&d.paths).unwrap().pid, pid_before);
assert!(wait_listed_lifecycle(&d.paths, &b_id, SessionLifecycle::Parked, Duration::from_secs(10)).await, "B rehydrated Parked");
assert!(wait_listed_lifecycle(&d.paths, &a_id, SessionLifecycle::Live, Duration::from_secs(1)).await, "A rehydrated Live");
tokio::time::sleep(Duration::from_millis(500)).await;
assert!(wait_listed_lifecycle(&d.paths, &a_id, SessionLifecycle::Live, Duration::from_secs(1)).await, "A keep-warm survived (not parked after grace)");
assert_eq!(listed_pins(&d.paths, &a_id, &b_id).await, (true, false), "the listing shows the pin after reload");
assert!(wait_hook_count(&plugin, &a_id, 2, Duration::from_secs(10)).await, "A's hook after the restore");
tokio::time::sleep(Duration::from_secs(1)).await;
assert_eq!(hook_count(&plugin, &b_id), 1, "B is parked: no hook since the reload");
let conn = SocketTransport::connect(&d.paths.sock, Hello::new(ClientKind::Test)).await.unwrap();
let (a2, snap) = SocketTransport::attach(conn, Attach::Existing { session_id: a_id.clone(), mode: AttachMode::Mirror }).await.unwrap();
assert_eq!(snap.view.model, "claude-opus-4-6", "/model survived reload");
assert_eq!(snap.view.context_window, 1_000_000, "settings_replay survived reload");
assert_eq!(snap.view.system_prompt.as_deref(), Some("reload-me"), "/system survived reload");
assert_eq!(snap.conversation.api_messages.len(), 2);
a2.detach().await;
let conn = SocketTransport::connect(&d.paths.sock, Hello::new(ClientKind::Test)).await.unwrap();
let (b2, snap) = SocketTransport::attach(conn, Attach::Existing { session_id: b_id.clone(), mode: AttachMode::Mirror }).await.unwrap();
assert_eq!(snap.conversation.api_messages.len(), 2, "parked session's history survived reload");
assert!(wait_hook_count(&plugin, &b_id, 2, Duration::from_secs(10)).await, "B's hook after the unpark");
b2.detach().await;
tokio::time::sleep(Duration::from_secs(1)).await;
assert_eq!(hook_count(&plugin, &b_id), 2, "B: one hook after the unpark, not more");
assert_eq!(hook_count(&plugin, &a_id), 2, "A: one hook after the restore, not more");
SocketTransport::shutdown(&d.paths.sock, true).await.unwrap();
}
fn self_disarming_binary(dir: &Path) -> PathBuf {
let p = dir.join("synaps-disarm");
let pv = serde_json::to_string(&agent_engine::daemon::reload::PrintVersion::current()).unwrap();
std::fs::write(
&p,
format!("#!/bin/sh\nif [ \"$2\" = \"--print-version\" ]; then echo '{pv}'; chmod 644 \"$0\"; exit 0; fi\nexit 1\n"),
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
p
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn exe_validation_refuses_and_exec_failure_keeps_serving() {
use std::os::unix::fs::PermissionsExt;
let guard = HomeGuard::new();
let (url, _hits, _) = spawn_stub(Script::Sse(ANTHROPIC_SSE)).await;
let d = spawn_daemon(&guard, &url).await;
let pid = d.child.id();
let (mut t, _) = attach_create(&d.paths, guard.home.path()).await;
one_turn(&mut t, "before").await;
let missing = guard.home.path().join("does-not-exist");
let r = SocketTransport::reload(&d.paths.sock, true, None, Some(missing)).await;
assert!(matches!(r, Err(TransportError::Refused(ref m)) if m.contains("does-not-exist")), "{r:?}");
let noexec = fake_older_binary(guard.home.path());
std::fs::set_permissions(&noexec, std::fs::Permissions::from_mode(0o644)).unwrap();
let r = SocketTransport::reload(&d.paths.sock, true, None, Some(noexec.clone())).await;
assert!(matches!(r, Err(TransportError::Refused(ref m)) if m.contains("not executable")), "{r:?}");
std::fs::set_permissions(&noexec, std::fs::Permissions::from_mode(0o777)).unwrap();
let r = SocketTransport::reload(&d.paths.sock, true, None, Some(noexec)).await;
assert!(matches!(r, Err(TransportError::Refused(ref m)) if m.contains("writable")), "{r:?}");
assert_eq!(registry::read_daemon_json(&d.paths).unwrap().generation, 1);
let disarm = self_disarming_binary(guard.home.path());
let r = SocketTransport::reload(&d.paths.sock, true, None, Some(disarm)).await;
assert!(matches!(r, Ok(2)), "{r:?}");
let t0 = std::time::Instant::now();
loop {
let info = registry::read_daemon_json(&d.paths).unwrap();
if info.generation == 1
&& !agent_engine::daemon::reload::reload_state_path(&d.paths).exists()
&& SocketTransport::ping(&d.paths.sock).await.is_ok()
{
assert_eq!(info.pid, pid);
break;
}
assert!(t0.elapsed() < Duration::from_secs(30), "old image did not recover: {info:?}");
tokio::time::sleep(Duration::from_millis(200)).await;
}
assert!(registry::is_alive(&d.paths));
let snap = tokio::time::timeout(Duration::from_secs(15), t.reconnect(AttachMode::Mirror)).await.unwrap().expect("reconnected");
assert_eq!(snap.conversation.api_messages.len(), 2);
let after = one_turn(&mut t, "after").await;
assert_eq!(after.len(), 4);
let old = fake_older_binary(guard.home.path());
let r = SocketTransport::reload(&d.paths.sock, true, None, Some(old)).await;
assert!(matches!(r, Err(TransportError::Refused(ref m)) if m.contains("older")), "{r:?}");
t.detach().await;
SocketTransport::shutdown(&d.paths.sock, true).await.unwrap();
}
fn install_slow_start_plugin(guard: &HomeGuard) -> PathBuf {
let dir = guard.base_dir().join("plugins/slow-start");
std::fs::create_dir_all(dir.join(".synaps-plugin")).unwrap();
std::fs::create_dir_all(dir.join("extensions")).unwrap();
std::fs::copy(
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/slow_session_start_extension.py"),
dir.join("extensions/slow_session_start_extension.py"),
)
.unwrap();
std::fs::write(
dir.join(".synaps-plugin/plugin.json"),
r#"{
"name": "slow-start",
"version": "0.1.0",
"extension": {
"protocol_version": 1,
"runtime": "process",
"command": "python3",
"args": ["extensions/slow_session_start_extension.py"],
"permissions": ["session.lifecycle"],
"hooks": [{"hook": "on_session_start"}]
}
}
"#,
)
.unwrap();
dir
}
async fn sessions_with_one_turn(paths: ®istry::DaemonPaths, cwd: &Path, n: usize) -> Vec<SessionId> {
let mut ids = Vec::with_capacity(n);
for i in 0..n {
let (mut t, _) = attach_create(paths, cwd).await;
one_turn(&mut t, &format!("before reload {i}")).await;
ids.push(t.session_id().clone());
t.detach().await;
}
ids
}
async fn answered_within(paths: ®istry::DaemonPaths, generation: u64, t0: std::time::Instant, budget: Duration) -> Option<Duration> {
loop {
if t0.elapsed() > budget {
return None;
}
if registry::read_daemon_json(paths).is_some_and(|i| i.generation == generation)
&& tokio::time::timeout(Duration::from_millis(500), SocketTransport::ping(&paths.sock))
.await
.is_ok_and(|r| r.is_ok())
{
let took = t0.elapsed();
return (took <= budget).then_some(took);
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn reload_with_a_slow_session_start_hook_answers_within_seconds() {
const SESSIONS: usize = 30;
let guard = HomeGuard::new();
let plugin = install_slow_start_plugin(&guard);
let (url, _hits, _) = spawn_stub(Script::Sse(ANTHROPIC_SSE)).await;
let d = spawn_daemon(&guard, &url).await;
let pid_before = d.child.id();
let ids = sessions_with_one_turn(&d.paths, guard.home.path(), SESSIONS).await;
std::fs::write(plugin.join("slow-ms"), "3000").unwrap();
let gen = SocketTransport::reload(&d.paths.sock, true, None, None).await.expect("reload accepted");
assert_eq!(gen, 2);
let t0 = std::time::Instant::now();
let took = answered_within(&d.paths, 2, t0, Duration::from_secs(20)).await;
assert!(took.is_some(), "the new image did not answer within 20 s of the reload");
eprintln!("reload with {SESSIONS} slow hooks: answered after {took:?}");
assert_eq!(registry::read_daemon_json(&d.paths).unwrap().pid, pid_before);
let listed = SocketTransport::sessions(&d.paths.sock).await.expect("sessions");
for id in &ids {
assert!(listed.iter().any(|m| &m.id == id), "{id} listed after the reload");
}
for id in &ids {
let conn = SocketTransport::connect(&d.paths.sock, Hello::new(ClientKind::Test)).await.unwrap();
let (t, snap) = tokio::time::timeout(
Duration::from_secs(15),
SocketTransport::attach(conn, Attach::Existing { session_id: id.clone(), mode: AttachMode::Mirror }),
)
.await
.expect("attach budget")
.expect("reattached");
assert_eq!(t.session_id(), id, "same id, not a fresh session");
assert_eq!(snap.conversation.api_messages.len(), 2, "{id}: history survived");
t.detach().await;
}
SocketTransport::shutdown(&d.paths.sock, true).await.unwrap();
}
const SLOW_START_SENTINEL: &str = "SLOW-START-SENTINEL";
fn body_with(bodies: &Bodies, marker: &str) -> Option<serde_json::Value> {
let bodies = bodies.lock().unwrap_or_else(|p| p.into_inner());
bodies
.iter()
.filter_map(|b| serde_json::from_slice::<serde_json::Value>(b).ok())
.find(|b| b["messages"].to_string().contains(marker))
}
fn hook_count(plugin: &Path, id: &SessionId) -> usize {
std::fs::read_to_string(plugin.join("session-start.jsonl"))
.unwrap_or_default()
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.filter(|v| v["session_id"].as_str() == Some(id.as_str()))
.count()
}
async fn wait_hook_count(plugin: &Path, id: &SessionId, want: usize, for_: Duration) -> bool {
let t0 = std::time::Instant::now();
while hook_count(plugin, id) < want {
if t0.elapsed() > for_ {
return false;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
true
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn every_restored_session_gets_its_session_start_injection() {
const SESSIONS: usize = 30;
let guard = HomeGuard::new();
let plugin = install_slow_start_plugin(&guard);
let (url, _hits, bodies) = spawn_stub(Script::Sse(ANTHROPIC_SSE)).await;
let d = spawn_daemon(&guard, &url).await;
let ids = sessions_with_one_turn(&d.paths, guard.home.path(), SESSIONS).await;
for i in 0..SESSIONS {
let body = body_with(&bodies, &format!("before reload {i}")).expect("pre-reload request");
assert!(body["system"].to_string().contains(SLOW_START_SENTINEL), "fixture wired: session {i}");
}
std::fs::write(plugin.join("slow-ms"), "300").unwrap();
let log = plugin.join("session-start.jsonl");
let gen = SocketTransport::reload(&d.paths.sock, true, None, None).await.expect("reload accepted");
assert_eq!(gen, 2);
let t0 = std::time::Instant::now();
assert!(answered_within(&d.paths, 2, t0, Duration::from_secs(10)).await.is_some(), "new image answers");
let lines = || std::fs::read_to_string(&log).map(|s| s.lines().count()).unwrap_or(0);
while lines() < 2 * SESSIONS {
assert!(t0.elapsed() < Duration::from_secs(60), "hooks after the reload: {}", lines() - SESSIONS);
tokio::time::sleep(Duration::from_millis(200)).await;
}
for (i, id) in ids.iter().enumerate() {
let conn = SocketTransport::connect(&d.paths.sock, Hello::new(ClientKind::Test)).await.unwrap();
let (mut t, _) = SocketTransport::attach(conn, Attach::Existing { session_id: id.clone(), mode: AttachMode::Mirror })
.await
.expect("reattached");
one_turn(&mut t, &format!("after reload {i}")).await;
t.detach().await;
let body = body_with(&bodies, &format!("after reload {i}")).expect("post-reload request");
assert!(
body["system"].to_string().contains(SLOW_START_SENTINEL),
"session {i} lost its session-start injection in the restore"
);
}
SocketTransport::shutdown(&d.paths.sock, true).await.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn concurrent_first_turns_after_a_reload_keep_the_injection() {
const SESSIONS: usize = 30;
let guard = HomeGuard::new();
let plugin = install_slow_start_plugin(&guard);
let (url, _hits, bodies) = spawn_stub(Script::Sse(ANTHROPIC_SSE)).await;
let d = spawn_daemon(&guard, &url).await;
let ids = sessions_with_one_turn(&d.paths, guard.home.path(), SESSIONS).await;
std::fs::write(plugin.join("slow-ms"), "300").unwrap();
let gen = SocketTransport::reload(&d.paths.sock, true, None, None).await.expect("reload accepted");
assert_eq!(gen, 2);
let t0 = std::time::Instant::now();
assert!(answered_within(&d.paths, 2, t0, Duration::from_secs(20)).await.is_some(), "new image answers");
let paths = &d.paths;
let turns = ids.iter().enumerate().map(|(i, id)| async move {
let conn = SocketTransport::connect(&paths.sock, Hello::new(ClientKind::Test)).await.unwrap();
let (mut t, _) = SocketTransport::attach(conn, Attach::Existing { session_id: id.clone(), mode: AttachMode::Mirror })
.await
.expect("reattached");
one_turn(&mut t, &format!("after reload {i}")).await;
t.detach().await;
});
futures::future::join_all(turns).await;
let missing: Vec<usize> = (0..SESSIONS)
.filter(|i| {
let body = body_with(&bodies, &format!("after reload {i}")).expect("post-reload request");
!body["system"].to_string().contains(SLOW_START_SENTINEL)
})
.collect();
assert!(missing.is_empty(), "first turns without the session-start injection: {missing:?}");
tokio::time::sleep(Duration::from_secs(1)).await;
let counts: Vec<usize> = ids.iter().map(|id| hook_count(&plugin, id)).collect();
assert!(counts.iter().all(|&n| n == 2), "one hook before and one after the reload per session: {counts:?}");
SocketTransport::shutdown(&d.paths.sock, true).await.unwrap();
}
async fn event_within(t: &mut SocketTransport, budget: Duration) -> Option<SessionEventWire> {
match tokio::time::timeout(budget, t.next_event()).await {
Ok(Some(env)) => Some(env.event),
_ => None,
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[serial]
async fn the_session_start_wait_yields_to_cancel_and_end() {
const SESSIONS: usize = 3;
let guard = HomeGuard::new();
let plugin = install_slow_start_plugin(&guard);
let (url, _hits, bodies) = spawn_stub(Script::Sse(ANTHROPIC_SSE)).await;
let d = spawn_daemon(&guard, &url).await;
let ids = sessions_with_one_turn(&d.paths, guard.home.path(), SESSIONS).await;
std::fs::write(plugin.join("slow-ms"), "3000").unwrap();
let gen = SocketTransport::reload(&d.paths.sock, true, None, None).await.expect("reload accepted");
assert_eq!(gen, 2);
let t0 = std::time::Instant::now();
assert!(answered_within(&d.paths, 2, t0, Duration::from_secs(20)).await.is_some(), "new image answers");
let attach = |id: SessionId| {
let sock = d.paths.sock.clone();
async move {
let conn = SocketTransport::connect(&sock, Hello::new(ClientKind::Test)).await.unwrap();
SocketTransport::attach(conn, Attach::Existing { session_id: id, mode: AttachMode::Mirror })
.await
.expect("reattached")
.0
}
};
let (end_id, cancel_id) = (ids[1].clone(), ids[2].clone());
let mut y = attach(end_id.clone()).await;
y.send_from_self(SessionCommand::Submit { text: "end me".into(), attachments: vec![] }).await.unwrap();
tokio::time::sleep(Duration::from_millis(500)).await;
let asked = std::time::Instant::now();
y.send_from_self(SessionCommand::End { reason: EndReason::ClientQuit }).await.unwrap();
loop {
let left = Duration::from_secs(10).saturating_sub(asked.elapsed());
match event_within(&mut y, left).await {
Some(SessionEventWire::Ended { .. }) => break,
Some(SessionEventWire::Refused { reason, .. }) => panic!("End refused: {reason}"),
Some(_) => {}
None => panic!("no Ended within 10 s of the End"),
}
}
let took = asked.elapsed();
assert!(took < Duration::from_millis(1500), "End during the session-start wait took {took:?}");
let listed = SocketTransport::sessions(&d.paths.sock).await.expect("sessions");
assert!(!listed.iter().any(|m| m.id == end_id), "the ended session left the list");
drop(y);
let mut x = attach(cancel_id.clone()).await;
x.send_from_self(SessionCommand::Submit { text: "cancel me".into(), attachments: vec![] }).await.unwrap();
tokio::time::sleep(Duration::from_millis(500)).await;
let asked = std::time::Instant::now();
x.send_from_self(SessionCommand::Cancel).await.unwrap();
let mut aborted = false;
loop {
let left = Duration::from_secs(10).saturating_sub(asked.elapsed());
match event_within(&mut x, left).await {
Some(SessionEventWire::Aborted { .. }) => aborted = true,
Some(SessionEventWire::Idle) if aborted => break,
Some(_) => {}
None => panic!("no Aborted + Idle within 10 s of the Cancel"),
}
}
let took = asked.elapsed();
assert!(took < Duration::from_millis(1500), "Cancel during the session-start wait took {took:?}");
assert!(body_with(&bodies, "cancel me").is_none(), "a canceled turn sent nothing");
assert!(body_with(&bodies, "end me").is_none(), "an ended session's turn sent nothing");
assert!(wait_hook_count(&plugin, &cancel_id, 2, Duration::from_secs(15)).await, "the hook ran after the cancel");
one_turn(&mut x, "after the cancel").await;
let body = body_with(&bodies, "after the cancel").expect("request after the cancel");
assert!(body["system"].to_string().contains(SLOW_START_SENTINEL), "the next turn carries the injection");
assert_eq!(hook_count(&plugin, &cancel_id), 2, "one hook after the reload, not more");
x.detach().await;
SocketTransport::shutdown(&d.paths.sock, true).await.unwrap();
}