Skip to main content

melin_server_runtime/replication/
tcp_receiver.rs

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