Skip to main content

melin_server_runtime/replication/
tcp_receiver.rs

1//! TCP replication receiver (replica side).
2//!
3//! Connects to the primary, authenticates, performs catch-up / snapshot
4//! recovery, and runs the streaming receive loop via [`streaming_loop`].
5//! Builds the replica's local pipeline (journal + matching stage + drain
6//! stages) to apply incoming events and ack durable batches back to the
7//! primary.
8
9use std::io::{self, Write};
10use std::net::{SocketAddr, TcpStream};
11use std::os::unix::io::RawFd;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use tracing::{info, warn};
16
17use melin_app::Application;
18use melin_journal::BufferedWriter;
19
20use super::auth::authenticate_with_primary;
21use super::receiver_transport::{
22    ControlFrameSource, ReceiverTransport, SessionExit, streaming_loop,
23};
24use super::{
25    ReplicaPipelineHandles, ResumePoint, ResyncDecision, StreamStartVerdict,
26    build_replica_pipeline_with_threads, handle_resync_verdict, handle_session_exit,
27    journal_failed_while_disconnected, recover_replica_state, sleep_then_double_backoff,
28    take_pipeline_for_promotion, teardown_replica_pipeline,
29};
30use crate::uring_teardown::{DrainBackoff, wake_pending_ops};
31use melin_transport_core::replication::protocol::{
32    Ack, Handshake, MAX_CONTROL_FRAME, MAX_DATA_FRAME, PrimaryMessage, decode_primary_message,
33    encode_ack, encode_handshake, read_frame,
34};
35
36/// Force the kernel to send a TCP ACK immediately rather than holding
37/// it in the delayed-ACK timer (~40 ms on Linux). Linux clears
38/// `TCP_QUICKACK` after each ACK it sends, so it has to be re-armed.
39///
40/// A `setsockopt` is ~0.5–1 µs on the receive path's critical section,
41/// so the re-arm is not unconditional: an outgoing ack frame already
42/// carries the TCP ACK, and the replica sends one per receive cycle in
43/// steady state. Only a cycle that produced no ack re-arms — see
44/// `UringTransport::acked_since_recv`.
45#[inline]
46fn arm_tcp_quickack(fd: RawFd) {
47    let on: libc::c_int = 1;
48    // SAFETY: fd is a live socket fd owned by the caller for the
49    // lifetime of this call; the option pointer is to a stack-local
50    // i32 with the right size.
51    let rc = unsafe {
52        libc::setsockopt(
53            fd,
54            libc::IPPROTO_TCP,
55            libc::TCP_QUICKACK,
56            &on as *const libc::c_int as *const libc::c_void,
57            std::mem::size_of::<libc::c_int>() as libc::socklen_t,
58        )
59    };
60    if rc != 0 {
61        // Best-effort tuning: a kernel that refuses TCP_QUICKACK (or a
62        // socket that just went away) costs delayed-ACK latency, not
63        // correctness, and the caller has no better recovery than
64        // continuing. Nothing to log per cycle either — it would fire on
65        // every receive.
66        debug_assert!(
67            std::io::Error::last_os_error().raw_os_error() != Some(libc::EFAULT),
68            "TCP_QUICKACK setsockopt got EFAULT — bad option pointer"
69        );
70    }
71}
72
73// ---------------------------------------------------------------------------
74// io_uring ReceiverTransport implementation
75// ---------------------------------------------------------------------------
76
77const TOKEN_RECV: u64 = 0;
78const TOKEN_SEND: u64 = 1;
79const TOKEN_PROVIDE: u64 = 2;
80
81// Multishot RECV buffer pool. 16 buffers × MAX_DATA_FRAME each.
82const NUM_RECV_BUFFERS: u16 = 16;
83const RECV_BUF_SIZE: usize = MAX_DATA_FRAME;
84const RECV_BUF_GROUP_ID: u16 = 0;
85
86// CQE flag bits (io_uring ABI).
87const IORING_CQE_F_BUFFER: u32 = 1 << 0;
88const IORING_CQE_F_MORE: u32 = 1 << 1;
89const IORING_CQE_BUFFER_SHIFT: u32 = 16;
90
91/// io_uring–backed receiver transport for kernel TCP.
92///
93/// Uses `IORING_OP_RECV_MULTI` against a provided buffer pool for
94/// incoming frames, and single-shot SEND for acks. Owns the io_uring
95/// ring, buffer pool, and ack send state.
96struct UringTransport {
97    ring: io_uring::IoUring,
98    /// Raw socket fd, also used for the `shutdown(2)` wake in `Drop`.
99    /// Always outlives the transport: the owning `TcpStream` lives in
100    /// the reconnect loop, while the transport is a local of the scoped
101    /// receiver thread that borrows it.
102    tcp_fd: RawFd,
103    // Backing storage for the io_uring provided-buffer pool. Never read
104    // through this handle — the kernel accesses it via raw pointers
105    // registered in ProvideBuffers, and reads go through `pool_ptr`.
106    // Owned here so it outlives every RECV the kernel may land in it;
107    // `Drop` quiesces the ring before letting it go.
108    recv_pool: Vec<u8>,
109    pool_ptr: *mut u8,
110    multishot_active: bool,
111    connected: bool,
112    ack_buf: Vec<u8>,
113    ack_offset: usize,
114    ack_in_flight: bool,
115    /// Newest ack accepted while a SEND was in flight — sent the moment
116    /// the in-flight SEND's CQE is reaped (send-latest-on-completion).
117    /// `Option<Ack>` rather than a queue: ack cursors are cumulative
118    /// and monotonic, so when several acks arrive while one send is in
119    /// flight only the newest pair needs the wire — intermediate values
120    /// are subsumed. Exactly one SEND is ever outstanding, which keeps
121    /// the TCP byte stream ordered (io_uring gives no ordering between
122    /// independent SQEs, and interleaved partial sends would corrupt
123    /// the frame stream).
124    pending_ack: Option<Ack>,
125    /// Whether an ack frame has been handed to the kernel since the last
126    /// receive cycle. An outgoing data segment carries the TCP ACK with
127    /// it, so an acking cycle needs no `TCP_QUICKACK` re-arm — only a
128    /// silent one does. See [`arm_tcp_quickack`].
129    acked_since_recv: bool,
130}
131
132impl UringTransport {
133    fn new(tcp_stream: &TcpStream) -> io::Result<Self> {
134        use io_uring::{IoUring, opcode, types};
135        use std::os::unix::io::AsRawFd;
136
137        let tcp_fd = tcp_stream.as_raw_fd();
138
139        let mut ring: IoUring = IoUring::builder()
140            .setup_single_issuer()
141            .build(64)
142            .map_err(|e| io::Error::other(format!("io_uring init failed: {e}")))?;
143
144        ring.submitter()
145            .register_files(&[tcp_fd])
146            .map_err(|e| io::Error::other(format!("io_uring register_files failed: {e}")))?;
147
148        arm_tcp_quickack(tcp_fd);
149
150        // Pin io-wq workers to core 0.
151        {
152            let mut cpuset: libc::cpu_set_t = unsafe { std::mem::zeroed() };
153            unsafe { libc::CPU_SET(0, &mut cpuset) };
154            let _ = ring.submitter().register_iowq_aff(&cpuset);
155        }
156
157        let mut recv_pool: Vec<u8> = vec![0u8; NUM_RECV_BUFFERS as usize * RECV_BUF_SIZE];
158        let pool_ptr = recv_pool.as_mut_ptr();
159
160        // Provide the buffer pool to io_uring.
161        {
162            let sqe = opcode::ProvideBuffers::new(
163                pool_ptr,
164                RECV_BUF_SIZE as i32,
165                NUM_RECV_BUFFERS,
166                RECV_BUF_GROUP_ID,
167                0,
168            )
169            .build()
170            .user_data(TOKEN_PROVIDE);
171            unsafe { ring.submission().push(&sqe).expect("SQ full") };
172            ring.submit_and_wait(1)?;
173            let cqe = ring
174                .completion()
175                .next()
176                .ok_or_else(|| io::Error::other("no cqe after ProvideBuffers"))?;
177            if cqe.result() < 0 {
178                return Err(io::Error::other(format!(
179                    "ProvideBuffers failed: {}",
180                    cqe.result()
181                )));
182            }
183        }
184
185        // Submit the initial multishot RECV.
186        let sqe = opcode::RecvMulti::new(types::Fd(tcp_fd), RECV_BUF_GROUP_ID)
187            .build()
188            .user_data(TOKEN_RECV);
189        unsafe { ring.submission().push(&sqe).expect("SQ full") };
190
191        Ok(UringTransport {
192            ring,
193            tcp_fd,
194            recv_pool,
195            pool_ptr,
196            multishot_active: true,
197            connected: true,
198            ack_buf: Vec::with_capacity(64),
199            ack_offset: 0,
200            ack_in_flight: false,
201            pending_ack: None,
202            acked_since_recv: false,
203        })
204    }
205
206    /// Encode `ack` into the send buffer and push its SEND SQE. The
207    /// caller is responsible for submitting (next `ring.submit()` —
208    /// either `poll_recv`'s top-of-call submit or the eager submit
209    /// after a chained send). Must only be called with no SEND in
210    /// flight: the buffer is pinned by `ack_in_flight` until the CQE.
211    fn push_ack_send(&mut self, ack: &Ack) {
212        use io_uring::{opcode, types};
213
214        debug_assert!(!self.ack_in_flight);
215        self.ack_buf.clear();
216        encode_ack(ack, &mut self.ack_buf);
217        let sqe = opcode::Send::new(
218            types::Fixed(0),
219            self.ack_buf.as_ptr(),
220            self.ack_buf.len() as u32,
221        )
222        .build()
223        .user_data(TOKEN_SEND);
224        // SAFETY: `ack_buf` is owned by this transport and pinned by
225        // `ack_in_flight = true` until the matching CQE is reaped. The
226        // ring is single-threaded.
227        unsafe { self.ring.submission().push(&sqe).expect("SQ full") };
228        self.ack_in_flight = true;
229        self.ack_offset = 0;
230        self.acked_since_recv = true;
231    }
232
233    /// Settle the kernel-ownership flags from CQEs that were reaped out
234    /// of the completion queue but will not be processed, because
235    /// [`Self::poll_recv`] is bailing out on a failed session.
236    ///
237    /// Only the flags — the payloads are deliberately discarded, and
238    /// nothing is resubmitted, since the session is over. Skipping this
239    /// would leave `ack_in_flight` set for a SEND the kernel has
240    /// already returned, and [`Self::drain`] would then wait out its
241    /// whole deadline on a completion that can never arrive again.
242    fn settle_unprocessed(&mut self, rest: &[(u64, i32, u32)]) {
243        for &(token, _result, flags) in rest {
244            match token {
245                TOKEN_RECV if (flags & IORING_CQE_F_MORE) == 0 => self.multishot_active = false,
246                TOKEN_SEND => self.ack_in_flight = false,
247                _ => {}
248            }
249        }
250    }
251
252    /// Reap completions until the kernel holds neither buffer, bounded
253    /// by [`crate::uring_teardown::DRAIN_TIMEOUT`]. `true` means the
254    /// multishot RECV has terminated and any SEND has settled, so both
255    /// buffers are provably free of kernel references.
256    ///
257    /// Unlike the sender's ring there is no in-flight *count* to drive
258    /// this: multishot RECV is a single SQE that yields many CQEs and
259    /// only settles on the one without `IORING_CQE_F_MORE`. So the two
260    /// existing flags are the drain condition, and CQEs are classified
261    /// here rather than reusing `poll_recv` — during teardown the
262    /// payloads are discarded, consumed buffers are not re-provided,
263    /// and neither the multishot nor a partial send is resubmitted.
264    fn drain(&mut self) -> bool {
265        // Flush SQEs pushed but never submitted — notably the initial
266        // multishot RECV and any resubmission queued by the last
267        // `poll_recv`. Until they reach the kernel no CQE is coming,
268        // but the kernel takes the buffer pointers the moment it sees
269        // them, so they cannot simply be abandoned in the SQ either.
270        loop {
271            match self.ring.submit() {
272                Ok(_) => break,
273                Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
274                Err(_) => return false,
275            }
276        }
277        let mut backoff = DrainBackoff::new();
278        while self.multishot_active || self.ack_in_flight {
279            for cqe in self.ring.completion() {
280                match cqe.user_data() {
281                    // The kernel stops writing into the pool only once
282                    // the multishot arm is retired, which it signals by
283                    // clearing F_MORE. A CQE that still carries F_MORE
284                    // is data landing mid-teardown: discard it and keep
285                    // waiting for the terminal one.
286                    TOKEN_RECV if (cqe.flags() & IORING_CQE_F_MORE) == 0 => {
287                        self.multishot_active = false;
288                    }
289                    // Settles whether the send completed, errored, or
290                    // went partial: either way the kernel is done
291                    // reading `ack_buf` for this operation.
292                    TOKEN_SEND => self.ack_in_flight = false,
293                    // ProvideBuffers only hands the kernel addresses;
294                    // it never touches pool contents, so a pending one
295                    // is not a reason to keep waiting.
296                    _ => {}
297                }
298            }
299            if (self.multishot_active || self.ack_in_flight) && !backoff.wait() {
300                return false;
301            }
302        }
303        true
304    }
305}
306
307impl Drop for UringTransport {
308    fn drop(&mut self) {
309        wake_pending_ops(self.tcp_fd);
310        if !self.drain() {
311            // The drain could not be proven complete, so the kernel may
312            // still land a RECV in the buffer pool or read `ack_buf`
313            // for an in-flight SEND. Leak both rather than hand the
314            // allocator memory the kernel still touches — a session
315            // that is tearing down anyway pays a bounded one-off cost.
316            std::mem::forget(std::mem::take(&mut self.recv_pool));
317            std::mem::forget(std::mem::take(&mut self.ack_buf));
318            tracing::warn!(
319                multishot_active = self.multishot_active,
320                ack_in_flight = self.ack_in_flight,
321                "io_uring teardown drain did not complete; leaking receiver buffers"
322            );
323        }
324    }
325}
326
327impl ReceiverTransport for UringTransport {
328    fn poll_recv(&mut self, recv_buf: &mut Vec<u8>) -> io::Result<bool> {
329        use io_uring::{opcode, types};
330
331        if !self.connected {
332            return Err(io::Error::other("not connected"));
333        }
334
335        // Submit pending SQEs (ProvideBuffers re-provisions, Send
336        // remainders, multishot resubmissions).
337        let pending = self.ring.submission().len();
338        if pending > 0 {
339            self.ring.submit()?;
340        }
341
342        // Drain CQEs.
343        let mut cqes: [(u64, i32, u32); 16] = [(0, 0, 0); 16];
344        let mut cqe_count = 0;
345        {
346            let mut cq = self.ring.completion();
347            while cqe_count < cqes.len() {
348                match cq.next() {
349                    Some(cqe) => {
350                        cqes[cqe_count] = (cqe.user_data(), cqe.result(), cqe.flags());
351                        cqe_count += 1;
352                    }
353                    None => break,
354                }
355            }
356        }
357
358        let mut any_recv = false;
359        // Set when a queued ack is chained onto a completed SEND below —
360        // it must hit the wire within THIS call, not ride the next
361        // poll_recv's top-of-call submit one loop iteration later.
362        let mut submit_chained_ack = false;
363
364        for (idx, &(token, result, flags)) in cqes[..cqe_count].iter().enumerate() {
365            // Every early return below abandons the CQEs after `idx`,
366            // which have already been reaped out of the CQ — hence the
367            // `settle_unprocessed` calls: without them teardown would
368            // wait on a completion that has come and gone.
369            match token {
370                TOKEN_RECV => {
371                    if (flags & IORING_CQE_F_MORE) == 0 {
372                        self.multishot_active = false;
373                    }
374                    if result < 0 {
375                        if result == -libc::ENOBUFS {
376                            tracing::debug!("recv multishot ENOBUFS — pool exhausted");
377                            continue;
378                        }
379                        self.connected = false;
380                        self.settle_unprocessed(&cqes[idx + 1..cqe_count]);
381                        return Err(io::Error::other(format!(
382                            "primary disconnected (recv returned {result})"
383                        )));
384                    }
385                    if result == 0 {
386                        self.connected = false;
387                        self.settle_unprocessed(&cqes[idx + 1..cqe_count]);
388                        return Err(io::Error::other("primary disconnected (recv returned 0)"));
389                    }
390                    if (flags & IORING_CQE_F_BUFFER) == 0 {
391                        self.connected = false;
392                        self.settle_unprocessed(&cqes[idx + 1..cqe_count]);
393                        return Err(io::Error::other("recv cqe missing F_BUFFER flag"));
394                    }
395
396                    let n = result as usize;
397                    let buf_id = (flags >> IORING_CQE_BUFFER_SHIFT) as usize;
398                    // SAFETY: buf_id from kernel CQE for a buffer we
399                    // provided from recv_pool; kernel wrote n bytes.
400                    let buf_ptr = unsafe { self.pool_ptr.add(buf_id * RECV_BUF_SIZE) };
401                    let slice = unsafe { std::slice::from_raw_parts(buf_ptr, n) };
402                    recv_buf.extend_from_slice(slice);
403                    any_recv = true;
404
405                    // Re-provide the consumed buffer.
406                    let provide_sqe = opcode::ProvideBuffers::new(
407                        buf_ptr,
408                        RECV_BUF_SIZE as i32,
409                        1,
410                        RECV_BUF_GROUP_ID,
411                        buf_id as u16,
412                    )
413                    .build()
414                    .user_data(TOKEN_PROVIDE);
415                    unsafe { self.ring.submission().push(&provide_sqe).expect("SQ full") };
416                }
417
418                TOKEN_PROVIDE if result < 0 => {
419                    tracing::debug!("ProvideBuffers re-provision failed: {result}");
420                }
421
422                TOKEN_SEND => {
423                    if result < 0 {
424                        self.connected = false;
425                        // This CQE is the kernel handing `ack_buf` back,
426                        // failure or not — record that before bailing.
427                        self.ack_in_flight = false;
428                        self.settle_unprocessed(&cqes[idx + 1..cqe_count]);
429                        return Err(io::Error::other(format!(
430                            "ack send error (returned {result})"
431                        )));
432                    }
433                    let sent = result as usize;
434                    self.ack_offset += sent;
435                    if self.ack_offset >= self.ack_buf.len() {
436                        self.ack_buf.clear();
437                        self.ack_offset = 0;
438                        self.ack_in_flight = false;
439                        // Send-latest-on-completion: an ack accepted
440                        // while this send was in flight goes out now
441                        // instead of waiting a full loop iteration for
442                        // the next flush. Chained only after FULL
443                        // completion — a partial send's remainder keeps
444                        // exclusive ownership of the byte stream.
445                        if let Some(ack) = self.pending_ack.take() {
446                            self.push_ack_send(&ack);
447                            submit_chained_ack = true;
448                        }
449                    } else {
450                        // Partial send — resubmit remainder.
451                        let sqe = opcode::Send::new(
452                            types::Fixed(0),
453                            self.ack_buf[self.ack_offset..].as_ptr(),
454                            (self.ack_buf.len() - self.ack_offset) as u32,
455                        )
456                        .build()
457                        .user_data(TOKEN_SEND);
458                        unsafe { self.ring.submission().push(&sqe).expect("SQ full") };
459                    }
460                }
461
462                _ => {}
463            }
464        }
465
466        // Resubmit multishot if terminated.
467        if !self.multishot_active {
468            let sqe = opcode::RecvMulti::new(types::Fd(self.tcp_fd), RECV_BUF_GROUP_ID)
469                .build()
470                .user_data(TOKEN_RECV);
471            unsafe { self.ring.submission().push(&sqe).expect("SQ full") };
472            self.multishot_active = true;
473        }
474
475        // Flush a chained ack to the kernel before returning (also
476        // carries any multishot resubmission pushed above).
477        if submit_chained_ack {
478            self.ring.submit()?;
479        }
480
481        if any_recv {
482            // One decision per receive cycle rather than a setsockopt per
483            // RECV CQE — and none at all while the replica is acking,
484            // since each ack frame is an outgoing segment that carries
485            // the TCP ACK. A cycle that produces no ack re-arms, so a
486            // stream that goes app-level silent waits at most one cycle
487            // (not a delayed-ACK timer) for its next immediate ACK.
488            if self.acked_since_recv {
489                self.acked_since_recv = false;
490            } else {
491                arm_tcp_quickack(self.tcp_fd);
492            }
493        }
494
495        Ok(any_recv)
496    }
497
498    fn send_ack(&mut self, ack: &Ack) -> io::Result<bool> {
499        if self.ack_in_flight {
500            // Coalesce: overwrite any previously queued value — the
501            // cursors are cumulative, so the newest pair subsumes
502            // everything before it. Sent on the in-flight SEND's CQE.
503            self.pending_ack = Some(*ack);
504            return Ok(true);
505        }
506        self.push_ack_send(ack);
507        Ok(true)
508    }
509
510    fn ack_in_flight(&self) -> bool {
511        // Pending counts as in flight: the drain paths use this to mean
512        // "everything offered has reached the wire", and the flush gate
513        // in `streaming_loop` recomputes a fresh ack next iteration
514        // anyway.
515        self.ack_in_flight || self.pending_ack.is_some()
516    }
517
518    fn is_connected(&mut self) -> bool {
519        self.connected
520    }
521}
522
523// ---------------------------------------------------------------------------
524// Public API
525// ---------------------------------------------------------------------------
526
527/// Kernel-TCP control-frame source: a blocking `read_frame` on the
528/// replication stream. Drives the shared [`receive_chunked_body`] (and,
529/// later, the rest of the resync transfer). See [`ControlFrameSource`].
530struct TcpFrameSource<'a> {
531    reader: &'a mut TcpStream,
532}
533
534impl ControlFrameSource for TcpFrameSource<'_> {
535    fn next_frame(
536        &mut self,
537        max_size: usize,
538    ) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
539        Ok(read_frame(self.reader, max_size)?)
540    }
541}
542
543/// Outcome of `run_receiver`: `None` = clean shutdown, `Some` = promotion.
544pub type ReceiverResult<A, W> = Result<Option<(A, W)>, Box<dyn std::error::Error>>;
545
546#[allow(clippy::too_many_arguments)]
547pub fn run_receiver<A>(
548    primary_addr: SocketAddr,
549    journal_path: &std::path::Path,
550    signing_key: &ed25519_dalek::SigningKey,
551    shutdown: &AtomicBool,
552    // The handles this loop shares with the admin endpoint and the
553    // control-plane raft driver — see [`super::ReplicaControlPlane`]
554    // for each field's contract.
555    control: &super::ReplicaControlPlane,
556    snapshot_interval_ms: u64,
557    snapshot_path: std::path::PathBuf,
558    cores: crate::layout::PipelineCores,
559    // Passed straight to the replica's journal stage; see
560    // `build_replica_pipeline_with_threads`.
561    staging_mode: melin_journal::StagingMode,
562    group_commit_delay: std::time::Duration,
563    pipeline_depth: usize,
564    fence_state: std::sync::Arc<melin_transport_core::fence::FenceState>,
565    // Applied to every instance this loop builds a pipeline around; see
566    // `build_replica_pipeline_with_threads`.
567    sizing: &A::Sizing,
568) -> ReceiverResult<A, BufferedWriter<A::Event>>
569where
570    A: Application + Send + 'static,
571    A::Event: Send + Sync + 'static,
572    A::Report: Send + 'static,
573    A::QueryResponse: Send + 'static,
574{
575    let super::ReplicaControlPlane {
576        promote,
577        tip_ready,
578        journal_tip,
579        primary_link_up,
580        // Stored by `accept_stream_start`, which takes `control` whole.
581        primary_ack_policy: _,
582        pipeline_healthy,
583    } = control;
584    // Recover whenever any journal segment survives — live OR archived;
585    // fresh replicas get `(None, None, 0, zeros)`. See
586    // `recover_replica_state` for the lineage rules.
587    let (mut app, mut journal_writer, mut last_sequence, mut chain_hash) =
588        recover_replica_state::<A, BufferedWriter<A::Event>>(
589            journal_path,
590            &snapshot_path,
591            &fence_state,
592            sizing,
593        )?;
594    // The fence epoch now reflects the recovered journal (a fresh replica
595    // legitimately recovers epoch 0). Seed the advertised sequence from
596    // the same recovery before releasing the flag, so the first tip the
597    // raft driver trusts is already the recovered high-water mark.
598    // Release-store so the driver's Acquire-load sees both.
599    journal_tip.advance(melin_transport_core::WireSeq::new(last_sequence));
600    tip_ready.store(true, Ordering::Release);
601
602    let mut backoff = std::time::Duration::from_secs(1);
603
604    // Consecutive mid-stream divergence resyncs this process has
605    // attempted — see `MAX_INPROCESS_DIVERGENCE_RESYNCS`.
606    let mut divergence_resyncs: u32 = 0;
607
608    let mut send_buf = Vec::with_capacity(64);
609    let mut pipeline: Option<ReplicaPipelineHandles<A, BufferedWriter<A::Event>>> = None;
610
611    // --- Outer reconnect loop ---
612    loop {
613        // No authenticated session while (re)connecting — the raft
614        // driver may auto-promote from here on if it wins an election.
615        primary_link_up.store(false, Ordering::Release);
616
617        if let Some(p) = pipeline.as_ref() {
618            // The handshake pair (last_sequence, chain_hash) must come
619            // from ONE FsyncState snapshot — the journal stage keeps
620            // flushing while we reconnect, and the primary's handshake
621            // validation recomputes its chain at exactly the sequence
622            // we claim. Reading the sequence and the hash from two
623            // separate sources would tear under load, and a torn pair
624            // is indistinguishable from divergence (false resync of a
625            // healthy replica). Taken once the journal covers what the
626            // last session published (see `settled_resume_point`).
627            match p
628                .settled_resume_point(|| shutdown.load(Ordering::Relaxed) || promote.is_requested())
629            {
630                ResumePoint::Settled(fsync_state) => {
631                    last_sequence = fsync_state.journal_seq.get();
632                    chain_hash = fsync_state.chain_hash;
633                }
634                // Handled just below, before any handshake.
635                ResumePoint::Interrupted => {}
636                // Torn down here rather than dialing the primary with a
637                // pipeline that cannot take the session.
638                ResumePoint::JournalFailed => {
639                    let after = handle_session_exit(
640                        journal_failed_while_disconnected(),
641                        &mut pipeline,
642                        &mut divergence_resyncs,
643                        &mut backoff,
644                        last_sequence,
645                        journal_path,
646                        &snapshot_path,
647                        &fence_state,
648                        shutdown,
649                        promote,
650                        // No session is open.
651                        || {},
652                        sizing,
653                    );
654                    // A fatal exit never keeps the pipeline, so going
655                    // round again dials only after a resync or not at all.
656                    if let Some(r) = after.adopt(
657                        &mut app,
658                        &mut journal_writer,
659                        &mut last_sequence,
660                        &mut chain_hash,
661                    ) {
662                        return r;
663                    }
664                    continue;
665                }
666            }
667        }
668
669        // Sole authority for shutdown/promote while disconnected: every
670        // backoff arm just sleeps (the sleep observes both flags) and
671        // continues back here, so teardown/promotion handling exists
672        // exactly once.
673        if shutdown.load(Ordering::Relaxed) {
674            if let Some(p) = pipeline.take() {
675                let _ = teardown_replica_pipeline::<A, BufferedWriter<A::Event>>(p);
676            }
677            return Ok(None);
678        }
679        if promote.is_requested() {
680            info!("promotion triggered while disconnected");
681            return take_pipeline_for_promotion(&mut pipeline, &mut app, &mut journal_writer);
682        }
683
684        // --- Connect and authenticate ---
685        info!(primary = %primary_addr, "connecting to primary as replica");
686
687        let stream = match TcpStream::connect(primary_addr) {
688            Ok(s) => s,
689            Err(e) => {
690                warn!(
691                    error = %e,
692                    backoff_secs = backoff.as_secs(),
693                    "failed to connect to primary — retrying"
694                );
695                sleep_then_double_backoff(&mut backoff, shutdown, promote);
696                continue;
697            }
698        };
699        if let Err(e) = stream.set_nodelay(true) {
700            warn!(error = %e, "failed to set TCP_NODELAY on replica receive socket");
701        }
702        {
703            use std::os::unix::io::AsRawFd;
704            let busy_poll: libc::c_int = 50;
705            let rc = unsafe {
706                libc::setsockopt(
707                    stream.as_raw_fd(),
708                    libc::SOL_SOCKET,
709                    libc::SO_BUSY_POLL,
710                    &busy_poll as *const libc::c_int as *const libc::c_void,
711                    std::mem::size_of::<libc::c_int>() as libc::socklen_t,
712                )
713            };
714            if rc != 0 {
715                let err = std::io::Error::last_os_error();
716                warn!(error = %err, "failed to set SO_BUSY_POLL on replica receive socket");
717            }
718        }
719        stream.set_read_timeout(Some(std::time::Duration::from_secs(5)))?;
720
721        let mut reader = stream.try_clone()?;
722        let mut tcp_writer = stream;
723
724        // `reader`/`tcp_writer` are clones of one socket; auth is sequential so
725        // a single handle works. Use `reader` (carries the read timeout above).
726        if let Err(e) = authenticate_with_primary(&mut reader, signing_key) {
727            // A read timeout means no challenge arrived: the target
728            // holds the port but is not serving — typically a
729            // not-yet-promoted replica (the listener is bound from boot)
730            // or a primary mid-start. Only other errors suggest a real
731            // credential problem. SO_RCVTIMEO surfaces as WouldBlock on
732            // Linux; match TimedOut too for portability.
733            if matches!(
734                e.kind(),
735                std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
736            ) {
737                warn!(error = %e, "no auth challenge from primary (not serving yet?) — retrying");
738            } else {
739                warn!(error = %e, "authentication failed — retrying");
740            }
741            // Drop the socket before sleeping — held through the
742            // backoff it would sit in the target's accept backlog, and
743            // a just-promoted primary would waste a replica slot (and
744            // its 10s auth timeout) on the zombie.
745            drop(reader);
746            drop(tcp_writer);
747            // Back off before redialing — without the sleep this loop
748            // hammers a primary that keeps refusing us.
749            sleep_then_double_backoff(&mut backoff, shutdown, promote);
750            continue;
751        }
752        info!("authenticated with primary");
753        // The primary is demonstrably alive from here until this
754        // session ends — the raft driver must not auto-promote past it.
755        primary_link_up.store(true, Ordering::Release);
756
757        // --- Handshake ---
758        // Advertise our fencing epoch. If we are ahead of the primary it
759        // sees this and self-demotes (it's a stale ex-primary); see
760        // `crate::fence` and the sender's handshake handling.
761        let handshake = Handshake {
762            last_sequence,
763            chain_hash,
764            epoch: fence_state.epoch(),
765        };
766        send_buf.clear();
767        encode_handshake(&handshake, &mut send_buf);
768        tcp_writer.write_all(&send_buf)?;
769        tcp_writer.flush()?;
770        send_buf.clear();
771
772        // --- Protocol negotiation ---
773        // `session_start` is the resume point this streaming session
774        // continues from — the value that anchors the receiver's
775        // sequence-contiguity gate. Derived from local knowledge (our
776        // own handshake, or the verified snapshot), never from the
777        // wire.
778        let response_frame = read_frame(&mut reader, MAX_CONTROL_FRAME)?;
779        let response = decode_primary_message(&response_frame)?;
780        let (stream_lineage, session_start) = match response {
781            PrimaryMessage::StreamStart {
782                start_sequence,
783                segment_start_sequence,
784                anchor_hash,
785                genesis_entries,
786                epoch,
787                ack_policy,
788            } => match super::accept_stream_start(
789                super::StreamStart {
790                    start_sequence,
791                    lineage: (segment_start_sequence, anchor_hash, genesis_entries),
792                    epoch,
793                    ack_policy,
794                },
795                "tcp",
796                &pipeline,
797                &mut journal_writer,
798                &fence_state,
799                control,
800            )? {
801                StreamStartVerdict::Follow(lineage) => (lineage, last_sequence),
802                // Disconnect and retry with backoff; the operator's logs
803                // flag the misdirected `--replica-of`.
804                StreamStartVerdict::StalePrimary => {
805                    // Drop the socket before sleeping — see the auth
806                    // failure arm above.
807                    drop(reader);
808                    drop(tcp_writer);
809                    sleep_then_double_backoff(&mut backoff, shutdown, promote);
810                    continue;
811                }
812            },
813            ref resync @ (PrimaryMessage::NeedSnapshot | PrimaryMessage::HashMismatch) => {
814                let divergent = matches!(resync, PrimaryMessage::HashMismatch);
815                // `map_err` drops `Send + Sync` by coercion — the `?` `From`
816                // bridge doesn't apply directly across the two boxed trait
817                // objects (`Box<dyn Error + Send + Sync>` → `Box<dyn Error>`).
818                let decision = handle_resync_verdict(
819                    divergent,
820                    &mut TcpFrameSource {
821                        reader: &mut reader,
822                    },
823                    &mut pipeline,
824                    &mut app,
825                    &mut journal_writer,
826                    journal_path,
827                    &snapshot_path,
828                    &fence_state,
829                    control,
830                    &mut last_sequence,
831                    &mut chain_hash,
832                )
833                .map_err(|e| -> Box<dyn std::error::Error> { e })?;
834                match decision {
835                    ResyncDecision::Ready {
836                        segment_start_sequence,
837                        anchor_hash,
838                        genesis_entries,
839                        resume_sequence,
840                    } => (
841                        (segment_start_sequence, anchor_hash, genesis_entries),
842                        resume_sequence,
843                    ),
844                    ResyncDecision::Retry => {
845                        // Drop the socket before sleeping — see the auth
846                        // failure arm above.
847                        drop(reader);
848                        drop(tcp_writer);
849                        sleep_then_double_backoff(&mut backoff, shutdown, promote);
850                        continue;
851                    }
852                }
853            }
854            _ => {
855                return Err(format!("unexpected response: {response:?}").into());
856            }
857        };
858
859        // --- Create journal for fresh replica ---
860        // The StreamStart lineage gives the segment header identity
861        // (starting sequence + chain anchor) the primary's own journal
862        // lineage began with; creating the local segment from the same
863        // identity makes the replica's segment byte-identical to the
864        // primary's, and adopted `Rotate` boundaries keep it that way
865        // across rotations (bitwise mirror). The header records the
866        // lineage's genesis length too, so it survives this replica's
867        // restarts and is there to check at promotion even with the
868        // primary gone.
869        if pipeline.is_none() && journal_writer.is_none() {
870            let (lineage_start, lineage_anchor, lineage_genesis) = stream_lineage;
871            let writer = BufferedWriter::create_continuing(
872                journal_path,
873                lineage_start,
874                lineage_anchor,
875                lineage_genesis,
876            )?;
877            app = Some(A::default());
878            journal_writer = Some(writer);
879        }
880
881        // --- Build pipeline if absent ---
882        if pipeline.is_none() {
883            let cur_app = app.take().expect("application initialized");
884            let cur_writer = journal_writer.take().expect("journal_writer initialized");
885            pipeline = Some(build_replica_pipeline_with_threads::<A>(
886                cur_app,
887                cur_writer,
888                cores,
889                staging_mode,
890                snapshot_interval_ms,
891                snapshot_path.clone(),
892                group_commit_delay,
893                Arc::clone(&fence_state),
894                Arc::clone(pipeline_healthy),
895                sizing,
896            )?);
897        }
898
899        // --- Streaming session ---
900        let result = {
901            let p = pipeline.as_mut().expect("pipeline must exist by here");
902            let input_producer = &mut p.input_producer;
903            let journal_cursor = p.journal_cursor.as_ref();
904            let stream_marks = &p.stream_marks;
905            let journal_failed = &p.journal_failed;
906            std::thread::scope(|s| {
907                let handle = std::thread::Builder::new()
908                    .name("replica-receiver".into())
909                    .spawn_scoped(s, || {
910                        melin_app::affinity::pin_thread("replica-receiver", cores.reader.core);
911                        let mut transport = match UringTransport::new(&tcp_writer) {
912                            Ok(t) => t,
913                            Err(e) => {
914                                tracing::error!(error = %e, "UringTransport init failed");
915                                // Synthetic result: streaming never started,
916                                // so `heard_from_primary` stays false and the
917                                // reconnect keeps its escalated backoff — this
918                                // failure is local (io_uring setup), and a
919                                // persistent one must not redial at 1 Hz.
920                                return super::receiver_transport::StreamingResult {
921                                    exit: SessionExit::Disconnected,
922                                    heard_from_primary: false,
923                                };
924                            }
925                        };
926                        streaming_loop::<UringTransport, A::Event>(
927                            &mut transport,
928                            input_producer,
929                            journal_cursor,
930                            shutdown,
931                            control,
932                            pipeline_depth,
933                            cores.reader.wait,
934                            session_start,
935                            Vec::with_capacity(MAX_DATA_FRAME + 4),
936                            None,
937                            stream_marks,
938                            journal_failed,
939                        )
940                    })
941                    .expect("spawn replica-receiver thread");
942                handle.join().expect("replica-receiver thread panicked")
943            })
944        };
945
946        primary_link_up.store(false, Ordering::Release);
947        let after = handle_session_exit(
948            result,
949            &mut pipeline,
950            &mut divergence_resyncs,
951            &mut backoff,
952            last_sequence,
953            journal_path,
954            &snapshot_path,
955            &fence_state,
956            shutdown,
957            promote,
958            // Kernel TCP needs no explicit close — the `TcpStream` is
959            // dropped on the next loop turn when a fresh connection
960            // replaces it.
961            || {},
962            sizing,
963        );
964        if let Some(r) = after.adopt(
965            &mut app,
966            &mut journal_writer,
967            &mut last_sequence,
968            &mut chain_hash,
969        ) {
970            return r;
971        }
972    }
973}
974
975// ---------------------------------------------------------------------------
976// Tests
977// ---------------------------------------------------------------------------
978
979#[cfg(test)]
980mod tests {
981    use super::*;
982    use crate::uring_teardown::DRAIN_TIMEOUT;
983    use std::io::Read;
984    use std::net::TcpListener;
985    use std::time::{Duration, Instant};
986
987    use melin_transport_core::replication::protocol::{ReplicaMessage, decode_replica_message};
988    use melin_wire_protocol::framing::split_frame_limited;
989
990    /// Connected localhost pair: (transport side, peer side).
991    fn socket_pair() -> (TcpStream, TcpStream) {
992        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
993        let addr = listener.local_addr().unwrap();
994        let client = TcpStream::connect(addr).unwrap();
995        let (server, _) = listener.accept().unwrap();
996        (client, server)
997    }
998
999    fn ack(seq: u64) -> Ack {
1000        Ack {
1001            acked_sequence: seq,
1002            in_memory_sequence: seq,
1003        }
1004    }
1005
1006    /// Drive `poll_recv` until every accepted ack has reached the wire.
1007    fn flush_acks(transport: &mut UringTransport) {
1008        let mut recv_buf = Vec::new();
1009        let deadline = Instant::now() + Duration::from_secs(5);
1010        while transport.ack_in_flight() {
1011            transport.poll_recv(&mut recv_buf).unwrap();
1012            assert!(Instant::now() < deadline, "ack send never completed");
1013            std::hint::spin_loop();
1014        }
1015    }
1016
1017    /// Read `expect` length-prefixed ack frames from the peer socket.
1018    fn read_acks(peer: &mut TcpStream, expect: usize) -> Vec<Ack> {
1019        peer.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
1020        let mut buf = Vec::new();
1021        let mut chunk = [0u8; 256];
1022        let mut acks = Vec::new();
1023        while acks.len() < expect {
1024            let n = peer.read(&mut chunk).expect("peer read");
1025            assert!(n > 0, "peer closed before all acks arrived");
1026            buf.extend_from_slice(&chunk[..n]);
1027            // Parse complete frames.
1028            while let Some(frame) = split_frame_limited(&buf, MAX_CONTROL_FRAME).unwrap() {
1029                match decode_replica_message(frame.payload).expect("decodable frame") {
1030                    ReplicaMessage::Ack(a) => acks.push(a),
1031                    other => panic!("expected Ack frame, got {other:?}"),
1032                }
1033                let consumed = frame.consumed();
1034                buf.drain(..consumed);
1035            }
1036        }
1037        assert!(buf.is_empty(), "trailing bytes after expected acks");
1038        acks
1039    }
1040
1041    #[test]
1042    fn coalesces_to_latest_ack_while_send_in_flight() {
1043        let (client, mut peer) = socket_pair();
1044        let mut transport = UringTransport::new(&client).unwrap();
1045
1046        // First ack goes straight onto the wire path; the next two are
1047        // accepted while it is in flight (the flag clears only when the
1048        // CQE is reaped in poll_recv, so this is deterministic) and
1049        // coalesce — only the newest survives.
1050        assert!(transport.send_ack(&ack(1)).unwrap());
1051        assert!(transport.ack_in_flight());
1052        assert!(transport.send_ack(&ack(2)).unwrap());
1053        assert!(transport.send_ack(&ack(3)).unwrap());
1054
1055        flush_acks(&mut transport);
1056
1057        // Exactly two frames, in cursor order: ack(2) was subsumed.
1058        let acks = read_acks(&mut peer, 2);
1059        let seqs: Vec<u64> = acks.iter().map(|a| a.acked_sequence).collect();
1060        assert_eq!(seqs, vec![1, 3]);
1061    }
1062
1063    #[test]
1064    fn sequential_acks_all_reach_the_wire_in_order() {
1065        let (client, mut peer) = socket_pair();
1066        let mut transport = UringTransport::new(&client).unwrap();
1067
1068        for seq in 1..=3 {
1069            assert!(transport.send_ack(&ack(seq)).unwrap());
1070            flush_acks(&mut transport);
1071        }
1072
1073        let acks = read_acks(&mut peer, 3);
1074        let seqs: Vec<u64> = acks.iter().map(|a| a.acked_sequence).collect();
1075        assert_eq!(seqs, vec![1, 2, 3]);
1076    }
1077
1078    /// The multishot RECV armed at construction stays armed for the
1079    /// whole session, so every ordinary session exit drops the
1080    /// transport with the kernel still holding pointers into the buffer
1081    /// pool. `Drop` has to quiesce it — and promptly: a drain that
1082    /// cannot settle only gives up on the deadline, so finishing well
1083    /// inside it is what proves the buffers were released rather than
1084    /// leaked.
1085    #[test]
1086    fn drop_quiesces_the_multishot_recv() {
1087        let (client, _peer) = socket_pair();
1088        let mut transport = UringTransport::new(&client).unwrap();
1089        // `new` only pushes the multishot RECV; the first poll submits
1090        // it. Teardown from that state is what a real session does.
1091        let mut recv_buf = Vec::new();
1092        transport.poll_recv(&mut recv_buf).unwrap();
1093        assert!(transport.multishot_active);
1094
1095        let started = Instant::now();
1096        drop(transport);
1097        let elapsed = started.elapsed();
1098        assert!(
1099            elapsed < DRAIN_TIMEOUT / 4,
1100            "teardown took {elapsed:?}, close to the {DRAIN_TIMEOUT:?} deadline"
1101        );
1102    }
1103
1104    /// A failing session bails out of `poll_recv` part-way through a
1105    /// batch, abandoning CQEs that were already reaped out of the
1106    /// completion queue. Their kernel-ownership flags still have to be
1107    /// recorded: otherwise teardown waits out its whole deadline for a
1108    /// SEND completion that was consumed here and can never arrive
1109    /// again, and then leaks the buffer pool.
1110    #[test]
1111    fn abandoned_cqes_still_settle_kernel_ownership() {
1112        let (client, _peer) = socket_pair();
1113        let mut transport = UringTransport::new(&client).unwrap();
1114        let mut recv_buf = Vec::new();
1115        transport.poll_recv(&mut recv_buf).unwrap();
1116
1117        // Stands in for a SEND whose CQE landed in the batch behind a
1118        // RECV CQE that failed the session.
1119        transport.ack_in_flight = true;
1120        transport.settle_unprocessed(&[(TOKEN_SEND, 13, 0)]);
1121        assert!(
1122            !transport.ack_in_flight,
1123            "an abandoned SEND completion must release `ack_buf`"
1124        );
1125
1126        // A RECV that still carries F_MORE leaves the arm live — the
1127        // kernel may write into the pool again, so teardown must wait.
1128        transport.settle_unprocessed(&[(TOKEN_RECV, 128, IORING_CQE_F_MORE)]);
1129        assert!(
1130            transport.multishot_active,
1131            "F_MORE must leave the multishot armed"
1132        );
1133        // Only the terminal completion retires it.
1134        transport.settle_unprocessed(&[(TOKEN_RECV, 0, 0)]);
1135        assert!(!transport.multishot_active);
1136
1137        // The real arm submitted by `poll_recv` above is still live in
1138        // the kernel; restore the flag the assertion just cleared so
1139        // `Drop` drains it instead of freeing the pool underneath it.
1140        transport.multishot_active = true;
1141    }
1142
1143    /// Same, for the other kernel-visible buffer: an ack SEND pushed
1144    /// but never submitted, so the drain has to flush the SQ before it
1145    /// can wait for anything.
1146    #[test]
1147    fn drop_quiesces_a_pending_ack_send() {
1148        let (client, _peer) = socket_pair();
1149        let mut transport = UringTransport::new(&client).unwrap();
1150        assert!(transport.send_ack(&ack(7)).unwrap());
1151        assert!(transport.ack_in_flight());
1152
1153        let started = Instant::now();
1154        drop(transport);
1155        let elapsed = started.elapsed();
1156        assert!(
1157            elapsed < DRAIN_TIMEOUT / 4,
1158            "teardown took {elapsed:?}, close to the {DRAIN_TIMEOUT:?} deadline"
1159        );
1160    }
1161
1162    /// Scripted-primary scaffolding shared by the end-to-end receiver
1163    /// tests below: a no-op application over a trivial event, the
1164    /// primary-side frame helpers, and common fixtures. Gated like its
1165    /// consumers so a `no-persist` build carries no dead test code.
1166    #[cfg(not(feature = "no-persist"))]
1167    mod scripted {
1168        use super::*;
1169        use melin_app::{AppEvent, Application, ApplyCtx, CodecError, QueryCtx, RejectReason};
1170
1171        // Re-export so the sibling test modules can name the generic
1172        // slot type without reaching into transport-core themselves.
1173        pub(super) use melin_transport_core::pipeline::InputSlot;
1174
1175        #[derive(Debug, Clone, Copy, PartialEq)]
1176        pub(super) struct EvtAdd(pub(super) u64);
1177
1178        impl AppEvent for EvtAdd {
1179            const MAX_ENCODED_SIZE: usize = 8;
1180
1181            fn encoded_size(&self) -> usize {
1182                8
1183            }
1184            fn encode(&self, buf: &mut [u8]) -> usize {
1185                buf[..8].copy_from_slice(&self.0.to_le_bytes());
1186                8
1187            }
1188            fn decode(buf: &[u8]) -> Result<Self, CodecError> {
1189                if buf.len() < 8 {
1190                    return Err(CodecError::Truncated);
1191                }
1192                Ok(EvtAdd(u64::from_le_bytes(buf[..8].try_into().expect("8"))))
1193            }
1194            fn is_query(&self) -> bool {
1195                false
1196            }
1197        }
1198
1199        #[derive(Debug, Clone, Copy)]
1200        pub(super) struct Rpt;
1201
1202        #[derive(Default)]
1203        pub(super) struct App;
1204
1205        impl Application for App {
1206            type Event = EvtAdd;
1207            type Report = Rpt;
1208            type QueryResponse = Rpt;
1209            type Sizing = ();
1210            const APP_VERSION: u16 = 1;
1211
1212            fn apply(&mut self, _e: EvtAdd, _ctx: &ApplyCtx, _out: &mut Vec<Rpt>) {}
1213            fn query(&self, _e: EvtAdd, _ctx: &QueryCtx) -> Option<Rpt> {
1214                None
1215            }
1216            fn tick(&mut self, _now_ns: u64, _out: &mut Vec<Rpt>) {}
1217            fn build_reject(_e: &EvtAdd, _r: RejectReason) -> Rpt {
1218                Rpt
1219            }
1220            fn snapshot<W: std::io::Write>(&self, _w: &mut W) -> std::io::Result<()> {
1221                Ok(())
1222            }
1223            fn restore<R: std::io::Read>(_r: &mut R) -> std::io::Result<Self> {
1224                Ok(App)
1225            }
1226        }
1227
1228        /// Deterministic replica signing-key bytes; [`replica_auth`]
1229        /// builds the matching authorized-keys table.
1230        pub(super) const REPLICA_KEY: [u8; 32] = [0xFC; 32];
1231
1232        /// The authorized-keys table listing [`REPLICA_KEY`]'s public
1233        /// half.
1234        pub(super) fn replica_auth() -> melin_app::auth::AuthorizedKeys {
1235            let key = ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY);
1236            let pub_b64 = base64::Engine::encode(
1237                &base64::engine::general_purpose::STANDARD,
1238                key.verifying_key().to_bytes(),
1239            );
1240            melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&format!(
1241                "replication {pub_b64} test-replica\n"
1242            ))
1243            .expect("parse keys")
1244        }
1245
1246        /// Read one length-prefixed `ReplicaMessage` frame.
1247        pub(super) fn read_replica_msg(stream: &mut TcpStream) -> ReplicaMessage {
1248            let mut len_buf = [0u8; 4];
1249            stream.read_exact(&mut len_buf).expect("frame length");
1250            let len = u32::from_le_bytes(len_buf) as usize;
1251            let mut payload = vec![0u8; len];
1252            stream.read_exact(&mut payload).expect("frame payload");
1253            decode_replica_message(&payload).expect("decodable replica frame")
1254        }
1255
1256        /// Skip frames until an `Ack` at or past `seq` arrives.
1257        pub(super) fn wait_for_ack(stream: &mut TcpStream, seq: u64) {
1258            loop {
1259                if let ReplicaMessage::Ack(a) = read_replica_msg(stream)
1260                    && a.acked_sequence >= seq
1261                {
1262                    return;
1263                }
1264            }
1265        }
1266
1267        /// Accept with a deadline so a replica that fails to (re)connect
1268        /// fails the test instead of hanging it.
1269        pub(super) fn accept_within(listener: &std::net::TcpListener, secs: u64) -> TcpStream {
1270            let deadline = Instant::now() + Duration::from_secs(secs);
1271            loop {
1272                match listener.accept() {
1273                    Ok((s, _)) => {
1274                        s.set_read_timeout(Some(Duration::from_secs(10)))
1275                            .expect("read timeout");
1276                        return s;
1277                    }
1278                    Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
1279                        assert!(Instant::now() < deadline, "replica never connected");
1280                        std::thread::sleep(Duration::from_millis(10));
1281                    }
1282                    Err(e) => panic!("accept failed: {e}"),
1283                }
1284            }
1285        }
1286    }
1287
1288    // -----------------------------------------------------------------
1289    // In-process divergence resync — end to end against a scripted
1290    // primary. Needs hash-chain (divergence is a chain verdict) and
1291    // real persistence (the resync re-derives state from disk).
1292    // -----------------------------------------------------------------
1293    #[cfg(all(feature = "hash-chain", not(feature = "no-persist")))]
1294    mod divergence_resync {
1295        use super::super::super::auth::authenticate_replica;
1296        use super::scripted::*;
1297        use super::*;
1298        use melin_journal::{BufferedWriter, JournalEvent, JournalWrite};
1299        use melin_transport_core::cursors::WireSeq;
1300        use melin_transport_core::replication::catchup::{lineage_origin, snapshot_transfer_with};
1301        use melin_transport_core::replication::protocol::{
1302            encode_hash_mismatch, encode_rotate, encode_stream_start,
1303        };
1304        use std::sync::Arc;
1305        use std::sync::atomic::AtomicBool;
1306
1307        /// A scripted primary brings a fresh replica up, streams one
1308        /// event, then announces a rotation with a deliberately wrong
1309        /// tail hash. The replica's journal stage detects divergence —
1310        /// `run_receiver` must repair it WITHOUT exiting: tear the
1311        /// pipeline down, re-derive its handshake position from disk,
1312        /// reconnect, take the HashMismatch verdict, archive the forked
1313        /// lineage, and complete the snapshot + segment-seed resync,
1314        /// then resume streaming on the rebased lineage.
1315        #[test]
1316        fn mid_stream_divergence_resyncs_in_process() {
1317            let dir = tempfile::tempdir().expect("tempdir");
1318
1319            // --- The scripted primary's journal + snapshot, served by
1320            // the real sender-side transfer code in session 2. Entries
1321            // 1..=5, rotation after 2, snapshot at 4 (mid-live-segment).
1322            let primary_journal = dir.path().join("primary.journal");
1323            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
1324            let mut chain_at_4 = [0u8; 32];
1325            for v in 1..=5u64 {
1326                w.append(&JournalEvent::App(EvtAdd(v))).expect("append");
1327                if v == 2 {
1328                    w.rotate_segment().expect("rotate");
1329                }
1330                if v == 4 {
1331                    chain_at_4 = w.chain_hash().expect("chain");
1332                }
1333            }
1334            drop(w);
1335            melin_transport_core::snapshot::save::<App>(
1336                &App,
1337                WireSeq::new(4),
1338                chain_at_4,
1339                0,
1340                Some(0),
1341                &primary_journal.with_extension("snapshot"),
1342            )
1343            .expect("save snapshot");
1344            let origin = lineage_origin(&primary_journal).expect("lineage");
1345            let (lineage_start, lineage_anchor, lineage_genesis) = (
1346                origin.starting_sequence,
1347                origin.anchor_hash,
1348                origin.genesis_entries,
1349            );
1350
1351            // --- Auth: one replica key, authorized.
1352            let authorized_keys = replica_auth();
1353
1354            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1355            listener.set_nonblocking(true).expect("nonblocking");
1356            let addr = listener.local_addr().expect("addr");
1357
1358            // --- Replica under test.
1359            let replica_journal = dir.path().join("replica.journal");
1360            let replica_snapshot = dir.path().join("replica.snapshot");
1361            let shutdown = Arc::new(AtomicBool::new(false));
1362            let control = crate::replication::ReplicaControlPlane::new();
1363            let cores = crate::layout::PipelineCores::unpinned();
1364            let replica = {
1365                let journal = replica_journal.clone();
1366                let shutdown = Arc::clone(&shutdown);
1367                let control = control.clone();
1368                std::thread::spawn(move || -> Result<bool, String> {
1369                    run_receiver::<App>(
1370                        addr,
1371                        &journal,
1372                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
1373                        &shutdown,
1374                        &control,
1375                        3_600_000,
1376                        replica_snapshot,
1377                        cores,
1378                        melin_journal::StagingMode::ZeroFill,
1379                        Duration::ZERO,
1380                        64,
1381                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
1382                        &(),
1383                    )
1384                    // ReceiverResult's error is !Send — stringify for join().
1385                    .map(|state| state.is_none())
1386                    .map_err(|e| e.to_string())
1387                })
1388            };
1389
1390            // --- Session 1: fresh sync, one event, then the poisoned
1391            // rotation announce.
1392            let mut s1 = accept_within(&listener, 30);
1393            let mut s1r = s1.try_clone().expect("clone");
1394            authenticate_replica(&mut s1r, &authorized_keys).expect("auth 1");
1395            match read_replica_msg(&mut s1) {
1396                ReplicaMessage::Handshake(h) => {
1397                    assert_eq!(h.last_sequence, 0, "fresh replica handshake")
1398                }
1399                other => panic!("expected Handshake, got {other:?}"),
1400            }
1401            let mut buf = Vec::new();
1402            encode_stream_start(
1403                0,
1404                lineage_start,
1405                lineage_anchor,
1406                lineage_genesis,
1407                0,
1408                1,
1409                &mut buf,
1410            );
1411            s1.write_all(&buf).expect("StreamStart 1");
1412            buf.clear();
1413            melin_transport_core::replication_wire::encode_input_batch(
1414                &[InputSlot::<EvtAdd> {
1415                    connection_id: 0,
1416                    key_hash: 0,
1417                    sequence: 1,
1418                    timestamp_ns: 1,
1419                    event: JournalEvent::App(EvtAdd(1)),
1420                    // `()` without latency-trace, a timestamp with it.
1421                    publish_ts: Default::default(),
1422                    recv_ts: Default::default(),
1423                }],
1424                &mut buf,
1425            )
1426            .expect("encode InputBatch");
1427            s1.write_all(&buf).expect("InputBatch");
1428            wait_for_ack(&mut s1, 1);
1429
1430            // Wrong tail hash at the announced boundary — the replica's
1431            // local chain at sequence 1 cannot match this.
1432            buf.clear();
1433            encode_rotate(1, &[0xEE; 32], &mut buf);
1434            s1.write_all(&buf).expect("Rotate");
1435
1436            // --- Session 2: the SAME process reconnects with its
1437            // position re-derived from the forked on-disk journal.
1438            let mut s2 = accept_within(&listener, 30);
1439            let mut s2r = s2.try_clone().expect("clone");
1440            authenticate_replica(&mut s2r, &authorized_keys).expect("auth 2");
1441            match read_replica_msg(&mut s2) {
1442                ReplicaMessage::Handshake(h) => assert_eq!(
1443                    h.last_sequence, 1,
1444                    "post-divergence handshake must carry the recovered journal position"
1445                ),
1446                other => panic!("expected Handshake, got {other:?}"),
1447            }
1448            // The journal stage's divergence failure latched the health
1449            // mirror before the teardown that led to this reconnect, and
1450            // the replacement pipeline is not built until after the resync
1451            // verdict — so at this deterministic point the replica must be
1452            // reporting unhealthy.
1453            assert!(
1454                !control.pipeline_healthy.load(Ordering::Acquire),
1455                "the divergence must have latched the health mirror false"
1456            );
1457            buf.clear();
1458            encode_hash_mismatch(&mut buf);
1459            s2.write_all(&buf).expect("HashMismatch");
1460            let transfer_shutdown = AtomicBool::new(false);
1461            let mut publish = |b: &[u8]| -> std::io::Result<()> {
1462                s2.write_all(b)?;
1463                s2.flush()
1464            };
1465            // Real sender-side resync: SnapshotBegin → chunks →
1466            // SegmentSeedBegin → seed → StreamStart → catch-up (entry 5).
1467            let end = snapshot_transfer_with::<EvtAdd>(
1468                &primary_journal,
1469                &mut publish,
1470                &transfer_shutdown,
1471                1,
1472            )
1473            .expect("snapshot transfer");
1474            assert_eq!(
1475                end,
1476                melin_transport_core::replication::catchup::CatchUpResult::Ok(5)
1477            );
1478            let mut s2_acks = s2.try_clone().expect("clone");
1479            wait_for_ack(&mut s2_acks, 5);
1480
1481            // Streaming resumed on the rebuilt pipeline — the rebuild
1482            // must have reset the health mirror.
1483            assert!(
1484                control.pipeline_healthy.load(Ordering::Acquire),
1485                "the resync rebuild must reset the health mirror true"
1486            );
1487
1488            // --- Clean shutdown; the receiver must return Ok(None) —
1489            // the divergence never escaped as a process-fatal error.
1490            shutdown.store(true, Ordering::Relaxed);
1491            let result = replica.join().expect("replica thread panicked");
1492            assert_eq!(result, Ok(true), "receiver must exit cleanly via shutdown");
1493
1494            // The forked lineage was archived (never deleted), and the
1495            // live journal is the re-seeded one.
1496            assert!(
1497                dir.path().join("replica.journal.divergent.0").exists(),
1498                "divergent lineage must be archived"
1499            );
1500            assert!(replica_journal.exists(), "re-seeded live journal");
1501        }
1502
1503        /// A resync whose snapshot transfer drops mid-flight must retry
1504        /// as a fresh replica with NO leftover in-memory state. On the
1505        /// in-process divergence repair path `recover_replica_state`
1506        /// leaves `app`/`journal_writer` populated; the resync arm
1507        /// archives the live journal (renaming it aside) before the
1508        /// transfer. If the transfer then fails and those handles are not
1509        /// nulled, the stale writer — its backing file now under the
1510        /// `.divergent.<n>` archive — survives the fresh-replica create
1511        /// gate (`journal_writer.is_none()` false) and is rebuilt into the
1512        /// next pipeline, so the live journal is never recreated and the
1513        /// new session streams onto an archived-away inode. This pins the
1514        /// fix (mirrors the DPDK receiver): after the failed transfer the
1515        /// retry creates a fresh live journal from the StreamStart lineage.
1516        #[test]
1517        fn resync_transfer_failure_rebuilds_clean_not_over_stale_writer() {
1518            let dir = tempfile::tempdir().expect("tempdir");
1519
1520            // Minimal scripted primary — only its lineage identity is used
1521            // (session 3 streams from genesis; the dropped session 2 never
1522            // reaches the snapshot body).
1523            let primary_journal = dir.path().join("primary.journal");
1524            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
1525            for v in 1..=2u64 {
1526                w.append(&JournalEvent::App(EvtAdd(v))).expect("append");
1527            }
1528            drop(w);
1529            let origin = lineage_origin(&primary_journal).expect("lineage");
1530            let (lineage_start, lineage_anchor, lineage_genesis) = (
1531                origin.starting_sequence,
1532                origin.anchor_hash,
1533                origin.genesis_entries,
1534            );
1535
1536            let authorized_keys = replica_auth();
1537
1538            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1539            listener.set_nonblocking(true).expect("nonblocking");
1540            let addr = listener.local_addr().expect("addr");
1541
1542            let replica_journal = dir.path().join("replica.journal");
1543            let replica_snapshot = dir.path().join("replica.snapshot");
1544            let shutdown = Arc::new(AtomicBool::new(false));
1545            let control = crate::replication::ReplicaControlPlane::new();
1546            let cores = crate::layout::PipelineCores::unpinned();
1547            let replica = {
1548                let journal = replica_journal.clone();
1549                let shutdown = Arc::clone(&shutdown);
1550                let control = control.clone();
1551                std::thread::spawn(move || -> Result<bool, String> {
1552                    run_receiver::<App>(
1553                        addr,
1554                        &journal,
1555                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
1556                        &shutdown,
1557                        &control,
1558                        3_600_000,
1559                        replica_snapshot,
1560                        cores,
1561                        melin_journal::StagingMode::ZeroFill,
1562                        Duration::ZERO,
1563                        64,
1564                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
1565                        &(),
1566                    )
1567                    .map(|state| state.is_none())
1568                    .map_err(|e| e.to_string())
1569                })
1570            };
1571
1572            // Helper: stream a single event (seq 1) over an established
1573            // session and wait for its ack.
1574            let stream_one = |s: &mut TcpStream, buf: &mut Vec<u8>| {
1575                buf.clear();
1576                encode_stream_start(0, lineage_start, lineage_anchor, lineage_genesis, 0, 1, buf);
1577                s.write_all(buf).expect("StreamStart");
1578                buf.clear();
1579                melin_transport_core::replication_wire::encode_input_batch(
1580                    &[InputSlot::<EvtAdd> {
1581                        connection_id: 0,
1582                        key_hash: 0,
1583                        sequence: 1,
1584                        timestamp_ns: 1,
1585                        event: JournalEvent::App(EvtAdd(1)),
1586                        publish_ts: Default::default(),
1587                        recv_ts: Default::default(),
1588                    }],
1589                    buf,
1590                )
1591                .expect("encode InputBatch");
1592                s.write_all(buf).expect("InputBatch");
1593            };
1594
1595            // --- Session 1: fresh sync, event 1, poisoned rotation —
1596            // the journal stage detects divergence and the receiver
1597            // repairs in-process (`recover_replica_state` repopulates
1598            // `app`/`journal_writer`).
1599            let mut buf = Vec::new();
1600            let mut s1 = accept_within(&listener, 30);
1601            let mut s1r = s1.try_clone().expect("clone");
1602            authenticate_replica(&mut s1r, &authorized_keys).expect("auth 1");
1603            match read_replica_msg(&mut s1) {
1604                ReplicaMessage::Handshake(h) => assert_eq!(h.last_sequence, 0, "fresh"),
1605                other => panic!("expected Handshake, got {other:?}"),
1606            }
1607            stream_one(&mut s1, &mut buf);
1608            wait_for_ack(&mut s1, 1);
1609            buf.clear();
1610            encode_rotate(1, &[0xEE; 32], &mut buf);
1611            s1.write_all(&buf).expect("Rotate");
1612
1613            // --- Session 2: reconnect at the recovered position (seq 1),
1614            // verdict HashMismatch (archives the live journal aside), then
1615            // DROP the connection before SnapshotBegin — the transfer
1616            // fails and the receiver must retry as a fresh replica.
1617            let mut s2 = accept_within(&listener, 30);
1618            let mut s2r = s2.try_clone().expect("clone");
1619            authenticate_replica(&mut s2r, &authorized_keys).expect("auth 2");
1620            match read_replica_msg(&mut s2) {
1621                ReplicaMessage::Handshake(h) => assert_eq!(
1622                    h.last_sequence, 1,
1623                    "reconnect carries the recovered forked position"
1624                ),
1625                other => panic!("expected Handshake, got {other:?}"),
1626            }
1627            buf.clear();
1628            encode_hash_mismatch(&mut buf);
1629            s2.write_all(&buf).expect("HashMismatch");
1630            s2.flush().expect("flush");
1631            // Both fds reference the same socket (try_clone dups) — drop
1632            // both to send FIN so the replica's SnapshotBegin read hits EOF.
1633            drop(s2);
1634            drop(s2r);
1635
1636            // --- Session 3: the receiver reconnects as a FRESH replica
1637            // (the archived lineage's position is meaningless, so
1638            // `last_sequence` is 0). It must recreate a live journal from
1639            // the StreamStart lineage, NOT rebuild over the stale writer.
1640            let mut s3 = accept_within(&listener, 30);
1641            let mut s3r = s3.try_clone().expect("clone");
1642            authenticate_replica(&mut s3r, &authorized_keys).expect("auth 3");
1643            match read_replica_msg(&mut s3) {
1644                ReplicaMessage::Handshake(h) => assert_eq!(
1645                    h.last_sequence, 0,
1646                    "after a failed transfer the retry handshakes as fresh"
1647                ),
1648                other => panic!("expected Handshake, got {other:?}"),
1649            }
1650            stream_one(&mut s3, &mut buf);
1651            wait_for_ack(&mut s3, 1);
1652
1653            shutdown.store(true, Ordering::Relaxed);
1654            let result = replica.join().expect("replica thread panicked");
1655            assert_eq!(result, Ok(true), "receiver exits cleanly via shutdown");
1656
1657            // The forked lineage stays archived, and a fresh live journal
1658            // was recreated from the lineage. With the bug, the stale
1659            // writer would be reused and this path would not exist (its
1660            // inode stranded under `.divergent.0`).
1661            assert!(
1662                dir.path().join("replica.journal.divergent.0").exists(),
1663                "forked lineage archived, never deleted"
1664            );
1665            assert!(
1666                replica_journal.exists(),
1667                "fresh live journal recreated after the failed transfer"
1668            );
1669        }
1670
1671        /// The in-process repair budget is one per process lifetime: a
1672        /// second mid-stream divergence is systematic (corruption or a
1673        /// serious bug that the first re-seed did not cure) and the
1674        /// receiver must exit hard instead of looping — each repair
1675        /// cycle archives a full journal copy, and a repair loop would
1676        /// fill the disk while masking the underlying fault.
1677        #[test]
1678        fn second_mid_stream_divergence_exits_hard() {
1679            let dir = tempfile::tempdir().expect("tempdir");
1680
1681            let primary_journal = dir.path().join("primary.journal");
1682            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
1683            let mut chain_at_4 = [0u8; 32];
1684            for v in 1..=5u64 {
1685                w.append(&JournalEvent::App(EvtAdd(v))).expect("append");
1686                if v == 2 {
1687                    w.rotate_segment().expect("rotate");
1688                }
1689                if v == 4 {
1690                    chain_at_4 = w.chain_hash().expect("chain");
1691                }
1692            }
1693            drop(w);
1694            melin_transport_core::snapshot::save::<App>(
1695                &App,
1696                WireSeq::new(4),
1697                chain_at_4,
1698                0,
1699                Some(0),
1700                &primary_journal.with_extension("snapshot"),
1701            )
1702            .expect("save snapshot");
1703            let origin = lineage_origin(&primary_journal).expect("lineage");
1704            let (lineage_start, lineage_anchor, lineage_genesis) = (
1705                origin.starting_sequence,
1706                origin.anchor_hash,
1707                origin.genesis_entries,
1708            );
1709
1710            let authorized_keys = replica_auth();
1711
1712            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1713            listener.set_nonblocking(true).expect("nonblocking");
1714            let addr = listener.local_addr().expect("addr");
1715
1716            let replica_journal = dir.path().join("replica.journal");
1717            let replica_snapshot = dir.path().join("replica.snapshot");
1718            let shutdown = Arc::new(AtomicBool::new(false));
1719            let control = crate::replication::ReplicaControlPlane::new();
1720            let cores = crate::layout::PipelineCores::unpinned();
1721            let replica = {
1722                let journal = replica_journal.clone();
1723                let shutdown = Arc::clone(&shutdown);
1724                let control = control.clone();
1725                std::thread::spawn(move || -> Result<bool, String> {
1726                    run_receiver::<App>(
1727                        addr,
1728                        &journal,
1729                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
1730                        &shutdown,
1731                        &control,
1732                        3_600_000,
1733                        replica_snapshot,
1734                        cores,
1735                        melin_journal::StagingMode::ZeroFill,
1736                        Duration::ZERO,
1737                        64,
1738                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
1739                        &(),
1740                    )
1741                    .map(|state| state.is_none())
1742                    .map_err(|e| e.to_string())
1743                })
1744            };
1745
1746            // Session 1: fresh sync, one event, poisoned rotation.
1747            let mut s1 = accept_within(&listener, 30);
1748            let mut s1r = s1.try_clone().expect("clone");
1749            authenticate_replica(&mut s1r, &authorized_keys).expect("auth 1");
1750            match read_replica_msg(&mut s1) {
1751                ReplicaMessage::Handshake(h) => assert_eq!(h.last_sequence, 0),
1752                other => panic!("expected Handshake, got {other:?}"),
1753            }
1754            let mut buf = Vec::new();
1755            encode_stream_start(
1756                0,
1757                lineage_start,
1758                lineage_anchor,
1759                lineage_genesis,
1760                0,
1761                1,
1762                &mut buf,
1763            );
1764            s1.write_all(&buf).expect("StreamStart 1");
1765            buf.clear();
1766            melin_transport_core::replication_wire::encode_input_batch(
1767                &[InputSlot::<EvtAdd> {
1768                    connection_id: 0,
1769                    key_hash: 0,
1770                    sequence: 1,
1771                    timestamp_ns: 1,
1772                    event: JournalEvent::App(EvtAdd(1)),
1773                    publish_ts: Default::default(),
1774                    recv_ts: Default::default(),
1775                }],
1776                &mut buf,
1777            )
1778            .expect("encode InputBatch");
1779            s1.write_all(&buf).expect("InputBatch");
1780            wait_for_ack(&mut s1, 1);
1781            buf.clear();
1782            encode_rotate(1, &[0xEE; 32], &mut buf);
1783            s1.write_all(&buf).expect("Rotate 1");
1784
1785            // Session 2: in-process repair (the one allowed), then a
1786            // SECOND poisoned rotation after streaming resumes.
1787            let mut s2 = accept_within(&listener, 30);
1788            let mut s2r = s2.try_clone().expect("clone");
1789            authenticate_replica(&mut s2r, &authorized_keys).expect("auth 2");
1790            match read_replica_msg(&mut s2) {
1791                ReplicaMessage::Handshake(h) => assert_eq!(h.last_sequence, 1),
1792                other => panic!("expected Handshake, got {other:?}"),
1793            }
1794            buf.clear();
1795            encode_hash_mismatch(&mut buf);
1796            s2.write_all(&buf).expect("HashMismatch");
1797            let transfer_shutdown = AtomicBool::new(false);
1798            {
1799                let mut publish = |b: &[u8]| -> std::io::Result<()> {
1800                    s2.write_all(b)?;
1801                    s2.flush()
1802                };
1803                snapshot_transfer_with::<EvtAdd>(
1804                    &primary_journal,
1805                    &mut publish,
1806                    &transfer_shutdown,
1807                    1,
1808                )
1809                .expect("snapshot transfer");
1810            }
1811            buf.clear();
1812            melin_transport_core::replication_wire::encode_input_batch(
1813                &[InputSlot::<EvtAdd> {
1814                    connection_id: 0,
1815                    key_hash: 0,
1816                    sequence: 6,
1817                    timestamp_ns: 6,
1818                    event: JournalEvent::App(EvtAdd(6)),
1819                    publish_ts: Default::default(),
1820                    recv_ts: Default::default(),
1821                }],
1822                &mut buf,
1823            )
1824            .expect("encode InputBatch");
1825            s2.write_all(&buf).expect("InputBatch 6");
1826            let mut s2_acks = s2.try_clone().expect("clone");
1827            wait_for_ack(&mut s2_acks, 6);
1828            buf.clear();
1829            encode_rotate(6, &[0xEE; 32], &mut buf);
1830            s2.write_all(&buf).expect("Rotate 2");
1831
1832            // No third connection: the receiver must give up and exit
1833            // with an error naming the recurrence.
1834            let result = replica.join().expect("replica thread panicked");
1835            let err = result.expect_err("second divergence must exit hard");
1836            assert!(
1837                err.contains("divergence recurred"),
1838                "error must name the recurrence: {err}"
1839            );
1840        }
1841
1842        // -------------------------------------------------------------
1843        // Promotion teardown helper (`take_pipeline_for_promotion`) — the
1844        // no-live-pipeline decision branches. With no pipeline to tear
1845        // down it hands back the warm state held in the receiver's locals
1846        // (a clean promotion) or errors when none is present. Colocated
1847        // here for the `App` + `BufferedWriter` fixtures above; the
1848        // Clean-pipeline branch needs a live pipeline (failover IT).
1849        // -------------------------------------------------------------
1850        type PromoteHandles =
1851            crate::replication::ReplicaPipelineHandles<App, BufferedWriter<EvtAdd>>;
1852
1853        #[test]
1854        fn promotion_hands_back_local_state_when_present() {
1855            let dir = tempfile::tempdir().unwrap();
1856            let mut pipeline: Option<PromoteHandles> = None;
1857            let mut app = Some(App);
1858            let mut journal_writer =
1859                Some(BufferedWriter::<EvtAdd>::create(&dir.path().join("p.journal")).unwrap());
1860
1861            let result = crate::replication::take_pipeline_for_promotion(
1862                &mut pipeline,
1863                &mut app,
1864                &mut journal_writer,
1865            );
1866
1867            assert!(matches!(result, Ok(Some(_))), "warm state must be promoted");
1868            assert!(
1869                app.is_none() && journal_writer.is_none(),
1870                "state must be moved into the result"
1871            );
1872        }
1873
1874        #[test]
1875        fn promotion_errs_when_no_local_state() {
1876            let mut pipeline: Option<PromoteHandles> = None;
1877            let mut app: Option<App> = None;
1878            let mut journal_writer: Option<BufferedWriter<EvtAdd>> = None;
1879
1880            let result = crate::replication::take_pipeline_for_promotion(
1881                &mut pipeline,
1882                &mut app,
1883                &mut journal_writer,
1884            );
1885
1886            // Not `expect_err` — the Ok payload (App) isn't `Debug`.
1887            let err = match result {
1888                Err(e) => e,
1889                Ok(_) => panic!("a promote with nothing to promote must error"),
1890            };
1891            assert!(
1892                err.to_string().contains("no local state available"),
1893                "{err}"
1894            );
1895        }
1896
1897        #[test]
1898        fn promotion_errs_on_partial_state() {
1899            // Application present, writer missing — not a usable hand-off.
1900            let mut pipeline: Option<PromoteHandles> = None;
1901            let mut app = Some(App);
1902            let mut journal_writer: Option<BufferedWriter<EvtAdd>> = None;
1903
1904            let result = crate::replication::take_pipeline_for_promotion(
1905                &mut pipeline,
1906                &mut app,
1907                &mut journal_writer,
1908            );
1909
1910            assert!(result.is_err(), "partial state is not a valid promotion");
1911        }
1912    }
1913
1914    // -----------------------------------------------------------------
1915    // Shutdown during reconnect backoff — end to end against a scripted
1916    // primary. Needs real persistence (the pipeline journals the one
1917    // streamed event); no chain verdicts involved, so no hash-chain.
1918    // -----------------------------------------------------------------
1919    #[cfg(not(feature = "no-persist"))]
1920    mod backoff_shutdown {
1921        use super::super::super::auth::authenticate_replica;
1922        use super::scripted::*;
1923        use super::*;
1924        use melin_journal::{BufferedWriter, JournalEvent, JournalWrite};
1925        use melin_transport_core::replication::catchup::lineage_origin;
1926        use melin_transport_core::replication::protocol::encode_stream_start;
1927        use std::sync::Arc;
1928        use std::sync::atomic::AtomicBool;
1929
1930        /// Names of every live thread in this process, via
1931        /// `/proc/self/task` (Linux-only, like the runtime itself).
1932        fn live_thread_names() -> Vec<String> {
1933            std::fs::read_dir("/proc/self/task")
1934                .expect("read /proc/self/task")
1935                .filter_map(|entry| {
1936                    // A thread may exit between readdir and the comm
1937                    // read — skipping it is the correct outcome.
1938                    let entry = entry.ok()?;
1939                    let name = std::fs::read_to_string(entry.path().join("comm")).ok()?;
1940                    Some(name.trim().to_string())
1941                })
1942                .collect()
1943        }
1944
1945        /// Set in the child process the test below runs its body in.
1946        const ISOLATED: &str = "MELIN_TEST_BACKOFF_SHUTDOWN_ISOLATED";
1947
1948        /// Shutdown while the receiver waits out a reconnect backoff must
1949        /// tear the live pipeline down — join every stage thread — rather
1950        /// than abandon it. Pins the fix for the connect-failure arm that
1951        /// returned without teardown (stages left detached mid-run, the
1952        /// journal writer never closed).
1953        ///
1954        /// The check reads every thread in the process by name, so it holds
1955        /// only where no other test shares the process: under plain `cargo
1956        /// test`, other tests' pipelines run stages of the same names, and
1957        /// the check failed on them. So the body runs in a child process of
1958        /// its own, this test binary filtered to this one test (nextest
1959        /// already gives each test a process; there it costs one re-exec).
1960        #[test]
1961        fn shutdown_during_reconnect_backoff_joins_pipeline_threads() {
1962            if std::env::var_os(ISOLATED).is_some() {
1963                return backoff_shutdown_body();
1964            }
1965            // The test's path as libtest names it: the module path without
1966            // the crate's name.
1967            let module = module_path!();
1968            let path = module.split_once("::").map_or(module, |(_, rest)| rest);
1969            let test = format!("{path}::shutdown_during_reconnect_backoff_joins_pipeline_threads");
1970            let output = std::process::Command::new(std::env::current_exe().expect("current_exe"))
1971                .args(["--exact", &test, "--test-threads=1", "--nocapture"])
1972                .env(ISOLATED, "1")
1973                .output()
1974                .expect("run the test in a process of its own");
1975            let stdout = String::from_utf8_lossy(&output.stdout);
1976            let stderr = String::from_utf8_lossy(&output.stderr);
1977            // A filter that matched nothing would also exit 0: require the
1978            // one test to have run and passed.
1979            assert!(
1980                output.status.success() && stdout.contains("1 passed"),
1981                "the isolated run failed ({}):\n{stdout}\n{stderr}",
1982                output.status
1983            );
1984        }
1985
1986        fn backoff_shutdown_body() {
1987            let dir = tempfile::tempdir().expect("tempdir");
1988
1989            // Minimal primary journal — only its lineage identity is
1990            // needed for the StreamStart the fresh replica accepts.
1991            let primary_journal = dir.path().join("primary.journal");
1992            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
1993            w.append(&JournalEvent::App(EvtAdd(1))).expect("append");
1994            drop(w);
1995            let origin = lineage_origin(&primary_journal).expect("lineage");
1996            let (lineage_start, lineage_anchor, lineage_genesis) = (
1997                origin.starting_sequence,
1998                origin.anchor_hash,
1999                origin.genesis_entries,
2000            );
2001
2002            let authorized_keys = replica_auth();
2003            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2004            listener.set_nonblocking(true).expect("nonblocking");
2005            let addr = listener.local_addr().expect("addr");
2006
2007            let shutdown = Arc::new(AtomicBool::new(false));
2008            let control = crate::replication::ReplicaControlPlane::new();
2009            let replica = {
2010                let journal = dir.path().join("replica.journal");
2011                let snapshot = dir.path().join("replica.snapshot");
2012                let shutdown = Arc::clone(&shutdown);
2013                let control = control.clone();
2014                std::thread::spawn(move || -> Result<bool, String> {
2015                    run_receiver::<App>(
2016                        addr,
2017                        &journal,
2018                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
2019                        &shutdown,
2020                        &control,
2021                        3_600_000,
2022                        snapshot,
2023                        crate::layout::PipelineCores::unpinned(),
2024                        melin_journal::StagingMode::ZeroFill,
2025                        Duration::ZERO,
2026                        64,
2027                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
2028                        &(),
2029                    )
2030                    // ReceiverResult's error is !Send — stringify for join().
2031                    .map(|state| state.is_none())
2032                    .map_err(|e| e.to_string())
2033                })
2034            };
2035
2036            // Session 1: bring the pipeline up for real — stream one
2037            // event and wait for its ack (acked ⇒ journaled ⇒ the stage
2038            // threads exist).
2039            let mut s1 = accept_within(&listener, 30);
2040            let mut s1r = s1.try_clone().expect("clone");
2041            authenticate_replica(&mut s1r, &authorized_keys).expect("auth");
2042            match read_replica_msg(&mut s1) {
2043                ReplicaMessage::Handshake(h) => {
2044                    assert_eq!(h.last_sequence, 0, "fresh replica handshake")
2045                }
2046                other => panic!("expected Handshake, got {other:?}"),
2047            }
2048            let mut buf = Vec::new();
2049            encode_stream_start(
2050                0,
2051                lineage_start,
2052                lineage_anchor,
2053                lineage_genesis,
2054                0,
2055                1,
2056                &mut buf,
2057            );
2058            s1.write_all(&buf).expect("StreamStart");
2059            buf.clear();
2060            melin_transport_core::replication_wire::encode_input_batch(
2061                &[InputSlot::<EvtAdd> {
2062                    connection_id: 0,
2063                    key_hash: 0,
2064                    sequence: 1,
2065                    timestamp_ns: 1,
2066                    event: JournalEvent::App(EvtAdd(1)),
2067                    publish_ts: Default::default(),
2068                    recv_ts: Default::default(),
2069                }],
2070                &mut buf,
2071            )
2072            .expect("encode InputBatch");
2073            s1.write_all(&buf).expect("InputBatch");
2074            wait_for_ack(&mut s1, 1);
2075
2076            // Kill the primary completely — both socket halves and the
2077            // listener — so every reconnect attempt is refused and the
2078            // receiver settles into the connect-failure backoff.
2079            drop(s1);
2080            drop(s1r);
2081            drop(listener);
2082            // Past the 1s post-disconnect backoff, inside the 2s
2083            // connect-failure backoff sleep. (Any other point in the
2084            // reconnect loop must satisfy the assertions too — this
2085            // timing just targets the arm that historically leaked.)
2086            std::thread::sleep(Duration::from_millis(1500));
2087
2088            shutdown.store(true, Ordering::Relaxed);
2089            let deadline = Instant::now() + Duration::from_secs(10);
2090            while !replica.is_finished() {
2091                assert!(
2092                    Instant::now() < deadline,
2093                    "receiver did not exit after shutdown"
2094                );
2095                std::thread::sleep(Duration::from_millis(20));
2096            }
2097            let result = replica.join().expect("replica thread panicked");
2098            assert_eq!(result, Ok(true), "receiver must exit cleanly via shutdown");
2099
2100            // The stage threads are named at spawn, and a leaked stage
2101            // spins forever — absence within the deadline proves the
2102            // teardown joined them. This process runs no other test (see
2103            // above), so every stage thread here is the receiver's.
2104            let deadline = Instant::now() + Duration::from_secs(10);
2105            loop {
2106                let names = live_thread_names();
2107                if !names
2108                    .iter()
2109                    .any(|n| n == "journal-seq" || n == "matching" || n == "drain")
2110                {
2111                    break;
2112                }
2113                assert!(
2114                    Instant::now() < deadline,
2115                    "pipeline threads still alive after shutdown: {names:?}"
2116                );
2117                std::thread::sleep(Duration::from_millis(50));
2118            }
2119        }
2120    }
2121
2122    // -----------------------------------------------------------------
2123    // The resume point a reconnecting replica hands the primary, end to
2124    // end against a scripted primary. Needs real persistence: the claim
2125    // is the journal's durable position.
2126    // -----------------------------------------------------------------
2127    #[cfg(not(feature = "no-persist"))]
2128    mod reconnect_resume {
2129        use super::super::super::auth::authenticate_replica;
2130        use super::scripted::*;
2131        use super::*;
2132        use melin_journal::{BufferedWriter, JournalEvent, JournalWrite};
2133        use melin_transport_core::replication::catchup::lineage_origin;
2134        use melin_transport_core::replication::protocol::encode_stream_start;
2135        use std::sync::Arc;
2136        use std::sync::atomic::AtomicBool;
2137
2138        /// A primary journal holding one entry: its path and lineage
2139        /// identity, for the `StreamStart` a replica accepts.
2140        fn primary_journal(
2141            dir: &std::path::Path,
2142        ) -> (std::path::PathBuf, melin_journal::FileHeaderInfo) {
2143            let path = dir.join("primary.journal");
2144            let mut w = BufferedWriter::<EvtAdd>::create(&path).expect("create");
2145            w.append(&JournalEvent::App(EvtAdd(1))).expect("append");
2146            drop(w);
2147            let lineage = lineage_origin(&path).expect("lineage");
2148            (path, lineage)
2149        }
2150
2151        /// Run the receiver against `addr` on `dir/replica.journal`,
2152        /// under `group_commit_delay`, until `shutdown`.
2153        fn spawn_replica(
2154            dir: &std::path::Path,
2155            addr: std::net::SocketAddr,
2156            group_commit_delay: Duration,
2157            shutdown: &Arc<AtomicBool>,
2158        ) -> std::thread::JoinHandle<Result<bool, String>> {
2159            let journal = dir.join("replica.journal");
2160            let snapshot = dir.join("replica.snapshot");
2161            let shutdown = Arc::clone(shutdown);
2162            std::thread::spawn(move || -> Result<bool, String> {
2163                run_receiver::<App>(
2164                    addr,
2165                    &journal,
2166                    &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
2167                    &shutdown,
2168                    &crate::replication::ReplicaControlPlane::new(),
2169                    3_600_000,
2170                    snapshot,
2171                    crate::layout::PipelineCores::unpinned(),
2172                    melin_journal::StagingMode::ZeroFill,
2173                    group_commit_delay,
2174                    64,
2175                    Arc::new(melin_transport_core::fence::FenceState::new(0)),
2176                    &(),
2177                )
2178                // ReceiverResult's error is !Send: stringify for join().
2179                .map(|state| state.is_none())
2180                .map_err(|e| e.to_string())
2181            })
2182        }
2183
2184        /// Accept the replica's next connection, authenticate it and
2185        /// return its handshake with the open stream.
2186        fn next_handshake(
2187            listener: &std::net::TcpListener,
2188        ) -> (TcpStream, TcpStream, crate::replication::Handshake) {
2189            let mut s = accept_within(listener, 30);
2190            let mut sr = s.try_clone().expect("clone");
2191            authenticate_replica(&mut sr, &replica_auth()).expect("auth");
2192            match read_replica_msg(&mut s) {
2193                ReplicaMessage::Handshake(h) => (s, sr, h),
2194                other => panic!("expected Handshake, got {other:?}"),
2195            }
2196        }
2197
2198        /// Answer a handshake: stream from `start` on the given lineage.
2199        fn stream_start(
2200            stream: &mut TcpStream,
2201            start: u64,
2202            lineage: melin_journal::FileHeaderInfo,
2203        ) {
2204            let mut buf = Vec::new();
2205            encode_stream_start(
2206                start,
2207                lineage.starting_sequence,
2208                lineage.anchor_hash,
2209                lineage.genesis_entries,
2210                0,
2211                1,
2212                &mut buf,
2213            );
2214            stream.write_all(&buf).expect("StreamStart");
2215        }
2216
2217        /// One streamed entry at `sequence`, as the primary frames it.
2218        fn entry(sequence: u64) -> InputSlot<EvtAdd> {
2219            InputSlot::<EvtAdd> {
2220                connection_id: 0,
2221                key_hash: 0,
2222                sequence,
2223                timestamp_ns: sequence,
2224                event: JournalEvent::App(EvtAdd(sequence)),
2225                publish_ts: Default::default(),
2226                recv_ts: Default::default(),
2227            }
2228        }
2229
2230        fn send_entries(stream: &mut TcpStream, sequences: &[u64]) {
2231            let slots: Vec<_> = sequences.iter().map(|&s| entry(s)).collect();
2232            let mut buf = Vec::new();
2233            melin_transport_core::replication_wire::encode_input_batch(&slots, &mut buf)
2234                .expect("encode InputBatch");
2235            stream.write_all(&buf).expect("InputBatch");
2236        }
2237
2238        fn stop(replica: std::thread::JoinHandle<Result<bool, String>>, shutdown: &AtomicBool) {
2239            shutdown.store(true, Ordering::Relaxed);
2240            let deadline = Instant::now() + Duration::from_secs(10);
2241            while !replica.is_finished() {
2242                assert!(
2243                    Instant::now() < deadline,
2244                    "receiver did not exit after shutdown"
2245                );
2246                std::thread::sleep(Duration::from_millis(20));
2247            }
2248            let result = replica.join().expect("replica thread panicked");
2249            assert_eq!(result, Ok(true), "receiver must exit cleanly via shutdown");
2250        }
2251
2252        /// A session ends on a stream gap with two received entries still
2253        /// unwritten: the group-commit delay holds them past the reconnect
2254        /// backoff. The next handshake claims them, once written, rather
2255        /// than the older durable position, from which the primary would
2256        /// resend them and the replica would take them a second time.
2257        #[test]
2258        fn a_reconnect_claims_entries_received_before_the_drop() {
2259            let dir = tempfile::tempdir().expect("tempdir");
2260            let (_, lineage) = primary_journal(dir.path());
2261            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2262            listener.set_nonblocking(true).expect("nonblocking");
2263            let shutdown = Arc::new(AtomicBool::new(false));
2264            // Longer than the 1 s backoff after a stream gap, with room
2265            // to spare: without the wait, the reconnect lands while
2266            // entries 2 and 3 are still unwritten.
2267            let delay = Duration::from_millis(2_500);
2268            let replica = spawn_replica(
2269                dir.path(),
2270                listener.local_addr().expect("addr"),
2271                delay,
2272                &shutdown,
2273            );
2274
2275            let (mut s1, _s1r, h1) = next_handshake(&listener);
2276            assert_eq!(h1.last_sequence, 0, "fresh replica handshake");
2277            stream_start(&mut s1, 0, lineage);
2278            // Entry 1 made durable first, so the durable position the
2279            // handshake could wrongly claim is 1, not the fresh zero.
2280            send_entries(&mut s1, &[1]);
2281            wait_for_ack(&mut s1, 1);
2282            // Entries 2 and 3 are taken, then 5 breaks contiguity: the
2283            // session ends without waiting for the ones it took.
2284            send_entries(&mut s1, &[2, 3, 5]);
2285
2286            let (mut s2, _s2r, h2) = next_handshake(&listener);
2287            assert_eq!(
2288                h2.last_sequence, 3,
2289                "the reconnect must claim the entries received before the drop"
2290            );
2291            // Streaming again, so shutdown finds the receiver in a session
2292            // rather than waiting on the primary's answer.
2293            stream_start(&mut s2, h2.last_sequence, lineage);
2294            stop(replica, &shutdown);
2295        }
2296
2297        /// A failed snapshot transfer backs off before redialing, and the
2298        /// socket must be closed before that sleep, not after it: held
2299        /// through the backoff, it ties up a primary slot on a dead session.
2300        #[test]
2301        fn a_failed_resync_transfer_closes_the_socket_before_backing_off() {
2302            use melin_transport_core::replication::protocol::encode_need_snapshot;
2303
2304            let dir = tempfile::tempdir().expect("tempdir");
2305            let (_, lineage) = primary_journal(dir.path());
2306            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2307            listener.set_nonblocking(true).expect("nonblocking");
2308            let shutdown = Arc::new(AtomicBool::new(false));
2309            let replica = spawn_replica(
2310                dir.path(),
2311                listener.local_addr().expect("addr"),
2312                Duration::ZERO,
2313                &shutdown,
2314            );
2315
2316            let (mut s1, _s1r, _) = next_handshake(&listener);
2317            // NeedSnapshot, then anything but the SnapshotBegin the
2318            // transfer expects: the transfer fails and the receiver retries.
2319            let mut buf = Vec::new();
2320            encode_need_snapshot(&mut buf);
2321            encode_need_snapshot(&mut buf);
2322            s1.write_all(&buf).expect("NeedSnapshot x2");
2323
2324            // The first reconnect backoff is 1 s; the close must land well
2325            // inside it.
2326            s1.set_read_timeout(Some(Duration::from_millis(700)))
2327                .expect("read timeout");
2328            let mut byte = [0u8; 1];
2329            match s1.read(&mut byte) {
2330                Ok(0) => {}
2331                Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => {}
2332                other => panic!("socket still open during the backoff: {other:?}"),
2333            }
2334
2335            // The retry dials again, as a fresh replica.
2336            let (mut s2, _s2r, h2) = next_handshake(&listener);
2337            assert_eq!(h2.last_sequence, 0, "retry handshakes as fresh");
2338            stream_start(&mut s2, 0, lineage);
2339            stop(replica, &shutdown);
2340        }
2341
2342        /// The journal stage dies after a session has ended, while the
2343        /// reconnect waits for it to cover the ring: entries 2 and 3 are
2344        /// held by the group-commit delay past the backoff that follows a
2345        /// stream gap, and the chain check announced between them carries
2346        /// a hash the replica's chain cannot match, so the stage fails
2347        /// before any of them is handed to the disk. The loop must take
2348        /// the fatal path there (here, the in-process divergence resync)
2349        /// before dialing, so the next session runs on the pipeline
2350        /// rebuilt from disk and journals what it is sent. Dialing with
2351        /// the dead pipeline instead would claim the same position (entry
2352        /// 1 was durable before the failure) but hand the session a
2353        /// journal stage that can take nothing: it would exit at once and
2354        /// never acknowledge entry 2.
2355        #[cfg(feature = "hash-chain")]
2356        #[test]
2357        fn a_journal_that_dies_while_disconnected_is_torn_down_before_dialing() {
2358            use melin_transport_core::replication::protocol::encode_chain_check;
2359
2360            let dir = tempfile::tempdir().expect("tempdir");
2361            let (_, lineage) = primary_journal(dir.path());
2362            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2363            listener.set_nonblocking(true).expect("nonblocking");
2364            let shutdown = Arc::new(AtomicBool::new(false));
2365            // Longer than the 1 s backoff after a stream gap, with room
2366            // to spare, so the failure lands inside the reconnect's wait.
2367            let delay = Duration::from_millis(2_500);
2368            let replica = spawn_replica(
2369                dir.path(),
2370                listener.local_addr().expect("addr"),
2371                delay,
2372                &shutdown,
2373            );
2374
2375            let (mut s1, _s1r, h1) = next_handshake(&listener);
2376            assert_eq!(h1.last_sequence, 0, "fresh replica handshake");
2377            stream_start(&mut s1, 0, lineage);
2378            send_entries(&mut s1, &[1]);
2379            wait_for_ack(&mut s1, 1);
2380            // Entry 2, a chain check at 2 no replica chain can match,
2381            // entry 3, then 5: the gap ends the session at once, with 2
2382            // and 3 still unwritten.
2383            send_entries(&mut s1, &[2]);
2384            let mut buf = Vec::new();
2385            encode_chain_check(2, &[0xEE; 32], &mut buf);
2386            s1.write_all(&buf).expect("ChainCheck");
2387            send_entries(&mut s1, &[3]);
2388            send_entries(&mut s1, &[5]);
2389
2390            let (mut s2, _s2r, h2) = next_handshake(&listener);
2391            assert_eq!(
2392                h2.last_sequence, 1,
2393                "the first dial after the journal died claims what is on disk"
2394            );
2395            // The discriminating step: only a rebuilt pipeline journals
2396            // and acknowledges entry 2 (after the group-commit delay).
2397            // The dead one would end the session unacknowledged, failing
2398            // the read below on EOF or its timeout.
2399            stream_start(&mut s2, h2.last_sequence, lineage);
2400            send_entries(&mut s2, &[2]);
2401            wait_for_ack(&mut s2, 2);
2402            stop(replica, &shutdown);
2403        }
2404
2405        /// A replica built over a journal that already holds an entry
2406        /// reconnects after a session that journaled nothing (a quiet
2407        /// primary). Its handshake claims that journal, exactly as the
2408        /// first one did, not an empty one.
2409        #[test]
2410        fn a_reconnect_after_a_quiet_session_claims_the_recovered_journal() {
2411            let dir = tempfile::tempdir().expect("tempdir");
2412            let (primary, lineage) = primary_journal(dir.path());
2413            // The replica holds what the primary holds.
2414            std::fs::copy(&primary, dir.path().join("replica.journal")).expect("copy");
2415            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2416            listener.set_nonblocking(true).expect("nonblocking");
2417            let shutdown = Arc::new(AtomicBool::new(false));
2418            let replica = spawn_replica(
2419                dir.path(),
2420                listener.local_addr().expect("addr"),
2421                Duration::ZERO,
2422                &shutdown,
2423            );
2424
2425            let (mut s1, s1r, h1) = next_handshake(&listener);
2426            assert_eq!(h1.last_sequence, 1, "recovered replica handshake");
2427            stream_start(&mut s1, 1, lineage);
2428            // Nothing streamed: the primary goes away.
2429            drop(s1);
2430            drop(s1r);
2431
2432            let (mut s2, _s2r, h2) = next_handshake(&listener);
2433            assert_eq!(
2434                (h2.last_sequence, h2.chain_hash),
2435                (h1.last_sequence, h1.chain_hash),
2436                "the reconnect must claim the journal the pipeline was built over"
2437            );
2438            // Streaming again, so shutdown finds the receiver in a session.
2439            stream_start(&mut s2, h2.last_sequence, lineage);
2440            stop(replica, &shutdown);
2441        }
2442
2443        /// The audit's scenario end to end: an entry damaged on the way
2444        /// still decodes — as a different event — and its CRC is the only
2445        /// thing that can tell. The replica must refuse it before applying,
2446        /// journaling or acknowledging it, drop the session, and reconnect
2447        /// claiming only what it verified; the intact resend is what lands
2448        /// in its journal.
2449        #[test]
2450        fn a_damaged_entry_is_refused_and_fetched_again() {
2451            let dir = tempfile::tempdir().expect("tempdir");
2452            let (_, lineage) = primary_journal(dir.path());
2453            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2454            listener.set_nonblocking(true).expect("nonblocking");
2455            let shutdown = Arc::new(AtomicBool::new(false));
2456            let replica = spawn_replica(
2457                dir.path(),
2458                listener.local_addr().expect("addr"),
2459                Duration::ZERO,
2460                &shutdown,
2461            );
2462
2463            let (mut s1, _s1r, h1) = next_handshake(&listener);
2464            assert_eq!(h1.last_sequence, 0, "fresh replica handshake");
2465            stream_start(&mut s1, 0, lineage);
2466            send_entries(&mut s1, &[1]);
2467            wait_for_ack(&mut s1, 1);
2468
2469            // Entry 2 with one payload bit flipped: `EvtAdd(2)` arrives as
2470            // a perfectly decodable `EvtAdd(3)`.
2471            let mut buf = Vec::new();
2472            melin_transport_core::replication_wire::encode_input_batch(&[entry(2)], &mut buf)
2473                .expect("encode InputBatch");
2474            let payload_at = buf.len() - melin_journal::codec::CRC_SIZE - 8;
2475            buf[payload_at] ^= 0x01;
2476            s1.write_all(&buf).expect("damaged InputBatch");
2477
2478            // Nothing acknowledges entry 2 — not in memory, not on disk —
2479            // before the replica drops the session.
2480            loop {
2481                let mut len_buf = [0u8; 4];
2482                if std::io::Read::read_exact(&mut s1, &mut len_buf).is_err() {
2483                    break;
2484                }
2485                let mut payload = vec![0u8; u32::from_le_bytes(len_buf) as usize];
2486                if std::io::Read::read_exact(&mut s1, &mut payload).is_err() {
2487                    break;
2488                }
2489                if let Ok(ReplicaMessage::Ack(a)) = decode_replica_message(&payload) {
2490                    assert!(
2491                        a.in_memory_sequence < 2 && a.acked_sequence < 2,
2492                        "the damaged entry was acknowledged: {a:?}"
2493                    );
2494                }
2495            }
2496
2497            let (mut s2, _s2r, h2) = next_handshake(&listener);
2498            assert_eq!(
2499                h2.last_sequence, 1,
2500                "the reconnect must claim only the entries that passed the check"
2501            );
2502            stream_start(&mut s2, 1, lineage);
2503            send_entries(&mut s2, &[2]);
2504            wait_for_ack(&mut s2, 2);
2505            stop(replica, &shutdown);
2506
2507            let mut reader =
2508                melin_journal::JournalReader::<EvtAdd>::open(&dir.path().join("replica.journal"))
2509                    .expect("open replica journal");
2510            let mut journaled = Vec::new();
2511            while let Some(e) = reader.next_entry().expect("read entry") {
2512                if let JournalEvent::App(EvtAdd(v)) = e.event {
2513                    journaled.push(v);
2514                }
2515            }
2516            assert_eq!(journaled, vec![1, 2], "only the intact entry is journaled");
2517        }
2518
2519        /// `lineage`, as a primary whose genesis took `genesis_entries`
2520        /// entries would advertise it.
2521        fn with_genesis(
2522            lineage: melin_journal::FileHeaderInfo,
2523            genesis_entries: Option<u64>,
2524        ) -> melin_journal::FileHeaderInfo {
2525            melin_journal::FileHeaderInfo {
2526                genesis_entries,
2527                ..lineage
2528            }
2529        }
2530
2531        fn replica_genesis(dir: &std::path::Path) -> Option<u64> {
2532            melin_journal::segment::read_header_info(&dir.join("replica.journal"))
2533                .expect("replica journal header")
2534                .genesis_entries
2535        }
2536
2537        /// A fresh replica writes the genesis length the primary
2538        /// advertises into the journal it creates, and keeps it: a
2539        /// restarted replica recovers it from its own header and follows
2540        /// a primary advertising the same.
2541        #[test]
2542        fn a_replica_learns_the_genesis_length_and_keeps_it_across_restarts() {
2543            let dir = tempfile::tempdir().expect("tempdir");
2544            let (_, base) = primary_journal(dir.path());
2545            let lineage = with_genesis(base, Some(3));
2546            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2547            listener.set_nonblocking(true).expect("nonblocking");
2548            let addr = listener.local_addr().expect("addr");
2549
2550            let shutdown = Arc::new(AtomicBool::new(false));
2551            let replica = spawn_replica(dir.path(), addr, Duration::ZERO, &shutdown);
2552            let (mut s1, _s1r, h1) = next_handshake(&listener);
2553            assert_eq!(h1.last_sequence, 0, "fresh replica handshake");
2554            stream_start(&mut s1, 0, lineage);
2555            send_entries(&mut s1, &[1, 2]);
2556            wait_for_ack(&mut s1, 2);
2557            assert_eq!(replica_genesis(dir.path()), Some(3), "learned at creation");
2558            stop(replica, &shutdown);
2559
2560            let shutdown = Arc::new(AtomicBool::new(false));
2561            let replica = spawn_replica(dir.path(), addr, Duration::ZERO, &shutdown);
2562            let (mut s2, _s2r, h2) = next_handshake(&listener);
2563            assert_eq!(h2.last_sequence, 2, "restarted replica handshake");
2564            stream_start(&mut s2, 2, lineage);
2565            send_entries(&mut s2, &[3]);
2566            wait_for_ack(&mut s2, 3);
2567            stop(replica, &shutdown);
2568            assert_eq!(
2569                replica_genesis(dir.path()),
2570                Some(3),
2571                "kept across a restart"
2572            );
2573        }
2574
2575        /// A replica holding an empty journal from before the genesis
2576        /// length was recorded — copied from a primary that stopped
2577        /// before journaling anything — learns the length when the
2578        /// upgraded primary, having journaled its genesis under the same
2579        /// anchor, advertises it. Left unknown, its promotion check would
2580        /// be skipped for the very partial genesis it guards.
2581        #[test]
2582        fn an_empty_old_format_replica_journal_learns_the_genesis_length() {
2583            let dir = tempfile::tempdir().expect("tempdir");
2584            let (_, base) = primary_journal(dir.path());
2585            assert_eq!(base.starting_sequence, 1);
2586            drop(
2587                melin_journal::BufferedWriter::<EvtAdd>::create_continuing(
2588                    &dir.path().join("replica.journal"),
2589                    1,
2590                    base.anchor_hash,
2591                    None,
2592                )
2593                .expect("old-format empty replica journal"),
2594            );
2595            assert_eq!(replica_genesis(dir.path()), None);
2596            let lineage = with_genesis(base, Some(3));
2597            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2598            listener.set_nonblocking(true).expect("nonblocking");
2599            let shutdown = Arc::new(AtomicBool::new(false));
2600            let replica = spawn_replica(
2601                dir.path(),
2602                listener.local_addr().expect("addr"),
2603                Duration::ZERO,
2604                &shutdown,
2605            );
2606
2607            let (mut s1, _s1r, h1) = next_handshake(&listener);
2608            assert_eq!(h1.last_sequence, 0, "empty replica handshake");
2609            stream_start(&mut s1, 0, lineage);
2610            send_entries(&mut s1, &[1, 2]);
2611            wait_for_ack(&mut s1, 2);
2612            stop(replica, &shutdown);
2613            assert_eq!(replica_genesis(dir.path()), Some(3));
2614        }
2615
2616        /// A replica whose journal records one genesis length refuses a
2617        /// primary advertising another — a lineage error, never an
2618        /// overwrite — and exits with the reason.
2619        #[test]
2620        fn a_primary_advertising_another_genesis_length_is_refused() {
2621            let dir = tempfile::tempdir().expect("tempdir");
2622            let (primary, lineage) = primary_journal(dir.path());
2623            assert_eq!(lineage.genesis_entries, Some(0));
2624            // The replica holds what the primary holds, genesis length
2625            // included.
2626            std::fs::copy(&primary, dir.path().join("replica.journal")).expect("copy");
2627            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2628            listener.set_nonblocking(true).expect("nonblocking");
2629            let shutdown = Arc::new(AtomicBool::new(false));
2630            let replica = spawn_replica(
2631                dir.path(),
2632                listener.local_addr().expect("addr"),
2633                Duration::ZERO,
2634                &shutdown,
2635            );
2636
2637            let (mut s1, _s1r, h1) = next_handshake(&listener);
2638            assert_eq!(h1.last_sequence, 1);
2639            stream_start(&mut s1, 1, with_genesis(lineage, Some(4)));
2640            let result = replica.join().expect("replica thread panicked");
2641            let err = result.expect_err("the replica must refuse the lineage");
2642            assert!(err.contains("refusing to follow the primary"), "{err}");
2643            assert_eq!(replica_genesis(dir.path()), Some(0), "never overwritten");
2644        }
2645
2646        /// The protected case end to end: a brand-new cluster whose
2647        /// primary dies while its first replica is still copying the
2648        /// genesis. The replica learned the genesis length from the
2649        /// primary — it is configured with none (the receiver takes no
2650        /// genesis at all) — and the promotion the operator requests next
2651        /// is refused on it.
2652        #[test]
2653        fn a_replica_promoted_mid_genesis_is_refused() {
2654            let dir = tempfile::tempdir().expect("tempdir");
2655            let (_, base) = primary_journal(dir.path());
2656            let lineage = with_genesis(base, Some(3));
2657            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2658            listener.set_nonblocking(true).expect("nonblocking");
2659            let addr = listener.local_addr().expect("addr");
2660            let shutdown = Arc::new(AtomicBool::new(false));
2661            let control = crate::replication::ReplicaControlPlane::new();
2662            let replica = {
2663                let journal = dir.path().join("replica.journal");
2664                let snapshot = dir.path().join("replica.snapshot");
2665                let shutdown = Arc::clone(&shutdown);
2666                let control = control.clone();
2667                std::thread::spawn(move || -> Result<Option<String>, String> {
2668                    run_receiver::<App>(
2669                        addr,
2670                        &journal,
2671                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
2672                        &shutdown,
2673                        &control,
2674                        3_600_000,
2675                        snapshot,
2676                        crate::layout::PipelineCores::unpinned(),
2677                        melin_journal::StagingMode::ZeroFill,
2678                        Duration::ZERO,
2679                        64,
2680                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
2681                        &(),
2682                    )
2683                    // The promotion path's check, on the writer the
2684                    // receiver hands over.
2685                    .map(|promoted| {
2686                        promoted.map(
2687                            |(_, writer)| match crate::server::check_promotable(&writer) {
2688                                Ok(()) => "promotable".to_string(),
2689                                Err(e) => e.to_string(),
2690                            },
2691                        )
2692                    })
2693                    .map_err(|e| e.to_string())
2694                })
2695            };
2696
2697            let (mut s1, s1r, h1) = next_handshake(&listener);
2698            assert_eq!(h1.last_sequence, 0, "fresh replica handshake");
2699            stream_start(&mut s1, 0, lineage);
2700            send_entries(&mut s1, &[1, 2]);
2701            wait_for_ack(&mut s1, 2);
2702            // The primary dies two entries into a three-entry genesis.
2703            drop(s1);
2704            drop(s1r);
2705            assert!(
2706                control
2707                    .promote
2708                    .request(crate::promotion::PromotionRequest::MANUAL),
2709                "promotion filed"
2710            );
2711
2712            let verdict = replica
2713                .join()
2714                .expect("replica thread panicked")
2715                .expect("the receiver hands its state over")
2716                .expect("promoted, not shut down");
2717            assert!(verdict.contains("refusing promotion"), "{verdict}");
2718            assert!(verdict.contains("holds 2 entries"), "{verdict}");
2719        }
2720    }
2721
2722    // -----------------------------------------------------------------
2723    // Boundary adoption under both staging modes — end to end against a
2724    // scripted primary. Needs hash-chain (the boundary announce carries
2725    // a chain value the replica verifies before rotating) and real
2726    // persistence (the rotation archives the on-disk segment).
2727    // -----------------------------------------------------------------
2728    #[cfg(all(feature = "hash-chain", not(feature = "no-persist")))]
2729    mod rotation_adoption {
2730        use super::super::super::auth::authenticate_replica;
2731        use super::scripted::*;
2732        use super::*;
2733        use melin_journal::{BufferedWriter, JournalEvent, JournalWrite};
2734        use melin_transport_core::replication::catchup::lineage_origin;
2735        use melin_transport_core::replication::protocol::{encode_rotate, encode_stream_start};
2736        use std::sync::Arc;
2737        use std::sync::atomic::AtomicBool;
2738
2739        /// Payloads of every entry in the journal at `path`, in order.
2740        fn payloads(path: &std::path::Path) -> Vec<u64> {
2741            let mut reader = melin_journal::JournalReader::<EvtAdd>::open(path).expect("open");
2742            let mut out = Vec::new();
2743            while let Some(entry) = reader.next_entry().expect("read entry") {
2744                if let JournalEvent::App(EvtAdd(v)) = entry.event {
2745                    out.push(v);
2746                }
2747            }
2748            out
2749        }
2750
2751        /// A scripted primary streams two events, announces the rotation
2752        /// boundary with the *correct* tail hash, and streams two more.
2753        /// The replica must verify the boundary, rotate — adopting the
2754        /// segment its preparer staged in `mode` — and keep acking on
2755        /// the new segment, leaving the sealed one archived.
2756        ///
2757        /// Run under both modes: everything observable (archive layout,
2758        /// entry placement across the boundary, acks, clean shutdown)
2759        /// must be identical, or the staging mode is not a tuning knob
2760        /// but a behaviour change.
2761        fn boundary_adoption_rotates_and_streams_on(mode: melin_journal::StagingMode) {
2762            // Shrink the prealloc chunk so the replica's preparer stages
2763            // a few MiB (chunk + margin) instead of 256 MiB per test.
2764            // The guard holds a process-wide lock, so the two mode
2765            // variants serialise rather than race the global override.
2766            let _prealloc_guard =
2767                melin_journal::test_utils::PreallocOverrideGuard::new(1024 * 1024);
2768            let dir = tempfile::tempdir().expect("tempdir");
2769
2770            // Scripted primary journal: entries 1..=2 carrying the same
2771            // stamps as the InputSlots below (deterministic timestamps,
2772            // zero key-hash/request-seq), so its chain value at the
2773            // boundary equals the replica's — journals are byte-for-byte
2774            // mirrors, and `append`'s wall-clock stamp would fork them.
2775            let primary_journal = dir.path().join("primary.journal");
2776            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
2777            for v in 1..=2u64 {
2778                w.batch_append_with_ts(&JournalEvent::App(EvtAdd(v)), v, 0)
2779                    .expect("append");
2780            }
2781            w.flush_batch_sync().expect("flush");
2782            let chain_at_2 = w.chain_hash().expect("chain");
2783            drop(w);
2784            let origin = lineage_origin(&primary_journal).expect("lineage");
2785            let (lineage_start, lineage_anchor, lineage_genesis) = (
2786                origin.starting_sequence,
2787                origin.anchor_hash,
2788                origin.genesis_entries,
2789            );
2790
2791            let authorized_keys = replica_auth();
2792            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2793            listener.set_nonblocking(true).expect("nonblocking");
2794            let addr = listener.local_addr().expect("addr");
2795
2796            let replica_journal = dir.path().join("replica.journal");
2797            let shutdown = Arc::new(AtomicBool::new(false));
2798            let control = crate::replication::ReplicaControlPlane::new();
2799            let replica = {
2800                let journal = replica_journal.clone();
2801                let snapshot = dir.path().join("replica.snapshot");
2802                let shutdown = Arc::clone(&shutdown);
2803                let control = control.clone();
2804                std::thread::spawn(move || -> Result<bool, String> {
2805                    run_receiver::<App>(
2806                        addr,
2807                        &journal,
2808                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
2809                        &shutdown,
2810                        &control,
2811                        3_600_000,
2812                        snapshot,
2813                        crate::layout::PipelineCores::unpinned(),
2814                        mode,
2815                        Duration::ZERO,
2816                        64,
2817                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
2818                        &(),
2819                    )
2820                    // ReceiverResult's error is !Send — stringify for join().
2821                    .map(|state| state.is_none())
2822                    .map_err(|e| e.to_string())
2823                })
2824            };
2825
2826            let mut s1 = accept_within(&listener, 30);
2827            let mut s1r = s1.try_clone().expect("clone");
2828            authenticate_replica(&mut s1r, &authorized_keys).expect("auth");
2829            match read_replica_msg(&mut s1) {
2830                ReplicaMessage::Handshake(h) => {
2831                    assert_eq!(h.last_sequence, 0, "fresh replica handshake")
2832                }
2833                other => panic!("expected Handshake, got {other:?}"),
2834            }
2835            let mut buf = Vec::new();
2836            encode_stream_start(
2837                0,
2838                lineage_start,
2839                lineage_anchor,
2840                lineage_genesis,
2841                0,
2842                1,
2843                &mut buf,
2844            );
2845            s1.write_all(&buf).expect("StreamStart");
2846
2847            let send_events = |s: &mut TcpStream, range: std::ops::RangeInclusive<u64>| {
2848                let slots: Vec<InputSlot<EvtAdd>> = range
2849                    .map(|v| InputSlot {
2850                        connection_id: 0,
2851                        key_hash: 0,
2852                        sequence: v,
2853                        timestamp_ns: v,
2854                        event: JournalEvent::App(EvtAdd(v)),
2855                        // `()` without latency-trace, a timestamp with it.
2856                        publish_ts: Default::default(),
2857                        recv_ts: Default::default(),
2858                    })
2859                    .collect();
2860                let mut buf = Vec::new();
2861                melin_transport_core::replication_wire::encode_input_batch(&slots, &mut buf)
2862                    .expect("encode InputBatch");
2863                s.write_all(&buf).expect("InputBatch");
2864            };
2865
2866            send_events(&mut s1, 1..=2);
2867            wait_for_ack(&mut s1, 2);
2868
2869            // Wait for the preparer's staged segment to reach full size
2870            // (the overridden prealloc chunk + the 8 MiB margin) so the
2871            // boundary below adopts it rather than racing staging into
2872            // the synchronous fallback — adoption is the path under
2873            // test. Both modes must produce it; a preparer that never
2874            // does is itself a failure.
2875            let staging = {
2876                // `<live>.next-staging` — the preparer's sibling-file
2877                // convention (crate-private, so spelled out here the way
2878                // the `.divergent.<n>` archive name is above).
2879                let mut s = replica_journal.clone().into_os_string();
2880                s.push(".next-staging");
2881                std::path::PathBuf::from(s)
2882            };
2883            let staged_len: u64 = (1 + 8) * 1024 * 1024;
2884            let deadline = Instant::now() + Duration::from_secs(10);
2885            loop {
2886                if std::fs::metadata(&staging).is_ok_and(|m| m.len() == staged_len) {
2887                    break;
2888                }
2889                assert!(
2890                    Instant::now() < deadline,
2891                    "preparer produced no full-size staged segment in {mode:?} mode"
2892                );
2893                std::thread::sleep(Duration::from_millis(10));
2894            }
2895            // Length is the last thing staging changes before the final
2896            // sync and the slot hand-off; give those a moment so the
2897            // rotation's `take()` finds the segment parked. (Losing this
2898            // race anyway is not a failure: the rotation falls back to
2899            // the synchronous path and every assertion below still
2900            // holds — the staged segment is then simply adopted at a
2901            // later boundary.)
2902            std::thread::sleep(Duration::from_millis(300));
2903
2904            // The boundary, with the correct tail hash — verified
2905            // against the replica's own chain, then adopted.
2906            buf.clear();
2907            encode_rotate(2, &chain_at_2, &mut buf);
2908            s1.write_all(&buf).expect("Rotate");
2909
2910            // Streaming continues on the new segment. The journal stage
2911            // holds entries past a pending boundary until the rotation
2912            // applies, so this ack proves the adoption completed.
2913            send_events(&mut s1, 3..=4);
2914            wait_for_ack(&mut s1, 4);
2915
2916            shutdown.store(true, Ordering::Relaxed);
2917            let result = replica.join().expect("replica thread panicked");
2918            assert_eq!(result, Ok(true), "receiver must exit cleanly via shutdown");
2919
2920            // The sealed segment was archived at the first archive slot
2921            // holding exactly the pre-boundary entries; the live segment
2922            // carries the stream from the boundary on.
2923            let archived = dir.path().join("replica.journal.000001");
2924            assert!(archived.exists(), "sealed segment must be archived");
2925            assert_eq!(payloads(&archived), vec![1, 2]);
2926            assert_eq!(payloads(&replica_journal), vec![3, 4]);
2927        }
2928
2929        #[test]
2930        fn boundary_adoption_zero_fill() {
2931            boundary_adoption_rotates_and_streams_on(melin_journal::StagingMode::ZeroFill);
2932        }
2933
2934        #[test]
2935        fn boundary_adoption_allocate() {
2936            boundary_adoption_rotates_and_streams_on(melin_journal::StagingMode::Allocate);
2937        }
2938    }
2939}