Skip to main content

ic_testkit/pic/
baseline_pool.rs

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