Skip to main content

melin_server_runtime/replication/
mod.rs

1//! Replication — synchronous input-stream streaming from primary to replica.
2//!
3//! The primary's JournalStage encodes each `InputSlot` it just durably
4//! journaled into a wire-ready `InputBatch` frame in the replication ring
5//! (separately from the journal-codec bytes it writes to disk). The
6//! `ReplicationSender` thread forwards those frames as-is over TCP/DPDK
7//! — no decode + re-encode on the hot path. The replica decodes the
8//! frames straight back into `InputSlot`s, publishes them to its local
9//! input disruptor with the primary's pre-assigned sequences and
10//! timestamps, and the replica's JournalStage re-encodes them through
11//! its own writer for byte-exact-on-replay durability.
12//!
13//! ## Wire Protocol
14//!
15//! Length-prefixed frames, little-endian, over a dedicated TCP connection
16//! (or DPDK pipe). The full `InputBatch` payload layout lives in
17//! `melin_transport_core::replication::protocol` /
18//! `melin_transport_core::replication_wire`.
19//!
20//! ### Auth (before handshake)
21//! - **Challenge** (Primary → Replica): `[len:u32][0x03][nonce:[u8;32]]`
22//! - **ChallengeResponse** (Replica → Primary): `[len:u32][0x04][signature:[u8;64]][pubkey:[u8;32]]`
23//! - **AuthOk** (Primary → Replica): `[len:u32][0x05]`
24//! - **AuthFailed** (Primary → Replica): `[len:u32][0x06]`
25//!
26//! ### Replica → Primary
27//! - **Handshake**: `[len:u32][0x01][last_sequence:u64][chain_hash:[u8;32]]`
28//! - **Ack**: `[len:u32][0x02][acked_sequence:u64][in_memory_sequence:u64]`
29//!
30//! ### Primary → Replica
31//! - **StreamStart**: `[len:u32][0x10][start_sequence:u64][segment_start_sequence:u64][anchor_hash:[u8;32]]`
32//!   — the segment header identity a fresh replica creates its journal
33//!   with (lineage origin for full catch-up, the seeded segment's
34//!   identity after a snapshot transfer)
35//! - **NeedSnapshot**: `[len:u32][0x11]`
36//! - **HashMismatch**: `[len:u32][0x12]` — divergent replica journal;
37//!   the replica archives its lineage, then the snapshot flow follows
38//! - **SnapshotBegin**: `[len:u32][0x13][snapshot_len:u64][snap_sequence:u64][snap_chain_hash:[u8;32]]`
39//! - **SnapshotChunk**: `[len:u32][0x14][data...]`
40//! - **SnapshotEnd**: `[len:u32][0x15][crc32c:u32]`
41//! - **Rotate**: `[len:u32][0x16][boundary_seq:u64][tail_hash:[u8;32]]`
42//!   — primary-driven rotation, verified + adopted by the replica
43//! - **ChainCheck**: `[len:u32][0x17][sequence:u64][chain_hash:[u8;32]]`
44//!   — periodic live-stream chain validation
45//! - **SegmentSeedBegin**: `[len:u32][0x18][seed_len:u64]` — raw byte
46//!   prefix of the primary's segment containing the snapshot boundary;
47//!   body rides SnapshotChunk frames, ends with SnapshotEnd
48//! - **InputBatch**: `[len:u32][0x21][count:u16][slot...]` — see
49//!   `transport-core::replication_wire` for the per-slot layout
50//! - **Heartbeat**: `[len:u32][0x30][sequence:u64]`
51//!
52//! ## Limitations
53//!
54//! - Dual replication (up to 2 replicas in parallel)
55//!
56//! See `docs/replication.md` for the full design document and limitation details.
57
58use std::sync::Arc;
59#[cfg(test)]
60use std::sync::atomic::AtomicU64;
61use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
62
63use melin_journal::{BufferedWriter, JournalWrite};
64
65use melin_app::Application;
66use melin_transport_core::pipeline::{InputSlot, OutputSlot};
67use melin_transport_core::replication::archive::{ArchiveReason, archive_local_lineage};
68use melin_transport_core::replication::protocol::{MAX_CONTROL_FRAME, decode_primary_message};
69
70mod auth;
71#[cfg(feature = "dpdk")]
72mod dpdk;
73mod receiver_transport;
74mod tcp_receiver;
75mod tcp_sender;
76
77use receiver_transport::{ControlFrameSource, SessionExit, StreamingResult, receive_chunked_body};
78
79/// Writer-side view of the trading-halt gate (the `replicas_connected`
80/// counter). The matching stage refuses new orders while the count is zero, so
81/// the counter must reflect the number of replicas that have **authenticated**
82/// — a bare connection must not lift the halt. Both senders (kernel-TCP and
83/// DPDK) lift/lower the gate through this view so the policy, the memory
84/// orderings, and the "trading halted" warning live in one place and cannot
85/// drift apart.
86///
87/// Deliberately a *borrowed view*, not an owner: `melin_transport_core` owns
88/// the `Arc<AtomicU32>` and reads it on the matching hot path and for the
89/// `melin_replicas_connected` gauge. This type is only the senders' write
90/// surface, so centralizing it costs nothing on the read side.
91pub(crate) struct ReplicaGate<'a> {
92    count: &'a AtomicU32,
93}
94
95impl<'a> ReplicaGate<'a> {
96    pub(crate) fn new(count: &'a AtomicU32) -> Self {
97        Self { count }
98    }
99
100    /// A replica has authenticated — lift the halt by one. `Release` so a peer
101    /// that observes the connect also observes everything that preceded it.
102    pub(crate) fn lift(&self) {
103        self.count.fetch_add(1, Ordering::Release);
104    }
105
106    /// A replica left — lower the halt by one. Returns `true` if it was the
107    /// last one (trading is now unprotected), emitting the halt warning here so
108    /// both senders share the wording. `fetch_sub` returns the *prior* count,
109    /// so `== 1` means this call took it to zero; deriving "last one" from the
110    /// returned value rather than a follow-up load avoids a TOCTOU race with a
111    /// concurrent reconnect's `lift`.
112    pub(crate) fn lower(&self) -> bool {
113        let was_last = self.count.fetch_sub(1, Ordering::Release) == 1;
114        if was_last {
115            tracing::warn!("all replicas disconnected — trading halted");
116        }
117        was_last
118    }
119}
120
121// Wire-protocol types, auth, catch-up, ack queueing, dual-track
122// cursor management, and per-replica metrics now live in
123// `melin_transport_core::replication`. Re-export the public types
124// here so the module's public API surface (e.g.
125// `melin_server_runtime::replication::Ack` / `::ReplicationMetrics`) is
126// unchanged for downstream consumers and tests.
127pub use melin_transport_core::replication::ack_queue::{
128    PendingAck, PendingAckQueue, try_flush_dual_track, wait_for_journal_cursor,
129};
130pub use melin_transport_core::replication::protocol::{
131    Ack, Handshake, PrimaryMessage, ReplicaMessage,
132};
133pub use melin_transport_core::replication::{ReplicaCursors, ReplicationMetrics, SentHighWater};
134
135#[cfg(feature = "dpdk")]
136pub use dpdk::{DpdkReplicationDriver, run_receiver_dpdk};
137pub use tcp_receiver::{ReceiverResult, run_receiver};
138pub use tcp_sender::{ReplicationListener, Sender, run_sender};
139
140/// The handles a replica's receive loop shares with the rest of the
141/// node — the admin endpoint and the control-plane raft driver.
142/// Bundled (rather than five positional parameters) because they
143/// always travel together through the kernel-TCP and DPDK receiver
144/// signatures, and a transposition between same-typed flags is exactly
145/// the bug a bundle prevents.
146#[derive(Clone)]
147pub struct ReplicaControlPlane {
148    /// Promotion request: polled by the receive loop; filed by the
149    /// admin `PROMOTE` command or the raft driver (auto-promotion).
150    pub promote: crate::promotion::PromotionRequest,
151    /// Flipped `true` once journal recovery has seeded the fence epoch,
152    /// so the raft driver can trust this node's advertised tip — see
153    /// `melin_raft::recency::TipSource`.
154    pub tip_ready: std::sync::Arc<AtomicBool>,
155    /// Sequence half of the advertised journal tip — see
156    /// [`melin_transport_core::AdvertisedJournalTip`].
157    pub journal_tip: melin_transport_core::AdvertisedJournalTip,
158    /// `true` while this replica holds an authenticated replication
159    /// connection to its primary. The raft driver refuses to
160    /// auto-promote while set: a live link means the primary is
161    /// demonstrably alive, and winning a control-plane election (e.g.
162    /// because a *different* node's raft died) must not depose a
163    /// healthy primary.
164    pub primary_link_up: std::sync::Arc<AtomicBool>,
165    /// The durability (acking) mode last advertised by the primary
166    /// (`DurabilityMode::as_u8`; `ACKING_MODE_UNKNOWN` until first
167    /// contact), refreshed by `StreamStart` and every `Heartbeat`. The
168    /// raft driver's auto-promotion refusal reads this — the mode the
169    /// *primary* acked under decides whether an election win proves
170    /// this replica holds every acked order, not this node's own
171    /// (possibly pre-staged) configuration. Falls back to the local
172    /// mode while unknown, which is exactly the pre-propagation
173    /// behavior.
174    pub primary_acking_mode: std::sync::Arc<std::sync::atomic::AtomicU8>,
175    /// Whether the replica's pipeline is healthy — fed to the replica's
176    /// minimal health endpoint (`/health` OK/ERR and the
177    /// `melin_pipeline_healthy` gauge). Process-lifetime (the per-session
178    /// pipeline is torn down and rebuilt across resyncs, so its own
179    /// `journal_failed` latch cannot be handed to the endpoint):
180    /// `true` from boot, latched `false` by the journal stage's failure
181    /// wrapper, reset `true` when a fresh pipeline is built.
182    pub pipeline_healthy: std::sync::Arc<AtomicBool>,
183}
184
185impl ReplicaControlPlane {
186    /// Fresh handles for a replica boot: nothing requested, tip not
187    /// trustworthy yet, tip sequence 0, primary link down, pipeline
188    /// healthy (nothing has failed yet).
189    pub fn new() -> Self {
190        Self {
191            promote: crate::promotion::PromotionRequest::new(),
192            tip_ready: std::sync::Arc::new(AtomicBool::new(false)),
193            journal_tip: melin_transport_core::AdvertisedJournalTip::new(
194                melin_transport_core::WireSeq::new(0),
195            ),
196            primary_link_up: std::sync::Arc::new(AtomicBool::new(false)),
197            primary_acking_mode: std::sync::Arc::new(std::sync::atomic::AtomicU8::new(
198                crate::durability_policy::ACKING_MODE_UNKNOWN,
199            )),
200            pipeline_healthy: std::sync::Arc::new(AtomicBool::new(true)),
201        }
202    }
203}
204
205impl Default for ReplicaControlPlane {
206    fn default() -> Self {
207        Self::new()
208    }
209}
210
211/// Diagnostic: emit a `tcp_info` span at debug level describing the
212/// kernel's view of the socket (rtt, cwnd, retrans, unacked, rcv_space,
213/// rto). Guarded internally with `tracing::enabled!` so the
214/// `getsockopt(TCP_INFO)` syscall is skipped when debug logging is off —
215/// the call is free at runtime unless `RUST_LOG=debug` (or a more
216/// specific filter) is active. Used to distinguish user-space stalls
217/// from TCP-level congestion collapse when diagnosing replication
218/// slowdowns.
219pub(super) fn log_tcp_info(fd: std::os::unix::io::RawFd, tag: &str, slot: usize) {
220    if !tracing::enabled!(tracing::Level::DEBUG) {
221        return;
222    }
223    // SAFETY: all-zero pattern is a valid `tcp_info` (all numeric fields).
224    // The kernel fills in whatever the running version supports and
225    // returns the written length in `len`.
226    let mut info: libc::tcp_info = unsafe { std::mem::zeroed() };
227    let mut len = std::mem::size_of::<libc::tcp_info>() as libc::socklen_t;
228    let rc = unsafe {
229        libc::getsockopt(
230            fd,
231            libc::IPPROTO_TCP,
232            libc::TCP_INFO,
233            &mut info as *mut _ as *mut libc::c_void,
234            &mut len,
235        )
236    };
237    if rc != 0 {
238        tracing::debug!(
239            slot,
240            tag,
241            errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
242            "tcp_info getsockopt failed"
243        );
244        return;
245    }
246    tracing::debug!(
247        slot,
248        tag,
249        state = info.tcpi_state,
250        ca_state = info.tcpi_ca_state,
251        rtt_us = info.tcpi_rtt,
252        rttvar_us = info.tcpi_rttvar,
253        snd_cwnd = info.tcpi_snd_cwnd,
254        snd_ssthresh = info.tcpi_snd_ssthresh,
255        snd_mss = info.tcpi_snd_mss,
256        rcv_mss = info.tcpi_rcv_mss,
257        unacked = info.tcpi_unacked,
258        retrans = info.tcpi_retrans,
259        total_retrans = info.tcpi_total_retrans,
260        lost = info.tcpi_lost,
261        rcv_space = info.tcpi_rcv_space,
262        rto_us = info.tcpi_rto,
263        "tcp_info"
264    );
265}
266
267/// Sleep for the given duration in 100ms increments, checking the shutdown
268/// flag and the promotion request between increments. Returns early if
269/// either is set.
270pub(super) fn sleep_checking_flags(
271    duration: std::time::Duration,
272    shutdown: &AtomicBool,
273    promote: &crate::promotion::PromotionRequest,
274) {
275    let deadline = std::time::Instant::now() + duration;
276    while std::time::Instant::now() < deadline {
277        if shutdown.load(Ordering::Relaxed) || promote.is_requested() {
278            return;
279        }
280        std::thread::sleep(std::time::Duration::from_millis(100));
281    }
282}
283
284/// Wait out one retry delay: sleep for the current `backoff` (checking
285/// the shutdown and promotion flags — see [`sleep_checking_flags`]),
286/// then double it, capped at [`MAX_BACKOFF`], for the next attempt.
287/// Sleep-then-double, so the first retry waits the base delay rather
288/// than twice it. Every retry arm in both receivers goes through this —
289/// don't hand-roll the idiom, the copies drift.
290pub(super) fn sleep_then_double_backoff(
291    backoff: &mut std::time::Duration,
292    shutdown: &AtomicBool,
293    promote: &crate::promotion::PromotionRequest,
294) {
295    sleep_checking_flags(*backoff, shutdown, promote);
296    *backoff = (*backoff * 2).min(MAX_BACKOFF);
297}
298
299/// Outcome of shutting down a replica pipeline. All stage threads are
300/// joined in every variant — by the time the caller sees this, no
301/// pipeline thread can still touch the journal or snapshot files.
302pub(super) enum TeardownOutcome<A, W> {
303    /// Clean exit: both stages returned their state, reusable for the
304    /// next pipeline build (post-snapshot) or promotion.
305    Clean(A, W),
306    /// The journal stage exited with an error — its writer is gone and
307    /// the matching stage's state is unusable (it may have applied
308    /// events the journal never persisted). Carries the error so the
309    /// orchestrator can distinguish repairable chain divergence
310    /// (in-process resync) from journal I/O death (exit).
311    JournalFailed(melin_journal::JournalError),
312    /// A stage thread panicked; no state survives.
313    Panicked,
314}
315
316/// Shut down the replica pipeline and extract Exchange + journal writer
317/// from the stage threads.
318///
319/// Relies on the caller having published a `JournalEvent::Shutdown`
320/// sentinel to the input ring before invoking this — the journal and
321/// matching stages exit when they consume the sentinel via the normal
322/// event-processing path. We deliberately don't flip `shutdown_flag`
323/// here: doing so could cause a stage to take its emergency-abort
324/// branch *before* consuming the sentinel, hitting the
325/// drain-vs-cursor race that the sentinel design exists to avoid.
326///
327/// `drain_handle` and `shadow_handle` still observe `shutdown_flag`
328/// because they don't process the input ring (no sentinel reaches
329/// them); set the flag for them only.
330pub(super) fn shutdown_pipeline<A: Send + 'static, W: Send + 'static>(
331    shutdown_flag: &AtomicBool,
332    journal_handle: std::thread::JoinHandle<Result<W, melin_journal::JournalError>>,
333    matching_handle: std::thread::JoinHandle<A>,
334    drain_handle: std::thread::JoinHandle<()>,
335    shadow_handle: Option<std::thread::JoinHandle<()>>,
336) -> TeardownOutcome<A, W> {
337    // Defense-in-depth: set the flag before joining. The sentinel was
338    // already published by `teardown_replica_pipeline` before this call,
339    // so no further events can arrive in the input ring — setting the
340    // flag here cannot race with new publishes. The flag is the fallback
341    // exit signal for paths that don't observe the sentinel: `run_sync`
342    // in `no-persist` builds, the drain consumer, the shadow stage, and
343    // any case where the receiver thread panicked before publishing the
344    // sentinel.
345    //
346    // The flag is also what makes these joins safe when the journal
347    // stage is already dead: the matching stage's gate on the (frozen)
348    // journal cursor is a non-blocking spin that re-checks the flag
349    // every iteration, and its shutdown drain is `try_consume`-bounded.
350    shutdown_flag.store(true, Ordering::Release);
351    // Join EVERY thread before reporting the outcome, even on failure:
352    // the in-process divergence-resync path archives the journal and
353    // snapshot right after this returns, and a still-running shadow
354    // thread could be mid-snapshot-write during those renames.
355    let journal_result = journal_handle.join();
356    let matching_result = matching_handle.join();
357    let _ = drain_handle.join();
358    if let Some(h) = shadow_handle {
359        let _ = h.join();
360    }
361    let writer = match journal_result {
362        Ok(Ok(w)) => w,
363        // Already error!-logged by the journal thread's spawn wrapper.
364        Ok(Err(e)) => return TeardownOutcome::JournalFailed(e),
365        Err(_) => return TeardownOutcome::Panicked,
366    };
367    match matching_result {
368        Ok(exchange) => TeardownOutcome::Clean(exchange, writer),
369        Err(_) => TeardownOutcome::Panicked,
370    }
371}
372
373/// Live replica pipeline — built once on first connect (or after a snapshot
374/// transfer), persists across `Disconnected` reconnects so the orchestrator
375/// doesn't pay the journal-recover + thread-spawn cost on every drop.
376///
377/// Shared between the kernel-TCP and DPDK receivers; both build pipelines
378/// with the same shape (journal / matching / drain / optional shadow), and
379/// both refresh handshake state from `last_seq` + `chain_hash_lock` at
380/// reconnect time.
381pub(super) struct ReplicaPipelineHandles<A: Application, W: Send + 'static> {
382    pub(super) input_producer: melin_pipeline::ring::Producer<InputSlot<A::Event>>,
383    pub(super) journal_cursor: Arc<melin_pipeline::padding::Sequence>,
384    /// Highest wire seq durably persisted, published by JournalStage after
385    /// each fsync. Read by the orchestrator to fill in the reconnect
386    /// handshake without owning the writer. Typed so the handshake's resume
387    /// point can never be sourced from a ring-space counter (the adjacent
388    /// `journal_cursor` resets to ~0 every process start).
389    pub(super) last_seq: melin_transport_core::DurableWireSeqCursor,
390    /// Seqlock-published fsync state (chain hash + journal seq + ring
391    /// cursor), read half. Option to mirror the primary-side pattern;
392    /// always Some on replicas now.
393    pub(super) chain_hash_lock:
394        Option<melin_pipeline::seqlock::SeqLockReader<melin_transport_core::pipeline::FsyncState>>,
395    /// Primary-announced rotation hand-off: the receiver thread pushes
396    /// `Rotate` boundaries here (in stream order), the journal stage
397    /// pops and rotates at exactly those sequences. Replicas have no
398    /// local rotation triggers.
399    pub(super) stream_marks: melin_transport_core::pipeline::StreamMarkQueue,
400    /// Latched by the journal thread's spawn wrapper when the stage
401    /// exits with an error (chain divergence, journal I/O failure).
402    /// The streaming receiver checks it and tears the session down —
403    /// without this, a dead journal stage freezes the journal cursor
404    /// and the receiver wedges forever on ring backpressure or the
405    /// ack-durability wait.
406    pub(super) journal_failed: Arc<AtomicBool>,
407    /// Per-pipeline shutdown flag — flipped only on a controlled teardown
408    /// (Promote/Shutdown/Fatal/Snapshot). NOT flipped on `Disconnected`.
409    pub(super) pipeline_shutdown: Arc<AtomicBool>,
410    pub(super) journal_handle: std::thread::JoinHandle<Result<W, melin_journal::JournalError>>,
411    pub(super) matching_handle: std::thread::JoinHandle<A>,
412    pub(super) drain_handle: std::thread::JoinHandle<()>,
413    pub(super) shadow_handle: Option<std::thread::JoinHandle<()>>,
414}
415
416/// [`ReplicaPipelineHandles`] over the journal's writer — the only
417/// instantiation that exists. The struct keeps its writer parameter
418/// because the teardown helpers are writer-agnostic plumbing; this
419/// alias spares every signature from spelling the concrete type out.
420pub(super) type ReplicaHandles<A> =
421    ReplicaPipelineHandles<A, BufferedWriter<<A as Application>::Event>>;
422
423/// Build the replica pipeline and spawn its stage threads on the configured
424/// cores. Returns the bundle of state the orchestrator keeps across
425/// `Disconnected` reconnects.
426#[allow(clippy::too_many_arguments)]
427pub(super) fn build_replica_pipeline_with_threads<A>(
428    exchange: A,
429    writer: BufferedWriter<A::Event>,
430    cores: crate::server::PipelineCores,
431    snapshot_interval_ms: u64,
432    snapshot_path: std::path::PathBuf,
433    group_commit_delay: std::time::Duration,
434    busy_spin: bool,
435    fence_state: Arc<melin_transport_core::fence::FenceState>,
436    // Process-lifetime health mirror for the replica health endpoint —
437    // see `ReplicaControlPlane::pipeline_healthy`. Reset `true` here (a
438    // fresh pipeline is healthy) and latched `false` by the journal
439    // thread's failure wrapper, alongside the per-pipeline
440    // `journal_failed` latch.
441    pipeline_healthy: Arc<AtomicBool>,
442) -> Result<ReplicaHandles<A>, Box<dyn std::error::Error>>
443where
444    A: Application + Send + 'static,
445    A::Event: Send + Sync + 'static,
446    A::Report: Send + 'static,
447    A::QueryResponse: Send + 'static,
448{
449    let shadow_exchange = <A as Application>::clone_via_snapshot(&exchange)?;
450
451    let enable_shadow = snapshot_interval_ms > 0;
452    // Shadow snapshot seeds its epoch from the fence state's current value
453    // (set from the replica's recovered journal before this builder runs).
454    let shadow_initial_epoch = fence_state.epoch();
455    let pipeline = melin_transport_core::pipeline::build_replica_pipeline(
456        exchange,
457        writer,
458        4096, // max_journal_batch
459        group_commit_delay,
460        busy_spin,
461        enable_shadow,
462        fence_state,
463    );
464
465    let pipeline_shutdown = Arc::new(AtomicBool::new(false));
466
467    let ps = Arc::clone(&pipeline_shutdown);
468    let journal_core = cores.journal;
469    let mut journal_stage = pipeline.journal_stage;
470    // Replicas never rotate on local triggers (size or operator
471    // command) — they adopt the boundaries the primary announces over
472    // the stream, which keeps segment boundaries (and with them chain
473    // values and journal bytes) identical across nodes.
474    let stream_marks: melin_transport_core::pipeline::StreamMarkQueue =
475        Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new()));
476    journal_stage.set_stream_marks(Arc::clone(&stream_marks));
477    journal_stage.set_preparer_core(cores.journal_prep);
478    journal_stage.set_disk_core(cores.journal_disk);
479    let journal_failed = Arc::new(AtomicBool::new(false));
480    let journal_failed_latch = Arc::clone(&journal_failed);
481    // A fresh pipeline is healthy — this also clears the latch after a
482    // successful in-process resync rebuild.
483    pipeline_healthy.store(true, Ordering::Release);
484    let journal_handle = std::thread::Builder::new()
485        .name("journal".into())
486        .spawn(move || {
487            melin_app::affinity::pin_thread("journal", journal_core);
488            let result = journal_stage.run(&ps);
489            if let Err(ref e) = result {
490                // Latch before logging so the receiver reacts even if
491                // logging stalls. A dead journal stage freezes the
492                // journal cursor; every downstream wait on it (ring
493                // backpressure, ack durability) would spin forever —
494                // the streaming loop polls this latch and tears the
495                // session down instead.
496                journal_failed_latch.store(true, Ordering::Release);
497                // Mirror into the process-lifetime gauge the replica
498                // health endpoint serves.
499                pipeline_healthy.store(false, Ordering::Release);
500                tracing::error!(error = %e, "replica journal stage failed — session teardown");
501            }
502            result
503        })
504        .expect("spawn journal thread");
505
506    let ps = Arc::clone(&pipeline_shutdown);
507    let matching_core = cores.matching;
508    let matching_stage = pipeline.matching_stage;
509    let matching_handle = std::thread::Builder::new()
510        .name("matching".into())
511        .spawn(move || {
512            melin_app::affinity::pin_thread("matching", matching_core);
513            matching_stage.run(&ps)
514        })
515        .expect("spawn matching thread");
516
517    // Drain thread uses the response core — replicas have no response stage,
518    // but the consumer needs to be drained so the output ring doesn't fill.
519    let ps = Arc::clone(&pipeline_shutdown);
520    let drain_core = cores.response;
521    let drain_consumer = pipeline.drain_consumer;
522    let drain_handle = std::thread::Builder::new()
523        .name("drain".into())
524        .spawn(move || {
525            melin_app::affinity::pin_thread("drain", drain_core);
526            let mut consumer = drain_consumer;
527            let mut batch = vec![OutputSlot::<A::Report, A::QueryResponse>::default(); 256];
528            loop {
529                if ps.load(Ordering::Relaxed) {
530                    return;
531                }
532                let count = consumer.consume_batch(&mut batch, 256);
533                if count == 0 {
534                    if busy_spin {
535                        std::hint::spin_loop();
536                    } else {
537                        std::thread::yield_now();
538                    }
539                }
540            }
541        })
542        .expect("spawn drain thread");
543
544    let shadow_handle = if let Some(shadow_cons) = pipeline.shadow_consumer {
545        let snap_path = snapshot_path;
546        let chain_lock = pipeline
547            .chain_hash_lock
548            .as_ref()
549            .expect("chain hash lock with shadow")
550            .clone();
551        let ps = Arc::clone(&pipeline_shutdown);
552        let shadow_core = cores.shadow;
553        Some(
554            std::thread::Builder::new()
555                .name("replica-shadow".into())
556                .spawn(move || {
557                    melin_app::affinity::pin_thread("replica-shadow", shadow_core);
558                    melin_transport_core::shadow::run(
559                        shadow_cons,
560                        shadow_exchange,
561                        snap_path,
562                        std::time::Duration::from_millis(snapshot_interval_ms),
563                        chain_lock,
564                        &ps,
565                        false,
566                        shadow_initial_epoch,
567                    );
568                })
569                .expect("spawn shadow thread"),
570        )
571    } else {
572        None
573    };
574
575    Ok(ReplicaPipelineHandles {
576        input_producer: pipeline.input_producer,
577        journal_cursor: pipeline.cursors.journal_ring_arc(),
578        last_seq: pipeline.cursors.durable_wire_seq(),
579        chain_hash_lock: pipeline.chain_hash_lock,
580        stream_marks,
581        journal_failed,
582        pipeline_shutdown,
583        journal_handle,
584        matching_handle,
585        drain_handle,
586        shadow_handle,
587    })
588}
589
590/// Tear down the pipeline: publish the shutdown sentinel, join all
591/// threads, return the recovered (App, journal writer) so the orchestrator
592/// can use them for the next pipeline build (e.g., post-snapshot) or
593/// pass them up on promotion.
594pub(super) fn teardown_replica_pipeline<A: Application + Send + 'static, W: Send + 'static>(
595    mut handles: ReplicaPipelineHandles<A, W>,
596) -> TeardownOutcome<A, W> {
597    // The sentinel is published here, not by callers, so no teardown
598    // path can forget it — the journal and matching stages exit by
599    // consuming it through the normal event path, draining anything
600    // received-but-not-yet-journaled (see `shutdown_pipeline` for why
601    // the flag alone is only the emergency fallback). Bounded retry
602    // rather than the blocking `publish`: if the journal stage dies
603    // with the ring full, its gate cursor freezes and a blocking
604    // publish would spin forever — re-check the failure latch between
605    // attempts and fall through to the flag-only teardown once it
606    // trips (the sentinel has no reader then anyway: the matching
607    // stage is gated behind the frozen journal cursor).
608    while !handles.journal_failed.load(Ordering::Acquire) {
609        match handles
610            .input_producer
611            .try_publish(InputSlot::<A::Event>::shutdown_sentinel())
612        {
613            Ok(_) => break,
614            // Ring full — consumers need to make progress first.
615            Err(_) => std::thread::yield_now(),
616        }
617    }
618    shutdown_pipeline::<A, W>(
619        &handles.pipeline_shutdown,
620        handles.journal_handle,
621        handles.matching_handle,
622        handles.drain_handle,
623        handles.shadow_handle,
624    )
625}
626
627/// How many mid-stream divergence resyncs the receiver attempts
628/// in-process (per process lifetime) before giving up. Mid-stream
629/// divergence is never expected in a healthy cluster — it means
630/// corruption or a serious bug somewhere — so the budget is exactly
631/// one: the first occurrence repairs automatically (archive the local
632/// lineage as `.divergent.<n>`, re-seed from the primary) and pages
633/// the operator via `melin_replica_divergence_total`; a second in the
634/// same process lifetime is systematic, and continuing to repair
635/// would fill the disk with archives while masking the underlying
636/// fault. Exit hard instead.
637pub(super) const MAX_INPROCESS_DIVERGENCE_RESYNCS: u32 = 1;
638
639/// Recover replica boot state from disk.
640///
641/// Recovers whenever any journal segment survives — live OR archived
642/// (a crash between rotation's rename and the new live file's creation
643/// leaves archives with no live segment, and recovery handles that
644/// layout; treating it as a fresh replica would discard local durable
645/// history and then fail `create_new` against the surviving lineage).
646/// Returns `(None, None, 0, zeros)` for a genuinely fresh replica.
647/// Also seeds the fencing epoch from the recovered journal.
648///
649/// Called at receiver startup, and again after a mid-stream chain
650/// divergence tears the pipeline down: the on-disk journal is
651/// self-consistent (merely forked from the primary's history), so
652/// recovery re-derives a truthful handshake pair `(last_sequence,
653/// chain_hash)` and the next connection takes the primary's
654/// HashMismatch → archive → reseed path in-process.
655#[allow(clippy::type_complexity)]
656pub(super) fn recover_replica_state<A, W>(
657    journal_path: &std::path::Path,
658    snapshot_path: &std::path::Path,
659    factory: &dyn melin_app::app_factory::AppFactory<App = A>,
660    fence_state: &melin_transport_core::fence::FenceState,
661) -> Result<(Option<A>, Option<W>, u64, [u8; 32]), Box<dyn std::error::Error>>
662where
663    A: Application,
664    W: melin_journal::JournalWrite<A::Event>,
665{
666    let lineage_exists =
667        journal_path.exists() || !melin_journal::segment::list_archives(journal_path)?.is_empty();
668    if !lineage_exists {
669        return Ok((None, None, 0u64, [0u8; 32]));
670    }
671    let engine = if snapshot_path.exists() {
672        tracing::info!("recovering replica from snapshot + journal");
673        melin_transport_core::JournaledApp::<A, W>::recover_from_snapshot(
674            snapshot_path,
675            journal_path,
676        )?
677    } else {
678        melin_transport_core::JournaledApp::<A, W>::recover(factory.empty(), journal_path)?
679    };
680    let next = engine.next_sequence();
681    let last = next.saturating_sub(1);
682    let hash = engine.chain_hash().unwrap_or([0u8; 32]);
683    // Seed the observed epoch from the replica's own recovered journal.
684    // Streaming `EpochBump`s and the snapshot-resync path raise it later.
685    fence_state.observe_epoch(engine.recovered_epoch());
686    let (mut exchange, writer) = engine.into_parts();
687    factory.apply_operator_policy(&mut exchange);
688    Ok((Some(exchange), Some(writer), last, hash))
689}
690
691/// Reconnect backoff cap shared by both receivers — exponential from
692/// 1 s, clamped here so a long outage settles to one attempt every 30 s
693/// without hammering a flapping primary. Reset to 1 s only when a
694/// session heard the primary speak (data or heartbeat) — see
695/// [`handle_session_exit`].
696pub(super) const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30);
697
698/// What the receiver's reconnect loop does after a streaming session
699/// ends — the transport-independent half of the exit dispatch, returned
700/// by [`handle_session_exit`].
701pub(in crate::replication) enum AfterSession<A, W> {
702    /// Terminal: `run_receiver*` returns this verbatim — clean shutdown
703    /// (`Ok(None)`), a promotion hand-off (`Ok(Some(state))`), or a
704    /// fatal error.
705    Return(ReceiverResult<A, W>),
706    /// A mid-stream divergence was repaired in-process: the recovered
707    /// on-disk state is the new handshake position. The caller adopts it
708    /// and reconnects; the primary's `HashMismatch` verdict then routes
709    /// the replica through archive + re-seed.
710    Resync {
711        exchange: Option<A>,
712        journal_writer: Option<W>,
713        last_sequence: u64,
714        chain_hash: [u8; 32],
715    },
716    /// A plain disconnect or a stream contiguity break — backoff has
717    /// already been applied (and the flags checked); the caller
718    /// reconnects, reusing the still-live pipeline.
719    Reconnect,
720}
721
722/// Dispatch a finished streaming session — shared by the kernel-TCP and
723/// DPDK receivers.
724///
725/// Folds the behaviours that were previously copied between the two
726/// receiver loops (and had drifted — the copies reset the post-resync
727/// writer differently, a latent corruption bug): the
728/// `Shutdown`/`Promote`/`Fatal` teardown (including the once-per-process
729/// in-process divergence-resync policy) and the `Disconnected` reconnect
730/// backoff. The shutdown-sentinel publish lives in
731/// [`teardown_replica_pipeline`].
732///
733/// `close` runs any transport-specific teardown that must precede a
734/// reconnect — the DPDK receiver closes its smoltcp socket so the
735/// primary's slot and the local socket-set entry are freed; the
736/// kernel-TCP receiver passes a no-op (its `TcpStream` is dropped by the
737/// caller on the next loop turn). It is invoked only on the
738/// reconnecting paths (in-process resync, plain disconnect, stream
739/// gap), never on a terminal return.
740// Twelve arguments is a lot, but each is a distinct piece of the
741// receiver loop's state; bundling them would only move the noise.
742#[allow(clippy::too_many_arguments)]
743pub(in crate::replication) fn handle_session_exit<A, W>(
744    result: StreamingResult,
745    pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
746    divergence_resyncs: &mut u32,
747    backoff: &mut std::time::Duration,
748    last_sequence: u64,
749    journal_path: &std::path::Path,
750    snapshot_path: &std::path::Path,
751    factory: &dyn melin_app::app_factory::AppFactory<App = A>,
752    fence_state: &melin_transport_core::fence::FenceState,
753    shutdown: &AtomicBool,
754    promote: &crate::promotion::PromotionRequest,
755    mut close: impl FnMut(),
756) -> AfterSession<A, W>
757where
758    A: Application + Send + 'static,
759    W: JournalWrite<A::Event> + Send + 'static,
760{
761    let StreamingResult {
762        exit,
763        heard_from_primary,
764    } = result;
765
766    match exit {
767        SessionExit::Shutdown => {
768            if let Some(p) = pipeline.take() {
769                let _ = teardown_replica_pipeline::<A, W>(p);
770            }
771            AfterSession::Return(Ok(None))
772        }
773
774        SessionExit::Promote => AfterSession::Return(match pipeline.take() {
775            Some(p) => match teardown_replica_pipeline::<A, W>(p) {
776                TeardownOutcome::Clean(ex, wr) => Ok(Some((ex, wr))),
777                _ => Err("pipeline failed during promotion".into()),
778            },
779            None => Err("pipeline missing on promote".into()),
780        }),
781
782        SessionExit::Fatal(e) => {
783            let outcome = match pipeline.take() {
784                Some(p) => teardown_replica_pipeline::<A, W>(p),
785                // Fatal implies a streaming session, which implies a
786                // pipeline — but don't turn a missing one into a resync.
787                None => return AfterSession::Return(Err(e)),
788            };
789            // Mid-stream chain divergence is repairable in-process: the
790            // on-disk journal is self-consistent (merely forked from the
791            // primary's history), so re-derive the handshake state from
792            // disk and reconnect — the primary judges the recovered
793            // position divergent and the next session takes the
794            // HashMismatch → archive → reseed path, no restart needed.
795            // Every other fatal exits as before: protocol violations and
796            // journal I/O death (ENOSPC, RO-FS) would fail the same way
797            // after a resync.
798            let TeardownOutcome::JournalFailed(
799                je @ melin_journal::JournalError::ReplicaChainDivergence { .. },
800            ) = outcome
801            else {
802                return AfterSession::Return(Err(e));
803            };
804
805            *divergence_resyncs += 1;
806            let attempt = *divergence_resyncs;
807            if attempt > MAX_INPROCESS_DIVERGENCE_RESYNCS {
808                return AfterSession::Return(Err(format!(
809                    "mid-stream chain divergence recurred {attempt} times — giving up on \
810                     in-process resync (each cycle archives the local journal and re-seeds \
811                     from the primary; recurrence at this rate means the primary keeps \
812                     streaming history that forks from what it announces): {je}"
813                )
814                .into()));
815            }
816            tracing::warn!(
817                error = %je,
818                attempt,
819                max_attempts = MAX_INPROCESS_DIVERGENCE_RESYNCS,
820                "mid-stream chain divergence — re-deriving local state for in-process resync"
821            );
822            // Transport-specific teardown before reconnecting.
823            close();
824            match recover_replica_state::<A, W>(journal_path, snapshot_path, factory, fence_state) {
825                Ok((exchange, journal_writer, seq, hash)) => AfterSession::Resync {
826                    exchange,
827                    journal_writer,
828                    last_sequence: seq,
829                    chain_hash: hash,
830                },
831                Err(e) => AfterSession::Return(Err(e)),
832            }
833        }
834
835        SessionExit::StreamGap(e) => {
836            // The primary's stream skipped past what we hold; the
837            // contiguous prefix is committed and the journal stage keeps
838            // flushing it, so re-handshaking at the durable position
839            // (re-read from `FsyncState` at the top of the reconnect
840            // loop, after the backoff) lets the primary re-stream the
841            // hole. The primary evidently spoke, so the backoff resets
842            // exactly as a heard-from disconnect does. Not a resync:
843            // nothing on disk is wrong.
844            close();
845            *backoff = std::time::Duration::from_secs(1);
846            tracing::warn!(
847                error = %e,
848                last_sequence,
849                "replication stream broke contiguity — reconnecting from the durable position"
850            );
851            sleep_then_double_backoff(backoff, shutdown, promote);
852            AfterSession::Reconnect
853        }
854
855        SessionExit::Disconnected => {
856            // Transport-specific teardown before reconnecting (smoltcp
857            // socket reclaim on DPDK; no-op on kernel TCP).
858            close();
859            // A session in which the primary spoke — data or heartbeat
860            // (heartbeats flow even on a quiet system) — proves it
861            // alive and serving: treat the drop as transient and reset
862            // the backoff. A session with no word from the primary
863            // keeps escalating: that covers instant-drop flaps and
864            // synthetic results from sessions that never started
865            // streaming (a persistent local transport failure must not
866            // redial at 1 Hz forever).
867            if heard_from_primary {
868                *backoff = std::time::Duration::from_secs(1);
869            }
870            tracing::warn!(
871                last_sequence,
872                heard_from_primary,
873                backoff_secs = backoff.as_secs(),
874                "reconnecting to primary"
875            );
876            sleep_then_double_backoff(backoff, shutdown, promote);
877            AfterSession::Reconnect
878        }
879    }
880}
881
882/// Tear the live pipeline down for a promotion that fired while the
883/// receiver was disconnected — at the top of the reconnect loop or
884/// during reconnect backoff — and return the warm Exchange + writer for
885/// the promoted primary. Shared by both receivers.
886///
887/// A clean teardown hands back the warm state; if there is no pipeline
888/// (promotion before the first connect, or after a resync that left the
889/// state in the receiver's locals) those locals carry it. A missing
890/// pair is a hard error — a promote with nothing to promote.
891pub(in crate::replication) fn take_pipeline_for_promotion<A, W>(
892    pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
893    exchange: &mut Option<A>,
894    journal_writer: &mut Option<W>,
895) -> ReceiverResult<A, W>
896where
897    A: Application + Send + 'static,
898    W: JournalWrite<A::Event> + Send + 'static,
899{
900    if let Some(p) = pipeline.take()
901        && let TeardownOutcome::Clean(e, w) = teardown_replica_pipeline::<A, W>(p)
902    {
903        *exchange = Some(e);
904        *journal_writer = Some(w);
905    }
906    match (exchange.take(), journal_writer.take()) {
907        (Some(e), Some(w)) => Ok(Some((e, w))),
908        _ => Err("promotion requested but no local state available".into()),
909    }
910}
911
912/// The four facts a successful snapshot + segment-seed transfer yields:
913/// `(snapshot App, snapshot sequence, snapshot chain hash, seed length)`.
914type ResyncTransfer<A> = (A, u64, [u8; 32], u64);
915
916/// What [`handle_resync_verdict`] resolved a `NeedSnapshot` /
917/// `HashMismatch` verdict to.
918pub(in crate::replication) enum ResyncDecision {
919    /// Resync complete — resume streaming from `resume_sequence` on the
920    /// re-seeded lineage `(segment_start_sequence, anchor_hash)`. The
921    /// recovered App + writer are left in the receiver's `exchange` /
922    /// `journal_writer` locals.
923    Ready {
924        segment_start_sequence: u64,
925        anchor_hash: [u8; 32],
926        resume_sequence: u64,
927    },
928    /// The transfer failed network-shaped (drop / restart mid-body); the
929    /// caller backs off and reconnects. The pre-resync lineage is already
930    /// archived and half-applied transfer state has been cleaned up.
931    Retry,
932}
933
934/// Receive the snapshot + segment-seed transfer that follows a resync
935/// verdict, installing both (snapshot → `snapshot_path`, seed →
936/// `journal_path`). Returns the snapshot App, its sequence + chain hash,
937/// and the seed length (the journal's `valid_end`). Shared by both
938/// receivers via [`ControlFrameSource`].
939///
940/// Errors here are network-shaped — the caller retries (see
941/// [`ResyncDecision::Retry`]); the post-transfer install in
942/// [`handle_resync_verdict`] is what's fatal.
943fn receive_resync_transfer<A, S>(
944    source: &mut S,
945    snapshot_path: &std::path::Path,
946    journal_path: &std::path::Path,
947    fence_state: &melin_transport_core::fence::FenceState,
948) -> Result<ResyncTransfer<A>, Box<dyn std::error::Error + Send + Sync>>
949where
950    A: Application,
951    S: ControlFrameSource,
952{
953    let (snap_len, snap_sequence, snap_chain_hash) =
954        match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
955            PrimaryMessage::SnapshotBegin {
956                snapshot_len,
957                snap_sequence,
958                snap_chain_hash,
959            } => (snapshot_len, snap_sequence, snap_chain_hash),
960            other => return Err(format!("expected SnapshotBegin, got {other:?}").into()),
961        };
962
963    tracing::info!(snap_sequence, snap_len, "receiving snapshot");
964    let tmp_path = snapshot_path.with_extension("snapshot.tmp");
965    receive_chunked_body(source, &tmp_path, snap_len, "snapshot")?;
966    std::fs::rename(&tmp_path, snapshot_path)?;
967    tracing::info!(snap_sequence, snap_len, "snapshot received and verified");
968
969    let (snap_exchange, _snap_seq, snap_hash, snap_epoch) =
970        melin_transport_core::snapshot::load::<A>(snapshot_path)?;
971    if snap_hash != snap_chain_hash {
972        return Err(format!(
973            "snapshot chain hash mismatch: primary sent {snap_chain_hash:02x?}, \
974             loaded snapshot has {snap_hash:02x?}"
975        )
976        .into());
977    }
978    // Adopt the primary's snapshot epoch — the resync rebases this replica
979    // onto the primary's lineage, including its epoch.
980    fence_state.observe_epoch(snap_epoch);
981
982    // Segment seed: the raw byte prefix of the primary's segment
983    // containing `snap_sequence`. Written verbatim as our live segment, it
984    // makes our segmentation a byte-copy of the primary's from birth —
985    // chain values comparable and `Rotate` verification valid immediately.
986    let seed_len = match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
987        PrimaryMessage::SegmentSeedBegin { seed_len } => seed_len,
988        other => {
989            return Err(format!("expected SegmentSeedBegin after snapshot, got {other:?}").into());
990        }
991    };
992    let seed_tmp = journal_path.with_extension("seed.tmp");
993    receive_chunked_body(source, &seed_tmp, seed_len, "segment seed")?;
994    // Structural check before installing: the CRC only proves transport
995    // integrity, not that the primary sent a well-formed prefix ending at
996    // the snapshot sequence. (With hash-chain on, the chain cross-check in
997    // `handle_resync_verdict` subsumes this; without it, this is the only
998    // guard.)
999    if let Err(e) =
1000        melin_journal::segment::verify_segment_prefix(&seed_tmp, snap_sequence, seed_len)
1001    {
1002        let _ = std::fs::remove_file(&seed_tmp);
1003        return Err(format!("segment seed failed structural verification: {e}").into());
1004    }
1005    std::fs::rename(&seed_tmp, journal_path)?;
1006    melin_journal::segment::fsync_parent_dir(journal_path)?;
1007    Ok((snap_exchange, snap_sequence, snap_chain_hash, seed_len))
1008}
1009
1010/// Handle a `NeedSnapshot` / `HashMismatch` resync verdict — shared by
1011/// both receivers. Tears the pipeline down, archives the local lineage
1012/// (never deleted), resets the handshake position, receives + installs
1013/// the snapshot and segment seed via `source`, opens the re-seeded
1014/// segment and ties its chain to the snapshot, then validates the
1015/// post-snapshot `StreamStart` inline before resuming.
1016///
1017/// On success the recovered App + writer are left in `exchange` /
1018/// `journal_writer` and [`ResyncDecision::Ready`] carries the resume
1019/// lineage. A network-shaped transfer failure yields
1020/// [`ResyncDecision::Retry`] (the caller backs off and reconnects). An
1021/// inconsistent primary or a local install failure is fatal (`Err`).
1022#[allow(clippy::too_many_arguments)]
1023pub(in crate::replication) fn handle_resync_verdict<A, W, S>(
1024    divergent: bool,
1025    source: &mut S,
1026    pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
1027    exchange: &mut Option<A>,
1028    journal_writer: &mut Option<W>,
1029    journal_path: &std::path::Path,
1030    snapshot_path: &std::path::Path,
1031    fence_state: &melin_transport_core::fence::FenceState,
1032    control: &ReplicaControlPlane,
1033    last_sequence: &mut u64,
1034    chain_hash: &mut [u8; 32],
1035) -> Result<ResyncDecision, Box<dyn std::error::Error + Send + Sync>>
1036where
1037    A: Application + Send + 'static,
1038    W: JournalWrite<A::Event> + Send + 'static,
1039    S: ControlFrameSource,
1040{
1041    // HashMismatch is NeedSnapshot plus the verdict that our local journal
1042    // holds divergent history (forked from the primary's — e.g. an
1043    // ex-primary rejoining after failover with an acked-but-unreplicated
1044    // suffix).
1045    if divergent {
1046        tracing::warn!(
1047            last_sequence = *last_sequence,
1048            "primary reports chain divergence — archiving local journal, resyncing from snapshot"
1049        );
1050    } else {
1051        tracing::info!("primary requires snapshot transfer — receiving snapshot");
1052    }
1053
1054    if let Some(p) = pipeline.take() {
1055        let _ = teardown_replica_pipeline::<A, W>(p);
1056    }
1057
1058    // Invalidate the in-memory App + writer before moving their backing
1059    // files aside. On the in-process divergence repair path these still
1060    // hold the recovered handles; a transfer failure returns `Retry`, and
1061    // without this reset the stale writer — now pointing at an
1062    // archived-away journal — would survive the fresh-replica create gate
1063    // and get rebuilt into the next pipeline.
1064    *exchange = None;
1065    *journal_writer = None;
1066
1067    // Move the local lineage aside — never delete. Divergent journals are
1068    // audit-trail material; stale ones may be the last copy of pruned
1069    // history.
1070    let reason = if divergent {
1071        ArchiveReason::Divergent
1072    } else {
1073        ArchiveReason::Resync
1074    };
1075    archive_local_lineage(journal_path, snapshot_path, reason)?;
1076    // Archived — a retried handshake must present as a fresh replica.
1077    *last_sequence = 0;
1078    *chain_hash = [0u8; 32];
1079    // The advertised control-plane tip must shrink with the holdings:
1080    // the archived suffix is either divergent (never acked) or being
1081    // replaced wholesale, so this is the one legitimate tip regression
1082    // (see `AdvertisedJournalTip::reset`).
1083    control
1084        .journal_tip
1085        .reset(melin_transport_core::WireSeq::new(0));
1086
1087    let (snap_exchange, snap_sequence, snap_chain_hash, seed_len) =
1088        match receive_resync_transfer::<A, S>(source, snapshot_path, journal_path, fence_state) {
1089            Ok(v) => v,
1090            Err(e) => {
1091                tracing::warn!(error = %e, "snapshot transfer failed — retrying");
1092                // Half-applied resync state, not audit material — drop it
1093                // so the retry starts clean (the pre-resync lineage is
1094                // already archived; this is not it).
1095                let _ = std::fs::remove_file(snapshot_path);
1096                return Ok(ResyncDecision::Retry);
1097            }
1098        };
1099    *exchange = Some(snap_exchange);
1100
1101    // Open the seeded segment for appending at the snapshot position —
1102    // recovery's resume path: the chain rebuilds from the seeded bytes and
1103    // its value at `snap_sequence` must equal the (verified) snapshot's
1104    // chain hash. `chain_hash()` is `None` only with `hash-chain` disabled
1105    // (nothing to tie); an all-zeros snapshot hash means the primary runs
1106    // without `hash-chain` (also nothing to tie).
1107    let writer = W::open_append(journal_path, snap_sequence, seed_len)?;
1108    let seeded_chain = writer.chain_hash().unwrap_or(snap_chain_hash);
1109    if snap_chain_hash != [0u8; 32] && seeded_chain != snap_chain_hash {
1110        return Err(format!(
1111            "segment seed chain at {snap_sequence} disagrees with the transferred snapshot's \
1112             hash — inconsistent primary"
1113        )
1114        .into());
1115    }
1116    let seeded_info = melin_journal::segment::read_header_info(journal_path)?;
1117    *journal_writer = Some(writer);
1118
1119    // Validate the post-snapshot StreamStart inline before resuming — its
1120    // lineage must agree with the seed the primary just transferred.
1121    match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
1122        PrimaryMessage::StreamStart {
1123            start_sequence,
1124            segment_start_sequence,
1125            anchor_hash,
1126            epoch,
1127            durability_mode,
1128        } => {
1129            if segment_start_sequence != seeded_info.starting_sequence
1130                || anchor_hash != seeded_info.anchor_hash
1131            {
1132                return Err(format!(
1133                    "post-snapshot StreamStart lineage (start {segment_start_sequence}) \
1134                     disagrees with the transferred segment seed (start {}) — inconsistent \
1135                     primary",
1136                    seeded_info.starting_sequence
1137                )
1138                .into());
1139            }
1140            // We just rebased onto this primary's snapshot, so adopt its
1141            // epoch wholesale (no stale-primary refusal — our prior state
1142            // was discarded).
1143            fence_state.observe_epoch(epoch);
1144            control
1145                .primary_acking_mode
1146                .store(durability_mode, Ordering::Release);
1147            tracing::info!(
1148                start_sequence,
1149                epoch,
1150                durability_mode,
1151                "streaming resumed after snapshot transfer"
1152            );
1153            // The transfer is installed and validated: the node's holdings
1154            // are exactly the snapshot position again.
1155            control
1156                .journal_tip
1157                .reset(melin_transport_core::WireSeq::new(snap_sequence));
1158            Ok(ResyncDecision::Ready {
1159                segment_start_sequence,
1160                anchor_hash,
1161                resume_sequence: snap_sequence,
1162            })
1163        }
1164        other => Err(format!("expected StreamStart after snapshot, got {other:?}").into()),
1165    }
1166}
1167
1168#[cfg(test)]
1169mod tests {
1170    use std::io::Write;
1171    use std::sync::Arc;
1172
1173    use super::auth::{authenticate_replica, authenticate_with_primary};
1174    use super::*;
1175    // Any `AppEvent` works as the pipeline's event type here — these
1176    // protocol-level tests never construct a real app event (the slot's
1177    // event is always `JournalEvent::Tick`), so the counter example's
1178    // event type stands in for an exchange event and keeps the runtime's
1179    // test deps free of exchange crates.
1180    use counter_server::CounterEvent;
1181    type InputSlot = melin_transport_core::pipeline::InputSlot<CounterEvent>;
1182    use melin_transport_core::replication::protocol::{
1183        MAX_CONTROL_FRAME, MAX_DATA_FRAME, MSG_AUTH_OK, MSG_CHALLENGE_RESPONSE, MSG_SNAPSHOT_BEGIN,
1184        MSG_SNAPSHOT_CHUNK, MSG_SNAPSHOT_END, decode_auth_result, decode_challenge,
1185        decode_challenge_response, decode_primary_message, decode_replica_message, encode_ack,
1186        encode_auth_failed, encode_auth_ok, encode_chain_check, encode_challenge,
1187        encode_challenge_response, encode_handshake, encode_hash_mismatch, encode_heartbeat,
1188        encode_input_batch, encode_need_snapshot, encode_rotate, encode_segment_seed_begin,
1189        encode_snapshot_begin, encode_snapshot_chunk, encode_snapshot_end, encode_stream_start,
1190        read_frame, try_decode_input_batch,
1191    };
1192
1193    /// Build a wire-ready `InputBatch` frame containing a single `Tick`
1194    /// slot at the given sequence — the protocol-level tests don't need
1195    /// real journal payloads, just something with a known max sequence.
1196    fn encode_input_batch_with_seq(end_sequence: u64, buf: &mut Vec<u8>) {
1197        let slot = InputSlot {
1198            connection_id: 0,
1199            key_hash: 0,
1200            request_seq: 0,
1201            sequence: end_sequence,
1202            timestamp_ns: 0,
1203            event: melin_journal::JournalEvent::Tick { now_ns: 0 },
1204            publish_ts: Default::default(),
1205            recv_ts: Default::default(),
1206        };
1207        encode_input_batch(&[slot], buf);
1208    }
1209
1210    #[test]
1211    fn handshake_encode_decode_round_trip() {
1212        let handshake = Handshake {
1213            last_sequence: 42,
1214            chain_hash: [0xAB; 32],
1215            // Non-zero so a dropped/zeroed epoch field is caught.
1216            epoch: 9,
1217        };
1218        let mut buf = Vec::new();
1219        encode_handshake(&handshake, &mut buf);
1220
1221        // Read frame: skip 4-byte length prefix.
1222        let payload = &buf[4..];
1223        let msg = decode_replica_message(payload).unwrap();
1224        match msg {
1225            ReplicaMessage::Handshake(h) => {
1226                assert_eq!(h.last_sequence, 42);
1227                assert_eq!(h.chain_hash, [0xAB; 32]);
1228                assert_eq!(h.epoch, 9);
1229            }
1230            _ => panic!("expected Handshake"),
1231        }
1232    }
1233
1234    #[test]
1235    fn ack_encode_decode_round_trip() {
1236        let ack = Ack {
1237            acked_sequence: 1000,
1238            in_memory_sequence: 1024,
1239        };
1240        let mut buf = Vec::new();
1241        encode_ack(&ack, &mut buf);
1242
1243        let payload = &buf[4..];
1244        let msg = decode_replica_message(payload).unwrap();
1245        match msg {
1246            ReplicaMessage::Ack(a) => {
1247                assert_eq!(a.acked_sequence, 1000);
1248                assert_eq!(a.in_memory_sequence, 1024);
1249            }
1250            _ => panic!("expected Ack"),
1251        }
1252    }
1253
1254    /// Pin the exact on-the-wire byte layout of an Ack frame. A future
1255    /// `repr(C)` field reorder, alignment change, or accidental
1256    /// big-endian wrapper substitution would silently break replica/
1257    /// primary compatibility — the const `size_of` assert in
1258    /// `protocol.rs` only catches size changes, not layout changes.
1259    /// This test pins the expected bytes so any such break shows up
1260    /// loudly on the next CI run.
1261    #[test]
1262    fn ack_wire_byte_pattern() {
1263        let ack = Ack {
1264            acked_sequence: 0xDEAD_BEEF_CAFE_F00D,
1265            in_memory_sequence: 0x1122_3344_5566_7788,
1266        };
1267        let mut buf = Vec::new();
1268        encode_ack(&ack, &mut buf);
1269        // [length:u32 LE = 17][tag:u8 = MSG_ACK = 0x02]
1270        // [acked_sequence:u64 LE][in_memory_sequence:u64 LE]
1271        let expected: &[u8] = &[
1272            0x11, 0x00, 0x00, 0x00, // length = 17 LE
1273            0x02, // MSG_ACK
1274            0x0D, 0xF0, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE, // acked_sequence LE
1275            0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, // in_memory_sequence LE
1276        ];
1277        assert_eq!(buf.as_slice(), expected, "Ack wire layout drifted");
1278    }
1279
1280    #[test]
1281    fn stream_start_encode_decode_round_trip() {
1282        let mut buf = Vec::new();
1283        // Non-zero epoch so a dropped/zeroed field is caught.
1284        // Non-zero epoch/mode so a dropped/zeroed field is caught.
1285        encode_stream_start(99, 42, [0xAA; 32], 5, 2, &mut buf);
1286
1287        let payload = &buf[4..];
1288        let msg = decode_primary_message(payload).unwrap();
1289        match msg {
1290            PrimaryMessage::StreamStart {
1291                start_sequence,
1292                segment_start_sequence,
1293                anchor_hash,
1294                epoch,
1295                durability_mode,
1296            } => {
1297                assert_eq!(start_sequence, 99);
1298                assert_eq!(segment_start_sequence, 42);
1299                assert_eq!(anchor_hash, [0xAA; 32]);
1300                assert_eq!(durability_mode, 2);
1301                assert_eq!(epoch, 5);
1302            }
1303            _ => panic!("expected StreamStart"),
1304        }
1305    }
1306
1307    #[test]
1308    fn heartbeat_encode_decode_round_trip() {
1309        let mut buf = Vec::new();
1310        encode_heartbeat(123, 2, &mut buf);
1311
1312        let payload = &buf[4..];
1313        let msg = decode_primary_message(payload).unwrap();
1314        match msg {
1315            PrimaryMessage::Heartbeat {
1316                sequence,
1317                durability_mode,
1318            } => {
1319                assert_eq!(sequence, 123);
1320                assert_eq!(durability_mode, 2);
1321            }
1322            _ => panic!("expected Heartbeat"),
1323        }
1324    }
1325
1326    #[test]
1327    fn need_snapshot_encode_decode_round_trip() {
1328        let mut buf = Vec::new();
1329        encode_need_snapshot(&mut buf);
1330
1331        let payload = &buf[4..];
1332        let msg = decode_primary_message(payload).unwrap();
1333        assert!(matches!(msg, PrimaryMessage::NeedSnapshot));
1334    }
1335
1336    #[test]
1337    fn hash_mismatch_encode_decode_round_trip() {
1338        let mut buf = Vec::new();
1339        encode_hash_mismatch(&mut buf);
1340
1341        let payload = &buf[4..];
1342        let msg = decode_primary_message(payload).unwrap();
1343        assert!(matches!(msg, PrimaryMessage::HashMismatch));
1344    }
1345
1346    #[test]
1347    fn rotate_encode_decode_round_trip() {
1348        let mut buf = Vec::new();
1349        encode_rotate(7_000_000, &[0xBB; 32], &mut buf);
1350
1351        let payload = &buf[4..];
1352        let msg = decode_primary_message(payload).unwrap();
1353        match msg {
1354            PrimaryMessage::Rotate {
1355                boundary_seq,
1356                tail_hash,
1357            } => {
1358                assert_eq!(boundary_seq, 7_000_000);
1359                assert_eq!(tail_hash, [0xBB; 32]);
1360            }
1361            _ => panic!("expected Rotate"),
1362        }
1363    }
1364
1365    #[test]
1366    fn chain_check_encode_decode_round_trip() {
1367        let mut buf = Vec::new();
1368        encode_chain_check(555, &[0xCC; 32], &mut buf);
1369
1370        let payload = &buf[4..];
1371        let msg = decode_primary_message(payload).unwrap();
1372        match msg {
1373            PrimaryMessage::ChainCheck {
1374                sequence,
1375                chain_hash,
1376            } => {
1377                assert_eq!(sequence, 555);
1378                assert_eq!(chain_hash, [0xCC; 32]);
1379            }
1380            _ => panic!("expected ChainCheck"),
1381        }
1382    }
1383
1384    /// Pin the wire layout of the shared `(sequence, hash)` frame body —
1385    /// same rationale as `ack_wire_byte_pattern`. Rotate and ChainCheck
1386    /// share the layout; only the tag differs.
1387    #[test]
1388    fn rotate_wire_byte_pattern() {
1389        let mut hash = [0u8; 32];
1390        for (i, b) in hash.iter_mut().enumerate() {
1391            *b = i as u8;
1392        }
1393        let mut buf = Vec::new();
1394        encode_rotate(0xDEAD_BEEF_CAFE_F00D, &hash, &mut buf);
1395        // [length:u32 LE = 41][tag:u8 = MSG_ROTATE = 0x16]
1396        // [boundary_seq:u64 LE][tail_hash:32 bytes verbatim]
1397        let mut expected = vec![
1398            0x29, 0x00, 0x00, 0x00, // length = 41 LE
1399            0x16, // MSG_ROTATE
1400            0x0D, 0xF0, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE, // boundary_seq LE
1401        ];
1402        expected.extend_from_slice(&hash);
1403        assert_eq!(
1404            buf.as_slice(),
1405            expected.as_slice(),
1406            "Rotate wire layout drifted"
1407        );
1408    }
1409
1410    #[test]
1411    fn segment_seed_begin_encode_decode_round_trip() {
1412        let mut buf = Vec::new();
1413        encode_segment_seed_begin(123_456, &mut buf);
1414
1415        let payload = &buf[4..];
1416        let msg = decode_primary_message(payload).unwrap();
1417        match msg {
1418            PrimaryMessage::SegmentSeedBegin { seed_len } => {
1419                assert_eq!(seed_len, 123_456);
1420            }
1421            _ => panic!("expected SegmentSeedBegin"),
1422        }
1423    }
1424
1425    #[test]
1426    fn unknown_replica_message_type_is_error() {
1427        let payload = [0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
1428        let result = decode_replica_message(&payload);
1429        assert!(result.is_err());
1430    }
1431
1432    #[test]
1433    fn unknown_primary_message_type_is_error() {
1434        let payload = [0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
1435        let result = decode_primary_message(&payload);
1436        assert!(result.is_err());
1437    }
1438
1439    #[test]
1440    fn read_frame_enforces_max_size() {
1441        // Create a buffer with a length prefix claiming 1000 bytes.
1442        let mut buf = Vec::new();
1443        buf.extend_from_slice(&1000u32.to_le_bytes());
1444        buf.extend_from_slice(&[0u8; 100]); // not enough data, but max_size check comes first
1445
1446        let mut cursor = std::io::Cursor::new(buf);
1447        let result = read_frame(&mut cursor, 64);
1448        assert!(result.is_err());
1449    }
1450
1451    #[test]
1452    fn challenge_encode_decode_round_trip() {
1453        let nonce = [0x42; 32];
1454        let mut buf = Vec::new();
1455        encode_challenge(&nonce, &mut buf);
1456
1457        let payload = &buf[4..];
1458        let decoded = decode_challenge(payload).unwrap();
1459        assert_eq!(decoded, nonce);
1460    }
1461
1462    #[test]
1463    fn challenge_response_encode_decode_round_trip() {
1464        let sig = [0xAA; 64];
1465        let pubkey = [0xBB; 32];
1466        let mut buf = Vec::new();
1467        encode_challenge_response(&sig, &pubkey, &mut buf);
1468
1469        let payload = &buf[4..];
1470        let (decoded_sig, decoded_pubkey) = decode_challenge_response(payload).unwrap();
1471        assert_eq!(decoded_sig, sig);
1472        assert_eq!(decoded_pubkey, pubkey);
1473    }
1474
1475    #[test]
1476    fn auth_ok_encode_decode_round_trip() {
1477        let mut buf = Vec::new();
1478        encode_auth_ok(&mut buf);
1479
1480        let payload = &buf[4..];
1481        assert!(decode_auth_result(payload).unwrap());
1482    }
1483
1484    #[test]
1485    fn auth_failed_encode_decode_round_trip() {
1486        let mut buf = Vec::new();
1487        encode_auth_failed(&mut buf);
1488
1489        let payload = &buf[4..];
1490        assert!(!decode_auth_result(payload).unwrap());
1491    }
1492
1493    #[test]
1494    fn decode_challenge_rejects_wrong_tag() {
1495        let mut payload = [0u8; 33];
1496        payload[0] = MSG_AUTH_OK;
1497        assert!(decode_challenge(&payload).is_err());
1498    }
1499
1500    #[test]
1501    fn decode_challenge_response_rejects_short_payload() {
1502        let payload = [MSG_CHALLENGE_RESPONSE; 10]; // too short
1503        assert!(decode_challenge_response(&payload).is_err());
1504    }
1505
1506    #[test]
1507    fn auth_round_trip_valid_key() {
1508        use ed25519_dalek::SigningKey;
1509        use std::os::unix::net::UnixStream;
1510
1511        let repl_key = SigningKey::from_bytes(&[0xFC; 32]);
1512        let pub_b64 = base64::Engine::encode(
1513            &base64::engine::general_purpose::STANDARD,
1514            repl_key.verifying_key().to_bytes(),
1515        );
1516        let keys_content = format!("replication {pub_b64} test-replica\n");
1517        let authorized_keys = melin_app::auth::AuthorizedKeys::parse(&keys_content).unwrap();
1518
1519        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
1520        primary_stream
1521            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
1522            .unwrap();
1523        replica_stream
1524            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
1525            .unwrap();
1526
1527        let repl_key_clone = SigningKey::from_bytes(&[0xFC; 32]);
1528        let replica_handle = std::thread::spawn(move || {
1529            let mut conn = replica_stream;
1530            authenticate_with_primary(&mut conn, &repl_key_clone)
1531        });
1532
1533        let mut conn = primary_stream;
1534        authenticate_replica(&mut conn, &authorized_keys).unwrap();
1535
1536        replica_handle.join().unwrap().unwrap();
1537    }
1538
1539    #[test]
1540    fn auth_rejects_unknown_key() {
1541        use ed25519_dalek::SigningKey;
1542        use std::os::unix::net::UnixStream;
1543
1544        // authorized_keys has one key, but the replica uses a different one.
1545        let authorized_key = SigningKey::from_bytes(&[0xAA; 32]);
1546        let rogue_key = SigningKey::from_bytes(&[0xBB; 32]);
1547        let pub_b64 = base64::Engine::encode(
1548            &base64::engine::general_purpose::STANDARD,
1549            authorized_key.verifying_key().to_bytes(),
1550        );
1551        let keys_content = format!("replication {pub_b64} authorized-replica\n");
1552        let authorized_keys = melin_app::auth::AuthorizedKeys::parse(&keys_content).unwrap();
1553
1554        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
1555        primary_stream
1556            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
1557            .unwrap();
1558        replica_stream
1559            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
1560            .unwrap();
1561
1562        let replica_handle = std::thread::spawn(move || {
1563            let mut conn = replica_stream;
1564            authenticate_with_primary(&mut conn, &rogue_key)
1565        });
1566
1567        let mut conn = primary_stream;
1568        let result = authenticate_replica(&mut conn, &authorized_keys);
1569        assert!(result.is_err());
1570        assert!(result.unwrap_err().to_string().contains("unknown"));
1571
1572        // Replica should also get a rejection.
1573        let replica_result = replica_handle.join().unwrap();
1574        assert!(replica_result.is_err());
1575    }
1576
1577    #[test]
1578    fn auth_rejects_wrong_permission() {
1579        use ed25519_dalek::SigningKey;
1580        use std::os::unix::net::UnixStream;
1581
1582        // Key exists but has Trader permission, not Replication.
1583        let key = SigningKey::from_bytes(&[0xCC; 32]);
1584        let pub_b64 = base64::Engine::encode(
1585            &base64::engine::general_purpose::STANDARD,
1586            key.verifying_key().to_bytes(),
1587        );
1588        let keys_content = format!("trader {pub_b64} wrong-role\n");
1589        let authorized_keys = melin_app::auth::AuthorizedKeys::parse(&keys_content).unwrap();
1590
1591        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
1592        primary_stream
1593            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
1594            .unwrap();
1595        replica_stream
1596            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
1597            .unwrap();
1598
1599        let replica_handle = std::thread::spawn(move || {
1600            let mut conn = replica_stream;
1601            authenticate_with_primary(&mut conn, &key)
1602        });
1603
1604        let mut conn = primary_stream;
1605        let result = authenticate_replica(&mut conn, &authorized_keys);
1606        assert!(result.is_err());
1607        assert!(result.unwrap_err().to_string().contains("Replication"));
1608
1609        let replica_result = replica_handle.join().unwrap();
1610        assert!(replica_result.is_err());
1611    }
1612
1613    /// A replica that sends a validly-formatted but tampered signature
1614    /// (correct public key, wrong signature bytes) is rejected.
1615    #[test]
1616    fn auth_rejects_invalid_signature() {
1617        use ed25519_dalek::SigningKey;
1618        use std::os::unix::net::UnixStream;
1619
1620        // Register the correct key.
1621        let correct_key = SigningKey::from_bytes(&[0xDD; 32]);
1622        let pub_b64 = base64::Engine::encode(
1623            &base64::engine::general_purpose::STANDARD,
1624            correct_key.verifying_key().to_bytes(),
1625        );
1626        let keys_content = format!("replication {pub_b64} test-replica\n");
1627        let authorized_keys = melin_app::auth::AuthorizedKeys::parse(&keys_content).unwrap();
1628
1629        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
1630        primary_stream
1631            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
1632            .unwrap();
1633        replica_stream
1634            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
1635            .unwrap();
1636
1637        // Replica side: read challenge, but sign with a DIFFERENT key,
1638        // then send the response with the correct public key (spoofing).
1639        let replica_handle = std::thread::spawn(move || {
1640            use melin_transport_core::replication::protocol::*;
1641
1642            let mut reader = replica_stream.try_clone().unwrap();
1643            let mut writer = replica_stream;
1644
1645            // Read the challenge.
1646            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
1647            let nonce = decode_challenge(&frame).unwrap();
1648
1649            // Sign with a WRONG key but send the CORRECT public key.
1650            let wrong_key = SigningKey::from_bytes(&[0xEE; 32]);
1651            let bad_signature = ed25519_dalek::Signer::sign(&wrong_key, &nonce);
1652            let correct_pubkey = correct_key.verifying_key();
1653
1654            let mut buf = Vec::with_capacity(128);
1655            encode_challenge_response(
1656                &bad_signature.to_bytes(),
1657                correct_pubkey.as_bytes(),
1658                &mut buf,
1659            );
1660            writer.write_all(&buf).unwrap();
1661            writer.flush().unwrap();
1662
1663            // Should receive AuthFailed.
1664            let result_frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
1665            let ok = decode_auth_result(&result_frame).unwrap();
1666            assert!(!ok, "should receive auth failure");
1667        });
1668
1669        let mut conn = primary_stream;
1670        let result = authenticate_replica(&mut conn, &authorized_keys);
1671        assert!(result.is_err());
1672        assert!(
1673            result
1674                .unwrap_err()
1675                .to_string()
1676                .contains("signature verification failed"),
1677            "should fail on signature verification"
1678        );
1679
1680        replica_handle.join().unwrap();
1681    }
1682
1683    #[test]
1684    fn sender_receiver_end_to_end() {
1685        use std::os::unix::net::UnixStream;
1686
1687        // Create a mock connection.
1688        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
1689
1690        // Spawn a thread simulating the replica side.
1691        let replica_handle = std::thread::spawn(move || {
1692            let mut reader = replica_stream.try_clone().unwrap();
1693            let mut writer = replica_stream;
1694
1695            // Send handshake.
1696            let mut buf = Vec::new();
1697            let handshake = Handshake {
1698                last_sequence: 0,
1699                chain_hash: [0u8; 32],
1700                epoch: 0,
1701            };
1702            encode_handshake(&handshake, &mut buf);
1703            writer.write_all(&buf).unwrap();
1704            writer.flush().unwrap();
1705            buf.clear();
1706
1707            // Read StreamStart.
1708            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
1709            let msg = decode_primary_message(&frame).unwrap();
1710            assert!(matches!(msg, PrimaryMessage::StreamStart { .. }));
1711
1712            // Read InputBatch.
1713            let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
1714            let slots: Vec<InputSlot> = try_decode_input_batch(&frame).expect("decode InputBatch");
1715            let end_seq = slots
1716                .last()
1717                .map(|s| s.sequence)
1718                .expect("InputBatch carried at least one slot");
1719
1720            // Send ack.
1721            let ack = Ack {
1722                acked_sequence: end_seq,
1723                in_memory_sequence: end_seq,
1724            };
1725            encode_ack(&ack, &mut buf);
1726            writer.write_all(&buf).unwrap();
1727            writer.flush().unwrap();
1728
1729            end_seq
1730        });
1731
1732        // Primary side: simulate handle_replica_connection partially.
1733        let mut p_reader = primary_stream.try_clone().unwrap();
1734        let mut p_writer = primary_stream;
1735
1736        // Read handshake.
1737        let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
1738        let handshake = match decode_replica_message(&frame).unwrap() {
1739            ReplicaMessage::Handshake(h) => h,
1740            _ => panic!("expected Handshake"),
1741        };
1742        assert_eq!(handshake.last_sequence, 0);
1743
1744        // Send StreamStart.
1745        let mut buf = Vec::new();
1746        encode_stream_start(0, 1, [0u8; 32], 0, 1, &mut buf); // fake lineage for test
1747        p_writer.write_all(&buf).unwrap();
1748        p_writer.flush().unwrap();
1749        buf.clear();
1750
1751        // Send an InputBatch with a single Tick slot at seq 42.
1752        encode_input_batch_with_seq(42, &mut buf);
1753        p_writer.write_all(&buf).unwrap();
1754        p_writer.flush().unwrap();
1755        buf.clear();
1756
1757        // Read ack.
1758        let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
1759        let ack = match decode_replica_message(&frame).unwrap() {
1760            ReplicaMessage::Ack(a) => a,
1761            _ => panic!("expected Ack"),
1762        };
1763        assert_eq!(ack.acked_sequence, 42);
1764
1765        // Join replica thread.
1766        let end_seq = replica_handle.join().unwrap();
1767        assert_eq!(end_seq, 42);
1768    }
1769
1770    #[test]
1771    fn multiple_data_batches_acked_in_order() {
1772        // Send multiple InputBatch frames, verify replica acks each one
1773        // and the cursor advances correctly.
1774        use std::os::unix::net::UnixStream;
1775
1776        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
1777
1778        let replica_handle = std::thread::spawn(move || {
1779            let mut reader = replica_stream.try_clone().unwrap();
1780            let mut writer = replica_stream;
1781            let mut buf = Vec::new();
1782
1783            // Send handshake.
1784            encode_handshake(
1785                &Handshake {
1786                    last_sequence: 0,
1787                    chain_hash: [0u8; 32],
1788                    epoch: 0,
1789                },
1790                &mut buf,
1791            );
1792            writer.write_all(&buf).unwrap();
1793            writer.flush().unwrap();
1794            buf.clear();
1795
1796            // Read StreamStart.
1797            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
1798            assert!(matches!(
1799                decode_primary_message(&frame).unwrap(),
1800                PrimaryMessage::StreamStart { .. }
1801            ));
1802
1803            // Read and ack 3 InputBatches.
1804            let mut acked_seqs = Vec::new();
1805            for _ in 0..3 {
1806                let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
1807                let slots: Vec<InputSlot> =
1808                    try_decode_input_batch(&frame).expect("decode InputBatch");
1809                let end_seq = slots
1810                    .last()
1811                    .map(|s| s.sequence)
1812                    .expect("InputBatch carried at least one slot");
1813                acked_seqs.push(end_seq);
1814
1815                encode_ack(
1816                    &Ack {
1817                        acked_sequence: end_seq,
1818                        in_memory_sequence: end_seq,
1819                    },
1820                    &mut buf,
1821                );
1822                writer.write_all(&buf).unwrap();
1823                writer.flush().unwrap();
1824                buf.clear();
1825            }
1826
1827            acked_seqs
1828        });
1829
1830        // Primary side.
1831        let mut p_reader = primary_stream.try_clone().unwrap();
1832        let mut p_writer = primary_stream;
1833        let mut buf = Vec::new();
1834
1835        // Read handshake.
1836        let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
1837        assert!(matches!(
1838            decode_replica_message(&frame).unwrap(),
1839            ReplicaMessage::Handshake(_)
1840        ));
1841
1842        // Send StreamStart.
1843        encode_stream_start(0, 1, [0u8; 32], 0, 1, &mut buf);
1844        p_writer.write_all(&buf).unwrap();
1845        p_writer.flush().unwrap();
1846        buf.clear();
1847
1848        // Send 3 InputBatches with increasing sequence numbers.
1849        for seq in [10u64, 20, 30] {
1850            encode_input_batch_with_seq(seq, &mut buf);
1851            p_writer.write_all(&buf).unwrap();
1852            p_writer.flush().unwrap();
1853            buf.clear();
1854        }
1855
1856        // Read 3 acks.
1857        for expected_seq in [10u64, 20, 30] {
1858            let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
1859            let ack = match decode_replica_message(&frame).unwrap() {
1860                ReplicaMessage::Ack(a) => a,
1861                other => panic!("expected Ack, got {other:?}"),
1862            };
1863            assert_eq!(ack.acked_sequence, expected_seq);
1864        }
1865
1866        let acked = replica_handle.join().unwrap();
1867        assert_eq!(acked, vec![10, 20, 30]);
1868    }
1869
1870    #[test]
1871    fn heartbeat_encode_contains_sequence() {
1872        // Heartbeat messages carry the last known sequence so the replica
1873        // can verify it hasn't missed any data.
1874        let mut buf = Vec::new();
1875        encode_heartbeat(999, 1, &mut buf);
1876
1877        let payload = &buf[4..];
1878        match decode_primary_message(payload).unwrap() {
1879            PrimaryMessage::Heartbeat { sequence, .. } => {
1880                assert_eq!(sequence, 999);
1881            }
1882            other => panic!("expected Heartbeat, got {other:?}"),
1883        }
1884    }
1885
1886    #[test]
1887    fn replica_mid_stream_handshake_with_nonzero_sequence() {
1888        // A replica that already has some data sends a non-zero last_sequence
1889        // in its handshake. The primary should respond with StreamStart
1890        // containing that sequence, and the replica should only receive
1891        // events after that point.
1892        use std::os::unix::net::UnixStream;
1893
1894        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
1895
1896        let replica_handle = std::thread::spawn(move || {
1897            let mut reader = replica_stream.try_clone().unwrap();
1898            let mut writer = replica_stream;
1899            let mut buf = Vec::new();
1900
1901            // Replica already has events up to sequence 100.
1902            encode_handshake(
1903                &Handshake {
1904                    last_sequence: 100,
1905                    chain_hash: [0xBB; 32],
1906                    epoch: 0,
1907                },
1908                &mut buf,
1909            );
1910            writer.write_all(&buf).unwrap();
1911            writer.flush().unwrap();
1912            buf.clear();
1913
1914            // Read StreamStart — should echo back our last_sequence.
1915            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
1916            match decode_primary_message(&frame).unwrap() {
1917                PrimaryMessage::StreamStart { start_sequence, .. } => {
1918                    assert_eq!(
1919                        start_sequence, 100,
1920                        "StreamStart should echo replica's last_sequence"
1921                    );
1922                }
1923                other => panic!("expected StreamStart, got {other:?}"),
1924            }
1925
1926            // Read an InputBatch — should be for events AFTER 100.
1927            let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
1928            let slots: Vec<InputSlot> = try_decode_input_batch(&frame).expect("decode InputBatch");
1929            let end_sequence = slots
1930                .last()
1931                .map(|s| s.sequence)
1932                .expect("InputBatch carried at least one slot");
1933            assert!(
1934                end_sequence > 100,
1935                "InputBatch should be after replica's last_sequence"
1936            );
1937        });
1938
1939        // Primary side.
1940        let mut p_reader = primary_stream.try_clone().unwrap();
1941        let mut p_writer = primary_stream;
1942        let mut buf = Vec::new();
1943
1944        // Read handshake.
1945        let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
1946        let handshake = match decode_replica_message(&frame).unwrap() {
1947            ReplicaMessage::Handshake(h) => h,
1948            _ => panic!("expected Handshake"),
1949        };
1950        assert_eq!(handshake.last_sequence, 100);
1951        assert_eq!(handshake.chain_hash, [0xBB; 32]);
1952
1953        // Send StreamStart echoing the replica's sequence.
1954        encode_stream_start(handshake.last_sequence, 1, [0u8; 32], 0, 1, &mut buf);
1955        p_writer.write_all(&buf).unwrap();
1956        p_writer.flush().unwrap();
1957        buf.clear();
1958
1959        // Send InputBatch with sequence 150 (after replica's 100).
1960        encode_input_batch_with_seq(150, &mut buf);
1961        p_writer.write_all(&buf).unwrap();
1962        p_writer.flush().unwrap();
1963
1964        replica_handle.join().unwrap();
1965    }
1966
1967    /// The receiver's stale-primary refusal: a replica that has observed
1968    /// epoch 5 must refuse a `StreamStart` advertising a *lower* epoch
1969    /// rather than follow that (divergent) lineage on top of its newer
1970    /// state. This reproduces the exact decision `run_receiver` makes on
1971    /// the normal-resume `StreamStart` — decode the frame off the wire,
1972    /// then `fence_state.refuses_primary(epoch)` — over a real socket so
1973    /// the epoch wire field and the policy are exercised together.
1974    ///
1975    /// In a current-build cluster this branch is a *second* line of
1976    /// defense: a primary reads the replica's higher-epoch handshake and
1977    /// self-demotes before it ever sends a `StreamStart` (see
1978    /// `tcp_sender::handle_replica_connection`). The receiver check guards
1979    /// the case where the primary does *not* fence — a non-fencing or
1980    /// older build — which is exactly what the mock primary below is.
1981    #[test]
1982    fn receiver_refuses_stream_start_from_stale_primary() {
1983        use std::os::unix::net::UnixStream;
1984
1985        use melin_transport_core::fence::FenceState;
1986
1987        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
1988
1989        // Mock primary that — unlike a current build — does *not* fence on
1990        // the handshake and streams its stale lineage anyway. It advertises
1991        // epoch 3 on the StreamStart.
1992        const STALE_EPOCH: u64 = 3;
1993        let primary_handle = std::thread::spawn(move || {
1994            let mut reader = primary_stream.try_clone().unwrap();
1995            let mut writer = primary_stream;
1996
1997            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
1998            let handshake = match decode_replica_message(&frame).unwrap() {
1999                ReplicaMessage::Handshake(h) => h,
2000                _ => panic!("expected Handshake"),
2001            };
2002            // The replica truthfully advertises its higher epoch; a correct
2003            // primary would fence here. This mock deliberately doesn't.
2004            assert_eq!(handshake.epoch, 5, "replica advertises its real epoch");
2005
2006            let mut buf = Vec::new();
2007            encode_stream_start(
2008                handshake.last_sequence,
2009                1,
2010                [0u8; 32],
2011                STALE_EPOCH,
2012                1,
2013                &mut buf,
2014            );
2015            writer.write_all(&buf).unwrap();
2016            writer.flush().unwrap();
2017        });
2018
2019        // Replica side: epoch 5, mirroring `run_receiver`'s handshake +
2020        // StreamStart handling.
2021        let fence_state = Arc::new(FenceState::new(5));
2022        let mut r_reader = replica_stream.try_clone().unwrap();
2023        let mut r_writer = replica_stream;
2024
2025        let mut buf = Vec::new();
2026        encode_handshake(
2027            &Handshake {
2028                last_sequence: 0,
2029                chain_hash: [0u8; 32],
2030                epoch: fence_state.epoch(),
2031            },
2032            &mut buf,
2033        );
2034        r_writer.write_all(&buf).unwrap();
2035        r_writer.flush().unwrap();
2036
2037        let frame = read_frame(&mut r_reader, MAX_CONTROL_FRAME).unwrap();
2038        let stream_epoch = match decode_primary_message(&frame).unwrap() {
2039            PrimaryMessage::StreamStart { epoch, .. } => epoch,
2040            other => panic!("expected StreamStart, got {other:?}"),
2041        };
2042        assert_eq!(stream_epoch, STALE_EPOCH, "epoch must survive the wire");
2043
2044        // This is the receiver's refusal decision. A stale primary must be
2045        // refused; the replica's own epoch must not have been lowered.
2046        assert!(
2047            fence_state.refuses_primary(stream_epoch),
2048            "replica at epoch 5 must refuse a StreamStart from epoch {STALE_EPOCH}"
2049        );
2050        assert_eq!(fence_state.epoch(), 5, "refusal must not lower our epoch");
2051
2052        // Sanity: an equal or newer epoch is followed, not refused (the
2053        // ex-primary-rejoin path). Guards against an inverted comparison.
2054        assert!(!fence_state.refuses_primary(5), "same tenure is followed");
2055        assert!(!fence_state.refuses_primary(6), "newer primary is followed");
2056
2057        primary_handle.join().unwrap();
2058    }
2059
2060    #[test]
2061    fn snapshot_begin_encode_decode_round_trip() {
2062        let mut buf = Vec::new();
2063        encode_snapshot_begin(1_000_000, 42, &[0xAB; 32], &mut buf);
2064
2065        let payload = &buf[4..];
2066        let msg = decode_primary_message(payload).unwrap();
2067        match msg {
2068            PrimaryMessage::SnapshotBegin {
2069                snapshot_len,
2070                snap_sequence,
2071                snap_chain_hash,
2072            } => {
2073                assert_eq!(snapshot_len, 1_000_000);
2074                assert_eq!(snap_sequence, 42);
2075                assert_eq!(snap_chain_hash, [0xAB; 32]);
2076            }
2077            _ => panic!("expected SnapshotBegin"),
2078        }
2079    }
2080
2081    #[test]
2082    fn snapshot_chunk_encode_decode_round_trip() {
2083        let data = vec![1, 2, 3, 4, 5, 6, 7, 8];
2084        let mut buf = Vec::new();
2085        encode_snapshot_chunk(&data, &mut buf);
2086
2087        let payload = &buf[4..];
2088        let msg = decode_primary_message(payload).unwrap();
2089        match msg {
2090            PrimaryMessage::SnapshotChunk(chunk) => {
2091                assert_eq!(chunk, data);
2092            }
2093            _ => panic!("expected SnapshotChunk"),
2094        }
2095    }
2096
2097    #[test]
2098    fn snapshot_end_encode_decode_round_trip() {
2099        let mut buf = Vec::new();
2100        encode_snapshot_end(0xDEADBEEF, &mut buf);
2101
2102        let payload = &buf[4..];
2103        let msg = decode_primary_message(payload).unwrap();
2104        match msg {
2105            PrimaryMessage::SnapshotEnd { crc32c } => {
2106                assert_eq!(crc32c, 0xDEADBEEF);
2107            }
2108            _ => panic!("expected SnapshotEnd"),
2109        }
2110    }
2111
2112    /// Simulate the receiver side of a snapshot transfer where the
2113    /// advertised snap_len doesn't match the actual bytes sent.
2114    /// The receiver must detect this and return an error.
2115    #[test]
2116    fn snapshot_receiver_detects_length_mismatch() {
2117        use std::os::unix::net::UnixStream;
2118
2119        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2120
2121        // Receiver thread — reads NeedSnapshot, then the snapshot transfer.
2122        let receiver = std::thread::spawn(move || -> String {
2123            let mut reader = replica_stream.try_clone().unwrap();
2124
2125            // Read NeedSnapshot.
2126            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2127            assert!(matches!(
2128                decode_primary_message(&frame).unwrap(),
2129                PrimaryMessage::NeedSnapshot,
2130            ));
2131
2132            // Read SnapshotBegin.
2133            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2134            let (snap_len, _snap_sequence, _snap_chain_hash) =
2135                match decode_primary_message(&frame).unwrap() {
2136                    PrimaryMessage::SnapshotBegin {
2137                        snapshot_len,
2138                        snap_sequence,
2139                        snap_chain_hash,
2140                    } => (snapshot_len, snap_sequence, snap_chain_hash),
2141                    other => panic!("expected SnapshotBegin, got {other:?}"),
2142                };
2143
2144            // Receive chunks and check length at SnapshotEnd.
2145            let mut received: u64 = 0;
2146            loop {
2147                let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2148                match decode_primary_message(&frame).unwrap() {
2149                    PrimaryMessage::SnapshotChunk(data) => {
2150                        received += data.len() as u64;
2151                    }
2152                    PrimaryMessage::SnapshotEnd { .. } => {
2153                        if received != snap_len {
2154                            return format!(
2155                                "snapshot length mismatch: expected {snap_len} bytes, got {received}"
2156                            );
2157                        }
2158                        return String::new(); // no error
2159                    }
2160                    other => panic!("unexpected message: {other:?}"),
2161                }
2162            }
2163        });
2164
2165        // Primary side — send snapshot with wrong advertised length.
2166        let mut writer = primary_stream;
2167        let mut buf = Vec::new();
2168
2169        let actual_data = vec![0xAA; 100];
2170        let wrong_len = 999u64; // advertise 999 bytes, send only 100
2171
2172        encode_need_snapshot(&mut buf);
2173        std::io::Write::write_all(&mut writer, &buf).unwrap();
2174        buf.clear();
2175
2176        encode_snapshot_begin(wrong_len, 42, &[0xBB; 32], &mut buf);
2177        std::io::Write::write_all(&mut writer, &buf).unwrap();
2178        buf.clear();
2179
2180        encode_snapshot_chunk(&actual_data, &mut buf);
2181        std::io::Write::write_all(&mut writer, &buf).unwrap();
2182        buf.clear();
2183
2184        let crc = crc32c::crc32c(&actual_data);
2185        encode_snapshot_end(crc, &mut buf);
2186        std::io::Write::write_all(&mut writer, &buf).unwrap();
2187        std::io::Write::flush(&mut writer).unwrap();
2188
2189        let error_msg = receiver.join().unwrap();
2190        assert!(
2191            error_msg.contains("length mismatch"),
2192            "expected length mismatch error, got: {error_msg:?}"
2193        );
2194    }
2195
2196    /// Simulate the receiver side of a snapshot transfer where the CRC
2197    /// in SnapshotEnd doesn't match the actual data. The receiver must
2198    /// detect and reject the transfer.
2199    #[test]
2200    fn snapshot_receiver_detects_crc_mismatch() {
2201        use std::os::unix::net::UnixStream;
2202
2203        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2204
2205        let receiver = std::thread::spawn(move || -> String {
2206            let mut reader = replica_stream.try_clone().unwrap();
2207
2208            // Read NeedSnapshot.
2209            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2210            assert!(matches!(
2211                decode_primary_message(&frame).unwrap(),
2212                PrimaryMessage::NeedSnapshot,
2213            ));
2214
2215            // Read SnapshotBegin.
2216            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2217            let snap_len = match decode_primary_message(&frame).unwrap() {
2218                PrimaryMessage::SnapshotBegin { snapshot_len, .. } => snapshot_len,
2219                other => panic!("expected SnapshotBegin, got {other:?}"),
2220            };
2221
2222            // Receive chunks, verify CRC at SnapshotEnd.
2223            let mut received_data = Vec::new();
2224            let mut received: u64 = 0;
2225            loop {
2226                let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2227                match decode_primary_message(&frame).unwrap() {
2228                    PrimaryMessage::SnapshotChunk(data) => {
2229                        received += data.len() as u64;
2230                        received_data.extend_from_slice(&data);
2231                    }
2232                    PrimaryMessage::SnapshotEnd {
2233                        crc32c: expected_crc,
2234                    } => {
2235                        if received != snap_len {
2236                            return format!("length mismatch: {snap_len} vs {received}");
2237                        }
2238                        let actual_crc = crc32c::crc32c(&received_data);
2239                        if actual_crc != expected_crc {
2240                            return format!(
2241                                "CRC mismatch: expected {expected_crc:#x}, got {actual_crc:#x}"
2242                            );
2243                        }
2244                        return String::new();
2245                    }
2246                    other => panic!("unexpected message: {other:?}"),
2247                }
2248            }
2249        });
2250
2251        // Primary side — send correct length but wrong CRC.
2252        let mut writer = primary_stream;
2253        let mut buf = Vec::new();
2254
2255        let data = vec![0xAA; 100];
2256
2257        encode_need_snapshot(&mut buf);
2258        std::io::Write::write_all(&mut writer, &buf).unwrap();
2259        buf.clear();
2260
2261        encode_snapshot_begin(data.len() as u64, 42, &[0xBB; 32], &mut buf);
2262        std::io::Write::write_all(&mut writer, &buf).unwrap();
2263        buf.clear();
2264
2265        encode_snapshot_chunk(&data, &mut buf);
2266        std::io::Write::write_all(&mut writer, &buf).unwrap();
2267        buf.clear();
2268
2269        // Send a wrong CRC (flip bits).
2270        let wrong_crc = !crc32c::crc32c(&data);
2271        encode_snapshot_end(wrong_crc, &mut buf);
2272        std::io::Write::write_all(&mut writer, &buf).unwrap();
2273        std::io::Write::flush(&mut writer).unwrap();
2274
2275        let error_msg = receiver.join().unwrap();
2276        assert!(
2277            error_msg.contains("CRC mismatch"),
2278            "expected CRC mismatch error, got: {error_msg:?}"
2279        );
2280    }
2281
2282    /// The receiver verifies the chain hash from the loaded snapshot
2283    /// matches the one advertised in SnapshotBegin. Simulate a mismatch.
2284    #[test]
2285    fn snapshot_receiver_detects_chain_hash_mismatch() {
2286        use std::os::unix::net::UnixStream;
2287
2288        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2289
2290        let receiver = std::thread::spawn(move || -> String {
2291            let mut reader = replica_stream.try_clone().unwrap();
2292
2293            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2294            assert!(matches!(
2295                decode_primary_message(&frame).unwrap(),
2296                PrimaryMessage::NeedSnapshot,
2297            ));
2298
2299            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2300            let (snap_len, _snap_sequence, snap_chain_hash) =
2301                match decode_primary_message(&frame).unwrap() {
2302                    PrimaryMessage::SnapshotBegin {
2303                        snapshot_len,
2304                        snap_sequence,
2305                        snap_chain_hash,
2306                    } => (snapshot_len, snap_sequence, snap_chain_hash),
2307                    other => panic!("expected SnapshotBegin, got {other:?}"),
2308                };
2309
2310            // Receive the snapshot data.
2311            let mut received_data = Vec::new();
2312            let mut received: u64 = 0;
2313            loop {
2314                let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2315                match decode_primary_message(&frame).unwrap() {
2316                    PrimaryMessage::SnapshotChunk(data) => {
2317                        received += data.len() as u64;
2318                        received_data.extend_from_slice(&data);
2319                    }
2320                    PrimaryMessage::SnapshotEnd {
2321                        crc32c: expected_crc,
2322                    } => {
2323                        assert_eq!(received, snap_len, "length should match");
2324                        let actual_crc = crc32c::crc32c(&received_data);
2325                        assert_eq!(actual_crc, expected_crc, "CRC should match");
2326                        break;
2327                    }
2328                    other => panic!("unexpected message: {other:?}"),
2329                }
2330            }
2331
2332            // Simulate chain hash verification: the loaded snapshot would
2333            // have a different chain hash than what SnapshotBegin advertised.
2334            let loaded_hash = [0xFF; 32]; // different from snap_chain_hash
2335            if loaded_hash != snap_chain_hash {
2336                return format!(
2337                    "snapshot chain hash mismatch: primary sent {snap_chain_hash:02x?}, \
2338                     loaded snapshot has {loaded_hash:02x?}"
2339                );
2340            }
2341            String::new()
2342        });
2343
2344        // Primary side — send valid snapshot but with a chain hash in
2345        // SnapshotBegin that won't match what the replica "loads".
2346        let mut writer = primary_stream;
2347        let mut buf = Vec::new();
2348
2349        let data = vec![0xAA; 64];
2350        // Advertise chain hash [0xBB; 32] — receiver will "load" [0xFF; 32].
2351        let advertised_hash = [0xBB; 32];
2352
2353        encode_need_snapshot(&mut buf);
2354        std::io::Write::write_all(&mut writer, &buf).unwrap();
2355        buf.clear();
2356
2357        encode_snapshot_begin(data.len() as u64, 10, &advertised_hash, &mut buf);
2358        std::io::Write::write_all(&mut writer, &buf).unwrap();
2359        buf.clear();
2360
2361        encode_snapshot_chunk(&data, &mut buf);
2362        std::io::Write::write_all(&mut writer, &buf).unwrap();
2363        buf.clear();
2364
2365        let crc = crc32c::crc32c(&data);
2366        encode_snapshot_end(crc, &mut buf);
2367        std::io::Write::write_all(&mut writer, &buf).unwrap();
2368        std::io::Write::flush(&mut writer).unwrap();
2369
2370        let error_msg = receiver.join().unwrap();
2371        assert!(
2372            error_msg.contains("chain hash mismatch"),
2373            "expected chain hash mismatch error, got: {error_msg:?}"
2374        );
2375    }
2376
2377    /// Primary-side magic validation: a file without the SNAP magic
2378    /// (0x534E4150) must be rejected before transfer.
2379    #[test]
2380    fn primary_rejects_snapshot_with_invalid_magic() {
2381        let tmp = tempfile::tempdir().unwrap();
2382        let snap_path = tmp.path().join("test.snapshot");
2383
2384        // Write a file with wrong magic but enough bytes for a header.
2385        let mut bad_snap = vec![0u8; 64];
2386        bad_snap[0..4].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); // wrong magic
2387
2388        std::fs::write(&snap_path, &bad_snap).unwrap();
2389
2390        // Replicate the primary's validation logic.
2391        let snap_data = std::fs::read(&snap_path).unwrap();
2392        assert!(
2393            snap_data.len() >= 48,
2394            "file should be big enough for header"
2395        );
2396
2397        let magic = u32::from_le_bytes(snap_data[0..4].try_into().unwrap());
2398        assert_ne!(magic, 0x534E_4150);
2399        assert_eq!(magic, 0xDEAD_BEEF);
2400    }
2401
2402    /// Primary-side: a snapshot file smaller than the 48-byte header
2403    /// must be rejected.
2404    #[test]
2405    fn primary_rejects_snapshot_too_small_for_header() {
2406        let tmp = tempfile::tempdir().unwrap();
2407        let snap_path = tmp.path().join("test.snapshot");
2408
2409        // Write a file smaller than the 48-byte header.
2410        std::fs::write(&snap_path, [0u8; 20]).unwrap();
2411
2412        let snap_data = std::fs::read(&snap_path).unwrap();
2413        assert!(
2414            snap_data.len() < 48,
2415            "file must be too small for header validation"
2416        );
2417    }
2418
2419    #[test]
2420    fn decode_snapshot_begin_too_short() {
2421        // SnapshotBegin needs type(1) + snapshot_len(8) + snap_sequence(8) + chain_hash(32) = 49.
2422        // Send only the type byte + a few extra bytes.
2423        let payload = [MSG_SNAPSHOT_BEGIN, 0x01, 0x02, 0x03];
2424        let err = decode_primary_message(&payload).unwrap_err();
2425        assert!(
2426            err.to_string().contains("SnapshotBegin too short"),
2427            "expected 'SnapshotBegin too short', got: {err}"
2428        );
2429    }
2430
2431    #[test]
2432    fn decode_snapshot_end_too_short() {
2433        // SnapshotEnd needs type(1) + crc32c(4) = 5. Send only the type byte.
2434        let payload = [MSG_SNAPSHOT_END];
2435        let err = decode_primary_message(&payload).unwrap_err();
2436        assert!(
2437            err.to_string().contains("SnapshotEnd too short"),
2438            "expected 'SnapshotEnd too short', got: {err}"
2439        );
2440    }
2441
2442    #[test]
2443    fn decode_snapshot_chunk_empty_data() {
2444        // SnapshotChunk with just the type byte — valid but empty payload.
2445        let payload = [MSG_SNAPSHOT_CHUNK];
2446        let msg = decode_primary_message(&payload).unwrap();
2447        match msg {
2448            PrimaryMessage::SnapshotChunk(data) => {
2449                assert!(data.is_empty());
2450            }
2451            _ => panic!("expected SnapshotChunk"),
2452        }
2453    }
2454
2455    // --- PendingAckQueue tests ---
2456
2457    fn make_journal_cursor(val: u64) -> melin_pipeline::padding::Sequence {
2458        melin_pipeline::padding::Sequence::new(AtomicU64::new(val))
2459    }
2460
2461    #[test]
2462    fn pending_ack_queue_push_and_pop_ready() {
2463        let mut q = PendingAckQueue::new(8);
2464        assert!(q.is_empty());
2465        assert!(!q.is_full());
2466
2467        q.push(10, 100);
2468        q.push(20, 200);
2469        assert!(!q.is_empty());
2470
2471        // Cursor at 5 — neither ready.
2472        let cursor = make_journal_cursor(5);
2473        assert!(q.pop_ready(&cursor).is_none());
2474
2475        // Cursor at 15 — first ready, second not.
2476        cursor.get().store(15, Ordering::Relaxed);
2477        assert_eq!(q.pop_ready(&cursor), Some(100));
2478        // Only one popped — second still pending.
2479        assert!(!q.is_empty());
2480
2481        // Cursor at 25 — second now ready.
2482        cursor.get().store(25, Ordering::Relaxed);
2483        assert_eq!(q.pop_ready(&cursor), Some(200));
2484        assert!(q.is_empty());
2485    }
2486
2487    #[test]
2488    fn pending_ack_queue_pop_ready_returns_highest_sequence() {
2489        // When multiple acks become ready simultaneously, pop_ready
2490        // returns the highest acked_sequence (ack semantics are
2491        // cumulative — "everything up to this sequence is durable").
2492        let mut q = PendingAckQueue::new(8);
2493        q.push(10, 100);
2494        q.push(20, 200);
2495        q.push(30, 300);
2496
2497        let cursor = make_journal_cursor(30);
2498        assert_eq!(q.pop_ready(&cursor), Some(300));
2499        assert!(q.is_empty());
2500    }
2501
2502    #[test]
2503    fn pending_ack_queue_capacity_and_full() {
2504        let mut q = PendingAckQueue::new(8);
2505        for i in 0..8 {
2506            assert!(!q.is_full());
2507            q.push(i as u64 + 1, (i + 1) as u64 * 100);
2508        }
2509        assert!(q.is_full());
2510    }
2511
2512    /// A full queue must absorb the push by merging it into the newest
2513    /// entry — never block, never drop, and never release a sequence
2514    /// before its own journal target. The merge is a *coarsening*: the
2515    /// entry it absorbed is acked later than it would have been.
2516    #[test]
2517    fn pending_ack_queue_merges_into_the_newest_entry_when_full() {
2518        let mut q = PendingAckQueue::new(4);
2519        for i in 1..=4u64 {
2520            q.push(i * 10, i * 100);
2521        }
2522        assert!(q.is_full());
2523        assert_eq!(q.merged(), 0);
2524
2525        // Fifth batch: target 50, sequence 500. Merges into (40, 400).
2526        q.push(50, 500);
2527        assert_eq!(q.merged(), 1);
2528        assert_eq!(q.len(), 4, "merge must not grow the queue");
2529
2530        // Cursor at 40 would have released 400 before the merge; now the
2531        // newest entry needs 50, so 300 is the highest safe ack.
2532        let cursor = make_journal_cursor(40);
2533        assert_eq!(q.pop_ready(&cursor), Some(300));
2534        assert!(!q.is_empty(), "merged entry is not durable yet");
2535
2536        // Cursor reaches the merged target — the merged entry releases
2537        // the newer sequence, subsuming the one it absorbed.
2538        cursor.get().store(50, Ordering::Relaxed);
2539        assert_eq!(q.pop_ready(&cursor), Some(500));
2540        assert!(q.is_empty());
2541    }
2542
2543    /// Repeated merges keep collapsing onto the same slot, so a journal
2544    /// stall of any length costs granularity and nothing else.
2545    #[test]
2546    fn pending_ack_queue_merges_repeatedly_without_growing() {
2547        let mut q = PendingAckQueue::new(2);
2548        for i in 1..=100u64 {
2549            q.push(i, i * 10);
2550        }
2551        assert_eq!(q.len(), 2);
2552        assert_eq!(q.merged(), 98);
2553
2554        // Nothing releases before its target...
2555        let cursor = make_journal_cursor(99);
2556        assert_eq!(q.pop_ready(&cursor), Some(10), "only the first entry");
2557        // ...and the merged tail carries the newest pair.
2558        cursor.get().store(100, Ordering::Relaxed);
2559        assert_eq!(q.pop_ready(&cursor), Some(1000));
2560    }
2561
2562    #[test]
2563    fn pending_ack_queue_pop_oldest_blocking() {
2564        let mut q = PendingAckQueue::new(8);
2565        q.push(10, 100);
2566        q.push(20, 200);
2567
2568        // Cursor already past both targets — pop_oldest_blocking
2569        // returns immediately.
2570        let cursor = make_journal_cursor(25);
2571        let seq = q.pop_oldest_blocking(&cursor, true, &AtomicBool::new(false));
2572        // Should pop both (oldest + any others that became ready).
2573        assert_eq!(seq, Some(200));
2574        assert!(q.is_empty());
2575    }
2576
2577    /// A dead journal stage (abort latch set) must abort the blocking
2578    /// wait instead of spinning forever on the frozen cursor.
2579    #[test]
2580    fn pending_ack_queue_blocking_wait_aborts_on_journal_failure() {
2581        let mut q = PendingAckQueue::new(8);
2582        q.push(10, 100);
2583
2584        // Cursor frozen BELOW the target; abort pre-latched.
2585        let cursor = make_journal_cursor(5);
2586        let abort = AtomicBool::new(true);
2587        assert_eq!(q.pop_oldest_blocking(&cursor, true, &abort), None);
2588        assert!(!q.is_empty(), "aborted wait must not pop the entry");
2589        assert_eq!(q.pop_all_blocking(&cursor, true, &abort), None);
2590    }
2591
2592    /// `shutdown_pipeline` must surface the journal stage's error kind —
2593    /// the orchestrator routes `ReplicaChainDivergence` into in-process
2594    /// resync and everything else into process exit, so collapsing the
2595    /// error into a None (the old behavior) breaks that dispatch. It
2596    /// must also join EVERY thread in the failure arms: the resync path
2597    /// archives journal + snapshot right after teardown, and a
2598    /// still-running shadow thread mid-snapshot-write would race the
2599    /// renames.
2600    #[test]
2601    fn shutdown_pipeline_surfaces_journal_error_kind() {
2602        let flag = AtomicBool::new(false);
2603        let matching_joined = Arc::new(AtomicBool::new(false));
2604        let mj = Arc::clone(&matching_joined);
2605
2606        let journal = std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> {
2607            Err(melin_journal::JournalError::ReplicaChainDivergence {
2608                sequence: 42,
2609                expected: [1u8; 32],
2610                actual: [2u8; 32],
2611            })
2612        });
2613        let matching = std::thread::spawn(move || {
2614            mj.store(true, Ordering::Release);
2615            7u64
2616        });
2617        let drain = std::thread::spawn(|| {});
2618
2619        let outcome = shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None);
2620        assert!(flag.load(Ordering::Acquire), "shutdown flag must be set");
2621        assert!(
2622            matching_joined.load(Ordering::Acquire),
2623            "matching thread must be joined even when the journal failed"
2624        );
2625        match outcome {
2626            TeardownOutcome::JournalFailed(
2627                melin_journal::JournalError::ReplicaChainDivergence { sequence, .. },
2628            ) => assert_eq!(sequence, 42),
2629            _ => panic!("expected JournalFailed(ReplicaChainDivergence)"),
2630        }
2631    }
2632
2633    #[test]
2634    fn shutdown_pipeline_clean_returns_both_states() {
2635        let flag = AtomicBool::new(false);
2636        let journal =
2637            std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> { Ok(11u32) });
2638        let matching = std::thread::spawn(|| 7u64);
2639        let drain = std::thread::spawn(|| {});
2640
2641        match shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None) {
2642            TeardownOutcome::Clean(app, writer) => {
2643                assert_eq!(app, 7);
2644                assert_eq!(writer, 11);
2645            }
2646            _ => panic!("expected Clean"),
2647        }
2648    }
2649
2650    #[test]
2651    fn shutdown_pipeline_panicked_stage_reports_panicked() {
2652        let flag = AtomicBool::new(false);
2653        let journal =
2654            std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> { Ok(11u32) });
2655        let matching = std::thread::spawn(|| -> u64 { panic!("matching stage died") });
2656        let drain = std::thread::spawn(|| {});
2657
2658        assert!(matches!(
2659            shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None),
2660            TeardownOutcome::Panicked
2661        ));
2662    }
2663
2664    /// `ReplicaPipelineHandles` over a real input ring (one gate
2665    /// consumer) with immediately-returning stage threads — just enough
2666    /// structure for `teardown_replica_pipeline` to run for real. The
2667    /// returned consumer observes what teardown published.
2668    fn teardown_fixture(
2669        capacity: usize,
2670    ) -> (
2671        ReplicaPipelineHandles<counter_server::Counter, u32>,
2672        melin_pipeline::ring::Consumer<InputSlot>,
2673    ) {
2674        use melin_app::app_factory::AppFactory;
2675        let (input_producer, mut consumers) =
2676            melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(capacity)
2677                .add_consumer()
2678                .build();
2679        let consumer = consumers.pop().expect("one consumer");
2680        let handles = ReplicaPipelineHandles {
2681            input_producer,
2682            journal_cursor: Arc::new(make_journal_cursor(0)),
2683            last_seq: melin_transport_core::DurableWireSeqCursor::detached(
2684                melin_transport_core::WireSeq::new(0),
2685            ),
2686            chain_hash_lock: None,
2687            stream_marks: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
2688            journal_failed: Arc::new(AtomicBool::new(false)),
2689            pipeline_shutdown: Arc::new(AtomicBool::new(false)),
2690            journal_handle: std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> {
2691                Ok(11)
2692            }),
2693            matching_handle: std::thread::spawn(|| counter_server::CounterFactory.empty()),
2694            drain_handle: std::thread::spawn(|| {}),
2695            shadow_handle: None,
2696        };
2697        (handles, consumer)
2698    }
2699
2700    /// The invariant behind moving the sentinel publish into
2701    /// `teardown_replica_pipeline`: a healthy teardown always puts the
2702    /// shutdown sentinel on the ring, so the stages exit by consuming it
2703    /// through the normal event path (draining anything ahead of it),
2704    /// not via the flag's emergency-abort branch.
2705    #[test]
2706    fn teardown_publishes_shutdown_sentinel() {
2707        let (handles, mut consumer) = teardown_fixture(8);
2708        assert!(matches!(
2709            teardown_replica_pipeline::<counter_server::Counter, u32>(handles),
2710            TeardownOutcome::Clean(..)
2711        ));
2712        let (_, slot) = consumer.try_consume().expect("sentinel on the ring");
2713        assert!(
2714            slot.event.is_shutdown(),
2715            "published slot must be the sentinel"
2716        );
2717        assert!(consumer.try_consume().is_none(), "exactly one sentinel");
2718    }
2719
2720    /// Drive `handle_session_exit` through one `Disconnected` exit with
2721    /// the given liveness flag and a backoff pre-escalated to
2722    /// `MAX_BACKOFF`, returning the post-exit backoff. Shutdown is
2723    /// latched so the backoff sleep returns immediately (the receiver's
2724    /// loop top would handle it on the next turn); the journal, factory
2725    /// and fence arguments are inert on the `Disconnected` path.
2726    fn backoff_after_disconnect(heard_from_primary: bool) -> std::time::Duration {
2727        let dir = tempfile::tempdir().expect("tempdir");
2728        type Writer = melin_journal::BufferedWriter<CounterEvent>;
2729        let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> = None;
2730        let mut divergence_resyncs = 0u32;
2731        let mut backoff = MAX_BACKOFF;
2732        let shutdown = AtomicBool::new(true);
2733        let promote = crate::promotion::PromotionRequest::new();
2734        let after = handle_session_exit::<counter_server::Counter, Writer>(
2735            StreamingResult {
2736                exit: SessionExit::Disconnected,
2737                heard_from_primary,
2738            },
2739            &mut pipeline,
2740            &mut divergence_resyncs,
2741            &mut backoff,
2742            0,
2743            &dir.path().join("r.journal"),
2744            &dir.path().join("r.snapshot"),
2745            &counter_server::CounterFactory,
2746            &melin_transport_core::fence::FenceState::new(0),
2747            &shutdown,
2748            &promote,
2749            || {},
2750        );
2751        assert!(matches!(after, AfterSession::Reconnect));
2752        backoff
2753    }
2754
2755    /// A session in which the primary spoke — even heartbeat-only on a
2756    /// quiet system — must reset the disconnect backoff. A stale
2757    /// escalated backoff delays primary-link recovery, and with it the
2758    /// auto-promotion veto, by up to MAX_BACKOFF.
2759    #[test]
2760    fn disconnect_resets_backoff_when_primary_spoke() {
2761        // Reset to 1s, then doubled for the next attempt by the shared
2762        // helper — NOT stuck at MAX_BACKOFF.
2763        assert_eq!(
2764            backoff_after_disconnect(true),
2765            std::time::Duration::from_secs(2)
2766        );
2767    }
2768
2769    /// A session with no word from the primary — an instant-drop flap,
2770    /// or the synthetic result of a session that never started
2771    /// streaming (local transport init failure) — must keep its
2772    /// escalated backoff rather than redialing at 1 Hz forever.
2773    #[test]
2774    fn disconnect_keeps_escalated_backoff_when_primary_silent() {
2775        assert_eq!(backoff_after_disconnect(false), MAX_BACKOFF);
2776    }
2777
2778    /// A stream contiguity break is a reconnect, never a process exit:
2779    /// the contiguous prefix is committed and the journal is intact, so
2780    /// the receiver re-handshakes from its durable position and the
2781    /// primary re-streams the hole. It used to fall through the `Fatal`
2782    /// arm and take the replica down for a documented-benign primary
2783    /// handoff corner. The pipeline must be left standing (it is what
2784    /// the reconnect resumes into) and the backoff reset — the primary
2785    /// was evidently speaking.
2786    #[test]
2787    fn stream_gap_reconnects_and_keeps_pipeline() {
2788        use melin_app::app_factory::AppFactory;
2789        let dir = tempfile::tempdir().expect("tempdir");
2790        type Writer = melin_journal::BufferedWriter<CounterEvent>;
2791        // Same shape as `teardown_fixture`, but with a real writer type:
2792        // `handle_session_exit` is bounded on `JournalWrite` for the
2793        // resync arm even though this path never touches it.
2794        let (input_producer, mut consumers) =
2795            melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
2796                .add_consumer()
2797                .build();
2798        let _consumer = consumers.pop().expect("one consumer");
2799        let writer_path = dir.path().join("w.journal");
2800        let handles = ReplicaPipelineHandles {
2801            input_producer,
2802            journal_cursor: Arc::new(make_journal_cursor(0)),
2803            last_seq: melin_transport_core::DurableWireSeqCursor::detached(
2804                melin_transport_core::WireSeq::new(0),
2805            ),
2806            chain_hash_lock: None,
2807            stream_marks: Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
2808            journal_failed: Arc::new(AtomicBool::new(false)),
2809            pipeline_shutdown: Arc::new(AtomicBool::new(false)),
2810            journal_handle: std::thread::spawn(move || Writer::create(&writer_path)),
2811            matching_handle: std::thread::spawn(|| counter_server::CounterFactory.empty()),
2812            drain_handle: std::thread::spawn(|| {}),
2813            shadow_handle: None,
2814        };
2815        let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> =
2816            Some(handles);
2817        let mut divergence_resyncs = 0u32;
2818        let mut backoff = MAX_BACKOFF;
2819        let shutdown = AtomicBool::new(true);
2820        let promote = crate::promotion::PromotionRequest::new();
2821        let mut closed = false;
2822        let after = handle_session_exit::<counter_server::Counter, Writer>(
2823            StreamingResult {
2824                exit: SessionExit::StreamGap("sequence gap: expected 3, got 5".into()),
2825                heard_from_primary: true,
2826            },
2827            &mut pipeline,
2828            &mut divergence_resyncs,
2829            &mut backoff,
2830            2,
2831            &dir.path().join("r.journal"),
2832            &dir.path().join("r.snapshot"),
2833            &counter_server::CounterFactory,
2834            &melin_transport_core::fence::FenceState::new(0),
2835            &shutdown,
2836            &promote,
2837            || closed = true,
2838        );
2839        assert!(
2840            matches!(after, AfterSession::Reconnect),
2841            "a stream gap must reconnect, not return an error"
2842        );
2843        assert!(
2844            pipeline.is_some(),
2845            "the pipeline must survive for the reconnect"
2846        );
2847        assert!(closed, "the transport must be closed before redialing");
2848        assert_eq!(
2849            backoff,
2850            std::time::Duration::from_secs(2),
2851            "backoff reset to 1s then doubled by the shared helper"
2852        );
2853        assert_eq!(divergence_resyncs, 0, "not a resync");
2854        if let Some(p) = pipeline.take() {
2855            let _ = teardown_replica_pipeline::<counter_server::Counter, Writer>(p);
2856        }
2857    }
2858
2859    /// With the journal-failure latch set and the ring full — the state
2860    /// a dead journal stage leaves behind (its frozen gate cursor means
2861    /// the ring never drains) — teardown must skip the sentinel and
2862    /// return, instead of spinning forever on ring backpressure.
2863    #[test]
2864    fn teardown_skips_sentinel_when_journal_failed_and_ring_full() {
2865        let (mut handles, mut consumer) = teardown_fixture(4);
2866        for seq in 0..4 {
2867            assert!(
2868                handles
2869                    .input_producer
2870                    .try_publish(InputSlot {
2871                        sequence: seq,
2872                        ..InputSlot::default()
2873                    })
2874                    .is_ok(),
2875                "ring must have space while filling"
2876            );
2877        }
2878        assert!(
2879            handles
2880                .input_producer
2881                .try_publish(InputSlot::default())
2882                .is_err(),
2883            "ring must be full"
2884        );
2885        handles.journal_failed.store(true, Ordering::Release);
2886        // Would spin forever on the frozen gate without the latch check.
2887        assert!(matches!(
2888            teardown_replica_pipeline::<counter_server::Counter, u32>(handles),
2889            TeardownOutcome::Clean(..)
2890        ));
2891        while let Some((_, slot)) = consumer.try_consume() {
2892            assert!(!slot.event.is_shutdown(), "no sentinel may be published");
2893        }
2894    }
2895
2896    #[test]
2897    fn pending_ack_queue_wraps_around() {
2898        let mut q = PendingAckQueue::new(8);
2899        let cursor = make_journal_cursor(100);
2900
2901        // Fill and drain multiple times to exercise circular buffer wrap.
2902        for round in 0..3 {
2903            for i in 0..8 {
2904                let target = (round * 8 + i) as u64 + 1;
2905                q.push(target, target * 10);
2906            }
2907            assert!(q.is_full());
2908            let seq = q.pop_ready(&cursor).expect("should be ready");
2909            assert_eq!(seq, (round * 8 + 8) as u64 * 10);
2910            assert!(q.is_empty());
2911        }
2912    }
2913
2914    #[test]
2915    fn pending_ack_queue_pop_all_blocking_empty() {
2916        let mut q = PendingAckQueue::new(8);
2917        let cursor = make_journal_cursor(0);
2918        assert!(
2919            q.pop_all_blocking(&cursor, true, &AtomicBool::new(false))
2920                .is_none()
2921        );
2922    }
2923
2924    // --- try_flush_dual_track tests ---
2925
2926    #[test]
2927    fn dual_track_returns_none_when_both_tracks_idle() {
2928        let mut q = PendingAckQueue::new(8);
2929        let cursor = make_journal_cursor(0);
2930        assert!(
2931            try_flush_dual_track(&mut q, &cursor, 0, 0, 0).is_none(),
2932            "no advance on either track → no ack"
2933        );
2934    }
2935
2936    #[test]
2937    fn dual_track_fires_on_persisted_advance_only() {
2938        // Persisted track moves: pop_ready returns 100; in-memory
2939        // stayed at 50 (last_sent). Expect ack with acked=100,
2940        // in_memory=50 (no regression — the wire field carries the
2941        // current sample, not a "no change" marker).
2942        let mut q = PendingAckQueue::new(8);
2943        q.push(10, 100);
2944        let cursor = make_journal_cursor(20);
2945        let ack = try_flush_dual_track(&mut q, &cursor, 50, 0, 50).expect("persisted advanced");
2946        assert_eq!(ack.acked_sequence, 100);
2947        assert_eq!(ack.in_memory_sequence, 50);
2948    }
2949
2950    #[test]
2951    fn dual_track_fires_on_in_memory_advance_only() {
2952        // Persisted is idle (queue empty): unwrap_or keeps the
2953        // last-sent acked at 100. In-memory bumped from 100 to 200.
2954        let mut q = PendingAckQueue::new(8);
2955        let cursor = make_journal_cursor(0);
2956        let ack = try_flush_dual_track(&mut q, &cursor, 200, 100, 100).expect("in-memory advanced");
2957        assert_eq!(ack.acked_sequence, 100);
2958        assert_eq!(ack.in_memory_sequence, 200);
2959    }
2960
2961    #[test]
2962    fn dual_track_coalesces_until_caller_updates_trackers() {
2963        // Caller did not advance trackers between calls. The queue
2964        // popped on call 1; on call 2 it's empty so persisted stays
2965        // at 100 (unwrap_or). In-memory advanced 50 → 80 between
2966        // calls. Second call must still fire because tracker is
2967        // still 50.
2968        let mut q = PendingAckQueue::new(8);
2969        q.push(10, 100);
2970        let cursor = make_journal_cursor(20);
2971        let ack1 = try_flush_dual_track(&mut q, &cursor, 50, 0, 50).expect("call 1 fires");
2972        assert_eq!(ack1.acked_sequence, 100);
2973        // Caller "forgot" to update trackers (simulates send failure).
2974        let ack2 = try_flush_dual_track(&mut q, &cursor, 80, 0, 50)
2975            .expect("call 2 fires on in-memory advance");
2976        assert_eq!(
2977            ack2.acked_sequence, 0,
2978            "no new persisted pop → unwrap_or(last_sent)"
2979        );
2980        assert_eq!(ack2.in_memory_sequence, 80);
2981    }
2982
2983    #[test]
2984    fn dual_track_no_duplicate_ack_after_backpressure_drain() {
2985        // Models the backpressure-drain → resume-normal-flush path
2986        // taken by every receiver when `PendingAckQueue` fills up.
2987        // The receiver must drain the queue (via the
2988        // pop_oldest_blocking path in production; pop_ready here),
2989        // update *both* trackers from the drained ack, and only
2990        // then resume the normal cursor-driven flush. The bug
2991        // classes this pins are:
2992        //   (a) emitting a follow-up ack whose `acked_sequence`
2993        //       regresses below what the drain already sent
2994        //       (caught by the debug_assert! in
2995        //       `try_flush_dual_track`, also asserted at value
2996        //       level here);
2997        //   (b) emitting a duplicate ack carrying the same
2998        //       cursors as the drain when neither track actually
2999        //       advanced — the leak class fixed by ensuring every
3000        //       send site updates both `last_sent_acked_seq` and
3001        //       `last_sent_in_memory_seq`.
3002        let mut q = PendingAckQueue::new(4);
3003        // Fill the queue: four pending acks at primary seqs
3004        // 100, 200, 300, 400 with journal targets 10..=40.
3005        for i in 1..=4u64 {
3006            q.push(i * 10, i * 100);
3007        }
3008        assert!(q.is_full());
3009
3010        // Backpressure path: caller drains all ready entries
3011        // before pushing more. Highest acked seen = 400.
3012        let cursor = make_journal_cursor(40);
3013        let drained = q.pop_ready(&cursor).expect("all entries durable");
3014        assert_eq!(drained, 400);
3015        assert!(q.is_empty());
3016
3017        // Caller updates BOTH trackers from the drain. in_memory
3018        // at the time of the drained batch was 450.
3019        let mut last_sent_acked = drained;
3020        let mut last_sent_in_mem = 450u64;
3021
3022        // Quiescent immediately after drain: no fresh push, no
3023        // in-memory advance → try_flush must return None.
3024        // Failing here means we'd emit a duplicate ack carrying
3025        // the same (400, 450) the backpressure drain already sent.
3026        assert!(
3027            try_flush_dual_track(
3028                &mut q,
3029                &cursor,
3030                last_sent_in_mem,
3031                last_sent_acked,
3032                last_sent_in_mem,
3033            )
3034            .is_none(),
3035            "no advance on either track after backpressure drain → no duplicate ack",
3036        );
3037
3038        // Push a fresh batch (primary seq 500, journal target 50),
3039        // cursor catches up, in_memory advances to 500.
3040        q.push(50, 500);
3041        let cursor2 = make_journal_cursor(50);
3042        let ack = try_flush_dual_track(&mut q, &cursor2, 500, last_sent_acked, last_sent_in_mem)
3043            .expect("fresh batch after drain fires a new ack");
3044        assert!(
3045            ack.acked_sequence >= last_sent_acked,
3046            "regression: drain sent {last_sent_acked} but next ack carries {}",
3047            ack.acked_sequence,
3048        );
3049        assert_eq!(ack.acked_sequence, 500);
3050        assert_eq!(ack.in_memory_sequence, 500);
3051
3052        last_sent_acked = ack.acked_sequence;
3053        last_sent_in_mem = ack.in_memory_sequence;
3054
3055        // Post-resume quiescent: confirms idempotency past the
3056        // resume point — the second flush sees neither track
3057        // advance and must stay silent.
3058        assert!(
3059            try_flush_dual_track(
3060                &mut q,
3061                &cursor2,
3062                last_sent_in_mem,
3063                last_sent_acked,
3064                last_sent_in_mem,
3065            )
3066            .is_none(),
3067            "post-resume idle → no further ack",
3068        );
3069    }
3070}