Skip to main content

ic_testkit/pic/
standalone_pool.rs

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