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