@@ -36,6 +36,13 @@ const PENDING_REQUEST_LIMIT: usize = 4096;
3636/// Product clients can pass an explicit binary path to [`SidecarTransport::spawn`].
3737const SIDECAR_BIN_ENV : & str = "AGENTOS_SIDECAR_BIN" ;
3838
39+ /// How long the host tolerates TOTAL inbound silence (no responses, events, sidecar requests, or
40+ /// heartbeats) before declaring the sidecar dead. The sidecar heartbeats every 10s from a dedicated
41+ /// thread even while busy, so this allows two missed beats plus margin; it bounds "sidecar is dead
42+ /// or wedged", never "this request is slow" — individual requests have no deadline of their own.
43+ /// Fixed protocol constant paired with the sidecar heartbeat cadence; mirrors the TS client.
44+ const SIDECAR_SILENCE_TIMEOUT : std:: time:: Duration = std:: time:: Duration :: from_secs ( 30 ) ;
45+
3946/// A registered callback that answers a sidecar-initiated request using generated wire types.
4047pub type WireSidecarCallback = Arc <
4148 dyn Fn (
@@ -68,6 +75,8 @@ pub struct SidecarTransport {
6875 request_writer_tx : mpsc:: Sender < Vec < u8 > > ,
6976 /// Outbound callback/control response frames. The writer drains this before regular requests.
7077 control_writer_tx : mpsc:: Sender < Vec < u8 > > ,
78+ /// When the reader last received any inbound frame; the silence watchdog reads it.
79+ last_inbound_at : parking_lot:: Mutex < std:: time:: Instant > ,
7180}
7281
7382impl SidecarTransport {
@@ -110,10 +119,15 @@ impl SidecarTransport {
110119 callbacks : SccHashMap :: new ( ) ,
111120 request_writer_tx,
112121 control_writer_tx,
122+ last_inbound_at : parking_lot:: Mutex :: new ( std:: time:: Instant :: now ( ) ) ,
113123 } ) ;
114124
115125 tokio:: spawn ( run_writer ( stdin, control_writer_rx, request_writer_rx) ) ;
116126 tokio:: spawn ( run_reader ( Arc :: downgrade ( & transport) , stdout) ) ;
127+ tokio:: spawn ( run_silence_watchdog (
128+ Arc :: downgrade ( & transport) ,
129+ SIDECAR_SILENCE_TIMEOUT ,
130+ ) ) ;
117131
118132 Ok ( transport)
119133 }
@@ -236,6 +250,17 @@ impl SidecarTransport {
236250 }
237251 }
238252 wire:: ProtocolFrame :: EventFrame ( event) => {
253+ // Transport-level liveness beats from the sidecar. Their arrival
254+ // already reset the silence watchdog in the reader; they carry no
255+ // meaning for event subscribers, so drop them here (mirrors the
256+ // TS client's heartbeat swallow).
257+ if matches ! (
258+ & event. payload,
259+ wire:: EventPayload :: StructuredEvent ( structured)
260+ if structured. name == "heartbeat"
261+ ) {
262+ return ;
263+ }
239264 let _ = self . event_tx . send ( ( event. ownership , event. payload ) ) ;
240265 }
241266 wire:: ProtocolFrame :: SidecarRequestFrame ( request) => {
@@ -429,6 +454,9 @@ async fn run_reader(transport: Weak<SidecarTransport>, mut stdout: ChildStdout)
429454 if stdout. read_exact ( & mut frame_bytes[ 4 ..] ) . await . is_err ( ) {
430455 break ;
431456 }
457+ // Any complete inbound frame proves the sidecar is alive; the silence
458+ // watchdog measures from here.
459+ * transport. last_inbound_at . lock ( ) = std:: time:: Instant :: now ( ) ;
432460
433461 let codec = WireFrameCodec :: new ( max_frame_bytes) ;
434462 match codec. decode ( & frame_bytes) {
@@ -446,6 +474,30 @@ fn frame_length_exceeds_limit(length: usize, max_frame_bytes: usize) -> bool {
446474 length > max_frame_bytes
447475}
448476
477+ /// Kill the sidecar and fail all in-flight requests once the transport has seen no inbound frames
478+ /// (not even heartbeats) for `timeout`. A silent sidecar is dead or wedged, not busy: a busy
479+ /// sidecar still heartbeats every 10s from a dedicated thread. Exits when the transport drops.
480+ async fn run_silence_watchdog ( transport : Weak < SidecarTransport > , timeout : std:: time:: Duration ) {
481+ let check_interval = ( timeout / 4 ) . min ( std:: time:: Duration :: from_secs ( 1 ) ) ;
482+ loop {
483+ tokio:: time:: sleep ( check_interval) . await ;
484+ let Some ( transport) = transport. upgrade ( ) else {
485+ return ;
486+ } ;
487+ let silence = transport. last_inbound_at . lock ( ) . elapsed ( ) ;
488+ if silence < timeout {
489+ continue ;
490+ }
491+ tracing:: error!(
492+ silence_ms = silence. as_millis( ) as u64 ,
493+ "sidecar unresponsive: no protocol frames or heartbeats; killing sidecar" ,
494+ ) ;
495+ transport. kill_child ( ) ;
496+ transport. fail_all_pending ( ) ;
497+ return ;
498+ }
499+ }
500+
449501fn resolve_sidecar_binary_path ( binary_path : Option < String > ) -> String {
450502 binary_path
451503 . or_else ( || std:: env:: var ( SIDECAR_BIN_ENV ) . ok ( ) )
@@ -473,6 +525,7 @@ mod tests {
473525 callbacks : SccHashMap :: new ( ) ,
474526 request_writer_tx,
475527 control_writer_tx,
528+ last_inbound_at : parking_lot:: Mutex :: new ( std:: time:: Instant :: now ( ) ) ,
476529 }
477530 }
478531
@@ -601,6 +654,96 @@ mod tests {
601654 ) ) ;
602655 }
603656
657+ #[ tokio:: test]
658+ async fn silence_watchdog_fails_pending_requests_after_sustained_silence ( ) {
659+ let transport = Arc :: new ( test_transport ( ) ) ;
660+ let ( tx, rx) = oneshot:: channel ( ) ;
661+ transport
662+ . register_pending_request ( 1 , tx)
663+ . expect ( "register pending request" ) ;
664+
665+ tokio:: spawn ( run_silence_watchdog (
666+ Arc :: downgrade ( & transport) ,
667+ std:: time:: Duration :: from_millis ( 40 ) ,
668+ ) ) ;
669+
670+ // No inbound activity at all: the watchdog must reject the pending
671+ // request (dropped sender -> disconnected error at the caller).
672+ rx. await
673+ . expect_err ( "watchdog should drop the pending sender" ) ;
674+ assert_eq ! ( pending_request_count( & transport) , 0 ) ;
675+ }
676+
677+ #[ tokio:: test]
678+ async fn silence_watchdog_stays_quiet_while_frames_arrive ( ) {
679+ let transport = Arc :: new ( test_transport ( ) ) ;
680+ let ( tx, mut rx) = oneshot:: channel ( ) ;
681+ transport
682+ . register_pending_request ( 1 , tx)
683+ . expect ( "register pending request" ) ;
684+
685+ tokio:: spawn ( run_silence_watchdog (
686+ Arc :: downgrade ( & transport) ,
687+ std:: time:: Duration :: from_millis ( 120 ) ,
688+ ) ) ;
689+
690+ // Simulate steady inbound activity (what the reader does per frame)
691+ // for well past the silence window; the watchdog must not fire.
692+ for _ in 0 ..6 {
693+ tokio:: time:: sleep ( std:: time:: Duration :: from_millis ( 40 ) ) . await ;
694+ * transport. last_inbound_at . lock ( ) = std:: time:: Instant :: now ( ) ;
695+ assert ! (
696+ rx. try_recv( ) . is_err( ) ,
697+ "pending request must remain registered while frames arrive"
698+ ) ;
699+ }
700+ assert_eq ! ( pending_request_count( & transport) , 1 ) ;
701+ }
702+
703+ #[ tokio:: test]
704+ async fn heartbeat_events_are_swallowed_before_the_event_fanout ( ) {
705+ let transport = Arc :: new ( test_transport ( ) ) ;
706+ let mut wire_events = transport. subscribe_wire_events ( ) ;
707+
708+ transport
709+ . handle_wire_frame ( wire:: ProtocolFrame :: EventFrame ( wire:: EventFrame {
710+ schema : wire:: protocol_schema ( ) ,
711+ ownership : wire:: OwnershipScope :: ConnectionOwnership ( wire:: ConnectionOwnership {
712+ connection_id : "sidecar-transport" . to_string ( ) ,
713+ } ) ,
714+ payload : wire:: EventPayload :: StructuredEvent ( wire:: StructuredEvent {
715+ name : "heartbeat" . to_string ( ) ,
716+ detail : std:: collections:: HashMap :: new ( ) ,
717+ } ) ,
718+ } ) )
719+ . await ;
720+ // A non-heartbeat structured event still fans out, proving the filter
721+ // is name-scoped rather than dropping all structured events.
722+ transport
723+ . handle_wire_frame ( wire:: ProtocolFrame :: EventFrame ( wire:: EventFrame {
724+ schema : wire:: protocol_schema ( ) ,
725+ ownership : wire:: OwnershipScope :: ConnectionOwnership ( wire:: ConnectionOwnership {
726+ connection_id : "conn-1" . to_string ( ) ,
727+ } ) ,
728+ payload : wire:: EventPayload :: StructuredEvent ( wire:: StructuredEvent {
729+ name : "limit_warning" . to_string ( ) ,
730+ detail : std:: collections:: HashMap :: new ( ) ,
731+ } ) ,
732+ } ) )
733+ . await ;
734+
735+ let ( _, payload) = wire_events. recv ( ) . await . expect ( "structured event" ) ;
736+ assert ! ( matches!(
737+ payload,
738+ wire:: EventPayload :: StructuredEvent ( wire:: StructuredEvent { name, .. } )
739+ if name == "limit_warning"
740+ ) ) ;
741+ assert ! (
742+ wire_events. try_recv( ) . is_err( ) ,
743+ "heartbeat must not fan out"
744+ ) ;
745+ }
746+
604747 #[ test]
605748 fn pending_request_guard_removes_registered_slot_on_drop ( ) {
606749 let transport = test_transport ( ) ;
0 commit comments