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        self.prepare_slot_with_outcome(self.slots.acquire(), total_started)
231    }
232
233    fn prepare_slot_with_outcome<'a>(
234        &'a self,
235        mut slot: BoundedSlotLease<'a, StandaloneFixtureBaseline>,
236        total_started: Instant,
237    ) -> Result<
238        (
239            CachedStandaloneCanisterFixtureGuard<'a>,
240            StandaloneFixturePoolOutcome,
241        ),
242        StandaloneFixturePoolError,
243    > {
244        let slot_index = slot.slot_index();
245        let mut timings = StandaloneFixturePoolTimings {
246            wait: slot.wait(),
247            ..StandaloneFixturePoolTimings::default()
248        };
249
250        if !slot.is_reusable() {
251            let rebuild_reason = Self::rebuild_reason_for_invalid_slot(&slot);
252            Self::discard_stale_slot(&mut slot, &mut timings);
253            let baseline = match self.build_slot(&mut timings) {
254                Ok(baseline) => baseline,
255                Err(source) => {
256                    timings.total = total_started.elapsed();
257                    return Err(StandaloneFixturePoolError::Preparation {
258                        stage: StandaloneFixturePoolStage::Build,
259                        source: Box::new(source),
260                        timings: Box::new(timings),
261                    });
262                }
263            };
264            slot.replace(baseline);
265            timings.total = total_started.elapsed();
266            let outcome = rebuild_reason.map_or_else(
267                || StandaloneFixturePoolOutcome::Built {
268                    slot: slot_index,
269                    timings,
270                },
271                |reason| StandaloneFixturePoolOutcome::Rebuilt {
272                    slot: slot_index,
273                    reason,
274                    timings,
275                },
276            );
277            return Ok((CachedStandaloneCanisterFixtureGuard { slot }, outcome));
278        }
279
280        let restore_started = Instant::now();
281        let restore = slot
282            .get()
283            .expect("populated fixture pool slot must remain present")
284            .restore(self.restore_funding);
285        timings.restore = Some(restore_started.elapsed());
286        match restore {
287            Ok(()) => {
288                timings.total = total_started.elapsed();
289                Ok((
290                    CachedStandaloneCanisterFixtureGuard { slot },
291                    StandaloneFixturePoolOutcome::Restored {
292                        slot: slot_index,
293                        timings,
294                    },
295                ))
296            }
297            Err(error) if transport::is_dead_pocket_ic_transport_error(&error) => {
298                Self::discard_stale_slot(&mut slot, &mut timings);
299                let baseline = match self.build_slot(&mut timings) {
300                    Ok(baseline) => baseline,
301                    Err(rebuild) => {
302                        timings.total = total_started.elapsed();
303                        return Err(StandaloneFixturePoolError::RecoveryFailed {
304                            original: Box::new(error),
305                            rebuild: Box::new(rebuild),
306                            timings: Box::new(timings),
307                        });
308                    }
309                };
310                slot.replace(baseline);
311                timings.total = total_started.elapsed();
312                Ok((
313                    CachedStandaloneCanisterFixtureGuard { slot },
314                    StandaloneFixturePoolOutcome::Rebuilt {
315                        slot: slot_index,
316                        reason: StandaloneFixturePoolRebuildReason::DeadPocketIcTransport,
317                        timings,
318                    },
319                ))
320            }
321            Err(source) => {
322                // Restoration may have changed an earlier canister before a
323                // later snapshot failed. Preserve the current error while
324                // preventing a partially restored slot from being reused.
325                slot.invalidate();
326                timings.total = total_started.elapsed();
327                Err(StandaloneFixturePoolError::Preparation {
328                    stage: StandaloneFixturePoolStage::Restore,
329                    source: Box::new(source),
330                    timings: Box::new(timings),
331                })
332            }
333        }
334    }
335
336    fn rebuild_reason_for_invalid_slot(
337        slot: &BoundedSlotLease<'_, StandaloneFixtureBaseline>,
338    ) -> Option<StandaloneFixturePoolRebuildReason> {
339        if slot.invalidated_by_unwind() {
340            Some(StandaloneFixturePoolRebuildReason::UnwindWhileLeased)
341        } else {
342            // Non-unwind invalidation only follows a failed restore.
343            slot.is_populated()
344                .then_some(StandaloneFixturePoolRebuildReason::PreviousRestoreFailure)
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
373impl Deref for CachedStandaloneCanisterFixtureGuard<'_> {
374    type Target = StandaloneCanisterFixture;
375
376    fn deref(&self) -> &Self::Target {
377        &self
378            .slot
379            .get()
380            .expect("leased fixture pool slot must remain populated")
381            .fixture
382    }
383}
384
385impl StandaloneFixturePoolOutcome {
386    /// Diagnostic slot index used by this acquisition.
387    #[must_use]
388    pub const fn slot(&self) -> usize {
389        match self {
390            Self::Built { slot, .. } | Self::Restored { slot, .. } | Self::Rebuilt { slot, .. } => {
391                *slot
392            }
393        }
394    }
395
396    /// Acquisition phase timings.
397    #[must_use]
398    pub const fn timings(&self) -> StandaloneFixturePoolTimings {
399        match self {
400            Self::Built { timings, .. }
401            | Self::Restored { timings, .. }
402            | Self::Rebuilt { timings, .. } => *timings,
403        }
404    }
405
406    /// Report whether an existing slot was restored without reconstruction.
407    #[must_use]
408    pub const fn is_reused(&self) -> bool {
409        matches!(self, Self::Restored { .. })
410    }
411}
412
413impl StandaloneFixturePoolTimings {
414    /// Time spent waiting for a capacity slot.
415    #[must_use]
416    pub const fn wait(self) -> Duration {
417        self.wait
418    }
419
420    /// Time spent building a fixture and capturing its baseline snapshot.
421    #[must_use]
422    pub const fn build(self) -> Option<Duration> {
423        self.build
424    }
425
426    /// Time spent restoring a populated slot.
427    #[must_use]
428    pub const fn restore(self) -> Option<Duration> {
429        self.restore
430    }
431
432    /// Time spent dropping an invalid or dead slot before rebuilding.
433    #[must_use]
434    pub const fn stale_teardown(self) -> Option<Duration> {
435        self.stale_teardown
436    }
437
438    /// Complete acquisition duration.
439    #[must_use]
440    pub const fn total(self) -> Duration {
441        self.total
442    }
443}
444
445impl std::fmt::Display for StandaloneFixturePoolTimings {
446    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
447        write!(
448            formatter,
449            "total={:?} wait={:?} build={:?} restore={:?} stale_teardown={:?}",
450            self.total, self.wait, self.build, self.restore, self.stale_teardown,
451        )
452    }
453}
454
455impl std::fmt::Display for StandaloneFixturePoolOutcome {
456    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457        match self {
458            Self::Built { slot, timings } => write!(formatter, "built slot={slot} {timings}"),
459            Self::Restored { slot, timings } => {
460                write!(formatter, "restored slot={slot} {timings}")
461            }
462            Self::Rebuilt {
463                slot,
464                reason,
465                timings,
466            } => write!(formatter, "rebuilt slot={slot} reason={reason:?} {timings}"),
467        }
468    }
469}
470
471impl StandaloneFixturePoolError {
472    /// Timings recorded before this acquisition failed.
473    #[must_use]
474    pub const fn timings(&self) -> StandaloneFixturePoolTimings {
475        match self {
476            Self::Preparation { timings, .. } | Self::RecoveryFailed { timings, .. } => **timings,
477        }
478    }
479}
480
481impl std::fmt::Display for StandaloneFixturePoolStage {
482    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
483        formatter.write_str(match self {
484            Self::Build => "fixture build and snapshot capture",
485            Self::Restore => "fixture snapshot restore",
486        })
487    }
488}
489
490impl std::fmt::Display for StandaloneFixturePoolError {
491    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
492        match self {
493            Self::Preparation { stage, source, .. } => {
494                write!(formatter, "standalone {stage} failed: {source}")
495            }
496            Self::RecoveryFailed {
497                original, rebuild, ..
498            } => write!(
499                formatter,
500                "standalone fixture restore failed ({original}); rebuilding the slot also failed: {rebuild}",
501            ),
502        }
503    }
504}
505
506impl std::error::Error for StandaloneFixturePoolError {
507    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
508        match self {
509            Self::Preparation { source, .. } => Some(source.as_ref()),
510            Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
511        }
512    }
513}
514
515#[cfg(test)]
516mod tests {
517    use std::num::NonZeroUsize;
518
519    use super::{
520        CachedStandaloneCanisterFixturePool, SnapshotRestoreFunding, StandaloneCanisterFixture,
521    };
522
523    const _: CachedStandaloneCanisterFixturePool = {
524        let pool: CachedStandaloneCanisterFixturePool =
525            CachedStandaloneCanisterFixturePool::new(NonZeroUsize::new(1).unwrap(), build_fixture);
526        pool.with_restore_funding(SnapshotRestoreFunding::TopUpTo { minimum_cycles: 1 })
527    };
528
529    fn build_fixture() -> StandaloneCanisterFixture {
530        panic!("constructing an empty pool must not invoke its builder")
531    }
532
533    #[test]
534    fn runtime_capacity_preserves_lazy_fixture_construction() {
535        for capacity in [1, 2, 4, 8].map(|value| NonZeroUsize::new(value).unwrap()) {
536            let pool = CachedStandaloneCanisterFixturePool::new(capacity, build_fixture);
537            assert_eq!(pool.capacity(), capacity);
538        }
539    }
540}