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 engine + drain
6//! stages) to apply incoming events and ack durable batches back to the
7//! primary.
8
9use std::io::{self, Write};
10use std::net::{SocketAddr, TcpStream};
11use std::os::unix::io::RawFd;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use tracing::{info, warn};
16
17use melin_app::Application;
18use melin_journal::BufferedWriter;
19
20use super::auth::authenticate_with_primary;
21use super::receiver_transport::{
22    ControlFrameSource, ReceiverTransport, SessionExit, streaming_loop,
23};
24use super::{
25    AfterSession, ReplicaPipelineHandles, ResyncDecision, build_replica_pipeline_with_threads,
26    handle_resync_verdict, handle_session_exit, recover_replica_state, sleep_then_double_backoff,
27    take_pipeline_for_promotion, teardown_replica_pipeline,
28};
29use crate::uring_teardown::{DrainBackoff, wake_pending_ops};
30use melin_transport_core::replication::protocol::{
31    Ack, Handshake, MAX_CONTROL_FRAME, MAX_DATA_FRAME, PrimaryMessage, decode_primary_message,
32    encode_ack, encode_handshake, read_frame,
33};
34
35/// Force the kernel to send a TCP ACK immediately rather than holding
36/// it in the delayed-ACK timer (~40 ms on Linux). Linux clears
37/// `TCP_QUICKACK` after each ACK it sends, so it has to be re-armed.
38///
39/// A `setsockopt` is ~0.5–1 µs on the receive path's critical section,
40/// so the re-arm is not unconditional: an outgoing ack frame already
41/// carries the TCP ACK, and the replica sends one per receive cycle in
42/// steady state. Only a cycle that produced no ack re-arms — see
43/// `UringTransport::acked_since_recv`.
44#[inline]
45fn arm_tcp_quickack(fd: RawFd) {
46    let on: libc::c_int = 1;
47    // SAFETY: fd is a live socket fd owned by the caller for the
48    // lifetime of this call; the option pointer is to a stack-local
49    // i32 with the right size.
50    let rc = unsafe {
51        libc::setsockopt(
52            fd,
53            libc::IPPROTO_TCP,
54            libc::TCP_QUICKACK,
55            &on as *const libc::c_int as *const libc::c_void,
56            std::mem::size_of::<libc::c_int>() as libc::socklen_t,
57        )
58    };
59    if rc != 0 {
60        // Best-effort tuning: a kernel that refuses TCP_QUICKACK (or a
61        // socket that just went away) costs delayed-ACK latency, not
62        // correctness, and the caller has no better recovery than
63        // continuing. Nothing to log per cycle either — it would fire on
64        // every receive.
65        debug_assert!(
66            std::io::Error::last_os_error().raw_os_error() != Some(libc::EFAULT),
67            "TCP_QUICKACK setsockopt got EFAULT — bad option pointer"
68        );
69    }
70}
71
72// ---------------------------------------------------------------------------
73// io_uring ReceiverTransport implementation
74// ---------------------------------------------------------------------------
75
76const TOKEN_RECV: u64 = 0;
77const TOKEN_SEND: u64 = 1;
78const TOKEN_PROVIDE: u64 = 2;
79
80// Multishot RECV buffer pool. 16 buffers × MAX_DATA_FRAME each.
81const NUM_RECV_BUFFERS: u16 = 16;
82const RECV_BUF_SIZE: usize = MAX_DATA_FRAME;
83const RECV_BUF_GROUP_ID: u16 = 0;
84
85// CQE flag bits (io_uring ABI).
86const IORING_CQE_F_BUFFER: u32 = 1 << 0;
87const IORING_CQE_F_MORE: u32 = 1 << 1;
88const IORING_CQE_BUFFER_SHIFT: u32 = 16;
89
90/// io_uring–backed receiver transport for kernel TCP.
91///
92/// Uses `IORING_OP_RECV_MULTI` against a provided buffer pool for
93/// incoming frames, and single-shot SEND for acks. Owns the io_uring
94/// ring, buffer pool, and ack send state.
95struct UringTransport {
96    ring: io_uring::IoUring,
97    /// Raw socket fd, also used for the `shutdown(2)` wake in `Drop`.
98    /// Always outlives the transport: the owning `TcpStream` lives in
99    /// the reconnect loop, while the transport is a local of the scoped
100    /// receiver thread that borrows it.
101    tcp_fd: RawFd,
102    // Backing storage for the io_uring provided-buffer pool. Never read
103    // through this handle — the kernel accesses it via raw pointers
104    // registered in ProvideBuffers, and reads go through `pool_ptr`.
105    // Owned here so it outlives every RECV the kernel may land in it;
106    // `Drop` quiesces the ring before letting it go.
107    recv_pool: Vec<u8>,
108    pool_ptr: *mut u8,
109    multishot_active: bool,
110    connected: bool,
111    ack_buf: Vec<u8>,
112    ack_offset: usize,
113    ack_in_flight: bool,
114    /// Newest ack accepted while a SEND was in flight — sent the moment
115    /// the in-flight SEND's CQE is reaped (send-latest-on-completion).
116    /// `Option<Ack>` rather than a queue: ack cursors are cumulative
117    /// and monotonic, so when several acks arrive while one send is in
118    /// flight only the newest pair needs the wire — intermediate values
119    /// are subsumed. Exactly one SEND is ever outstanding, which keeps
120    /// the TCP byte stream ordered (io_uring gives no ordering between
121    /// independent SQEs, and interleaved partial sends would corrupt
122    /// the frame stream).
123    pending_ack: Option<Ack>,
124    /// Whether an ack frame has been handed to the kernel since the last
125    /// receive cycle. An outgoing data segment carries the TCP ACK with
126    /// it, so an acking cycle needs no `TCP_QUICKACK` re-arm — only a
127    /// silent one does. See [`arm_tcp_quickack`].
128    acked_since_recv: bool,
129}
130
131impl UringTransport {
132    fn new(tcp_stream: &TcpStream) -> io::Result<Self> {
133        use io_uring::{IoUring, opcode, types};
134        use std::os::unix::io::AsRawFd;
135
136        let tcp_fd = tcp_stream.as_raw_fd();
137
138        let mut ring: IoUring = IoUring::builder()
139            .setup_single_issuer()
140            .build(64)
141            .map_err(|e| io::Error::other(format!("io_uring init failed: {e}")))?;
142
143        ring.submitter()
144            .register_files(&[tcp_fd])
145            .map_err(|e| io::Error::other(format!("io_uring register_files failed: {e}")))?;
146
147        arm_tcp_quickack(tcp_fd);
148
149        // Pin io-wq workers to core 0.
150        {
151            let mut cpuset: libc::cpu_set_t = unsafe { std::mem::zeroed() };
152            unsafe { libc::CPU_SET(0, &mut cpuset) };
153            let _ = ring.submitter().register_iowq_aff(&cpuset);
154        }
155
156        let mut recv_pool: Vec<u8> = vec![0u8; NUM_RECV_BUFFERS as usize * RECV_BUF_SIZE];
157        let pool_ptr = recv_pool.as_mut_ptr();
158
159        // Provide the buffer pool to io_uring.
160        {
161            let sqe = opcode::ProvideBuffers::new(
162                pool_ptr,
163                RECV_BUF_SIZE as i32,
164                NUM_RECV_BUFFERS,
165                RECV_BUF_GROUP_ID,
166                0,
167            )
168            .build()
169            .user_data(TOKEN_PROVIDE);
170            unsafe { ring.submission().push(&sqe).expect("SQ full") };
171            ring.submit_and_wait(1)?;
172            let cqe = ring
173                .completion()
174                .next()
175                .ok_or_else(|| io::Error::other("no cqe after ProvideBuffers"))?;
176            if cqe.result() < 0 {
177                return Err(io::Error::other(format!(
178                    "ProvideBuffers failed: {}",
179                    cqe.result()
180                )));
181            }
182        }
183
184        // Submit the initial multishot RECV.
185        let sqe = opcode::RecvMulti::new(types::Fd(tcp_fd), RECV_BUF_GROUP_ID)
186            .build()
187            .user_data(TOKEN_RECV);
188        unsafe { ring.submission().push(&sqe).expect("SQ full") };
189
190        Ok(UringTransport {
191            ring,
192            tcp_fd,
193            recv_pool,
194            pool_ptr,
195            multishot_active: true,
196            connected: true,
197            ack_buf: Vec::with_capacity(64),
198            ack_offset: 0,
199            ack_in_flight: false,
200            pending_ack: None,
201            acked_since_recv: false,
202        })
203    }
204
205    /// Encode `ack` into the send buffer and push its SEND SQE. The
206    /// caller is responsible for submitting (next `ring.submit()` —
207    /// either `poll_recv`'s top-of-call submit or the eager submit
208    /// after a chained send). Must only be called with no SEND in
209    /// flight: the buffer is pinned by `ack_in_flight` until the CQE.
210    fn push_ack_send(&mut self, ack: &Ack) {
211        use io_uring::{opcode, types};
212
213        debug_assert!(!self.ack_in_flight);
214        self.ack_buf.clear();
215        encode_ack(ack, &mut self.ack_buf);
216        let sqe = opcode::Send::new(
217            types::Fixed(0),
218            self.ack_buf.as_ptr(),
219            self.ack_buf.len() as u32,
220        )
221        .build()
222        .user_data(TOKEN_SEND);
223        // SAFETY: `ack_buf` is owned by this transport and pinned by
224        // `ack_in_flight = true` until the matching CQE is reaped. The
225        // ring is single-threaded.
226        unsafe { self.ring.submission().push(&sqe).expect("SQ full") };
227        self.ack_in_flight = true;
228        self.ack_offset = 0;
229        self.acked_since_recv = true;
230    }
231
232    /// Settle the kernel-ownership flags from CQEs that were reaped out
233    /// of the completion queue but will not be processed, because
234    /// [`Self::poll_recv`] is bailing out on a failed session.
235    ///
236    /// Only the flags — the payloads are deliberately discarded, and
237    /// nothing is resubmitted, since the session is over. Skipping this
238    /// would leave `ack_in_flight` set for a SEND the kernel has
239    /// already returned, and [`Self::drain`] would then wait out its
240    /// whole deadline on a completion that can never arrive again.
241    fn settle_unprocessed(&mut self, rest: &[(u64, i32, u32)]) {
242        for &(token, _result, flags) in rest {
243            match token {
244                TOKEN_RECV if (flags & IORING_CQE_F_MORE) == 0 => self.multishot_active = false,
245                TOKEN_SEND => self.ack_in_flight = false,
246                _ => {}
247            }
248        }
249    }
250
251    /// Reap completions until the kernel holds neither buffer, bounded
252    /// by [`crate::uring_teardown::DRAIN_TIMEOUT`]. `true` means the
253    /// multishot RECV has terminated and any SEND has settled, so both
254    /// buffers are provably free of kernel references.
255    ///
256    /// Unlike the sender's ring there is no in-flight *count* to drive
257    /// this: multishot RECV is a single SQE that yields many CQEs and
258    /// only settles on the one without `IORING_CQE_F_MORE`. So the two
259    /// existing flags are the drain condition, and CQEs are classified
260    /// here rather than reusing `poll_recv` — during teardown the
261    /// payloads are discarded, consumed buffers are not re-provided,
262    /// and neither the multishot nor a partial send is resubmitted.
263    fn drain(&mut self) -> bool {
264        // Flush SQEs pushed but never submitted — notably the initial
265        // multishot RECV and any resubmission queued by the last
266        // `poll_recv`. Until they reach the kernel no CQE is coming,
267        // but the kernel takes the buffer pointers the moment it sees
268        // them, so they cannot simply be abandoned in the SQ either.
269        loop {
270            match self.ring.submit() {
271                Ok(_) => break,
272                Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
273                Err(_) => return false,
274            }
275        }
276        let mut backoff = DrainBackoff::new();
277        while self.multishot_active || self.ack_in_flight {
278            for cqe in self.ring.completion() {
279                match cqe.user_data() {
280                    // The kernel stops writing into the pool only once
281                    // the multishot arm is retired, which it signals by
282                    // clearing F_MORE. A CQE that still carries F_MORE
283                    // is data landing mid-teardown: discard it and keep
284                    // waiting for the terminal one.
285                    TOKEN_RECV if (cqe.flags() & IORING_CQE_F_MORE) == 0 => {
286                        self.multishot_active = false;
287                    }
288                    // Settles whether the send completed, errored, or
289                    // went partial: either way the kernel is done
290                    // reading `ack_buf` for this operation.
291                    TOKEN_SEND => self.ack_in_flight = false,
292                    // ProvideBuffers only hands the kernel addresses;
293                    // it never touches pool contents, so a pending one
294                    // is not a reason to keep waiting.
295                    _ => {}
296                }
297            }
298            if (self.multishot_active || self.ack_in_flight) && !backoff.wait() {
299                return false;
300            }
301        }
302        true
303    }
304}
305
306impl Drop for UringTransport {
307    fn drop(&mut self) {
308        wake_pending_ops(self.tcp_fd);
309        if !self.drain() {
310            // The drain could not be proven complete, so the kernel may
311            // still land a RECV in the buffer pool or read `ack_buf`
312            // for an in-flight SEND. Leak both rather than hand the
313            // allocator memory the kernel still touches — a session
314            // that is tearing down anyway pays a bounded one-off cost.
315            std::mem::forget(std::mem::take(&mut self.recv_pool));
316            std::mem::forget(std::mem::take(&mut self.ack_buf));
317            tracing::warn!(
318                multishot_active = self.multishot_active,
319                ack_in_flight = self.ack_in_flight,
320                "io_uring teardown drain did not complete; leaking receiver buffers"
321            );
322        }
323    }
324}
325
326impl ReceiverTransport for UringTransport {
327    fn poll_recv(&mut self, recv_buf: &mut Vec<u8>) -> io::Result<bool> {
328        use io_uring::{opcode, types};
329
330        if !self.connected {
331            return Err(io::Error::other("not connected"));
332        }
333
334        // Submit pending SQEs (ProvideBuffers re-provisions, Send
335        // remainders, multishot resubmissions).
336        let pending = self.ring.submission().len();
337        if pending > 0 {
338            self.ring.submit()?;
339        }
340
341        // Drain CQEs.
342        let mut cqes: [(u64, i32, u32); 16] = [(0, 0, 0); 16];
343        let mut cqe_count = 0;
344        {
345            let mut cq = self.ring.completion();
346            while cqe_count < cqes.len() {
347                match cq.next() {
348                    Some(cqe) => {
349                        cqes[cqe_count] = (cqe.user_data(), cqe.result(), cqe.flags());
350                        cqe_count += 1;
351                    }
352                    None => break,
353                }
354            }
355        }
356
357        let mut any_recv = false;
358        // Set when a queued ack is chained onto a completed SEND below —
359        // it must hit the wire within THIS call, not ride the next
360        // poll_recv's top-of-call submit one loop iteration later.
361        let mut submit_chained_ack = false;
362
363        for (idx, &(token, result, flags)) in cqes[..cqe_count].iter().enumerate() {
364            // Every early return below abandons the CQEs after `idx`,
365            // which have already been reaped out of the CQ — hence the
366            // `settle_unprocessed` calls: without them teardown would
367            // wait on a completion that has come and gone.
368            match token {
369                TOKEN_RECV => {
370                    if (flags & IORING_CQE_F_MORE) == 0 {
371                        self.multishot_active = false;
372                    }
373                    if result < 0 {
374                        if result == -libc::ENOBUFS {
375                            tracing::debug!("recv multishot ENOBUFS — pool exhausted");
376                            continue;
377                        }
378                        self.connected = false;
379                        self.settle_unprocessed(&cqes[idx + 1..cqe_count]);
380                        return Err(io::Error::other(format!(
381                            "primary disconnected (recv returned {result})"
382                        )));
383                    }
384                    if result == 0 {
385                        self.connected = false;
386                        self.settle_unprocessed(&cqes[idx + 1..cqe_count]);
387                        return Err(io::Error::other("primary disconnected (recv returned 0)"));
388                    }
389                    if (flags & IORING_CQE_F_BUFFER) == 0 {
390                        self.connected = false;
391                        self.settle_unprocessed(&cqes[idx + 1..cqe_count]);
392                        return Err(io::Error::other("recv cqe missing F_BUFFER flag"));
393                    }
394
395                    let n = result as usize;
396                    let buf_id = (flags >> IORING_CQE_BUFFER_SHIFT) as usize;
397                    // SAFETY: buf_id from kernel CQE for a buffer we
398                    // provided from recv_pool; kernel wrote n bytes.
399                    let buf_ptr = unsafe { self.pool_ptr.add(buf_id * RECV_BUF_SIZE) };
400                    let slice = unsafe { std::slice::from_raw_parts(buf_ptr, n) };
401                    recv_buf.extend_from_slice(slice);
402                    any_recv = true;
403
404                    // Re-provide the consumed buffer.
405                    let provide_sqe = opcode::ProvideBuffers::new(
406                        buf_ptr,
407                        RECV_BUF_SIZE as i32,
408                        1,
409                        RECV_BUF_GROUP_ID,
410                        buf_id as u16,
411                    )
412                    .build()
413                    .user_data(TOKEN_PROVIDE);
414                    unsafe { self.ring.submission().push(&provide_sqe).expect("SQ full") };
415                }
416
417                TOKEN_PROVIDE if result < 0 => {
418                    tracing::debug!("ProvideBuffers re-provision failed: {result}");
419                }
420
421                TOKEN_SEND => {
422                    if result < 0 {
423                        self.connected = false;
424                        // This CQE is the kernel handing `ack_buf` back,
425                        // failure or not — record that before bailing.
426                        self.ack_in_flight = false;
427                        self.settle_unprocessed(&cqes[idx + 1..cqe_count]);
428                        return Err(io::Error::other(format!(
429                            "ack send error (returned {result})"
430                        )));
431                    }
432                    let sent = result as usize;
433                    self.ack_offset += sent;
434                    if self.ack_offset >= self.ack_buf.len() {
435                        self.ack_buf.clear();
436                        self.ack_offset = 0;
437                        self.ack_in_flight = false;
438                        // Send-latest-on-completion: an ack accepted
439                        // while this send was in flight goes out now
440                        // instead of waiting a full loop iteration for
441                        // the next flush. Chained only after FULL
442                        // completion — a partial send's remainder keeps
443                        // exclusive ownership of the byte stream.
444                        if let Some(ack) = self.pending_ack.take() {
445                            self.push_ack_send(&ack);
446                            submit_chained_ack = true;
447                        }
448                    } else {
449                        // Partial send — resubmit remainder.
450                        let sqe = opcode::Send::new(
451                            types::Fixed(0),
452                            self.ack_buf[self.ack_offset..].as_ptr(),
453                            (self.ack_buf.len() - self.ack_offset) as u32,
454                        )
455                        .build()
456                        .user_data(TOKEN_SEND);
457                        unsafe { self.ring.submission().push(&sqe).expect("SQ full") };
458                    }
459                }
460
461                _ => {}
462            }
463        }
464
465        // Resubmit multishot if terminated.
466        if !self.multishot_active {
467            let sqe = opcode::RecvMulti::new(types::Fd(self.tcp_fd), RECV_BUF_GROUP_ID)
468                .build()
469                .user_data(TOKEN_RECV);
470            unsafe { self.ring.submission().push(&sqe).expect("SQ full") };
471            self.multishot_active = true;
472        }
473
474        // Flush a chained ack to the kernel before returning (also
475        // carries any multishot resubmission pushed above).
476        if submit_chained_ack {
477            self.ring.submit()?;
478        }
479
480        if any_recv {
481            // One decision per receive cycle rather than a setsockopt per
482            // RECV CQE — and none at all while the replica is acking,
483            // since each ack frame is an outgoing segment that carries
484            // the TCP ACK. A cycle that produces no ack re-arms, so a
485            // stream that goes app-level silent waits at most one cycle
486            // (not a delayed-ACK timer) for its next immediate ACK.
487            if self.acked_since_recv {
488                self.acked_since_recv = false;
489            } else {
490                arm_tcp_quickack(self.tcp_fd);
491            }
492        }
493
494        Ok(any_recv)
495    }
496
497    fn send_ack(&mut self, ack: &Ack) -> io::Result<bool> {
498        if self.ack_in_flight {
499            // Coalesce: overwrite any previously queued value — the
500            // cursors are cumulative, so the newest pair subsumes
501            // everything before it. Sent on the in-flight SEND's CQE.
502            self.pending_ack = Some(*ack);
503            return Ok(true);
504        }
505        self.push_ack_send(ack);
506        Ok(true)
507    }
508
509    fn ack_in_flight(&self) -> bool {
510        // Pending counts as in flight: the drain paths use this to mean
511        // "everything offered has reached the wire", and the flush gate
512        // in `streaming_loop` recomputes a fresh ack next iteration
513        // anyway.
514        self.ack_in_flight || self.pending_ack.is_some()
515    }
516
517    fn is_connected(&mut self) -> bool {
518        self.connected
519    }
520}
521
522// ---------------------------------------------------------------------------
523// Public API
524// ---------------------------------------------------------------------------
525
526/// Kernel-TCP control-frame source: a blocking `read_frame` on the
527/// replication stream. Drives the shared [`receive_chunked_body`] (and,
528/// later, the rest of the resync transfer). See [`ControlFrameSource`].
529struct TcpFrameSource<'a> {
530    reader: &'a mut TcpStream,
531}
532
533impl ControlFrameSource for TcpFrameSource<'_> {
534    fn next_frame(
535        &mut self,
536        max_size: usize,
537    ) -> Result<Vec<u8>, Box<dyn std::error::Error + Send + Sync>> {
538        Ok(read_frame(self.reader, max_size)?)
539    }
540}
541
542/// Outcome of `run_receiver`: `None` = clean shutdown, `Some` = promotion.
543pub type ReceiverResult<A, W> = Result<Option<(A, W)>, Box<dyn std::error::Error>>;
544
545#[allow(clippy::too_many_arguments)]
546pub fn run_receiver<A>(
547    primary_addr: SocketAddr,
548    journal_path: &std::path::Path,
549    signing_key: &ed25519_dalek::SigningKey,
550    shutdown: &AtomicBool,
551    // The handles this loop shares with the admin endpoint and the
552    // control-plane raft driver — see [`super::ReplicaControlPlane`]
553    // for each field's contract.
554    control: &super::ReplicaControlPlane,
555    snapshot_interval_ms: u64,
556    snapshot_path: std::path::PathBuf,
557    cores: crate::layout::PipelineCores,
558    // Passed straight to the replica's journal stage; see
559    // `build_replica_pipeline_with_threads`.
560    staging_mode: melin_journal::StagingMode,
561    group_commit_delay: std::time::Duration,
562    pipeline_depth: usize,
563    fence_state: std::sync::Arc<melin_transport_core::fence::FenceState>,
564    // Applied to every instance this loop builds a pipeline around; see
565    // `build_replica_pipeline_with_threads`.
566    sizing: &A::Sizing,
567) -> ReceiverResult<A, BufferedWriter<A::Event>>
568where
569    A: Application + Send + 'static,
570    A::Event: Send + Sync + 'static,
571    A::Report: Send + 'static,
572    A::QueryResponse: Send + 'static,
573{
574    let super::ReplicaControlPlane {
575        promote,
576        tip_ready,
577        journal_tip,
578        primary_link_up,
579        primary_ack_policy,
580        pipeline_healthy,
581    } = control;
582    // Recover whenever any journal segment survives — live OR archived;
583    // fresh replicas get `(None, None, 0, zeros)`. See
584    // `recover_replica_state` for the lineage rules.
585    let (mut exchange, mut journal_writer, mut last_sequence, mut chain_hash) =
586        recover_replica_state::<A, BufferedWriter<A::Event>>(
587            journal_path,
588            &snapshot_path,
589            &fence_state,
590            sizing,
591        )?;
592    // The fence epoch now reflects the recovered journal (a fresh replica
593    // legitimately recovers epoch 0). Seed the advertised sequence from
594    // the same recovery before releasing the flag, so the first tip the
595    // raft driver trusts is already the recovered high-water mark.
596    // Release-store so the driver's Acquire-load sees both.
597    journal_tip.advance(melin_transport_core::WireSeq::new(last_sequence));
598    tip_ready.store(true, Ordering::Release);
599
600    let mut backoff = std::time::Duration::from_secs(1);
601
602    // Consecutive mid-stream divergence resyncs this process has
603    // attempted — see `MAX_INPROCESS_DIVERGENCE_RESYNCS`.
604    let mut divergence_resyncs: u32 = 0;
605
606    let mut send_buf = Vec::with_capacity(64);
607    let mut pipeline: Option<ReplicaPipelineHandles<A, BufferedWriter<A::Event>>> = None;
608
609    // --- Outer reconnect loop ---
610    loop {
611        // No authenticated session while (re)connecting — the raft
612        // driver may auto-promote from here on if it wins an election.
613        primary_link_up.store(false, Ordering::Release);
614
615        if let Some(p) = pipeline.as_ref() {
616            // The handshake pair (last_sequence, chain_hash) must come
617            // from ONE FsyncState snapshot — the journal stage keeps
618            // flushing while we reconnect, and the primary's handshake
619            // validation recomputes its chain at exactly the sequence
620            // we claim. Reading the sequence and the hash from two
621            // separate sources would tear under load, and a torn pair
622            // is indistinguishable from divergence (false resync of a
623            // healthy replica).
624            if let Some(ref lock) = p.chain_hash_lock {
625                let fsync_state = lock.load();
626                last_sequence = fsync_state.journal_seq.get();
627                chain_hash = fsync_state.chain_hash;
628            } else {
629                last_sequence = p.last_seq.load().get();
630            }
631        }
632
633        // Sole authority for shutdown/promote while disconnected: every
634        // backoff arm just sleeps (the sleep observes both flags) and
635        // continues back here, so teardown/promotion handling exists
636        // exactly once.
637        if shutdown.load(Ordering::Relaxed) {
638            if let Some(p) = pipeline.take() {
639                let _ = teardown_replica_pipeline::<A, BufferedWriter<A::Event>>(p);
640            }
641            return Ok(None);
642        }
643        if promote.is_requested() {
644            info!("promotion triggered while disconnected");
645            return take_pipeline_for_promotion(&mut pipeline, &mut exchange, &mut journal_writer);
646        }
647
648        // --- Connect and authenticate ---
649        info!(primary = %primary_addr, "connecting to primary as replica");
650
651        let stream = match TcpStream::connect(primary_addr) {
652            Ok(s) => s,
653            Err(e) => {
654                warn!(
655                    error = %e,
656                    backoff_secs = backoff.as_secs(),
657                    "failed to connect to primary — retrying"
658                );
659                sleep_then_double_backoff(&mut backoff, shutdown, promote);
660                continue;
661            }
662        };
663        if let Err(e) = stream.set_nodelay(true) {
664            warn!(error = %e, "failed to set TCP_NODELAY on replica receive socket");
665        }
666        {
667            use std::os::unix::io::AsRawFd;
668            let busy_poll: libc::c_int = 50;
669            let rc = unsafe {
670                libc::setsockopt(
671                    stream.as_raw_fd(),
672                    libc::SOL_SOCKET,
673                    libc::SO_BUSY_POLL,
674                    &busy_poll as *const libc::c_int as *const libc::c_void,
675                    std::mem::size_of::<libc::c_int>() as libc::socklen_t,
676                )
677            };
678            if rc != 0 {
679                let err = std::io::Error::last_os_error();
680                warn!(error = %err, "failed to set SO_BUSY_POLL on replica receive socket");
681            }
682        }
683        stream.set_read_timeout(Some(std::time::Duration::from_secs(5)))?;
684
685        let mut reader = stream.try_clone()?;
686        let mut tcp_writer = stream;
687
688        // `reader`/`tcp_writer` are clones of one socket; auth is sequential so
689        // a single handle works. Use `reader` (carries the read timeout above).
690        if let Err(e) = authenticate_with_primary(&mut reader, signing_key) {
691            // A read timeout means no challenge arrived: the target
692            // holds the port but is not serving — typically a
693            // not-yet-promoted replica (the listener is bound from boot)
694            // or a primary mid-start. Only other errors suggest a real
695            // credential problem. SO_RCVTIMEO surfaces as WouldBlock on
696            // Linux; match TimedOut too for portability.
697            if matches!(
698                e.kind(),
699                std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
700            ) {
701                warn!(error = %e, "no auth challenge from primary (not serving yet?) — retrying");
702            } else {
703                warn!(error = %e, "authentication failed — retrying");
704            }
705            // Drop the socket before sleeping — held through the
706            // backoff it would sit in the target's accept backlog, and
707            // a just-promoted primary would waste a replica slot (and
708            // its 10s auth timeout) on the zombie.
709            drop(reader);
710            drop(tcp_writer);
711            // Back off before redialing — without the sleep this loop
712            // hammers a primary that keeps refusing us.
713            sleep_then_double_backoff(&mut backoff, shutdown, promote);
714            continue;
715        }
716        info!("authenticated with primary");
717        // The primary is demonstrably alive from here until this
718        // session ends — the raft driver must not auto-promote past it.
719        primary_link_up.store(true, Ordering::Release);
720
721        // --- Handshake ---
722        // Advertise our fencing epoch. If we are ahead of the primary it
723        // sees this and self-demotes (it's a stale ex-primary); see
724        // `crate::fence` and the sender's handshake handling.
725        let handshake = Handshake {
726            last_sequence,
727            chain_hash,
728            epoch: fence_state.epoch(),
729        };
730        send_buf.clear();
731        encode_handshake(&handshake, &mut send_buf);
732        tcp_writer.write_all(&send_buf)?;
733        tcp_writer.flush()?;
734        send_buf.clear();
735
736        // --- Protocol negotiation ---
737        // `session_start` is the resume point this streaming session
738        // continues from — the value that anchors the receiver's
739        // sequence-contiguity gate. Derived from local knowledge (our
740        // own handshake, or the verified snapshot), never from the
741        // wire.
742        let response_frame = read_frame(&mut reader, MAX_CONTROL_FRAME)?;
743        let response = decode_primary_message(&response_frame)?;
744        let (stream_lineage, session_start) = match response {
745            PrimaryMessage::StreamStart {
746                start_sequence,
747                segment_start_sequence,
748                anchor_hash,
749                epoch,
750                ack_policy,
751            } => {
752                // Fence: refuse to follow a primary whose epoch is behind
753                // ours — following its (divergent) lineage on top of our
754                // more-current state would corrupt the journal. Disconnect
755                // and retry with backoff; the operator's logs flag the
756                // misdirected `--replica-of`. Our handshake (already sent)
757                // carries our higher epoch, so the stale primary also fences
758                // itself on its side.
759                let our_epoch = fence_state.epoch();
760                if fence_state.refuses_primary(epoch) {
761                    warn!(
762                        primary_epoch = epoch,
763                        our_epoch,
764                        "primary is behind our fencing epoch — refusing to follow stale primary"
765                    );
766                    // Drop the socket before sleeping — see the auth
767                    // failure arm above.
768                    drop(reader);
769                    drop(tcp_writer);
770                    sleep_then_double_backoff(&mut backoff, shutdown, promote);
771                    continue;
772                }
773                // Adopt the primary's epoch immediately; streamed `EpochBump`s
774                // keep it current thereafter. Likewise the ack policy
775                // (heartbeats keep it current mid-session).
776                fence_state.observe_epoch(epoch);
777                primary_ack_policy.store(ack_policy, Ordering::Release);
778                info!(start_sequence, epoch, ack_policy, "streaming started");
779                ((segment_start_sequence, anchor_hash), last_sequence)
780            }
781            ref resync @ (PrimaryMessage::NeedSnapshot | PrimaryMessage::HashMismatch) => {
782                let divergent = matches!(resync, PrimaryMessage::HashMismatch);
783                // `map_err` drops `Send + Sync` by coercion — the `?` `From`
784                // bridge doesn't apply directly across the two boxed trait
785                // objects (`Box<dyn Error + Send + Sync>` → `Box<dyn Error>`).
786                let decision = handle_resync_verdict(
787                    divergent,
788                    &mut TcpFrameSource {
789                        reader: &mut reader,
790                    },
791                    &mut pipeline,
792                    &mut exchange,
793                    &mut journal_writer,
794                    journal_path,
795                    &snapshot_path,
796                    &fence_state,
797                    control,
798                    &mut last_sequence,
799                    &mut chain_hash,
800                )
801                .map_err(|e| -> Box<dyn std::error::Error> { e })?;
802                match decision {
803                    ResyncDecision::Ready {
804                        segment_start_sequence,
805                        anchor_hash,
806                        resume_sequence,
807                    } => ((segment_start_sequence, anchor_hash), resume_sequence),
808                    ResyncDecision::Retry => {
809                        sleep_then_double_backoff(&mut backoff, shutdown, promote);
810                        continue;
811                    }
812                }
813            }
814            _ => {
815                return Err(format!("unexpected response: {response:?}").into());
816            }
817        };
818
819        // --- Create journal for fresh replica ---
820        // The StreamStart lineage gives the segment header identity
821        // (starting sequence + chain anchor) the primary's own journal
822        // lineage began with; creating the local segment from the same
823        // identity makes the replica's segment byte-identical to the
824        // primary's, and adopted `Rotate` boundaries keep it that way
825        // across rotations (bitwise mirror).
826        if pipeline.is_none() && journal_writer.is_none() {
827            let (lineage_start, lineage_anchor) = stream_lineage;
828            let writer =
829                BufferedWriter::create_continuing(journal_path, lineage_start, lineage_anchor)?;
830            exchange = Some(A::default());
831            journal_writer = Some(writer);
832        }
833
834        // --- Build pipeline if absent ---
835        if pipeline.is_none() {
836            let cur_exchange = exchange.take().expect("exchange initialized");
837            let cur_writer = journal_writer.take().expect("journal_writer initialized");
838            pipeline = Some(build_replica_pipeline_with_threads::<A>(
839                cur_exchange,
840                cur_writer,
841                cores,
842                staging_mode,
843                snapshot_interval_ms,
844                snapshot_path.clone(),
845                group_commit_delay,
846                Arc::clone(&fence_state),
847                Arc::clone(pipeline_healthy),
848                sizing,
849            )?);
850        }
851
852        // --- Streaming session ---
853        let result = {
854            let p = pipeline.as_mut().expect("pipeline must exist by here");
855            let input_producer = &mut p.input_producer;
856            let journal_cursor = p.journal_cursor.as_ref();
857            let stream_marks = &p.stream_marks;
858            let journal_failed = &p.journal_failed;
859            std::thread::scope(|s| {
860                let handle = std::thread::Builder::new()
861                    .name("replica-receiver".into())
862                    .spawn_scoped(s, || {
863                        melin_app::affinity::pin_thread("replica-receiver", cores.reader.core);
864                        let mut transport = match UringTransport::new(&tcp_writer) {
865                            Ok(t) => t,
866                            Err(e) => {
867                                tracing::error!(error = %e, "UringTransport init failed");
868                                // Synthetic result: streaming never started,
869                                // so `heard_from_primary` stays false and the
870                                // reconnect keeps its escalated backoff — this
871                                // failure is local (io_uring setup), and a
872                                // persistent one must not redial at 1 Hz.
873                                return super::receiver_transport::StreamingResult {
874                                    exit: SessionExit::Disconnected,
875                                    heard_from_primary: false,
876                                };
877                            }
878                        };
879                        streaming_loop::<UringTransport, A::Event>(
880                            &mut transport,
881                            input_producer,
882                            journal_cursor,
883                            shutdown,
884                            control,
885                            pipeline_depth,
886                            cores.reader.wait,
887                            session_start,
888                            Vec::with_capacity(MAX_DATA_FRAME + 4),
889                            None,
890                            stream_marks,
891                            journal_failed,
892                        )
893                    })
894                    .expect("spawn replica-receiver thread");
895                handle.join().expect("replica-receiver thread panicked")
896            })
897        };
898
899        primary_link_up.store(false, Ordering::Release);
900        match handle_session_exit(
901            result,
902            &mut pipeline,
903            &mut divergence_resyncs,
904            &mut backoff,
905            last_sequence,
906            journal_path,
907            &snapshot_path,
908            &fence_state,
909            shutdown,
910            promote,
911            // Kernel TCP needs no explicit close — the `TcpStream` is
912            // dropped on the next loop turn when a fresh connection
913            // replaces it.
914            || {},
915            sizing,
916        ) {
917            AfterSession::Return(r) => return r,
918            AfterSession::Resync {
919                exchange: ex,
920                journal_writer: wr,
921                last_sequence: seq,
922                chain_hash: hash,
923            } => {
924                exchange = ex;
925                journal_writer = wr;
926                last_sequence = seq;
927                chain_hash = hash;
928                continue;
929            }
930            AfterSession::Reconnect => {}
931        }
932    }
933}
934
935// ---------------------------------------------------------------------------
936// Tests
937// ---------------------------------------------------------------------------
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942    use crate::uring_teardown::DRAIN_TIMEOUT;
943    use std::io::Read;
944    use std::net::TcpListener;
945    use std::time::{Duration, Instant};
946
947    use melin_transport_core::replication::protocol::{ReplicaMessage, decode_replica_message};
948
949    /// Connected localhost pair: (transport side, peer side).
950    fn socket_pair() -> (TcpStream, TcpStream) {
951        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
952        let addr = listener.local_addr().unwrap();
953        let client = TcpStream::connect(addr).unwrap();
954        let (server, _) = listener.accept().unwrap();
955        (client, server)
956    }
957
958    fn ack(seq: u64) -> Ack {
959        Ack {
960            acked_sequence: seq,
961            in_memory_sequence: seq,
962        }
963    }
964
965    /// Drive `poll_recv` until every accepted ack has reached the wire.
966    fn flush_acks(transport: &mut UringTransport) {
967        let mut recv_buf = Vec::new();
968        let deadline = Instant::now() + Duration::from_secs(5);
969        while transport.ack_in_flight() {
970            transport.poll_recv(&mut recv_buf).unwrap();
971            assert!(Instant::now() < deadline, "ack send never completed");
972            std::hint::spin_loop();
973        }
974    }
975
976    /// Read `expect` length-prefixed ack frames from the peer socket.
977    fn read_acks(peer: &mut TcpStream, expect: usize) -> Vec<Ack> {
978        peer.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
979        let mut buf = Vec::new();
980        let mut chunk = [0u8; 256];
981        let mut acks = Vec::new();
982        while acks.len() < expect {
983            let n = peer.read(&mut chunk).expect("peer read");
984            assert!(n > 0, "peer closed before all acks arrived");
985            buf.extend_from_slice(&chunk[..n]);
986            // Parse complete frames.
987            loop {
988                if buf.len() < 4 {
989                    break;
990                }
991                let len = u32::from_le_bytes(buf[0..4].try_into().unwrap()) as usize;
992                if buf.len() < 4 + len {
993                    break;
994                }
995                match decode_replica_message(&buf[4..4 + len]).expect("decodable frame") {
996                    ReplicaMessage::Ack(a) => acks.push(a),
997                    other => panic!("expected Ack frame, got {other:?}"),
998                }
999                buf.drain(..4 + len);
1000            }
1001        }
1002        assert!(buf.is_empty(), "trailing bytes after expected acks");
1003        acks
1004    }
1005
1006    #[test]
1007    fn coalesces_to_latest_ack_while_send_in_flight() {
1008        let (client, mut peer) = socket_pair();
1009        let mut transport = UringTransport::new(&client).unwrap();
1010
1011        // First ack goes straight onto the wire path; the next two are
1012        // accepted while it is in flight (the flag clears only when the
1013        // CQE is reaped in poll_recv, so this is deterministic) and
1014        // coalesce — only the newest survives.
1015        assert!(transport.send_ack(&ack(1)).unwrap());
1016        assert!(transport.ack_in_flight());
1017        assert!(transport.send_ack(&ack(2)).unwrap());
1018        assert!(transport.send_ack(&ack(3)).unwrap());
1019
1020        flush_acks(&mut transport);
1021
1022        // Exactly two frames, in cursor order: ack(2) was subsumed.
1023        let acks = read_acks(&mut peer, 2);
1024        let seqs: Vec<u64> = acks.iter().map(|a| a.acked_sequence).collect();
1025        assert_eq!(seqs, vec![1, 3]);
1026    }
1027
1028    #[test]
1029    fn sequential_acks_all_reach_the_wire_in_order() {
1030        let (client, mut peer) = socket_pair();
1031        let mut transport = UringTransport::new(&client).unwrap();
1032
1033        for seq in 1..=3 {
1034            assert!(transport.send_ack(&ack(seq)).unwrap());
1035            flush_acks(&mut transport);
1036        }
1037
1038        let acks = read_acks(&mut peer, 3);
1039        let seqs: Vec<u64> = acks.iter().map(|a| a.acked_sequence).collect();
1040        assert_eq!(seqs, vec![1, 2, 3]);
1041    }
1042
1043    /// The multishot RECV armed at construction stays armed for the
1044    /// whole session, so every ordinary session exit drops the
1045    /// transport with the kernel still holding pointers into the buffer
1046    /// pool. `Drop` has to quiesce it — and promptly: a drain that
1047    /// cannot settle only gives up on the deadline, so finishing well
1048    /// inside it is what proves the buffers were released rather than
1049    /// leaked.
1050    #[test]
1051    fn drop_quiesces_the_multishot_recv() {
1052        let (client, _peer) = socket_pair();
1053        let mut transport = UringTransport::new(&client).unwrap();
1054        // `new` only pushes the multishot RECV; the first poll submits
1055        // it. Teardown from that state is what a real session does.
1056        let mut recv_buf = Vec::new();
1057        transport.poll_recv(&mut recv_buf).unwrap();
1058        assert!(transport.multishot_active);
1059
1060        let started = Instant::now();
1061        drop(transport);
1062        let elapsed = started.elapsed();
1063        assert!(
1064            elapsed < DRAIN_TIMEOUT / 4,
1065            "teardown took {elapsed:?}, close to the {DRAIN_TIMEOUT:?} deadline"
1066        );
1067    }
1068
1069    /// A failing session bails out of `poll_recv` part-way through a
1070    /// batch, abandoning CQEs that were already reaped out of the
1071    /// completion queue. Their kernel-ownership flags still have to be
1072    /// recorded: otherwise teardown waits out its whole deadline for a
1073    /// SEND completion that was consumed here and can never arrive
1074    /// again, and then leaks the buffer pool.
1075    #[test]
1076    fn abandoned_cqes_still_settle_kernel_ownership() {
1077        let (client, _peer) = socket_pair();
1078        let mut transport = UringTransport::new(&client).unwrap();
1079        let mut recv_buf = Vec::new();
1080        transport.poll_recv(&mut recv_buf).unwrap();
1081
1082        // Stands in for a SEND whose CQE landed in the batch behind a
1083        // RECV CQE that failed the session.
1084        transport.ack_in_flight = true;
1085        transport.settle_unprocessed(&[(TOKEN_SEND, 13, 0)]);
1086        assert!(
1087            !transport.ack_in_flight,
1088            "an abandoned SEND completion must release `ack_buf`"
1089        );
1090
1091        // A RECV that still carries F_MORE leaves the arm live — the
1092        // kernel may write into the pool again, so teardown must wait.
1093        transport.settle_unprocessed(&[(TOKEN_RECV, 128, IORING_CQE_F_MORE)]);
1094        assert!(
1095            transport.multishot_active,
1096            "F_MORE must leave the multishot armed"
1097        );
1098        // Only the terminal completion retires it.
1099        transport.settle_unprocessed(&[(TOKEN_RECV, 0, 0)]);
1100        assert!(!transport.multishot_active);
1101
1102        // The real arm submitted by `poll_recv` above is still live in
1103        // the kernel; restore the flag the assertion just cleared so
1104        // `Drop` drains it instead of freeing the pool underneath it.
1105        transport.multishot_active = true;
1106    }
1107
1108    /// Same, for the other kernel-visible buffer: an ack SEND pushed
1109    /// but never submitted, so the drain has to flush the SQ before it
1110    /// can wait for anything.
1111    #[test]
1112    fn drop_quiesces_a_pending_ack_send() {
1113        let (client, _peer) = socket_pair();
1114        let mut transport = UringTransport::new(&client).unwrap();
1115        assert!(transport.send_ack(&ack(7)).unwrap());
1116        assert!(transport.ack_in_flight());
1117
1118        let started = Instant::now();
1119        drop(transport);
1120        let elapsed = started.elapsed();
1121        assert!(
1122            elapsed < DRAIN_TIMEOUT / 4,
1123            "teardown took {elapsed:?}, close to the {DRAIN_TIMEOUT:?} deadline"
1124        );
1125    }
1126
1127    /// Scripted-primary scaffolding shared by the end-to-end receiver
1128    /// tests below: a no-op application over a trivial event, the
1129    /// primary-side frame helpers, and common fixtures. Gated like its
1130    /// consumers so a `no-persist` build carries no dead test code.
1131    #[cfg(not(feature = "no-persist"))]
1132    mod scripted {
1133        use super::*;
1134        use melin_app::{AppEvent, Application, ApplyCtx, CodecError, RejectReason};
1135
1136        // Re-export so the sibling test modules can name the generic
1137        // slot type without reaching into transport-core themselves.
1138        pub(super) use melin_transport_core::pipeline::InputSlot;
1139
1140        #[derive(Debug, Clone, Copy, PartialEq)]
1141        pub(super) struct EvtAdd(pub(super) u64);
1142
1143        impl AppEvent for EvtAdd {
1144            const MAX_ENCODED_SIZE: usize = 8;
1145
1146            fn encoded_size(&self) -> usize {
1147                8
1148            }
1149            fn encode(&self, buf: &mut [u8]) -> usize {
1150                buf[..8].copy_from_slice(&self.0.to_le_bytes());
1151                8
1152            }
1153            fn decode(buf: &[u8]) -> Result<Self, CodecError> {
1154                if buf.len() < 8 {
1155                    return Err(CodecError::Truncated);
1156                }
1157                Ok(EvtAdd(u64::from_le_bytes(buf[..8].try_into().expect("8"))))
1158            }
1159            fn is_query(&self) -> bool {
1160                false
1161            }
1162        }
1163
1164        #[derive(Debug, Clone, Copy)]
1165        pub(super) struct Rpt;
1166
1167        #[derive(Default)]
1168        pub(super) struct App;
1169
1170        impl Application for App {
1171            type Event = EvtAdd;
1172            type Report = Rpt;
1173            type QueryResponse = Rpt;
1174            type Sizing = ();
1175            const APP_VERSION: u16 = 1;
1176
1177            fn apply(&mut self, _e: EvtAdd, _ctx: &ApplyCtx, _out: &mut Vec<Rpt>) -> Option<Rpt> {
1178                None
1179            }
1180            fn tick(&mut self, _now_ns: u64, _out: &mut Vec<Rpt>) {}
1181            fn check_request_seq(&mut self, _key_hash: u64, _seq: u64) -> bool {
1182                true
1183            }
1184            fn build_reject(_e: &EvtAdd, _r: RejectReason) -> Rpt {
1185                Rpt
1186            }
1187            fn snapshot<W: std::io::Write>(&self, _w: &mut W) -> std::io::Result<()> {
1188                Ok(())
1189            }
1190            fn restore<R: std::io::Read>(_r: &mut R) -> std::io::Result<Self> {
1191                Ok(App)
1192            }
1193        }
1194
1195        /// Deterministic replica signing-key bytes; [`replica_auth`]
1196        /// builds the matching authorized-keys table.
1197        pub(super) const REPLICA_KEY: [u8; 32] = [0xFC; 32];
1198
1199        /// The authorized-keys table listing [`REPLICA_KEY`]'s public
1200        /// half.
1201        pub(super) fn replica_auth() -> melin_app::auth::AuthorizedKeys {
1202            let key = ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY);
1203            let pub_b64 = base64::Engine::encode(
1204                &base64::engine::general_purpose::STANDARD,
1205                key.verifying_key().to_bytes(),
1206            );
1207            melin_app::auth::AuthorizedKeys::parse(&format!("replication {pub_b64} test-replica\n"))
1208                .expect("parse keys")
1209        }
1210
1211        /// Read one length-prefixed `ReplicaMessage` frame.
1212        pub(super) fn read_replica_msg(stream: &mut TcpStream) -> ReplicaMessage {
1213            let mut len_buf = [0u8; 4];
1214            stream.read_exact(&mut len_buf).expect("frame length");
1215            let len = u32::from_le_bytes(len_buf) as usize;
1216            let mut payload = vec![0u8; len];
1217            stream.read_exact(&mut payload).expect("frame payload");
1218            decode_replica_message(&payload).expect("decodable replica frame")
1219        }
1220
1221        /// Skip frames until an `Ack` at or past `seq` arrives.
1222        pub(super) fn wait_for_ack(stream: &mut TcpStream, seq: u64) {
1223            loop {
1224                if let ReplicaMessage::Ack(a) = read_replica_msg(stream)
1225                    && a.acked_sequence >= seq
1226                {
1227                    return;
1228                }
1229            }
1230        }
1231
1232        /// Accept with a deadline so a replica that fails to (re)connect
1233        /// fails the test instead of hanging it.
1234        pub(super) fn accept_within(listener: &std::net::TcpListener, secs: u64) -> TcpStream {
1235            let deadline = Instant::now() + Duration::from_secs(secs);
1236            loop {
1237                match listener.accept() {
1238                    Ok((s, _)) => {
1239                        s.set_read_timeout(Some(Duration::from_secs(10)))
1240                            .expect("read timeout");
1241                        return s;
1242                    }
1243                    Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
1244                        assert!(Instant::now() < deadline, "replica never connected");
1245                        std::thread::sleep(Duration::from_millis(10));
1246                    }
1247                    Err(e) => panic!("accept failed: {e}"),
1248                }
1249            }
1250        }
1251    }
1252
1253    // -----------------------------------------------------------------
1254    // In-process divergence resync — end to end against a scripted
1255    // primary. Needs hash-chain (divergence is a chain verdict) and
1256    // real persistence (the resync re-derives state from disk).
1257    // -----------------------------------------------------------------
1258    #[cfg(all(feature = "hash-chain", not(feature = "no-persist")))]
1259    mod divergence_resync {
1260        use super::super::super::auth::authenticate_replica;
1261        use super::scripted::*;
1262        use super::*;
1263        use melin_journal::{BufferedWriter, JournalEvent, JournalWrite};
1264        use melin_transport_core::cursors::WireSeq;
1265        use melin_transport_core::replication::catchup::{lineage_origin, snapshot_transfer_with};
1266        use melin_transport_core::replication::protocol::{
1267            encode_hash_mismatch, encode_rotate, encode_stream_start,
1268        };
1269        use std::sync::Arc;
1270        use std::sync::atomic::AtomicBool;
1271
1272        /// A scripted primary brings a fresh replica up, streams one
1273        /// event, then announces a rotation with a deliberately wrong
1274        /// tail hash. The replica's journal stage detects divergence —
1275        /// `run_receiver` must repair it WITHOUT exiting: tear the
1276        /// pipeline down, re-derive its handshake position from disk,
1277        /// reconnect, take the HashMismatch verdict, archive the forked
1278        /// lineage, and complete the snapshot + segment-seed resync,
1279        /// then resume streaming on the rebased lineage.
1280        #[test]
1281        fn mid_stream_divergence_resyncs_in_process() {
1282            let dir = tempfile::tempdir().expect("tempdir");
1283
1284            // --- The scripted primary's journal + snapshot, served by
1285            // the real sender-side transfer code in session 2. Entries
1286            // 1..=5, rotation after 2, snapshot at 4 (mid-live-segment).
1287            let primary_journal = dir.path().join("primary.journal");
1288            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
1289            let mut chain_at_4 = [0u8; 32];
1290            for v in 1..=5u64 {
1291                w.append(&JournalEvent::App(EvtAdd(v))).expect("append");
1292                if v == 2 {
1293                    w.rotate_segment().expect("rotate");
1294                }
1295                if v == 4 {
1296                    chain_at_4 = w.chain_hash().expect("chain");
1297                }
1298            }
1299            drop(w);
1300            melin_transport_core::snapshot::save::<App>(
1301                &App,
1302                WireSeq::new(4),
1303                chain_at_4,
1304                0,
1305                &primary_journal.with_extension("snapshot"),
1306            )
1307            .expect("save snapshot");
1308            let (lineage_start, lineage_anchor) =
1309                lineage_origin(&primary_journal).expect("lineage");
1310
1311            // --- Auth: one replica key, authorized.
1312            let authorized_keys = replica_auth();
1313
1314            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1315            listener.set_nonblocking(true).expect("nonblocking");
1316            let addr = listener.local_addr().expect("addr");
1317
1318            // --- Replica under test.
1319            let replica_journal = dir.path().join("replica.journal");
1320            let replica_snapshot = dir.path().join("replica.snapshot");
1321            let shutdown = Arc::new(AtomicBool::new(false));
1322            let control = crate::replication::ReplicaControlPlane::new();
1323            let cores = crate::layout::PipelineCores::unpinned();
1324            let replica = {
1325                let journal = replica_journal.clone();
1326                let shutdown = Arc::clone(&shutdown);
1327                let control = control.clone();
1328                std::thread::spawn(move || -> Result<bool, String> {
1329                    run_receiver::<App>(
1330                        addr,
1331                        &journal,
1332                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
1333                        &shutdown,
1334                        &control,
1335                        3_600_000,
1336                        replica_snapshot,
1337                        cores,
1338                        melin_journal::StagingMode::ZeroFill,
1339                        Duration::ZERO,
1340                        64,
1341                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
1342                        &(),
1343                    )
1344                    // ReceiverResult's error is !Send — stringify for join().
1345                    .map(|state| state.is_none())
1346                    .map_err(|e| e.to_string())
1347                })
1348            };
1349
1350            // --- Session 1: fresh sync, one event, then the poisoned
1351            // rotation announce.
1352            let mut s1 = accept_within(&listener, 30);
1353            let mut s1r = s1.try_clone().expect("clone");
1354            authenticate_replica(&mut s1r, &authorized_keys).expect("auth 1");
1355            match read_replica_msg(&mut s1) {
1356                ReplicaMessage::Handshake(h) => {
1357                    assert_eq!(h.last_sequence, 0, "fresh replica handshake")
1358                }
1359                other => panic!("expected Handshake, got {other:?}"),
1360            }
1361            let mut buf = Vec::new();
1362            encode_stream_start(0, lineage_start, lineage_anchor, 0, 1, &mut buf);
1363            s1.write_all(&buf).expect("StreamStart 1");
1364            buf.clear();
1365            melin_transport_core::replication_wire::encode_input_batch(
1366                &[InputSlot::<EvtAdd> {
1367                    connection_id: 0,
1368                    key_hash: 0,
1369                    request_seq: 0,
1370                    sequence: 1,
1371                    timestamp_ns: 1,
1372                    event: JournalEvent::App(EvtAdd(1)),
1373                    // `()` without latency-trace, a timestamp with it.
1374                    publish_ts: Default::default(),
1375                    recv_ts: Default::default(),
1376                }],
1377                &mut buf,
1378            );
1379            s1.write_all(&buf).expect("InputBatch");
1380            wait_for_ack(&mut s1, 1);
1381
1382            // Wrong tail hash at the announced boundary — the replica's
1383            // local chain at sequence 1 cannot match this.
1384            buf.clear();
1385            encode_rotate(1, &[0xEE; 32], &mut buf);
1386            s1.write_all(&buf).expect("Rotate");
1387
1388            // --- Session 2: the SAME process reconnects with its
1389            // position re-derived from the forked on-disk journal.
1390            let mut s2 = accept_within(&listener, 30);
1391            let mut s2r = s2.try_clone().expect("clone");
1392            authenticate_replica(&mut s2r, &authorized_keys).expect("auth 2");
1393            match read_replica_msg(&mut s2) {
1394                ReplicaMessage::Handshake(h) => assert_eq!(
1395                    h.last_sequence, 1,
1396                    "post-divergence handshake must carry the recovered journal position"
1397                ),
1398                other => panic!("expected Handshake, got {other:?}"),
1399            }
1400            // The journal stage's divergence failure latched the health
1401            // mirror before the teardown that led to this reconnect, and
1402            // the replacement pipeline is not built until after the resync
1403            // verdict — so at this deterministic point the replica must be
1404            // reporting unhealthy.
1405            assert!(
1406                !control.pipeline_healthy.load(Ordering::Acquire),
1407                "the divergence must have latched the health mirror false"
1408            );
1409            buf.clear();
1410            encode_hash_mismatch(&mut buf);
1411            s2.write_all(&buf).expect("HashMismatch");
1412            let transfer_shutdown = AtomicBool::new(false);
1413            let mut publish = |b: &[u8]| -> std::io::Result<()> {
1414                s2.write_all(b)?;
1415                s2.flush()
1416            };
1417            // Real sender-side resync: SnapshotBegin → chunks →
1418            // SegmentSeedBegin → seed → StreamStart → catch-up (entry 5).
1419            let end = snapshot_transfer_with::<EvtAdd>(
1420                &primary_journal,
1421                &mut publish,
1422                &transfer_shutdown,
1423                1,
1424            )
1425            .expect("snapshot transfer");
1426            assert_eq!(
1427                end,
1428                melin_transport_core::replication::catchup::CatchUpResult::Ok(5)
1429            );
1430            let mut s2_acks = s2.try_clone().expect("clone");
1431            wait_for_ack(&mut s2_acks, 5);
1432
1433            // Streaming resumed on the rebuilt pipeline — the rebuild
1434            // must have reset the health mirror.
1435            assert!(
1436                control.pipeline_healthy.load(Ordering::Acquire),
1437                "the resync rebuild must reset the health mirror true"
1438            );
1439
1440            // --- Clean shutdown; the receiver must return Ok(None) —
1441            // the divergence never escaped as a process-fatal error.
1442            shutdown.store(true, Ordering::Relaxed);
1443            let result = replica.join().expect("replica thread panicked");
1444            assert_eq!(result, Ok(true), "receiver must exit cleanly via shutdown");
1445
1446            // The forked lineage was archived (never deleted), and the
1447            // live journal is the re-seeded one.
1448            assert!(
1449                dir.path().join("replica.journal.divergent.0").exists(),
1450                "divergent lineage must be archived"
1451            );
1452            assert!(replica_journal.exists(), "re-seeded live journal");
1453        }
1454
1455        /// A resync whose snapshot transfer drops mid-flight must retry
1456        /// as a fresh replica with NO leftover in-memory state. On the
1457        /// in-process divergence repair path `recover_replica_state`
1458        /// leaves `exchange`/`journal_writer` populated; the resync arm
1459        /// archives the live journal (renaming it aside) before the
1460        /// transfer. If the transfer then fails and those handles are not
1461        /// nulled, the stale writer — its backing file now under the
1462        /// `.divergent.<n>` archive — survives the fresh-replica create
1463        /// gate (`journal_writer.is_none()` false) and is rebuilt into the
1464        /// next pipeline, so the live journal is never recreated and the
1465        /// new session streams onto an archived-away inode. This pins the
1466        /// fix (mirrors the DPDK receiver): after the failed transfer the
1467        /// retry creates a fresh live journal from the StreamStart lineage.
1468        #[test]
1469        fn resync_transfer_failure_rebuilds_clean_not_over_stale_writer() {
1470            let dir = tempfile::tempdir().expect("tempdir");
1471
1472            // Minimal scripted primary — only its lineage identity is used
1473            // (session 3 streams from genesis; the dropped session 2 never
1474            // reaches the snapshot body).
1475            let primary_journal = dir.path().join("primary.journal");
1476            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
1477            for v in 1..=2u64 {
1478                w.append(&JournalEvent::App(EvtAdd(v))).expect("append");
1479            }
1480            drop(w);
1481            let (lineage_start, lineage_anchor) =
1482                lineage_origin(&primary_journal).expect("lineage");
1483
1484            let authorized_keys = replica_auth();
1485
1486            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1487            listener.set_nonblocking(true).expect("nonblocking");
1488            let addr = listener.local_addr().expect("addr");
1489
1490            let replica_journal = dir.path().join("replica.journal");
1491            let replica_snapshot = dir.path().join("replica.snapshot");
1492            let shutdown = Arc::new(AtomicBool::new(false));
1493            let control = crate::replication::ReplicaControlPlane::new();
1494            let cores = crate::layout::PipelineCores::unpinned();
1495            let replica = {
1496                let journal = replica_journal.clone();
1497                let shutdown = Arc::clone(&shutdown);
1498                let control = control.clone();
1499                std::thread::spawn(move || -> Result<bool, String> {
1500                    run_receiver::<App>(
1501                        addr,
1502                        &journal,
1503                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
1504                        &shutdown,
1505                        &control,
1506                        3_600_000,
1507                        replica_snapshot,
1508                        cores,
1509                        melin_journal::StagingMode::ZeroFill,
1510                        Duration::ZERO,
1511                        64,
1512                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
1513                        &(),
1514                    )
1515                    .map(|state| state.is_none())
1516                    .map_err(|e| e.to_string())
1517                })
1518            };
1519
1520            // Helper: stream a single event (seq 1) over an established
1521            // session and wait for its ack.
1522            let stream_one = |s: &mut TcpStream, buf: &mut Vec<u8>| {
1523                buf.clear();
1524                encode_stream_start(0, lineage_start, lineage_anchor, 0, 1, buf);
1525                s.write_all(buf).expect("StreamStart");
1526                buf.clear();
1527                melin_transport_core::replication_wire::encode_input_batch(
1528                    &[InputSlot::<EvtAdd> {
1529                        connection_id: 0,
1530                        key_hash: 0,
1531                        request_seq: 0,
1532                        sequence: 1,
1533                        timestamp_ns: 1,
1534                        event: JournalEvent::App(EvtAdd(1)),
1535                        publish_ts: Default::default(),
1536                        recv_ts: Default::default(),
1537                    }],
1538                    buf,
1539                );
1540                s.write_all(buf).expect("InputBatch");
1541            };
1542
1543            // --- Session 1: fresh sync, event 1, poisoned rotation —
1544            // the journal stage detects divergence and the receiver
1545            // repairs in-process (`recover_replica_state` repopulates
1546            // `exchange`/`journal_writer`).
1547            let mut buf = Vec::new();
1548            let mut s1 = accept_within(&listener, 30);
1549            let mut s1r = s1.try_clone().expect("clone");
1550            authenticate_replica(&mut s1r, &authorized_keys).expect("auth 1");
1551            match read_replica_msg(&mut s1) {
1552                ReplicaMessage::Handshake(h) => assert_eq!(h.last_sequence, 0, "fresh"),
1553                other => panic!("expected Handshake, got {other:?}"),
1554            }
1555            stream_one(&mut s1, &mut buf);
1556            wait_for_ack(&mut s1, 1);
1557            buf.clear();
1558            encode_rotate(1, &[0xEE; 32], &mut buf);
1559            s1.write_all(&buf).expect("Rotate");
1560
1561            // --- Session 2: reconnect at the recovered position (seq 1),
1562            // verdict HashMismatch (archives the live journal aside), then
1563            // DROP the connection before SnapshotBegin — the transfer
1564            // fails and the receiver must retry as a fresh replica.
1565            let mut s2 = accept_within(&listener, 30);
1566            let mut s2r = s2.try_clone().expect("clone");
1567            authenticate_replica(&mut s2r, &authorized_keys).expect("auth 2");
1568            match read_replica_msg(&mut s2) {
1569                ReplicaMessage::Handshake(h) => assert_eq!(
1570                    h.last_sequence, 1,
1571                    "reconnect carries the recovered forked position"
1572                ),
1573                other => panic!("expected Handshake, got {other:?}"),
1574            }
1575            buf.clear();
1576            encode_hash_mismatch(&mut buf);
1577            s2.write_all(&buf).expect("HashMismatch");
1578            s2.flush().expect("flush");
1579            // Both fds reference the same socket (try_clone dups) — drop
1580            // both to send FIN so the replica's SnapshotBegin read hits EOF.
1581            drop(s2);
1582            drop(s2r);
1583
1584            // --- Session 3: the receiver reconnects as a FRESH replica
1585            // (the archived lineage's position is meaningless, so
1586            // `last_sequence` is 0). It must recreate a live journal from
1587            // the StreamStart lineage, NOT rebuild over the stale writer.
1588            let mut s3 = accept_within(&listener, 30);
1589            let mut s3r = s3.try_clone().expect("clone");
1590            authenticate_replica(&mut s3r, &authorized_keys).expect("auth 3");
1591            match read_replica_msg(&mut s3) {
1592                ReplicaMessage::Handshake(h) => assert_eq!(
1593                    h.last_sequence, 0,
1594                    "after a failed transfer the retry handshakes as fresh"
1595                ),
1596                other => panic!("expected Handshake, got {other:?}"),
1597            }
1598            stream_one(&mut s3, &mut buf);
1599            wait_for_ack(&mut s3, 1);
1600
1601            shutdown.store(true, Ordering::Relaxed);
1602            let result = replica.join().expect("replica thread panicked");
1603            assert_eq!(result, Ok(true), "receiver exits cleanly via shutdown");
1604
1605            // The forked lineage stays archived, and a fresh live journal
1606            // was recreated from the lineage. With the bug, the stale
1607            // writer would be reused and this path would not exist (its
1608            // inode stranded under `.divergent.0`).
1609            assert!(
1610                dir.path().join("replica.journal.divergent.0").exists(),
1611                "forked lineage archived, never deleted"
1612            );
1613            assert!(
1614                replica_journal.exists(),
1615                "fresh live journal recreated after the failed transfer"
1616            );
1617        }
1618
1619        /// The in-process repair budget is one per process lifetime: a
1620        /// second mid-stream divergence is systematic (corruption or a
1621        /// serious bug that the first re-seed did not cure) and the
1622        /// receiver must exit hard instead of looping — each repair
1623        /// cycle archives a full journal copy, and a repair loop would
1624        /// fill the disk while masking the underlying fault.
1625        #[test]
1626        fn second_mid_stream_divergence_exits_hard() {
1627            let dir = tempfile::tempdir().expect("tempdir");
1628
1629            let primary_journal = dir.path().join("primary.journal");
1630            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
1631            let mut chain_at_4 = [0u8; 32];
1632            for v in 1..=5u64 {
1633                w.append(&JournalEvent::App(EvtAdd(v))).expect("append");
1634                if v == 2 {
1635                    w.rotate_segment().expect("rotate");
1636                }
1637                if v == 4 {
1638                    chain_at_4 = w.chain_hash().expect("chain");
1639                }
1640            }
1641            drop(w);
1642            melin_transport_core::snapshot::save::<App>(
1643                &App,
1644                WireSeq::new(4),
1645                chain_at_4,
1646                0,
1647                &primary_journal.with_extension("snapshot"),
1648            )
1649            .expect("save snapshot");
1650            let (lineage_start, lineage_anchor) =
1651                lineage_origin(&primary_journal).expect("lineage");
1652
1653            let authorized_keys = replica_auth();
1654
1655            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1656            listener.set_nonblocking(true).expect("nonblocking");
1657            let addr = listener.local_addr().expect("addr");
1658
1659            let replica_journal = dir.path().join("replica.journal");
1660            let replica_snapshot = dir.path().join("replica.snapshot");
1661            let shutdown = Arc::new(AtomicBool::new(false));
1662            let control = crate::replication::ReplicaControlPlane::new();
1663            let cores = crate::layout::PipelineCores::unpinned();
1664            let replica = {
1665                let journal = replica_journal.clone();
1666                let shutdown = Arc::clone(&shutdown);
1667                let control = control.clone();
1668                std::thread::spawn(move || -> Result<bool, String> {
1669                    run_receiver::<App>(
1670                        addr,
1671                        &journal,
1672                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
1673                        &shutdown,
1674                        &control,
1675                        3_600_000,
1676                        replica_snapshot,
1677                        cores,
1678                        melin_journal::StagingMode::ZeroFill,
1679                        Duration::ZERO,
1680                        64,
1681                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
1682                        &(),
1683                    )
1684                    .map(|state| state.is_none())
1685                    .map_err(|e| e.to_string())
1686                })
1687            };
1688
1689            // Session 1: fresh sync, one event, poisoned rotation.
1690            let mut s1 = accept_within(&listener, 30);
1691            let mut s1r = s1.try_clone().expect("clone");
1692            authenticate_replica(&mut s1r, &authorized_keys).expect("auth 1");
1693            match read_replica_msg(&mut s1) {
1694                ReplicaMessage::Handshake(h) => assert_eq!(h.last_sequence, 0),
1695                other => panic!("expected Handshake, got {other:?}"),
1696            }
1697            let mut buf = Vec::new();
1698            encode_stream_start(0, lineage_start, lineage_anchor, 0, 1, &mut buf);
1699            s1.write_all(&buf).expect("StreamStart 1");
1700            buf.clear();
1701            melin_transport_core::replication_wire::encode_input_batch(
1702                &[InputSlot::<EvtAdd> {
1703                    connection_id: 0,
1704                    key_hash: 0,
1705                    request_seq: 0,
1706                    sequence: 1,
1707                    timestamp_ns: 1,
1708                    event: JournalEvent::App(EvtAdd(1)),
1709                    publish_ts: Default::default(),
1710                    recv_ts: Default::default(),
1711                }],
1712                &mut buf,
1713            );
1714            s1.write_all(&buf).expect("InputBatch");
1715            wait_for_ack(&mut s1, 1);
1716            buf.clear();
1717            encode_rotate(1, &[0xEE; 32], &mut buf);
1718            s1.write_all(&buf).expect("Rotate 1");
1719
1720            // Session 2: in-process repair (the one allowed), then a
1721            // SECOND poisoned rotation after streaming resumes.
1722            let mut s2 = accept_within(&listener, 30);
1723            let mut s2r = s2.try_clone().expect("clone");
1724            authenticate_replica(&mut s2r, &authorized_keys).expect("auth 2");
1725            match read_replica_msg(&mut s2) {
1726                ReplicaMessage::Handshake(h) => assert_eq!(h.last_sequence, 1),
1727                other => panic!("expected Handshake, got {other:?}"),
1728            }
1729            buf.clear();
1730            encode_hash_mismatch(&mut buf);
1731            s2.write_all(&buf).expect("HashMismatch");
1732            let transfer_shutdown = AtomicBool::new(false);
1733            {
1734                let mut publish = |b: &[u8]| -> std::io::Result<()> {
1735                    s2.write_all(b)?;
1736                    s2.flush()
1737                };
1738                snapshot_transfer_with::<EvtAdd>(
1739                    &primary_journal,
1740                    &mut publish,
1741                    &transfer_shutdown,
1742                    1,
1743                )
1744                .expect("snapshot transfer");
1745            }
1746            buf.clear();
1747            melin_transport_core::replication_wire::encode_input_batch(
1748                &[InputSlot::<EvtAdd> {
1749                    connection_id: 0,
1750                    key_hash: 0,
1751                    request_seq: 0,
1752                    sequence: 6,
1753                    timestamp_ns: 6,
1754                    event: JournalEvent::App(EvtAdd(6)),
1755                    publish_ts: Default::default(),
1756                    recv_ts: Default::default(),
1757                }],
1758                &mut buf,
1759            );
1760            s2.write_all(&buf).expect("InputBatch 6");
1761            let mut s2_acks = s2.try_clone().expect("clone");
1762            wait_for_ack(&mut s2_acks, 6);
1763            buf.clear();
1764            encode_rotate(6, &[0xEE; 32], &mut buf);
1765            s2.write_all(&buf).expect("Rotate 2");
1766
1767            // No third connection: the receiver must give up and exit
1768            // with an error naming the recurrence.
1769            let result = replica.join().expect("replica thread panicked");
1770            let err = result.expect_err("second divergence must exit hard");
1771            assert!(
1772                err.contains("divergence recurred"),
1773                "error must name the recurrence: {err}"
1774            );
1775        }
1776
1777        // -------------------------------------------------------------
1778        // Promotion teardown helper (`take_pipeline_for_promotion`) — the
1779        // no-live-pipeline decision branches. With no pipeline to tear
1780        // down it hands back the warm state held in the receiver's locals
1781        // (a clean promotion) or errors when none is present. Colocated
1782        // here for the `App` + `BufferedWriter` fixtures above; the
1783        // Clean-pipeline branch needs a live pipeline (failover IT).
1784        // -------------------------------------------------------------
1785        type PromoteHandles =
1786            crate::replication::ReplicaPipelineHandles<App, BufferedWriter<EvtAdd>>;
1787
1788        #[test]
1789        fn promotion_hands_back_local_state_when_present() {
1790            let dir = tempfile::tempdir().unwrap();
1791            let mut pipeline: Option<PromoteHandles> = None;
1792            let mut exchange = Some(App);
1793            let mut journal_writer =
1794                Some(BufferedWriter::<EvtAdd>::create(&dir.path().join("p.journal")).unwrap());
1795
1796            let result = crate::replication::take_pipeline_for_promotion(
1797                &mut pipeline,
1798                &mut exchange,
1799                &mut journal_writer,
1800            );
1801
1802            assert!(matches!(result, Ok(Some(_))), "warm state must be promoted");
1803            assert!(
1804                exchange.is_none() && journal_writer.is_none(),
1805                "state must be moved into the result"
1806            );
1807        }
1808
1809        #[test]
1810        fn promotion_errs_when_no_local_state() {
1811            let mut pipeline: Option<PromoteHandles> = None;
1812            let mut exchange: Option<App> = None;
1813            let mut journal_writer: Option<BufferedWriter<EvtAdd>> = None;
1814
1815            let result = crate::replication::take_pipeline_for_promotion(
1816                &mut pipeline,
1817                &mut exchange,
1818                &mut journal_writer,
1819            );
1820
1821            // Not `expect_err` — the Ok payload (App) isn't `Debug`.
1822            let err = match result {
1823                Err(e) => e,
1824                Ok(_) => panic!("a promote with nothing to promote must error"),
1825            };
1826            assert!(
1827                err.to_string().contains("no local state available"),
1828                "{err}"
1829            );
1830        }
1831
1832        #[test]
1833        fn promotion_errs_on_partial_state() {
1834            // Exchange present, writer missing — not a usable hand-off.
1835            let mut pipeline: Option<PromoteHandles> = None;
1836            let mut exchange = Some(App);
1837            let mut journal_writer: Option<BufferedWriter<EvtAdd>> = None;
1838
1839            let result = crate::replication::take_pipeline_for_promotion(
1840                &mut pipeline,
1841                &mut exchange,
1842                &mut journal_writer,
1843            );
1844
1845            assert!(result.is_err(), "partial state is not a valid promotion");
1846        }
1847    }
1848
1849    // -----------------------------------------------------------------
1850    // Shutdown during reconnect backoff — end to end against a scripted
1851    // primary. Needs real persistence (the pipeline journals the one
1852    // streamed event); no chain verdicts involved, so no hash-chain.
1853    // -----------------------------------------------------------------
1854    #[cfg(not(feature = "no-persist"))]
1855    mod backoff_shutdown {
1856        use super::super::super::auth::authenticate_replica;
1857        use super::scripted::*;
1858        use super::*;
1859        use melin_journal::{BufferedWriter, JournalEvent, JournalWrite};
1860        use melin_transport_core::replication::catchup::lineage_origin;
1861        use melin_transport_core::replication::protocol::encode_stream_start;
1862        use std::sync::Arc;
1863        use std::sync::atomic::AtomicBool;
1864
1865        /// Names of every live thread in this process, via
1866        /// `/proc/self/task` (Linux-only, like the runtime itself).
1867        fn live_thread_names() -> Vec<String> {
1868            std::fs::read_dir("/proc/self/task")
1869                .expect("read /proc/self/task")
1870                .filter_map(|entry| {
1871                    // A thread may exit between readdir and the comm
1872                    // read — skipping it is the correct outcome.
1873                    let entry = entry.ok()?;
1874                    let name = std::fs::read_to_string(entry.path().join("comm")).ok()?;
1875                    Some(name.trim().to_string())
1876                })
1877                .collect()
1878        }
1879
1880        /// Shutdown while the receiver waits out a reconnect backoff must
1881        /// tear the live pipeline down — join every stage thread — rather
1882        /// than abandon it. Pins the fix for the connect-failure arm that
1883        /// returned without teardown (stages left detached mid-run, the
1884        /// journal writer never closed).
1885        #[test]
1886        fn shutdown_during_reconnect_backoff_joins_pipeline_threads() {
1887            let dir = tempfile::tempdir().expect("tempdir");
1888
1889            // Minimal primary journal — only its lineage identity is
1890            // needed for the StreamStart the fresh replica accepts.
1891            let primary_journal = dir.path().join("primary.journal");
1892            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
1893            w.append(&JournalEvent::App(EvtAdd(1))).expect("append");
1894            drop(w);
1895            let (lineage_start, lineage_anchor) =
1896                lineage_origin(&primary_journal).expect("lineage");
1897
1898            let authorized_keys = replica_auth();
1899            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1900            listener.set_nonblocking(true).expect("nonblocking");
1901            let addr = listener.local_addr().expect("addr");
1902
1903            let shutdown = Arc::new(AtomicBool::new(false));
1904            let control = crate::replication::ReplicaControlPlane::new();
1905            let replica = {
1906                let journal = dir.path().join("replica.journal");
1907                let snapshot = dir.path().join("replica.snapshot");
1908                let shutdown = Arc::clone(&shutdown);
1909                let control = control.clone();
1910                std::thread::spawn(move || -> Result<bool, String> {
1911                    run_receiver::<App>(
1912                        addr,
1913                        &journal,
1914                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
1915                        &shutdown,
1916                        &control,
1917                        3_600_000,
1918                        snapshot,
1919                        crate::layout::PipelineCores::unpinned(),
1920                        melin_journal::StagingMode::ZeroFill,
1921                        Duration::ZERO,
1922                        64,
1923                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
1924                        &(),
1925                    )
1926                    // ReceiverResult's error is !Send — stringify for join().
1927                    .map(|state| state.is_none())
1928                    .map_err(|e| e.to_string())
1929                })
1930            };
1931
1932            // Session 1: bring the pipeline up for real — stream one
1933            // event and wait for its ack (acked ⇒ journaled ⇒ the stage
1934            // threads exist).
1935            let mut s1 = accept_within(&listener, 30);
1936            let mut s1r = s1.try_clone().expect("clone");
1937            authenticate_replica(&mut s1r, &authorized_keys).expect("auth");
1938            match read_replica_msg(&mut s1) {
1939                ReplicaMessage::Handshake(h) => {
1940                    assert_eq!(h.last_sequence, 0, "fresh replica handshake")
1941                }
1942                other => panic!("expected Handshake, got {other:?}"),
1943            }
1944            let mut buf = Vec::new();
1945            encode_stream_start(0, lineage_start, lineage_anchor, 0, 1, &mut buf);
1946            s1.write_all(&buf).expect("StreamStart");
1947            buf.clear();
1948            melin_transport_core::replication_wire::encode_input_batch(
1949                &[InputSlot::<EvtAdd> {
1950                    connection_id: 0,
1951                    key_hash: 0,
1952                    request_seq: 0,
1953                    sequence: 1,
1954                    timestamp_ns: 1,
1955                    event: JournalEvent::App(EvtAdd(1)),
1956                    publish_ts: Default::default(),
1957                    recv_ts: Default::default(),
1958                }],
1959                &mut buf,
1960            );
1961            s1.write_all(&buf).expect("InputBatch");
1962            wait_for_ack(&mut s1, 1);
1963
1964            // Kill the primary completely — both socket halves and the
1965            // listener — so every reconnect attempt is refused and the
1966            // receiver settles into the connect-failure backoff.
1967            drop(s1);
1968            drop(s1r);
1969            drop(listener);
1970            // Past the 1s post-disconnect backoff, inside the 2s
1971            // connect-failure backoff sleep. (Any other point in the
1972            // reconnect loop must satisfy the assertions too — this
1973            // timing just targets the arm that historically leaked.)
1974            std::thread::sleep(Duration::from_millis(1500));
1975
1976            shutdown.store(true, Ordering::Relaxed);
1977            let deadline = Instant::now() + Duration::from_secs(10);
1978            while !replica.is_finished() {
1979                assert!(
1980                    Instant::now() < deadline,
1981                    "receiver did not exit after shutdown"
1982                );
1983                std::thread::sleep(Duration::from_millis(20));
1984            }
1985            let result = replica.join().expect("replica thread panicked");
1986            assert_eq!(result, Ok(true), "receiver must exit cleanly via shutdown");
1987
1988            // The stage threads are named at spawn, and a leaked stage
1989            // spins forever — absence within the deadline proves the
1990            // teardown joined them. Deadline-poll rather than one-shot:
1991            // under plain `cargo test`, concurrent tests' stages may be
1992            // momentarily alive, but those exit on their own.
1993            let deadline = Instant::now() + Duration::from_secs(10);
1994            loop {
1995                let names = live_thread_names();
1996                if !names
1997                    .iter()
1998                    .any(|n| n == "journal-seq" || n == "matching" || n == "drain")
1999                {
2000                    break;
2001                }
2002                assert!(
2003                    Instant::now() < deadline,
2004                    "pipeline threads still alive after shutdown: {names:?}"
2005                );
2006                std::thread::sleep(Duration::from_millis(50));
2007            }
2008        }
2009    }
2010
2011    // -----------------------------------------------------------------
2012    // Boundary adoption under both staging modes — end to end against a
2013    // scripted primary. Needs hash-chain (the boundary announce carries
2014    // a chain value the replica verifies before rotating) and real
2015    // persistence (the rotation archives the on-disk segment).
2016    // -----------------------------------------------------------------
2017    #[cfg(all(feature = "hash-chain", not(feature = "no-persist")))]
2018    mod rotation_adoption {
2019        use super::super::super::auth::authenticate_replica;
2020        use super::scripted::*;
2021        use super::*;
2022        use melin_journal::{BufferedWriter, JournalEvent, JournalWrite};
2023        use melin_transport_core::replication::catchup::lineage_origin;
2024        use melin_transport_core::replication::protocol::{encode_rotate, encode_stream_start};
2025        use std::sync::Arc;
2026        use std::sync::atomic::AtomicBool;
2027
2028        /// Payloads of every entry in the journal at `path`, in order.
2029        fn payloads(path: &std::path::Path) -> Vec<u64> {
2030            let mut reader = melin_journal::JournalReader::<EvtAdd>::open(path).expect("open");
2031            let mut out = Vec::new();
2032            while let Some(entry) = reader.next_entry().expect("read entry") {
2033                if let JournalEvent::App(EvtAdd(v)) = entry.event {
2034                    out.push(v);
2035                }
2036            }
2037            out
2038        }
2039
2040        /// A scripted primary streams two events, announces the rotation
2041        /// boundary with the *correct* tail hash, and streams two more.
2042        /// The replica must verify the boundary, rotate — adopting the
2043        /// segment its preparer staged in `mode` — and keep acking on
2044        /// the new segment, leaving the sealed one archived.
2045        ///
2046        /// Run under both modes: everything observable (archive layout,
2047        /// entry placement across the boundary, acks, clean shutdown)
2048        /// must be identical, or the staging mode is not a tuning knob
2049        /// but a behaviour change.
2050        fn boundary_adoption_rotates_and_streams_on(mode: melin_journal::StagingMode) {
2051            // Shrink the prealloc chunk so the replica's preparer stages
2052            // a few MiB (chunk + margin) instead of 256 MiB per test.
2053            // The guard holds a process-wide lock, so the two mode
2054            // variants serialise rather than race the global override.
2055            let _prealloc_guard =
2056                melin_journal::test_utils::PreallocOverrideGuard::new(1024 * 1024);
2057            let dir = tempfile::tempdir().expect("tempdir");
2058
2059            // Scripted primary journal: entries 1..=2 carrying the same
2060            // stamps as the InputSlots below (deterministic timestamps,
2061            // zero key-hash/request-seq), so its chain value at the
2062            // boundary equals the replica's — journals are byte-for-byte
2063            // mirrors, and `append`'s wall-clock stamp would fork them.
2064            let primary_journal = dir.path().join("primary.journal");
2065            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
2066            for v in 1..=2u64 {
2067                w.batch_append_with_ts(&JournalEvent::App(EvtAdd(v)), v, 0, 0)
2068                    .expect("append");
2069            }
2070            w.flush_batch_sync().expect("flush");
2071            let chain_at_2 = w.chain_hash().expect("chain");
2072            drop(w);
2073            let (lineage_start, lineage_anchor) =
2074                lineage_origin(&primary_journal).expect("lineage");
2075
2076            let authorized_keys = replica_auth();
2077            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2078            listener.set_nonblocking(true).expect("nonblocking");
2079            let addr = listener.local_addr().expect("addr");
2080
2081            let replica_journal = dir.path().join("replica.journal");
2082            let shutdown = Arc::new(AtomicBool::new(false));
2083            let control = crate::replication::ReplicaControlPlane::new();
2084            let replica = {
2085                let journal = replica_journal.clone();
2086                let snapshot = dir.path().join("replica.snapshot");
2087                let shutdown = Arc::clone(&shutdown);
2088                let control = control.clone();
2089                std::thread::spawn(move || -> Result<bool, String> {
2090                    run_receiver::<App>(
2091                        addr,
2092                        &journal,
2093                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
2094                        &shutdown,
2095                        &control,
2096                        3_600_000,
2097                        snapshot,
2098                        crate::layout::PipelineCores::unpinned(),
2099                        mode,
2100                        Duration::ZERO,
2101                        64,
2102                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
2103                        &(),
2104                    )
2105                    // ReceiverResult's error is !Send — stringify for join().
2106                    .map(|state| state.is_none())
2107                    .map_err(|e| e.to_string())
2108                })
2109            };
2110
2111            let mut s1 = accept_within(&listener, 30);
2112            let mut s1r = s1.try_clone().expect("clone");
2113            authenticate_replica(&mut s1r, &authorized_keys).expect("auth");
2114            match read_replica_msg(&mut s1) {
2115                ReplicaMessage::Handshake(h) => {
2116                    assert_eq!(h.last_sequence, 0, "fresh replica handshake")
2117                }
2118                other => panic!("expected Handshake, got {other:?}"),
2119            }
2120            let mut buf = Vec::new();
2121            encode_stream_start(0, lineage_start, lineage_anchor, 0, 1, &mut buf);
2122            s1.write_all(&buf).expect("StreamStart");
2123
2124            let send_events = |s: &mut TcpStream, range: std::ops::RangeInclusive<u64>| {
2125                let slots: Vec<InputSlot<EvtAdd>> = range
2126                    .map(|v| InputSlot {
2127                        connection_id: 0,
2128                        key_hash: 0,
2129                        request_seq: 0,
2130                        sequence: v,
2131                        timestamp_ns: v,
2132                        event: JournalEvent::App(EvtAdd(v)),
2133                        // `()` without latency-trace, a timestamp with it.
2134                        publish_ts: Default::default(),
2135                        recv_ts: Default::default(),
2136                    })
2137                    .collect();
2138                let mut buf = Vec::new();
2139                melin_transport_core::replication_wire::encode_input_batch(&slots, &mut buf);
2140                s.write_all(&buf).expect("InputBatch");
2141            };
2142
2143            send_events(&mut s1, 1..=2);
2144            wait_for_ack(&mut s1, 2);
2145
2146            // Wait for the preparer's staged segment to reach full size
2147            // (the overridden prealloc chunk + the 8 MiB margin) so the
2148            // boundary below adopts it rather than racing staging into
2149            // the synchronous fallback — adoption is the path under
2150            // test. Both modes must produce it; a preparer that never
2151            // does is itself a failure.
2152            let staging = {
2153                // `<live>.next-staging` — the preparer's sibling-file
2154                // convention (crate-private, so spelled out here the way
2155                // the `.divergent.<n>` archive name is above).
2156                let mut s = replica_journal.clone().into_os_string();
2157                s.push(".next-staging");
2158                std::path::PathBuf::from(s)
2159            };
2160            let staged_len: u64 = (1 + 8) * 1024 * 1024;
2161            let deadline = Instant::now() + Duration::from_secs(10);
2162            loop {
2163                if std::fs::metadata(&staging).is_ok_and(|m| m.len() == staged_len) {
2164                    break;
2165                }
2166                assert!(
2167                    Instant::now() < deadline,
2168                    "preparer produced no full-size staged segment in {mode:?} mode"
2169                );
2170                std::thread::sleep(Duration::from_millis(10));
2171            }
2172            // Length is the last thing staging changes before the final
2173            // sync and the slot hand-off; give those a moment so the
2174            // rotation's `take()` finds the segment parked. (Losing this
2175            // race anyway is not a failure: the rotation falls back to
2176            // the synchronous path and every assertion below still
2177            // holds — the staged segment is then simply adopted at a
2178            // later boundary.)
2179            std::thread::sleep(Duration::from_millis(300));
2180
2181            // The boundary, with the correct tail hash — verified
2182            // against the replica's own chain, then adopted.
2183            buf.clear();
2184            encode_rotate(2, &chain_at_2, &mut buf);
2185            s1.write_all(&buf).expect("Rotate");
2186
2187            // Streaming continues on the new segment. The journal stage
2188            // holds entries past a pending boundary until the rotation
2189            // applies, so this ack proves the adoption completed.
2190            send_events(&mut s1, 3..=4);
2191            wait_for_ack(&mut s1, 4);
2192
2193            shutdown.store(true, Ordering::Relaxed);
2194            let result = replica.join().expect("replica thread panicked");
2195            assert_eq!(result, Ok(true), "receiver must exit cleanly via shutdown");
2196
2197            // The sealed segment was archived at the first archive slot
2198            // holding exactly the pre-boundary entries; the live segment
2199            // carries the stream from the boundary on.
2200            let archived = dir.path().join("replica.journal.000001");
2201            assert!(archived.exists(), "sealed segment must be archived");
2202            assert_eq!(payloads(&archived), vec![1, 2]);
2203            assert_eq!(payloads(&replica_journal), vec![3, 4]);
2204        }
2205
2206        #[test]
2207        fn boundary_adoption_zero_fill() {
2208            boundary_adoption_rotates_and_streams_on(melin_journal::StagingMode::ZeroFill);
2209        }
2210
2211        #[test]
2212        fn boundary_adoption_allocate() {
2213            boundary_adoption_rotates_and_streams_on(melin_journal::StagingMode::Allocate);
2214        }
2215    }
2216}