Skip to main content

agentos_sidecar_client/
transport.rs

1//! `SidecarTransport`: spawns a native sidecar binary and speaks the existing framed
2//! BARE protocol over its stdio.
3//!
4//! This mirrors the TypeScript `Sidecar`. Generated wire payloads are the native
5//! transport path.
6//!
7//! Request-id direction is load-bearing: host-initiated `Request`/`Response` frames use positive ids
8//! allocated by this transport, while sidecar-initiated `SidecarRequest`/`SidecarResponse` callbacks
9//! echo the id allocated by the sidecar.
10
11use std::process::Stdio;
12use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
13use std::sync::{Arc, Weak};
14
15use scc::HashMap as SccHashMap;
16use tokio::io::{AsyncReadExt, AsyncWrite, AsyncWriteExt};
17use tokio::process::{Child, ChildStdout, Command};
18use tokio::sync::{broadcast, mpsc, oneshot};
19
20use crate::wire::{self, WireFrameCodec};
21use crate::TransportError;
22
23/// Broadcast capacity for the structured/lifecycle/process event fan-out.
24const EVENT_CHANNEL_CAPACITY: usize = 4096;
25
26/// Maximum outbound frames buffered while the writer task drains to sidecar stdin.
27const REQUEST_FRAME_QUEUE_CAPACITY: usize = 4096;
28
29/// Maximum callback/control response frames buffered ahead of regular host requests.
30const CONTROL_FRAME_QUEUE_CAPACITY: usize = 1024;
31
32/// Maximum in-flight host-initiated sidecar requests per transport.
33const PENDING_REQUEST_LIMIT: usize = 4096;
34
35/// Env var that overrides the sidecar binary path. Defaults to `agentos-native-sidecar` on `PATH`.
36/// Product clients can pass an explicit binary path to [`SidecarTransport::spawn`].
37const SIDECAR_BIN_ENV: &str = "AGENTOS_SIDECAR_BIN";
38
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.
44const SIDECAR_SILENCE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
45
46/// A registered callback that answers a sidecar-initiated request using generated wire types.
47pub type WireSidecarCallback = Arc<
48    dyn Fn(
49            wire::SidecarRequestPayload,
50            wire::OwnershipScope,
51        ) -> futures::future::BoxFuture<
52            'static,
53            Result<wire::SidecarResponsePayload, TransportError>,
54        > + Send
55        + Sync,
56>;
57
58/// Owns the spawned sidecar child, the framed BARE stdio I/O tasks, the pending-response map, the
59/// event fan-out, and the callback dispatch table.
60pub struct SidecarTransport {
61    /// The spawned sidecar process (stdout/stdin taken by the I/O tasks; kept for kill on drop).
62    child: parking_lot::Mutex<Option<Child>>,
63    /// Pending host-initiated requests, keyed by positive `RequestId`.
64    pending: SccHashMap<wire::RequestId, oneshot::Sender<wire::ResponsePayload>>,
65    pending_request_lock: parking_lot::Mutex<()>,
66    /// Host request-id counter (positive, starts at 1).
67    request_counter: AtomicI64,
68    /// Negotiated max frame size.
69    max_frame_bytes: AtomicUsize,
70    /// Structured-event fan-out for `Event` frames.
71    event_tx: broadcast::Sender<(wire::OwnershipScope, wire::EventPayload)>,
72    /// Registered host callbacks for `SidecarRequest` frames.
73    callbacks: SccHashMap<&'static str, WireSidecarCallback>,
74    /// Outbound host request frames drained by the writer task into the child's stdin.
75    request_writer_tx: mpsc::Sender<Vec<u8>>,
76    /// Outbound callback/control response frames. The writer drains this before regular requests.
77    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>,
80}
81
82impl SidecarTransport {
83    /// Spawn the native sidecar binary and start the stdio I/O tasks.
84    ///
85    /// Does NOT run the handshake. Product clients drive Authenticate and any follow-up setup using
86    /// [`request_wire`](Self::request_wire) once the transport is live.
87    pub async fn spawn(binary_path: Option<String>) -> Result<Arc<Self>, TransportError> {
88        let bin = resolve_sidecar_binary_path(binary_path);
89        let mut child = Command::new(&bin)
90            .stdin(Stdio::piped())
91            .stdout(Stdio::piped())
92            .stderr(Stdio::inherit())
93            .kill_on_drop(true)
94            .spawn()
95            .map_err(|error| {
96                TransportError::Sidecar(format!("failed to spawn sidecar '{bin}': {error}"))
97            })?;
98
99        let stdin = child
100            .stdin
101            .take()
102            .ok_or_else(|| TransportError::Sidecar("sidecar stdin was not piped".to_string()))?;
103        let stdout = child
104            .stdout
105            .take()
106            .ok_or_else(|| TransportError::Sidecar("sidecar stdout was not piped".to_string()))?;
107
108        let (request_writer_tx, request_writer_rx) = mpsc::channel(REQUEST_FRAME_QUEUE_CAPACITY);
109        let (control_writer_tx, control_writer_rx) = mpsc::channel(CONTROL_FRAME_QUEUE_CAPACITY);
110        let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
111
112        let transport = Arc::new(Self {
113            child: parking_lot::Mutex::new(Some(child)),
114            pending: SccHashMap::new(),
115            pending_request_lock: parking_lot::Mutex::new(()),
116            request_counter: AtomicI64::new(1),
117            max_frame_bytes: AtomicUsize::new(wire::DEFAULT_MAX_FRAME_BYTES),
118            event_tx,
119            callbacks: SccHashMap::new(),
120            request_writer_tx,
121            control_writer_tx,
122            last_inbound_at: parking_lot::Mutex::new(std::time::Instant::now()),
123        });
124
125        tokio::spawn(run_writer(stdin, control_writer_rx, request_writer_rx));
126        tokio::spawn(run_reader(Arc::downgrade(&transport), stdout));
127        tokio::spawn(run_silence_watchdog(
128            Arc::downgrade(&transport),
129            SIDECAR_SILENCE_TIMEOUT,
130        ));
131
132        Ok(transport)
133    }
134
135    /// Allocate the next positive host request id.
136    pub fn next_request_id(&self) -> wire::RequestId {
137        self.request_counter.fetch_add(1, Ordering::SeqCst)
138    }
139
140    /// Issue a host request using generated wire protocol types and await a generated response.
141    pub async fn request_wire(
142        &self,
143        ownership: wire::OwnershipScope,
144        payload: wire::RequestPayload,
145    ) -> Result<wire::ResponsePayload, TransportError> {
146        self.request_wire_with_frame_limit(ownership, payload, None)
147            .await
148    }
149
150    /// Issue a host request using generated wire protocol types with a caller-specific frame limit.
151    pub async fn request_wire_bounded(
152        &self,
153        ownership: wire::OwnershipScope,
154        payload: wire::RequestPayload,
155        max_frame_bytes: usize,
156    ) -> Result<wire::ResponsePayload, TransportError> {
157        self.request_wire_with_frame_limit(ownership, payload, Some(max_frame_bytes))
158            .await
159    }
160
161    async fn request_wire_with_frame_limit(
162        &self,
163        ownership: wire::OwnershipScope,
164        payload: wire::RequestPayload,
165        max_frame_bytes: Option<usize>,
166    ) -> Result<wire::ResponsePayload, TransportError> {
167        let request_id = self.next_request_id();
168        let frame = wire::ProtocolFrame::RequestFrame(wire::RequestFrame {
169            schema: wire::protocol_schema(),
170            request_id,
171            ownership,
172            payload,
173        });
174        let bytes = self.encode_wire_frame(&frame, max_frame_bytes)?;
175
176        let (tx, rx) = oneshot::channel();
177        self.register_pending_request(request_id, tx)?;
178        let _pending_guard = PendingRequestGuard::new(self, request_id);
179
180        if self.request_writer_tx.send(bytes).await.is_err() {
181            self.pending.remove(&request_id);
182            return Err(TransportError::Sidecar(
183                "sidecar transport closed".to_string(),
184            ));
185        }
186
187        rx.await
188            .map_err(|_| TransportError::Sidecar("sidecar transport disconnected".to_string()))
189    }
190
191    /// Subscribe to structured/lifecycle/process events using generated wire protocol types.
192    pub fn subscribe_wire_events(
193        &self,
194    ) -> broadcast::Receiver<(wire::OwnershipScope, wire::EventPayload)> {
195        self.event_tx.subscribe()
196    }
197
198    /// Register a callback that answers a class of sidecar-initiated requests using generated wire
199    /// protocol types.
200    pub fn register_wire_callback(&self, key: &'static str, callback: WireSidecarCallback) {
201        let _ = self.callbacks.insert(key, callback);
202    }
203
204    /// Return the currently negotiated max frame size.
205    pub fn max_frame_bytes(&self) -> usize {
206        self.max_frame_bytes.load(Ordering::Relaxed)
207    }
208
209    /// Update the negotiated max frame size after authentication.
210    pub fn set_max_frame_bytes(&self, max_frame_bytes: usize) {
211        self.max_frame_bytes
212            .store(max_frame_bytes, Ordering::SeqCst);
213    }
214
215    /// Kill the child sidecar process if this transport still owns one.
216    pub fn kill_child(&self) {
217        if let Some(mut child) = self.child.lock().take() {
218            let _ = child.start_kill();
219        }
220    }
221
222    fn encode_wire_frame(
223        &self,
224        frame: &wire::ProtocolFrame,
225        max_frame_bytes: Option<usize>,
226    ) -> Result<Vec<u8>, TransportError> {
227        let transport_limit = self.max_frame_bytes.load(Ordering::Relaxed);
228        let max_frame_bytes = max_frame_bytes
229            .map(|limit| limit.min(transport_limit))
230            .unwrap_or(transport_limit);
231        let codec = WireFrameCodec::new(max_frame_bytes);
232        Ok(codec.encode(frame)?)
233    }
234
235    /// Route a decoded inbound frame. Host transports only legitimately receive `Response`, `Event`,
236    /// and `SidecarRequest` frames.
237    async fn handle_wire_frame(self: &Arc<Self>, frame: wire::ProtocolFrame) {
238        match frame {
239            wire::ProtocolFrame::ResponseFrame(response) => {
240                match self.pending.remove(&response.request_id) {
241                    Some((_, tx)) => {
242                        let _ = tx.send(response.payload);
243                    }
244                    None => {
245                        tracing::warn!(
246                            request_id = response.request_id,
247                            "response for unknown request id"
248                        )
249                    }
250                }
251            }
252            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                }
264                let _ = self.event_tx.send((event.ownership, event.payload));
265            }
266            wire::ProtocolFrame::SidecarRequestFrame(request) => {
267                self.dispatch_sidecar_request(request).await
268            }
269            wire::ProtocolFrame::SidecarResponseFrame(_) | wire::ProtocolFrame::RequestFrame(_) => {
270                tracing::warn!("unexpected inbound frame on host transport")
271            }
272        }
273    }
274
275    /// Dispatch a sidecar-initiated request to its registered callback. The callback runs in a
276    /// spawned task so long-running host callbacks (tool execution, permission prompts) cannot stall
277    /// the reader loop, which must keep draining responses for any requests the callback itself
278    /// issues through this transport.
279    async fn dispatch_sidecar_request(self: &Arc<Self>, frame: wire::SidecarRequestFrame) {
280        let key = sidecar_request_key(&frame.payload);
281        let callback = self.callbacks.read(&key, |_, value| value.clone());
282        match callback {
283            Some(callback) => {
284                let transport = Arc::downgrade(self);
285                tokio::spawn(async move {
286                    match callback(frame.payload, frame.ownership.clone()).await {
287                        Ok(payload) => {
288                            let response = wire::ProtocolFrame::SidecarResponseFrame(
289                                wire::SidecarResponseFrame {
290                                    schema: wire::protocol_schema(),
291                                    request_id: frame.request_id,
292                                    ownership: frame.ownership,
293                                    payload,
294                                },
295                            );
296                            // If the transport is gone, the child is being killed; drop the reply.
297                            let Some(transport) = transport.upgrade() else {
298                                return;
299                            };
300                            if let Ok(bytes) = transport.encode_wire_frame(&response, None) {
301                                let _ = transport.control_writer_tx.send(bytes).await;
302                            }
303                        }
304                        Err(error) => tracing::warn!(?error, key, "sidecar callback failed"),
305                    }
306                });
307            }
308            None => tracing::warn!(key, "no callback registered for sidecar request"),
309        }
310    }
311
312    /// Reject every in-flight request after the transport disconnects.
313    fn fail_all_pending(&self) {
314        self.pending.clear();
315    }
316
317    fn register_pending_request(
318        &self,
319        request_id: wire::RequestId,
320        tx: oneshot::Sender<wire::ResponsePayload>,
321    ) -> Result<(), TransportError> {
322        let _guard = self.pending_request_lock.lock();
323        if pending_request_count(self) >= PENDING_REQUEST_LIMIT {
324            return Err(TransportError::Sidecar(format!(
325                "sidecar pending request limit exceeded: at most {PENDING_REQUEST_LIMIT} requests can be in flight"
326            )));
327        }
328        let _ = self.pending.insert(request_id, tx);
329        Ok(())
330    }
331}
332
333struct PendingRequestGuard<'a> {
334    transport: &'a SidecarTransport,
335    request_id: wire::RequestId,
336}
337
338impl<'a> PendingRequestGuard<'a> {
339    fn new(transport: &'a SidecarTransport, request_id: wire::RequestId) -> Self {
340        Self {
341            transport,
342            request_id,
343        }
344    }
345}
346
347impl Drop for PendingRequestGuard<'_> {
348    fn drop(&mut self) {
349        let _ = self.transport.pending.remove(&self.request_id);
350    }
351}
352
353fn pending_request_count(transport: &SidecarTransport) -> usize {
354    let mut count = 0;
355    transport.pending.scan(|_, _| {
356        count += 1;
357    });
358    count
359}
360
361/// Map a sidecar-request payload to the callback registry key.
362fn sidecar_request_key(payload: &wire::SidecarRequestPayload) -> &'static str {
363    match payload {
364        wire::SidecarRequestPayload::HostCallbackRequest(_) => "host_callback",
365        wire::SidecarRequestPayload::JsBridgeCallRequest(_) => "js_bridge_call",
366        wire::SidecarRequestPayload::ExtEnvelope(_) => "ext",
367    }
368}
369
370/// Drain outbound channels into the child's stdin. Control responses are preferred so a full request
371/// queue cannot starve sidecar-request replies.
372async fn run_writer<W>(
373    mut stdin: W,
374    mut control_rx: mpsc::Receiver<Vec<u8>>,
375    mut request_rx: mpsc::Receiver<Vec<u8>>,
376) where
377    W: AsyncWrite + Unpin,
378{
379    let mut prefer_control = true;
380    loop {
381        let (bytes, wrote_control) = if prefer_control {
382            tokio::select! {
383                biased;
384                bytes = control_rx.recv() => match bytes {
385                    Some(bytes) => (bytes, true),
386                    None => match request_rx.recv().await {
387                        Some(bytes) => (bytes, false),
388                        None => break,
389                    },
390                },
391                bytes = request_rx.recv() => match bytes {
392                    Some(bytes) => (bytes, false),
393                    None => match control_rx.recv().await {
394                        Some(bytes) => (bytes, true),
395                        None => break,
396                    },
397                },
398            }
399        } else {
400            tokio::select! {
401                biased;
402                bytes = request_rx.recv() => match bytes {
403                    Some(bytes) => (bytes, false),
404                    None => match control_rx.recv().await {
405                        Some(bytes) => (bytes, true),
406                        None => break,
407                    },
408                },
409                bytes = control_rx.recv() => match bytes {
410                    Some(bytes) => (bytes, true),
411                    None => match request_rx.recv().await {
412                        Some(bytes) => (bytes, false),
413                        None => break,
414                    },
415                },
416            }
417        };
418        if stdin.write_all(&bytes).await.is_err() {
419            break;
420        }
421        if stdin.flush().await.is_err() {
422            break;
423        }
424        prefer_control = !wrote_control;
425    }
426}
427
428/// Read length-prefixed BARE frames from the child's stdout and route them. Holds a `Weak` so the
429/// transport can drop (and `kill_on_drop` the child) independently; exits on EOF/read error or once
430/// the transport is gone.
431async fn run_reader(transport: Weak<SidecarTransport>, mut stdout: ChildStdout) {
432    loop {
433        let mut length_buf = [0u8; 4];
434        if stdout.read_exact(&mut length_buf).await.is_err() {
435            break;
436        }
437        let length = u32::from_be_bytes(length_buf) as usize;
438
439        let Some(transport) = transport.upgrade() else {
440            break;
441        };
442        let max_frame_bytes = transport.max_frame_bytes.load(Ordering::Relaxed);
443        if frame_length_exceeds_limit(length, max_frame_bytes) {
444            tracing::warn!(
445                size = length,
446                max = max_frame_bytes,
447                "sidecar frame exceeds negotiated limit"
448            );
449            break;
450        }
451
452        let mut frame_bytes = vec![0u8; 4 + length];
453        frame_bytes[..4].copy_from_slice(&length_buf);
454        if stdout.read_exact(&mut frame_bytes[4..]).await.is_err() {
455            break;
456        }
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();
460
461        let codec = WireFrameCodec::new(max_frame_bytes);
462        match codec.decode(&frame_bytes) {
463            Ok(frame) => transport.handle_wire_frame(frame).await,
464            Err(error) => tracing::warn!(?error, "failed to decode sidecar frame"),
465        }
466    }
467
468    if let Some(transport) = transport.upgrade() {
469        transport.fail_all_pending();
470    }
471}
472
473fn frame_length_exceeds_limit(length: usize, max_frame_bytes: usize) -> bool {
474    length > max_frame_bytes
475}
476
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.
480async 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
501fn resolve_sidecar_binary_path(binary_path: Option<String>) -> String {
502    binary_path
503        .or_else(|| std::env::var(SIDECAR_BIN_ENV).ok())
504        .unwrap_or_else(|| "agentos-native-sidecar".to_string())
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510    use std::sync::Mutex;
511
512    static ENV_LOCK: Mutex<()> = Mutex::new(());
513
514    fn test_transport() -> SidecarTransport {
515        let (request_writer_tx, _request_writer_rx) = mpsc::channel(REQUEST_FRAME_QUEUE_CAPACITY);
516        let (control_writer_tx, _control_writer_rx) = mpsc::channel(CONTROL_FRAME_QUEUE_CAPACITY);
517        let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
518        SidecarTransport {
519            child: parking_lot::Mutex::new(None),
520            pending: SccHashMap::new(),
521            pending_request_lock: parking_lot::Mutex::new(()),
522            request_counter: AtomicI64::new(1),
523            max_frame_bytes: AtomicUsize::new(wire::DEFAULT_MAX_FRAME_BYTES),
524            event_tx,
525            callbacks: SccHashMap::new(),
526            request_writer_tx,
527            control_writer_tx,
528            last_inbound_at: parking_lot::Mutex::new(std::time::Instant::now()),
529        }
530    }
531
532    #[test]
533    fn binary_path_prefers_explicit_path_over_env() {
534        let _guard = ENV_LOCK.lock().expect("env lock");
535        let previous = std::env::var(SIDECAR_BIN_ENV).ok();
536        std::env::set_var(SIDECAR_BIN_ENV, "/tmp/from-env");
537
538        assert_eq!(
539            resolve_sidecar_binary_path(Some("/tmp/from-config".to_string())),
540            "/tmp/from-config"
541        );
542
543        restore_env(SIDECAR_BIN_ENV, previous);
544    }
545
546    #[test]
547    fn binary_path_uses_secure_exec_env_fallback() {
548        let _guard = ENV_LOCK.lock().expect("env lock");
549        let previous = std::env::var(SIDECAR_BIN_ENV).ok();
550        std::env::set_var(SIDECAR_BIN_ENV, "/tmp/agentos-native-sidecar");
551
552        assert_eq!(
553            resolve_sidecar_binary_path(None),
554            "/tmp/agentos-native-sidecar"
555        );
556
557        restore_env(SIDECAR_BIN_ENV, previous);
558    }
559
560    #[test]
561    fn binary_path_defaults_to_agentos_native_sidecar() {
562        let _guard = ENV_LOCK.lock().expect("env lock");
563        let previous = std::env::var(SIDECAR_BIN_ENV).ok();
564        std::env::remove_var(SIDECAR_BIN_ENV);
565
566        assert_eq!(resolve_sidecar_binary_path(None), "agentos-native-sidecar");
567
568        restore_env(SIDECAR_BIN_ENV, previous);
569    }
570
571    fn restore_env(key: &str, value: Option<String>) {
572        match value {
573            Some(value) => std::env::set_var(key, value),
574            None => std::env::remove_var(key),
575        }
576    }
577
578    #[test]
579    fn frame_length_limit_rejects_oversized_declared_length() {
580        assert!(!frame_length_exceeds_limit(1024, 1024));
581        assert!(frame_length_exceeds_limit(1025, 1024));
582    }
583
584    #[test]
585    fn transport_encodes_requests_with_generated_wire_codec() {
586        let transport = test_transport();
587        let frame = wire::ProtocolFrame::RequestFrame(wire::RequestFrame {
588            schema: wire::protocol_schema(),
589            request_id: 7,
590            ownership: wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
591                connection_id: "conn-1".to_string(),
592            }),
593            payload: wire::RequestPayload::AuthenticateRequest(wire::AuthenticateRequest {
594                client_name: "transport-test".to_string(),
595                auth_token: "token".to_string(),
596                protocol_version: wire::PROTOCOL_VERSION,
597                bridge_version: 1,
598            }),
599        });
600
601        let encoded = transport
602            .encode_wire_frame(&frame, None)
603            .expect("encode transport frame");
604        let decoded = WireFrameCodec::default()
605            .decode(&encoded)
606            .expect("decode generated wire frame");
607
608        assert!(matches!(
609            decoded,
610            wire::ProtocolFrame::RequestFrame(wire::RequestFrame {
611                payload: wire::RequestPayload::AuthenticateRequest(_),
612                ..
613            })
614        ));
615    }
616
617    #[tokio::test]
618    async fn transport_fans_out_generated_wire_events() {
619        let transport = Arc::new(test_transport());
620        let mut wire_events = transport.subscribe_wire_events();
621
622        transport
623            .handle_wire_frame(wire::ProtocolFrame::EventFrame(wire::EventFrame {
624                schema: wire::protocol_schema(),
625                ownership: wire::OwnershipScope::VmOwnership(wire::VmOwnership {
626                    connection_id: "conn-1".to_string(),
627                    session_id: "session-1".to_string(),
628                    vm_id: "vm-1".to_string(),
629                }),
630                payload: wire::EventPayload::ProcessOutputEvent(wire::ProcessOutputEvent {
631                    process_id: "proc-1".to_string(),
632                    channel: wire::StreamChannel::Stdout,
633                    chunk: b"hello".to_vec(),
634                }),
635            }))
636            .await;
637
638        let (ownership, payload) = wire_events.recv().await.expect("wire event");
639        assert!(matches!(
640            ownership,
641            wire::OwnershipScope::VmOwnership(wire::VmOwnership {
642                connection_id,
643                session_id,
644                vm_id,
645            }) if connection_id == "conn-1" && session_id == "session-1" && vm_id == "vm-1"
646        ));
647        assert!(matches!(
648            payload,
649            wire::EventPayload::ProcessOutputEvent(wire::ProcessOutputEvent {
650                process_id,
651                channel: wire::StreamChannel::Stdout,
652                chunk,
653            }) if process_id == "proc-1" && chunk == b"hello".to_vec()
654        ));
655    }
656
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
747    #[test]
748    fn pending_request_guard_removes_registered_slot_on_drop() {
749        let transport = test_transport();
750        let (tx, _rx) = oneshot::channel();
751        transport
752            .register_pending_request(1, tx)
753            .expect("register pending request");
754
755        {
756            let _guard = PendingRequestGuard::new(&transport, 1);
757            assert_eq!(pending_request_count(&transport), 1);
758        }
759
760        assert_eq!(pending_request_count(&transport), 0);
761    }
762
763    #[test]
764    fn pending_request_limit_rejects_full_transport() {
765        let transport = test_transport();
766        for request_id in 1..=PENDING_REQUEST_LIMIT as wire::RequestId {
767            let (tx, _rx) = oneshot::channel();
768            transport
769                .register_pending_request(request_id, tx)
770                .expect("register pending request");
771        }
772        let (tx, _rx) = oneshot::channel();
773        let error = transport
774            .register_pending_request((PENDING_REQUEST_LIMIT + 1) as wire::RequestId, tx)
775            .expect_err("full pending map should reject");
776
777        assert!(
778            error
779                .to_string()
780                .contains("sidecar pending request limit exceeded"),
781            "unexpected error: {error}"
782        );
783    }
784
785    #[tokio::test]
786    async fn writer_prioritizes_control_frames_over_request_backlog() {
787        let (client, mut server) = tokio::io::duplex(64);
788        let (control_tx, control_rx) = mpsc::channel(CONTROL_FRAME_QUEUE_CAPACITY);
789        let (request_tx, request_rx) = mpsc::channel(REQUEST_FRAME_QUEUE_CAPACITY);
790        request_tx
791            .send(vec![b'r'])
792            .await
793            .expect("send request frame");
794        control_tx
795            .send(vec![b'c'])
796            .await
797            .expect("send control frame");
798        drop(control_tx);
799        drop(request_tx);
800
801        let writer = tokio::spawn(run_writer(client, control_rx, request_rx));
802        let mut first = [0u8; 1];
803        server
804            .read_exact(&mut first)
805            .await
806            .expect("read first byte");
807        writer.await.expect("writer task");
808
809        assert_eq!(first, [b'c']);
810    }
811
812    #[tokio::test]
813    async fn writer_alternates_when_control_and_request_are_ready() {
814        let (client, mut server) = tokio::io::duplex(64);
815        let (control_tx, control_rx) = mpsc::channel(CONTROL_FRAME_QUEUE_CAPACITY);
816        let (request_tx, request_rx) = mpsc::channel(REQUEST_FRAME_QUEUE_CAPACITY);
817        control_tx.send(vec![b'c']).await.expect("control one");
818        control_tx.send(vec![b'C']).await.expect("control two");
819        request_tx.send(vec![b'r']).await.expect("request one");
820        request_tx.send(vec![b'R']).await.expect("request two");
821        drop(control_tx);
822        drop(request_tx);
823
824        let writer = tokio::spawn(run_writer(client, control_rx, request_rx));
825        let mut output = [0u8; 4];
826        server.read_exact(&mut output).await.expect("read output");
827        writer.await.expect("writer task");
828
829        assert_eq!(output, [b'c', b'r', b'C', b'R']);
830    }
831}