1use 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
28const EVENT_CHANNEL_CAPACITY: usize = 4096;
30
31const REQUEST_FRAME_QUEUE_CAPACITY: usize = 4096;
33
34const CONTROL_FRAME_QUEUE_CAPACITY: usize = 1024;
36
37const PENDING_REQUEST_LIMIT: usize = 4096;
39
40const SIDECAR_BIN_ENV: &str = "AGENTOS_SIDECAR_BIN";
43
44#[cfg(unix)]
46const CONTROL_FD: std::os::fd::RawFd = 3;
47
48const SIDECAR_SILENCE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
54
55const 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
63pub 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
75pub struct SidecarTransport {
78 child: parking_lot::Mutex<Option<Child>>,
80 child_termination: tokio::sync::Mutex<()>,
81 pending: SccHashMap<wire::RequestId, oneshot::Sender<wire::ResponsePayload>>,
83 pending_request_lock: parking_lot::Mutex<()>,
84 request_counter: AtomicI64,
86 max_frame_bytes: AtomicUsize,
88 event_tx: Arc<EventSenderSlot>,
92 callbacks: SccHashMap<&'static str, WireSidecarCallback>,
94 request_writer_tx: mpsc::Sender<Vec<u8>>,
96 control_writer_tx: mpsc::Sender<Vec<u8>>,
98 last_inbound_at: parking_lot::Mutex<std::time::Instant>,
100 disconnected: AtomicBool,
103}
104
105impl SidecarTransport {
106 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 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 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 pub fn next_request_id(&self) -> wire::RequestId {
229 self.request_counter.fetch_add(1, Ordering::SeqCst)
230 }
231
232 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 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 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 None => broadcast::channel(1).1,
291 }
292 }
293
294 pub fn register_wire_callback(&self, key: &'static str, callback: WireSidecarCallback) {
297 let _ = self.callbacks.insert(key, callback);
298 }
299
300 pub fn max_frame_bytes(&self) -> usize {
302 self.max_frame_bytes.load(Ordering::Relaxed)
303 }
304
305 pub fn is_disconnected(&self) -> bool {
308 self.disconnected.load(Ordering::SeqCst)
309 }
310
311 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 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 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 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 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 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 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 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 fn fail_all_pending(&self) {
483 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 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 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
579fn 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
588async 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
644async 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 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 *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
719async 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
748struct 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 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 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 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 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 #[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}