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    time::{Duration, Instant},
7};
8
9use super::{
10    ControllerSnapshotError, ControllerSnapshots, PocketIcSnapshotExt, SnapshotRestoreFunding,
11    StandaloneCanisterFixture,
12    bounded_pool::{BoundedSlotLease, BoundedSlotPool},
13    transport,
14};
15
16struct StandaloneFixtureBaseline {
17    fixture: StandaloneCanisterFixture,
18    snapshots: ControllerSnapshots,
19}
20
21impl StandaloneFixtureBaseline {
22    fn capture(fixture: StandaloneCanisterFixture) -> Result<Self, ControllerSnapshotError> {
23        let canister_id = fixture.canister_id();
24        let snapshots = fixture
25            .pocket_ic()
26            .capture_controller_snapshots(canister_id, [canister_id])?;
27
28        Ok(Self { fixture, snapshots })
29    }
30
31    fn restore(&self, funding: SnapshotRestoreFunding) -> Result<(), ControllerSnapshotError> {
32        self.fixture
33            .pocket_ic()
34            .restore_snapshots_with_captured_senders_and_funding(&self.snapshots, funding)
35    }
36}
37
38/// Timings for one standalone fixture-pool acquisition.
39#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
40pub struct StandaloneFixturePoolTimings {
41    wait: Duration,
42    build: Option<Duration>,
43    restore: Option<Duration>,
44    stale_teardown: Option<Duration>,
45    total: Duration,
46}
47
48/// Whether a standalone fixture-pool lease was built, restored, or rebuilt.
49#[non_exhaustive]
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub enum StandaloneFixturePoolOutcome {
52    /// An empty slot was built and its snapshot was captured.
53    Built {
54        /// Diagnostic slot index.
55        slot: usize,
56        /// Acquisition phase timings.
57        timings: StandaloneFixturePoolTimings,
58    },
59    /// A populated slot was restored successfully.
60    Restored {
61        /// Diagnostic slot index.
62        slot: usize,
63        /// Acquisition phase timings.
64        timings: StandaloneFixturePoolTimings,
65    },
66    /// An invalid or dead slot was rebuilt.
67    Rebuilt {
68        /// Diagnostic slot index.
69        slot: usize,
70        /// Reason the previous slot could not be reused.
71        reason: StandaloneFixturePoolRebuildReason,
72        /// Acquisition phase timings.
73        timings: StandaloneFixturePoolTimings,
74    },
75}
76
77/// Why a standalone fixture-pool slot was rebuilt.
78#[non_exhaustive]
79#[derive(Clone, Copy, Debug, Eq, PartialEq)]
80pub enum StandaloneFixturePoolRebuildReason {
81    /// PocketIC transport was no longer reachable during snapshot restoration.
82    DeadPocketIcTransport,
83    /// A previous restore failed after it may have partially changed the slot.
84    PreviousRestoreFailure,
85    /// A lease was dropped while its thread was unwinding.
86    UnwindWhileLeased,
87}
88
89/// Standalone fixture preparation stage associated with a snapshot failure.
90#[non_exhaustive]
91#[derive(Clone, Copy, Debug, Eq, PartialEq)]
92pub enum StandaloneFixturePoolStage {
93    /// Building a fixture and capturing its baseline snapshot.
94    Build,
95    /// Restoring a populated fixture's baseline snapshot.
96    Restore,
97}
98
99/// Failure to acquire a standalone fixture-pool lease with structured diagnostics.
100#[non_exhaustive]
101#[derive(Debug)]
102pub enum StandaloneFixturePoolError {
103    /// Initial build/capture or snapshot restoration failed.
104    Preparation {
105        /// Failed lifecycle stage.
106        stage: StandaloneFixturePoolStage,
107        /// Structured snapshot failure.
108        source: Box<ControllerSnapshotError>,
109        /// Timings recorded before acquisition failed.
110        timings: Box<StandaloneFixturePoolTimings>,
111    },
112    /// Dead-transport restoration failed and replacement snapshot capture also failed.
113    RecoveryFailed {
114        /// Original dead-transport restoration failure.
115        original: Box<ControllerSnapshotError>,
116        /// Snapshot failure while building the replacement slot.
117        rebuild: Box<ControllerSnapshotError>,
118        /// Combined restore, teardown, and rebuild timings.
119        timings: Box<StandaloneFixturePoolTimings>,
120    },
121}
122
123/// Caller-owned bounded pool of independently restorable standalone fixtures.
124///
125/// Each slot owns one PocketIC instance, one installed canister, and one
126/// captured baseline snapshot. Acquiring a populated slot restores that
127/// snapshot before returning it. At most `CAPACITY` leases can overlap; a
128/// caller waits only when every slot is in use.
129///
130/// One pool owns one fixture builder. The builder must produce the same Wasm,
131/// init arguments, topology, and seeded baseline each time it runs. It is not
132/// evaluated on a cache hit; use a separate pool for each recipe.
133///
134/// Snapshot restoration rewinds the installed canister, not the surrounding
135/// PocketIC instance. Instance time, other canisters, and cycle changes not
136/// covered by the selected [`SnapshotRestoreFunding`] policy may persist.
137///
138/// The pool contains no process-global state. Downstream suites select a
139/// capacity that fits their host and keep lifecycle-sensitive tests on fresh
140/// [`StandaloneCanisterFixture`] values when snapshot restoration is not the
141/// intended isolation boundary.
142pub struct CachedStandaloneCanisterFixturePool<
143    const CAPACITY: usize,
144    B = fn() -> StandaloneCanisterFixture,
145> {
146    build: B,
147    slots: OnceLock<BoundedSlotPool<StandaloneFixtureBaseline>>,
148    restore_funding: SnapshotRestoreFunding,
149}
150
151/// Exclusive lease of one independently restored standalone fixture.
152///
153/// The lease dereferences to [`StandaloneCanisterFixture`], so existing call
154/// helpers can borrow it without adding a second fixture API.
155pub struct CachedStandaloneCanisterFixtureGuard<'a> {
156    slot: BoundedSlotLease<'a, StandaloneFixtureBaseline>,
157}
158
159impl<const CAPACITY: usize, B> CachedStandaloneCanisterFixturePool<CAPACITY, B>
160where
161    B: Fn() -> StandaloneCanisterFixture,
162{
163    /// Create an empty pool that owns its fixture builder.
164    ///
165    /// # Panics
166    ///
167    /// Panics at compile time for a statically initialized zero-capacity pool,
168    /// or at runtime if constructed dynamically with zero capacity.
169    #[must_use]
170    pub const fn new(build: B) -> Self {
171        assert!(CAPACITY > 0, "fixture pool capacity must be non-zero");
172
173        Self {
174            build,
175            slots: OnceLock::new(),
176            restore_funding: SnapshotRestoreFunding::Preserve,
177        }
178    }
179
180    /// Select the cycle-funding policy applied immediately before each
181    /// snapshot restore.
182    #[must_use]
183    pub const fn with_restore_funding(mut self, funding: SnapshotRestoreFunding) -> Self {
184        self.restore_funding = funding;
185        self
186    }
187
188    /// Acquire one isolated fixture, building a slot on first use and restoring
189    /// its captured snapshot on later uses.
190    ///
191    /// The pool's builder runs only when an empty or invalid slot is populated
192    /// or a dead PocketIC instance must be replaced.
193    ///
194    /// A recognized dead-instance transport failure evicts and rebuilds only
195    /// the affected slot. Other snapshot failures are returned unchanged and
196    /// invalidate the possibly partially restored slot for the next lease.
197    ///
198    /// Acquire one fixture with a structured lifecycle outcome and phase timings.
199    ///
200    /// # Errors
201    ///
202    /// Returns the failed preparation stage, the structured snapshot error,
203    /// and all phase timings completed before failure. If dead-transport
204    /// restoration and replacement capture both fail, both errors are retained.
205    pub fn acquire(
206        &self,
207    ) -> Result<
208        (
209            CachedStandaloneCanisterFixtureGuard<'_>,
210            StandaloneFixturePoolOutcome,
211        ),
212        StandaloneFixturePoolError,
213    > {
214        let total_started = Instant::now();
215        self.prepare_slot_with_outcome(self.slots().acquire(), total_started)
216    }
217
218    fn prepare_slot_with_outcome<'a>(
219        &'a self,
220        mut slot: BoundedSlotLease<'a, StandaloneFixtureBaseline>,
221        total_started: Instant,
222    ) -> Result<
223        (
224            CachedStandaloneCanisterFixtureGuard<'a>,
225            StandaloneFixturePoolOutcome,
226        ),
227        StandaloneFixturePoolError,
228    > {
229        let slot_index = slot.slot_index();
230        let mut timings = StandaloneFixturePoolTimings {
231            wait: slot.wait(),
232            ..StandaloneFixturePoolTimings::default()
233        };
234
235        if !slot.is_reusable() {
236            let rebuild_reason = Self::rebuild_reason_for_invalid_slot(&slot);
237            Self::discard_stale_slot(&mut slot, &mut timings);
238            let baseline = match self.build_slot(&mut timings) {
239                Ok(baseline) => baseline,
240                Err(source) => {
241                    timings.total = total_started.elapsed();
242                    return Err(StandaloneFixturePoolError::Preparation {
243                        stage: StandaloneFixturePoolStage::Build,
244                        source: Box::new(source),
245                        timings: Box::new(timings),
246                    });
247                }
248            };
249            slot.replace(baseline);
250            timings.total = total_started.elapsed();
251            let outcome = rebuild_reason.map_or_else(
252                || StandaloneFixturePoolOutcome::Built {
253                    slot: slot_index,
254                    timings,
255                },
256                |reason| StandaloneFixturePoolOutcome::Rebuilt {
257                    slot: slot_index,
258                    reason,
259                    timings,
260                },
261            );
262            return Ok((CachedStandaloneCanisterFixtureGuard { slot }, outcome));
263        }
264
265        let restore_started = Instant::now();
266        let restore = slot
267            .get()
268            .expect("populated fixture pool slot must remain present")
269            .restore(self.restore_funding);
270        timings.restore = Some(restore_started.elapsed());
271        match restore {
272            Ok(()) => {
273                timings.total = total_started.elapsed();
274                Ok((
275                    CachedStandaloneCanisterFixtureGuard { slot },
276                    StandaloneFixturePoolOutcome::Restored {
277                        slot: slot_index,
278                        timings,
279                    },
280                ))
281            }
282            Err(error) if transport::is_dead_pocket_ic_transport_error(&error) => {
283                Self::discard_stale_slot(&mut slot, &mut timings);
284                let baseline = match self.build_slot(&mut timings) {
285                    Ok(baseline) => baseline,
286                    Err(rebuild) => {
287                        timings.total = total_started.elapsed();
288                        return Err(StandaloneFixturePoolError::RecoveryFailed {
289                            original: Box::new(error),
290                            rebuild: Box::new(rebuild),
291                            timings: Box::new(timings),
292                        });
293                    }
294                };
295                slot.replace(baseline);
296                timings.total = total_started.elapsed();
297                Ok((
298                    CachedStandaloneCanisterFixtureGuard { slot },
299                    StandaloneFixturePoolOutcome::Rebuilt {
300                        slot: slot_index,
301                        reason: StandaloneFixturePoolRebuildReason::DeadPocketIcTransport,
302                        timings,
303                    },
304                ))
305            }
306            Err(source) => {
307                // Restoration may have changed an earlier canister before a
308                // later snapshot failed. Preserve the current error while
309                // preventing a partially restored slot from being reused.
310                slot.invalidate();
311                timings.total = total_started.elapsed();
312                Err(StandaloneFixturePoolError::Preparation {
313                    stage: StandaloneFixturePoolStage::Restore,
314                    source: Box::new(source),
315                    timings: Box::new(timings),
316                })
317            }
318        }
319    }
320
321    fn rebuild_reason_for_invalid_slot(
322        slot: &BoundedSlotLease<'_, StandaloneFixtureBaseline>,
323    ) -> Option<StandaloneFixturePoolRebuildReason> {
324        if slot.invalidated_by_unwind() {
325            Some(StandaloneFixturePoolRebuildReason::UnwindWhileLeased)
326        } else {
327            // Non-unwind invalidation only follows a failed restore.
328            slot.is_populated()
329                .then_some(StandaloneFixturePoolRebuildReason::PreviousRestoreFailure)
330        }
331    }
332
333    fn build_slot(
334        &self,
335        timings: &mut StandaloneFixturePoolTimings,
336    ) -> Result<StandaloneFixtureBaseline, ControllerSnapshotError> {
337        let started = Instant::now();
338        let result = StandaloneFixtureBaseline::capture((self.build)());
339        timings.build = Some(started.elapsed());
340        result
341    }
342
343    fn discard_stale_slot(
344        slot: &mut BoundedSlotLease<'_, StandaloneFixtureBaseline>,
345        timings: &mut StandaloneFixturePoolTimings,
346    ) {
347        if !slot.is_populated() {
348            return;
349        }
350        let started = Instant::now();
351        if let Some(stale) = slot.take() {
352            let _ = catch_unwind(AssertUnwindSafe(|| drop(stale)));
353        }
354        timings.stale_teardown = Some(started.elapsed());
355    }
356
357    fn slots(&self) -> &BoundedSlotPool<StandaloneFixtureBaseline> {
358        self.slots.get_or_init(|| {
359            BoundedSlotPool::new(
360                NonZeroUsize::new(CAPACITY).expect("fixture pool capacity must be non-zero"),
361            )
362        })
363    }
364}
365
366impl Deref for CachedStandaloneCanisterFixtureGuard<'_> {
367    type Target = StandaloneCanisterFixture;
368
369    fn deref(&self) -> &Self::Target {
370        &self
371            .slot
372            .get()
373            .expect("leased fixture pool slot must remain populated")
374            .fixture
375    }
376}
377
378impl StandaloneFixturePoolOutcome {
379    /// Diagnostic slot index used by this acquisition.
380    #[must_use]
381    pub const fn slot(&self) -> usize {
382        match self {
383            Self::Built { slot, .. } | Self::Restored { slot, .. } | Self::Rebuilt { slot, .. } => {
384                *slot
385            }
386        }
387    }
388
389    /// Acquisition phase timings.
390    #[must_use]
391    pub const fn timings(&self) -> StandaloneFixturePoolTimings {
392        match self {
393            Self::Built { timings, .. }
394            | Self::Restored { timings, .. }
395            | Self::Rebuilt { timings, .. } => *timings,
396        }
397    }
398
399    /// Report whether an existing slot was restored without reconstruction.
400    #[must_use]
401    pub const fn is_reused(&self) -> bool {
402        matches!(self, Self::Restored { .. })
403    }
404}
405
406impl StandaloneFixturePoolTimings {
407    /// Time spent waiting for a capacity slot.
408    #[must_use]
409    pub const fn wait(self) -> Duration {
410        self.wait
411    }
412
413    /// Time spent building a fixture and capturing its baseline snapshot.
414    #[must_use]
415    pub const fn build(self) -> Option<Duration> {
416        self.build
417    }
418
419    /// Time spent restoring a populated slot.
420    #[must_use]
421    pub const fn restore(self) -> Option<Duration> {
422        self.restore
423    }
424
425    /// Time spent dropping an invalid or dead slot before rebuilding.
426    #[must_use]
427    pub const fn stale_teardown(self) -> Option<Duration> {
428        self.stale_teardown
429    }
430
431    /// Complete acquisition duration.
432    #[must_use]
433    pub const fn total(self) -> Duration {
434        self.total
435    }
436}
437
438impl std::fmt::Display for StandaloneFixturePoolTimings {
439    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
440        write!(
441            formatter,
442            "total={:?} wait={:?} build={:?} restore={:?} stale_teardown={:?}",
443            self.total, self.wait, self.build, self.restore, self.stale_teardown,
444        )
445    }
446}
447
448impl std::fmt::Display for StandaloneFixturePoolOutcome {
449    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
450        match self {
451            Self::Built { slot, timings } => write!(formatter, "built slot={slot} {timings}"),
452            Self::Restored { slot, timings } => {
453                write!(formatter, "restored slot={slot} {timings}")
454            }
455            Self::Rebuilt {
456                slot,
457                reason,
458                timings,
459            } => write!(formatter, "rebuilt slot={slot} reason={reason:?} {timings}"),
460        }
461    }
462}
463
464impl StandaloneFixturePoolError {
465    /// Timings recorded before this acquisition failed.
466    #[must_use]
467    pub const fn timings(&self) -> StandaloneFixturePoolTimings {
468        match self {
469            Self::Preparation { timings, .. } | Self::RecoveryFailed { timings, .. } => **timings,
470        }
471    }
472}
473
474impl std::fmt::Display for StandaloneFixturePoolStage {
475    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476        formatter.write_str(match self {
477            Self::Build => "fixture build and snapshot capture",
478            Self::Restore => "fixture snapshot restore",
479        })
480    }
481}
482
483impl std::fmt::Display for StandaloneFixturePoolError {
484    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485        match self {
486            Self::Preparation { stage, source, .. } => {
487                write!(formatter, "standalone {stage} failed: {source}")
488            }
489            Self::RecoveryFailed {
490                original, rebuild, ..
491            } => write!(
492                formatter,
493                "standalone fixture restore failed ({original}); rebuilding the slot also failed: {rebuild}",
494            ),
495        }
496    }
497}
498
499impl std::error::Error for StandaloneFixturePoolError {
500    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
501        match self {
502            Self::Preparation { source, .. } => Some(source.as_ref()),
503            Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
504        }
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::{
511        CachedStandaloneCanisterFixturePool, SnapshotRestoreFunding, StandaloneCanisterFixture,
512    };
513
514    const _: CachedStandaloneCanisterFixturePool<1> =
515        CachedStandaloneCanisterFixturePool::<1>::new(build_fixture)
516            .with_restore_funding(SnapshotRestoreFunding::TopUpTo { minimum_cycles: 1 });
517
518    fn build_fixture() -> StandaloneCanisterFixture {
519        panic!("constructing an empty pool must not invoke its builder")
520    }
521
522    #[test]
523    fn nonzero_pool_constructs() {
524        let _pool = CachedStandaloneCanisterFixturePool::<2>::new(build_fixture);
525    }
526}