Skip to main content

ic_testkit/pic/
standalone_recipe.rs

1use std::io;
2
3use candid::Principal;
4
5use super::{
6    CachedPocketIcBaseline, CanisterRestoreReceipt, CanisterSnapshotTarget, FailureDisposition,
7    FixtureRecipeId, PocketIcBaselineRecipe, PreparedBaseline, ReadinessReceipt, RebuildReason,
8    ResetReceipt, ResetRequirements, SnapshotRestoreFunding, StandaloneCanisterFixture,
9    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        Self {
40            id,
41            requirements: ResetRequirements::try_new(funding.into(), [])
42                .expect("empty non-snapshot requirements are valid"),
43            funding,
44            build,
45        }
46    }
47}
48
49impl<B> PocketIcBaselineRecipe for StandaloneCanisterRecipe<B>
50where
51    B: Fn() -> io::Result<StandaloneCanisterFixture> + Send + Sync + 'static,
52{
53    type Metadata = Principal;
54    // Retain the typed snapshot/contract cause without another error vocabulary.
55    type Error = io::Error;
56
57    fn id(&self) -> &FixtureRecipeId {
58        &self.id
59    }
60
61    fn reset_requirements(&self) -> &ResetRequirements {
62        &self.requirements
63    }
64
65    fn build(&self) -> Result<CachedPocketIcBaseline<Principal>, io::Error> {
66        let (pocket_ic, canister_id) = (self.build)()?.into_parts();
67        CachedPocketIcBaseline::capture(
68            pocket_ic,
69            [CanisterSnapshotTarget::new(canister_id, None)],
70            canister_id,
71        )
72        .map_err(io::Error::other)
73    }
74
75    fn restore_canisters(
76        &self,
77        baseline: &CachedPocketIcBaseline<Principal>,
78    ) -> Result<CanisterRestoreReceipt, io::Error> {
79        baseline.restore(self.funding).map_err(io::Error::other)
80    }
81
82    fn reset_non_snapshot_state(
83        &self,
84        _baseline: &CachedPocketIcBaseline<Principal>,
85    ) -> Result<ResetReceipt, io::Error> {
86        Ok(ResetReceipt::empty())
87    }
88
89    fn drive_to_readiness(
90        &self,
91        _baseline: &CachedPocketIcBaseline<Principal>,
92    ) -> Result<ReadinessReceipt, io::Error> {
93        ReadinessReceipt::try_new("snapshot-operation-completed").map_err(io::Error::other)
94    }
95
96    fn validate(
97        &self,
98        _baseline: &CachedPocketIcBaseline<Principal>,
99        _preparation: &PreparedBaseline,
100    ) -> Result<ValidationReceipt, io::Error> {
101        ValidationReceipt::try_new(self.id.clone(), "single-canister-snapshot-completed")
102            .map_err(io::Error::other)
103    }
104
105    fn classify_failure(
106        &self,
107        _stage: super::BaselinePreparationStage,
108        error: &io::Error,
109    ) -> FailureDisposition {
110        if is_dead_pocket_ic_transport_error(error) {
111            FailureDisposition::Rebuild(RebuildReason::DeadPocketIcTransport)
112        } else {
113            FailureDisposition::Fatal
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use std::num::NonZeroUsize;
121
122    use super::*;
123    use crate::pic::CachedPocketIcBaselinePool;
124
125    fn build_fixture() -> io::Result<StandaloneCanisterFixture> {
126        panic!("constructing an empty pool must not invoke its builder")
127    }
128
129    #[test]
130    fn runtime_capacity_preserves_lazy_fixture_construction() {
131        for capacity in [1, 2, 4, 8].map(|value| NonZeroUsize::new(value).unwrap()) {
132            let recipe = StandaloneCanisterRecipe::new(
133                FixtureRecipeId::try_new("lazy-single-canister").unwrap(),
134                SnapshotRestoreFunding::TopUpTo { minimum_cycles: 1 },
135                build_fixture,
136            );
137            let pool = CachedPocketIcBaselinePool::new(capacity, recipe);
138            assert_eq!(pool.capacity(), capacity);
139        }
140    }
141
142    #[test]
143    fn builder_errors_preserve_the_cause_and_release_the_slot_for_another_attempt() {
144        let recipe = StandaloneCanisterRecipe::new(
145            FixtureRecipeId::try_new("failed-single-canister").unwrap(),
146            SnapshotRestoreFunding::Preserve,
147            || {
148                Err(io::Error::new(
149                    io::ErrorKind::PermissionDenied,
150                    std::fmt::Error,
151                ))
152            },
153        );
154        let pool = CachedPocketIcBaselinePool::new(NonZeroUsize::new(1).unwrap(), recipe);
155        for _ in 0..2 {
156            let Err(super::super::BaselinePoolError::Preparation {
157                error:
158                    super::super::BaselinePoolPreparationError::Recipe {
159                        stage: super::super::BaselinePreparationStage::Build,
160                        source,
161                    },
162                timings,
163            }) = pool.acquire()
164            else {
165                panic!("builder failure must return at the build stage");
166            };
167            assert_eq!(source.kind(), io::ErrorKind::PermissionDenied);
168            assert!(source.get_ref().unwrap().is::<std::fmt::Error>());
169            assert!(timings.build().is_some());
170            assert!(timings.restore().is_none());
171            assert!(timings.total() >= timings.build().unwrap());
172        }
173    }
174}