Skip to main content

ic_testkit/pic/
baseline_pool.rs

1use std::{
2    collections::BTreeMap,
3    num::NonZeroUsize,
4    ops::Deref,
5    panic::{AssertUnwindSafe, catch_unwind},
6    time::{Duration, Instant},
7};
8
9use super::{
10    CachedPocketIcBaseline, ControllerSnapshotError, SnapshotRestoreFunding,
11    bounded_pool::{BoundedSlotLease, BoundedSlotPool},
12};
13
14/// Reset domain whose handling is declared by a pooled baseline recipe.
15#[non_exhaustive]
16#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
17pub enum ResetDomainKind {
18    /// PocketIC simulated time.
19    PocketIcTime,
20    /// Canisters outside the captured baseline set.
21    ExtraCanisters,
22    /// Pending ingress, timers, or cross-canister messages.
23    PendingMessages,
24    /// Subnet metrics, routing, allocation, or other subnet-global state.
25    SubnetState,
26    /// Files, processes, services, or other caller-owned resources.
27    ExternalResources,
28}
29
30/// PocketIC time handling required or achieved by one reset.
31#[non_exhaustive]
32#[derive(Clone, Copy, Debug, Eq, PartialEq)]
33pub enum TimeResetPolicy {
34    /// Preserve the current simulator time rather than claiming it was reset.
35    PreserveCurrent,
36    /// Restore the exact time recorded by the recipe baseline.
37    RestoreBaseline,
38    /// Treat any relevant time mutation as requiring slot reconstruction.
39    RebuildOnMutation,
40}
41
42/// Extra-canister handling required or achieved by one reset.
43#[non_exhaustive]
44#[derive(Clone, Copy, Debug, Eq, PartialEq)]
45pub enum ExtraCanisterPolicy {
46    /// Validate that the baseline canister set is unchanged.
47    RequireBaselineSet,
48    /// Remove canisters explicitly tracked by the recipe.
49    RemoveTracked,
50    /// Treat any extra-canister change as requiring slot reconstruction.
51    RebuildOnChange,
52}
53
54/// Generic handling for reset domains without a more specific policy.
55#[non_exhaustive]
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum StateResetPolicy {
58    /// Reset the domain through caller-owned recipe logic.
59    ResetByRecipe,
60    /// Validate that the domain remained unchanged.
61    ValidateUnchanged,
62    /// Explicitly declare the domain irrelevant to this recipe's guarantees.
63    IrrelevantByRecipeContract,
64    /// Treat any relevant change as requiring slot reconstruction.
65    RebuildOnChange,
66}
67
68/// Policy for one non-snapshot reset domain.
69///
70/// [`ResetRequirements`] declares policies that a recipe requires;
71/// [`ResetReceipt`] reports policies achieved by its reset operation. The pool
72/// checks every required domain against the reported policy before reuse.
73///
74/// ```
75/// use ic_testkit::pic::{
76///     SnapshotRestoreFunding, ResetDomainKind, ResetDomainPolicy, ResetReceipt,
77///     ResetRequirements, TimeResetPolicy,
78/// };
79///
80/// let requirements = ResetRequirements::try_new(
81///     SnapshotRestoreFunding::Preserve,
82///     [ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent)],
83/// )?;
84/// // The reset operation deliberately preserves simulated time.
85/// let receipt = ResetReceipt::try_new([
86///     ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent),
87/// ])?;
88/// assert_eq!(
89///     requirements.get(ResetDomainKind::PocketIcTime),
90///     receipt.get(ResetDomainKind::PocketIcTime),
91/// );
92/// # Ok::<(), ic_testkit::pic::BaselinePoolContractError>(())
93/// ```
94#[non_exhaustive]
95#[derive(Clone, Debug, Eq, PartialEq)]
96pub enum ResetDomainPolicy {
97    /// PocketIC time policy.
98    PocketIcTime(TimeResetPolicy),
99    /// Extra-canister policy.
100    ExtraCanisters(ExtraCanisterPolicy),
101    /// Pending-message policy.
102    PendingMessages(StateResetPolicy),
103    /// Subnet-state policy.
104    SubnetState(StateResetPolicy),
105    /// External-resource policy.
106    ExternalResources(StateResetPolicy),
107}
108
109/// Typed reset guarantees required by one fixture recipe.
110#[derive(Clone, Debug, Eq, PartialEq)]
111pub struct ResetRequirements {
112    funding: SnapshotRestoreFunding,
113    domains: BTreeMap<ResetDomainKind, ResetDomainPolicy>,
114}
115
116/// Typed reset guarantees achieved by one preparation pass.
117#[derive(Clone, Debug, Default, Eq, PartialEq)]
118pub struct ResetReceipt(BTreeMap<ResetDomainKind, ResetDomainPolicy>);
119
120/// Contract failure while constructing or verifying recipe reset evidence.
121#[non_exhaustive]
122#[derive(Clone, Debug, Eq, PartialEq)]
123pub enum BaselinePoolContractError {
124    /// A reset domain was declared more than once.
125    DuplicateResetDomain { domain: ResetDomainKind },
126    /// A required reset domain had no matching achievement.
127    MissingResetDomain { domain: ResetDomainKind },
128    /// A reset achievement did not satisfy the required policy.
129    ResetPolicyMismatch {
130        requirement: ResetDomainPolicy,
131        achievement: ResetDomainPolicy,
132    },
133}
134
135/// Whether validation is observing a newly built or restored baseline.
136#[non_exhaustive]
137#[derive(Clone, Debug, Eq, PartialEq)]
138pub enum PreparedBaseline {
139    /// The recipe just built this baseline.
140    Built,
141    /// The pool restored an existing baseline and the recipe reset its other state.
142    Restored {
143        /// Typed non-snapshot reset receipt.
144        reset: ResetReceipt,
145    },
146}
147
148/// Recipe stage associated with a structured preparation failure.
149#[non_exhaustive]
150#[derive(Clone, Copy, Debug, Eq, PartialEq)]
151pub enum BaselinePreparationStage {
152    /// Constructing a new baseline.
153    Build,
154    /// Restoring captured canisters.
155    RestoreCanisters,
156    /// Resetting state outside the snapshots.
157    ResetNonSnapshotState,
158    /// Driving the restored topology to readiness.
159    DriveToReadiness,
160    /// Validating a newly built baseline.
161    ValidateBuilt,
162    /// Validating a restored baseline.
163    ValidateRestored,
164}
165
166/// Why an invalid or failed slot was reconstructed.
167#[non_exhaustive]
168#[derive(Clone, Debug, Eq, PartialEq)]
169pub enum RebuildReason {
170    /// PocketIC transport was no longer reachable.
171    DeadPocketIcTransport,
172    /// Captured snapshot restoration failed.
173    SnapshotRestoreFailure,
174    /// Non-snapshot reset failed.
175    ResetFailure,
176    /// Readiness or quiescence could not be established.
177    ReadinessFailure,
178    /// Required and achieved reset domains did not match.
179    ResetCoverageMismatch,
180    /// Final invariant validation failed.
181    InvariantValidationFailure,
182    /// A caller explicitly invalidated its lease.
183    ExplicitLeaseInvalidation,
184    /// A lease was dropped while its thread was unwinding.
185    UnwindWhileLeased,
186    /// Recipe-specific structured reason.
187    RecipeClassified { code: String },
188}
189
190/// Recipe decision for a failed restored-slot preparation stage.
191#[non_exhaustive]
192#[derive(Clone, Debug, Eq, PartialEq)]
193pub enum FailureDisposition {
194    /// Return the failure without rebuilding during this acquisition.
195    Fatal,
196    /// Invalidate and rebuild the slot once.
197    Rebuild(RebuildReason),
198}
199
200/// Timings for one baseline-pool acquisition.
201#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
202pub struct BaselinePoolTimings {
203    wait: Duration,
204    build: Option<Duration>,
205    restore: Option<Duration>,
206    reset: Option<Duration>,
207    readiness: Option<Duration>,
208    validation: Option<Duration>,
209    stale_teardown: Option<Duration>,
210    total: Duration,
211}
212
213/// One successful acquisition's slot, timings and preparation disposition.
214#[derive(Clone, Debug, Eq, PartialEq)]
215pub struct BaselinePoolOutcome {
216    /// Diagnostic slot index.
217    pub slot: usize,
218    /// Acquisition phase timings.
219    pub timings: BaselinePoolTimings,
220    /// Whether this acquisition built, restored or rebuilt the baseline.
221    pub disposition: BaselinePoolDisposition,
222}
223
224/// Whether a baseline-pool lease was built, restored, or rebuilt.
225#[non_exhaustive]
226#[derive(Clone, Debug, Eq, PartialEq)]
227pub enum BaselinePoolDisposition {
228    /// An empty slot was constructed and validated.
229    Built,
230    /// An existing slot was restored, reset, and validated.
231    Restored,
232    /// An invalid or failed slot was reconstructed and validated.
233    Rebuilt(RebuildReason),
234}
235
236/// One failed recipe or contract stage while preparing a baseline slot.
237#[non_exhaustive]
238#[derive(Debug)]
239pub enum BaselinePoolPreparationError<E> {
240    /// Caller-owned recipe logic returned an error.
241    Recipe {
242        /// Failed lifecycle stage.
243        stage: BaselinePreparationStage,
244        /// Caller-owned structured source error.
245        source: E,
246    },
247    /// Canonical restoration of the captured snapshot set failed.
248    Restore(ControllerSnapshotError),
249    /// Typed non-snapshot reset evidence violated the pool contract.
250    Contract(BaselinePoolContractError),
251}
252
253/// Failure to acquire a validated baseline-pool lease.
254#[non_exhaustive]
255#[derive(Debug)]
256pub enum BaselinePoolError<E> {
257    /// Initial construction or a non-rebuilt preparation failed.
258    Preparation {
259        /// Recipe or contract failure that stopped acquisition.
260        error: BaselinePoolPreparationError<E>,
261        /// Phase timings recorded before acquisition failed.
262        timings: Box<BaselinePoolTimings>,
263    },
264    /// Reused-slot preparation failed and its one rebuild attempt also failed.
265    RecoveryFailed {
266        /// Original restore/reset/readiness/validation failure.
267        original: Box<BaselinePoolPreparationError<E>>,
268        /// Failure while rebuilding or validating the replacement.
269        rebuild: Box<BaselinePoolPreparationError<E>>,
270        /// Combined timings for preparation, stale teardown, and rebuilding.
271        timings: Box<BaselinePoolTimings>,
272    },
273}
274
275/// Caller-owned build, preflight and non-snapshot reset policy for one pooled baseline.
276pub trait PocketIcBaselineRecipe: Send + Sync + 'static {
277    /// Metadata retained beside every baseline owned by this recipe.
278    type Metadata: Send + 'static;
279    /// Structured caller error shared by recipe lifecycle stages.
280    type Error: std::error::Error + Send + Sync + 'static;
281
282    /// Reset guarantees required before an existing slot may be reused.
283    fn reset_requirements(&self) -> &ResetRequirements;
284
285    /// Construct and capture one complete baseline.
286    fn build(&self) -> Result<CachedPocketIcBaseline<Self::Metadata>, Self::Error>;
287
288    /// Admit recipe-specific preconditions before the pool restores snapshots.
289    ///
290    /// Most recipes need no preflight. Checks such as exact current cycle-balance
291    /// guards run here, before restoration can charge cycles. This cannot replace
292    /// the pool's canonical restore or manufacture its completion evidence.
293    fn prepare_restore(
294        &self,
295        _baseline: &CachedPocketIcBaseline<Self::Metadata>,
296    ) -> Result<(), Self::Error> {
297        Ok(())
298    }
299
300    /// Reset state not covered by canister snapshots.
301    fn reset_non_snapshot_state(
302        &self,
303        baseline: &CachedPocketIcBaseline<Self::Metadata>,
304    ) -> Result<ResetReceipt, Self::Error>;
305
306    /// Drive the topology to the recipe's readiness boundary.
307    /// Successful completion permits final invariant validation.
308    fn drive_to_readiness(
309        &self,
310        baseline: &CachedPocketIcBaseline<Self::Metadata>,
311    ) -> Result<(), Self::Error>;
312
313    /// Validate the same baseline invariants after build and restore.
314    /// Success admits this pool-owned recipe's baseline for leasing.
315    fn validate(
316        &self,
317        baseline: &CachedPocketIcBaseline<Self::Metadata>,
318        preparation: &PreparedBaseline,
319    ) -> Result<(), Self::Error>;
320
321    /// Classify a restored-slot recipe failure as fatal or rebuildable.
322    fn classify_failure(
323        &self,
324        stage: BaselinePreparationStage,
325        _error: &(dyn std::error::Error + 'static),
326    ) -> FailureDisposition {
327        FailureDisposition::Rebuild(stage.default_rebuild_reason())
328    }
329}
330
331/// Caller-owned runtime-capacity pool of independently restorable PocketIC baselines.
332///
333/// One pool structurally owns one [`PocketIcBaselineRecipe`]. A warm
334/// acquisition restores the complete captured canister set, applies the
335/// recipe's non-snapshot reset, reaches its readiness boundary, checks typed
336/// reset coverage, and validates final invariants before exposing a lease.
337/// Each capacity slot owns an independent PocketIC instance.
338///
339/// Snapshot reuse is not a complete PocketIC rollback. The recipe must account
340/// for time, extra canisters, pending messages, subnet state, cycles, and
341/// external resources when those domains matter to its tests.
342pub struct CachedPocketIcBaselinePool<R>
343where
344    R: PocketIcBaselineRecipe,
345{
346    recipe: R,
347    slots: BoundedSlotPool<CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
348}
349
350/// Exclusive lease of one validated pooled PocketIC baseline.
351pub struct CachedPocketIcBaselinePoolGuard<'a, R>
352where
353    R: PocketIcBaselineRecipe,
354{
355    slot: BoundedSlotLease<'a, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
356}
357
358impl ResetDomainPolicy {
359    /// Domain governed by this policy.
360    #[must_use]
361    pub const fn domain(&self) -> ResetDomainKind {
362        match self {
363            Self::PocketIcTime(_) => ResetDomainKind::PocketIcTime,
364            Self::ExtraCanisters(_) => ResetDomainKind::ExtraCanisters,
365            Self::PendingMessages(_) => ResetDomainKind::PendingMessages,
366            Self::SubnetState(_) => ResetDomainKind::SubnetState,
367            Self::ExternalResources(_) => ResetDomainKind::ExternalResources,
368        }
369    }
370}
371
372fn collect_reset_domains(
373    policies: impl IntoIterator<Item = ResetDomainPolicy>,
374) -> Result<BTreeMap<ResetDomainKind, ResetDomainPolicy>, BaselinePoolContractError> {
375    let mut domains = BTreeMap::new();
376    for policy in policies {
377        let domain = policy.domain();
378        if domains.insert(domain, policy).is_some() {
379            return Err(BaselinePoolContractError::DuplicateResetDomain { domain });
380        }
381    }
382    Ok(domains)
383}
384
385impl ResetRequirements {
386    /// Construct a duplicate-checked reset requirement set.
387    ///
388    /// The pool restores the complete captured set using `funding`.
389    /// `requirements` describe only non-snapshot domains.
390    pub fn try_new<I>(
391        funding: SnapshotRestoreFunding,
392        requirements: I,
393    ) -> Result<Self, BaselinePoolContractError>
394    where
395        I: IntoIterator<Item = ResetDomainPolicy>,
396    {
397        Ok(Self {
398            funding,
399            domains: collect_reset_domains(requirements)?,
400        })
401    }
402
403    /// Funding selected once for the pool's canonical snapshot restore.
404    #[must_use]
405    pub const fn funding(&self) -> SnapshotRestoreFunding {
406        self.funding
407    }
408
409    /// Read the requirement for one domain.
410    #[must_use]
411    pub fn get(&self, domain: ResetDomainKind) -> Option<&ResetDomainPolicy> {
412        self.domains.get(&domain)
413    }
414
415    /// Iterate over requirements in deterministic domain order.
416    pub fn iter(&self) -> impl Iterator<Item = &ResetDomainPolicy> {
417        self.domains.values()
418    }
419
420    fn verify(&self, receipt: &ResetReceipt) -> Result<(), BaselinePoolContractError> {
421        for (domain, requirement) in &self.domains {
422            let Some(achievement) = receipt.0.get(domain) else {
423                return Err(BaselinePoolContractError::MissingResetDomain { domain: *domain });
424            };
425            if achievement != requirement {
426                return Err(BaselinePoolContractError::ResetPolicyMismatch {
427                    requirement: requirement.clone(),
428                    achievement: achievement.clone(),
429                });
430            }
431        }
432        Ok(())
433    }
434}
435
436impl ResetReceipt {
437    /// Construct a duplicate-checked non-snapshot reset achievement set.
438    ///
439    /// Snapshot restoration is performed and recorded by the pool itself.
440    pub fn try_new<I>(achievements: I) -> Result<Self, BaselinePoolContractError>
441    where
442        I: IntoIterator<Item = ResetDomainPolicy>,
443    {
444        Ok(Self(collect_reset_domains(achievements)?))
445    }
446
447    /// Create an empty receipt for recipes with no non-snapshot reset achievements.
448    #[must_use]
449    pub const fn empty() -> Self {
450        Self(BTreeMap::new())
451    }
452
453    /// Read the achievement for one domain.
454    #[must_use]
455    pub fn get(&self, domain: ResetDomainKind) -> Option<&ResetDomainPolicy> {
456        self.0.get(&domain)
457    }
458
459    /// Iterate over achievements in deterministic domain order.
460    pub fn iter(&self) -> impl Iterator<Item = &ResetDomainPolicy> {
461        self.0.values()
462    }
463}
464
465impl BaselinePreparationStage {
466    /// Default rebuild reason used by [`PocketIcBaselineRecipe::classify_failure`].
467    ///
468    /// Recipes that override classification can use this for their fallback
469    /// after handling a more specific error such as dead PocketIC transport.
470    #[must_use]
471    pub fn default_rebuild_reason(self) -> RebuildReason {
472        match self {
473            Self::RestoreCanisters => RebuildReason::SnapshotRestoreFailure,
474            Self::ResetNonSnapshotState => RebuildReason::ResetFailure,
475            Self::DriveToReadiness => RebuildReason::ReadinessFailure,
476            Self::ValidateRestored | Self::ValidateBuilt => {
477                RebuildReason::InvariantValidationFailure
478            }
479            Self::Build => RebuildReason::RecipeClassified {
480                code: "build".to_owned(),
481            },
482        }
483    }
484}
485
486impl BaselinePoolTimings {
487    /// Time spent waiting for a capacity slot.
488    #[must_use]
489    pub const fn wait(self) -> Duration {
490        self.wait
491    }
492
493    /// Time spent constructing a new baseline.
494    #[must_use]
495    pub const fn build(self) -> Option<Duration> {
496        self.build
497    }
498
499    /// Time spent restoring captured canisters.
500    #[must_use]
501    pub const fn restore(self) -> Option<Duration> {
502        self.restore
503    }
504
505    /// Time spent resetting non-snapshot state.
506    #[must_use]
507    pub const fn reset(self) -> Option<Duration> {
508        self.reset
509    }
510
511    /// Time spent driving the topology to readiness.
512    #[must_use]
513    pub const fn readiness(self) -> Option<Duration> {
514        self.readiness
515    }
516
517    /// Time spent validating final baseline invariants.
518    #[must_use]
519    pub const fn validation(self) -> Option<Duration> {
520        self.validation
521    }
522
523    /// Time spent dropping an invalid baseline before rebuilding.
524    #[must_use]
525    pub const fn stale_teardown(self) -> Option<Duration> {
526        self.stale_teardown
527    }
528
529    /// Complete acquisition duration.
530    #[must_use]
531    pub const fn total(self) -> Duration {
532        self.total
533    }
534}
535
536impl std::fmt::Display for BaselinePoolTimings {
537    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
538        write!(
539            formatter,
540            "total={:?} wait={:?} build={:?} restore={:?} reset={:?} readiness={:?} validation={:?} stale_teardown={:?}",
541            self.total,
542            self.wait,
543            self.build,
544            self.restore,
545            self.reset,
546            self.readiness,
547            self.validation,
548            self.stale_teardown,
549        )
550    }
551}
552
553impl std::fmt::Display for BaselinePoolOutcome {
554    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
555        match &self.disposition {
556            BaselinePoolDisposition::Built => formatter.write_str("built")?,
557            BaselinePoolDisposition::Restored => formatter.write_str("restored")?,
558            BaselinePoolDisposition::Rebuilt(reason) => {
559                write!(
560                    formatter,
561                    "rebuilt slot={} reason={reason:?} {}",
562                    self.slot, self.timings
563                )?;
564                return Ok(());
565            }
566        }
567        write!(formatter, " slot={} {}", self.slot, self.timings)
568    }
569}
570
571impl<E> BaselinePoolError<E> {
572    /// Timings recorded before this acquisition failed.
573    #[must_use]
574    pub const fn timings(&self) -> BaselinePoolTimings {
575        match self {
576            Self::Preparation { timings, .. } | Self::RecoveryFailed { timings, .. } => **timings,
577        }
578    }
579}
580
581impl<R> CachedPocketIcBaselinePool<R>
582where
583    R: PocketIcBaselineRecipe,
584{
585    /// Create a runtime-capacity pool that structurally owns one recipe.
586    ///
587    /// Construction is const and lazy: slots are allocated on first acquisition,
588    /// and the recipe builds a baseline only when a slot needs one.
589    #[must_use]
590    pub const fn new(capacity: NonZeroUsize, recipe: R) -> Self {
591        Self {
592            recipe,
593            slots: BoundedSlotPool::new(capacity),
594        }
595    }
596
597    /// Maximum number of simultaneously leased PocketIC baselines.
598    #[must_use]
599    pub const fn capacity(&self) -> NonZeroUsize {
600        self.slots.capacity()
601    }
602
603    /// Acquire one fully built or restored and validated baseline lease.
604    ///
605    /// A rebuildable warm-preparation failure discards the stale slot and
606    /// performs at most one build attempt. Recipe and caller panics are never
607    /// converted into cache misses; the lease is invalidated while the panic
608    /// continues unwinding.
609    ///
610    /// # Errors
611    ///
612    /// Returns a stage-specific recipe or contract error. If warm preparation
613    /// and its one recovery build both fail, the error preserves both causes.
614    pub fn acquire(
615        &self,
616    ) -> Result<
617        (CachedPocketIcBaselinePoolGuard<'_, R>, BaselinePoolOutcome),
618        BaselinePoolError<R::Error>,
619    > {
620        let total_started = Instant::now();
621        let mut slot = self.slots.acquire();
622        let mut timings = BaselinePoolTimings {
623            wait: slot.wait(),
624            ..BaselinePoolTimings::default()
625        };
626        let slot_index = slot.slot_index();
627
628        let (rebuild_reason, original) = if slot.is_reusable() {
629            match self.prepare_reused(&slot, &mut timings) {
630                Ok(()) => {
631                    timings.total = total_started.elapsed();
632                    return Ok((
633                        CachedPocketIcBaselinePoolGuard { slot },
634                        BaselinePoolOutcome {
635                            slot: slot_index,
636                            timings,
637                            disposition: BaselinePoolDisposition::Restored,
638                        },
639                    ));
640                }
641                Err(original) => match self.failure_disposition(&original) {
642                    FailureDisposition::Fatal => {
643                        // A failed restore may have partially changed the instance.
644                        // Discard it without treating a fatal failure as a rebuild.
645                        Self::discard_stale_slot(&mut slot, &mut timings);
646                        timings.total = total_started.elapsed();
647                        return Err(BaselinePoolError::Preparation {
648                            error: original,
649                            timings: Box::new(timings),
650                        });
651                    }
652                    FailureDisposition::Rebuild(reason) => (Some(reason), Some(original)),
653                },
654            }
655        } else {
656            let reason = if slot.invalidated_by_unwind() {
657                Some(RebuildReason::UnwindWhileLeased)
658            } else {
659                slot.invalidation_reason().cloned()
660            };
661            (reason, None)
662        };
663
664        if slot.is_populated() {
665            Self::discard_stale_slot(&mut slot, &mut timings);
666        }
667        let build = self.build_slot(&mut slot, &mut timings);
668        timings.total = total_started.elapsed();
669        if let Err(error) = build {
670            return Err(match original {
671                Some(original) => BaselinePoolError::RecoveryFailed {
672                    original: Box::new(original),
673                    rebuild: Box::new(error),
674                    timings: Box::new(timings),
675                },
676                None => BaselinePoolError::Preparation {
677                    error,
678                    timings: Box::new(timings),
679                },
680            });
681        }
682
683        let outcome = BaselinePoolOutcome {
684            slot: slot_index,
685            timings,
686            disposition: rebuild_reason.map_or(
687                BaselinePoolDisposition::Built,
688                BaselinePoolDisposition::Rebuilt,
689            ),
690        };
691        Ok((CachedPocketIcBaselinePoolGuard { slot }, outcome))
692    }
693
694    fn prepare_reused(
695        &self,
696        slot: &BoundedSlotLease<'_, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
697        timings: &mut BaselinePoolTimings,
698    ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
699        let baseline = slot
700            .get()
701            .expect("reusable baseline slot must be populated");
702
703        let started = Instant::now();
704        let restore = self
705            .recipe
706            .prepare_restore(baseline)
707            .map_err(|source| BaselinePoolPreparationError::Recipe {
708                stage: BaselinePreparationStage::RestoreCanisters,
709                source,
710            })
711            .and_then(|()| {
712                baseline
713                    .restore(self.recipe.reset_requirements().funding())
714                    .map_err(BaselinePoolPreparationError::Restore)
715            });
716        add_timing(&mut timings.restore, started.elapsed());
717        restore?;
718
719        let started = Instant::now();
720        let reset_result = self.recipe.reset_non_snapshot_state(baseline);
721        add_timing(&mut timings.reset, started.elapsed());
722        let reset = reset_result.map_err(|source| BaselinePoolPreparationError::Recipe {
723            stage: BaselinePreparationStage::ResetNonSnapshotState,
724            source,
725        })?;
726
727        let started = Instant::now();
728        let readiness_result = self.recipe.drive_to_readiness(baseline);
729        add_timing(&mut timings.readiness, started.elapsed());
730        readiness_result.map_err(|source| BaselinePoolPreparationError::Recipe {
731            stage: BaselinePreparationStage::DriveToReadiness,
732            source,
733        })?;
734        self.recipe
735            .reset_requirements()
736            .verify(&reset)
737            .map_err(BaselinePoolPreparationError::Contract)?;
738
739        let preparation = PreparedBaseline::Restored { reset };
740        self.validate_baseline(
741            baseline,
742            &preparation,
743            BaselinePreparationStage::ValidateRestored,
744            timings,
745        )?;
746        Ok(())
747    }
748
749    fn build_slot(
750        &self,
751        slot: &mut BoundedSlotLease<'_, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
752        timings: &mut BaselinePoolTimings,
753    ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
754        let started = Instant::now();
755        let build = self.recipe.build();
756        add_timing(&mut timings.build, started.elapsed());
757        let baseline = build.map_err(|source| BaselinePoolPreparationError::Recipe {
758            stage: BaselinePreparationStage::Build,
759            source,
760        })?;
761
762        if let Err(error) = self.validate_baseline(
763            &baseline,
764            &PreparedBaseline::Built,
765            BaselinePreparationStage::ValidateBuilt,
766            timings,
767        ) {
768            drop_baseline_safely(baseline);
769            return Err(error);
770        }
771        let replaced = slot.replace(baseline);
772        debug_assert!(replaced.is_none());
773        Ok(())
774    }
775
776    fn validate_baseline(
777        &self,
778        baseline: &CachedPocketIcBaseline<R::Metadata>,
779        preparation: &PreparedBaseline,
780        stage: BaselinePreparationStage,
781        timings: &mut BaselinePoolTimings,
782    ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
783        let started = Instant::now();
784        let validation = self.recipe.validate(baseline, preparation);
785        add_timing(&mut timings.validation, started.elapsed());
786        validation.map_err(|source| BaselinePoolPreparationError::Recipe { stage, source })
787    }
788
789    fn failure_disposition(
790        &self,
791        error: &BaselinePoolPreparationError<R::Error>,
792    ) -> FailureDisposition {
793        match error {
794            BaselinePoolPreparationError::Recipe { stage, source } => {
795                self.recipe.classify_failure(*stage, source)
796            }
797            BaselinePoolPreparationError::Restore(source) => self
798                .recipe
799                .classify_failure(BaselinePreparationStage::RestoreCanisters, source),
800            BaselinePoolPreparationError::Contract(_) => {
801                FailureDisposition::Rebuild(RebuildReason::ResetCoverageMismatch)
802            }
803        }
804    }
805
806    fn discard_stale_slot(
807        slot: &mut BoundedSlotLease<'_, CachedPocketIcBaseline<R::Metadata>, RebuildReason>,
808        timings: &mut BaselinePoolTimings,
809    ) {
810        let started = Instant::now();
811        if let Some(stale) = slot.take() {
812            drop_baseline_safely(stale);
813        }
814        timings.stale_teardown = Some(started.elapsed());
815    }
816}
817
818impl<R> CachedPocketIcBaselinePoolGuard<'_, R>
819where
820    R: PocketIcBaselineRecipe,
821{
822    /// Diagnostic slot index held by this lease.
823    #[must_use]
824    pub const fn slot(&self) -> usize {
825        self.slot.slot_index()
826    }
827
828    /// Mark this slot non-reusable with a structured rebuild reason.
829    pub fn invalidate(&mut self, reason: RebuildReason) {
830        self.slot.invalidate(reason);
831    }
832}
833
834impl<R> Deref for CachedPocketIcBaselinePoolGuard<'_, R>
835where
836    R: PocketIcBaselineRecipe,
837{
838    type Target = CachedPocketIcBaseline<R::Metadata>;
839
840    fn deref(&self) -> &Self::Target {
841        self.slot
842            .get()
843            .expect("leased baseline pool slot must be populated")
844    }
845}
846
847const fn add_timing(total: &mut Option<Duration>, elapsed: Duration) {
848    *total = Some(match *total {
849        None => elapsed,
850        Some(previous) => previous.saturating_add(elapsed),
851    });
852}
853
854fn drop_baseline_safely<M>(baseline: CachedPocketIcBaseline<M>) {
855    let _ = catch_unwind(AssertUnwindSafe(|| drop(baseline)));
856}
857
858impl std::fmt::Display for BaselinePreparationStage {
859    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
860        formatter.write_str(match self {
861            Self::Build => "build",
862            Self::RestoreCanisters => "canister restore",
863            Self::ResetNonSnapshotState => "non-snapshot reset",
864            Self::DriveToReadiness => "readiness",
865            Self::ValidateBuilt => "built-baseline validation",
866            Self::ValidateRestored => "restored-baseline validation",
867        })
868    }
869}
870
871impl std::fmt::Display for BaselinePoolContractError {
872    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
873        match self {
874            Self::DuplicateResetDomain { domain } => {
875                write!(
876                    formatter,
877                    "reset domain {domain:?} was reported more than once"
878                )
879            }
880            Self::MissingResetDomain { domain } => {
881                write!(
882                    formatter,
883                    "required reset domain {domain:?} was not achieved"
884                )
885            }
886            Self::ResetPolicyMismatch {
887                requirement,
888                achievement,
889            } => write!(
890                formatter,
891                "reset achievement {achievement:?} does not satisfy {requirement:?}",
892            ),
893        }
894    }
895}
896
897impl std::error::Error for BaselinePoolContractError {}
898
899impl<E> std::fmt::Display for BaselinePoolPreparationError<E>
900where
901    E: std::fmt::Display,
902{
903    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
904        match self {
905            Self::Recipe { stage, source } => {
906                write!(formatter, "baseline {stage} failed: {source}")
907            }
908            Self::Restore(source) => {
909                write!(formatter, "baseline canister restore failed: {source}")
910            }
911            Self::Contract(error) => write!(formatter, "baseline pool contract failed: {error}"),
912        }
913    }
914}
915
916impl<E> std::error::Error for BaselinePoolPreparationError<E>
917where
918    E: std::error::Error + 'static,
919{
920    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
921        match self {
922            Self::Recipe { source, .. } => Some(source),
923            Self::Restore(source) => Some(source),
924            Self::Contract(error) => Some(error),
925        }
926    }
927}
928
929impl<E> std::fmt::Display for BaselinePoolError<E>
930where
931    E: std::fmt::Display,
932{
933    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
934        match self {
935            Self::Preparation { error, .. } => error.fmt(formatter),
936            Self::RecoveryFailed {
937                original, rebuild, ..
938            } => write!(
939                formatter,
940                "baseline preparation failed ({original}); rebuilding the slot also failed: {rebuild}",
941            ),
942        }
943    }
944}
945
946impl<E> std::error::Error for BaselinePoolError<E>
947where
948    E: std::error::Error + 'static,
949{
950    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
951        match self {
952            Self::Preparation { error, .. } => Some(error),
953            Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
954        }
955    }
956}
957
958#[cfg(test)]
959mod tests {
960    use super::{
961        BaselinePoolContractError, BaselinePoolTimings, ExtraCanisterPolicy, ResetDomainKind,
962        ResetDomainPolicy, ResetReceipt, ResetRequirements, SnapshotRestoreFunding,
963        StateResetPolicy, TimeResetPolicy, add_timing,
964    };
965    use std::time::Duration;
966
967    #[test]
968    fn phase_timing_preserves_absence_zero_and_saturation() {
969        let mut timings = BaselinePoolTimings::default();
970        assert_eq!(timings.restore(), None);
971        add_timing(&mut timings.restore, Duration::ZERO);
972        assert_eq!(timings.restore(), Some(Duration::ZERO));
973        add_timing(&mut timings.restore, Duration::from_secs(2));
974        assert_eq!(timings.restore(), Some(Duration::from_secs(2)));
975        add_timing(&mut timings.restore, Duration::MAX);
976        assert_eq!(timings.restore(), Some(Duration::MAX));
977        assert_eq!(timings.build(), None);
978    }
979
980    #[test]
981    fn reset_declarations_and_receipts_reject_duplicate_domains() {
982        let policies = [
983            ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent),
984            ResetDomainPolicy::PocketIcTime(TimeResetPolicy::RebuildOnMutation),
985        ];
986        let error = BaselinePoolContractError::DuplicateResetDomain {
987            domain: ResetDomainKind::PocketIcTime,
988        };
989        assert_eq!(
990            ResetRequirements::try_new(SnapshotRestoreFunding::Preserve, policies.clone()),
991            Err(error.clone()),
992        );
993        assert_eq!(ResetReceipt::try_new(policies), Err(error));
994    }
995
996    #[test]
997    fn required_policy_must_match_achieved_policy() {
998        for (requirement, achievement) in [
999            (
1000                ResetDomainPolicy::PocketIcTime(TimeResetPolicy::PreserveCurrent),
1001                ResetDomainPolicy::PocketIcTime(TimeResetPolicy::RebuildOnMutation),
1002            ),
1003            (
1004                ResetDomainPolicy::ExtraCanisters(ExtraCanisterPolicy::RequireBaselineSet),
1005                ResetDomainPolicy::ExtraCanisters(ExtraCanisterPolicy::RemoveTracked),
1006            ),
1007            (
1008                ResetDomainPolicy::PendingMessages(StateResetPolicy::ValidateUnchanged),
1009                ResetDomainPolicy::PendingMessages(StateResetPolicy::IrrelevantByRecipeContract),
1010            ),
1011            (
1012                ResetDomainPolicy::SubnetState(StateResetPolicy::ResetByRecipe),
1013                ResetDomainPolicy::SubnetState(StateResetPolicy::RebuildOnChange),
1014            ),
1015            (
1016                ResetDomainPolicy::ExternalResources(StateResetPolicy::ValidateUnchanged),
1017                ResetDomainPolicy::ExternalResources(StateResetPolicy::ResetByRecipe),
1018            ),
1019        ] {
1020            let requirements =
1021                ResetRequirements::try_new(SnapshotRestoreFunding::Preserve, [requirement.clone()])
1022                    .unwrap();
1023            let receipt = ResetReceipt::try_new([requirement.clone()]).unwrap();
1024            assert_eq!(requirements.verify(&receipt), Ok(()));
1025            assert_eq!(
1026                requirements.verify(&ResetReceipt::empty()),
1027                Err(BaselinePoolContractError::MissingResetDomain {
1028                    domain: requirement.domain(),
1029                }),
1030            );
1031            let receipt = ResetReceipt::try_new([achievement.clone()]).unwrap();
1032            assert_eq!(
1033                requirements.verify(&receipt),
1034                Err(BaselinePoolContractError::ResetPolicyMismatch {
1035                    requirement,
1036                    achievement,
1037                }),
1038            );
1039        }
1040    }
1041}