@@ -2,6 +2,7 @@ use std::collections::HashMap;
22use std:: future:: Future ;
33use std:: pin:: Pin ;
44use std:: sync:: Arc ;
5+ use std:: time:: Duration ;
56
67use tokio:: io:: { AsyncBufReadExt , AsyncWriteExt , BufReader } ;
78use tokio:: process:: { Child , Command } ;
@@ -10,6 +11,10 @@ use tokio::sync::{Mutex, oneshot};
1011use crate :: protocol:: { JsonRpcNotification , JsonRpcRequest , JsonRpcResponse } ;
1112use crate :: transport:: Transport ;
1213
14+ /// Per-request timeout — a request that gets no response within this window
15+ /// fails rather than hanging forever (e.g. the server died mid-request).
16+ const REQUEST_TIMEOUT : Duration = Duration :: from_secs ( 30 ) ;
17+
1318/// Stdin/stdout transport for MCP servers launched as child processes.
1419///
1520/// The MCP stdio protocol frames messages as `Content-Length: N\r\n\r\n{json}`
@@ -72,18 +77,23 @@ impl StdioTransport {
7277 loop {
7378 match read_message ( & mut reader) . await {
7479 Ok ( Some ( data) ) => {
75- // Try to parse as a response (has "id" field)
80+ // A response carries an "id"; a notification does not.
7681 if let Ok ( resp) = serde_json:: from_str :: < JsonRpcResponse > ( & data) {
7782 let mut map = pending_clone. lock ( ) . await ;
7883 if let Some ( tx) = map. remove ( & resp. id ) {
7984 let _ = tx. send ( resp) ;
8085 }
86+ } else if let Ok ( notif) = serde_json:: from_str :: < JsonRpcNotification > ( & data)
87+ {
88+ // No consumer surface for stdio server notifications
89+ // yet; log so they're observable but not lost silently.
90+ tracing:: debug!(
91+ method = notif. method,
92+ "received MCP server notification (no consumer wired)"
93+ ) ;
8194 }
82- // Notifications from server are silently dropped for now.
83- // TODO: emit server notifications through a channel.
8495 }
8596 Ok ( None ) => {
86- // EOF — server process closed stdout
8797 tracing:: debug!( "MCP server stdout closed" ) ;
8898 break ;
8999 }
@@ -93,6 +103,10 @@ impl StdioTransport {
93103 }
94104 }
95105 }
106+ // Reader is exiting (EOF or error): drop every pending sender so
107+ // in-flight `rx.await` callers get an error promptly instead of
108+ // hanging forever waiting on a dead server.
109+ pending_clone. lock ( ) . await . clear ( ) ;
96110 } ) ;
97111
98112 Ok ( Self {
@@ -142,10 +156,20 @@ impl Transport for StdioTransport {
142156 tracing:: debug!( method = %req. method, id, "sending MCP request" ) ;
143157 self . write_message ( & json) . await ?;
144158
145- // Wait for the response from the reader task.
146- rx. await . map_err ( |_| {
147- crab_core:: Error :: Other ( "MCP server closed connection before responding" . into ( ) )
148- } )
159+ // Wait for the response, bounded by REQUEST_TIMEOUT so a dead or
160+ // wedged server can't hang the caller indefinitely.
161+ match tokio:: time:: timeout ( REQUEST_TIMEOUT , rx) . await {
162+ Ok ( Ok ( resp) ) => Ok ( resp) ,
163+ Ok ( Err ( _) ) => Err ( crab_core:: Error :: Other (
164+ "MCP server closed connection before responding" . into ( ) ,
165+ ) ) ,
166+ Err ( _) => {
167+ self . pending . lock ( ) . await . remove ( & id) ;
168+ Err ( crab_core:: Error :: Other ( format ! (
169+ "MCP server did not respond within {REQUEST_TIMEOUT:?}"
170+ ) ) )
171+ }
172+ }
149173 } )
150174 }
151175
@@ -258,4 +282,46 @@ mod tests {
258282 let msg = read_message ( & mut reader) . await . unwrap ( ) . unwrap ( ) ;
259283 assert_eq ! ( msg, "{}" ) ;
260284 }
285+
286+ #[ test]
287+ fn request_timeout_is_bounded ( ) {
288+ assert_eq ! ( REQUEST_TIMEOUT , Duration :: from_secs( 30 ) ) ;
289+ }
290+
291+ /// A server that exits immediately (closing stdout) must cause an in-flight
292+ /// request to fail promptly rather than hang forever. The reader task hits
293+ /// EOF, drains `pending`, and the dropped sender resolves `rx.await` with an
294+ /// error well before the 30s request timeout.
295+ #[ tokio:: test]
296+ async fn send_errors_when_server_dies_before_responding ( ) {
297+ let ( command, args) = if cfg ! ( windows) {
298+ (
299+ "cmd" . to_string ( ) ,
300+ vec ! [ "/C" . to_string( ) , "exit" . to_string( ) ] ,
301+ )
302+ } else {
303+ (
304+ "sh" . to_string ( ) ,
305+ vec ! [ "-c" . to_string( ) , "exit 0" . to_string( ) ] ,
306+ )
307+ } ;
308+
309+ let transport = StdioTransport :: spawn ( & command, & args, None )
310+ . await
311+ . expect ( "spawn short-lived server" ) ;
312+
313+ let req = JsonRpcRequest :: new ( "initialize" , None ) ;
314+ // Bound the whole call so a regression (a real hang) fails the test
315+ // instead of stalling the suite.
316+ let result = tokio:: time:: timeout ( Duration :: from_secs ( 5 ) , transport. send ( req) ) . await ;
317+
318+ assert ! (
319+ result. is_ok( ) ,
320+ "send hung instead of erroring on dead server"
321+ ) ;
322+ assert ! (
323+ result. unwrap( ) . is_err( ) ,
324+ "send should error when the server dies before responding"
325+ ) ;
326+ }
261327}
0 commit comments