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