Skip to main content

ic_testkit/pic/
standalone_pool.rs

1use std::{
2    ops::Deref,
3    panic::{AssertUnwindSafe, catch_unwind},
4    sync::{
5        Condvar, Mutex, MutexGuard, TryLockError,
6        atomic::{AtomicUsize, Ordering},
7    },
8};
9
10use super::{
11    ControllerSnapshotError, ControllerSnapshots, PocketIcSnapshotExt, SnapshotRestoreFunding,
12    StandaloneCanisterFixture, transport,
13};
14
15struct StandaloneFixtureBaseline {
16    fixture: StandaloneCanisterFixture,
17    snapshots: ControllerSnapshots,
18}
19
20impl StandaloneFixtureBaseline {
21    fn capture(fixture: StandaloneCanisterFixture) -> Result<Self, ControllerSnapshotError> {
22        let canister_id = fixture.canister_id();
23        let snapshots = fixture
24            .pocket_ic()
25            .capture_controller_snapshots(canister_id, [canister_id])?;
26
27        Ok(Self { fixture, snapshots })
28    }
29
30    fn restore(&self, funding: SnapshotRestoreFunding) -> Result<(), ControllerSnapshotError> {
31        self.fixture
32            .pocket_ic()
33            .restore_controller_snapshots_with_funding(
34                self.fixture.canister_id(),
35                &self.snapshots,
36                funding,
37            )
38    }
39}
40
41/// Caller-owned bounded pool of independently restorable standalone fixtures.
42///
43/// Each slot owns one PocketIC instance, one installed canister, and one
44/// captured baseline snapshot. Acquiring a populated slot restores that
45/// snapshot before returning it. At most `CAPACITY` leases can overlap; a
46/// caller waits only when every slot is in use.
47///
48/// One pool represents one logical fixture recipe. Every call to
49/// [`acquire`](Self::acquire) must supply a builder for the same Wasm, init
50/// arguments, topology, and seeded baseline. The builder is not evaluated on
51/// a cache hit, so callers should use a separate pool for each recipe.
52///
53/// Snapshot restoration rewinds the installed canister, not the surrounding
54/// PocketIC instance. Instance time, other canisters, and cycle changes not
55/// covered by the selected [`SnapshotRestoreFunding`] policy may persist.
56///
57/// The pool contains no process-global state. Downstream suites select a
58/// capacity that fits their host and keep lifecycle-sensitive tests on fresh
59/// [`StandaloneCanisterFixture`] values when snapshot restoration is not the
60/// intended isolation boundary.
61pub struct CachedStandaloneCanisterFixturePool<const CAPACITY: usize> {
62    slots: [Mutex<Option<StandaloneFixtureBaseline>>; CAPACITY],
63    next_slot: AtomicUsize,
64    wait_lock: Mutex<()>,
65    slot_released: Condvar,
66    restore_funding: SnapshotRestoreFunding,
67}
68
69/// Exclusive lease of one independently restored standalone fixture.
70///
71/// The lease dereferences to [`StandaloneCanisterFixture`], so existing call
72/// helpers can borrow it without adding a second fixture API.
73pub struct CachedStandaloneCanisterFixtureGuard<'a> {
74    slot: Option<MutexGuard<'a, Option<StandaloneFixtureBaseline>>>,
75    wait_lock: &'a Mutex<()>,
76    slot_released: &'a Condvar,
77}
78
79impl<const CAPACITY: usize> CachedStandaloneCanisterFixturePool<CAPACITY> {
80    /// Create an empty caller-owned fixture pool.
81    ///
82    /// # Panics
83    ///
84    /// Panics at compile time for a statically initialized zero-capacity pool,
85    /// or at runtime if constructed dynamically with zero capacity.
86    #[must_use]
87    pub const fn new() -> Self {
88        assert!(CAPACITY > 0, "fixture pool capacity must be non-zero");
89
90        Self {
91            slots: [const { Mutex::new(None) }; CAPACITY],
92            next_slot: AtomicUsize::new(0),
93            wait_lock: Mutex::new(()),
94            slot_released: Condvar::new(),
95            restore_funding: SnapshotRestoreFunding::Preserve,
96        }
97    }
98
99    /// Select the cycle-funding policy applied immediately before each
100    /// snapshot restore.
101    #[must_use]
102    pub const fn with_restore_funding(mut self, funding: SnapshotRestoreFunding) -> Self {
103        self.restore_funding = funding;
104        self
105    }
106
107    /// Acquire one isolated fixture, building a slot on first use and restoring
108    /// its captured snapshot on later uses.
109    ///
110    /// `build` must create the same logical fixture baseline on every call to
111    /// this pool. It runs only when an empty slot is first populated or a dead
112    /// PocketIC instance must be replaced.
113    ///
114    /// A recognized dead-instance transport failure evicts and rebuilds only
115    /// the affected slot. Other snapshot failures are returned unchanged.
116    ///
117    /// # Errors
118    ///
119    /// Returns the structured snapshot capture or restore failure for the
120    /// selected slot.
121    pub fn acquire<B>(
122        &self,
123        build: B,
124    ) -> Result<(CachedStandaloneCanisterFixtureGuard<'_>, bool), ControllerSnapshotError>
125    where
126        B: Fn() -> StandaloneCanisterFixture,
127    {
128        let mut start = self.next_slot.fetch_add(1, Ordering::Relaxed) % CAPACITY;
129
130        loop {
131            if let Some(slot) = self.try_acquire_slot(start) {
132                return self.prepare_slot(slot, &build);
133            }
134
135            let wait_guard = self
136                .wait_lock
137                .lock()
138                .unwrap_or_else(std::sync::PoisonError::into_inner);
139            if let Some(slot) = self.try_acquire_slot(start) {
140                drop(wait_guard);
141                return self.prepare_slot(slot, &build);
142            }
143
144            drop(
145                self.slot_released
146                    .wait(wait_guard)
147                    .unwrap_or_else(std::sync::PoisonError::into_inner),
148            );
149            start = self.next_slot.fetch_add(1, Ordering::Relaxed) % CAPACITY;
150        }
151    }
152
153    fn try_acquire_slot(
154        &self,
155        start: usize,
156    ) -> Option<MutexGuard<'_, Option<StandaloneFixtureBaseline>>> {
157        for offset in 0..CAPACITY {
158            let slot_index = (start + offset) % CAPACITY;
159            match self.slots[slot_index].try_lock() {
160                Ok(slot) => return Some(slot),
161                Err(TryLockError::Poisoned(error)) => return Some(error.into_inner()),
162                Err(TryLockError::WouldBlock) => {}
163            }
164        }
165
166        None
167    }
168
169    fn prepare_slot<'a, B>(
170        &'a self,
171        slot: MutexGuard<'a, Option<StandaloneFixtureBaseline>>,
172        build: &B,
173    ) -> Result<(CachedStandaloneCanisterFixtureGuard<'a>, bool), ControllerSnapshotError>
174    where
175        B: Fn() -> StandaloneCanisterFixture,
176    {
177        // Wrap the reservation before calling caller code. Its Drop path
178        // releases the slot and wakes another waiter on success, error, or
179        // unwind.
180        let mut guard = self.guard(slot);
181        let slot = guard
182            .slot
183            .as_mut()
184            .expect("fixture pool reservation must retain its slot");
185        let cache_hit = slot.is_some();
186        if !cache_hit {
187            **slot = Some(StandaloneFixtureBaseline::capture(build())?);
188            return Ok((guard, false));
189        }
190
191        let restore = slot
192            .as_ref()
193            .expect("populated fixture pool slot must remain present")
194            .restore(self.restore_funding);
195        match restore {
196            Ok(()) => Ok((guard, true)),
197            Err(error) if snapshot_error_is_dead_instance_transport(&error) => {
198                let stale = slot.take();
199                if let Some(stale) = stale {
200                    let _ = catch_unwind(AssertUnwindSafe(|| drop(stale)));
201                }
202                **slot = Some(StandaloneFixtureBaseline::capture(build())?);
203                Ok((guard, false))
204            }
205            Err(error) => Err(error),
206        }
207    }
208
209    const fn guard<'a>(
210        &'a self,
211        slot: MutexGuard<'a, Option<StandaloneFixtureBaseline>>,
212    ) -> CachedStandaloneCanisterFixtureGuard<'a> {
213        CachedStandaloneCanisterFixtureGuard {
214            slot: Some(slot),
215            wait_lock: &self.wait_lock,
216            slot_released: &self.slot_released,
217        }
218    }
219}
220
221impl<const CAPACITY: usize> Default for CachedStandaloneCanisterFixturePool<CAPACITY> {
222    fn default() -> Self {
223        Self::new()
224    }
225}
226
227impl Deref for CachedStandaloneCanisterFixtureGuard<'_> {
228    type Target = StandaloneCanisterFixture;
229
230    fn deref(&self) -> &Self::Target {
231        &self
232            .slot
233            .as_ref()
234            .expect("fixture pool guard must retain its slot")
235            .as_ref()
236            .expect("leased fixture pool slot must remain populated")
237            .fixture
238    }
239}
240
241impl Drop for CachedStandaloneCanisterFixtureGuard<'_> {
242    fn drop(&mut self) {
243        drop(self.slot.take());
244
245        // Pair the notification with the same lock used by acquire's second
246        // availability check so a release cannot be lost between that check
247        // and the condvar wait.
248        let wait_guard = self
249            .wait_lock
250            .lock()
251            .unwrap_or_else(std::sync::PoisonError::into_inner);
252        self.slot_released.notify_one();
253        drop(wait_guard);
254    }
255}
256
257fn snapshot_error_is_dead_instance_transport(error: &ControllerSnapshotError) -> bool {
258    matches!(
259        error,
260        ControllerSnapshotError::RestorePanicked { message, .. }
261            if transport::is_dead_instance_transport_error(message)
262    )
263}
264
265#[cfg(test)]
266mod tests {
267    use super::CachedStandaloneCanisterFixturePool;
268
269    const _: CachedStandaloneCanisterFixturePool<1> = CachedStandaloneCanisterFixturePool::new();
270
271    #[test]
272    fn nonzero_pool_constructs() {
273        let _pool = CachedStandaloneCanisterFixturePool::<2>::new();
274    }
275}