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}
20
21impl StandaloneFixtureBaseline {
22 fn capture(fixture: StandaloneCanisterFixture) -> Result<Self, ControllerSnapshotError> {
23 let canister_id = fixture.canister_id();
24 let snapshots = fixture
25 .pocket_ic()
26 .capture_controller_snapshots(canister_id, [canister_id])?;
27
28 Ok(Self { fixture, snapshots })
29 }
30
31 fn restore(&self, funding: SnapshotRestoreFunding) -> Result<(), ControllerSnapshotError> {
32 self.fixture
33 .pocket_ic()
34 .restore_snapshots_with_captured_senders_and_funding(&self.snapshots, funding)
35 }
36}
37
38#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
40pub struct StandaloneFixturePoolTimings {
41 wait: Duration,
42 build: Option<Duration>,
43 restore: Option<Duration>,
44 stale_teardown: Option<Duration>,
45 total: Duration,
46}
47
48#[non_exhaustive]
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub enum StandaloneFixturePoolOutcome {
52 Built {
54 slot: usize,
56 timings: StandaloneFixturePoolTimings,
58 },
59 Restored {
61 slot: usize,
63 timings: StandaloneFixturePoolTimings,
65 },
66 Rebuilt {
68 slot: usize,
70 reason: StandaloneFixturePoolRebuildReason,
72 timings: StandaloneFixturePoolTimings,
74 },
75}
76
77#[non_exhaustive]
79#[derive(Clone, Copy, Debug, Eq, PartialEq)]
80pub enum StandaloneFixturePoolRebuildReason {
81 DeadPocketIcTransport,
83 PreviousRestoreFailure,
85 UnwindWhileLeased,
87}
88
89#[non_exhaustive]
91#[derive(Clone, Copy, Debug, Eq, PartialEq)]
92pub enum StandaloneFixturePoolStage {
93 Build,
95 Restore,
97}
98
99#[non_exhaustive]
101#[derive(Debug)]
102pub enum StandaloneFixturePoolError {
103 Preparation {
105 stage: StandaloneFixturePoolStage,
107 source: Box<ControllerSnapshotError>,
109 timings: Box<StandaloneFixturePoolTimings>,
111 },
112 RecoveryFailed {
114 original: Box<ControllerSnapshotError>,
116 rebuild: Box<ControllerSnapshotError>,
118 timings: Box<StandaloneFixturePoolTimings>,
120 },
121}
122
123pub struct CachedStandaloneCanisterFixturePool<
143 const CAPACITY: usize,
144 B = fn() -> StandaloneCanisterFixture,
145> {
146 build: B,
147 slots: OnceLock<BoundedSlotPool<StandaloneFixtureBaseline>>,
148 restore_funding: SnapshotRestoreFunding,
149}
150
151pub struct CachedStandaloneCanisterFixtureGuard<'a> {
156 slot: BoundedSlotLease<'a, StandaloneFixtureBaseline>,
157}
158
159impl<const CAPACITY: usize, B> CachedStandaloneCanisterFixturePool<CAPACITY, B>
160where
161 B: Fn() -> StandaloneCanisterFixture,
162{
163 #[must_use]
170 pub const fn new(build: B) -> Self {
171 assert!(CAPACITY > 0, "fixture pool capacity must be non-zero");
172
173 Self {
174 build,
175 slots: OnceLock::new(),
176 restore_funding: SnapshotRestoreFunding::Preserve,
177 }
178 }
179
180 #[must_use]
183 pub const fn with_restore_funding(mut self, funding: SnapshotRestoreFunding) -> Self {
184 self.restore_funding = funding;
185 self
186 }
187
188 pub fn acquire(
206 &self,
207 ) -> Result<
208 (
209 CachedStandaloneCanisterFixtureGuard<'_>,
210 StandaloneFixturePoolOutcome,
211 ),
212 StandaloneFixturePoolError,
213 > {
214 let total_started = Instant::now();
215 self.prepare_slot_with_outcome(self.slots().acquire(), total_started)
216 }
217
218 fn prepare_slot_with_outcome<'a>(
219 &'a self,
220 mut slot: BoundedSlotLease<'a, StandaloneFixtureBaseline>,
221 total_started: Instant,
222 ) -> Result<
223 (
224 CachedStandaloneCanisterFixtureGuard<'a>,
225 StandaloneFixturePoolOutcome,
226 ),
227 StandaloneFixturePoolError,
228 > {
229 let slot_index = slot.slot_index();
230 let mut timings = StandaloneFixturePoolTimings {
231 wait: slot.wait(),
232 ..StandaloneFixturePoolTimings::default()
233 };
234
235 if !slot.is_reusable() {
236 let rebuild_reason = Self::rebuild_reason_for_invalid_slot(&slot);
237 Self::discard_stale_slot(&mut slot, &mut timings);
238 let baseline = match self.build_slot(&mut timings) {
239 Ok(baseline) => baseline,
240 Err(source) => {
241 timings.total = total_started.elapsed();
242 return Err(StandaloneFixturePoolError::Preparation {
243 stage: StandaloneFixturePoolStage::Build,
244 source: Box::new(source),
245 timings: Box::new(timings),
246 });
247 }
248 };
249 slot.replace(baseline);
250 timings.total = total_started.elapsed();
251 let outcome = rebuild_reason.map_or_else(
252 || StandaloneFixturePoolOutcome::Built {
253 slot: slot_index,
254 timings,
255 },
256 |reason| StandaloneFixturePoolOutcome::Rebuilt {
257 slot: slot_index,
258 reason,
259 timings,
260 },
261 );
262 return Ok((CachedStandaloneCanisterFixtureGuard { slot }, outcome));
263 }
264
265 let restore_started = Instant::now();
266 let restore = slot
267 .get()
268 .expect("populated fixture pool slot must remain present")
269 .restore(self.restore_funding);
270 timings.restore = Some(restore_started.elapsed());
271 match restore {
272 Ok(()) => {
273 timings.total = total_started.elapsed();
274 Ok((
275 CachedStandaloneCanisterFixtureGuard { slot },
276 StandaloneFixturePoolOutcome::Restored {
277 slot: slot_index,
278 timings,
279 },
280 ))
281 }
282 Err(error) if transport::is_dead_pocket_ic_transport_error(&error) => {
283 Self::discard_stale_slot(&mut slot, &mut timings);
284 let baseline = match self.build_slot(&mut timings) {
285 Ok(baseline) => baseline,
286 Err(rebuild) => {
287 timings.total = total_started.elapsed();
288 return Err(StandaloneFixturePoolError::RecoveryFailed {
289 original: Box::new(error),
290 rebuild: Box::new(rebuild),
291 timings: Box::new(timings),
292 });
293 }
294 };
295 slot.replace(baseline);
296 timings.total = total_started.elapsed();
297 Ok((
298 CachedStandaloneCanisterFixtureGuard { slot },
299 StandaloneFixturePoolOutcome::Rebuilt {
300 slot: slot_index,
301 reason: StandaloneFixturePoolRebuildReason::DeadPocketIcTransport,
302 timings,
303 },
304 ))
305 }
306 Err(source) => {
307 slot.invalidate();
311 timings.total = total_started.elapsed();
312 Err(StandaloneFixturePoolError::Preparation {
313 stage: StandaloneFixturePoolStage::Restore,
314 source: Box::new(source),
315 timings: Box::new(timings),
316 })
317 }
318 }
319 }
320
321 fn rebuild_reason_for_invalid_slot(
322 slot: &BoundedSlotLease<'_, StandaloneFixtureBaseline>,
323 ) -> Option<StandaloneFixturePoolRebuildReason> {
324 if slot.invalidated_by_unwind() {
325 Some(StandaloneFixturePoolRebuildReason::UnwindWhileLeased)
326 } else {
327 slot.is_populated()
329 .then_some(StandaloneFixturePoolRebuildReason::PreviousRestoreFailure)
330 }
331 }
332
333 fn build_slot(
334 &self,
335 timings: &mut StandaloneFixturePoolTimings,
336 ) -> Result<StandaloneFixtureBaseline, ControllerSnapshotError> {
337 let started = Instant::now();
338 let result = StandaloneFixtureBaseline::capture((self.build)());
339 timings.build = Some(started.elapsed());
340 result
341 }
342
343 fn discard_stale_slot(
344 slot: &mut BoundedSlotLease<'_, StandaloneFixtureBaseline>,
345 timings: &mut StandaloneFixturePoolTimings,
346 ) {
347 if !slot.is_populated() {
348 return;
349 }
350 let started = Instant::now();
351 if let Some(stale) = slot.take() {
352 let _ = catch_unwind(AssertUnwindSafe(|| drop(stale)));
353 }
354 timings.stale_teardown = Some(started.elapsed());
355 }
356
357 fn slots(&self) -> &BoundedSlotPool<StandaloneFixtureBaseline> {
358 self.slots.get_or_init(|| {
359 BoundedSlotPool::new(
360 NonZeroUsize::new(CAPACITY).expect("fixture pool capacity must be non-zero"),
361 )
362 })
363 }
364}
365
366impl Deref for CachedStandaloneCanisterFixtureGuard<'_> {
367 type Target = StandaloneCanisterFixture;
368
369 fn deref(&self) -> &Self::Target {
370 &self
371 .slot
372 .get()
373 .expect("leased fixture pool slot must remain populated")
374 .fixture
375 }
376}
377
378impl StandaloneFixturePoolOutcome {
379 #[must_use]
381 pub const fn slot(&self) -> usize {
382 match self {
383 Self::Built { slot, .. } | Self::Restored { slot, .. } | Self::Rebuilt { slot, .. } => {
384 *slot
385 }
386 }
387 }
388
389 #[must_use]
391 pub const fn timings(&self) -> StandaloneFixturePoolTimings {
392 match self {
393 Self::Built { timings, .. }
394 | Self::Restored { timings, .. }
395 | Self::Rebuilt { timings, .. } => *timings,
396 }
397 }
398
399 #[must_use]
401 pub const fn is_reused(&self) -> bool {
402 matches!(self, Self::Restored { .. })
403 }
404}
405
406impl StandaloneFixturePoolTimings {
407 #[must_use]
409 pub const fn wait(self) -> Duration {
410 self.wait
411 }
412
413 #[must_use]
415 pub const fn build(self) -> Option<Duration> {
416 self.build
417 }
418
419 #[must_use]
421 pub const fn restore(self) -> Option<Duration> {
422 self.restore
423 }
424
425 #[must_use]
427 pub const fn stale_teardown(self) -> Option<Duration> {
428 self.stale_teardown
429 }
430
431 #[must_use]
433 pub const fn total(self) -> Duration {
434 self.total
435 }
436}
437
438impl std::fmt::Display for StandaloneFixturePoolTimings {
439 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
440 write!(
441 formatter,
442 "total={:?} wait={:?} build={:?} restore={:?} stale_teardown={:?}",
443 self.total, self.wait, self.build, self.restore, self.stale_teardown,
444 )
445 }
446}
447
448impl std::fmt::Display for StandaloneFixturePoolOutcome {
449 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
450 match self {
451 Self::Built { slot, timings } => write!(formatter, "built slot={slot} {timings}"),
452 Self::Restored { slot, timings } => {
453 write!(formatter, "restored slot={slot} {timings}")
454 }
455 Self::Rebuilt {
456 slot,
457 reason,
458 timings,
459 } => write!(formatter, "rebuilt slot={slot} reason={reason:?} {timings}"),
460 }
461 }
462}
463
464impl StandaloneFixturePoolError {
465 #[must_use]
467 pub const fn timings(&self) -> StandaloneFixturePoolTimings {
468 match self {
469 Self::Preparation { timings, .. } | Self::RecoveryFailed { timings, .. } => **timings,
470 }
471 }
472}
473
474impl std::fmt::Display for StandaloneFixturePoolStage {
475 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476 formatter.write_str(match self {
477 Self::Build => "fixture build and snapshot capture",
478 Self::Restore => "fixture snapshot restore",
479 })
480 }
481}
482
483impl std::fmt::Display for StandaloneFixturePoolError {
484 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485 match self {
486 Self::Preparation { stage, source, .. } => {
487 write!(formatter, "standalone {stage} failed: {source}")
488 }
489 Self::RecoveryFailed {
490 original, rebuild, ..
491 } => write!(
492 formatter,
493 "standalone fixture restore failed ({original}); rebuilding the slot also failed: {rebuild}",
494 ),
495 }
496 }
497}
498
499impl std::error::Error for StandaloneFixturePoolError {
500 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
501 match self {
502 Self::Preparation { source, .. } => Some(source.as_ref()),
503 Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
504 }
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::{
511 CachedStandaloneCanisterFixturePool, SnapshotRestoreFunding, StandaloneCanisterFixture,
512 };
513
514 const _: CachedStandaloneCanisterFixturePool<1> =
515 CachedStandaloneCanisterFixturePool::<1>::new(build_fixture)
516 .with_restore_funding(SnapshotRestoreFunding::TopUpTo { minimum_cycles: 1 });
517
518 fn build_fixture() -> StandaloneCanisterFixture {
519 panic!("constructing an empty pool must not invoke its builder")
520 }
521
522 #[test]
523 fn nonzero_pool_constructs() {
524 let _pool = CachedStandaloneCanisterFixturePool::<2>::new(build_fixture);
525 }
526}