Skip to main content

ic_testkit/pic/
lifecycle.rs

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