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
15#[cfg(unix)]
16use command_fds::{CommandFdExt, FdMapping};
17#[cfg(unix)]
18use std::os::unix::net::UnixStream as StdUnixStream;
19
20use scc::HashMap as SccHashMap;
21use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
22use tokio::process::{Child, Command};
23use tokio::sync::{broadcast, mpsc, oneshot};
24
25use crate::wire::{self, WireFrameCodec};
26use crate::TransportError;
27
28/// Broadcast capacity for the structured/lifecycle/process event fan-out.
29const EVENT_CHANNEL_CAPACITY: usize = 4096;
30
31/// Maximum outbound frames buffered while the writer task drains to sidecar stdin.
32const REQUEST_FRAME_QUEUE_CAPACITY: usize = 4096;
33
34/// Maximum callback/control response frames buffered ahead of regular host requests.
35const CONTROL_FRAME_QUEUE_CAPACITY: usize = 1024;
36
37/// Maximum in-flight host-initiated sidecar requests per transport.
38const PENDING_REQUEST_LIMIT: usize = 4096;
39
40/// Env var that overrides the sidecar binary path. Defaults to `agentos-native-sidecar` on `PATH`.
41/// Product clients can pass an explicit binary path to [`SidecarTransport::spawn`].
42const SIDECAR_BIN_ENV: &str = "AGENTOS_SIDECAR_BIN";
43
44/// Fixed inherited descriptor carrying the full-duplex response/control lane.
45#[cfg(unix)]
46const CONTROL_FD: std::os::fd::RawFd = 3;
47
48/// How long the host tolerates TOTAL inbound silence (no responses, events, sidecar requests, or
49/// heartbeats) before declaring the sidecar dead. The sidecar heartbeats every 10s from a dedicated
50/// thread even while busy, so this allows two missed beats plus margin; it bounds "sidecar is dead
51/// or wedged", never "this request is slow" — individual requests have no deadline of their own.
52/// Fixed protocol constant paired with the sidecar heartbeat cadence; mirrors the TS client.
53const SIDECAR_SILENCE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
54
55/// A registered callback that answers a sidecar-initiated request using generated wire types.
56pub type WireSidecarCallback = Arc<
57    dyn Fn(
58            wire::SidecarRequestPayload,
59            wire::OwnershipScope,
60        ) -> futures::future::BoxFuture<
61            'static,
62            Result<wire::SidecarResponsePayload, TransportError>,
63        > + Send
64        + Sync,
65>;
66
67/// Owns the spawned sidecar child, the framed BARE stdio I/O tasks, the pending-response map, the
68/// event fan-out, and the callback dispatch table.
69pub struct SidecarTransport {
70    /// The spawned sidecar process (stdout/stdin taken by the I/O tasks; kept for kill on drop).
71    child: parking_lot::Mutex<Option<Child>>,
72    /// Pending host-initiated requests, keyed by positive `RequestId`.
73    pending: SccHashMap<wire::RequestId, oneshot::Sender<wire::ResponsePayload>>,
74    pending_request_lock: parking_lot::Mutex<()>,
75    /// Host request-id counter (positive, starts at 1).
76    request_counter: AtomicI64,
77    /// Negotiated max frame size.
78    max_frame_bytes: AtomicUsize,
79    /// Structured-event fan-out for `Event` frames.
80    event_tx: broadcast::Sender<(wire::OwnershipScope, wire::EventPayload)>,
81    /// Registered host callbacks for `SidecarRequest` frames.
82    callbacks: SccHashMap<&'static str, WireSidecarCallback>,
83    /// Outbound host request frames drained by the writer task into the child's stdin.
84    request_writer_tx: mpsc::Sender<Vec<u8>>,
85    /// Outbound callback/control response frames. The writer drains this before regular requests.
86    control_writer_tx: mpsc::Sender<Vec<u8>>,
87    /// When the reader last received any inbound frame; the silence watchdog reads it.
88    last_inbound_at: parking_lot::Mutex<std::time::Instant>,
89}
90
91impl SidecarTransport {
92    /// Spawn the native sidecar binary and start the stdio I/O tasks.
93    ///
94    /// Does NOT run the handshake. Product clients drive Authenticate and any follow-up setup using
95    /// [`request_wire`](Self::request_wire) once the transport is live.
96    pub async fn spawn(binary_path: Option<String>) -> Result<Arc<Self>, TransportError> {
97        #[cfg(not(unix))]
98        {
99            let _ = binary_path;
100            return Err(TransportError::Sidecar(
101                "the native sidecar response/control transport is unsupported on this platform"
102                    .to_string(),
103            ));
104        }
105
106        #[cfg(unix)]
107        {
108            Self::spawn_unix(binary_path).await
109        }
110    }
111
112    #[cfg(unix)]
113    async fn spawn_unix(binary_path: Option<String>) -> Result<Arc<Self>, TransportError> {
114        let bin = resolve_sidecar_binary_path(binary_path);
115        let (control_parent, control_child) = StdUnixStream::pair().map_err(|error| {
116            TransportError::Sidecar(format!(
117                "failed to create sidecar control socketpair: {error}"
118            ))
119        })?;
120        control_parent.set_nonblocking(true).map_err(|error| {
121            TransportError::Sidecar(format!(
122                "failed to configure sidecar control socket: {error}"
123            ))
124        })?;
125        let mut command = Command::new(&bin);
126        command
127            .stdin(Stdio::piped())
128            .stdout(Stdio::piped())
129            .stderr(Stdio::inherit())
130            .kill_on_drop(true);
131        // `command-fds` performs the child-only dup after fork, avoiding the
132        // CLOEXEC race caused by making an arbitrary descriptor inheritable in
133        // this multithreaded process.
134        map_control_fd(&mut command, control_child)?;
135        let mut child = command.spawn().map_err(|error| {
136            TransportError::Sidecar(format!("failed to spawn sidecar '{bin}': {error}"))
137        })?;
138        drop(command);
139        let stdin = child
140            .stdin
141            .take()
142            .ok_or_else(|| TransportError::Sidecar("sidecar stdin was not piped".to_string()))?;
143        let stdout = child
144            .stdout
145            .take()
146            .ok_or_else(|| TransportError::Sidecar("sidecar stdout was not piped".to_string()))?;
147        let control = tokio::net::UnixStream::from_std(control_parent).map_err(|error| {
148            TransportError::Sidecar(format!("failed to adopt sidecar control socket: {error}"))
149        })?;
150        let (control_reader, control_writer) = control.into_split();
151
152        let (request_writer_tx, request_writer_rx) = mpsc::channel(REQUEST_FRAME_QUEUE_CAPACITY);
153        let (control_writer_tx, control_writer_rx) = mpsc::channel(CONTROL_FRAME_QUEUE_CAPACITY);
154        let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
155
156        let transport = Arc::new(Self {
157            child: parking_lot::Mutex::new(Some(child)),
158            pending: SccHashMap::new(),
159            pending_request_lock: parking_lot::Mutex::new(()),
160            request_counter: AtomicI64::new(1),
161            max_frame_bytes: AtomicUsize::new(wire::DEFAULT_MAX_FRAME_BYTES),
162            event_tx,
163            callbacks: SccHashMap::new(),
164            request_writer_tx,
165            control_writer_tx,
166            last_inbound_at: parking_lot::Mutex::new(std::time::Instant::now()),
167        });
168
169        tokio::spawn(run_writer(
170            Arc::downgrade(&transport),
171            "ordinary request",
172            stdin,
173            request_writer_rx,
174        ));
175        tokio::spawn(run_writer(
176            Arc::downgrade(&transport),
177            "response/control",
178            control_writer,
179            control_writer_rx,
180        ));
181        tokio::spawn(run_reader(
182            Arc::downgrade(&transport),
183            stdout,
184            InboundLane::Event,
185        ));
186        tokio::spawn(run_reader(
187            Arc::downgrade(&transport),
188            control_reader,
189            InboundLane::Control,
190        ));
191        tokio::spawn(run_silence_watchdog(
192            Arc::downgrade(&transport),
193            SIDECAR_SILENCE_TIMEOUT,
194        ));
195
196        Ok(transport)
197    }
198
199    /// Allocate the next positive host request id.
200    pub fn next_request_id(&self) -> wire::RequestId {
201        self.request_counter.fetch_add(1, Ordering::SeqCst)
202    }
203
204    /// Issue a host request using generated wire protocol types and await a generated response.
205    pub async fn request_wire(
206        &self,
207        ownership: wire::OwnershipScope,
208        payload: wire::RequestPayload,
209    ) -> Result<wire::ResponsePayload, TransportError> {
210        self.request_wire_with_frame_limit(ownership, payload, None)
211            .await
212    }
213
214    /// Issue a host request using generated wire protocol types with a caller-specific frame limit.
215    pub async fn request_wire_bounded(
216        &self,
217        ownership: wire::OwnershipScope,
218        payload: wire::RequestPayload,
219        max_frame_bytes: usize,
220    ) -> Result<wire::ResponsePayload, TransportError> {
221        self.request_wire_with_frame_limit(ownership, payload, Some(max_frame_bytes))
222            .await
223    }
224
225    async fn request_wire_with_frame_limit(
226        &self,
227        ownership: wire::OwnershipScope,
228        payload: wire::RequestPayload,
229        max_frame_bytes: Option<usize>,
230    ) -> Result<wire::ResponsePayload, TransportError> {
231        let request_id = self.next_request_id();
232        let frame = wire::ProtocolFrame::RequestFrame(wire::RequestFrame {
233            schema: wire::protocol_schema(),
234            request_id,
235            ownership,
236            payload,
237        });
238        let bytes = self.encode_wire_frame(&frame, max_frame_bytes)?;
239
240        let (tx, rx) = oneshot::channel();
241        self.register_pending_request(request_id, tx)?;
242        let _pending_guard = PendingRequestGuard::new(self, request_id);
243
244        if self.request_writer_tx.send(bytes).await.is_err() {
245            self.pending.remove(&request_id);
246            return Err(TransportError::Sidecar(
247                "sidecar transport closed".to_string(),
248            ));
249        }
250
251        rx.await
252            .map_err(|_| TransportError::Sidecar("sidecar transport disconnected".to_string()))
253    }
254
255    /// Subscribe to structured/lifecycle/process events using generated wire protocol types.
256    pub fn subscribe_wire_events(
257        &self,
258    ) -> broadcast::Receiver<(wire::OwnershipScope, wire::EventPayload)> {
259        self.event_tx.subscribe()
260    }
261
262    /// Register a callback that answers a class of sidecar-initiated requests using generated wire
263    /// protocol types.
264    pub fn register_wire_callback(&self, key: &'static str, callback: WireSidecarCallback) {
265        let _ = self.callbacks.insert(key, callback);
266    }
267
268    /// Return the currently negotiated max frame size.
269    pub fn max_frame_bytes(&self) -> usize {
270        self.max_frame_bytes.load(Ordering::Relaxed)
271    }
272
273    /// Update the negotiated max frame size after authentication.
274    pub fn set_max_frame_bytes(&self, max_frame_bytes: usize) {
275        self.max_frame_bytes
276            .store(max_frame_bytes, Ordering::SeqCst);
277    }
278
279    /// Request graceful process termination through the physically independent
280    /// response/control lane. This does not reinterpret an ordinary request as
281    /// control traffic.
282    pub async fn shutdown(&self, reason: impl Into<String>) -> Result<(), TransportError> {
283        let frame = wire::ProtocolFrame::ControlFrame(wire::ControlFrame {
284            schema: wire::protocol_schema(),
285            payload: wire::ControlPayload::ShutdownControl(wire::ShutdownControl {
286                reason: reason.into(),
287            }),
288        });
289        let bytes = self.encode_wire_frame(&frame, None)?;
290        self.control_writer_tx.send(bytes).await.map_err(|_| {
291            TransportError::Sidecar("sidecar response/control transport closed".to_string())
292        })
293    }
294
295    /// Kill the child sidecar process if this transport still owns one.
296    pub fn kill_child(&self) {
297        if let Some(mut child) = self.child.lock().take() {
298            let _ = child.start_kill();
299        }
300    }
301
302    fn encode_wire_frame(
303        &self,
304        frame: &wire::ProtocolFrame,
305        max_frame_bytes: Option<usize>,
306    ) -> Result<Vec<u8>, TransportError> {
307        let transport_limit = self.max_frame_bytes.load(Ordering::Relaxed);
308        let max_frame_bytes = max_frame_bytes
309            .map(|limit| limit.min(transport_limit))
310            .unwrap_or(transport_limit);
311        let codec = WireFrameCodec::new(max_frame_bytes);
312        Ok(codec.encode(frame)?)
313    }
314
315    /// Route a decoded inbound frame. Host transports only legitimately receive `Response`, `Event`,
316    /// and `SidecarRequest` frames.
317    async fn handle_wire_frame(self: &Arc<Self>, frame: wire::ProtocolFrame) {
318        match frame {
319            wire::ProtocolFrame::ResponseFrame(response) => {
320                match self.pending.remove(&response.request_id) {
321                    Some((_, tx)) => {
322                        let _ = tx.send(response.payload);
323                    }
324                    None => {
325                        tracing::warn!(
326                            request_id = response.request_id,
327                            "response for unknown request id"
328                        )
329                    }
330                }
331            }
332            wire::ProtocolFrame::EventFrame(event) => {
333                // Transport-level liveness beats from the sidecar. Their arrival
334                // already reset the silence watchdog in the reader; they carry no
335                // meaning for event subscribers, so drop them here (mirrors the
336                // TS client's heartbeat swallow).
337                if matches!(
338                    &event.payload,
339                    wire::EventPayload::StructuredEvent(structured)
340                        if structured.name == "heartbeat"
341                ) {
342                    return;
343                }
344                let _ = self.event_tx.send((event.ownership, event.payload));
345            }
346            wire::ProtocolFrame::SidecarRequestFrame(request) => {
347                self.dispatch_sidecar_request(request).await
348            }
349            wire::ProtocolFrame::SidecarResponseFrame(_)
350            | wire::ProtocolFrame::RequestFrame(_)
351            | wire::ProtocolFrame::ControlFrame(_) => {
352                tracing::warn!("unexpected inbound frame on host transport")
353            }
354        }
355    }
356
357    /// Dispatch a sidecar-initiated request to its registered callback. The callback runs in a
358    /// spawned task so long-running host callbacks (binding execution, permission prompts) cannot stall
359    /// the reader loop, which must keep draining responses for any requests the callback itself
360    /// issues through this transport.
361    async fn dispatch_sidecar_request(self: &Arc<Self>, frame: wire::SidecarRequestFrame) {
362        let key = sidecar_request_key(&frame.payload);
363        let callback = self.callbacks.read(&key, |_, value| value.clone());
364        match callback {
365            Some(callback) => {
366                let transport = Arc::downgrade(self);
367                tokio::spawn(async move {
368                    match callback(frame.payload, frame.ownership.clone()).await {
369                        Ok(payload) => {
370                            let response = wire::ProtocolFrame::SidecarResponseFrame(
371                                wire::SidecarResponseFrame {
372                                    schema: wire::protocol_schema(),
373                                    request_id: frame.request_id,
374                                    ownership: frame.ownership,
375                                    payload,
376                                },
377                            );
378                            // If the transport is gone, the child is being killed; drop the reply.
379                            let Some(transport) = transport.upgrade() else {
380                                return;
381                            };
382                            if let Ok(bytes) = transport.encode_wire_frame(&response, None) {
383                                let _ = transport.control_writer_tx.send(bytes).await;
384                            }
385                        }
386                        Err(error) => tracing::warn!(?error, key, "sidecar callback failed"),
387                    }
388                });
389            }
390            None => tracing::warn!(key, "no callback registered for sidecar request"),
391        }
392    }
393
394    /// Reject every in-flight request after the transport disconnects.
395    fn fail_all_pending(&self) {
396        self.pending.clear();
397    }
398
399    fn disconnect(&self) {
400        self.kill_child();
401        self.fail_all_pending();
402    }
403
404    fn register_pending_request(
405        &self,
406        request_id: wire::RequestId,
407        tx: oneshot::Sender<wire::ResponsePayload>,
408    ) -> Result<(), TransportError> {
409        let _guard = self.pending_request_lock.lock();
410        if pending_request_count(self) >= PENDING_REQUEST_LIMIT {
411            return Err(TransportError::Sidecar(format!(
412                "sidecar pending request limit exceeded: at most {PENDING_REQUEST_LIMIT} requests can be in flight"
413            )));
414        }
415        let _ = self.pending.insert(request_id, tx);
416        Ok(())
417    }
418}
419
420#[cfg(unix)]
421fn map_control_fd(
422    command: &mut Command,
423    control_child: StdUnixStream,
424) -> Result<(), TransportError> {
425    command
426        .fd_mappings(vec![FdMapping {
427            parent_fd: control_child.into(),
428            child_fd: CONTROL_FD,
429        }])
430        .map_err(|error| {
431            TransportError::Sidecar(format!(
432                "failed to map sidecar response/control fd: {error}"
433            ))
434        })?;
435    Ok(())
436}
437
438struct PendingRequestGuard<'a> {
439    transport: &'a SidecarTransport,
440    request_id: wire::RequestId,
441}
442
443impl<'a> PendingRequestGuard<'a> {
444    fn new(transport: &'a SidecarTransport, request_id: wire::RequestId) -> Self {
445        Self {
446            transport,
447            request_id,
448        }
449    }
450}
451
452impl Drop for PendingRequestGuard<'_> {
453    fn drop(&mut self) {
454        let _ = self.transport.pending.remove(&self.request_id);
455    }
456}
457
458fn pending_request_count(transport: &SidecarTransport) -> usize {
459    let mut count = 0;
460    transport.pending.scan(|_, _| {
461        count += 1;
462    });
463    count
464}
465
466/// Map a sidecar-request payload to the callback registry key.
467fn sidecar_request_key(payload: &wire::SidecarRequestPayload) -> &'static str {
468    match payload {
469        wire::SidecarRequestPayload::HostCallbackRequest(_) => "host_callback",
470        wire::SidecarRequestPayload::JsBridgeCallRequest(_) => "js_bridge_call",
471        wire::SidecarRequestPayload::ExtEnvelope(_) => "ext",
472    }
473}
474
475/// Drain one bounded outbound lane into its physically independent stream.
476async fn run_writer<W>(
477    transport: Weak<SidecarTransport>,
478    lane: &'static str,
479    mut writer: W,
480    mut frames: mpsc::Receiver<Vec<u8>>,
481) where
482    W: AsyncWrite + Unpin,
483{
484    while let Some(bytes) = frames.recv().await {
485        let result = async {
486            writer.write_all(&bytes).await?;
487            writer.flush().await
488        }
489        .await;
490        if let Err(error) = result {
491            tracing::warn!(?error, lane, "sidecar writer failed");
492            if let Some(transport) = transport.upgrade() {
493                transport.disconnect();
494            }
495            return;
496        }
497    }
498}
499
500#[derive(Clone, Copy, Debug)]
501enum InboundLane {
502    Event,
503    Control,
504}
505
506impl InboundLane {
507    fn accepts(self, frame: &wire::ProtocolFrame) -> bool {
508        match self {
509            Self::Event => {
510                matches!(frame, wire::ProtocolFrame::EventFrame(event) if !is_heartbeat(event))
511            }
512            Self::Control => match frame {
513                wire::ProtocolFrame::ResponseFrame(_)
514                | wire::ProtocolFrame::SidecarRequestFrame(_) => true,
515                wire::ProtocolFrame::EventFrame(event) => is_heartbeat(event),
516                wire::ProtocolFrame::RequestFrame(_)
517                | wire::ProtocolFrame::SidecarResponseFrame(_)
518                | wire::ProtocolFrame::ControlFrame(_) => false,
519            },
520        }
521    }
522}
523
524fn is_heartbeat(event: &wire::EventFrame) -> bool {
525    matches!(
526        &event.payload,
527        wire::EventPayload::StructuredEvent(structured) if structured.name == "heartbeat"
528    )
529}
530
531/// Read length-prefixed BARE frames from one physical inbound lane and route them.
532async fn run_reader<R>(transport: Weak<SidecarTransport>, mut reader: R, lane: InboundLane)
533where
534    R: AsyncRead + Unpin,
535{
536    loop {
537        let mut length_buf = [0u8; 4];
538        if let Err(error) = reader.read_exact(&mut length_buf).await {
539            if let Some(transport) = transport.upgrade() {
540                tracing::warn!(?error, ?lane, "sidecar reader ended");
541                transport.disconnect();
542            }
543            return;
544        }
545        let length = u32::from_be_bytes(length_buf) as usize;
546
547        let Some(transport) = transport.upgrade() else {
548            break;
549        };
550        let max_frame_bytes = transport.max_frame_bytes.load(Ordering::Relaxed);
551        if frame_length_exceeds_limit(length, max_frame_bytes) {
552            tracing::warn!(
553                size = length,
554                max = max_frame_bytes,
555                "sidecar frame exceeds negotiated limit"
556            );
557            transport.disconnect();
558            return;
559        }
560
561        let mut frame_bytes = vec![0u8; 4 + length];
562        frame_bytes[..4].copy_from_slice(&length_buf);
563        if let Err(error) = reader.read_exact(&mut frame_bytes[4..]).await {
564            tracing::warn!(?error, ?lane, "sidecar reader ended mid-frame");
565            transport.disconnect();
566            return;
567        }
568        // Any complete inbound frame proves the sidecar is alive; the silence
569        // watchdog measures from here.
570        *transport.last_inbound_at.lock() = std::time::Instant::now();
571
572        let codec = WireFrameCodec::new(max_frame_bytes);
573        match codec.decode(&frame_bytes) {
574            Ok(frame) if lane.accepts(&frame) => transport.handle_wire_frame(frame).await,
575            Ok(frame) => {
576                tracing::warn!(?lane, frame = ?frame, "sidecar frame arrived on wrong transport lane");
577                transport.disconnect();
578                return;
579            }
580            Err(error) => {
581                tracing::warn!(?error, ?lane, "failed to decode sidecar frame");
582                transport.disconnect();
583                return;
584            }
585        }
586    }
587}
588
589fn frame_length_exceeds_limit(length: usize, max_frame_bytes: usize) -> bool {
590    length > max_frame_bytes
591}
592
593/// Kill the sidecar and fail all in-flight requests once the transport has seen no inbound frames
594/// (not even heartbeats) for `timeout`. A silent sidecar is dead or wedged, not busy: a busy
595/// sidecar still heartbeats every 10s from a dedicated thread. Exits when the transport drops.
596async fn run_silence_watchdog(transport: Weak<SidecarTransport>, timeout: std::time::Duration) {
597    let check_interval = (timeout / 4).min(std::time::Duration::from_secs(1));
598    loop {
599        tokio::time::sleep(check_interval).await;
600        let Some(transport) = transport.upgrade() else {
601            return;
602        };
603        let silence = transport.last_inbound_at.lock().elapsed();
604        if silence < timeout {
605            continue;
606        }
607        tracing::error!(
608            silence_ms = silence.as_millis() as u64,
609            "sidecar unresponsive: no protocol frames or heartbeats; killing sidecar",
610        );
611        transport.kill_child();
612        transport.fail_all_pending();
613        return;
614    }
615}
616
617fn resolve_sidecar_binary_path(binary_path: Option<String>) -> String {
618    binary_path
619        .or_else(|| std::env::var(SIDECAR_BIN_ENV).ok())
620        .unwrap_or_else(|| "agentos-native-sidecar".to_string())
621}
622
623#[cfg(test)]
624mod tests {
625    use super::*;
626    use std::sync::Mutex;
627
628    static ENV_LOCK: Mutex<()> = Mutex::new(());
629
630    fn test_transport() -> SidecarTransport {
631        let (request_writer_tx, _request_writer_rx) = mpsc::channel(REQUEST_FRAME_QUEUE_CAPACITY);
632        let (control_writer_tx, _control_writer_rx) = mpsc::channel(CONTROL_FRAME_QUEUE_CAPACITY);
633        let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
634        SidecarTransport {
635            child: parking_lot::Mutex::new(None),
636            pending: SccHashMap::new(),
637            pending_request_lock: parking_lot::Mutex::new(()),
638            request_counter: AtomicI64::new(1),
639            max_frame_bytes: AtomicUsize::new(wire::DEFAULT_MAX_FRAME_BYTES),
640            event_tx,
641            callbacks: SccHashMap::new(),
642            request_writer_tx,
643            control_writer_tx,
644            last_inbound_at: parking_lot::Mutex::new(std::time::Instant::now()),
645        }
646    }
647
648    #[test]
649    fn binary_path_prefers_explicit_path_over_env() {
650        let _guard = ENV_LOCK.lock().expect("env lock");
651        let previous = std::env::var(SIDECAR_BIN_ENV).ok();
652        std::env::set_var(SIDECAR_BIN_ENV, "/tmp/from-env");
653
654        assert_eq!(
655            resolve_sidecar_binary_path(Some("/tmp/from-config".to_string())),
656            "/tmp/from-config"
657        );
658
659        restore_env(SIDECAR_BIN_ENV, previous);
660    }
661
662    #[test]
663    fn binary_path_uses_agentos_env_fallback() {
664        let _guard = ENV_LOCK.lock().expect("env lock");
665        let previous = std::env::var(SIDECAR_BIN_ENV).ok();
666        std::env::set_var(SIDECAR_BIN_ENV, "/tmp/agentos-native-sidecar");
667
668        assert_eq!(
669            resolve_sidecar_binary_path(None),
670            "/tmp/agentos-native-sidecar"
671        );
672
673        restore_env(SIDECAR_BIN_ENV, previous);
674    }
675
676    #[test]
677    fn binary_path_defaults_to_agentos_native_sidecar() {
678        let _guard = ENV_LOCK.lock().expect("env lock");
679        let previous = std::env::var(SIDECAR_BIN_ENV).ok();
680        std::env::remove_var(SIDECAR_BIN_ENV);
681
682        assert_eq!(resolve_sidecar_binary_path(None), "agentos-native-sidecar");
683
684        restore_env(SIDECAR_BIN_ENV, previous);
685    }
686
687    fn restore_env(key: &str, value: Option<String>) {
688        match value {
689            Some(value) => std::env::set_var(key, value),
690            None => std::env::remove_var(key),
691        }
692    }
693
694    #[test]
695    fn frame_length_limit_rejects_oversized_declared_length() {
696        assert!(!frame_length_exceeds_limit(1024, 1024));
697        assert!(frame_length_exceeds_limit(1025, 1024));
698    }
699
700    #[test]
701    fn transport_encodes_requests_with_generated_wire_codec() {
702        let transport = test_transport();
703        let frame = wire::ProtocolFrame::RequestFrame(wire::RequestFrame {
704            schema: wire::protocol_schema(),
705            request_id: 7,
706            ownership: wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
707                connection_id: "conn-1".to_string(),
708            }),
709            payload: wire::RequestPayload::AuthenticateRequest(wire::AuthenticateRequest {
710                client_name: "transport-test".to_string(),
711                auth_token: "token".to_string(),
712                protocol_version: wire::PROTOCOL_VERSION,
713                bridge_version: 1,
714            }),
715        });
716
717        let encoded = transport
718            .encode_wire_frame(&frame, None)
719            .expect("encode transport frame");
720        let decoded = WireFrameCodec::default()
721            .decode(&encoded)
722            .expect("decode generated wire frame");
723
724        assert!(matches!(
725            decoded,
726            wire::ProtocolFrame::RequestFrame(wire::RequestFrame {
727                payload: wire::RequestPayload::AuthenticateRequest(_),
728                ..
729            })
730        ));
731    }
732
733    #[tokio::test]
734    async fn transport_fans_out_generated_wire_events() {
735        let transport = Arc::new(test_transport());
736        let mut wire_events = transport.subscribe_wire_events();
737
738        transport
739            .handle_wire_frame(wire::ProtocolFrame::EventFrame(wire::EventFrame {
740                schema: wire::protocol_schema(),
741                ownership: wire::OwnershipScope::VmOwnership(wire::VmOwnership {
742                    connection_id: "conn-1".to_string(),
743                    session_id: "session-1".to_string(),
744                    vm_id: "vm-1".to_string(),
745                }),
746                payload: wire::EventPayload::ProcessOutputEvent(wire::ProcessOutputEvent {
747                    process_id: "proc-1".to_string(),
748                    channel: wire::StreamChannel::Stdout,
749                    chunk: b"hello".to_vec(),
750                }),
751            }))
752            .await;
753
754        let (ownership, payload) = wire_events.recv().await.expect("wire event");
755        assert!(matches!(
756            ownership,
757            wire::OwnershipScope::VmOwnership(wire::VmOwnership {
758                connection_id,
759                session_id,
760                vm_id,
761            }) if connection_id == "conn-1" && session_id == "session-1" && vm_id == "vm-1"
762        ));
763        assert!(matches!(
764            payload,
765            wire::EventPayload::ProcessOutputEvent(wire::ProcessOutputEvent {
766                process_id,
767                channel: wire::StreamChannel::Stdout,
768                chunk,
769            }) if process_id == "proc-1" && chunk == b"hello".to_vec()
770        ));
771    }
772
773    #[tokio::test]
774    async fn event_before_response_is_delivered_without_transport_history() {
775        let transport = Arc::new(test_transport());
776        let mut events = transport.subscribe_wire_events();
777        let (response_tx, response_rx) = oneshot::channel();
778        transport
779            .register_pending_request(7, response_tx)
780            .expect("register pending request");
781        let ownership = wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
782            connection_id: "conn-1".to_string(),
783        });
784
785        transport
786            .handle_wire_frame(wire::ProtocolFrame::EventFrame(wire::EventFrame {
787                schema: wire::protocol_schema(),
788                ownership: ownership.clone(),
789                payload: wire::EventPayload::StructuredEvent(wire::StructuredEvent {
790                    name: "process_started_before_reply".to_string(),
791                    detail: std::collections::HashMap::new(),
792                }),
793            }))
794            .await;
795        transport
796            .handle_wire_frame(wire::ProtocolFrame::ResponseFrame(wire::ResponseFrame {
797                schema: wire::protocol_schema(),
798                request_id: 7,
799                ownership,
800                payload: wire::ResponsePayload::ExtEnvelope(wire::ExtEnvelope {
801                    namespace: "test".to_string(),
802                    payload: Vec::new(),
803                }),
804            }))
805            .await;
806
807        let (_, event) = events.recv().await.expect("live event");
808        assert!(matches!(
809            event,
810            wire::EventPayload::StructuredEvent(wire::StructuredEvent { name, .. })
811                if name == "process_started_before_reply"
812        ));
813        assert!(matches!(
814            response_rx.await.expect("response"),
815            wire::ResponsePayload::ExtEnvelope(_)
816        ));
817    }
818
819    #[tokio::test]
820    async fn bounded_event_fanout_reports_lag_instead_of_retaining_history() {
821        let (event_tx, mut events) = broadcast::channel(2);
822        for index in 0..3 {
823            event_tx
824                .send((
825                    wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
826                        connection_id: "conn-1".to_string(),
827                    }),
828                    wire::EventPayload::StructuredEvent(wire::StructuredEvent {
829                        name: format!("event-{index}"),
830                        detail: std::collections::HashMap::new(),
831                    }),
832                ))
833                .expect("active receiver");
834        }
835
836        assert!(matches!(
837            events.recv().await,
838            Err(broadcast::error::RecvError::Lagged(1))
839        ));
840    }
841
842    #[tokio::test]
843    async fn silence_watchdog_fails_pending_requests_after_sustained_silence() {
844        let transport = Arc::new(test_transport());
845        let (tx, rx) = oneshot::channel();
846        transport
847            .register_pending_request(1, tx)
848            .expect("register pending request");
849
850        tokio::spawn(run_silence_watchdog(
851            Arc::downgrade(&transport),
852            std::time::Duration::from_millis(40),
853        ));
854
855        // No inbound activity at all: the watchdog must reject the pending
856        // request (dropped sender -> disconnected error at the caller).
857        rx.await
858            .expect_err("watchdog should drop the pending sender");
859        assert_eq!(pending_request_count(&transport), 0);
860    }
861
862    #[tokio::test]
863    async fn silence_watchdog_stays_quiet_while_frames_arrive() {
864        let transport = Arc::new(test_transport());
865        let (tx, mut rx) = oneshot::channel();
866        transport
867            .register_pending_request(1, tx)
868            .expect("register pending request");
869
870        tokio::spawn(run_silence_watchdog(
871            Arc::downgrade(&transport),
872            std::time::Duration::from_millis(120),
873        ));
874
875        // Simulate steady inbound activity (what the reader does per frame)
876        // for well past the silence window; the watchdog must not fire.
877        for _ in 0..6 {
878            tokio::time::sleep(std::time::Duration::from_millis(40)).await;
879            *transport.last_inbound_at.lock() = std::time::Instant::now();
880            assert!(
881                rx.try_recv().is_err(),
882                "pending request must remain registered while frames arrive"
883            );
884        }
885        assert_eq!(pending_request_count(&transport), 1);
886    }
887
888    #[tokio::test]
889    async fn heartbeat_events_are_swallowed_before_the_event_fanout() {
890        let transport = Arc::new(test_transport());
891        let mut wire_events = transport.subscribe_wire_events();
892
893        transport
894            .handle_wire_frame(wire::ProtocolFrame::EventFrame(wire::EventFrame {
895                schema: wire::protocol_schema(),
896                ownership: wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
897                    connection_id: "sidecar-transport".to_string(),
898                }),
899                payload: wire::EventPayload::StructuredEvent(wire::StructuredEvent {
900                    name: "heartbeat".to_string(),
901                    detail: std::collections::HashMap::new(),
902                }),
903            }))
904            .await;
905        // A non-heartbeat structured event still fans out, proving the filter
906        // is name-scoped rather than dropping all structured events.
907        transport
908            .handle_wire_frame(wire::ProtocolFrame::EventFrame(wire::EventFrame {
909                schema: wire::protocol_schema(),
910                ownership: wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
911                    connection_id: "conn-1".to_string(),
912                }),
913                payload: wire::EventPayload::StructuredEvent(wire::StructuredEvent {
914                    name: "limit_warning".to_string(),
915                    detail: std::collections::HashMap::new(),
916                }),
917            }))
918            .await;
919
920        let (_, payload) = wire_events.recv().await.expect("structured event");
921        assert!(matches!(
922            payload,
923            wire::EventPayload::StructuredEvent(wire::StructuredEvent { name, .. })
924                if name == "limit_warning"
925        ));
926        assert!(
927            wire_events.try_recv().is_err(),
928            "heartbeat must not fan out"
929        );
930    }
931
932    #[test]
933    fn pending_request_guard_removes_registered_slot_on_drop() {
934        let transport = test_transport();
935        let (tx, _rx) = oneshot::channel();
936        transport
937            .register_pending_request(1, tx)
938            .expect("register pending request");
939
940        {
941            let _guard = PendingRequestGuard::new(&transport, 1);
942            assert_eq!(pending_request_count(&transport), 1);
943        }
944
945        assert_eq!(pending_request_count(&transport), 0);
946    }
947
948    #[test]
949    fn pending_request_limit_rejects_full_transport() {
950        let transport = test_transport();
951        for request_id in 1..=PENDING_REQUEST_LIMIT as wire::RequestId {
952            let (tx, _rx) = oneshot::channel();
953            transport
954                .register_pending_request(request_id, tx)
955                .expect("register pending request");
956        }
957        let (tx, _rx) = oneshot::channel();
958        let error = transport
959            .register_pending_request((PENDING_REQUEST_LIMIT + 1) as wire::RequestId, tx)
960            .expect_err("full pending map should reject");
961
962        assert!(
963            error
964                .to_string()
965                .contains("sidecar pending request limit exceeded"),
966            "unexpected error: {error}"
967        );
968    }
969
970    #[tokio::test]
971    async fn shutdown_uses_typed_control_frame() {
972        let (request_writer_tx, _request_writer_rx) = mpsc::channel(4);
973        let (control_writer_tx, mut control_writer_rx) = mpsc::channel(4);
974        let (event_tx, _) = broadcast::channel(4);
975        let transport = SidecarTransport {
976            child: parking_lot::Mutex::new(None),
977            pending: SccHashMap::new(),
978            pending_request_lock: parking_lot::Mutex::new(()),
979            request_counter: AtomicI64::new(1),
980            max_frame_bytes: AtomicUsize::new(wire::DEFAULT_MAX_FRAME_BYTES),
981            event_tx,
982            callbacks: SccHashMap::new(),
983            request_writer_tx,
984            control_writer_tx,
985            last_inbound_at: parking_lot::Mutex::new(std::time::Instant::now()),
986        };
987
988        transport
989            .shutdown("test complete")
990            .await
991            .expect("enqueue typed shutdown");
992        let bytes = control_writer_rx.recv().await.expect("control frame bytes");
993        let frame = WireFrameCodec::default()
994            .decode(&bytes)
995            .expect("decode shutdown frame");
996        assert!(matches!(
997            frame,
998            wire::ProtocolFrame::ControlFrame(wire::ControlFrame {
999                payload: wire::ControlPayload::ShutdownControl(wire::ShutdownControl { reason }),
1000                ..
1001            }) if reason == "test complete"
1002        ));
1003    }
1004
1005    #[cfg(unix)]
1006    #[tokio::test]
1007    async fn unix_spawn_maps_duplex_control_socket_to_fd_three() {
1008        let (mut parent, child) = StdUnixStream::pair().expect("control socketpair");
1009        parent
1010            .set_read_timeout(Some(std::time::Duration::from_secs(2)))
1011            .expect("control read timeout");
1012        let mut command = Command::new("sh");
1013        command.arg("-c").arg("printf mapped-control >&3");
1014        map_control_fd(&mut command, child).expect("map child control fd");
1015        let mut child = command.spawn().expect("spawn fd mapping probe");
1016        drop(command);
1017
1018        let mut received = [0_u8; 14];
1019        std::io::Read::read_exact(&mut parent, &mut received).expect("read mapped fd output");
1020        assert_eq!(&received, b"mapped-control");
1021        assert!(child
1022            .wait()
1023            .await
1024            .expect("wait for mapping probe")
1025            .success());
1026    }
1027
1028    #[tokio::test]
1029    async fn control_writer_progresses_while_ordinary_stream_is_blocked() {
1030        let transport = Arc::new(test_transport());
1031        let (ordinary_client, _ordinary_server) = tokio::io::duplex(1);
1032        let (control_client, mut control_server) = tokio::io::duplex(64);
1033        let (control_tx, control_rx) = mpsc::channel(CONTROL_FRAME_QUEUE_CAPACITY);
1034        let (request_tx, request_rx) = mpsc::channel(REQUEST_FRAME_QUEUE_CAPACITY);
1035        request_tx
1036            .send(vec![b'r'; 64])
1037            .await
1038            .expect("send request frame");
1039        control_tx
1040            .send(vec![b'c'])
1041            .await
1042            .expect("send control frame");
1043        let ordinary_writer = tokio::spawn(run_writer(
1044            Arc::downgrade(&transport),
1045            "ordinary",
1046            ordinary_client,
1047            request_rx,
1048        ));
1049        let control_writer = tokio::spawn(run_writer(
1050            Arc::downgrade(&transport),
1051            "control",
1052            control_client,
1053            control_rx,
1054        ));
1055        let mut first = [0u8; 1];
1056        tokio::time::timeout(
1057            std::time::Duration::from_secs(1),
1058            control_server.read_exact(&mut first),
1059        )
1060        .await
1061        .expect("control write must not wait for ordinary stream")
1062        .expect("read first byte");
1063        assert_eq!(first, [b'c']);
1064        ordinary_writer.abort();
1065        control_writer.abort();
1066    }
1067
1068    #[tokio::test]
1069    async fn eof_on_either_inbound_lane_fails_pending_requests() {
1070        for lane in [InboundLane::Event, InboundLane::Control] {
1071            let transport = Arc::new(test_transport());
1072            let (tx, rx) = oneshot::channel();
1073            transport
1074                .register_pending_request(1, tx)
1075                .expect("register pending request");
1076            let (reader, peer) = tokio::io::duplex(64);
1077            drop(peer);
1078
1079            tokio::time::timeout(
1080                std::time::Duration::from_secs(1),
1081                run_reader(Arc::downgrade(&transport), reader, lane),
1082            )
1083            .await
1084            .expect("reader must terminate on EOF");
1085
1086            rx.await.expect_err("EOF must fail pending requests");
1087            assert_eq!(pending_request_count(&transport), 0);
1088        }
1089    }
1090
1091    #[tokio::test]
1092    async fn writer_preserves_order_within_one_lane() {
1093        let transport = Arc::new(test_transport());
1094        let (client, mut server) = tokio::io::duplex(64);
1095        let (tx, rx) = mpsc::channel(CONTROL_FRAME_QUEUE_CAPACITY);
1096        tx.send(vec![b'c']).await.expect("control one");
1097        tx.send(vec![b'C']).await.expect("control two");
1098        drop(tx);
1099
1100        let writer = tokio::spawn(run_writer(
1101            Arc::downgrade(&transport),
1102            "control",
1103            client,
1104            rx,
1105        ));
1106        let mut output = [0u8; 2];
1107        server.read_exact(&mut output).await.expect("read output");
1108        writer.await.expect("writer task");
1109
1110        assert_eq!(output, [b'c', b'C']);
1111    }
1112}