1#[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
8use std::collections::HashMap;
9use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU8, Ordering};
10use std::sync::Arc;
11
12use once_cell::sync::OnceCell;
13use scc::HashMap as SccHashMap;
14use serde::Serialize;
15use uuid::Uuid;
16
17use agentos_sidecar_client::wire;
18
19use crate::agent_os::AgentOs;
20use crate::error::ClientError;
21use crate::transport::SidecarProcess;
22
23const SHARED_SIDECAR_POOL_LIMIT: usize = 1024;
25
26const AGENTOS_SIDECAR_BIN_ENV: &str = "AGENTOS_SIDECAR_BIN";
28
29static SHARED_SIDECAR_PACKAGE_CACHE: OnceCell<crate::software::ProcessPackageCacheOptions> =
30 OnceCell::new();
31
32#[cfg(feature = "sidecar-internals")]
35pub fn configure_shared_sidecar_package_cache(
36 options: crate::software::ProcessPackageCacheOptions,
37) -> Result<(), ClientError> {
38 options.validate()?;
39 if let Some(existing) = SHARED_SIDECAR_PACKAGE_CACHE.get() {
40 if existing == &options {
41 return Ok(());
42 }
43 return Err(ClientError::PackageCacheConfiguration(
44 "shared sidecar package cache is already configured differently".into(),
45 ));
46 }
47 match SHARED_SIDECAR_PACKAGE_CACHE.set(options.clone()) {
48 Ok(()) => Ok(()),
49 Err(_) => configure_shared_sidecar_package_cache(options),
50 }
51}
52
53fn sidecar_spawn_args() -> Result<Vec<String>, ClientError> {
54 let Some(options) = SHARED_SIDECAR_PACKAGE_CACHE.get() else {
55 return Ok(Vec::new());
56 };
57 let mut args = vec!["sidecar".to_owned()];
58 if let Some(path) = &options.root {
59 let path = path.to_str().ok_or_else(|| {
60 ClientError::PackageCacheConfiguration(
61 "package cache directory is not valid UTF-8 for sidecar startup".into(),
62 )
63 })?;
64 args.extend(["--package-cache-dir".into(), path.into()]);
65 }
66 for (name, value) in [
67 ("--package-cache-min-free-bytes", options.min_free_bytes),
68 ("--package-cache-max-bytes", options.max_bytes),
69 ("--package-cache-max-entries", options.max_entries as u64),
70 (
71 "--package-cache-max-concurrent-acquisitions",
72 options.max_concurrent_acquisitions as u64,
73 ),
74 (
75 "--package-cache-max-pending-acquisitions",
76 options.max_pending_acquisitions as u64,
77 ),
78 (
79 "--package-cache-acquisition-timeout-ms",
80 options.acquisition_timeout_ms,
81 ),
82 (
83 "--package-cache-max-source-entries",
84 options.max_source_entries as u64,
85 ),
86 ("--package-cache-source-ttl-ms", options.source_ttl_ms),
87 ] {
88 args.extend([name.into(), value.to_string()]);
89 }
90 Ok(args)
91}
92
93pub(crate) struct SharedConnection {
96 pub(crate) transport: Arc<SidecarProcess>,
97 pub(crate) connection_id: String,
98 #[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
99 package_session_id: Option<String>,
100 authenticated: bool,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
110#[serde(rename_all = "lowercase")]
111pub enum SidecarState {
112 Ready,
113 Disposing,
114 Disposed,
115}
116
117impl SidecarState {
118 pub const fn as_str(self) -> &'static str {
120 match self {
121 SidecarState::Ready => "ready",
122 SidecarState::Disposing => "disposing",
123 SidecarState::Disposed => "disposed",
124 }
125 }
126
127 pub(crate) const fn as_u8(self) -> u8 {
128 match self {
129 SidecarState::Ready => 0,
130 SidecarState::Disposing => 1,
131 SidecarState::Disposed => 2,
132 }
133 }
134
135 pub(crate) const fn from_u8(value: u8) -> Self {
136 match value {
137 0 => SidecarState::Ready,
138 1 => SidecarState::Disposing,
139 2 => SidecarState::Disposed,
140 _ => SidecarState::Disposed,
142 }
143 }
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
153#[serde(tag = "kind", rename_all = "lowercase")]
154pub enum AgentOsSidecarPlacement {
155 Shared {
156 #[serde(skip_serializing_if = "Option::is_none")]
157 pool: Option<String>,
158 },
159 Explicit {
160 #[serde(rename = "sidecarId")]
161 sidecar_id: String,
162 },
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
170#[serde(rename_all = "camelCase")]
171pub struct AgentOsSidecarDescription {
172 pub sidecar_id: String,
173 pub placement: AgentOsSidecarPlacement,
174 pub state: SidecarState,
175 pub active_vm_count: u32,
176}
177
178pub struct AgentOsSidecar {
180 pub(crate) sidecar_id: String,
181 pub(crate) placement: AgentOsSidecarPlacement,
182 pub(crate) shared_pool: Option<String>,
183 pub(crate) state: AtomicU8,
184 pub(crate) active_vm_count: AtomicU32,
185 pub(crate) active_package_sessions: AtomicU32,
186 lifecycle: tokio::sync::Mutex<()>,
187 pub(crate) sidecar_binary_path: Option<String>,
191 pub(crate) connection: tokio::sync::Mutex<Option<SharedConnection>>,
194 live_transport: parking_lot::Mutex<Option<Arc<SidecarProcess>>>,
197}
198
199impl AgentOsSidecar {
200 pub(crate) fn new(
202 sidecar_id: impl Into<String>,
203 placement: AgentOsSidecarPlacement,
204 shared_pool: Option<String>,
205 sidecar_binary_path: Option<String>,
206 ) -> Self {
207 Self {
208 sidecar_id: sidecar_id.into(),
209 placement,
210 shared_pool,
211 state: AtomicU8::new(SidecarState::Ready.as_u8()),
212 active_vm_count: AtomicU32::new(0),
213 active_package_sessions: AtomicU32::new(0),
214 lifecycle: tokio::sync::Mutex::new(()),
215 sidecar_binary_path,
216 connection: tokio::sync::Mutex::new(None),
217 live_transport: parking_lot::Mutex::new(None),
218 }
219 }
220
221 pub(crate) async fn ensure_connection(
226 &self,
227 ) -> Result<(Arc<SidecarProcess>, String, usize), ClientError> {
228 let mut guard = self.connection.lock().await;
229 if self.state.load(Ordering::SeqCst) != SidecarState::Ready.as_u8() {
230 return Err(ClientError::Sidecar(
231 "sidecar is disposing or disposed".into(),
232 ));
233 }
234 if let Some(existing) = guard.as_ref() {
235 if existing.transport.is_disconnected() {
236 return Err(ClientError::SidecarExited(
237 "its VMs are gone; create a new VM".into(),
238 ));
239 }
240 if !existing.authenticated {
241 return Err(ClientError::Sidecar(
242 "sidecar authentication did not complete; dispose this sidecar before retrying"
243 .into(),
244 ));
245 }
246 let max_frame = existing.transport.max_frame_bytes();
247 return Ok((
248 existing.transport.clone(),
249 existing.connection_id.clone(),
250 max_frame,
251 ));
252 }
253
254 let transport = SidecarProcess::spawn_with_args(
255 Some(self.resolved_sidecar_binary_path()),
256 sidecar_spawn_args()?,
257 )
258 .await?;
259 *self.live_transport.lock() = Some(transport.clone());
262 *guard = Some(SharedConnection {
263 transport: transport.clone(),
264 connection_id: String::new(),
265 #[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
266 package_session_id: None,
267 authenticated: false,
268 });
269 let authed = match transport
270 .request_wire(
271 wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
272 connection_id: "client-hint".to_string(),
273 }),
274 wire::RequestPayload::AuthenticateRequest(wire::AuthenticateRequest {
275 client_name: "agentos-client".to_string(),
276 auth_token: "agentos-client".to_string(),
277 protocol_version: wire::PROTOCOL_VERSION,
278 bridge_version: agentos_bridge::bridge_contract().version,
279 }),
280 )
281 .await?
282 {
283 wire::ResponsePayload::AuthenticatedResponse(authed) => authed,
284 wire::ResponsePayload::RejectedResponse(rejected) => {
285 return Err(ClientError::Kernel {
286 code: rejected.code,
287 message: rejected.message,
288 });
289 }
290 _ => {
291 return Err(ClientError::Sidecar(
292 "unexpected authenticate response".to_string(),
293 ));
294 }
295 };
296 let max_frame = authed.max_frame_bytes as usize;
297 transport.set_max_frame_bytes(max_frame);
298
299 *guard = Some(SharedConnection {
300 transport: transport.clone(),
301 connection_id: authed.connection_id.clone(),
302 #[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
303 package_session_id: None,
304 authenticated: true,
305 });
306 Ok((transport, authed.connection_id, max_frame))
307 }
308
309 pub(crate) async fn kill_connection(&self) -> Result<(), ClientError> {
313 let mut connection = self.connection.lock().await;
314 if let Some(connection) = connection.as_ref() {
315 connection.transport.terminate_child().await?;
316 }
317 connection.take();
319 self.live_transport.lock().take();
320 Ok(())
321 }
322
323 pub(crate) async fn acquire_vm_lease(
324 self: &Arc<Self>,
325 ) -> Result<AgentOsSidecarVmLease, ClientError> {
326 self.acquire_lease(false).await
327 }
328
329 async fn acquire_lease(
330 self: &Arc<Self>,
331 package: bool,
332 ) -> Result<AgentOsSidecarVmLease, ClientError> {
333 let _lifecycle = self.lifecycle.lock().await;
334 if self.process_lost() {
335 return Err(ClientError::SidecarExited(
336 "its VMs are gone; create a new VM".into(),
337 ));
338 }
339 if self.current_state() != SidecarState::Ready {
340 return Err(ClientError::Sidecar(
341 "sidecar is disposing or disposed".into(),
342 ));
343 }
344 let count = if package {
345 &self.active_package_sessions
346 } else {
347 &self.active_vm_count
348 };
349 count
350 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
351 count.checked_add(1)
352 })
353 .map_err(|_| {
354 ClientError::Sidecar("limit_exceeded: sidecar lease count overflow".into())
355 })?;
356 Ok(AgentOsSidecarVmLease {
357 sidecar: self.clone(),
358 package,
359 released: AtomicBool::new(false),
360 })
361 }
362
363 pub(crate) async fn dispose_if_unused(&self) -> Result<(), ClientError> {
364 let _lifecycle = self.lifecycle.lock().await;
365 if self.active_vm_count.load(Ordering::SeqCst) == 0
366 && self.active_package_sessions.load(Ordering::SeqCst) == 0
367 {
368 self.dispose_locked().await?;
369 }
370 Ok(())
371 }
372
373 #[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
375 pub async fn open_package_session(
376 self: &Arc<Self>,
377 ) -> Result<AgentOsPackageSession, ClientError> {
378 let lease = self.acquire_lease(true).await?;
381 let result = async {
382 let (transport, connection_id, _) = self.ensure_connection().await?;
383 let mut connection = self.connection.lock().await;
384 let current = connection.as_mut().ok_or_else(|| {
385 ClientError::Sidecar(
386 "sidecar connection closed while opening package session".into(),
387 )
388 })?;
389 let session_id = if let Some(session_id) = ¤t.package_session_id {
393 session_id.clone()
394 } else {
395 let response = transport
396 .request_wire(
397 wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
398 connection_id: connection_id.clone(),
399 }),
400 wire::RequestPayload::OpenSessionRequest(wire::OpenSessionRequest {
401 placement: crate::agent_os::sidecar_wire_placement(self),
402 metadata: HashMap::new(),
403 }),
404 )
405 .await?;
406 let wire::ResponsePayload::SessionOpenedResponse(opened) = response else {
407 return Err(match response {
408 wire::ResponsePayload::RejectedResponse(rejected) => {
409 ClientError::from_rejection(rejected)
410 }
411 other => ClientError::Sidecar(format!(
412 "unexpected package session response: {other:?}"
413 )),
414 });
415 };
416 current.package_session_id = Some(opened.session_id.clone());
417 opened.session_id
418 };
419 drop(connection);
420 Ok((transport, connection_id, session_id))
421 }
422 .await;
423 let (transport, connection_id, session_id) = match result {
424 Ok(opened) => opened,
425 Err(error) => {
426 lease.release();
427 if let Err(cleanup_error) = self.dispose_if_unused().await {
428 tracing::error!(%cleanup_error, "failed to stop sidecar after package session open failed");
429 }
430 return Err(error);
431 }
432 };
433 Ok(AgentOsPackageSession {
434 sidecar: self.clone(),
435 transport,
436 connection_id,
437 session_id,
438 closed: AtomicBool::new(false),
439 lease,
440 })
441 }
442
443 fn resolved_sidecar_binary_path(&self) -> String {
444 self.sidecar_binary_path
445 .clone()
446 .or_else(|| std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok())
447 .unwrap_or_else(|| "agentos-native-sidecar".to_string())
448 }
449
450 pub fn describe(&self) -> AgentOsSidecarDescription {
457 AgentOsSidecarDescription {
458 sidecar_id: self.sidecar_id.clone(),
459 placement: self.placement.clone(),
460 state: self.current_state(),
461 active_vm_count: self.active_vm_count.load(Ordering::SeqCst),
462 }
463 }
464
465 fn current_state(&self) -> SidecarState {
468 let state = SidecarState::from_u8(self.state.load(Ordering::SeqCst));
469 if state == SidecarState::Ready && self.process_lost() {
470 SidecarState::Disposed
471 } else {
472 state
473 }
474 }
475
476 fn process_lost(&self) -> bool {
477 self.live_transport
478 .lock()
479 .as_ref()
480 .is_some_and(|transport| transport.is_disconnected())
481 }
482
483 pub async fn dispose(&self) -> Result<(), ClientError> {
493 let _lifecycle = self.lifecycle.lock().await;
494 self.dispose_locked().await
495 }
496
497 async fn dispose_locked(&self) -> Result<(), ClientError> {
498 if SidecarState::from_u8(self.state.load(Ordering::SeqCst)) == SidecarState::Disposed {
499 return Ok(());
500 }
501
502 self.state
503 .store(SidecarState::Disposing.as_u8(), Ordering::SeqCst);
504
505 self.kill_connection().await?;
510 self.active_vm_count.store(0, Ordering::SeqCst);
511 self.active_package_sessions.store(0, Ordering::SeqCst);
512 self.state
513 .store(SidecarState::Disposed.as_u8(), Ordering::SeqCst);
514
515 if let Some(pool) = self.shared_pool.as_deref() {
516 let self_ptr = self as *const AgentOsSidecar;
518 let _ = shared_sidecars()
519 .remove_if(pool, |cached| std::ptr::eq(Arc::as_ptr(cached), self_ptr));
520 }
521
522 Ok(())
523 }
524}
525
526#[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
528pub struct AgentOsPackageSession {
529 sidecar: Arc<AgentOsSidecar>,
530 transport: Arc<SidecarProcess>,
531 connection_id: String,
532 session_id: String,
533 closed: AtomicBool,
534 lease: AgentOsSidecarVmLease,
535}
536
537#[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
538impl AgentOsPackageSession {
539 pub async fn cache_stats(
540 &self,
541 ) -> Result<crate::software::ProcessPackageCacheStats, ClientError> {
542 if self.closed.load(Ordering::SeqCst) {
543 return Err(ClientError::Sidecar("package session is closed".into()));
544 }
545 let response = self
546 .transport
547 .request_wire(
548 wire::OwnershipScope::SessionOwnership(wire::SessionOwnership {
549 connection_id: self.connection_id.clone(),
550 session_id: self.session_id.clone(),
551 }),
552 wire::RequestPayload::GetPackageCacheStatsRequest,
553 )
554 .await?;
555 match response {
556 wire::ResponsePayload::PackageCacheStatsResponse(stats) => {
557 let count = |value: u64| usize::try_from(value).unwrap_or(usize::MAX);
558 Ok(crate::software::ProcessPackageCacheStats {
559 entries: count(stats.entries),
560 source_entries: count(stats.source_entries),
561 bytes: stats.bytes,
562 pinned_entries: count(stats.pinned_entries),
563 pending_acquisitions: count(stats.pending_acquisitions),
564 hits: stats.hits,
565 misses: stats.misses,
566 coalesced_waiters: stats.coalesced_waiters,
567 acquisitions: stats.acquisitions,
568 evictions: stats.evictions,
569 capacity_failures: stats.capacity_failures,
570 cancelled_acquisitions: stats.cancelled_acquisitions,
571 })
572 }
573 wire::ResponsePayload::RejectedResponse(rejected) => {
574 Err(ClientError::from_rejection(rejected))
575 }
576 other => Err(ClientError::Sidecar(format!(
577 "unexpected package cache stats response: {other:?}"
578 ))),
579 }
580 }
581
582 pub async fn acquire(
583 &self,
584 source: crate::software::PackageSource,
585 advisory: bool,
586 timeout_ms: Option<u64>,
587 options: Option<&crate::software::PackageResolverOptions>,
588 ) -> Result<crate::software::InstalledSoftware, ClientError> {
589 if self.closed.load(Ordering::SeqCst) {
590 return Err(ClientError::Sidecar("package session is closed".into()));
591 }
592 let source = match source {
593 crate::software::PackageSource::Url {
594 url,
595 expected_digest,
596 } => wire::PackageAcquisitionSource::PackageUrlSource(wire::PackageUrlSource {
597 url,
598 expected_digest,
599 }),
600 crate::software::PackageSource::Path {
601 path,
602 expected_digest,
603 } => wire::PackageAcquisitionSource::PackagePathSource(wire::PackagePathSource {
604 path,
605 expected_digest,
606 }),
607 };
608 let response = self
609 .transport
610 .request_wire(
611 wire::OwnershipScope::SessionOwnership(wire::SessionOwnership {
612 connection_id: self.connection_id.clone(),
613 session_id: self.session_id.clone(),
614 }),
615 wire::RequestPayload::AcquirePackageRequest(wire::AcquirePackageRequest {
616 source,
617 advisory,
618 timeout_ms,
619 max_package_bytes: options.map(|options| options.max_package_bytes),
620 download_timeout_ms: options.map(|options| options.download_timeout_ms),
621 connect_timeout_ms: options.map(|options| options.connect_timeout_ms),
622 max_redirects: options.map(|options| options.max_redirects as u32),
623 allow_insecure_local_http: options
624 .is_some_and(|options| options.allow_insecure_local_http),
625 }),
626 )
627 .await?;
628 match response {
629 wire::ResponsePayload::PackageAcquiredResponse(package) => {
630 Ok(crate::software::InstalledSoftware {
631 package_id: package.package_id,
632 digest: package.digest,
633 size_bytes: package.size,
634 package_name: package.package_name,
635 version: package.version,
636 commands: package.commands,
637 })
638 }
639 wire::ResponsePayload::RejectedResponse(rejected) => {
640 Err(ClientError::from_rejection(rejected))
641 }
642 other => Err(ClientError::Sidecar(format!(
643 "unexpected acquire_package response: {other:?}"
644 ))),
645 }
646 }
647
648 pub async fn close(&self) -> Result<(), ClientError> {
649 self.closed.store(true, Ordering::SeqCst);
650 self.lease.release();
651 self.sidecar.dispose_if_unused().await
653 }
654
655 #[cfg(feature = "service-internals")]
656 pub(crate) async fn shutdown_worker(&self) -> Result<(), ClientError> {
657 self.closed.store(true, Ordering::SeqCst);
658 self.lease.release();
659 self.sidecar.dispose().await
660 }
661}
662
663pub(crate) struct AgentOsSidecarVmLease {
665 pub(crate) sidecar: Arc<AgentOsSidecar>,
666 package: bool,
667 released: AtomicBool,
668}
669
670impl AgentOsSidecarVmLease {
671 pub(crate) fn retain_until_worker_shutdown(self) {
674 self.released.store(true, Ordering::SeqCst);
675 }
676
677 pub(crate) async fn dispose(self) -> Result<(), ClientError> {
686 self.release();
687 Ok(())
688 }
689
690 fn release(&self) -> bool {
691 if self.released.swap(true, Ordering::SeqCst) {
692 return false;
693 }
694 let count = if self.package {
695 &self.sidecar.active_package_sessions
696 } else {
697 &self.sidecar.active_vm_count
698 };
699 count
700 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
701 Some(count.saturating_sub(1))
702 })
703 .expect("saturating lease decrement");
704 true
705 }
706}
707
708impl Drop for AgentOsSidecarVmLease {
709 fn drop(&mut self) {
710 if self.release()
711 && self.sidecar.state.load(Ordering::SeqCst) == SidecarState::Ready.as_u8()
712 {
713 tracing::warn!(sidecar_id = %self.sidecar.sidecar_id, "sidecar lease dropped without explicit cleanup; ownership released, call sidecar.dispose() to confirm child teardown");
714 }
715 }
716}
717
718static SHARED_SIDECARS: OnceCell<SccHashMap<String, Arc<AgentOsSidecar>>> = OnceCell::new();
720static SHARED_SIDECAR_POOL_LOCK: OnceCell<parking_lot::Mutex<()>> = OnceCell::new();
721
722pub(crate) fn shared_sidecars() -> &'static SccHashMap<String, Arc<AgentOsSidecar>> {
724 SHARED_SIDECARS.get_or_init(SccHashMap::new)
725}
726
727fn shared_sidecar_pool_lock() -> &'static parking_lot::Mutex<()> {
728 SHARED_SIDECAR_POOL_LOCK.get_or_init(parking_lot::Mutex::default)
729}
730
731fn shared_sidecar_pool_len(cache: &SccHashMap<String, Arc<AgentOsSidecar>>) -> usize {
732 let mut len = 0;
733 cache.scan(|_, _| {
734 len += 1;
735 });
736 len
737}
738
739fn prune_disposed_shared_sidecars(cache: &SccHashMap<String, Arc<AgentOsSidecar>>) {
740 let mut disposed_pools = Vec::new();
741 cache.scan(|pool, sidecar| {
742 if sidecar.describe().state == SidecarState::Disposed {
743 disposed_pools.push(pool.clone());
744 }
745 });
746 for pool in disposed_pools {
747 let _ = cache.remove_if(&pool, |sidecar| {
748 sidecar.describe().state == SidecarState::Disposed
749 });
750 }
751}
752
753#[cfg(test)]
754fn ensure_shared_sidecar_pool_capacity(
755 cache: &SccHashMap<String, Arc<AgentOsSidecar>>,
756) -> Result<(), ClientError> {
757 if shared_sidecar_pool_len(cache) >= SHARED_SIDECAR_POOL_LIMIT {
758 return Err(shared_sidecar_pool_limit_error());
759 }
760 Ok(())
761}
762
763fn shared_sidecar_pool_limit_error() -> ClientError {
764 ClientError::Sidecar(format!(
765 "shared sidecar pool limit exceeded: at most {SHARED_SIDECAR_POOL_LIMIT} pools can be cached"
766 ))
767}
768
769impl AgentOs {
770 pub async fn create_sidecar(
775 sidecar_id: Option<String>,
776 ) -> Result<Arc<AgentOsSidecar>, ClientError> {
777 let sidecar_id =
778 sidecar_id.unwrap_or_else(|| format!("agentos-native-sidecar-{}", Uuid::new_v4()));
779 let placement = AgentOsSidecarPlacement::Explicit {
780 sidecar_id: sidecar_id.clone(),
781 };
782 Ok(Arc::new(AgentOsSidecar::new(
783 sidecar_id, placement, None, None,
784 )))
785 }
786
787 pub async fn get_shared_sidecar(
796 pool: Option<String>,
797 sidecar_binary_path: Option<String>,
798 ) -> Result<Arc<AgentOsSidecar>, ClientError> {
799 let pool = pool.unwrap_or_else(|| "default".to_string());
800 let cache = shared_sidecars();
801 let _guard = shared_sidecar_pool_lock().lock();
802
803 if let Some(existing) = cache.read(&pool, |_, sidecar| sidecar.clone()) {
805 if existing.describe().state != SidecarState::Disposed {
806 return Ok(existing);
807 }
808 }
809 prune_disposed_shared_sidecars(cache);
810
811 let placement_pool = if pool.is_empty() {
815 None
816 } else {
817 Some(pool.clone())
818 };
819 let sidecar = Arc::new(AgentOsSidecar::new(
820 format!("agentos-shared-sidecar:{pool}"),
821 AgentOsSidecarPlacement::Shared {
822 pool: placement_pool,
823 },
824 Some(pool.clone()),
825 sidecar_binary_path,
826 ));
827
828 let cache_len = shared_sidecar_pool_len(cache);
831 match cache.entry(pool) {
832 scc::hash_map::Entry::Occupied(mut occupied) => {
833 if occupied.get().describe().state == SidecarState::Disposed {
834 *occupied.get_mut() = sidecar.clone();
835 Ok(sidecar)
836 } else {
837 Ok(occupied.get().clone())
838 }
839 }
840 scc::hash_map::Entry::Vacant(vacant) => {
841 if cache_len >= SHARED_SIDECAR_POOL_LIMIT {
842 return Err(shared_sidecar_pool_limit_error());
843 }
844 vacant.insert_entry(sidecar.clone());
845 Ok(sidecar)
846 }
847 }
848 }
849}
850
851#[cfg(test)]
852mod tests {
853 use super::*;
854 use std::sync::Mutex;
855
856 static ENV_LOCK: Mutex<()> = Mutex::new(());
857
858 #[tokio::test]
859 async fn opening_leases_prevent_last_vm_from_disposing_the_child() {
860 let sidecar = shared("opening-leases", SidecarState::Ready);
861 let vm = sidecar.acquire_vm_lease().await.unwrap();
862 let package = sidecar.acquire_lease(true).await.unwrap();
863 vm.dispose().await.unwrap();
864 sidecar.dispose_if_unused().await.unwrap();
865 assert_eq!(sidecar.describe().state, SidecarState::Ready);
866 assert_eq!(sidecar.active_package_sessions.load(Ordering::SeqCst), 1);
867 package.release();
868 sidecar.dispose_if_unused().await.unwrap();
869 assert_eq!(sidecar.describe().state, SidecarState::Disposed);
870 assert!(sidecar.acquire_vm_lease().await.is_err());
871 assert!(sidecar.ensure_connection().await.is_err());
872 drop(package);
873 assert_eq!(sidecar.active_package_sessions.load(Ordering::SeqCst), 0);
874 }
875
876 #[tokio::test]
877 async fn unconfirmed_vm_cleanup_keeps_ownership_until_force_disposal() {
878 let sidecar = shared("unconfirmed-cleanup", SidecarState::Ready);
879 sidecar
880 .acquire_vm_lease()
881 .await
882 .unwrap()
883 .retain_until_worker_shutdown();
884 sidecar.dispose_if_unused().await.unwrap();
885 assert_eq!(sidecar.describe().active_vm_count, 1);
886 assert_eq!(sidecar.describe().state, SidecarState::Ready);
887 sidecar.dispose().await.unwrap();
888 assert_eq!(sidecar.describe().active_vm_count, 0);
889 assert_eq!(sidecar.describe().state, SidecarState::Disposed);
890 }
891
892 #[tokio::test]
893 async fn failed_package_session_open_releases_its_reserved_lease() {
894 let sidecar = Arc::new(AgentOsSidecar::new(
895 "missing-binary",
896 AgentOsSidecarPlacement::Explicit {
897 sidecar_id: "missing-binary".into(),
898 },
899 None,
900 Some("/agentos-missing-test-binary".into()),
901 ));
902 assert!(sidecar.open_package_session().await.is_err());
903 assert_eq!(sidecar.active_package_sessions.load(Ordering::SeqCst), 0);
904 assert_eq!(sidecar.describe().state, SidecarState::Disposed);
905 }
906
907 #[tokio::test]
908 async fn failed_vm_creation_releases_its_reserved_lease() {
909 let sidecar = Arc::new(AgentOsSidecar::new(
910 "missing-vm-binary",
911 AgentOsSidecarPlacement::Explicit {
912 sidecar_id: "missing-vm-binary".into(),
913 },
914 None,
915 Some("/agentos-missing-test-binary".into()),
916 ));
917 let result = AgentOs::create(crate::AgentOsConfig {
918 sidecar: Some(crate::AgentOsSidecarConfig::Explicit {
919 handle: sidecar.clone(),
920 }),
921 ..Default::default()
922 })
923 .await;
924 assert!(result.is_err());
925 assert_eq!(sidecar.describe().active_vm_count, 0);
926 assert_eq!(sidecar.describe().state, SidecarState::Disposed);
927 }
928
929 fn shared(pool: &str, state: SidecarState) -> Arc<AgentOsSidecar> {
930 let sidecar = Arc::new(AgentOsSidecar::new(
931 format!("agentos-shared-sidecar:{pool}"),
932 AgentOsSidecarPlacement::Shared {
933 pool: Some(pool.to_string()),
934 },
935 Some(pool.to_string()),
936 None,
937 ));
938 sidecar.state.store(state.as_u8(), Ordering::SeqCst);
939 sidecar
940 }
941
942 #[test]
943 fn sidecar_binary_path_prefers_explicit_wrapper_path() {
944 let _guard = ENV_LOCK.lock().expect("env lock");
945 let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok();
946 std::env::set_var(AGENTOS_SIDECAR_BIN_ENV, "/tmp/from-env");
947 let sidecar = AgentOsSidecar::new(
948 "explicit-test",
949 AgentOsSidecarPlacement::Explicit {
950 sidecar_id: "explicit-test".to_string(),
951 },
952 None,
953 Some("/tmp/from-config".to_string()),
954 );
955
956 assert_eq!(sidecar.resolved_sidecar_binary_path(), "/tmp/from-config");
957
958 restore_env(AGENTOS_SIDECAR_BIN_ENV, previous);
959 }
960
961 #[test]
962 fn sidecar_binary_path_uses_agent_os_env_fallback() {
963 let _guard = ENV_LOCK.lock().expect("env lock");
964 let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok();
965 std::env::set_var(AGENTOS_SIDECAR_BIN_ENV, "/tmp/agentos-native-sidecar");
966 let sidecar = shared("env-test", SidecarState::Ready);
967
968 assert_eq!(
969 sidecar.resolved_sidecar_binary_path(),
970 "/tmp/agentos-native-sidecar"
971 );
972
973 restore_env(AGENTOS_SIDECAR_BIN_ENV, previous);
974 }
975
976 #[test]
977 fn sidecar_binary_path_defaults_to_agent_os_wrapper() {
978 let _guard = ENV_LOCK.lock().expect("env lock");
979 let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok();
980 std::env::remove_var(AGENTOS_SIDECAR_BIN_ENV);
981 let sidecar = shared("default-test", SidecarState::Ready);
982
983 assert_eq!(
984 sidecar.resolved_sidecar_binary_path(),
985 "agentos-native-sidecar"
986 );
987
988 restore_env(AGENTOS_SIDECAR_BIN_ENV, previous);
989 }
990
991 fn restore_env(key: &str, value: Option<String>) {
992 match value {
993 Some(value) => std::env::set_var(key, value),
994 None => std::env::remove_var(key),
995 }
996 }
997
998 #[test]
999 fn prune_disposed_shared_sidecars_keeps_live_entries() {
1000 let cache = SccHashMap::new();
1001 let _ = cache.insert("live".to_string(), shared("live", SidecarState::Ready));
1002 let _ = cache.insert(
1003 "disposed".to_string(),
1004 shared("disposed", SidecarState::Disposed),
1005 );
1006
1007 prune_disposed_shared_sidecars(&cache);
1008
1009 assert_eq!(shared_sidecar_pool_len(&cache), 1);
1010 assert!(cache.read("live", |_, _| ()).is_some());
1011 assert!(cache.read("disposed", |_, _| ()).is_none());
1012 }
1013
1014 #[test]
1015 fn shared_sidecar_pool_capacity_rejects_full_live_cache() {
1016 let cache = SccHashMap::new();
1017 for index in 0..SHARED_SIDECAR_POOL_LIMIT {
1018 let pool = format!("pool-{index}");
1019 let _ = cache.insert(pool.clone(), shared(&pool, SidecarState::Ready));
1020 }
1021
1022 let error =
1023 ensure_shared_sidecar_pool_capacity(&cache).expect_err("full cache should reject");
1024
1025 assert!(
1026 error
1027 .to_string()
1028 .contains("shared sidecar pool limit exceeded"),
1029 "unexpected error: {error}"
1030 );
1031 }
1032
1033 #[test]
1034 fn shared_sidecar_pool_capacity_allows_after_pruning_disposed_entries() {
1035 let cache = SccHashMap::new();
1036 for index in 0..SHARED_SIDECAR_POOL_LIMIT {
1037 let pool = format!("pool-{index}");
1038 let state = if index == 0 {
1039 SidecarState::Disposed
1040 } else {
1041 SidecarState::Ready
1042 };
1043 let _ = cache.insert(pool.clone(), shared(&pool, state));
1044 }
1045
1046 prune_disposed_shared_sidecars(&cache);
1047
1048 ensure_shared_sidecar_pool_capacity(&cache).expect("pruned cache should admit one entry");
1049 assert_eq!(
1050 shared_sidecar_pool_len(&cache),
1051 SHARED_SIDECAR_POOL_LIMIT - 1
1052 );
1053 }
1054
1055 #[tokio::test]
1056 async fn get_shared_sidecar_inserts_vacant_pool_without_reentrant_scan() {
1057 let pool = format!("unit-{}", Uuid::new_v4());
1058 let sidecar = AgentOs::get_shared_sidecar(Some(pool.clone()), None)
1059 .await
1060 .expect("shared sidecar");
1061
1062 assert_eq!(sidecar.shared_pool.as_deref(), Some(pool.as_str()));
1063
1064 sidecar.dispose().await.expect("dispose shared sidecar");
1065 }
1066
1067 #[test]
1068 fn dispose_removes_only_same_shared_sidecar_instance() {
1069 let pool = format!("dispose-race-{}", Uuid::new_v4());
1070 let old = shared(&pool, SidecarState::Ready);
1071 let replacement = shared(&pool, SidecarState::Ready);
1072 let cache = shared_sidecars();
1073 let _guard = shared_sidecar_pool_lock().lock();
1074
1075 let _ = cache.insert(pool.clone(), replacement.clone());
1076 old.state
1077 .store(SidecarState::Disposing.as_u8(), Ordering::SeqCst);
1078 old.active_vm_count.store(0, Ordering::SeqCst);
1079 old.state
1080 .store(SidecarState::Disposed.as_u8(), Ordering::SeqCst);
1081 let old_ptr = Arc::as_ptr(&old);
1082 let _ = cache.remove_if(&pool, |cached| std::ptr::eq(Arc::as_ptr(cached), old_ptr));
1083
1084 let cached = cache
1085 .read(&pool, |_, cached| cached.clone())
1086 .expect("replacement should remain cached");
1087 assert!(Arc::ptr_eq(&cached, &replacement));
1088
1089 let replacement_ptr = Arc::as_ptr(&replacement);
1090 let _ = cache.remove_if(&pool, |cached| {
1091 std::ptr::eq(Arc::as_ptr(cached), replacement_ptr)
1092 });
1093 }
1094}