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