Skip to main content

ic_testkit/pic/
standalone_recipe.rs

1use std::io;
2
3use candid::Principal;
4
5use super::{
6    CachedPocketIcBaseline, CanisterSnapshotTarget, FailureDisposition, PocketIcBaselineRecipe,
7    PreparedBaseline, RebuildReason, ResetPolicies, ResetReceipt, ResetRequirements,
8    SnapshotRestoreFunding, StandaloneCanisterFixture, is_dead_pocket_ic_transport_error,
9};
10
11/// Snapshot-only recipe for one caller-built, installed canister.
12///
13/// Use with [`super::CachedPocketIcBaselinePool`]. The baseline metadata is the
14/// installed canister ID; calls go through the baseline's borrowed PocketIC.
15/// The builder owns initialization and readiness before capture and must produce
16/// the same topology and seeded state for every slot of this recipe. It returns
17/// `io::Result<StandaloneCanisterFixture>`; build errors retain their original
18/// cause and are reported at the pool's build stage, without invoking capture.
19///
20/// Only the captured canister is restored. Time, other canisters, pending work
21/// and external resources are not reset. Use a custom recipe or a fresh instance
22/// when those mutations matter. Funding is applied immediately before restore.
23/// Non-transport restore failures are fatal: the pool discards the affected
24/// baseline before returning the error. Dead transport permits one recovery build.
25pub struct StandaloneCanisterRecipe<B> {
26    requirements: ResetRequirements,
27    build: B,
28}
29
30impl<B> StandaloneCanisterRecipe<B> {
31    /// Describe a single-canister recipe without invoking its builder.
32    ///
33    /// Static suites can initialize the recipe and pool with [`std::sync::LazyLock`].
34    #[must_use]
35    pub fn new(funding: SnapshotRestoreFunding, build: B) -> Self {
36        Self {
37            requirements: ResetRequirements {
38                funding,
39                policies: ResetPolicies::default(),
40            },
41            build,
42        }
43    }
44}
45
46impl<B> PocketIcBaselineRecipe for StandaloneCanisterRecipe<B>
47where
48    B: Fn() -> io::Result<StandaloneCanisterFixture> + Send + Sync + 'static,
49{
50    type Metadata = Principal;
51    // Retain the typed snapshot/contract cause without another error vocabulary.
52    type Error = io::Error;
53
54    fn reset_requirements(&self) -> &ResetRequirements {
55        &self.requirements
56    }
57
58    fn build(&self) -> Result<CachedPocketIcBaseline<Principal>, io::Error> {
59        let (pocket_ic, canister_id) = (self.build)()?.into_parts();
60        CachedPocketIcBaseline::capture(
61            pocket_ic,
62            [CanisterSnapshotTarget {
63                canister_id,
64                sender: None,
65            }],
66            canister_id,
67        )
68        .map_err(io::Error::other)
69    }
70
71    fn reset_non_snapshot_state(
72        &self,
73        _baseline: &CachedPocketIcBaseline<Principal>,
74    ) -> Result<ResetReceipt, io::Error> {
75        Ok(ResetReceipt::default())
76    }
77
78    fn drive_to_readiness(
79        &self,
80        _baseline: &CachedPocketIcBaseline<Principal>,
81    ) -> Result<(), io::Error> {
82        Ok(())
83    }
84
85    fn validate(
86        &self,
87        _baseline: &CachedPocketIcBaseline<Principal>,
88        _preparation: &PreparedBaseline,
89    ) -> Result<(), io::Error> {
90        Ok(())
91    }
92
93    fn classify_failure(
94        &self,
95        _stage: super::BaselinePreparationStage,
96        error: &(dyn std::error::Error + 'static),
97    ) -> FailureDisposition {
98        if is_dead_pocket_ic_transport_error(error) {
99            FailureDisposition::Rebuild(RebuildReason::DeadPocketIcTransport)
100        } else {
101            FailureDisposition::Fatal
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use std::num::NonZeroUsize;
109
110    use super::*;
111    use crate::pic::CachedPocketIcBaselinePool;
112
113    fn build_fixture() -> io::Result<StandaloneCanisterFixture> {
114        panic!("constructing an empty pool must not invoke its builder")
115    }
116
117    #[test]
118    fn runtime_capacity_preserves_lazy_fixture_construction() {
119        for capacity in [1, 2, 4, 8].map(|value| NonZeroUsize::new(value).unwrap()) {
120            let recipe = StandaloneCanisterRecipe::new(
121                SnapshotRestoreFunding::TopUpTo { minimum_cycles: 1 },
122                build_fixture,
123            );
124            let pool = CachedPocketIcBaselinePool::new(capacity, recipe);
125            assert_eq!(pool.capacity(), capacity);
126        }
127    }
128
129    #[test]
130    fn builder_errors_preserve_the_cause_and_release_the_slot_for_another_attempt() {
131        let recipe = StandaloneCanisterRecipe::new(SnapshotRestoreFunding::Preserve, || {
132            Err(io::Error::new(
133                io::ErrorKind::PermissionDenied,
134                std::fmt::Error,
135            ))
136        });
137        let pool = CachedPocketIcBaselinePool::new(NonZeroUsize::new(1).unwrap(), recipe);
138        for _ in 0..2 {
139            let Err(super::super::BaselinePoolError::Preparation {
140                error:
141                    super::super::BaselinePoolPreparationError::Recipe {
142                        stage: super::super::BaselinePreparationStage::Build,
143                        source,
144                    },
145                timings,
146            }) = pool.acquire()
147            else {
148                panic!("builder failure must return at the build stage");
149            };
150            assert_eq!(source.kind(), io::ErrorKind::PermissionDenied);
151            assert!(source.get_ref().unwrap().is::<std::fmt::Error>());
152            assert!(timings.build().is_some());
153            assert!(timings.restore().is_none());
154            assert!(timings.total() >= timings.build().unwrap());
155        }
156    }
157}