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, PocketIcCapturedSnapshotExt, PocketIcSnapshotExt,
11    SnapshotRestoreFunding, 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 represents one logical fixture recipe. Every call to
136/// [`acquire`](Self::acquire) must supply a builder for the same Wasm, init
137/// arguments, topology, and seeded baseline. The builder is not evaluated on
138/// a cache hit, so callers should use a separate pool for each recipe.
139///
140/// Snapshot restoration rewinds the installed canister, not the surrounding
141/// PocketIC instance. Instance time, other canisters, and cycle changes not
142/// covered by the selected [`SnapshotRestoreFunding`] policy may persist.
143///
144/// The pool contains no process-global state. Downstream suites select a
145/// capacity that fits their host and keep lifecycle-sensitive tests on fresh
146/// [`StandaloneCanisterFixture`] values when snapshot restoration is not the
147/// intended isolation boundary.
148pub struct CachedStandaloneCanisterFixturePool<const CAPACITY: usize> {
149    slots: OnceLock<BoundedSlotPool<StandaloneFixtureBaseline>>,
150    restore_funding: SnapshotRestoreFunding,
151}
152
153/// Exclusive lease of one independently restored standalone fixture.
154///
155/// The lease dereferences to [`StandaloneCanisterFixture`], so existing call
156/// helpers can borrow it without adding a second fixture API.
157pub struct CachedStandaloneCanisterFixtureGuard<'a> {
158    slot: BoundedSlotLease<'a, StandaloneFixtureBaseline>,
159}
160
161impl<const CAPACITY: usize> CachedStandaloneCanisterFixturePool<CAPACITY> {
162    /// Create an empty caller-owned fixture pool.
163    ///
164    /// # Panics
165    ///
166    /// Panics at compile time for a statically initialized zero-capacity pool,
167    /// or at runtime if constructed dynamically with zero capacity.
168    #[must_use]
169    pub const fn new() -> Self {
170        assert!(CAPACITY > 0, "fixture pool capacity must be non-zero");
171
172        Self {
173            slots: OnceLock::new(),
174            restore_funding: SnapshotRestoreFunding::Preserve,
175        }
176    }
177
178    /// Select the cycle-funding policy applied immediately before each
179    /// snapshot restore.
180    #[must_use]
181    pub const fn with_restore_funding(mut self, funding: SnapshotRestoreFunding) -> Self {
182        self.restore_funding = funding;
183        self
184    }
185
186    /// Acquire one isolated fixture, building a slot on first use and restoring
187    /// its captured snapshot on later uses.
188    ///
189    /// `build` must create the same logical fixture baseline on every call to
190    /// this pool. It runs only when an empty slot is first populated or a dead
191    /// PocketIC instance must be replaced.
192    ///
193    /// A recognized dead-instance transport failure evicts and rebuilds only
194    /// the affected slot. Other snapshot failures are returned unchanged and
195    /// invalidate the possibly partially restored slot for the next lease.
196    ///
197    /// # Errors
198    ///
199    /// Returns the structured snapshot capture or restore failure for the
200    /// selected slot.
201    pub fn acquire<B>(
202        &self,
203        build: B,
204    ) -> Result<(CachedStandaloneCanisterFixtureGuard<'_>, bool), ControllerSnapshotError>
205    where
206        B: Fn() -> StandaloneCanisterFixture,
207    {
208        self.acquire_with_outcome(build)
209            .map(|(guard, outcome)| (guard, outcome.is_reused()))
210            .map_err(StandaloneFixturePoolError::into_snapshot_error)
211    }
212
213    /// Acquire one fixture with structured lifecycle outcome and phase timings.
214    ///
215    /// This is the diagnostic counterpart to [`acquire`](Self::acquire). It
216    /// distinguishes a new slot from restoration and reconstruction while the
217    /// compatibility method continues to report restoration as a boolean.
218    ///
219    /// # Errors
220    ///
221    /// Returns the failed preparation stage, the structured snapshot error,
222    /// and all phase timings completed before failure. If dead-transport
223    /// restoration and replacement capture both fail, both errors are retained.
224    pub fn acquire_with_outcome<B>(
225        &self,
226        build: B,
227    ) -> Result<
228        (
229            CachedStandaloneCanisterFixtureGuard<'_>,
230            StandaloneFixturePoolOutcome,
231        ),
232        StandaloneFixturePoolError,
233    >
234    where
235        B: Fn() -> StandaloneCanisterFixture,
236    {
237        let total_started = Instant::now();
238        self.prepare_slot_with_outcome(self.slots().acquire(), &build, total_started)
239    }
240
241    fn prepare_slot_with_outcome<'a, B>(
242        &'a self,
243        mut slot: BoundedSlotLease<'a, StandaloneFixtureBaseline>,
244        build: &B,
245        total_started: Instant,
246    ) -> Result<
247        (
248            CachedStandaloneCanisterFixtureGuard<'a>,
249            StandaloneFixturePoolOutcome,
250        ),
251        StandaloneFixturePoolError,
252    >
253    where
254        B: Fn() -> StandaloneCanisterFixture,
255    {
256        let slot_index = slot.slot_index();
257        let mut timings = StandaloneFixturePoolTimings {
258            wait: slot.wait(),
259            ..StandaloneFixturePoolTimings::default()
260        };
261
262        if !slot.is_reusable() {
263            let rebuild_reason = Self::rebuild_reason_for_invalid_slot(&slot);
264            Self::discard_stale_slot(&mut slot, &mut timings);
265            let baseline = match Self::build_slot(build, &mut timings) {
266                Ok(baseline) => baseline,
267                Err(source) => {
268                    timings.total = total_started.elapsed();
269                    return Err(StandaloneFixturePoolError::Preparation {
270                        stage: StandaloneFixturePoolStage::Build,
271                        source: Box::new(source),
272                        timings: Box::new(timings),
273                    });
274                }
275            };
276            slot.replace(baseline);
277            timings.total = total_started.elapsed();
278            let outcome = rebuild_reason.map_or_else(
279                || StandaloneFixturePoolOutcome::Built {
280                    slot: slot_index,
281                    timings,
282                },
283                |reason| StandaloneFixturePoolOutcome::Rebuilt {
284                    slot: slot_index,
285                    reason,
286                    timings,
287                },
288            );
289            return Ok((CachedStandaloneCanisterFixtureGuard { slot }, outcome));
290        }
291
292        let restore_started = Instant::now();
293        let restore = slot
294            .get()
295            .expect("populated fixture pool slot must remain present")
296            .restore(self.restore_funding);
297        timings.restore = Some(restore_started.elapsed());
298        match restore {
299            Ok(()) => {
300                slot.get_mut()
301                    .expect("restored fixture pool slot must remain present")
302                    .invalidation_reason = None;
303                timings.total = total_started.elapsed();
304                Ok((
305                    CachedStandaloneCanisterFixtureGuard { slot },
306                    StandaloneFixturePoolOutcome::Restored {
307                        slot: slot_index,
308                        timings,
309                    },
310                ))
311            }
312            Err(error) if snapshot_error_is_dead_instance_transport(&error) => {
313                Self::discard_stale_slot(&mut slot, &mut timings);
314                let baseline = match Self::build_slot(build, &mut timings) {
315                    Ok(baseline) => baseline,
316                    Err(rebuild) => {
317                        timings.total = total_started.elapsed();
318                        return Err(StandaloneFixturePoolError::RecoveryFailed {
319                            original: Box::new(error),
320                            rebuild: Box::new(rebuild),
321                            timings: Box::new(timings),
322                        });
323                    }
324                };
325                slot.replace(baseline);
326                timings.total = total_started.elapsed();
327                Ok((
328                    CachedStandaloneCanisterFixtureGuard { slot },
329                    StandaloneFixturePoolOutcome::Rebuilt {
330                        slot: slot_index,
331                        reason: StandaloneFixturePoolRebuildReason::DeadPocketIcTransport,
332                        timings,
333                    },
334                ))
335            }
336            Err(source) => {
337                if let Some(baseline) = slot.get_mut() {
338                    baseline.invalidation_reason =
339                        Some(StandaloneFixturePoolRebuildReason::PreviousRestoreFailure);
340                }
341                // Restoration may have changed an earlier canister before a
342                // later snapshot failed. Preserve the current error while
343                // preventing a partially restored slot from being reused.
344                slot.invalidate();
345                timings.total = total_started.elapsed();
346                Err(StandaloneFixturePoolError::Preparation {
347                    stage: StandaloneFixturePoolStage::Restore,
348                    source: Box::new(source),
349                    timings: Box::new(timings),
350                })
351            }
352        }
353    }
354
355    fn rebuild_reason_for_invalid_slot(
356        slot: &BoundedSlotLease<'_, StandaloneFixtureBaseline>,
357    ) -> Option<StandaloneFixturePoolRebuildReason> {
358        if slot.invalidated_by_unwind() {
359            Some(StandaloneFixturePoolRebuildReason::UnwindWhileLeased)
360        } else {
361            slot.get()
362                .and_then(|baseline| baseline.invalidation_reason)
363                .or_else(|| {
364                    slot.is_populated()
365                        .then_some(StandaloneFixturePoolRebuildReason::PreviousRestoreFailure)
366                })
367        }
368    }
369
370    fn build_slot<B>(
371        build: &B,
372        timings: &mut StandaloneFixturePoolTimings,
373    ) -> Result<StandaloneFixtureBaseline, ControllerSnapshotError>
374    where
375        B: Fn() -> StandaloneCanisterFixture,
376    {
377        let started = Instant::now();
378        let result = StandaloneFixtureBaseline::capture(build());
379        timings.build = Some(started.elapsed());
380        result
381    }
382
383    fn discard_stale_slot(
384        slot: &mut BoundedSlotLease<'_, StandaloneFixtureBaseline>,
385        timings: &mut StandaloneFixturePoolTimings,
386    ) {
387        if !slot.is_populated() {
388            return;
389        }
390        let started = Instant::now();
391        if let Some(stale) = slot.take() {
392            let _ = catch_unwind(AssertUnwindSafe(|| drop(stale)));
393        }
394        timings.stale_teardown = Some(started.elapsed());
395    }
396
397    fn slots(&self) -> &BoundedSlotPool<StandaloneFixtureBaseline> {
398        self.slots.get_or_init(|| {
399            BoundedSlotPool::new(
400                NonZeroUsize::new(CAPACITY).expect("fixture pool capacity must be non-zero"),
401            )
402        })
403    }
404}
405
406impl<const CAPACITY: usize> Default for CachedStandaloneCanisterFixturePool<CAPACITY> {
407    fn default() -> Self {
408        Self::new()
409    }
410}
411
412impl Deref for CachedStandaloneCanisterFixtureGuard<'_> {
413    type Target = StandaloneCanisterFixture;
414
415    fn deref(&self) -> &Self::Target {
416        &self
417            .slot
418            .get()
419            .expect("leased fixture pool slot must remain populated")
420            .fixture
421    }
422}
423
424impl StandaloneFixturePoolOutcome {
425    /// Diagnostic slot index used by this acquisition.
426    #[must_use]
427    pub const fn slot(&self) -> usize {
428        match self {
429            Self::Built { slot, .. } | Self::Restored { slot, .. } | Self::Rebuilt { slot, .. } => {
430                *slot
431            }
432        }
433    }
434
435    /// Acquisition phase timings.
436    #[must_use]
437    pub const fn timings(&self) -> StandaloneFixturePoolTimings {
438        match self {
439            Self::Built { timings, .. }
440            | Self::Restored { timings, .. }
441            | Self::Rebuilt { timings, .. } => *timings,
442        }
443    }
444
445    /// Report whether an existing slot was restored without reconstruction.
446    #[must_use]
447    pub const fn is_reused(&self) -> bool {
448        matches!(self, Self::Restored { .. })
449    }
450}
451
452impl StandaloneFixturePoolTimings {
453    /// Time spent waiting for a capacity slot.
454    #[must_use]
455    pub const fn wait(self) -> Duration {
456        self.wait
457    }
458
459    /// Time spent building a fixture and capturing its baseline snapshot.
460    #[must_use]
461    pub const fn build(self) -> Option<Duration> {
462        self.build
463    }
464
465    /// Time spent restoring a populated slot.
466    #[must_use]
467    pub const fn restore(self) -> Option<Duration> {
468        self.restore
469    }
470
471    /// Time spent dropping an invalid or dead slot before rebuilding.
472    #[must_use]
473    pub const fn stale_teardown(self) -> Option<Duration> {
474        self.stale_teardown
475    }
476
477    /// Complete acquisition duration.
478    #[must_use]
479    pub const fn total(self) -> Duration {
480        self.total
481    }
482}
483
484impl std::fmt::Display for StandaloneFixturePoolTimings {
485    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486        write!(
487            formatter,
488            "total={:?} wait={:?} build={:?} restore={:?} stale_teardown={:?}",
489            self.total, self.wait, self.build, self.restore, self.stale_teardown,
490        )
491    }
492}
493
494impl std::fmt::Display for StandaloneFixturePoolOutcome {
495    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
496        match self {
497            Self::Built { slot, timings } => write!(formatter, "built slot={slot} {timings}"),
498            Self::Restored { slot, timings } => {
499                write!(formatter, "restored slot={slot} {timings}")
500            }
501            Self::Rebuilt {
502                slot,
503                reason,
504                timings,
505            } => write!(formatter, "rebuilt slot={slot} reason={reason:?} {timings}"),
506        }
507    }
508}
509
510impl StandaloneFixturePoolError {
511    /// Timings recorded before this acquisition failed.
512    #[must_use]
513    pub const fn timings(&self) -> StandaloneFixturePoolTimings {
514        match self {
515            Self::Preparation { timings, .. } | Self::RecoveryFailed { timings, .. } => **timings,
516        }
517    }
518
519    fn into_snapshot_error(self) -> ControllerSnapshotError {
520        match self {
521            Self::Preparation { source, .. } => *source,
522            Self::RecoveryFailed { rebuild, .. } => *rebuild,
523        }
524    }
525}
526
527impl std::fmt::Display for StandaloneFixturePoolStage {
528    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
529        formatter.write_str(match self {
530            Self::Build => "fixture build and snapshot capture",
531            Self::Restore => "fixture snapshot restore",
532        })
533    }
534}
535
536impl std::fmt::Display for StandaloneFixturePoolError {
537    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
538        match self {
539            Self::Preparation { stage, source, .. } => {
540                write!(formatter, "standalone {stage} failed: {source}")
541            }
542            Self::RecoveryFailed {
543                original, rebuild, ..
544            } => write!(
545                formatter,
546                "standalone fixture restore failed ({original}); rebuilding the slot also failed: {rebuild}",
547            ),
548        }
549    }
550}
551
552impl std::error::Error for StandaloneFixturePoolError {
553    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
554        match self {
555            Self::Preparation { source, .. } => Some(source.as_ref()),
556            Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
557        }
558    }
559}
560
561fn snapshot_error_is_dead_instance_transport(error: &ControllerSnapshotError) -> bool {
562    matches!(
563        error,
564        ControllerSnapshotError::RestorePanicked { message, .. }
565            if transport::is_dead_instance_transport_error(message)
566    )
567}
568
569#[cfg(test)]
570mod tests {
571    use super::CachedStandaloneCanisterFixturePool;
572
573    const _: CachedStandaloneCanisterFixturePool<1> = CachedStandaloneCanisterFixturePool::new();
574
575    #[test]
576    fn nonzero_pool_constructs() {
577        let _pool = CachedStandaloneCanisterFixturePool::<2>::new();
578    }
579}