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 let mut slot = self.slots.acquire();
231 let slot_index = slot.slot_index();
232 let mut timings = StandaloneFixturePoolTimings {
233 wait: slot.wait(),
234 ..StandaloneFixturePoolTimings::default()
235 };
236
237 let (rebuild_reason, original) = if slot.is_reusable() {
238 let restore_started = Instant::now();
239 let restore = slot
240 .get()
241 .expect("populated fixture pool slot must remain present")
242 .restore(self.restore_funding);
243 timings.restore = Some(restore_started.elapsed());
244 match restore {
245 Ok(()) => {
246 timings.total = total_started.elapsed();
247 return Ok((
248 CachedStandaloneCanisterFixtureGuard { slot },
249 StandaloneFixturePoolOutcome::Restored {
250 slot: slot_index,
251 timings,
252 },
253 ));
254 }
255 Err(error) if transport::is_dead_pocket_ic_transport_error(&error) => (
256 Some(StandaloneFixturePoolRebuildReason::DeadPocketIcTransport),
257 Some(error),
258 ),
259 Err(source) => {
260 slot.invalidate();
263 timings.total = total_started.elapsed();
264 return Err(StandaloneFixturePoolError::Preparation {
265 stage: StandaloneFixturePoolStage::Restore,
266 source: Box::new(source),
267 timings: Box::new(timings),
268 });
269 }
270 }
271 } else {
272 (Self::rebuild_reason_for_invalid_slot(&slot), None)
273 };
274
275 Self::discard_stale_slot(&mut slot, &mut timings);
276 let baseline = match self.build_slot(&mut timings) {
277 Ok(baseline) => baseline,
278 Err(source) => {
279 timings.total = total_started.elapsed();
280 return Err(match original {
281 Some(original) => StandaloneFixturePoolError::RecoveryFailed {
282 original: Box::new(original),
283 rebuild: Box::new(source),
284 timings: Box::new(timings),
285 },
286 None => StandaloneFixturePoolError::Preparation {
287 stage: StandaloneFixturePoolStage::Build,
288 source: Box::new(source),
289 timings: Box::new(timings),
290 },
291 });
292 }
293 };
294 slot.replace(baseline);
295 timings.total = total_started.elapsed();
296 let outcome = rebuild_reason.map_or_else(
297 || StandaloneFixturePoolOutcome::Built {
298 slot: slot_index,
299 timings,
300 },
301 |reason| StandaloneFixturePoolOutcome::Rebuilt {
302 slot: slot_index,
303 reason,
304 timings,
305 },
306 );
307 Ok((CachedStandaloneCanisterFixtureGuard { slot }, outcome))
308 }
309
310 fn rebuild_reason_for_invalid_slot(
311 slot: &BoundedSlotLease<'_, StandaloneFixtureBaseline>,
312 ) -> Option<StandaloneFixturePoolRebuildReason> {
313 if slot.invalidated_by_unwind() {
314 Some(StandaloneFixturePoolRebuildReason::UnwindWhileLeased)
315 } else {
316 slot.is_populated()
318 .then_some(StandaloneFixturePoolRebuildReason::PreviousRestoreFailure)
319 }
320 }
321
322 fn build_slot(
323 &self,
324 timings: &mut StandaloneFixturePoolTimings,
325 ) -> Result<StandaloneFixtureBaseline, ControllerSnapshotError> {
326 let started = Instant::now();
327 let result = StandaloneFixtureBaseline::capture((self.build)());
328 timings.build = Some(started.elapsed());
329 result
330 }
331
332 fn discard_stale_slot(
333 slot: &mut BoundedSlotLease<'_, StandaloneFixtureBaseline>,
334 timings: &mut StandaloneFixturePoolTimings,
335 ) {
336 if !slot.is_populated() {
337 return;
338 }
339 let started = Instant::now();
340 if let Some(stale) = slot.take() {
341 let _ = catch_unwind(AssertUnwindSafe(|| drop(stale)));
342 }
343 timings.stale_teardown = Some(started.elapsed());
344 }
345}
346
347impl Deref for CachedStandaloneCanisterFixtureGuard<'_> {
348 type Target = StandaloneCanisterFixture;
349
350 fn deref(&self) -> &Self::Target {
351 &self
352 .slot
353 .get()
354 .expect("leased fixture pool slot must remain populated")
355 .fixture
356 }
357}
358
359impl StandaloneFixturePoolOutcome {
360 #[must_use]
362 pub const fn slot(&self) -> usize {
363 match self {
364 Self::Built { slot, .. } | Self::Restored { slot, .. } | Self::Rebuilt { slot, .. } => {
365 *slot
366 }
367 }
368 }
369
370 #[must_use]
372 pub const fn timings(&self) -> StandaloneFixturePoolTimings {
373 match self {
374 Self::Built { timings, .. }
375 | Self::Restored { timings, .. }
376 | Self::Rebuilt { timings, .. } => *timings,
377 }
378 }
379
380 #[must_use]
382 pub const fn is_reused(&self) -> bool {
383 matches!(self, Self::Restored { .. })
384 }
385}
386
387impl StandaloneFixturePoolTimings {
388 #[must_use]
390 pub const fn wait(self) -> Duration {
391 self.wait
392 }
393
394 #[must_use]
396 pub const fn build(self) -> Option<Duration> {
397 self.build
398 }
399
400 #[must_use]
402 pub const fn restore(self) -> Option<Duration> {
403 self.restore
404 }
405
406 #[must_use]
408 pub const fn stale_teardown(self) -> Option<Duration> {
409 self.stale_teardown
410 }
411
412 #[must_use]
414 pub const fn total(self) -> Duration {
415 self.total
416 }
417}
418
419impl std::fmt::Display for StandaloneFixturePoolTimings {
420 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
421 write!(
422 formatter,
423 "total={:?} wait={:?} build={:?} restore={:?} stale_teardown={:?}",
424 self.total, self.wait, self.build, self.restore, self.stale_teardown,
425 )
426 }
427}
428
429impl std::fmt::Display for StandaloneFixturePoolOutcome {
430 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431 match self {
432 Self::Built { slot, timings } => write!(formatter, "built slot={slot} {timings}"),
433 Self::Restored { slot, timings } => {
434 write!(formatter, "restored slot={slot} {timings}")
435 }
436 Self::Rebuilt {
437 slot,
438 reason,
439 timings,
440 } => write!(formatter, "rebuilt slot={slot} reason={reason:?} {timings}"),
441 }
442 }
443}
444
445impl StandaloneFixturePoolError {
446 #[must_use]
448 pub const fn timings(&self) -> StandaloneFixturePoolTimings {
449 match self {
450 Self::Preparation { timings, .. } | Self::RecoveryFailed { timings, .. } => **timings,
451 }
452 }
453}
454
455impl std::fmt::Display for StandaloneFixturePoolStage {
456 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457 formatter.write_str(match self {
458 Self::Build => "fixture build and snapshot capture",
459 Self::Restore => "fixture snapshot restore",
460 })
461 }
462}
463
464impl std::fmt::Display for StandaloneFixturePoolError {
465 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
466 match self {
467 Self::Preparation { stage, source, .. } => {
468 write!(formatter, "standalone {stage} failed: {source}")
469 }
470 Self::RecoveryFailed {
471 original, rebuild, ..
472 } => write!(
473 formatter,
474 "standalone fixture restore failed ({original}); rebuilding the slot also failed: {rebuild}",
475 ),
476 }
477 }
478}
479
480impl std::error::Error for StandaloneFixturePoolError {
481 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
482 match self {
483 Self::Preparation { source, .. } => Some(source.as_ref()),
484 Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
485 }
486 }
487}
488
489#[cfg(test)]
490mod tests {
491 use std::num::NonZeroUsize;
492
493 use super::{
494 CachedStandaloneCanisterFixturePool, SnapshotRestoreFunding, StandaloneCanisterFixture,
495 };
496
497 const _: CachedStandaloneCanisterFixturePool = {
498 let pool: CachedStandaloneCanisterFixturePool =
499 CachedStandaloneCanisterFixturePool::new(NonZeroUsize::new(1).unwrap(), build_fixture);
500 pool.with_restore_funding(SnapshotRestoreFunding::TopUpTo { minimum_cycles: 1 })
501 };
502
503 fn build_fixture() -> StandaloneCanisterFixture {
504 panic!("constructing an empty pool must not invoke its builder")
505 }
506
507 #[test]
508 fn runtime_capacity_preserves_lazy_fixture_construction() {
509 for capacity in [1, 2, 4, 8].map(|value| NonZeroUsize::new(value).unwrap()) {
510 let pool = CachedStandaloneCanisterFixturePool::new(capacity, build_fixture);
511 assert_eq!(pool.capacity(), capacity);
512 }
513 }
514}