Skip to content
Open
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
57 changes: 52 additions & 5 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,11 @@ pub enum AcpError {
#[error("Agent did not stop within {0:?} after cancellation")]
CancelDrainTimeout(std::time::Duration),

#[error("Request timeout — agent did not respond within {0:?}")]
Timeout(std::time::Duration),
#[error("Request timeout — agent did not respond to {method} within {timeout:?}")]
Timeout {
method: String,
timeout: std::time::Duration,
},

#[error("Write timeout — agent stopped reading stdin (blocked for {0:?})")]
WriteTimeout(std::time::Duration),
Expand Down Expand Up @@ -966,6 +969,19 @@ impl AcpClient {
&mut self,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, AcpError> {
self.send_request_with_timeout(method, params, Self::REQUEST_TIMEOUT)
.await
}

/// [`send_request`](Self::send_request) with an explicit timeout instead
/// of `REQUEST_TIMEOUT` — the seam that lets tests exercise the timeout
/// path without a 60s wait.
async fn send_request_with_timeout(
&mut self,
method: &str,
params: serde_json::Value,
timeout: std::time::Duration,
) -> Result<serde_json::Value, AcpError> {
let id = self.next_id;
self.next_id += 1;
Expand All @@ -982,15 +998,22 @@ impl AcpClient {
// Wrap write + read in a single timeout so a hung agent can't block forever.
// We cannot use an async block that borrows `self` mutably across two awaits
// inside timeout(), so we sequence them with early-return on timeout.
let timeout = Self::REQUEST_TIMEOUT;
match tokio::time::timeout(timeout, self.write_ndjson(&msg)).await {
Ok(result) => result?,
Err(_) => return Err(AcpError::Timeout(timeout)),
Err(_) => {
return Err(AcpError::Timeout {
method: method.to_string(),
timeout,
})
}
}

match tokio::time::timeout(timeout, self.read_until_response(id)).await {
Ok(result) => result,
Err(_) => Err(AcpError::Timeout(timeout)),
Err(_) => Err(AcpError::Timeout {
method: method.to_string(),
timeout,
}),
}
}

Expand Down Expand Up @@ -2810,6 +2833,30 @@ mod tests {
assert_eq!(result.unwrap()["worked"], serde_json::json!(true));
}

/// A hung non-prompt RPC must surface WHICH method timed out. When an
/// agent stalls during `session/new` (e.g. goose blocking on MCP
/// extension startup while the package index is unreachable), a bare
/// "agent did not respond within 60s" gives the operator nothing to
/// act on — the error must name the hung method.
#[tokio::test]
async fn request_timeout_error_names_the_hung_rpc_method() {
// Agent that reads the request but never responds.
let mut client = spawn_script("read -t 5 _req; sleep 5").await;
let err = client
.send_request_with_timeout(
"session/new",
serde_json::json!({}),
std::time::Duration::from_millis(100),
)
.await
.expect_err("request against a silent agent must time out");
let msg = err.to_string();
assert!(
msg.contains("session/new"),
"timeout error must name the hung RPC method; got: {msg}"
);
}

#[tokio::test]
async fn keepalive_resets_idle_past_deadline() {
// Keepalive session/update lines every 50ms against a 100ms idle deadline.
Expand Down
2 changes: 1 addition & 1 deletion crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3029,7 +3029,7 @@ fn handle_prompt_result(
e,
acp::AcpError::Io(_)
| acp::AcpError::WriteTimeout(_)
| acp::AcpError::Timeout(_)
| acp::AcpError::Timeout { .. }
| acp::AcpError::Protocol(_)
);
let error_code = match &e {
Expand Down
14 changes: 10 additions & 4 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -911,7 +911,7 @@ async fn apply_model_switch(
// so the caller can respawn the agent instead of reusing a poisoned one.
Ok(Err(e @ AcpError::Io(_)))
| Ok(Err(e @ AcpError::WriteTimeout(_)))
| Ok(Err(e @ AcpError::Timeout(_)))
| Ok(Err(e @ AcpError::Timeout { .. }))
| Ok(Err(e @ AcpError::Protocol(_)))
| Ok(Err(e @ AcpError::AgentExited)) => {
tracing::error!(
Expand All @@ -934,7 +934,10 @@ async fn apply_model_switch(
target: "pool::model",
"model set via {method_label} timed out ({MODEL_SWITCH_TIMEOUT:?}) — treating as fatal"
);
return Err(AcpError::Timeout(MODEL_SWITCH_TIMEOUT));
return Err(AcpError::Timeout {
method: method_label,
timeout: MODEL_SWITCH_TIMEOUT,
});
}
}
Ok(())
Expand Down Expand Up @@ -987,7 +990,7 @@ async fn apply_permission_mode(
// so the caller can respawn the agent.
Ok(Err(e @ AcpError::Io(_)))
| Ok(Err(e @ AcpError::WriteTimeout(_)))
| Ok(Err(e @ AcpError::Timeout(_)))
| Ok(Err(e @ AcpError::Timeout { .. }))
| Ok(Err(e @ AcpError::Protocol(_)))
| Ok(Err(e @ AcpError::AgentExited)) => {
tracing::error!(
Expand All @@ -1009,7 +1012,10 @@ async fn apply_permission_mode(
target: "pool::permission",
"permission mode set timed out ({PERMISSION_MODE_TIMEOUT:?}) — treating as fatal"
);
return Err(AcpError::Timeout(PERMISSION_MODE_TIMEOUT));
return Err(AcpError::Timeout {
method: "session/set_config_option".to_string(),
timeout: PERMISSION_MODE_TIMEOUT,
});
}
}
Ok(())
Expand Down
Loading