Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
649 changes: 576 additions & 73 deletions src-tauri/src/acp/connection.rs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src-tauri/src/acp/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1794,6 +1794,7 @@ mod tests {
status: crate::acp::types::ConnectionStatus::Connected,
owner_window_label: "test-window".to_string(),
cmd_tx: tx,
handshake: tokio_util::sync::CancellationToken::new(),
state: Arc::new(RwLock::new(state)),
emitter: EventEmitter::Noop,
prompt_lock: Arc::new(tokio::sync::Mutex::new(())),
Expand Down
150 changes: 149 additions & 1 deletion src-tauri/src/acp/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,7 @@ impl ConnectionManager {
status: ConnectionStatus::Connected,
owner_window_label: "test-window".to_string(),
cmd_tx: tx,
handshake: tokio_util::sync::CancellationToken::new(),
state: Arc::new(tokio::sync::RwLock::new(state)),
emitter,
prompt_lock: Arc::new(tokio::sync::Mutex::new(())),
Expand Down Expand Up @@ -665,6 +666,7 @@ impl ConnectionManager {
status: ConnectionStatus::Connected,
owner_window_label: "test-window".to_string(),
cmd_tx: tx,
handshake: tokio_util::sync::CancellationToken::new(),
state: Arc::new(tokio::sync::RwLock::new(state)),
emitter,
prompt_lock: Arc::new(tokio::sync::Mutex::new(())),
Expand Down Expand Up @@ -2360,6 +2362,10 @@ impl ConnectionManager {
};
if let Some(conn) = removed {
tracing::info!("[ACP] disconnect connection={}", conn_id);
// First: a connection still in its handshake is not reading
// `cmd_tx`, and a full channel would hold the send below until it
// did. Cancelled, it stops and drops the receiver instead.
conn.handshake.cancel();
let _ = conn.cmd_tx.send(ConnectionCommand::Disconnect).await;
Ok(())
} else {
Expand Down Expand Up @@ -2624,6 +2630,7 @@ impl ConnectionManager {
// Same handoff as `disconnect`: closing a window leaves
// the agents exiting, not exited.
self.park_draining(&conn).await;
conn.handshake.cancel();
txs.push(conn.cmd_tx);
}
}
Expand Down Expand Up @@ -2710,6 +2717,7 @@ impl ConnectionManager {
// immediately, so the child is parked as draining to stay
// visible while it exits — and, here, to be swept below.
self.park_draining(&conn).await;
conn.handshake.cancel();
txs.push(conn.cmd_tx);
}
}
Expand Down Expand Up @@ -2817,7 +2825,10 @@ impl ConnectionManager {
let mut connections = self.connections.lock().await;
connections
.drain()
.map(|(_, conn)| (conn.cmd_tx, conn.child_pid))
.map(|(_, conn)| {
conn.handshake.cancel();
(conn.cmd_tx, conn.child_pid)
})
.collect()
};
let disconnected = handles.len();
Expand Down Expand Up @@ -4502,6 +4513,7 @@ mod tests {
status: ConnectionStatus::Connected,
owner_window_label: "test-window".to_string(),
cmd_tx: tx,
handshake: tokio_util::sync::CancellationToken::new(),
state: Arc::new(RwLock::new(state)),
emitter: EventEmitter::Noop,
prompt_lock: Arc::new(tokio::sync::Mutex::new(())),
Expand Down Expand Up @@ -4759,6 +4771,138 @@ mod tests {
);
}

/// Every path that ends a connection also cancels its handshake. The
/// `Disconnect` each sends goes unread until the session is established, so
/// a path that skipped the cancel would leave a connection still starting
/// up to finish its handshake — and its agent running — before it noticed.
#[tokio::test]
async fn every_disconnect_path_cancels_the_handshake() {
let mgr = ConnectionManager::new();
async fn connect(
mgr: &ConnectionManager,
id: &str,
agent_type: AgentType,
) -> tokio_util::sync::CancellationToken {
mgr.insert_test_connection(id, agent_type, None, EventEmitter::Noop)
.await;
mgr.connections.lock().await[id].handshake.clone()
}

let closed_tab = connect(&mgr, "closed-tab", AgentType::Codex).await;
mgr.disconnect("closed-tab").await.unwrap();
assert!(closed_tab.is_cancelled(), "disconnect");

let closed_window = connect(&mgr, "closed-window", AgentType::Codex).await;
mgr.disconnect_by_owner_window("test-window").await;
assert!(closed_window.is_cancelled(), "disconnect_by_owner_window");

let signed_out = connect(&mgr, "signed-out", AgentType::Antigravity).await;
mgr.disconnect_by_agent_type(AgentType::Antigravity).await;
assert!(signed_out.is_cancelled(), "disconnect_by_agent_type");

let quit = connect(&mgr, "quit", AgentType::Codex).await;
mgr.disconnect_all().await;
assert!(quit.is_cancelled(), "disconnect_all");
}

/// The case that made it matter: a draft switching agents disconnects the
/// connection it is leaving, whose agent may not have answered Initialize
/// yet. The `Disconnect` goes unread until the conversation loop starts,
/// and Initialize waits up to 60 s, so the agent process outlived the
/// disconnect by up to a minute.
///
/// The agent is `sleep`, launched as a custom binary agent: it never
/// answers. Unix-only (relies on `sleep` / `kill(2)`).
#[cfg(unix)]
#[allow(clippy::await_holding_lock)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn disconnect_stops_an_agent_that_has_not_answered_initialize() {
use crate::acp::custom_registry::{
hydrate, hydrate_test_guard, BinaryPlatformSpec, CustomAgentDef, CustomAgentSpec,
CustomDistributionKind,
};

// `hydrate` replaces a process-global map; hold its lock throughout.
let _registry = hydrate_test_guard();
let def = CustomAgentDef {
registry_id: "never-answers".into(),
name: "Never answers".into(),
description: String::new(),
version: "1.0.0".into(),
distribution_kind: CustomDistributionKind::Binary,
spec: CustomAgentSpec {
binary: BTreeMap::from([(
crate::acp::registry::current_platform().to_string(),
BinaryPlatformSpec {
archive: "https://example.invalid/never-answers.tar.gz".into(),
// Nothing cached under this id, so the launch falls
// back to `sleep` on PATH.
cmd: "sleep".into(),
args: vec!["120".into()],
env: BTreeMap::new(),
sha256: None,
},
)]),
..Default::default()
},
icon_url: None,
skills_shared_store: false,
skills_dir: None,
source: Default::default(),
version_probe: None,
supports_mcp: true,
};
assert!(hydrate(std::slice::from_ref(&def)).is_empty());
let agent_type = AgentType::custom("never-answers").unwrap();

let mgr = ConnectionManager::new();
let dir = tempfile::tempdir().expect("tempdir");
let id = mgr
.spawn_agent(
agent_type,
Some(dir.path().to_string_lossy().into_owned()),
None,
BTreeMap::new(),
"test-window".to_string(),
EventEmitter::Noop,
None,
BTreeMap::new(),
)
.await
.expect("the agent launches");
let pid_cell = Arc::clone(&mgr.connections.lock().await[&id].child_pid);
let mut pid = 0;
for _ in 0..500 {
pid = pid_cell.load(std::sync::atomic::Ordering::SeqCst);
if pid != 0 {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert_ne!(pid, 0, "the agent process never started");
// Up and still waiting to be answered, so whatever ends it below is
// the disconnect and not the agent leaving on its own.
assert!(is_alive(pid as i32), "the agent exited on its own");

mgr.disconnect(&id).await.unwrap();

// Longer than `wait_until_dead` allows, for a loaded CI machine; the
// handshake used to hold on for up to 60 s.
let mut gone = false;
for _ in 0..500 {
if !is_alive(pid as i32) {
gone = true;
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
hydrate(&[]);
assert!(
gone,
"agent {pid} outlived its disconnect — the handshake kept it running"
);
}

/// Like [`spawn_process_tree`], but both levels ignore `SIGTERM`: the shell
/// sets it to `SIG_IGN`, which `sleep` then inherits across `exec`. Stands
/// in for an agent that traps it and takes its time — or never leaves.
Expand Down Expand Up @@ -4957,6 +5101,7 @@ mod tests {
status: ConnectionStatus::Connected,
owner_window_label: "test-window".to_string(),
cmd_tx: tx,
handshake: tokio_util::sync::CancellationToken::new(),
state: Arc::new(RwLock::new(state)),
emitter: EventEmitter::Noop,
prompt_lock: Arc::new(tokio::sync::Mutex::new(())),
Expand Down Expand Up @@ -5850,6 +5995,7 @@ mod tests {
status: ConnectionStatus::Connected,
owner_window_label: "test-window".to_string(),
cmd_tx: tx,
handshake: tokio_util::sync::CancellationToken::new(),
state: Arc::new(RwLock::new(state)),
emitter: EventEmitter::Noop,
prompt_lock: Arc::new(tokio::sync::Mutex::new(())),
Expand Down Expand Up @@ -7533,6 +7679,7 @@ mod tests {
status: ConnectionStatus::Connected,
owner_window_label: "test-window".to_string(),
cmd_tx: tx,
handshake: tokio_util::sync::CancellationToken::new(),
state: Arc::new(RwLock::new(state)),
emitter: EventEmitter::Noop,
prompt_lock: Arc::new(tokio::sync::Mutex::new(())),
Expand Down Expand Up @@ -8257,6 +8404,7 @@ mod tests {
status: ConnectionStatus::Connected,
owner_window_label: "test-window".to_string(),
cmd_tx: tx,
handshake: tokio_util::sync::CancellationToken::new(),
state: Arc::new(RwLock::new(state)),
emitter: EventEmitter::Noop,
prompt_lock: Arc::new(tokio::sync::Mutex::new(())),
Expand Down
Loading
Loading