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