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        // Set by `accept_stream_start`, which takes `control` whole.
581        primary_observed: _,
582        lineage_ack_policy,
583        pipeline_healthy,
584    } = control;
585    // Recover whenever any journal segment survives — live OR archived;
586    // fresh replicas get `(None, None, 0, zeros)`. See
587    // `recover_replica_state` for the lineage rules.
588    let (mut app, mut journal_writer, mut last_sequence, mut chain_hash) =
589        recover_replica_state::<A, BufferedWriter<A::Event>>(
590            journal_path,
591            &snapshot_path,
592            &fence_state,
593            lineage_ack_policy,
594            sizing,
595        )?;
596    // The fence epoch now reflects the recovered journal (a fresh replica
597    // legitimately recovers epoch 0). Seed the advertised sequence from
598    // the same recovery before releasing the flag, so the first tip the
599    // raft driver trusts is already the recovered high-water mark.
600    // Release-store so the driver's Acquire-load sees both.
601    journal_tip.advance(melin_transport_core::WireSeq::new(last_sequence));
602    tip_ready.store(true, Ordering::Release);
603
604    let mut backoff = std::time::Duration::from_secs(1);
605
606    // Consecutive mid-stream divergence resyncs this process has
607    // attempted — see `MAX_INPROCESS_DIVERGENCE_RESYNCS`.
608    let mut divergence_resyncs: u32 = 0;
609
610    let mut send_buf = Vec::with_capacity(64);
611    let mut pipeline: Option<ReplicaPipelineHandles<A, BufferedWriter<A::Event>>> = None;
612
613    // --- Outer reconnect loop ---
614    loop {
615        // No authenticated session while (re)connecting — the raft
616        // driver may auto-promote from here on if it wins an election.
617        primary_link_up.store(false, Ordering::Release);
618
619        if let Some(p) = pipeline.as_ref() {
620            // The handshake pair (last_sequence, chain_hash) must come
621            // from ONE FsyncState snapshot — the journal stage keeps
622            // flushing while we reconnect, and the primary's handshake
623            // validation recomputes its chain at exactly the sequence
624            // we claim. Reading the sequence and the hash from two
625            // separate sources would tear under load, and a torn pair
626            // is indistinguishable from divergence (false resync of a
627            // healthy replica). Taken once the journal covers what the
628            // last session published (see `settled_resume_point`).
629            match p
630                .settled_resume_point(|| shutdown.load(Ordering::Relaxed) || promote.is_requested())
631            {
632                ResumePoint::Settled(fsync_state) => {
633                    last_sequence = fsync_state.journal_seq.get();
634                    chain_hash = fsync_state.chain_hash;
635                }
636                // Handled just below, before any handshake.
637                ResumePoint::Interrupted => {}
638                // Torn down here rather than dialing the primary with a
639                // pipeline that cannot take the session.
640                ResumePoint::JournalFailed => {
641                    let after = handle_session_exit(
642                        journal_failed_while_disconnected(),
643                        &mut pipeline,
644                        &mut divergence_resyncs,
645                        &mut backoff,
646                        last_sequence,
647                        journal_path,
648                        &snapshot_path,
649                        &fence_state,
650                        lineage_ack_policy,
651                        shutdown,
652                        promote,
653                        // No session is open.
654                        || {},
655                        sizing,
656                    );
657                    // A fatal exit never keeps the pipeline, so going
658                    // round again dials only after a resync or not at all.
659                    if let Some(r) = after.adopt(
660                        &mut app,
661                        &mut journal_writer,
662                        &mut last_sequence,
663                        &mut chain_hash,
664                    ) {
665                        return r;
666                    }
667                    continue;
668                }
669            }
670        }
671
672        // Sole authority for shutdown/promote while disconnected: every
673        // backoff arm just sleeps (the sleep observes both flags) and
674        // continues back here, so teardown/promotion handling exists
675        // exactly once.
676        if shutdown.load(Ordering::Relaxed) {
677            if let Some(p) = pipeline.take() {
678                let _ = teardown_replica_pipeline::<A, BufferedWriter<A::Event>>(p);
679            }
680            return Ok(None);
681        }
682        if promote.is_requested() {
683            info!("promotion triggered while disconnected");
684            return take_pipeline_for_promotion(&mut pipeline, &mut app, &mut journal_writer);
685        }
686
687        // --- Connect and authenticate ---
688        info!(primary = %primary_addr, "connecting to primary as replica");
689
690        let stream = match TcpStream::connect(primary_addr) {
691            Ok(s) => s,
692            Err(e) => {
693                warn!(
694                    error = %e,
695                    backoff_secs = backoff.as_secs(),
696                    "failed to connect to primary — retrying"
697                );
698                sleep_then_double_backoff(&mut backoff, shutdown, promote);
699                continue;
700            }
701        };
702        if let Err(e) = stream.set_nodelay(true) {
703            warn!(error = %e, "failed to set TCP_NODELAY on replica receive socket");
704        }
705        {
706            use std::os::unix::io::AsRawFd;
707            let busy_poll: libc::c_int = 50;
708            let rc = unsafe {
709                libc::setsockopt(
710                    stream.as_raw_fd(),
711                    libc::SOL_SOCKET,
712                    libc::SO_BUSY_POLL,
713                    &busy_poll as *const libc::c_int as *const libc::c_void,
714                    std::mem::size_of::<libc::c_int>() as libc::socklen_t,
715                )
716            };
717            if rc != 0 {
718                let err = std::io::Error::last_os_error();
719                warn!(error = %err, "failed to set SO_BUSY_POLL on replica receive socket");
720            }
721        }
722        stream.set_read_timeout(Some(std::time::Duration::from_secs(5)))?;
723
724        let mut reader = stream.try_clone()?;
725        let mut tcp_writer = stream;
726
727        // `reader`/`tcp_writer` are clones of one socket; auth is sequential so
728        // a single handle works. Use `reader` (carries the read timeout above).
729        if let Err(e) = authenticate_with_primary(&mut reader, signing_key) {
730            // A read timeout means no challenge arrived: the target
731            // holds the port but is not serving — typically a
732            // not-yet-promoted replica (the listener is bound from boot)
733            // or a primary mid-start. Only other errors suggest a real
734            // credential problem. SO_RCVTIMEO surfaces as WouldBlock on
735            // Linux; match TimedOut too for portability.
736            if matches!(
737                e.kind(),
738                std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
739            ) {
740                warn!(error = %e, "no auth challenge from primary (not serving yet?) — retrying");
741            } else {
742                warn!(error = %e, "authentication failed — retrying");
743            }
744            // Drop the socket before sleeping — held through the
745            // backoff it would sit in the target's accept backlog, and
746            // a just-promoted primary would waste a replica slot (and
747            // its 10s auth timeout) on the zombie.
748            drop(reader);
749            drop(tcp_writer);
750            // Back off before redialing — without the sleep this loop
751            // hammers a primary that keeps refusing us.
752            sleep_then_double_backoff(&mut backoff, shutdown, promote);
753            continue;
754        }
755        info!("authenticated with primary");
756        // The primary is demonstrably alive from here until this
757        // session ends — the raft driver must not auto-promote past it.
758        primary_link_up.store(true, Ordering::Release);
759
760        // --- Handshake ---
761        // Advertise our fencing epoch. If we are ahead of the primary it
762        // sees this and self-demotes (it's a stale ex-primary); see
763        // `crate::fence` and the sender's handshake handling.
764        let handshake = Handshake {
765            last_sequence,
766            chain_hash,
767            epoch: fence_state.epoch(),
768        };
769        send_buf.clear();
770        encode_handshake(&handshake, &mut send_buf);
771        tcp_writer.write_all(&send_buf)?;
772        tcp_writer.flush()?;
773        send_buf.clear();
774
775        // --- Protocol negotiation ---
776        // `session_start` is the resume point this streaming session
777        // continues from — the value that anchors the receiver's
778        // sequence-contiguity gate. Derived from local knowledge (our
779        // own handshake, or the verified snapshot), never from the
780        // wire.
781        let response_frame = read_frame(&mut reader, MAX_CONTROL_FRAME)?;
782        let response = decode_primary_message(&response_frame)?;
783        let (stream_lineage, session_start) = match response {
784            PrimaryMessage::StreamStart {
785                start_sequence,
786                segment_start_sequence,
787                anchor_hash,
788                genesis_entries,
789                epoch,
790            } => match super::accept_stream_start(
791                super::StreamStart {
792                    start_sequence,
793                    lineage: (segment_start_sequence, anchor_hash, genesis_entries),
794                    epoch,
795                },
796                "tcp",
797                &pipeline,
798                &mut journal_writer,
799                &fence_state,
800                control,
801            )? {
802                StreamStartVerdict::Follow(lineage) => (lineage, last_sequence),
803                // Disconnect and retry with backoff; the operator's logs
804                // flag the misdirected `--replica-of`.
805                StreamStartVerdict::StalePrimary => {
806                    // Drop the socket before sleeping — see the auth
807                    // failure arm above.
808                    drop(reader);
809                    drop(tcp_writer);
810                    sleep_then_double_backoff(&mut backoff, shutdown, promote);
811                    continue;
812                }
813            },
814            ref resync @ (PrimaryMessage::NeedSnapshot | PrimaryMessage::HashMismatch) => {
815                let divergent = matches!(resync, PrimaryMessage::HashMismatch);
816                // `map_err` drops `Send + Sync` by coercion — the `?` `From`
817                // bridge doesn't apply directly across the two boxed trait
818                // objects (`Box<dyn Error + Send + Sync>` → `Box<dyn Error>`).
819                let decision = handle_resync_verdict(
820                    divergent,
821                    &mut TcpFrameSource {
822                        reader: &mut reader,
823                    },
824                    &mut pipeline,
825                    &mut app,
826                    &mut journal_writer,
827                    journal_path,
828                    &snapshot_path,
829                    &fence_state,
830                    control,
831                    &mut last_sequence,
832                    &mut chain_hash,
833                )
834                .map_err(|e| -> Box<dyn std::error::Error> { e })?;
835                match decision {
836                    ResyncDecision::Ready {
837                        segment_start_sequence,
838                        anchor_hash,
839                        genesis_entries,
840                        resume_sequence,
841                    } => (
842                        (segment_start_sequence, anchor_hash, genesis_entries),
843                        resume_sequence,
844                    ),
845                    ResyncDecision::Retry => {
846                        // Drop the socket before sleeping — see the auth
847                        // failure arm above.
848                        drop(reader);
849                        drop(tcp_writer);
850                        sleep_then_double_backoff(&mut backoff, shutdown, promote);
851                        continue;
852                    }
853                }
854            }
855            _ => {
856                return Err(format!("unexpected response: {response:?}").into());
857            }
858        };
859
860        // --- Create journal for fresh replica ---
861        // The StreamStart lineage gives the segment header identity
862        // (starting sequence + chain anchor) the primary's own journal
863        // lineage began with; creating the local segment from the same
864        // identity makes the replica's segment byte-identical to the
865        // primary's, and adopted `Rotate` boundaries keep it that way
866        // across rotations (bitwise mirror). The header records the
867        // lineage's genesis length too, so it survives this replica's
868        // restarts and is there to check at promotion even with the
869        // primary gone.
870        if pipeline.is_none() && journal_writer.is_none() {
871            let (lineage_start, lineage_anchor, lineage_genesis) = stream_lineage;
872            let writer = BufferedWriter::create_continuing(
873                journal_path,
874                lineage_start,
875                lineage_anchor,
876                lineage_genesis,
877            )?;
878            app = Some(A::default());
879            journal_writer = Some(writer);
880        }
881
882        // --- Build pipeline if absent ---
883        if pipeline.is_none() {
884            let cur_app = app.take().expect("application initialized");
885            let cur_writer = journal_writer.take().expect("journal_writer initialized");
886            pipeline = Some(build_replica_pipeline_with_threads::<A>(
887                cur_app,
888                cur_writer,
889                cores,
890                staging_mode,
891                snapshot_interval_ms,
892                snapshot_path.clone(),
893                group_commit_delay,
894                Arc::clone(&fence_state),
895                lineage_ack_policy,
896                Arc::clone(pipeline_healthy),
897                sizing,
898            )?);
899        }
900
901        // --- Streaming session ---
902        let result = {
903            let p = pipeline.as_mut().expect("pipeline must exist by here");
904            let input_producer = &mut p.input_producer;
905            let journal_cursor = p.journal_cursor.as_ref();
906            let stream_marks = &p.stream_marks;
907            let journal_failed = &p.journal_failed;
908            std::thread::scope(|s| {
909                let receiver = || {
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                // Created in its own placement, not this thread's — see
942                // `melin_app::affinity::spawn_placed`.
943                let handle = melin_app::affinity::with_child_context(
944                    "replica-receiver",
945                    cores.reader.core,
946                    || {
947                        std::thread::Builder::new()
948                            .name("replica-receiver".into())
949                            .spawn_scoped(s, receiver)
950                    },
951                )
952                .expect("spawn replica-receiver thread");
953                handle.join().expect("replica-receiver thread panicked")
954            })
955        };
956
957        primary_link_up.store(false, Ordering::Release);
958        let after = handle_session_exit(
959            result,
960            &mut pipeline,
961            &mut divergence_resyncs,
962            &mut backoff,
963            last_sequence,
964            journal_path,
965            &snapshot_path,
966            &fence_state,
967            lineage_ack_policy,
968            shutdown,
969            promote,
970            // Kernel TCP needs no explicit close — the `TcpStream` is
971            // dropped on the next loop turn when a fresh connection
972            // replaces it.
973            || {},
974            sizing,
975        );
976        if let Some(r) = after.adopt(
977            &mut app,
978            &mut journal_writer,
979            &mut last_sequence,
980            &mut chain_hash,
981        ) {
982            return r;
983        }
984    }
985}
986
987// ---------------------------------------------------------------------------
988// Tests
989// ---------------------------------------------------------------------------
990
991#[cfg(test)]
992mod tests {
993    use super::*;
994    use crate::uring_teardown::DRAIN_TIMEOUT;
995    use std::io::Read;
996    use std::net::TcpListener;
997    use std::time::{Duration, Instant};
998
999    use melin_transport_core::replication::protocol::{ReplicaMessage, decode_replica_message};
1000    use melin_wire_protocol::framing::split_frame_limited;
1001
1002    /// Connected localhost pair: (transport side, peer side).
1003    fn socket_pair() -> (TcpStream, TcpStream) {
1004        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1005        let addr = listener.local_addr().unwrap();
1006        let client = TcpStream::connect(addr).unwrap();
1007        let (server, _) = listener.accept().unwrap();
1008        (client, server)
1009    }
1010
1011    fn ack(seq: u64) -> Ack {
1012        Ack {
1013            acked_sequence: seq,
1014            in_memory_sequence: seq,
1015        }
1016    }
1017
1018    /// Drive `poll_recv` until every accepted ack has reached the wire.
1019    fn flush_acks(transport: &mut UringTransport) {
1020        let mut recv_buf = Vec::new();
1021        let deadline = Instant::now() + Duration::from_secs(5);
1022        while transport.ack_in_flight() {
1023            transport.poll_recv(&mut recv_buf).unwrap();
1024            assert!(Instant::now() < deadline, "ack send never completed");
1025            std::hint::spin_loop();
1026        }
1027    }
1028
1029    /// Read `expect` length-prefixed ack frames from the peer socket.
1030    fn read_acks(peer: &mut TcpStream, expect: usize) -> Vec<Ack> {
1031        peer.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
1032        let mut buf = Vec::new();
1033        let mut chunk = [0u8; 256];
1034        let mut acks = Vec::new();
1035        while acks.len() < expect {
1036            let n = peer.read(&mut chunk).expect("peer read");
1037            assert!(n > 0, "peer closed before all acks arrived");
1038            buf.extend_from_slice(&chunk[..n]);
1039            // Parse complete frames.
1040            while let Some(frame) = split_frame_limited(&buf, MAX_CONTROL_FRAME).unwrap() {
1041                match decode_replica_message(frame.payload).expect("decodable frame") {
1042                    ReplicaMessage::Ack(a) => acks.push(a),
1043                    other => panic!("expected Ack frame, got {other:?}"),
1044                }
1045                let consumed = frame.consumed();
1046                buf.drain(..consumed);
1047            }
1048        }
1049        assert!(buf.is_empty(), "trailing bytes after expected acks");
1050        acks
1051    }
1052
1053    #[test]
1054    fn coalesces_to_latest_ack_while_send_in_flight() {
1055        let (client, mut peer) = socket_pair();
1056        let mut transport = UringTransport::new(&client).unwrap();
1057
1058        // First ack goes straight onto the wire path; the next two are
1059        // accepted while it is in flight (the flag clears only when the
1060        // CQE is reaped in poll_recv, so this is deterministic) and
1061        // coalesce — only the newest survives.
1062        assert!(transport.send_ack(&ack(1)).unwrap());
1063        assert!(transport.ack_in_flight());
1064        assert!(transport.send_ack(&ack(2)).unwrap());
1065        assert!(transport.send_ack(&ack(3)).unwrap());
1066
1067        flush_acks(&mut transport);
1068
1069        // Exactly two frames, in cursor order: ack(2) was subsumed.
1070        let acks = read_acks(&mut peer, 2);
1071        let seqs: Vec<u64> = acks.iter().map(|a| a.acked_sequence).collect();
1072        assert_eq!(seqs, vec![1, 3]);
1073    }
1074
1075    #[test]
1076    fn sequential_acks_all_reach_the_wire_in_order() {
1077        let (client, mut peer) = socket_pair();
1078        let mut transport = UringTransport::new(&client).unwrap();
1079
1080        for seq in 1..=3 {
1081            assert!(transport.send_ack(&ack(seq)).unwrap());
1082            flush_acks(&mut transport);
1083        }
1084
1085        let acks = read_acks(&mut peer, 3);
1086        let seqs: Vec<u64> = acks.iter().map(|a| a.acked_sequence).collect();
1087        assert_eq!(seqs, vec![1, 2, 3]);
1088    }
1089
1090    /// The multishot RECV armed at construction stays armed for the
1091    /// whole session, so every ordinary session exit drops the
1092    /// transport with the kernel still holding pointers into the buffer
1093    /// pool. `Drop` has to quiesce it — and promptly: a drain that
1094    /// cannot settle only gives up on the deadline, so finishing well
1095    /// inside it is what proves the buffers were released rather than
1096    /// leaked.
1097    #[test]
1098    fn drop_quiesces_the_multishot_recv() {
1099        let (client, _peer) = socket_pair();
1100        let mut transport = UringTransport::new(&client).unwrap();
1101        // `new` only pushes the multishot RECV; the first poll submits
1102        // it. Teardown from that state is what a real session does.
1103        let mut recv_buf = Vec::new();
1104        transport.poll_recv(&mut recv_buf).unwrap();
1105        assert!(transport.multishot_active);
1106
1107        let started = Instant::now();
1108        drop(transport);
1109        let elapsed = started.elapsed();
1110        assert!(
1111            elapsed < DRAIN_TIMEOUT / 4,
1112            "teardown took {elapsed:?}, close to the {DRAIN_TIMEOUT:?} deadline"
1113        );
1114    }
1115
1116    /// A failing session bails out of `poll_recv` part-way through a
1117    /// batch, abandoning CQEs that were already reaped out of the
1118    /// completion queue. Their kernel-ownership flags still have to be
1119    /// recorded: otherwise teardown waits out its whole deadline for a
1120    /// SEND completion that was consumed here and can never arrive
1121    /// again, and then leaks the buffer pool.
1122    #[test]
1123    fn abandoned_cqes_still_settle_kernel_ownership() {
1124        let (client, _peer) = socket_pair();
1125        let mut transport = UringTransport::new(&client).unwrap();
1126        let mut recv_buf = Vec::new();
1127        transport.poll_recv(&mut recv_buf).unwrap();
1128
1129        // Stands in for a SEND whose CQE landed in the batch behind a
1130        // RECV CQE that failed the session.
1131        transport.ack_in_flight = true;
1132        transport.settle_unprocessed(&[(TOKEN_SEND, 13, 0)]);
1133        assert!(
1134            !transport.ack_in_flight,
1135            "an abandoned SEND completion must release `ack_buf`"
1136        );
1137
1138        // A RECV that still carries F_MORE leaves the arm live — the
1139        // kernel may write into the pool again, so teardown must wait.
1140        transport.settle_unprocessed(&[(TOKEN_RECV, 128, IORING_CQE_F_MORE)]);
1141        assert!(
1142            transport.multishot_active,
1143            "F_MORE must leave the multishot armed"
1144        );
1145        // Only the terminal completion retires it.
1146        transport.settle_unprocessed(&[(TOKEN_RECV, 0, 0)]);
1147        assert!(!transport.multishot_active);
1148
1149        // The real arm submitted by `poll_recv` above is still live in
1150        // the kernel; restore the flag the assertion just cleared so
1151        // `Drop` drains it instead of freeing the pool underneath it.
1152        transport.multishot_active = true;
1153    }
1154
1155    /// Same, for the other kernel-visible buffer: an ack SEND pushed
1156    /// but never submitted, so the drain has to flush the SQ before it
1157    /// can wait for anything.
1158    #[test]
1159    fn drop_quiesces_a_pending_ack_send() {
1160        let (client, _peer) = socket_pair();
1161        let mut transport = UringTransport::new(&client).unwrap();
1162        assert!(transport.send_ack(&ack(7)).unwrap());
1163        assert!(transport.ack_in_flight());
1164
1165        let started = Instant::now();
1166        drop(transport);
1167        let elapsed = started.elapsed();
1168        assert!(
1169            elapsed < DRAIN_TIMEOUT / 4,
1170            "teardown took {elapsed:?}, close to the {DRAIN_TIMEOUT:?} deadline"
1171        );
1172    }
1173
1174    /// Scripted-primary scaffolding shared by the end-to-end receiver
1175    /// tests below: a no-op application over a trivial event, the
1176    /// primary-side frame helpers, and common fixtures. Gated like its
1177    /// consumers so a `no-persist` build carries no dead test code.
1178    #[cfg(not(feature = "no-persist"))]
1179    mod scripted {
1180        use super::*;
1181        use melin_app::{AppEvent, Application, ApplyCtx, CodecError, QueryCtx, RejectReason};
1182
1183        // Re-export so the sibling test modules can name the generic
1184        // slot type without reaching into transport-core themselves.
1185        pub(super) use melin_transport_core::pipeline::InputSlot;
1186
1187        #[derive(Debug, Clone, Copy, PartialEq)]
1188        pub(super) struct EvtAdd(pub(super) u64);
1189
1190        impl AppEvent for EvtAdd {
1191            const MAX_ENCODED_SIZE: usize = 8;
1192
1193            fn encoded_size(&self) -> usize {
1194                8
1195            }
1196            fn encode(&self, buf: &mut [u8]) -> usize {
1197                buf[..8].copy_from_slice(&self.0.to_le_bytes());
1198                8
1199            }
1200            fn decode(buf: &[u8]) -> Result<Self, CodecError> {
1201                if buf.len() < 8 {
1202                    return Err(CodecError::Truncated);
1203                }
1204                Ok(EvtAdd(u64::from_le_bytes(buf[..8].try_into().expect("8"))))
1205            }
1206            fn is_query(&self) -> bool {
1207                false
1208            }
1209        }
1210
1211        #[derive(Debug, Clone, Copy)]
1212        pub(super) struct Rpt;
1213
1214        #[derive(Default)]
1215        pub(super) struct App;
1216
1217        impl Application for App {
1218            type Event = EvtAdd;
1219            type Report = Rpt;
1220            type QueryResponse = Rpt;
1221            type Sizing = ();
1222            const APP_VERSION: u16 = 1;
1223
1224            fn apply(&mut self, _e: EvtAdd, _ctx: &ApplyCtx, _out: &mut Vec<Rpt>) {}
1225            fn query(&self, _e: EvtAdd, _ctx: &QueryCtx) -> Option<Rpt> {
1226                None
1227            }
1228            fn tick(&mut self, _now_ns: u64, _out: &mut Vec<Rpt>) {}
1229            fn build_reject(_e: &EvtAdd, _r: RejectReason) -> Rpt {
1230                Rpt
1231            }
1232            fn snapshot<W: std::io::Write>(&self, _w: &mut W) -> std::io::Result<()> {
1233                Ok(())
1234            }
1235            fn restore<R: std::io::Read>(_r: &mut R) -> std::io::Result<Self> {
1236                Ok(App)
1237            }
1238        }
1239
1240        /// Deterministic replica signing-key bytes; [`replica_auth`]
1241        /// builds the matching authorized-keys table.
1242        pub(super) const REPLICA_KEY: [u8; 32] = [0xFC; 32];
1243
1244        /// The authorized-keys table listing [`REPLICA_KEY`]'s public
1245        /// half.
1246        pub(super) fn replica_auth() -> melin_app::auth::AuthorizedKeys {
1247            let key = ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY);
1248            let pub_b64 = base64::Engine::encode(
1249                &base64::engine::general_purpose::STANDARD,
1250                key.verifying_key().to_bytes(),
1251            );
1252            melin_app::auth::AuthorizedKeys::parse::<melin_app::auth::NoRoles>(&format!(
1253                "replication {pub_b64} test-replica\n"
1254            ))
1255            .expect("parse keys")
1256        }
1257
1258        /// Read one length-prefixed `ReplicaMessage` frame.
1259        pub(super) fn read_replica_msg(stream: &mut TcpStream) -> ReplicaMessage {
1260            let mut len_buf = [0u8; 4];
1261            stream.read_exact(&mut len_buf).expect("frame length");
1262            let len = u32::from_le_bytes(len_buf) as usize;
1263            let mut payload = vec![0u8; len];
1264            stream.read_exact(&mut payload).expect("frame payload");
1265            decode_replica_message(&payload).expect("decodable replica frame")
1266        }
1267
1268        /// Skip frames until an `Ack` at or past `seq` arrives.
1269        pub(super) fn wait_for_ack(stream: &mut TcpStream, seq: u64) {
1270            loop {
1271                if let ReplicaMessage::Ack(a) = read_replica_msg(stream)
1272                    && a.acked_sequence >= seq
1273                {
1274                    return;
1275                }
1276            }
1277        }
1278
1279        /// Accept with a deadline so a replica that fails to (re)connect
1280        /// fails the test instead of hanging it.
1281        pub(super) fn accept_within(listener: &std::net::TcpListener, secs: u64) -> TcpStream {
1282            let deadline = Instant::now() + Duration::from_secs(secs);
1283            loop {
1284                match listener.accept() {
1285                    Ok((s, _)) => {
1286                        s.set_read_timeout(Some(Duration::from_secs(10)))
1287                            .expect("read timeout");
1288                        return s;
1289                    }
1290                    Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
1291                        assert!(Instant::now() < deadline, "replica never connected");
1292                        std::thread::sleep(Duration::from_millis(10));
1293                    }
1294                    Err(e) => panic!("accept failed: {e}"),
1295                }
1296            }
1297        }
1298    }
1299
1300    // -----------------------------------------------------------------
1301    // In-process divergence resync — end to end against a scripted
1302    // primary. Needs hash-chain (divergence is a chain verdict) and
1303    // real persistence (the resync re-derives state from disk).
1304    // -----------------------------------------------------------------
1305    #[cfg(all(feature = "hash-chain", not(feature = "no-persist")))]
1306    mod divergence_resync {
1307        use super::super::super::auth::authenticate_replica;
1308        use super::scripted::*;
1309        use super::*;
1310        use melin_journal::{
1311            AckPolicyByte, AckPolicyChange, BufferedWriter, JournalEvent, JournalWrite,
1312        };
1313        use melin_transport_core::cursors::WireSeq;
1314        use melin_transport_core::replication::catchup::{lineage_origin, snapshot_transfer_with};
1315        use melin_transport_core::replication::protocol::{
1316            PrimaryMessage, decode_primary_message, encode_hash_mismatch, encode_rotate,
1317            encode_stream_start,
1318        };
1319        use std::sync::Arc;
1320        use std::sync::atomic::AtomicBool;
1321
1322        /// The policy the forked replica journals and the primary never
1323        /// did.
1324        const DIVERGENT_POLICY: AckPolicyByte = AckPolicyByte(1);
1325
1326        /// The policy the primary's snapshot lineage records: what the
1327        /// resync must rebase the replica onto.
1328        const PRIMARY_POLICY: AckPolicyByte = AckPolicyByte(0);
1329
1330        /// The primary's snapshot lineage policy, whole: every field set
1331        /// apart from the forked record's, so adopting it whole shows.
1332        const PRIMARY_LINEAGE_POLICY: melin_transport_core::lineage::LineagePolicy =
1333            melin_transport_core::lineage::LineagePolicy {
1334                change: AckPolicyChange {
1335                    policy: PRIMARY_POLICY,
1336                    unwitnessed: true,
1337                },
1338                wire_seq: WireSeq::new(3),
1339                previous: Some(AckPolicyByte(2)),
1340            };
1341
1342        /// A shadow snapshot interval short enough for a test to wait on.
1343        const SHADOW_INTERVAL_MS: u64 = 50;
1344
1345        /// A scripted primary brings a fresh replica up, streams one
1346        /// event, then announces a rotation with a deliberately wrong
1347        /// tail hash. The replica's journal stage detects divergence —
1348        /// `run_receiver` must repair it WITHOUT exiting: tear the
1349        /// pipeline down, re-derive its handshake position from disk,
1350        /// reconnect, take the HashMismatch verdict, archive the forked
1351        /// lineage, and complete the snapshot + segment-seed resync,
1352        /// then resume streaming on the rebased lineage.
1353        #[test]
1354        fn mid_stream_divergence_resyncs_in_process() {
1355            let dir = tempfile::tempdir().expect("tempdir");
1356
1357            // --- The scripted primary's journal + snapshot, served by
1358            // the real sender-side transfer code in session 2. Entries
1359            // 1..=5, rotation after 2, snapshot at 4 (mid-live-segment).
1360            let primary_journal = dir.path().join("primary.journal");
1361            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
1362            let mut chain_at_4 = [0u8; 32];
1363            for v in 1..=5u64 {
1364                w.append(&JournalEvent::App(EvtAdd(v))).expect("append");
1365                if v == 2 {
1366                    w.rotate_segment().expect("rotate");
1367                }
1368                if v == 4 {
1369                    chain_at_4 = w.chain_hash().expect("chain");
1370                }
1371            }
1372            drop(w);
1373            // The snapshot's lineage carries a policy of its own, distinct
1374            // from the forked one, so the rebase's adoption of it is
1375            // observable (the archive step alone clears the cell to `None`).
1376            melin_transport_core::snapshot::save::<App>(
1377                &App,
1378                WireSeq::new(4),
1379                chain_at_4,
1380                melin_transport_core::lineage::Lineage {
1381                    epoch: 0,
1382                    ack_policy: Some(PRIMARY_LINEAGE_POLICY),
1383                },
1384                Some(0),
1385                &primary_journal.with_extension("snapshot"),
1386            )
1387            .expect("save snapshot");
1388            let origin = lineage_origin(&primary_journal).expect("lineage");
1389            let (lineage_start, lineage_anchor, lineage_genesis) = (
1390                origin.starting_sequence,
1391                origin.anchor_hash,
1392                origin.genesis_entries,
1393            );
1394
1395            // --- Auth: one replica key, authorized.
1396            let authorized_keys = replica_auth();
1397
1398            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1399            listener.set_nonblocking(true).expect("nonblocking");
1400            let addr = listener.local_addr().expect("addr");
1401
1402            // --- Replica under test.
1403            let replica_journal = dir.path().join("replica.journal");
1404            let replica_snapshot = dir.path().join("replica.snapshot");
1405            let replica_snapshot_check = replica_snapshot.clone();
1406            let shutdown = Arc::new(AtomicBool::new(false));
1407            let control = crate::replication::ReplicaControlPlane::new();
1408            let cores = crate::layout::PipelineCores::unpinned();
1409            let replica = {
1410                let journal = replica_journal.clone();
1411                let shutdown = Arc::clone(&shutdown);
1412                let control = control.clone();
1413                std::thread::spawn(move || -> Result<bool, String> {
1414                    run_receiver::<App>(
1415                        addr,
1416                        &journal,
1417                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
1418                        &shutdown,
1419                        &control,
1420                        // Short, so the rebuilt pipeline's shadow writes a
1421                        // snapshot of its own after the resync (see the
1422                        // lineage check below).
1423                        SHADOW_INTERVAL_MS,
1424                        replica_snapshot,
1425                        cores,
1426                        melin_journal::StagingMode::ZeroFill,
1427                        Duration::ZERO,
1428                        64,
1429                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
1430                        &(),
1431                    )
1432                    // ReceiverResult's error is !Send — stringify for join().
1433                    .map(|state| state.is_none())
1434                    .map_err(|e| e.to_string())
1435                })
1436            };
1437
1438            // --- Session 1: fresh sync, one event, then the poisoned
1439            // rotation announce.
1440            let mut s1 = accept_within(&listener, 30);
1441            let mut s1r = s1.try_clone().expect("clone");
1442            authenticate_replica(&mut s1r, &authorized_keys).expect("auth 1");
1443            match read_replica_msg(&mut s1) {
1444                ReplicaMessage::Handshake(h) => {
1445                    assert_eq!(h.last_sequence, 0, "fresh replica handshake")
1446                }
1447                other => panic!("expected Handshake, got {other:?}"),
1448            }
1449            let mut buf = Vec::new();
1450            encode_stream_start(
1451                0,
1452                lineage_start,
1453                lineage_anchor,
1454                lineage_genesis,
1455                0,
1456                &mut buf,
1457            );
1458            s1.write_all(&buf).expect("StreamStart 1");
1459            buf.clear();
1460            // The forked entry is an ack-policy change the primary's
1461            // lineage does not hold (its entry 1 is an application
1462            // event): the resync must drop it with the rest of the fork.
1463            melin_transport_core::replication_wire::encode_input_batch(
1464                &[InputSlot::<EvtAdd> {
1465                    connection_id: 0,
1466                    key_hash: 0,
1467                    sequence: 1,
1468                    timestamp_ns: 1,
1469                    event: JournalEvent::AckPolicyChange(AckPolicyChange {
1470                        policy: DIVERGENT_POLICY,
1471                        unwitnessed: false,
1472                    }),
1473                    // `()` without latency-trace, a timestamp with it.
1474                    publish_ts: Default::default(),
1475                    recv_ts: Default::default(),
1476                }],
1477                &mut buf,
1478            )
1479            .expect("encode InputBatch");
1480            s1.write_all(&buf).expect("InputBatch");
1481            wait_for_ack(&mut s1, 1);
1482
1483            // Wrong tail hash at the announced boundary — the replica's
1484            // local chain at sequence 1 cannot match this.
1485            buf.clear();
1486            encode_rotate(1, &[0xEE; 32], &mut buf);
1487            s1.write_all(&buf).expect("Rotate");
1488
1489            // --- Session 2: the SAME process reconnects with its
1490            // position re-derived from the forked on-disk journal.
1491            let mut s2 = accept_within(&listener, 30);
1492            let mut s2r = s2.try_clone().expect("clone");
1493            authenticate_replica(&mut s2r, &authorized_keys).expect("auth 2");
1494            match read_replica_msg(&mut s2) {
1495                ReplicaMessage::Handshake(h) => assert_eq!(
1496                    h.last_sequence, 1,
1497                    "post-divergence handshake must carry the recovered journal position"
1498                ),
1499                other => panic!("expected Handshake, got {other:?}"),
1500            }
1501            // The journal stage's divergence failure latched the health
1502            // mirror before the teardown that led to this reconnect, and
1503            // the replacement pipeline is not built until after the resync
1504            // verdict — so at this deterministic point the replica must be
1505            // reporting unhealthy.
1506            assert!(
1507                !control.pipeline_healthy.load(Ordering::Acquire),
1508                "the divergence must have latched the health mirror false"
1509            );
1510            // The handshake position was re-derived by recovering the
1511            // forked journal, and the lineage with it: its change is the
1512            // policy this replica holds until the resync replaces it.
1513            assert_eq!(
1514                control.lineage_ack_policy.load(),
1515                Some(melin_transport_core::lineage::LineagePolicy {
1516                    change: AckPolicyChange {
1517                        policy: DIVERGENT_POLICY,
1518                        unwitnessed: false,
1519                    },
1520                    wire_seq: WireSeq::new(1),
1521                    previous: None,
1522                }),
1523                "recovery must store the forked journal's policy"
1524            );
1525            buf.clear();
1526            encode_hash_mismatch(&mut buf);
1527            s2.write_all(&buf).expect("HashMismatch");
1528            let transfer_shutdown = AtomicBool::new(false);
1529            let mut publish = |b: &[u8]| -> std::io::Result<()> {
1530                s2.write_all(b)?;
1531                s2.flush()
1532            };
1533            // Real sender-side resync: SnapshotBegin → chunks →
1534            // SegmentSeedBegin → seed → StreamStart → catch-up (entry 5).
1535            let end = snapshot_transfer_with::<EvtAdd>(
1536                &primary_journal,
1537                &mut publish,
1538                &transfer_shutdown,
1539            )
1540            .expect("snapshot transfer");
1541            assert_eq!(
1542                end,
1543                melin_transport_core::replication::catchup::CatchUpResult::Ok(5)
1544            );
1545            let mut s2_acks = s2.try_clone().expect("clone");
1546            wait_for_ack(&mut s2_acks, 5);
1547
1548            // Streaming resumed on the rebuilt pipeline — the rebuild
1549            // must have reset the health mirror.
1550            assert!(
1551                control.pipeline_healthy.load(Ordering::Acquire),
1552                "the resync rebuild must reset the health mirror true"
1553            );
1554            // The rebase took the primary's snapshot lineage whole: its
1555            // policy is in force, and the forked change is gone, not kept
1556            // as the newest value seen.
1557            assert_eq!(
1558                control.lineage_ack_policy.load(),
1559                Some(PRIMARY_LINEAGE_POLICY),
1560                "the resync must adopt the primary snapshot's policy and drop the divergent tail's"
1561            );
1562            // The rebuilt pipeline's shadow starts from that cell: its
1563            // own snapshot, past the transferred one (at 4), records the
1564            // adopted policy although the stream since carried no change.
1565            let deadline = std::time::Instant::now() + Duration::from_secs(30);
1566            let shadow_lineage = loop {
1567                // No snapshot past 4 yet (or none at all) is "not yet".
1568                if let Ok((_, header)) =
1569                    melin_transport_core::snapshot::load::<App>(&replica_snapshot_check)
1570                    && header.sequence == 5
1571                {
1572                    break header.lineage;
1573                }
1574                assert!(
1575                    std::time::Instant::now() < deadline,
1576                    "the rebuilt shadow never snapshotted entry 5"
1577                );
1578                std::thread::sleep(Duration::from_millis(10));
1579            };
1580            assert_eq!(
1581                shadow_lineage.ack_policy,
1582                Some(PRIMARY_LINEAGE_POLICY),
1583                "the replica's shadow must seed its lineage from the adopted policy"
1584            );
1585
1586            // --- Clean shutdown; the receiver must return Ok(None) —
1587            // the divergence never escaped as a process-fatal error.
1588            shutdown.store(true, Ordering::Relaxed);
1589            let result = replica.join().expect("replica thread panicked");
1590            assert_eq!(result, Ok(true), "receiver must exit cleanly via shutdown");
1591
1592            // The forked lineage was archived (never deleted), and the
1593            // live journal is the re-seeded one.
1594            assert!(
1595                dir.path().join("replica.journal.divergent.0").exists(),
1596                "divergent lineage must be archived"
1597            );
1598            assert!(replica_journal.exists(), "re-seeded live journal");
1599        }
1600
1601        /// A resync whose snapshot transfer drops mid-flight must retry
1602        /// as a fresh replica with NO leftover in-memory state. On the
1603        /// in-process divergence repair path `recover_replica_state`
1604        /// leaves `app`/`journal_writer` populated; the resync arm
1605        /// archives the live journal (renaming it aside) before the
1606        /// transfer. If the transfer then fails and those handles are not
1607        /// nulled, the stale writer — its backing file now under the
1608        /// `.divergent.<n>` archive — survives the fresh-replica create
1609        /// gate (`journal_writer.is_none()` false) and is rebuilt into the
1610        /// next pipeline, so the live journal is never recreated and the
1611        /// new session streams onto an archived-away inode. This pins the
1612        /// fix (mirrors the DPDK receiver): after the failed transfer the
1613        /// retry creates a fresh live journal from the StreamStart lineage.
1614        ///
1615        /// The transfer fails AFTER the snapshot was received but before
1616        /// the segment seed (the primary drops), so the snapshot's lineage
1617        /// must not be adopted: a fresh replica holds no lineage, so no
1618        /// ack policy.
1619        #[test]
1620        fn resync_transfer_failure_rebuilds_clean_not_over_stale_writer() {
1621            let dir = tempfile::tempdir().expect("tempdir");
1622
1623            // Scripted primary: entries 1..=3 and a snapshot at 2 whose
1624            // lineage records a policy. Session 2 sends that snapshot and
1625            // drops before the seed; session 3 streams from genesis on
1626            // the same lineage identity.
1627            let primary_journal = dir.path().join("primary.journal");
1628            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
1629            let mut chain_at_2 = [0u8; 32];
1630            for v in 1..=3u64 {
1631                w.append(&JournalEvent::App(EvtAdd(v))).expect("append");
1632                if v == 2 {
1633                    chain_at_2 = w.chain_hash().expect("chain");
1634                }
1635            }
1636            drop(w);
1637            melin_transport_core::snapshot::save::<App>(
1638                &App,
1639                WireSeq::new(2),
1640                chain_at_2,
1641                melin_transport_core::lineage::Lineage {
1642                    epoch: 0,
1643                    ack_policy: Some(PRIMARY_LINEAGE_POLICY),
1644                },
1645                Some(0),
1646                &primary_journal.with_extension("snapshot"),
1647            )
1648            .expect("save snapshot");
1649            let origin = lineage_origin(&primary_journal).expect("lineage");
1650            let (lineage_start, lineage_anchor, lineage_genesis) = (
1651                origin.starting_sequence,
1652                origin.anchor_hash,
1653                origin.genesis_entries,
1654            );
1655
1656            let authorized_keys = replica_auth();
1657
1658            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1659            listener.set_nonblocking(true).expect("nonblocking");
1660            let addr = listener.local_addr().expect("addr");
1661
1662            let replica_journal = dir.path().join("replica.journal");
1663            let replica_snapshot = dir.path().join("replica.snapshot");
1664            let shutdown = Arc::new(AtomicBool::new(false));
1665            let control = crate::replication::ReplicaControlPlane::new();
1666            let cores = crate::layout::PipelineCores::unpinned();
1667            let replica = {
1668                let journal = replica_journal.clone();
1669                let shutdown = Arc::clone(&shutdown);
1670                let control = control.clone();
1671                std::thread::spawn(move || -> Result<bool, String> {
1672                    run_receiver::<App>(
1673                        addr,
1674                        &journal,
1675                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
1676                        &shutdown,
1677                        &control,
1678                        3_600_000,
1679                        replica_snapshot,
1680                        cores,
1681                        melin_journal::StagingMode::ZeroFill,
1682                        Duration::ZERO,
1683                        64,
1684                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
1685                        &(),
1686                    )
1687                    .map(|state| state.is_none())
1688                    .map_err(|e| e.to_string())
1689                })
1690            };
1691
1692            // Helper: stream a single event (seq 1) over an established
1693            // session and wait for its ack.
1694            let stream_one = |s: &mut TcpStream, buf: &mut Vec<u8>| {
1695                buf.clear();
1696                encode_stream_start(0, lineage_start, lineage_anchor, lineage_genesis, 0, buf);
1697                s.write_all(buf).expect("StreamStart");
1698                buf.clear();
1699                melin_transport_core::replication_wire::encode_input_batch(
1700                    &[InputSlot::<EvtAdd> {
1701                        connection_id: 0,
1702                        key_hash: 0,
1703                        sequence: 1,
1704                        timestamp_ns: 1,
1705                        event: JournalEvent::App(EvtAdd(1)),
1706                        publish_ts: Default::default(),
1707                        recv_ts: Default::default(),
1708                    }],
1709                    buf,
1710                )
1711                .expect("encode InputBatch");
1712                s.write_all(buf).expect("InputBatch");
1713            };
1714
1715            // --- Session 1: fresh sync, event 1, poisoned rotation —
1716            // the journal stage detects divergence and the receiver
1717            // repairs in-process (`recover_replica_state` repopulates
1718            // `app`/`journal_writer`).
1719            let mut buf = Vec::new();
1720            let mut s1 = accept_within(&listener, 30);
1721            let mut s1r = s1.try_clone().expect("clone");
1722            authenticate_replica(&mut s1r, &authorized_keys).expect("auth 1");
1723            match read_replica_msg(&mut s1) {
1724                ReplicaMessage::Handshake(h) => assert_eq!(h.last_sequence, 0, "fresh"),
1725                other => panic!("expected Handshake, got {other:?}"),
1726            }
1727            stream_one(&mut s1, &mut buf);
1728            wait_for_ack(&mut s1, 1);
1729            buf.clear();
1730            encode_rotate(1, &[0xEE; 32], &mut buf);
1731            s1.write_all(&buf).expect("Rotate");
1732
1733            // --- Session 2: reconnect at the recovered position (seq 1),
1734            // verdict HashMismatch (archives the live journal aside), send
1735            // the snapshot, then DROP the connection before the segment
1736            // seed — the transfer fails after the snapshot is received but
1737            // before the seed, so its lineage must not be adopted, and the
1738            // receiver must retry as a fresh replica.
1739            let mut s2 = accept_within(&listener, 30);
1740            let mut s2r = s2.try_clone().expect("clone");
1741            authenticate_replica(&mut s2r, &authorized_keys).expect("auth 2");
1742            match read_replica_msg(&mut s2) {
1743                ReplicaMessage::Handshake(h) => assert_eq!(
1744                    h.last_sequence, 1,
1745                    "reconnect carries the recovered forked position"
1746                ),
1747                other => panic!("expected Handshake, got {other:?}"),
1748            }
1749            buf.clear();
1750            encode_hash_mismatch(&mut buf);
1751            s2.write_all(&buf).expect("HashMismatch");
1752            s2.flush().expect("flush");
1753            {
1754                let transfer_shutdown = AtomicBool::new(false);
1755                // Every publish is one whole frame (length prefix, then
1756                // payload): refuse the seed's, so the snapshot is the last
1757                // thing the replica receives.
1758                let mut publish = |b: &[u8]| -> std::io::Result<()> {
1759                    if matches!(
1760                        decode_primary_message(&b[4..]),
1761                        Ok(PrimaryMessage::SegmentSeedBegin { .. })
1762                    ) {
1763                        return Err(std::io::Error::other("scripted drop before the seed"));
1764                    }
1765                    s2.write_all(b)?;
1766                    s2.flush()
1767                };
1768                snapshot_transfer_with::<EvtAdd>(
1769                    &primary_journal,
1770                    &mut publish,
1771                    &transfer_shutdown,
1772                )
1773                .expect_err("the scripted primary drops before the seed");
1774            }
1775            // Both fds reference the same socket (try_clone dups) — drop
1776            // both to send FIN so the replica's SegmentSeedBegin read hits
1777            // EOF.
1778            drop(s2);
1779            drop(s2r);
1780
1781            // --- Session 3: the receiver reconnects as a FRESH replica
1782            // (the archived lineage's position is meaningless, so
1783            // `last_sequence` is 0). It must recreate a live journal from
1784            // the StreamStart lineage, NOT rebuild over the stale writer.
1785            let mut s3 = accept_within(&listener, 30);
1786            let mut s3r = s3.try_clone().expect("clone");
1787            authenticate_replica(&mut s3r, &authorized_keys).expect("auth 3");
1788            match read_replica_msg(&mut s3) {
1789                ReplicaMessage::Handshake(h) => assert_eq!(
1790                    h.last_sequence, 0,
1791                    "after a failed transfer the retry handshakes as fresh"
1792                ),
1793                other => panic!("expected Handshake, got {other:?}"),
1794            }
1795            // The archive cleared the cell before the transfer began, a
1796            // failed transfer adopts nothing, and no recovery runs on the
1797            // Retry path, so this read is deterministic.
1798            assert_eq!(
1799                control.lineage_ack_policy.load(),
1800                None,
1801                "a failed transfer must not adopt the snapshot's policy"
1802            );
1803            stream_one(&mut s3, &mut buf);
1804            wait_for_ack(&mut s3, 1);
1805
1806            shutdown.store(true, Ordering::Relaxed);
1807            let result = replica.join().expect("replica thread panicked");
1808            assert_eq!(result, Ok(true), "receiver exits cleanly via shutdown");
1809
1810            // The forked lineage stays archived, and a fresh live journal
1811            // was recreated from the lineage. With the bug, the stale
1812            // writer would be reused and this path would not exist (its
1813            // inode stranded under `.divergent.0`).
1814            assert!(
1815                dir.path().join("replica.journal.divergent.0").exists(),
1816                "forked lineage archived, never deleted"
1817            );
1818            assert!(
1819                replica_journal.exists(),
1820                "fresh live journal recreated after the failed transfer"
1821            );
1822        }
1823
1824        /// The in-process repair budget is one per process lifetime: a
1825        /// second mid-stream divergence is systematic (corruption or a
1826        /// serious bug that the first re-seed did not cure) and the
1827        /// receiver must exit hard instead of looping — each repair
1828        /// cycle archives a full journal copy, and a repair loop would
1829        /// fill the disk while masking the underlying fault.
1830        #[test]
1831        fn second_mid_stream_divergence_exits_hard() {
1832            let dir = tempfile::tempdir().expect("tempdir");
1833
1834            let primary_journal = dir.path().join("primary.journal");
1835            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
1836            let mut chain_at_4 = [0u8; 32];
1837            for v in 1..=5u64 {
1838                w.append(&JournalEvent::App(EvtAdd(v))).expect("append");
1839                if v == 2 {
1840                    w.rotate_segment().expect("rotate");
1841                }
1842                if v == 4 {
1843                    chain_at_4 = w.chain_hash().expect("chain");
1844                }
1845            }
1846            drop(w);
1847            melin_transport_core::snapshot::save::<App>(
1848                &App,
1849                WireSeq::new(4),
1850                chain_at_4,
1851                melin_transport_core::lineage::Lineage::GENESIS,
1852                Some(0),
1853                &primary_journal.with_extension("snapshot"),
1854            )
1855            .expect("save snapshot");
1856            let origin = lineage_origin(&primary_journal).expect("lineage");
1857            let (lineage_start, lineage_anchor, lineage_genesis) = (
1858                origin.starting_sequence,
1859                origin.anchor_hash,
1860                origin.genesis_entries,
1861            );
1862
1863            let authorized_keys = replica_auth();
1864
1865            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1866            listener.set_nonblocking(true).expect("nonblocking");
1867            let addr = listener.local_addr().expect("addr");
1868
1869            let replica_journal = dir.path().join("replica.journal");
1870            let replica_snapshot = dir.path().join("replica.snapshot");
1871            let shutdown = Arc::new(AtomicBool::new(false));
1872            let control = crate::replication::ReplicaControlPlane::new();
1873            let cores = crate::layout::PipelineCores::unpinned();
1874            let replica = {
1875                let journal = replica_journal.clone();
1876                let shutdown = Arc::clone(&shutdown);
1877                let control = control.clone();
1878                std::thread::spawn(move || -> Result<bool, String> {
1879                    run_receiver::<App>(
1880                        addr,
1881                        &journal,
1882                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
1883                        &shutdown,
1884                        &control,
1885                        3_600_000,
1886                        replica_snapshot,
1887                        cores,
1888                        melin_journal::StagingMode::ZeroFill,
1889                        Duration::ZERO,
1890                        64,
1891                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
1892                        &(),
1893                    )
1894                    .map(|state| state.is_none())
1895                    .map_err(|e| e.to_string())
1896                })
1897            };
1898
1899            // Session 1: fresh sync, one event, poisoned rotation.
1900            let mut s1 = accept_within(&listener, 30);
1901            let mut s1r = s1.try_clone().expect("clone");
1902            authenticate_replica(&mut s1r, &authorized_keys).expect("auth 1");
1903            match read_replica_msg(&mut s1) {
1904                ReplicaMessage::Handshake(h) => assert_eq!(h.last_sequence, 0),
1905                other => panic!("expected Handshake, got {other:?}"),
1906            }
1907            let mut buf = Vec::new();
1908            encode_stream_start(
1909                0,
1910                lineage_start,
1911                lineage_anchor,
1912                lineage_genesis,
1913                0,
1914                &mut buf,
1915            );
1916            s1.write_all(&buf).expect("StreamStart 1");
1917            buf.clear();
1918            melin_transport_core::replication_wire::encode_input_batch(
1919                &[InputSlot::<EvtAdd> {
1920                    connection_id: 0,
1921                    key_hash: 0,
1922                    sequence: 1,
1923                    timestamp_ns: 1,
1924                    event: JournalEvent::App(EvtAdd(1)),
1925                    publish_ts: Default::default(),
1926                    recv_ts: Default::default(),
1927                }],
1928                &mut buf,
1929            )
1930            .expect("encode InputBatch");
1931            s1.write_all(&buf).expect("InputBatch");
1932            wait_for_ack(&mut s1, 1);
1933            buf.clear();
1934            encode_rotate(1, &[0xEE; 32], &mut buf);
1935            s1.write_all(&buf).expect("Rotate 1");
1936
1937            // Session 2: in-process repair (the one allowed), then a
1938            // SECOND poisoned rotation after streaming resumes.
1939            let mut s2 = accept_within(&listener, 30);
1940            let mut s2r = s2.try_clone().expect("clone");
1941            authenticate_replica(&mut s2r, &authorized_keys).expect("auth 2");
1942            match read_replica_msg(&mut s2) {
1943                ReplicaMessage::Handshake(h) => assert_eq!(h.last_sequence, 1),
1944                other => panic!("expected Handshake, got {other:?}"),
1945            }
1946            buf.clear();
1947            encode_hash_mismatch(&mut buf);
1948            s2.write_all(&buf).expect("HashMismatch");
1949            let transfer_shutdown = AtomicBool::new(false);
1950            {
1951                let mut publish = |b: &[u8]| -> std::io::Result<()> {
1952                    s2.write_all(b)?;
1953                    s2.flush()
1954                };
1955                snapshot_transfer_with::<EvtAdd>(
1956                    &primary_journal,
1957                    &mut publish,
1958                    &transfer_shutdown,
1959                )
1960                .expect("snapshot transfer");
1961            }
1962            buf.clear();
1963            melin_transport_core::replication_wire::encode_input_batch(
1964                &[InputSlot::<EvtAdd> {
1965                    connection_id: 0,
1966                    key_hash: 0,
1967                    sequence: 6,
1968                    timestamp_ns: 6,
1969                    event: JournalEvent::App(EvtAdd(6)),
1970                    publish_ts: Default::default(),
1971                    recv_ts: Default::default(),
1972                }],
1973                &mut buf,
1974            )
1975            .expect("encode InputBatch");
1976            s2.write_all(&buf).expect("InputBatch 6");
1977            let mut s2_acks = s2.try_clone().expect("clone");
1978            wait_for_ack(&mut s2_acks, 6);
1979            buf.clear();
1980            encode_rotate(6, &[0xEE; 32], &mut buf);
1981            s2.write_all(&buf).expect("Rotate 2");
1982
1983            // No third connection: the receiver must give up and exit
1984            // with an error naming the recurrence.
1985            let result = replica.join().expect("replica thread panicked");
1986            let err = result.expect_err("second divergence must exit hard");
1987            assert!(
1988                err.contains("divergence recurred"),
1989                "error must name the recurrence: {err}"
1990            );
1991        }
1992
1993        // -------------------------------------------------------------
1994        // Promotion teardown helper (`take_pipeline_for_promotion`) — the
1995        // no-live-pipeline decision branches. With no pipeline to tear
1996        // down it hands back the warm state held in the receiver's locals
1997        // (a clean promotion) or errors when none is present. Colocated
1998        // here for the `App` + `BufferedWriter` fixtures above; the
1999        // Clean-pipeline branch needs a live pipeline (failover IT).
2000        // -------------------------------------------------------------
2001        type PromoteHandles =
2002            crate::replication::ReplicaPipelineHandles<App, BufferedWriter<EvtAdd>>;
2003
2004        #[test]
2005        fn promotion_hands_back_local_state_when_present() {
2006            let dir = tempfile::tempdir().unwrap();
2007            let mut pipeline: Option<PromoteHandles> = None;
2008            let mut app = Some(App);
2009            let mut journal_writer =
2010                Some(BufferedWriter::<EvtAdd>::create(&dir.path().join("p.journal")).unwrap());
2011
2012            let result = crate::replication::take_pipeline_for_promotion(
2013                &mut pipeline,
2014                &mut app,
2015                &mut journal_writer,
2016            );
2017
2018            assert!(matches!(result, Ok(Some(_))), "warm state must be promoted");
2019            assert!(
2020                app.is_none() && journal_writer.is_none(),
2021                "state must be moved into the result"
2022            );
2023        }
2024
2025        #[test]
2026        fn promotion_errs_when_no_local_state() {
2027            let mut pipeline: Option<PromoteHandles> = None;
2028            let mut app: Option<App> = None;
2029            let mut journal_writer: Option<BufferedWriter<EvtAdd>> = None;
2030
2031            let result = crate::replication::take_pipeline_for_promotion(
2032                &mut pipeline,
2033                &mut app,
2034                &mut journal_writer,
2035            );
2036
2037            // Not `expect_err` — the Ok payload (App) isn't `Debug`.
2038            let err = match result {
2039                Err(e) => e,
2040                Ok(_) => panic!("a promote with nothing to promote must error"),
2041            };
2042            assert!(
2043                err.to_string().contains("no local state available"),
2044                "{err}"
2045            );
2046        }
2047
2048        #[test]
2049        fn promotion_errs_on_partial_state() {
2050            // Application present, writer missing — not a usable hand-off.
2051            let mut pipeline: Option<PromoteHandles> = None;
2052            let mut app = Some(App);
2053            let mut journal_writer: Option<BufferedWriter<EvtAdd>> = None;
2054
2055            let result = crate::replication::take_pipeline_for_promotion(
2056                &mut pipeline,
2057                &mut app,
2058                &mut journal_writer,
2059            );
2060
2061            assert!(result.is_err(), "partial state is not a valid promotion");
2062        }
2063    }
2064
2065    // -----------------------------------------------------------------
2066    // Shutdown during reconnect backoff — end to end against a scripted
2067    // primary. Needs real persistence (the pipeline journals the one
2068    // streamed event); no chain verdicts involved, so no hash-chain.
2069    // -----------------------------------------------------------------
2070    #[cfg(not(feature = "no-persist"))]
2071    mod backoff_shutdown {
2072        use super::super::super::auth::authenticate_replica;
2073        use super::scripted::*;
2074        use super::*;
2075        use melin_journal::{BufferedWriter, JournalEvent, JournalWrite};
2076        use melin_transport_core::replication::catchup::lineage_origin;
2077        use melin_transport_core::replication::protocol::encode_stream_start;
2078        use std::sync::Arc;
2079        use std::sync::atomic::AtomicBool;
2080
2081        /// Names of every live thread in this process, via
2082        /// `/proc/self/task` (Linux-only, like the runtime itself).
2083        fn live_thread_names() -> Vec<String> {
2084            std::fs::read_dir("/proc/self/task")
2085                .expect("read /proc/self/task")
2086                .filter_map(|entry| {
2087                    // A thread may exit between readdir and the comm
2088                    // read — skipping it is the correct outcome.
2089                    let entry = entry.ok()?;
2090                    let name = std::fs::read_to_string(entry.path().join("comm")).ok()?;
2091                    Some(name.trim().to_string())
2092                })
2093                .collect()
2094        }
2095
2096        /// Set in the child process the test below runs its body in.
2097        const ISOLATED: &str = "MELIN_TEST_BACKOFF_SHUTDOWN_ISOLATED";
2098
2099        /// Shutdown while the receiver waits out a reconnect backoff must
2100        /// tear the live pipeline down — join every stage thread — rather
2101        /// than abandon it. Pins the fix for the connect-failure arm that
2102        /// returned without teardown (stages left detached mid-run, the
2103        /// journal writer never closed).
2104        ///
2105        /// The check reads every thread in the process by name, so it holds
2106        /// only where no other test shares the process: under plain `cargo
2107        /// test`, other tests' pipelines run stages of the same names, and
2108        /// the check failed on them. So the body runs in a child process of
2109        /// its own, this test binary filtered to this one test (nextest
2110        /// already gives each test a process; there it costs one re-exec).
2111        #[test]
2112        fn shutdown_during_reconnect_backoff_joins_pipeline_threads() {
2113            if std::env::var_os(ISOLATED).is_some() {
2114                return backoff_shutdown_body();
2115            }
2116            // The test's path as libtest names it: the module path without
2117            // the crate's name.
2118            let module = module_path!();
2119            let path = module.split_once("::").map_or(module, |(_, rest)| rest);
2120            let test = format!("{path}::shutdown_during_reconnect_backoff_joins_pipeline_threads");
2121            let output = std::process::Command::new(std::env::current_exe().expect("current_exe"))
2122                .args(["--exact", &test, "--test-threads=1", "--nocapture"])
2123                .env(ISOLATED, "1")
2124                .output()
2125                .expect("run the test in a process of its own");
2126            let stdout = String::from_utf8_lossy(&output.stdout);
2127            let stderr = String::from_utf8_lossy(&output.stderr);
2128            // A filter that matched nothing would also exit 0: require the
2129            // one test to have run and passed.
2130            assert!(
2131                output.status.success() && stdout.contains("1 passed"),
2132                "the isolated run failed ({}):\n{stdout}\n{stderr}",
2133                output.status
2134            );
2135        }
2136
2137        fn backoff_shutdown_body() {
2138            let dir = tempfile::tempdir().expect("tempdir");
2139
2140            // Minimal primary journal — only its lineage identity is
2141            // needed for the StreamStart the fresh replica accepts.
2142            let primary_journal = dir.path().join("primary.journal");
2143            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
2144            w.append(&JournalEvent::App(EvtAdd(1))).expect("append");
2145            drop(w);
2146            let origin = lineage_origin(&primary_journal).expect("lineage");
2147            let (lineage_start, lineage_anchor, lineage_genesis) = (
2148                origin.starting_sequence,
2149                origin.anchor_hash,
2150                origin.genesis_entries,
2151            );
2152
2153            let authorized_keys = replica_auth();
2154            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2155            listener.set_nonblocking(true).expect("nonblocking");
2156            let addr = listener.local_addr().expect("addr");
2157
2158            let shutdown = Arc::new(AtomicBool::new(false));
2159            let control = crate::replication::ReplicaControlPlane::new();
2160            let replica = {
2161                let journal = dir.path().join("replica.journal");
2162                let snapshot = dir.path().join("replica.snapshot");
2163                let shutdown = Arc::clone(&shutdown);
2164                let control = control.clone();
2165                std::thread::spawn(move || -> Result<bool, String> {
2166                    run_receiver::<App>(
2167                        addr,
2168                        &journal,
2169                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
2170                        &shutdown,
2171                        &control,
2172                        3_600_000,
2173                        snapshot,
2174                        crate::layout::PipelineCores::unpinned(),
2175                        melin_journal::StagingMode::ZeroFill,
2176                        Duration::ZERO,
2177                        64,
2178                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
2179                        &(),
2180                    )
2181                    // ReceiverResult's error is !Send — stringify for join().
2182                    .map(|state| state.is_none())
2183                    .map_err(|e| e.to_string())
2184                })
2185            };
2186
2187            // Session 1: bring the pipeline up for real — stream one
2188            // event and wait for its ack (acked ⇒ journaled ⇒ the stage
2189            // threads exist).
2190            let mut s1 = accept_within(&listener, 30);
2191            let mut s1r = s1.try_clone().expect("clone");
2192            authenticate_replica(&mut s1r, &authorized_keys).expect("auth");
2193            match read_replica_msg(&mut s1) {
2194                ReplicaMessage::Handshake(h) => {
2195                    assert_eq!(h.last_sequence, 0, "fresh replica handshake")
2196                }
2197                other => panic!("expected Handshake, got {other:?}"),
2198            }
2199            let mut buf = Vec::new();
2200            encode_stream_start(
2201                0,
2202                lineage_start,
2203                lineage_anchor,
2204                lineage_genesis,
2205                0,
2206                &mut buf,
2207            );
2208            s1.write_all(&buf).expect("StreamStart");
2209            buf.clear();
2210            melin_transport_core::replication_wire::encode_input_batch(
2211                &[InputSlot::<EvtAdd> {
2212                    connection_id: 0,
2213                    key_hash: 0,
2214                    sequence: 1,
2215                    timestamp_ns: 1,
2216                    event: JournalEvent::App(EvtAdd(1)),
2217                    publish_ts: Default::default(),
2218                    recv_ts: Default::default(),
2219                }],
2220                &mut buf,
2221            )
2222            .expect("encode InputBatch");
2223            s1.write_all(&buf).expect("InputBatch");
2224            wait_for_ack(&mut s1, 1);
2225
2226            // Kill the primary completely — both socket halves and the
2227            // listener — so every reconnect attempt is refused and the
2228            // receiver settles into the connect-failure backoff.
2229            drop(s1);
2230            drop(s1r);
2231            drop(listener);
2232            // Past the 1s post-disconnect backoff, inside the 2s
2233            // connect-failure backoff sleep. (Any other point in the
2234            // reconnect loop must satisfy the assertions too — this
2235            // timing just targets the arm that historically leaked.)
2236            std::thread::sleep(Duration::from_millis(1500));
2237
2238            shutdown.store(true, Ordering::Relaxed);
2239            let deadline = Instant::now() + Duration::from_secs(10);
2240            while !replica.is_finished() {
2241                assert!(
2242                    Instant::now() < deadline,
2243                    "receiver did not exit after shutdown"
2244                );
2245                std::thread::sleep(Duration::from_millis(20));
2246            }
2247            let result = replica.join().expect("replica thread panicked");
2248            assert_eq!(result, Ok(true), "receiver must exit cleanly via shutdown");
2249
2250            // The stage threads are named at spawn, and a leaked stage
2251            // spins forever — absence within the deadline proves the
2252            // teardown joined them. This process runs no other test (see
2253            // above), so every stage thread here is the receiver's.
2254            let deadline = Instant::now() + Duration::from_secs(10);
2255            loop {
2256                let names = live_thread_names();
2257                if !names
2258                    .iter()
2259                    .any(|n| n == "journal-seq" || n == "matching" || n == "drain")
2260                {
2261                    break;
2262                }
2263                assert!(
2264                    Instant::now() < deadline,
2265                    "pipeline threads still alive after shutdown: {names:?}"
2266                );
2267                std::thread::sleep(Duration::from_millis(50));
2268            }
2269        }
2270    }
2271
2272    // -----------------------------------------------------------------
2273    // The resume point a reconnecting replica hands the primary, end to
2274    // end against a scripted primary. Needs real persistence: the claim
2275    // is the journal's durable position.
2276    // -----------------------------------------------------------------
2277    #[cfg(not(feature = "no-persist"))]
2278    mod reconnect_resume {
2279        use super::super::super::auth::authenticate_replica;
2280        use super::scripted::*;
2281        use super::*;
2282        use melin_journal::{BufferedWriter, JournalEvent, JournalWrite};
2283        use melin_transport_core::replication::catchup::lineage_origin;
2284        use melin_transport_core::replication::protocol::encode_stream_start;
2285        use std::sync::Arc;
2286        use std::sync::atomic::AtomicBool;
2287
2288        /// A primary journal holding one entry: its path and lineage
2289        /// identity, for the `StreamStart` a replica accepts.
2290        fn primary_journal(
2291            dir: &std::path::Path,
2292        ) -> (std::path::PathBuf, melin_journal::FileHeaderInfo) {
2293            let path = dir.join("primary.journal");
2294            let mut w = BufferedWriter::<EvtAdd>::create(&path).expect("create");
2295            w.append(&JournalEvent::App(EvtAdd(1))).expect("append");
2296            drop(w);
2297            let lineage = lineage_origin(&path).expect("lineage");
2298            (path, lineage)
2299        }
2300
2301        /// Run the receiver against `addr` on `dir/replica.journal`,
2302        /// under `group_commit_delay`, until `shutdown`.
2303        fn spawn_replica(
2304            dir: &std::path::Path,
2305            addr: std::net::SocketAddr,
2306            group_commit_delay: Duration,
2307            shutdown: &Arc<AtomicBool>,
2308        ) -> std::thread::JoinHandle<Result<bool, String>> {
2309            let journal = dir.join("replica.journal");
2310            let snapshot = dir.join("replica.snapshot");
2311            let shutdown = Arc::clone(shutdown);
2312            std::thread::spawn(move || -> Result<bool, String> {
2313                run_receiver::<App>(
2314                    addr,
2315                    &journal,
2316                    &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
2317                    &shutdown,
2318                    &crate::replication::ReplicaControlPlane::new(),
2319                    3_600_000,
2320                    snapshot,
2321                    crate::layout::PipelineCores::unpinned(),
2322                    melin_journal::StagingMode::ZeroFill,
2323                    group_commit_delay,
2324                    64,
2325                    Arc::new(melin_transport_core::fence::FenceState::new(0)),
2326                    &(),
2327                )
2328                // ReceiverResult's error is !Send: stringify for join().
2329                .map(|state| state.is_none())
2330                .map_err(|e| e.to_string())
2331            })
2332        }
2333
2334        /// Accept the replica's next connection, authenticate it and
2335        /// return its handshake with the open stream.
2336        fn next_handshake(
2337            listener: &std::net::TcpListener,
2338        ) -> (TcpStream, TcpStream, crate::replication::Handshake) {
2339            let mut s = accept_within(listener, 30);
2340            let mut sr = s.try_clone().expect("clone");
2341            authenticate_replica(&mut sr, &replica_auth()).expect("auth");
2342            match read_replica_msg(&mut s) {
2343                ReplicaMessage::Handshake(h) => (s, sr, h),
2344                other => panic!("expected Handshake, got {other:?}"),
2345            }
2346        }
2347
2348        /// Answer a handshake: stream from `start` on the given lineage.
2349        fn stream_start(
2350            stream: &mut TcpStream,
2351            start: u64,
2352            lineage: melin_journal::FileHeaderInfo,
2353        ) {
2354            let mut buf = Vec::new();
2355            encode_stream_start(
2356                start,
2357                lineage.starting_sequence,
2358                lineage.anchor_hash,
2359                lineage.genesis_entries,
2360                0,
2361                &mut buf,
2362            );
2363            stream.write_all(&buf).expect("StreamStart");
2364        }
2365
2366        /// One streamed entry at `sequence`, as the primary frames it.
2367        fn entry(sequence: u64) -> InputSlot<EvtAdd> {
2368            InputSlot::<EvtAdd> {
2369                connection_id: 0,
2370                key_hash: 0,
2371                sequence,
2372                timestamp_ns: sequence,
2373                event: JournalEvent::App(EvtAdd(sequence)),
2374                publish_ts: Default::default(),
2375                recv_ts: Default::default(),
2376            }
2377        }
2378
2379        fn send_entries(stream: &mut TcpStream, sequences: &[u64]) {
2380            let slots: Vec<_> = sequences.iter().map(|&s| entry(s)).collect();
2381            let mut buf = Vec::new();
2382            melin_transport_core::replication_wire::encode_input_batch(&slots, &mut buf)
2383                .expect("encode InputBatch");
2384            stream.write_all(&buf).expect("InputBatch");
2385        }
2386
2387        fn stop(replica: std::thread::JoinHandle<Result<bool, String>>, shutdown: &AtomicBool) {
2388            shutdown.store(true, Ordering::Relaxed);
2389            let deadline = Instant::now() + Duration::from_secs(10);
2390            while !replica.is_finished() {
2391                assert!(
2392                    Instant::now() < deadline,
2393                    "receiver did not exit after shutdown"
2394                );
2395                std::thread::sleep(Duration::from_millis(20));
2396            }
2397            let result = replica.join().expect("replica thread panicked");
2398            assert_eq!(result, Ok(true), "receiver must exit cleanly via shutdown");
2399        }
2400
2401        /// A session ends on a stream gap with two received entries still
2402        /// unwritten: the group-commit delay holds them past the reconnect
2403        /// backoff. The next handshake claims them, once written, rather
2404        /// than the older durable position, from which the primary would
2405        /// resend them and the replica would take them a second time.
2406        #[test]
2407        fn a_reconnect_claims_entries_received_before_the_drop() {
2408            let dir = tempfile::tempdir().expect("tempdir");
2409            let (_, lineage) = primary_journal(dir.path());
2410            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2411            listener.set_nonblocking(true).expect("nonblocking");
2412            let shutdown = Arc::new(AtomicBool::new(false));
2413            // Longer than the 1 s backoff after a stream gap, with room
2414            // to spare: without the wait, the reconnect lands while
2415            // entries 2 and 3 are still unwritten.
2416            let delay = Duration::from_millis(2_500);
2417            let replica = spawn_replica(
2418                dir.path(),
2419                listener.local_addr().expect("addr"),
2420                delay,
2421                &shutdown,
2422            );
2423
2424            let (mut s1, _s1r, h1) = next_handshake(&listener);
2425            assert_eq!(h1.last_sequence, 0, "fresh replica handshake");
2426            stream_start(&mut s1, 0, lineage);
2427            // Entry 1 made durable first, so the durable position the
2428            // handshake could wrongly claim is 1, not the fresh zero.
2429            send_entries(&mut s1, &[1]);
2430            wait_for_ack(&mut s1, 1);
2431            // Entries 2 and 3 are taken, then 5 breaks contiguity: the
2432            // session ends without waiting for the ones it took.
2433            send_entries(&mut s1, &[2, 3, 5]);
2434
2435            let (mut s2, _s2r, h2) = next_handshake(&listener);
2436            assert_eq!(
2437                h2.last_sequence, 3,
2438                "the reconnect must claim the entries received before the drop"
2439            );
2440            // Streaming again, so shutdown finds the receiver in a session
2441            // rather than waiting on the primary's answer.
2442            stream_start(&mut s2, h2.last_sequence, lineage);
2443            stop(replica, &shutdown);
2444        }
2445
2446        /// A failed snapshot transfer backs off before redialing, and the
2447        /// socket must be closed before that sleep, not after it: held
2448        /// through the backoff, it ties up a primary slot on a dead session.
2449        #[test]
2450        fn a_failed_resync_transfer_closes_the_socket_before_backing_off() {
2451            use melin_transport_core::replication::protocol::encode_need_snapshot;
2452
2453            let dir = tempfile::tempdir().expect("tempdir");
2454            let (_, lineage) = primary_journal(dir.path());
2455            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2456            listener.set_nonblocking(true).expect("nonblocking");
2457            let shutdown = Arc::new(AtomicBool::new(false));
2458            let replica = spawn_replica(
2459                dir.path(),
2460                listener.local_addr().expect("addr"),
2461                Duration::ZERO,
2462                &shutdown,
2463            );
2464
2465            let (mut s1, _s1r, _) = next_handshake(&listener);
2466            // NeedSnapshot, then anything but the SnapshotBegin the
2467            // transfer expects: the transfer fails and the receiver retries.
2468            let mut buf = Vec::new();
2469            encode_need_snapshot(&mut buf);
2470            encode_need_snapshot(&mut buf);
2471            s1.write_all(&buf).expect("NeedSnapshot x2");
2472
2473            // The first reconnect backoff is 1 s; the close must land well
2474            // inside it.
2475            s1.set_read_timeout(Some(Duration::from_millis(700)))
2476                .expect("read timeout");
2477            let mut byte = [0u8; 1];
2478            match s1.read(&mut byte) {
2479                Ok(0) => {}
2480                Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => {}
2481                other => panic!("socket still open during the backoff: {other:?}"),
2482            }
2483
2484            // The retry dials again, as a fresh replica.
2485            let (mut s2, _s2r, h2) = next_handshake(&listener);
2486            assert_eq!(h2.last_sequence, 0, "retry handshakes as fresh");
2487            stream_start(&mut s2, 0, lineage);
2488            stop(replica, &shutdown);
2489        }
2490
2491        /// The journal stage dies after a session has ended, while the
2492        /// reconnect waits for it to cover the ring: entries 2 and 3 are
2493        /// held by the group-commit delay past the backoff that follows a
2494        /// stream gap, and the chain check announced between them carries
2495        /// a hash the replica's chain cannot match, so the stage fails
2496        /// before any of them is handed to the disk. The loop must take
2497        /// the fatal path there (here, the in-process divergence resync)
2498        /// before dialing, so the next session runs on the pipeline
2499        /// rebuilt from disk and journals what it is sent. Dialing with
2500        /// the dead pipeline instead would claim the same position (entry
2501        /// 1 was durable before the failure) but hand the session a
2502        /// journal stage that can take nothing: it would exit at once and
2503        /// never acknowledge entry 2.
2504        #[cfg(feature = "hash-chain")]
2505        #[test]
2506        fn a_journal_that_dies_while_disconnected_is_torn_down_before_dialing() {
2507            use melin_transport_core::replication::protocol::encode_chain_check;
2508
2509            let dir = tempfile::tempdir().expect("tempdir");
2510            let (_, lineage) = primary_journal(dir.path());
2511            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2512            listener.set_nonblocking(true).expect("nonblocking");
2513            let shutdown = Arc::new(AtomicBool::new(false));
2514            // Longer than the 1 s backoff after a stream gap, with room
2515            // to spare, so the failure lands inside the reconnect's wait.
2516            let delay = Duration::from_millis(2_500);
2517            let replica = spawn_replica(
2518                dir.path(),
2519                listener.local_addr().expect("addr"),
2520                delay,
2521                &shutdown,
2522            );
2523
2524            let (mut s1, _s1r, h1) = next_handshake(&listener);
2525            assert_eq!(h1.last_sequence, 0, "fresh replica handshake");
2526            stream_start(&mut s1, 0, lineage);
2527            send_entries(&mut s1, &[1]);
2528            wait_for_ack(&mut s1, 1);
2529            // Entry 2, a chain check at 2 no replica chain can match,
2530            // entry 3, then 5: the gap ends the session at once, with 2
2531            // and 3 still unwritten.
2532            send_entries(&mut s1, &[2]);
2533            let mut buf = Vec::new();
2534            encode_chain_check(2, &[0xEE; 32], &mut buf);
2535            s1.write_all(&buf).expect("ChainCheck");
2536            send_entries(&mut s1, &[3]);
2537            send_entries(&mut s1, &[5]);
2538
2539            let (mut s2, _s2r, h2) = next_handshake(&listener);
2540            assert_eq!(
2541                h2.last_sequence, 1,
2542                "the first dial after the journal died claims what is on disk"
2543            );
2544            // The discriminating step: only a rebuilt pipeline journals
2545            // and acknowledges entry 2 (after the group-commit delay).
2546            // The dead one would end the session unacknowledged, failing
2547            // the read below on EOF or its timeout.
2548            stream_start(&mut s2, h2.last_sequence, lineage);
2549            send_entries(&mut s2, &[2]);
2550            wait_for_ack(&mut s2, 2);
2551            stop(replica, &shutdown);
2552        }
2553
2554        /// A replica built over a journal that already holds an entry
2555        /// reconnects after a session that journaled nothing (a quiet
2556        /// primary). Its handshake claims that journal, exactly as the
2557        /// first one did, not an empty one.
2558        #[test]
2559        fn a_reconnect_after_a_quiet_session_claims_the_recovered_journal() {
2560            let dir = tempfile::tempdir().expect("tempdir");
2561            let (primary, lineage) = primary_journal(dir.path());
2562            // The replica holds what the primary holds.
2563            std::fs::copy(&primary, dir.path().join("replica.journal")).expect("copy");
2564            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2565            listener.set_nonblocking(true).expect("nonblocking");
2566            let shutdown = Arc::new(AtomicBool::new(false));
2567            let replica = spawn_replica(
2568                dir.path(),
2569                listener.local_addr().expect("addr"),
2570                Duration::ZERO,
2571                &shutdown,
2572            );
2573
2574            let (mut s1, s1r, h1) = next_handshake(&listener);
2575            assert_eq!(h1.last_sequence, 1, "recovered replica handshake");
2576            stream_start(&mut s1, 1, lineage);
2577            // Nothing streamed: the primary goes away.
2578            drop(s1);
2579            drop(s1r);
2580
2581            let (mut s2, _s2r, h2) = next_handshake(&listener);
2582            assert_eq!(
2583                (h2.last_sequence, h2.chain_hash),
2584                (h1.last_sequence, h1.chain_hash),
2585                "the reconnect must claim the journal the pipeline was built over"
2586            );
2587            // Streaming again, so shutdown finds the receiver in a session.
2588            stream_start(&mut s2, h2.last_sequence, lineage);
2589            stop(replica, &shutdown);
2590        }
2591
2592        /// The audit's scenario end to end: an entry damaged on the way
2593        /// still decodes — as a different event — and its CRC is the only
2594        /// thing that can tell. The replica must refuse it before applying,
2595        /// journaling or acknowledging it, drop the session, and reconnect
2596        /// claiming only what it verified; the intact resend is what lands
2597        /// in its journal.
2598        #[test]
2599        fn a_damaged_entry_is_refused_and_fetched_again() {
2600            let dir = tempfile::tempdir().expect("tempdir");
2601            let (_, lineage) = primary_journal(dir.path());
2602            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2603            listener.set_nonblocking(true).expect("nonblocking");
2604            let shutdown = Arc::new(AtomicBool::new(false));
2605            let replica = spawn_replica(
2606                dir.path(),
2607                listener.local_addr().expect("addr"),
2608                Duration::ZERO,
2609                &shutdown,
2610            );
2611
2612            let (mut s1, _s1r, h1) = next_handshake(&listener);
2613            assert_eq!(h1.last_sequence, 0, "fresh replica handshake");
2614            stream_start(&mut s1, 0, lineage);
2615            send_entries(&mut s1, &[1]);
2616            wait_for_ack(&mut s1, 1);
2617
2618            // Entry 2 with one payload bit flipped: `EvtAdd(2)` arrives as
2619            // a perfectly decodable `EvtAdd(3)`.
2620            let mut buf = Vec::new();
2621            melin_transport_core::replication_wire::encode_input_batch(&[entry(2)], &mut buf)
2622                .expect("encode InputBatch");
2623            let payload_at = buf.len() - melin_journal::codec::CRC_SIZE - 8;
2624            buf[payload_at] ^= 0x01;
2625            s1.write_all(&buf).expect("damaged InputBatch");
2626
2627            // Nothing acknowledges entry 2 — not in memory, not on disk —
2628            // before the replica drops the session.
2629            loop {
2630                let mut len_buf = [0u8; 4];
2631                if std::io::Read::read_exact(&mut s1, &mut len_buf).is_err() {
2632                    break;
2633                }
2634                let mut payload = vec![0u8; u32::from_le_bytes(len_buf) as usize];
2635                if std::io::Read::read_exact(&mut s1, &mut payload).is_err() {
2636                    break;
2637                }
2638                if let Ok(ReplicaMessage::Ack(a)) = decode_replica_message(&payload) {
2639                    assert!(
2640                        a.in_memory_sequence < 2 && a.acked_sequence < 2,
2641                        "the damaged entry was acknowledged: {a:?}"
2642                    );
2643                }
2644            }
2645
2646            let (mut s2, _s2r, h2) = next_handshake(&listener);
2647            assert_eq!(
2648                h2.last_sequence, 1,
2649                "the reconnect must claim only the entries that passed the check"
2650            );
2651            stream_start(&mut s2, 1, lineage);
2652            send_entries(&mut s2, &[2]);
2653            wait_for_ack(&mut s2, 2);
2654            stop(replica, &shutdown);
2655
2656            let mut reader =
2657                melin_journal::JournalReader::<EvtAdd>::open(&dir.path().join("replica.journal"))
2658                    .expect("open replica journal");
2659            let mut journaled = Vec::new();
2660            while let Some(e) = reader.next_entry().expect("read entry") {
2661                if let JournalEvent::App(EvtAdd(v)) = e.event {
2662                    journaled.push(v);
2663                }
2664            }
2665            assert_eq!(journaled, vec![1, 2], "only the intact entry is journaled");
2666        }
2667
2668        /// `lineage`, as a primary whose genesis took `genesis_entries`
2669        /// entries would advertise it.
2670        fn with_genesis(
2671            lineage: melin_journal::FileHeaderInfo,
2672            genesis_entries: Option<u64>,
2673        ) -> melin_journal::FileHeaderInfo {
2674            melin_journal::FileHeaderInfo {
2675                genesis_entries,
2676                ..lineage
2677            }
2678        }
2679
2680        fn replica_genesis(dir: &std::path::Path) -> Option<u64> {
2681            melin_journal::segment::read_header_info(&dir.join("replica.journal"))
2682                .expect("replica journal header")
2683                .genesis_entries
2684        }
2685
2686        /// A fresh replica writes the genesis length the primary
2687        /// advertises into the journal it creates, and keeps it: a
2688        /// restarted replica recovers it from its own header and follows
2689        /// a primary advertising the same.
2690        #[test]
2691        fn a_replica_learns_the_genesis_length_and_keeps_it_across_restarts() {
2692            let dir = tempfile::tempdir().expect("tempdir");
2693            let (_, base) = primary_journal(dir.path());
2694            let lineage = with_genesis(base, Some(3));
2695            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2696            listener.set_nonblocking(true).expect("nonblocking");
2697            let addr = listener.local_addr().expect("addr");
2698
2699            let shutdown = Arc::new(AtomicBool::new(false));
2700            let replica = spawn_replica(dir.path(), addr, Duration::ZERO, &shutdown);
2701            let (mut s1, _s1r, h1) = next_handshake(&listener);
2702            assert_eq!(h1.last_sequence, 0, "fresh replica handshake");
2703            stream_start(&mut s1, 0, lineage);
2704            send_entries(&mut s1, &[1, 2]);
2705            wait_for_ack(&mut s1, 2);
2706            assert_eq!(replica_genesis(dir.path()), Some(3), "learned at creation");
2707            stop(replica, &shutdown);
2708
2709            let shutdown = Arc::new(AtomicBool::new(false));
2710            let replica = spawn_replica(dir.path(), addr, Duration::ZERO, &shutdown);
2711            let (mut s2, _s2r, h2) = next_handshake(&listener);
2712            assert_eq!(h2.last_sequence, 2, "restarted replica handshake");
2713            stream_start(&mut s2, 2, lineage);
2714            send_entries(&mut s2, &[3]);
2715            wait_for_ack(&mut s2, 3);
2716            stop(replica, &shutdown);
2717            assert_eq!(
2718                replica_genesis(dir.path()),
2719                Some(3),
2720                "kept across a restart"
2721            );
2722        }
2723
2724        /// A replica holding an empty journal from before the genesis
2725        /// length was recorded — copied from a primary that stopped
2726        /// before journaling anything — learns the length when the
2727        /// upgraded primary, having journaled its genesis under the same
2728        /// anchor, advertises it. Left unknown, its promotion check would
2729        /// be skipped for the very partial genesis it guards.
2730        #[test]
2731        fn an_empty_old_format_replica_journal_learns_the_genesis_length() {
2732            let dir = tempfile::tempdir().expect("tempdir");
2733            let (_, base) = primary_journal(dir.path());
2734            assert_eq!(base.starting_sequence, 1);
2735            drop(
2736                melin_journal::BufferedWriter::<EvtAdd>::create_continuing(
2737                    &dir.path().join("replica.journal"),
2738                    1,
2739                    base.anchor_hash,
2740                    None,
2741                )
2742                .expect("old-format empty replica journal"),
2743            );
2744            assert_eq!(replica_genesis(dir.path()), None);
2745            let lineage = with_genesis(base, Some(3));
2746            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2747            listener.set_nonblocking(true).expect("nonblocking");
2748            let shutdown = Arc::new(AtomicBool::new(false));
2749            let replica = spawn_replica(
2750                dir.path(),
2751                listener.local_addr().expect("addr"),
2752                Duration::ZERO,
2753                &shutdown,
2754            );
2755
2756            let (mut s1, _s1r, h1) = next_handshake(&listener);
2757            assert_eq!(h1.last_sequence, 0, "empty replica handshake");
2758            stream_start(&mut s1, 0, lineage);
2759            send_entries(&mut s1, &[1, 2]);
2760            wait_for_ack(&mut s1, 2);
2761            stop(replica, &shutdown);
2762            assert_eq!(replica_genesis(dir.path()), Some(3));
2763        }
2764
2765        /// A replica whose journal records one genesis length refuses a
2766        /// primary advertising another — a lineage error, never an
2767        /// overwrite — and exits with the reason.
2768        #[test]
2769        fn a_primary_advertising_another_genesis_length_is_refused() {
2770            let dir = tempfile::tempdir().expect("tempdir");
2771            let (primary, lineage) = primary_journal(dir.path());
2772            assert_eq!(lineage.genesis_entries, Some(0));
2773            // The replica holds what the primary holds, genesis length
2774            // included.
2775            std::fs::copy(&primary, dir.path().join("replica.journal")).expect("copy");
2776            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2777            listener.set_nonblocking(true).expect("nonblocking");
2778            let shutdown = Arc::new(AtomicBool::new(false));
2779            let replica = spawn_replica(
2780                dir.path(),
2781                listener.local_addr().expect("addr"),
2782                Duration::ZERO,
2783                &shutdown,
2784            );
2785
2786            let (mut s1, _s1r, h1) = next_handshake(&listener);
2787            assert_eq!(h1.last_sequence, 1);
2788            stream_start(&mut s1, 1, with_genesis(lineage, Some(4)));
2789            let result = replica.join().expect("replica thread panicked");
2790            let err = result.expect_err("the replica must refuse the lineage");
2791            assert!(err.contains("refusing to follow the primary"), "{err}");
2792            assert_eq!(replica_genesis(dir.path()), Some(0), "never overwritten");
2793        }
2794
2795        /// The protected case end to end: a brand-new cluster whose
2796        /// primary dies while its first replica is still copying the
2797        /// genesis. The replica learned the genesis length from the
2798        /// primary — it is configured with none (the receiver takes no
2799        /// genesis at all) — and the promotion the operator requests next
2800        /// is refused on it.
2801        #[test]
2802        fn a_replica_promoted_mid_genesis_is_refused() {
2803            let dir = tempfile::tempdir().expect("tempdir");
2804            let (_, base) = primary_journal(dir.path());
2805            let lineage = with_genesis(base, Some(3));
2806            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2807            listener.set_nonblocking(true).expect("nonblocking");
2808            let addr = listener.local_addr().expect("addr");
2809            let shutdown = Arc::new(AtomicBool::new(false));
2810            let control = crate::replication::ReplicaControlPlane::new();
2811            let replica = {
2812                let journal = dir.path().join("replica.journal");
2813                let snapshot = dir.path().join("replica.snapshot");
2814                let shutdown = Arc::clone(&shutdown);
2815                let control = control.clone();
2816                std::thread::spawn(move || -> Result<Option<String>, String> {
2817                    run_receiver::<App>(
2818                        addr,
2819                        &journal,
2820                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
2821                        &shutdown,
2822                        &control,
2823                        3_600_000,
2824                        snapshot,
2825                        crate::layout::PipelineCores::unpinned(),
2826                        melin_journal::StagingMode::ZeroFill,
2827                        Duration::ZERO,
2828                        64,
2829                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
2830                        &(),
2831                    )
2832                    // The promotion path's check, on the writer the
2833                    // receiver hands over.
2834                    .map(|promoted| {
2835                        promoted.map(
2836                            |(_, writer)| match crate::server::check_promotable(&writer) {
2837                                Ok(()) => "promotable".to_string(),
2838                                Err(e) => e.to_string(),
2839                            },
2840                        )
2841                    })
2842                    .map_err(|e| e.to_string())
2843                })
2844            };
2845
2846            let (mut s1, s1r, h1) = next_handshake(&listener);
2847            assert_eq!(h1.last_sequence, 0, "fresh replica handshake");
2848            stream_start(&mut s1, 0, lineage);
2849            send_entries(&mut s1, &[1, 2]);
2850            wait_for_ack(&mut s1, 2);
2851            // The primary dies two entries into a three-entry genesis.
2852            drop(s1);
2853            drop(s1r);
2854            assert!(
2855                control
2856                    .promote
2857                    .request(crate::promotion::PromotionRequest::MANUAL),
2858                "promotion filed"
2859            );
2860
2861            let verdict = replica
2862                .join()
2863                .expect("replica thread panicked")
2864                .expect("the receiver hands its state over")
2865                .expect("promoted, not shut down");
2866            assert!(verdict.contains("refusing promotion"), "{verdict}");
2867            assert!(verdict.contains("holds 2 entries"), "{verdict}");
2868        }
2869    }
2870
2871    // -----------------------------------------------------------------
2872    // Boundary adoption under both staging modes — end to end against a
2873    // scripted primary. Needs hash-chain (the boundary announce carries
2874    // a chain value the replica verifies before rotating) and real
2875    // persistence (the rotation archives the on-disk segment).
2876    // -----------------------------------------------------------------
2877    #[cfg(all(feature = "hash-chain", not(feature = "no-persist")))]
2878    mod rotation_adoption {
2879        use super::super::super::auth::authenticate_replica;
2880        use super::scripted::*;
2881        use super::*;
2882        use melin_journal::{BufferedWriter, JournalEvent, JournalWrite};
2883        use melin_transport_core::replication::catchup::lineage_origin;
2884        use melin_transport_core::replication::protocol::{encode_rotate, encode_stream_start};
2885        use std::sync::Arc;
2886        use std::sync::atomic::AtomicBool;
2887
2888        /// Payloads of every entry in the journal at `path`, in order.
2889        fn payloads(path: &std::path::Path) -> Vec<u64> {
2890            let mut reader = melin_journal::JournalReader::<EvtAdd>::open(path).expect("open");
2891            let mut out = Vec::new();
2892            while let Some(entry) = reader.next_entry().expect("read entry") {
2893                if let JournalEvent::App(EvtAdd(v)) = entry.event {
2894                    out.push(v);
2895                }
2896            }
2897            out
2898        }
2899
2900        /// A scripted primary streams two events, announces the rotation
2901        /// boundary with the *correct* tail hash, and streams two more.
2902        /// The replica must verify the boundary, rotate — adopting the
2903        /// segment its preparer staged in `mode` — and keep acking on
2904        /// the new segment, leaving the sealed one archived.
2905        ///
2906        /// Run under both modes: everything observable (archive layout,
2907        /// entry placement across the boundary, acks, clean shutdown)
2908        /// must be identical, or the staging mode is not a tuning knob
2909        /// but a behaviour change.
2910        fn boundary_adoption_rotates_and_streams_on(mode: melin_journal::StagingMode) {
2911            // Shrink the prealloc chunk so the replica's preparer stages
2912            // a few MiB (chunk + margin) instead of 256 MiB per test.
2913            // The guard holds a process-wide lock, so the two mode
2914            // variants serialise rather than race the global override.
2915            let _prealloc_guard =
2916                melin_journal::test_utils::PreallocOverrideGuard::new(1024 * 1024);
2917            let dir = tempfile::tempdir().expect("tempdir");
2918
2919            // Scripted primary journal: entries 1..=2 carrying the same
2920            // stamps as the InputSlots below (deterministic timestamps,
2921            // zero key-hash/request-seq), so its chain value at the
2922            // boundary equals the replica's — journals are byte-for-byte
2923            // mirrors, and `append`'s wall-clock stamp would fork them.
2924            let primary_journal = dir.path().join("primary.journal");
2925            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
2926            for v in 1..=2u64 {
2927                w.batch_append_with_ts(&JournalEvent::App(EvtAdd(v)), v, 0)
2928                    .expect("append");
2929            }
2930            w.flush_batch_sync().expect("flush");
2931            let chain_at_2 = w.chain_hash().expect("chain");
2932            drop(w);
2933            let origin = lineage_origin(&primary_journal).expect("lineage");
2934            let (lineage_start, lineage_anchor, lineage_genesis) = (
2935                origin.starting_sequence,
2936                origin.anchor_hash,
2937                origin.genesis_entries,
2938            );
2939
2940            let authorized_keys = replica_auth();
2941            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2942            listener.set_nonblocking(true).expect("nonblocking");
2943            let addr = listener.local_addr().expect("addr");
2944
2945            let replica_journal = dir.path().join("replica.journal");
2946            let shutdown = Arc::new(AtomicBool::new(false));
2947            let control = crate::replication::ReplicaControlPlane::new();
2948            let replica = {
2949                let journal = replica_journal.clone();
2950                let snapshot = dir.path().join("replica.snapshot");
2951                let shutdown = Arc::clone(&shutdown);
2952                let control = control.clone();
2953                std::thread::spawn(move || -> Result<bool, String> {
2954                    run_receiver::<App>(
2955                        addr,
2956                        &journal,
2957                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
2958                        &shutdown,
2959                        &control,
2960                        3_600_000,
2961                        snapshot,
2962                        crate::layout::PipelineCores::unpinned(),
2963                        mode,
2964                        Duration::ZERO,
2965                        64,
2966                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
2967                        &(),
2968                    )
2969                    // ReceiverResult's error is !Send — stringify for join().
2970                    .map(|state| state.is_none())
2971                    .map_err(|e| e.to_string())
2972                })
2973            };
2974
2975            let mut s1 = accept_within(&listener, 30);
2976            let mut s1r = s1.try_clone().expect("clone");
2977            authenticate_replica(&mut s1r, &authorized_keys).expect("auth");
2978            match read_replica_msg(&mut s1) {
2979                ReplicaMessage::Handshake(h) => {
2980                    assert_eq!(h.last_sequence, 0, "fresh replica handshake")
2981                }
2982                other => panic!("expected Handshake, got {other:?}"),
2983            }
2984            let mut buf = Vec::new();
2985            encode_stream_start(
2986                0,
2987                lineage_start,
2988                lineage_anchor,
2989                lineage_genesis,
2990                0,
2991                &mut buf,
2992            );
2993            s1.write_all(&buf).expect("StreamStart");
2994
2995            let send_events = |s: &mut TcpStream, range: std::ops::RangeInclusive<u64>| {
2996                let slots: Vec<InputSlot<EvtAdd>> = range
2997                    .map(|v| InputSlot {
2998                        connection_id: 0,
2999                        key_hash: 0,
3000                        sequence: v,
3001                        timestamp_ns: v,
3002                        event: JournalEvent::App(EvtAdd(v)),
3003                        // `()` without latency-trace, a timestamp with it.
3004                        publish_ts: Default::default(),
3005                        recv_ts: Default::default(),
3006                    })
3007                    .collect();
3008                let mut buf = Vec::new();
3009                melin_transport_core::replication_wire::encode_input_batch(&slots, &mut buf)
3010                    .expect("encode InputBatch");
3011                s.write_all(&buf).expect("InputBatch");
3012            };
3013
3014            send_events(&mut s1, 1..=2);
3015            wait_for_ack(&mut s1, 2);
3016
3017            // Wait for the preparer's staged segment to reach full size
3018            // (the overridden prealloc chunk + the 8 MiB margin) so the
3019            // boundary below adopts it rather than racing staging into
3020            // the synchronous fallback — adoption is the path under
3021            // test. Both modes must produce it; a preparer that never
3022            // does is itself a failure.
3023            let staging = {
3024                // `<live>.next-staging` — the preparer's sibling-file
3025                // convention (crate-private, so spelled out here the way
3026                // the `.divergent.<n>` archive name is above).
3027                let mut s = replica_journal.clone().into_os_string();
3028                s.push(".next-staging");
3029                std::path::PathBuf::from(s)
3030            };
3031            let staged_len: u64 = (1 + 8) * 1024 * 1024;
3032            let deadline = Instant::now() + Duration::from_secs(10);
3033            loop {
3034                if std::fs::metadata(&staging).is_ok_and(|m| m.len() == staged_len) {
3035                    break;
3036                }
3037                assert!(
3038                    Instant::now() < deadline,
3039                    "preparer produced no full-size staged segment in {mode:?} mode"
3040                );
3041                std::thread::sleep(Duration::from_millis(10));
3042            }
3043            // Length is the last thing staging changes before the final
3044            // sync and the slot hand-off; give those a moment so the
3045            // rotation's `take()` finds the segment parked. (Losing this
3046            // race anyway is not a failure: the rotation falls back to
3047            // the synchronous path and every assertion below still
3048            // holds — the staged segment is then simply adopted at a
3049            // later boundary.)
3050            std::thread::sleep(Duration::from_millis(300));
3051
3052            // The boundary, with the correct tail hash — verified
3053            // against the replica's own chain, then adopted.
3054            buf.clear();
3055            encode_rotate(2, &chain_at_2, &mut buf);
3056            s1.write_all(&buf).expect("Rotate");
3057
3058            // Streaming continues on the new segment. The journal stage
3059            // holds entries past a pending boundary until the rotation
3060            // applies, so this ack proves the adoption completed.
3061            send_events(&mut s1, 3..=4);
3062            wait_for_ack(&mut s1, 4);
3063
3064            shutdown.store(true, Ordering::Relaxed);
3065            let result = replica.join().expect("replica thread panicked");
3066            assert_eq!(result, Ok(true), "receiver must exit cleanly via shutdown");
3067
3068            // The sealed segment was archived at the first archive slot
3069            // holding exactly the pre-boundary entries; the live segment
3070            // carries the stream from the boundary on.
3071            let archived = dir.path().join("replica.journal.000001");
3072            assert!(archived.exists(), "sealed segment must be archived");
3073            assert_eq!(payloads(&archived), vec![1, 2]);
3074            assert_eq!(payloads(&replica_journal), vec![3, 4]);
3075        }
3076
3077        #[test]
3078        fn boundary_adoption_zero_fill() {
3079            boundary_adoption_rotates_and_streams_on(melin_journal::StagingMode::ZeroFill);
3080        }
3081
3082        #[test]
3083        fn boundary_adoption_allocate() {
3084            boundary_adoption_rotates_and_streams_on(melin_journal::StagingMode::Allocate);
3085        }
3086    }
3087}