mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 01:49:49 -10:00
Merge pull request #6802 from Hmbown/integration/pr6741-mcp-budget-20260930
Verified original PR #6802 at c7694c140e: exact-head Linux, macOS and Windows tests and doctests, required checks and resolved review threads. Contributor authorship and existing history are preserved.
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
4 files changed
+528
-52
No files matched your search
+125
-44
@@ -532,8 +532,15 @@ pub struct McpTimeouts {
|
||||
fn default_connect_timeout() -> u64 {
|
||||
30
|
||||
}
|
||||
// 30 minutes: an MCP tool call legitimately runs minutes — builds, test
|
||||
// suites, scrapes, remote jobs. The old 60s default returned "timed out" to
|
||||
// the model for healthy-but-slow tools, which then retried and compounded
|
||||
// the cost. Per-server and global `execute_timeout` overrides still win.
|
||||
// Scope note: `prompts/get` also routes through `effective_execute_timeout`,
|
||||
// so it inherits this default; its server-side template work is normally
|
||||
// fast, but the override knob is the intended way to keep it tight.
|
||||
fn default_execute_timeout() -> u64 {
|
||||
60
|
||||
1800
|
||||
}
|
||||
fn default_read_timeout() -> u64 {
|
||||
120
|
||||
@@ -1756,6 +1763,20 @@ impl Drop for PendingAuthorityWatch {
|
||||
}
|
||||
}
|
||||
|
||||
/// Total request ceiling handed to the HTTP client: it must cover the longest
|
||||
/// request the connection carries (`tools/call` at the execute budget), so a
|
||||
/// raised `execute_timeout` governs HTTP servers too. This is a ceiling for
|
||||
/// the transport, not the read knob.
|
||||
///
|
||||
/// Streamable HTTP reads the reply inside the POST; `call_method` bounds the
|
||||
/// send and the receive with the request's own budget, so this ceiling is
|
||||
/// only the transport's outer safety net for requests without one.
|
||||
fn http_request_ceiling_secs(config: &McpServerConfig, global: &McpTimeouts) -> u64 {
|
||||
config
|
||||
.effective_read_timeout(global)
|
||||
.max(config.effective_execute_timeout(global))
|
||||
}
|
||||
|
||||
impl McpConnection {
|
||||
/// Connect to an MCP server and initialize it.
|
||||
///
|
||||
@@ -1817,7 +1838,12 @@ impl McpConnection {
|
||||
config.allow_private_network,
|
||||
network_policy,
|
||||
Duration::from_secs(connect_timeout_secs),
|
||||
Duration::from_secs(read_timeout_secs),
|
||||
// Transport total-request ceiling, not the read knob: it must
|
||||
// cover the longest request this connection carries
|
||||
// (`tools/call` at the execute budget), so a raised
|
||||
// `execute_timeout` governs HTTP servers too. The read knob
|
||||
// itself stays intact for the connection-level waits below.
|
||||
Duration::from_secs(http_request_ceiling_secs(&config, global_timeouts)),
|
||||
)?;
|
||||
let oauth_runtime = if config.reviewed_plugin.is_some() {
|
||||
None
|
||||
@@ -2408,31 +2434,67 @@ impl McpConnection {
|
||||
}
|
||||
|
||||
let call_id = self.next_id();
|
||||
if let Err(error) = self
|
||||
.send(serde_json::json!({
|
||||
// One deadline bounds the whole request. Streamable HTTP reads the
|
||||
// reply inside the POST, so a budget on the receive alone would leave
|
||||
// that transport to its client-wide ceiling (the larger of the read
|
||||
// and execute knobs) instead of this request's own budget.
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_secs);
|
||||
let expired_error = anyhow::anyhow!(
|
||||
"MCP method '{}' on server '{}' timed out after {}s",
|
||||
method,
|
||||
self.name,
|
||||
timeout_secs
|
||||
);
|
||||
match tokio::time::timeout_at(
|
||||
deadline,
|
||||
self.send(serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": &call_id,
|
||||
"method": method,
|
||||
"params": params
|
||||
}))
|
||||
.await
|
||||
})),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return self.finish_guarded_error(error).await;
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(error)) => return self.finish_guarded_error(error).await,
|
||||
Err(_) => {
|
||||
// A send abandoned mid-write can leave a partial frame on a
|
||||
// stream transport, so the frame boundary is unknown: rebuild
|
||||
// the connection rather than reuse it.
|
||||
self.state = ConnectionState::Disconnected;
|
||||
return self.finish_guarded_error(expired_error).await;
|
||||
}
|
||||
}
|
||||
|
||||
let response =
|
||||
match tokio::time::timeout(Duration::from_secs(timeout_secs), self.recv(call_id))
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"MCP method '{}' on server '{}' timed out after {}s",
|
||||
method, self.name, timeout_secs
|
||||
)
|
||||
}) {
|
||||
Ok(Ok(response)) => response,
|
||||
Ok(Err(error)) => return self.finish_guarded_error(error).await,
|
||||
Err(error) => return self.finish_guarded_error(error).await,
|
||||
};
|
||||
// The request's own budget is its only receive deadline. A per-frame
|
||||
// read-knob wait here either undercut it — a server is silent for the
|
||||
// whole execution of a tool call, so the knob fired first, marked the
|
||||
// connection Disconnected, and capped a raised `execute_timeout` — or
|
||||
// tied with it, leaving the connection's fate to timer order. On
|
||||
// expiry the request is abandoned and the connection kept: a late
|
||||
// reply carries the abandoned id and the next receive skips it.
|
||||
//
|
||||
// Known limitation: the server is never told a request was abandoned
|
||||
// (no `notifications/cancelled`), whether by this budget or by the
|
||||
// caller dropping the call (turn cancellation). A server that handles
|
||||
// requests one at a time answers the next call only after finishing
|
||||
// the abandoned one, so that call can wait up to its own budget —
|
||||
// 1800s for `tools/call` by default. Cancelling `cancel_token` instead
|
||||
// marks the connection dead, so the pool rebuilds it (a new child for
|
||||
// stdio) before the next call.
|
||||
let response = match tokio::time::timeout_at(deadline, self.recv_reply(call_id, None))
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"MCP method '{}' on server '{}' timed out after {}s",
|
||||
method, self.name, timeout_secs
|
||||
)
|
||||
}) {
|
||||
Ok(Ok(response)) => response,
|
||||
Ok(Err(error)) => return self.finish_guarded_error(error).await,
|
||||
Err(error) => return self.finish_guarded_error(error).await,
|
||||
};
|
||||
|
||||
if let Some(error) = response.get("error") {
|
||||
if self.config.reviewed_plugin.is_some() {
|
||||
@@ -2551,34 +2613,53 @@ impl McpConnection {
|
||||
result
|
||||
}
|
||||
|
||||
/// Handshake and discovery receive: each frame wait is bounded by the read
|
||||
/// knob, and a server that stays silent past it is treated as dead.
|
||||
async fn recv(&mut self, expected_id: String) -> Result<serde_json::Value> {
|
||||
loop {
|
||||
let bytes = match tokio::time::timeout(
|
||||
Duration::from_secs(self.read_timeout_secs),
|
||||
async {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = self.cancel_token.cancelled() => {
|
||||
anyhow::bail!("MCP connection '{}' was cancelled", self.name)
|
||||
}
|
||||
result = self.transport.recv() => result,
|
||||
}
|
||||
},
|
||||
)
|
||||
self.recv_reply(expected_id, Some(self.read_timeout_secs))
|
||||
.await
|
||||
{
|
||||
Ok(result) => result.inspect_err(|_e| {
|
||||
self.state = ConnectionState::Disconnected;
|
||||
})?,
|
||||
Err(_) => {
|
||||
self.state = ConnectionState::Disconnected;
|
||||
anyhow::bail!(
|
||||
"Timed out waiting for MCP JSON-RPC response from server '{}' after {}s",
|
||||
self.name,
|
||||
self.read_timeout_secs
|
||||
);
|
||||
}
|
||||
|
||||
/// The next transport frame, unless the connection is cancelled first.
|
||||
async fn next_frame(&mut self) -> Result<Vec<u8>> {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = self.cancel_token.cancelled() => {
|
||||
anyhow::bail!("MCP connection '{}' was cancelled", self.name)
|
||||
}
|
||||
result = self.transport.recv() => result,
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive the reply to `expected_id`, skipping notifications and replies
|
||||
/// to other (abandoned) requests. `frame_timeout_secs` bounds each frame
|
||||
/// wait and treats its expiry as a dead connection; `None` leaves the
|
||||
/// whole wait to the caller's own request budget.
|
||||
async fn recv_reply(
|
||||
&mut self,
|
||||
expected_id: String,
|
||||
frame_timeout_secs: Option<u64>,
|
||||
) -> Result<serde_json::Value> {
|
||||
loop {
|
||||
let frame = match frame_timeout_secs {
|
||||
Some(secs) => {
|
||||
match tokio::time::timeout(Duration::from_secs(secs), self.next_frame()).await {
|
||||
Ok(frame) => frame,
|
||||
Err(_) => {
|
||||
self.state = ConnectionState::Disconnected;
|
||||
anyhow::bail!(
|
||||
"Timed out waiting for MCP JSON-RPC response from server '{}' after {}s",
|
||||
self.name,
|
||||
secs
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => self.next_frame().await,
|
||||
};
|
||||
let bytes = frame.inspect_err(|_e| {
|
||||
self.state = ConnectionState::Disconnected;
|
||||
})?;
|
||||
let value: serde_json::Value = match serde_json::from_slice(&bytes) {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
|
||||
+387
-2
@@ -79,7 +79,7 @@ fn mark_workspace_trusted(workspace: &Path) -> WorkspaceTrustConfigGuard {
|
||||
fn test_mcp_config_defaults() {
|
||||
let config = McpConfig::default();
|
||||
assert_eq!(config.timeouts.connect_timeout, 30);
|
||||
assert_eq!(config.timeouts.execute_timeout, 60);
|
||||
assert_eq!(config.timeouts.execute_timeout, 1800);
|
||||
assert_eq!(config.timeouts.read_timeout, 120);
|
||||
assert!(config.servers.is_empty());
|
||||
}
|
||||
@@ -2805,7 +2805,10 @@ fn test_server_effective_timeouts() {
|
||||
};
|
||||
|
||||
assert_eq!(server_with_override.effective_connect_timeout(&global), 20);
|
||||
assert_eq!(server_with_override.effective_execute_timeout(&global), 60); // global default
|
||||
assert_eq!(
|
||||
server_with_override.effective_execute_timeout(&global),
|
||||
1800
|
||||
); // global default
|
||||
assert_eq!(server_with_override.effective_read_timeout(&global), 180);
|
||||
}
|
||||
|
||||
@@ -2884,6 +2887,21 @@ impl McpTransport for ScriptedThenHangingTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/// A transport that answers inside `send`, as Streamable HTTP reads the reply
|
||||
/// within the POST, and never finishes that send.
|
||||
struct HangingSendTransport;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl McpTransport for HangingSendTransport {
|
||||
async fn send(&mut self, _msg: Vec<u8>) -> Result<()> {
|
||||
std::future::pending().await
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Result<Vec<u8>> {
|
||||
std::future::pending().await
|
||||
}
|
||||
}
|
||||
|
||||
/// A transport whose write side is gone — the shape a crashed or exited
|
||||
/// stdio MCP child leaves behind (EPIPE on the next `write_all`).
|
||||
struct FailingSendTransport;
|
||||
@@ -3047,6 +3065,126 @@ fn json_frame(value: serde_json::Value) -> Vec<u8> {
|
||||
serde_json::to_vec(&value).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_request_ceiling_covers_the_execute_budget() {
|
||||
let mut config = test_server_config();
|
||||
config.execute_timeout = Some(1800);
|
||||
config.read_timeout = Some(120);
|
||||
let global = McpTimeouts {
|
||||
connect_timeout: 10,
|
||||
execute_timeout: 1800,
|
||||
read_timeout: 120,
|
||||
};
|
||||
assert_eq!(http_request_ceiling_secs(&config, &global), 1800);
|
||||
|
||||
// A per-server read knob above both stays intact.
|
||||
config.read_timeout = Some(3600);
|
||||
assert_eq!(http_request_ceiling_secs(&config, &global), 3600);
|
||||
}
|
||||
|
||||
/// A transport that stays silent for a fixed delay, then answers with a
|
||||
/// matching result — the shape of an MCP server executing a long tool call.
|
||||
struct DelayedResponseTransport {
|
||||
delay: Duration,
|
||||
pending: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl McpTransport for DelayedResponseTransport {
|
||||
async fn send(&mut self, msg: Vec<u8>) -> Result<()> {
|
||||
// Echo the request id so every call gets its matching response.
|
||||
let request: serde_json::Value = serde_json::from_slice(&msg)?;
|
||||
let response = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": request["id"].clone(),
|
||||
"result": {"ok": true}
|
||||
});
|
||||
self.pending = Some(json_frame(response));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Result<Vec<u8>> {
|
||||
tokio::time::sleep(self.delay).await;
|
||||
self.pending.take().context("delayed transport exhausted")
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_tool_response_after_the_read_knob_still_completes_within_the_execute_budget() {
|
||||
let mut connection = test_connection(Box::new(DelayedResponseTransport {
|
||||
// The response arrives after the read knob (1s) but well inside the
|
||||
// execute budget (30s): the read knob must not bound a request's
|
||||
// receive and mark the connection dead before the reply lands.
|
||||
delay: Duration::from_millis(1500),
|
||||
pending: None,
|
||||
}));
|
||||
connection.read_timeout_secs = 1;
|
||||
let result = connection
|
||||
.call_tool("slow", serde_json::json!({}), 30)
|
||||
.await
|
||||
.expect("a silent tool execution must not be cut off by the read knob");
|
||||
assert_eq!(result, serde_json::json!({"ok": true}));
|
||||
assert!(
|
||||
connection.is_ready(),
|
||||
"a completed call keeps the connection"
|
||||
);
|
||||
}
|
||||
|
||||
/// A wedged server fails a request at the request's own budget, and the
|
||||
/// request is abandoned, not the connection: its late reply carries the
|
||||
/// abandoned id and is skipped (see the stdio fixtures below). The read knob
|
||||
/// (1s) and the budget (2s) are distinct on purpose: equal deadlines used to
|
||||
/// race, and whichever timer won decided whether the connection survived.
|
||||
/// The per-frame read-knob disconnect is still pinned for the handshake by
|
||||
/// `recv_times_out_waiting_for_mcp_response_and_disconnects`.
|
||||
#[tokio::test]
|
||||
async fn a_wedged_request_fails_at_its_own_budget_and_keeps_the_connection() {
|
||||
let mut connection = test_connection(Box::new(HangingValueTransport {
|
||||
sent: Arc::new(Mutex::new(Vec::new())),
|
||||
}));
|
||||
connection.read_timeout_secs = 1;
|
||||
let error = connection
|
||||
.read_resource("file:///wedged", 2)
|
||||
.await
|
||||
.expect_err("a wedged server must fail the request at its budget");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("MCP method 'resources/read' on server 'mock' timed out after 2s"),
|
||||
"the request budget, not the read knob, must end the request: {error:#}"
|
||||
);
|
||||
assert!(
|
||||
connection.is_ready(),
|
||||
"an expired request must not declare the connection dead"
|
||||
);
|
||||
}
|
||||
|
||||
/// A request whose transport blocks inside `send` (Streamable HTTP) still
|
||||
/// ends at its own budget, not at the transport's larger client ceiling, and
|
||||
/// the connection is rebuilt because the abandoned write may be partial.
|
||||
#[tokio::test]
|
||||
async fn a_request_blocked_inside_send_ends_at_its_own_budget() {
|
||||
let mut connection = test_connection(Box::new(HangingSendTransport));
|
||||
connection.read_timeout_secs = 30;
|
||||
let error = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(10),
|
||||
connection.read_resource("file:///wedged-post", 1),
|
||||
)
|
||||
.await
|
||||
.expect("the request budget, not the transport, must end a blocked send")
|
||||
.expect_err("a POST that never completes must end at the request budget");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("MCP method 'resources/read' on server 'mock' timed out after 1s"),
|
||||
"{error:#}"
|
||||
);
|
||||
assert!(
|
||||
!connection.is_ready(),
|
||||
"a send abandoned mid-write leaves the frame boundary unknown, so the connection is rebuilt"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_method_skips_notifications_and_unmatched_responses() {
|
||||
let sent = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -9093,6 +9231,253 @@ async fn json_rpc_session_error_on_tool_call_is_not_replayed() {
|
||||
assert_eq!(server_call_count(&call_log), 2);
|
||||
}
|
||||
|
||||
/// A sequential stdio server for the `tools/call` deadline fixtures. Every
|
||||
/// `initialize` appends `init <pid>` to `$INIT_LOG`, so a test can tell a
|
||||
/// reused connection from a rebuilt one; every `tools/call` appends
|
||||
/// `<tool> <id>` to `$CALL_LOG` before it runs, and its reply text is that
|
||||
/// same line, so a test can tell which request a reply answers. `slow` sleeps
|
||||
/// `$SLOW_SECS` before replying, `hang` never replies, `fast` replies at once.
|
||||
#[cfg(unix)]
|
||||
const DEADLINE_STDIO_SERVER: &str = r#"#!/bin/sh
|
||||
while IFS= read -r line; do
|
||||
id=$(printf '%s\n' "$line" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p')
|
||||
case "$line" in
|
||||
*'"method":"notifications/'*)
|
||||
;;
|
||||
*'"method":"initialize"'*)
|
||||
echo "init $$" >> "$INIT_LOG"
|
||||
printf '{"jsonrpc":"2.0","id":"%s","result":{"protocolVersion":"2024-11-05","serverInfo":{"name":"deadline","version":"1.0.0"},"capabilities":{"tools":{}}}}\n' "$id"
|
||||
;;
|
||||
*'"method":"tools/list"'*)
|
||||
printf '{"jsonrpc":"2.0","id":"%s","result":{"tools":[{"name":"slow","inputSchema":{"type":"object"}},{"name":"fast","inputSchema":{"type":"object"}},{"name":"hang","inputSchema":{"type":"object"}}]}}\n' "$id"
|
||||
;;
|
||||
*'"method":"tools/call"'*)
|
||||
tool=$(printf '%s\n' "$line" | sed -n 's/.*"name":"\([^"]*\)".*/\1/p')
|
||||
echo "$tool $id" >> "$CALL_LOG"
|
||||
case "$tool" in
|
||||
slow) sleep "$SLOW_SECS" ;;
|
||||
hang) while :; do sleep 0.05; done ;;
|
||||
esac
|
||||
printf '{"jsonrpc":"2.0","id":"%s","result":{"content":[{"type":"text","text":"%s %s"}]}}\n' "$id" "$tool" "$id"
|
||||
;;
|
||||
*)
|
||||
[ -n "$id" ] && printf '{"jsonrpc":"2.0","id":"%s","result":{}}\n' "$id"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
"#;
|
||||
|
||||
/// A pool with one `deadline` stdio server; returns it with the call and
|
||||
/// init log paths.
|
||||
#[cfg(unix)]
|
||||
fn deadline_stdio_pool(
|
||||
dir: &Path,
|
||||
slow_secs: &str,
|
||||
configure: impl FnOnce(&mut McpServerConfig),
|
||||
) -> (McpPool, PathBuf, PathBuf) {
|
||||
let script = dir.join("server.sh");
|
||||
fs::write(&script, DEADLINE_STDIO_SERVER).unwrap();
|
||||
let call_log = dir.join("calls.log");
|
||||
let init_log = dir.join("inits.log");
|
||||
let mut server = test_server_config();
|
||||
server.command = Some("sh".to_string());
|
||||
server.args = vec![script.to_string_lossy().into_owned()];
|
||||
for (key, value) in [
|
||||
("CALL_LOG", call_log.to_string_lossy().into_owned()),
|
||||
("INIT_LOG", init_log.to_string_lossy().into_owned()),
|
||||
("SLOW_SECS", slow_secs.to_string()),
|
||||
] {
|
||||
server.env.insert(key.to_string(), value);
|
||||
}
|
||||
configure(&mut server);
|
||||
let mut cfg = McpConfig::default();
|
||||
cfg.servers.insert("deadline".to_string(), server);
|
||||
(McpPool::new(cfg), call_log, init_log)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn log_lines(path: &Path) -> Vec<String> {
|
||||
fs::read_to_string(path)
|
||||
.map(|log| log.lines().map(str::to_string).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The result `DEADLINE_STDIO_SERVER` returns for the call it logged as `call`.
|
||||
#[cfg(unix)]
|
||||
fn deadline_reply(call: &str) -> serde_json::Value {
|
||||
serde_json::json!({ "content": [{ "type": "text", "text": call }] })
|
||||
}
|
||||
|
||||
/// #6741 over a real stdio child: a `tools/call` whose reply arrives after
|
||||
/// the read knob — the receive budget every request used to get — but inside
|
||||
/// the execute budget completes, and the next call on the same connection gets
|
||||
/// its own reply. The late frame is consumed once, by the request it answers;
|
||||
/// nothing is replayed and the child is not restarted.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn stdio_tool_reply_after_the_read_knob_completes_within_the_execute_budget() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut pool, call_log, init_log) = deadline_stdio_pool(dir.path(), "3", |server| {
|
||||
server.read_timeout = Some(1);
|
||||
server.execute_timeout = Some(30);
|
||||
});
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let slow = pool
|
||||
.call_tool("mcp_deadline_slow", serde_json::json!({}))
|
||||
.await
|
||||
.expect("a reply inside the execute budget must not be cut off at the read knob");
|
||||
assert!(
|
||||
started.elapsed() >= Duration::from_secs(3),
|
||||
"the reply must really have outlived the 1s read knob"
|
||||
);
|
||||
let fast = pool
|
||||
.call_tool("mcp_deadline_fast", serde_json::json!({}))
|
||||
.await
|
||||
.expect("the next call on the same connection must get its own reply");
|
||||
|
||||
let calls = log_lines(&call_log);
|
||||
assert_eq!(calls.len(), 2, "neither call may be replayed: {calls:?}");
|
||||
assert!(calls[0].starts_with("slow ") && calls[1].starts_with("fast "));
|
||||
assert_eq!(slow, deadline_reply(&calls[0]));
|
||||
assert_eq!(fast, deadline_reply(&calls[1]));
|
||||
assert_eq!(
|
||||
log_lines(&init_log).len(),
|
||||
1,
|
||||
"both calls must share one connection and child"
|
||||
);
|
||||
}
|
||||
|
||||
/// An explicit `execute_timeout` shorter than the read knob still governs a
|
||||
/// real stdio `tools/call`: the call fails at its own 2s budget, not at the
|
||||
/// 120s read knob or the 1800s default. The request is abandoned, not the
|
||||
/// connection: the child's late reply to it reaches the pipe ahead of the
|
||||
/// next call's reply and is skipped, so the next call gets its own reply on
|
||||
/// the same child.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn explicit_shorter_execute_budget_ends_a_stdio_tool_call_and_skips_its_late_reply() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut pool, call_log, init_log) = deadline_stdio_pool(dir.path(), "3", |server| {
|
||||
server.execute_timeout = Some(2);
|
||||
});
|
||||
pool.get_or_connect("deadline").await.unwrap();
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let error = pool
|
||||
.call_tool("mcp_deadline_slow", serde_json::json!({}))
|
||||
.await
|
||||
.expect_err("an explicit shorter budget must end the call");
|
||||
let elapsed = started.elapsed();
|
||||
assert!(
|
||||
format!("{error:#}")
|
||||
.contains("MCP method 'tools/call' on server 'deadline' timed out after 2s"),
|
||||
"unexpected error: {error:#}"
|
||||
);
|
||||
assert!(
|
||||
elapsed < Duration::from_secs(3),
|
||||
"the explicit budget, not the 3s reply, must end the call: {elapsed:?}"
|
||||
);
|
||||
|
||||
let fast = pool
|
||||
.call_tool("mcp_deadline_fast", serde_json::json!({}))
|
||||
.await
|
||||
.expect("the kept connection must answer the next call");
|
||||
let calls = log_lines(&call_log);
|
||||
assert_eq!(calls.len(), 2, "neither call may be replayed: {calls:?}");
|
||||
assert!(calls[0].starts_with("slow ") && calls[1].starts_with("fast "));
|
||||
assert_eq!(
|
||||
fast,
|
||||
deadline_reply(&calls[1]),
|
||||
"the abandoned call's late reply must not answer the next call"
|
||||
);
|
||||
assert_eq!(
|
||||
log_lines(&init_log).len(),
|
||||
1,
|
||||
"an expired request must not rebuild the connection"
|
||||
);
|
||||
}
|
||||
|
||||
/// Owned cancellation of an in-flight stdio `tools/call` under the default
|
||||
/// 1800s execute budget: cancelling the connection's own token ends the call
|
||||
/// promptly instead of waiting out the budget, and marks the connection dead.
|
||||
/// The next call rebuilds it on a fresh child, the cancelled call's child is
|
||||
/// terminated rather than left running, and the cancelled call is not replayed.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn cancelling_an_inflight_stdio_tool_call_does_not_wait_for_the_execute_budget() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mut pool, call_log, init_log) = deadline_stdio_pool(dir.path(), "0", |_| {});
|
||||
assert_eq!(
|
||||
pool.config.servers["deadline"].effective_execute_timeout(&pool.config.timeouts),
|
||||
1800,
|
||||
"the fixture must run under the default execute budget"
|
||||
);
|
||||
pool.get_or_connect("deadline").await.unwrap();
|
||||
let cancel = pool.connections["deadline"].cancel_token.clone();
|
||||
|
||||
let call = tokio::spawn(async move {
|
||||
let result = pool
|
||||
.call_tool("mcp_deadline_hang", serde_json::json!({}))
|
||||
.await;
|
||||
(pool, result)
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
while log_lines(&call_log).is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the child never received the call");
|
||||
|
||||
cancel.cancel();
|
||||
let (mut pool, result) = tokio::time::timeout(Duration::from_secs(5), call)
|
||||
.await
|
||||
.expect("cancellation must not wait for the execute budget")
|
||||
.expect("the call task panicked");
|
||||
let error = result.expect_err("a cancelled call must not report success");
|
||||
assert!(
|
||||
format!("{error:#}").contains("MCP connection 'deadline' was cancelled"),
|
||||
"unexpected error: {error:#}"
|
||||
);
|
||||
assert!(
|
||||
pool.connected_servers().is_empty(),
|
||||
"a cancelled connection must not be reused"
|
||||
);
|
||||
|
||||
let fast = pool
|
||||
.call_tool("mcp_deadline_fast", serde_json::json!({}))
|
||||
.await
|
||||
.expect("the next call must rebuild the connection");
|
||||
let calls = log_lines(&call_log);
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
2,
|
||||
"the cancelled call must not be replayed: {calls:?}"
|
||||
);
|
||||
assert!(calls[0].starts_with("hang ") && calls[1].starts_with("fast "));
|
||||
assert_eq!(fast, deadline_reply(&calls[1]));
|
||||
|
||||
let inits = log_lines(&init_log);
|
||||
assert_eq!(
|
||||
inits.len(),
|
||||
2,
|
||||
"the next call must run on a fresh child: {inits:?}"
|
||||
);
|
||||
let cancelled_pid: i32 = inits[0]
|
||||
.strip_prefix("init ")
|
||||
.and_then(|pid| pid.parse().ok())
|
||||
.expect("the init log records the child pid");
|
||||
tokio::time::timeout(STDIO_SHUTDOWN_GRACE + Duration::from_secs(1), async {
|
||||
// SAFETY: signal zero only checks whether the pid still exists.
|
||||
while unsafe { libc::kill(cancelled_pid, 0) } == 0 {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the cancelled call's child must be terminated, not left running");
|
||||
}
|
||||
|
||||
/// A Streamable HTTP server that reads the whole `tools/call` POST and then
|
||||
/// drops the connection may have run it: the call runs once and the next
|
||||
/// call succeeds on a rebuilt connection.
|
||||
|
||||
+8
-3
@@ -378,8 +378,8 @@ The CLI also exposes helper tools when MCP is enabled:
|
||||
```json
|
||||
{
|
||||
"timeouts": {
|
||||
"connect_timeout": 10,
|
||||
"execute_timeout": 60,
|
||||
"connect_timeout": 30,
|
||||
"execute_timeout": 1800,
|
||||
"read_timeout": 120
|
||||
},
|
||||
"servers": {
|
||||
@@ -490,7 +490,12 @@ Per-server settings:
|
||||
- `command` (string, required)
|
||||
- `args` (array of strings, optional)
|
||||
- `env` (object, optional)
|
||||
- `connect_timeout`, `execute_timeout`, `read_timeout` (seconds, optional). `connect_timeout` defaults to 30 and covers spawn, `initialize` and the first `tools/list`, so a cold `uvx`/`npx` package download counts against it.
|
||||
- `connect_timeout`, `execute_timeout`, `read_timeout` (seconds, optional). A per-server value overrides the global `timeouts` block, and each budget is independent of the others:
|
||||
- `connect_timeout` (default 30) covers spawn, `initialize` and the first `tools/list`, so a cold `uvx`/`npx` package download counts against it.
|
||||
- `execute_timeout` (default 1800) is the budget for each `tools/call` and `prompts/get`. An explicit shorter value is respected, and `read_timeout` never cuts a running tool short.
|
||||
- `read_timeout` (default 120) bounds each reply wait during the handshake and tool discovery, and is the budget for `resources/read`.
|
||||
- A request that runs out of budget fails with a timeout. The connection is kept, and a reply that arrives later is discarded instead of being handed to another call. Interrupting the turn stops the wait at once, but Codewhale does not send `notifications/cancelled` yet, so a server that handles one request at a time finishes the abandoned call before it answers the next one.
|
||||
- Streamable HTTP servers return the reply inside the POST itself; the request's own budget bounds that POST too. A request that expires while still sending closes the connection, which is rebuilt before the next call.
|
||||
- `disabled` (bool, optional)
|
||||
- `enabled` (bool, optional, default `true`)
|
||||
- `required` (bool, optional): startup/connect validation fails if this server cannot initialize.
|
||||
|
||||
+8
-3
@@ -248,8 +248,8 @@ Codewhale 同时读取 `servers` 和 `mcpServers`,因此设置页生成的片
|
||||
```json
|
||||
{
|
||||
"timeouts": {
|
||||
"connect_timeout": 10,
|
||||
"execute_timeout": 60,
|
||||
"connect_timeout": 30,
|
||||
"execute_timeout": 1800,
|
||||
"read_timeout": 120
|
||||
},
|
||||
"servers": {
|
||||
@@ -341,7 +341,12 @@ codewhale-tui mcp tools codewhale
|
||||
- `command`(字符串,必需)
|
||||
- `args`(字符串数组,可选)
|
||||
- `env`(对象,可选)
|
||||
- `connect_timeout`、`execute_timeout`、`read_timeout`(秒,可选)
|
||||
- `connect_timeout`、`execute_timeout`、`read_timeout`(秒,可选)。服务器级的值会覆盖全局 `timeouts` 块,三个预算彼此独立:
|
||||
- `connect_timeout`(默认 30)覆盖进程启动、`initialize` 和首次 `tools/list`,因此首次通过 `uvx`/`npx` 下载包的时间也计入其中。
|
||||
- `execute_timeout`(默认 1800)是每次 `tools/call` 和 `prompts/get` 的预算。显式设置的更短值会被遵守,`read_timeout` 不会提前截断正在运行的工具。
|
||||
- `read_timeout`(默认 120)限制握手和工具发现期间每次等待回复的时间,也是 `resources/read` 的预算。
|
||||
- 请求超出预算时以超时错误失败。连接会被保留,之后迟到的回复会被丢弃,不会被交给其他调用。中断当前轮次会立即停止等待,但 Codewhale 目前还不会发送 `notifications/cancelled`,因此一次只处理一个请求的服务器会先完成被放弃的调用,再回答下一个请求。
|
||||
- Streamable HTTP 服务器在 POST 响应中直接返回回复;该 POST 同样受请求自身时限约束。若请求在发送阶段超时,连接会被关闭,并在下一次调用前重建。
|
||||
- `disabled`(布尔值,可选)
|
||||
- `enabled`(布尔值,可选,默认 `true`)
|
||||
- `required`(布尔值,可选):如果该服务器无法初始化,启动/连接验证会失败。
|
||||
|
||||
Reference in new issue
Block a user