Skip to main content

ic_testkit/pic/
lifecycle.rs

1use std::panic::{AssertUnwindSafe, catch_unwind};
2use std::time::Duration;
3
4use candid::Principal;
5use pocket_ic::{ErrorCode, PocketIc, RejectResponse};
6
7use super::{CanisterInstallError, PocketIcDiagnosticsExt, transport};
8
9///
10/// InstallSpec
11///
12
13#[non_exhaustive]
14pub struct InstallSpec {
15    pub wasm: Vec<u8>,
16    pub init_bytes: Vec<u8>,
17    pub cycles: u128,
18    pub install_sender: Option<Principal>,
19    pub label: Option<String>,
20}
21
22impl InstallSpec {
23    /// Build one generic canister install specification.
24    #[must_use]
25    pub const fn new(wasm: Vec<u8>, init_bytes: Vec<u8>, cycles: u128) -> Self {
26        Self {
27            wasm,
28            init_bytes,
29            cycles,
30            install_sender: None,
31            label: None,
32        }
33    }
34
35    /// Set the management-call sender used for `install_canister`.
36    #[must_use]
37    pub const fn install_sender(mut self, sender: Principal) -> Self {
38        self.install_sender = Some(sender);
39        self
40    }
41
42    /// Set a diagnostic label for install failures.
43    #[must_use]
44    pub fn label(mut self, label: impl Into<String>) -> Self {
45        self.label = Some(label.into());
46        self
47    }
48}
49
50/// Retry limits and simulated cooldown for install-code operations.
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52pub struct RetryPolicy {
53    max_attempts: usize,
54    cooldown: Duration,
55}
56
57/// Invalid install-code retry policy configuration.
58#[non_exhaustive]
59#[derive(Clone, Copy, Debug, Eq, PartialEq)]
60pub enum RetryPolicyError {
61    /// A retry policy must execute its operation at least once.
62    ZeroMaxAttempts,
63}
64
65impl std::fmt::Display for RetryPolicyError {
66    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match self {
68            Self::ZeroMaxAttempts => {
69                formatter.write_str("retry policy requires at least one attempt")
70            }
71        }
72    }
73}
74
75impl std::error::Error for RetryPolicyError {}
76
77impl RetryPolicy {
78    /// Create a policy with an exact, non-zero maximum attempt count.
79    pub const fn try_new(
80        max_attempts: usize,
81        cooldown: Duration,
82    ) -> Result<Self, RetryPolicyError> {
83        if max_attempts == 0 {
84            Err(RetryPolicyError::ZeroMaxAttempts)
85        } else {
86            Ok(Self {
87                max_attempts,
88                cooldown,
89            })
90        }
91    }
92
93    /// Read the maximum number of operation attempts, including the first.
94    #[must_use]
95    pub const fn max_attempts(self) -> usize {
96        self.max_attempts
97    }
98
99    /// Read the simulated cooldown applied between rate-limited attempts.
100    #[must_use]
101    pub const fn cooldown(self) -> Duration {
102        self.cooldown
103    }
104}
105
106/// Generic canister installation and install-code retry policies.
107pub trait CanisterInstallExt {
108    /// Create and install one canister from raw wasm and init bytes.
109    #[must_use]
110    fn create_and_install_with_args(
111        &self,
112        wasm: Vec<u8>,
113        init_bytes: Vec<u8>,
114        install_cycles: u128,
115    ) -> Principal;
116
117    /// Fallible counterpart to [`create_and_install_with_args`](Self::create_and_install_with_args).
118    fn try_create_and_install_with_args(
119        &self,
120        wasm: Vec<u8>,
121        init_bytes: Vec<u8>,
122        install_cycles: u128,
123    ) -> Result<Principal, CanisterInstallError>;
124
125    /// Create and install one canister from a reusable specification.
126    #[must_use]
127    fn create_and_install(&self, spec: InstallSpec) -> Principal;
128
129    /// Fallible counterpart to [`create_and_install`](Self::create_and_install).
130    fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError>;
131
132    /// Sequentially create and install multiple canisters.
133    #[must_use]
134    fn create_and_install_many<I>(&self, specs: I) -> Vec<Principal>
135    where
136        I: IntoIterator<Item = InstallSpec>;
137
138    /// Fallible counterpart to [`create_and_install_many`](Self::create_and_install_many).
139    fn try_create_and_install_many<I>(
140        &self,
141        specs: I,
142    ) -> Result<Vec<Principal>, CanisterInstallError>
143    where
144        I: IntoIterator<Item = InstallSpec>;
145
146    /// Advance simulated time and rounds past an install-code cooldown.
147    fn wait_out_install_code_rate_limit(&self, cooldown: Duration);
148
149    /// Retry an operation only while PocketIC reports install-code rate limiting.
150    fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
151    where
152        F: FnMut() -> Result<T, RejectResponse>;
153}
154
155impl CanisterInstallExt for PocketIc {
156    /// Install one arbitrary wasm module with caller-provided init bytes.
157    ///
158    /// This is the generic install path for downstreams that use `ic-testkit`
159    /// without depending on application-specific init payload conventions.
160    fn create_and_install_with_args(
161        &self,
162        wasm: Vec<u8>,
163        init_bytes: Vec<u8>,
164        install_cycles: u128,
165    ) -> Principal {
166        self.try_create_and_install_with_args(wasm, init_bytes, install_cycles)
167            .unwrap_or_else(|err| panic!("{err}"))
168    }
169
170    /// Install one arbitrary wasm module with caller-provided init bytes.
171    fn try_create_and_install_with_args(
172        &self,
173        wasm: Vec<u8>,
174        init_bytes: Vec<u8>,
175        install_cycles: u128,
176    ) -> Result<Principal, CanisterInstallError> {
177        self.try_create_and_install(InstallSpec::new(wasm, init_bytes, install_cycles))
178    }
179
180    /// Install one arbitrary wasm module from a generic install specification.
181    fn create_and_install(&self, spec: InstallSpec) -> Principal {
182        self.try_create_and_install(spec)
183            .unwrap_or_else(|err| panic!("{err}"))
184    }
185
186    /// Install one arbitrary wasm module from a generic install specification.
187    fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError> {
188        try_create_funded_and_install(self, spec)
189    }
190
191    /// Sequentially install multiple arbitrary wasm modules into this PocketIC instance.
192    ///
193    /// Installs are attempted in iterator order. If one install fails, earlier
194    /// installs remain in the PocketIC instance, the failed canister may exist
195    /// with the id exposed by `CanisterInstallError::canister_id()`, and later
196    /// installs are not attempted.
197    fn create_and_install_many<I>(&self, specs: I) -> Vec<Principal>
198    where
199        I: IntoIterator<Item = InstallSpec>,
200    {
201        self.try_create_and_install_many(specs)
202            .unwrap_or_else(|err| panic!("{err}"))
203    }
204
205    /// Sequentially install multiple arbitrary wasm modules into this PocketIC instance.
206    ///
207    /// Installs are attempted in iterator order. If one install fails, earlier
208    /// installs remain in the PocketIC instance, the failed canister may exist
209    /// with the id exposed by `CanisterInstallError::canister_id()`, and later
210    /// installs are not attempted.
211    fn try_create_and_install_many<I>(
212        &self,
213        specs: I,
214    ) -> Result<Vec<Principal>, CanisterInstallError>
215    where
216        I: IntoIterator<Item = InstallSpec>,
217    {
218        specs
219            .into_iter()
220            .map(|spec| self.try_create_and_install(spec))
221            .collect()
222    }
223
224    /// Wait out the PocketIC `install_code` cooldown window inside the same instance.
225    fn wait_out_install_code_rate_limit(&self, cooldown: Duration) {
226        self.advance_time(cooldown);
227        self.tick();
228        self.tick();
229    }
230
231    fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
232    where
233        F: FnMut() -> Result<T, RejectResponse>,
234    {
235        retry_install_code_with(policy, op, || {
236            self.wait_out_install_code_rate_limit(policy.cooldown());
237        })
238    }
239}
240
241// Install a canister after creating it and optionally adding extra cycles.
242fn try_create_funded_and_install(
243    pocket_ic: &PocketIc,
244    spec: InstallSpec,
245) -> Result<Principal, CanisterInstallError> {
246    let canister_id = pocket_ic.create_canister();
247    if spec.cycles > 0 {
248        let _ = pocket_ic.add_cycles(canister_id, spec.cycles);
249    }
250
251    let install = catch_unwind(AssertUnwindSafe(|| {
252        pocket_ic.install_canister(canister_id, spec.wasm, spec.init_bytes, spec.install_sender);
253    }));
254    if let Err(payload) = install {
255        let message = transport::panic_payload_to_string(payload.as_ref());
256        let context = if let Some(label) = &spec.label {
257            format!("install_canister trapped ({label})")
258        } else {
259            "install_canister trapped".to_string()
260        };
261        // Diagnostics are best-effort and must never replace the original
262        // structured install failure, including if stderr or PocketIC fails.
263        let _ = catch_unwind(AssertUnwindSafe(|| {
264            pocket_ic.dump_canister_debug(canister_id, &context);
265        }));
266
267        return if let Some(label) = spec.label {
268            Err(CanisterInstallError::labeled(canister_id, label, message))
269        } else {
270            Err(CanisterInstallError::new(canister_id, message))
271        };
272    }
273
274    Ok(canister_id)
275}
276
277fn is_install_code_rate_limited(response: &RejectResponse) -> bool {
278    response.error_code == ErrorCode::CanisterInstallCodeRateLimited
279}
280
281fn retry_install_code_with<T, F, W>(
282    policy: RetryPolicy,
283    mut op: F,
284    mut wait_out_cooldown: W,
285) -> Result<T, RejectResponse>
286where
287    F: FnMut() -> Result<T, RejectResponse>,
288    W: FnMut(),
289{
290    for attempt in 1..=policy.max_attempts() {
291        match op() {
292            Ok(value) => return Ok(value),
293            Err(err) if is_install_code_rate_limited(&err) && attempt < policy.max_attempts() => {
294                wait_out_cooldown();
295            }
296            Err(err) => return Err(err),
297        }
298    }
299
300    unreachable!("RetryPolicy guarantees at least one attempt")
301}
302
303#[cfg(test)]
304mod tests {
305    use std::{cell::Cell, time::Duration};
306
307    use pocket_ic::{ErrorCode, RejectCode, RejectResponse};
308
309    use super::{RetryPolicy, RetryPolicyError, retry_install_code_with};
310
311    fn rejection(error_code: ErrorCode, message: &str) -> RejectResponse {
312        RejectResponse {
313            reject_code: RejectCode::SysTransient,
314            reject_message: message.to_string(),
315            error_code,
316            certified: false,
317        }
318    }
319
320    #[test]
321    fn retry_policy_counts_the_first_attempt() {
322        let attempts = Cell::new(0);
323        let waits = Cell::new(0);
324        let rate_limited = rejection(
325            ErrorCode::CanisterInstallCodeRateLimited,
326            "install-code rate limit",
327        );
328        let result = retry_install_code_with(
329            RetryPolicy::try_new(3, Duration::from_secs(1)).expect("valid retry policy"),
330            || {
331                attempts.set(attempts.get() + 1);
332                Err::<(), _>(rate_limited.clone())
333            },
334            || waits.set(waits.get() + 1),
335        );
336
337        assert_eq!(result, Err(rate_limited));
338        assert_eq!(attempts.get(), 3);
339        assert_eq!(waits.get(), 2);
340    }
341
342    #[test]
343    fn retry_policy_stops_on_non_rate_limit_failure() {
344        let attempts = Cell::new(0);
345        let not_retryable = rejection(ErrorCode::CanisterRejectedMessage, "not retryable");
346        let result = retry_install_code_with(
347            RetryPolicy::try_new(3, Duration::from_secs(1)).expect("valid retry policy"),
348            || {
349                attempts.set(attempts.get() + 1);
350                Err::<(), _>(not_retryable.clone())
351            },
352            || panic!("non-rate-limit failure must not wait"),
353        );
354
355        assert_eq!(result, Err(not_retryable));
356        assert_eq!(attempts.get(), 1);
357    }
358
359    #[test]
360    fn retry_policy_rejects_zero_attempts() {
361        assert_eq!(
362            RetryPolicy::try_new(0, Duration::from_secs(1)),
363            Err(RetryPolicyError::ZeroMaxAttempts)
364        );
365    }
366}