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