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