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