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