Skip to main content

agentos_client/
sidecar.rs

1//! `AgentOsSidecar` (public transport handle) + placement/description + the process-global shared
2//! pool + internal lease accounting.
3//!
4//! Ported from `packages/core/src/agent-os.ts` (`AgentOsSidecar`). The shared-sidecar pool is a
5//! process-global map (default pool `"default"`).
6
7use 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
21/// Maximum shared sidecar pool entries retained process-wide.
22const SHARED_SIDECAR_POOL_LIMIT: usize = 1024;
23
24/// Env var that overrides the Agent OS wrapper sidecar binary path.
25const AGENTOS_SIDECAR_BIN_ENV: &str = "AGENTOS_SIDECAR_BIN";
26
27/// The lazily-established shared sidecar process + authenticated connection. Multiple VMs in the same
28/// (shared) sidecar reuse this single process/connection, each opening its own session + VM on it.
29pub(crate) struct SharedConnection {
30    pub(crate) transport: Arc<SidecarProcess>,
31    pub(crate) connection_id: String,
32}
33
34/// Sidecar lifecycle state, encoded as a `u8` for `AtomicU8`.
35///
36/// Parity: TypeScript `describe()` returns a JSON-serializable description whose `state` is exactly
37/// `"ready" | "disposing" | "disposed"`. The `#[serde(rename_all = "lowercase")]` attribute and the
38/// matching [`SidecarState::as_str`] reproduce that wire string so [`AgentOsSidecarDescription`]
39/// serializes to the same JSON shape.
40#[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    /// The TypeScript wire string for this state (`"ready" | "disposing" | "disposed"`).
50    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            // Any other bit pattern is unreachable; the field is only written via `as_u8`.
72            _ => SidecarState::Disposed,
73        }
74    }
75}
76
77/// Where a sidecar lives.
78///
79/// Parity: TypeScript `AgentOsSidecarPlacement` is `{ kind: "shared"; pool?: string }` or
80/// `{ kind: "explicit"; sidecarId: string }`. The serde `tag`/`rename` attributes reproduce that
81/// JSON shape, including omitting `pool` when it is `None` (matching the `...(pool ? { pool } : {})`
82/// spread in `getSharedAgentOsSidecarInternal`).
83#[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/// A sync, deep-clone snapshot of a sidecar's state.
97///
98/// Parity: serializes to the TypeScript `AgentOsSidecarDescription` JSON shape
99/// (`{ sidecarId, placement, state, activeVmCount }`).
100#[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
109/// Public transport handle for a (possibly shared) native sidecar process hosting VMs.
110pub 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    /// Absolute path to the `agentos-sidecar` binary, threaded from `AgentOsConfig` when present.
117    /// Otherwise `ensure_connection` resolves the Agent OS env fallback and passes an explicit path
118    /// to the generic transport.
119    pub(crate) sidecar_binary_path: Option<String>,
120    /// The shared sidecar process + authenticated connection, established on the first VM `create`
121    /// against this sidecar and reused by every subsequent VM in the same (shared) sidecar.
122    pub(crate) connection: tokio::sync::Mutex<Option<SharedConnection>>,
123}
124
125impl AgentOsSidecar {
126    /// Construct a sidecar handle.
127    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    /// Get (or lazily establish) the shared sidecar process + authenticated connection. The first
145    /// caller spawns the `agentos-sidecar` child and runs the `Authenticate` handshake; subsequent
146    /// callers reuse the same transport + connection id. This is what makes a shared sidecar host
147    /// multiple VMs in one process.
148    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    /// Kill the shared sidecar child process if a connection was established. Used when the last VM
200    /// on a shared sidecar shuts down, so the sidecar process does not leak (process-global pool
201    /// entries are never dropped, so `kill_on_drop` alone would not fire at process exit).
202    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    /// Snapshot the sidecar's current state. SYNC.
216    ///
217    /// Parity: TypeScript `describe()` returns a deep clone of the internal description so callers
218    /// cannot mutate sidecar state through the returned value. The Rust struct derives `Clone`, so
219    /// constructing a fresh [`AgentOsSidecarDescription`] from the current atomics produces the same
220    /// snapshot semantics.
221    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    /// Dispose the sidecar. Idempotent; disposes active leases and aggregates errors.
231    ///
232    /// Parity with TypeScript `AgentOsSidecar.dispose()`:
233    /// 1. If already `disposed`, return immediately (idempotent).
234    /// 2. Transition to `disposing`.
235    /// 3. Dispose every active lease, collecting (not short-circuiting on) errors.
236    /// 4. Reset `active_vm_count` to 0 and transition to `disposed`.
237    /// 5. If this sidecar is the cached shared sidecar for its pool, remove it from the pool.
238    /// 6. If any lease disposal failed, return an aggregated error.
239    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        // Parity note: TypeScript iterates `state.activeLeases` here and aggregates per-lease
250        // disposal errors. Active leases are owned by `AgentOs` and are released through
251        // `AgentOsSidecarVmLease::dispose` during `AgentOs::shutdown`.
252        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            // Only remove the cached entry if it still points at this exact sidecar instance.
258            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            // Parity: TypeScript throws `new Error(errors.map(e => e.message).join("; "))`, a bare
267            // joined message with NO prefix. The aggregated text is built here verbatim.
268            //
269            // Constraint: `ClientError` (error.rs, owned by another agent) currently has no
270            // transparent/no-prefix variant, so the only generic carrier is `ClientError::Sidecar`,
271            // whose `Display` prepends `"sidecar error: "`. To surface the joined string byte-for-byte
272            // identical to TS, error.rs must grow a transparent variant (e.g.
273            // `#[error("{0}")] Aggregate(String)`); this site should switch to it once it exists. The
274            // joined string is constructed here so that wiring is a one-line variant swap.
275            let aggregated = errors.join("; ");
276            Err(ClientError::Sidecar(aggregated))
277        }
278    }
279}
280
281/// A lease over a VM; released on `AgentOs` dispose.
282pub(crate) struct AgentOsSidecarVmLease {
283    pub(crate) sidecar: Arc<AgentOsSidecar>,
284}
285
286impl AgentOsSidecarVmLease {
287    /// Release the lease.
288    ///
289    /// Parity with the TypeScript lease `dispose()`: it is idempotent, removes itself from the
290    /// owning sidecar's active-lease set, recomputes `activeVmCount`, and disposes the underlying
291    /// session transport client. Consuming `self` here gives the idempotence for free (the lease
292    /// cannot be disposed twice). The active-vm count is decremented (saturating at 0) to mirror
293    /// `state.description.activeVmCount = state.activeLeases.size`.
294    ///
295    pub(crate) async fn dispose(self) -> Result<(), ClientError> {
296        let sidecar = self.sidecar;
297        // Mirror `activeVmCount = activeLeases.size` by decrementing, never underflowing past 0.
298        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
315/// Process-global shared-sidecar pool, keyed by pool name (default `"default"`).
316static SHARED_SIDECARS: OnceCell<SccHashMap<String, Arc<AgentOsSidecar>>> = OnceCell::new();
317static SHARED_SIDECAR_POOL_LOCK: OnceCell<parking_lot::Mutex<()>> = OnceCell::new();
318
319/// Access (initializing on first use) the process-global shared-sidecar pool.
320pub(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    /// Create an explicit sidecar handle. `sidecar_id` defaults to `agentos-sidecar-<uuid>`.
368    ///
369    /// Parity with TypeScript `createAgentOsSidecarInternal`: the explicit handle carries an
370    /// `Explicit` placement whose `sidecar_id` echoes the resolved id and has no shared pool.
371    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    /// Get (or create) a pooled shared sidecar. Pool defaults to `"default"`. Uses the process-global
385    /// cache.
386    ///
387    /// Parity with TypeScript `getSharedAgentOsSidecarInternal`: return the cached sidecar for the
388    /// pool when it exists and is not disposed; otherwise build a fresh handle
389    /// (`agentos-shared-sidecar:<pool>`, `Shared` placement) and cache it. Because the cache is a
390    /// process-global concurrent map rather than a synchronously-checked `Map`, the insert is done
391    /// atomically with `entry`/`insert` so two racing callers converge on a single live handle.
392    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        // Fast path: reuse a cached, non-disposed sidecar for this pool.
401        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        // Parity: TypeScript builds placement `{ kind: "shared", ...(pool ? { pool } : {}) }`, so an
409        // empty-string pool (a non-nullish value that survives `?? "default"`) is OMITTED from the
410        // placement. The `sharedPool` field used for cache cleanup still carries the raw pool value.
411        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        // Insert atomically, replacing a stale (disposed) entry but yielding to a live one that a
426        // concurrent caller may have just installed.
427        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}