1use std::{
2 num::NonZeroUsize,
3 ops::Deref,
4 panic::{AssertUnwindSafe, catch_unwind},
5 sync::OnceLock,
6 time::{Duration, Instant},
7};
8
9use super::{
10 ControllerSnapshotError, ControllerSnapshots, PocketIcCapturedSnapshotExt, PocketIcSnapshotExt,
11 SnapshotRestoreFunding, StandaloneCanisterFixture,
12 bounded_pool::{BoundedSlotLease, BoundedSlotPool},
13 transport,
14};
15
16struct StandaloneFixtureBaseline {
17 fixture: StandaloneCanisterFixture,
18 snapshots: ControllerSnapshots,
19 invalidation_reason: Option<StandaloneFixturePoolRebuildReason>,
20}
21
22impl StandaloneFixtureBaseline {
23 fn capture(fixture: StandaloneCanisterFixture) -> Result<Self, ControllerSnapshotError> {
24 let canister_id = fixture.canister_id();
25 let snapshots = fixture
26 .pocket_ic()
27 .capture_controller_snapshots(canister_id, [canister_id])?;
28
29 Ok(Self {
30 fixture,
31 snapshots,
32 invalidation_reason: None,
33 })
34 }
35
36 fn restore(&self, funding: SnapshotRestoreFunding) -> Result<(), ControllerSnapshotError> {
37 self.fixture
38 .pocket_ic()
39 .restore_snapshots_with_captured_senders_and_funding(&self.snapshots, funding)
40 }
41}
42
43#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
45pub struct StandaloneFixturePoolTimings {
46 wait: Duration,
47 build: Option<Duration>,
48 restore: Option<Duration>,
49 stale_teardown: Option<Duration>,
50 total: Duration,
51}
52
53#[non_exhaustive]
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub enum StandaloneFixturePoolOutcome {
57 Built {
59 slot: usize,
61 timings: StandaloneFixturePoolTimings,
63 },
64 Restored {
66 slot: usize,
68 timings: StandaloneFixturePoolTimings,
70 },
71 Rebuilt {
73 slot: usize,
75 reason: StandaloneFixturePoolRebuildReason,
77 timings: StandaloneFixturePoolTimings,
79 },
80}
81
82#[non_exhaustive]
84#[derive(Clone, Copy, Debug, Eq, PartialEq)]
85pub enum StandaloneFixturePoolRebuildReason {
86 DeadPocketIcTransport,
88 PreviousRestoreFailure,
90 UnwindWhileLeased,
92}
93
94#[non_exhaustive]
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub enum StandaloneFixturePoolStage {
98 Build,
100 Restore,
102}
103
104#[non_exhaustive]
106#[derive(Debug)]
107pub enum StandaloneFixturePoolError {
108 Preparation {
110 stage: StandaloneFixturePoolStage,
112 source: Box<ControllerSnapshotError>,
114 timings: Box<StandaloneFixturePoolTimings>,
116 },
117 RecoveryFailed {
119 original: Box<ControllerSnapshotError>,
121 rebuild: Box<ControllerSnapshotError>,
123 timings: Box<StandaloneFixturePoolTimings>,
125 },
126}
127
128pub struct CachedStandaloneCanisterFixturePool<const CAPACITY: usize> {
149 slots: OnceLock<BoundedSlotPool<StandaloneFixtureBaseline>>,
150 restore_funding: SnapshotRestoreFunding,
151}
152
153pub struct CachedStandaloneCanisterFixtureGuard<'a> {
158 slot: BoundedSlotLease<'a, StandaloneFixtureBaseline>,
159}
160
161impl<const CAPACITY: usize> CachedStandaloneCanisterFixturePool<CAPACITY> {
162 #[must_use]
169 pub const fn new() -> Self {
170 assert!(CAPACITY > 0, "fixture pool capacity must be non-zero");
171
172 Self {
173 slots: OnceLock::new(),
174 restore_funding: SnapshotRestoreFunding::Preserve,
175 }
176 }
177
178 #[must_use]
181 pub const fn with_restore_funding(mut self, funding: SnapshotRestoreFunding) -> Self {
182 self.restore_funding = funding;
183 self
184 }
185
186 pub fn acquire<B>(
202 &self,
203 build: B,
204 ) -> Result<(CachedStandaloneCanisterFixtureGuard<'_>, bool), ControllerSnapshotError>
205 where
206 B: Fn() -> StandaloneCanisterFixture,
207 {
208 self.acquire_with_outcome(build)
209 .map(|(guard, outcome)| (guard, outcome.is_reused()))
210 .map_err(StandaloneFixturePoolError::into_snapshot_error)
211 }
212
213 pub fn acquire_with_outcome<B>(
225 &self,
226 build: B,
227 ) -> Result<
228 (
229 CachedStandaloneCanisterFixtureGuard<'_>,
230 StandaloneFixturePoolOutcome,
231 ),
232 StandaloneFixturePoolError,
233 >
234 where
235 B: Fn() -> StandaloneCanisterFixture,
236 {
237 let total_started = Instant::now();
238 self.prepare_slot_with_outcome(self.slots().acquire(), &build, total_started)
239 }
240
241 fn prepare_slot_with_outcome<'a, B>(
242 &'a self,
243 mut slot: BoundedSlotLease<'a, StandaloneFixtureBaseline>,
244 build: &B,
245 total_started: Instant,
246 ) -> Result<
247 (
248 CachedStandaloneCanisterFixtureGuard<'a>,
249 StandaloneFixturePoolOutcome,
250 ),
251 StandaloneFixturePoolError,
252 >
253 where
254 B: Fn() -> StandaloneCanisterFixture,
255 {
256 let slot_index = slot.slot_index();
257 let mut timings = StandaloneFixturePoolTimings {
258 wait: slot.wait(),
259 ..StandaloneFixturePoolTimings::default()
260 };
261
262 if !slot.is_reusable() {
263 let rebuild_reason = Self::rebuild_reason_for_invalid_slot(&slot);
264 Self::discard_stale_slot(&mut slot, &mut timings);
265 let baseline = match Self::build_slot(build, &mut timings) {
266 Ok(baseline) => baseline,
267 Err(source) => {
268 timings.total = total_started.elapsed();
269 return Err(StandaloneFixturePoolError::Preparation {
270 stage: StandaloneFixturePoolStage::Build,
271 source: Box::new(source),
272 timings: Box::new(timings),
273 });
274 }
275 };
276 slot.replace(baseline);
277 timings.total = total_started.elapsed();
278 let outcome = rebuild_reason.map_or_else(
279 || StandaloneFixturePoolOutcome::Built {
280 slot: slot_index,
281 timings,
282 },
283 |reason| StandaloneFixturePoolOutcome::Rebuilt {
284 slot: slot_index,
285 reason,
286 timings,
287 },
288 );
289 return Ok((CachedStandaloneCanisterFixtureGuard { slot }, outcome));
290 }
291
292 let restore_started = Instant::now();
293 let restore = slot
294 .get()
295 .expect("populated fixture pool slot must remain present")
296 .restore(self.restore_funding);
297 timings.restore = Some(restore_started.elapsed());
298 match restore {
299 Ok(()) => {
300 slot.get_mut()
301 .expect("restored fixture pool slot must remain present")
302 .invalidation_reason = None;
303 timings.total = total_started.elapsed();
304 Ok((
305 CachedStandaloneCanisterFixtureGuard { slot },
306 StandaloneFixturePoolOutcome::Restored {
307 slot: slot_index,
308 timings,
309 },
310 ))
311 }
312 Err(error) if snapshot_error_is_dead_instance_transport(&error) => {
313 Self::discard_stale_slot(&mut slot, &mut timings);
314 let baseline = match Self::build_slot(build, &mut timings) {
315 Ok(baseline) => baseline,
316 Err(rebuild) => {
317 timings.total = total_started.elapsed();
318 return Err(StandaloneFixturePoolError::RecoveryFailed {
319 original: Box::new(error),
320 rebuild: Box::new(rebuild),
321 timings: Box::new(timings),
322 });
323 }
324 };
325 slot.replace(baseline);
326 timings.total = total_started.elapsed();
327 Ok((
328 CachedStandaloneCanisterFixtureGuard { slot },
329 StandaloneFixturePoolOutcome::Rebuilt {
330 slot: slot_index,
331 reason: StandaloneFixturePoolRebuildReason::DeadPocketIcTransport,
332 timings,
333 },
334 ))
335 }
336 Err(source) => {
337 if let Some(baseline) = slot.get_mut() {
338 baseline.invalidation_reason =
339 Some(StandaloneFixturePoolRebuildReason::PreviousRestoreFailure);
340 }
341 slot.invalidate();
345 timings.total = total_started.elapsed();
346 Err(StandaloneFixturePoolError::Preparation {
347 stage: StandaloneFixturePoolStage::Restore,
348 source: Box::new(source),
349 timings: Box::new(timings),
350 })
351 }
352 }
353 }
354
355 fn rebuild_reason_for_invalid_slot(
356 slot: &BoundedSlotLease<'_, StandaloneFixtureBaseline>,
357 ) -> Option<StandaloneFixturePoolRebuildReason> {
358 if slot.invalidated_by_unwind() {
359 Some(StandaloneFixturePoolRebuildReason::UnwindWhileLeased)
360 } else {
361 slot.get()
362 .and_then(|baseline| baseline.invalidation_reason)
363 .or_else(|| {
364 slot.is_populated()
365 .then_some(StandaloneFixturePoolRebuildReason::PreviousRestoreFailure)
366 })
367 }
368 }
369
370 fn build_slot<B>(
371 build: &B,
372 timings: &mut StandaloneFixturePoolTimings,
373 ) -> Result<StandaloneFixtureBaseline, ControllerSnapshotError>
374 where
375 B: Fn() -> StandaloneCanisterFixture,
376 {
377 let started = Instant::now();
378 let result = StandaloneFixtureBaseline::capture(build());
379 timings.build = Some(started.elapsed());
380 result
381 }
382
383 fn discard_stale_slot(
384 slot: &mut BoundedSlotLease<'_, StandaloneFixtureBaseline>,
385 timings: &mut StandaloneFixturePoolTimings,
386 ) {
387 if !slot.is_populated() {
388 return;
389 }
390 let started = Instant::now();
391 if let Some(stale) = slot.take() {
392 let _ = catch_unwind(AssertUnwindSafe(|| drop(stale)));
393 }
394 timings.stale_teardown = Some(started.elapsed());
395 }
396
397 fn slots(&self) -> &BoundedSlotPool<StandaloneFixtureBaseline> {
398 self.slots.get_or_init(|| {
399 BoundedSlotPool::new(
400 NonZeroUsize::new(CAPACITY).expect("fixture pool capacity must be non-zero"),
401 )
402 })
403 }
404}
405
406impl<const CAPACITY: usize> Default for CachedStandaloneCanisterFixturePool<CAPACITY> {
407 fn default() -> Self {
408 Self::new()
409 }
410}
411
412impl Deref for CachedStandaloneCanisterFixtureGuard<'_> {
413 type Target = StandaloneCanisterFixture;
414
415 fn deref(&self) -> &Self::Target {
416 &self
417 .slot
418 .get()
419 .expect("leased fixture pool slot must remain populated")
420 .fixture
421 }
422}
423
424impl StandaloneFixturePoolOutcome {
425 #[must_use]
427 pub const fn slot(&self) -> usize {
428 match self {
429 Self::Built { slot, .. } | Self::Restored { slot, .. } | Self::Rebuilt { slot, .. } => {
430 *slot
431 }
432 }
433 }
434
435 #[must_use]
437 pub const fn timings(&self) -> StandaloneFixturePoolTimings {
438 match self {
439 Self::Built { timings, .. }
440 | Self::Restored { timings, .. }
441 | Self::Rebuilt { timings, .. } => *timings,
442 }
443 }
444
445 #[must_use]
447 pub const fn is_reused(&self) -> bool {
448 matches!(self, Self::Restored { .. })
449 }
450}
451
452impl StandaloneFixturePoolTimings {
453 #[must_use]
455 pub const fn wait(self) -> Duration {
456 self.wait
457 }
458
459 #[must_use]
461 pub const fn build(self) -> Option<Duration> {
462 self.build
463 }
464
465 #[must_use]
467 pub const fn restore(self) -> Option<Duration> {
468 self.restore
469 }
470
471 #[must_use]
473 pub const fn stale_teardown(self) -> Option<Duration> {
474 self.stale_teardown
475 }
476
477 #[must_use]
479 pub const fn total(self) -> Duration {
480 self.total
481 }
482}
483
484impl std::fmt::Display for StandaloneFixturePoolTimings {
485 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
486 write!(
487 formatter,
488 "total={:?} wait={:?} build={:?} restore={:?} stale_teardown={:?}",
489 self.total, self.wait, self.build, self.restore, self.stale_teardown,
490 )
491 }
492}
493
494impl std::fmt::Display for StandaloneFixturePoolOutcome {
495 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
496 match self {
497 Self::Built { slot, timings } => write!(formatter, "built slot={slot} {timings}"),
498 Self::Restored { slot, timings } => {
499 write!(formatter, "restored slot={slot} {timings}")
500 }
501 Self::Rebuilt {
502 slot,
503 reason,
504 timings,
505 } => write!(formatter, "rebuilt slot={slot} reason={reason:?} {timings}"),
506 }
507 }
508}
509
510impl StandaloneFixturePoolError {
511 #[must_use]
513 pub const fn timings(&self) -> StandaloneFixturePoolTimings {
514 match self {
515 Self::Preparation { timings, .. } | Self::RecoveryFailed { timings, .. } => **timings,
516 }
517 }
518
519 fn into_snapshot_error(self) -> ControllerSnapshotError {
520 match self {
521 Self::Preparation { source, .. } => *source,
522 Self::RecoveryFailed { rebuild, .. } => *rebuild,
523 }
524 }
525}
526
527impl std::fmt::Display for StandaloneFixturePoolStage {
528 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
529 formatter.write_str(match self {
530 Self::Build => "fixture build and snapshot capture",
531 Self::Restore => "fixture snapshot restore",
532 })
533 }
534}
535
536impl std::fmt::Display for StandaloneFixturePoolError {
537 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
538 match self {
539 Self::Preparation { stage, source, .. } => {
540 write!(formatter, "standalone {stage} failed: {source}")
541 }
542 Self::RecoveryFailed {
543 original, rebuild, ..
544 } => write!(
545 formatter,
546 "standalone fixture restore failed ({original}); rebuilding the slot also failed: {rebuild}",
547 ),
548 }
549 }
550}
551
552impl std::error::Error for StandaloneFixturePoolError {
553 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
554 match self {
555 Self::Preparation { source, .. } => Some(source.as_ref()),
556 Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
557 }
558 }
559}
560
561fn snapshot_error_is_dead_instance_transport(error: &ControllerSnapshotError) -> bool {
562 matches!(
563 error,
564 ControllerSnapshotError::RestorePanicked { message, .. }
565 if transport::is_dead_instance_transport_error(message)
566 )
567}
568
569#[cfg(test)]
570mod tests {
571 use super::CachedStandaloneCanisterFixturePool;
572
573 const _: CachedStandaloneCanisterFixturePool<1> = CachedStandaloneCanisterFixturePool::new();
574
575 #[test]
576 fn nonzero_pool_constructs() {
577 let _pool = CachedStandaloneCanisterFixturePool::<2>::new();
578 }
579}