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