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