Skip to main content

ic_testkit/pic/
lifecycle.rs

1use std::{num::NonZeroUsize, time::Duration};
2
3use crate::pocket_ic::{ClientError, ErrorCode, PocketIc};
4use ic_platform_types::principal::ExternalPrincipal as Principal;
5
6use super::CanisterInstallError;
7
8/// Inputs and diagnostic context for one generic canister installation.
9pub struct InstallSpec {
10    /// Wasm module installed into the newly created canister.
11    pub wasm: Vec<u8>,
12    /// Raw initialization bytes encoded by the caller.
13    pub init_bytes: Vec<u8>,
14    /// Extra cycles added after `PocketIC` creates the canister and before install.
15    pub cycles: u128,
16    /// Optional sender for the management-canister install operation.
17    pub install_sender: Option<Principal>,
18    /// Optional human-readable context included in install errors.
19    pub label: Option<String>,
20}
21
22/// Retry limits and simulated cooldown for install-code operations.
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub struct RetryPolicy {
25    /// Maximum attempts, including the initial call.
26    pub max_attempts: NonZeroUsize,
27    /// Simulated cooldown between retryable attempts.
28    pub cooldown: Duration,
29}
30
31/// Generic canister installation and structured install-code retry policy.
32///
33/// The extension creates canisters using `PocketIC` defaults. `InstallSpec::cycles`
34/// is an additional top-up, not the complete initial balance.
35pub trait CanisterInstallExt {
36    /// Create and install one canister from a reusable specification.
37    ///
38    /// Retains typed client failures during creation, cycle funding, and installation.
39    /// The error identifies the stage and retains an id when creation succeeded.
40    /// Callers choose whether to collect diagnostics; this performs no diagnostic
41    /// requests or error logging.
42    ///
43    /// # Errors
44    /// Returns the failed creation or installation stage, retaining canister identity when available.
45    fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError>;
46
47    /// Advance simulated time once, then tick twice for an install-code cooldown.
48    /// Zero duration skips time reads/writes while retaining both ticks.
49    ///
50    /// # Errors
51    /// Returns the first client failure without dispatching remaining ticks.
52    fn wait_out_install_code_rate_limit(&self, cooldown: Duration) -> Result<(), ClientError>;
53
54    /// Retry only while `PocketIC` reports install-code rate limiting.
55    ///
56    /// `RetryPolicy::max_attempts` includes the initial call. Before each
57    /// retry, this advances simulated time by the configured cooldown and
58    /// executes two `PocketIC` ticks. Other rejections are returned unchanged.
59    ///
60    /// # Errors
61    /// Returns non-rate-limit failures immediately, or the final rate-limit rejection.
62    /// A cooldown failure stops further installation attempts.
63    fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, ClientError>
64    where
65        F: FnMut() -> Result<T, ClientError>;
66}
67
68impl CanisterInstallExt for PocketIc {
69    /// Install one arbitrary wasm module from a generic install specification.
70    fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError> {
71        let canister_id =
72            self.create_canister()
73                .map_err(|source| CanisterInstallError::CreateCanister {
74                    label: spec.label.clone(),
75                    source,
76                })?;
77        if spec.cycles > 0 {
78            self.add_cycles(canister_id, spec.cycles)
79                .map_err(|source| CanisterInstallError::AddCycles {
80                    canister_id,
81                    label: spec.label.clone(),
82                    source,
83                })?;
84        }
85
86        // Move the Wasm and init payload while retaining the label for failures.
87        self.install_canister(canister_id, spec.wasm, spec.init_bytes, spec.install_sender)
88            .map_err(|source| CanisterInstallError::InstallCode {
89                canister_id,
90                label: spec.label,
91                source,
92            })?;
93
94        Ok(canister_id)
95    }
96
97    /// Wait out the `PocketIC` `install_code` cooldown window inside the same instance.
98    fn wait_out_install_code_rate_limit(&self, cooldown: Duration) -> Result<(), ClientError> {
99        super::time::advance_and_tick(self, cooldown, 2)
100    }
101
102    fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, ClientError>
103    where
104        F: FnMut() -> Result<T, ClientError>,
105    {
106        retry_install_code_with(policy, op, || {
107            self.wait_out_install_code_rate_limit(policy.cooldown)
108        })
109    }
110}
111
112fn retry_install_code_with<T, F, W>(
113    policy: RetryPolicy,
114    mut op: F,
115    mut wait_out_cooldown: W,
116) -> Result<T, ClientError>
117where
118    F: FnMut() -> Result<T, ClientError>,
119    W: FnMut() -> Result<(), ClientError>,
120{
121    let mut retries = 1..policy.max_attempts.get();
122    loop {
123        match op() {
124            Err(ClientError::Canister(err))
125                if err.error_code == ErrorCode::CanisterInstallCodeRateLimited
126                    && retries.next().is_some() =>
127            {
128                wait_out_cooldown()?;
129            }
130            result => return result,
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests;