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::{CanisterDiagnosticsRequest, 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    let diagnostic_sender = spec.install_sender.unwrap_or_else(Principal::anonymous);
257    if spec.cycles > 0 {
258        let _ = pocket_ic.add_cycles(canister_id, spec.cycles);
259    }
260
261    let install = catch_unwind(AssertUnwindSafe(|| {
262        pocket_ic.install_canister(canister_id, spec.wasm, spec.init_bytes, spec.install_sender);
263    }));
264    if let Err(payload) = install {
265        let message = transport::panic_payload_to_string(payload.as_ref());
266        let context = if let Some(label) = &spec.label {
267            format!("install_canister trapped ({label})")
268        } else {
269            "install_canister trapped".to_string()
270        };
271        // Diagnostics are best-effort and must never replace the original
272        // structured install failure, including if stderr or PocketIC fails.
273        let _ = catch_unwind(AssertUnwindSafe(|| {
274            let report = pocket_ic.collect_canister_diagnostics(CanisterDiagnosticsRequest::new(
275                canister_id,
276                diagnostic_sender,
277                diagnostic_sender,
278            ));
279            eprintln!("{context}: {report}");
280        }));
281
282        return if let Some(label) = spec.label {
283            Err(CanisterInstallError::labeled(canister_id, label, message))
284        } else {
285            Err(CanisterInstallError::new(canister_id, message))
286        };
287    }
288
289    Ok(canister_id)
290}
291
292fn is_install_code_rate_limited(response: &RejectResponse) -> bool {
293    response.error_code == ErrorCode::CanisterInstallCodeRateLimited
294}
295
296fn retry_install_code_with<T, F, W>(
297    policy: RetryPolicy,
298    mut op: F,
299    mut wait_out_cooldown: W,
300) -> Result<T, RejectResponse>
301where
302    F: FnMut() -> Result<T, RejectResponse>,
303    W: FnMut(),
304{
305    for attempt in 1..=policy.max_attempts() {
306        match op() {
307            Ok(value) => return Ok(value),
308            Err(err) if is_install_code_rate_limited(&err) && attempt < policy.max_attempts() => {
309                wait_out_cooldown();
310            }
311            Err(err) => return Err(err),
312        }
313    }
314
315    unreachable!("RetryPolicy guarantees at least one attempt")
316}
317
318#[cfg(test)]
319mod tests {
320    use std::{cell::Cell, time::Duration};
321
322    use pocket_ic::{ErrorCode, RejectCode, RejectResponse};
323
324    use super::{RetryPolicy, RetryPolicyError, retry_install_code_with};
325
326    fn rejection(error_code: ErrorCode, message: &str) -> RejectResponse {
327        RejectResponse {
328            reject_code: RejectCode::SysTransient,
329            reject_message: message.to_string(),
330            error_code,
331            certified: false,
332        }
333    }
334
335    #[test]
336    fn retry_policy_counts_the_first_attempt() {
337        let attempts = Cell::new(0);
338        let waits = Cell::new(0);
339        let rate_limited = rejection(
340            ErrorCode::CanisterInstallCodeRateLimited,
341            "install-code rate limit",
342        );
343        let result = retry_install_code_with(
344            RetryPolicy::try_new(3, Duration::from_secs(1)).expect("valid retry policy"),
345            || {
346                attempts.set(attempts.get() + 1);
347                Err::<(), _>(rate_limited.clone())
348            },
349            || waits.set(waits.get() + 1),
350        );
351
352        assert_eq!(result, Err(rate_limited));
353        assert_eq!(attempts.get(), 3);
354        assert_eq!(waits.get(), 2);
355    }
356
357    #[test]
358    fn retry_policy_stops_on_non_rate_limit_failure() {
359        let attempts = Cell::new(0);
360        let not_retryable = rejection(ErrorCode::CanisterRejectedMessage, "not retryable");
361        let result = retry_install_code_with(
362            RetryPolicy::try_new(3, Duration::from_secs(1)).expect("valid retry policy"),
363            || {
364                attempts.set(attempts.get() + 1);
365                Err::<(), _>(not_retryable.clone())
366            },
367            || panic!("non-rate-limit failure must not wait"),
368        );
369
370        assert_eq!(result, Err(not_retryable));
371        assert_eq!(attempts.get(), 1);
372    }
373
374    #[test]
375    fn retry_policy_rejects_zero_attempts() {
376        assert_eq!(
377            RetryPolicy::try_new(0, Duration::from_secs(1)),
378            Err(RetryPolicyError::ZeroMaxAttempts)
379        );
380    }
381}