Skip to main content

ic_testkit/pic/
baseline_pool.rs

1use candid::Principal;
2use std::{
3    collections::{BTreeMap, BTreeSet},
4    num::NonZeroUsize,
5    ops::Deref,
6    panic::{AssertUnwindSafe, catch_unwind},
7    time::{Duration, Instant},
8};
9
10use crate::timing::saturating_add_optional_duration;
11
12use super::{
13    CachedPocketIcBaseline,
14    bounded_pool::{BoundedSlotLease, BoundedSlotPool},
15};
16
17/// Caller-owned stable identity for one pooled fixture recipe.
18#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
19pub struct FixtureRecipeId(String);
20
21/// Reset domain whose handling is declared by a pooled baseline recipe.
22#[non_exhaustive]
23#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
24pub enum ResetDomainKind {
25    /// PocketIC simulated time.
26    PocketIcTime,
27    /// Canisters outside the captured baseline set.
28    ExtraCanisters,
29    /// Pending ingress, timers, or cross-canister messages.
30    PendingMessages,
31    /// Subnet metrics, routing, allocation, or other subnet-global state.
32    SubnetState,
33    /// Files, processes, services, or other caller-owned resources.
34    ExternalResources,
35}
36
37/// Cycle handling required or achieved by one reset.
38#[non_exhaustive]
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub enum CycleResetPolicy {
41    /// Do not proactively add or remove cycles before snapshot restoration.
42    ///
43    /// PocketIC may still charge cycles while performing the restore.
44    PreserveCurrent,
45    /// Add cycles as needed to reach this minimum immediately before restore,
46    /// without removing excess.
47    TopUpTo(u128),
48    /// Restore the exact balance recorded by the recipe baseline.
49    RestoreExactBaseline,
50    /// Treat any relevant cycle mutation as requiring slot reconstruction.
51    RebuildOnMutation,
52}
53
54/// PocketIC time handling required or achieved by one reset.
55#[non_exhaustive]
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum TimeResetPolicy {
58    /// Preserve the current simulator time rather than claiming it was reset.
59    PreserveCurrent,
60    /// Restore the exact time recorded by the recipe baseline.
61    RestoreBaseline,
62    /// Treat any relevant time mutation as requiring slot reconstruction.
63    RebuildOnMutation,
64}
65
66/// Extra-canister handling required or achieved by one reset.
67#[non_exhaustive]
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub enum ExtraCanisterPolicy {
70    /// Validate that the baseline canister set is unchanged.
71    RequireBaselineSet,
72    /// Remove canisters explicitly tracked by the recipe.
73    RemoveTracked,
74    /// Treat any extra-canister change as requiring slot reconstruction.
75    RebuildOnChange,
76}
77
78/// Generic handling for reset domains without a more specific policy.
79#[non_exhaustive]
80#[derive(Clone, Copy, Debug, Eq, PartialEq)]
81pub enum StateResetPolicy {
82    /// Reset the domain through caller-owned recipe logic.
83    ResetByRecipe,
84    /// Validate that the domain remained unchanged.
85    ValidateUnchanged,
86    /// Explicitly declare the domain irrelevant to this recipe's guarantees.
87    IrrelevantByRecipeContract,
88    /// Treat any relevant change as requiring slot reconstruction.
89    RebuildOnChange,
90}
91
92/// One reset guarantee required before a baseline may be reused.
93#[non_exhaustive]
94#[derive(Clone, Debug, Eq, PartialEq)]
95pub enum ResetRequirement {
96    /// Apply this time policy.
97    PocketIcTime(TimeResetPolicy),
98    /// Apply this extra-canister policy.
99    ExtraCanisters(ExtraCanisterPolicy),
100    /// Apply this pending-message policy.
101    PendingMessages(StateResetPolicy),
102    /// Apply this subnet-state policy.
103    SubnetState(StateResetPolicy),
104    /// Apply this external-resource policy.
105    ExternalResources(StateResetPolicy),
106}
107
108/// One reset guarantee reported as achieved by a recipe.
109#[non_exhaustive]
110#[derive(Clone, Debug, Eq, PartialEq)]
111pub enum ResetAchievement {
112    /// This time policy was achieved.
113    PocketIcTime(TimeResetPolicy),
114    /// This extra-canister policy was achieved.
115    ExtraCanisters(ExtraCanisterPolicy),
116    /// This pending-message policy was achieved.
117    PendingMessages(StateResetPolicy),
118    /// This subnet-state policy was achieved.
119    SubnetState(StateResetPolicy),
120    /// This external-resource policy was achieved.
121    ExternalResources(StateResetPolicy),
122}
123
124/// Typed reset guarantees required by one fixture recipe.
125#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct ResetRequirements {
127    cycle_policy: CycleResetPolicy,
128    domains: BTreeMap<ResetDomainKind, ResetRequirement>,
129}
130
131/// Typed reset guarantees achieved by one preparation pass.
132#[derive(Clone, Debug, Default, Eq, PartialEq)]
133pub struct ResetReceipt(BTreeMap<ResetDomainKind, ResetAchievement>);
134
135/// Receipt for restoring the recipe's captured canister set.
136#[derive(Clone, Debug, Eq, PartialEq)]
137pub struct CanisterRestoreReceipt {
138    canister_ids: Vec<Principal>,
139    cycle_policy: CycleResetPolicy,
140}
141
142/// Receipt identifying the readiness boundary reached after reset.
143#[derive(Clone, Debug, Eq, PartialEq)]
144pub struct ReadinessReceipt {
145    identity: String,
146}
147
148/// Receipt proving the recipe's final invariant validation ran successfully.
149#[derive(Clone, Debug, Eq, PartialEq)]
150pub struct ValidationReceipt {
151    recipe_id: FixtureRecipeId,
152    invariant_identity: String,
153}
154
155/// Contract failure while constructing or verifying recipe reset evidence.
156#[non_exhaustive]
157#[derive(Clone, Debug, Eq, PartialEq)]
158pub enum BaselinePoolContractError {
159    /// Recipe identity was empty or whitespace-only.
160    EmptyRecipeIdentity,
161    /// A receipt identity was empty or whitespace-only.
162    EmptyReceiptIdentity { receipt: &'static str },
163    /// A reset domain was declared more than once.
164    DuplicateResetDomain { domain: ResetDomainKind },
165    /// A restored canister appeared more than once.
166    DuplicateCanisterId { canister_id: Principal },
167    /// A restore receipt contained no canisters.
168    EmptyCanisterSet,
169    /// The restore receipt did not satisfy the required cycle policy.
170    CyclePolicyMismatch {
171        required: CycleResetPolicy,
172        achieved: CycleResetPolicy,
173    },
174    /// The restored canister receipt did not identify the complete snapshot set.
175    RestoreCanisterSetMismatch {
176        expected: Vec<Principal>,
177        actual: Vec<Principal>,
178    },
179    /// A required reset domain had no matching achievement.
180    MissingResetDomain { domain: ResetDomainKind },
181    /// A reset achievement did not satisfy the required policy.
182    ResetPolicyMismatch {
183        requirement: ResetRequirement,
184        achievement: ResetAchievement,
185    },
186    /// Final validation reported a recipe other than the pool-owned recipe.
187    RecipeIdentityMismatch {
188        expected: FixtureRecipeId,
189        actual: FixtureRecipeId,
190    },
191}
192
193/// Whether validation is observing a newly built or restored baseline.
194#[non_exhaustive]
195#[derive(Clone, Debug, Eq, PartialEq)]
196pub enum PreparedBaseline {
197    /// The recipe just built this baseline.
198    Built,
199    /// The recipe restored and reset an existing baseline.
200    Restored {
201        /// Captured canisters restored by the recipe.
202        canisters: CanisterRestoreReceipt,
203        /// Typed non-snapshot reset receipt.
204        reset: ResetReceipt,
205        /// Readiness boundary reached after reset.
206        readiness: ReadinessReceipt,
207    },
208}
209
210/// Recipe stage associated with a structured preparation failure.
211#[non_exhaustive]
212#[derive(Clone, Copy, Debug, Eq, PartialEq)]
213pub enum BaselinePreparationStage {
214    /// Constructing a new baseline.
215    Build,
216    /// Restoring captured canisters.
217    RestoreCanisters,
218    /// Resetting state outside the snapshots.
219    ResetNonSnapshotState,
220    /// Driving the restored topology to readiness.
221    DriveToReadiness,
222    /// Validating a newly built baseline.
223    ValidateBuilt,
224    /// Validating a restored baseline.
225    ValidateRestored,
226}
227
228/// Why an invalid or failed slot was reconstructed.
229#[non_exhaustive]
230#[derive(Clone, Debug, Eq, PartialEq)]
231pub enum RebuildReason {
232    /// PocketIC transport was no longer reachable.
233    DeadPocketIcTransport,
234    /// Captured snapshot restoration failed.
235    SnapshotRestoreFailure,
236    /// Non-snapshot reset failed.
237    ResetFailure,
238    /// Readiness or quiescence could not be established.
239    ReadinessFailure,
240    /// Required and achieved reset domains did not match.
241    ResetCoverageMismatch,
242    /// Final invariant validation failed.
243    InvariantValidationFailure,
244    /// A caller explicitly invalidated its lease.
245    ExplicitLeaseInvalidation,
246    /// A lease was dropped while its thread was unwinding.
247    UnwindWhileLeased,
248    /// Recipe-specific structured reason.
249    RecipeClassified { code: String },
250}
251
252/// Recipe decision for a failed restored-slot preparation stage.
253#[non_exhaustive]
254#[derive(Clone, Debug, Eq, PartialEq)]
255pub enum FailureDisposition {
256    /// Return the failure without rebuilding during this acquisition.
257    Fatal,
258    /// Invalidate and rebuild the slot once.
259    Rebuild(RebuildReason),
260}
261
262/// Timings for one baseline-pool acquisition.
263#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
264pub struct BaselinePoolTimings {
265    wait: Duration,
266    build: Option<Duration>,
267    restore: Option<Duration>,
268    reset: Option<Duration>,
269    readiness: Option<Duration>,
270    validation: Option<Duration>,
271    stale_teardown: Option<Duration>,
272    total: Duration,
273}
274
275/// Whether a baseline-pool lease was built, restored, or rebuilt.
276#[non_exhaustive]
277#[derive(Clone, Debug, Eq, PartialEq)]
278pub enum BaselinePoolOutcome {
279    /// An empty slot was constructed and validated.
280    Built {
281        /// Diagnostic slot index.
282        slot: usize,
283        /// Acquisition phase timings.
284        timings: BaselinePoolTimings,
285    },
286    /// An existing slot was restored, reset, and validated.
287    Restored {
288        /// Diagnostic slot index.
289        slot: usize,
290        /// Acquisition phase timings.
291        timings: BaselinePoolTimings,
292    },
293    /// An invalid or failed slot was reconstructed and validated.
294    Rebuilt {
295        /// Diagnostic slot index.
296        slot: usize,
297        /// Reason the previous slot could not be reused.
298        reason: RebuildReason,
299        /// Acquisition phase timings.
300        timings: BaselinePoolTimings,
301    },
302}
303
304/// One failed recipe or contract stage while preparing a baseline slot.
305#[non_exhaustive]
306#[derive(Debug)]
307pub enum BaselinePoolPreparationError<E> {
308    /// Caller-owned recipe logic returned an error.
309    Recipe {
310        /// Failed lifecycle stage.
311        stage: BaselinePreparationStage,
312        /// Caller-owned structured source error.
313        source: E,
314    },
315    /// Typed reset or recipe evidence violated the pool contract.
316    Contract(BaselinePoolContractError),
317}
318
319/// Failure to acquire a validated baseline-pool lease.
320#[non_exhaustive]
321#[derive(Debug)]
322pub enum BaselinePoolError<E> {
323    /// Initial construction or a non-rebuilt preparation failed.
324    Preparation {
325        /// Recipe or contract failure that stopped acquisition.
326        error: BaselinePoolPreparationError<E>,
327        /// Phase timings recorded before acquisition failed.
328        timings: Box<BaselinePoolTimings>,
329    },
330    /// Reused-slot preparation failed and its one rebuild attempt also failed.
331    RecoveryFailed {
332        /// Original restore/reset/readiness/validation failure.
333        original: Box<BaselinePoolPreparationError<E>>,
334        /// Failure while rebuilding or validating the replacement.
335        rebuild: Box<BaselinePoolPreparationError<E>>,
336        /// Combined timings for preparation, stale teardown, and rebuilding.
337        timings: Box<BaselinePoolTimings>,
338    },
339}
340
341/// Complete caller-owned lifecycle recipe for one pooled PocketIC baseline.
342pub trait PocketIcBaselineRecipe: Send + Sync + 'static {
343    /// Metadata retained beside every baseline owned by this recipe.
344    type Metadata: Send + 'static;
345    /// Structured caller error shared by recipe lifecycle stages.
346    type Error: std::error::Error + Send + Sync + 'static;
347
348    /// Stable caller-owned recipe identity.
349    fn id(&self) -> &FixtureRecipeId;
350
351    /// Reset guarantees required before an existing slot may be reused.
352    fn reset_requirements(&self) -> &ResetRequirements;
353
354    /// Construct and capture one complete baseline.
355    fn build(&self) -> Result<CachedPocketIcBaseline<Self::Metadata>, Self::Error>;
356
357    /// Restore every captured canister and report the cycle policy applied.
358    fn restore_canisters(
359        &self,
360        baseline: &CachedPocketIcBaseline<Self::Metadata>,
361    ) -> Result<CanisterRestoreReceipt, Self::Error>;
362
363    /// Reset state not covered by canister snapshots.
364    fn reset_non_snapshot_state(
365        &self,
366        baseline: &CachedPocketIcBaseline<Self::Metadata>,
367    ) -> Result<ResetReceipt, Self::Error>;
368
369    /// Drive the topology to the recipe's readiness boundary.
370    fn drive_to_readiness(
371        &self,
372        baseline: &CachedPocketIcBaseline<Self::Metadata>,
373    ) -> Result<ReadinessReceipt, Self::Error>;
374
375    /// Validate the same baseline invariants after build and restore.
376    fn validate(
377        &self,
378        baseline: &CachedPocketIcBaseline<Self::Metadata>,
379        preparation: &PreparedBaseline,
380    ) -> Result<ValidationReceipt, Self::Error>;
381
382    /// Classify a restored-slot recipe failure as fatal or rebuildable.
383    fn classify_failure(
384        &self,
385        stage: BaselinePreparationStage,
386        _error: &Self::Error,
387    ) -> FailureDisposition {
388        FailureDisposition::Rebuild(stage.default_rebuild_reason())
389    }
390}
391
392/// Caller-owned runtime-capacity pool of independently restorable PocketIC baselines.
393///
394/// One pool structurally owns one [`PocketIcBaselineRecipe`]. A warm
395/// acquisition restores the complete captured canister set, applies the
396/// recipe's non-snapshot reset, reaches its readiness boundary, checks typed
397/// reset coverage, and validates final invariants before exposing a lease.
398/// Each capacity slot owns an independent PocketIC instance.
399///
400/// Snapshot reuse is not a complete PocketIC rollback. The recipe must account
401/// for time, extra canisters, pending messages, subnet state, cycles, and
402/// external resources when those domains matter to its tests.
403pub struct CachedPocketIcBaselinePool<R>
404where
405    R: PocketIcBaselineRecipe,
406{
407    recipe: R,
408    slots: BoundedSlotPool<BaselineSlot<R::Metadata>>,
409}
410
411struct BaselineSlot<M> {
412    baseline: CachedPocketIcBaseline<M>,
413    invalidation_reason: Option<RebuildReason>,
414}
415
416/// Exclusive lease of one validated pooled PocketIC baseline.
417pub struct CachedPocketIcBaselinePoolGuard<'a, R>
418where
419    R: PocketIcBaselineRecipe,
420{
421    slot: BoundedSlotLease<'a, BaselineSlot<R::Metadata>>,
422}
423
424impl FixtureRecipeId {
425    /// Construct a nonempty caller-owned stable recipe identity.
426    pub fn try_new(identity: impl Into<String>) -> Result<Self, BaselinePoolContractError> {
427        let identity = identity.into();
428        if identity.trim().is_empty() {
429            return Err(BaselinePoolContractError::EmptyRecipeIdentity);
430        }
431        Ok(Self(identity))
432    }
433
434    /// Borrow the recipe identity.
435    #[must_use]
436    pub fn as_str(&self) -> &str {
437        &self.0
438    }
439}
440
441impl ResetRequirement {
442    /// Domain governed by this requirement.
443    #[must_use]
444    pub const fn domain(&self) -> ResetDomainKind {
445        match self {
446            Self::PocketIcTime(_) => ResetDomainKind::PocketIcTime,
447            Self::ExtraCanisters(_) => ResetDomainKind::ExtraCanisters,
448            Self::PendingMessages(_) => ResetDomainKind::PendingMessages,
449            Self::SubnetState(_) => ResetDomainKind::SubnetState,
450            Self::ExternalResources(_) => ResetDomainKind::ExternalResources,
451        }
452    }
453}
454
455impl ResetAchievement {
456    /// Domain governed by this achievement.
457    #[must_use]
458    pub const fn domain(&self) -> ResetDomainKind {
459        match self {
460            Self::PocketIcTime(_) => ResetDomainKind::PocketIcTime,
461            Self::ExtraCanisters(_) => ResetDomainKind::ExtraCanisters,
462            Self::PendingMessages(_) => ResetDomainKind::PendingMessages,
463            Self::SubnetState(_) => ResetDomainKind::SubnetState,
464            Self::ExternalResources(_) => ResetDomainKind::ExternalResources,
465        }
466    }
467
468    fn satisfies(&self, requirement: &ResetRequirement) -> bool {
469        match (requirement, self) {
470            (ResetRequirement::PocketIcTime(left), Self::PocketIcTime(right)) => left == right,
471            (ResetRequirement::ExtraCanisters(left), Self::ExtraCanisters(right)) => left == right,
472            (ResetRequirement::PendingMessages(left), Self::PendingMessages(right))
473            | (ResetRequirement::SubnetState(left), Self::SubnetState(right))
474            | (ResetRequirement::ExternalResources(left), Self::ExternalResources(right)) => {
475                left == right
476            }
477            _ => false,
478        }
479    }
480}
481
482impl ResetRequirements {
483    /// Construct a duplicate-checked reset requirement set.
484    ///
485    /// Every recipe restores its complete snapshot set. The required cycle
486    /// policy is explicit; `requirements` describe only non-snapshot domains.
487    pub fn try_new<I>(
488        cycle_policy: CycleResetPolicy,
489        requirements: I,
490    ) -> Result<Self, BaselinePoolContractError>
491    where
492        I: IntoIterator<Item = ResetRequirement>,
493    {
494        let mut domains = BTreeMap::new();
495        for requirement in requirements {
496            let domain = requirement.domain();
497            if domains.insert(domain, requirement).is_some() {
498                return Err(BaselinePoolContractError::DuplicateResetDomain { domain });
499            }
500        }
501        Ok(Self {
502            cycle_policy,
503            domains,
504        })
505    }
506
507    /// Cycle policy required of the canister restore receipt.
508    #[must_use]
509    pub const fn cycle_policy(&self) -> CycleResetPolicy {
510        self.cycle_policy
511    }
512
513    /// Read the requirement for one domain.
514    #[must_use]
515    pub fn get(&self, domain: ResetDomainKind) -> Option<&ResetRequirement> {
516        self.domains.get(&domain)
517    }
518
519    /// Iterate over requirements in deterministic domain order.
520    pub fn iter(&self) -> impl Iterator<Item = &ResetRequirement> {
521        self.domains.values()
522    }
523
524    fn verify(
525        &self,
526        restore: &CanisterRestoreReceipt,
527        receipt: &ResetReceipt,
528    ) -> Result<(), BaselinePoolContractError> {
529        if restore.cycle_policy != self.cycle_policy {
530            return Err(BaselinePoolContractError::CyclePolicyMismatch {
531                required: self.cycle_policy,
532                achieved: restore.cycle_policy,
533            });
534        }
535        for (domain, requirement) in &self.domains {
536            let Some(achievement) = receipt.0.get(domain) else {
537                return Err(BaselinePoolContractError::MissingResetDomain { domain: *domain });
538            };
539            if !achievement.satisfies(requirement) {
540                return Err(BaselinePoolContractError::ResetPolicyMismatch {
541                    requirement: requirement.clone(),
542                    achievement: achievement.clone(),
543                });
544            }
545        }
546        Ok(())
547    }
548}
549
550impl ResetReceipt {
551    /// Construct a duplicate-checked non-snapshot reset achievement set.
552    ///
553    /// Snapshot restoration and cycle policy are verified separately through
554    /// [`CanisterRestoreReceipt`].
555    pub fn try_new<I>(achievements: I) -> Result<Self, BaselinePoolContractError>
556    where
557        I: IntoIterator<Item = ResetAchievement>,
558    {
559        let mut domains = BTreeMap::new();
560        for achievement in achievements {
561            let domain = achievement.domain();
562            if domains.insert(domain, achievement).is_some() {
563                return Err(BaselinePoolContractError::DuplicateResetDomain { domain });
564            }
565        }
566        Ok(Self(domains))
567    }
568
569    /// Create an empty receipt for recipes with no non-snapshot reset achievements.
570    #[must_use]
571    pub const fn empty() -> Self {
572        Self(BTreeMap::new())
573    }
574
575    /// Read the achievement for one domain.
576    #[must_use]
577    pub fn get(&self, domain: ResetDomainKind) -> Option<&ResetAchievement> {
578        self.0.get(&domain)
579    }
580
581    /// Iterate over achievements in deterministic domain order.
582    pub fn iter(&self) -> impl Iterator<Item = &ResetAchievement> {
583        self.0.values()
584    }
585}
586
587impl CanisterRestoreReceipt {
588    /// Construct a deterministic, duplicate-checked canister restore receipt.
589    pub fn try_new<I>(
590        canister_ids: I,
591        cycle_policy: CycleResetPolicy,
592    ) -> Result<Self, BaselinePoolContractError>
593    where
594        I: IntoIterator<Item = Principal>,
595    {
596        let mut unique = BTreeSet::new();
597        for canister_id in canister_ids {
598            if !unique.insert(canister_id) {
599                return Err(BaselinePoolContractError::DuplicateCanisterId { canister_id });
600            }
601        }
602        if unique.is_empty() {
603            return Err(BaselinePoolContractError::EmptyCanisterSet);
604        }
605        Ok(Self {
606            canister_ids: unique.into_iter().collect(),
607            cycle_policy,
608        })
609    }
610
611    /// Construct a restore receipt for the exact canister set captured by a baseline.
612    ///
613    /// This is the preferred constructor after successfully calling
614    /// [`CachedPocketIcBaseline::restore`] or
615    /// [`CachedPocketIcBaseline::restore_with_funding`]. Deriving the set from
616    /// the baseline avoids duplicating canister ids in recipe metadata solely
617    /// to satisfy the pool contract.
618    pub fn try_from_baseline<M>(
619        baseline: &CachedPocketIcBaseline<M>,
620        cycle_policy: CycleResetPolicy,
621    ) -> Result<Self, BaselinePoolContractError> {
622        Self::try_new(baseline.snapshot_canister_ids(), cycle_policy)
623    }
624
625    /// Restored canister ids in deterministic order.
626    #[must_use]
627    pub fn canister_ids(&self) -> &[Principal] {
628        &self.canister_ids
629    }
630
631    /// Cycle policy applied while restoring canisters.
632    #[must_use]
633    pub const fn cycle_policy(&self) -> CycleResetPolicy {
634        self.cycle_policy
635    }
636}
637
638impl ReadinessReceipt {
639    /// Construct a nonempty caller-owned readiness identity.
640    pub fn try_new(identity: impl Into<String>) -> Result<Self, BaselinePoolContractError> {
641        Ok(Self {
642            identity: nonempty_receipt_identity("readiness", identity.into())?,
643        })
644    }
645
646    /// Borrow the readiness identity.
647    #[must_use]
648    pub fn identity(&self) -> &str {
649        &self.identity
650    }
651}
652
653impl ValidationReceipt {
654    /// Construct final validation evidence for one recipe.
655    pub fn try_new(
656        recipe_id: FixtureRecipeId,
657        invariant_identity: impl Into<String>,
658    ) -> Result<Self, BaselinePoolContractError> {
659        Ok(Self {
660            recipe_id,
661            invariant_identity: nonempty_receipt_identity("validation", invariant_identity.into())?,
662        })
663    }
664
665    /// Recipe identity validated by this receipt.
666    #[must_use]
667    pub const fn recipe_id(&self) -> &FixtureRecipeId {
668        &self.recipe_id
669    }
670
671    /// Borrow the caller-owned invariant identity.
672    #[must_use]
673    pub fn invariant_identity(&self) -> &str {
674        &self.invariant_identity
675    }
676}
677
678impl BaselinePreparationStage {
679    /// Default rebuild reason used by [`PocketIcBaselineRecipe::classify_failure`].
680    ///
681    /// Recipes that override classification can use this for their fallback
682    /// after handling a more specific error such as dead PocketIC transport.
683    #[must_use]
684    pub fn default_rebuild_reason(self) -> RebuildReason {
685        match self {
686            Self::RestoreCanisters => RebuildReason::SnapshotRestoreFailure,
687            Self::ResetNonSnapshotState => RebuildReason::ResetFailure,
688            Self::DriveToReadiness => RebuildReason::ReadinessFailure,
689            Self::ValidateRestored | Self::ValidateBuilt => {
690                RebuildReason::InvariantValidationFailure
691            }
692            Self::Build => RebuildReason::RecipeClassified {
693                code: "build".to_owned(),
694            },
695        }
696    }
697}
698
699impl BaselinePoolTimings {
700    /// Time spent waiting for a capacity slot.
701    #[must_use]
702    pub const fn wait(self) -> Duration {
703        self.wait
704    }
705
706    /// Time spent constructing a new baseline.
707    #[must_use]
708    pub const fn build(self) -> Option<Duration> {
709        self.build
710    }
711
712    /// Time spent restoring captured canisters.
713    #[must_use]
714    pub const fn restore(self) -> Option<Duration> {
715        self.restore
716    }
717
718    /// Time spent resetting non-snapshot state.
719    #[must_use]
720    pub const fn reset(self) -> Option<Duration> {
721        self.reset
722    }
723
724    /// Time spent driving the topology to readiness.
725    #[must_use]
726    pub const fn readiness(self) -> Option<Duration> {
727        self.readiness
728    }
729
730    /// Time spent validating final baseline invariants.
731    #[must_use]
732    pub const fn validation(self) -> Option<Duration> {
733        self.validation
734    }
735
736    /// Time spent dropping an invalid baseline before rebuilding.
737    #[must_use]
738    pub const fn stale_teardown(self) -> Option<Duration> {
739        self.stale_teardown
740    }
741
742    /// Complete acquisition duration.
743    #[must_use]
744    pub const fn total(self) -> Duration {
745        self.total
746    }
747}
748
749impl BaselinePoolOutcome {
750    /// Diagnostic slot index used by this acquisition.
751    #[must_use]
752    pub const fn slot(&self) -> usize {
753        match self {
754            Self::Built { slot, .. } | Self::Restored { slot, .. } | Self::Rebuilt { slot, .. } => {
755                *slot
756            }
757        }
758    }
759
760    /// Acquisition phase timings.
761    #[must_use]
762    pub const fn timings(&self) -> BaselinePoolTimings {
763        match self {
764            Self::Built { timings, .. }
765            | Self::Restored { timings, .. }
766            | Self::Rebuilt { timings, .. } => *timings,
767        }
768    }
769}
770
771impl std::fmt::Display for BaselinePoolTimings {
772    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
773        write!(
774            formatter,
775            "total={:?} wait={:?} build={:?} restore={:?} reset={:?} readiness={:?} validation={:?} stale_teardown={:?}",
776            self.total,
777            self.wait,
778            self.build,
779            self.restore,
780            self.reset,
781            self.readiness,
782            self.validation,
783            self.stale_teardown,
784        )
785    }
786}
787
788impl std::fmt::Display for BaselinePoolOutcome {
789    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
790        match self {
791            Self::Built { slot, timings } => write!(formatter, "built slot={slot} {timings}"),
792            Self::Restored { slot, timings } => {
793                write!(formatter, "restored slot={slot} {timings}")
794            }
795            Self::Rebuilt {
796                slot,
797                reason,
798                timings,
799            } => write!(formatter, "rebuilt slot={slot} reason={reason:?} {timings}"),
800        }
801    }
802}
803
804impl<E> BaselinePoolError<E> {
805    /// Timings recorded before this acquisition failed.
806    #[must_use]
807    pub const fn timings(&self) -> BaselinePoolTimings {
808        match self {
809            Self::Preparation { timings, .. } | Self::RecoveryFailed { timings, .. } => **timings,
810        }
811    }
812}
813
814impl<R> CachedPocketIcBaselinePool<R>
815where
816    R: PocketIcBaselineRecipe,
817{
818    /// Create a runtime-capacity pool that structurally owns one recipe.
819    #[must_use]
820    pub fn new(capacity: NonZeroUsize, recipe: R) -> Self {
821        Self {
822            recipe,
823            slots: BoundedSlotPool::new(capacity),
824        }
825    }
826
827    /// Borrow the caller-owned identity of this pool's only recipe.
828    #[must_use]
829    pub fn recipe_id(&self) -> &FixtureRecipeId {
830        self.recipe.id()
831    }
832
833    /// Maximum number of simultaneously leased PocketIC baselines.
834    #[must_use]
835    pub fn capacity(&self) -> NonZeroUsize {
836        self.slots.capacity()
837    }
838
839    /// Acquire one fully built or restored and validated baseline lease.
840    ///
841    /// A rebuildable warm-preparation failure discards the stale slot and
842    /// performs at most one build attempt. Recipe and caller panics are never
843    /// converted into cache misses; the lease is invalidated while the panic
844    /// continues unwinding.
845    ///
846    /// # Errors
847    ///
848    /// Returns a stage-specific recipe or contract error. If warm preparation
849    /// and its one recovery build both fail, the error preserves both causes.
850    pub fn acquire(
851        &self,
852    ) -> Result<
853        (CachedPocketIcBaselinePoolGuard<'_, R>, BaselinePoolOutcome),
854        BaselinePoolError<R::Error>,
855    > {
856        let total_started = Instant::now();
857        let mut slot = self.slots.acquire();
858        let mut timings = BaselinePoolTimings {
859            wait: slot.wait(),
860            ..BaselinePoolTimings::default()
861        };
862        let slot_index = slot.slot_index();
863
864        if slot.is_reusable() {
865            match self.prepare_reused(&mut slot, &mut timings) {
866                Ok(()) => {
867                    timings.total = total_started.elapsed();
868                    return Ok((
869                        CachedPocketIcBaselinePoolGuard { slot },
870                        BaselinePoolOutcome::Restored {
871                            slot: slot_index,
872                            timings,
873                        },
874                    ));
875                }
876                Err(original) => {
877                    let disposition = self.failure_disposition(&original);
878                    match disposition {
879                        FailureDisposition::Fatal => {
880                            // A failed restore may have partially changed the
881                            // instance. Discard it, but do not reinterpret a
882                            // caller-declared fatal error as a rebuild reason.
883                            Self::discard_stale_slot(&mut slot, &mut timings);
884                            timings.total = total_started.elapsed();
885                            return Err(BaselinePoolError::Preparation {
886                                error: original,
887                                timings: Box::new(timings),
888                            });
889                        }
890                        FailureDisposition::Rebuild(reason) => {
891                            Self::discard_stale_slot(&mut slot, &mut timings);
892                            if let Err(rebuild) = self.build_slot(&mut slot, &mut timings) {
893                                timings.total = total_started.elapsed();
894                                return Err(BaselinePoolError::RecoveryFailed {
895                                    original: Box::new(original),
896                                    rebuild: Box::new(rebuild),
897                                    timings: Box::new(timings),
898                                });
899                            }
900                            timings.total = total_started.elapsed();
901                            return Ok((
902                                CachedPocketIcBaselinePoolGuard { slot },
903                                BaselinePoolOutcome::Rebuilt {
904                                    slot: slot_index,
905                                    reason,
906                                    timings,
907                                },
908                            ));
909                        }
910                    }
911                }
912            }
913        }
914
915        let rebuild_reason = if slot.invalidated_by_unwind() {
916            Some(RebuildReason::UnwindWhileLeased)
917        } else {
918            slot.get()
919                .and_then(|slot| slot.invalidation_reason.clone())
920                .or_else(|| {
921                    slot.is_populated()
922                        .then_some(RebuildReason::ExplicitLeaseInvalidation)
923                })
924        };
925        if slot.is_populated() {
926            Self::discard_stale_slot(&mut slot, &mut timings);
927        }
928        if let Err(error) = self.build_slot(&mut slot, &mut timings) {
929            timings.total = total_started.elapsed();
930            return Err(BaselinePoolError::Preparation {
931                error,
932                timings: Box::new(timings),
933            });
934        }
935        timings.total = total_started.elapsed();
936
937        let outcome = rebuild_reason.map_or_else(
938            || BaselinePoolOutcome::Built {
939                slot: slot_index,
940                timings,
941            },
942            |reason| BaselinePoolOutcome::Rebuilt {
943                slot: slot_index,
944                reason,
945                timings,
946            },
947        );
948        Ok((CachedPocketIcBaselinePoolGuard { slot }, outcome))
949    }
950
951    fn prepare_reused(
952        &self,
953        slot: &mut BoundedSlotLease<'_, BaselineSlot<R::Metadata>>,
954        timings: &mut BaselinePoolTimings,
955    ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
956        let baseline = &slot
957            .get()
958            .expect("reusable baseline slot must be populated")
959            .baseline;
960
961        let started = Instant::now();
962        let restore = self.recipe.restore_canisters(baseline);
963        add_timing(&mut timings.restore, started.elapsed());
964        let canisters = restore.map_err(|source| BaselinePoolPreparationError::Recipe {
965            stage: BaselinePreparationStage::RestoreCanisters,
966            source,
967        })?;
968        let expected_canisters = baseline.snapshot_canister_ids().collect::<Vec<_>>();
969        if canisters.canister_ids() != expected_canisters {
970            return Err(BaselinePoolPreparationError::Contract(
971                BaselinePoolContractError::RestoreCanisterSetMismatch {
972                    expected: expected_canisters,
973                    actual: canisters.canister_ids().to_vec(),
974                },
975            ));
976        }
977
978        let started = Instant::now();
979        let reset_result = self.recipe.reset_non_snapshot_state(baseline);
980        add_timing(&mut timings.reset, started.elapsed());
981        let reset = reset_result.map_err(|source| BaselinePoolPreparationError::Recipe {
982            stage: BaselinePreparationStage::ResetNonSnapshotState,
983            source,
984        })?;
985
986        let started = Instant::now();
987        let readiness_result = self.recipe.drive_to_readiness(baseline);
988        add_timing(&mut timings.readiness, started.elapsed());
989        let readiness =
990            readiness_result.map_err(|source| BaselinePoolPreparationError::Recipe {
991                stage: BaselinePreparationStage::DriveToReadiness,
992                source,
993            })?;
994        self.recipe
995            .reset_requirements()
996            .verify(&canisters, &reset)
997            .map_err(BaselinePoolPreparationError::Contract)?;
998
999        let preparation = PreparedBaseline::Restored {
1000            canisters,
1001            reset,
1002            readiness,
1003        };
1004        self.validate_baseline(
1005            baseline,
1006            &preparation,
1007            BaselinePreparationStage::ValidateRestored,
1008            timings,
1009        )?;
1010        slot.get_mut()
1011            .expect("validated baseline slot must remain populated")
1012            .invalidation_reason = None;
1013        Ok(())
1014    }
1015
1016    fn build_slot(
1017        &self,
1018        slot: &mut BoundedSlotLease<'_, BaselineSlot<R::Metadata>>,
1019        timings: &mut BaselinePoolTimings,
1020    ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
1021        let started = Instant::now();
1022        let build = self.recipe.build();
1023        add_timing(&mut timings.build, started.elapsed());
1024        let baseline = build.map_err(|source| BaselinePoolPreparationError::Recipe {
1025            stage: BaselinePreparationStage::Build,
1026            source,
1027        })?;
1028
1029        if let Err(error) = self.validate_baseline(
1030            &baseline,
1031            &PreparedBaseline::Built,
1032            BaselinePreparationStage::ValidateBuilt,
1033            timings,
1034        ) {
1035            drop_baseline_safely(baseline);
1036            return Err(error);
1037        }
1038        let replaced = slot.replace(BaselineSlot {
1039            baseline,
1040            invalidation_reason: None,
1041        });
1042        debug_assert!(replaced.is_none());
1043        Ok(())
1044    }
1045
1046    fn validate_baseline(
1047        &self,
1048        baseline: &CachedPocketIcBaseline<R::Metadata>,
1049        preparation: &PreparedBaseline,
1050        stage: BaselinePreparationStage,
1051        timings: &mut BaselinePoolTimings,
1052    ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
1053        let started = Instant::now();
1054        let validation = self.recipe.validate(baseline, preparation);
1055        add_timing(&mut timings.validation, started.elapsed());
1056        let receipt =
1057            validation.map_err(|source| BaselinePoolPreparationError::Recipe { stage, source })?;
1058        if receipt.recipe_id() != self.recipe.id() {
1059            return Err(BaselinePoolPreparationError::Contract(
1060                BaselinePoolContractError::RecipeIdentityMismatch {
1061                    expected: self.recipe.id().clone(),
1062                    actual: receipt.recipe_id().clone(),
1063                },
1064            ));
1065        }
1066        Ok(())
1067    }
1068
1069    fn failure_disposition(
1070        &self,
1071        error: &BaselinePoolPreparationError<R::Error>,
1072    ) -> FailureDisposition {
1073        match error {
1074            BaselinePoolPreparationError::Recipe { stage, source } => {
1075                self.recipe.classify_failure(*stage, source)
1076            }
1077            BaselinePoolPreparationError::Contract(
1078                BaselinePoolContractError::RecipeIdentityMismatch { .. },
1079            ) => FailureDisposition::Fatal,
1080            BaselinePoolPreparationError::Contract(_) => {
1081                FailureDisposition::Rebuild(rebuild_reason_for_error(error))
1082            }
1083        }
1084    }
1085
1086    fn discard_stale_slot(
1087        slot: &mut BoundedSlotLease<'_, BaselineSlot<R::Metadata>>,
1088        timings: &mut BaselinePoolTimings,
1089    ) {
1090        let started = Instant::now();
1091        if let Some(stale) = slot.take() {
1092            drop_baseline_safely(stale.baseline);
1093        }
1094        timings.stale_teardown = Some(started.elapsed());
1095    }
1096}
1097
1098impl<R> CachedPocketIcBaselinePoolGuard<'_, R>
1099where
1100    R: PocketIcBaselineRecipe,
1101{
1102    /// Diagnostic slot index held by this lease.
1103    #[must_use]
1104    pub const fn slot(&self) -> usize {
1105        self.slot.slot_index()
1106    }
1107
1108    /// Mark this slot non-reusable with a structured rebuild reason.
1109    pub fn invalidate(&mut self, reason: RebuildReason) {
1110        if let Some(slot) = self.slot.get_mut() {
1111            slot.invalidation_reason = Some(reason);
1112        }
1113        self.slot.invalidate();
1114    }
1115}
1116
1117impl<R> Deref for CachedPocketIcBaselinePoolGuard<'_, R>
1118where
1119    R: PocketIcBaselineRecipe,
1120{
1121    type Target = CachedPocketIcBaseline<R::Metadata>;
1122
1123    fn deref(&self) -> &Self::Target {
1124        &self
1125            .slot
1126            .get()
1127            .expect("leased baseline pool slot must be populated")
1128            .baseline
1129    }
1130}
1131
1132fn nonempty_receipt_identity(
1133    receipt: &'static str,
1134    identity: String,
1135) -> Result<String, BaselinePoolContractError> {
1136    if identity.trim().is_empty() {
1137        return Err(BaselinePoolContractError::EmptyReceiptIdentity { receipt });
1138    }
1139    Ok(identity)
1140}
1141
1142const fn add_timing(total: &mut Option<Duration>, elapsed: Duration) {
1143    *total = saturating_add_optional_duration(*total, Some(elapsed));
1144}
1145
1146fn rebuild_reason_for_error<E>(error: &BaselinePoolPreparationError<E>) -> RebuildReason {
1147    match error {
1148        BaselinePoolPreparationError::Recipe { stage, .. } => stage.default_rebuild_reason(),
1149        BaselinePoolPreparationError::Contract(
1150            BaselinePoolContractError::MissingResetDomain { .. }
1151            | BaselinePoolContractError::ResetPolicyMismatch { .. }
1152            | BaselinePoolContractError::CyclePolicyMismatch { .. }
1153            | BaselinePoolContractError::DuplicateResetDomain { .. }
1154            | BaselinePoolContractError::RestoreCanisterSetMismatch { .. },
1155        ) => RebuildReason::ResetCoverageMismatch,
1156        BaselinePoolPreparationError::Contract(_) => RebuildReason::InvariantValidationFailure,
1157    }
1158}
1159
1160fn drop_baseline_safely<M>(baseline: CachedPocketIcBaseline<M>) {
1161    let _ = catch_unwind(AssertUnwindSafe(|| drop(baseline)));
1162}
1163
1164impl std::fmt::Display for FixtureRecipeId {
1165    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1166        formatter.write_str(&self.0)
1167    }
1168}
1169
1170impl std::fmt::Display for BaselinePreparationStage {
1171    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1172        formatter.write_str(match self {
1173            Self::Build => "build",
1174            Self::RestoreCanisters => "canister restore",
1175            Self::ResetNonSnapshotState => "non-snapshot reset",
1176            Self::DriveToReadiness => "readiness",
1177            Self::ValidateBuilt => "built-baseline validation",
1178            Self::ValidateRestored => "restored-baseline validation",
1179        })
1180    }
1181}
1182
1183impl std::fmt::Display for BaselinePoolContractError {
1184    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1185        match self {
1186            Self::EmptyRecipeIdentity => formatter.write_str("fixture recipe identity is empty"),
1187            Self::EmptyReceiptIdentity { receipt } => {
1188                write!(formatter, "{receipt} receipt identity is empty")
1189            }
1190            Self::DuplicateResetDomain { domain } => {
1191                write!(
1192                    formatter,
1193                    "reset domain {domain:?} was reported more than once"
1194                )
1195            }
1196            Self::DuplicateCanisterId { canister_id } => {
1197                write!(formatter, "restore receipt repeats canister {canister_id}")
1198            }
1199            Self::EmptyCanisterSet => formatter.write_str("restore receipt contains no canisters"),
1200            Self::CyclePolicyMismatch { required, achieved } => write!(
1201                formatter,
1202                "restore cycle policy {achieved:?} does not satisfy {required:?}",
1203            ),
1204            Self::RestoreCanisterSetMismatch { expected, actual } => write!(
1205                formatter,
1206                "restore receipt identified canisters {actual:?}, expected captured set {expected:?}",
1207            ),
1208            Self::MissingResetDomain { domain } => {
1209                write!(
1210                    formatter,
1211                    "required reset domain {domain:?} was not achieved"
1212                )
1213            }
1214            Self::ResetPolicyMismatch {
1215                requirement,
1216                achievement,
1217            } => write!(
1218                formatter,
1219                "reset achievement {achievement:?} does not satisfy {requirement:?}",
1220            ),
1221            Self::RecipeIdentityMismatch { expected, actual } => write!(
1222                formatter,
1223                "validation receipt used recipe `{actual}` instead of `{expected}`",
1224            ),
1225        }
1226    }
1227}
1228
1229impl std::error::Error for BaselinePoolContractError {}
1230
1231impl<E> std::fmt::Display for BaselinePoolPreparationError<E>
1232where
1233    E: std::fmt::Display,
1234{
1235    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1236        match self {
1237            Self::Recipe { stage, source } => {
1238                write!(formatter, "baseline {stage} failed: {source}")
1239            }
1240            Self::Contract(error) => write!(formatter, "baseline pool contract failed: {error}"),
1241        }
1242    }
1243}
1244
1245impl<E> std::error::Error for BaselinePoolPreparationError<E>
1246where
1247    E: std::error::Error + 'static,
1248{
1249    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1250        match self {
1251            Self::Recipe { source, .. } => Some(source),
1252            Self::Contract(error) => Some(error),
1253        }
1254    }
1255}
1256
1257impl<E> std::fmt::Display for BaselinePoolError<E>
1258where
1259    E: std::fmt::Display,
1260{
1261    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1262        match self {
1263            Self::Preparation { error, .. } => error.fmt(formatter),
1264            Self::RecoveryFailed {
1265                original, rebuild, ..
1266            } => write!(
1267                formatter,
1268                "baseline preparation failed ({original}); rebuilding the slot also failed: {rebuild}",
1269            ),
1270        }
1271    }
1272}
1273
1274impl<E> std::error::Error for BaselinePoolError<E>
1275where
1276    E: std::error::Error + 'static,
1277{
1278    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1279        match self {
1280            Self::Preparation { error, .. } => Some(error),
1281            Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
1282        }
1283    }
1284}
1285
1286#[cfg(test)]
1287mod tests {
1288    use super::{
1289        BaselinePoolContractError, CanisterRestoreReceipt, CycleResetPolicy, FixtureRecipeId,
1290        ResetAchievement, ResetDomainKind, ResetReceipt, ResetRequirement, ResetRequirements,
1291        TimeResetPolicy,
1292    };
1293    use candid::Principal;
1294
1295    #[test]
1296    fn recipe_identity_must_be_nonempty() {
1297        assert!(matches!(
1298            FixtureRecipeId::try_new("  "),
1299            Err(BaselinePoolContractError::EmptyRecipeIdentity)
1300        ));
1301    }
1302
1303    #[test]
1304    fn reset_requirements_reject_duplicate_domains() {
1305        let result = ResetRequirements::try_new(
1306            CycleResetPolicy::PreserveCurrent,
1307            [
1308                ResetRequirement::PocketIcTime(TimeResetPolicy::PreserveCurrent),
1309                ResetRequirement::PocketIcTime(TimeResetPolicy::RebuildOnMutation),
1310            ],
1311        );
1312        assert!(matches!(
1313            result,
1314            Err(BaselinePoolContractError::DuplicateResetDomain {
1315                domain: ResetDomainKind::PocketIcTime,
1316            })
1317        ));
1318    }
1319
1320    #[test]
1321    fn required_policy_must_match_achieved_policy() {
1322        let requirements = ResetRequirements::try_new(
1323            CycleResetPolicy::PreserveCurrent,
1324            [ResetRequirement::PocketIcTime(
1325                TimeResetPolicy::PreserveCurrent,
1326            )],
1327        )
1328        .unwrap();
1329        let restore = CanisterRestoreReceipt::try_new(
1330            [Principal::anonymous()],
1331            CycleResetPolicy::PreserveCurrent,
1332        )
1333        .unwrap();
1334        let receipt = ResetReceipt::try_new([ResetAchievement::PocketIcTime(
1335            TimeResetPolicy::RebuildOnMutation,
1336        )])
1337        .unwrap();
1338        assert!(matches!(
1339            requirements.verify(&restore, &receipt),
1340            Err(BaselinePoolContractError::ResetPolicyMismatch { .. })
1341        ));
1342    }
1343
1344    #[test]
1345    fn restore_cycle_policy_must_match_required_policy() {
1346        let requirements =
1347            ResetRequirements::try_new(CycleResetPolicy::RestoreExactBaseline, []).unwrap();
1348        let restore = CanisterRestoreReceipt::try_new(
1349            [Principal::anonymous()],
1350            CycleResetPolicy::PreserveCurrent,
1351        )
1352        .unwrap();
1353        assert!(matches!(
1354            requirements.verify(&restore, &ResetReceipt::empty()),
1355            Err(BaselinePoolContractError::CyclePolicyMismatch {
1356                required: CycleResetPolicy::RestoreExactBaseline,
1357                achieved: CycleResetPolicy::PreserveCurrent,
1358            })
1359        ));
1360    }
1361}