1use std::{
2 num::NonZeroUsize,
3 ops::Deref,
4 panic::{AssertUnwindSafe, catch_unwind},
5 time::{Duration, Instant},
6};
7
8use super::{
9 ControllerSnapshotError, ControllerSnapshots, PocketIcSnapshotExt, SnapshotRestoreFunding,
10 StandaloneCanisterFixture,
11 bounded_pool::{BoundedSlotLease, BoundedSlotPool},
12 transport,
13};
14
15struct StandaloneFixtureBaseline {
16 fixture: StandaloneCanisterFixture,
17 snapshots: ControllerSnapshots,
18}
19
20impl StandaloneFixtureBaseline {
21 fn capture(fixture: StandaloneCanisterFixture) -> Result<Self, ControllerSnapshotError> {
22 let canister_id = fixture.canister_id();
23 let snapshots = fixture
24 .pocket_ic()
25 .capture_controller_snapshots(canister_id, [canister_id])?;
26
27 Ok(Self { fixture, snapshots })
28 }
29
30 fn restore(&self, funding: SnapshotRestoreFunding) -> Result<(), ControllerSnapshotError> {
31 self.fixture
32 .pocket_ic()
33 .restore_snapshots_with_captured_senders_and_funding(&self.snapshots, funding)
34 }
35}
36
37#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
39pub struct StandaloneFixturePoolTimings {
40 wait: Duration,
41 build: Option<Duration>,
42 restore: Option<Duration>,
43 stale_teardown: Option<Duration>,
44 total: Duration,
45}
46
47#[non_exhaustive]
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub enum StandaloneFixturePoolOutcome {
51 Built {
53 slot: usize,
55 timings: StandaloneFixturePoolTimings,
57 },
58 Restored {
60 slot: usize,
62 timings: StandaloneFixturePoolTimings,
64 },
65 Rebuilt {
67 slot: usize,
69 reason: StandaloneFixturePoolRebuildReason,
71 timings: StandaloneFixturePoolTimings,
73 },
74}
75
76#[non_exhaustive]
78#[derive(Clone, Copy, Debug, Eq, PartialEq)]
79pub enum StandaloneFixturePoolRebuildReason {
80 DeadPocketIcTransport,
82 PreviousRestoreFailure,
84 UnwindWhileLeased,
86}
87
88#[non_exhaustive]
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub enum StandaloneFixturePoolStage {
92 Build,
94 Restore,
96}
97
98#[non_exhaustive]
100#[derive(Debug)]
101pub enum StandaloneFixturePoolError {
102 Preparation {
104 stage: StandaloneFixturePoolStage,
106 source: Box<ControllerSnapshotError>,
108 timings: Box<StandaloneFixturePoolTimings>,
110 },
111 RecoveryFailed {
113 original: Box<ControllerSnapshotError>,
115 rebuild: Box<ControllerSnapshotError>,
117 timings: Box<StandaloneFixturePoolTimings>,
119 },
120}
121
122pub struct CachedStandaloneCanisterFixturePool<B = fn() -> StandaloneCanisterFixture> {
142 build: B,
143 slots: BoundedSlotPool<StandaloneFixtureBaseline>,
144 restore_funding: SnapshotRestoreFunding,
145}
146
147pub struct CachedStandaloneCanisterFixtureGuard<'a> {
152 slot: BoundedSlotLease<'a, StandaloneFixtureBaseline>,
153}
154
155impl<B> CachedStandaloneCanisterFixturePool<B>
156where
157 B: Fn() -> StandaloneCanisterFixture,
158{
159 #[must_use]
181 pub const fn new(capacity: NonZeroUsize, build: B) -> Self {
182 Self {
183 build,
184 slots: BoundedSlotPool::new(capacity),
185 restore_funding: SnapshotRestoreFunding::Preserve,
186 }
187 }
188
189 #[must_use]
191 pub const fn capacity(&self) -> NonZeroUsize {
192 self.slots.capacity()
193 }
194
195 #[must_use]
198 pub const fn with_restore_funding(mut self, funding: SnapshotRestoreFunding) -> Self {
199 self.restore_funding = funding;
200 self
201 }
202
203 pub fn acquire(
221 &self,
222 ) -> Result<
223 (
224 CachedStandaloneCanisterFixtureGuard<'_>,
225 StandaloneFixturePoolOutcome,
226 ),
227 StandaloneFixturePoolError,
228 > {
229 let total_started = Instant::now();
230 self.prepare_slot_with_outcome(self.slots.acquire(), total_started)
231 }
232
233 fn prepare_slot_with_outcome<'a>(
234 &'a self,
235 mut slot: BoundedSlotLease<'a, StandaloneFixtureBaseline>,
236 total_started: Instant,
237 ) -> Result<
238 (
239 CachedStandaloneCanisterFixtureGuard<'a>,
240 StandaloneFixturePoolOutcome,
241 ),
242 StandaloneFixturePoolError,
243 > {
244 let slot_index = slot.slot_index();
245 let mut timings = StandaloneFixturePoolTimings {
246 wait: slot.wait(),
247 ..StandaloneFixturePoolTimings::default()
248 };
249
250 if !slot.is_reusable() {
251 let rebuild_reason = Self::rebuild_reason_for_invalid_slot(&slot);
252 Self::discard_stale_slot(&mut slot, &mut timings);
253 let baseline = match self.build_slot(&mut timings) {
254 Ok(baseline) => baseline,
255 Err(source) => {
256 timings.total = total_started.elapsed();
257 return Err(StandaloneFixturePoolError::Preparation {
258 stage: StandaloneFixturePoolStage::Build,
259 source: Box::new(source),
260 timings: Box::new(timings),
261 });
262 }
263 };
264 slot.replace(baseline);
265 timings.total = total_started.elapsed();
266 let outcome = rebuild_reason.map_or_else(
267 || StandaloneFixturePoolOutcome::Built {
268 slot: slot_index,
269 timings,
270 },
271 |reason| StandaloneFixturePoolOutcome::Rebuilt {
272 slot: slot_index,
273 reason,
274 timings,
275 },
276 );
277 return Ok((CachedStandaloneCanisterFixtureGuard { slot }, outcome));
278 }
279
280 let restore_started = Instant::now();
281 let restore = slot
282 .get()
283 .expect("populated fixture pool slot must remain present")
284 .restore(self.restore_funding);
285 timings.restore = Some(restore_started.elapsed());
286 match restore {
287 Ok(()) => {
288 timings.total = total_started.elapsed();
289 Ok((
290 CachedStandaloneCanisterFixtureGuard { slot },
291 StandaloneFixturePoolOutcome::Restored {
292 slot: slot_index,
293 timings,
294 },
295 ))
296 }
297 Err(error) if transport::is_dead_pocket_ic_transport_error(&error) => {
298 Self::discard_stale_slot(&mut slot, &mut timings);
299 let baseline = match self.build_slot(&mut timings) {
300 Ok(baseline) => baseline,
301 Err(rebuild) => {
302 timings.total = total_started.elapsed();
303 return Err(StandaloneFixturePoolError::RecoveryFailed {
304 original: Box::new(error),
305 rebuild: Box::new(rebuild),
306 timings: Box::new(timings),
307 });
308 }
309 };
310 slot.replace(baseline);
311 timings.total = total_started.elapsed();
312 Ok((
313 CachedStandaloneCanisterFixtureGuard { slot },
314 StandaloneFixturePoolOutcome::Rebuilt {
315 slot: slot_index,
316 reason: StandaloneFixturePoolRebuildReason::DeadPocketIcTransport,
317 timings,
318 },
319 ))
320 }
321 Err(source) => {
322 slot.invalidate();
326 timings.total = total_started.elapsed();
327 Err(StandaloneFixturePoolError::Preparation {
328 stage: StandaloneFixturePoolStage::Restore,
329 source: Box::new(source),
330 timings: Box::new(timings),
331 })
332 }
333 }
334 }
335
336 fn rebuild_reason_for_invalid_slot(
337 slot: &BoundedSlotLease<'_, StandaloneFixtureBaseline>,
338 ) -> Option<StandaloneFixturePoolRebuildReason> {
339 if slot.invalidated_by_unwind() {
340 Some(StandaloneFixturePoolRebuildReason::UnwindWhileLeased)
341 } else {
342 slot.is_populated()
344 .then_some(StandaloneFixturePoolRebuildReason::PreviousRestoreFailure)
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
373impl Deref for CachedStandaloneCanisterFixtureGuard<'_> {
374 type Target = StandaloneCanisterFixture;
375
376 fn deref(&self) -> &Self::Target {
377 &self
378 .slot
379 .get()
380 .expect("leased fixture pool slot must remain populated")
381 .fixture
382 }
383}
384
385impl StandaloneFixturePoolOutcome {
386 #[must_use]
388 pub const fn slot(&self) -> usize {
389 match self {
390 Self::Built { slot, .. } | Self::Restored { slot, .. } | Self::Rebuilt { slot, .. } => {
391 *slot
392 }
393 }
394 }
395
396 #[must_use]
398 pub const fn timings(&self) -> StandaloneFixturePoolTimings {
399 match self {
400 Self::Built { timings, .. }
401 | Self::Restored { timings, .. }
402 | Self::Rebuilt { timings, .. } => *timings,
403 }
404 }
405
406 #[must_use]
408 pub const fn is_reused(&self) -> bool {
409 matches!(self, Self::Restored { .. })
410 }
411}
412
413impl StandaloneFixturePoolTimings {
414 #[must_use]
416 pub const fn wait(self) -> Duration {
417 self.wait
418 }
419
420 #[must_use]
422 pub const fn build(self) -> Option<Duration> {
423 self.build
424 }
425
426 #[must_use]
428 pub const fn restore(self) -> Option<Duration> {
429 self.restore
430 }
431
432 #[must_use]
434 pub const fn stale_teardown(self) -> Option<Duration> {
435 self.stale_teardown
436 }
437
438 #[must_use]
440 pub const fn total(self) -> Duration {
441 self.total
442 }
443}
444
445impl std::fmt::Display for StandaloneFixturePoolTimings {
446 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
447 write!(
448 formatter,
449 "total={:?} wait={:?} build={:?} restore={:?} stale_teardown={:?}",
450 self.total, self.wait, self.build, self.restore, self.stale_teardown,
451 )
452 }
453}
454
455impl std::fmt::Display for StandaloneFixturePoolOutcome {
456 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457 match self {
458 Self::Built { slot, timings } => write!(formatter, "built slot={slot} {timings}"),
459 Self::Restored { slot, timings } => {
460 write!(formatter, "restored slot={slot} {timings}")
461 }
462 Self::Rebuilt {
463 slot,
464 reason,
465 timings,
466 } => write!(formatter, "rebuilt slot={slot} reason={reason:?} {timings}"),
467 }
468 }
469}
470
471impl StandaloneFixturePoolError {
472 #[must_use]
474 pub const fn timings(&self) -> StandaloneFixturePoolTimings {
475 match self {
476 Self::Preparation { timings, .. } | Self::RecoveryFailed { timings, .. } => **timings,
477 }
478 }
479}
480
481impl std::fmt::Display for StandaloneFixturePoolStage {
482 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
483 formatter.write_str(match self {
484 Self::Build => "fixture build and snapshot capture",
485 Self::Restore => "fixture snapshot restore",
486 })
487 }
488}
489
490impl std::fmt::Display for StandaloneFixturePoolError {
491 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
492 match self {
493 Self::Preparation { stage, source, .. } => {
494 write!(formatter, "standalone {stage} failed: {source}")
495 }
496 Self::RecoveryFailed {
497 original, rebuild, ..
498 } => write!(
499 formatter,
500 "standalone fixture restore failed ({original}); rebuilding the slot also failed: {rebuild}",
501 ),
502 }
503 }
504}
505
506impl std::error::Error for StandaloneFixturePoolError {
507 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
508 match self {
509 Self::Preparation { source, .. } => Some(source.as_ref()),
510 Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
511 }
512 }
513}
514
515#[cfg(test)]
516mod tests {
517 use std::num::NonZeroUsize;
518
519 use super::{
520 CachedStandaloneCanisterFixturePool, SnapshotRestoreFunding, StandaloneCanisterFixture,
521 };
522
523 const _: CachedStandaloneCanisterFixturePool = {
524 let pool: CachedStandaloneCanisterFixturePool =
525 CachedStandaloneCanisterFixturePool::new(NonZeroUsize::new(1).unwrap(), build_fixture);
526 pool.with_restore_funding(SnapshotRestoreFunding::TopUpTo { minimum_cycles: 1 })
527 };
528
529 fn build_fixture() -> StandaloneCanisterFixture {
530 panic!("constructing an empty pool must not invoke its builder")
531 }
532
533 #[test]
534 fn runtime_capacity_preserves_lazy_fixture_construction() {
535 for capacity in [1, 2, 4, 8].map(|value| NonZeroUsize::new(value).unwrap()) {
536 let pool = CachedStandaloneCanisterFixturePool::new(capacity, build_fixture);
537 assert_eq!(pool.capacity(), capacity);
538 }
539 }
540}