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