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 transport.set_max_write_files_entries(authed.max_write_files_entries as usize);
299
300 *guard = Some(SharedConnection {
301 transport: transport.clone(),
302 connection_id: authed.connection_id.clone(),
303 #[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
304 package_session_id: None,
305 authenticated: true,
306 });
307 Ok((transport, authed.connection_id, max_frame))
308 }
309
310 pub(crate) async fn kill_connection(&self) -> Result<(), ClientError> {
314 let mut connection = self.connection.lock().await;
315 if let Some(connection) = connection.as_ref() {
316 connection.transport.terminate_child().await?;
317 }
318 connection.take();
320 self.live_transport.lock().take();
321 Ok(())
322 }
323
324 pub(crate) async fn acquire_vm_lease(
325 self: &Arc<Self>,
326 ) -> Result<AgentOsSidecarVmLease, ClientError> {
327 self.acquire_lease(false).await
328 }
329
330 async fn acquire_lease(
331 self: &Arc<Self>,
332 package: bool,
333 ) -> Result<AgentOsSidecarVmLease, ClientError> {
334 let _lifecycle = self.lifecycle.lock().await;
335 if self.process_lost() {
336 return Err(ClientError::SidecarExited(
337 "its VMs are gone; create a new VM".into(),
338 ));
339 }
340 if self.current_state() != SidecarState::Ready {
341 return Err(ClientError::Sidecar(
342 "sidecar is disposing or disposed".into(),
343 ));
344 }
345 let count = if package {
346 &self.active_package_sessions
347 } else {
348 &self.active_vm_count
349 };
350 count
351 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
352 count.checked_add(1)
353 })
354 .map_err(|_| {
355 ClientError::Sidecar("limit_exceeded: sidecar lease count overflow".into())
356 })?;
357 Ok(AgentOsSidecarVmLease {
358 sidecar: self.clone(),
359 package,
360 released: AtomicBool::new(false),
361 })
362 }
363
364 pub(crate) async fn dispose_if_unused(&self) -> Result<(), ClientError> {
365 let _lifecycle = self.lifecycle.lock().await;
366 if self.active_vm_count.load(Ordering::SeqCst) == 0
367 && self.active_package_sessions.load(Ordering::SeqCst) == 0
368 {
369 self.dispose_locked().await?;
370 }
371 Ok(())
372 }
373
374 #[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
376 pub async fn open_package_session(
377 self: &Arc<Self>,
378 ) -> Result<AgentOsPackageSession, ClientError> {
379 let lease = self.acquire_lease(true).await?;
382 let result = async {
383 let (transport, connection_id, _) = self.ensure_connection().await?;
384 let mut connection = self.connection.lock().await;
385 let current = connection.as_mut().ok_or_else(|| {
386 ClientError::Sidecar(
387 "sidecar connection closed while opening package session".into(),
388 )
389 })?;
390 let session_id = if let Some(session_id) = ¤t.package_session_id {
394 session_id.clone()
395 } else {
396 let response = transport
397 .request_wire(
398 wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
399 connection_id: connection_id.clone(),
400 }),
401 wire::RequestPayload::OpenSessionRequest(wire::OpenSessionRequest {
402 placement: crate::agent_os::sidecar_wire_placement(self),
403 metadata: HashMap::new(),
404 }),
405 )
406 .await?;
407 let wire::ResponsePayload::SessionOpenedResponse(opened) = response else {
408 return Err(match response {
409 wire::ResponsePayload::RejectedResponse(rejected) => {
410 ClientError::from_rejection(rejected)
411 }
412 other => ClientError::Sidecar(format!(
413 "unexpected package session response: {other:?}"
414 )),
415 });
416 };
417 current.package_session_id = Some(opened.session_id.clone());
418 opened.session_id
419 };
420 drop(connection);
421 Ok((transport, connection_id, session_id))
422 }
423 .await;
424 let (transport, connection_id, session_id) = match result {
425 Ok(opened) => opened,
426 Err(error) => {
427 lease.release();
428 if let Err(cleanup_error) = self.dispose_if_unused().await {
429 tracing::error!(%cleanup_error, "failed to stop sidecar after package session open failed");
430 }
431 return Err(error);
432 }
433 };
434 Ok(AgentOsPackageSession {
435 sidecar: self.clone(),
436 transport,
437 connection_id,
438 session_id,
439 closed: AtomicBool::new(false),
440 lease,
441 })
442 }
443
444 fn resolved_sidecar_binary_path(&self) -> String {
445 self.sidecar_binary_path
446 .clone()
447 .or_else(|| std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok())
448 .unwrap_or_else(|| "agentos-native-sidecar".to_string())
449 }
450
451 pub fn describe(&self) -> AgentOsSidecarDescription {
458 AgentOsSidecarDescription {
459 sidecar_id: self.sidecar_id.clone(),
460 placement: self.placement.clone(),
461 state: self.current_state(),
462 active_vm_count: self.active_vm_count.load(Ordering::SeqCst),
463 }
464 }
465
466 fn current_state(&self) -> SidecarState {
469 let state = SidecarState::from_u8(self.state.load(Ordering::SeqCst));
470 if state == SidecarState::Ready && self.process_lost() {
471 SidecarState::Disposed
472 } else {
473 state
474 }
475 }
476
477 fn process_lost(&self) -> bool {
478 self.live_transport
479 .lock()
480 .as_ref()
481 .is_some_and(|transport| transport.is_disconnected())
482 }
483
484 pub async fn dispose(&self) -> Result<(), ClientError> {
494 let _lifecycle = self.lifecycle.lock().await;
495 self.dispose_locked().await
496 }
497
498 async fn dispose_locked(&self) -> Result<(), ClientError> {
499 if SidecarState::from_u8(self.state.load(Ordering::SeqCst)) == SidecarState::Disposed {
500 return Ok(());
501 }
502
503 self.state
504 .store(SidecarState::Disposing.as_u8(), Ordering::SeqCst);
505
506 self.kill_connection().await?;
511 self.active_vm_count.store(0, Ordering::SeqCst);
512 self.active_package_sessions.store(0, Ordering::SeqCst);
513 self.state
514 .store(SidecarState::Disposed.as_u8(), Ordering::SeqCst);
515
516 if let Some(pool) = self.shared_pool.as_deref() {
517 let self_ptr = self as *const AgentOsSidecar;
519 let _ = shared_sidecars()
520 .remove_if(pool, |cached| std::ptr::eq(Arc::as_ptr(cached), self_ptr));
521 }
522
523 Ok(())
524 }
525}
526
527#[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
529pub struct AgentOsPackageSession {
530 sidecar: Arc<AgentOsSidecar>,
531 transport: Arc<SidecarProcess>,
532 connection_id: String,
533 session_id: String,
534 closed: AtomicBool,
535 lease: AgentOsSidecarVmLease,
536}
537
538#[cfg(any(feature = "service-internals", feature = "sidecar-internals", test))]
539impl AgentOsPackageSession {
540 pub async fn cache_stats(
541 &self,
542 ) -> Result<crate::software::ProcessPackageCacheStats, ClientError> {
543 if self.closed.load(Ordering::SeqCst) {
544 return Err(ClientError::Sidecar("package session is closed".into()));
545 }
546 let response = self
547 .transport
548 .request_wire(
549 wire::OwnershipScope::SessionOwnership(wire::SessionOwnership {
550 connection_id: self.connection_id.clone(),
551 session_id: self.session_id.clone(),
552 }),
553 wire::RequestPayload::GetPackageCacheStatsRequest,
554 )
555 .await?;
556 match response {
557 wire::ResponsePayload::PackageCacheStatsResponse(stats) => {
558 let count = |value: u64| usize::try_from(value).unwrap_or(usize::MAX);
559 Ok(crate::software::ProcessPackageCacheStats {
560 entries: count(stats.entries),
561 source_entries: count(stats.source_entries),
562 bytes: stats.bytes,
563 pinned_entries: count(stats.pinned_entries),
564 pending_acquisitions: count(stats.pending_acquisitions),
565 hits: stats.hits,
566 misses: stats.misses,
567 coalesced_waiters: stats.coalesced_waiters,
568 acquisitions: stats.acquisitions,
569 evictions: stats.evictions,
570 capacity_failures: stats.capacity_failures,
571 cancelled_acquisitions: stats.cancelled_acquisitions,
572 })
573 }
574 wire::ResponsePayload::RejectedResponse(rejected) => {
575 Err(ClientError::from_rejection(rejected))
576 }
577 other => Err(ClientError::Sidecar(format!(
578 "unexpected package cache stats response: {other:?}"
579 ))),
580 }
581 }
582
583 pub async fn acquire(
584 &self,
585 source: crate::software::PackageSource,
586 advisory: bool,
587 timeout_ms: Option<u64>,
588 options: Option<&crate::software::PackageResolverOptions>,
589 ) -> Result<crate::software::InstalledSoftware, ClientError> {
590 if self.closed.load(Ordering::SeqCst) {
591 return Err(ClientError::Sidecar("package session is closed".into()));
592 }
593 let source = match source {
594 crate::software::PackageSource::Url {
595 url,
596 expected_digest,
597 } => wire::PackageAcquisitionSource::PackageUrlSource(wire::PackageUrlSource {
598 url,
599 expected_digest,
600 }),
601 crate::software::PackageSource::Path {
602 path,
603 expected_digest,
604 } => wire::PackageAcquisitionSource::PackagePathSource(wire::PackagePathSource {
605 path,
606 expected_digest,
607 }),
608 };
609 let response = self
610 .transport
611 .request_wire(
612 wire::OwnershipScope::SessionOwnership(wire::SessionOwnership {
613 connection_id: self.connection_id.clone(),
614 session_id: self.session_id.clone(),
615 }),
616 wire::RequestPayload::AcquirePackageRequest(wire::AcquirePackageRequest {
617 source,
618 advisory,
619 timeout_ms,
620 max_package_bytes: options.map(|options| options.max_package_bytes),
621 download_timeout_ms: options.map(|options| options.download_timeout_ms),
622 connect_timeout_ms: options.map(|options| options.connect_timeout_ms),
623 max_redirects: options.map(|options| options.max_redirects as u32),
624 allow_insecure_local_http: options
625 .is_some_and(|options| options.allow_insecure_local_http),
626 }),
627 )
628 .await?;
629 match response {
630 wire::ResponsePayload::PackageAcquiredResponse(package) => {
631 Ok(crate::software::InstalledSoftware {
632 package_id: package.package_id,
633 digest: package.digest,
634 size_bytes: package.size,
635 package_name: package.package_name,
636 version: package.version,
637 commands: package.commands,
638 })
639 }
640 wire::ResponsePayload::RejectedResponse(rejected) => {
641 Err(ClientError::from_rejection(rejected))
642 }
643 other => Err(ClientError::Sidecar(format!(
644 "unexpected acquire_package response: {other:?}"
645 ))),
646 }
647 }
648
649 pub async fn close(&self) -> Result<(), ClientError> {
650 self.closed.store(true, Ordering::SeqCst);
651 self.lease.release();
652 self.sidecar.dispose_if_unused().await
654 }
655
656 #[cfg(feature = "service-internals")]
657 pub(crate) async fn shutdown_worker(&self) -> Result<(), ClientError> {
658 self.closed.store(true, Ordering::SeqCst);
659 self.lease.release();
660 self.sidecar.dispose().await
661 }
662}
663
664pub(crate) struct AgentOsSidecarVmLease {
666 pub(crate) sidecar: Arc<AgentOsSidecar>,
667 package: bool,
668 released: AtomicBool,
669}
670
671impl AgentOsSidecarVmLease {
672 pub(crate) fn retain_until_worker_shutdown(self) {
675 self.released.store(true, Ordering::SeqCst);
676 }
677
678 pub(crate) async fn dispose(self) -> Result<(), ClientError> {
687 self.release();
688 Ok(())
689 }
690
691 fn release(&self) -> bool {
692 if self.released.swap(true, Ordering::SeqCst) {
693 return false;
694 }
695 let count = if self.package {
696 &self.sidecar.active_package_sessions
697 } else {
698 &self.sidecar.active_vm_count
699 };
700 count
701 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
702 Some(count.saturating_sub(1))
703 })
704 .expect("saturating lease decrement");
705 true
706 }
707}
708
709impl Drop for AgentOsSidecarVmLease {
710 fn drop(&mut self) {
711 if self.release()
712 && self.sidecar.state.load(Ordering::SeqCst) == SidecarState::Ready.as_u8()
713 {
714 tracing::warn!(sidecar_id = %self.sidecar.sidecar_id, "sidecar lease dropped without explicit cleanup; ownership released, call sidecar.dispose() to confirm child teardown");
715 }
716 }
717}
718
719static SHARED_SIDECARS: OnceCell<SccHashMap<String, Arc<AgentOsSidecar>>> = OnceCell::new();
721static SHARED_SIDECAR_POOL_LOCK: OnceCell<parking_lot::Mutex<()>> = OnceCell::new();
722
723pub(crate) fn shared_sidecars() -> &'static SccHashMap<String, Arc<AgentOsSidecar>> {
725 SHARED_SIDECARS.get_or_init(SccHashMap::new)
726}
727
728fn shared_sidecar_pool_lock() -> &'static parking_lot::Mutex<()> {
729 SHARED_SIDECAR_POOL_LOCK.get_or_init(parking_lot::Mutex::default)
730}
731
732fn shared_sidecar_pool_len(cache: &SccHashMap<String, Arc<AgentOsSidecar>>) -> usize {
733 let mut len = 0;
734 cache.scan(|_, _| {
735 len += 1;
736 });
737 len
738}
739
740fn prune_disposed_shared_sidecars(cache: &SccHashMap<String, Arc<AgentOsSidecar>>) {
741 let mut disposed_pools = Vec::new();
742 cache.scan(|pool, sidecar| {
743 if sidecar.describe().state == SidecarState::Disposed {
744 disposed_pools.push(pool.clone());
745 }
746 });
747 for pool in disposed_pools {
748 let _ = cache.remove_if(&pool, |sidecar| {
749 sidecar.describe().state == SidecarState::Disposed
750 });
751 }
752}
753
754#[cfg(test)]
755fn ensure_shared_sidecar_pool_capacity(
756 cache: &SccHashMap<String, Arc<AgentOsSidecar>>,
757) -> Result<(), ClientError> {
758 if shared_sidecar_pool_len(cache) >= SHARED_SIDECAR_POOL_LIMIT {
759 return Err(shared_sidecar_pool_limit_error());
760 }
761 Ok(())
762}
763
764fn shared_sidecar_pool_limit_error() -> ClientError {
765 ClientError::Sidecar(format!(
766 "shared sidecar pool limit exceeded: at most {SHARED_SIDECAR_POOL_LIMIT} pools can be cached"
767 ))
768}
769
770impl AgentOs {
771 pub async fn create_sidecar(
776 sidecar_id: Option<String>,
777 ) -> Result<Arc<AgentOsSidecar>, ClientError> {
778 let sidecar_id =
779 sidecar_id.unwrap_or_else(|| format!("agentos-native-sidecar-{}", Uuid::new_v4()));
780 let placement = AgentOsSidecarPlacement::Explicit {
781 sidecar_id: sidecar_id.clone(),
782 };
783 Ok(Arc::new(AgentOsSidecar::new(
784 sidecar_id, placement, None, None,
785 )))
786 }
787
788 pub async fn get_shared_sidecar(
797 pool: Option<String>,
798 sidecar_binary_path: Option<String>,
799 ) -> Result<Arc<AgentOsSidecar>, ClientError> {
800 let pool = pool.unwrap_or_else(|| "default".to_string());
801 let cache = shared_sidecars();
802 let _guard = shared_sidecar_pool_lock().lock();
803
804 if let Some(existing) = cache.read(&pool, |_, sidecar| sidecar.clone()) {
806 if existing.describe().state != SidecarState::Disposed {
807 return Ok(existing);
808 }
809 }
810 prune_disposed_shared_sidecars(cache);
811
812 let placement_pool = if pool.is_empty() {
816 None
817 } else {
818 Some(pool.clone())
819 };
820 let sidecar = Arc::new(AgentOsSidecar::new(
821 format!("agentos-shared-sidecar:{pool}"),
822 AgentOsSidecarPlacement::Shared {
823 pool: placement_pool,
824 },
825 Some(pool.clone()),
826 sidecar_binary_path,
827 ));
828
829 let cache_len = shared_sidecar_pool_len(cache);
832 match cache.entry(pool) {
833 scc::hash_map::Entry::Occupied(mut occupied) => {
834 if occupied.get().describe().state == SidecarState::Disposed {
835 *occupied.get_mut() = sidecar.clone();
836 Ok(sidecar)
837 } else {
838 Ok(occupied.get().clone())
839 }
840 }
841 scc::hash_map::Entry::Vacant(vacant) => {
842 if cache_len >= SHARED_SIDECAR_POOL_LIMIT {
843 return Err(shared_sidecar_pool_limit_error());
844 }
845 vacant.insert_entry(sidecar.clone());
846 Ok(sidecar)
847 }
848 }
849 }
850}
851
852#[cfg(test)]
853mod tests {
854 use super::*;
855 use std::sync::Mutex;
856
857 static ENV_LOCK: Mutex<()> = Mutex::new(());
858
859 #[tokio::test]
860 async fn opening_leases_prevent_last_vm_from_disposing_the_child() {
861 let sidecar = shared("opening-leases", SidecarState::Ready);
862 let vm = sidecar.acquire_vm_lease().await.unwrap();
863 let package = sidecar.acquire_lease(true).await.unwrap();
864 vm.dispose().await.unwrap();
865 sidecar.dispose_if_unused().await.unwrap();
866 assert_eq!(sidecar.describe().state, SidecarState::Ready);
867 assert_eq!(sidecar.active_package_sessions.load(Ordering::SeqCst), 1);
868 package.release();
869 sidecar.dispose_if_unused().await.unwrap();
870 assert_eq!(sidecar.describe().state, SidecarState::Disposed);
871 assert!(sidecar.acquire_vm_lease().await.is_err());
872 assert!(sidecar.ensure_connection().await.is_err());
873 drop(package);
874 assert_eq!(sidecar.active_package_sessions.load(Ordering::SeqCst), 0);
875 }
876
877 #[tokio::test]
878 async fn unconfirmed_vm_cleanup_keeps_ownership_until_force_disposal() {
879 let sidecar = shared("unconfirmed-cleanup", SidecarState::Ready);
880 sidecar
881 .acquire_vm_lease()
882 .await
883 .unwrap()
884 .retain_until_worker_shutdown();
885 sidecar.dispose_if_unused().await.unwrap();
886 assert_eq!(sidecar.describe().active_vm_count, 1);
887 assert_eq!(sidecar.describe().state, SidecarState::Ready);
888 sidecar.dispose().await.unwrap();
889 assert_eq!(sidecar.describe().active_vm_count, 0);
890 assert_eq!(sidecar.describe().state, SidecarState::Disposed);
891 }
892
893 #[tokio::test]
894 async fn failed_package_session_open_releases_its_reserved_lease() {
895 let sidecar = Arc::new(AgentOsSidecar::new(
896 "missing-binary",
897 AgentOsSidecarPlacement::Explicit {
898 sidecar_id: "missing-binary".into(),
899 },
900 None,
901 Some("/agentos-missing-test-binary".into()),
902 ));
903 assert!(sidecar.open_package_session().await.is_err());
904 assert_eq!(sidecar.active_package_sessions.load(Ordering::SeqCst), 0);
905 assert_eq!(sidecar.describe().state, SidecarState::Disposed);
906 }
907
908 #[tokio::test]
909 async fn failed_vm_creation_releases_its_reserved_lease() {
910 let sidecar = Arc::new(AgentOsSidecar::new(
911 "missing-vm-binary",
912 AgentOsSidecarPlacement::Explicit {
913 sidecar_id: "missing-vm-binary".into(),
914 },
915 None,
916 Some("/agentos-missing-test-binary".into()),
917 ));
918 let result = AgentOs::create(crate::AgentOsConfig {
919 sidecar: Some(crate::AgentOsSidecarConfig::Explicit {
920 handle: sidecar.clone(),
921 }),
922 ..Default::default()
923 })
924 .await;
925 assert!(result.is_err());
926 assert_eq!(sidecar.describe().active_vm_count, 0);
927 assert_eq!(sidecar.describe().state, SidecarState::Disposed);
928 }
929
930 fn shared(pool: &str, state: SidecarState) -> Arc<AgentOsSidecar> {
931 let sidecar = Arc::new(AgentOsSidecar::new(
932 format!("agentos-shared-sidecar:{pool}"),
933 AgentOsSidecarPlacement::Shared {
934 pool: Some(pool.to_string()),
935 },
936 Some(pool.to_string()),
937 None,
938 ));
939 sidecar.state.store(state.as_u8(), Ordering::SeqCst);
940 sidecar
941 }
942
943 #[test]
944 fn sidecar_binary_path_prefers_explicit_wrapper_path() {
945 let _guard = ENV_LOCK.lock().expect("env lock");
946 let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok();
947 std::env::set_var(AGENTOS_SIDECAR_BIN_ENV, "/tmp/from-env");
948 let sidecar = AgentOsSidecar::new(
949 "explicit-test",
950 AgentOsSidecarPlacement::Explicit {
951 sidecar_id: "explicit-test".to_string(),
952 },
953 None,
954 Some("/tmp/from-config".to_string()),
955 );
956
957 assert_eq!(sidecar.resolved_sidecar_binary_path(), "/tmp/from-config");
958
959 restore_env(AGENTOS_SIDECAR_BIN_ENV, previous);
960 }
961
962 #[test]
963 fn sidecar_binary_path_uses_agent_os_env_fallback() {
964 let _guard = ENV_LOCK.lock().expect("env lock");
965 let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok();
966 std::env::set_var(AGENTOS_SIDECAR_BIN_ENV, "/tmp/agentos-native-sidecar");
967 let sidecar = shared("env-test", SidecarState::Ready);
968
969 assert_eq!(
970 sidecar.resolved_sidecar_binary_path(),
971 "/tmp/agentos-native-sidecar"
972 );
973
974 restore_env(AGENTOS_SIDECAR_BIN_ENV, previous);
975 }
976
977 #[test]
978 fn sidecar_binary_path_defaults_to_agent_os_wrapper() {
979 let _guard = ENV_LOCK.lock().expect("env lock");
980 let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok();
981 std::env::remove_var(AGENTOS_SIDECAR_BIN_ENV);
982 let sidecar = shared("default-test", SidecarState::Ready);
983
984 assert_eq!(
985 sidecar.resolved_sidecar_binary_path(),
986 "agentos-native-sidecar"
987 );
988
989 restore_env(AGENTOS_SIDECAR_BIN_ENV, previous);
990 }
991
992 fn restore_env(key: &str, value: Option<String>) {
993 match value {
994 Some(value) => std::env::set_var(key, value),
995 None => std::env::remove_var(key),
996 }
997 }
998
999 #[test]
1000 fn prune_disposed_shared_sidecars_keeps_live_entries() {
1001 let cache = SccHashMap::new();
1002 let _ = cache.insert("live".to_string(), shared("live", SidecarState::Ready));
1003 let _ = cache.insert(
1004 "disposed".to_string(),
1005 shared("disposed", SidecarState::Disposed),
1006 );
1007
1008 prune_disposed_shared_sidecars(&cache);
1009
1010 assert_eq!(shared_sidecar_pool_len(&cache), 1);
1011 assert!(cache.read("live", |_, _| ()).is_some());
1012 assert!(cache.read("disposed", |_, _| ()).is_none());
1013 }
1014
1015 #[test]
1016 fn shared_sidecar_pool_capacity_rejects_full_live_cache() {
1017 let cache = SccHashMap::new();
1018 for index in 0..SHARED_SIDECAR_POOL_LIMIT {
1019 let pool = format!("pool-{index}");
1020 let _ = cache.insert(pool.clone(), shared(&pool, SidecarState::Ready));
1021 }
1022
1023 let error =
1024 ensure_shared_sidecar_pool_capacity(&cache).expect_err("full cache should reject");
1025
1026 assert!(
1027 error
1028 .to_string()
1029 .contains("shared sidecar pool limit exceeded"),
1030 "unexpected error: {error}"
1031 );
1032 }
1033
1034 #[test]
1035 fn shared_sidecar_pool_capacity_allows_after_pruning_disposed_entries() {
1036 let cache = SccHashMap::new();
1037 for index in 0..SHARED_SIDECAR_POOL_LIMIT {
1038 let pool = format!("pool-{index}");
1039 let state = if index == 0 {
1040 SidecarState::Disposed
1041 } else {
1042 SidecarState::Ready
1043 };
1044 let _ = cache.insert(pool.clone(), shared(&pool, state));
1045 }
1046
1047 prune_disposed_shared_sidecars(&cache);
1048
1049 ensure_shared_sidecar_pool_capacity(&cache).expect("pruned cache should admit one entry");
1050 assert_eq!(
1051 shared_sidecar_pool_len(&cache),
1052 SHARED_SIDECAR_POOL_LIMIT - 1
1053 );
1054 }
1055
1056 #[tokio::test]
1057 async fn get_shared_sidecar_inserts_vacant_pool_without_reentrant_scan() {
1058 let pool = format!("unit-{}", Uuid::new_v4());
1059 let sidecar = AgentOs::get_shared_sidecar(Some(pool.clone()), None)
1060 .await
1061 .expect("shared sidecar");
1062
1063 assert_eq!(sidecar.shared_pool.as_deref(), Some(pool.as_str()));
1064
1065 sidecar.dispose().await.expect("dispose shared sidecar");
1066 }
1067
1068 #[test]
1069 fn dispose_removes_only_same_shared_sidecar_instance() {
1070 let pool = format!("dispose-race-{}", Uuid::new_v4());
1071 let old = shared(&pool, SidecarState::Ready);
1072 let replacement = shared(&pool, SidecarState::Ready);
1073 let cache = shared_sidecars();
1074 let _guard = shared_sidecar_pool_lock().lock();
1075
1076 let _ = cache.insert(pool.clone(), replacement.clone());
1077 old.state
1078 .store(SidecarState::Disposing.as_u8(), Ordering::SeqCst);
1079 old.active_vm_count.store(0, Ordering::SeqCst);
1080 old.state
1081 .store(SidecarState::Disposed.as_u8(), Ordering::SeqCst);
1082 let old_ptr = Arc::as_ptr(&old);
1083 let _ = cache.remove_if(&pool, |cached| std::ptr::eq(Arc::as_ptr(cached), old_ptr));
1084
1085 let cached = cache
1086 .read(&pool, |_, cached| cached.clone())
1087 .expect("replacement should remain cached");
1088 assert!(Arc::ptr_eq(&cached, &replacement));
1089
1090 let replacement_ptr = Arc::as_ptr(&replacement);
1091 let _ = cache.remove_if(&pool, |cached| {
1092 std::ptr::eq(Arc::as_ptr(cached), replacement_ptr)
1093 });
1094 }
1095}