Skip to main content

ic_testkit/pic/
standalone_recipe.rs

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