Skip to main content

melin_server_runtime/replication/
mod.rs

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