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, PocketIcSnapshotExt, SnapshotRestoreFunding,
11 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_controller_snapshots_with_funding(
40 self.fixture.canister_id(),
41 &self.snapshots,
42 funding,
43 )
44 }
45}
46
47#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
49pub struct StandaloneFixturePoolTimings {
50 wait: Duration,
51 build: Option<Duration>,
52 restore: Option<Duration>,
53 stale_teardown: Option<Duration>,
54 total: Duration,
55}
56
57#[non_exhaustive]
59#[derive(Clone, Debug, Eq, PartialEq)]
60pub enum StandaloneFixturePoolOutcome {
61 Built {
63 slot: usize,
65 timings: StandaloneFixturePoolTimings,
67 },
68 Restored {
70 slot: usize,
72 timings: StandaloneFixturePoolTimings,
74 },
75 Rebuilt {
77 slot: usize,
79 reason: StandaloneFixturePoolRebuildReason,
81 timings: StandaloneFixturePoolTimings,
83 },
84}
85
86#[non_exhaustive]
88#[derive(Clone, Copy, Debug, Eq, PartialEq)]
89pub enum StandaloneFixturePoolRebuildReason {
90 DeadPocketIcTransport,
92 PreviousRestoreFailure,
94 UnwindWhileLeased,
96}
97
98#[non_exhaustive]
100#[derive(Clone, Copy, Debug, Eq, PartialEq)]
101pub enum StandaloneFixturePoolStage {
102 Build,
104 Restore,
106}
107
108#[non_exhaustive]
110#[derive(Debug)]
111pub enum StandaloneFixturePoolError {
112 Preparation {
114 stage: StandaloneFixturePoolStage,
116 source: Box<ControllerSnapshotError>,
118 timings: Box<StandaloneFixturePoolTimings>,
120 },
121 RecoveryFailed {
123 original: Box<ControllerSnapshotError>,
125 rebuild: Box<ControllerSnapshotError>,
127 timings: Box<StandaloneFixturePoolTimings>,
129 },
130}
131
132pub struct CachedStandaloneCanisterFixturePool<const CAPACITY: usize> {
153 slots: OnceLock<BoundedSlotPool<StandaloneFixtureBaseline>>,
154 restore_funding: SnapshotRestoreFunding,
155}
156
157pub struct CachedStandaloneCanisterFixtureGuard<'a> {
162 slot: BoundedSlotLease<'a, StandaloneFixtureBaseline>,
163}
164
165impl<const CAPACITY: usize> CachedStandaloneCanisterFixturePool<CAPACITY> {
166 #[must_use]
173 pub const fn new() -> Self {
174 assert!(CAPACITY > 0, "fixture pool capacity must be non-zero");
175
176 Self {
177 slots: OnceLock::new(),
178 restore_funding: SnapshotRestoreFunding::Preserve,
179 }
180 }
181
182 #[must_use]
185 pub const fn with_restore_funding(mut self, funding: SnapshotRestoreFunding) -> Self {
186 self.restore_funding = funding;
187 self
188 }
189
190 pub fn acquire<B>(
206 &self,
207 build: B,
208 ) -> Result<(CachedStandaloneCanisterFixtureGuard<'_>, bool), ControllerSnapshotError>
209 where
210 B: Fn() -> StandaloneCanisterFixture,
211 {
212 self.acquire_with_outcome(build)
213 .map(|(guard, outcome)| (guard, outcome.is_reused()))
214 .map_err(StandaloneFixturePoolError::into_snapshot_error)
215 }
216
217 pub fn acquire_with_outcome<B>(
229 &self,
230 build: B,
231 ) -> Result<
232 (
233 CachedStandaloneCanisterFixtureGuard<'_>,
234 StandaloneFixturePoolOutcome,
235 ),
236 StandaloneFixturePoolError,
237 >
238 where
239 B: Fn() -> StandaloneCanisterFixture,
240 {
241 let total_started = Instant::now();
242 self.prepare_slot_with_outcome(self.slots().acquire(), &build, total_started)
243 }
244
245 fn prepare_slot_with_outcome<'a, B>(
246 &'a self,
247 mut slot: BoundedSlotLease<'a, StandaloneFixtureBaseline>,
248 build: &B,
249 total_started: Instant,
250 ) -> Result<
251 (
252 CachedStandaloneCanisterFixtureGuard<'a>,
253 StandaloneFixturePoolOutcome,
254 ),
255 StandaloneFixturePoolError,
256 >
257 where
258 B: Fn() -> StandaloneCanisterFixture,
259 {
260 let slot_index = slot.slot_index();
261 let mut timings = StandaloneFixturePoolTimings {
262 wait: slot.wait(),
263 ..StandaloneFixturePoolTimings::default()
264 };
265
266 if !slot.is_reusable() {
267 let rebuild_reason = Self::rebuild_reason_for_invalid_slot(&slot);
268 Self::discard_stale_slot(&mut slot, &mut timings);
269 let baseline = match Self::build_slot(build, &mut timings) {
270 Ok(baseline) => baseline,
271 Err(source) => {
272 timings.total = total_started.elapsed();
273 return Err(StandaloneFixturePoolError::Preparation {
274 stage: StandaloneFixturePoolStage::Build,
275 source: Box::new(source),
276 timings: Box::new(timings),
277 });
278 }
279 };
280 slot.replace(baseline);
281 timings.total = total_started.elapsed();
282 let outcome = rebuild_reason.map_or_else(
283 || StandaloneFixturePoolOutcome::Built {
284 slot: slot_index,
285 timings,
286 },
287 |reason| StandaloneFixturePoolOutcome::Rebuilt {
288 slot: slot_index,
289 reason,
290 timings,
291 },
292 );
293 return Ok((CachedStandaloneCanisterFixtureGuard { slot }, outcome));
294 }
295
296 let restore_started = Instant::now();
297 let restore = slot
298 .get()
299 .expect("populated fixture pool slot must remain present")
300 .restore(self.restore_funding);
301 timings.restore = Some(restore_started.elapsed());
302 match restore {
303 Ok(()) => {
304 slot.get_mut()
305 .expect("restored fixture pool slot must remain present")
306 .invalidation_reason = None;
307 timings.total = total_started.elapsed();
308 Ok((
309 CachedStandaloneCanisterFixtureGuard { slot },
310 StandaloneFixturePoolOutcome::Restored {
311 slot: slot_index,
312 timings,
313 },
314 ))
315 }
316 Err(error) if snapshot_error_is_dead_instance_transport(&error) => {
317 Self::discard_stale_slot(&mut slot, &mut timings);
318 let baseline = match Self::build_slot(build, &mut timings) {
319 Ok(baseline) => baseline,
320 Err(rebuild) => {
321 timings.total = total_started.elapsed();
322 return Err(StandaloneFixturePoolError::RecoveryFailed {
323 original: Box::new(error),
324 rebuild: Box::new(rebuild),
325 timings: Box::new(timings),
326 });
327 }
328 };
329 slot.replace(baseline);
330 timings.total = total_started.elapsed();
331 Ok((
332 CachedStandaloneCanisterFixtureGuard { slot },
333 StandaloneFixturePoolOutcome::Rebuilt {
334 slot: slot_index,
335 reason: StandaloneFixturePoolRebuildReason::DeadPocketIcTransport,
336 timings,
337 },
338 ))
339 }
340 Err(source) => {
341 if let Some(baseline) = slot.get_mut() {
342 baseline.invalidation_reason =
343 Some(StandaloneFixturePoolRebuildReason::PreviousRestoreFailure);
344 }
345 slot.invalidate();
349 timings.total = total_started.elapsed();
350 Err(StandaloneFixturePoolError::Preparation {
351 stage: StandaloneFixturePoolStage::Restore,
352 source: Box::new(source),
353 timings: Box::new(timings),
354 })
355 }
356 }
357 }
358
359 fn rebuild_reason_for_invalid_slot(
360 slot: &BoundedSlotLease<'_, StandaloneFixtureBaseline>,
361 ) -> Option<StandaloneFixturePoolRebuildReason> {
362 if slot.invalidated_by_unwind() {
363 Some(StandaloneFixturePoolRebuildReason::UnwindWhileLeased)
364 } else {
365 slot.get()
366 .and_then(|baseline| baseline.invalidation_reason)
367 .or_else(|| {
368 slot.is_populated()
369 .then_some(StandaloneFixturePoolRebuildReason::PreviousRestoreFailure)
370 })
371 }
372 }
373
374 fn build_slot<B>(
375 build: &B,
376 timings: &mut StandaloneFixturePoolTimings,
377 ) -> Result<StandaloneFixtureBaseline, ControllerSnapshotError>
378 where
379 B: Fn() -> StandaloneCanisterFixture,
380 {
381 let started = Instant::now();
382 let result = StandaloneFixtureBaseline::capture(build());
383 timings.build = Some(started.elapsed());
384 result
385 }
386
387 fn discard_stale_slot(
388 slot: &mut BoundedSlotLease<'_, StandaloneFixtureBaseline>,
389 timings: &mut StandaloneFixturePoolTimings,
390 ) {
391 if !slot.is_populated() {
392 return;
393 }
394 let started = Instant::now();
395 if let Some(stale) = slot.take() {
396 let _ = catch_unwind(AssertUnwindSafe(|| drop(stale)));
397 }
398 timings.stale_teardown = Some(started.elapsed());
399 }
400
401 fn slots(&self) -> &BoundedSlotPool<StandaloneFixtureBaseline> {
402 self.slots.get_or_init(|| {
403 BoundedSlotPool::new(
404 NonZeroUsize::new(CAPACITY).expect("fixture pool capacity must be non-zero"),
405 )
406 })
407 }
408}
409
410impl<const CAPACITY: usize> Default for CachedStandaloneCanisterFixturePool<CAPACITY> {
411 fn default() -> Self {
412 Self::new()
413 }
414}
415
416impl Deref for CachedStandaloneCanisterFixtureGuard<'_> {
417 type Target = StandaloneCanisterFixture;
418
419 fn deref(&self) -> &Self::Target {
420 &self
421 .slot
422 .get()
423 .expect("leased fixture pool slot must remain populated")
424 .fixture
425 }
426}
427
428impl StandaloneFixturePoolOutcome {
429 #[must_use]
431 pub const fn slot(&self) -> usize {
432 match self {
433 Self::Built { slot, .. } | Self::Restored { slot, .. } | Self::Rebuilt { slot, .. } => {
434 *slot
435 }
436 }
437 }
438
439 #[must_use]
441 pub const fn timings(&self) -> StandaloneFixturePoolTimings {
442 match self {
443 Self::Built { timings, .. }
444 | Self::Restored { timings, .. }
445 | Self::Rebuilt { timings, .. } => *timings,
446 }
447 }
448
449 #[must_use]
451 pub const fn is_reused(&self) -> bool {
452 matches!(self, Self::Restored { .. })
453 }
454}
455
456impl StandaloneFixturePoolTimings {
457 #[must_use]
459 pub const fn wait(self) -> Duration {
460 self.wait
461 }
462
463 #[must_use]
465 pub const fn build(self) -> Option<Duration> {
466 self.build
467 }
468
469 #[must_use]
471 pub const fn restore(self) -> Option<Duration> {
472 self.restore
473 }
474
475 #[must_use]
477 pub const fn stale_teardown(self) -> Option<Duration> {
478 self.stale_teardown
479 }
480
481 #[must_use]
483 pub const fn total(self) -> Duration {
484 self.total
485 }
486}
487
488impl StandaloneFixturePoolError {
489 #[must_use]
491 pub const fn timings(&self) -> StandaloneFixturePoolTimings {
492 match self {
493 Self::Preparation { timings, .. } | Self::RecoveryFailed { timings, .. } => **timings,
494 }
495 }
496
497 fn into_snapshot_error(self) -> ControllerSnapshotError {
498 match self {
499 Self::Preparation { source, .. } => *source,
500 Self::RecoveryFailed { rebuild, .. } => *rebuild,
501 }
502 }
503}
504
505impl std::fmt::Display for StandaloneFixturePoolStage {
506 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
507 formatter.write_str(match self {
508 Self::Build => "fixture build and snapshot capture",
509 Self::Restore => "fixture snapshot restore",
510 })
511 }
512}
513
514impl std::fmt::Display for StandaloneFixturePoolError {
515 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516 match self {
517 Self::Preparation { stage, source, .. } => {
518 write!(formatter, "standalone {stage} failed: {source}")
519 }
520 Self::RecoveryFailed {
521 original, rebuild, ..
522 } => write!(
523 formatter,
524 "standalone fixture restore failed ({original}); rebuilding the slot also failed: {rebuild}",
525 ),
526 }
527 }
528}
529
530impl std::error::Error for StandaloneFixturePoolError {
531 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
532 match self {
533 Self::Preparation { source, .. } => Some(source.as_ref()),
534 Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
535 }
536 }
537}
538
539fn snapshot_error_is_dead_instance_transport(error: &ControllerSnapshotError) -> bool {
540 matches!(
541 error,
542 ControllerSnapshotError::RestorePanicked { message, .. }
543 if transport::is_dead_instance_transport_error(message)
544 )
545}
546
547#[cfg(test)]
548mod tests {
549 use super::CachedStandaloneCanisterFixturePool;
550
551 const _: CachedStandaloneCanisterFixturePool<1> = CachedStandaloneCanisterFixturePool::new();
552
553 #[test]
554 fn nonzero_pool_constructs() {
555 let _pool = CachedStandaloneCanisterFixturePool::<2>::new();
556 }
557}