1use std::{
2 collections::{BTreeMap, BTreeSet},
3 panic::{AssertUnwindSafe, catch_unwind},
4};
5
6use candid::Principal;
7use pocket_ic::{PocketIc, RejectResponse};
8
9use super::transport;
10
11#[derive(Clone, Debug, Eq, PartialEq)]
12struct ControllerSnapshot {
13 snapshot_id: Vec<u8>,
14 sender: Option<Principal>,
15}
16
17#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct ControllerSnapshots(BTreeMap<Principal, ControllerSnapshot>);
20
21#[derive(Clone, Debug, Eq, PartialEq)]
23pub struct SnapshotAttemptFailure {
24 sender: Option<Principal>,
25 response: RejectResponse,
26}
27
28#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct SnapshotCleanupFailure {
31 canister_id: Principal,
32 sender: Option<Principal>,
33 response: Option<Box<RejectResponse>>,
34 panic_message: Option<String>,
35}
36
37#[non_exhaustive]
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub enum SnapshotRestoreFunding {
41 Preserve,
43 TopUpTo {
45 minimum_cycles: u128,
47 },
48}
49
50#[non_exhaustive]
52#[derive(Clone, Debug, Eq, PartialEq)]
53pub enum ControllerSnapshotError {
54 DuplicateCanisterId {
56 canister_id: Principal,
58 },
59 CaptureFailed {
61 canister_id: Principal,
63 attempts: Vec<SnapshotAttemptFailure>,
65 cleanup_failures: Vec<SnapshotCleanupFailure>,
67 },
68 CapturePanicked {
70 canister_id: Principal,
72 message: String,
74 cleanup_failures: Vec<SnapshotCleanupFailure>,
76 },
77 RestoreFailed {
79 canister_id: Principal,
81 attempts: Vec<SnapshotAttemptFailure>,
83 },
84 RestorePanicked {
86 canister_id: Principal,
88 message: String,
90 },
91}
92
93enum SnapshotCaptureFailure {
94 Rejected(Vec<SnapshotAttemptFailure>),
95 Panicked(String),
96}
97
98pub trait PocketIcSnapshotExt {
100 fn capture_controller_snapshots<I>(
106 &self,
107 controller_id: Principal,
108 canister_ids: I,
109 ) -> Result<ControllerSnapshots, ControllerSnapshotError>
110 where
111 I: IntoIterator<Item = Principal>;
112
113 fn restore_controller_snapshots(
118 &self,
119 controller_id: Principal,
120 snapshots: &ControllerSnapshots,
121 ) -> Result<(), ControllerSnapshotError>;
122
123 fn restore_controller_snapshots_with_funding(
128 &self,
129 controller_id: Principal,
130 snapshots: &ControllerSnapshots,
131 funding: SnapshotRestoreFunding,
132 ) -> Result<(), ControllerSnapshotError>;
133}
134
135impl PocketIcSnapshotExt for PocketIc {
136 fn capture_controller_snapshots<I>(
137 &self,
138 controller_id: Principal,
139 canister_ids: I,
140 ) -> Result<ControllerSnapshots, ControllerSnapshotError>
141 where
142 I: IntoIterator<Item = Principal>,
143 {
144 let canister_ids = ordered_unique_canister_ids(canister_ids)?;
145 let mut snapshots = BTreeMap::new();
146
147 for canister_id in canister_ids {
148 match try_take_controller_snapshot(self, controller_id, canister_id) {
149 Ok(snapshot) => {
150 snapshots.insert(canister_id, snapshot);
151 }
152 Err(SnapshotCaptureFailure::Rejected(attempts)) => {
153 let cleanup_failures = cleanup_captured_snapshots(self, &snapshots);
154 return Err(ControllerSnapshotError::CaptureFailed {
155 canister_id,
156 attempts,
157 cleanup_failures,
158 });
159 }
160 Err(SnapshotCaptureFailure::Panicked(message)) => {
161 let cleanup_failures = cleanup_captured_snapshots(self, &snapshots);
162 return Err(ControllerSnapshotError::CapturePanicked {
163 canister_id,
164 message,
165 cleanup_failures,
166 });
167 }
168 }
169 }
170
171 Ok(ControllerSnapshots(snapshots))
172 }
173
174 fn restore_controller_snapshots(
175 &self,
176 controller_id: Principal,
177 snapshots: &ControllerSnapshots,
178 ) -> Result<(), ControllerSnapshotError> {
179 self.restore_controller_snapshots_with_funding(
180 controller_id,
181 snapshots,
182 SnapshotRestoreFunding::Preserve,
183 )
184 }
185
186 fn restore_controller_snapshots_with_funding(
187 &self,
188 controller_id: Principal,
189 snapshots: &ControllerSnapshots,
190 funding: SnapshotRestoreFunding,
191 ) -> Result<(), ControllerSnapshotError> {
192 for (canister_id, snapshot_id, sender) in snapshots.iter() {
193 restore_controller_snapshot(
194 self,
195 controller_id,
196 canister_id,
197 sender,
198 snapshot_id,
199 funding,
200 )?;
201 }
202 Ok(())
203 }
204}
205
206impl ControllerSnapshots {
207 #[must_use]
209 pub fn len(&self) -> usize {
210 self.0.len()
211 }
212
213 #[must_use]
215 pub fn is_empty(&self) -> bool {
216 self.0.is_empty()
217 }
218
219 pub fn canister_ids(&self) -> impl Iterator<Item = Principal> + '_ {
221 self.0.keys().copied()
222 }
223
224 pub(super) fn iter(&self) -> impl Iterator<Item = (Principal, &[u8], Option<Principal>)> + '_ {
225 self.0.iter().map(|(canister_id, snapshot)| {
226 (
227 *canister_id,
228 snapshot.snapshot_id.as_slice(),
229 snapshot.sender,
230 )
231 })
232 }
233}
234
235impl SnapshotAttemptFailure {
236 #[must_use]
238 pub const fn sender(&self) -> Option<Principal> {
239 self.sender
240 }
241
242 #[must_use]
244 pub const fn response(&self) -> &RejectResponse {
245 &self.response
246 }
247}
248
249impl SnapshotCleanupFailure {
250 #[must_use]
252 pub const fn canister_id(&self) -> Principal {
253 self.canister_id
254 }
255
256 #[must_use]
258 pub const fn sender(&self) -> Option<Principal> {
259 self.sender
260 }
261
262 #[must_use]
264 pub fn response(&self) -> Option<&RejectResponse> {
265 self.response.as_deref()
266 }
267
268 #[must_use]
270 pub fn panic_message(&self) -> Option<&str> {
271 self.panic_message.as_deref()
272 }
273}
274
275impl std::fmt::Display for ControllerSnapshotError {
276 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277 match self {
278 Self::DuplicateCanisterId { canister_id } => {
279 write!(f, "duplicate canister id in snapshot set: {canister_id}")
280 }
281 Self::CaptureFailed {
282 canister_id,
283 attempts,
284 cleanup_failures,
285 } => write!(
286 f,
287 "failed to capture snapshot for {canister_id} after {} sender attempts; {} partial snapshots could not be cleaned up",
288 attempts.len(),
289 cleanup_failures.len()
290 ),
291 Self::CapturePanicked {
292 canister_id,
293 message,
294 cleanup_failures,
295 } => write!(
296 f,
297 "snapshot capture panicked for {canister_id}: {message}; {} partial snapshots could not be cleaned up",
298 cleanup_failures.len()
299 ),
300 Self::RestoreFailed {
301 canister_id,
302 attempts,
303 } => write!(
304 f,
305 "failed to restore snapshot for {canister_id} after {} sender attempts",
306 attempts.len()
307 ),
308 Self::RestorePanicked {
309 canister_id,
310 message,
311 } => write!(f, "snapshot restore panicked for {canister_id}: {message}"),
312 }
313 }
314}
315
316impl std::error::Error for ControllerSnapshotError {}
317
318fn ordered_unique_canister_ids<I>(
319 canister_ids: I,
320) -> Result<Vec<Principal>, ControllerSnapshotError>
321where
322 I: IntoIterator<Item = Principal>,
323{
324 let mut unique = BTreeSet::new();
325 for canister_id in canister_ids {
326 if !unique.insert(canister_id) {
327 return Err(ControllerSnapshotError::DuplicateCanisterId { canister_id });
328 }
329 }
330 Ok(unique.into_iter().collect())
331}
332
333fn try_take_controller_snapshot(
334 pocket_ic: &PocketIc,
335 controller_id: Principal,
336 canister_id: Principal,
337) -> Result<ControllerSnapshot, SnapshotCaptureFailure> {
338 let candidates = controller_sender_candidates(controller_id, canister_id);
339 let mut attempts = Vec::new();
340
341 for sender in candidates {
342 let capture = catch_unwind(AssertUnwindSafe(|| {
343 pocket_ic.take_canister_snapshot(canister_id, sender, None)
344 }));
345 match capture {
346 Err(payload) => {
347 return Err(SnapshotCaptureFailure::Panicked(
348 transport::panic_payload_to_string(payload.as_ref()),
349 ));
350 }
351 Ok(snapshot) => match snapshot {
352 Ok(snapshot) => {
353 return Ok(ControllerSnapshot {
354 snapshot_id: snapshot.id,
355 sender,
356 });
357 }
358 Err(response) => attempts.push(SnapshotAttemptFailure { sender, response }),
359 },
360 }
361 }
362
363 Err(SnapshotCaptureFailure::Rejected(attempts))
364}
365
366fn cleanup_captured_snapshots(
367 pocket_ic: &PocketIc,
368 snapshots: &BTreeMap<Principal, ControllerSnapshot>,
369) -> Vec<SnapshotCleanupFailure> {
370 let mut failures = Vec::new();
371 for (canister_id, snapshot) in snapshots {
372 let cleanup = catch_unwind(AssertUnwindSafe(|| {
373 pocket_ic.delete_canister_snapshot(
374 *canister_id,
375 snapshot.sender,
376 snapshot.snapshot_id.clone(),
377 )
378 }));
379 match cleanup {
380 Ok(Ok(())) => {}
381 Ok(Err(response)) => failures.push(SnapshotCleanupFailure {
382 canister_id: *canister_id,
383 sender: snapshot.sender,
384 response: Some(Box::new(response)),
385 panic_message: None,
386 }),
387 Err(payload) => failures.push(SnapshotCleanupFailure {
388 canister_id: *canister_id,
389 sender: snapshot.sender,
390 response: None,
391 panic_message: Some(transport::panic_payload_to_string(payload.as_ref())),
392 }),
393 }
394 }
395 failures
396}
397
398fn restore_controller_snapshot(
399 pocket_ic: &PocketIc,
400 controller_id: Principal,
401 canister_id: Principal,
402 snapshot_sender: Option<Principal>,
403 snapshot_id: &[u8],
404 funding: SnapshotRestoreFunding,
405) -> Result<(), ControllerSnapshotError> {
406 let fallback_sender = if snapshot_sender.is_some() {
407 None
408 } else {
409 Some(controller_id)
410 };
411 let candidates = [snapshot_sender, fallback_sender];
412 let mut attempts = Vec::new();
413
414 for sender in candidates {
415 let restore = catch_unwind(AssertUnwindSafe(|| {
416 apply_snapshot_restore_funding(pocket_ic, canister_id, funding);
417 pocket_ic.load_canister_snapshot(canister_id, sender, snapshot_id.to_vec())
418 }));
419 match restore {
420 Err(payload) => {
421 return Err(ControllerSnapshotError::RestorePanicked {
422 canister_id,
423 message: transport::panic_payload_to_string(payload.as_ref()),
424 });
425 }
426 Ok(Ok(())) => return Ok(()),
427 Ok(Err(response)) => attempts.push(SnapshotAttemptFailure { sender, response }),
428 }
429 }
430
431 Err(ControllerSnapshotError::RestoreFailed {
432 canister_id,
433 attempts,
434 })
435}
436
437fn apply_snapshot_restore_funding(
438 pocket_ic: &PocketIc,
439 canister_id: Principal,
440 funding: SnapshotRestoreFunding,
441) {
442 if funding == SnapshotRestoreFunding::Preserve {
443 return;
444 }
445
446 let balance = pocket_ic.cycle_balance(canister_id);
447 let top_up = snapshot_restore_top_up(balance, funding);
448 if top_up > 0 {
449 let _ = pocket_ic.add_cycles(canister_id, top_up);
450 }
451}
452
453const fn snapshot_restore_top_up(balance: u128, funding: SnapshotRestoreFunding) -> u128 {
454 match funding {
455 SnapshotRestoreFunding::Preserve => 0,
456 SnapshotRestoreFunding::TopUpTo { minimum_cycles } => {
457 minimum_cycles.saturating_sub(balance)
458 }
459 }
460}
461
462fn controller_sender_candidates(
463 controller_id: Principal,
464 canister_id: Principal,
465) -> [Option<Principal>; 2] {
466 if canister_id == controller_id {
467 [None, Some(controller_id)]
468 } else {
469 [Some(controller_id), None]
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use candid::Principal;
476
477 use super::{
478 ControllerSnapshotError, SnapshotRestoreFunding, ordered_unique_canister_ids,
479 snapshot_restore_top_up,
480 };
481
482 #[test]
483 fn duplicate_canister_ids_are_rejected_before_capture() {
484 let canister_id = Principal::from_slice(&[1]);
485 let error = ordered_unique_canister_ids([canister_id, canister_id]).unwrap_err();
486
487 assert_eq!(
488 error,
489 ControllerSnapshotError::DuplicateCanisterId { canister_id }
490 );
491 }
492
493 #[test]
494 fn canister_ids_are_sorted_deterministically() {
495 let first = Principal::from_slice(&[1]);
496 let second = Principal::from_slice(&[2]);
497
498 assert_eq!(
499 ordered_unique_canister_ids([second, first]).unwrap(),
500 vec![first, second]
501 );
502 }
503
504 #[test]
505 fn snapshot_restore_funding_is_explicit() {
506 assert_eq!(
507 snapshot_restore_top_up(10, SnapshotRestoreFunding::Preserve),
508 0
509 );
510 assert_eq!(
511 snapshot_restore_top_up(10, SnapshotRestoreFunding::TopUpTo { minimum_cycles: 25 }),
512 15
513 );
514 assert_eq!(
515 snapshot_restore_top_up(30, SnapshotRestoreFunding::TopUpTo { minimum_cycles: 25 }),
516 0
517 );
518 }
519}