1use std::{
2 collections::BTreeMap,
3 panic::{AssertUnwindSafe, catch_unwind},
4};
5
6use candid::Principal;
7use pocket_ic::{PocketIc, RejectResponse};
8
9use super::{PocketIcOperationError, 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, Copy, Debug, Eq, PartialEq)]
23pub struct CanisterSnapshotTarget {
24 canister_id: Principal,
25 sender: Option<Principal>,
26}
27
28#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct SnapshotAttemptFailure {
31 sender: Option<Principal>,
32 response: RejectResponse,
33}
34
35#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct SnapshotCleanupFailure {
38 canister_id: Principal,
39 sender: Option<Principal>,
40 response: Option<Box<RejectResponse>>,
41 panic_message: Option<String>,
42}
43
44#[non_exhaustive]
46#[derive(Clone, Copy, Debug, Eq, PartialEq)]
47pub enum SnapshotRestoreFunding {
48 Preserve,
50 TopUpTo {
52 minimum_cycles: u128,
54 },
55}
56
57#[non_exhaustive]
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub enum ControllerSnapshotError {
61 DuplicateCanisterId {
63 canister_id: Principal,
65 },
66 CaptureFailed {
68 canister_id: Principal,
70 attempts: Vec<SnapshotAttemptFailure>,
72 cleanup_failures: Vec<SnapshotCleanupFailure>,
74 },
75 CapturePanicked {
77 canister_id: Principal,
79 source: PocketIcOperationError,
81 cleanup_failures: Vec<SnapshotCleanupFailure>,
83 },
84 RestoreFailed {
86 canister_id: Principal,
88 attempts: Vec<SnapshotAttemptFailure>,
90 },
91 RestorePanicked {
93 canister_id: Principal,
95 source: PocketIcOperationError,
97 },
98}
99
100enum SnapshotCaptureFailure {
101 Rejected(Vec<SnapshotAttemptFailure>),
102 Panicked(PocketIcOperationError),
103}
104
105pub trait PocketIcSnapshotExt {
107 fn capture_snapshots_with_senders<I>(
113 &self,
114 targets: I,
115 ) -> Result<ControllerSnapshots, ControllerSnapshotError>
116 where
117 I: IntoIterator<Item = CanisterSnapshotTarget>;
118
119 fn capture_controller_snapshots<I>(
125 &self,
126 controller_id: Principal,
127 canister_ids: I,
128 ) -> Result<ControllerSnapshots, ControllerSnapshotError>
129 where
130 I: IntoIterator<Item = Principal>;
131
132 fn restore_controller_snapshots(
137 &self,
138 controller_id: Principal,
139 snapshots: &ControllerSnapshots,
140 ) -> Result<(), ControllerSnapshotError>;
141
142 fn restore_controller_snapshots_with_funding(
147 &self,
148 controller_id: Principal,
149 snapshots: &ControllerSnapshots,
150 funding: SnapshotRestoreFunding,
151 ) -> Result<(), ControllerSnapshotError>;
152
153 fn restore_snapshots_with_captured_senders(
159 &self,
160 snapshots: &ControllerSnapshots,
161 ) -> Result<(), ControllerSnapshotError>;
162
163 fn restore_snapshots_with_captured_senders_and_funding(
165 &self,
166 snapshots: &ControllerSnapshots,
167 funding: SnapshotRestoreFunding,
168 ) -> Result<(), ControllerSnapshotError>;
169}
170
171impl PocketIcSnapshotExt for PocketIc {
172 fn capture_snapshots_with_senders<I>(
173 &self,
174 targets: I,
175 ) -> Result<ControllerSnapshots, ControllerSnapshotError>
176 where
177 I: IntoIterator<Item = CanisterSnapshotTarget>,
178 {
179 capture_snapshot_set(
180 self,
181 targets
182 .into_iter()
183 .map(|target| (target.canister_id, std::iter::once(target.sender))),
184 )
185 }
186
187 fn capture_controller_snapshots<I>(
188 &self,
189 controller_id: Principal,
190 canister_ids: I,
191 ) -> Result<ControllerSnapshots, ControllerSnapshotError>
192 where
193 I: IntoIterator<Item = Principal>,
194 {
195 capture_snapshot_set(
196 self,
197 canister_ids.into_iter().map(|canister_id| {
198 (
199 canister_id,
200 controller_sender_candidates(controller_id, canister_id),
201 )
202 }),
203 )
204 }
205
206 fn restore_controller_snapshots(
207 &self,
208 controller_id: Principal,
209 snapshots: &ControllerSnapshots,
210 ) -> Result<(), ControllerSnapshotError> {
211 self.restore_controller_snapshots_with_funding(
212 controller_id,
213 snapshots,
214 SnapshotRestoreFunding::Preserve,
215 )
216 }
217
218 fn restore_controller_snapshots_with_funding(
219 &self,
220 controller_id: Principal,
221 snapshots: &ControllerSnapshots,
222 funding: SnapshotRestoreFunding,
223 ) -> Result<(), ControllerSnapshotError> {
224 for (canister_id, snapshot_id, sender) in snapshots.iter() {
225 restore_controller_snapshot(
226 self,
227 canister_id,
228 snapshot_id,
229 funding,
230 [
231 sender,
232 if sender.is_some() {
233 None
234 } else {
235 Some(controller_id)
236 },
237 ],
238 )?;
239 }
240 Ok(())
241 }
242
243 fn restore_snapshots_with_captured_senders(
244 &self,
245 snapshots: &ControllerSnapshots,
246 ) -> Result<(), ControllerSnapshotError> {
247 self.restore_snapshots_with_captured_senders_and_funding(
248 snapshots,
249 SnapshotRestoreFunding::Preserve,
250 )
251 }
252
253 fn restore_snapshots_with_captured_senders_and_funding(
254 &self,
255 snapshots: &ControllerSnapshots,
256 funding: SnapshotRestoreFunding,
257 ) -> Result<(), ControllerSnapshotError> {
258 for (canister_id, snapshot_id, sender) in snapshots.iter() {
259 restore_controller_snapshot(
260 self,
261 canister_id,
262 snapshot_id,
263 funding,
264 std::iter::once(sender),
265 )?;
266 }
267 Ok(())
268 }
269}
270
271impl CanisterSnapshotTarget {
272 #[must_use]
274 pub const fn new(canister_id: Principal, sender: Option<Principal>) -> Self {
275 Self {
276 canister_id,
277 sender,
278 }
279 }
280
281 #[must_use]
283 pub const fn canister_id(self) -> Principal {
284 self.canister_id
285 }
286
287 #[must_use]
289 pub const fn sender(self) -> Option<Principal> {
290 self.sender
291 }
292}
293
294fn capture_snapshot_set<I, S>(
295 pocket_ic: &PocketIc,
296 targets: I,
297) -> Result<ControllerSnapshots, ControllerSnapshotError>
298where
299 I: IntoIterator<Item = (Principal, S)>,
300 S: IntoIterator<Item = Option<Principal>>,
301{
302 let mut ordered_targets = BTreeMap::new();
305 for (canister_id, senders) in targets {
306 if ordered_targets.insert(canister_id, senders).is_some() {
307 return Err(ControllerSnapshotError::DuplicateCanisterId { canister_id });
308 }
309 }
310 let mut snapshots = BTreeMap::new();
311 for (canister_id, senders) in ordered_targets {
312 let failure = match try_take_snapshot(pocket_ic, canister_id, senders) {
313 Ok(snapshot) => {
314 snapshots.insert(canister_id, snapshot);
315 continue;
316 }
317 Err(failure) => failure,
318 };
319 let cleanup_failures = cleanup_captured_snapshots(pocket_ic, snapshots);
320 return Err(match failure {
321 SnapshotCaptureFailure::Rejected(attempts) => ControllerSnapshotError::CaptureFailed {
322 canister_id,
323 attempts,
324 cleanup_failures,
325 },
326 SnapshotCaptureFailure::Panicked(source) => ControllerSnapshotError::CapturePanicked {
327 canister_id,
328 source,
329 cleanup_failures,
330 },
331 });
332 }
333 Ok(ControllerSnapshots(snapshots))
334}
335
336impl ControllerSnapshots {
337 #[must_use]
339 pub fn len(&self) -> usize {
340 self.0.len()
341 }
342
343 #[must_use]
345 pub fn is_empty(&self) -> bool {
346 self.0.is_empty()
347 }
348
349 pub fn canister_ids(&self) -> impl Iterator<Item = Principal> + '_ {
351 self.0.keys().copied()
352 }
353
354 pub(super) fn iter(&self) -> impl Iterator<Item = (Principal, &[u8], Option<Principal>)> + '_ {
355 self.0.iter().map(|(canister_id, snapshot)| {
356 (
357 *canister_id,
358 snapshot.snapshot_id.as_slice(),
359 snapshot.sender,
360 )
361 })
362 }
363}
364
365impl SnapshotAttemptFailure {
366 #[must_use]
368 pub const fn sender(&self) -> Option<Principal> {
369 self.sender
370 }
371
372 #[must_use]
374 pub const fn response(&self) -> &RejectResponse {
375 &self.response
376 }
377}
378
379impl SnapshotCleanupFailure {
380 #[must_use]
382 pub const fn canister_id(&self) -> Principal {
383 self.canister_id
384 }
385
386 #[must_use]
388 pub const fn sender(&self) -> Option<Principal> {
389 self.sender
390 }
391
392 #[must_use]
394 pub fn response(&self) -> Option<&RejectResponse> {
395 self.response.as_deref()
396 }
397
398 #[must_use]
400 pub fn panic_message(&self) -> Option<&str> {
401 self.panic_message.as_deref()
402 }
403}
404
405impl std::fmt::Display for ControllerSnapshotError {
406 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407 match self {
408 Self::DuplicateCanisterId { canister_id } => {
409 write!(f, "duplicate canister id in snapshot set: {canister_id}")
410 }
411 Self::CaptureFailed {
412 canister_id,
413 attempts,
414 cleanup_failures,
415 } => write!(
416 f,
417 "failed to capture snapshot for {canister_id} after {} sender attempts; {} partial snapshots could not be cleaned up",
418 attempts.len(),
419 cleanup_failures.len()
420 ),
421 Self::CapturePanicked {
422 canister_id,
423 source,
424 cleanup_failures,
425 } => write!(
426 f,
427 "snapshot capture panicked for {canister_id}: {source}; {} partial snapshots could not be cleaned up",
428 cleanup_failures.len()
429 ),
430 Self::RestoreFailed {
431 canister_id,
432 attempts,
433 } => write!(
434 f,
435 "failed to restore snapshot for {canister_id} after {} sender attempts",
436 attempts.len()
437 ),
438 Self::RestorePanicked {
439 canister_id,
440 source,
441 } => write!(f, "snapshot restore panicked for {canister_id}: {source}"),
442 }
443 }
444}
445
446impl std::error::Error for ControllerSnapshotError {
447 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
448 match self {
449 Self::CapturePanicked { source, .. } | Self::RestorePanicked { source, .. } => {
450 Some(source)
451 }
452 _ => None,
453 }
454 }
455}
456
457fn try_take_snapshot(
458 pocket_ic: &PocketIc,
459 canister_id: Principal,
460 candidates: impl IntoIterator<Item = Option<Principal>>,
461) -> Result<ControllerSnapshot, SnapshotCaptureFailure> {
462 let mut attempts = Vec::new();
463
464 for sender in candidates {
465 let capture = catch_unwind(AssertUnwindSafe(|| {
466 pocket_ic.take_canister_snapshot(canister_id, sender, None)
467 }));
468 match capture {
469 Err(payload) => {
470 return Err(SnapshotCaptureFailure::Panicked(
471 PocketIcOperationError::from_panic(payload.as_ref()),
472 ));
473 }
474 Ok(Ok(snapshot)) => {
475 return Ok(ControllerSnapshot {
476 snapshot_id: snapshot.id,
477 sender,
478 });
479 }
480 Ok(Err(response)) => attempts.push(SnapshotAttemptFailure { sender, response }),
481 }
482 }
483
484 Err(SnapshotCaptureFailure::Rejected(attempts))
485}
486
487fn cleanup_captured_snapshots(
488 pocket_ic: &PocketIc,
489 snapshots: BTreeMap<Principal, ControllerSnapshot>,
490) -> Vec<SnapshotCleanupFailure> {
491 let mut failures = Vec::new();
492 for (canister_id, snapshot) in snapshots {
493 let cleanup = catch_unwind(AssertUnwindSafe(|| {
494 pocket_ic.delete_canister_snapshot(canister_id, snapshot.sender, snapshot.snapshot_id)
495 }));
496 match cleanup {
497 Ok(Ok(())) => {}
498 Ok(Err(response)) => failures.push(SnapshotCleanupFailure {
499 canister_id,
500 sender: snapshot.sender,
501 response: Some(Box::new(response)),
502 panic_message: None,
503 }),
504 Err(payload) => failures.push(SnapshotCleanupFailure {
505 canister_id,
506 sender: snapshot.sender,
507 response: None,
508 panic_message: Some(transport::panic_payload_to_string(payload.as_ref())),
509 }),
510 }
511 }
512 failures
513}
514
515fn restore_controller_snapshot(
516 pocket_ic: &PocketIc,
517 canister_id: Principal,
518 snapshot_id: &[u8],
519 funding: SnapshotRestoreFunding,
520 candidates: impl IntoIterator<Item = Option<Principal>>,
521) -> Result<(), ControllerSnapshotError> {
522 let mut attempts = Vec::new();
523
524 for sender in candidates {
525 let restore = catch_unwind(AssertUnwindSafe(|| {
526 apply_snapshot_restore_funding(pocket_ic, canister_id, funding);
527 pocket_ic.load_canister_snapshot(canister_id, sender, snapshot_id.to_vec())
528 }));
529 match restore {
530 Err(payload) => {
531 return Err(ControllerSnapshotError::RestorePanicked {
532 canister_id,
533 source: PocketIcOperationError::from_panic(payload.as_ref()),
534 });
535 }
536 Ok(Ok(())) => return Ok(()),
537 Ok(Err(response)) => attempts.push(SnapshotAttemptFailure { sender, response }),
538 }
539 }
540
541 Err(ControllerSnapshotError::RestoreFailed {
542 canister_id,
543 attempts,
544 })
545}
546
547fn apply_snapshot_restore_funding(
548 pocket_ic: &PocketIc,
549 canister_id: Principal,
550 funding: SnapshotRestoreFunding,
551) {
552 if funding == SnapshotRestoreFunding::Preserve {
553 return;
554 }
555
556 let balance = pocket_ic.cycle_balance(canister_id);
557 let top_up = snapshot_restore_top_up(balance, funding);
558 if top_up > 0 {
559 let _ = pocket_ic.add_cycles(canister_id, top_up);
560 }
561}
562
563const fn snapshot_restore_top_up(balance: u128, funding: SnapshotRestoreFunding) -> u128 {
564 match funding {
565 SnapshotRestoreFunding::Preserve => 0,
566 SnapshotRestoreFunding::TopUpTo { minimum_cycles } => {
567 minimum_cycles.saturating_sub(balance)
568 }
569 }
570}
571
572fn controller_sender_candidates(
573 controller_id: Principal,
574 canister_id: Principal,
575) -> [Option<Principal>; 2] {
576 if canister_id == controller_id {
577 [None, Some(controller_id)]
578 } else {
579 [Some(controller_id), None]
580 }
581}
582
583#[cfg(test)]
584mod tests {
585 use super::{SnapshotRestoreFunding, snapshot_restore_top_up};
586
587 #[test]
588 fn snapshot_restore_funding_is_explicit() {
589 assert_eq!(
590 snapshot_restore_top_up(10, SnapshotRestoreFunding::Preserve),
591 0
592 );
593 assert_eq!(
594 snapshot_restore_top_up(10, SnapshotRestoreFunding::TopUpTo { minimum_cycles: 25 }),
595 15
596 );
597 assert_eq!(
598 snapshot_restore_top_up(30, SnapshotRestoreFunding::TopUpTo { minimum_cycles: 25 }),
599 0
600 );
601 }
602}