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 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    /// Acquire one fixture with a structured lifecycle outcome and phase timings.
198    ///
199    /// # Errors
200    ///
201    /// Returns the failed preparation stage, the structured snapshot error,
202    /// and all phase timings completed before failure. If dead-transport
203    /// restoration and replacement capture both fail, both errors are retained.
204    pub fn acquire<B>(
205        &self,
206        build: B,
207    ) -> Result<
208        (
209            CachedStandaloneCanisterFixtureGuard<'_>,
210            StandaloneFixturePoolOutcome,
211        ),
212        StandaloneFixturePoolError,
213    >
214    where
215        B: Fn() -> StandaloneCanisterFixture,
216    {
217        let total_started = Instant::now();
218        self.prepare_slot_with_outcome(self.slots().acquire(), &build, total_started)
219    }
220
221    fn prepare_slot_with_outcome<'a, B>(
222        &'a self,
223        mut slot: BoundedSlotLease<'a, StandaloneFixtureBaseline>,
224        build: &B,
225        total_started: Instant,
226    ) -> Result<
227        (
228            CachedStandaloneCanisterFixtureGuard<'a>,
229            StandaloneFixturePoolOutcome,
230        ),
231        StandaloneFixturePoolError,
232    >
233    where
234        B: Fn() -> StandaloneCanisterFixture,
235    {
236        let slot_index = slot.slot_index();
237        let mut timings = StandaloneFixturePoolTimings {
238            wait: slot.wait(),
239            ..StandaloneFixturePoolTimings::default()
240        };
241
242        if !slot.is_reusable() {
243            let rebuild_reason = Self::rebuild_reason_for_invalid_slot(&slot);
244            Self::discard_stale_slot(&mut slot, &mut timings);
245            let baseline = match Self::build_slot(build, &mut timings) {
246                Ok(baseline) => baseline,
247                Err(source) => {
248                    timings.total = total_started.elapsed();
249                    return Err(StandaloneFixturePoolError::Preparation {
250                        stage: StandaloneFixturePoolStage::Build,
251                        source: Box::new(source),
252                        timings: Box::new(timings),
253                    });
254                }
255            };
256            slot.replace(baseline);
257            timings.total = total_started.elapsed();
258            let outcome = rebuild_reason.map_or_else(
259                || StandaloneFixturePoolOutcome::Built {
260                    slot: slot_index,
261                    timings,
262                },
263                |reason| StandaloneFixturePoolOutcome::Rebuilt {
264                    slot: slot_index,
265                    reason,
266                    timings,
267                },
268            );
269            return Ok((CachedStandaloneCanisterFixtureGuard { slot }, outcome));
270        }
271
272        let restore_started = Instant::now();
273        let restore = slot
274            .get()
275            .expect("populated fixture pool slot must remain present")
276            .restore(self.restore_funding);
277        timings.restore = Some(restore_started.elapsed());
278        match restore {
279            Ok(()) => {
280                slot.get_mut()
281                    .expect("restored fixture pool slot must remain present")
282                    .invalidation_reason = None;
283                timings.total = total_started.elapsed();
284                Ok((
285                    CachedStandaloneCanisterFixtureGuard { slot },
286                    StandaloneFixturePoolOutcome::Restored {
287                        slot: slot_index,
288                        timings,
289                    },
290                ))
291            }
292            Err(error) if snapshot_error_is_dead_instance_transport(&error) => {
293                Self::discard_stale_slot(&mut slot, &mut timings);
294                let baseline = match Self::build_slot(build, &mut timings) {
295                    Ok(baseline) => baseline,
296                    Err(rebuild) => {
297                        timings.total = total_started.elapsed();
298                        return Err(StandaloneFixturePoolError::RecoveryFailed {
299                            original: Box::new(error),
300                            rebuild: Box::new(rebuild),
301                            timings: Box::new(timings),
302                        });
303                    }
304                };
305                slot.replace(baseline);
306                timings.total = total_started.elapsed();
307                Ok((
308                    CachedStandaloneCanisterFixtureGuard { slot },
309                    StandaloneFixturePoolOutcome::Rebuilt {
310                        slot: slot_index,
311                        reason: StandaloneFixturePoolRebuildReason::DeadPocketIcTransport,
312                        timings,
313                    },
314                ))
315            }
316            Err(source) => {
317                if let Some(baseline) = slot.get_mut() {
318                    baseline.invalidation_reason =
319                        Some(StandaloneFixturePoolRebuildReason::PreviousRestoreFailure);
320                }
321                // Restoration may have changed an earlier canister before a
322                // later snapshot failed. Preserve the current error while
323                // preventing a partially restored slot from being reused.
324                slot.invalidate();
325                timings.total = total_started.elapsed();
326                Err(StandaloneFixturePoolError::Preparation {
327                    stage: StandaloneFixturePoolStage::Restore,
328                    source: Box::new(source),
329                    timings: Box::new(timings),
330                })
331            }
332        }
333    }
334
335    fn rebuild_reason_for_invalid_slot(
336        slot: &BoundedSlotLease<'_, StandaloneFixtureBaseline>,
337    ) -> Option<StandaloneFixturePoolRebuildReason> {
338        if slot.invalidated_by_unwind() {
339            Some(StandaloneFixturePoolRebuildReason::UnwindWhileLeased)
340        } else {
341            slot.get()
342                .and_then(|baseline| baseline.invalidation_reason)
343                .or_else(|| {
344                    slot.is_populated()
345                        .then_some(StandaloneFixturePoolRebuildReason::PreviousRestoreFailure)
346                })
347        }
348    }
349
350    fn build_slot<B>(
351        build: &B,
352        timings: &mut StandaloneFixturePoolTimings,
353    ) -> Result<StandaloneFixtureBaseline, ControllerSnapshotError>
354    where
355        B: Fn() -> StandaloneCanisterFixture,
356    {
357        let started = Instant::now();
358        let result = StandaloneFixtureBaseline::capture(build());
359        timings.build = Some(started.elapsed());
360        result
361    }
362
363    fn discard_stale_slot(
364        slot: &mut BoundedSlotLease<'_, StandaloneFixtureBaseline>,
365        timings: &mut StandaloneFixturePoolTimings,
366    ) {
367        if !slot.is_populated() {
368            return;
369        }
370        let started = Instant::now();
371        if let Some(stale) = slot.take() {
372            let _ = catch_unwind(AssertUnwindSafe(|| drop(stale)));
373        }
374        timings.stale_teardown = Some(started.elapsed());
375    }
376
377    fn slots(&self) -> &BoundedSlotPool<StandaloneFixtureBaseline> {
378        self.slots.get_or_init(|| {
379            BoundedSlotPool::new(
380                NonZeroUsize::new(CAPACITY).expect("fixture pool capacity must be non-zero"),
381            )
382        })
383    }
384}
385
386impl<const CAPACITY: usize> Default for CachedStandaloneCanisterFixturePool<CAPACITY> {
387    fn default() -> Self {
388        Self::new()
389    }
390}
391
392impl Deref for CachedStandaloneCanisterFixtureGuard<'_> {
393    type Target = StandaloneCanisterFixture;
394
395    fn deref(&self) -> &Self::Target {
396        &self
397            .slot
398            .get()
399            .expect("leased fixture pool slot must remain populated")
400            .fixture
401    }
402}
403
404impl StandaloneFixturePoolOutcome {
405    /// Diagnostic slot index used by this acquisition.
406    #[must_use]
407    pub const fn slot(&self) -> usize {
408        match self {
409            Self::Built { slot, .. } | Self::Restored { slot, .. } | Self::Rebuilt { slot, .. } => {
410                *slot
411            }
412        }
413    }
414
415    /// Acquisition phase timings.
416    #[must_use]
417    pub const fn timings(&self) -> StandaloneFixturePoolTimings {
418        match self {
419            Self::Built { timings, .. }
420            | Self::Restored { timings, .. }
421            | Self::Rebuilt { timings, .. } => *timings,
422        }
423    }
424
425    /// Report whether an existing slot was restored without reconstruction.
426    #[must_use]
427    pub const fn is_reused(&self) -> bool {
428        matches!(self, Self::Restored { .. })
429    }
430}
431
432impl StandaloneFixturePoolTimings {
433    /// Time spent waiting for a capacity slot.
434    #[must_use]
435    pub const fn wait(self) -> Duration {
436        self.wait
437    }
438
439    /// Time spent building a fixture and capturing its baseline snapshot.
440    #[must_use]
441    pub const fn build(self) -> Option<Duration> {
442        self.build
443    }
444
445    /// Time spent restoring a populated slot.
446    #[must_use]
447    pub const fn restore(self) -> Option<Duration> {
448        self.restore
449    }
450
451    /// Time spent dropping an invalid or dead slot before rebuilding.
452    #[must_use]
453    pub const fn stale_teardown(self) -> Option<Duration> {
454        self.stale_teardown
455    }
456
457    /// Complete acquisition duration.
458    #[must_use]
459    pub const fn total(self) -> Duration {
460        self.total
461    }
462}
463
464impl std::fmt::Display for StandaloneFixturePoolTimings {
465    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
466        write!(
467            formatter,
468            "total={:?} wait={:?} build={:?} restore={:?} stale_teardown={:?}",
469            self.total, self.wait, self.build, self.restore, self.stale_teardown,
470        )
471    }
472}
473
474impl std::fmt::Display for StandaloneFixturePoolOutcome {
475    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476        match self {
477            Self::Built { slot, timings } => write!(formatter, "built slot={slot} {timings}"),
478            Self::Restored { slot, timings } => {
479                write!(formatter, "restored slot={slot} {timings}")
480            }
481            Self::Rebuilt {
482                slot,
483                reason,
484                timings,
485            } => write!(formatter, "rebuilt slot={slot} reason={reason:?} {timings}"),
486        }
487    }
488}
489
490impl StandaloneFixturePoolError {
491    /// Timings recorded before this acquisition failed.
492    #[must_use]
493    pub const fn timings(&self) -> StandaloneFixturePoolTimings {
494        match self {
495            Self::Preparation { timings, .. } | Self::RecoveryFailed { timings, .. } => **timings,
496        }
497    }
498}
499
500impl std::fmt::Display for StandaloneFixturePoolStage {
501    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
502        formatter.write_str(match self {
503            Self::Build => "fixture build and snapshot capture",
504            Self::Restore => "fixture snapshot restore",
505        })
506    }
507}
508
509impl std::fmt::Display for StandaloneFixturePoolError {
510    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
511        match self {
512            Self::Preparation { stage, source, .. } => {
513                write!(formatter, "standalone {stage} failed: {source}")
514            }
515            Self::RecoveryFailed {
516                original, rebuild, ..
517            } => write!(
518                formatter,
519                "standalone fixture restore failed ({original}); rebuilding the slot also failed: {rebuild}",
520            ),
521        }
522    }
523}
524
525impl std::error::Error for StandaloneFixturePoolError {
526    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
527        match self {
528            Self::Preparation { source, .. } => Some(source.as_ref()),
529            Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
530        }
531    }
532}
533
534fn snapshot_error_is_dead_instance_transport(error: &ControllerSnapshotError) -> bool {
535    matches!(
536        error,
537        ControllerSnapshotError::RestorePanicked { message, .. }
538            if transport::is_dead_instance_transport_error(message)
539    )
540}
541
542#[cfg(test)]
543mod tests {
544    use super::CachedStandaloneCanisterFixturePool;
545
546    const _: CachedStandaloneCanisterFixturePool<1> = CachedStandaloneCanisterFixturePool::new();
547
548    #[test]
549    fn nonzero_pool_constructs() {
550        let _pool = CachedStandaloneCanisterFixturePool::<2>::new();
551    }
552}