Skip to main content

atomr_cluster_tools/
kill_switch.rs

1//! FR-5 — cluster-wide kill switch (emergency halt latch).
2//!
3//! A [`ClusterKillSwitch`] is a distributed, monotonic "engaged / not
4//! engaged" latch backed by a distributed-data [`Flag`] CRDT stored in a
5//! [`Replicator`] under a well-known key. Because `Flag` OR-merges, once
6//! *any* node engages the switch the engaged state **survives merge,
7//! gossip, and rebalance** — there is no way to accidentally un-latch by
8//! losing a delta or by a node rejoining with a stale (off) copy. This
9//! is exactly the property an emergency halt needs.
10//!
11//! # Epochs (how "reset" works)
12//!
13//! A `Flag` is monotonic: it can only go `false -> true`, never back.
14//! That is the right safety property for *engaging*, but it means we
15//! cannot simply flip the same flag off to recover. Instead the switch
16//! is **epoch-versioned**: the current epoch is held in an
17//! [`LwwRegister<u64>`], and the latch flag lives under a key derived
18//! from `(base_key, epoch)`. [`ClusterKillSwitch::reset`] advances the
19//! epoch (LWW with a higher timestamp), which moves the gate to a
20//! *fresh* flag that starts `false`. The old epoch's flag stays latched
21//! forever (audit trail), but no longer gates the system.
22//!
23//! Reset is deliberately guarded by a **two-person rule**
24//! ([`ResetAuthorization`]): two distinct, non-empty approvers.
25//!
26//! # Quiescence
27//!
28//! [`ClusterKillSwitch::engage`] only flips the latch. Bringing the
29//! system to a safe stop is a separate step: registered
30//! [`HaltGuarded`] parties are asked to halt and acknowledge via
31//! [`ClusterKillSwitch::await_quiescence`]. Quiescence has two tiers:
32//!
33//! * **Local** — registered [`HaltGuarded`] parties on this node are
34//!   asked to halt and acknowledge through the ack channel they were
35//!   handed at registration.
36//! * **Cross-node** — when a [`HaltTransport`] is attached (via
37//!   [`ClusterKillSwitch::with_transport`]), `await_quiescence` also
38//!   broadcasts a [`HaltPdu::Halt`] to every peer and awaits a
39//!   [`HaltPdu::Ack`] back from each. Peers feed inbound PDUs in via
40//!   [`ClusterKillSwitch::apply_pdu`], which engages their local latch,
41//!   quiesces their own guarded parties, and acks. With no transport the
42//!   switch is purely local — sufficient for single-node operation and
43//!   tests.
44//!
45//! A `link_stream` adapter (so an atomr-streams `KillSwitch` could
46//! derive from this latch) is intentionally **out of scope** here to
47//! avoid taking an `atomr-streams` dependency; the streams-side switch
48//! would subscribe to this latch's flag key and complete its stream
49//! when engaged.
50
51use std::collections::{HashMap, HashSet};
52use std::sync::atomic::{AtomicU64, Ordering};
53use std::sync::Arc;
54use std::time::Duration;
55
56use parking_lot::Mutex;
57use tokio::sync::Notify;
58
59use atomr_cluster::QuorumObserver;
60use atomr_distributed_data::{Flag, LwwRegister, Replicator};
61
62/// Why the kill switch was engaged. Recorded alongside the latch for
63/// post-incident analysis.
64#[derive(Debug, Clone, PartialEq, Eq)]
65#[non_exhaustive]
66pub enum HaltReason {
67    /// Operator pulled the switch manually; carries a free-form note.
68    Manual(String),
69    /// SBR / quorum machinery reported quorum loss (see
70    /// [`KillSwitchQuorumObserver`]).
71    QuorumLost,
72    /// A risk/pre-trade guard tripped; carries a description.
73    RiskBreach(String),
74}
75
76/// Token returned by [`ClusterKillSwitch::engage`], identifying the
77/// engage operation (monotonic, per-process).
78#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
79pub struct HaltToken(pub u64);
80
81/// A party that must be brought to a safe stop when the switch engages.
82/// `on_halt` should stop accepting new work and prepare to acknowledge
83/// quiescence; it must not block for long.
84pub trait HaltGuarded: Send {
85    fn on_halt(&mut self, reason: &HaltReason);
86}
87
88/// Two-person authorization required to [`ClusterKillSwitch::reset`].
89#[derive(Debug, Clone)]
90pub struct ResetAuthorization {
91    pub approver_a: String,
92    pub approver_b: String,
93}
94
95/// Result of [`ClusterKillSwitch::await_quiescence`].
96///
97/// `acked` / `total` are the **combined** local + remote counts so existing
98/// callers keep working; the `remote_*` fields break that down for clusters.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub struct QuiescenceReport {
101    /// How many parties acknowledged within the timeout (local + remote).
102    pub acked: usize,
103    /// How many parties were expected to ack (local guarded + remote peers).
104    pub total: usize,
105    /// `true` if not all parties acked before the deadline.
106    pub timed_out: bool,
107    /// How many remote peers acked their halt within the timeout.
108    pub remote_acked: usize,
109    /// How many remote peers a halt was broadcast to (0 with no transport).
110    pub remote_total: usize,
111}
112
113/// Error returned by [`ClusterKillSwitch::reset`] when authorization is
114/// invalid.
115#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
116pub enum ResetError {
117    #[error("reset requires two non-empty approvers")]
118    MissingApprover,
119    #[error("reset requires two distinct approvers (got the same identity twice)")]
120    DuplicateApprover,
121}
122
123/// A registered guarded party: its halt callback plus an ack channel.
124/// On halt we invoke `on_halt`, then await its ack on the receiver.
125struct Guarded {
126    party: Mutex<Box<dyn HaltGuarded>>,
127    ack_rx: Mutex<Option<tokio::sync::oneshot::Receiver<()>>>,
128}
129
130/// Handle handed back from [`ClusterKillSwitch::register_guarded`] that a
131/// party uses to acknowledge it has quiesced.
132pub struct AckHandle {
133    tx: Option<tokio::sync::oneshot::Sender<()>>,
134}
135
136impl AckHandle {
137    /// Acknowledge that this party has reached a safe stopped state.
138    pub fn ack(mut self) {
139        if let Some(tx) = self.tx.take() {
140            let _ = tx.send(());
141        }
142    }
143}
144
145/// Wire shape of a cross-node kill-switch exchange. Mirrors the
146/// `MediatorPdu` pattern used by [`ClusterPubSub`](crate::ClusterPubSub):
147/// an opaque, serializable message that a [`HaltTransport`] carries to a
148/// peer and that the peer feeds back via [`ClusterKillSwitch::apply_pdu`].
149#[derive(Debug, Clone, PartialEq, Eq)]
150#[non_exhaustive]
151pub enum HaltPdu {
152    /// Ask the receiving node to engage its latch for `epoch`, bring its
153    /// local guarded parties to a safe stop, and ack. Carries the
154    /// originator's node id and the engaging [`HaltReason`] so the remote
155    /// audit trail matches the coordinator's.
156    Halt { from: String, epoch: u64, reason: HaltReason },
157    /// Acknowledge that `from` has quiesced its local parties for `epoch`.
158    Ack { from: String, epoch: u64 },
159}
160
161/// Pluggable transport for cross-node kill-switch fan-out. Sends a
162/// [`HaltPdu`] to a peer node identified by an opaque string node id
163/// (typically `Address::to_string()`); the receiver feeds inbound PDUs
164/// back into its local switch via [`ClusterKillSwitch::apply_pdu`].
165///
166/// This is the kill-switch analogue of
167/// [`MediatorTransport`](crate::MediatorTransport): atomr does not bind
168/// the safety latch to a concrete wire, so the cluster layer supplies the
169/// transport.
170pub trait HaltTransport: Send + Sync + 'static {
171    /// Node ids of all known peers, **excluding** this node.
172    fn peers(&self) -> Vec<String>;
173    /// Send `pdu` to `target_node`.
174    fn send(&self, target_node: &str, pdu: HaltPdu);
175}
176
177/// Distributed emergency-halt latch. Cheap to clone via `Arc`.
178pub struct ClusterKillSwitch {
179    replicator: Arc<Replicator>,
180    /// Base well-known key; the per-epoch flag key is derived from it.
181    base_key: String,
182    /// Stable node id used for LWW register writes.
183    node: String,
184    /// Monotonic source for [`HaltToken`]s and LWW timestamps.
185    seq: AtomicU64,
186    /// Reason recorded by the most recent local `engage`.
187    last_reason: Mutex<Option<HaltReason>>,
188    /// Locally-registered guarded parties for quiescence.
189    guarded: Mutex<Vec<Guarded>>,
190    /// Optional cross-node fan-out transport. `None` ⇒ purely local.
191    transport: Option<Arc<dyn HaltTransport>>,
192    /// Per-epoch set of peer node ids that have acked their halt.
193    remote_acks: Mutex<HashMap<u64, HashSet<String>>>,
194    /// Wakes `await_quiescence` when a remote ack lands in `remote_acks`.
195    ack_notify: Notify,
196}
197
198impl ClusterKillSwitch {
199    /// Create a switch over `replicator`, gating on the well-known
200    /// `base_key`. `node` is this node's stable id (used for LWW
201    /// register tie-breaking on the epoch counter).
202    pub fn new(
203        replicator: Arc<Replicator>,
204        base_key: impl Into<String>,
205        node: impl Into<String>,
206    ) -> Arc<Self> {
207        Self::build(replicator, base_key, node, None)
208    }
209
210    /// Like [`new`](Self::new) but attaches a [`HaltTransport`] so
211    /// [`await_quiescence`](Self::await_quiescence) also fans the halt out
212    /// to remote peers and collects their acks.
213    pub fn with_transport(
214        replicator: Arc<Replicator>,
215        base_key: impl Into<String>,
216        node: impl Into<String>,
217        transport: Arc<dyn HaltTransport>,
218    ) -> Arc<Self> {
219        Self::build(replicator, base_key, node, Some(transport))
220    }
221
222    fn build(
223        replicator: Arc<Replicator>,
224        base_key: impl Into<String>,
225        node: impl Into<String>,
226        transport: Option<Arc<dyn HaltTransport>>,
227    ) -> Arc<Self> {
228        let base_key = base_key.into();
229        let this = Arc::new(Self {
230            replicator,
231            base_key,
232            node: node.into(),
233            seq: AtomicU64::new(0),
234            last_reason: Mutex::new(None),
235            guarded: Mutex::new(Vec::new()),
236            transport,
237            remote_acks: Mutex::new(HashMap::new()),
238            ack_notify: Notify::new(),
239        });
240        // Seed the epoch register at 0 if absent so reads are stable.
241        if this.epoch_register().is_none() {
242            this.replicator.update(&this.epoch_key(), LwwRegister::new(&this.node, 0u64, this.next_ts()));
243        }
244        this
245    }
246
247    /// Access the backing replicator (telemetry / testing).
248    pub fn replicator(&self) -> &Arc<Replicator> {
249        &self.replicator
250    }
251
252    fn epoch_key(&self) -> String {
253        format!("{}::epoch", self.base_key)
254    }
255
256    /// Per-epoch flag key. The gate flag for epoch N lives here.
257    fn flag_key(&self, epoch: u64) -> String {
258        format!("{}::flag::{}", self.base_key, epoch)
259    }
260
261    fn next_ts(&self) -> u64 {
262        self.seq.fetch_add(1, Ordering::SeqCst) + 1
263    }
264
265    fn epoch_register(&self) -> Option<LwwRegister<u64>> {
266        self.replicator.get::<LwwRegister<u64>>(&self.epoch_key())
267    }
268
269    /// Current epoch (0 if unset).
270    pub fn epoch(&self) -> u64 {
271        self.epoch_register().map(|r| *r.value()).unwrap_or(0)
272    }
273
274    /// Engage the latch for the current epoch and record `reason`.
275    /// Idempotent at the CRDT level (OR-merge); calling twice is safe.
276    pub fn engage(&self, reason: HaltReason) -> HaltToken {
277        self.engage_epoch(self.epoch(), reason);
278        HaltToken(self.next_ts())
279    }
280
281    /// Engage (OR-merge → `true`) the latch flag for a specific `epoch` and
282    /// record `reason`. Shared by [`engage`](Self::engage) and the remote
283    /// [`HaltPdu::Halt`] path so both produce the same latched state.
284    fn engage_epoch(&self, epoch: u64, reason: HaltReason) {
285        let mut flag = self.replicator.get::<Flag>(&self.flag_key(epoch)).unwrap_or_default();
286        flag.switch_on();
287        self.replicator.update(&self.flag_key(epoch), flag);
288        *self.last_reason.lock() = Some(reason);
289    }
290
291    /// Read the merged latch state for the current epoch. Survives
292    /// merge/rebalance because the backing `Flag` only OR-merges.
293    pub fn is_engaged(&self) -> bool {
294        let epoch = self.epoch();
295        self.replicator.get::<Flag>(&self.flag_key(epoch)).map(|f| f.enabled()).unwrap_or(false)
296    }
297
298    /// Reason recorded by the most recent local `engage`, if any.
299    pub fn last_reason(&self) -> Option<HaltReason> {
300        self.last_reason.lock().clone()
301    }
302
303    /// Register a guarded party. Returns an [`AckHandle`] the party
304    /// keeps; when its `on_halt` runs (during `await_quiescence`) it
305    /// signals quiescence by calling [`AckHandle::ack`].
306    pub fn register_guarded(&self, party: Box<dyn HaltGuarded>) -> AckHandle {
307        let (tx, rx) = tokio::sync::oneshot::channel();
308        self.guarded.lock().push(Guarded { party: Mutex::new(party), ack_rx: Mutex::new(Some(rx)) });
309        AckHandle { tx: Some(tx) }
310    }
311
312    /// Bring the system to a safe stop and report how many parties
313    /// acknowledged within `timeout`.
314    ///
315    /// Always quiesces this node's local [`HaltGuarded`] parties. When a
316    /// [`HaltTransport`] is attached it *also* broadcasts a
317    /// [`HaltPdu::Halt`] to every peer and waits for a [`HaltPdu::Ack`]
318    /// from each, so the returned [`QuiescenceReport`] reflects the whole
319    /// cluster. `timeout` bounds the combined wait.
320    pub async fn await_quiescence(&self, timeout: Duration) -> QuiescenceReport {
321        let deadline = tokio::time::Instant::now() + timeout;
322        let epoch = self.epoch();
323        let reason = self.last_reason().unwrap_or(HaltReason::Manual(String::new()));
324
325        // --- Local tier: halt + collect our own guarded parties. ---
326        let (local_acked, local_total) = self.collect_local(deadline, &reason).await;
327
328        // --- Remote tier: broadcast the halt and await peer acks. ---
329        let (remote_acked, remote_total) = match &self.transport {
330            Some(transport) => {
331                let peers = transport.peers();
332                let remote_total = peers.len();
333                if remote_total == 0 {
334                    (0, 0)
335                } else {
336                    // Start each epoch's remote-ack accounting from empty.
337                    self.remote_acks.lock().entry(epoch).or_default().clear();
338                    for peer in &peers {
339                        transport.send(
340                            peer,
341                            HaltPdu::Halt { from: self.node.clone(), epoch, reason: reason.clone() },
342                        );
343                    }
344                    let acked = self.collect_remote(epoch, peers, deadline).await;
345                    (acked, remote_total)
346                }
347            }
348            None => (0, 0),
349        };
350
351        let acked = local_acked + remote_acked;
352        let total = local_total + remote_total;
353        QuiescenceReport { acked, total, timed_out: acked < total, remote_acked, remote_total }
354    }
355
356    /// Fire `on_halt` for every local guarded party and count how many ack
357    /// before `deadline`. Returns `(acked, total)`.
358    async fn collect_local(&self, deadline: tokio::time::Instant, reason: &HaltReason) -> (usize, usize) {
359        let mut receivers = Vec::new();
360        {
361            let guard = self.guarded.lock();
362            for g in guard.iter() {
363                g.party.lock().on_halt(reason);
364                if let Some(rx) = g.ack_rx.lock().take() {
365                    receivers.push(rx);
366                }
367            }
368        }
369        let total = receivers.len();
370        let mut acked = 0usize;
371        // Shared absolute deadline: once it passes, remaining waits return
372        // immediately, so partial acks are still counted accurately.
373        for rx in receivers {
374            if let Ok(Ok(())) = tokio::time::timeout_at(deadline, rx).await {
375                acked += 1;
376            }
377        }
378        (acked, total)
379    }
380
381    /// Wait until every peer in `peers` has acked `epoch` (or `deadline`
382    /// passes), returning the number that acked.
383    async fn collect_remote(&self, epoch: u64, peers: Vec<String>, deadline: tokio::time::Instant) -> usize {
384        let target = peers.len();
385        loop {
386            // Register for notification *before* reading the count so an ack
387            // landing in the gap still wakes us (tokio's race-free idiom).
388            let notified = self.ack_notify.notified();
389            tokio::pin!(notified);
390            notified.as_mut().enable();
391
392            let have = self.remote_ack_count(epoch, &peers);
393            if have >= target {
394                return have;
395            }
396            if tokio::time::timeout_at(deadline, notified).await.is_err() {
397                return self.remote_ack_count(epoch, &peers);
398            }
399        }
400    }
401
402    /// How many of `peers` have acked `epoch`.
403    fn remote_ack_count(&self, epoch: u64, peers: &[String]) -> usize {
404        let acks = self.remote_acks.lock();
405        match acks.get(&epoch) {
406            Some(set) => peers.iter().filter(|p| set.contains(p.as_str())).count(),
407            None => 0,
408        }
409    }
410
411    /// Apply an inbound [`HaltPdu`] delivered by a [`HaltTransport`].
412    ///
413    /// * [`HaltPdu::Halt`] — engage this node's latch for the named epoch,
414    ///   quiesce its local guarded parties (bounded by `local_timeout`),
415    ///   then ack back to the originator.
416    /// * [`HaltPdu::Ack`] — record a peer's ack and wake any in-flight
417    ///   [`await_quiescence`](Self::await_quiescence).
418    pub async fn apply_pdu(&self, pdu: HaltPdu, local_timeout: Duration) {
419        match pdu {
420            HaltPdu::Halt { from, epoch, reason } => {
421                self.engage_epoch(epoch, reason.clone());
422                let deadline = tokio::time::Instant::now() + local_timeout;
423                let _ = self.collect_local(deadline, &reason).await;
424                if let Some(transport) = &self.transport {
425                    transport.send(&from, HaltPdu::Ack { from: self.node.clone(), epoch });
426                }
427            }
428            HaltPdu::Ack { from, epoch } => {
429                self.remote_acks.lock().entry(epoch).or_default().insert(from);
430                self.ack_notify.notify_waiters();
431            }
432        }
433    }
434
435    /// Reset the switch by advancing to a fresh epoch (the old epoch's
436    /// latched flag is left as an audit record). Requires a valid
437    /// two-person [`ResetAuthorization`].
438    ///
439    /// See the module docs for why this advances an epoch instead of
440    /// flipping the flag: a `Flag` cannot un-latch.
441    pub fn reset(&self, authz: ResetAuthorization) -> Result<u64, ResetError> {
442        if authz.approver_a.trim().is_empty() || authz.approver_b.trim().is_empty() {
443            return Err(ResetError::MissingApprover);
444        }
445        if authz.approver_a == authz.approver_b {
446            return Err(ResetError::DuplicateApprover);
447        }
448        let new_epoch = self.epoch() + 1;
449        self.replicator.update(&self.epoch_key(), LwwRegister::new(&self.node, new_epoch, self.next_ts()));
450        // New epoch's flag starts unset → is_engaged() is false again.
451        *self.last_reason.lock() = None;
452        Ok(new_epoch)
453    }
454}
455
456/// Adapter wiring FR-7 → FR-5: a [`QuorumObserver`] that engages the
457/// kill switch when quorum is lost. Drop this into
458/// `SbrRuntime::with_observer` to halt the node on partition loss.
459pub struct KillSwitchQuorumObserver(pub Arc<ClusterKillSwitch>);
460
461impl QuorumObserver for KillSwitchQuorumObserver {
462    fn on_quorum_lost(&self) {
463        self.0.engage(HaltReason::QuorumLost);
464    }
465    fn on_quorum_regained(&self) {
466        // Recovery is an explicit, audited two-person `reset` — we do
467        // not auto-clear the latch on regain. Intentionally a no-op.
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use std::sync::atomic::AtomicBool;
475
476    fn switch() -> Arc<ClusterKillSwitch> {
477        ClusterKillSwitch::new(Replicator::new(), "atomr/killswitch", "node-1")
478    }
479
480    #[test]
481    fn engage_latches_and_is_engaged() {
482        let ks = switch();
483        assert!(!ks.is_engaged());
484        let t = ks.engage(HaltReason::Manual("drill".into()));
485        assert!(ks.is_engaged());
486        assert_eq!(ks.last_reason(), Some(HaltReason::Manual("drill".into())));
487        assert!(t.0 > 0);
488        // Engaging again is idempotent.
489        ks.engage(HaltReason::Manual("again".into()));
490        assert!(ks.is_engaged());
491    }
492
493    #[test]
494    fn engaged_survives_independent_merge_of_off_copy() {
495        // Simulate a stale (off) copy merging in: OR-merge keeps it on.
496        let ks = switch();
497        ks.engage(HaltReason::QuorumLost);
498        let epoch = ks.epoch();
499        // Merge a fresh default (false) Flag — must not un-latch.
500        ks.replicator().update(&format!("atomr/killswitch::flag::{epoch}"), Flag::new());
501        assert!(ks.is_engaged());
502    }
503
504    #[test]
505    fn reset_requires_two_distinct_nonempty_approvers() {
506        let ks = switch();
507        ks.engage(HaltReason::Manual("x".into()));
508        assert!(ks.is_engaged());
509
510        assert_eq!(
511            ks.reset(ResetAuthorization { approver_a: "".into(), approver_b: "bob".into() }),
512            Err(ResetError::MissingApprover)
513        );
514        assert_eq!(
515            ks.reset(ResetAuthorization { approver_a: "amy".into(), approver_b: "amy".into() }),
516            Err(ResetError::DuplicateApprover)
517        );
518        // Still engaged — no valid reset happened.
519        assert!(ks.is_engaged());
520
521        // Valid two-person reset advances the epoch and re-arms.
522        let new_epoch = ks
523            .reset(ResetAuthorization { approver_a: "amy".into(), approver_b: "bob".into() })
524            .expect("valid authz");
525        assert_eq!(new_epoch, 1);
526        assert!(!ks.is_engaged(), "new epoch flag must start unset");
527        assert!(ks.last_reason().is_none());
528
529        // The switch can be engaged again in the new epoch.
530        ks.engage(HaltReason::RiskBreach("limit".into()));
531        assert!(ks.is_engaged());
532    }
533
534    #[tokio::test]
535    async fn await_quiescence_collects_acks() {
536        // A guarded party that, on halt, records the fact and acks
537        // quiescence through the handle it was given at registration.
538        struct Party {
539            halted: Arc<AtomicBool>,
540            ack: Arc<Mutex<Option<AckHandle>>>,
541        }
542        impl HaltGuarded for Party {
543            fn on_halt(&mut self, _reason: &HaltReason) {
544                self.halted.store(true, Ordering::SeqCst);
545                if let Some(h) = self.ack.lock().take() {
546                    h.ack();
547                }
548            }
549        }
550
551        let ks = switch();
552        let halted = Arc::new(AtomicBool::new(false));
553        let ack_slot = Arc::new(Mutex::new(None));
554        let party = Box::new(Party { halted: halted.clone(), ack: ack_slot.clone() });
555        // Register, then hand the party its own ack handle via the slot.
556        let handle = ks.register_guarded(party);
557        *ack_slot.lock() = Some(handle);
558
559        ks.engage(HaltReason::Manual("drill".into()));
560        let report = ks.await_quiescence(Duration::from_millis(500)).await;
561        assert!(halted.load(Ordering::SeqCst));
562        assert_eq!(report.total, 1);
563        assert_eq!(report.acked, 1);
564        assert!(!report.timed_out);
565    }
566
567    #[tokio::test]
568    async fn await_quiescence_times_out_without_ack() {
569        struct Silent;
570        impl HaltGuarded for Silent {
571            fn on_halt(&mut self, _reason: &HaltReason) {}
572        }
573        let ks = switch();
574        // Keep the handle alive but never ack.
575        let _handle = ks.register_guarded(Box::new(Silent));
576        let report = ks.await_quiescence(Duration::from_millis(50)).await;
577        assert_eq!(report.total, 1);
578        assert!(report.timed_out);
579    }
580
581    #[test]
582    fn quorum_observer_engages_on_loss() {
583        let ks = switch();
584        let obs = KillSwitchQuorumObserver(ks.clone());
585        assert!(!ks.is_engaged());
586        obs.on_quorum_lost();
587        assert!(ks.is_engaged());
588        assert_eq!(ks.last_reason(), Some(HaltReason::QuorumLost));
589        // Regain is a no-op (recovery is an explicit reset).
590        obs.on_quorum_regained();
591        assert!(ks.is_engaged());
592    }
593
594    #[test]
595    fn no_transport_reports_zero_remote() {
596        // A switch without a transport never has remote parties.
597        let rt = tokio::runtime::Runtime::new().unwrap();
598        rt.block_on(async {
599            let ks = switch();
600            ks.engage(HaltReason::Manual("x".into()));
601            let report = ks.await_quiescence(Duration::from_millis(50)).await;
602            assert_eq!(report.remote_total, 0);
603            assert_eq!(report.remote_acked, 0);
604            assert_eq!(report.total, 0); // no local parties either
605            assert!(!report.timed_out);
606        });
607    }
608
609    /// In-memory [`HaltTransport`] that routes PDUs between switches held in
610    /// a shared registry, spawning the receiver's `apply_pdu` on send.
611    #[derive(Clone)]
612    struct MemNet {
613        self_node: String,
614        reg: Arc<Mutex<HashMap<String, Arc<ClusterKillSwitch>>>>,
615        ack_timeout: Duration,
616    }
617
618    impl HaltTransport for MemNet {
619        fn peers(&self) -> Vec<String> {
620            self.reg.lock().keys().filter(|k| *k != &self.self_node).cloned().collect()
621        }
622        fn send(&self, target: &str, pdu: HaltPdu) {
623            let sw = self.reg.lock().get(target).cloned();
624            if let Some(sw) = sw {
625                let to = self.ack_timeout;
626                tokio::spawn(async move {
627                    sw.apply_pdu(pdu, to).await;
628                });
629            }
630        }
631    }
632
633    // A guarded party that records the halt and acks via its handle.
634    struct Party {
635        halted: Arc<AtomicBool>,
636        ack: Arc<Mutex<Option<AckHandle>>>,
637    }
638    impl HaltGuarded for Party {
639        fn on_halt(&mut self, _reason: &HaltReason) {
640            self.halted.store(true, Ordering::SeqCst);
641            if let Some(h) = self.ack.lock().take() {
642                h.ack();
643            }
644        }
645    }
646
647    #[tokio::test]
648    async fn cross_node_halt_fans_out_and_collects_remote_acks() {
649        let reg: Arc<Mutex<HashMap<String, Arc<ClusterKillSwitch>>>> = Arc::new(Mutex::new(HashMap::new()));
650        let net_a = Arc::new(MemNet {
651            self_node: "A".into(),
652            reg: reg.clone(),
653            ack_timeout: Duration::from_millis(500),
654        });
655        let net_b = Arc::new(MemNet {
656            self_node: "B".into(),
657            reg: reg.clone(),
658            ack_timeout: Duration::from_millis(500),
659        });
660        let a = ClusterKillSwitch::with_transport(Replicator::new(), "atomr/ks", "A", net_a);
661        let b = ClusterKillSwitch::with_transport(Replicator::new(), "atomr/ks", "B", net_b);
662        reg.lock().insert("A".into(), a.clone());
663        reg.lock().insert("B".into(), b.clone());
664
665        // B has a local guarded party that acks when halted.
666        let b_halted = Arc::new(AtomicBool::new(false));
667        let b_ack = Arc::new(Mutex::new(None));
668        let handle = b.register_guarded(Box::new(Party { halted: b_halted.clone(), ack: b_ack.clone() }));
669        *b_ack.lock() = Some(handle);
670
671        // Engage on A, then quiesce: A fans the halt to B and awaits B's ack.
672        a.engage(HaltReason::RiskBreach("limit breach".into()));
673        let report = a.await_quiescence(Duration::from_millis(2000)).await;
674
675        assert_eq!(report.remote_total, 1, "A should broadcast to its one peer (B)");
676        assert_eq!(report.remote_acked, 1, "B should ack its halt");
677        assert_eq!(report.total, 1);
678        assert_eq!(report.acked, 1);
679        assert!(!report.timed_out);
680
681        // The halt actually reached B: its latch engaged and its party halted.
682        assert!(b.is_engaged(), "B's latch must engage from the Halt PDU");
683        assert!(b_halted.load(Ordering::SeqCst), "B's guarded party must be halted");
684        // The reason rode along in the PDU.
685        assert_eq!(b.last_reason(), Some(HaltReason::RiskBreach("limit breach".into())));
686    }
687
688    #[tokio::test]
689    async fn cross_node_times_out_when_peer_silent() {
690        // B is registered as a peer but has NO transport of its own, so it
691        // can never ack — A must report a timeout against the missing remote.
692        let reg: Arc<Mutex<HashMap<String, Arc<ClusterKillSwitch>>>> = Arc::new(Mutex::new(HashMap::new()));
693        let net_a = Arc::new(MemNet {
694            self_node: "A".into(),
695            reg: reg.clone(),
696            ack_timeout: Duration::from_millis(100),
697        });
698        let a = ClusterKillSwitch::with_transport(Replicator::new(), "atomr/ks", "A", net_a);
699        // A bare switch for B with no transport: receiving a Halt cannot ack.
700        let b = ClusterKillSwitch::new(Replicator::new(), "atomr/ks", "B");
701        reg.lock().insert("A".into(), a.clone());
702        reg.lock().insert("B".into(), b.clone());
703
704        a.engage(HaltReason::Manual("drill".into()));
705        let report = a.await_quiescence(Duration::from_millis(150)).await;
706
707        assert_eq!(report.remote_total, 1);
708        assert_eq!(report.remote_acked, 0);
709        assert!(report.timed_out, "missing remote ack must surface as a timeout");
710        // B still engaged locally even though it could not ack back.
711        assert!(b.is_engaged());
712    }
713}