Skip to main content

ic_testkit/pic/
standalone_pool.rs

1use std::{
2    num::NonZeroUsize,
3    ops::Deref,
4    panic::{AssertUnwindSafe, catch_unwind},
5    sync::OnceLock,
6};
7
8use super::{
9    ControllerSnapshotError, ControllerSnapshots, PocketIcSnapshotExt, SnapshotRestoreFunding,
10    StandaloneCanisterFixture,
11    bounded_pool::{BoundedSlotLease, BoundedSlotPool},
12    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: OnceLock<BoundedSlotPool<StandaloneFixtureBaseline>>,
63    restore_funding: SnapshotRestoreFunding,
64}
65
66/// Exclusive lease of one independently restored standalone fixture.
67///
68/// The lease dereferences to [`StandaloneCanisterFixture`], so existing call
69/// helpers can borrow it without adding a second fixture API.
70pub struct CachedStandaloneCanisterFixtureGuard<'a> {
71    slot: BoundedSlotLease<'a, StandaloneFixtureBaseline>,
72}
73
74impl<const CAPACITY: usize> CachedStandaloneCanisterFixturePool<CAPACITY> {
75    /// Create an empty caller-owned fixture pool.
76    ///
77    /// # Panics
78    ///
79    /// Panics at compile time for a statically initialized zero-capacity pool,
80    /// or at runtime if constructed dynamically with zero capacity.
81    #[must_use]
82    pub const fn new() -> Self {
83        assert!(CAPACITY > 0, "fixture pool capacity must be non-zero");
84
85        Self {
86            slots: OnceLock::new(),
87            restore_funding: SnapshotRestoreFunding::Preserve,
88        }
89    }
90
91    /// Select the cycle-funding policy applied immediately before each
92    /// snapshot restore.
93    #[must_use]
94    pub const fn with_restore_funding(mut self, funding: SnapshotRestoreFunding) -> Self {
95        self.restore_funding = funding;
96        self
97    }
98
99    /// Acquire one isolated fixture, building a slot on first use and restoring
100    /// its captured snapshot on later uses.
101    ///
102    /// `build` must create the same logical fixture baseline on every call to
103    /// this pool. It runs only when an empty slot is first populated or a dead
104    /// PocketIC instance must be replaced.
105    ///
106    /// A recognized dead-instance transport failure evicts and rebuilds only
107    /// the affected slot. Other snapshot failures are returned unchanged and
108    /// invalidate the possibly partially restored slot for the next lease.
109    ///
110    /// # Errors
111    ///
112    /// Returns the structured snapshot capture or restore failure for the
113    /// selected slot.
114    pub fn acquire<B>(
115        &self,
116        build: B,
117    ) -> Result<(CachedStandaloneCanisterFixtureGuard<'_>, bool), ControllerSnapshotError>
118    where
119        B: Fn() -> StandaloneCanisterFixture,
120    {
121        self.prepare_slot(self.slots().acquire(), &build)
122    }
123
124    fn prepare_slot<'a, B>(
125        &'a self,
126        mut slot: BoundedSlotLease<'a, StandaloneFixtureBaseline>,
127        build: &B,
128    ) -> Result<(CachedStandaloneCanisterFixtureGuard<'a>, bool), ControllerSnapshotError>
129    where
130        B: Fn() -> StandaloneCanisterFixture,
131    {
132        if !slot.is_reusable() {
133            if let Some(stale) = slot.take() {
134                let _ = catch_unwind(AssertUnwindSafe(|| drop(stale)));
135            }
136            slot.replace(StandaloneFixtureBaseline::capture(build())?);
137            return Ok((CachedStandaloneCanisterFixtureGuard { slot }, false));
138        }
139
140        let restore = slot
141            .get()
142            .expect("populated fixture pool slot must remain present")
143            .restore(self.restore_funding);
144        match restore {
145            Ok(()) => Ok((CachedStandaloneCanisterFixtureGuard { slot }, true)),
146            Err(error) if snapshot_error_is_dead_instance_transport(&error) => {
147                let stale = slot.take();
148                if let Some(stale) = stale {
149                    let _ = catch_unwind(AssertUnwindSafe(|| drop(stale)));
150                }
151                slot.replace(StandaloneFixtureBaseline::capture(build())?);
152                Ok((CachedStandaloneCanisterFixtureGuard { slot }, false))
153            }
154            Err(error) => {
155                // Restoration may have changed an earlier canister before a
156                // later snapshot failed. Preserve the current error while
157                // preventing a partially restored slot from being reused.
158                slot.invalidate();
159                Err(error)
160            }
161        }
162    }
163
164    fn slots(&self) -> &BoundedSlotPool<StandaloneFixtureBaseline> {
165        self.slots.get_or_init(|| {
166            BoundedSlotPool::new(
167                NonZeroUsize::new(CAPACITY).expect("fixture pool capacity must be non-zero"),
168            )
169        })
170    }
171}
172
173impl<const CAPACITY: usize> Default for CachedStandaloneCanisterFixturePool<CAPACITY> {
174    fn default() -> Self {
175        Self::new()
176    }
177}
178
179impl Deref for CachedStandaloneCanisterFixtureGuard<'_> {
180    type Target = StandaloneCanisterFixture;
181
182    fn deref(&self) -> &Self::Target {
183        &self
184            .slot
185            .get()
186            .expect("leased fixture pool slot must remain populated")
187            .fixture
188    }
189}
190
191fn snapshot_error_is_dead_instance_transport(error: &ControllerSnapshotError) -> bool {
192    matches!(
193        error,
194        ControllerSnapshotError::RestorePanicked { message, .. }
195            if transport::is_dead_instance_transport_error(message)
196    )
197}
198
199#[cfg(test)]
200mod tests {
201    use super::CachedStandaloneCanisterFixturePool;
202
203    const _: CachedStandaloneCanisterFixturePool<1> = CachedStandaloneCanisterFixturePool::new();
204
205    #[test]
206    fn nonzero_pool_constructs() {
207        let _pool = CachedStandaloneCanisterFixturePool::<2>::new();
208    }
209}