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