Skip to main content

melin_server_runtime/replication/
tcp_receiver.rs

1//! TCP replication receiver (replica side).
2//!
3//! Connects to the primary, authenticates, performs catch-up / snapshot
4//! recovery, and runs the streaming receive loop via [`streaming_loop`].
5//! Builds the replica's local pipeline (journal + matching stage + drain
6//! stages) to apply incoming events and ack durable batches back to the
7//! primary.
8
9use std::io::{self, Write};
10use std::net::{SocketAddr, TcpStream};
11use std::os::unix::io::RawFd;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, Ordering};
14
15use tracing::{info, warn};
16
17use melin_app::Application;
18use melin_journal::BufferedWriter;
19
20use super::auth::authenticate_with_primary;
21use super::receiver_transport::{
22    ControlFrameSource, ReceiverTransport, SessionExit, streaming_loop,
23};
24use super::{
25    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 app, 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 app, &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 app,
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            app = Some(A::default());
831            journal_writer = Some(writer);
832        }
833
834        // --- Build pipeline if absent ---
835        if pipeline.is_none() {
836            let cur_app = app.take().expect("application initialized");
837            let cur_writer = journal_writer.take().expect("journal_writer initialized");
838            pipeline = Some(build_replica_pipeline_with_threads::<A>(
839                cur_app,
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                app: ex,
920                journal_writer: wr,
921                last_sequence: seq,
922                chain_hash: hash,
923            } => {
924                app = 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, QueryCtx, 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>) {}
1178            fn query(&self, _e: EvtAdd, _ctx: &QueryCtx) -> Option<Rpt> {
1179                None
1180            }
1181            fn tick(&mut self, _now_ns: u64, _out: &mut Vec<Rpt>) {}
1182            fn build_reject(_e: &EvtAdd, _r: RejectReason) -> Rpt {
1183                Rpt
1184            }
1185            fn snapshot<W: std::io::Write>(&self, _w: &mut W) -> std::io::Result<()> {
1186                Ok(())
1187            }
1188            fn restore<R: std::io::Read>(_r: &mut R) -> std::io::Result<Self> {
1189                Ok(App)
1190            }
1191        }
1192
1193        /// Deterministic replica signing-key bytes; [`replica_auth`]
1194        /// builds the matching authorized-keys table.
1195        pub(super) const REPLICA_KEY: [u8; 32] = [0xFC; 32];
1196
1197        /// The authorized-keys table listing [`REPLICA_KEY`]'s public
1198        /// half.
1199        pub(super) fn replica_auth() -> melin_app::auth::AuthorizedKeys {
1200            let key = ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY);
1201            let pub_b64 = base64::Engine::encode(
1202                &base64::engine::general_purpose::STANDARD,
1203                key.verifying_key().to_bytes(),
1204            );
1205            melin_app::auth::AuthorizedKeys::parse(&format!("replication {pub_b64} test-replica\n"))
1206                .expect("parse keys")
1207        }
1208
1209        /// Read one length-prefixed `ReplicaMessage` frame.
1210        pub(super) fn read_replica_msg(stream: &mut TcpStream) -> ReplicaMessage {
1211            let mut len_buf = [0u8; 4];
1212            stream.read_exact(&mut len_buf).expect("frame length");
1213            let len = u32::from_le_bytes(len_buf) as usize;
1214            let mut payload = vec![0u8; len];
1215            stream.read_exact(&mut payload).expect("frame payload");
1216            decode_replica_message(&payload).expect("decodable replica frame")
1217        }
1218
1219        /// Skip frames until an `Ack` at or past `seq` arrives.
1220        pub(super) fn wait_for_ack(stream: &mut TcpStream, seq: u64) {
1221            loop {
1222                if let ReplicaMessage::Ack(a) = read_replica_msg(stream)
1223                    && a.acked_sequence >= seq
1224                {
1225                    return;
1226                }
1227            }
1228        }
1229
1230        /// Accept with a deadline so a replica that fails to (re)connect
1231        /// fails the test instead of hanging it.
1232        pub(super) fn accept_within(listener: &std::net::TcpListener, secs: u64) -> TcpStream {
1233            let deadline = Instant::now() + Duration::from_secs(secs);
1234            loop {
1235                match listener.accept() {
1236                    Ok((s, _)) => {
1237                        s.set_read_timeout(Some(Duration::from_secs(10)))
1238                            .expect("read timeout");
1239                        return s;
1240                    }
1241                    Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
1242                        assert!(Instant::now() < deadline, "replica never connected");
1243                        std::thread::sleep(Duration::from_millis(10));
1244                    }
1245                    Err(e) => panic!("accept failed: {e}"),
1246                }
1247            }
1248        }
1249    }
1250
1251    // -----------------------------------------------------------------
1252    // In-process divergence resync — end to end against a scripted
1253    // primary. Needs hash-chain (divergence is a chain verdict) and
1254    // real persistence (the resync re-derives state from disk).
1255    // -----------------------------------------------------------------
1256    #[cfg(all(feature = "hash-chain", not(feature = "no-persist")))]
1257    mod divergence_resync {
1258        use super::super::super::auth::authenticate_replica;
1259        use super::scripted::*;
1260        use super::*;
1261        use melin_journal::{BufferedWriter, JournalEvent, JournalWrite};
1262        use melin_transport_core::cursors::WireSeq;
1263        use melin_transport_core::replication::catchup::{lineage_origin, snapshot_transfer_with};
1264        use melin_transport_core::replication::protocol::{
1265            encode_hash_mismatch, encode_rotate, encode_stream_start,
1266        };
1267        use std::sync::Arc;
1268        use std::sync::atomic::AtomicBool;
1269
1270        /// A scripted primary brings a fresh replica up, streams one
1271        /// event, then announces a rotation with a deliberately wrong
1272        /// tail hash. The replica's journal stage detects divergence —
1273        /// `run_receiver` must repair it WITHOUT exiting: tear the
1274        /// pipeline down, re-derive its handshake position from disk,
1275        /// reconnect, take the HashMismatch verdict, archive the forked
1276        /// lineage, and complete the snapshot + segment-seed resync,
1277        /// then resume streaming on the rebased lineage.
1278        #[test]
1279        fn mid_stream_divergence_resyncs_in_process() {
1280            let dir = tempfile::tempdir().expect("tempdir");
1281
1282            // --- The scripted primary's journal + snapshot, served by
1283            // the real sender-side transfer code in session 2. Entries
1284            // 1..=5, rotation after 2, snapshot at 4 (mid-live-segment).
1285            let primary_journal = dir.path().join("primary.journal");
1286            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
1287            let mut chain_at_4 = [0u8; 32];
1288            for v in 1..=5u64 {
1289                w.append(&JournalEvent::App(EvtAdd(v))).expect("append");
1290                if v == 2 {
1291                    w.rotate_segment().expect("rotate");
1292                }
1293                if v == 4 {
1294                    chain_at_4 = w.chain_hash().expect("chain");
1295                }
1296            }
1297            drop(w);
1298            melin_transport_core::snapshot::save::<App>(
1299                &App,
1300                WireSeq::new(4),
1301                chain_at_4,
1302                0,
1303                &primary_journal.with_extension("snapshot"),
1304            )
1305            .expect("save snapshot");
1306            let (lineage_start, lineage_anchor) =
1307                lineage_origin(&primary_journal).expect("lineage");
1308
1309            // --- Auth: one replica key, authorized.
1310            let authorized_keys = replica_auth();
1311
1312            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1313            listener.set_nonblocking(true).expect("nonblocking");
1314            let addr = listener.local_addr().expect("addr");
1315
1316            // --- Replica under test.
1317            let replica_journal = dir.path().join("replica.journal");
1318            let replica_snapshot = dir.path().join("replica.snapshot");
1319            let shutdown = Arc::new(AtomicBool::new(false));
1320            let control = crate::replication::ReplicaControlPlane::new();
1321            let cores = crate::layout::PipelineCores::unpinned();
1322            let replica = {
1323                let journal = replica_journal.clone();
1324                let shutdown = Arc::clone(&shutdown);
1325                let control = control.clone();
1326                std::thread::spawn(move || -> Result<bool, String> {
1327                    run_receiver::<App>(
1328                        addr,
1329                        &journal,
1330                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
1331                        &shutdown,
1332                        &control,
1333                        3_600_000,
1334                        replica_snapshot,
1335                        cores,
1336                        melin_journal::StagingMode::ZeroFill,
1337                        Duration::ZERO,
1338                        64,
1339                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
1340                        &(),
1341                    )
1342                    // ReceiverResult's error is !Send — stringify for join().
1343                    .map(|state| state.is_none())
1344                    .map_err(|e| e.to_string())
1345                })
1346            };
1347
1348            // --- Session 1: fresh sync, one event, then the poisoned
1349            // rotation announce.
1350            let mut s1 = accept_within(&listener, 30);
1351            let mut s1r = s1.try_clone().expect("clone");
1352            authenticate_replica(&mut s1r, &authorized_keys).expect("auth 1");
1353            match read_replica_msg(&mut s1) {
1354                ReplicaMessage::Handshake(h) => {
1355                    assert_eq!(h.last_sequence, 0, "fresh replica handshake")
1356                }
1357                other => panic!("expected Handshake, got {other:?}"),
1358            }
1359            let mut buf = Vec::new();
1360            encode_stream_start(0, lineage_start, lineage_anchor, 0, 1, &mut buf);
1361            s1.write_all(&buf).expect("StreamStart 1");
1362            buf.clear();
1363            melin_transport_core::replication_wire::encode_input_batch(
1364                &[InputSlot::<EvtAdd> {
1365                    connection_id: 0,
1366                    key_hash: 0,
1367                    sequence: 1,
1368                    timestamp_ns: 1,
1369                    event: JournalEvent::App(EvtAdd(1)),
1370                    // `()` without latency-trace, a timestamp with it.
1371                    publish_ts: Default::default(),
1372                    recv_ts: Default::default(),
1373                }],
1374                &mut buf,
1375            );
1376            s1.write_all(&buf).expect("InputBatch");
1377            wait_for_ack(&mut s1, 1);
1378
1379            // Wrong tail hash at the announced boundary — the replica's
1380            // local chain at sequence 1 cannot match this.
1381            buf.clear();
1382            encode_rotate(1, &[0xEE; 32], &mut buf);
1383            s1.write_all(&buf).expect("Rotate");
1384
1385            // --- Session 2: the SAME process reconnects with its
1386            // position re-derived from the forked on-disk journal.
1387            let mut s2 = accept_within(&listener, 30);
1388            let mut s2r = s2.try_clone().expect("clone");
1389            authenticate_replica(&mut s2r, &authorized_keys).expect("auth 2");
1390            match read_replica_msg(&mut s2) {
1391                ReplicaMessage::Handshake(h) => assert_eq!(
1392                    h.last_sequence, 1,
1393                    "post-divergence handshake must carry the recovered journal position"
1394                ),
1395                other => panic!("expected Handshake, got {other:?}"),
1396            }
1397            // The journal stage's divergence failure latched the health
1398            // mirror before the teardown that led to this reconnect, and
1399            // the replacement pipeline is not built until after the resync
1400            // verdict — so at this deterministic point the replica must be
1401            // reporting unhealthy.
1402            assert!(
1403                !control.pipeline_healthy.load(Ordering::Acquire),
1404                "the divergence must have latched the health mirror false"
1405            );
1406            buf.clear();
1407            encode_hash_mismatch(&mut buf);
1408            s2.write_all(&buf).expect("HashMismatch");
1409            let transfer_shutdown = AtomicBool::new(false);
1410            let mut publish = |b: &[u8]| -> std::io::Result<()> {
1411                s2.write_all(b)?;
1412                s2.flush()
1413            };
1414            // Real sender-side resync: SnapshotBegin → chunks →
1415            // SegmentSeedBegin → seed → StreamStart → catch-up (entry 5).
1416            let end = snapshot_transfer_with::<EvtAdd>(
1417                &primary_journal,
1418                &mut publish,
1419                &transfer_shutdown,
1420                1,
1421            )
1422            .expect("snapshot transfer");
1423            assert_eq!(
1424                end,
1425                melin_transport_core::replication::catchup::CatchUpResult::Ok(5)
1426            );
1427            let mut s2_acks = s2.try_clone().expect("clone");
1428            wait_for_ack(&mut s2_acks, 5);
1429
1430            // Streaming resumed on the rebuilt pipeline — the rebuild
1431            // must have reset the health mirror.
1432            assert!(
1433                control.pipeline_healthy.load(Ordering::Acquire),
1434                "the resync rebuild must reset the health mirror true"
1435            );
1436
1437            // --- Clean shutdown; the receiver must return Ok(None) —
1438            // the divergence never escaped as a process-fatal error.
1439            shutdown.store(true, Ordering::Relaxed);
1440            let result = replica.join().expect("replica thread panicked");
1441            assert_eq!(result, Ok(true), "receiver must exit cleanly via shutdown");
1442
1443            // The forked lineage was archived (never deleted), and the
1444            // live journal is the re-seeded one.
1445            assert!(
1446                dir.path().join("replica.journal.divergent.0").exists(),
1447                "divergent lineage must be archived"
1448            );
1449            assert!(replica_journal.exists(), "re-seeded live journal");
1450        }
1451
1452        /// A resync whose snapshot transfer drops mid-flight must retry
1453        /// as a fresh replica with NO leftover in-memory state. On the
1454        /// in-process divergence repair path `recover_replica_state`
1455        /// leaves `app`/`journal_writer` populated; the resync arm
1456        /// archives the live journal (renaming it aside) before the
1457        /// transfer. If the transfer then fails and those handles are not
1458        /// nulled, the stale writer — its backing file now under the
1459        /// `.divergent.<n>` archive — survives the fresh-replica create
1460        /// gate (`journal_writer.is_none()` false) and is rebuilt into the
1461        /// next pipeline, so the live journal is never recreated and the
1462        /// new session streams onto an archived-away inode. This pins the
1463        /// fix (mirrors the DPDK receiver): after the failed transfer the
1464        /// retry creates a fresh live journal from the StreamStart lineage.
1465        #[test]
1466        fn resync_transfer_failure_rebuilds_clean_not_over_stale_writer() {
1467            let dir = tempfile::tempdir().expect("tempdir");
1468
1469            // Minimal scripted primary — only its lineage identity is used
1470            // (session 3 streams from genesis; the dropped session 2 never
1471            // reaches the snapshot body).
1472            let primary_journal = dir.path().join("primary.journal");
1473            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
1474            for v in 1..=2u64 {
1475                w.append(&JournalEvent::App(EvtAdd(v))).expect("append");
1476            }
1477            drop(w);
1478            let (lineage_start, lineage_anchor) =
1479                lineage_origin(&primary_journal).expect("lineage");
1480
1481            let authorized_keys = replica_auth();
1482
1483            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1484            listener.set_nonblocking(true).expect("nonblocking");
1485            let addr = listener.local_addr().expect("addr");
1486
1487            let replica_journal = dir.path().join("replica.journal");
1488            let replica_snapshot = dir.path().join("replica.snapshot");
1489            let shutdown = Arc::new(AtomicBool::new(false));
1490            let control = crate::replication::ReplicaControlPlane::new();
1491            let cores = crate::layout::PipelineCores::unpinned();
1492            let replica = {
1493                let journal = replica_journal.clone();
1494                let shutdown = Arc::clone(&shutdown);
1495                let control = control.clone();
1496                std::thread::spawn(move || -> Result<bool, String> {
1497                    run_receiver::<App>(
1498                        addr,
1499                        &journal,
1500                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
1501                        &shutdown,
1502                        &control,
1503                        3_600_000,
1504                        replica_snapshot,
1505                        cores,
1506                        melin_journal::StagingMode::ZeroFill,
1507                        Duration::ZERO,
1508                        64,
1509                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
1510                        &(),
1511                    )
1512                    .map(|state| state.is_none())
1513                    .map_err(|e| e.to_string())
1514                })
1515            };
1516
1517            // Helper: stream a single event (seq 1) over an established
1518            // session and wait for its ack.
1519            let stream_one = |s: &mut TcpStream, buf: &mut Vec<u8>| {
1520                buf.clear();
1521                encode_stream_start(0, lineage_start, lineage_anchor, 0, 1, buf);
1522                s.write_all(buf).expect("StreamStart");
1523                buf.clear();
1524                melin_transport_core::replication_wire::encode_input_batch(
1525                    &[InputSlot::<EvtAdd> {
1526                        connection_id: 0,
1527                        key_hash: 0,
1528                        sequence: 1,
1529                        timestamp_ns: 1,
1530                        event: JournalEvent::App(EvtAdd(1)),
1531                        publish_ts: Default::default(),
1532                        recv_ts: Default::default(),
1533                    }],
1534                    buf,
1535                );
1536                s.write_all(buf).expect("InputBatch");
1537            };
1538
1539            // --- Session 1: fresh sync, event 1, poisoned rotation —
1540            // the journal stage detects divergence and the receiver
1541            // repairs in-process (`recover_replica_state` repopulates
1542            // `app`/`journal_writer`).
1543            let mut buf = Vec::new();
1544            let mut s1 = accept_within(&listener, 30);
1545            let mut s1r = s1.try_clone().expect("clone");
1546            authenticate_replica(&mut s1r, &authorized_keys).expect("auth 1");
1547            match read_replica_msg(&mut s1) {
1548                ReplicaMessage::Handshake(h) => assert_eq!(h.last_sequence, 0, "fresh"),
1549                other => panic!("expected Handshake, got {other:?}"),
1550            }
1551            stream_one(&mut s1, &mut buf);
1552            wait_for_ack(&mut s1, 1);
1553            buf.clear();
1554            encode_rotate(1, &[0xEE; 32], &mut buf);
1555            s1.write_all(&buf).expect("Rotate");
1556
1557            // --- Session 2: reconnect at the recovered position (seq 1),
1558            // verdict HashMismatch (archives the live journal aside), then
1559            // DROP the connection before SnapshotBegin — the transfer
1560            // fails and the receiver must retry as a fresh replica.
1561            let mut s2 = accept_within(&listener, 30);
1562            let mut s2r = s2.try_clone().expect("clone");
1563            authenticate_replica(&mut s2r, &authorized_keys).expect("auth 2");
1564            match read_replica_msg(&mut s2) {
1565                ReplicaMessage::Handshake(h) => assert_eq!(
1566                    h.last_sequence, 1,
1567                    "reconnect carries the recovered forked position"
1568                ),
1569                other => panic!("expected Handshake, got {other:?}"),
1570            }
1571            buf.clear();
1572            encode_hash_mismatch(&mut buf);
1573            s2.write_all(&buf).expect("HashMismatch");
1574            s2.flush().expect("flush");
1575            // Both fds reference the same socket (try_clone dups) — drop
1576            // both to send FIN so the replica's SnapshotBegin read hits EOF.
1577            drop(s2);
1578            drop(s2r);
1579
1580            // --- Session 3: the receiver reconnects as a FRESH replica
1581            // (the archived lineage's position is meaningless, so
1582            // `last_sequence` is 0). It must recreate a live journal from
1583            // the StreamStart lineage, NOT rebuild over the stale writer.
1584            let mut s3 = accept_within(&listener, 30);
1585            let mut s3r = s3.try_clone().expect("clone");
1586            authenticate_replica(&mut s3r, &authorized_keys).expect("auth 3");
1587            match read_replica_msg(&mut s3) {
1588                ReplicaMessage::Handshake(h) => assert_eq!(
1589                    h.last_sequence, 0,
1590                    "after a failed transfer the retry handshakes as fresh"
1591                ),
1592                other => panic!("expected Handshake, got {other:?}"),
1593            }
1594            stream_one(&mut s3, &mut buf);
1595            wait_for_ack(&mut s3, 1);
1596
1597            shutdown.store(true, Ordering::Relaxed);
1598            let result = replica.join().expect("replica thread panicked");
1599            assert_eq!(result, Ok(true), "receiver exits cleanly via shutdown");
1600
1601            // The forked lineage stays archived, and a fresh live journal
1602            // was recreated from the lineage. With the bug, the stale
1603            // writer would be reused and this path would not exist (its
1604            // inode stranded under `.divergent.0`).
1605            assert!(
1606                dir.path().join("replica.journal.divergent.0").exists(),
1607                "forked lineage archived, never deleted"
1608            );
1609            assert!(
1610                replica_journal.exists(),
1611                "fresh live journal recreated after the failed transfer"
1612            );
1613        }
1614
1615        /// The in-process repair budget is one per process lifetime: a
1616        /// second mid-stream divergence is systematic (corruption or a
1617        /// serious bug that the first re-seed did not cure) and the
1618        /// receiver must exit hard instead of looping — each repair
1619        /// cycle archives a full journal copy, and a repair loop would
1620        /// fill the disk while masking the underlying fault.
1621        #[test]
1622        fn second_mid_stream_divergence_exits_hard() {
1623            let dir = tempfile::tempdir().expect("tempdir");
1624
1625            let primary_journal = dir.path().join("primary.journal");
1626            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
1627            let mut chain_at_4 = [0u8; 32];
1628            for v in 1..=5u64 {
1629                w.append(&JournalEvent::App(EvtAdd(v))).expect("append");
1630                if v == 2 {
1631                    w.rotate_segment().expect("rotate");
1632                }
1633                if v == 4 {
1634                    chain_at_4 = w.chain_hash().expect("chain");
1635                }
1636            }
1637            drop(w);
1638            melin_transport_core::snapshot::save::<App>(
1639                &App,
1640                WireSeq::new(4),
1641                chain_at_4,
1642                0,
1643                &primary_journal.with_extension("snapshot"),
1644            )
1645            .expect("save snapshot");
1646            let (lineage_start, lineage_anchor) =
1647                lineage_origin(&primary_journal).expect("lineage");
1648
1649            let authorized_keys = replica_auth();
1650
1651            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1652            listener.set_nonblocking(true).expect("nonblocking");
1653            let addr = listener.local_addr().expect("addr");
1654
1655            let replica_journal = dir.path().join("replica.journal");
1656            let replica_snapshot = dir.path().join("replica.snapshot");
1657            let shutdown = Arc::new(AtomicBool::new(false));
1658            let control = crate::replication::ReplicaControlPlane::new();
1659            let cores = crate::layout::PipelineCores::unpinned();
1660            let replica = {
1661                let journal = replica_journal.clone();
1662                let shutdown = Arc::clone(&shutdown);
1663                let control = control.clone();
1664                std::thread::spawn(move || -> Result<bool, String> {
1665                    run_receiver::<App>(
1666                        addr,
1667                        &journal,
1668                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
1669                        &shutdown,
1670                        &control,
1671                        3_600_000,
1672                        replica_snapshot,
1673                        cores,
1674                        melin_journal::StagingMode::ZeroFill,
1675                        Duration::ZERO,
1676                        64,
1677                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
1678                        &(),
1679                    )
1680                    .map(|state| state.is_none())
1681                    .map_err(|e| e.to_string())
1682                })
1683            };
1684
1685            // Session 1: fresh sync, one event, poisoned rotation.
1686            let mut s1 = accept_within(&listener, 30);
1687            let mut s1r = s1.try_clone().expect("clone");
1688            authenticate_replica(&mut s1r, &authorized_keys).expect("auth 1");
1689            match read_replica_msg(&mut s1) {
1690                ReplicaMessage::Handshake(h) => assert_eq!(h.last_sequence, 0),
1691                other => panic!("expected Handshake, got {other:?}"),
1692            }
1693            let mut buf = Vec::new();
1694            encode_stream_start(0, lineage_start, lineage_anchor, 0, 1, &mut buf);
1695            s1.write_all(&buf).expect("StreamStart 1");
1696            buf.clear();
1697            melin_transport_core::replication_wire::encode_input_batch(
1698                &[InputSlot::<EvtAdd> {
1699                    connection_id: 0,
1700                    key_hash: 0,
1701                    sequence: 1,
1702                    timestamp_ns: 1,
1703                    event: JournalEvent::App(EvtAdd(1)),
1704                    publish_ts: Default::default(),
1705                    recv_ts: Default::default(),
1706                }],
1707                &mut buf,
1708            );
1709            s1.write_all(&buf).expect("InputBatch");
1710            wait_for_ack(&mut s1, 1);
1711            buf.clear();
1712            encode_rotate(1, &[0xEE; 32], &mut buf);
1713            s1.write_all(&buf).expect("Rotate 1");
1714
1715            // Session 2: in-process repair (the one allowed), then a
1716            // SECOND poisoned rotation after streaming resumes.
1717            let mut s2 = accept_within(&listener, 30);
1718            let mut s2r = s2.try_clone().expect("clone");
1719            authenticate_replica(&mut s2r, &authorized_keys).expect("auth 2");
1720            match read_replica_msg(&mut s2) {
1721                ReplicaMessage::Handshake(h) => assert_eq!(h.last_sequence, 1),
1722                other => panic!("expected Handshake, got {other:?}"),
1723            }
1724            buf.clear();
1725            encode_hash_mismatch(&mut buf);
1726            s2.write_all(&buf).expect("HashMismatch");
1727            let transfer_shutdown = AtomicBool::new(false);
1728            {
1729                let mut publish = |b: &[u8]| -> std::io::Result<()> {
1730                    s2.write_all(b)?;
1731                    s2.flush()
1732                };
1733                snapshot_transfer_with::<EvtAdd>(
1734                    &primary_journal,
1735                    &mut publish,
1736                    &transfer_shutdown,
1737                    1,
1738                )
1739                .expect("snapshot transfer");
1740            }
1741            buf.clear();
1742            melin_transport_core::replication_wire::encode_input_batch(
1743                &[InputSlot::<EvtAdd> {
1744                    connection_id: 0,
1745                    key_hash: 0,
1746                    sequence: 6,
1747                    timestamp_ns: 6,
1748                    event: JournalEvent::App(EvtAdd(6)),
1749                    publish_ts: Default::default(),
1750                    recv_ts: Default::default(),
1751                }],
1752                &mut buf,
1753            );
1754            s2.write_all(&buf).expect("InputBatch 6");
1755            let mut s2_acks = s2.try_clone().expect("clone");
1756            wait_for_ack(&mut s2_acks, 6);
1757            buf.clear();
1758            encode_rotate(6, &[0xEE; 32], &mut buf);
1759            s2.write_all(&buf).expect("Rotate 2");
1760
1761            // No third connection: the receiver must give up and exit
1762            // with an error naming the recurrence.
1763            let result = replica.join().expect("replica thread panicked");
1764            let err = result.expect_err("second divergence must exit hard");
1765            assert!(
1766                err.contains("divergence recurred"),
1767                "error must name the recurrence: {err}"
1768            );
1769        }
1770
1771        // -------------------------------------------------------------
1772        // Promotion teardown helper (`take_pipeline_for_promotion`) — the
1773        // no-live-pipeline decision branches. With no pipeline to tear
1774        // down it hands back the warm state held in the receiver's locals
1775        // (a clean promotion) or errors when none is present. Colocated
1776        // here for the `App` + `BufferedWriter` fixtures above; the
1777        // Clean-pipeline branch needs a live pipeline (failover IT).
1778        // -------------------------------------------------------------
1779        type PromoteHandles =
1780            crate::replication::ReplicaPipelineHandles<App, BufferedWriter<EvtAdd>>;
1781
1782        #[test]
1783        fn promotion_hands_back_local_state_when_present() {
1784            let dir = tempfile::tempdir().unwrap();
1785            let mut pipeline: Option<PromoteHandles> = None;
1786            let mut app = Some(App);
1787            let mut journal_writer =
1788                Some(BufferedWriter::<EvtAdd>::create(&dir.path().join("p.journal")).unwrap());
1789
1790            let result = crate::replication::take_pipeline_for_promotion(
1791                &mut pipeline,
1792                &mut app,
1793                &mut journal_writer,
1794            );
1795
1796            assert!(matches!(result, Ok(Some(_))), "warm state must be promoted");
1797            assert!(
1798                app.is_none() && journal_writer.is_none(),
1799                "state must be moved into the result"
1800            );
1801        }
1802
1803        #[test]
1804        fn promotion_errs_when_no_local_state() {
1805            let mut pipeline: Option<PromoteHandles> = None;
1806            let mut app: Option<App> = None;
1807            let mut journal_writer: Option<BufferedWriter<EvtAdd>> = None;
1808
1809            let result = crate::replication::take_pipeline_for_promotion(
1810                &mut pipeline,
1811                &mut app,
1812                &mut journal_writer,
1813            );
1814
1815            // Not `expect_err` — the Ok payload (App) isn't `Debug`.
1816            let err = match result {
1817                Err(e) => e,
1818                Ok(_) => panic!("a promote with nothing to promote must error"),
1819            };
1820            assert!(
1821                err.to_string().contains("no local state available"),
1822                "{err}"
1823            );
1824        }
1825
1826        #[test]
1827        fn promotion_errs_on_partial_state() {
1828            // Application present, writer missing — not a usable hand-off.
1829            let mut pipeline: Option<PromoteHandles> = None;
1830            let mut app = Some(App);
1831            let mut journal_writer: Option<BufferedWriter<EvtAdd>> = None;
1832
1833            let result = crate::replication::take_pipeline_for_promotion(
1834                &mut pipeline,
1835                &mut app,
1836                &mut journal_writer,
1837            );
1838
1839            assert!(result.is_err(), "partial state is not a valid promotion");
1840        }
1841    }
1842
1843    // -----------------------------------------------------------------
1844    // Shutdown during reconnect backoff — end to end against a scripted
1845    // primary. Needs real persistence (the pipeline journals the one
1846    // streamed event); no chain verdicts involved, so no hash-chain.
1847    // -----------------------------------------------------------------
1848    #[cfg(not(feature = "no-persist"))]
1849    mod backoff_shutdown {
1850        use super::super::super::auth::authenticate_replica;
1851        use super::scripted::*;
1852        use super::*;
1853        use melin_journal::{BufferedWriter, JournalEvent, JournalWrite};
1854        use melin_transport_core::replication::catchup::lineage_origin;
1855        use melin_transport_core::replication::protocol::encode_stream_start;
1856        use std::sync::Arc;
1857        use std::sync::atomic::AtomicBool;
1858
1859        /// Names of every live thread in this process, via
1860        /// `/proc/self/task` (Linux-only, like the runtime itself).
1861        fn live_thread_names() -> Vec<String> {
1862            std::fs::read_dir("/proc/self/task")
1863                .expect("read /proc/self/task")
1864                .filter_map(|entry| {
1865                    // A thread may exit between readdir and the comm
1866                    // read — skipping it is the correct outcome.
1867                    let entry = entry.ok()?;
1868                    let name = std::fs::read_to_string(entry.path().join("comm")).ok()?;
1869                    Some(name.trim().to_string())
1870                })
1871                .collect()
1872        }
1873
1874        /// Shutdown while the receiver waits out a reconnect backoff must
1875        /// tear the live pipeline down — join every stage thread — rather
1876        /// than abandon it. Pins the fix for the connect-failure arm that
1877        /// returned without teardown (stages left detached mid-run, the
1878        /// journal writer never closed).
1879        #[test]
1880        fn shutdown_during_reconnect_backoff_joins_pipeline_threads() {
1881            let dir = tempfile::tempdir().expect("tempdir");
1882
1883            // Minimal primary journal — only its lineage identity is
1884            // needed for the StreamStart the fresh replica accepts.
1885            let primary_journal = dir.path().join("primary.journal");
1886            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
1887            w.append(&JournalEvent::App(EvtAdd(1))).expect("append");
1888            drop(w);
1889            let (lineage_start, lineage_anchor) =
1890                lineage_origin(&primary_journal).expect("lineage");
1891
1892            let authorized_keys = replica_auth();
1893            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1894            listener.set_nonblocking(true).expect("nonblocking");
1895            let addr = listener.local_addr().expect("addr");
1896
1897            let shutdown = Arc::new(AtomicBool::new(false));
1898            let control = crate::replication::ReplicaControlPlane::new();
1899            let replica = {
1900                let journal = dir.path().join("replica.journal");
1901                let snapshot = dir.path().join("replica.snapshot");
1902                let shutdown = Arc::clone(&shutdown);
1903                let control = control.clone();
1904                std::thread::spawn(move || -> Result<bool, String> {
1905                    run_receiver::<App>(
1906                        addr,
1907                        &journal,
1908                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
1909                        &shutdown,
1910                        &control,
1911                        3_600_000,
1912                        snapshot,
1913                        crate::layout::PipelineCores::unpinned(),
1914                        melin_journal::StagingMode::ZeroFill,
1915                        Duration::ZERO,
1916                        64,
1917                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
1918                        &(),
1919                    )
1920                    // ReceiverResult's error is !Send — stringify for join().
1921                    .map(|state| state.is_none())
1922                    .map_err(|e| e.to_string())
1923                })
1924            };
1925
1926            // Session 1: bring the pipeline up for real — stream one
1927            // event and wait for its ack (acked ⇒ journaled ⇒ the stage
1928            // threads exist).
1929            let mut s1 = accept_within(&listener, 30);
1930            let mut s1r = s1.try_clone().expect("clone");
1931            authenticate_replica(&mut s1r, &authorized_keys).expect("auth");
1932            match read_replica_msg(&mut s1) {
1933                ReplicaMessage::Handshake(h) => {
1934                    assert_eq!(h.last_sequence, 0, "fresh replica handshake")
1935                }
1936                other => panic!("expected Handshake, got {other:?}"),
1937            }
1938            let mut buf = Vec::new();
1939            encode_stream_start(0, lineage_start, lineage_anchor, 0, 1, &mut buf);
1940            s1.write_all(&buf).expect("StreamStart");
1941            buf.clear();
1942            melin_transport_core::replication_wire::encode_input_batch(
1943                &[InputSlot::<EvtAdd> {
1944                    connection_id: 0,
1945                    key_hash: 0,
1946                    sequence: 1,
1947                    timestamp_ns: 1,
1948                    event: JournalEvent::App(EvtAdd(1)),
1949                    publish_ts: Default::default(),
1950                    recv_ts: Default::default(),
1951                }],
1952                &mut buf,
1953            );
1954            s1.write_all(&buf).expect("InputBatch");
1955            wait_for_ack(&mut s1, 1);
1956
1957            // Kill the primary completely — both socket halves and the
1958            // listener — so every reconnect attempt is refused and the
1959            // receiver settles into the connect-failure backoff.
1960            drop(s1);
1961            drop(s1r);
1962            drop(listener);
1963            // Past the 1s post-disconnect backoff, inside the 2s
1964            // connect-failure backoff sleep. (Any other point in the
1965            // reconnect loop must satisfy the assertions too — this
1966            // timing just targets the arm that historically leaked.)
1967            std::thread::sleep(Duration::from_millis(1500));
1968
1969            shutdown.store(true, Ordering::Relaxed);
1970            let deadline = Instant::now() + Duration::from_secs(10);
1971            while !replica.is_finished() {
1972                assert!(
1973                    Instant::now() < deadline,
1974                    "receiver did not exit after shutdown"
1975                );
1976                std::thread::sleep(Duration::from_millis(20));
1977            }
1978            let result = replica.join().expect("replica thread panicked");
1979            assert_eq!(result, Ok(true), "receiver must exit cleanly via shutdown");
1980
1981            // The stage threads are named at spawn, and a leaked stage
1982            // spins forever — absence within the deadline proves the
1983            // teardown joined them. Deadline-poll rather than one-shot:
1984            // under plain `cargo test`, concurrent tests' stages may be
1985            // momentarily alive, but those exit on their own.
1986            let deadline = Instant::now() + Duration::from_secs(10);
1987            loop {
1988                let names = live_thread_names();
1989                if !names
1990                    .iter()
1991                    .any(|n| n == "journal-seq" || n == "matching" || n == "drain")
1992                {
1993                    break;
1994                }
1995                assert!(
1996                    Instant::now() < deadline,
1997                    "pipeline threads still alive after shutdown: {names:?}"
1998                );
1999                std::thread::sleep(Duration::from_millis(50));
2000            }
2001        }
2002    }
2003
2004    // -----------------------------------------------------------------
2005    // Boundary adoption under both staging modes — end to end against a
2006    // scripted primary. Needs hash-chain (the boundary announce carries
2007    // a chain value the replica verifies before rotating) and real
2008    // persistence (the rotation archives the on-disk segment).
2009    // -----------------------------------------------------------------
2010    #[cfg(all(feature = "hash-chain", not(feature = "no-persist")))]
2011    mod rotation_adoption {
2012        use super::super::super::auth::authenticate_replica;
2013        use super::scripted::*;
2014        use super::*;
2015        use melin_journal::{BufferedWriter, JournalEvent, JournalWrite};
2016        use melin_transport_core::replication::catchup::lineage_origin;
2017        use melin_transport_core::replication::protocol::{encode_rotate, encode_stream_start};
2018        use std::sync::Arc;
2019        use std::sync::atomic::AtomicBool;
2020
2021        /// Payloads of every entry in the journal at `path`, in order.
2022        fn payloads(path: &std::path::Path) -> Vec<u64> {
2023            let mut reader = melin_journal::JournalReader::<EvtAdd>::open(path).expect("open");
2024            let mut out = Vec::new();
2025            while let Some(entry) = reader.next_entry().expect("read entry") {
2026                if let JournalEvent::App(EvtAdd(v)) = entry.event {
2027                    out.push(v);
2028                }
2029            }
2030            out
2031        }
2032
2033        /// A scripted primary streams two events, announces the rotation
2034        /// boundary with the *correct* tail hash, and streams two more.
2035        /// The replica must verify the boundary, rotate — adopting the
2036        /// segment its preparer staged in `mode` — and keep acking on
2037        /// the new segment, leaving the sealed one archived.
2038        ///
2039        /// Run under both modes: everything observable (archive layout,
2040        /// entry placement across the boundary, acks, clean shutdown)
2041        /// must be identical, or the staging mode is not a tuning knob
2042        /// but a behaviour change.
2043        fn boundary_adoption_rotates_and_streams_on(mode: melin_journal::StagingMode) {
2044            // Shrink the prealloc chunk so the replica's preparer stages
2045            // a few MiB (chunk + margin) instead of 256 MiB per test.
2046            // The guard holds a process-wide lock, so the two mode
2047            // variants serialise rather than race the global override.
2048            let _prealloc_guard =
2049                melin_journal::test_utils::PreallocOverrideGuard::new(1024 * 1024);
2050            let dir = tempfile::tempdir().expect("tempdir");
2051
2052            // Scripted primary journal: entries 1..=2 carrying the same
2053            // stamps as the InputSlots below (deterministic timestamps,
2054            // zero key-hash/request-seq), so its chain value at the
2055            // boundary equals the replica's — journals are byte-for-byte
2056            // mirrors, and `append`'s wall-clock stamp would fork them.
2057            let primary_journal = dir.path().join("primary.journal");
2058            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
2059            for v in 1..=2u64 {
2060                w.batch_append_with_ts(&JournalEvent::App(EvtAdd(v)), v, 0)
2061                    .expect("append");
2062            }
2063            w.flush_batch_sync().expect("flush");
2064            let chain_at_2 = w.chain_hash().expect("chain");
2065            drop(w);
2066            let (lineage_start, lineage_anchor) =
2067                lineage_origin(&primary_journal).expect("lineage");
2068
2069            let authorized_keys = replica_auth();
2070            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
2071            listener.set_nonblocking(true).expect("nonblocking");
2072            let addr = listener.local_addr().expect("addr");
2073
2074            let replica_journal = dir.path().join("replica.journal");
2075            let shutdown = Arc::new(AtomicBool::new(false));
2076            let control = crate::replication::ReplicaControlPlane::new();
2077            let replica = {
2078                let journal = replica_journal.clone();
2079                let snapshot = dir.path().join("replica.snapshot");
2080                let shutdown = Arc::clone(&shutdown);
2081                let control = control.clone();
2082                std::thread::spawn(move || -> Result<bool, String> {
2083                    run_receiver::<App>(
2084                        addr,
2085                        &journal,
2086                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
2087                        &shutdown,
2088                        &control,
2089                        3_600_000,
2090                        snapshot,
2091                        crate::layout::PipelineCores::unpinned(),
2092                        mode,
2093                        Duration::ZERO,
2094                        64,
2095                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
2096                        &(),
2097                    )
2098                    // ReceiverResult's error is !Send — stringify for join().
2099                    .map(|state| state.is_none())
2100                    .map_err(|e| e.to_string())
2101                })
2102            };
2103
2104            let mut s1 = accept_within(&listener, 30);
2105            let mut s1r = s1.try_clone().expect("clone");
2106            authenticate_replica(&mut s1r, &authorized_keys).expect("auth");
2107            match read_replica_msg(&mut s1) {
2108                ReplicaMessage::Handshake(h) => {
2109                    assert_eq!(h.last_sequence, 0, "fresh replica handshake")
2110                }
2111                other => panic!("expected Handshake, got {other:?}"),
2112            }
2113            let mut buf = Vec::new();
2114            encode_stream_start(0, lineage_start, lineage_anchor, 0, 1, &mut buf);
2115            s1.write_all(&buf).expect("StreamStart");
2116
2117            let send_events = |s: &mut TcpStream, range: std::ops::RangeInclusive<u64>| {
2118                let slots: Vec<InputSlot<EvtAdd>> = range
2119                    .map(|v| InputSlot {
2120                        connection_id: 0,
2121                        key_hash: 0,
2122                        sequence: v,
2123                        timestamp_ns: v,
2124                        event: JournalEvent::App(EvtAdd(v)),
2125                        // `()` without latency-trace, a timestamp with it.
2126                        publish_ts: Default::default(),
2127                        recv_ts: Default::default(),
2128                    })
2129                    .collect();
2130                let mut buf = Vec::new();
2131                melin_transport_core::replication_wire::encode_input_batch(&slots, &mut buf);
2132                s.write_all(&buf).expect("InputBatch");
2133            };
2134
2135            send_events(&mut s1, 1..=2);
2136            wait_for_ack(&mut s1, 2);
2137
2138            // Wait for the preparer's staged segment to reach full size
2139            // (the overridden prealloc chunk + the 8 MiB margin) so the
2140            // boundary below adopts it rather than racing staging into
2141            // the synchronous fallback — adoption is the path under
2142            // test. Both modes must produce it; a preparer that never
2143            // does is itself a failure.
2144            let staging = {
2145                // `<live>.next-staging` — the preparer's sibling-file
2146                // convention (crate-private, so spelled out here the way
2147                // the `.divergent.<n>` archive name is above).
2148                let mut s = replica_journal.clone().into_os_string();
2149                s.push(".next-staging");
2150                std::path::PathBuf::from(s)
2151            };
2152            let staged_len: u64 = (1 + 8) * 1024 * 1024;
2153            let deadline = Instant::now() + Duration::from_secs(10);
2154            loop {
2155                if std::fs::metadata(&staging).is_ok_and(|m| m.len() == staged_len) {
2156                    break;
2157                }
2158                assert!(
2159                    Instant::now() < deadline,
2160                    "preparer produced no full-size staged segment in {mode:?} mode"
2161                );
2162                std::thread::sleep(Duration::from_millis(10));
2163            }
2164            // Length is the last thing staging changes before the final
2165            // sync and the slot hand-off; give those a moment so the
2166            // rotation's `take()` finds the segment parked. (Losing this
2167            // race anyway is not a failure: the rotation falls back to
2168            // the synchronous path and every assertion below still
2169            // holds — the staged segment is then simply adopted at a
2170            // later boundary.)
2171            std::thread::sleep(Duration::from_millis(300));
2172
2173            // The boundary, with the correct tail hash — verified
2174            // against the replica's own chain, then adopted.
2175            buf.clear();
2176            encode_rotate(2, &chain_at_2, &mut buf);
2177            s1.write_all(&buf).expect("Rotate");
2178
2179            // Streaming continues on the new segment. The journal stage
2180            // holds entries past a pending boundary until the rotation
2181            // applies, so this ack proves the adoption completed.
2182            send_events(&mut s1, 3..=4);
2183            wait_for_ack(&mut s1, 4);
2184
2185            shutdown.store(true, Ordering::Relaxed);
2186            let result = replica.join().expect("replica thread panicked");
2187            assert_eq!(result, Ok(true), "receiver must exit cleanly via shutdown");
2188
2189            // The sealed segment was archived at the first archive slot
2190            // holding exactly the pre-boundary entries; the live segment
2191            // carries the stream from the boundary on.
2192            let archived = dir.path().join("replica.journal.000001");
2193            assert!(archived.exists(), "sealed segment must be archived");
2194            assert_eq!(payloads(&archived), vec![1, 2]);
2195            assert_eq!(payloads(&replica_journal), vec![3, 4]);
2196        }
2197
2198        #[test]
2199        fn boundary_adoption_zero_fill() {
2200            boundary_adoption_rotates_and_streams_on(melin_journal::StagingMode::ZeroFill);
2201        }
2202
2203        #[test]
2204        fn boundary_adoption_allocate() {
2205            boundary_adoption_rotates_and_streams_on(melin_journal::StagingMode::Allocate);
2206        }
2207    }
2208}