Skip to main content

polyc_runtime/
warm_swap.rs

1//! Warm drain-swap-resume — the click-to-restart path for a wire- and
2//! schema-compatible binary swap (see the update PRD, [`crate::compat`], and
3//! [`crate::stager`]).
4//!
5//! This is the "click to restart" layer. A [`Compatibility::Warm`] release is a
6//! new binary whose fingerprint is otherwise identical to the running one — only
7//! binary internals moved — so it cannot be picked up at a turn boundary the way
8//! a hot config bundle is ([`crate::hot_reload`]); the process itself has to be
9//! replaced. But a live conversation must not be yanked mid-turn. This module
10//! orchestrates the smallest safe restart: stop admitting new turns, let
11//! in-flight turns and paused approvals reach a committed checkpoint, release the
12//! single-writer lease, swap the process or image, re-acquire the lease, and
13//! resume from the last committed turn.
14//!
15//! # It orchestrates; it does not reinvent
16//!
17//! Every invariant this path relies on is already built and tested elsewhere —
18//! the job here is to sequence those primitives, not to duplicate them:
19//!
20//! - **The commit marker is the drain point.** A turn is persisted to the event
21//!   log — bracketed by its `turn_start`/`turn_complete` pair — before the
22//!   harness is dialed, and a turn missing its `turn_complete` is discarded on
23//!   replay (the control plane's `filter_committed_turns`). So "drain" is not a
24//!   new barrier: it is waiting for the in-flight turn to reach its existing
25//!   commit marker, which doubles as the rollback net.
26//! - **The lease is the existing single-writer guard.** The per-conversation
27//!   `coordination.k8s.io/v1` Lease already enforces exactly one writer; a warm
28//!   swap releases it before the swap and re-acquires it after, so the swapped-in
29//!   process is the sole writer with no split-brain window.
30//! - **Resume is the existing replay path.** Restarting from the last committed
31//!   turn — re-driving any suspended HITL approval — is the same reconstruct the
32//!   control plane runs on every reconnect.
33//! - **The swap core reuses the stager wholesale.** Verifying the new binary's
34//!   signature, applying the pointer flip, health-checking it, and auto-rolling
35//!   back on failure are [`crate::stager`]'s job; the only bespoke piece is the
36//!   [`Activator`], which for a warm swap is a process or image swap.
37//!
38//! Because those primitives live in the control plane (a container) while this
39//! crate is a foundation component, the orchestrator is expressed over injectable
40//! seams — the same shape [`crate::stager`] and [`crate::hot_reload`] use. The
41//! control plane wires each seam to its real drain / lease / resume code; the
42//! state machine and its ordering invariant are exercised here without a cluster.
43//!
44//! # The classifier gate
45//!
46//! Only a release that classifies [`Compatibility::Warm`] takes this path.
47//! [`ensure_warm`] refuses every other verdict: a [`Compatibility::Hot`] change
48//! reloads at the turn boundary with no restart, a [`Compatibility::Cold`] change
49//! needs a coordinated fleet redeploy, and an
50//! [`Incompatible`](crate::compat::Compatibility::Incompatible) bundle was
51//! authored against a runtime this build cannot satisfy. The gate runs before any
52//! seam is touched, so a non-warm release never closes admission, drains, or
53//! releases the lease.
54//!
55//! # The ordering invariant
56//!
57//! [`WarmSwap::run`] walks a fixed sequence, and the ordering is the whole point:
58//!
59//! ```text
60//!   ensure_warm ─▶ close admission ─▶ drain to committed checkpoint
61//!                                              │
62//!                                              ▼
63//!            release lease ─▶ swap (stager: verify→apply→health→rollback)
64//!                                              │
65//!                                              ▼
66//!            re-acquire lease ─▶ resume from the last committed turn
67//! ```
68//!
69//! - No new turn is admitted once `close` runs, which is *before* the drain
70//!   begins.
71//! - In-flight turns and paused approvals reach a committed checkpoint *before*
72//!   the swap — the checkpoint is captured while the lease is still held.
73//! - The lease is released *before* it is re-acquired, so there is never a moment
74//!   with two writers.
75//! - Resume restarts from exactly the drained checkpoint, so no committed turn is
76//!   dropped and none is replayed twice; a paused approval captured in the
77//!   checkpoint is re-driven.
78//!
79//! The lease is re-acquired and the conversation is resumed on *whichever* binary
80//! is live after the swap — the new one on a committed swap, the previous one if
81//! the health check rolled back — so a conversation is never left drained-but-dead
82//! behind a failed swap.
83
84use thiserror::Error;
85
86use crate::compat::Compatibility;
87use crate::stager::{
88    Activator, HealthCheck, Outcome, ReleaseId, SignatureVerifier, StageError, Stager, UpdateSource,
89};
90
91/// The drained resume point: the last fully-committed turn plus the paused
92/// approvals that must survive the swap.
93///
94/// Captured by [`Drain::drain`] while the lease is still held, and threaded into
95/// [`Resume::resume`] so the swapped-in process restarts from exactly here. Turns
96/// that never reached their `turn_complete` marker are not part of it — they are
97/// the interrupted turns replay discards — so resuming from this point neither
98/// drops a committed turn nor replays an uncommitted one.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct Checkpoint {
101    /// The id of the last fully-committed turn — the point resume restarts from.
102    last_committed_turn: String,
103    /// How many HITL approvals were paused at the checkpoint. They are persisted
104    /// with the turn, so they survive the swap and are re-driven on resume.
105    paused_approvals: usize,
106}
107
108impl Checkpoint {
109    /// Build a checkpoint from the last committed turn id and the count of paused
110    /// approvals carried across the swap.
111    #[must_use]
112    pub fn new(last_committed_turn: impl Into<String>, paused_approvals: usize) -> Self {
113        Self {
114            last_committed_turn: last_committed_turn.into(),
115            paused_approvals,
116        }
117    }
118
119    /// The id of the last fully-committed turn — the resume restart point.
120    #[must_use]
121    pub fn last_committed_turn(&self) -> &str {
122        &self.last_committed_turn
123    }
124
125    /// How many paused HITL approvals were captured at the checkpoint and must be
126    /// re-driven after the swap.
127    #[must_use]
128    pub const fn paused_approvals(&self) -> usize {
129        self.paused_approvals
130    }
131}
132
133/// What resume did after the swap: where it restarted and how many paused
134/// approvals it re-drove.
135///
136/// [`WarmSwap::run`] checks this against the drained [`Checkpoint`] so a seam that
137/// restarts from the wrong turn — dropping or duplicating a committed turn — is a
138/// hard error rather than a silent divergence.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct ResumeReport {
141    /// The turn id resume restarted from. Must equal the checkpoint's
142    /// [`Checkpoint::last_committed_turn`].
143    resumed_from: String,
144    /// How many paused approvals were re-driven on resume.
145    redriven_approvals: usize,
146}
147
148impl ResumeReport {
149    /// Build a resume report from the turn resumed and the approvals re-driven.
150    #[must_use]
151    pub fn new(resumed_from: impl Into<String>, redriven_approvals: usize) -> Self {
152        Self {
153            resumed_from: resumed_from.into(),
154            redriven_approvals,
155        }
156    }
157
158    /// The turn id resume restarted from.
159    #[must_use]
160    pub fn resumed_from(&self) -> &str {
161        &self.resumed_from
162    }
163
164    /// How many paused approvals resume re-drove.
165    #[must_use]
166    pub const fn redriven_approvals(&self) -> usize {
167        self.redriven_approvals
168    }
169}
170
171/// The result of a warm swap that got as far as resuming.
172///
173/// Carries the swap [`Outcome`] (committed onto the new binary, or rolled back to
174/// the previous one), the drained [`Checkpoint`], and the [`ResumeReport`]. The
175/// swap outcome is honest about a rollback, so the surface presenting the restart
176/// can say which binary is actually serving.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct WarmSwapReport {
179    /// What the swap did — [`Outcome::Committed`] onto the new binary or
180    /// [`Outcome::RolledBack`] to the previous one after a failed health check.
181    pub swap: Outcome,
182    /// The point the conversation drained to and resumed from.
183    pub checkpoint: Checkpoint,
184    /// Where resume restarted and how many approvals it re-drove.
185    pub resume: ResumeReport,
186}
187
188/// Why a warm swap was refused or could not complete.
189#[derive(Debug, Error)]
190pub enum WarmSwapError {
191    /// The release did not classify [`Compatibility::Warm`], so it does not take
192    /// the click-to-restart path. A hot change reloads with no restart, a cold
193    /// change needs a coordinated redeploy, and an incompatible bundle was built
194    /// for a runtime this binary cannot satisfy.
195    #[error("refused: only a warm binary swap takes the click-to-restart path ({})", verdict_label(.0))]
196    NotWarm(Compatibility),
197    /// Admission could not be closed, so new turns cannot be reliably stopped and
198    /// the swap is abandoned before the lease is touched.
199    #[error("could not stop admitting new turns: {0}")]
200    Admission(String),
201    /// The drain could not reach a committed checkpoint, so the swap is abandoned
202    /// with the lease still held — nothing was released or swapped.
203    #[error("draining to a committed checkpoint failed: {0}")]
204    Drain(String),
205    /// A lease operation failed — either releasing the lease before the swap or
206    /// re-acquiring it after.
207    #[error("lease {op} failed: {reason}")]
208    Lease {
209        /// Which lease operation failed: `"release"` or `"re-acquire"`.
210        op: &'static str,
211        /// The underlying failure.
212        reason: String,
213    },
214    /// The stager could not verify or apply the binary swap. Wraps the underlying
215    /// [`StageError`] — most importantly [`StageError::Unverified`], which means
216    /// the new binary's signature did not verify and nothing was swapped.
217    #[error(transparent)]
218    Stage(#[from] StageError),
219    /// Resume could not restart the conversation from the checkpoint after the
220    /// swap. The lease has already been re-acquired, so this is a resume failure
221    /// on a live binary, not a lost lease.
222    #[error("resuming from the last committed turn failed: {0}")]
223    Resume(String),
224    /// Resume restarted from a different turn than the one drained — a dropped or
225    /// duplicated turn. Named separately from [`Self::Resume`] because it is a
226    /// correctness violation, not a transport failure.
227    #[error(
228        "resume restarted from turn {resumed_from:?} but drained to {expected:?} — \
229         a committed turn would be dropped or replayed"
230    )]
231    ResumeMismatch {
232        /// The turn resume actually restarted from.
233        resumed_from: String,
234        /// The checkpoint turn it should have restarted from.
235        expected: String,
236    },
237}
238
239/// A short, plain-language name for a refused verdict, for [`WarmSwapError`].
240const fn verdict_label(verdict: &Compatibility) -> &'static str {
241    match verdict {
242        Compatibility::Warm => "warm",
243        Compatibility::Hot => "a config change that reloads with no restart",
244        Compatibility::Cold => "a format change that needs a coordinated redeploy",
245        Compatibility::Incompatible(_) => "built for a different runtime",
246    }
247}
248
249/// Gate a classification verdict onto the warm path: [`Ok`] only for
250/// [`Compatibility::Warm`].
251///
252/// This is the classifier interlock in one place — the swap proceeds only when
253/// the change is a wire- and schema-compatible binary swap. Any other verdict
254/// (`Hot`, `Cold`, or `Incompatible`) is refused before any seam is touched.
255///
256/// # Errors
257///
258/// Returns [`WarmSwapError::NotWarm`] carrying the refused verdict for anything
259/// other than [`Compatibility::Warm`].
260pub fn ensure_warm(verdict: &Compatibility) -> Result<(), WarmSwapError> {
261    if *verdict == Compatibility::Warm {
262        Ok(())
263    } else {
264        Err(WarmSwapError::NotWarm(verdict.clone()))
265    }
266}
267
268/// Stops admitting new turns — the readiness flip / turn-admission gate.
269///
270/// Called once, before the drain, so no turn is admitted while the conversation
271/// drains. In production this flips the readiness signal the edge and the
272/// admission check read, so the load balancer stops routing new turns here.
273pub trait AdmissionGate {
274    /// Stop admitting new turns.
275    ///
276    /// # Errors
277    ///
278    /// Returns [`WarmSwapError::Admission`] when admission cannot be closed.
279    fn close(&self) -> Result<(), WarmSwapError>;
280}
281
282impl<F> AdmissionGate for F
283where
284    F: Fn() -> Result<(), WarmSwapError>,
285{
286    fn close(&self) -> Result<(), WarmSwapError> {
287        self()
288    }
289}
290
291/// Drains in-flight turns and paused approvals to a committed checkpoint.
292///
293/// Runs while the lease is still held. It waits for the in-flight turn to reach
294/// its `turn_complete` marker — the commit point every turn is already bracketed
295/// by — and captures the last committed turn plus any paused approvals as a
296/// [`Checkpoint`]. It never forces a partial turn to commit: an interrupted turn
297/// is simply excluded, exactly as replay would discard it.
298pub trait Drain {
299    /// Drain to a committed checkpoint and return it.
300    ///
301    /// # Errors
302    ///
303    /// Returns [`WarmSwapError::Drain`] when the conversation cannot reach a
304    /// committed checkpoint (for example a journal sync failed).
305    fn drain(&self) -> Result<Checkpoint, WarmSwapError>;
306}
307
308impl<F> Drain for F
309where
310    F: Fn() -> Result<Checkpoint, WarmSwapError>,
311{
312    fn drain(&self) -> Result<Checkpoint, WarmSwapError> {
313        self()
314    }
315}
316
317/// Releases and re-acquires the single-writer per-conversation lease.
318///
319/// A warm swap releases the lease after draining and re-acquires it after the
320/// swap, so the swapped-in process is the sole writer with no split-brain window.
321/// In production this wraps the `coordination.k8s.io/v1` Lease: `release` is the
322/// lease handle's drop (clears `holderIdentity`) and `reacquire` is a fresh
323/// `try_acquire`.
324pub trait LeaseControl {
325    /// Release the lease held for this conversation.
326    ///
327    /// # Errors
328    ///
329    /// Returns [`WarmSwapError::Lease`] (with `op = "release"`) when the lease
330    /// cannot be released.
331    fn release(&self) -> Result<(), WarmSwapError>;
332
333    /// Re-acquire the lease for this conversation.
334    ///
335    /// # Errors
336    ///
337    /// Returns [`WarmSwapError::Lease`] (with `op = "re-acquire"`) when the lease
338    /// cannot be re-acquired.
339    fn reacquire(&self) -> Result<(), WarmSwapError>;
340}
341
342/// Resumes the conversation from the last committed turn after the swap.
343///
344/// Given the drained [`Checkpoint`], it replays from the last committed turn and
345/// re-drives any paused HITL approval — the same reconstruct the control plane
346/// runs on a reconnect. It restarts from exactly the checkpoint turn, so no
347/// committed turn is dropped and none is replayed twice.
348pub trait Resume {
349    /// Resume from `checkpoint` and report where it restarted.
350    ///
351    /// # Errors
352    ///
353    /// Returns [`WarmSwapError::Resume`] when the conversation cannot be resumed.
354    fn resume(&self, checkpoint: &Checkpoint) -> Result<ResumeReport, WarmSwapError>;
355}
356
357impl<F> Resume for F
358where
359    F: Fn(&Checkpoint) -> Result<ResumeReport, WarmSwapError>,
360{
361    fn resume(&self, checkpoint: &Checkpoint) -> Result<ResumeReport, WarmSwapError> {
362        self(checkpoint)
363    }
364}
365
366/// The warm drain-swap-resume orchestrator over its four control seams.
367///
368/// Holds the admission gate, the drain, the lease control, and the resume seam.
369/// [`WarmSwap::run`] drives the fixed sequence, delegating the swap core (verify →
370/// apply → health check → auto-rollback) to a [`Stager`] whose [`Activator`] is a
371/// process or image swap. The orchestrator adds only the ordering: close before
372/// drain, drain before release, release before swap, swap before re-acquire,
373/// re-acquire before resume.
374pub struct WarmSwap<G, D, L, R> {
375    gate: G,
376    drain: D,
377    lease: L,
378    resume: R,
379}
380
381impl<G, D, L, R> WarmSwap<G, D, L, R>
382where
383    G: AdmissionGate,
384    D: Drain,
385    L: LeaseControl,
386    R: Resume,
387{
388    /// Build a warm-swap orchestrator over its four control seams.
389    pub const fn new(gate: G, drain: D, lease: L, resume: R) -> Self {
390        Self {
391            gate,
392            drain,
393            lease,
394            resume,
395        }
396    }
397
398    /// Run the warm drain-swap-resume for a `Warm`-classified `release`.
399    ///
400    /// The sequence is fixed and its ordering is the invariant:
401    ///
402    /// 1. gate on the classifier — refuse anything but [`Compatibility::Warm`]
403    ///    before touching any seam;
404    /// 2. close admission, so no new turn is admitted while draining;
405    /// 3. drain to a committed checkpoint — in-flight turns and paused approvals
406    ///    reach their `turn_complete` marker, captured as a [`Checkpoint`], while
407    ///    the lease is still held;
408    /// 4. release the lease;
409    /// 5. swap via the [`Stager`] — verify the new binary's signature, apply the
410    ///    pointer flip, health-check, and auto-roll-back on failure;
411    /// 6. re-acquire the lease — always, on whichever binary is now live, so the
412    ///    conversation is never left drained-but-dead;
413    /// 7. resume from the checkpoint, re-driving paused approvals, and verify it
414    ///    restarted from exactly the drained turn.
415    ///
416    /// The lease re-acquire in step 6 runs even when the swap rolled back or its
417    /// signature was refused, so a failed swap surfaces its error only after the
418    /// conversation is back under a held lease.
419    ///
420    /// # Errors
421    ///
422    /// Returns [`WarmSwapError::NotWarm`] when `verdict` is not warm (nothing is
423    /// drained or swapped); [`WarmSwapError::Admission`] or
424    /// [`WarmSwapError::Drain`] when the pre-swap steps fail (the lease is still
425    /// held); [`WarmSwapError::Lease`] when a release or re-acquire fails;
426    /// [`WarmSwapError::Stage`] when the stager cannot verify or apply the swap
427    /// (surfaced after the lease is re-acquired); and [`WarmSwapError::Resume`] or
428    /// [`WarmSwapError::ResumeMismatch`] when resume fails or restarts from the
429    /// wrong turn.
430    pub fn run<S, V, A, H>(
431        &self,
432        verdict: &Compatibility,
433        stager: &mut Stager<S, V, A, H>,
434        release: &ReleaseId,
435    ) -> Result<WarmSwapReport, WarmSwapError>
436    where
437        S: UpdateSource,
438        V: SignatureVerifier,
439        A: Activator,
440        H: HealthCheck,
441    {
442        // 1. Classifier gate — no seam is touched for a non-warm release.
443        ensure_warm(verdict)?;
444
445        // 2. Stop admitting new turns BEFORE the drain begins.
446        self.gate.close()?;
447
448        // 3. Drain to a committed checkpoint while the lease is still held. This
449        //    is the existing commit marker doubling as the drain point and the
450        //    rollback net — an interrupted turn is excluded, never forced.
451        let checkpoint = self.drain.drain()?;
452
453        // 4. Release the lease before the swap, so the re-acquire in step 6 never
454        //    races a still-held writer.
455        self.lease
456            .release()
457            .map_err(|e| relabel_lease(e, "release"))?;
458
459        // 5. Swap: reuse the stager wholesale for verify → apply → health →
460        //    auto-rollback. Capture the result rather than `?`-returning it, so
461        //    the lease is re-acquired even when the swap is refused or rolls back.
462        let swap = stager.stage_and_apply(release);
463
464        // 6. Re-acquire the lease on whichever binary is now live, ALWAYS — a
465        //    failed swap must not leave the conversation drained-but-dead.
466        self.lease
467            .reacquire()
468            .map_err(|e| relabel_lease(e, "re-acquire"))?;
469
470        // A swap that could not even apply (unverified binary, failed flip) is a
471        // hard error — surfaced now that the lease is back and the previous binary
472        // is live for the caller's retry. We never paper the swap error over.
473        let swap = swap?;
474
475        // 7. Resume from exactly the drained checkpoint, re-driving paused
476        //    approvals, and verify no committed turn was dropped or duplicated.
477        let resume = self.resume.resume(&checkpoint)?;
478        if resume.resumed_from() != checkpoint.last_committed_turn() {
479            return Err(WarmSwapError::ResumeMismatch {
480                resumed_from: resume.resumed_from().to_owned(),
481                expected: checkpoint.last_committed_turn().to_owned(),
482            });
483        }
484
485        Ok(WarmSwapReport {
486            swap,
487            checkpoint,
488            resume,
489        })
490    }
491}
492
493/// Stamp the failing lease operation onto a [`WarmSwapError::Lease`] so the error
494/// names whether the release or the re-acquire failed, regardless of how the seam
495/// built it.
496fn relabel_lease(err: WarmSwapError, op: &'static str) -> WarmSwapError {
497    match err {
498        WarmSwapError::Lease { reason, .. } => WarmSwapError::Lease { op, reason },
499        other => other,
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    #![allow(clippy::pedantic, clippy::nursery, missing_docs)]
506
507    use std::cell::RefCell;
508    use std::path::PathBuf;
509    use std::rc::Rc;
510
511    use super::*;
512    use crate::compat::{Incompatibility, RuntimeTarget, StagedBundle};
513    use crate::stager::{Health, StagedArtifact};
514
515    /// A shared ordered log of every step across all seams, so a test can assert
516    /// the exact sequence a warm swap walked — the deterministic-runtime style of
517    /// the interrupted-turn / resume tests.
518    type Trace = Rc<RefCell<Vec<String>>>;
519
520    fn trace() -> Trace {
521        Rc::new(RefCell::new(Vec::new()))
522    }
523
524    fn log(trace: &Trace, step: impl Into<String>) {
525        trace.borrow_mut().push(step.into());
526    }
527
528    /// An admission gate that records the close and exposes whether a turn would
529    /// still be admitted — so a test can prove no turn is admitted after close.
530    #[derive(Clone)]
531    struct RecordingGate {
532        trace: Trace,
533        open: Rc<RefCell<bool>>,
534    }
535
536    impl RecordingGate {
537        fn new(trace: &Trace) -> Self {
538            Self {
539                trace: Rc::clone(trace),
540                open: Rc::new(RefCell::new(true)),
541            }
542        }
543
544        /// Whether a new turn would be admitted right now.
545        fn admits(&self) -> bool {
546            *self.open.borrow()
547        }
548    }
549
550    impl AdmissionGate for RecordingGate {
551        fn close(&self) -> Result<(), WarmSwapError> {
552            *self.open.borrow_mut() = false;
553            log(&self.trace, "close");
554            Ok(())
555        }
556    }
557
558    /// A drain over a tiny model event log: a list of `(turn_id, committed)`. It
559    /// records the drain, asserts admission is already closed, and returns the
560    /// last COMMITTED turn — an interrupted turn is excluded exactly as
561    /// `filter_committed_turns` would discard it.
562    #[derive(Clone)]
563    struct RecordingDrain {
564        trace: Trace,
565        gate: RecordingGate,
566        log: Vec<(&'static str, bool)>,
567        paused_approvals: usize,
568    }
569
570    impl RecordingDrain {
571        fn new(
572            trace: &Trace,
573            gate: &RecordingGate,
574            model_log: Vec<(&'static str, bool)>,
575            paused_approvals: usize,
576        ) -> Self {
577            Self {
578                trace: Rc::clone(trace),
579                gate: gate.clone(),
580                log: model_log,
581                paused_approvals,
582            }
583        }
584    }
585
586    impl Drain for RecordingDrain {
587        fn drain(&self) -> Result<Checkpoint, WarmSwapError> {
588            // Admission MUST already be closed before the drain begins.
589            assert!(
590                !self.gate.admits(),
591                "drain began while new turns were still admitted"
592            );
593            log(&self.trace, "drain");
594            // The resume point is the last fully-committed turn; an uncommitted
595            // (interrupted) turn is never the checkpoint.
596            let last_committed = self
597                .log
598                .iter()
599                .rev()
600                .find_map(|(id, committed)| committed.then_some(*id))
601                .expect("model log has at least one committed turn");
602            Ok(Checkpoint::new(last_committed, self.paused_approvals))
603        }
604    }
605
606    /// Lease control that records release / re-acquire and tracks held state, so a
607    /// test can prove the lease was released before it was re-acquired and never
608    /// double-held.
609    #[derive(Clone)]
610    struct RecordingLease {
611        trace: Trace,
612        held: Rc<RefCell<bool>>,
613        fail_release: bool,
614        fail_reacquire: bool,
615    }
616
617    impl RecordingLease {
618        fn new(trace: &Trace) -> Self {
619            Self {
620                trace: Rc::clone(trace),
621                held: Rc::new(RefCell::new(true)),
622                fail_release: false,
623                fail_reacquire: false,
624            }
625        }
626
627        fn failing_reacquire(trace: &Trace) -> Self {
628            Self {
629                fail_reacquire: true,
630                ..Self::new(trace)
631            }
632        }
633
634        fn is_held(&self) -> bool {
635            *self.held.borrow()
636        }
637    }
638
639    impl LeaseControl for RecordingLease {
640        fn release(&self) -> Result<(), WarmSwapError> {
641            if self.fail_release {
642                return Err(WarmSwapError::Lease {
643                    op: "release",
644                    reason: "api timeout".to_owned(),
645                });
646            }
647            assert!(self.is_held(), "released a lease that was not held");
648            *self.held.borrow_mut() = false;
649            log(&self.trace, "lease.release");
650            Ok(())
651        }
652
653        fn reacquire(&self) -> Result<(), WarmSwapError> {
654            if self.fail_reacquire {
655                return Err(WarmSwapError::Lease {
656                    op: "re-acquire",
657                    reason: "held by another".to_owned(),
658                });
659            }
660            assert!(
661                !self.is_held(),
662                "re-acquired a lease that was never released — split-brain writer"
663            );
664            *self.held.borrow_mut() = true;
665            log(&self.trace, "lease.reacquire");
666            Ok(())
667        }
668    }
669
670    /// A resume seam that records the restart, re-drives the paused approvals from
671    /// the checkpoint, and (unless told to drift) restarts from exactly the
672    /// checkpoint turn.
673    #[derive(Clone)]
674    struct RecordingResume {
675        trace: Trace,
676        gate: RecordingGate,
677        lease: RecordingLease,
678        drift_to: Option<&'static str>,
679    }
680
681    impl RecordingResume {
682        fn new(trace: &Trace, gate: &RecordingGate, lease: &RecordingLease) -> Self {
683            Self {
684                trace: Rc::clone(trace),
685                gate: gate.clone(),
686                lease: lease.clone(),
687                drift_to: None,
688            }
689        }
690
691        fn drifting(
692            trace: &Trace,
693            gate: &RecordingGate,
694            lease: &RecordingLease,
695            to: &'static str,
696        ) -> Self {
697            Self {
698                drift_to: Some(to),
699                ..Self::new(trace, gate, lease)
700            }
701        }
702    }
703
704    impl Resume for RecordingResume {
705        fn resume(&self, checkpoint: &Checkpoint) -> Result<ResumeReport, WarmSwapError> {
706            // Resume runs only under a re-held lease, on a live binary.
707            assert!(self.lease.is_held(), "resumed without a re-acquired lease");
708            log(
709                &self.trace,
710                format!("resume@{}", checkpoint.last_committed_turn()),
711            );
712            // The swapped-in process comes back ready to admit turns again.
713            *self.gate.open.borrow_mut() = true;
714            let from = self.drift_to.unwrap_or(checkpoint.last_committed_turn());
715            Ok(ResumeReport::new(from, checkpoint.paused_approvals()))
716        }
717    }
718
719    /// A recording swap activator (the process / image swap), sharing its flip log
720    /// with the trace.
721    #[derive(Clone)]
722    struct SwapActivator {
723        trace: Trace,
724    }
725
726    impl SwapActivator {
727        fn new(trace: &Trace) -> Self {
728            Self {
729                trace: Rc::clone(trace),
730            }
731        }
732    }
733
734    impl Activator for SwapActivator {
735        fn activate(&self, release: &ReleaseId) -> Result<(), StageError> {
736            log(&self.trace, format!("swap:{release}"));
737            Ok(())
738        }
739    }
740
741    fn artifact_for(release: &ReleaseId) -> StagedArtifact {
742        StagedArtifact {
743            release: release.clone(),
744            staged_path: PathBuf::from(format!("/var/lib/polychrome/staged/{release}")),
745            bundle: StagedBundle::new(
746                RuntimeTarget::new(3, 7, "polychrome.uno/v1"),
747                format!("catalog-{release}"),
748            ),
749            signed_bytes: format!("bytes-of-{release}").into_bytes(),
750            signature: vec![0xAB; 4],
751            signer_public_key: vec![0xCD; 4],
752        }
753    }
754
755    fn ok_source(release: &ReleaseId) -> Result<StagedArtifact, StageError> {
756        Ok(artifact_for(release))
757    }
758
759    // --- The classifier gate ---------------------------------------------------
760
761    #[test]
762    fn ensure_warm_admits_only_warm() {
763        assert!(ensure_warm(&Compatibility::Warm).is_ok());
764        for verdict in [
765            Compatibility::Hot,
766            Compatibility::Cold,
767            Compatibility::Incompatible(Incompatibility::Wire),
768        ] {
769            let err = ensure_warm(&verdict).unwrap_err();
770            assert!(
771                matches!(err, WarmSwapError::NotWarm(v) if v == verdict),
772                "non-warm verdict must be refused: {verdict:?}",
773            );
774        }
775    }
776
777    #[test]
778    fn a_non_warm_release_touches_no_seam() {
779        let trace = trace();
780        let gate = RecordingGate::new(&trace);
781        let drain = RecordingDrain::new(&trace, &gate, vec![("t1", true)], 0);
782        let lease = RecordingLease::new(&trace);
783        let resume = RecordingResume::new(&trace, &gate, &lease);
784        let swap = WarmSwap::new(gate.clone(), drain, lease.clone(), resume);
785
786        let mut stager = Stager::new(
787            |_: &ReleaseId| -> Result<StagedArtifact, StageError> {
788                panic!("download must not run for a non-warm release")
789            },
790            |_: &StagedArtifact| panic!("verify must not run for a non-warm release"),
791            SwapActivator::new(&trace),
792            || panic!("health check must not run for a non-warm release"),
793            ReleaseId::new("v1"),
794        );
795
796        // A HOT release must not take the warm path.
797        let err = swap
798            .run(&Compatibility::Hot, &mut stager, &ReleaseId::new("v2"))
799            .unwrap_err();
800
801        assert!(matches!(err, WarmSwapError::NotWarm(Compatibility::Hot)));
802        // No seam ran: admission is still open, the lease is still held, and the
803        // trace is empty.
804        assert!(
805            gate.admits(),
806            "admission must stay open for a refused release"
807        );
808        assert!(
809            lease.is_held(),
810            "the lease must stay held for a refused release"
811        );
812        assert!(
813            trace.borrow().is_empty(),
814            "no seam runs for a refused release"
815        );
816    }
817
818    // --- The ordering invariant, happy path ------------------------------------
819
820    #[test]
821    fn warm_swap_walks_close_drain_release_swap_reacquire_resume_in_order() {
822        let trace = trace();
823        let gate = RecordingGate::new(&trace);
824        // Model log: one committed turn, then an INTERRUPTED turn (no complete).
825        // The drain checkpoint must be the committed turn, never the orphan.
826        let drain = RecordingDrain::new(
827            &trace,
828            &gate,
829            vec![("t1", true), ("t2-interrupted", false)],
830            2,
831        );
832        let lease = RecordingLease::new(&trace);
833        let resume = RecordingResume::new(&trace, &gate, &lease);
834        let swap = WarmSwap::new(gate.clone(), drain, lease.clone(), resume);
835
836        let mut stager = Stager::new(
837            ok_source,
838            |_: &StagedArtifact| true,
839            SwapActivator::new(&trace),
840            || Health::Healthy,
841            ReleaseId::new("v1"),
842        );
843
844        let report = swap
845            .run(&Compatibility::Warm, &mut stager, &ReleaseId::new("v2"))
846            .unwrap();
847
848        // The exact ordering invariant, observed across every seam.
849        assert_eq!(
850            *trace.borrow(),
851            vec![
852                "close".to_owned(),
853                "drain".to_owned(),
854                "lease.release".to_owned(),
855                "swap:v2".to_owned(),
856                "lease.reacquire".to_owned(),
857                "resume@t1".to_owned(),
858            ],
859        );
860        // The swap committed onto the new binary.
861        assert_eq!(
862            report.swap,
863            Outcome::Committed {
864                version: ReleaseId::new("v2"),
865            }
866        );
867        // The checkpoint is the last COMMITTED turn, not the interrupted one.
868        assert_eq!(report.checkpoint.last_committed_turn(), "t1");
869        // Resume restarted from exactly the drained turn — no drop, no duplicate.
870        assert_eq!(report.resume.resumed_from(), "t1");
871        // The paused approvals survived the swap and were re-driven on resume.
872        assert_eq!(report.checkpoint.paused_approvals(), 2);
873        assert_eq!(report.resume.redriven_approvals(), 2);
874        // The lease is held again and admission is open on the swapped-in binary.
875        assert!(lease.is_held());
876        assert!(gate.admits());
877    }
878
879    #[test]
880    fn no_new_turn_is_admitted_once_the_drain_begins() {
881        // The gate's `close` runs before `drain`, and the drain asserts admission
882        // is already closed. This test makes the guarantee explicit: after the
883        // swap kicks off, a turn arriving mid-drain is refused.
884        let trace = trace();
885        let gate = RecordingGate::new(&trace);
886        assert!(gate.admits(), "admission starts open");
887
888        // Closing admission (step 2) flips it before any drain work.
889        gate.close().unwrap();
890        assert!(
891            !gate.admits(),
892            "a turn arriving after admission closes is refused"
893        );
894    }
895
896    #[test]
897    fn a_paused_approval_survives_the_warm_restart() {
898        let trace = trace();
899        let gate = RecordingGate::new(&trace);
900        // Three approvals paused at the checkpoint.
901        let drain = RecordingDrain::new(&trace, &gate, vec![("t7", true)], 3);
902        let lease = RecordingLease::new(&trace);
903        let resume = RecordingResume::new(&trace, &gate, &lease);
904        let swap = WarmSwap::new(gate, drain, lease, resume);
905
906        let mut stager = Stager::new(
907            ok_source,
908            |_: &StagedArtifact| true,
909            SwapActivator::new(&trace),
910            || Health::Healthy,
911            ReleaseId::new("v1"),
912        );
913
914        let report = swap
915            .run(&Compatibility::Warm, &mut stager, &ReleaseId::new("v2"))
916            .unwrap();
917
918        // The approvals paused before the swap are carried across it and re-driven
919        // after resume — none is lost to the restart.
920        assert_eq!(report.checkpoint.paused_approvals(), 3);
921        assert_eq!(report.resume.redriven_approvals(), 3);
922        assert_eq!(report.resume.resumed_from(), "t7");
923    }
924
925    // --- The swap rolled back: lease still re-acquired, conversation resumed ----
926
927    #[test]
928    fn a_rolled_back_swap_still_reacquires_the_lease_and_resumes() {
929        let trace = trace();
930        let gate = RecordingGate::new(&trace);
931        let drain = RecordingDrain::new(&trace, &gate, vec![("t1", true)], 1);
932        let lease = RecordingLease::new(&trace);
933        let resume = RecordingResume::new(&trace, &gate, &lease);
934        let swap = WarmSwap::new(gate.clone(), drain, lease.clone(), resume);
935
936        // The new binary fails its health check → the stager auto-rolls back to v1.
937        let mut stager = Stager::new(
938            ok_source,
939            |_: &StagedArtifact| true,
940            SwapActivator::new(&trace),
941            || Health::Unhealthy("readiness probe timed out".to_owned()),
942            ReleaseId::new("v1"),
943        );
944
945        let report = swap
946            .run(&Compatibility::Warm, &mut stager, &ReleaseId::new("v2"))
947            .unwrap();
948
949        // The swap rolled back to the previous binary...
950        assert_eq!(
951            report.swap,
952            Outcome::RolledBack {
953                stayed_on: ReleaseId::new("v1"),
954                reason: "readiness probe timed out".to_owned(),
955            }
956        );
957        // ...but the conversation was NOT left drained-but-dead: the lease is held
958        // again and it resumed from the committed turn on the previous binary.
959        assert!(
960            lease.is_held(),
961            "lease re-acquired even on a rolled-back swap"
962        );
963        assert!(gate.admits(), "admission re-opened on the previous binary");
964        assert_eq!(report.resume.resumed_from(), "t1");
965        // The pointer flipped forward to v2 then back to v1 — a rollback flip.
966        assert_eq!(
967            *trace.borrow(),
968            vec![
969                "close".to_owned(),
970                "drain".to_owned(),
971                "lease.release".to_owned(),
972                "swap:v2".to_owned(),
973                "swap:v1".to_owned(),
974                "lease.reacquire".to_owned(),
975                "resume@t1".to_owned(),
976            ],
977        );
978    }
979
980    // --- An unverified new binary is refused; the lease is put back -------------
981
982    #[test]
983    fn an_unverified_binary_is_refused_and_the_lease_is_reacquired() {
984        let trace = trace();
985        let gate = RecordingGate::new(&trace);
986        let drain = RecordingDrain::new(&trace, &gate, vec![("t1", true)], 0);
987        let lease = RecordingLease::new(&trace);
988        let resume = RecordingResume::new(&trace, &gate, &lease);
989        let swap = WarmSwap::new(gate, drain, lease.clone(), resume);
990
991        // The new binary's signature does not verify — the stager applies nothing.
992        let mut stager = Stager::new(
993            ok_source,
994            |_: &StagedArtifact| false,
995            SwapActivator::new(&trace),
996            || panic!("health check must not run for an unverified binary"),
997            ReleaseId::new("v1"),
998        );
999
1000        let err = swap
1001            .run(&Compatibility::Warm, &mut stager, &ReleaseId::new("v2"))
1002            .unwrap_err();
1003
1004        assert!(matches!(err, WarmSwapError::Stage(StageError::Unverified)));
1005        // Nothing swapped, but the lease is back so the conversation is not stuck
1006        // behind a released lease: release then re-acquire framed the refused swap.
1007        assert!(
1008            lease.is_held(),
1009            "the lease is re-acquired after a refused swap"
1010        );
1011        assert_eq!(
1012            *trace.borrow(),
1013            vec![
1014                "close".to_owned(),
1015                "drain".to_owned(),
1016                "lease.release".to_owned(),
1017                "lease.reacquire".to_owned(),
1018            ],
1019        );
1020        // Resume never runs for a refused swap — the swap error is surfaced first.
1021        assert!(!trace.borrow().iter().any(|s| s.starts_with("resume")));
1022    }
1023
1024    // --- Resume that drifts off the checkpoint is a hard correctness error ------
1025
1026    #[test]
1027    fn resume_from_the_wrong_turn_is_a_mismatch_error() {
1028        let trace = trace();
1029        let gate = RecordingGate::new(&trace);
1030        let drain = RecordingDrain::new(&trace, &gate, vec![("t1", true)], 0);
1031        let lease = RecordingLease::new(&trace);
1032        // Resume drifts to a different turn than the checkpoint — a dropped or
1033        // duplicated committed turn.
1034        let resume = RecordingResume::drifting(&trace, &gate, &lease, "t0-stale");
1035        let swap = WarmSwap::new(gate, drain, lease, resume);
1036
1037        let mut stager = Stager::new(
1038            ok_source,
1039            |_: &StagedArtifact| true,
1040            SwapActivator::new(&trace),
1041            || Health::Healthy,
1042            ReleaseId::new("v1"),
1043        );
1044
1045        let err = swap
1046            .run(&Compatibility::Warm, &mut stager, &ReleaseId::new("v2"))
1047            .unwrap_err();
1048
1049        assert!(matches!(
1050            err,
1051            WarmSwapError::ResumeMismatch { resumed_from, expected }
1052                if resumed_from == "t0-stale" && expected == "t1"
1053        ));
1054    }
1055
1056    // --- A failed drain leaves the lease held; nothing is released or swapped ---
1057
1058    #[test]
1059    fn a_failed_drain_never_releases_the_lease_or_swaps() {
1060        let trace = trace();
1061        let gate = RecordingGate::new(&trace);
1062        let lease = RecordingLease::new(&trace);
1063        let failing_drain = {
1064            let trace = Rc::clone(&trace);
1065            move || -> Result<Checkpoint, WarmSwapError> {
1066                log(&trace, "drain");
1067                Err(WarmSwapError::Drain("journal sync failed".to_owned()))
1068            }
1069        };
1070        let resume = RecordingResume::new(&trace, &gate, &lease);
1071        let swap = WarmSwap::new(gate.clone(), failing_drain, lease.clone(), resume);
1072
1073        let mut stager = Stager::new(
1074            ok_source,
1075            |_: &StagedArtifact| panic!("verify must not run when the drain fails"),
1076            SwapActivator::new(&trace),
1077            || panic!("health check must not run when the drain fails"),
1078            ReleaseId::new("v1"),
1079        );
1080
1081        let err = swap
1082            .run(&Compatibility::Warm, &mut stager, &ReleaseId::new("v2"))
1083            .unwrap_err();
1084
1085        assert!(matches!(err, WarmSwapError::Drain(_)));
1086        // The lease is still held — a failed drain must not release it — and
1087        // nothing swapped.
1088        assert!(lease.is_held(), "a failed drain keeps the lease held");
1089        assert_eq!(
1090            *trace.borrow(),
1091            vec!["close".to_owned(), "drain".to_owned()]
1092        );
1093    }
1094
1095    // --- A failed re-acquire is reported as a lease re-acquire error ------------
1096
1097    #[test]
1098    fn a_failed_reacquire_surfaces_as_a_lease_error() {
1099        let trace = trace();
1100        let gate = RecordingGate::new(&trace);
1101        let drain = RecordingDrain::new(&trace, &gate, vec![("t1", true)], 0);
1102        let lease = RecordingLease::failing_reacquire(&trace);
1103        let resume = RecordingResume::new(&trace, &gate, &lease);
1104        let swap = WarmSwap::new(gate, drain, lease, resume);
1105
1106        let mut stager = Stager::new(
1107            ok_source,
1108            |_: &StagedArtifact| true,
1109            SwapActivator::new(&trace),
1110            || Health::Healthy,
1111            ReleaseId::new("v1"),
1112        );
1113
1114        let err = swap
1115            .run(&Compatibility::Warm, &mut stager, &ReleaseId::new("v2"))
1116            .unwrap_err();
1117
1118        assert!(matches!(
1119            err,
1120            WarmSwapError::Lease {
1121                op: "re-acquire",
1122                ..
1123            }
1124        ));
1125    }
1126
1127    #[test]
1128    fn not_warm_error_reads_plainly() {
1129        let msg = WarmSwapError::NotWarm(Compatibility::Hot).to_string();
1130        assert_eq!(
1131            msg,
1132            "refused: only a warm binary swap takes the click-to-restart path \
1133             (a config change that reloads with no restart)",
1134        );
1135        for banned in ["sorry", "please", "unfortunately"] {
1136            assert!(!msg.to_lowercase().contains(banned));
1137        }
1138    }
1139}