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 once, then tick twice for an install-code cooldown.
104    /// Zero duration skips time reads/writes while retaining both ticks.
105    fn wait_out_install_code_rate_limit(&self, cooldown: Duration);
106
107    /// Retry only while PocketIC reports install-code rate limiting.
108    ///
109    /// `RetryPolicy::max_attempts` includes the initial call. Before each
110    /// retry, this advances simulated time by the configured cooldown and
111    /// executes two PocketIC ticks. Other rejections are returned unchanged.
112    fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
113    where
114        F: FnMut() -> Result<T, RejectResponse>;
115}
116
117impl CanisterInstallExt for PocketIc {
118    /// Install one arbitrary wasm module from a generic install specification.
119    fn try_create_and_install(&self, spec: InstallSpec) -> Result<Principal, CanisterInstallError> {
120        let canister_id =
121            try_install_step(CanisterInstallPhase::CreateCanister, None, &spec, || {
122                self.create_canister()
123            })?;
124        let diagnostic_sender = spec.install_sender.unwrap_or_else(Principal::anonymous);
125        if spec.cycles > 0 {
126            try_install_step(
127                CanisterInstallPhase::AddCycles,
128                Some(canister_id),
129                &spec,
130                || self.add_cycles(canister_id, spec.cycles),
131            )?;
132        }
133
134        // Move the Wasm and init payload while retaining the label for failures.
135        let install = capture_operation(|| {
136            self.install_canister(canister_id, spec.wasm, spec.init_bytes, spec.install_sender);
137        });
138        if let Err(source) = install {
139            let context = if let Some(label) = &spec.label {
140                format!("install_canister trapped ({label})")
141            } else {
142                "install_canister trapped".to_string()
143            };
144            // Diagnostics are best-effort and must never replace the original
145            // structured install failure, including if stderr or PocketIC fails.
146            let _ = capture_operation(|| {
147                let report = self.collect_canister_diagnostics(CanisterDiagnosticsRequest::new(
148                    canister_id,
149                    diagnostic_sender,
150                    diagnostic_sender,
151                ));
152                eprintln!("{context}: {report}");
153            });
154
155            return Err(CanisterInstallError::new(
156                CanisterInstallPhase::InstallCode,
157                Some(canister_id),
158                spec.label,
159                source,
160            ));
161        }
162
163        Ok(canister_id)
164    }
165
166    /// Sequentially install multiple arbitrary wasm modules into this PocketIC instance.
167    ///
168    /// Installs are attempted in iterator order. If one install fails, earlier
169    /// installs remain in the PocketIC instance, the failed canister may exist
170    /// with the id exposed by `CanisterInstallError::canister_id()`, and later
171    /// installs are not attempted.
172    fn try_create_and_install_many<I>(
173        &self,
174        specs: I,
175    ) -> Result<Vec<Principal>, CanisterInstallError>
176    where
177        I: IntoIterator<Item = InstallSpec>,
178    {
179        specs
180            .into_iter()
181            .map(|spec| self.try_create_and_install(spec))
182            .collect()
183    }
184
185    /// Wait out the PocketIC `install_code` cooldown window inside the same instance.
186    fn wait_out_install_code_rate_limit(&self, cooldown: Duration) {
187        super::time::advance_and_tick(self, cooldown, 2);
188    }
189
190    fn retry_install_code<T, F>(&self, policy: RetryPolicy, op: F) -> Result<T, RejectResponse>
191    where
192        F: FnMut() -> Result<T, RejectResponse>,
193    {
194        retry_install_code_with(policy, op, || {
195            self.wait_out_install_code_rate_limit(policy.cooldown());
196        })
197    }
198}
199
200fn try_install_step<T>(
201    phase: CanisterInstallPhase,
202    canister_id: Option<Principal>,
203    spec: &InstallSpec,
204    operation: impl FnOnce() -> T,
205) -> Result<T, CanisterInstallError> {
206    capture_operation(operation)
207        .map_err(|source| CanisterInstallError::new(phase, canister_id, spec.label.clone(), source))
208}
209
210fn retry_install_code_with<T, F, W>(
211    policy: RetryPolicy,
212    mut op: F,
213    mut wait_out_cooldown: W,
214) -> Result<T, RejectResponse>
215where
216    F: FnMut() -> Result<T, RejectResponse>,
217    W: FnMut(),
218{
219    let mut retries = 1..policy.max_attempts().get();
220    loop {
221        match op() {
222            Err(err)
223                if err.error_code == ErrorCode::CanisterInstallCodeRateLimited
224                    && retries.next().is_some() =>
225            {
226                wait_out_cooldown();
227            }
228            result => return result,
229        }
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use std::{
236        cell::{Cell, RefCell},
237        num::NonZeroUsize,
238        time::Duration,
239    };
240
241    use pocket_ic::{ErrorCode, RejectCode, RejectResponse};
242
243    use super::{RetryPolicy, retry_install_code_with};
244
245    fn rejection(error_code: ErrorCode, message: &str) -> RejectResponse {
246        RejectResponse {
247            reject_code: RejectCode::SysTransient,
248            reject_message: message.to_string(),
249            error_code,
250            certified: false,
251        }
252    }
253
254    #[test]
255    fn retry_policy_counts_the_first_attempt() {
256        for max_attempts in [1, 3] {
257            let attempts = Cell::new(0);
258            let waits = Cell::new(0);
259            let result = retry_install_code_with(
260                RetryPolicy::new(
261                    NonZeroUsize::new(max_attempts).unwrap(),
262                    Duration::from_secs(1),
263                ),
264                || {
265                    attempts.set(attempts.get() + 1);
266                    Err::<(), _>(rejection(
267                        ErrorCode::CanisterInstallCodeRateLimited,
268                        &format!("rate limit on attempt {}", attempts.get()),
269                    ))
270                },
271                || waits.set(waits.get() + 1),
272            );
273
274            assert_eq!(
275                result,
276                Err(rejection(
277                    ErrorCode::CanisterInstallCodeRateLimited,
278                    &format!("rate limit on attempt {max_attempts}"),
279                ))
280            );
281            assert_eq!(attempts.get(), max_attempts);
282            assert_eq!(waits.get(), max_attempts - 1);
283        }
284    }
285
286    #[test]
287    fn retry_policy_waits_only_between_retryable_attempts() {
288        for success_attempt in [1, 3] {
289            let events = RefCell::new(Vec::new());
290            let attempts = Cell::new(0);
291            let result = retry_install_code_with(
292                RetryPolicy::new(NonZeroUsize::new(3).unwrap(), Duration::from_secs(1)),
293                || {
294                    events.borrow_mut().push("attempt");
295                    attempts.set(attempts.get() + 1);
296                    if attempts.get() == success_attempt {
297                        Ok(42)
298                    } else {
299                        Err(rejection(
300                            ErrorCode::CanisterInstallCodeRateLimited,
301                            "rate limit",
302                        ))
303                    }
304                },
305                || events.borrow_mut().push("wait"),
306            );
307            assert_eq!(result, Ok(42));
308            assert_eq!(attempts.get(), success_attempt);
309            assert_eq!(
310                events.into_inner(),
311                if success_attempt == 1 {
312                    vec!["attempt"]
313                } else {
314                    vec!["attempt", "wait", "attempt", "wait", "attempt"]
315                }
316            );
317        }
318    }
319
320    #[test]
321    fn retry_policy_stops_on_non_rate_limit_failure() {
322        let attempts = Cell::new(0);
323        let not_retryable = rejection(ErrorCode::CanisterRejectedMessage, "not retryable");
324        let result = retry_install_code_with(
325            RetryPolicy::new(NonZeroUsize::new(3).unwrap(), Duration::from_secs(1)),
326            || {
327                attempts.set(attempts.get() + 1);
328                Err::<(), _>(not_retryable.clone())
329            },
330            || panic!("non-rate-limit failure must not wait"),
331        );
332
333        assert_eq!(result, Err(not_retryable));
334        assert_eq!(attempts.get(), 1);
335    }
336}