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_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<
148 const CAPACITY: usize,
149 B = fn() -> StandaloneCanisterFixture,
150> {
151 build: B,
152 slots: OnceLock<BoundedSlotPool<StandaloneFixtureBaseline>>,
153 restore_funding: SnapshotRestoreFunding,
154}
155
156pub struct CachedStandaloneCanisterFixtureGuard<'a> {
161 slot: BoundedSlotLease<'a, StandaloneFixtureBaseline>,
162}
163
164impl<const CAPACITY: usize, B> CachedStandaloneCanisterFixturePool<CAPACITY, B>
165where
166 B: Fn() -> StandaloneCanisterFixture,
167{
168 #[must_use]
175 pub const fn new(build: B) -> Self {
176 assert!(CAPACITY > 0, "fixture pool capacity must be non-zero");
177
178 Self {
179 build,
180 slots: OnceLock::new(),
181 restore_funding: SnapshotRestoreFunding::Preserve,
182 }
183 }
184
185 #[must_use]
188 pub const fn with_restore_funding(mut self, funding: SnapshotRestoreFunding) -> Self {
189 self.restore_funding = funding;
190 self
191 }
192
193 pub fn acquire(
211 &self,
212 ) -> Result<
213 (
214 CachedStandaloneCanisterFixtureGuard<'_>,
215 StandaloneFixturePoolOutcome,
216 ),
217 StandaloneFixturePoolError,
218 > {
219 let total_started = Instant::now();
220 self.prepare_slot_with_outcome(self.slots().acquire(), total_started)
221 }
222
223 fn prepare_slot_with_outcome<'a>(
224 &'a self,
225 mut slot: BoundedSlotLease<'a, StandaloneFixtureBaseline>,
226 total_started: Instant,
227 ) -> Result<
228 (
229 CachedStandaloneCanisterFixtureGuard<'a>,
230 StandaloneFixturePoolOutcome,
231 ),
232 StandaloneFixturePoolError,
233 > {
234 let slot_index = slot.slot_index();
235 let mut timings = StandaloneFixturePoolTimings {
236 wait: slot.wait(),
237 ..StandaloneFixturePoolTimings::default()
238 };
239
240 if !slot.is_reusable() {
241 let rebuild_reason = Self::rebuild_reason_for_invalid_slot(&slot);
242 Self::discard_stale_slot(&mut slot, &mut timings);
243 let baseline = match self.build_slot(&mut timings) {
244 Ok(baseline) => baseline,
245 Err(source) => {
246 timings.total = total_started.elapsed();
247 return Err(StandaloneFixturePoolError::Preparation {
248 stage: StandaloneFixturePoolStage::Build,
249 source: Box::new(source),
250 timings: Box::new(timings),
251 });
252 }
253 };
254 slot.replace(baseline);
255 timings.total = total_started.elapsed();
256 let outcome = rebuild_reason.map_or_else(
257 || StandaloneFixturePoolOutcome::Built {
258 slot: slot_index,
259 timings,
260 },
261 |reason| StandaloneFixturePoolOutcome::Rebuilt {
262 slot: slot_index,
263 reason,
264 timings,
265 },
266 );
267 return Ok((CachedStandaloneCanisterFixtureGuard { slot }, outcome));
268 }
269
270 let restore_started = Instant::now();
271 let restore = slot
272 .get()
273 .expect("populated fixture pool slot must remain present")
274 .restore(self.restore_funding);
275 timings.restore = Some(restore_started.elapsed());
276 match restore {
277 Ok(()) => {
278 slot.get_mut()
279 .expect("restored fixture pool slot must remain present")
280 .invalidation_reason = None;
281 timings.total = total_started.elapsed();
282 Ok((
283 CachedStandaloneCanisterFixtureGuard { slot },
284 StandaloneFixturePoolOutcome::Restored {
285 slot: slot_index,
286 timings,
287 },
288 ))
289 }
290 Err(error) if transport::is_dead_pocket_ic_transport_error(&error) => {
291 Self::discard_stale_slot(&mut slot, &mut timings);
292 let baseline = match self.build_slot(&mut timings) {
293 Ok(baseline) => baseline,
294 Err(rebuild) => {
295 timings.total = total_started.elapsed();
296 return Err(StandaloneFixturePoolError::RecoveryFailed {
297 original: Box::new(error),
298 rebuild: Box::new(rebuild),
299 timings: Box::new(timings),
300 });
301 }
302 };
303 slot.replace(baseline);
304 timings.total = total_started.elapsed();
305 Ok((
306 CachedStandaloneCanisterFixtureGuard { slot },
307 StandaloneFixturePoolOutcome::Rebuilt {
308 slot: slot_index,
309 reason: StandaloneFixturePoolRebuildReason::DeadPocketIcTransport,
310 timings,
311 },
312 ))
313 }
314 Err(source) => {
315 if let Some(baseline) = slot.get_mut() {
316 baseline.invalidation_reason =
317 Some(StandaloneFixturePoolRebuildReason::PreviousRestoreFailure);
318 }
319 slot.invalidate();
323 timings.total = total_started.elapsed();
324 Err(StandaloneFixturePoolError::Preparation {
325 stage: StandaloneFixturePoolStage::Restore,
326 source: Box::new(source),
327 timings: Box::new(timings),
328 })
329 }
330 }
331 }
332
333 fn rebuild_reason_for_invalid_slot(
334 slot: &BoundedSlotLease<'_, StandaloneFixtureBaseline>,
335 ) -> Option<StandaloneFixturePoolRebuildReason> {
336 if slot.invalidated_by_unwind() {
337 Some(StandaloneFixturePoolRebuildReason::UnwindWhileLeased)
338 } else {
339 slot.get()
340 .and_then(|baseline| baseline.invalidation_reason)
341 .or_else(|| {
342 slot.is_populated()
343 .then_some(StandaloneFixturePoolRebuildReason::PreviousRestoreFailure)
344 })
345 }
346 }
347
348 fn build_slot(
349 &self,
350 timings: &mut StandaloneFixturePoolTimings,
351 ) -> Result<StandaloneFixtureBaseline, ControllerSnapshotError> {
352 let started = Instant::now();
353 let result = StandaloneFixtureBaseline::capture((self.build)());
354 timings.build = Some(started.elapsed());
355 result
356 }
357
358 fn discard_stale_slot(
359 slot: &mut BoundedSlotLease<'_, StandaloneFixtureBaseline>,
360 timings: &mut StandaloneFixturePoolTimings,
361 ) {
362 if !slot.is_populated() {
363 return;
364 }
365 let started = Instant::now();
366 if let Some(stale) = slot.take() {
367 let _ = catch_unwind(AssertUnwindSafe(|| drop(stale)));
368 }
369 timings.stale_teardown = Some(started.elapsed());
370 }
371
372 fn slots(&self) -> &BoundedSlotPool<StandaloneFixtureBaseline> {
373 self.slots.get_or_init(|| {
374 BoundedSlotPool::new(
375 NonZeroUsize::new(CAPACITY).expect("fixture pool capacity must be non-zero"),
376 )
377 })
378 }
379}
380
381impl Deref for CachedStandaloneCanisterFixtureGuard<'_> {
382 type Target = StandaloneCanisterFixture;
383
384 fn deref(&self) -> &Self::Target {
385 &self
386 .slot
387 .get()
388 .expect("leased fixture pool slot must remain populated")
389 .fixture
390 }
391}
392
393impl StandaloneFixturePoolOutcome {
394 #[must_use]
396 pub const fn slot(&self) -> usize {
397 match self {
398 Self::Built { slot, .. } | Self::Restored { slot, .. } | Self::Rebuilt { slot, .. } => {
399 *slot
400 }
401 }
402 }
403
404 #[must_use]
406 pub const fn timings(&self) -> StandaloneFixturePoolTimings {
407 match self {
408 Self::Built { timings, .. }
409 | Self::Restored { timings, .. }
410 | Self::Rebuilt { timings, .. } => *timings,
411 }
412 }
413
414 #[must_use]
416 pub const fn is_reused(&self) -> bool {
417 matches!(self, Self::Restored { .. })
418 }
419}
420
421impl StandaloneFixturePoolTimings {
422 #[must_use]
424 pub const fn wait(self) -> Duration {
425 self.wait
426 }
427
428 #[must_use]
430 pub const fn build(self) -> Option<Duration> {
431 self.build
432 }
433
434 #[must_use]
436 pub const fn restore(self) -> Option<Duration> {
437 self.restore
438 }
439
440 #[must_use]
442 pub const fn stale_teardown(self) -> Option<Duration> {
443 self.stale_teardown
444 }
445
446 #[must_use]
448 pub const fn total(self) -> Duration {
449 self.total
450 }
451}
452
453impl std::fmt::Display for StandaloneFixturePoolTimings {
454 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455 write!(
456 formatter,
457 "total={:?} wait={:?} build={:?} restore={:?} stale_teardown={:?}",
458 self.total, self.wait, self.build, self.restore, self.stale_teardown,
459 )
460 }
461}
462
463impl std::fmt::Display for StandaloneFixturePoolOutcome {
464 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
465 match self {
466 Self::Built { slot, timings } => write!(formatter, "built slot={slot} {timings}"),
467 Self::Restored { slot, timings } => {
468 write!(formatter, "restored slot={slot} {timings}")
469 }
470 Self::Rebuilt {
471 slot,
472 reason,
473 timings,
474 } => write!(formatter, "rebuilt slot={slot} reason={reason:?} {timings}"),
475 }
476 }
477}
478
479impl StandaloneFixturePoolError {
480 #[must_use]
482 pub const fn timings(&self) -> StandaloneFixturePoolTimings {
483 match self {
484 Self::Preparation { timings, .. } | Self::RecoveryFailed { timings, .. } => **timings,
485 }
486 }
487}
488
489impl std::fmt::Display for StandaloneFixturePoolStage {
490 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
491 formatter.write_str(match self {
492 Self::Build => "fixture build and snapshot capture",
493 Self::Restore => "fixture snapshot restore",
494 })
495 }
496}
497
498impl std::fmt::Display for StandaloneFixturePoolError {
499 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
500 match self {
501 Self::Preparation { stage, source, .. } => {
502 write!(formatter, "standalone {stage} failed: {source}")
503 }
504 Self::RecoveryFailed {
505 original, rebuild, ..
506 } => write!(
507 formatter,
508 "standalone fixture restore failed ({original}); rebuilding the slot also failed: {rebuild}",
509 ),
510 }
511 }
512}
513
514impl std::error::Error for StandaloneFixturePoolError {
515 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
516 match self {
517 Self::Preparation { source, .. } => Some(source.as_ref()),
518 Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
519 }
520 }
521}
522
523#[cfg(test)]
524mod tests {
525 use super::{
526 CachedStandaloneCanisterFixturePool, SnapshotRestoreFunding, StandaloneCanisterFixture,
527 };
528
529 const _: CachedStandaloneCanisterFixturePool<1> =
530 CachedStandaloneCanisterFixturePool::<1>::new(build_fixture)
531 .with_restore_funding(SnapshotRestoreFunding::TopUpTo { minimum_cycles: 1 });
532
533 fn build_fixture() -> StandaloneCanisterFixture {
534 panic!("constructing an empty pool must not invoke its builder")
535 }
536
537 #[test]
538 fn nonzero_pool_constructs() {
539 let _pool = CachedStandaloneCanisterFixturePool::<2>::new(build_fixture);
540 }
541}