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