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