Skip to main content

ic_testkit/pic/
lifecycle.rs

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