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