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}
195
196impl AgentOsSidecar {
197 pub(crate) fn new(
199 sidecar_id: impl Into<String>,
200 placement: AgentOsSidecarPlacement,
201 shared_pool: Option<String>,
202 sidecar_binary_path: Option<String>,
203 ) -> Self {
204 Self {
205 sidecar_id: sidecar_id.into(),
206 placement,
207 shared_pool,
208 state: AtomicU8::new(SidecarState::Ready.as_u8()),
209 active_vm_count: AtomicU32::new(0),
210 active_package_sessions: AtomicU32::new(0),
211 lifecycle: tokio::sync::Mutex::new(()),
212 sidecar_binary_path,
213 connection: tokio::sync::Mutex::new(None),
214 }
215 }
216
217 pub(crate) async fn ensure_connection(
222 &self,
223 ) -> Result<(Arc<SidecarProcess>, String, usize), ClientError> {
224 let mut guard = self.connection.lock().await;
225 if self.state.load(Ordering::SeqCst) != SidecarState::Ready.as_u8() {
226 return Err(ClientError::Sidecar(
227 "sidecar is disposing or disposed".into(),
228 ));
229 }
230 if let Some(existing) = guard.as_ref() {
231 if !existing.authenticated {
232 return Err(ClientError::Sidecar(
233 "sidecar authentication did not complete; dispose this sidecar before retrying"
234 .into(),
235 ));
236 }
237 let max_frame = existing.transport.max_frame_bytes();
238 return Ok((
239 existing.transport.clone(),
240 existing.connection_id.clone(),
241 max_frame,
242 ));
243 }
244
245 let transport = SidecarProcess::spawn_with_args(
246 Some(self.resolved_sidecar_binary_path()),
247 sidecar_spawn_args()?,
248 )
249 .await?;
250 *guard = Some(SharedConnection {
253 transport: transport.clone(),
254 connection_id: String::new(),
255 #[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
256 package_session_id: None,
257 authenticated: false,
258 });
259 let authed = match transport
260 .request_wire(
261 wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
262 connection_id: "client-hint".to_string(),
263 }),
264 wire::RequestPayload::AuthenticateRequest(wire::AuthenticateRequest {
265 client_name: "agentos-client".to_string(),
266 auth_token: "agentos-client".to_string(),
267 protocol_version: wire::PROTOCOL_VERSION,
268 bridge_version: agentos_bridge::bridge_contract().version,
269 }),
270 )
271 .await?
272 {
273 wire::ResponsePayload::AuthenticatedResponse(authed) => authed,
274 wire::ResponsePayload::RejectedResponse(rejected) => {
275 return Err(ClientError::Kernel {
276 code: rejected.code,
277 message: rejected.message,
278 });
279 }
280 _ => {
281 return Err(ClientError::Sidecar(
282 "unexpected authenticate response".to_string(),
283 ));
284 }
285 };
286 let max_frame = authed.max_frame_bytes as usize;
287 transport.set_max_frame_bytes(max_frame);
288
289 *guard = Some(SharedConnection {
290 transport: transport.clone(),
291 connection_id: authed.connection_id.clone(),
292 #[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
293 package_session_id: None,
294 authenticated: true,
295 });
296 Ok((transport, authed.connection_id, max_frame))
297 }
298
299 pub(crate) async fn kill_connection(&self) -> Result<(), ClientError> {
303 let mut connection = self.connection.lock().await;
304 if let Some(connection) = connection.as_ref() {
305 connection.transport.terminate_child().await?;
306 }
307 connection.take();
309 Ok(())
310 }
311
312 pub(crate) async fn acquire_vm_lease(
313 self: &Arc<Self>,
314 ) -> Result<AgentOsSidecarVmLease, ClientError> {
315 self.acquire_lease(false).await
316 }
317
318 async fn acquire_lease(
319 self: &Arc<Self>,
320 package: bool,
321 ) -> Result<AgentOsSidecarVmLease, ClientError> {
322 let _lifecycle = self.lifecycle.lock().await;
323 if self.state.load(Ordering::SeqCst) != SidecarState::Ready.as_u8() {
324 return Err(ClientError::Sidecar(
325 "sidecar is disposing or disposed".into(),
326 ));
327 }
328 let count = if package {
329 &self.active_package_sessions
330 } else {
331 &self.active_vm_count
332 };
333 count
334 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
335 count.checked_add(1)
336 })
337 .map_err(|_| {
338 ClientError::Sidecar("limit_exceeded: sidecar lease count overflow".into())
339 })?;
340 Ok(AgentOsSidecarVmLease {
341 sidecar: self.clone(),
342 package,
343 released: AtomicBool::new(false),
344 })
345 }
346
347 pub(crate) async fn dispose_if_unused(&self) -> Result<(), ClientError> {
348 let _lifecycle = self.lifecycle.lock().await;
349 if self.active_vm_count.load(Ordering::SeqCst) == 0
350 && self.active_package_sessions.load(Ordering::SeqCst) == 0
351 {
352 self.dispose_locked().await?;
353 }
354 Ok(())
355 }
356
357 #[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
359 pub async fn open_package_session(
360 self: &Arc<Self>,
361 ) -> Result<AgentOsPackageSession, ClientError> {
362 let lease = self.acquire_lease(true).await?;
365 let result = async {
366 let (transport, connection_id, _) = self.ensure_connection().await?;
367 let mut connection = self.connection.lock().await;
368 let current = connection.as_mut().ok_or_else(|| {
369 ClientError::Sidecar(
370 "sidecar connection closed while opening package session".into(),
371 )
372 })?;
373 let session_id = if let Some(session_id) = ¤t.package_session_id {
377 session_id.clone()
378 } else {
379 let response = transport
380 .request_wire(
381 wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
382 connection_id: connection_id.clone(),
383 }),
384 wire::RequestPayload::OpenSessionRequest(wire::OpenSessionRequest {
385 placement: crate::agent_os::sidecar_wire_placement(self),
386 metadata: HashMap::new(),
387 }),
388 )
389 .await?;
390 let wire::ResponsePayload::SessionOpenedResponse(opened) = response else {
391 return Err(match response {
392 wire::ResponsePayload::RejectedResponse(rejected) => {
393 ClientError::from_rejection(rejected)
394 }
395 other => ClientError::Sidecar(format!(
396 "unexpected package session response: {other:?}"
397 )),
398 });
399 };
400 current.package_session_id = Some(opened.session_id.clone());
401 opened.session_id
402 };
403 drop(connection);
404 Ok((transport, connection_id, session_id))
405 }
406 .await;
407 let (transport, connection_id, session_id) = match result {
408 Ok(opened) => opened,
409 Err(error) => {
410 lease.release();
411 if let Err(cleanup_error) = self.dispose_if_unused().await {
412 tracing::error!(%cleanup_error, "failed to stop sidecar after package session open failed");
413 }
414 return Err(error);
415 }
416 };
417 Ok(AgentOsPackageSession {
418 sidecar: self.clone(),
419 transport,
420 connection_id,
421 session_id,
422 closed: AtomicBool::new(false),
423 lease,
424 })
425 }
426
427 fn resolved_sidecar_binary_path(&self) -> String {
428 self.sidecar_binary_path
429 .clone()
430 .or_else(|| std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok())
431 .unwrap_or_else(|| "agentos-native-sidecar".to_string())
432 }
433
434 pub fn describe(&self) -> AgentOsSidecarDescription {
441 AgentOsSidecarDescription {
442 sidecar_id: self.sidecar_id.clone(),
443 placement: self.placement.clone(),
444 state: SidecarState::from_u8(self.state.load(Ordering::SeqCst)),
445 active_vm_count: self.active_vm_count.load(Ordering::SeqCst),
446 }
447 }
448
449 pub async fn dispose(&self) -> Result<(), ClientError> {
459 let _lifecycle = self.lifecycle.lock().await;
460 self.dispose_locked().await
461 }
462
463 async fn dispose_locked(&self) -> Result<(), ClientError> {
464 if SidecarState::from_u8(self.state.load(Ordering::SeqCst)) == SidecarState::Disposed {
465 return Ok(());
466 }
467
468 self.state
469 .store(SidecarState::Disposing.as_u8(), Ordering::SeqCst);
470
471 self.kill_connection().await?;
476 self.active_vm_count.store(0, Ordering::SeqCst);
477 self.active_package_sessions.store(0, Ordering::SeqCst);
478 self.state
479 .store(SidecarState::Disposed.as_u8(), Ordering::SeqCst);
480
481 if let Some(pool) = self.shared_pool.as_deref() {
482 let self_ptr = self as *const AgentOsSidecar;
484 let _ = shared_sidecars()
485 .remove_if(pool, |cached| std::ptr::eq(Arc::as_ptr(cached), self_ptr));
486 }
487
488 Ok(())
489 }
490}
491
492#[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
494pub struct AgentOsPackageSession {
495 sidecar: Arc<AgentOsSidecar>,
496 transport: Arc<SidecarProcess>,
497 connection_id: String,
498 session_id: String,
499 closed: AtomicBool,
500 lease: AgentOsSidecarVmLease,
501}
502
503#[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
504impl AgentOsPackageSession {
505 pub async fn cache_stats(
506 &self,
507 ) -> Result<crate::software::ProcessPackageCacheStats, ClientError> {
508 if self.closed.load(Ordering::SeqCst) {
509 return Err(ClientError::Sidecar("package session is closed".into()));
510 }
511 let response = self
512 .transport
513 .request_wire(
514 wire::OwnershipScope::SessionOwnership(wire::SessionOwnership {
515 connection_id: self.connection_id.clone(),
516 session_id: self.session_id.clone(),
517 }),
518 wire::RequestPayload::GetPackageCacheStatsRequest,
519 )
520 .await?;
521 match response {
522 wire::ResponsePayload::PackageCacheStatsResponse(stats) => {
523 let count = |value: u64| usize::try_from(value).unwrap_or(usize::MAX);
524 Ok(crate::software::ProcessPackageCacheStats {
525 entries: count(stats.entries),
526 source_entries: count(stats.source_entries),
527 bytes: stats.bytes,
528 pinned_entries: count(stats.pinned_entries),
529 pending_acquisitions: count(stats.pending_acquisitions),
530 hits: stats.hits,
531 misses: stats.misses,
532 coalesced_waiters: stats.coalesced_waiters,
533 acquisitions: stats.acquisitions,
534 evictions: stats.evictions,
535 capacity_failures: stats.capacity_failures,
536 cancelled_acquisitions: stats.cancelled_acquisitions,
537 })
538 }
539 wire::ResponsePayload::RejectedResponse(rejected) => {
540 Err(ClientError::from_rejection(rejected))
541 }
542 other => Err(ClientError::Sidecar(format!(
543 "unexpected package cache stats response: {other:?}"
544 ))),
545 }
546 }
547
548 pub async fn acquire(
549 &self,
550 source: crate::software::PackageSource,
551 advisory: bool,
552 timeout_ms: Option<u64>,
553 options: Option<&crate::software::PackageResolverOptions>,
554 ) -> Result<crate::software::InstalledSoftware, ClientError> {
555 if self.closed.load(Ordering::SeqCst) {
556 return Err(ClientError::Sidecar("package session is closed".into()));
557 }
558 let source = match source {
559 crate::software::PackageSource::Url {
560 url,
561 expected_digest,
562 } => wire::PackageAcquisitionSource::PackageUrlSource(wire::PackageUrlSource {
563 url,
564 expected_digest,
565 }),
566 crate::software::PackageSource::Path {
567 path,
568 expected_digest,
569 } => wire::PackageAcquisitionSource::PackagePathSource(wire::PackagePathSource {
570 path,
571 expected_digest,
572 }),
573 };
574 let response = self
575 .transport
576 .request_wire(
577 wire::OwnershipScope::SessionOwnership(wire::SessionOwnership {
578 connection_id: self.connection_id.clone(),
579 session_id: self.session_id.clone(),
580 }),
581 wire::RequestPayload::AcquirePackageRequest(wire::AcquirePackageRequest {
582 source,
583 advisory,
584 timeout_ms,
585 max_package_bytes: options.map(|options| options.max_package_bytes),
586 download_timeout_ms: options.map(|options| options.download_timeout_ms),
587 connect_timeout_ms: options.map(|options| options.connect_timeout_ms),
588 max_redirects: options.map(|options| options.max_redirects as u32),
589 allow_insecure_local_http: options
590 .is_some_and(|options| options.allow_insecure_local_http),
591 }),
592 )
593 .await?;
594 match response {
595 wire::ResponsePayload::PackageAcquiredResponse(package) => {
596 Ok(crate::software::InstalledSoftware {
597 package_id: package.package_id,
598 digest: package.digest,
599 size_bytes: package.size,
600 package_name: package.package_name,
601 version: package.version,
602 commands: package.commands,
603 })
604 }
605 wire::ResponsePayload::RejectedResponse(rejected) => {
606 Err(ClientError::from_rejection(rejected))
607 }
608 other => Err(ClientError::Sidecar(format!(
609 "unexpected acquire_package response: {other:?}"
610 ))),
611 }
612 }
613
614 pub async fn close(&self) -> Result<(), ClientError> {
615 self.closed.store(true, Ordering::SeqCst);
616 self.lease.release();
617 self.sidecar.dispose_if_unused().await
619 }
620
621 #[cfg(feature = "service-internals")]
622 pub(crate) async fn shutdown_worker(&self) -> Result<(), ClientError> {
623 self.closed.store(true, Ordering::SeqCst);
624 self.lease.release();
625 self.sidecar.dispose().await
626 }
627}
628
629pub(crate) struct AgentOsSidecarVmLease {
631 pub(crate) sidecar: Arc<AgentOsSidecar>,
632 package: bool,
633 released: AtomicBool,
634}
635
636impl AgentOsSidecarVmLease {
637 pub(crate) fn retain_until_worker_shutdown(self) {
640 self.released.store(true, Ordering::SeqCst);
641 }
642
643 pub(crate) async fn dispose(self) -> Result<(), ClientError> {
652 self.release();
653 Ok(())
654 }
655
656 fn release(&self) -> bool {
657 if self.released.swap(true, Ordering::SeqCst) {
658 return false;
659 }
660 let count = if self.package {
661 &self.sidecar.active_package_sessions
662 } else {
663 &self.sidecar.active_vm_count
664 };
665 count
666 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
667 Some(count.saturating_sub(1))
668 })
669 .expect("saturating lease decrement");
670 true
671 }
672}
673
674impl Drop for AgentOsSidecarVmLease {
675 fn drop(&mut self) {
676 if self.release()
677 && self.sidecar.state.load(Ordering::SeqCst) == SidecarState::Ready.as_u8()
678 {
679 tracing::warn!(sidecar_id = %self.sidecar.sidecar_id, "sidecar lease dropped without explicit cleanup; ownership released, call sidecar.dispose() to confirm child teardown");
680 }
681 }
682}
683
684static SHARED_SIDECARS: OnceCell<SccHashMap<String, Arc<AgentOsSidecar>>> = OnceCell::new();
686static SHARED_SIDECAR_POOL_LOCK: OnceCell<parking_lot::Mutex<()>> = OnceCell::new();
687
688pub(crate) fn shared_sidecars() -> &'static SccHashMap<String, Arc<AgentOsSidecar>> {
690 SHARED_SIDECARS.get_or_init(SccHashMap::new)
691}
692
693fn shared_sidecar_pool_lock() -> &'static parking_lot::Mutex<()> {
694 SHARED_SIDECAR_POOL_LOCK.get_or_init(parking_lot::Mutex::default)
695}
696
697fn shared_sidecar_pool_len(cache: &SccHashMap<String, Arc<AgentOsSidecar>>) -> usize {
698 let mut len = 0;
699 cache.scan(|_, _| {
700 len += 1;
701 });
702 len
703}
704
705fn prune_disposed_shared_sidecars(cache: &SccHashMap<String, Arc<AgentOsSidecar>>) {
706 let mut disposed_pools = Vec::new();
707 cache.scan(|pool, sidecar| {
708 if sidecar.describe().state == SidecarState::Disposed {
709 disposed_pools.push(pool.clone());
710 }
711 });
712 for pool in disposed_pools {
713 let _ = cache.remove_if(&pool, |sidecar| {
714 sidecar.describe().state == SidecarState::Disposed
715 });
716 }
717}
718
719#[cfg(test)]
720fn ensure_shared_sidecar_pool_capacity(
721 cache: &SccHashMap<String, Arc<AgentOsSidecar>>,
722) -> Result<(), ClientError> {
723 if shared_sidecar_pool_len(cache) >= SHARED_SIDECAR_POOL_LIMIT {
724 return Err(shared_sidecar_pool_limit_error());
725 }
726 Ok(())
727}
728
729fn shared_sidecar_pool_limit_error() -> ClientError {
730 ClientError::Sidecar(format!(
731 "shared sidecar pool limit exceeded: at most {SHARED_SIDECAR_POOL_LIMIT} pools can be cached"
732 ))
733}
734
735impl AgentOs {
736 pub async fn create_sidecar(
741 sidecar_id: Option<String>,
742 ) -> Result<Arc<AgentOsSidecar>, ClientError> {
743 let sidecar_id =
744 sidecar_id.unwrap_or_else(|| format!("agentos-native-sidecar-{}", Uuid::new_v4()));
745 let placement = AgentOsSidecarPlacement::Explicit {
746 sidecar_id: sidecar_id.clone(),
747 };
748 Ok(Arc::new(AgentOsSidecar::new(
749 sidecar_id, placement, None, None,
750 )))
751 }
752
753 pub async fn get_shared_sidecar(
762 pool: Option<String>,
763 sidecar_binary_path: Option<String>,
764 ) -> Result<Arc<AgentOsSidecar>, ClientError> {
765 let pool = pool.unwrap_or_else(|| "default".to_string());
766 let cache = shared_sidecars();
767 let _guard = shared_sidecar_pool_lock().lock();
768
769 if let Some(existing) = cache.read(&pool, |_, sidecar| sidecar.clone()) {
771 if existing.describe().state != SidecarState::Disposed {
772 return Ok(existing);
773 }
774 }
775 prune_disposed_shared_sidecars(cache);
776
777 let placement_pool = if pool.is_empty() {
781 None
782 } else {
783 Some(pool.clone())
784 };
785 let sidecar = Arc::new(AgentOsSidecar::new(
786 format!("agentos-shared-sidecar:{pool}"),
787 AgentOsSidecarPlacement::Shared {
788 pool: placement_pool,
789 },
790 Some(pool.clone()),
791 sidecar_binary_path,
792 ));
793
794 let cache_len = shared_sidecar_pool_len(cache);
797 match cache.entry(pool) {
798 scc::hash_map::Entry::Occupied(mut occupied) => {
799 if occupied.get().describe().state == SidecarState::Disposed {
800 *occupied.get_mut() = sidecar.clone();
801 Ok(sidecar)
802 } else {
803 Ok(occupied.get().clone())
804 }
805 }
806 scc::hash_map::Entry::Vacant(vacant) => {
807 if cache_len >= SHARED_SIDECAR_POOL_LIMIT {
808 return Err(shared_sidecar_pool_limit_error());
809 }
810 vacant.insert_entry(sidecar.clone());
811 Ok(sidecar)
812 }
813 }
814 }
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820 use std::sync::Mutex;
821
822 static ENV_LOCK: Mutex<()> = Mutex::new(());
823
824 #[tokio::test]
825 async fn opening_leases_prevent_last_vm_from_disposing_the_child() {
826 let sidecar = shared("opening-leases", SidecarState::Ready);
827 let vm = sidecar.acquire_vm_lease().await.unwrap();
828 let package = sidecar.acquire_lease(true).await.unwrap();
829 vm.dispose().await.unwrap();
830 sidecar.dispose_if_unused().await.unwrap();
831 assert_eq!(sidecar.describe().state, SidecarState::Ready);
832 assert_eq!(sidecar.active_package_sessions.load(Ordering::SeqCst), 1);
833 package.release();
834 sidecar.dispose_if_unused().await.unwrap();
835 assert_eq!(sidecar.describe().state, SidecarState::Disposed);
836 assert!(sidecar.acquire_vm_lease().await.is_err());
837 assert!(sidecar.ensure_connection().await.is_err());
838 drop(package);
839 assert_eq!(sidecar.active_package_sessions.load(Ordering::SeqCst), 0);
840 }
841
842 #[tokio::test]
843 async fn unconfirmed_vm_cleanup_keeps_ownership_until_force_disposal() {
844 let sidecar = shared("unconfirmed-cleanup", SidecarState::Ready);
845 sidecar
846 .acquire_vm_lease()
847 .await
848 .unwrap()
849 .retain_until_worker_shutdown();
850 sidecar.dispose_if_unused().await.unwrap();
851 assert_eq!(sidecar.describe().active_vm_count, 1);
852 assert_eq!(sidecar.describe().state, SidecarState::Ready);
853 sidecar.dispose().await.unwrap();
854 assert_eq!(sidecar.describe().active_vm_count, 0);
855 assert_eq!(sidecar.describe().state, SidecarState::Disposed);
856 }
857
858 #[tokio::test]
859 async fn failed_package_session_open_releases_its_reserved_lease() {
860 let sidecar = Arc::new(AgentOsSidecar::new(
861 "missing-binary",
862 AgentOsSidecarPlacement::Explicit {
863 sidecar_id: "missing-binary".into(),
864 },
865 None,
866 Some("/agentos-missing-test-binary".into()),
867 ));
868 assert!(sidecar.open_package_session().await.is_err());
869 assert_eq!(sidecar.active_package_sessions.load(Ordering::SeqCst), 0);
870 assert_eq!(sidecar.describe().state, SidecarState::Disposed);
871 }
872
873 #[tokio::test]
874 async fn failed_vm_creation_releases_its_reserved_lease() {
875 let sidecar = Arc::new(AgentOsSidecar::new(
876 "missing-vm-binary",
877 AgentOsSidecarPlacement::Explicit {
878 sidecar_id: "missing-vm-binary".into(),
879 },
880 None,
881 Some("/agentos-missing-test-binary".into()),
882 ));
883 let result = AgentOs::create(crate::AgentOsConfig {
884 sidecar: Some(crate::AgentOsSidecarConfig::Explicit {
885 handle: sidecar.clone(),
886 }),
887 ..Default::default()
888 })
889 .await;
890 assert!(result.is_err());
891 assert_eq!(sidecar.describe().active_vm_count, 0);
892 assert_eq!(sidecar.describe().state, SidecarState::Disposed);
893 }
894
895 fn shared(pool: &str, state: SidecarState) -> Arc<AgentOsSidecar> {
896 let sidecar = Arc::new(AgentOsSidecar::new(
897 format!("agentos-shared-sidecar:{pool}"),
898 AgentOsSidecarPlacement::Shared {
899 pool: Some(pool.to_string()),
900 },
901 Some(pool.to_string()),
902 None,
903 ));
904 sidecar.state.store(state.as_u8(), Ordering::SeqCst);
905 sidecar
906 }
907
908 #[test]
909 fn sidecar_binary_path_prefers_explicit_wrapper_path() {
910 let _guard = ENV_LOCK.lock().expect("env lock");
911 let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok();
912 std::env::set_var(AGENTOS_SIDECAR_BIN_ENV, "/tmp/from-env");
913 let sidecar = AgentOsSidecar::new(
914 "explicit-test",
915 AgentOsSidecarPlacement::Explicit {
916 sidecar_id: "explicit-test".to_string(),
917 },
918 None,
919 Some("/tmp/from-config".to_string()),
920 );
921
922 assert_eq!(sidecar.resolved_sidecar_binary_path(), "/tmp/from-config");
923
924 restore_env(AGENTOS_SIDECAR_BIN_ENV, previous);
925 }
926
927 #[test]
928 fn sidecar_binary_path_uses_agent_os_env_fallback() {
929 let _guard = ENV_LOCK.lock().expect("env lock");
930 let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok();
931 std::env::set_var(AGENTOS_SIDECAR_BIN_ENV, "/tmp/agentos-native-sidecar");
932 let sidecar = shared("env-test", SidecarState::Ready);
933
934 assert_eq!(
935 sidecar.resolved_sidecar_binary_path(),
936 "/tmp/agentos-native-sidecar"
937 );
938
939 restore_env(AGENTOS_SIDECAR_BIN_ENV, previous);
940 }
941
942 #[test]
943 fn sidecar_binary_path_defaults_to_agent_os_wrapper() {
944 let _guard = ENV_LOCK.lock().expect("env lock");
945 let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok();
946 std::env::remove_var(AGENTOS_SIDECAR_BIN_ENV);
947 let sidecar = shared("default-test", SidecarState::Ready);
948
949 assert_eq!(
950 sidecar.resolved_sidecar_binary_path(),
951 "agentos-native-sidecar"
952 );
953
954 restore_env(AGENTOS_SIDECAR_BIN_ENV, previous);
955 }
956
957 fn restore_env(key: &str, value: Option<String>) {
958 match value {
959 Some(value) => std::env::set_var(key, value),
960 None => std::env::remove_var(key),
961 }
962 }
963
964 #[test]
965 fn prune_disposed_shared_sidecars_keeps_live_entries() {
966 let cache = SccHashMap::new();
967 let _ = cache.insert("live".to_string(), shared("live", SidecarState::Ready));
968 let _ = cache.insert(
969 "disposed".to_string(),
970 shared("disposed", SidecarState::Disposed),
971 );
972
973 prune_disposed_shared_sidecars(&cache);
974
975 assert_eq!(shared_sidecar_pool_len(&cache), 1);
976 assert!(cache.read("live", |_, _| ()).is_some());
977 assert!(cache.read("disposed", |_, _| ()).is_none());
978 }
979
980 #[test]
981 fn shared_sidecar_pool_capacity_rejects_full_live_cache() {
982 let cache = SccHashMap::new();
983 for index in 0..SHARED_SIDECAR_POOL_LIMIT {
984 let pool = format!("pool-{index}");
985 let _ = cache.insert(pool.clone(), shared(&pool, SidecarState::Ready));
986 }
987
988 let error =
989 ensure_shared_sidecar_pool_capacity(&cache).expect_err("full cache should reject");
990
991 assert!(
992 error
993 .to_string()
994 .contains("shared sidecar pool limit exceeded"),
995 "unexpected error: {error}"
996 );
997 }
998
999 #[test]
1000 fn shared_sidecar_pool_capacity_allows_after_pruning_disposed_entries() {
1001 let cache = SccHashMap::new();
1002 for index in 0..SHARED_SIDECAR_POOL_LIMIT {
1003 let pool = format!("pool-{index}");
1004 let state = if index == 0 {
1005 SidecarState::Disposed
1006 } else {
1007 SidecarState::Ready
1008 };
1009 let _ = cache.insert(pool.clone(), shared(&pool, state));
1010 }
1011
1012 prune_disposed_shared_sidecars(&cache);
1013
1014 ensure_shared_sidecar_pool_capacity(&cache).expect("pruned cache should admit one entry");
1015 assert_eq!(
1016 shared_sidecar_pool_len(&cache),
1017 SHARED_SIDECAR_POOL_LIMIT - 1
1018 );
1019 }
1020
1021 #[tokio::test]
1022 async fn get_shared_sidecar_inserts_vacant_pool_without_reentrant_scan() {
1023 let pool = format!("unit-{}", Uuid::new_v4());
1024 let sidecar = AgentOs::get_shared_sidecar(Some(pool.clone()), None)
1025 .await
1026 .expect("shared sidecar");
1027
1028 assert_eq!(sidecar.shared_pool.as_deref(), Some(pool.as_str()));
1029
1030 sidecar.dispose().await.expect("dispose shared sidecar");
1031 }
1032
1033 #[test]
1034 fn dispose_removes_only_same_shared_sidecar_instance() {
1035 let pool = format!("dispose-race-{}", Uuid::new_v4());
1036 let old = shared(&pool, SidecarState::Ready);
1037 let replacement = shared(&pool, SidecarState::Ready);
1038 let cache = shared_sidecars();
1039 let _guard = shared_sidecar_pool_lock().lock();
1040
1041 let _ = cache.insert(pool.clone(), replacement.clone());
1042 old.state
1043 .store(SidecarState::Disposing.as_u8(), Ordering::SeqCst);
1044 old.active_vm_count.store(0, Ordering::SeqCst);
1045 old.state
1046 .store(SidecarState::Disposed.as_u8(), Ordering::SeqCst);
1047 let old_ptr = Arc::as_ptr(&old);
1048 let _ = cache.remove_if(&pool, |cached| std::ptr::eq(Arc::as_ptr(cached), old_ptr));
1049
1050 let cached = cache
1051 .read(&pool, |_, cached| cached.clone())
1052 .expect("replacement should remain cached");
1053 assert!(Arc::ptr_eq(&cached, &replacement));
1054
1055 let replacement_ptr = Arc::as_ptr(&replacement);
1056 let _ = cache.remove_if(&pool, |cached| {
1057 std::ptr::eq(Arc::as_ptr(cached), replacement_ptr)
1058 });
1059 }
1060}