Skip to main content

ic_testkit/pic/
standalone_recipe.rs

1use std::io;
2
3use ic_platform::principal::ExternalPrincipal as 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/// This recipe stops/drains its single canister before capture and warm restore,
24/// then starts it at readiness. Automatic progress must remain disabled.
25/// Non-transport restore failures are fatal: the pool discards the affected
26/// baseline before returning the error. Dead transport permits one recovery build.
27pub struct StandaloneCanisterRecipe<B> {
28    requirements: ResetRequirements,
29    build: B,
30}
31
32impl<B> StandaloneCanisterRecipe<B> {
33    /// Describe a single-canister recipe without invoking its builder.
34    ///
35    /// Static suites can initialize the recipe and pool with [`std::sync::LazyLock`].
36    #[must_use]
37    pub fn new(funding: SnapshotRestoreFunding, build: B) -> Self {
38        Self {
39            requirements: ResetRequirements {
40                funding,
41                policies: ResetPolicies::default(),
42            },
43            build,
44        }
45    }
46}
47
48impl<B> PocketIcBaselineRecipe for StandaloneCanisterRecipe<B>
49where
50    B: Fn() -> io::Result<StandaloneCanisterFixture> + Send + Sync + 'static,
51{
52    type Metadata = Principal;
53    // Retain the typed snapshot/contract cause without another error vocabulary.
54    type Error = io::Error;
55
56    fn reset_requirements(&self) -> &ResetRequirements {
57        &self.requirements
58    }
59
60    fn build(&self) -> Result<CachedPocketIcBaseline<Principal>, io::Error> {
61        let (pocket_ic, canister_id) = (self.build)()?.into_parts();
62        super::transport::capture_call(|| pocket_ic.instance().stop_canister(canister_id, None))
63            .map_err(|source| {
64                io::Error::other(super::ControllerSnapshotError::CaptureFailed {
65                    canister_id,
66                    sender: None,
67                    source: Box::new(source),
68                    cleanup_failures: Vec::new(),
69                })
70            })?;
71        let baseline = CachedPocketIcBaseline::capture(
72            pocket_ic,
73            [CanisterSnapshotTarget {
74                canister_id,
75                sender: None,
76            }],
77            canister_id,
78        )
79        .map_err(io::Error::other)?;
80        self.drive_to_readiness(&baseline)?;
81        Ok(baseline)
82    }
83
84    fn reset_non_snapshot_state(
85        &self,
86        _baseline: &CachedPocketIcBaseline<Principal>,
87    ) -> Result<ResetReceipt, io::Error> {
88        Ok(ResetReceipt::default())
89    }
90
91    fn prepare_restore(
92        &self,
93        baseline: &CachedPocketIcBaseline<Principal>,
94    ) -> Result<(), io::Error> {
95        super::transport::capture_call(|| {
96            baseline
97                .pocket_ic()
98                .stop_canister(*baseline.metadata(), None)
99        })
100        .map_err(io::Error::other)
101    }
102
103    fn drive_to_readiness(
104        &self,
105        baseline: &CachedPocketIcBaseline<Principal>,
106    ) -> Result<(), io::Error> {
107        super::transport::capture_call(|| {
108            baseline
109                .pocket_ic()
110                .start_canister(*baseline.metadata(), None)
111        })
112        .map_err(io::Error::other)
113    }
114
115    fn validate(
116        &self,
117        _baseline: &CachedPocketIcBaseline<Principal>,
118        _preparation: &PreparedBaseline,
119    ) -> Result<(), io::Error> {
120        Ok(())
121    }
122
123    fn classify_failure(
124        &self,
125        _stage: super::BaselinePreparationStage,
126        error: &(dyn std::error::Error + 'static),
127    ) -> FailureDisposition {
128        if is_dead_pocket_ic_transport_error(error) {
129            FailureDisposition::Rebuild(RebuildReason::DeadPocketIcTransport)
130        } else {
131            FailureDisposition::Fatal
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use std::num::NonZeroUsize;
139
140    use super::*;
141    use crate::pic::CachedPocketIcBaselinePool;
142
143    fn build_fixture() -> io::Result<StandaloneCanisterFixture> {
144        panic!("constructing an empty pool must not invoke its builder")
145    }
146
147    #[test]
148    fn runtime_capacity_preserves_lazy_fixture_construction() {
149        for capacity in [1, 2, 4, 8].map(|value| NonZeroUsize::new(value).unwrap()) {
150            let recipe = StandaloneCanisterRecipe::new(
151                SnapshotRestoreFunding::TopUpTo { minimum_cycles: 1 },
152                build_fixture,
153            );
154            let pool = CachedPocketIcBaselinePool::new(capacity, recipe);
155            assert_eq!(pool.capacity(), capacity);
156        }
157    }
158
159    #[test]
160    fn builder_errors_preserve_the_cause_and_release_the_slot_for_another_attempt() {
161        let recipe = StandaloneCanisterRecipe::new(SnapshotRestoreFunding::Preserve, || {
162            Err(io::Error::new(
163                io::ErrorKind::PermissionDenied,
164                std::fmt::Error,
165            ))
166        });
167        let pool = CachedPocketIcBaselinePool::new(NonZeroUsize::new(1).unwrap(), recipe);
168        for _ in 0..2 {
169            let Err(super::super::BaselinePoolError::Preparation {
170                error:
171                    super::super::BaselinePoolPreparationError::Recipe {
172                        stage: super::super::BaselinePreparationStage::Build,
173                        source,
174                    },
175                timings,
176            }) = pool.acquire()
177            else {
178                panic!("builder failure must return at the build stage");
179            };
180            assert_eq!(source.kind(), io::ErrorKind::PermissionDenied);
181            assert!(source.get_ref().unwrap().is::<std::fmt::Error>());
182            assert!(timings.build().is_some());
183            assert!(timings.restore().is_none());
184            assert!(timings.total() >= timings.build().unwrap());
185        }
186    }
187}