1use std::sync::atomic::{AtomicU32, AtomicU8, Ordering};
8use std::sync::Arc;
9
10use once_cell::sync::OnceCell;
11use scc::HashMap as SccHashMap;
12use serde::Serialize;
13use uuid::Uuid;
14
15use agentos_sidecar_client::wire;
16
17use crate::agent_os::AgentOs;
18use crate::error::ClientError;
19use crate::transport::SidecarProcess;
20
21const SHARED_SIDECAR_POOL_LIMIT: usize = 1024;
23
24const AGENTOS_SIDECAR_BIN_ENV: &str = "AGENTOS_SIDECAR_BIN";
26
27pub(crate) struct SharedConnection {
30 pub(crate) transport: Arc<SidecarProcess>,
31 pub(crate) connection_id: String,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
41#[serde(rename_all = "lowercase")]
42pub enum SidecarState {
43 Ready,
44 Disposing,
45 Disposed,
46}
47
48impl SidecarState {
49 pub const fn as_str(self) -> &'static str {
51 match self {
52 SidecarState::Ready => "ready",
53 SidecarState::Disposing => "disposing",
54 SidecarState::Disposed => "disposed",
55 }
56 }
57
58 pub(crate) const fn as_u8(self) -> u8 {
59 match self {
60 SidecarState::Ready => 0,
61 SidecarState::Disposing => 1,
62 SidecarState::Disposed => 2,
63 }
64 }
65
66 pub(crate) const fn from_u8(value: u8) -> Self {
67 match value {
68 0 => SidecarState::Ready,
69 1 => SidecarState::Disposing,
70 2 => SidecarState::Disposed,
71 _ => SidecarState::Disposed,
73 }
74 }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
84#[serde(tag = "kind", rename_all = "lowercase")]
85pub enum AgentOsSidecarPlacement {
86 Shared {
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pool: Option<String>,
89 },
90 Explicit {
91 #[serde(rename = "sidecarId")]
92 sidecar_id: String,
93 },
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
101#[serde(rename_all = "camelCase")]
102pub struct AgentOsSidecarDescription {
103 pub sidecar_id: String,
104 pub placement: AgentOsSidecarPlacement,
105 pub state: SidecarState,
106 pub active_vm_count: u32,
107}
108
109pub struct AgentOsSidecar {
111 pub(crate) sidecar_id: String,
112 pub(crate) placement: AgentOsSidecarPlacement,
113 pub(crate) shared_pool: Option<String>,
114 pub(crate) state: AtomicU8,
115 pub(crate) active_vm_count: AtomicU32,
116 pub(crate) sidecar_binary_path: Option<String>,
120 pub(crate) connection: tokio::sync::Mutex<Option<SharedConnection>>,
123}
124
125impl AgentOsSidecar {
126 pub(crate) fn new(
128 sidecar_id: impl Into<String>,
129 placement: AgentOsSidecarPlacement,
130 shared_pool: Option<String>,
131 sidecar_binary_path: Option<String>,
132 ) -> Self {
133 Self {
134 sidecar_id: sidecar_id.into(),
135 placement,
136 shared_pool,
137 state: AtomicU8::new(SidecarState::Ready.as_u8()),
138 active_vm_count: AtomicU32::new(0),
139 sidecar_binary_path,
140 connection: tokio::sync::Mutex::new(None),
141 }
142 }
143
144 pub(crate) async fn ensure_connection(
149 &self,
150 ) -> Result<(Arc<SidecarProcess>, String, usize), ClientError> {
151 let mut guard = self.connection.lock().await;
152 if let Some(existing) = guard.as_ref() {
153 let max_frame = existing.transport.max_frame_bytes();
154 return Ok((
155 existing.transport.clone(),
156 existing.connection_id.clone(),
157 max_frame,
158 ));
159 }
160
161 let transport = SidecarProcess::spawn(Some(self.resolved_sidecar_binary_path())).await?;
162 let authed = match transport
163 .request_wire(
164 wire::OwnershipScope::ConnectionOwnership(wire::ConnectionOwnership {
165 connection_id: "client-hint".to_string(),
166 }),
167 wire::RequestPayload::AuthenticateRequest(wire::AuthenticateRequest {
168 client_name: "agentos-client".to_string(),
169 auth_token: "agentos-client".to_string(),
170 protocol_version: wire::PROTOCOL_VERSION,
171 bridge_version: agentos_bridge::bridge_contract().version,
172 }),
173 )
174 .await?
175 {
176 wire::ResponsePayload::AuthenticatedResponse(authed) => authed,
177 wire::ResponsePayload::RejectedResponse(rejected) => {
178 return Err(ClientError::Kernel {
179 code: rejected.code,
180 message: rejected.message,
181 });
182 }
183 _ => {
184 return Err(ClientError::Sidecar(
185 "unexpected authenticate response".to_string(),
186 ));
187 }
188 };
189 let max_frame = authed.max_frame_bytes as usize;
190 transport.set_max_frame_bytes(max_frame);
191
192 *guard = Some(SharedConnection {
193 transport: transport.clone(),
194 connection_id: authed.connection_id.clone(),
195 });
196 Ok((transport, authed.connection_id, max_frame))
197 }
198
199 pub(crate) async fn kill_connection(&self) {
203 if let Some(connection) = self.connection.lock().await.take() {
204 connection.transport.kill_child();
205 }
206 }
207
208 fn resolved_sidecar_binary_path(&self) -> String {
209 self.sidecar_binary_path
210 .clone()
211 .or_else(|| std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok())
212 .unwrap_or_else(|| "agentos-sidecar".to_string())
213 }
214
215 pub fn describe(&self) -> AgentOsSidecarDescription {
222 AgentOsSidecarDescription {
223 sidecar_id: self.sidecar_id.clone(),
224 placement: self.placement.clone(),
225 state: SidecarState::from_u8(self.state.load(Ordering::SeqCst)),
226 active_vm_count: self.active_vm_count.load(Ordering::SeqCst),
227 }
228 }
229
230 pub async fn dispose(&self) -> Result<(), ClientError> {
240 if SidecarState::from_u8(self.state.load(Ordering::SeqCst)) == SidecarState::Disposed {
241 return Ok(());
242 }
243
244 self.state
245 .store(SidecarState::Disposing.as_u8(), Ordering::SeqCst);
246
247 let errors: Vec<String> = Vec::new();
248
249 self.active_vm_count.store(0, Ordering::SeqCst);
253 self.state
254 .store(SidecarState::Disposed.as_u8(), Ordering::SeqCst);
255
256 if let Some(pool) = self.shared_pool.as_deref() {
257 let self_ptr = self as *const AgentOsSidecar;
259 let _ = shared_sidecars()
260 .remove_if(pool, |cached| std::ptr::eq(Arc::as_ptr(cached), self_ptr));
261 }
262
263 if errors.is_empty() {
264 Ok(())
265 } else {
266 let aggregated = errors.join("; ");
276 Err(ClientError::Sidecar(aggregated))
277 }
278 }
279}
280
281pub(crate) struct AgentOsSidecarVmLease {
283 pub(crate) sidecar: Arc<AgentOsSidecar>,
284}
285
286impl AgentOsSidecarVmLease {
287 pub(crate) async fn dispose(self) -> Result<(), ClientError> {
296 let sidecar = self.sidecar;
297 let mut current = sidecar.active_vm_count.load(Ordering::SeqCst);
299 loop {
300 let next = current.saturating_sub(1);
301 match sidecar.active_vm_count.compare_exchange_weak(
302 current,
303 next,
304 Ordering::SeqCst,
305 Ordering::SeqCst,
306 ) {
307 Ok(_) => break,
308 Err(observed) => current = observed,
309 }
310 }
311 Ok(())
312 }
313}
314
315static SHARED_SIDECARS: OnceCell<SccHashMap<String, Arc<AgentOsSidecar>>> = OnceCell::new();
317static SHARED_SIDECAR_POOL_LOCK: OnceCell<parking_lot::Mutex<()>> = OnceCell::new();
318
319pub(crate) fn shared_sidecars() -> &'static SccHashMap<String, Arc<AgentOsSidecar>> {
321 SHARED_SIDECARS.get_or_init(SccHashMap::new)
322}
323
324fn shared_sidecar_pool_lock() -> &'static parking_lot::Mutex<()> {
325 SHARED_SIDECAR_POOL_LOCK.get_or_init(parking_lot::Mutex::default)
326}
327
328fn shared_sidecar_pool_len(cache: &SccHashMap<String, Arc<AgentOsSidecar>>) -> usize {
329 let mut len = 0;
330 cache.scan(|_, _| {
331 len += 1;
332 });
333 len
334}
335
336fn prune_disposed_shared_sidecars(cache: &SccHashMap<String, Arc<AgentOsSidecar>>) {
337 let mut disposed_pools = Vec::new();
338 cache.scan(|pool, sidecar| {
339 if sidecar.describe().state == SidecarState::Disposed {
340 disposed_pools.push(pool.clone());
341 }
342 });
343 for pool in disposed_pools {
344 let _ = cache.remove_if(&pool, |sidecar| {
345 sidecar.describe().state == SidecarState::Disposed
346 });
347 }
348}
349
350#[cfg(test)]
351fn ensure_shared_sidecar_pool_capacity(
352 cache: &SccHashMap<String, Arc<AgentOsSidecar>>,
353) -> Result<(), ClientError> {
354 if shared_sidecar_pool_len(cache) >= SHARED_SIDECAR_POOL_LIMIT {
355 return Err(shared_sidecar_pool_limit_error());
356 }
357 Ok(())
358}
359
360fn shared_sidecar_pool_limit_error() -> ClientError {
361 ClientError::Sidecar(format!(
362 "shared sidecar pool limit exceeded: at most {SHARED_SIDECAR_POOL_LIMIT} pools can be cached"
363 ))
364}
365
366impl AgentOs {
367 pub async fn create_sidecar(
372 sidecar_id: Option<String>,
373 ) -> Result<Arc<AgentOsSidecar>, ClientError> {
374 let sidecar_id =
375 sidecar_id.unwrap_or_else(|| format!("agentos-sidecar-{}", Uuid::new_v4()));
376 let placement = AgentOsSidecarPlacement::Explicit {
377 sidecar_id: sidecar_id.clone(),
378 };
379 Ok(Arc::new(AgentOsSidecar::new(
380 sidecar_id, placement, None, None,
381 )))
382 }
383
384 pub async fn get_shared_sidecar(
393 pool: Option<String>,
394 sidecar_binary_path: Option<String>,
395 ) -> Result<Arc<AgentOsSidecar>, ClientError> {
396 let pool = pool.unwrap_or_else(|| "default".to_string());
397 let cache = shared_sidecars();
398 let _guard = shared_sidecar_pool_lock().lock();
399
400 if let Some(existing) = cache.read(&pool, |_, sidecar| sidecar.clone()) {
402 if existing.describe().state != SidecarState::Disposed {
403 return Ok(existing);
404 }
405 }
406 prune_disposed_shared_sidecars(cache);
407
408 let placement_pool = if pool.is_empty() {
412 None
413 } else {
414 Some(pool.clone())
415 };
416 let sidecar = Arc::new(AgentOsSidecar::new(
417 format!("agentos-shared-sidecar:{pool}"),
418 AgentOsSidecarPlacement::Shared {
419 pool: placement_pool,
420 },
421 Some(pool.clone()),
422 sidecar_binary_path,
423 ));
424
425 let cache_len = shared_sidecar_pool_len(cache);
428 match cache.entry(pool) {
429 scc::hash_map::Entry::Occupied(mut occupied) => {
430 if occupied.get().describe().state == SidecarState::Disposed {
431 *occupied.get_mut() = sidecar.clone();
432 Ok(sidecar)
433 } else {
434 Ok(occupied.get().clone())
435 }
436 }
437 scc::hash_map::Entry::Vacant(vacant) => {
438 if cache_len >= SHARED_SIDECAR_POOL_LIMIT {
439 return Err(shared_sidecar_pool_limit_error());
440 }
441 vacant.insert_entry(sidecar.clone());
442 Ok(sidecar)
443 }
444 }
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use std::sync::Mutex;
452
453 static ENV_LOCK: Mutex<()> = Mutex::new(());
454
455 fn shared(pool: &str, state: SidecarState) -> Arc<AgentOsSidecar> {
456 let sidecar = Arc::new(AgentOsSidecar::new(
457 format!("agentos-shared-sidecar:{pool}"),
458 AgentOsSidecarPlacement::Shared {
459 pool: Some(pool.to_string()),
460 },
461 Some(pool.to_string()),
462 None,
463 ));
464 sidecar.state.store(state.as_u8(), Ordering::SeqCst);
465 sidecar
466 }
467
468 #[test]
469 fn sidecar_binary_path_prefers_explicit_wrapper_path() {
470 let _guard = ENV_LOCK.lock().expect("env lock");
471 let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok();
472 std::env::set_var(AGENTOS_SIDECAR_BIN_ENV, "/tmp/from-env");
473 let sidecar = AgentOsSidecar::new(
474 "explicit-test",
475 AgentOsSidecarPlacement::Explicit {
476 sidecar_id: "explicit-test".to_string(),
477 },
478 None,
479 Some("/tmp/from-config".to_string()),
480 );
481
482 assert_eq!(sidecar.resolved_sidecar_binary_path(), "/tmp/from-config");
483
484 restore_env(AGENTOS_SIDECAR_BIN_ENV, previous);
485 }
486
487 #[test]
488 fn sidecar_binary_path_uses_agent_os_env_fallback() {
489 let _guard = ENV_LOCK.lock().expect("env lock");
490 let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok();
491 std::env::set_var(AGENTOS_SIDECAR_BIN_ENV, "/tmp/agentos-sidecar");
492 let sidecar = shared("env-test", SidecarState::Ready);
493
494 assert_eq!(
495 sidecar.resolved_sidecar_binary_path(),
496 "/tmp/agentos-sidecar"
497 );
498
499 restore_env(AGENTOS_SIDECAR_BIN_ENV, previous);
500 }
501
502 #[test]
503 fn sidecar_binary_path_defaults_to_agent_os_wrapper() {
504 let _guard = ENV_LOCK.lock().expect("env lock");
505 let previous = std::env::var(AGENTOS_SIDECAR_BIN_ENV).ok();
506 std::env::remove_var(AGENTOS_SIDECAR_BIN_ENV);
507 let sidecar = shared("default-test", SidecarState::Ready);
508
509 assert_eq!(sidecar.resolved_sidecar_binary_path(), "agentos-sidecar");
510
511 restore_env(AGENTOS_SIDECAR_BIN_ENV, previous);
512 }
513
514 fn restore_env(key: &str, value: Option<String>) {
515 match value {
516 Some(value) => std::env::set_var(key, value),
517 None => std::env::remove_var(key),
518 }
519 }
520
521 #[test]
522 fn prune_disposed_shared_sidecars_keeps_live_entries() {
523 let cache = SccHashMap::new();
524 let _ = cache.insert("live".to_string(), shared("live", SidecarState::Ready));
525 let _ = cache.insert(
526 "disposed".to_string(),
527 shared("disposed", SidecarState::Disposed),
528 );
529
530 prune_disposed_shared_sidecars(&cache);
531
532 assert_eq!(shared_sidecar_pool_len(&cache), 1);
533 assert!(cache.read("live", |_, _| ()).is_some());
534 assert!(cache.read("disposed", |_, _| ()).is_none());
535 }
536
537 #[test]
538 fn shared_sidecar_pool_capacity_rejects_full_live_cache() {
539 let cache = SccHashMap::new();
540 for index in 0..SHARED_SIDECAR_POOL_LIMIT {
541 let pool = format!("pool-{index}");
542 let _ = cache.insert(pool.clone(), shared(&pool, SidecarState::Ready));
543 }
544
545 let error =
546 ensure_shared_sidecar_pool_capacity(&cache).expect_err("full cache should reject");
547
548 assert!(
549 error
550 .to_string()
551 .contains("shared sidecar pool limit exceeded"),
552 "unexpected error: {error}"
553 );
554 }
555
556 #[test]
557 fn shared_sidecar_pool_capacity_allows_after_pruning_disposed_entries() {
558 let cache = SccHashMap::new();
559 for index in 0..SHARED_SIDECAR_POOL_LIMIT {
560 let pool = format!("pool-{index}");
561 let state = if index == 0 {
562 SidecarState::Disposed
563 } else {
564 SidecarState::Ready
565 };
566 let _ = cache.insert(pool.clone(), shared(&pool, state));
567 }
568
569 prune_disposed_shared_sidecars(&cache);
570
571 ensure_shared_sidecar_pool_capacity(&cache).expect("pruned cache should admit one entry");
572 assert_eq!(
573 shared_sidecar_pool_len(&cache),
574 SHARED_SIDECAR_POOL_LIMIT - 1
575 );
576 }
577
578 #[tokio::test]
579 async fn get_shared_sidecar_inserts_vacant_pool_without_reentrant_scan() {
580 let pool = format!("unit-{}", Uuid::new_v4());
581 let sidecar = AgentOs::get_shared_sidecar(Some(pool.clone()), None)
582 .await
583 .expect("shared sidecar");
584
585 assert_eq!(sidecar.shared_pool.as_deref(), Some(pool.as_str()));
586
587 sidecar.dispose().await.expect("dispose shared sidecar");
588 }
589
590 #[test]
591 fn dispose_removes_only_same_shared_sidecar_instance() {
592 let pool = format!("dispose-race-{}", Uuid::new_v4());
593 let old = shared(&pool, SidecarState::Ready);
594 let replacement = shared(&pool, SidecarState::Ready);
595 let cache = shared_sidecars();
596 let _guard = shared_sidecar_pool_lock().lock();
597
598 let _ = cache.insert(pool.clone(), replacement.clone());
599 old.state
600 .store(SidecarState::Disposing.as_u8(), Ordering::SeqCst);
601 old.active_vm_count.store(0, Ordering::SeqCst);
602 old.state
603 .store(SidecarState::Disposed.as_u8(), Ordering::SeqCst);
604 let old_ptr = Arc::as_ptr(&old);
605 let _ = cache.remove_if(&pool, |cached| std::ptr::eq(Arc::as_ptr(cached), old_ptr));
606
607 let cached = cache
608 .read(&pool, |_, cached| cached.clone())
609 .expect("replacement should remain cached");
610 assert!(Arc::ptr_eq(&cached, &replacement));
611
612 let replacement_ptr = Arc::as_ptr(&replacement);
613 let _ = cache.remove_if(&pool, |cached| {
614 std::ptr::eq(Arc::as_ptr(cached), replacement_ptr)
615 });
616 }
617}