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]][epoch:u64][genesis_known:u8][genesis_entries:u64]`
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), and the lineage's genesis
35//!   length, which a fresh replica records and one with a journal checks
36//!   against its own
37//! - **NeedSnapshot**: `[len:u32][0x11]`
38//! - **HashMismatch**: `[len:u32][0x12]` — divergent replica journal;
39//!   the replica archives its lineage, then the snapshot flow follows
40//! - **SnapshotBegin**: `[len:u32][0x13][snapshot_len:u64][snap_sequence:u64][snap_chain_hash:[u8;32]]`
41//! - **SnapshotChunk**: `[len:u32][0x14][data...]`
42//! - **SnapshotEnd**: `[len:u32][0x15][crc32c:u32]`
43//! - **Rotate**: `[len:u32][0x16][boundary_seq:u64][tail_hash:[u8;32]]`
44//!   — primary-driven rotation, verified + adopted by the replica
45//! - **ChainCheck**: `[len:u32][0x17][sequence:u64][chain_hash:[u8;32]]`
46//!   — periodic live-stream chain validation
47//! - **SegmentSeedBegin**: `[len:u32][0x18][seed_len:u64]` — raw byte
48//!   prefix of the primary's segment containing the snapshot boundary;
49//!   body rides SnapshotChunk frames, ends with SnapshotEnd
50//! - **InputBatch**: `[len:u32][0x21][count:u16][slot...]` — see
51//!   `transport-core::replication_wire` for the per-slot layout
52//! - **Heartbeat**: `[len:u32][0x30][sequence:u64]`
53//!
54//! ## Limitations
55//!
56//! - Dual replication (up to 2 replicas in parallel)
57//!
58//! See `docs/replication.md` for the full design document and limitation details.
59
60use std::sync::Arc;
61#[cfg(test)]
62use std::sync::atomic::AtomicU64;
63use std::sync::atomic::{AtomicBool, Ordering};
64
65use melin_journal::{BufferedWriter, JournalWrite};
66
67use melin_app::Application;
68use melin_transport_core::lineage::SharedLineagePolicy;
69use melin_transport_core::node_store::NodeStore;
70use melin_transport_core::pipeline::{InputSlot, OutputSlot};
71use melin_transport_core::replication::archive::{ArchiveReason, archive_local_lineage};
72use melin_transport_core::replication::protocol::{MAX_CONTROL_FRAME, decode_primary_message};
73
74mod auth;
75#[cfg(feature = "dpdk")]
76mod dpdk;
77mod receiver_transport;
78mod tcp_receiver;
79mod tcp_sender;
80// Only the DPDK sender uses the worker, but it is pure thread/channel
81// plumbing with no libdpdk dependency — so it is also compiled under
82// `test`, keeping its mechanics (and the affinity trap it exists to
83// avoid) testable on a build without the `dpdk` feature.
84#[cfg(any(feature = "dpdk", test))]
85mod validation_worker;
86// The DPDK sender's join worker: likewise thread/channel plumbing over the
87// transport-agnostic catch-up steps, compiled under `test` so it is tested
88// without libdpdk.
89#[cfg(any(feature = "dpdk", test))]
90mod join_worker;
91
92use receiver_transport::{ControlFrameSource, SessionExit, StreamingResult, receive_chunked_body};
93
94/// Log a replica's join ending, on either sender: from here its slot
95/// streams live and the replica's acks count. Without it, a join that
96/// never ends and one that ended look alike in the log.
97pub(crate) fn log_replica_live(slot: usize, sent: &SentHighWater) {
98    tracing::info!(
99        slot,
100        sent_sequence = sent.get(),
101        "replica caught up, streaming live"
102    );
103}
104
105// Wire-protocol types, auth, catch-up, ack queueing, dual-track
106// cursor management, and per-replica metrics live in
107// `melin_transport_core::replication`, imported here for the submodules.
108// Only the metrics are public: a program running a sender reads them.
109pub use melin_transport_core::replication::ReplicationMetrics;
110pub(crate) use melin_transport_core::replication::ack_queue::{
111    PendingAckQueue, try_flush_dual_track,
112};
113pub(crate) use melin_transport_core::replication::protocol::PrimaryMessage;
114pub(crate) use melin_transport_core::replication::{ReplicaCursors, SentHighWater};
115
116#[cfg(feature = "dpdk")]
117pub(crate) use dpdk::REPL_DISPATCH_BURST;
118#[cfg(feature = "dpdk")]
119pub(crate) use dpdk::{DpdkReplicationDriver, run_receiver_dpdk};
120pub use tcp_receiver::{ReceiverResult, run_receiver};
121pub use tcp_sender::{ReplicationListener, Sender, run_sender};
122
123/// The handles a replica's receive loop shares with the rest of the
124/// node — the admin endpoint and the control-plane raft driver.
125/// Bundled (rather than five positional parameters) because they
126/// always travel together through the kernel-TCP and DPDK receiver
127/// signatures, and a transposition between same-typed flags is exactly
128/// the bug a bundle prevents.
129#[derive(Clone)]
130pub struct ReplicaControlPlane {
131    /// Promotion request: polled by the receive loop; filed by the
132    /// admin `PROMOTE` command or the raft driver (auto-promotion).
133    pub(crate) promote: crate::promotion::PromotionRequest,
134    /// Flipped `true` once journal recovery has seeded the fence epoch,
135    /// so the raft driver can trust this node's advertised tip — see
136    /// `melin_raft::TipSource`.
137    pub(crate) tip_ready: std::sync::Arc<AtomicBool>,
138    /// Sequence half of the advertised journal tip — see
139    /// [`melin_transport_core::AdvertisedJournalTip`].
140    pub(crate) journal_tip: melin_transport_core::AdvertisedJournalTip,
141    /// `true` while this replica holds an authenticated replication
142    /// connection to its primary. The raft driver refuses to
143    /// auto-promote while set: a live link means the primary is
144    /// demonstrably alive, and winning a control-plane election (e.g.
145    /// because a *different* node's raft died) must not depose a
146    /// healthy primary.
147    pub(crate) primary_link_up: std::sync::Arc<AtomicBool>,
148    /// Set once a primary's `StreamStart` has been accepted since this
149    /// process booted, and never cleared: the auto-promotion rule's
150    /// "a primary has been seen" input, which tells a blank genesis node
151    /// from a replica that followed a primary before it died.
152    pub(crate) primary_observed: std::sync::Arc<AtomicBool>,
153    /// The ack policy the lineage this replica holds has in force: the
154    /// newest journaled `AckPolicyChange` it holds, with the transition to
155    /// it (`LineagePolicy`), or `None` when it holds none. The raft
156    /// driver's auto-promotion refusal judges by the newest policy, so it
157    /// judges the policy the entries this replica holds were acked under,
158    /// never one advertised ahead of them. It seeds the replica's shadow
159    /// snapshots and a promoted primary's lineage and gate too: the gate
160    /// starts under the policy before the transition, with the change
161    /// pending, as a restarted primary's does.
162    ///
163    /// One writer, the receive thread: stored whole from the recovered
164    /// lineage at boot and after a divergence recovery, from the snapshot
165    /// at a resync, cleared when the local lineage is archived (so a change
166    /// in a divergent tail disappears with the tail), and folded as each
167    /// change is accepted from the stream, before the advertised tip moves
168    /// past it (see `receiver_transport::process_streaming_frames`). Not by
169    /// the replica's matching stage, which lags the receive thread: two
170    /// writers would let an older value overwrite a newer one.
171    pub(crate) lineage_ack_policy: std::sync::Arc<SharedLineagePolicy>,
172    /// Whether the replica's pipeline is healthy — fed to the replica's
173    /// minimal health endpoint (`/health` OK/ERR and the
174    /// `melin_pipeline_healthy` gauge). Process-lifetime (the per-session
175    /// pipeline is torn down and rebuilt across resyncs, so its own
176    /// `journal_failed` latch cannot be handed to the endpoint):
177    /// `true` from boot, latched `false` by the journal stage's failure
178    /// wrapper, reset `true` when a fresh pipeline is built.
179    pub(crate) pipeline_healthy: std::sync::Arc<AtomicBool>,
180}
181
182impl ReplicaControlPlane {
183    /// Fresh handles for a replica boot: nothing requested, tip not
184    /// trustworthy yet, tip sequence 0, primary link down and never seen,
185    /// no lineage policy held, pipeline healthy (nothing has failed yet).
186    pub fn new() -> Self {
187        Self {
188            promote: crate::promotion::PromotionRequest::new(),
189            tip_ready: std::sync::Arc::new(AtomicBool::new(false)),
190            journal_tip: melin_transport_core::AdvertisedJournalTip::new(
191                melin_transport_core::WireSeq::new(0),
192            ),
193            primary_link_up: std::sync::Arc::new(AtomicBool::new(false)),
194            primary_observed: std::sync::Arc::new(AtomicBool::new(false)),
195            lineage_ack_policy: std::sync::Arc::new(SharedLineagePolicy::new(None)),
196            pipeline_healthy: std::sync::Arc::new(AtomicBool::new(true)),
197        }
198    }
199}
200
201impl Default for ReplicaControlPlane {
202    fn default() -> Self {
203        Self::new()
204    }
205}
206
207/// Diagnostic: emit a `tcp_info` span at debug level describing the
208/// kernel's view of the socket (rtt, cwnd, retrans, unacked, rcv_space,
209/// rto). Guarded internally with `tracing::enabled!` so the
210/// `getsockopt(TCP_INFO)` syscall is skipped when debug logging is off —
211/// the call is free at runtime unless `RUST_LOG=debug` (or a more
212/// specific filter) is active. Used to distinguish user-space stalls
213/// from TCP-level congestion collapse when diagnosing replication
214/// slowdowns.
215pub(super) fn log_tcp_info(fd: std::os::unix::io::RawFd, tag: &str, slot: usize) {
216    if !tracing::enabled!(tracing::Level::DEBUG) {
217        return;
218    }
219    // SAFETY: all-zero pattern is a valid `tcp_info` (all numeric fields).
220    // The kernel fills in whatever the running version supports and
221    // returns the written length in `len`.
222    let mut info: libc::tcp_info = unsafe { std::mem::zeroed() };
223    let mut len = std::mem::size_of::<libc::tcp_info>() as libc::socklen_t;
224    let rc = unsafe {
225        libc::getsockopt(
226            fd,
227            libc::IPPROTO_TCP,
228            libc::TCP_INFO,
229            &mut info as *mut _ as *mut libc::c_void,
230            &mut len,
231        )
232    };
233    if rc != 0 {
234        tracing::debug!(
235            slot,
236            tag,
237            errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
238            "tcp_info getsockopt failed"
239        );
240        return;
241    }
242    tracing::debug!(
243        slot,
244        tag,
245        state = info.tcpi_state,
246        ca_state = info.tcpi_ca_state,
247        rtt_us = info.tcpi_rtt,
248        rttvar_us = info.tcpi_rttvar,
249        snd_cwnd = info.tcpi_snd_cwnd,
250        snd_ssthresh = info.tcpi_snd_ssthresh,
251        snd_mss = info.tcpi_snd_mss,
252        rcv_mss = info.tcpi_rcv_mss,
253        unacked = info.tcpi_unacked,
254        retrans = info.tcpi_retrans,
255        total_retrans = info.tcpi_total_retrans,
256        lost = info.tcpi_lost,
257        rcv_space = info.tcpi_rcv_space,
258        rto_us = info.tcpi_rto,
259        "tcp_info"
260    );
261}
262
263/// Sleep for the given duration in 100ms increments, checking the shutdown
264/// flag and the promotion request between increments. Returns early if
265/// either is set.
266pub(super) fn sleep_checking_flags(
267    duration: std::time::Duration,
268    shutdown: &AtomicBool,
269    promote: &crate::promotion::PromotionRequest,
270) {
271    let deadline = std::time::Instant::now() + duration;
272    while std::time::Instant::now() < deadline {
273        if shutdown.load(Ordering::Relaxed) || promote.is_requested() {
274            return;
275        }
276        std::thread::sleep(std::time::Duration::from_millis(100));
277    }
278}
279
280/// Wait out one retry delay: sleep for the current `backoff` (checking
281/// the shutdown and promotion flags — see [`sleep_checking_flags`]),
282/// then double it, capped at [`MAX_BACKOFF`], for the next attempt.
283/// Sleep-then-double, so the first retry waits the base delay rather
284/// than twice it. Every retry arm in both receivers goes through this —
285/// don't hand-roll the idiom, the copies drift.
286pub(super) fn sleep_then_double_backoff(
287    backoff: &mut std::time::Duration,
288    shutdown: &AtomicBool,
289    promote: &crate::promotion::PromotionRequest,
290) {
291    sleep_checking_flags(*backoff, shutdown, promote);
292    *backoff = (*backoff * 2).min(MAX_BACKOFF);
293}
294
295/// Outcome of shutting down a replica pipeline. All stage threads are
296/// joined in every variant — by the time the caller sees this, no
297/// pipeline thread can still touch the journal or snapshot files.
298pub(super) enum TeardownOutcome<A, W> {
299    /// Clean exit: both stages returned their state, reusable for the
300    /// next pipeline build (post-snapshot) or promotion.
301    Clean(A, W),
302    /// The journal stage exited with an error — its writer is gone and
303    /// the matching stage's state is unusable (it may have applied
304    /// events the journal never persisted). Carries the error so the
305    /// orchestrator can distinguish repairable chain divergence
306    /// (in-process resync) from journal I/O death (exit).
307    JournalFailed(melin_journal::JournalError),
308    /// A stage thread panicked; no state survives.
309    Panicked,
310}
311
312/// Shut down the replica pipeline and extract the application + journal writer
313/// from the stage threads.
314///
315/// Relies on the caller having published a `JournalEvent::Shutdown`
316/// sentinel to the input ring before invoking this — the journal and
317/// matching stages exit when they consume the sentinel via the normal
318/// event-processing path. We deliberately don't flip `shutdown_flag`
319/// here: doing so could cause a stage to take its emergency-abort
320/// branch *before* consuming the sentinel, hitting the
321/// drain-vs-cursor race that the sentinel design exists to avoid.
322///
323/// `drain_handle` and `shadow_handle` still observe `shutdown_flag`
324/// because they don't process the input ring (no sentinel reaches
325/// them); set the flag for them only.
326pub(super) fn shutdown_pipeline<A: Send + 'static, W: Send + 'static>(
327    shutdown_flag: &AtomicBool,
328    journal_handle: std::thread::JoinHandle<Result<W, melin_journal::JournalError>>,
329    matching_handle: std::thread::JoinHandle<A>,
330    drain_handle: std::thread::JoinHandle<()>,
331    shadow_handle: Option<std::thread::JoinHandle<()>>,
332) -> TeardownOutcome<A, W> {
333    // Defense-in-depth: set the flag before joining. The sentinel was
334    // already published by `teardown_replica_pipeline` before this call,
335    // so no further events can arrive in the input ring — setting the
336    // flag here cannot race with new publishes. The flag is the fallback
337    // exit signal for paths that don't observe the sentinel: the sequencer
338    // in `no-persist` builds, the drain consumer, the shadow stage, and
339    // any case where the receiver thread panicked before publishing the
340    // sentinel.
341    //
342    // The flag is also what makes these joins safe when the journal
343    // stage is already dead: the matching stage's gate on the (frozen)
344    // journal cursor is a non-blocking spin that re-checks the flag
345    // every iteration, and its shutdown drain is `try_consume`-bounded.
346    shutdown_flag.store(true, Ordering::Release);
347    // Join EVERY thread before reporting the outcome, even on failure:
348    // the in-process divergence-resync path archives the journal and
349    // snapshot right after this returns, and a still-running shadow
350    // thread could be mid-snapshot-write during those renames.
351    let journal_result = journal_handle.join();
352    let matching_result = matching_handle.join();
353    let _ = drain_handle.join();
354    if let Some(h) = shadow_handle {
355        let _ = h.join();
356    }
357    let writer = match journal_result {
358        Ok(Ok(w)) => w,
359        // Already error!-logged by the journal thread's spawn wrapper.
360        Ok(Err(e)) => return TeardownOutcome::JournalFailed(e),
361        Err(_) => return TeardownOutcome::Panicked,
362    };
363    match matching_result {
364        Ok(app) => TeardownOutcome::Clean(app, writer),
365        Err(_) => TeardownOutcome::Panicked,
366    }
367}
368
369/// Live replica pipeline — built once on first connect (or after a snapshot
370/// transfer), persists across `Disconnected` reconnects so the orchestrator
371/// doesn't pay the journal-recover + thread-spawn cost on every drop.
372///
373/// Shared between the kernel-TCP and DPDK receivers; both build pipelines
374/// with the same shape (journal / matching / drain / optional shadow), and
375/// both take the next handshake from [`Self::settled_resume_point`] at
376/// reconnect time.
377pub(super) struct ReplicaPipelineHandles<A: Application, W: Send + 'static> {
378    pub(super) input_producer: melin_pipeline::ring::Producer<InputSlot<A::Event>>,
379    pub(super) journal_cursor: Arc<melin_pipeline::padding::Sequence>,
380    /// Seqlock-published fsync state (journal seq + chain hash + ring
381    /// position), read half: the reconnect handshake's resume point, read
382    /// without owning the writer. Seeded with the journal the pipeline
383    /// was built over, so it is never behind what is on disk.
384    pub(super) chain_hash_lock:
385        melin_pipeline::seqlock::SeqLockReader<melin_transport_core::pipeline::FsyncState>,
386    /// Primary-announced rotation hand-off: the receiver thread pushes
387    /// `Rotate` boundaries here (in stream order), the journal stage
388    /// pops and rotates at exactly those sequences. Replicas have no
389    /// local rotation triggers.
390    pub(super) stream_marks: melin_transport_core::pipeline::StreamMarkQueue,
391    /// The lineage's genesis length (`None`: unknown, a pre-v16 header),
392    /// read from the writer's header when the pipeline was built. Fixed
393    /// for the pipeline's life: rotation carries it forward unchanged and
394    /// anything that changes the lineage (a resync) tears the pipeline
395    /// down first. Kept here so the reconnect handshake can compare it
396    /// against the primary's without re-reading the live segment by path,
397    /// which the journal's disk thread may be renaming mid-rotation.
398    pub(super) genesis_entries: Option<u64>,
399    /// Latched by the journal thread's spawn wrapper when the stage
400    /// exits with an error (chain divergence, journal I/O failure, a
401    /// refused sequence) or panics (see `run_journal_stage`).
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/// What [`ReplicaPipelineHandles::settled_resume_point`] found.
417pub(super) enum ResumePoint {
418    /// The fsync state, covering every slot published into the input
419    /// ring: the next handshake's pair.
420    Settled(melin_transport_core::pipeline::FsyncState),
421    /// The journal stage has died. The pipeline cannot take another
422    /// session; the caller tears it down as a fatal session exit would
423    /// (see [`journal_failed_while_disconnected`]) instead of dialing.
424    JournalFailed,
425    /// Shutdown or promotion was requested; the caller handles both
426    /// before any handshake.
427    Interrupted,
428}
429
430impl<A: Application, W: Send + 'static> ReplicaPipelineHandles<A, W> {
431    /// The next handshake's resume point: the fsync state, once it covers
432    /// every slot published into the input ring.
433    ///
434    /// A session publishes ahead of durability, so when it ends the ring
435    /// can still hold entries the journal has not made durable. A
436    /// handshake at the durable position taken before they land has the
437    /// primary resend them, and the next session's contiguity gate, which
438    /// starts at that position, publishes them a second time: the matching
439    /// stage applies them twice, and the journal stage refuses them,
440    /// stopping the replica. Once the fsync state covers the ring, its
441    /// position is exactly what the replica holds.
442    ///
443    /// Stops waiting when the journal stage has failed (nothing more will
444    /// be journaled) or `interrupted` returns true.
445    pub(super) fn settled_resume_point(&self, interrupted: impl Fn() -> bool) -> ResumePoint {
446        // Only a streaming session publishes into the ring, and the
447        // previous one has ended (kernel TCP joined its thread; DPDK ran
448        // it on this one), so the cursor is final for this wait.
449        let published = self.input_producer.peek_cursor();
450        let mut outcome = ResumePoint::Interrupted;
451        // Yielding, whatever the ingress strategy: this is the reconnect
452        // path, and what it waits out is a disk stall.
453        melin_pipeline::wait::WaitStrategy::SpinThenYield.wait_until(|| {
454            // First, even when the ring is covered: a stage that died
455            // after its last durable batch would otherwise hand out a
456            // ready pair, and the loop would dial the primary and take
457            // one of its replica slots only for the session to fail on
458            // its first iteration.
459            if self.journal_failed.load(Ordering::Acquire) {
460                outcome = ResumePoint::JournalFailed;
461                return true;
462            }
463            let state = self.chain_hash_lock.load();
464            if state.input_ring_seq.get() >= published {
465                outcome = ResumePoint::Settled(state);
466                return true;
467            }
468            interrupted()
469        });
470        outcome
471    }
472}
473
474/// The session exit a reconnect loop dispatches when
475/// [`ReplicaPipelineHandles::settled_resume_point`] finds the journal
476/// stage dead: the same fatal teardown a streaming session takes when it
477/// sees the latch. A chain divergence is repaired in-process; anything
478/// else stops the replica. Nothing was heard from a primary, so the
479/// backoff stays escalated.
480pub(in crate::replication) fn journal_failed_while_disconnected() -> StreamingResult {
481    StreamingResult {
482        exit: SessionExit::Fatal("replica journal stage failed while disconnected".into()),
483        heard_from_primary: false,
484    }
485}
486
487/// Run the replica's journal stage on its thread, latching `failed` (and
488/// clearing `healthy`) if it stops for any reason other than a clean
489/// teardown: an error, or a panic.
490///
491/// Every wait on the journal's progress — the streaming loop's
492/// backpressure and ack waits, the reconnect's resume-point wait, the
493/// teardown's sentinel publish — gives up on that latch. A panic never
494/// returns an error, so without the guard below it would leave them all
495/// spinning on a cursor that will never move. (A release build aborts on
496/// panic; this matters wherever the profile unwinds.)
497fn run_journal_stage<W, E: std::fmt::Display>(
498    run: impl FnOnce() -> Result<W, E>,
499    failed: &AtomicBool,
500    healthy: &AtomicBool,
501) -> Result<W, E> {
502    /// Latches on drop unless defused: dropped during unwinding, it
503    /// reports the panic the result never will.
504    struct PanicLatch<'a> {
505        failed: &'a AtomicBool,
506        healthy: &'a AtomicBool,
507        armed: bool,
508    }
509    impl Drop for PanicLatch<'_> {
510        fn drop(&mut self) {
511            if self.armed {
512                self.failed.store(true, Ordering::Release);
513                self.healthy.store(false, Ordering::Release);
514                tracing::error!("replica journal stage panicked — session teardown");
515            }
516        }
517    }
518    let mut guard = PanicLatch {
519        failed,
520        healthy,
521        armed: true,
522    };
523    let result = run();
524    guard.armed = false;
525    if let Err(ref e) = result {
526        // Latch before logging so the receiver reacts even if logging
527        // stalls. A dead journal stage freezes the journal cursor; every
528        // downstream wait on it (ring backpressure, ack durability) would
529        // spin forever — the streaming loop polls this latch and tears
530        // the session down instead.
531        failed.store(true, Ordering::Release);
532        // Mirror into the process-lifetime gauge the replica health
533        // endpoint serves.
534        healthy.store(false, Ordering::Release);
535        tracing::error!(error = %e, "replica journal stage failed — session teardown");
536    }
537    result
538}
539
540/// [`ReplicaPipelineHandles`] over the journal's writer — the only
541/// instantiation that exists. The struct keeps its writer parameter
542/// because the teardown helpers are writer-agnostic plumbing; this
543/// alias spares every signature from spelling the concrete type out.
544pub(super) type ReplicaHandles<A> =
545    ReplicaPipelineHandles<A, BufferedWriter<<A as Application>::Event>>;
546
547/// Build the replica pipeline and spawn its stage threads on the configured
548/// cores. Returns the bundle of state the orchestrator keeps across
549/// `Disconnected` reconnects.
550#[allow(clippy::too_many_arguments)]
551pub(super) fn build_replica_pipeline_with_threads<A>(
552    mut app: A,
553    writer: BufferedWriter<A::Event>,
554    cores: crate::layout::PipelineCores,
555    // How the replica's segment preparer materialises staged extents.
556    // A replica's rotation stall sits on the ack path — under
557    // `disk+ram`/`two-disks` it delays the primary's ack gate — so this
558    // is not a primary-only tuning knob.
559    staging_mode: melin_journal::StagingMode,
560    snapshot_interval_ms: u64,
561    // Where the replica's shadow stage writes its snapshots.
562    store: &NodeStore,
563    group_commit_delay: std::time::Duration,
564    fence_state: Arc<melin_transport_core::fence::FenceState>,
565    // The lineage's ack policy as recovered (see
566    // `ReplicaControlPlane::lineage_ack_policy`), seeding the shadow
567    // stage's next to the fence epoch.
568    lineage_policy: &SharedLineagePolicy,
569    // Process-lifetime health mirror for the replica health endpoint —
570    // see `ReplicaControlPlane::pipeline_healthy`. Reset `true` here (a
571    // fresh pipeline is healthy) and latched `false` by the journal
572    // thread's failure wrapper, alongside the per-pipeline
573    // `journal_failed` latch.
574    pipeline_healthy: Arc<AtomicBool>,
575    // The node's sizing, applied to every instance a pipeline is built
576    // around — see `Application::prefault`.
577    sizing: &A::Sizing,
578) -> Result<ReplicaHandles<A>, Box<dyn std::error::Error>>
579where
580    A: Application + Send + 'static,
581    A::Event: Send + Sync + 'static,
582    A::Report: Send + 'static,
583    A::QueryResponse: Send + 'static,
584{
585    // Before the shadow copy is taken, so that neither the stream's
586    // first events nor the copy's first snapshot grow the collections on
587    // the matching thread. A replica's apply sits on the primary's ack
588    // path under `disk+ram`, so its page faults are the primary's tail.
589    <A as Application>::prefault(&mut app, sizing);
590    let shadow_app = <A as Application>::clone_via_snapshot(&app)?;
591
592    let enable_shadow = snapshot_interval_ms > 0;
593    // Shadow snapshot seeds its lineage from the fence state's epoch and
594    // the lineage policy cell (both set from the replica's recovered
595    // journal, or the snapshot a resync installed, before this builder
596    // runs).
597    let shadow_initial_lineage = melin_transport_core::lineage::Lineage {
598        epoch: fence_state.epoch(),
599        ack_policy: lineage_policy.load(),
600    };
601    // The lineage's genesis length, stamped into the replica's own
602    // snapshots — the same value its journal header holds.
603    let genesis_entries = writer.read_header_info()?.genesis_entries;
604    // The first sequence the stream will bring, which the shadow counts
605    // from (see `shadow::run`).
606    let starting_wire_seq = writer.next_sequence();
607    let pipeline = melin_transport_core::pipeline::build_replica_pipeline(
608        app,
609        writer,
610        4096, // max_journal_batch
611        group_commit_delay,
612        cores.stage_waits(),
613        enable_shadow,
614        fence_state,
615    );
616
617    let pipeline_shutdown = Arc::new(AtomicBool::new(false));
618
619    let ps = Arc::clone(&pipeline_shutdown);
620    let journal_core = cores.journal_seq.core;
621    let mut journal_stage = pipeline.journal_stage;
622    // Replicas never rotate on local triggers (size or operator
623    // command) — they adopt the boundaries the primary announces over
624    // the stream, which keeps segment boundaries (and with them chain
625    // values and journal bytes) identical across nodes.
626    let stream_marks = melin_transport_core::pipeline::StreamMarks::shared();
627    journal_stage.set_stream_marks(Arc::clone(&stream_marks));
628    cores.place_journal_children(&mut journal_stage);
629    journal_stage.set_staging_mode(staging_mode);
630    let journal_failed = Arc::new(AtomicBool::new(false));
631    let journal_failed_latch = Arc::clone(&journal_failed);
632    // Start the journal's disk and preparer threads here rather than on
633    // journal-seq — see `JournalStage::start`. Before the health latch
634    // below, so a failed start never reports a healthy pipeline.
635    let sequencer = journal_stage.start()?;
636    // A fresh pipeline is healthy — this also clears the latch after a
637    // successful in-process resync rebuild.
638    pipeline_healthy.store(true, Ordering::Release);
639    // Every stage thread is created already in its own placement's
640    // context, and this thread is put back where it was after each. Left
641    // to inherit it, a stage thread starts wherever this thread is — after
642    // `start` above, possibly on the disk thread's isolated core, where
643    // that thread spins at real-time priority and the stage never runs.
644    //
645    // A stage thread that cannot be created, or does not start, fails the
646    // build. The stages already running are told to stop first, so a
647    // failed build leaves nothing spinning behind it.
648    let abandon = |e: String| -> Box<dyn std::error::Error> {
649        pipeline_shutdown.store(true, Ordering::Release);
650        e.into()
651    };
652    let journal_handle =
653        melin_app::affinity::spawn_placed("journal-seq", journal_core, move || {
654            run_journal_stage(
655                || sequencer.run(&ps),
656                &journal_failed_latch,
657                &pipeline_healthy,
658            )
659        })
660        .map_err(|e| abandon(format!("start the replica's journal-seq thread: {e}")))?;
661
662    let ps = Arc::clone(&pipeline_shutdown);
663    let matching_core = cores.matching.core;
664    let matching_stage = pipeline.matching_stage;
665    let matching_handle = melin_app::affinity::spawn_placed("matching", matching_core, move || {
666        matching_stage.run(&ps)
667    })
668    .map_err(|e| abandon(format!("start the replica's matching thread: {e}")))?;
669
670    // Drain thread uses the response core — replicas have no response stage,
671    // but the consumer needs to be drained so the output ring doesn't fill.
672    let ps = Arc::clone(&pipeline_shutdown);
673    let drain = cores.response;
674    let drain_consumer = pipeline.drain_consumer;
675    let drain_handle = melin_app::affinity::spawn_placed("drain", drain.core, move || {
676        let mut consumer = drain_consumer;
677        let mut batch = vec![OutputSlot::<A::Report, A::QueryResponse>::default(); 256];
678        let mut waiter = drain.wait.waiter();
679        loop {
680            if ps.load(Ordering::Relaxed) {
681                return;
682            }
683            let count = consumer.consume_batch(&mut batch, 256);
684            if count == 0 {
685                waiter.idle();
686            } else {
687                waiter.reset();
688            }
689        }
690    })
691    .map_err(|e| abandon(format!("start the replica's drain thread: {e}")))?;
692
693    let shadow_handle = if let Some(shadow_cons) = pipeline.shadow_consumer {
694        let snap_path = store.snapshot().to_path_buf();
695        let chain_lock = pipeline.chain_hash_lock.clone();
696        let ps = Arc::clone(&pipeline_shutdown);
697        let shadow = cores.shadow;
698        Some(
699            melin_app::affinity::spawn_placed("replica-shadow", shadow.core, move || {
700                melin_transport_core::shadow::run(
701                    shadow_cons,
702                    shadow_app,
703                    snap_path,
704                    std::time::Duration::from_millis(snapshot_interval_ms),
705                    chain_lock,
706                    &ps,
707                    shadow.wait,
708                    shadow_initial_lineage,
709                    starting_wire_seq,
710                    genesis_entries,
711                );
712            })
713            .map_err(|e| abandon(format!("start the replica's shadow thread: {e}")))?,
714        )
715    } else {
716        None
717    };
718
719    Ok(ReplicaPipelineHandles {
720        input_producer: pipeline.input_producer,
721        journal_cursor: pipeline.cursors.journal_ring_arc(),
722        chain_hash_lock: pipeline.chain_hash_lock,
723        stream_marks,
724        genesis_entries,
725        journal_failed,
726        pipeline_shutdown,
727        journal_handle,
728        matching_handle,
729        drain_handle,
730        shadow_handle,
731    })
732}
733
734/// Tear down the pipeline: publish the shutdown sentinel, join all
735/// threads, return the recovered (App, journal writer) so the orchestrator
736/// can use them for the next pipeline build (e.g., post-snapshot) or
737/// pass them up on promotion.
738pub(super) fn teardown_replica_pipeline<A: Application + Send + 'static, W: Send + 'static>(
739    mut handles: ReplicaPipelineHandles<A, W>,
740) -> TeardownOutcome<A, W> {
741    // The sentinel is published here, not by callers, so no teardown
742    // path can forget it — the journal and matching stages exit by
743    // consuming it through the normal event path, draining anything
744    // received-but-not-yet-journaled (see `shutdown_pipeline` for why
745    // the flag alone is only the emergency fallback). Bounded retry
746    // rather than the blocking `publish`: if the journal stage dies
747    // with the ring full, its gate cursor freezes and a blocking
748    // publish would spin forever — re-check the failure latch between
749    // attempts and fall through to the flag-only teardown once it
750    // trips (the sentinel has no reader then anyway: the matching
751    // stage is gated behind the frozen journal cursor).
752    //
753    // Deliberately a plain yield rather than the node's wait strategy:
754    // this is the orchestrator thread on a teardown path, where
755    // latency is irrelevant and handing the CPU to the stage threads
756    // is unconditionally the right move, whatever the node's policy.
757    while !handles.journal_failed.load(Ordering::Acquire) {
758        match handles
759            .input_producer
760            .try_publish(InputSlot::<A::Event>::shutdown_sentinel())
761        {
762            Ok(_) => break,
763            // Ring full — consumers need to make progress first.
764            Err(_) => std::thread::yield_now(),
765        }
766    }
767    shutdown_pipeline::<A, W>(
768        &handles.pipeline_shutdown,
769        handles.journal_handle,
770        handles.matching_handle,
771        handles.drain_handle,
772        handles.shadow_handle,
773    )
774}
775
776/// How many mid-stream divergence resyncs the receiver attempts
777/// in-process (per process lifetime) before giving up. Mid-stream
778/// divergence is never expected in a healthy cluster — it means
779/// corruption or a serious bug somewhere — so the budget is exactly
780/// one: the first occurrence repairs automatically (archive the local
781/// lineage as `.divergent.<n>`, re-seed from the primary) and pages
782/// the operator via `melin_replica_divergence_total`; a second in the
783/// same process lifetime is systematic, and continuing to repair
784/// would fill the disk with archives while masking the underlying
785/// fault. Exit hard instead.
786pub(super) const MAX_INPROCESS_DIVERGENCE_RESYNCS: u32 = 1;
787
788/// Recover replica boot state from disk.
789///
790/// Recovers whenever any journal segment survives — live OR archived
791/// (a crash between rotation's rename and the new live file's creation
792/// leaves archives with no live segment, and recovery handles that
793/// layout; treating it as a fresh replica would discard local durable
794/// history and then fail `create_new` against the surviving lineage).
795/// Returns `(None, None, 0, zeros)` for a genuinely fresh replica.
796/// Also seeds the fencing epoch from the recovered journal.
797///
798/// Called at receiver startup, and again after a mid-stream chain
799/// divergence tears the pipeline down: the on-disk journal is
800/// self-consistent (merely forked from the primary's history), so
801/// recovery re-derives a truthful handshake pair `(last_sequence,
802/// chain_hash)` and the next connection takes the primary's
803/// HashMismatch → archive → reseed path in-process.
804#[allow(clippy::type_complexity)]
805pub(super) fn recover_replica_state<A, W>(
806    store: &NodeStore,
807    fence_state: &melin_transport_core::fence::FenceState,
808    lineage_policy: &SharedLineagePolicy,
809    // Applied to a genesis instance before the journal is replayed into
810    // it — see `init_engine`. The pipeline build sizes the result again.
811    sizing: &A::Sizing,
812) -> Result<(Option<A>, Option<W>, u64, [u8; 32]), Box<dyn std::error::Error>>
813where
814    A: Application,
815    W: melin_journal::JournalWrite<A::Event>,
816{
817    // Swept every boot, like the genesis staging file: only a later adopt
818    // that meets its narrow guard would otherwise remove it.
819    let journal_path = store.journal();
820    let snapshot_path = store.snapshot();
821    remove_restamp_staging(journal_path)?;
822    if !store.journal_lineage_exists()? {
823        // Nothing held, so no policy either (the epoch stays: fencing
824        // never moves it back).
825        lineage_policy.store(None);
826        return Ok((None, None, 0u64, [0u8; 32]));
827    }
828    let engine = if snapshot_path.try_exists()? {
829        tracing::info!("recovering replica from snapshot + journal");
830        melin_transport_core::JournaledApp::<A, W>::recover_from_snapshot(
831            snapshot_path,
832            journal_path,
833        )?
834    } else {
835        let mut app = A::default();
836        <A as Application>::prefault(&mut app, sizing);
837        melin_transport_core::JournaledApp::<A, W>::recover(app, journal_path)?
838    };
839    let next = engine.next_sequence();
840    let last = next.saturating_sub(1);
841    let hash = engine.chain_hash().unwrap_or([0u8; 32]);
842    // Seed the lineage from the replica's own recovered journal.
843    // Streaming `EpochBump`s and the snapshot-resync path raise the epoch
844    // later.
845    adopt_recovered_lineage(fence_state, lineage_policy, engine.recovered_lineage());
846    let (app, writer) = engine.into_parts();
847    Ok((Some(app), Some(writer), last, hash))
848}
849
850/// Adopt a lineage recovered from disk (a journal replay, or a snapshot
851/// the primary sent) as this replica's. The epoch is observed, a maximum,
852/// since fencing never moves it back; the ack policy is stored whole,
853/// since the recovered lineage replaces whatever this node held, and a
854/// change in a tail it dropped must go with the tail.
855fn adopt_recovered_lineage(
856    fence_state: &melin_transport_core::fence::FenceState,
857    lineage_policy: &SharedLineagePolicy,
858    lineage: melin_transport_core::lineage::Lineage,
859) {
860    fence_state.observe_epoch(lineage.epoch);
861    lineage_policy.store(lineage.ack_policy);
862}
863
864/// Reconnect backoff cap shared by both receivers — exponential from
865/// 1 s, clamped here so a long outage settles to one attempt every 30 s
866/// without hammering a flapping primary. Reset to 1 s only when a
867/// session heard the primary speak (data or heartbeat) — see
868/// [`handle_session_exit`].
869pub(super) const MAX_BACKOFF: std::time::Duration = std::time::Duration::from_secs(30);
870
871/// What the receiver's reconnect loop does after a streaming session
872/// ends — the transport-independent half of the exit dispatch, returned
873/// by [`handle_session_exit`].
874pub(in crate::replication) enum AfterSession<A, W> {
875    /// Terminal: `run_receiver*` returns this verbatim — clean shutdown
876    /// (`Ok(None)`), a promotion hand-off (`Ok(Some(state))`), or a
877    /// fatal error.
878    Return(ReceiverResult<A, W>),
879    /// A mid-stream divergence was repaired in-process: the recovered
880    /// on-disk state is the new handshake position. The caller adopts it
881    /// and reconnects; the primary's `HashMismatch` verdict then routes
882    /// the replica through archive + re-seed.
883    Resync {
884        app: Option<A>,
885        journal_writer: Option<W>,
886        last_sequence: u64,
887        chain_hash: [u8; 32],
888    },
889    /// A plain disconnect or a stream contiguity break — backoff has
890    /// already been applied (and the flags checked); the caller
891    /// reconnects, reusing the still-live pipeline.
892    Reconnect,
893}
894
895impl<A, W> AfterSession<A, W> {
896    /// Fold this outcome into the reconnect loop's state: the result
897    /// `run_receiver*` must return, or `None` to go round the loop again
898    /// (having adopted a resync's recovered state). One copy for every
899    /// dispatch site in both receivers, so they cannot drift apart.
900    pub(in crate::replication) fn adopt(
901        self,
902        app: &mut Option<A>,
903        journal_writer: &mut Option<W>,
904        last_sequence: &mut u64,
905        chain_hash: &mut [u8; 32],
906    ) -> Option<ReceiverResult<A, W>> {
907        match self {
908            AfterSession::Return(r) => Some(r),
909            AfterSession::Resync {
910                app: recovered_app,
911                journal_writer: recovered_writer,
912                last_sequence: recovered_sequence,
913                chain_hash: recovered_hash,
914            } => {
915                *app = recovered_app;
916                *journal_writer = recovered_writer;
917                *last_sequence = recovered_sequence;
918                *chain_hash = recovered_hash;
919                None
920            }
921            AfterSession::Reconnect => None,
922        }
923    }
924}
925
926/// Dispatch a finished streaming session — shared by the kernel-TCP and
927/// DPDK receivers.
928///
929/// Folds the behaviours that were previously copied between the two
930/// receiver loops (and had drifted — the copies reset the post-resync
931/// writer differently, a latent corruption bug): the
932/// `Shutdown`/`Promote`/`Fatal` teardown (including the once-per-process
933/// in-process divergence-resync policy) and the `Disconnected` reconnect
934/// backoff. The shutdown-sentinel publish lives in
935/// [`teardown_replica_pipeline`].
936///
937/// `close` ends the session's connection, transport-specifically. It is
938/// invoked exactly once, first, on every exit, terminal or not: the
939/// session is over whatever follows, and the primary must learn so now
940/// rather than after the pipeline teardown or the backoff. The DPDK
941/// receiver resets its smoltcp socket, which tells the primary (a DPDK
942/// node is its own TCP stack, so nothing else would once it stops) and
943/// frees the local socket-set entry; the kernel-TCP receiver passes a
944/// no-op (its `TcpStream` is dropped by the caller, and the kernel tells
945/// the primary).
946// A long argument list, but each is a distinct piece of the receiver
947// loop's state, borrowed from its locals; bundling them would only move
948// the noise.
949#[allow(clippy::too_many_arguments)]
950pub(in crate::replication) fn handle_session_exit<A, W>(
951    result: StreamingResult,
952    pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
953    divergence_resyncs: &mut u32,
954    backoff: &mut std::time::Duration,
955    last_sequence: u64,
956    store: &NodeStore,
957    fence_state: &melin_transport_core::fence::FenceState,
958    lineage_policy: &SharedLineagePolicy,
959    shutdown: &AtomicBool,
960    promote: &crate::promotion::PromotionRequest,
961    close: impl FnOnce(),
962    // For the resync path, which recovers the local journal afresh — see
963    // `recover_replica_state`.
964    sizing: &A::Sizing,
965) -> AfterSession<A, W>
966where
967    A: Application + Send + 'static,
968    W: JournalWrite<A::Event> + Send + 'static,
969{
970    let StreamingResult {
971        exit,
972        heard_from_primary,
973    } = result;
974
975    close();
976
977    match exit {
978        SessionExit::Shutdown => {
979            if let Some(p) = pipeline.take() {
980                let _ = teardown_replica_pipeline::<A, W>(p);
981            }
982            AfterSession::Return(Ok(None))
983        }
984
985        SessionExit::Promote => AfterSession::Return(match pipeline.take() {
986            Some(p) => match teardown_replica_pipeline::<A, W>(p) {
987                TeardownOutcome::Clean(ex, wr) => Ok(Some((ex, wr))),
988                _ => Err("pipeline failed during promotion".into()),
989            },
990            None => Err("pipeline missing on promote".into()),
991        }),
992
993        SessionExit::Fatal(e) => {
994            let outcome = match pipeline.take() {
995                Some(p) => teardown_replica_pipeline::<A, W>(p),
996                // Fatal implies a streaming session, which implies a
997                // pipeline — but don't turn a missing one into a resync.
998                None => return AfterSession::Return(Err(e)),
999            };
1000            // Mid-stream chain divergence is repairable in-process: the
1001            // on-disk journal is self-consistent (merely forked from the
1002            // primary's history), so re-derive the handshake state from
1003            // disk and reconnect — the primary judges the recovered
1004            // position divergent and the next session takes the
1005            // HashMismatch → archive → reseed path, no restart needed.
1006            // Every other fatal exits as before: protocol violations and
1007            // journal I/O death (ENOSPC, RO-FS) would fail the same way
1008            // after a resync. The journal's own error names the cause; the
1009            // exit's message alone may not (the reconnect wait's does not).
1010            let je = match outcome {
1011                TeardownOutcome::JournalFailed(
1012                    je @ melin_journal::JournalError::ReplicaChainDivergence { .. },
1013                ) => je,
1014                TeardownOutcome::JournalFailed(je) => {
1015                    return AfterSession::Return(Err(format!("{e}: {je}").into()));
1016                }
1017                TeardownOutcome::Clean(..) | TeardownOutcome::Panicked => {
1018                    return AfterSession::Return(Err(e));
1019                }
1020            };
1021
1022            *divergence_resyncs += 1;
1023            let attempt = *divergence_resyncs;
1024            if attempt > MAX_INPROCESS_DIVERGENCE_RESYNCS {
1025                return AfterSession::Return(Err(format!(
1026                    "mid-stream chain divergence recurred {attempt} times — giving up on \
1027                     in-process resync (each cycle archives the local journal and re-seeds \
1028                     from the primary; recurrence at this rate means the primary keeps \
1029                     streaming history that forks from what it announces): {je}"
1030                )
1031                .into()));
1032            }
1033            tracing::warn!(
1034                error = %je,
1035                attempt,
1036                max_attempts = MAX_INPROCESS_DIVERGENCE_RESYNCS,
1037                "mid-stream chain divergence — re-deriving local state for in-process resync"
1038            );
1039            match recover_replica_state::<A, W>(store, fence_state, lineage_policy, sizing) {
1040                Ok((app, journal_writer, seq, hash)) => AfterSession::Resync {
1041                    app,
1042                    journal_writer,
1043                    last_sequence: seq,
1044                    chain_hash: hash,
1045                },
1046                Err(e) => AfterSession::Return(Err(e)),
1047            }
1048        }
1049
1050        SessionExit::StreamGap(e) => {
1051            // The primary's stream skipped past what we hold; the
1052            // contiguous prefix is committed and the journal stage keeps
1053            // flushing it, so re-handshaking at the durable position
1054            // (re-read from `FsyncState` at the top of the reconnect
1055            // loop, after the backoff) lets the primary re-stream the
1056            // hole. The primary evidently spoke, so the backoff resets
1057            // exactly as a heard-from disconnect does. Not a resync:
1058            // nothing on disk is wrong.
1059            *backoff = std::time::Duration::from_secs(1);
1060            tracing::warn!(
1061                error = %e,
1062                last_sequence,
1063                "replication stream broke contiguity — reconnecting from the durable position"
1064            );
1065            sleep_then_double_backoff(backoff, shutdown, promote);
1066            AfterSession::Reconnect
1067        }
1068
1069        SessionExit::Corrupted(e) => {
1070            // A replicated entry arrived damaged and was refused before
1071            // anything could apply, journal or acknowledge it; the frames
1072            // before it are committed. The primary still holds the intact
1073            // entries, so re-handshaking at the durable position has them
1074            // sent again. Not a resync: nothing local is wrong.
1075            //
1076            // The backoff resets, exactly as for a stream gap: frames
1077            // arrived, so the primary evidently spoke, and nothing else
1078            // resets the backoff after a long clean session — leaving it
1079            // to escalate would make one isolated bit flip, days after a
1080            // start-up that escalated it, cost a `MAX_BACKOFF` stall
1081            // (writes blocked under a replica-requiring ack policy). A
1082            // link that keeps damaging frames is redialled at the floor
1083            // rate, each time logged below; every redial re-streams
1084            // useful data, so that costs nothing the primary cannot bear.
1085            // `warn!`, not `error!`: the server is working as designed —
1086            // what needs attention is the path between the nodes.
1087            *backoff = std::time::Duration::from_secs(1);
1088            tracing::warn!(
1089                error = %e,
1090                last_sequence,
1091                backoff_secs = backoff.as_secs(),
1092                "replicated entry failed its integrity check — refused, reconnecting to \
1093                 fetch it again; recurrence points at the network, a NIC or memory"
1094            );
1095            sleep_then_double_backoff(backoff, shutdown, promote);
1096            AfterSession::Reconnect
1097        }
1098
1099        SessionExit::Disconnected => {
1100            // A session in which the primary spoke — data or heartbeat
1101            // (heartbeats flow even on a quiet system) — proves it
1102            // alive and serving: treat the drop as transient and reset
1103            // the backoff. A session with no word from the primary
1104            // keeps escalating: that covers instant-drop flaps and
1105            // synthetic results from sessions that never started
1106            // streaming (a persistent local transport failure must not
1107            // redial at 1 Hz forever).
1108            if heard_from_primary {
1109                *backoff = std::time::Duration::from_secs(1);
1110            }
1111            tracing::warn!(
1112                last_sequence,
1113                heard_from_primary,
1114                backoff_secs = backoff.as_secs(),
1115                "reconnecting to primary"
1116            );
1117            sleep_then_double_backoff(backoff, shutdown, promote);
1118            AfterSession::Reconnect
1119        }
1120    }
1121}
1122
1123/// Tear the live pipeline down for a promotion that fired while the
1124/// receiver was disconnected — at the top of the reconnect loop or
1125/// during reconnect backoff — and return the warm application + writer for
1126/// the promoted primary. Shared by both receivers.
1127///
1128/// A clean teardown hands back the warm state; if there is no pipeline
1129/// (promotion before the first connect, or after a resync that left the
1130/// state in the receiver's locals) those locals carry it. A missing
1131/// pair is a hard error — a promote with nothing to promote.
1132pub(in crate::replication) fn take_pipeline_for_promotion<A, W>(
1133    pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
1134    app: &mut Option<A>,
1135    journal_writer: &mut Option<W>,
1136) -> ReceiverResult<A, W>
1137where
1138    A: Application + Send + 'static,
1139    W: JournalWrite<A::Event> + Send + 'static,
1140{
1141    if let Some(p) = pipeline.take()
1142        && let TeardownOutcome::Clean(e, w) = teardown_replica_pipeline::<A, W>(p)
1143    {
1144        *app = Some(e);
1145        *journal_writer = Some(w);
1146    }
1147    match (app.take(), journal_writer.take()) {
1148        (Some(e), Some(w)) => Ok(Some((e, w))),
1149        _ => Err("promotion requested but no local state available".into()),
1150    }
1151}
1152
1153/// The facts a successful snapshot + segment-seed transfer yields.
1154struct ResyncTransfer<A> {
1155    /// The application loaded from the transferred snapshot.
1156    app: A,
1157    /// The snapshot's sequence: where the seeded segment is opened.
1158    snap_sequence: u64,
1159    /// The snapshot's chain hash, as the primary announced and the loaded
1160    /// snapshot confirmed.
1161    snap_chain_hash: [u8; 32],
1162    /// The seed's length: the seeded journal's `valid_end`.
1163    seed_len: u64,
1164    /// The snapshot's lineage. Adopted by the caller once the whole
1165    /// transfer has succeeded, so a failed one leaves none behind.
1166    lineage: melin_transport_core::lineage::Lineage,
1167}
1168
1169/// What [`handle_resync_verdict`] resolved a `NeedSnapshot` /
1170/// `HashMismatch` verdict to.
1171pub(in crate::replication) enum ResyncDecision {
1172    /// Resync complete — resume streaming from `resume_sequence` on the
1173    /// re-seeded lineage `(segment_start_sequence, anchor_hash)`. The
1174    /// recovered App + writer are left in the receiver's `app` /
1175    /// `journal_writer` locals.
1176    Ready {
1177        segment_start_sequence: u64,
1178        anchor_hash: [u8; 32],
1179        /// The lineage's genesis length, as the seeded segment's header
1180        /// (and the `StreamStart` that matched it) records it.
1181        genesis_entries: Option<u64>,
1182        resume_sequence: u64,
1183    },
1184    /// The transfer failed network-shaped (drop / restart mid-body); the
1185    /// caller backs off and reconnects. The pre-resync lineage is already
1186    /// archived and half-applied transfer state has been cleaned up.
1187    Retry,
1188}
1189
1190/// Receive the snapshot + segment-seed transfer that follows a resync
1191/// verdict, installing both (snapshot → the store's snapshot, by way of
1192/// its `.tmp`, the shadow stage's own temp name; seed → the store's
1193/// journal), and returns what they yield ([`ResyncTransfer`]).
1194/// Shared by both receivers via [`ControlFrameSource`].
1195///
1196/// Errors here are network-shaped — the caller retries (see
1197/// [`ResyncDecision::Retry`]); the post-transfer install in
1198/// [`handle_resync_verdict`] is what's fatal.
1199fn receive_resync_transfer<A, S>(
1200    source: &mut S,
1201    store: &NodeStore,
1202) -> Result<ResyncTransfer<A>, Box<dyn std::error::Error + Send + Sync>>
1203where
1204    A: Application + Send,
1205    S: ControlFrameSource,
1206{
1207    let (snap_len, snap_sequence, snap_chain_hash) =
1208        match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
1209            PrimaryMessage::SnapshotBegin {
1210                snapshot_len,
1211                snap_sequence,
1212                snap_chain_hash,
1213            } => (snapshot_len, snap_sequence, snap_chain_hash),
1214            other => return Err(format!("expected SnapshotBegin, got {other:?}").into()),
1215        };
1216
1217    tracing::info!(snap_sequence, snap_len, "receiving snapshot");
1218    let snapshot_path = store.snapshot();
1219    let journal_path = store.journal();
1220    let tmp_path = store.snapshot_tmp();
1221    receive_chunked_body(source, &tmp_path, snap_len, "snapshot")?;
1222    std::fs::rename(&tmp_path, snapshot_path)?;
1223    // The snapshot's own directory: the seed's sync below covers only the
1224    // journal's, which the snapshot need not share.
1225    melin_journal::segment::fsync_parent_dir(snapshot_path)?;
1226    tracing::info!(snap_sequence, snap_len, "snapshot received and verified");
1227
1228    // The primary is already sending the seed: a large state must not
1229    // leave the link unanswered while it loads (see `serviced`).
1230    let (snap_app, snap_header) =
1231        source.serviced(|| melin_transport_core::snapshot::load::<A>(snapshot_path))?;
1232    let snap_hash = snap_header.chain_hash;
1233    if snap_hash != snap_chain_hash {
1234        return Err(format!(
1235            "snapshot chain hash mismatch: primary sent {snap_chain_hash:02x?}, \
1236             loaded snapshot has {snap_hash:02x?}"
1237        )
1238        .into());
1239    }
1240
1241    // Segment seed: the raw byte prefix of the primary's segment
1242    // containing `snap_sequence`. Written verbatim as our live segment, it
1243    // makes our segmentation a byte-copy of the primary's from birth —
1244    // chain values comparable and `Rotate` verification valid immediately.
1245    let seed_len = match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
1246        PrimaryMessage::SegmentSeedBegin { seed_len } => seed_len,
1247        other => {
1248            return Err(format!("expected SegmentSeedBegin after snapshot, got {other:?}").into());
1249        }
1250    };
1251    let seed_tmp = journal_path.with_extension("seed.tmp");
1252    receive_chunked_body(source, &seed_tmp, seed_len, "segment seed")?;
1253    // Structural check before installing: the CRC only proves transport
1254    // integrity, not that the primary sent a well-formed prefix ending at
1255    // the snapshot sequence. (With hash-chain on, the chain cross-check in
1256    // `handle_resync_verdict` subsumes this; without it, this is the only
1257    // guard.) Reads the whole seed, so it runs serviced too.
1258    source.serviced(|| -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1259        if let Err(e) =
1260            melin_journal::segment::verify_segment_prefix(&seed_tmp, snap_sequence, seed_len)
1261        {
1262            let _ = std::fs::remove_file(&seed_tmp);
1263            return Err(format!("segment seed failed structural verification: {e}").into());
1264        }
1265        std::fs::rename(&seed_tmp, journal_path)?;
1266        melin_journal::segment::fsync_parent_dir(journal_path)?;
1267        Ok(())
1268    })?;
1269    Ok(ResyncTransfer {
1270        app: snap_app,
1271        snap_sequence,
1272        snap_chain_hash,
1273        seed_len,
1274        lineage: snap_header.lineage,
1275    })
1276}
1277
1278/// Handle a `NeedSnapshot` / `HashMismatch` resync verdict — shared by
1279/// both receivers. Tears the pipeline down, archives the local lineage
1280/// (never deleted), resets the handshake position, receives + installs
1281/// the snapshot and segment seed via `source`, opens the re-seeded
1282/// segment and ties its chain to the snapshot, then validates the
1283/// post-snapshot `StreamStart` inline before resuming.
1284///
1285/// On success the recovered App + writer are left in `app` /
1286/// `journal_writer` and [`ResyncDecision::Ready`] carries the resume
1287/// lineage. A network-shaped transfer failure yields
1288/// [`ResyncDecision::Retry`] (the caller backs off and reconnects). An
1289/// inconsistent primary or a local install failure is fatal (`Err`).
1290#[allow(clippy::too_many_arguments)]
1291pub(in crate::replication) fn handle_resync_verdict<A, W, S>(
1292    divergent: bool,
1293    source: &mut S,
1294    pipeline: &mut Option<ReplicaPipelineHandles<A, W>>,
1295    app: &mut Option<A>,
1296    journal_writer: &mut Option<W>,
1297    store: &NodeStore,
1298    fence_state: &melin_transport_core::fence::FenceState,
1299    control: &ReplicaControlPlane,
1300    last_sequence: &mut u64,
1301    chain_hash: &mut [u8; 32],
1302) -> Result<ResyncDecision, Box<dyn std::error::Error + Send + Sync>>
1303where
1304    A: Application + Send + 'static,
1305    // The pipeline's rings carry events, and its teardown runs serviced
1306    // on a helper thread.
1307    A::Event: Send,
1308    W: JournalWrite<A::Event> + Send + 'static,
1309    S: ControlFrameSource,
1310{
1311    // HashMismatch is NeedSnapshot plus the verdict that our local journal
1312    // holds divergent history (forked from the primary's — e.g. an
1313    // ex-primary rejoining after failover with an acked-but-unreplicated
1314    // suffix).
1315    if divergent {
1316        tracing::warn!(
1317            last_sequence = *last_sequence,
1318            "primary reports chain divergence — archiving local journal, resyncing from snapshot"
1319        );
1320    } else {
1321        tracing::info!("primary requires snapshot transfer — receiving snapshot");
1322    }
1323
1324    // Invalidate the in-memory App + writer before moving their backing
1325    // files aside. On the in-process divergence repair path these still
1326    // hold the recovered handles; a transfer failure returns `Retry`, and
1327    // without this reset the stale writer — now pointing at an
1328    // archived-away journal — would survive the fresh-replica create gate
1329    // and get rebuilt into the next pipeline.
1330    let old_pipeline = pipeline.take();
1331    let old_app = app.take();
1332    let old_writer = journal_writer.take();
1333
1334    // Move the local lineage aside — never delete. Divergent journals are
1335    // audit-trail material; stale ones may be the last copy of pruned
1336    // history.
1337    //
1338    // No `check_advertised_genesis` here: a resync adopts the primary's
1339    // lineage wholesale (its seed carries the primary's header) and keeps
1340    // ours intact in the archive, so no recorded genesis length is
1341    // overwritten — the hazard that check guards on the streaming path.
1342    // The genesis length is treated like the chain anchor, which a resync
1343    // also replaces.
1344    let reason = if divergent {
1345        ArchiveReason::Divergent
1346    } else {
1347        ArchiveReason::Resync
1348    };
1349    // The primary starts the transfer as soon as it has sent its verdict,
1350    // and the teardown waits on the journal: serviced (see `serviced`).
1351    source.serviced(move || {
1352        if let Some(p) = old_pipeline {
1353            // What the teardown hands back (the app and writer, or the
1354            // stage's failure) is discarded: the resync archives this
1355            // lineage and installs the primary's in its place.
1356            let _ = teardown_replica_pipeline::<A, W>(p);
1357        }
1358        // Dropped after the teardown and before the archive, as before.
1359        drop(old_app);
1360        drop(old_writer);
1361        // Where the lineage went is logged by the archive itself; the
1362        // resync needs only the paths freed.
1363        archive_local_lineage(store, reason).map(drop)
1364    })?;
1365    // Archived — a retried handshake must present as a fresh replica,
1366    // holding no lineage, so no policy either.
1367    *last_sequence = 0;
1368    *chain_hash = [0u8; 32];
1369    control.lineage_ack_policy.store(None);
1370    // The advertised control-plane tip must shrink with the holdings:
1371    // the archived suffix is either divergent (never acked) or being
1372    // replaced wholesale, so this is the one legitimate tip regression
1373    // (see `AdvertisedJournalTip::reset`).
1374    control
1375        .journal_tip
1376        .reset(melin_transport_core::WireSeq::new(0));
1377
1378    let ResyncTransfer {
1379        app: snap_app,
1380        snap_sequence,
1381        snap_chain_hash,
1382        seed_len,
1383        lineage: snap_lineage,
1384    } = match receive_resync_transfer::<A, S>(source, store) {
1385        Ok(v) => v,
1386        Err(e) => {
1387            tracing::warn!(error = %e, "snapshot transfer failed — retrying");
1388            // Half-applied resync state, not audit material — drop it
1389            // so the retry starts clean (the pre-resync lineage is
1390            // already archived; this is not it).
1391            let _ = std::fs::remove_file(store.snapshot());
1392            return Ok(ResyncDecision::Retry);
1393        }
1394    };
1395    let journal_path = store.journal();
1396    // Adopt the primary's snapshot lineage — the resync rebases this
1397    // replica onto the primary's lineage, its epoch and ack policy
1398    // included. Only now: a transfer that failed part-way adopts nothing.
1399    adopt_recovered_lineage(fence_state, &control.lineage_ack_policy, snap_lineage);
1400    *app = Some(snap_app);
1401
1402    // Open the seeded segment for appending at the snapshot position —
1403    // recovery's resume path: the chain rebuilds from the seeded bytes and
1404    // its value at `snap_sequence` must equal the (verified) snapshot's
1405    // chain hash. `chain_hash()` is `None` only with `hash-chain` disabled
1406    // (nothing to tie); an all-zeros snapshot hash means the primary runs
1407    // without `hash-chain` (also nothing to tie).
1408    // Rebuilds the chain over the whole seed: serviced (see `serviced`).
1409    let writer = source.serviced(|| W::open_append(journal_path, snap_sequence, seed_len))?;
1410    let seeded_chain = writer.chain_hash().unwrap_or(snap_chain_hash);
1411    if snap_chain_hash != [0u8; 32] && seeded_chain != snap_chain_hash {
1412        return Err(format!(
1413            "segment seed chain at {snap_sequence} disagrees with the transferred snapshot's \
1414             hash — inconsistent primary"
1415        )
1416        .into());
1417    }
1418    let seeded_info = melin_journal::segment::read_header_info(journal_path)?;
1419    *journal_writer = Some(writer);
1420
1421    // Validate the post-snapshot StreamStart inline before resuming — its
1422    // lineage must agree with the seed the primary just transferred.
1423    match decode_primary_message(&source.next_frame(MAX_CONTROL_FRAME)?)? {
1424        PrimaryMessage::StreamStart {
1425            start_sequence,
1426            segment_start_sequence,
1427            anchor_hash,
1428            genesis_entries,
1429            epoch,
1430        } => {
1431            if segment_start_sequence != seeded_info.starting_sequence
1432                || anchor_hash != seeded_info.anchor_hash
1433                || genesis_entries != seeded_info.genesis_entries
1434            {
1435                return Err(format!(
1436                    "post-snapshot StreamStart lineage (start {segment_start_sequence}, genesis \
1437                     {}) disagrees with the transferred segment seed (start {}, genesis {}) — \
1438                     inconsistent primary",
1439                    describe_genesis(genesis_entries),
1440                    seeded_info.starting_sequence,
1441                    describe_genesis(seeded_info.genesis_entries),
1442                )
1443                .into());
1444            }
1445            // We just rebased onto this primary's snapshot, so adopt its
1446            // epoch wholesale (no stale-primary refusal — our prior state
1447            // was discarded).
1448            // The lineage policy came with the snapshot (adopted above,
1449            // once the transfer succeeded); the stream's changes keep it
1450            // current from here.
1451            fence_state.observe_epoch(epoch);
1452            control.primary_observed.store(true, Ordering::Release);
1453            tracing::info!(
1454                start_sequence,
1455                epoch,
1456                "streaming resumed after snapshot transfer"
1457            );
1458            // The transfer is installed and validated: the node's holdings
1459            // are exactly the snapshot position again.
1460            control
1461                .journal_tip
1462                .reset(melin_transport_core::WireSeq::new(snap_sequence));
1463            Ok(ResyncDecision::Ready {
1464                segment_start_sequence,
1465                anchor_hash,
1466                genesis_entries,
1467                resume_sequence: snap_sequence,
1468            })
1469        }
1470        other => Err(format!("expected StreamStart after snapshot, got {other:?}").into()),
1471    }
1472}
1473
1474/// A genesis length for a log line or an error: the count, or that it
1475/// is unknown.
1476fn describe_genesis(genesis_entries: Option<u64>) -> String {
1477    match genesis_entries {
1478        Some(n) => format!("{n} entries"),
1479        None => "unknown".to_string(),
1480    }
1481}
1482
1483/// The genesis length this replica's journal records, for the
1484/// `StreamStart` cross-check: `None` when the replica has no journal yet
1485/// (nothing to compare), `Some(None)` for a lineage whose length is
1486/// unknown, `Some(Some(n))` otherwise.
1487///
1488/// Never reads the live segment by path while a pipeline runs: the
1489/// journal's disk thread owns the writer then and a rotation renames the
1490/// live segment away before installing its successor, so a read in that
1491/// window would find no file. The pipeline carries the value captured at
1492/// build time instead.
1493pub(in crate::replication) fn local_lineage_genesis<A, W>(
1494    pipeline: &Option<ReplicaPipelineHandles<A, W>>,
1495    journal_writer: &Option<W>,
1496) -> Result<Option<Option<u64>>, melin_journal::JournalError>
1497where
1498    A: Application,
1499    W: JournalWrite<A::Event> + Send + 'static,
1500{
1501    match (pipeline, journal_writer) {
1502        (Some(p), _) => Ok(Some(p.genesis_entries)),
1503        // No pipeline: this thread owns the writer, so the header read
1504        // cannot race a rotation.
1505        (None, Some(w)) => Ok(Some(w.read_header_info()?.genesis_entries)),
1506        (None, None) => Ok(None),
1507    }
1508}
1509
1510/// Where [`adopt_genesis_into_empty_unknown_journal`] stages the
1511/// rewritten segment before renaming it over the journal.
1512fn restamp_staging_path(journal_path: &std::path::Path) -> std::path::PathBuf {
1513    journal_path.with_extension("restamp.tmp")
1514}
1515
1516/// Remove a restamp staging file left by a crash before its rename. The
1517/// rename had not happened, so the live journal is the old segment and
1518/// the staged copy is only debris.
1519fn remove_restamp_staging(journal_path: &std::path::Path) -> std::io::Result<()> {
1520    match std::fs::remove_file(restamp_staging_path(journal_path)) {
1521        Ok(()) => Ok(()),
1522        // No leftover from an interrupted earlier attempt.
1523        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1524        Err(e) => Err(e),
1525    }
1526}
1527
1528/// Record the primary's genesis length in this replica's journal when
1529/// the journal holds no entry and its header has no length (`None`,
1530/// written by a release before the field existed).
1531///
1532/// The case it closes: a cluster created under such a release whose
1533/// primary stopped before journaling anything, after this replica copied
1534/// its empty journal. The upgraded primary journals its genesis into a
1535/// new header that keeps the anchor (see `genesis_target` in the server
1536/// boot path), so this replica's chain still matches at sequence 0 and it
1537/// streams the genesis — but, left with `None`, it would skip the
1538/// promotion check for exactly the partial genesis that check exists to
1539/// refuse, and its segment would no longer be a copy of the primary's.
1540///
1541/// Only an empty segment starting at sequence 1 with no archive and the
1542/// primary's own starting sequence and anchor is rewritten: nothing was
1543/// journaled, acknowledged or served from it, so replacing its header
1544/// changes no history. The new segment is staged and renamed over the old
1545/// one, so a crash leaves one or the other. Anything else is left alone.
1546///
1547/// Requires no running pipeline: the caller owns `journal_writer`. So it
1548/// covers the upgrade that restarts the replica as well as the primary; a
1549/// replica left running across the primary's re-genesis keeps the unknown
1550/// length — the unchecked behaviour of earlier releases, never a wrong
1551/// refusal.
1552pub(in crate::replication) fn adopt_genesis_into_empty_unknown_journal<E, W>(
1553    journal_writer: &mut Option<W>,
1554    lineage: (u64, [u8; 32], Option<u64>),
1555) -> Result<(), melin_journal::JournalError>
1556where
1557    E: melin_app::AppEvent,
1558    W: JournalWrite<E>,
1559{
1560    let (segment_start_sequence, anchor_hash, advertised) = lineage;
1561    let Some(writer) = journal_writer.as_ref() else {
1562        return Ok(());
1563    };
1564    if advertised.is_none()
1565        || segment_start_sequence != 1
1566        || writer.segment_starting_sequence() != 1
1567        || writer.next_sequence() != 1
1568    {
1569        return Ok(());
1570    }
1571    let info = writer.read_header_info()?;
1572    if info.genesis_entries.is_some() || info.anchor_hash != anchor_hash {
1573        return Ok(());
1574    }
1575    let journal_path = writer.path().to_path_buf();
1576    if !melin_journal::segment::list_archives(&journal_path)?.is_empty() {
1577        return Ok(());
1578    }
1579
1580    let staging = restamp_staging_path(&journal_path);
1581    remove_restamp_staging(&journal_path)?;
1582    let staged = W::create_continuing(&staging, 1, anchor_hash, advertised)?;
1583    let valid_end = staged.valid_end();
1584    drop(staged);
1585    // Close the old segment before its name is taken over.
1586    drop(journal_writer.take());
1587    std::fs::rename(&staging, &journal_path)?;
1588    melin_journal::segment::fsync_parent_dir(&journal_path)?;
1589    *journal_writer = Some(W::open_append(&journal_path, 0, valid_end)?);
1590    tracing::info!(
1591        journal = %journal_path.display(),
1592        genesis_entries = ?advertised,
1593        "recorded the primary's genesis length in this replica's empty journal"
1594    );
1595    Ok(())
1596}
1597
1598/// The replica's answer to a live `StreamStart` (not the post-snapshot
1599/// one, which `handle_resync_verdict` validates itself).
1600pub(in crate::replication) enum StreamStartVerdict {
1601    /// The primary is behind this node's fencing epoch: a stale
1602    /// ex-primary. Nothing was adopted; the caller drops the connection
1603    /// and retries with backoff. The handshake already carried our higher
1604    /// epoch, so the stale primary fences itself on its side.
1605    StalePrimary,
1606    /// Follow the primary. Its epoch is adopted, and the primary recorded
1607    /// as observed; the lineage — `(segment start, anchor, genesis length)` — is what a
1608    /// fresh replica creates its journal from.
1609    Follow((u64, [u8; 32], Option<u64>)),
1610}
1611
1612/// The fields of a live `StreamStart` that [`accept_stream_start`] acts
1613/// on, grouped so both receivers hand them over by name rather than as a
1614/// run of positional integers.
1615pub(in crate::replication) struct StreamStart {
1616    /// The sequence the primary resumes streaming from (logged only).
1617    pub start_sequence: u64,
1618    /// The primary's lineage: `(segment start, anchor, genesis length)`.
1619    /// A tuple because it is handed back whole in
1620    /// [`StreamStartVerdict::Follow`] and to the fresh-journal path.
1621    pub lineage: (u64, [u8; 32], Option<u64>),
1622    /// The primary's fencing epoch.
1623    pub epoch: u64,
1624}
1625
1626/// Accept or refuse a primary's `StreamStart`, shared by the kernel-TCP
1627/// and DPDK receivers so the two cannot drift on what following a
1628/// primary means.
1629///
1630/// In order: refuse a primary behind our fencing epoch — following its
1631/// divergent lineage on top of our more-current state would corrupt the
1632/// journal — before anything is touched; record the primary's genesis
1633/// length in an empty pre-upgrade journal; refuse a lineage whose genesis
1634/// length disagrees with ours; then adopt the primary's epoch (streamed
1635/// `EpochBump`s keep it current thereafter) and record that a primary has
1636/// been observed. The ack policy is not taken from the primary: the
1637/// replica learns it from the changes its lineage holds.
1638///
1639/// An `Err` is fatal for the receive loop: the journal could not be read
1640/// or rewritten, or the two journals do not share one lineage.
1641///
1642/// `transport` names the receiver (`"tcp"` or `"dpdk"`) in the refusal
1643/// and streaming-started logs, so an operator running both can tell
1644/// which path a line came from.
1645pub(in crate::replication) fn accept_stream_start<A, W>(
1646    start: StreamStart,
1647    transport: &'static str,
1648    pipeline: &Option<ReplicaPipelineHandles<A, W>>,
1649    journal_writer: &mut Option<W>,
1650    fence_state: &melin_transport_core::fence::FenceState,
1651    control: &ReplicaControlPlane,
1652) -> Result<StreamStartVerdict, Box<dyn std::error::Error>>
1653where
1654    A: Application,
1655    W: JournalWrite<A::Event> + Send + 'static,
1656{
1657    let StreamStart {
1658        start_sequence,
1659        lineage,
1660        epoch,
1661    } = start;
1662    if fence_state.refuses_primary(epoch) {
1663        tracing::warn!(
1664            transport,
1665            primary_epoch = epoch,
1666            our_epoch = fence_state.epoch(),
1667            "primary is behind our fencing epoch — refusing to follow stale primary"
1668        );
1669        return Ok(StreamStartVerdict::StalePrimary);
1670    }
1671    // A replica with a journal already records its lineage's genesis
1672    // length; the primary's must agree with it.
1673    if pipeline.is_none() {
1674        adopt_genesis_into_empty_unknown_journal(journal_writer, lineage)?;
1675    }
1676    if let Some(local) = local_lineage_genesis(pipeline, journal_writer)? {
1677        check_advertised_genesis(local, lineage.2)?;
1678    }
1679    fence_state.observe_epoch(epoch);
1680    control.primary_observed.store(true, Ordering::Release);
1681    tracing::info!(transport, start_sequence, epoch, "streaming started");
1682    Ok(StreamStartVerdict::Follow(lineage))
1683}
1684
1685/// Refuse to follow a primary whose lineage records a different genesis
1686/// length than this replica's journal does.
1687///
1688/// The length is lineage metadata, fixed when the journal was created
1689/// and copied into every segment and every replica's journal since, so
1690/// within one lineage two known values always agree. A difference means
1691/// the two journals do not share the lineage the matching chain hashes
1692/// suggest — a header damaged or edited by hand, or a bug — and nothing
1693/// says which side is right: overwriting ours would silently change what
1694/// a later promotion checks against. Exit and let the operator decide.
1695///
1696/// An unknown value on either side is no conflict: it is a lineage begun
1697/// before the length was recorded (or a peer that kept such a header),
1698/// and it stays as it is — headers are written once.
1699pub(in crate::replication) fn check_advertised_genesis(
1700    local: Option<u64>,
1701    advertised: Option<u64>,
1702) -> Result<(), String> {
1703    match (local, advertised) {
1704        (Some(local), Some(advertised)) if local != advertised => Err(format!(
1705            "refusing to follow the primary: its journal records a genesis of {advertised} \
1706             entries but this replica's records {local} — the two journals do not share one \
1707             lineage (a damaged or hand-edited journal header, or a bug); inspect both journals, \
1708             then resync this replica from scratch (move its journal, archives and snapshots \
1709             aside) or restore the primary"
1710        )),
1711        _ => Ok(()),
1712    }
1713}
1714
1715#[cfg(test)]
1716mod tests {
1717    use std::io::Write;
1718    use std::sync::Arc;
1719
1720    use super::auth::{authenticate_replica, authenticate_with_primary};
1721    use super::*;
1722    // Any `AppEvent` works as the pipeline's event type here — these
1723    // protocol-level tests never construct a real app event (the slot's
1724    // event is always `JournalEvent::Tick`), so the counter example's
1725    // event type stands in for a production application's event and keeps
1726    // the runtime's test deps free of any application crate but the example.
1727    use counter_server::CounterEvent;
1728    type InputSlot = melin_transport_core::pipeline::InputSlot<CounterEvent>;
1729    use melin_transport_core::replication::protocol::{
1730        Ack, Handshake, MAX_CONTROL_FRAME, MAX_DATA_FRAME, MSG_AUTH_OK, MSG_CHALLENGE_RESPONSE,
1731        MSG_SNAPSHOT_BEGIN, MSG_SNAPSHOT_CHUNK, MSG_SNAPSHOT_END, ReplicaMessage,
1732        decode_auth_result, decode_challenge, decode_challenge_response, decode_primary_message,
1733        decode_replica_message, encode_ack, encode_auth_failed, encode_auth_ok, encode_chain_check,
1734        encode_challenge, encode_challenge_response, encode_handshake, encode_hash_mismatch,
1735        encode_heartbeat, encode_need_snapshot, encode_rotate, encode_segment_seed_begin,
1736        encode_snapshot_begin, encode_snapshot_chunk, encode_snapshot_end, encode_stream_start,
1737        read_frame,
1738    };
1739    use melin_transport_core::replication_wire::{encode_input_batch, try_decode_input_batch};
1740
1741    /// Build a wire-ready `InputBatch` frame containing a single `Tick`
1742    /// slot at the given sequence — the protocol-level tests don't need
1743    /// real journal payloads, just something with a known max sequence.
1744    fn encode_input_batch_with_seq(end_sequence: u64, buf: &mut Vec<u8>) {
1745        let slot = InputSlot {
1746            connection_id: 0,
1747            request_id: melin_transport_core::pipeline::RequestId(0),
1748            key_hash: 0,
1749            sequence: end_sequence,
1750            timestamp_ns: 0,
1751            event: melin_journal::JournalEvent::Tick { now_ns: 0 },
1752            publish_ts: Default::default(),
1753            recv_ts: Default::default(),
1754        };
1755        encode_input_batch(&[slot], buf).expect("encode InputBatch");
1756    }
1757
1758    #[test]
1759    fn handshake_encode_decode_round_trip() {
1760        let handshake = Handshake {
1761            last_sequence: 42,
1762            chain_hash: [0xAB; 32],
1763            // Non-zero so a dropped/zeroed epoch field is caught.
1764            epoch: 9,
1765        };
1766        let mut buf = Vec::new();
1767        encode_handshake(&handshake, &mut buf);
1768
1769        // Read frame: skip 4-byte length prefix.
1770        let payload = &buf[4..];
1771        let msg = decode_replica_message(payload).unwrap();
1772        match msg {
1773            ReplicaMessage::Handshake(h) => {
1774                assert_eq!(h.last_sequence, 42);
1775                assert_eq!(h.chain_hash, [0xAB; 32]);
1776                assert_eq!(h.epoch, 9);
1777            }
1778            _ => panic!("expected Handshake"),
1779        }
1780    }
1781
1782    #[test]
1783    fn ack_encode_decode_round_trip() {
1784        let ack = Ack {
1785            acked_sequence: 1000,
1786            in_memory_sequence: 1024,
1787        };
1788        let mut buf = Vec::new();
1789        encode_ack(&ack, &mut buf);
1790
1791        let payload = &buf[4..];
1792        let msg = decode_replica_message(payload).unwrap();
1793        match msg {
1794            ReplicaMessage::Ack(a) => {
1795                assert_eq!(a.acked_sequence, 1000);
1796                assert_eq!(a.in_memory_sequence, 1024);
1797            }
1798            _ => panic!("expected Ack"),
1799        }
1800    }
1801
1802    /// Pin the exact on-the-wire byte layout of an Ack frame. A future
1803    /// `repr(C)` field reorder, alignment change, or accidental
1804    /// big-endian wrapper substitution would silently break replica/
1805    /// primary compatibility — the const `size_of` assert in
1806    /// `protocol.rs` only catches size changes, not layout changes.
1807    /// This test pins the expected bytes so any such break shows up
1808    /// loudly on the next CI run.
1809    #[test]
1810    fn ack_wire_byte_pattern() {
1811        let ack = Ack {
1812            acked_sequence: 0xDEAD_BEEF_CAFE_F00D,
1813            in_memory_sequence: 0x1122_3344_5566_7788,
1814        };
1815        let mut buf = Vec::new();
1816        encode_ack(&ack, &mut buf);
1817        // [length:u32 LE = 17][tag:u8 = MSG_ACK = 0x02]
1818        // [acked_sequence:u64 LE][in_memory_sequence:u64 LE]
1819        let expected: &[u8] = &[
1820            0x11, 0x00, 0x00, 0x00, // length = 17 LE
1821            0x02, // MSG_ACK
1822            0x0D, 0xF0, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE, // acked_sequence LE
1823            0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, // in_memory_sequence LE
1824        ];
1825        assert_eq!(buf.as_slice(), expected, "Ack wire layout drifted");
1826    }
1827
1828    /// Two known genesis lengths must agree; an unknown one on either
1829    /// side is no conflict (a lineage begun before the length was
1830    /// recorded), and nothing is ever adopted over a known one.
1831    #[test]
1832    fn an_advertised_genesis_length_must_match_a_known_one() {
1833        check_advertised_genesis(Some(3), Some(3)).expect("the same lineage");
1834        check_advertised_genesis(Some(0), Some(0)).expect("no genesis on both sides");
1835        let err = check_advertised_genesis(Some(3), Some(4)).expect_err("different lengths");
1836        assert!(err.contains("refusing to follow the primary"), "{err}");
1837        assert!(
1838            err.contains("genesis of 4") && err.contains("records 3"),
1839            "{err}"
1840        );
1841        check_advertised_genesis(Some(0), Some(2)).expect_err("none against some");
1842        check_advertised_genesis(None, Some(3)).expect("ours unknown");
1843        check_advertised_genesis(Some(3), None).expect("theirs unknown");
1844        check_advertised_genesis(None, None).expect("both unknown");
1845    }
1846
1847    /// An empty journal written before the genesis length existed takes
1848    /// the primary's, so the promotion check guards it; a journal that
1849    /// holds an entry, already has a length, or carries another anchor is
1850    /// left as it is.
1851    #[test]
1852    fn an_empty_unknown_journal_adopts_the_primarys_genesis_length() {
1853        type Writer = melin_journal::BufferedWriter<CounterEvent>;
1854        let dir = tempfile::tempdir().expect("tempdir");
1855        let anchor = [9u8; 32];
1856        let header = |w: &Option<Writer>| {
1857            w.as_ref()
1858                .expect("writer")
1859                .read_header_info()
1860                .expect("header")
1861                .genesis_entries
1862        };
1863
1864        // The pre-upgrade empty journal: adopted, still empty, same anchor.
1865        let path = dir.path().join("empty.journal");
1866        let mut w = Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
1867        adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("adopt");
1868        assert_eq!(header(&w), Some(3));
1869        let adopted = w.as_ref().expect("writer");
1870        assert_eq!(adopted.next_sequence(), 1);
1871        assert_eq!(adopted.path(), path.as_path());
1872        assert_eq!(
1873            melin_journal::segment::read_header_info(&path)
1874                .expect("on disk")
1875                .anchor_hash,
1876            anchor
1877        );
1878        assert!(!path.with_extension("restamp.tmp").exists());
1879
1880        // The primary's length is unknown too: nothing to adopt.
1881        let path = dir.path().join("both-unknown.journal");
1882        let mut w = Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
1883        adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, None)).expect("no-op");
1884        assert_eq!(header(&w), None);
1885
1886        // Another lineage's anchor: not this primary's journal.
1887        let path = dir.path().join("other-anchor.journal");
1888        let mut w = Some(Writer::create_continuing(&path, 1, [1; 32], None).expect("create"));
1889        adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
1890        assert_eq!(header(&w), None);
1891
1892        // A known length is never rewritten (a mismatch is refused by
1893        // `check_advertised_genesis` instead).
1894        let path = dir.path().join("known.journal");
1895        let mut w = Some(Writer::create_continuing(&path, 1, anchor, Some(2)).expect("create"));
1896        adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
1897        assert_eq!(header(&w), Some(2));
1898
1899        // A journal holding an entry has history: its header stays.
1900        let path = dir.path().join("history.journal");
1901        let mut writer = Writer::create_continuing(&path, 1, anchor, None).expect("create");
1902        let seq = writer.allocate_sequence();
1903        writer
1904            .encode_event(seq, 0, &melin_journal::JournalEvent::Tick { now_ns: 0 }, 0)
1905            .expect("encode");
1906        writer.flush_batch_sync().expect("flush");
1907        let mut w = Some(writer);
1908        adopt_genesis_into_empty_unknown_journal(&mut w, (1, anchor, Some(3))).expect("no-op");
1909        assert_eq!(header(&w), None);
1910
1911        // No journal: nothing to do.
1912        let mut none: Option<Writer> = None;
1913        adopt_genesis_into_empty_unknown_journal(&mut none, (1, anchor, Some(3))).expect("no-op");
1914        assert!(none.is_none());
1915    }
1916
1917    /// A restamp staging file left by a crash before its rename is swept
1918    /// at replica recovery, whatever the journal's state — not only by a
1919    /// later adopt that happens to meet its guard.
1920    #[test]
1921    fn replica_recovery_sweeps_a_leftover_restamp_staging_file() {
1922        type Writer = melin_journal::BufferedWriter<CounterEvent>;
1923        let dir = tempfile::tempdir().expect("tempdir");
1924        let fence = melin_transport_core::fence::FenceState::new(0);
1925        let journal = dir.path().join("replica.journal");
1926        let store = NodeStore::resolve(journal.clone(), None).expect("store");
1927        let staging = journal.with_extension("restamp.tmp");
1928
1929        // No lineage at all.
1930        std::fs::write(&staging, b"debris").expect("write staging");
1931        let (app, writer, _, _) = recover_replica_state::<counter_server::Counter, Writer>(
1932            &store,
1933            &fence,
1934            &SharedLineagePolicy::new(None),
1935            &(),
1936        )
1937        .expect("recover");
1938        assert!(app.is_none() && writer.is_none());
1939        assert!(!staging.exists(), "swept without a journal");
1940
1941        // A journal that holds history, which the adopt guard never touches.
1942        let mut w = Writer::create_continuing(&journal, 1, [0; 32], None).expect("create");
1943        let seq = w.allocate_sequence();
1944        w.encode_event(seq, 0, &melin_journal::JournalEvent::Tick { now_ns: 0 }, 0)
1945            .expect("encode");
1946        w.flush_batch_sync().expect("flush");
1947        drop(w);
1948        std::fs::write(&staging, b"debris").expect("write staging");
1949        let (_, writer, last, _) = recover_replica_state::<counter_server::Counter, Writer>(
1950            &store,
1951            &fence,
1952            &SharedLineagePolicy::new(None),
1953            &(),
1954        )
1955        .expect("recover");
1956        assert!(writer.is_some());
1957        assert_eq!(last, 1);
1958        assert!(!staging.exists(), "swept beside a journal with history");
1959    }
1960
1961    /// Replica recovery adopts the recovered lineage: the epoch as a
1962    /// maximum, the ack policy stored whole over whatever the cell held —
1963    /// the journaled change (a lower byte than the cell's: stored, not a
1964    /// maximum), or nothing when the journal holds none, or when there is
1965    /// no lineage at all.
1966    #[test]
1967    fn replica_recovery_stores_the_lineage_policy_whole() {
1968        use melin_journal::{AckPolicyByte, AckPolicyChange, JournalEvent};
1969        use melin_transport_core::lineage::LineagePolicy;
1970        type Writer = melin_journal::BufferedWriter<CounterEvent>;
1971        let dir = tempfile::tempdir().expect("tempdir");
1972        let fence = melin_transport_core::fence::FenceState::new(0);
1973        let journal = dir.path().join("replica.journal");
1974        let store = NodeStore::resolve(journal.clone(), None).expect("store");
1975        // A value no recovery below produces, so a record left alone
1976        // shows.
1977        let stale = Some(LineagePolicy {
1978            change: AckPolicyChange {
1979                policy: AckPolicyByte(2),
1980                unwitnessed: true,
1981            },
1982            wire_seq: melin_transport_core::WireSeq::new(9),
1983            previous: Some(AckPolicyByte(1)),
1984        });
1985        let cell = SharedLineagePolicy::new(stale);
1986        let recover = |cell: &SharedLineagePolicy| {
1987            recover_replica_state::<counter_server::Counter, Writer>(&store, &fence, cell, &())
1988                .expect("recover")
1989        };
1990
1991        // No lineage: nothing held.
1992        recover(&cell);
1993        assert_eq!(cell.load(), None);
1994
1995        // A journal with no change: nothing held either.
1996        let mut w = Writer::create_continuing(&journal, 1, [0; 32], None).expect("create");
1997        let append = |w: &mut Writer, event: JournalEvent<CounterEvent>| {
1998            let seq = w.allocate_sequence();
1999            w.encode_event(seq, 0, &event, 0).expect("encode");
2000            w.flush_batch_sync().expect("flush");
2001        };
2002        append(&mut w, JournalEvent::Tick { now_ns: 0 });
2003        cell.store(stale);
2004        recover(&cell);
2005        assert_eq!(cell.load(), None);
2006
2007        append(&mut w, JournalEvent::EpochBump { epoch: 4 });
2008        append(
2009            &mut w,
2010            JournalEvent::AckPolicyChange(AckPolicyChange {
2011                policy: AckPolicyByte(0),
2012                unwitnessed: false,
2013            }),
2014        );
2015        drop(w);
2016        cell.store(stale);
2017        recover(&cell);
2018        assert_eq!(
2019            cell.load(),
2020            Some(LineagePolicy {
2021                change: AckPolicyChange {
2022                    policy: AckPolicyByte(0),
2023                    unwitnessed: false,
2024                },
2025                wire_seq: melin_transport_core::WireSeq::new(3),
2026                previous: None,
2027            }),
2028            "the journal's own record, at the change's sequence"
2029        );
2030        assert_eq!(fence.epoch(), 4);
2031    }
2032
2033    /// A primary behind this node's fencing epoch is refused before
2034    /// anything is adopted: not its epoch, not its genesis length into our
2035    /// journal, and it does not count as a primary observed.
2036    #[test]
2037    fn a_stale_primary_is_refused_before_anything_is_adopted() {
2038        type Writer = melin_journal::BufferedWriter<CounterEvent>;
2039        let dir = tempfile::tempdir().expect("tempdir");
2040        let path = dir.path().join("replica.journal");
2041        let anchor = [9u8; 32];
2042        let mut journal_writer =
2043            Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
2044        let fence = melin_transport_core::fence::FenceState::new(5);
2045        let control = ReplicaControlPlane::new();
2046
2047        let verdict = accept_stream_start::<counter_server::Counter, Writer>(
2048            StreamStart {
2049                start_sequence: 1,
2050                lineage: (1, anchor, Some(3)),
2051                epoch: 4,
2052            },
2053            "test",
2054            &None,
2055            &mut journal_writer,
2056            &fence,
2057            &control,
2058        )
2059        .expect("a refusal is not an error");
2060
2061        assert!(matches!(verdict, StreamStartVerdict::StalePrimary));
2062        assert_eq!(fence.epoch(), 5);
2063        assert!(!control.primary_observed.load(Ordering::Acquire));
2064        let header = journal_writer
2065            .as_ref()
2066            .expect("writer")
2067            .read_header_info()
2068            .expect("header");
2069        assert_eq!(header.genesis_entries, None, "journal left untouched");
2070    }
2071
2072    /// Following a primary adopts its epoch, records it as observed and
2073    /// its genesis length in an empty pre-upgrade journal, and hands back
2074    /// the lineage the fresh-journal path creates from. The lineage's ack
2075    /// policy is left to the entries this replica holds.
2076    #[test]
2077    fn following_a_primary_adopts_its_epoch_and_genesis() {
2078        type Writer = melin_journal::BufferedWriter<CounterEvent>;
2079        let dir = tempfile::tempdir().expect("tempdir");
2080        let path = dir.path().join("replica.journal");
2081        let anchor = [9u8; 32];
2082        let mut journal_writer =
2083            Some(Writer::create_continuing(&path, 1, anchor, None).expect("create"));
2084        let fence = melin_transport_core::fence::FenceState::new(5);
2085        let control = ReplicaControlPlane::new();
2086        let held = Some(melin_transport_core::lineage::LineagePolicy::after(
2087            None,
2088            melin_transport_core::WireSeq::new(1),
2089            melin_journal::AckPolicyChange {
2090                policy: crate::ack_policy::AckPolicy::DiskAndRam.to_byte(),
2091                unwitnessed: false,
2092            },
2093        ));
2094        control.lineage_ack_policy.store(held);
2095
2096        let verdict = accept_stream_start::<counter_server::Counter, Writer>(
2097            StreamStart {
2098                start_sequence: 1,
2099                lineage: (1, anchor, Some(3)),
2100                epoch: 7,
2101            },
2102            "test",
2103            &None,
2104            &mut journal_writer,
2105            &fence,
2106            &control,
2107        )
2108        .expect("same lineage");
2109
2110        let StreamStartVerdict::Follow(lineage) = verdict else {
2111            panic!("expected Follow");
2112        };
2113        assert_eq!(lineage, (1, anchor, Some(3)));
2114        assert_eq!(fence.epoch(), 7);
2115        assert!(control.primary_observed.load(Ordering::Acquire));
2116        assert_eq!(control.lineage_ack_policy.load(), held, "untouched");
2117        let header = journal_writer
2118            .as_ref()
2119            .expect("writer")
2120            .read_header_info()
2121            .expect("header");
2122        assert_eq!(header.genesis_entries, Some(3));
2123    }
2124
2125    /// A primary whose genesis length disagrees with ours is fatal, and
2126    /// its epoch is not adopted on the way out.
2127    #[test]
2128    fn a_disagreeing_genesis_length_is_fatal() {
2129        type Writer = melin_journal::BufferedWriter<CounterEvent>;
2130        let dir = tempfile::tempdir().expect("tempdir");
2131        let path = dir.path().join("replica.journal");
2132        let anchor = [9u8; 32];
2133        let mut journal_writer =
2134            Some(Writer::create_continuing(&path, 1, anchor, Some(2)).expect("create"));
2135        let fence = melin_transport_core::fence::FenceState::new(5);
2136        let control = ReplicaControlPlane::new();
2137
2138        let err = accept_stream_start::<counter_server::Counter, Writer>(
2139            StreamStart {
2140                start_sequence: 1,
2141                lineage: (1, anchor, Some(3)),
2142                epoch: 7,
2143            },
2144            "test",
2145            &None,
2146            &mut journal_writer,
2147            &fence,
2148            &control,
2149        )
2150        .err()
2151        .expect("different lineages");
2152
2153        assert!(err.to_string().contains("refusing to follow the primary"));
2154        assert_eq!(fence.epoch(), 5);
2155    }
2156
2157    /// The handshake's local genesis length comes from the running
2158    /// pipeline, never from the live segment's path: a rotation renames
2159    /// that file away before installing its successor, and a read in the
2160    /// gap would fail the session. Removing the file stands in for that
2161    /// gap. Without a pipeline the writer's own header is read, and with
2162    /// no journal at all there is nothing to compare.
2163    #[test]
2164    fn the_local_genesis_length_never_reads_the_live_segment_under_a_pipeline() {
2165        type Writer = melin_journal::BufferedWriter<CounterEvent>;
2166        let dir = tempfile::tempdir().expect("tempdir");
2167        let path = dir.path().join("replica.journal");
2168
2169        let writer = Writer::create_continuing(&path, 1, [7; 32], Some(5)).expect("create");
2170        let mut journal_writer = Some(writer);
2171        assert_eq!(
2172            local_lineage_genesis::<counter_server::Counter, Writer>(&None, &journal_writer)
2173                .expect("header read"),
2174            Some(Some(5)),
2175        );
2176
2177        let (input_producer, mut consumers) =
2178            melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
2179                .add_consumer()
2180                .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
2181        let _consumer = consumers.pop().expect("one consumer");
2182        let owned = journal_writer.take().expect("writer");
2183        let pipeline = Some(ReplicaPipelineHandles {
2184            input_producer,
2185            journal_cursor: Arc::new(make_journal_cursor(0)),
2186            chain_hash_lock: fsync_state_at(0, 0).1,
2187            stream_marks: melin_transport_core::pipeline::StreamMarks::shared(),
2188            genesis_entries: Some(5),
2189            journal_failed: Arc::new(AtomicBool::new(false)),
2190            pipeline_shutdown: Arc::new(AtomicBool::new(false)),
2191            journal_handle: std::thread::spawn(
2192                move || -> Result<Writer, melin_journal::JournalError> { Ok(owned) },
2193            ),
2194            matching_handle: std::thread::spawn(counter_server::Counter::default),
2195            drain_handle: std::thread::spawn(|| {}),
2196            shadow_handle: None,
2197        });
2198        std::fs::remove_file(&path).expect("simulate the rotation gap");
2199        assert_eq!(
2200            local_lineage_genesis(&pipeline, &journal_writer).expect("no file read"),
2201            Some(Some(5)),
2202        );
2203
2204        assert_eq!(
2205            local_lineage_genesis::<counter_server::Counter, Writer>(&None, &None)
2206                .expect("nothing to read"),
2207            None,
2208        );
2209    }
2210
2211    #[test]
2212    fn stream_start_encode_decode_round_trip() {
2213        let mut buf = Vec::new();
2214        // Non-zero epoch so a dropped/zeroed field is caught.
2215        encode_stream_start(99, 42, [0xAA; 32], Some(7), 5, &mut buf);
2216
2217        let payload = &buf[4..];
2218        let msg = decode_primary_message(payload).unwrap();
2219        match msg {
2220            PrimaryMessage::StreamStart {
2221                start_sequence,
2222                segment_start_sequence,
2223                anchor_hash,
2224                genesis_entries,
2225                epoch,
2226            } => {
2227                assert_eq!(start_sequence, 99);
2228                assert_eq!(segment_start_sequence, 42);
2229                assert_eq!(anchor_hash, [0xAA; 32]);
2230                assert_eq!(genesis_entries, Some(7));
2231                assert_eq!(epoch, 5);
2232            }
2233            _ => panic!("expected StreamStart"),
2234        }
2235    }
2236
2237    #[test]
2238    fn heartbeat_encode_decode_round_trip() {
2239        let mut buf = Vec::new();
2240        encode_heartbeat(123, &mut buf);
2241
2242        let payload = &buf[4..];
2243        let msg = decode_primary_message(payload).unwrap();
2244        match msg {
2245            PrimaryMessage::Heartbeat { sequence } => assert_eq!(sequence, 123),
2246            _ => panic!("expected Heartbeat"),
2247        }
2248    }
2249
2250    #[test]
2251    fn need_snapshot_encode_decode_round_trip() {
2252        let mut buf = Vec::new();
2253        encode_need_snapshot(&mut buf);
2254
2255        let payload = &buf[4..];
2256        let msg = decode_primary_message(payload).unwrap();
2257        assert!(matches!(msg, PrimaryMessage::NeedSnapshot));
2258    }
2259
2260    #[test]
2261    fn hash_mismatch_encode_decode_round_trip() {
2262        let mut buf = Vec::new();
2263        encode_hash_mismatch(&mut buf);
2264
2265        let payload = &buf[4..];
2266        let msg = decode_primary_message(payload).unwrap();
2267        assert!(matches!(msg, PrimaryMessage::HashMismatch));
2268    }
2269
2270    #[test]
2271    fn rotate_encode_decode_round_trip() {
2272        let mut buf = Vec::new();
2273        encode_rotate(7_000_000, &[0xBB; 32], &mut buf);
2274
2275        let payload = &buf[4..];
2276        let msg = decode_primary_message(payload).unwrap();
2277        match msg {
2278            PrimaryMessage::Rotate {
2279                boundary_seq,
2280                tail_hash,
2281            } => {
2282                assert_eq!(boundary_seq, 7_000_000);
2283                assert_eq!(tail_hash, [0xBB; 32]);
2284            }
2285            _ => panic!("expected Rotate"),
2286        }
2287    }
2288
2289    #[test]
2290    fn chain_check_encode_decode_round_trip() {
2291        let mut buf = Vec::new();
2292        encode_chain_check(555, &[0xCC; 32], &mut buf);
2293
2294        let payload = &buf[4..];
2295        let msg = decode_primary_message(payload).unwrap();
2296        match msg {
2297            PrimaryMessage::ChainCheck {
2298                sequence,
2299                chain_hash,
2300            } => {
2301                assert_eq!(sequence, 555);
2302                assert_eq!(chain_hash, [0xCC; 32]);
2303            }
2304            _ => panic!("expected ChainCheck"),
2305        }
2306    }
2307
2308    /// Pin the wire layout of the shared `(sequence, hash)` frame body —
2309    /// same rationale as `ack_wire_byte_pattern`. Rotate and ChainCheck
2310    /// share the layout; only the tag differs.
2311    #[test]
2312    fn rotate_wire_byte_pattern() {
2313        let mut hash = [0u8; 32];
2314        for (i, b) in hash.iter_mut().enumerate() {
2315            *b = i as u8;
2316        }
2317        let mut buf = Vec::new();
2318        encode_rotate(0xDEAD_BEEF_CAFE_F00D, &hash, &mut buf);
2319        // [length:u32 LE = 41][tag:u8 = MSG_ROTATE = 0x16]
2320        // [boundary_seq:u64 LE][tail_hash:32 bytes verbatim]
2321        let mut expected = vec![
2322            0x29, 0x00, 0x00, 0x00, // length = 41 LE
2323            0x16, // MSG_ROTATE
2324            0x0D, 0xF0, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE, // boundary_seq LE
2325        ];
2326        expected.extend_from_slice(&hash);
2327        assert_eq!(
2328            buf.as_slice(),
2329            expected.as_slice(),
2330            "Rotate wire layout drifted"
2331        );
2332    }
2333
2334    #[test]
2335    fn segment_seed_begin_encode_decode_round_trip() {
2336        let mut buf = Vec::new();
2337        encode_segment_seed_begin(123_456, &mut buf);
2338
2339        let payload = &buf[4..];
2340        let msg = decode_primary_message(payload).unwrap();
2341        match msg {
2342            PrimaryMessage::SegmentSeedBegin { seed_len } => {
2343                assert_eq!(seed_len, 123_456);
2344            }
2345            _ => panic!("expected SegmentSeedBegin"),
2346        }
2347    }
2348
2349    #[test]
2350    fn unknown_replica_message_type_is_error() {
2351        let payload = [0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
2352        let result = decode_replica_message(&payload);
2353        assert!(result.is_err());
2354    }
2355
2356    #[test]
2357    fn unknown_primary_message_type_is_error() {
2358        let payload = [0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
2359        let result = decode_primary_message(&payload);
2360        assert!(result.is_err());
2361    }
2362
2363    #[test]
2364    fn read_frame_enforces_max_size() {
2365        // Create a buffer with a length prefix claiming 1000 bytes.
2366        let mut buf = Vec::new();
2367        buf.extend_from_slice(&1000u32.to_le_bytes());
2368        buf.extend_from_slice(&[0u8; 100]); // not enough data, but max_size check comes first
2369
2370        let mut cursor = std::io::Cursor::new(buf);
2371        let result = read_frame(&mut cursor, 64);
2372        assert!(result.is_err());
2373    }
2374
2375    #[test]
2376    fn challenge_encode_decode_round_trip() {
2377        let nonce = [0x42; 32];
2378        let mut buf = Vec::new();
2379        encode_challenge(&nonce, &mut buf);
2380
2381        let payload = &buf[4..];
2382        let decoded = decode_challenge(payload).unwrap();
2383        assert_eq!(decoded, nonce);
2384    }
2385
2386    #[test]
2387    fn challenge_response_encode_decode_round_trip() {
2388        let sig = [0xAA; 64];
2389        let pubkey = [0xBB; 32];
2390        let mut buf = Vec::new();
2391        encode_challenge_response(&sig, &pubkey, &mut buf);
2392
2393        let payload = &buf[4..];
2394        let (decoded_sig, decoded_pubkey) = decode_challenge_response(payload).unwrap();
2395        assert_eq!(decoded_sig, sig);
2396        assert_eq!(decoded_pubkey, pubkey);
2397    }
2398
2399    #[test]
2400    fn auth_ok_encode_decode_round_trip() {
2401        let mut buf = Vec::new();
2402        encode_auth_ok(&mut buf);
2403
2404        let payload = &buf[4..];
2405        assert!(decode_auth_result(payload).unwrap());
2406    }
2407
2408    #[test]
2409    fn auth_failed_encode_decode_round_trip() {
2410        let mut buf = Vec::new();
2411        encode_auth_failed(&mut buf);
2412
2413        let payload = &buf[4..];
2414        assert!(!decode_auth_result(payload).unwrap());
2415    }
2416
2417    #[test]
2418    fn decode_challenge_rejects_wrong_tag() {
2419        let mut payload = [0u8; 33];
2420        payload[0] = MSG_AUTH_OK;
2421        assert!(decode_challenge(&payload).is_err());
2422    }
2423
2424    #[test]
2425    fn decode_challenge_response_rejects_short_payload() {
2426        let payload = [MSG_CHALLENGE_RESPONSE; 10]; // too short
2427        assert!(decode_challenge_response(&payload).is_err());
2428    }
2429
2430    #[test]
2431    fn auth_round_trip_valid_key() {
2432        use ed25519_dalek::SigningKey;
2433        use std::os::unix::net::UnixStream;
2434
2435        let repl_key = SigningKey::from_bytes(&[0xFC; 32]);
2436        let pub_b64 = base64::Engine::encode(
2437            &base64::engine::general_purpose::STANDARD,
2438            repl_key.verifying_key().to_bytes(),
2439        );
2440        let keys_content = format!("replication {pub_b64} test-replica\n");
2441        let authorized_keys =
2442            melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
2443                .unwrap();
2444
2445        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2446        primary_stream
2447            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2448            .unwrap();
2449        replica_stream
2450            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2451            .unwrap();
2452
2453        let repl_key_clone = SigningKey::from_bytes(&[0xFC; 32]);
2454        let replica_handle = std::thread::spawn(move || {
2455            let mut conn = replica_stream;
2456            authenticate_with_primary(&mut conn, &repl_key_clone)
2457        });
2458
2459        let mut conn = primary_stream;
2460        authenticate_replica(&mut conn, &authorized_keys).unwrap();
2461
2462        replica_handle.join().unwrap().unwrap();
2463    }
2464
2465    #[test]
2466    fn auth_rejects_unknown_key() {
2467        use ed25519_dalek::SigningKey;
2468        use std::os::unix::net::UnixStream;
2469
2470        // authorized_keys has one key, but the replica uses a different one.
2471        let authorized_key = SigningKey::from_bytes(&[0xAA; 32]);
2472        let rogue_key = SigningKey::from_bytes(&[0xBB; 32]);
2473        let pub_b64 = base64::Engine::encode(
2474            &base64::engine::general_purpose::STANDARD,
2475            authorized_key.verifying_key().to_bytes(),
2476        );
2477        let keys_content = format!("replication {pub_b64} authorized-replica\n");
2478        let authorized_keys =
2479            melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
2480                .unwrap();
2481
2482        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2483        primary_stream
2484            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2485            .unwrap();
2486        replica_stream
2487            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2488            .unwrap();
2489
2490        let replica_handle = std::thread::spawn(move || {
2491            let mut conn = replica_stream;
2492            authenticate_with_primary(&mut conn, &rogue_key)
2493        });
2494
2495        let mut conn = primary_stream;
2496        let result = authenticate_replica(&mut conn, &authorized_keys);
2497        assert!(result.is_err());
2498        assert!(result.unwrap_err().to_string().contains("unknown"));
2499
2500        // Replica should also get a rejection.
2501        let replica_result = replica_handle.join().unwrap();
2502        assert!(replica_result.is_err());
2503    }
2504
2505    #[test]
2506    fn auth_rejects_wrong_permission() {
2507        use ed25519_dalek::SigningKey;
2508        use std::os::unix::net::UnixStream;
2509
2510        // Key exists but is listed as trader, not replication.
2511        let key = SigningKey::from_bytes(&[0xCC; 32]);
2512        let pub_b64 = base64::Engine::encode(
2513            &base64::engine::general_purpose::STANDARD,
2514            key.verifying_key().to_bytes(),
2515        );
2516        let authorized_keys = crate::test_roles::desk_keys("trader", &pub_b64);
2517
2518        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2519        primary_stream
2520            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2521            .unwrap();
2522        replica_stream
2523            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2524            .unwrap();
2525
2526        let replica_handle = std::thread::spawn(move || {
2527            let mut conn = replica_stream;
2528            authenticate_with_primary(&mut conn, &key)
2529        });
2530
2531        let mut conn = primary_stream;
2532        let result = authenticate_replica(&mut conn, &authorized_keys);
2533        assert!(result.is_err());
2534        assert_eq!(
2535            result.unwrap_err().to_string(),
2536            "key listed as trader, expected replication"
2537        );
2538
2539        let replica_result = replica_handle.join().unwrap();
2540        assert!(replica_result.is_err());
2541    }
2542
2543    /// A replica that sends a validly-formatted but tampered signature
2544    /// (correct public key, wrong signature bytes) is rejected.
2545    #[test]
2546    fn auth_rejects_invalid_signature() {
2547        use ed25519_dalek::SigningKey;
2548        use std::os::unix::net::UnixStream;
2549
2550        // Register the correct key.
2551        let correct_key = SigningKey::from_bytes(&[0xDD; 32]);
2552        let pub_b64 = base64::Engine::encode(
2553            &base64::engine::general_purpose::STANDARD,
2554            correct_key.verifying_key().to_bytes(),
2555        );
2556        let keys_content = format!("replication {pub_b64} test-replica\n");
2557        let authorized_keys =
2558            melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&keys_content)
2559                .unwrap();
2560
2561        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2562        primary_stream
2563            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2564            .unwrap();
2565        replica_stream
2566            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
2567            .unwrap();
2568
2569        // Replica side: read challenge, but sign with a DIFFERENT key,
2570        // then send the response with the correct public key (spoofing).
2571        let replica_handle = std::thread::spawn(move || {
2572            use melin_transport_core::replication::protocol::*;
2573
2574            let mut reader = replica_stream.try_clone().unwrap();
2575            let mut writer = replica_stream;
2576
2577            // Read the challenge.
2578            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2579            let nonce = decode_challenge(&frame).unwrap();
2580
2581            // Sign with a WRONG key but send the CORRECT public key.
2582            let wrong_key = SigningKey::from_bytes(&[0xEE; 32]);
2583            let bad_signature = ed25519_dalek::Signer::sign(&wrong_key, &nonce);
2584            let correct_pubkey = correct_key.verifying_key();
2585
2586            let mut buf = Vec::with_capacity(128);
2587            encode_challenge_response(
2588                &bad_signature.to_bytes(),
2589                correct_pubkey.as_bytes(),
2590                &mut buf,
2591            );
2592            writer.write_all(&buf).unwrap();
2593            writer.flush().unwrap();
2594
2595            // Should receive AuthFailed.
2596            let result_frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2597            let ok = decode_auth_result(&result_frame).unwrap();
2598            assert!(!ok, "should receive auth failure");
2599        });
2600
2601        let mut conn = primary_stream;
2602        let result = authenticate_replica(&mut conn, &authorized_keys);
2603        assert!(result.is_err());
2604        assert!(
2605            result
2606                .unwrap_err()
2607                .to_string()
2608                .contains("signature verification failed"),
2609            "should fail on signature verification"
2610        );
2611
2612        replica_handle.join().unwrap();
2613    }
2614
2615    #[test]
2616    fn sender_receiver_end_to_end() {
2617        use std::os::unix::net::UnixStream;
2618
2619        // Create a mock connection.
2620        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2621
2622        // Spawn a thread simulating the replica side.
2623        let replica_handle = std::thread::spawn(move || {
2624            let mut reader = replica_stream.try_clone().unwrap();
2625            let mut writer = replica_stream;
2626
2627            // Send handshake.
2628            let mut buf = Vec::new();
2629            let handshake = Handshake {
2630                last_sequence: 0,
2631                chain_hash: [0u8; 32],
2632                epoch: 0,
2633            };
2634            encode_handshake(&handshake, &mut buf);
2635            writer.write_all(&buf).unwrap();
2636            writer.flush().unwrap();
2637            buf.clear();
2638
2639            // Read StreamStart.
2640            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2641            let msg = decode_primary_message(&frame).unwrap();
2642            assert!(matches!(msg, PrimaryMessage::StreamStart { .. }));
2643
2644            // Read InputBatch.
2645            let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2646            let slots: Vec<InputSlot> = try_decode_input_batch(&frame).expect("decode InputBatch");
2647            let end_seq = slots
2648                .last()
2649                .map(|s| s.sequence)
2650                .expect("InputBatch carried at least one slot");
2651
2652            // Send ack.
2653            let ack = Ack {
2654                acked_sequence: end_seq,
2655                in_memory_sequence: end_seq,
2656            };
2657            encode_ack(&ack, &mut buf);
2658            writer.write_all(&buf).unwrap();
2659            writer.flush().unwrap();
2660
2661            end_seq
2662        });
2663
2664        // Primary side: simulate handle_replica_connection partially.
2665        let mut p_reader = primary_stream.try_clone().unwrap();
2666        let mut p_writer = primary_stream;
2667
2668        // Read handshake.
2669        let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2670        let handshake = match decode_replica_message(&frame).unwrap() {
2671            ReplicaMessage::Handshake(h) => h,
2672            _ => panic!("expected Handshake"),
2673        };
2674        assert_eq!(handshake.last_sequence, 0);
2675
2676        // Send StreamStart.
2677        let mut buf = Vec::new();
2678        encode_stream_start(0, 1, [0u8; 32], Some(0), 0, &mut buf); // fake lineage for test
2679        p_writer.write_all(&buf).unwrap();
2680        p_writer.flush().unwrap();
2681        buf.clear();
2682
2683        // Send an InputBatch with a single Tick slot at seq 42.
2684        encode_input_batch_with_seq(42, &mut buf);
2685        p_writer.write_all(&buf).unwrap();
2686        p_writer.flush().unwrap();
2687        buf.clear();
2688
2689        // Read ack.
2690        let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2691        let ack = match decode_replica_message(&frame).unwrap() {
2692            ReplicaMessage::Ack(a) => a,
2693            _ => panic!("expected Ack"),
2694        };
2695        assert_eq!(ack.acked_sequence, 42);
2696
2697        // Join replica thread.
2698        let end_seq = replica_handle.join().unwrap();
2699        assert_eq!(end_seq, 42);
2700    }
2701
2702    #[test]
2703    fn multiple_data_batches_acked_in_order() {
2704        // Send multiple InputBatch frames, verify replica acks each one
2705        // and the cursor advances correctly.
2706        use std::os::unix::net::UnixStream;
2707
2708        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2709
2710        let replica_handle = std::thread::spawn(move || {
2711            let mut reader = replica_stream.try_clone().unwrap();
2712            let mut writer = replica_stream;
2713            let mut buf = Vec::new();
2714
2715            // Send handshake.
2716            encode_handshake(
2717                &Handshake {
2718                    last_sequence: 0,
2719                    chain_hash: [0u8; 32],
2720                    epoch: 0,
2721                },
2722                &mut buf,
2723            );
2724            writer.write_all(&buf).unwrap();
2725            writer.flush().unwrap();
2726            buf.clear();
2727
2728            // Read StreamStart.
2729            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2730            assert!(matches!(
2731                decode_primary_message(&frame).unwrap(),
2732                PrimaryMessage::StreamStart { .. }
2733            ));
2734
2735            // Read and ack 3 InputBatches.
2736            let mut acked_seqs = Vec::new();
2737            for _ in 0..3 {
2738                let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2739                let slots: Vec<InputSlot> =
2740                    try_decode_input_batch(&frame).expect("decode InputBatch");
2741                let end_seq = slots
2742                    .last()
2743                    .map(|s| s.sequence)
2744                    .expect("InputBatch carried at least one slot");
2745                acked_seqs.push(end_seq);
2746
2747                encode_ack(
2748                    &Ack {
2749                        acked_sequence: end_seq,
2750                        in_memory_sequence: end_seq,
2751                    },
2752                    &mut buf,
2753                );
2754                writer.write_all(&buf).unwrap();
2755                writer.flush().unwrap();
2756                buf.clear();
2757            }
2758
2759            acked_seqs
2760        });
2761
2762        // Primary side.
2763        let mut p_reader = primary_stream.try_clone().unwrap();
2764        let mut p_writer = primary_stream;
2765        let mut buf = Vec::new();
2766
2767        // Read handshake.
2768        let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2769        assert!(matches!(
2770            decode_replica_message(&frame).unwrap(),
2771            ReplicaMessage::Handshake(_)
2772        ));
2773
2774        // Send StreamStart.
2775        encode_stream_start(0, 1, [0u8; 32], Some(0), 0, &mut buf);
2776        p_writer.write_all(&buf).unwrap();
2777        p_writer.flush().unwrap();
2778        buf.clear();
2779
2780        // Send 3 InputBatches with increasing sequence numbers.
2781        for seq in [10u64, 20, 30] {
2782            encode_input_batch_with_seq(seq, &mut buf);
2783            p_writer.write_all(&buf).unwrap();
2784            p_writer.flush().unwrap();
2785            buf.clear();
2786        }
2787
2788        // Read 3 acks.
2789        for expected_seq in [10u64, 20, 30] {
2790            let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2791            let ack = match decode_replica_message(&frame).unwrap() {
2792                ReplicaMessage::Ack(a) => a,
2793                other => panic!("expected Ack, got {other:?}"),
2794            };
2795            assert_eq!(ack.acked_sequence, expected_seq);
2796        }
2797
2798        let acked = replica_handle.join().unwrap();
2799        assert_eq!(acked, vec![10, 20, 30]);
2800    }
2801
2802    #[test]
2803    fn heartbeat_encode_contains_sequence() {
2804        // Heartbeat messages carry the last known sequence so the replica
2805        // can verify it hasn't missed any data.
2806        let mut buf = Vec::new();
2807        encode_heartbeat(999, &mut buf);
2808
2809        let payload = &buf[4..];
2810        match decode_primary_message(payload).unwrap() {
2811            PrimaryMessage::Heartbeat { sequence, .. } => {
2812                assert_eq!(sequence, 999);
2813            }
2814            other => panic!("expected Heartbeat, got {other:?}"),
2815        }
2816    }
2817
2818    #[test]
2819    fn replica_mid_stream_handshake_with_nonzero_sequence() {
2820        // A replica that already has some data sends a non-zero last_sequence
2821        // in its handshake. The primary should respond with StreamStart
2822        // containing that sequence, and the replica should only receive
2823        // events after that point.
2824        use std::os::unix::net::UnixStream;
2825
2826        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2827
2828        let replica_handle = std::thread::spawn(move || {
2829            let mut reader = replica_stream.try_clone().unwrap();
2830            let mut writer = replica_stream;
2831            let mut buf = Vec::new();
2832
2833            // Replica already has events up to sequence 100.
2834            encode_handshake(
2835                &Handshake {
2836                    last_sequence: 100,
2837                    chain_hash: [0xBB; 32],
2838                    epoch: 0,
2839                },
2840                &mut buf,
2841            );
2842            writer.write_all(&buf).unwrap();
2843            writer.flush().unwrap();
2844            buf.clear();
2845
2846            // Read StreamStart — should echo back our last_sequence.
2847            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2848            match decode_primary_message(&frame).unwrap() {
2849                PrimaryMessage::StreamStart { start_sequence, .. } => {
2850                    assert_eq!(
2851                        start_sequence, 100,
2852                        "StreamStart should echo replica's last_sequence"
2853                    );
2854                }
2855                other => panic!("expected StreamStart, got {other:?}"),
2856            }
2857
2858            // Read an InputBatch — should be for events AFTER 100.
2859            let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
2860            let slots: Vec<InputSlot> = try_decode_input_batch(&frame).expect("decode InputBatch");
2861            let end_sequence = slots
2862                .last()
2863                .map(|s| s.sequence)
2864                .expect("InputBatch carried at least one slot");
2865            assert!(
2866                end_sequence > 100,
2867                "InputBatch should be after replica's last_sequence"
2868            );
2869        });
2870
2871        // Primary side.
2872        let mut p_reader = primary_stream.try_clone().unwrap();
2873        let mut p_writer = primary_stream;
2874        let mut buf = Vec::new();
2875
2876        // Read handshake.
2877        let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
2878        let handshake = match decode_replica_message(&frame).unwrap() {
2879            ReplicaMessage::Handshake(h) => h,
2880            _ => panic!("expected Handshake"),
2881        };
2882        assert_eq!(handshake.last_sequence, 100);
2883        assert_eq!(handshake.chain_hash, [0xBB; 32]);
2884
2885        // Send StreamStart echoing the replica's sequence.
2886        encode_stream_start(handshake.last_sequence, 1, [0u8; 32], Some(0), 0, &mut buf);
2887        p_writer.write_all(&buf).unwrap();
2888        p_writer.flush().unwrap();
2889        buf.clear();
2890
2891        // Send InputBatch with sequence 150 (after replica's 100).
2892        encode_input_batch_with_seq(150, &mut buf);
2893        p_writer.write_all(&buf).unwrap();
2894        p_writer.flush().unwrap();
2895
2896        replica_handle.join().unwrap();
2897    }
2898
2899    /// The receiver's stale-primary refusal: a replica that has observed
2900    /// epoch 5 must refuse a `StreamStart` advertising a *lower* epoch
2901    /// rather than follow that (divergent) lineage on top of its newer
2902    /// state. This reproduces the exact decision `run_receiver` makes on
2903    /// the normal-resume `StreamStart` — decode the frame off the wire,
2904    /// then `fence_state.refuses_primary(epoch)` — over a real socket so
2905    /// the epoch wire field and the policy are exercised together.
2906    ///
2907    /// In a current-build cluster this branch is a *second* line of
2908    /// defense: a primary reads the replica's higher-epoch handshake and
2909    /// self-demotes before it ever sends a `StreamStart` (see
2910    /// `tcp_sender::handle_replica_connection`). The receiver check guards
2911    /// the case where the primary does *not* fence — a non-fencing or
2912    /// older build — which is exactly what the mock primary below is.
2913    #[test]
2914    fn receiver_refuses_stream_start_from_stale_primary() {
2915        use std::os::unix::net::UnixStream;
2916
2917        use melin_transport_core::fence::FenceState;
2918
2919        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
2920
2921        // Mock primary that — unlike a current build — does *not* fence on
2922        // the handshake and streams its stale lineage anyway. It advertises
2923        // epoch 3 on the StreamStart.
2924        const STALE_EPOCH: u64 = 3;
2925        let primary_handle = std::thread::spawn(move || {
2926            let mut reader = primary_stream.try_clone().unwrap();
2927            let mut writer = primary_stream;
2928
2929            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
2930            let handshake = match decode_replica_message(&frame).unwrap() {
2931                ReplicaMessage::Handshake(h) => h,
2932                _ => panic!("expected Handshake"),
2933            };
2934            // The replica truthfully advertises its higher epoch; a correct
2935            // primary would fence here. This mock deliberately doesn't.
2936            assert_eq!(handshake.epoch, 5, "replica advertises its real epoch");
2937
2938            let mut buf = Vec::new();
2939            encode_stream_start(
2940                handshake.last_sequence,
2941                1,
2942                [0u8; 32],
2943                Some(0),
2944                STALE_EPOCH,
2945                &mut buf,
2946            );
2947            writer.write_all(&buf).unwrap();
2948            writer.flush().unwrap();
2949        });
2950
2951        // Replica side: epoch 5, mirroring `run_receiver`'s handshake +
2952        // StreamStart handling.
2953        let fence_state = Arc::new(FenceState::new(5));
2954        let mut r_reader = replica_stream.try_clone().unwrap();
2955        let mut r_writer = replica_stream;
2956
2957        let mut buf = Vec::new();
2958        encode_handshake(
2959            &Handshake {
2960                last_sequence: 0,
2961                chain_hash: [0u8; 32],
2962                epoch: fence_state.epoch(),
2963            },
2964            &mut buf,
2965        );
2966        r_writer.write_all(&buf).unwrap();
2967        r_writer.flush().unwrap();
2968
2969        let frame = read_frame(&mut r_reader, MAX_CONTROL_FRAME).unwrap();
2970        let stream_epoch = match decode_primary_message(&frame).unwrap() {
2971            PrimaryMessage::StreamStart { epoch, .. } => epoch,
2972            other => panic!("expected StreamStart, got {other:?}"),
2973        };
2974        assert_eq!(stream_epoch, STALE_EPOCH, "epoch must survive the wire");
2975
2976        // This is the receiver's refusal decision. A stale primary must be
2977        // refused; the replica's own epoch must not have been lowered.
2978        assert!(
2979            fence_state.refuses_primary(stream_epoch),
2980            "replica at epoch 5 must refuse a StreamStart from epoch {STALE_EPOCH}"
2981        );
2982        assert_eq!(fence_state.epoch(), 5, "refusal must not lower our epoch");
2983
2984        // Sanity: an equal or newer epoch is followed, not refused (the
2985        // ex-primary-rejoin path). Guards against an inverted comparison.
2986        assert!(!fence_state.refuses_primary(5), "same tenure is followed");
2987        assert!(!fence_state.refuses_primary(6), "newer primary is followed");
2988
2989        primary_handle.join().unwrap();
2990    }
2991
2992    #[test]
2993    fn snapshot_begin_encode_decode_round_trip() {
2994        let mut buf = Vec::new();
2995        encode_snapshot_begin(1_000_000, 42, &[0xAB; 32], &mut buf);
2996
2997        let payload = &buf[4..];
2998        let msg = decode_primary_message(payload).unwrap();
2999        match msg {
3000            PrimaryMessage::SnapshotBegin {
3001                snapshot_len,
3002                snap_sequence,
3003                snap_chain_hash,
3004            } => {
3005                assert_eq!(snapshot_len, 1_000_000);
3006                assert_eq!(snap_sequence, 42);
3007                assert_eq!(snap_chain_hash, [0xAB; 32]);
3008            }
3009            _ => panic!("expected SnapshotBegin"),
3010        }
3011    }
3012
3013    #[test]
3014    fn snapshot_chunk_encode_decode_round_trip() {
3015        let data = vec![1, 2, 3, 4, 5, 6, 7, 8];
3016        let mut buf = Vec::new();
3017        encode_snapshot_chunk(&data, &mut buf);
3018
3019        let payload = &buf[4..];
3020        let msg = decode_primary_message(payload).unwrap();
3021        match msg {
3022            PrimaryMessage::SnapshotChunk(chunk) => {
3023                assert_eq!(chunk, data);
3024            }
3025            _ => panic!("expected SnapshotChunk"),
3026        }
3027    }
3028
3029    #[test]
3030    fn snapshot_end_encode_decode_round_trip() {
3031        let mut buf = Vec::new();
3032        encode_snapshot_end(0xDEADBEEF, &mut buf);
3033
3034        let payload = &buf[4..];
3035        let msg = decode_primary_message(payload).unwrap();
3036        match msg {
3037            PrimaryMessage::SnapshotEnd { crc32c } => {
3038                assert_eq!(crc32c, 0xDEADBEEF);
3039            }
3040            _ => panic!("expected SnapshotEnd"),
3041        }
3042    }
3043
3044    /// Simulate the receiver side of a snapshot transfer where the
3045    /// advertised snap_len doesn't match the actual bytes sent.
3046    /// The receiver must detect this and return an error.
3047    #[test]
3048    fn snapshot_receiver_detects_length_mismatch() {
3049        use std::os::unix::net::UnixStream;
3050
3051        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
3052
3053        // Receiver thread — reads NeedSnapshot, then the snapshot transfer.
3054        let receiver = std::thread::spawn(move || -> String {
3055            let mut reader = replica_stream.try_clone().unwrap();
3056
3057            // Read NeedSnapshot.
3058            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3059            assert!(matches!(
3060                decode_primary_message(&frame).unwrap(),
3061                PrimaryMessage::NeedSnapshot,
3062            ));
3063
3064            // Read SnapshotBegin.
3065            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3066            let (snap_len, _snap_sequence, _snap_chain_hash) =
3067                match decode_primary_message(&frame).unwrap() {
3068                    PrimaryMessage::SnapshotBegin {
3069                        snapshot_len,
3070                        snap_sequence,
3071                        snap_chain_hash,
3072                    } => (snapshot_len, snap_sequence, snap_chain_hash),
3073                    other => panic!("expected SnapshotBegin, got {other:?}"),
3074                };
3075
3076            // Receive chunks and check length at SnapshotEnd.
3077            let mut received: u64 = 0;
3078            loop {
3079                let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
3080                match decode_primary_message(&frame).unwrap() {
3081                    PrimaryMessage::SnapshotChunk(data) => {
3082                        received += data.len() as u64;
3083                    }
3084                    PrimaryMessage::SnapshotEnd { .. } => {
3085                        if received != snap_len {
3086                            return format!(
3087                                "snapshot length mismatch: expected {snap_len} bytes, got {received}"
3088                            );
3089                        }
3090                        return String::new(); // no error
3091                    }
3092                    other => panic!("unexpected message: {other:?}"),
3093                }
3094            }
3095        });
3096
3097        // Primary side — send snapshot with wrong advertised length.
3098        let mut writer = primary_stream;
3099        let mut buf = Vec::new();
3100
3101        let actual_data = vec![0xAA; 100];
3102        let wrong_len = 999u64; // advertise 999 bytes, send only 100
3103
3104        encode_need_snapshot(&mut buf);
3105        std::io::Write::write_all(&mut writer, &buf).unwrap();
3106        buf.clear();
3107
3108        encode_snapshot_begin(wrong_len, 42, &[0xBB; 32], &mut buf);
3109        std::io::Write::write_all(&mut writer, &buf).unwrap();
3110        buf.clear();
3111
3112        encode_snapshot_chunk(&actual_data, &mut buf);
3113        std::io::Write::write_all(&mut writer, &buf).unwrap();
3114        buf.clear();
3115
3116        let crc = crc32c::crc32c(&actual_data);
3117        encode_snapshot_end(crc, &mut buf);
3118        std::io::Write::write_all(&mut writer, &buf).unwrap();
3119        std::io::Write::flush(&mut writer).unwrap();
3120
3121        let error_msg = receiver.join().unwrap();
3122        assert!(
3123            error_msg.contains("length mismatch"),
3124            "expected length mismatch error, got: {error_msg:?}"
3125        );
3126    }
3127
3128    /// Simulate the receiver side of a snapshot transfer where the CRC
3129    /// in SnapshotEnd doesn't match the actual data. The receiver must
3130    /// detect and reject the transfer.
3131    #[test]
3132    fn snapshot_receiver_detects_crc_mismatch() {
3133        use std::os::unix::net::UnixStream;
3134
3135        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
3136
3137        let receiver = std::thread::spawn(move || -> String {
3138            let mut reader = replica_stream.try_clone().unwrap();
3139
3140            // Read NeedSnapshot.
3141            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3142            assert!(matches!(
3143                decode_primary_message(&frame).unwrap(),
3144                PrimaryMessage::NeedSnapshot,
3145            ));
3146
3147            // Read SnapshotBegin.
3148            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3149            let snap_len = match decode_primary_message(&frame).unwrap() {
3150                PrimaryMessage::SnapshotBegin { snapshot_len, .. } => snapshot_len,
3151                other => panic!("expected SnapshotBegin, got {other:?}"),
3152            };
3153
3154            // Receive chunks, verify CRC at SnapshotEnd.
3155            let mut received_data = Vec::new();
3156            let mut received: u64 = 0;
3157            loop {
3158                let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
3159                match decode_primary_message(&frame).unwrap() {
3160                    PrimaryMessage::SnapshotChunk(data) => {
3161                        received += data.len() as u64;
3162                        received_data.extend_from_slice(&data);
3163                    }
3164                    PrimaryMessage::SnapshotEnd {
3165                        crc32c: expected_crc,
3166                    } => {
3167                        if received != snap_len {
3168                            return format!("length mismatch: {snap_len} vs {received}");
3169                        }
3170                        let actual_crc = crc32c::crc32c(&received_data);
3171                        if actual_crc != expected_crc {
3172                            return format!(
3173                                "CRC mismatch: expected {expected_crc:#x}, got {actual_crc:#x}"
3174                            );
3175                        }
3176                        return String::new();
3177                    }
3178                    other => panic!("unexpected message: {other:?}"),
3179                }
3180            }
3181        });
3182
3183        // Primary side — send correct length but wrong CRC.
3184        let mut writer = primary_stream;
3185        let mut buf = Vec::new();
3186
3187        let data = vec![0xAA; 100];
3188
3189        encode_need_snapshot(&mut buf);
3190        std::io::Write::write_all(&mut writer, &buf).unwrap();
3191        buf.clear();
3192
3193        encode_snapshot_begin(data.len() as u64, 42, &[0xBB; 32], &mut buf);
3194        std::io::Write::write_all(&mut writer, &buf).unwrap();
3195        buf.clear();
3196
3197        encode_snapshot_chunk(&data, &mut buf);
3198        std::io::Write::write_all(&mut writer, &buf).unwrap();
3199        buf.clear();
3200
3201        // Send a wrong CRC (flip bits).
3202        let wrong_crc = !crc32c::crc32c(&data);
3203        encode_snapshot_end(wrong_crc, &mut buf);
3204        std::io::Write::write_all(&mut writer, &buf).unwrap();
3205        std::io::Write::flush(&mut writer).unwrap();
3206
3207        let error_msg = receiver.join().unwrap();
3208        assert!(
3209            error_msg.contains("CRC mismatch"),
3210            "expected CRC mismatch error, got: {error_msg:?}"
3211        );
3212    }
3213
3214    /// The receiver verifies the chain hash from the loaded snapshot
3215    /// matches the one advertised in SnapshotBegin. Simulate a mismatch.
3216    #[test]
3217    fn snapshot_receiver_detects_chain_hash_mismatch() {
3218        use std::os::unix::net::UnixStream;
3219
3220        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
3221
3222        let receiver = std::thread::spawn(move || -> String {
3223            let mut reader = replica_stream.try_clone().unwrap();
3224
3225            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3226            assert!(matches!(
3227                decode_primary_message(&frame).unwrap(),
3228                PrimaryMessage::NeedSnapshot,
3229            ));
3230
3231            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
3232            let (snap_len, _snap_sequence, snap_chain_hash) =
3233                match decode_primary_message(&frame).unwrap() {
3234                    PrimaryMessage::SnapshotBegin {
3235                        snapshot_len,
3236                        snap_sequence,
3237                        snap_chain_hash,
3238                    } => (snapshot_len, snap_sequence, snap_chain_hash),
3239                    other => panic!("expected SnapshotBegin, got {other:?}"),
3240                };
3241
3242            // Receive the snapshot data.
3243            let mut received_data = Vec::new();
3244            let mut received: u64 = 0;
3245            loop {
3246                let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
3247                match decode_primary_message(&frame).unwrap() {
3248                    PrimaryMessage::SnapshotChunk(data) => {
3249                        received += data.len() as u64;
3250                        received_data.extend_from_slice(&data);
3251                    }
3252                    PrimaryMessage::SnapshotEnd {
3253                        crc32c: expected_crc,
3254                    } => {
3255                        assert_eq!(received, snap_len, "length should match");
3256                        let actual_crc = crc32c::crc32c(&received_data);
3257                        assert_eq!(actual_crc, expected_crc, "CRC should match");
3258                        break;
3259                    }
3260                    other => panic!("unexpected message: {other:?}"),
3261                }
3262            }
3263
3264            // Simulate chain hash verification: the loaded snapshot would
3265            // have a different chain hash than what SnapshotBegin advertised.
3266            let loaded_hash = [0xFF; 32]; // different from snap_chain_hash
3267            if loaded_hash != snap_chain_hash {
3268                return format!(
3269                    "snapshot chain hash mismatch: primary sent {snap_chain_hash:02x?}, \
3270                     loaded snapshot has {loaded_hash:02x?}"
3271                );
3272            }
3273            String::new()
3274        });
3275
3276        // Primary side — send valid snapshot but with a chain hash in
3277        // SnapshotBegin that won't match what the replica "loads".
3278        let mut writer = primary_stream;
3279        let mut buf = Vec::new();
3280
3281        let data = vec![0xAA; 64];
3282        // Advertise chain hash [0xBB; 32] — receiver will "load" [0xFF; 32].
3283        let advertised_hash = [0xBB; 32];
3284
3285        encode_need_snapshot(&mut buf);
3286        std::io::Write::write_all(&mut writer, &buf).unwrap();
3287        buf.clear();
3288
3289        encode_snapshot_begin(data.len() as u64, 10, &advertised_hash, &mut buf);
3290        std::io::Write::write_all(&mut writer, &buf).unwrap();
3291        buf.clear();
3292
3293        encode_snapshot_chunk(&data, &mut buf);
3294        std::io::Write::write_all(&mut writer, &buf).unwrap();
3295        buf.clear();
3296
3297        let crc = crc32c::crc32c(&data);
3298        encode_snapshot_end(crc, &mut buf);
3299        std::io::Write::write_all(&mut writer, &buf).unwrap();
3300        std::io::Write::flush(&mut writer).unwrap();
3301
3302        let error_msg = receiver.join().unwrap();
3303        assert!(
3304            error_msg.contains("chain hash mismatch"),
3305            "expected chain hash mismatch error, got: {error_msg:?}"
3306        );
3307    }
3308
3309    /// Primary-side magic validation: a file without the SNAP magic
3310    /// (0x534E4150) must be rejected before transfer.
3311    #[test]
3312    fn primary_rejects_snapshot_with_invalid_magic() {
3313        let tmp = tempfile::tempdir().unwrap();
3314        let snap_path = tmp.path().join("test.snapshot");
3315
3316        // Write a file with wrong magic but enough bytes for a header.
3317        let mut bad_snap = vec![0u8; 64];
3318        bad_snap[0..4].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); // wrong magic
3319
3320        std::fs::write(&snap_path, &bad_snap).unwrap();
3321
3322        // Replicate the primary's validation logic.
3323        let snap_data = std::fs::read(&snap_path).unwrap();
3324        assert!(
3325            snap_data.len() >= 48,
3326            "file should be big enough for header"
3327        );
3328
3329        let magic = u32::from_le_bytes(snap_data[0..4].try_into().unwrap());
3330        assert_ne!(magic, 0x534E_4150);
3331        assert_eq!(magic, 0xDEAD_BEEF);
3332    }
3333
3334    /// Primary-side: a snapshot file smaller than the 48-byte header
3335    /// must be rejected.
3336    #[test]
3337    fn primary_rejects_snapshot_too_small_for_header() {
3338        let tmp = tempfile::tempdir().unwrap();
3339        let snap_path = tmp.path().join("test.snapshot");
3340
3341        // Write a file smaller than the 48-byte header.
3342        std::fs::write(&snap_path, [0u8; 20]).unwrap();
3343
3344        let snap_data = std::fs::read(&snap_path).unwrap();
3345        assert!(
3346            snap_data.len() < 48,
3347            "file must be too small for header validation"
3348        );
3349    }
3350
3351    #[test]
3352    fn decode_snapshot_begin_too_short() {
3353        // SnapshotBegin needs type(1) + snapshot_len(8) + snap_sequence(8) + chain_hash(32) = 49.
3354        // Send only the type byte + a few extra bytes.
3355        let payload = [MSG_SNAPSHOT_BEGIN, 0x01, 0x02, 0x03];
3356        let err = decode_primary_message(&payload).unwrap_err();
3357        assert!(
3358            err.to_string().contains("SnapshotBegin too short"),
3359            "expected 'SnapshotBegin too short', got: {err}"
3360        );
3361    }
3362
3363    #[test]
3364    fn decode_snapshot_end_too_short() {
3365        // SnapshotEnd needs type(1) + crc32c(4) = 5. Send only the type byte.
3366        let payload = [MSG_SNAPSHOT_END];
3367        let err = decode_primary_message(&payload).unwrap_err();
3368        assert!(
3369            err.to_string().contains("SnapshotEnd too short"),
3370            "expected 'SnapshotEnd too short', got: {err}"
3371        );
3372    }
3373
3374    #[test]
3375    fn decode_snapshot_chunk_empty_data() {
3376        // SnapshotChunk with just the type byte — valid but empty payload.
3377        let payload = [MSG_SNAPSHOT_CHUNK];
3378        let msg = decode_primary_message(&payload).unwrap();
3379        match msg {
3380            PrimaryMessage::SnapshotChunk(data) => {
3381                assert!(data.is_empty());
3382            }
3383            _ => panic!("expected SnapshotChunk"),
3384        }
3385    }
3386
3387    // --- PendingAckQueue tests ---
3388
3389    fn make_journal_cursor(val: u64) -> melin_pipeline::padding::Sequence {
3390        melin_pipeline::padding::Sequence::new(AtomicU64::new(val))
3391    }
3392
3393    #[test]
3394    fn pending_ack_queue_push_and_pop_ready() {
3395        let mut q = PendingAckQueue::new(8);
3396        assert!(q.is_empty());
3397        assert!(!q.is_full());
3398
3399        q.push(10, 100);
3400        q.push(20, 200);
3401        assert!(!q.is_empty());
3402
3403        // Cursor at 5 — neither ready.
3404        let cursor = make_journal_cursor(5);
3405        assert!(q.pop_ready(&cursor).is_none());
3406
3407        // Cursor at 15 — first ready, second not.
3408        cursor.get().store(15, Ordering::Relaxed);
3409        assert_eq!(q.pop_ready(&cursor), Some(100));
3410        // Only one popped — second still pending.
3411        assert!(!q.is_empty());
3412
3413        // Cursor at 25 — second now ready.
3414        cursor.get().store(25, Ordering::Relaxed);
3415        assert_eq!(q.pop_ready(&cursor), Some(200));
3416        assert!(q.is_empty());
3417    }
3418
3419    #[test]
3420    fn pending_ack_queue_pop_ready_returns_highest_sequence() {
3421        // When multiple acks become ready simultaneously, pop_ready
3422        // returns the highest acked_sequence (ack semantics are
3423        // cumulative — "everything up to this sequence is durable").
3424        let mut q = PendingAckQueue::new(8);
3425        q.push(10, 100);
3426        q.push(20, 200);
3427        q.push(30, 300);
3428
3429        let cursor = make_journal_cursor(30);
3430        assert_eq!(q.pop_ready(&cursor), Some(300));
3431        assert!(q.is_empty());
3432    }
3433
3434    #[test]
3435    fn pending_ack_queue_capacity_and_full() {
3436        let mut q = PendingAckQueue::new(8);
3437        for i in 0..8 {
3438            assert!(!q.is_full());
3439            q.push(i as u64 + 1, (i + 1) as u64 * 100);
3440        }
3441        assert!(q.is_full());
3442    }
3443
3444    /// A full queue must absorb the push by merging it into the newest
3445    /// entry — never block, never drop, and never release a sequence
3446    /// before its own journal target. The merge is a *coarsening*: the
3447    /// entry it absorbed is acked later than it would have been.
3448    #[test]
3449    fn pending_ack_queue_merges_into_the_newest_entry_when_full() {
3450        let mut q = PendingAckQueue::new(4);
3451        for i in 1..=4u64 {
3452            q.push(i * 10, i * 100);
3453        }
3454        assert!(q.is_full());
3455        assert_eq!(q.merged(), 0);
3456
3457        // Fifth batch: target 50, sequence 500. Merges into (40, 400).
3458        q.push(50, 500);
3459        assert_eq!(q.merged(), 1);
3460        assert_eq!(q.len(), 4, "merge must not grow the queue");
3461
3462        // Cursor at 40 would have released 400 before the merge; now the
3463        // newest entry needs 50, so 300 is the highest safe ack.
3464        let cursor = make_journal_cursor(40);
3465        assert_eq!(q.pop_ready(&cursor), Some(300));
3466        assert!(!q.is_empty(), "merged entry is not durable yet");
3467
3468        // Cursor reaches the merged target — the merged entry releases
3469        // the newer sequence, subsuming the one it absorbed.
3470        cursor.get().store(50, Ordering::Relaxed);
3471        assert_eq!(q.pop_ready(&cursor), Some(500));
3472        assert!(q.is_empty());
3473    }
3474
3475    /// Repeated merges keep collapsing onto the same slot, so a journal
3476    /// stall of any length costs granularity and nothing else.
3477    #[test]
3478    fn pending_ack_queue_merges_repeatedly_without_growing() {
3479        let mut q = PendingAckQueue::new(2);
3480        for i in 1..=100u64 {
3481            q.push(i, i * 10);
3482        }
3483        assert_eq!(q.len(), 2);
3484        assert_eq!(q.merged(), 98);
3485
3486        // Nothing releases before its target...
3487        let cursor = make_journal_cursor(99);
3488        assert_eq!(q.pop_ready(&cursor), Some(10), "only the first entry");
3489        // ...and the merged tail carries the newest pair.
3490        cursor.get().store(100, Ordering::Relaxed);
3491        assert_eq!(q.pop_ready(&cursor), Some(1000));
3492    }
3493
3494    #[test]
3495    fn pending_ack_queue_pop_oldest_blocking() {
3496        let mut q = PendingAckQueue::new(8);
3497        q.push(10, 100);
3498        q.push(20, 200);
3499
3500        // Cursor already past both targets — pop_oldest_blocking
3501        // returns immediately.
3502        let cursor = make_journal_cursor(25);
3503        let seq = q.pop_oldest_blocking(
3504            &cursor,
3505            melin_pipeline::wait::WaitStrategy::BusySpin,
3506            &AtomicBool::new(false),
3507        );
3508        // Should pop both (oldest + any others that became ready).
3509        assert_eq!(seq, Some(200));
3510        assert!(q.is_empty());
3511    }
3512
3513    /// A dead journal stage (abort latch set) must abort the blocking
3514    /// wait instead of spinning forever on the frozen cursor.
3515    #[test]
3516    fn pending_ack_queue_blocking_wait_aborts_on_journal_failure() {
3517        let mut q = PendingAckQueue::new(8);
3518        q.push(10, 100);
3519
3520        // Cursor frozen BELOW the target; abort pre-latched.
3521        let cursor = make_journal_cursor(5);
3522        let abort = AtomicBool::new(true);
3523        assert_eq!(
3524            q.pop_oldest_blocking(
3525                &cursor,
3526                melin_pipeline::wait::WaitStrategy::BusySpin,
3527                &abort
3528            ),
3529            None
3530        );
3531        assert!(!q.is_empty(), "aborted wait must not pop the entry");
3532        assert_eq!(
3533            q.pop_all_blocking(
3534                &cursor,
3535                melin_pipeline::wait::WaitStrategy::BusySpin,
3536                &abort
3537            ),
3538            None
3539        );
3540    }
3541
3542    /// `shutdown_pipeline` must surface the journal stage's error kind —
3543    /// the orchestrator routes `ReplicaChainDivergence` into in-process
3544    /// resync and everything else into process exit, so collapsing the
3545    /// error into a None (the old behavior) breaks that dispatch. It
3546    /// must also join EVERY thread in the failure arms: the resync path
3547    /// archives journal + snapshot right after teardown, and a
3548    /// still-running shadow thread mid-snapshot-write would race the
3549    /// renames.
3550    #[test]
3551    fn shutdown_pipeline_surfaces_journal_error_kind() {
3552        let flag = AtomicBool::new(false);
3553        let matching_joined = Arc::new(AtomicBool::new(false));
3554        let mj = Arc::clone(&matching_joined);
3555
3556        let journal = std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> {
3557            Err(melin_journal::JournalError::ReplicaChainDivergence {
3558                sequence: 42,
3559                expected: [1u8; 32],
3560                actual: [2u8; 32],
3561            })
3562        });
3563        let matching = std::thread::spawn(move || {
3564            mj.store(true, Ordering::Release);
3565            7u64
3566        });
3567        let drain = std::thread::spawn(|| {});
3568
3569        let outcome = shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None);
3570        assert!(flag.load(Ordering::Acquire), "shutdown flag must be set");
3571        assert!(
3572            matching_joined.load(Ordering::Acquire),
3573            "matching thread must be joined even when the journal failed"
3574        );
3575        match outcome {
3576            TeardownOutcome::JournalFailed(
3577                melin_journal::JournalError::ReplicaChainDivergence { sequence, .. },
3578            ) => assert_eq!(sequence, 42),
3579            _ => panic!("expected JournalFailed(ReplicaChainDivergence)"),
3580        }
3581    }
3582
3583    #[test]
3584    fn shutdown_pipeline_clean_returns_both_states() {
3585        let flag = AtomicBool::new(false);
3586        let journal =
3587            std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> { Ok(11u32) });
3588        let matching = std::thread::spawn(|| 7u64);
3589        let drain = std::thread::spawn(|| {});
3590
3591        match shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None) {
3592            TeardownOutcome::Clean(app, writer) => {
3593                assert_eq!(app, 7);
3594                assert_eq!(writer, 11);
3595            }
3596            _ => panic!("expected Clean"),
3597        }
3598    }
3599
3600    #[test]
3601    fn shutdown_pipeline_panicked_stage_reports_panicked() {
3602        let flag = AtomicBool::new(false);
3603        let journal =
3604            std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> { Ok(11u32) });
3605        let matching = std::thread::spawn(|| -> u64 { panic!("matching stage died") });
3606        let drain = std::thread::spawn(|| {});
3607
3608        assert!(matches!(
3609            shutdown_pipeline::<u64, u32>(&flag, journal, matching, drain, None),
3610            TeardownOutcome::Panicked
3611        ));
3612    }
3613
3614    /// `ReplicaPipelineHandles` over a real input ring (one gate
3615    /// consumer) with immediately-returning stage threads — just enough
3616    /// structure for `teardown_replica_pipeline` to run for real. The
3617    /// returned consumer observes what teardown published.
3618    fn teardown_fixture(
3619        capacity: usize,
3620    ) -> (
3621        ReplicaPipelineHandles<counter_server::Counter, u32>,
3622        melin_pipeline::ring::Consumer<InputSlot>,
3623    ) {
3624        handles_fixture(capacity, fsync_state_at(0, 0).1)
3625    }
3626
3627    /// A seqlock holding fsync state at `journal_seq` and ring position
3628    /// `ring_pos`, as the journal stage would publish it.
3629    fn fsync_state_at(
3630        journal_seq: u64,
3631        ring_pos: u64,
3632    ) -> (
3633        melin_pipeline::seqlock::SeqLockWriter<melin_transport_core::pipeline::FsyncState>,
3634        melin_pipeline::seqlock::SeqLockReader<melin_transport_core::pipeline::FsyncState>,
3635    ) {
3636        melin_pipeline::seqlock::split(melin_transport_core::pipeline::FsyncState {
3637            journal_seq: melin_transport_core::WireSeq::new(journal_seq),
3638            chain_hash: [0u8; 32],
3639            input_ring_seq: melin_transport_core::cursors::RingPos::new(ring_pos),
3640        })
3641    }
3642
3643    /// [`teardown_fixture`] reading the given fsync state.
3644    fn handles_fixture(
3645        capacity: usize,
3646        chain_hash_lock: melin_pipeline::seqlock::SeqLockReader<
3647            melin_transport_core::pipeline::FsyncState,
3648        >,
3649    ) -> (
3650        ReplicaPipelineHandles<counter_server::Counter, u32>,
3651        melin_pipeline::ring::Consumer<InputSlot>,
3652    ) {
3653        let (input_producer, mut consumers) =
3654            melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(capacity)
3655                .add_consumer()
3656                .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
3657        let consumer = consumers.pop().expect("one consumer");
3658        let handles = ReplicaPipelineHandles {
3659            input_producer,
3660            journal_cursor: Arc::new(make_journal_cursor(0)),
3661            chain_hash_lock,
3662            stream_marks: melin_transport_core::pipeline::StreamMarks::shared(),
3663            genesis_entries: None,
3664            journal_failed: Arc::new(AtomicBool::new(false)),
3665            pipeline_shutdown: Arc::new(AtomicBool::new(false)),
3666            journal_handle: std::thread::spawn(|| -> Result<u32, melin_journal::JournalError> {
3667                Ok(11)
3668            }),
3669            matching_handle: std::thread::spawn(counter_server::Counter::default),
3670            drain_handle: std::thread::spawn(|| {}),
3671            shadow_handle: None,
3672        };
3673        (handles, consumer)
3674    }
3675
3676    /// The invariant behind moving the sentinel publish into
3677    /// `teardown_replica_pipeline`: a healthy teardown always puts the
3678    /// shutdown sentinel on the ring, so the stages exit by consuming it
3679    /// through the normal event path (draining anything ahead of it),
3680    /// not via the flag's emergency-abort branch.
3681    #[test]
3682    fn teardown_publishes_shutdown_sentinel() {
3683        let (handles, mut consumer) = teardown_fixture(8);
3684        assert!(matches!(
3685            teardown_replica_pipeline::<counter_server::Counter, u32>(handles),
3686            TeardownOutcome::Clean(..)
3687        ));
3688        let (_, slot) = consumer.try_consume().expect("sentinel on the ring");
3689        assert!(
3690            slot.event.is_shutdown(),
3691            "published slot must be the sentinel"
3692        );
3693        assert!(consumer.try_consume().is_none(), "exactly one sentinel");
3694    }
3695
3696    /// The scheduling policy a running thread has now.
3697    fn policy_of<T>(handle: &std::thread::JoinHandle<T>) -> libc::c_int {
3698        use std::os::unix::thread::JoinHandleExt;
3699        let mut policy: libc::c_int = 0;
3700        let mut param = libc::sched_param { sched_priority: 0 };
3701        let ret =
3702            unsafe { libc::pthread_getschedparam(handle.as_pthread_t(), &mut policy, &mut param) };
3703        assert_eq!(ret, 0, "pthread_getschedparam failed: {ret}");
3704        policy
3705    }
3706
3707    /// Every thread the replica pipeline builder spawns starts in the
3708    /// scheduling context of its own placement, not in the builder's.
3709    ///
3710    /// A new thread inherits its creator's CPU mask and policy, and Linux
3711    /// offers no way to change them before it first runs. On a tuned host
3712    /// the builder can sit on an isolated core: the receiver after a
3713    /// rebuild, or any thread whose home mask includes isolated CPUs. A
3714    /// child created there has to run once to pin itself elsewhere, and
3715    /// if a real-time thread spins on that core it never does — the stage
3716    /// never starts, and the replica wedges when its input ring fills.
3717    /// `JournalStage::start` hands the disk thread and the preparer their
3718    /// context before they exist for that reason; the stage threads need
3719    /// the same.
3720    ///
3721    /// Where a child started cannot be read back once it has pinned
3722    /// itself, but its policy can (see `melin_test_node::sched`): the
3723    /// builder runs at `SCHED_BATCH`, and a child that inherited its
3724    /// context keeps it after its own pin, while one handed its own starts
3725    /// at `SCHED_OTHER`. The end-to-end form, both nodes on both
3726    /// transports, is `tests/stall_guards.rs`.
3727    #[test]
3728    fn replica_pipeline_threads_start_in_their_own_scheduling_context() {
3729        let Some(core) = melin_test_node::sched::first_pinnable_cpu() else {
3730            // No core to place a child on but the `0` sentinel.
3731            eprintln!("skipped: this thread may run on CPU 0 only");
3732            return;
3733        };
3734        let dir = tempfile::tempdir().expect("tempdir");
3735
3736        let policies = std::thread::spawn(move || {
3737            // The context no child may start in.
3738            melin_test_node::sched::set_batch_policy();
3739
3740            let writer = melin_journal::BufferedWriter::<CounterEvent>::create_continuing(
3741                &dir.path().join("replica.journal"),
3742                1,
3743                [7; 32],
3744                Some(5),
3745            )
3746            .expect("create journal");
3747            // Every thread on the same core, yielding: what matters is
3748            // that each has a core of its own to be placed on, not which.
3749            let placed = crate::layout::Placement::yielding(core);
3750            let cores = crate::layout::PipelineCores {
3751                journal_seq: placed,
3752                matching: placed,
3753                response: placed,
3754                shadow: placed,
3755                journal_prep: placed,
3756                journal_disk: placed,
3757                ..crate::layout::PipelineCores::unpinned()
3758            };
3759            let handles = build_replica_pipeline_with_threads(
3760                counter_server::Counter::default(),
3761                writer,
3762                cores,
3763                melin_journal::StagingMode::Allocate,
3764                // Non-zero so the shadow thread is spawned; long enough
3765                // that it never takes a snapshot.
3766                3_600_000,
3767                &NodeStore::resolve(dir.path().join("replica.journal"), None).expect("store"),
3768                std::time::Duration::ZERO,
3769                Arc::new(melin_transport_core::fence::FenceState::new(0)),
3770                &SharedLineagePolicy::new(None),
3771                Arc::new(AtomicBool::new(true)),
3772                &(),
3773            )
3774            .expect("build the replica pipeline");
3775
3776            let policies = [
3777                ("journal-seq", Some(policy_of(&handles.journal_handle))),
3778                ("matching", Some(policy_of(&handles.matching_handle))),
3779                ("drain", Some(policy_of(&handles.drain_handle))),
3780                (
3781                    "replica-shadow",
3782                    handles.shadow_handle.as_ref().map(policy_of),
3783                ),
3784            ];
3785            let outcome = teardown_replica_pipeline(handles);
3786            assert!(
3787                matches!(outcome, TeardownOutcome::Clean(..)),
3788                "the pipeline must tear down cleanly"
3789            );
3790            policies
3791        })
3792        .join()
3793        .expect("builder thread");
3794
3795        let inherited: Vec<&str> = policies
3796            .iter()
3797            .map(|(name, policy)| {
3798                (
3799                    *name,
3800                    policy.unwrap_or_else(|| panic!("{name} was not spawned")),
3801                )
3802            })
3803            .filter(|&(_, policy)| policy == libc::SCHED_BATCH)
3804            .map(|(name, _)| name)
3805            .collect();
3806        assert!(
3807            inherited.is_empty(),
3808            "{inherited:?} started in the builder's scheduling context instead of their \
3809             own placement; on an isolated core held by a real-time spinner they would \
3810             never run"
3811        );
3812    }
3813
3814    /// A session left three slots in the ring that the journal has not
3815    /// made durable. The resume point waits for the fsync that covers
3816    /// them rather than handing the handshake the older durable position,
3817    /// from which the primary would resend them and the next session
3818    /// would publish them a second time.
3819    #[test]
3820    fn resume_point_waits_for_the_journal_to_cover_the_ring() {
3821        let (mut fsync, reader) = fsync_state_at(10, 0);
3822        let (mut handles, _consumer) = handles_fixture(8, reader);
3823        for _ in 0..3 {
3824            handles.input_producer.publish(InputSlot::default());
3825        }
3826        let journal = std::thread::spawn(move || {
3827            std::thread::sleep(std::time::Duration::from_millis(50));
3828            fsync.store(melin_transport_core::pipeline::FsyncState {
3829                journal_seq: melin_transport_core::WireSeq::new(13),
3830                chain_hash: [0u8; 32],
3831                input_ring_seq: melin_transport_core::cursors::RingPos::new(3),
3832            });
3833        });
3834        let outcome = handles.settled_resume_point(|| false);
3835        journal.join().unwrap();
3836        assert!(
3837            matches!(outcome, ResumePoint::Settled(s) if s.journal_seq.get() == 13),
3838            "resumes past the ring's slots"
3839        );
3840    }
3841
3842    /// Nothing published since the fsync state was last updated (here,
3843    /// its seed): the resume point is that state, at once.
3844    #[test]
3845    fn resume_point_with_nothing_in_flight_is_immediate() {
3846        let (_fsync, reader) = fsync_state_at(10, 0);
3847        let (handles, _consumer) = handles_fixture(8, reader);
3848        assert!(matches!(
3849            handles.settled_resume_point(|| false),
3850            ResumePoint::Settled(s) if s.journal_seq.get() == 10
3851        ));
3852    }
3853
3854    /// A failed journal stage never covers the ring, and neither does one
3855    /// the node is abandoning: the wait gives up on either, and says
3856    /// which.
3857    #[test]
3858    fn resume_point_gives_up_on_a_failed_journal_or_an_interrupt() {
3859        let (_fsync, reader) = fsync_state_at(10, 0);
3860        let (mut handles, _consumer) = handles_fixture(8, reader);
3861        handles.input_producer.publish(InputSlot::default());
3862
3863        assert!(matches!(
3864            handles.settled_resume_point(|| true),
3865            ResumePoint::Interrupted
3866        ));
3867        handles.journal_failed.store(true, Ordering::Release);
3868        assert!(matches!(
3869            handles.settled_resume_point(|| false),
3870            ResumePoint::JournalFailed
3871        ));
3872    }
3873
3874    /// A journal stage that died after its last durable batch leaves the
3875    /// ring covered. The wait must still report it dead, ahead of the
3876    /// covered state: handed that state, the reconnect loop would dial
3877    /// the primary and take one of its replica slots with a pipeline that
3878    /// cannot take the session.
3879    #[test]
3880    fn resume_point_reports_a_dead_journal_even_when_the_ring_is_covered() {
3881        let (_fsync, reader) = fsync_state_at(10, 0);
3882        let (handles, _consumer) = handles_fixture(8, reader);
3883        handles.journal_failed.store(true, Ordering::Release);
3884        assert!(matches!(
3885            handles.settled_resume_point(|| false),
3886            ResumePoint::JournalFailed
3887        ));
3888    }
3889
3890    /// A journal thread that panics returns no error, so the stage's
3891    /// wrapper must latch the failure on the way out: otherwise every
3892    /// wait on the journal's progress, the reconnect's included, spins on
3893    /// a cursor that will never move.
3894    #[test]
3895    fn a_panicked_journal_stage_latches_the_failure() {
3896        let failed = Arc::new(AtomicBool::new(false));
3897        let healthy = Arc::new(AtomicBool::new(true));
3898        let journal_handle = {
3899            let (failed, healthy) = (Arc::clone(&failed), Arc::clone(&healthy));
3900            std::thread::spawn(move || {
3901                run_journal_stage(
3902                    || -> Result<u32, melin_journal::JournalError> {
3903                        panic!("journal stage died without returning an error")
3904                    },
3905                    &failed,
3906                    &healthy,
3907                )
3908            })
3909        };
3910        // The thread's panic is the point; joining reports it.
3911        assert!(journal_handle.join().is_err(), "the stage panicked");
3912        assert!(failed.load(Ordering::Acquire), "the failure is latched");
3913        assert!(
3914            !healthy.load(Ordering::Acquire),
3915            "the node reports unhealthy"
3916        );
3917    }
3918
3919    /// End to end over the handles: a slot the panicked stage will never
3920    /// cover is in the ring, and the reconnect's wait must end anyway
3921    /// rather than spin until shutdown.
3922    #[test]
3923    fn resume_point_ends_when_the_journal_thread_panicked() {
3924        let (_fsync, reader) = fsync_state_at(10, 0);
3925        let (mut handles, _consumer) = handles_fixture(8, reader);
3926        handles.input_producer.publish(InputSlot::default());
3927        let failed = Arc::clone(&handles.journal_failed);
3928        let healthy = Arc::new(AtomicBool::new(true));
3929        let dead = std::thread::spawn(move || {
3930            run_journal_stage(
3931                || -> Result<u32, melin_journal::JournalError> { panic!("journal stage died") },
3932                &failed,
3933                &healthy,
3934            )
3935        });
3936        assert!(dead.join().is_err(), "the stage panicked");
3937
3938        // Bounded, so a regression fails instead of hanging the suite.
3939        let give_up = AtomicBool::new(false);
3940        let outcome = std::thread::scope(|s| {
3941            let wait = s.spawn(|| handles.settled_resume_point(|| give_up.load(Ordering::Relaxed)));
3942            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
3943            while !wait.is_finished() && std::time::Instant::now() < deadline {
3944                std::thread::sleep(std::time::Duration::from_millis(5));
3945            }
3946            give_up.store(true, Ordering::Relaxed);
3947            wait.join().expect("wait thread")
3948        });
3949        assert!(
3950            matches!(outcome, ResumePoint::JournalFailed),
3951            "the wait must end on the panicked stage, not on the test's give-up"
3952        );
3953    }
3954
3955    /// The reconnect loop's dispatch for a journal found dead by the
3956    /// wait: the fatal teardown, never a reconnect with the dead
3957    /// pipeline. Its error stops the replica; the pipeline is gone.
3958    #[test]
3959    fn a_journal_found_dead_by_the_wait_takes_the_fatal_path() {
3960        let dir = tempfile::tempdir().expect("tempdir");
3961        type Writer = melin_journal::BufferedWriter<CounterEvent>;
3962        let (input_producer, mut consumers) =
3963            melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
3964                .add_consumer()
3965                .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
3966        let _consumer = consumers.pop().expect("one consumer");
3967        let handles = ReplicaPipelineHandles {
3968            input_producer,
3969            journal_cursor: Arc::new(make_journal_cursor(0)),
3970            chain_hash_lock: fsync_state_at(0, 0).1,
3971            stream_marks: melin_transport_core::pipeline::StreamMarks::shared(),
3972            genesis_entries: None,
3973            journal_failed: Arc::new(AtomicBool::new(true)),
3974            pipeline_shutdown: Arc::new(AtomicBool::new(false)),
3975            journal_handle: std::thread::spawn(
3976                || -> Result<Writer, melin_journal::JournalError> {
3977                    Err(melin_journal::JournalError::ReplicaSequenceMismatch {
3978                        expected: 4,
3979                        actual: 1,
3980                    })
3981                },
3982            ),
3983            matching_handle: std::thread::spawn(counter_server::Counter::default),
3984            drain_handle: std::thread::spawn(|| {}),
3985            shadow_handle: None,
3986        };
3987        let mut pipeline = Some(handles);
3988        let mut divergence_resyncs = 0u32;
3989        let mut backoff = MAX_BACKOFF;
3990        let mut closes = 0;
3991        let after = handle_session_exit::<counter_server::Counter, Writer>(
3992            journal_failed_while_disconnected(),
3993            &mut pipeline,
3994            &mut divergence_resyncs,
3995            &mut backoff,
3996            0,
3997            &NodeStore::resolve(dir.path().join("r.journal"), None).expect("store"),
3998            &melin_transport_core::fence::FenceState::new(0),
3999            &SharedLineagePolicy::new(None),
4000            &AtomicBool::new(false),
4001            &crate::promotion::PromotionRequest::new(),
4002            || closes += 1,
4003            &(),
4004        );
4005        let AfterSession::Return(Err(err)) = after else {
4006            panic!("a refused sequence stops the replica rather than reconnecting or resyncing");
4007        };
4008        assert!(
4009            err.to_string()
4010                .contains("refused to journal sequence 1: the next sequence is 4"),
4011            "the returned error names the journal's cause, got: {err}"
4012        );
4013        assert!(pipeline.is_none(), "the dead pipeline was torn down");
4014        assert_eq!(divergence_resyncs, 0);
4015        // Once, as on every exit (the receivers pass a no-op here, as no
4016        // session is open while the wait runs).
4017        assert_eq!(closes, 1);
4018    }
4019
4020    /// Every exit ends the connection, exactly once, terminal or not:
4021    /// a replica that stops or promotes must tell its primary as surely
4022    /// as one that reconnects (on DPDK nothing else will).
4023    #[test]
4024    fn every_session_exit_closes_the_connection_once() {
4025        type Writer = melin_journal::BufferedWriter<CounterEvent>;
4026        // A constructor per exit: `SessionExit` is neither `Clone` nor
4027        // `Debug`, so each case is built fresh and labelled by hand.
4028        type Case = (&'static str, fn() -> SessionExit);
4029        let exits: [Case; 6] = [
4030            ("shutdown", || SessionExit::Shutdown),
4031            ("promote", || SessionExit::Promote),
4032            ("fatal", || SessionExit::Fatal("protocol violation".into())),
4033            ("disconnected", || SessionExit::Disconnected),
4034            ("stream gap", || SessionExit::StreamGap("gap".into())),
4035            ("corrupted", || SessionExit::Corrupted("damaged".into())),
4036        ];
4037        for (label, exit) in exits {
4038            let dir = tempfile::tempdir().expect("tempdir");
4039            let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> =
4040                None;
4041            let mut divergence_resyncs = 0u32;
4042            let mut backoff = MAX_BACKOFF;
4043            // Latched so the reconnecting exits' backoff sleep returns
4044            // at once.
4045            let shutdown = AtomicBool::new(true);
4046            let mut closes = 0;
4047            // The outcome is beside the point here (with no pipeline the
4048            // terminal exits return errors); only the close count is.
4049            let _after = handle_session_exit::<counter_server::Counter, Writer>(
4050                StreamingResult {
4051                    exit: exit(),
4052                    heard_from_primary: true,
4053                },
4054                &mut pipeline,
4055                &mut divergence_resyncs,
4056                &mut backoff,
4057                0,
4058                &NodeStore::resolve(dir.path().join("r.journal"), None).expect("store"),
4059                &melin_transport_core::fence::FenceState::new(0),
4060                &SharedLineagePolicy::new(None),
4061                &shutdown,
4062                &crate::promotion::PromotionRequest::new(),
4063                || closes += 1,
4064                &(),
4065            );
4066            assert_eq!(closes, 1, "{label}");
4067        }
4068    }
4069
4070    /// Drive `handle_session_exit` through one `Disconnected` exit with
4071    /// the given liveness flag and a backoff pre-escalated to
4072    /// `MAX_BACKOFF`, returning the post-exit backoff. Shutdown is
4073    /// latched so the backoff sleep returns immediately (the receiver's
4074    /// loop top would handle it on the next turn); the journal and fence
4075    /// arguments are inert on the `Disconnected` path.
4076    fn backoff_after_disconnect(heard_from_primary: bool) -> std::time::Duration {
4077        backoff_after_exit(SessionExit::Disconnected, heard_from_primary)
4078    }
4079
4080    /// As [`backoff_after_disconnect`], for any reconnecting exit.
4081    fn backoff_after_exit(exit: SessionExit, heard_from_primary: bool) -> std::time::Duration {
4082        let dir = tempfile::tempdir().expect("tempdir");
4083        type Writer = melin_journal::BufferedWriter<CounterEvent>;
4084        let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> = None;
4085        let mut divergence_resyncs = 0u32;
4086        let mut backoff = MAX_BACKOFF;
4087        let shutdown = AtomicBool::new(true);
4088        let promote = crate::promotion::PromotionRequest::new();
4089        let after = handle_session_exit::<counter_server::Counter, Writer>(
4090            StreamingResult {
4091                exit,
4092                heard_from_primary,
4093            },
4094            &mut pipeline,
4095            &mut divergence_resyncs,
4096            &mut backoff,
4097            0,
4098            &NodeStore::resolve(dir.path().join("r.journal"), None).expect("store"),
4099            &melin_transport_core::fence::FenceState::new(0),
4100            &SharedLineagePolicy::new(None),
4101            &shutdown,
4102            &promote,
4103            || {},
4104            &(),
4105        );
4106        assert!(matches!(after, AfterSession::Reconnect));
4107        backoff
4108    }
4109
4110    /// A session in which the primary spoke — even heartbeat-only on a
4111    /// quiet system — must reset the disconnect backoff. A stale
4112    /// escalated backoff delays primary-link recovery, and with it the
4113    /// auto-promotion veto, by up to MAX_BACKOFF.
4114    #[test]
4115    fn disconnect_resets_backoff_when_primary_spoke() {
4116        // Reset to 1s, then doubled for the next attempt by the shared
4117        // helper — NOT stuck at MAX_BACKOFF.
4118        assert_eq!(
4119            backoff_after_disconnect(true),
4120            std::time::Duration::from_secs(2)
4121        );
4122    }
4123
4124    /// A session with no word from the primary — an instant-drop flap,
4125    /// or the synthetic result of a session that never started
4126    /// streaming (local transport init failure) — must keep its
4127    /// escalated backoff rather than redialing at 1 Hz forever.
4128    #[test]
4129    fn disconnect_keeps_escalated_backoff_when_primary_silent() {
4130        assert_eq!(backoff_after_disconnect(false), MAX_BACKOFF);
4131    }
4132
4133    /// A damaged replicated entry proves the primary spoke, so it resets
4134    /// the backoff: one bit flip after a long clean session (with a
4135    /// backoff escalated at start-up and never reset since) must not
4136    /// cost a `MAX_BACKOFF` stall before the entry is fetched again.
4137    #[test]
4138    fn corrupted_entry_resets_backoff() {
4139        assert_eq!(
4140            backoff_after_exit(SessionExit::Corrupted("damaged slot".into()), true),
4141            std::time::Duration::from_secs(2)
4142        );
4143    }
4144
4145    /// A stream contiguity break is a reconnect, never a process exit:
4146    /// the contiguous prefix is committed and the journal is intact, so
4147    /// the receiver re-handshakes from its durable position and the
4148    /// primary re-streams the hole. It used to fall through the `Fatal`
4149    /// arm and take the replica down for a documented-benign primary
4150    /// handoff corner. The pipeline must be left standing (it is what
4151    /// the reconnect resumes into) and the backoff reset — the primary
4152    /// was evidently speaking.
4153    #[test]
4154    fn stream_gap_reconnects_and_keeps_pipeline() {
4155        let dir = tempfile::tempdir().expect("tempdir");
4156        type Writer = melin_journal::BufferedWriter<CounterEvent>;
4157        // Same shape as `teardown_fixture`, but with a real writer type:
4158        // `handle_session_exit` is bounded on `JournalWrite` for the
4159        // resync arm even though this path never touches it.
4160        let (input_producer, mut consumers) =
4161            melin_pipeline::ring::DisruptorBuilder::<InputSlot>::new(4)
4162                .add_consumer()
4163                .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
4164        let _consumer = consumers.pop().expect("one consumer");
4165        let writer_path = dir.path().join("w.journal");
4166        let handles = ReplicaPipelineHandles {
4167            input_producer,
4168            journal_cursor: Arc::new(make_journal_cursor(0)),
4169            chain_hash_lock: fsync_state_at(0, 0).1,
4170            stream_marks: melin_transport_core::pipeline::StreamMarks::shared(),
4171            genesis_entries: None,
4172            journal_failed: Arc::new(AtomicBool::new(false)),
4173            pipeline_shutdown: Arc::new(AtomicBool::new(false)),
4174            journal_handle: std::thread::spawn(move || Writer::create(&writer_path)),
4175            matching_handle: std::thread::spawn(counter_server::Counter::default),
4176            drain_handle: std::thread::spawn(|| {}),
4177            shadow_handle: None,
4178        };
4179        let mut pipeline: Option<ReplicaPipelineHandles<counter_server::Counter, Writer>> =
4180            Some(handles);
4181        let mut divergence_resyncs = 0u32;
4182        let mut backoff = MAX_BACKOFF;
4183        let shutdown = AtomicBool::new(true);
4184        let promote = crate::promotion::PromotionRequest::new();
4185        let mut closed = false;
4186        let after = handle_session_exit::<counter_server::Counter, Writer>(
4187            StreamingResult {
4188                exit: SessionExit::StreamGap("sequence gap: expected 3, got 5".into()),
4189                heard_from_primary: true,
4190            },
4191            &mut pipeline,
4192            &mut divergence_resyncs,
4193            &mut backoff,
4194            2,
4195            &NodeStore::resolve(dir.path().join("r.journal"), None).expect("store"),
4196            &melin_transport_core::fence::FenceState::new(0),
4197            &SharedLineagePolicy::new(None),
4198            &shutdown,
4199            &promote,
4200            || closed = true,
4201            &(),
4202        );
4203        assert!(
4204            matches!(after, AfterSession::Reconnect),
4205            "a stream gap must reconnect, not return an error"
4206        );
4207        assert!(
4208            pipeline.is_some(),
4209            "the pipeline must survive for the reconnect"
4210        );
4211        assert!(closed, "the transport must be closed before redialing");
4212        assert_eq!(
4213            backoff,
4214            std::time::Duration::from_secs(2),
4215            "backoff reset to 1s then doubled by the shared helper"
4216        );
4217        assert_eq!(divergence_resyncs, 0, "not a resync");
4218        if let Some(p) = pipeline.take() {
4219            let _ = teardown_replica_pipeline::<counter_server::Counter, Writer>(p);
4220        }
4221    }
4222
4223    /// With the journal-failure latch set and the ring full — the state
4224    /// a dead journal stage leaves behind (its frozen gate cursor means
4225    /// the ring never drains) — teardown must skip the sentinel and
4226    /// return, instead of spinning forever on ring backpressure.
4227    #[test]
4228    fn teardown_skips_sentinel_when_journal_failed_and_ring_full() {
4229        let (mut handles, mut consumer) = teardown_fixture(4);
4230        for seq in 0..4 {
4231            assert!(
4232                handles
4233                    .input_producer
4234                    .try_publish(InputSlot {
4235                        sequence: seq,
4236                        ..InputSlot::default()
4237                    })
4238                    .is_ok(),
4239                "ring must have space while filling"
4240            );
4241        }
4242        assert!(
4243            handles
4244                .input_producer
4245                .try_publish(InputSlot::default())
4246                .is_err(),
4247            "ring must be full"
4248        );
4249        handles.journal_failed.store(true, Ordering::Release);
4250        // Would spin forever on the frozen gate without the latch check.
4251        assert!(matches!(
4252            teardown_replica_pipeline::<counter_server::Counter, u32>(handles),
4253            TeardownOutcome::Clean(..)
4254        ));
4255        while let Some((_, slot)) = consumer.try_consume() {
4256            assert!(!slot.event.is_shutdown(), "no sentinel may be published");
4257        }
4258    }
4259
4260    #[test]
4261    fn pending_ack_queue_wraps_around() {
4262        let mut q = PendingAckQueue::new(8);
4263        let cursor = make_journal_cursor(100);
4264
4265        // Fill and drain multiple times to exercise circular buffer wrap.
4266        for round in 0..3 {
4267            for i in 0..8 {
4268                let target = (round * 8 + i) as u64 + 1;
4269                q.push(target, target * 10);
4270            }
4271            assert!(q.is_full());
4272            let seq = q.pop_ready(&cursor).expect("should be ready");
4273            assert_eq!(seq, (round * 8 + 8) as u64 * 10);
4274            assert!(q.is_empty());
4275        }
4276    }
4277
4278    #[test]
4279    fn pending_ack_queue_pop_all_blocking_empty() {
4280        let mut q = PendingAckQueue::new(8);
4281        let cursor = make_journal_cursor(0);
4282        assert!(
4283            q.pop_all_blocking(
4284                &cursor,
4285                melin_pipeline::wait::WaitStrategy::BusySpin,
4286                &AtomicBool::new(false)
4287            )
4288            .is_none()
4289        );
4290    }
4291
4292    // --- try_flush_dual_track tests ---
4293
4294    #[test]
4295    fn dual_track_returns_none_when_both_tracks_idle() {
4296        let mut q = PendingAckQueue::new(8);
4297        let cursor = make_journal_cursor(0);
4298        assert!(
4299            try_flush_dual_track(&mut q, &cursor, 0, 0, 0).is_none(),
4300            "no advance on either track → no ack"
4301        );
4302    }
4303
4304    #[test]
4305    fn dual_track_fires_on_persisted_advance_only() {
4306        // Persisted track moves: pop_ready returns 100; in-memory
4307        // stayed at 50 (last_sent). Expect ack with acked=100,
4308        // in_memory=50 (no regression — the wire field carries the
4309        // current sample, not a "no change" marker).
4310        let mut q = PendingAckQueue::new(8);
4311        q.push(10, 100);
4312        let cursor = make_journal_cursor(20);
4313        let ack = try_flush_dual_track(&mut q, &cursor, 50, 0, 50).expect("persisted advanced");
4314        assert_eq!(ack.acked_sequence, 100);
4315        assert_eq!(ack.in_memory_sequence, 50);
4316    }
4317
4318    #[test]
4319    fn dual_track_fires_on_in_memory_advance_only() {
4320        // Persisted is idle (queue empty): unwrap_or keeps the
4321        // last-sent acked at 100. In-memory bumped from 100 to 200.
4322        let mut q = PendingAckQueue::new(8);
4323        let cursor = make_journal_cursor(0);
4324        let ack = try_flush_dual_track(&mut q, &cursor, 200, 100, 100).expect("in-memory advanced");
4325        assert_eq!(ack.acked_sequence, 100);
4326        assert_eq!(ack.in_memory_sequence, 200);
4327    }
4328
4329    #[test]
4330    fn dual_track_coalesces_until_caller_updates_trackers() {
4331        // Caller did not advance trackers between calls. The queue
4332        // popped on call 1; on call 2 it's empty so persisted stays
4333        // at 100 (unwrap_or). In-memory advanced 50 → 80 between
4334        // calls. Second call must still fire because tracker is
4335        // still 50.
4336        let mut q = PendingAckQueue::new(8);
4337        q.push(10, 100);
4338        let cursor = make_journal_cursor(20);
4339        let ack1 = try_flush_dual_track(&mut q, &cursor, 50, 0, 50).expect("call 1 fires");
4340        assert_eq!(ack1.acked_sequence, 100);
4341        // Caller "forgot" to update trackers (simulates send failure).
4342        let ack2 = try_flush_dual_track(&mut q, &cursor, 80, 0, 50)
4343            .expect("call 2 fires on in-memory advance");
4344        assert_eq!(
4345            ack2.acked_sequence, 0,
4346            "no new persisted pop → unwrap_or(last_sent)"
4347        );
4348        assert_eq!(ack2.in_memory_sequence, 80);
4349    }
4350
4351    #[test]
4352    fn dual_track_no_duplicate_ack_after_backpressure_drain() {
4353        // Models the backpressure-drain → resume-normal-flush path
4354        // taken by every receiver when `PendingAckQueue` fills up.
4355        // The receiver must drain the queue (via the
4356        // pop_oldest_blocking path in production; pop_ready here),
4357        // update *both* trackers from the drained ack, and only
4358        // then resume the normal cursor-driven flush. The bug
4359        // classes this pins are:
4360        //   (a) emitting a follow-up ack whose `acked_sequence`
4361        //       regresses below what the drain already sent
4362        //       (caught by the debug_assert! in
4363        //       `try_flush_dual_track`, also asserted at value
4364        //       level here);
4365        //   (b) emitting a duplicate ack carrying the same
4366        //       cursors as the drain when neither track actually
4367        //       advanced — the leak class fixed by ensuring every
4368        //       send site updates both `last_sent_acked_seq` and
4369        //       `last_sent_in_memory_seq`.
4370        let mut q = PendingAckQueue::new(4);
4371        // Fill the queue: four pending acks at primary seqs
4372        // 100, 200, 300, 400 with journal targets 10..=40.
4373        for i in 1..=4u64 {
4374            q.push(i * 10, i * 100);
4375        }
4376        assert!(q.is_full());
4377
4378        // Backpressure path: caller drains all ready entries
4379        // before pushing more. Highest acked seen = 400.
4380        let cursor = make_journal_cursor(40);
4381        let drained = q.pop_ready(&cursor).expect("all entries durable");
4382        assert_eq!(drained, 400);
4383        assert!(q.is_empty());
4384
4385        // Caller updates BOTH trackers from the drain. in_memory
4386        // at the time of the drained batch was 450.
4387        let mut last_sent_acked = drained;
4388        let mut last_sent_in_mem = 450u64;
4389
4390        // Quiescent immediately after drain: no fresh push, no
4391        // in-memory advance → try_flush must return None.
4392        // Failing here means we'd emit a duplicate ack carrying
4393        // the same (400, 450) the backpressure drain already sent.
4394        assert!(
4395            try_flush_dual_track(
4396                &mut q,
4397                &cursor,
4398                last_sent_in_mem,
4399                last_sent_acked,
4400                last_sent_in_mem,
4401            )
4402            .is_none(),
4403            "no advance on either track after backpressure drain → no duplicate ack",
4404        );
4405
4406        // Push a fresh batch (primary seq 500, journal target 50),
4407        // cursor catches up, in_memory advances to 500.
4408        q.push(50, 500);
4409        let cursor2 = make_journal_cursor(50);
4410        let ack = try_flush_dual_track(&mut q, &cursor2, 500, last_sent_acked, last_sent_in_mem)
4411            .expect("fresh batch after drain fires a new ack");
4412        assert!(
4413            ack.acked_sequence >= last_sent_acked,
4414            "regression: drain sent {last_sent_acked} but next ack carries {}",
4415            ack.acked_sequence,
4416        );
4417        assert_eq!(ack.acked_sequence, 500);
4418        assert_eq!(ack.in_memory_sequence, 500);
4419
4420        last_sent_acked = ack.acked_sequence;
4421        last_sent_in_mem = ack.in_memory_sequence;
4422
4423        // Post-resume quiescent: confirms idempotency past the
4424        // resume point — the second flush sees neither track
4425        // advance and must stay silent.
4426        assert!(
4427            try_flush_dual_track(
4428                &mut q,
4429                &cursor2,
4430                last_sent_in_mem,
4431                last_sent_acked,
4432                last_sent_in_mem,
4433            )
4434            .is_none(),
4435            "post-resume idle → no further ack",
4436        );
4437    }
4438}