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(&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: &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        if !baseline
969            .snapshot_canister_ids()
970            .eq(canisters.canister_ids().iter().copied())
971        {
972            return Err(BaselinePoolPreparationError::Contract(
973                BaselinePoolContractError::RestoreCanisterSetMismatch {
974                    expected: baseline.snapshot_canister_ids().collect(),
975                    actual: canisters.canister_ids().to_vec(),
976                },
977            ));
978        }
979
980        let started = Instant::now();
981        let reset_result = self.recipe.reset_non_snapshot_state(baseline);
982        add_timing(&mut timings.reset, started.elapsed());
983        let reset = reset_result.map_err(|source| BaselinePoolPreparationError::Recipe {
984            stage: BaselinePreparationStage::ResetNonSnapshotState,
985            source,
986        })?;
987
988        let started = Instant::now();
989        let readiness_result = self.recipe.drive_to_readiness(baseline);
990        add_timing(&mut timings.readiness, started.elapsed());
991        let readiness =
992            readiness_result.map_err(|source| BaselinePoolPreparationError::Recipe {
993                stage: BaselinePreparationStage::DriveToReadiness,
994                source,
995            })?;
996        self.recipe
997            .reset_requirements()
998            .verify(&canisters, &reset)
999            .map_err(BaselinePoolPreparationError::Contract)?;
1000
1001        let preparation = PreparedBaseline::Restored {
1002            canisters,
1003            reset,
1004            readiness,
1005        };
1006        self.validate_baseline(
1007            baseline,
1008            &preparation,
1009            BaselinePreparationStage::ValidateRestored,
1010            timings,
1011        )?;
1012        Ok(())
1013    }
1014
1015    fn build_slot(
1016        &self,
1017        slot: &mut BoundedSlotLease<'_, BaselineSlot<R::Metadata>>,
1018        timings: &mut BaselinePoolTimings,
1019    ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
1020        let started = Instant::now();
1021        let build = self.recipe.build();
1022        add_timing(&mut timings.build, started.elapsed());
1023        let baseline = build.map_err(|source| BaselinePoolPreparationError::Recipe {
1024            stage: BaselinePreparationStage::Build,
1025            source,
1026        })?;
1027
1028        if let Err(error) = self.validate_baseline(
1029            &baseline,
1030            &PreparedBaseline::Built,
1031            BaselinePreparationStage::ValidateBuilt,
1032            timings,
1033        ) {
1034            drop_baseline_safely(baseline);
1035            return Err(error);
1036        }
1037        let replaced = slot.replace(BaselineSlot {
1038            baseline,
1039            invalidation_reason: None,
1040        });
1041        debug_assert!(replaced.is_none());
1042        Ok(())
1043    }
1044
1045    fn validate_baseline(
1046        &self,
1047        baseline: &CachedPocketIcBaseline<R::Metadata>,
1048        preparation: &PreparedBaseline,
1049        stage: BaselinePreparationStage,
1050        timings: &mut BaselinePoolTimings,
1051    ) -> Result<(), BaselinePoolPreparationError<R::Error>> {
1052        let started = Instant::now();
1053        let validation = self.recipe.validate(baseline, preparation);
1054        add_timing(&mut timings.validation, started.elapsed());
1055        let receipt =
1056            validation.map_err(|source| BaselinePoolPreparationError::Recipe { stage, source })?;
1057        if receipt.recipe_id() != self.recipe.id() {
1058            return Err(BaselinePoolPreparationError::Contract(
1059                BaselinePoolContractError::RecipeIdentityMismatch {
1060                    expected: self.recipe.id().clone(),
1061                    actual: receipt.recipe_id().clone(),
1062                },
1063            ));
1064        }
1065        Ok(())
1066    }
1067
1068    fn failure_disposition(
1069        &self,
1070        error: &BaselinePoolPreparationError<R::Error>,
1071    ) -> FailureDisposition {
1072        match error {
1073            BaselinePoolPreparationError::Recipe { stage, source } => {
1074                self.recipe.classify_failure(*stage, source)
1075            }
1076            BaselinePoolPreparationError::Contract(
1077                BaselinePoolContractError::RecipeIdentityMismatch { .. },
1078            ) => FailureDisposition::Fatal,
1079            BaselinePoolPreparationError::Contract(_) => {
1080                FailureDisposition::Rebuild(rebuild_reason_for_error(error))
1081            }
1082        }
1083    }
1084
1085    fn discard_stale_slot(
1086        slot: &mut BoundedSlotLease<'_, BaselineSlot<R::Metadata>>,
1087        timings: &mut BaselinePoolTimings,
1088    ) {
1089        let started = Instant::now();
1090        if let Some(stale) = slot.take() {
1091            drop_baseline_safely(stale.baseline);
1092        }
1093        timings.stale_teardown = Some(started.elapsed());
1094    }
1095}
1096
1097impl<R> CachedPocketIcBaselinePoolGuard<'_, R>
1098where
1099    R: PocketIcBaselineRecipe,
1100{
1101    /// Diagnostic slot index held by this lease.
1102    #[must_use]
1103    pub const fn slot(&self) -> usize {
1104        self.slot.slot_index()
1105    }
1106
1107    /// Mark this slot non-reusable with a structured rebuild reason.
1108    pub fn invalidate(&mut self, reason: RebuildReason) {
1109        if let Some(slot) = self.slot.get_mut() {
1110            slot.invalidation_reason = Some(reason);
1111        }
1112        self.slot.invalidate();
1113    }
1114}
1115
1116impl<R> Deref for CachedPocketIcBaselinePoolGuard<'_, R>
1117where
1118    R: PocketIcBaselineRecipe,
1119{
1120    type Target = CachedPocketIcBaseline<R::Metadata>;
1121
1122    fn deref(&self) -> &Self::Target {
1123        &self
1124            .slot
1125            .get()
1126            .expect("leased baseline pool slot must be populated")
1127            .baseline
1128    }
1129}
1130
1131fn nonempty_receipt_identity(
1132    receipt: &'static str,
1133    identity: String,
1134) -> Result<String, BaselinePoolContractError> {
1135    if identity.trim().is_empty() {
1136        return Err(BaselinePoolContractError::EmptyReceiptIdentity { receipt });
1137    }
1138    Ok(identity)
1139}
1140
1141const fn add_timing(total: &mut Option<Duration>, elapsed: Duration) {
1142    *total = saturating_add_optional_duration(*total, Some(elapsed));
1143}
1144
1145fn rebuild_reason_for_error<E>(error: &BaselinePoolPreparationError<E>) -> RebuildReason {
1146    match error {
1147        BaselinePoolPreparationError::Recipe { stage, .. } => stage.default_rebuild_reason(),
1148        BaselinePoolPreparationError::Contract(
1149            BaselinePoolContractError::MissingResetDomain { .. }
1150            | BaselinePoolContractError::ResetPolicyMismatch { .. }
1151            | BaselinePoolContractError::CyclePolicyMismatch { .. }
1152            | BaselinePoolContractError::DuplicateResetDomain { .. }
1153            | BaselinePoolContractError::RestoreCanisterSetMismatch { .. },
1154        ) => RebuildReason::ResetCoverageMismatch,
1155        BaselinePoolPreparationError::Contract(_) => RebuildReason::InvariantValidationFailure,
1156    }
1157}
1158
1159fn drop_baseline_safely<M>(baseline: CachedPocketIcBaseline<M>) {
1160    let _ = catch_unwind(AssertUnwindSafe(|| drop(baseline)));
1161}
1162
1163impl std::fmt::Display for FixtureRecipeId {
1164    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1165        formatter.write_str(&self.0)
1166    }
1167}
1168
1169impl std::fmt::Display for BaselinePreparationStage {
1170    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1171        formatter.write_str(match self {
1172            Self::Build => "build",
1173            Self::RestoreCanisters => "canister restore",
1174            Self::ResetNonSnapshotState => "non-snapshot reset",
1175            Self::DriveToReadiness => "readiness",
1176            Self::ValidateBuilt => "built-baseline validation",
1177            Self::ValidateRestored => "restored-baseline validation",
1178        })
1179    }
1180}
1181
1182impl std::fmt::Display for BaselinePoolContractError {
1183    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1184        match self {
1185            Self::EmptyRecipeIdentity => formatter.write_str("fixture recipe identity is empty"),
1186            Self::EmptyReceiptIdentity { receipt } => {
1187                write!(formatter, "{receipt} receipt identity is empty")
1188            }
1189            Self::DuplicateResetDomain { domain } => {
1190                write!(
1191                    formatter,
1192                    "reset domain {domain:?} was reported more than once"
1193                )
1194            }
1195            Self::DuplicateCanisterId { canister_id } => {
1196                write!(formatter, "restore receipt repeats canister {canister_id}")
1197            }
1198            Self::EmptyCanisterSet => formatter.write_str("restore receipt contains no canisters"),
1199            Self::CyclePolicyMismatch { required, achieved } => write!(
1200                formatter,
1201                "restore cycle policy {achieved:?} does not satisfy {required:?}",
1202            ),
1203            Self::RestoreCanisterSetMismatch { expected, actual } => write!(
1204                formatter,
1205                "restore receipt identified canisters {actual:?}, expected captured set {expected:?}",
1206            ),
1207            Self::MissingResetDomain { domain } => {
1208                write!(
1209                    formatter,
1210                    "required reset domain {domain:?} was not achieved"
1211                )
1212            }
1213            Self::ResetPolicyMismatch {
1214                requirement,
1215                achievement,
1216            } => write!(
1217                formatter,
1218                "reset achievement {achievement:?} does not satisfy {requirement:?}",
1219            ),
1220            Self::RecipeIdentityMismatch { expected, actual } => write!(
1221                formatter,
1222                "validation receipt used recipe `{actual}` instead of `{expected}`",
1223            ),
1224        }
1225    }
1226}
1227
1228impl std::error::Error for BaselinePoolContractError {}
1229
1230impl<E> std::fmt::Display for BaselinePoolPreparationError<E>
1231where
1232    E: std::fmt::Display,
1233{
1234    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1235        match self {
1236            Self::Recipe { stage, source } => {
1237                write!(formatter, "baseline {stage} failed: {source}")
1238            }
1239            Self::Contract(error) => write!(formatter, "baseline pool contract failed: {error}"),
1240        }
1241    }
1242}
1243
1244impl<E> std::error::Error for BaselinePoolPreparationError<E>
1245where
1246    E: std::error::Error + 'static,
1247{
1248    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1249        match self {
1250            Self::Recipe { source, .. } => Some(source),
1251            Self::Contract(error) => Some(error),
1252        }
1253    }
1254}
1255
1256impl<E> std::fmt::Display for BaselinePoolError<E>
1257where
1258    E: std::fmt::Display,
1259{
1260    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1261        match self {
1262            Self::Preparation { error, .. } => error.fmt(formatter),
1263            Self::RecoveryFailed {
1264                original, rebuild, ..
1265            } => write!(
1266                formatter,
1267                "baseline preparation failed ({original}); rebuilding the slot also failed: {rebuild}",
1268            ),
1269        }
1270    }
1271}
1272
1273impl<E> std::error::Error for BaselinePoolError<E>
1274where
1275    E: std::error::Error + 'static,
1276{
1277    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1278        match self {
1279            Self::Preparation { error, .. } => Some(error),
1280            Self::RecoveryFailed { original, .. } => Some(original.as_ref()),
1281        }
1282    }
1283}
1284
1285#[cfg(test)]
1286mod tests {
1287    use super::{
1288        BaselinePoolContractError, CanisterRestoreReceipt, CycleResetPolicy, FixtureRecipeId,
1289        ResetAchievement, ResetDomainKind, ResetReceipt, ResetRequirement, ResetRequirements,
1290        TimeResetPolicy,
1291    };
1292    use candid::Principal;
1293
1294    #[test]
1295    fn recipe_identity_must_be_nonempty() {
1296        assert!(matches!(
1297            FixtureRecipeId::try_new("  "),
1298            Err(BaselinePoolContractError::EmptyRecipeIdentity)
1299        ));
1300    }
1301
1302    #[test]
1303    fn reset_requirements_reject_duplicate_domains() {
1304        let result = ResetRequirements::try_new(
1305            CycleResetPolicy::PreserveCurrent,
1306            [
1307                ResetRequirement::PocketIcTime(TimeResetPolicy::PreserveCurrent),
1308                ResetRequirement::PocketIcTime(TimeResetPolicy::RebuildOnMutation),
1309            ],
1310        );
1311        assert!(matches!(
1312            result,
1313            Err(BaselinePoolContractError::DuplicateResetDomain {
1314                domain: ResetDomainKind::PocketIcTime,
1315            })
1316        ));
1317    }
1318
1319    #[test]
1320    fn required_policy_must_match_achieved_policy() {
1321        let requirements = ResetRequirements::try_new(
1322            CycleResetPolicy::PreserveCurrent,
1323            [ResetRequirement::PocketIcTime(
1324                TimeResetPolicy::PreserveCurrent,
1325            )],
1326        )
1327        .unwrap();
1328        let restore = CanisterRestoreReceipt::try_new(
1329            [Principal::anonymous()],
1330            CycleResetPolicy::PreserveCurrent,
1331        )
1332        .unwrap();
1333        let receipt = ResetReceipt::try_new([ResetAchievement::PocketIcTime(
1334            TimeResetPolicy::RebuildOnMutation,
1335        )])
1336        .unwrap();
1337        assert!(matches!(
1338            requirements.verify(&restore, &receipt),
1339            Err(BaselinePoolContractError::ResetPolicyMismatch { .. })
1340        ));
1341    }
1342
1343    #[test]
1344    fn restore_cycle_policy_must_match_required_policy() {
1345        let requirements =
1346            ResetRequirements::try_new(CycleResetPolicy::RestoreExactBaseline, []).unwrap();
1347        let restore = CanisterRestoreReceipt::try_new(
1348            [Principal::anonymous()],
1349            CycleResetPolicy::PreserveCurrent,
1350        )
1351        .unwrap();
1352        assert!(matches!(
1353            requirements.verify(&restore, &ResetReceipt::empty()),
1354            Err(BaselinePoolContractError::CyclePolicyMismatch {
1355                required: CycleResetPolicy::RestoreExactBaseline,
1356                achieved: CycleResetPolicy::PreserveCurrent,
1357            })
1358        ));
1359    }
1360}