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][genesis_len:u32][genesis_bytes...]`
32//! - **NeedSnapshot**: `[len:u32][0x11]`
33//! - **HashMismatch**: `[len:u32][0x12]`
34//! - **SnapshotBegin**: `[len:u32][0x13][snapshot_len:u64][snap_sequence:u64][snap_chain_hash:[u8;32]]`
35//! - **SnapshotChunk**: `[len:u32][0x14][data...]`
36//! - **SnapshotEnd**: `[len:u32][0x15][crc32c:u32]`
37//! - **InputBatch**: `[len:u32][0x21][count:u16][slot...]` — see
38//!   `transport-core::replication_wire` for the per-slot layout
39//! - **Heartbeat**: `[len:u32][0x30][sequence:u64]`
40//!
41//! ## v1 Limitations
42//!
43//! - No handshake chain hash validation (HashMismatch never sent)
44//! - Dual replication (up to 2 replicas in parallel)
45//!
46//! See `docs/replication.md` for the full design document and limitation details.
47
48use std::sync::Arc;
49use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
50
51use melin_journal::JournalWrite;
52use melin_transport_core::pipeline::{JournalStage, JournalStageRun};
53
54use melin_app::Application;
55use melin_transport_core::pipeline::{InputSlot, OutputSlot};
56
57mod auth;
58#[cfg(feature = "dpdk")]
59mod dpdk;
60mod receiver_transport;
61mod tcp_receiver;
62mod tcp_sender;
63
64// Wire-protocol types, auth, catch-up, ack queueing, dual-track
65// cursor management, and per-replica metrics now live in
66// `melin_transport_core::replication`. Re-export the public types
67// here so the module's public API surface (e.g.
68// `melin_server_runtime::replication::Ack` / `::ReplicationMetrics`) is
69// unchanged for downstream consumers and tests.
70pub use melin_transport_core::replication::ReplicationMetrics;
71pub use melin_transport_core::replication::ack_queue::{
72    PendingAck, PendingAckQueue, try_flush_dual_track, update_dual_replication_cursor,
73    wait_for_journal_cursor,
74};
75pub use melin_transport_core::replication::protocol::{
76    Ack, Handshake, PrimaryMessage, ReplicaMessage,
77};
78
79#[cfg(feature = "dpdk")]
80pub use dpdk::{DpdkReplicationDriver, run_receiver_dpdk};
81pub use tcp_receiver::{ReceiverResult, run_receiver};
82pub use tcp_sender::{Sender, run_sender};
83
84/// Diagnostic: emit a `tcp_info` span at debug level describing the
85/// kernel's view of the socket (rtt, cwnd, retrans, unacked, rcv_space,
86/// rto). Guarded internally with `tracing::enabled!` so the
87/// `getsockopt(TCP_INFO)` syscall is skipped when debug logging is off —
88/// the call is free at runtime unless `RUST_LOG=debug` (or a more
89/// specific filter) is active. Used to distinguish user-space stalls
90/// from TCP-level congestion collapse when diagnosing replication
91/// slowdowns.
92pub(super) fn log_tcp_info(fd: std::os::unix::io::RawFd, tag: &str, slot: usize) {
93    if !tracing::enabled!(tracing::Level::DEBUG) {
94        return;
95    }
96    // SAFETY: all-zero pattern is a valid `tcp_info` (all numeric fields).
97    // The kernel fills in whatever the running version supports and
98    // returns the written length in `len`.
99    let mut info: libc::tcp_info = unsafe { std::mem::zeroed() };
100    let mut len = std::mem::size_of::<libc::tcp_info>() as libc::socklen_t;
101    let rc = unsafe {
102        libc::getsockopt(
103            fd,
104            libc::IPPROTO_TCP,
105            libc::TCP_INFO,
106            &mut info as *mut _ as *mut libc::c_void,
107            &mut len,
108        )
109    };
110    if rc != 0 {
111        tracing::debug!(
112            slot,
113            tag,
114            errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0),
115            "tcp_info getsockopt failed"
116        );
117        return;
118    }
119    tracing::debug!(
120        slot,
121        tag,
122        state = info.tcpi_state,
123        ca_state = info.tcpi_ca_state,
124        rtt_us = info.tcpi_rtt,
125        rttvar_us = info.tcpi_rttvar,
126        snd_cwnd = info.tcpi_snd_cwnd,
127        snd_ssthresh = info.tcpi_snd_ssthresh,
128        snd_mss = info.tcpi_snd_mss,
129        rcv_mss = info.tcpi_rcv_mss,
130        unacked = info.tcpi_unacked,
131        retrans = info.tcpi_retrans,
132        total_retrans = info.tcpi_total_retrans,
133        lost = info.tcpi_lost,
134        rcv_space = info.tcpi_rcv_space,
135        rto_us = info.tcpi_rto,
136        "tcp_info"
137    );
138}
139
140/// Sleep for the given duration in 100ms increments, checking shutdown
141/// and promote flags between increments. Returns early if either is set.
142pub(super) fn sleep_checking_flags(
143    duration: std::time::Duration,
144    shutdown: &AtomicBool,
145    promote: &AtomicBool,
146) {
147    let deadline = std::time::Instant::now() + duration;
148    while std::time::Instant::now() < deadline {
149        if shutdown.load(Ordering::Relaxed) || promote.load(Ordering::Acquire) {
150            return;
151        }
152        std::thread::sleep(std::time::Duration::from_millis(100));
153    }
154}
155
156/// Shut down the replica pipeline and extract Exchange + SectorWriter from
157/// the stage threads. Returns None if a thread panicked.
158///
159/// Relies on the caller having published a `JournalEvent::Shutdown`
160/// sentinel to the input ring before invoking this — the journal and
161/// matching stages exit when they consume the sentinel via the normal
162/// event-processing path. We deliberately don't flip `shutdown_flag`
163/// here: doing so could cause a stage to take its emergency-abort
164/// branch *before* consuming the sentinel, hitting the
165/// drain-vs-cursor race that the sentinel design exists to avoid.
166///
167/// `drain_handle` and `shadow_handle` still observe `shutdown_flag`
168/// because they don't process the input ring (no sentinel reaches
169/// them); set the flag for them only.
170pub(super) fn shutdown_pipeline<A: Application + Send + 'static, W: Send + 'static>(
171    shutdown_flag: &AtomicBool,
172    journal_handle: std::thread::JoinHandle<Result<W, melin_journal::JournalError>>,
173    matching_handle: std::thread::JoinHandle<A>,
174    drain_handle: std::thread::JoinHandle<()>,
175    shadow_handle: Option<std::thread::JoinHandle<()>>,
176) -> Option<(A, W)> {
177    // Defense-in-depth: set the flag before joining. The sentinel was
178    // already published by `tcp_receiver` before this call, so no further
179    // events can arrive in the input ring — setting the flag here cannot
180    // race with new publishes. The flag is the fallback exit signal for
181    // paths that don't observe the sentinel: `run_sync` in `no-persist`
182    // builds, the drain consumer, the shadow stage, and any case where
183    // the receiver thread panicked before publishing the sentinel.
184    shutdown_flag.store(true, Ordering::Release);
185    let writer = match journal_handle.join() {
186        Ok(Ok(w)) => w,
187        Ok(Err(e)) => {
188            tracing::error!(error = %e, "replica journal stage returned error on shutdown");
189            return None;
190        }
191        Err(_) => return None,
192    };
193    let exchange = matching_handle.join().ok()?;
194    let _ = drain_handle.join();
195    if let Some(h) = shadow_handle {
196        let _ = h.join();
197    }
198    Some((exchange, writer))
199}
200
201/// Live replica pipeline — built once on first connect (or after a snapshot
202/// transfer), persists across `Disconnected` reconnects so the orchestrator
203/// doesn't pay the journal-recover + thread-spawn cost on every drop.
204///
205/// Shared between the kernel-TCP and DPDK receivers; both build pipelines
206/// with the same shape (journal / matching / drain / optional shadow), and
207/// both refresh handshake state from `last_seq` + `chain_hash_lock` at
208/// reconnect time.
209pub(super) struct ReplicaPipelineHandles<A: Application, W: Send + 'static> {
210    pub(super) input_producer: melin_pipeline::ring::Producer<InputSlot<A::Event>>,
211    pub(super) journal_cursor: Arc<melin_pipeline::padding::Sequence>,
212    /// Highest journal sequence durably persisted, published by JournalStage
213    /// after each fsync. Read by the orchestrator to fill in the reconnect
214    /// handshake without owning the writer.
215    pub(super) last_seq: Arc<AtomicU64>,
216    /// SeqLock-published fsync state (chain hash + journal seq + ring
217    /// cursor). Option to mirror the primary-side pattern; always Some
218    /// on replicas now.
219    pub(super) chain_hash_lock:
220        Option<Arc<melin_pipeline::seqlock::SeqLock<melin_transport_core::pipeline::FsyncState>>>,
221    /// Per-pipeline shutdown flag — flipped only on a controlled teardown
222    /// (Promote/Shutdown/Fatal/Snapshot). NOT flipped on `Disconnected`.
223    pub(super) pipeline_shutdown: Arc<AtomicBool>,
224    pub(super) journal_handle: std::thread::JoinHandle<Result<W, melin_journal::JournalError>>,
225    pub(super) matching_handle: std::thread::JoinHandle<A>,
226    pub(super) drain_handle: std::thread::JoinHandle<()>,
227    pub(super) shadow_handle: Option<std::thread::JoinHandle<()>>,
228}
229
230/// Build the replica pipeline and spawn its stage threads on the configured
231/// cores. Returns the bundle of state the orchestrator keeps across
232/// `Disconnected` reconnects.
233pub(super) fn build_replica_pipeline_with_threads<A, W>(
234    exchange: A,
235    writer: W,
236    cores: crate::server::PipelineCores,
237    snapshot_interval_ms: u64,
238    snapshot_path: std::path::PathBuf,
239    group_commit_delay: std::time::Duration,
240    busy_spin: bool,
241    rotation: Option<(u64, Arc<AtomicBool>)>,
242) -> Result<ReplicaPipelineHandles<A, W>, Box<dyn std::error::Error>>
243where
244    A: Application + Send + 'static,
245    A::Event: Send + Sync + 'static,
246    A::Report: Send + 'static,
247    A::QueryResponse: Send + 'static,
248    W: JournalWrite<A::Event> + Send + 'static,
249    JournalStage<A::Event, W>: JournalStageRun<A::Event, Writer = W>,
250{
251    let shadow_exchange = <A as Application>::clone_via_snapshot(&exchange)?;
252
253    let enable_shadow = snapshot_interval_ms > 0;
254    let pipeline = melin_transport_core::pipeline::build_replica_pipeline(
255        exchange,
256        writer,
257        4096, // max_journal_batch
258        group_commit_delay,
259        busy_spin,
260        enable_shadow,
261    );
262
263    let pipeline_shutdown = Arc::new(AtomicBool::new(false));
264
265    let ps = Arc::clone(&pipeline_shutdown);
266    let journal_core = cores.journal;
267    let mut journal_stage = pipeline.journal_stage;
268    if let Some((max_bytes, flag)) = rotation {
269        journal_stage.set_rotation(max_bytes, Some(flag));
270    }
271    let journal_handle = std::thread::Builder::new()
272        .name("journal".into())
273        .spawn(move || {
274            melin_app::affinity::pin_thread("journal", journal_core);
275            journal_stage.run(&ps)
276        })
277        .expect("spawn journal thread");
278
279    let ps = Arc::clone(&pipeline_shutdown);
280    let matching_core = cores.matching;
281    let matching_stage = pipeline.matching_stage;
282    let matching_handle = std::thread::Builder::new()
283        .name("matching".into())
284        .spawn(move || {
285            melin_app::affinity::pin_thread("matching", matching_core);
286            matching_stage.run(&ps)
287        })
288        .expect("spawn matching thread");
289
290    // Drain thread uses the response core — replicas have no response stage,
291    // but the consumer needs to be drained so the output ring doesn't fill.
292    let ps = Arc::clone(&pipeline_shutdown);
293    let drain_core = cores.response;
294    let drain_consumer = pipeline.drain_consumer;
295    let drain_handle = std::thread::Builder::new()
296        .name("drain".into())
297        .spawn(move || {
298            melin_app::affinity::pin_thread("drain", drain_core);
299            let mut consumer = drain_consumer;
300            let mut batch = vec![OutputSlot::<A::Report, A::QueryResponse>::default(); 256];
301            loop {
302                if ps.load(Ordering::Relaxed) {
303                    return;
304                }
305                let count = consumer.consume_batch(&mut batch, 256);
306                if count == 0 {
307                    if busy_spin {
308                        std::hint::spin_loop();
309                    } else {
310                        std::thread::yield_now();
311                    }
312                }
313            }
314        })
315        .expect("spawn drain thread");
316
317    let shadow_handle = if let Some(shadow_cons) = pipeline.shadow_consumer {
318        let snap_path = snapshot_path;
319        let chain_lock = pipeline
320            .chain_hash_lock
321            .as_ref()
322            .expect("chain hash lock with shadow")
323            .clone();
324        let ps = Arc::clone(&pipeline_shutdown);
325        let shadow_core = cores.shadow;
326        Some(
327            std::thread::Builder::new()
328                .name("replica-shadow".into())
329                .spawn(move || {
330                    melin_app::affinity::pin_thread("replica-shadow", shadow_core);
331                    melin_transport_core::shadow::run(
332                        shadow_cons,
333                        shadow_exchange,
334                        snap_path,
335                        std::time::Duration::from_millis(snapshot_interval_ms),
336                        chain_lock,
337                        &ps,
338                        false,
339                    );
340                })
341                .expect("spawn shadow thread"),
342        )
343    } else {
344        None
345    };
346
347    Ok(ReplicaPipelineHandles {
348        input_producer: pipeline.input_producer,
349        journal_cursor: pipeline.journal_cursor,
350        last_seq: pipeline.last_seq,
351        chain_hash_lock: pipeline.chain_hash_lock,
352        pipeline_shutdown,
353        journal_handle,
354        matching_handle,
355        drain_handle,
356        shadow_handle,
357    })
358}
359
360/// Tear down the pipeline: signal shutdown, join all threads, return the
361/// recovered (App, SectorWriter) so the orchestrator can use them for the
362/// next pipeline build (e.g., post-snapshot) or pass them up on promotion.
363pub(super) fn teardown_replica_pipeline<A: Application + Send + 'static, W: Send + 'static>(
364    handles: ReplicaPipelineHandles<A, W>,
365) -> Option<(A, W)> {
366    shutdown_pipeline::<A, W>(
367        &handles.pipeline_shutdown,
368        handles.journal_handle,
369        handles.matching_handle,
370        handles.drain_handle,
371        handles.shadow_handle,
372    )
373}
374
375#[cfg(test)]
376mod tests {
377    use std::io::Write;
378    use std::sync::Arc;
379    use std::sync::atomic::AtomicU32;
380
381    use super::auth::{authenticate_replica, authenticate_with_primary};
382    use super::*;
383    use melin_trading::trading_event::TradingEvent;
384    type InputSlot = melin_transport_core::pipeline::InputSlot<TradingEvent>;
385    use melin_transport_core::replication::protocol::{
386        MAX_CONTROL_FRAME, MAX_DATA_FRAME, MSG_AUTH_OK, MSG_CHALLENGE_RESPONSE, MSG_SNAPSHOT_BEGIN,
387        MSG_SNAPSHOT_CHUNK, MSG_SNAPSHOT_END, decode_auth_result, decode_challenge,
388        decode_challenge_response, decode_primary_message, decode_replica_message, encode_ack,
389        encode_auth_failed, encode_auth_ok, encode_challenge, encode_challenge_response,
390        encode_handshake, encode_hash_mismatch, encode_heartbeat, encode_input_batch,
391        encode_need_snapshot, encode_snapshot_begin, encode_snapshot_chunk, encode_snapshot_end,
392        encode_stream_start, read_frame, try_decode_input_batch,
393    };
394
395    /// Build a wire-ready `InputBatch` frame containing a single `Tick`
396    /// slot at the given sequence — the protocol-level tests don't need
397    /// real journal payloads, just something with a known max sequence.
398    fn encode_input_batch_with_seq(end_sequence: u64, buf: &mut Vec<u8>) {
399        let slot = InputSlot {
400            connection_id: 0,
401            key_hash: 0,
402            request_seq: 0,
403            sequence: end_sequence,
404            timestamp_ns: 0,
405            event: melin_journal::JournalEvent::Tick { now_ns: 0 },
406            publish_ts: Default::default(),
407            recv_ts: Default::default(),
408        };
409        encode_input_batch(&[slot], buf);
410    }
411
412    #[test]
413    fn handshake_encode_decode_round_trip() {
414        let handshake = Handshake {
415            last_sequence: 42,
416            chain_hash: [0xAB; 32],
417        };
418        let mut buf = Vec::new();
419        encode_handshake(&handshake, &mut buf);
420
421        // Read frame: skip 4-byte length prefix.
422        let payload = &buf[4..];
423        let msg = decode_replica_message(payload).unwrap();
424        match msg {
425            ReplicaMessage::Handshake(h) => {
426                assert_eq!(h.last_sequence, 42);
427                assert_eq!(h.chain_hash, [0xAB; 32]);
428            }
429            _ => panic!("expected Handshake"),
430        }
431    }
432
433    #[test]
434    fn ack_encode_decode_round_trip() {
435        let ack = Ack {
436            acked_sequence: 1000,
437            in_memory_sequence: 1024,
438        };
439        let mut buf = Vec::new();
440        encode_ack(&ack, &mut buf);
441
442        let payload = &buf[4..];
443        let msg = decode_replica_message(payload).unwrap();
444        match msg {
445            ReplicaMessage::Ack(a) => {
446                assert_eq!(a.acked_sequence, 1000);
447                assert_eq!(a.in_memory_sequence, 1024);
448            }
449            _ => panic!("expected Ack"),
450        }
451    }
452
453    /// Pin the exact on-the-wire byte layout of an Ack frame. A future
454    /// `repr(C)` field reorder, alignment change, or accidental
455    /// big-endian wrapper substitution would silently break replica/
456    /// primary compatibility — the const `size_of` assert in
457    /// `protocol.rs` only catches size changes, not layout changes.
458    /// This test pins the expected bytes so any such break shows up
459    /// loudly on the next CI run.
460    #[test]
461    fn ack_wire_byte_pattern() {
462        let ack = Ack {
463            acked_sequence: 0xDEAD_BEEF_CAFE_F00D,
464            in_memory_sequence: 0x1122_3344_5566_7788,
465        };
466        let mut buf = Vec::new();
467        encode_ack(&ack, &mut buf);
468        // [length:u32 LE = 17][tag:u8 = MSG_ACK = 0x02]
469        // [acked_sequence:u64 LE][in_memory_sequence:u64 LE]
470        let expected: &[u8] = &[
471            0x11, 0x00, 0x00, 0x00, // length = 17 LE
472            0x02, // MSG_ACK
473            0x0D, 0xF0, 0xFE, 0xCA, 0xEF, 0xBE, 0xAD, 0xDE, // acked_sequence LE
474            0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, // in_memory_sequence LE
475        ];
476        assert_eq!(buf.as_slice(), expected, "Ack wire layout drifted");
477    }
478
479    #[test]
480    fn stream_start_encode_decode_round_trip() {
481        let mut buf = Vec::new();
482        encode_stream_start(99, &[0xAA; 64], &mut buf);
483
484        let payload = &buf[4..];
485        let msg = decode_primary_message(payload).unwrap();
486        match msg {
487            PrimaryMessage::StreamStart {
488                start_sequence,
489                genesis_entry,
490            } => {
491                assert_eq!(start_sequence, 99);
492                assert_eq!(genesis_entry, vec![0xAA; 64]);
493            }
494            _ => panic!("expected StreamStart"),
495        }
496    }
497
498    #[test]
499    fn heartbeat_encode_decode_round_trip() {
500        let mut buf = Vec::new();
501        encode_heartbeat(123, &mut buf);
502
503        let payload = &buf[4..];
504        let msg = decode_primary_message(payload).unwrap();
505        match msg {
506            PrimaryMessage::Heartbeat { sequence } => {
507                assert_eq!(sequence, 123);
508            }
509            _ => panic!("expected Heartbeat"),
510        }
511    }
512
513    #[test]
514    fn need_snapshot_encode_decode_round_trip() {
515        let mut buf = Vec::new();
516        encode_need_snapshot(&mut buf);
517
518        let payload = &buf[4..];
519        let msg = decode_primary_message(payload).unwrap();
520        assert!(matches!(msg, PrimaryMessage::NeedSnapshot));
521    }
522
523    #[test]
524    fn hash_mismatch_encode_decode_round_trip() {
525        let mut buf = Vec::new();
526        encode_hash_mismatch(&mut buf);
527
528        let payload = &buf[4..];
529        let msg = decode_primary_message(payload).unwrap();
530        assert!(matches!(msg, PrimaryMessage::HashMismatch));
531    }
532
533    #[test]
534    fn unknown_replica_message_type_is_error() {
535        let payload = [0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
536        let result = decode_replica_message(&payload);
537        assert!(result.is_err());
538    }
539
540    #[test]
541    fn unknown_primary_message_type_is_error() {
542        let payload = [0xFF, 0, 0, 0, 0, 0, 0, 0, 0];
543        let result = decode_primary_message(&payload);
544        assert!(result.is_err());
545    }
546
547    #[test]
548    fn read_frame_enforces_max_size() {
549        // Create a buffer with a length prefix claiming 1000 bytes.
550        let mut buf = Vec::new();
551        buf.extend_from_slice(&1000u32.to_le_bytes());
552        buf.extend_from_slice(&[0u8; 100]); // not enough data, but max_size check comes first
553
554        let mut cursor = std::io::Cursor::new(buf);
555        let result = read_frame(&mut cursor, 64);
556        assert!(result.is_err());
557    }
558
559    #[test]
560    fn challenge_encode_decode_round_trip() {
561        let nonce = [0x42; 32];
562        let mut buf = Vec::new();
563        encode_challenge(&nonce, &mut buf);
564
565        let payload = &buf[4..];
566        let decoded = decode_challenge(payload).unwrap();
567        assert_eq!(decoded, nonce);
568    }
569
570    #[test]
571    fn challenge_response_encode_decode_round_trip() {
572        let sig = [0xAA; 64];
573        let pubkey = [0xBB; 32];
574        let mut buf = Vec::new();
575        encode_challenge_response(&sig, &pubkey, &mut buf);
576
577        let payload = &buf[4..];
578        let (decoded_sig, decoded_pubkey) = decode_challenge_response(payload).unwrap();
579        assert_eq!(decoded_sig, sig);
580        assert_eq!(decoded_pubkey, pubkey);
581    }
582
583    #[test]
584    fn auth_ok_encode_decode_round_trip() {
585        let mut buf = Vec::new();
586        encode_auth_ok(&mut buf);
587
588        let payload = &buf[4..];
589        assert!(decode_auth_result(payload).unwrap());
590    }
591
592    #[test]
593    fn auth_failed_encode_decode_round_trip() {
594        let mut buf = Vec::new();
595        encode_auth_failed(&mut buf);
596
597        let payload = &buf[4..];
598        assert!(!decode_auth_result(payload).unwrap());
599    }
600
601    #[test]
602    fn decode_challenge_rejects_wrong_tag() {
603        let mut payload = [0u8; 33];
604        payload[0] = MSG_AUTH_OK;
605        assert!(decode_challenge(&payload).is_err());
606    }
607
608    #[test]
609    fn decode_challenge_response_rejects_short_payload() {
610        let payload = [MSG_CHALLENGE_RESPONSE; 10]; // too short
611        assert!(decode_challenge_response(&payload).is_err());
612    }
613
614    #[test]
615    fn auth_round_trip_valid_key() {
616        use ed25519_dalek::SigningKey;
617        use std::os::unix::net::UnixStream;
618
619        let repl_key = SigningKey::from_bytes(&[0xFC; 32]);
620        let pub_b64 = base64::Engine::encode(
621            &base64::engine::general_purpose::STANDARD,
622            repl_key.verifying_key().to_bytes(),
623        );
624        let keys_content = format!("replication {pub_b64} test-replica\n");
625        let authorized_keys = melin_app::auth::AuthorizedKeys::parse(&keys_content).unwrap();
626
627        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
628        primary_stream
629            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
630            .unwrap();
631        replica_stream
632            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
633            .unwrap();
634
635        let repl_key_clone = SigningKey::from_bytes(&[0xFC; 32]);
636        let replica_handle = std::thread::spawn(move || {
637            let mut reader = replica_stream.try_clone().unwrap();
638            let mut writer = replica_stream;
639            authenticate_with_primary(&mut reader, &mut writer, &repl_key_clone)
640        });
641
642        let mut reader = primary_stream.try_clone().unwrap();
643        let mut writer = primary_stream;
644        authenticate_replica(&mut reader, &mut writer, &authorized_keys).unwrap();
645
646        replica_handle.join().unwrap().unwrap();
647    }
648
649    #[test]
650    fn auth_rejects_unknown_key() {
651        use ed25519_dalek::SigningKey;
652        use std::os::unix::net::UnixStream;
653
654        // authorized_keys has one key, but the replica uses a different one.
655        let authorized_key = SigningKey::from_bytes(&[0xAA; 32]);
656        let rogue_key = SigningKey::from_bytes(&[0xBB; 32]);
657        let pub_b64 = base64::Engine::encode(
658            &base64::engine::general_purpose::STANDARD,
659            authorized_key.verifying_key().to_bytes(),
660        );
661        let keys_content = format!("replication {pub_b64} authorized-replica\n");
662        let authorized_keys = melin_app::auth::AuthorizedKeys::parse(&keys_content).unwrap();
663
664        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
665        primary_stream
666            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
667            .unwrap();
668        replica_stream
669            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
670            .unwrap();
671
672        let replica_handle = std::thread::spawn(move || {
673            let mut reader = replica_stream.try_clone().unwrap();
674            let mut writer = replica_stream;
675            authenticate_with_primary(&mut reader, &mut writer, &rogue_key)
676        });
677
678        let mut reader = primary_stream.try_clone().unwrap();
679        let mut writer = primary_stream;
680        let result = authenticate_replica(&mut reader, &mut writer, &authorized_keys);
681        assert!(result.is_err());
682        assert!(result.unwrap_err().to_string().contains("unknown"));
683
684        // Replica should also get a rejection.
685        let replica_result = replica_handle.join().unwrap();
686        assert!(replica_result.is_err());
687    }
688
689    #[test]
690    fn auth_rejects_wrong_permission() {
691        use ed25519_dalek::SigningKey;
692        use std::os::unix::net::UnixStream;
693
694        // Key exists but has Trader permission, not Replication.
695        let key = SigningKey::from_bytes(&[0xCC; 32]);
696        let pub_b64 = base64::Engine::encode(
697            &base64::engine::general_purpose::STANDARD,
698            key.verifying_key().to_bytes(),
699        );
700        let keys_content = format!("trader {pub_b64} wrong-role\n");
701        let authorized_keys = melin_app::auth::AuthorizedKeys::parse(&keys_content).unwrap();
702
703        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
704        primary_stream
705            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
706            .unwrap();
707        replica_stream
708            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
709            .unwrap();
710
711        let replica_handle = std::thread::spawn(move || {
712            let mut reader = replica_stream.try_clone().unwrap();
713            let mut writer = replica_stream;
714            authenticate_with_primary(&mut reader, &mut writer, &key)
715        });
716
717        let mut reader = primary_stream.try_clone().unwrap();
718        let mut writer = primary_stream;
719        let result = authenticate_replica(&mut reader, &mut writer, &authorized_keys);
720        assert!(result.is_err());
721        assert!(result.unwrap_err().to_string().contains("Replication"));
722
723        let replica_result = replica_handle.join().unwrap();
724        assert!(replica_result.is_err());
725    }
726
727    /// A replica that sends a validly-formatted but tampered signature
728    /// (correct public key, wrong signature bytes) is rejected.
729    #[test]
730    fn auth_rejects_invalid_signature() {
731        use ed25519_dalek::SigningKey;
732        use std::os::unix::net::UnixStream;
733
734        // Register the correct key.
735        let correct_key = SigningKey::from_bytes(&[0xDD; 32]);
736        let pub_b64 = base64::Engine::encode(
737            &base64::engine::general_purpose::STANDARD,
738            correct_key.verifying_key().to_bytes(),
739        );
740        let keys_content = format!("replication {pub_b64} test-replica\n");
741        let authorized_keys = melin_app::auth::AuthorizedKeys::parse(&keys_content).unwrap();
742
743        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
744        primary_stream
745            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
746            .unwrap();
747        replica_stream
748            .set_read_timeout(Some(std::time::Duration::from_secs(5)))
749            .unwrap();
750
751        // Replica side: read challenge, but sign with a DIFFERENT key,
752        // then send the response with the correct public key (spoofing).
753        let replica_handle = std::thread::spawn(move || {
754            use melin_transport_core::replication::protocol::*;
755
756            let mut reader = replica_stream.try_clone().unwrap();
757            let mut writer = replica_stream;
758
759            // Read the challenge.
760            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
761            let nonce = decode_challenge(&frame).unwrap();
762
763            // Sign with a WRONG key but send the CORRECT public key.
764            let wrong_key = SigningKey::from_bytes(&[0xEE; 32]);
765            let bad_signature = ed25519_dalek::Signer::sign(&wrong_key, &nonce);
766            let correct_pubkey = correct_key.verifying_key();
767
768            let mut buf = Vec::with_capacity(128);
769            encode_challenge_response(
770                &bad_signature.to_bytes(),
771                correct_pubkey.as_bytes(),
772                &mut buf,
773            );
774            writer.write_all(&buf).unwrap();
775            writer.flush().unwrap();
776
777            // Should receive AuthFailed.
778            let result_frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
779            let ok = decode_auth_result(&result_frame).unwrap();
780            assert!(!ok, "should receive auth failure");
781        });
782
783        let mut reader = primary_stream.try_clone().unwrap();
784        let mut writer = primary_stream;
785        let result = authenticate_replica(&mut reader, &mut writer, &authorized_keys);
786        assert!(result.is_err());
787        assert!(
788            result
789                .unwrap_err()
790                .to_string()
791                .contains("signature verification failed"),
792            "should fail on signature verification"
793        );
794
795        replica_handle.join().unwrap();
796    }
797
798    #[test]
799    fn sender_receiver_end_to_end() {
800        use std::os::unix::net::UnixStream;
801
802        // Create a mock connection.
803        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
804
805        let replication_cursor = Arc::new(AtomicU64::new(0));
806
807        // Spawn a thread simulating the replica side.
808        let _replica_cursor = Arc::clone(&replication_cursor);
809        let replica_handle = std::thread::spawn(move || {
810            let mut reader = replica_stream.try_clone().unwrap();
811            let mut writer = replica_stream;
812
813            // Send handshake.
814            let mut buf = Vec::new();
815            let handshake = Handshake {
816                last_sequence: 0,
817                chain_hash: [0u8; 32],
818            };
819            encode_handshake(&handshake, &mut buf);
820            writer.write_all(&buf).unwrap();
821            writer.flush().unwrap();
822            buf.clear();
823
824            // Read StreamStart.
825            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
826            let msg = decode_primary_message(&frame).unwrap();
827            assert!(matches!(msg, PrimaryMessage::StreamStart { .. }));
828
829            // Read InputBatch.
830            let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
831            let slots: Vec<InputSlot> = try_decode_input_batch(&frame).expect("decode InputBatch");
832            let end_seq = slots
833                .last()
834                .map(|s| s.sequence)
835                .expect("InputBatch carried at least one slot");
836
837            // Send ack.
838            let ack = Ack {
839                acked_sequence: end_seq,
840                in_memory_sequence: end_seq,
841            };
842            encode_ack(&ack, &mut buf);
843            writer.write_all(&buf).unwrap();
844            writer.flush().unwrap();
845
846            end_seq
847        });
848
849        // Primary side: simulate handle_replica_connection partially.
850        let mut p_reader = primary_stream.try_clone().unwrap();
851        let mut p_writer = primary_stream;
852
853        // Read handshake.
854        let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
855        let handshake = match decode_replica_message(&frame).unwrap() {
856            ReplicaMessage::Handshake(h) => h,
857            _ => panic!("expected Handshake"),
858        };
859        assert_eq!(handshake.last_sequence, 0);
860
861        // Send StreamStart.
862        let mut buf = Vec::new();
863        encode_stream_start(0, &[0u8; 32], &mut buf); // fake genesis bytes for test
864        p_writer.write_all(&buf).unwrap();
865        p_writer.flush().unwrap();
866        buf.clear();
867
868        // Send an InputBatch with a single Tick slot at seq 42.
869        encode_input_batch_with_seq(42, &mut buf);
870        p_writer.write_all(&buf).unwrap();
871        p_writer.flush().unwrap();
872        buf.clear();
873
874        // Read ack.
875        let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
876        let ack = match decode_replica_message(&frame).unwrap() {
877            ReplicaMessage::Ack(a) => a,
878            _ => panic!("expected Ack"),
879        };
880        assert_eq!(ack.acked_sequence, 42);
881
882        // Join replica thread.
883        let end_seq = replica_handle.join().unwrap();
884        assert_eq!(end_seq, 42);
885    }
886
887    #[test]
888    fn disconnect_degrades_cursor_to_max() {
889        // When a replica disconnects, run_sender resets the replication
890        // cursor to u64::MAX so the response stage stops gating on acks.
891        // Test the cursor lifecycle: starts at 0, set during handshake,
892        // then reset to MAX on disconnect.
893        let cursor = Arc::new(AtomicU64::new(0));
894
895        // Simulate handshake: cursor set to last_sequence + 1.
896        let handshake_seq = 42u64;
897        cursor.store(handshake_seq + 1, Ordering::Release);
898        assert_eq!(cursor.load(Ordering::Acquire), 43);
899
900        // Simulate ack advancing cursor.
901        cursor.fetch_max(100 + 1, Ordering::Release);
902        assert_eq!(cursor.load(Ordering::Acquire), 101);
903
904        // Simulate disconnect: run_sender resets to MAX.
905        cursor.store(u64::MAX, Ordering::Release);
906        assert_eq!(cursor.load(Ordering::Acquire), u64::MAX);
907
908        // Simulate reconnect: cursor set back to handshake value.
909        cursor.store(1, Ordering::Release);
910        assert_eq!(cursor.load(Ordering::Acquire), 1);
911    }
912
913    #[test]
914    fn ack_advances_cursor_monotonically() {
915        // Acks must only advance the cursor, never regress it.
916        // A stale ack (lower sequence) should be ignored.
917        let cursor = Arc::new(AtomicU64::new(0));
918
919        // Simulate processing ack seq=100 → cursor should become 101.
920        let new_val = 100 + 1;
921        cursor.fetch_max(new_val, Ordering::Release);
922        assert_eq!(cursor.load(Ordering::Acquire), 101);
923
924        // Stale ack seq=50 → cursor should stay at 101.
925        let stale_val = 50 + 1;
926        cursor.fetch_max(stale_val, Ordering::Release);
927        assert_eq!(cursor.load(Ordering::Acquire), 101);
928
929        // Newer ack seq=200 → cursor should advance to 201.
930        let newer_val = 200 + 1;
931        cursor.fetch_max(newer_val, Ordering::Release);
932        assert_eq!(cursor.load(Ordering::Acquire), 201);
933    }
934
935    #[test]
936    fn multiple_data_batches_acked_in_order() {
937        // Send multiple InputBatch frames, verify replica acks each one
938        // and the cursor advances correctly.
939        use std::os::unix::net::UnixStream;
940
941        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
942
943        let replica_handle = std::thread::spawn(move || {
944            let mut reader = replica_stream.try_clone().unwrap();
945            let mut writer = replica_stream;
946            let mut buf = Vec::new();
947
948            // Send handshake.
949            encode_handshake(
950                &Handshake {
951                    last_sequence: 0,
952                    chain_hash: [0u8; 32],
953                },
954                &mut buf,
955            );
956            writer.write_all(&buf).unwrap();
957            writer.flush().unwrap();
958            buf.clear();
959
960            // Read StreamStart.
961            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
962            assert!(matches!(
963                decode_primary_message(&frame).unwrap(),
964                PrimaryMessage::StreamStart { .. }
965            ));
966
967            // Read and ack 3 InputBatches.
968            let mut acked_seqs = Vec::new();
969            for _ in 0..3 {
970                let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
971                let slots: Vec<InputSlot> =
972                    try_decode_input_batch(&frame).expect("decode InputBatch");
973                let end_seq = slots
974                    .last()
975                    .map(|s| s.sequence)
976                    .expect("InputBatch carried at least one slot");
977                acked_seqs.push(end_seq);
978
979                encode_ack(
980                    &Ack {
981                        acked_sequence: end_seq,
982                        in_memory_sequence: end_seq,
983                    },
984                    &mut buf,
985                );
986                writer.write_all(&buf).unwrap();
987                writer.flush().unwrap();
988                buf.clear();
989            }
990
991            acked_seqs
992        });
993
994        // Primary side.
995        let mut p_reader = primary_stream.try_clone().unwrap();
996        let mut p_writer = primary_stream;
997        let mut buf = Vec::new();
998
999        // Read handshake.
1000        let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
1001        assert!(matches!(
1002            decode_replica_message(&frame).unwrap(),
1003            ReplicaMessage::Handshake(_)
1004        ));
1005
1006        // Send StreamStart.
1007        encode_stream_start(0, &[0u8; 32], &mut buf);
1008        p_writer.write_all(&buf).unwrap();
1009        p_writer.flush().unwrap();
1010        buf.clear();
1011
1012        // Send 3 InputBatches with increasing sequence numbers.
1013        for seq in [10u64, 20, 30] {
1014            encode_input_batch_with_seq(seq, &mut buf);
1015            p_writer.write_all(&buf).unwrap();
1016            p_writer.flush().unwrap();
1017            buf.clear();
1018        }
1019
1020        // Read 3 acks.
1021        for expected_seq in [10u64, 20, 30] {
1022            let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
1023            let ack = match decode_replica_message(&frame).unwrap() {
1024                ReplicaMessage::Ack(a) => a,
1025                other => panic!("expected Ack, got {other:?}"),
1026            };
1027            assert_eq!(ack.acked_sequence, expected_seq);
1028        }
1029
1030        let acked = replica_handle.join().unwrap();
1031        assert_eq!(acked, vec![10, 20, 30]);
1032    }
1033
1034    #[test]
1035    fn heartbeat_encode_contains_sequence() {
1036        // Heartbeat messages carry the last known sequence so the replica
1037        // can verify it hasn't missed any data.
1038        let mut buf = Vec::new();
1039        encode_heartbeat(999, &mut buf);
1040
1041        let payload = &buf[4..];
1042        match decode_primary_message(payload).unwrap() {
1043            PrimaryMessage::Heartbeat { sequence } => {
1044                assert_eq!(sequence, 999);
1045            }
1046            other => panic!("expected Heartbeat, got {other:?}"),
1047        }
1048    }
1049
1050    #[test]
1051    fn replica_mid_stream_handshake_with_nonzero_sequence() {
1052        // A replica that already has some data sends a non-zero last_sequence
1053        // in its handshake. The primary should respond with StreamStart
1054        // containing that sequence, and the replica should only receive
1055        // events after that point.
1056        use std::os::unix::net::UnixStream;
1057
1058        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
1059
1060        let replica_handle = std::thread::spawn(move || {
1061            let mut reader = replica_stream.try_clone().unwrap();
1062            let mut writer = replica_stream;
1063            let mut buf = Vec::new();
1064
1065            // Replica already has events up to sequence 100.
1066            encode_handshake(
1067                &Handshake {
1068                    last_sequence: 100,
1069                    chain_hash: [0xBB; 32],
1070                },
1071                &mut buf,
1072            );
1073            writer.write_all(&buf).unwrap();
1074            writer.flush().unwrap();
1075            buf.clear();
1076
1077            // Read StreamStart — should echo back our last_sequence.
1078            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
1079            match decode_primary_message(&frame).unwrap() {
1080                PrimaryMessage::StreamStart { start_sequence, .. } => {
1081                    assert_eq!(
1082                        start_sequence, 100,
1083                        "StreamStart should echo replica's last_sequence"
1084                    );
1085                }
1086                other => panic!("expected StreamStart, got {other:?}"),
1087            }
1088
1089            // Read an InputBatch — should be for events AFTER 100.
1090            let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
1091            let slots: Vec<InputSlot> = try_decode_input_batch(&frame).expect("decode InputBatch");
1092            let end_sequence = slots
1093                .last()
1094                .map(|s| s.sequence)
1095                .expect("InputBatch carried at least one slot");
1096            assert!(
1097                end_sequence > 100,
1098                "InputBatch should be after replica's last_sequence"
1099            );
1100        });
1101
1102        // Primary side.
1103        let mut p_reader = primary_stream.try_clone().unwrap();
1104        let mut p_writer = primary_stream;
1105        let mut buf = Vec::new();
1106
1107        // Read handshake.
1108        let frame = read_frame(&mut p_reader, MAX_CONTROL_FRAME).unwrap();
1109        let handshake = match decode_replica_message(&frame).unwrap() {
1110            ReplicaMessage::Handshake(h) => h,
1111            _ => panic!("expected Handshake"),
1112        };
1113        assert_eq!(handshake.last_sequence, 100);
1114        assert_eq!(handshake.chain_hash, [0xBB; 32]);
1115
1116        // Send StreamStart echoing the replica's sequence.
1117        encode_stream_start(handshake.last_sequence, &[0u8; 32], &mut buf);
1118        p_writer.write_all(&buf).unwrap();
1119        p_writer.flush().unwrap();
1120        buf.clear();
1121
1122        // Send InputBatch with sequence 150 (after replica's 100).
1123        encode_input_batch_with_seq(150, &mut buf);
1124        p_writer.write_all(&buf).unwrap();
1125        p_writer.flush().unwrap();
1126
1127        replica_handle.join().unwrap();
1128    }
1129
1130    #[test]
1131    fn snapshot_begin_encode_decode_round_trip() {
1132        let mut buf = Vec::new();
1133        encode_snapshot_begin(1_000_000, 42, &[0xAB; 32], &mut buf);
1134
1135        let payload = &buf[4..];
1136        let msg = decode_primary_message(payload).unwrap();
1137        match msg {
1138            PrimaryMessage::SnapshotBegin {
1139                snapshot_len,
1140                snap_sequence,
1141                snap_chain_hash,
1142            } => {
1143                assert_eq!(snapshot_len, 1_000_000);
1144                assert_eq!(snap_sequence, 42);
1145                assert_eq!(snap_chain_hash, [0xAB; 32]);
1146            }
1147            _ => panic!("expected SnapshotBegin"),
1148        }
1149    }
1150
1151    #[test]
1152    fn snapshot_chunk_encode_decode_round_trip() {
1153        let data = vec![1, 2, 3, 4, 5, 6, 7, 8];
1154        let mut buf = Vec::new();
1155        encode_snapshot_chunk(&data, &mut buf);
1156
1157        let payload = &buf[4..];
1158        let msg = decode_primary_message(payload).unwrap();
1159        match msg {
1160            PrimaryMessage::SnapshotChunk(chunk) => {
1161                assert_eq!(chunk, data);
1162            }
1163            _ => panic!("expected SnapshotChunk"),
1164        }
1165    }
1166
1167    #[test]
1168    fn snapshot_end_encode_decode_round_trip() {
1169        let mut buf = Vec::new();
1170        encode_snapshot_end(0xDEADBEEF, &mut buf);
1171
1172        let payload = &buf[4..];
1173        let msg = decode_primary_message(payload).unwrap();
1174        match msg {
1175            PrimaryMessage::SnapshotEnd { crc32c } => {
1176                assert_eq!(crc32c, 0xDEADBEEF);
1177            }
1178            _ => panic!("expected SnapshotEnd"),
1179        }
1180    }
1181
1182    /// Simulate the receiver side of a snapshot transfer where the
1183    /// advertised snap_len doesn't match the actual bytes sent.
1184    /// The receiver must detect this and return an error.
1185    #[test]
1186    fn snapshot_receiver_detects_length_mismatch() {
1187        use std::os::unix::net::UnixStream;
1188
1189        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
1190
1191        // Receiver thread — reads NeedSnapshot, then the snapshot transfer.
1192        let receiver = std::thread::spawn(move || -> String {
1193            let mut reader = replica_stream.try_clone().unwrap();
1194
1195            // Read NeedSnapshot.
1196            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
1197            assert!(matches!(
1198                decode_primary_message(&frame).unwrap(),
1199                PrimaryMessage::NeedSnapshot,
1200            ));
1201
1202            // Read SnapshotBegin.
1203            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
1204            let (snap_len, _snap_sequence, _snap_chain_hash) =
1205                match decode_primary_message(&frame).unwrap() {
1206                    PrimaryMessage::SnapshotBegin {
1207                        snapshot_len,
1208                        snap_sequence,
1209                        snap_chain_hash,
1210                    } => (snapshot_len, snap_sequence, snap_chain_hash),
1211                    other => panic!("expected SnapshotBegin, got {other:?}"),
1212                };
1213
1214            // Receive chunks and check length at SnapshotEnd.
1215            let mut received: u64 = 0;
1216            loop {
1217                let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
1218                match decode_primary_message(&frame).unwrap() {
1219                    PrimaryMessage::SnapshotChunk(data) => {
1220                        received += data.len() as u64;
1221                    }
1222                    PrimaryMessage::SnapshotEnd { .. } => {
1223                        if received != snap_len {
1224                            return format!(
1225                                "snapshot length mismatch: expected {snap_len} bytes, got {received}"
1226                            );
1227                        }
1228                        return String::new(); // no error
1229                    }
1230                    other => panic!("unexpected message: {other:?}"),
1231                }
1232            }
1233        });
1234
1235        // Primary side — send snapshot with wrong advertised length.
1236        let mut writer = primary_stream;
1237        let mut buf = Vec::new();
1238
1239        let actual_data = vec![0xAA; 100];
1240        let wrong_len = 999u64; // advertise 999 bytes, send only 100
1241
1242        encode_need_snapshot(&mut buf);
1243        std::io::Write::write_all(&mut writer, &buf).unwrap();
1244        buf.clear();
1245
1246        encode_snapshot_begin(wrong_len, 42, &[0xBB; 32], &mut buf);
1247        std::io::Write::write_all(&mut writer, &buf).unwrap();
1248        buf.clear();
1249
1250        encode_snapshot_chunk(&actual_data, &mut buf);
1251        std::io::Write::write_all(&mut writer, &buf).unwrap();
1252        buf.clear();
1253
1254        let crc = crc32c::crc32c(&actual_data);
1255        encode_snapshot_end(crc, &mut buf);
1256        std::io::Write::write_all(&mut writer, &buf).unwrap();
1257        std::io::Write::flush(&mut writer).unwrap();
1258
1259        let error_msg = receiver.join().unwrap();
1260        assert!(
1261            error_msg.contains("length mismatch"),
1262            "expected length mismatch error, got: {error_msg:?}"
1263        );
1264    }
1265
1266    /// Simulate the receiver side of a snapshot transfer where the CRC
1267    /// in SnapshotEnd doesn't match the actual data. The receiver must
1268    /// detect and reject the transfer.
1269    #[test]
1270    fn snapshot_receiver_detects_crc_mismatch() {
1271        use std::os::unix::net::UnixStream;
1272
1273        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
1274
1275        let receiver = std::thread::spawn(move || -> String {
1276            let mut reader = replica_stream.try_clone().unwrap();
1277
1278            // Read NeedSnapshot.
1279            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
1280            assert!(matches!(
1281                decode_primary_message(&frame).unwrap(),
1282                PrimaryMessage::NeedSnapshot,
1283            ));
1284
1285            // Read SnapshotBegin.
1286            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
1287            let snap_len = match decode_primary_message(&frame).unwrap() {
1288                PrimaryMessage::SnapshotBegin { snapshot_len, .. } => snapshot_len,
1289                other => panic!("expected SnapshotBegin, got {other:?}"),
1290            };
1291
1292            // Receive chunks, verify CRC at SnapshotEnd.
1293            let mut received_data = Vec::new();
1294            let mut received: u64 = 0;
1295            loop {
1296                let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
1297                match decode_primary_message(&frame).unwrap() {
1298                    PrimaryMessage::SnapshotChunk(data) => {
1299                        received += data.len() as u64;
1300                        received_data.extend_from_slice(&data);
1301                    }
1302                    PrimaryMessage::SnapshotEnd {
1303                        crc32c: expected_crc,
1304                    } => {
1305                        if received != snap_len {
1306                            return format!("length mismatch: {snap_len} vs {received}");
1307                        }
1308                        let actual_crc = crc32c::crc32c(&received_data);
1309                        if actual_crc != expected_crc {
1310                            return format!(
1311                                "CRC mismatch: expected {expected_crc:#x}, got {actual_crc:#x}"
1312                            );
1313                        }
1314                        return String::new();
1315                    }
1316                    other => panic!("unexpected message: {other:?}"),
1317                }
1318            }
1319        });
1320
1321        // Primary side — send correct length but wrong CRC.
1322        let mut writer = primary_stream;
1323        let mut buf = Vec::new();
1324
1325        let data = vec![0xAA; 100];
1326
1327        encode_need_snapshot(&mut buf);
1328        std::io::Write::write_all(&mut writer, &buf).unwrap();
1329        buf.clear();
1330
1331        encode_snapshot_begin(data.len() as u64, 42, &[0xBB; 32], &mut buf);
1332        std::io::Write::write_all(&mut writer, &buf).unwrap();
1333        buf.clear();
1334
1335        encode_snapshot_chunk(&data, &mut buf);
1336        std::io::Write::write_all(&mut writer, &buf).unwrap();
1337        buf.clear();
1338
1339        // Send a wrong CRC (flip bits).
1340        let wrong_crc = !crc32c::crc32c(&data);
1341        encode_snapshot_end(wrong_crc, &mut buf);
1342        std::io::Write::write_all(&mut writer, &buf).unwrap();
1343        std::io::Write::flush(&mut writer).unwrap();
1344
1345        let error_msg = receiver.join().unwrap();
1346        assert!(
1347            error_msg.contains("CRC mismatch"),
1348            "expected CRC mismatch error, got: {error_msg:?}"
1349        );
1350    }
1351
1352    /// The receiver verifies the chain hash from the loaded snapshot
1353    /// matches the one advertised in SnapshotBegin. Simulate a mismatch.
1354    #[test]
1355    fn snapshot_receiver_detects_chain_hash_mismatch() {
1356        use std::os::unix::net::UnixStream;
1357
1358        let (primary_stream, replica_stream) = UnixStream::pair().unwrap();
1359
1360        let receiver = std::thread::spawn(move || -> String {
1361            let mut reader = replica_stream.try_clone().unwrap();
1362
1363            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
1364            assert!(matches!(
1365                decode_primary_message(&frame).unwrap(),
1366                PrimaryMessage::NeedSnapshot,
1367            ));
1368
1369            let frame = read_frame(&mut reader, MAX_CONTROL_FRAME).unwrap();
1370            let (snap_len, _snap_sequence, snap_chain_hash) =
1371                match decode_primary_message(&frame).unwrap() {
1372                    PrimaryMessage::SnapshotBegin {
1373                        snapshot_len,
1374                        snap_sequence,
1375                        snap_chain_hash,
1376                    } => (snapshot_len, snap_sequence, snap_chain_hash),
1377                    other => panic!("expected SnapshotBegin, got {other:?}"),
1378                };
1379
1380            // Receive the snapshot data.
1381            let mut received_data = Vec::new();
1382            let mut received: u64 = 0;
1383            loop {
1384                let frame = read_frame(&mut reader, MAX_DATA_FRAME).unwrap();
1385                match decode_primary_message(&frame).unwrap() {
1386                    PrimaryMessage::SnapshotChunk(data) => {
1387                        received += data.len() as u64;
1388                        received_data.extend_from_slice(&data);
1389                    }
1390                    PrimaryMessage::SnapshotEnd {
1391                        crc32c: expected_crc,
1392                    } => {
1393                        assert_eq!(received, snap_len, "length should match");
1394                        let actual_crc = crc32c::crc32c(&received_data);
1395                        assert_eq!(actual_crc, expected_crc, "CRC should match");
1396                        break;
1397                    }
1398                    other => panic!("unexpected message: {other:?}"),
1399                }
1400            }
1401
1402            // Simulate chain hash verification: the loaded snapshot would
1403            // have a different chain hash than what SnapshotBegin advertised.
1404            let loaded_hash = [0xFF; 32]; // different from snap_chain_hash
1405            if loaded_hash != snap_chain_hash {
1406                return format!(
1407                    "snapshot chain hash mismatch: primary sent {snap_chain_hash:02x?}, \
1408                     loaded snapshot has {loaded_hash:02x?}"
1409                );
1410            }
1411            String::new()
1412        });
1413
1414        // Primary side — send valid snapshot but with a chain hash in
1415        // SnapshotBegin that won't match what the replica "loads".
1416        let mut writer = primary_stream;
1417        let mut buf = Vec::new();
1418
1419        let data = vec![0xAA; 64];
1420        // Advertise chain hash [0xBB; 32] — receiver will "load" [0xFF; 32].
1421        let advertised_hash = [0xBB; 32];
1422
1423        encode_need_snapshot(&mut buf);
1424        std::io::Write::write_all(&mut writer, &buf).unwrap();
1425        buf.clear();
1426
1427        encode_snapshot_begin(data.len() as u64, 10, &advertised_hash, &mut buf);
1428        std::io::Write::write_all(&mut writer, &buf).unwrap();
1429        buf.clear();
1430
1431        encode_snapshot_chunk(&data, &mut buf);
1432        std::io::Write::write_all(&mut writer, &buf).unwrap();
1433        buf.clear();
1434
1435        let crc = crc32c::crc32c(&data);
1436        encode_snapshot_end(crc, &mut buf);
1437        std::io::Write::write_all(&mut writer, &buf).unwrap();
1438        std::io::Write::flush(&mut writer).unwrap();
1439
1440        let error_msg = receiver.join().unwrap();
1441        assert!(
1442            error_msg.contains("chain hash mismatch"),
1443            "expected chain hash mismatch error, got: {error_msg:?}"
1444        );
1445    }
1446
1447    /// Primary-side magic validation: a file without the SNAP magic
1448    /// (0x534E4150) must be rejected before transfer.
1449    #[test]
1450    fn primary_rejects_snapshot_with_invalid_magic() {
1451        let tmp = tempfile::tempdir().unwrap();
1452        let snap_path = tmp.path().join("test.snapshot");
1453
1454        // Write a file with wrong magic but enough bytes for a header.
1455        let mut bad_snap = vec![0u8; 64];
1456        bad_snap[0..4].copy_from_slice(&0xDEAD_BEEFu32.to_le_bytes()); // wrong magic
1457
1458        std::fs::write(&snap_path, &bad_snap).unwrap();
1459
1460        // Replicate the primary's validation logic.
1461        let snap_data = std::fs::read(&snap_path).unwrap();
1462        assert!(
1463            snap_data.len() >= 48,
1464            "file should be big enough for header"
1465        );
1466
1467        let magic = u32::from_le_bytes(snap_data[0..4].try_into().unwrap());
1468        assert_ne!(magic, 0x534E_4150);
1469        assert_eq!(magic, 0xDEAD_BEEF);
1470    }
1471
1472    /// Primary-side: a snapshot file smaller than the 48-byte header
1473    /// must be rejected.
1474    #[test]
1475    fn primary_rejects_snapshot_too_small_for_header() {
1476        let tmp = tempfile::tempdir().unwrap();
1477        let snap_path = tmp.path().join("test.snapshot");
1478
1479        // Write a file smaller than the 48-byte header.
1480        std::fs::write(&snap_path, [0u8; 20]).unwrap();
1481
1482        let snap_data = std::fs::read(&snap_path).unwrap();
1483        assert!(
1484            snap_data.len() < 48,
1485            "file must be too small for header validation"
1486        );
1487    }
1488
1489    #[test]
1490    fn decode_snapshot_begin_too_short() {
1491        // SnapshotBegin needs type(1) + snapshot_len(8) + snap_sequence(8) + chain_hash(32) = 49.
1492        // Send only the type byte + a few extra bytes.
1493        let payload = [MSG_SNAPSHOT_BEGIN, 0x01, 0x02, 0x03];
1494        let err = decode_primary_message(&payload).unwrap_err();
1495        assert!(
1496            err.to_string().contains("SnapshotBegin too short"),
1497            "expected 'SnapshotBegin too short', got: {err}"
1498        );
1499    }
1500
1501    #[test]
1502    fn decode_snapshot_end_too_short() {
1503        // SnapshotEnd needs type(1) + crc32c(4) = 5. Send only the type byte.
1504        let payload = [MSG_SNAPSHOT_END];
1505        let err = decode_primary_message(&payload).unwrap_err();
1506        assert!(
1507            err.to_string().contains("SnapshotEnd too short"),
1508            "expected 'SnapshotEnd too short', got: {err}"
1509        );
1510    }
1511
1512    #[test]
1513    fn decode_snapshot_chunk_empty_data() {
1514        // SnapshotChunk with just the type byte — valid but empty payload.
1515        let payload = [MSG_SNAPSHOT_CHUNK];
1516        let msg = decode_primary_message(&payload).unwrap();
1517        match msg {
1518            PrimaryMessage::SnapshotChunk(data) => {
1519                assert!(data.is_empty());
1520            }
1521            _ => panic!("expected SnapshotChunk"),
1522        }
1523    }
1524
1525    // --- PendingAckQueue tests ---
1526
1527    fn make_journal_cursor(val: u64) -> melin_pipeline::padding::Sequence {
1528        melin_pipeline::padding::Sequence::new(AtomicU64::new(val))
1529    }
1530
1531    #[test]
1532    fn pending_ack_queue_push_and_pop_ready() {
1533        let mut q = PendingAckQueue::new(8);
1534        assert!(q.is_empty());
1535        assert!(!q.is_full());
1536
1537        q.push(10, 100);
1538        q.push(20, 200);
1539        assert!(!q.is_empty());
1540
1541        // Cursor at 5 — neither ready.
1542        let cursor = make_journal_cursor(5);
1543        assert!(q.pop_ready(&cursor).is_none());
1544
1545        // Cursor at 15 — first ready, second not.
1546        cursor.get().store(15, Ordering::Relaxed);
1547        assert_eq!(q.pop_ready(&cursor), Some(100));
1548        // Only one popped — second still pending.
1549        assert!(!q.is_empty());
1550
1551        // Cursor at 25 — second now ready.
1552        cursor.get().store(25, Ordering::Relaxed);
1553        assert_eq!(q.pop_ready(&cursor), Some(200));
1554        assert!(q.is_empty());
1555    }
1556
1557    #[test]
1558    fn pending_ack_queue_pop_ready_returns_highest_sequence() {
1559        // When multiple acks become ready simultaneously, pop_ready
1560        // returns the highest acked_sequence (ack semantics are
1561        // cumulative — "everything up to this sequence is durable").
1562        let mut q = PendingAckQueue::new(8);
1563        q.push(10, 100);
1564        q.push(20, 200);
1565        q.push(30, 300);
1566
1567        let cursor = make_journal_cursor(30);
1568        assert_eq!(q.pop_ready(&cursor), Some(300));
1569        assert!(q.is_empty());
1570    }
1571
1572    #[test]
1573    fn pending_ack_queue_capacity_and_full() {
1574        let mut q = PendingAckQueue::new(8);
1575        for i in 0..8 {
1576            assert!(!q.is_full());
1577            q.push(i as u64 + 1, (i + 1) as u64 * 100);
1578        }
1579        assert!(q.is_full());
1580    }
1581
1582    #[test]
1583    fn pending_ack_queue_pop_oldest_blocking() {
1584        let mut q = PendingAckQueue::new(8);
1585        q.push(10, 100);
1586        q.push(20, 200);
1587
1588        // Cursor already past both targets — pop_oldest_blocking
1589        // returns immediately.
1590        let cursor = make_journal_cursor(25);
1591        let seq = q.pop_oldest_blocking(&cursor, true);
1592        // Should pop both (oldest + any others that became ready).
1593        assert_eq!(seq, 200);
1594        assert!(q.is_empty());
1595    }
1596
1597    #[test]
1598    fn pending_ack_queue_wraps_around() {
1599        let mut q = PendingAckQueue::new(8);
1600        let cursor = make_journal_cursor(100);
1601
1602        // Fill and drain multiple times to exercise circular buffer wrap.
1603        for round in 0..3 {
1604            for i in 0..8 {
1605                let target = (round * 8 + i) as u64 + 1;
1606                q.push(target, target * 10);
1607            }
1608            assert!(q.is_full());
1609            let seq = q.pop_ready(&cursor).expect("should be ready");
1610            assert_eq!(seq, (round * 8 + 8) as u64 * 10);
1611            assert!(q.is_empty());
1612        }
1613    }
1614
1615    #[test]
1616    fn pending_ack_queue_pop_all_blocking_empty() {
1617        let mut q = PendingAckQueue::new(8);
1618        let cursor = make_journal_cursor(0);
1619        assert!(q.pop_all_blocking(&cursor, true).is_none());
1620    }
1621
1622    // --- try_flush_dual_track tests ---
1623
1624    #[test]
1625    fn dual_track_returns_none_when_both_tracks_idle() {
1626        let mut q = PendingAckQueue::new(8);
1627        let cursor = make_journal_cursor(0);
1628        assert!(
1629            try_flush_dual_track(&mut q, &cursor, 0, 0, 0).is_none(),
1630            "no advance on either track → no ack"
1631        );
1632    }
1633
1634    #[test]
1635    fn dual_track_fires_on_persisted_advance_only() {
1636        // Persisted track moves: pop_ready returns 100; in-memory
1637        // stayed at 50 (last_sent). Expect ack with acked=100,
1638        // in_memory=50 (no regression — the wire field carries the
1639        // current sample, not a "no change" marker).
1640        let mut q = PendingAckQueue::new(8);
1641        q.push(10, 100);
1642        let cursor = make_journal_cursor(20);
1643        let ack = try_flush_dual_track(&mut q, &cursor, 50, 0, 50).expect("persisted advanced");
1644        assert_eq!(ack.acked_sequence, 100);
1645        assert_eq!(ack.in_memory_sequence, 50);
1646    }
1647
1648    #[test]
1649    fn dual_track_fires_on_in_memory_advance_only() {
1650        // Persisted is idle (queue empty): unwrap_or keeps the
1651        // last-sent acked at 100. In-memory bumped from 100 to 200.
1652        let mut q = PendingAckQueue::new(8);
1653        let cursor = make_journal_cursor(0);
1654        let ack = try_flush_dual_track(&mut q, &cursor, 200, 100, 100).expect("in-memory advanced");
1655        assert_eq!(ack.acked_sequence, 100);
1656        assert_eq!(ack.in_memory_sequence, 200);
1657    }
1658
1659    #[test]
1660    fn dual_track_coalesces_until_caller_updates_trackers() {
1661        // Caller did not advance trackers between calls. The queue
1662        // popped on call 1; on call 2 it's empty so persisted stays
1663        // at 100 (unwrap_or). In-memory advanced 50 → 80 between
1664        // calls. Second call must still fire because tracker is
1665        // still 50.
1666        let mut q = PendingAckQueue::new(8);
1667        q.push(10, 100);
1668        let cursor = make_journal_cursor(20);
1669        let ack1 = try_flush_dual_track(&mut q, &cursor, 50, 0, 50).expect("call 1 fires");
1670        assert_eq!(ack1.acked_sequence, 100);
1671        // Caller "forgot" to update trackers (simulates send failure).
1672        let ack2 = try_flush_dual_track(&mut q, &cursor, 80, 0, 50)
1673            .expect("call 2 fires on in-memory advance");
1674        assert_eq!(
1675            ack2.acked_sequence, 0,
1676            "no new persisted pop → unwrap_or(last_sent)"
1677        );
1678        assert_eq!(ack2.in_memory_sequence, 80);
1679    }
1680
1681    #[test]
1682    fn dual_track_no_duplicate_ack_after_backpressure_drain() {
1683        // Models the backpressure-drain → resume-normal-flush path
1684        // taken by every receiver when `PendingAckQueue` fills up.
1685        // The receiver must drain the queue (via the
1686        // pop_oldest_blocking path in production; pop_ready here),
1687        // update *both* trackers from the drained ack, and only
1688        // then resume the normal cursor-driven flush. The bug
1689        // classes this pins are:
1690        //   (a) emitting a follow-up ack whose `acked_sequence`
1691        //       regresses below what the drain already sent
1692        //       (caught by the debug_assert! in
1693        //       `try_flush_dual_track`, also asserted at value
1694        //       level here);
1695        //   (b) emitting a duplicate ack carrying the same
1696        //       cursors as the drain when neither track actually
1697        //       advanced — the leak class fixed by ensuring every
1698        //       send site updates both `last_sent_acked_seq` and
1699        //       `last_sent_in_memory_seq`.
1700        let mut q = PendingAckQueue::new(4);
1701        // Fill the queue: four pending acks at primary seqs
1702        // 100, 200, 300, 400 with journal targets 10..=40.
1703        for i in 1..=4u64 {
1704            q.push(i * 10, i * 100);
1705        }
1706        assert!(q.is_full());
1707
1708        // Backpressure path: caller drains all ready entries
1709        // before pushing more. Highest acked seen = 400.
1710        let cursor = make_journal_cursor(40);
1711        let drained = q.pop_ready(&cursor).expect("all entries durable");
1712        assert_eq!(drained, 400);
1713        assert!(q.is_empty());
1714
1715        // Caller updates BOTH trackers from the drain. in_memory
1716        // at the time of the drained batch was 450.
1717        let mut last_sent_acked = drained;
1718        let mut last_sent_in_mem = 450u64;
1719
1720        // Quiescent immediately after drain: no fresh push, no
1721        // in-memory advance → try_flush must return None.
1722        // Failing here means we'd emit a duplicate ack carrying
1723        // the same (400, 450) the backpressure drain already sent.
1724        assert!(
1725            try_flush_dual_track(
1726                &mut q,
1727                &cursor,
1728                last_sent_in_mem,
1729                last_sent_acked,
1730                last_sent_in_mem,
1731            )
1732            .is_none(),
1733            "no advance on either track after backpressure drain → no duplicate ack",
1734        );
1735
1736        // Push a fresh batch (primary seq 500, journal target 50),
1737        // cursor catches up, in_memory advances to 500.
1738        q.push(50, 500);
1739        let cursor2 = make_journal_cursor(50);
1740        let ack = try_flush_dual_track(&mut q, &cursor2, 500, last_sent_acked, last_sent_in_mem)
1741            .expect("fresh batch after drain fires a new ack");
1742        assert!(
1743            ack.acked_sequence >= last_sent_acked,
1744            "regression: drain sent {last_sent_acked} but next ack carries {}",
1745            ack.acked_sequence,
1746        );
1747        assert_eq!(ack.acked_sequence, 500);
1748        assert_eq!(ack.in_memory_sequence, 500);
1749
1750        last_sent_acked = ack.acked_sequence;
1751        last_sent_in_mem = ack.in_memory_sequence;
1752
1753        // Post-resume quiescent: confirms idempotency past the
1754        // resume point — the second flush sees neither track
1755        // advance and must stay silent.
1756        assert!(
1757            try_flush_dual_track(
1758                &mut q,
1759                &cursor2,
1760                last_sent_in_mem,
1761                last_sent_acked,
1762                last_sent_in_mem,
1763            )
1764            .is_none(),
1765            "post-resume idle → no further ack",
1766        );
1767    }
1768
1769    // --- Dual-replica cursor update tests ---
1770
1771    #[test]
1772    fn dual_cursor_takes_min_and_max_of_both_slots() {
1773        let cursor_min = Arc::new(AtomicU64::new(0));
1774        let cursor_max = Arc::new(AtomicU64::new(0));
1775        // Slot 0 at seq 100, slot 1 at seq 50 → min = 50, max = 100.
1776        update_dual_replication_cursor(100, 50, &cursor_min, &cursor_max);
1777        assert_eq!(cursor_min.load(Ordering::Relaxed), 50);
1778        assert_eq!(cursor_max.load(Ordering::Relaxed), 100);
1779    }
1780
1781    #[test]
1782    fn dual_cursor_idle_slot_uses_max() {
1783        let cursor_min = Arc::new(AtomicU64::new(0));
1784        let cursor_max = Arc::new(AtomicU64::new(0));
1785        // Slot 0 at seq 100, slot 1 idle (u64::MAX) → min = 100, max = u64::MAX.
1786        update_dual_replication_cursor(100, u64::MAX, &cursor_min, &cursor_max);
1787        assert_eq!(cursor_min.load(Ordering::Relaxed), 100);
1788        assert_eq!(cursor_max.load(Ordering::Relaxed), u64::MAX);
1789    }
1790
1791    #[test]
1792    fn dual_cursor_both_idle() {
1793        let cursor_min = Arc::new(AtomicU64::new(42));
1794        let cursor_max = Arc::new(AtomicU64::new(42));
1795        // Both idle → min = max = u64::MAX (no replicas gating).
1796        update_dual_replication_cursor(u64::MAX, u64::MAX, &cursor_min, &cursor_max);
1797        assert_eq!(cursor_min.load(Ordering::Relaxed), u64::MAX);
1798        assert_eq!(cursor_max.load(Ordering::Relaxed), u64::MAX);
1799    }
1800
1801    #[test]
1802    fn dual_cursor_decreases_when_slower_replica_connects() {
1803        let cursor_min = Arc::new(AtomicU64::new(0));
1804        let cursor_max = Arc::new(AtomicU64::new(0));
1805
1806        // Slot 0 streaming alone → min = 100, max = u64::MAX.
1807        update_dual_replication_cursor(100, u64::MAX, &cursor_min, &cursor_max);
1808        assert_eq!(cursor_min.load(Ordering::Relaxed), 100);
1809
1810        // Slot 1 connects with acked_cursor = 51 (last_sequence 50).
1811        // Min must decrease to 51, max stays at 100.
1812        update_dual_replication_cursor(51, 100, &cursor_min, &cursor_max);
1813        assert_eq!(cursor_min.load(Ordering::Relaxed), 51);
1814        assert_eq!(cursor_max.load(Ordering::Relaxed), 100);
1815    }
1816
1817    #[test]
1818    fn dual_cursor_advances_as_slower_replica_catches_up() {
1819        let cursor_min = Arc::new(AtomicU64::new(0));
1820        let cursor_max = Arc::new(AtomicU64::new(0));
1821
1822        // Initial: slot 0 at 100, slot 1 at 51 → min = 51, max = 100.
1823        update_dual_replication_cursor(51, 100, &cursor_min, &cursor_max);
1824        assert_eq!(cursor_min.load(Ordering::Relaxed), 51);
1825        assert_eq!(cursor_max.load(Ordering::Relaxed), 100);
1826
1827        // Slot 1 catches up to 80 → min = 80, max = 100.
1828        update_dual_replication_cursor(80, 100, &cursor_min, &cursor_max);
1829        assert_eq!(cursor_min.load(Ordering::Relaxed), 80);
1830
1831        // Both at 100 → min = max = 100.
1832        update_dual_replication_cursor(100, 100, &cursor_min, &cursor_max);
1833        assert_eq!(cursor_min.load(Ordering::Relaxed), 100);
1834        assert_eq!(cursor_max.load(Ordering::Relaxed), 100);
1835
1836        // Both advance → min = max = 150.
1837        update_dual_replication_cursor(150, 150, &cursor_min, &cursor_max);
1838        assert_eq!(cursor_min.load(Ordering::Relaxed), 150);
1839        assert_eq!(cursor_max.load(Ordering::Relaxed), 150);
1840    }
1841
1842    #[test]
1843    fn dual_cursor_slot_disconnect_raises_to_surviving() {
1844        let cursor_min = Arc::new(AtomicU64::new(0));
1845        let cursor_max = Arc::new(AtomicU64::new(0));
1846
1847        // Both streaming: slot 0 at 100, slot 1 at 80 → min = 80, max = 100.
1848        update_dual_replication_cursor(80, 100, &cursor_min, &cursor_max);
1849        assert_eq!(cursor_min.load(Ordering::Relaxed), 80);
1850        assert_eq!(cursor_max.load(Ordering::Relaxed), 100);
1851
1852        // Slot 1 disconnects (goes to u64::MAX) → min = 100, max = u64::MAX.
1853        update_dual_replication_cursor(100, u64::MAX, &cursor_min, &cursor_max);
1854        assert_eq!(cursor_min.load(Ordering::Relaxed), 100);
1855        assert_eq!(cursor_max.load(Ordering::Relaxed), u64::MAX);
1856    }
1857
1858    // --- Cursor reset test ---
1859
1860    #[test]
1861    fn disconnect_resets_cursor_to_max() {
1862        // Verify the cursor reset behavior documented in the replication
1863        // cursor table: "All replicas disconnect → u64::MAX".
1864        let cursor = Arc::new(AtomicU64::new(42));
1865        let replicas_connected = Arc::new(AtomicU32::new(1));
1866
1867        // Simulate disconnect: decrement connected count.
1868        replicas_connected.fetch_sub(1, Ordering::Release);
1869
1870        // The sender loop checks and resets.
1871        if replicas_connected.load(Ordering::Relaxed) == 0 {
1872            cursor.store(u64::MAX, Ordering::Release);
1873        }
1874
1875        assert_eq!(cursor.load(Ordering::Relaxed), u64::MAX);
1876    }
1877
1878    #[test]
1879    fn cursor_not_reset_when_replica_still_connected() {
1880        let cursor = Arc::new(AtomicU64::new(42));
1881        let replicas_connected = Arc::new(AtomicU32::new(2));
1882
1883        // One replica disconnects, one remains.
1884        replicas_connected.fetch_sub(1, Ordering::Release);
1885
1886        if replicas_connected.load(Ordering::Relaxed) == 0 {
1887            cursor.store(u64::MAX, Ordering::Release);
1888        }
1889
1890        // Cursor should NOT be reset — one replica still connected.
1891        assert_eq!(cursor.load(Ordering::Relaxed), 42);
1892    }
1893}