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