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, RebuildReason, ResetReceipt,
8    ResetRequirements, SnapshotRestoreFunding, StandaloneCanisterFixture,
9    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 {
70                canister_id,
71                sender: None,
72            }],
73            canister_id,
74        )
75        .map_err(io::Error::other)
76    }
77
78    fn restore_canisters(
79        &self,
80        baseline: &CachedPocketIcBaseline<Principal>,
81    ) -> Result<CanisterRestoreReceipt, io::Error> {
82        baseline.restore(self.funding).map_err(io::Error::other)
83    }
84
85    fn reset_non_snapshot_state(
86        &self,
87        _baseline: &CachedPocketIcBaseline<Principal>,
88    ) -> Result<ResetReceipt, io::Error> {
89        Ok(ResetReceipt::empty())
90    }
91
92    fn drive_to_readiness(
93        &self,
94        _baseline: &CachedPocketIcBaseline<Principal>,
95    ) -> Result<(), io::Error> {
96        Ok(())
97    }
98
99    fn validate(
100        &self,
101        _baseline: &CachedPocketIcBaseline<Principal>,
102        _preparation: &PreparedBaseline,
103    ) -> Result<(), io::Error> {
104        Ok(())
105    }
106
107    fn classify_failure(
108        &self,
109        _stage: super::BaselinePreparationStage,
110        error: &io::Error,
111    ) -> FailureDisposition {
112        if is_dead_pocket_ic_transport_error(error) {
113            FailureDisposition::Rebuild(RebuildReason::DeadPocketIcTransport)
114        } else {
115            FailureDisposition::Fatal
116        }
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use std::num::NonZeroUsize;
123
124    use super::*;
125    use crate::pic::CachedPocketIcBaselinePool;
126
127    fn build_fixture() -> io::Result<StandaloneCanisterFixture> {
128        panic!("constructing an empty pool must not invoke its builder")
129    }
130
131    #[test]
132    fn runtime_capacity_preserves_lazy_fixture_construction() {
133        for capacity in [1, 2, 4, 8].map(|value| NonZeroUsize::new(value).unwrap()) {
134            let recipe = StandaloneCanisterRecipe::new(
135                FixtureRecipeId::try_new("lazy-single-canister").unwrap(),
136                SnapshotRestoreFunding::TopUpTo { minimum_cycles: 1 },
137                build_fixture,
138            );
139            let pool = CachedPocketIcBaselinePool::new(capacity, recipe);
140            assert_eq!(pool.capacity(), capacity);
141        }
142    }
143
144    #[test]
145    fn builder_errors_preserve_the_cause_and_release_the_slot_for_another_attempt() {
146        let recipe = StandaloneCanisterRecipe::new(
147            FixtureRecipeId::try_new("failed-single-canister").unwrap(),
148            SnapshotRestoreFunding::Preserve,
149            || {
150                Err(io::Error::new(
151                    io::ErrorKind::PermissionDenied,
152                    std::fmt::Error,
153                ))
154            },
155        );
156        let pool = CachedPocketIcBaselinePool::new(NonZeroUsize::new(1).unwrap(), recipe);
157        for _ in 0..2 {
158            let Err(super::super::BaselinePoolError::Preparation {
159                error:
160                    super::super::BaselinePoolPreparationError::Recipe {
161                        stage: super::super::BaselinePreparationStage::Build,
162                        source,
163                    },
164                timings,
165            }) = pool.acquire()
166            else {
167                panic!("builder failure must return at the build stage");
168            };
169            assert_eq!(source.kind(), io::ErrorKind::PermissionDenied);
170            assert!(source.get_ref().unwrap().is::<std::fmt::Error>());
171            assert!(timings.build().is_some());
172            assert!(timings.restore().is_none());
173            assert!(timings.total() >= timings.build().unwrap());
174        }
175    }
176}