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}