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::server::PipelineCores,
558    group_commit_delay: std::time::Duration,
559    pipeline_depth: usize,
560    busy_spin: bool,
561    factory: std::sync::Arc<dyn melin_app::app_factory::AppFactory<App = A>>,
562    fence_state: std::sync::Arc<melin_transport_core::fence::FenceState>,
563) -> ReceiverResult<A, BufferedWriter<A::Event>>
564where
565    A: Application + Send + 'static,
566    A::Event: Send + Sync + 'static,
567    A::Report: Send + 'static,
568    A::QueryResponse: Send + 'static,
569{
570    let super::ReplicaControlPlane {
571        promote,
572        tip_ready,
573        journal_tip,
574        primary_link_up,
575        primary_acking_mode,
576        pipeline_healthy,
577    } = control;
578    // Recover whenever any journal segment survives — live OR archived;
579    // fresh replicas get `(None, None, 0, zeros)`. See
580    // `recover_replica_state` for the lineage rules.
581    let (mut exchange, mut journal_writer, mut last_sequence, mut chain_hash) =
582        recover_replica_state::<A, BufferedWriter<A::Event>>(
583            journal_path,
584            &snapshot_path,
585            factory.as_ref(),
586            &fence_state,
587        )?;
588    // The fence epoch now reflects the recovered journal (a fresh replica
589    // legitimately recovers epoch 0). Seed the advertised sequence from
590    // the same recovery before releasing the flag, so the first tip the
591    // raft driver trusts is already the recovered high-water mark.
592    // Release-store so the driver's Acquire-load sees both.
593    journal_tip.advance(melin_transport_core::WireSeq::new(last_sequence));
594    tip_ready.store(true, Ordering::Release);
595
596    let mut backoff = std::time::Duration::from_secs(1);
597
598    // Consecutive mid-stream divergence resyncs this process has
599    // attempted — see `MAX_INPROCESS_DIVERGENCE_RESYNCS`.
600    let mut divergence_resyncs: u32 = 0;
601
602    let mut send_buf = Vec::with_capacity(64);
603    let mut pipeline: Option<ReplicaPipelineHandles<A, BufferedWriter<A::Event>>> = None;
604
605    // --- Outer reconnect loop ---
606    loop {
607        // No authenticated session while (re)connecting — the raft
608        // driver may auto-promote from here on if it wins an election.
609        primary_link_up.store(false, Ordering::Release);
610
611        if let Some(p) = pipeline.as_ref() {
612            // The handshake pair (last_sequence, chain_hash) must come
613            // from ONE FsyncState snapshot — the journal stage keeps
614            // flushing while we reconnect, and the primary's handshake
615            // validation recomputes its chain at exactly the sequence
616            // we claim. Reading the sequence and the hash from two
617            // separate sources would tear under load, and a torn pair
618            // is indistinguishable from divergence (false resync of a
619            // healthy replica).
620            if let Some(ref lock) = p.chain_hash_lock {
621                let fsync_state = lock.load();
622                last_sequence = fsync_state.journal_seq.get();
623                chain_hash = fsync_state.chain_hash;
624            } else {
625                last_sequence = p.last_seq.load().get();
626            }
627        }
628
629        // Sole authority for shutdown/promote while disconnected: every
630        // backoff arm just sleeps (the sleep observes both flags) and
631        // continues back here, so teardown/promotion handling exists
632        // exactly once.
633        if shutdown.load(Ordering::Relaxed) {
634            if let Some(p) = pipeline.take() {
635                let _ = teardown_replica_pipeline::<A, BufferedWriter<A::Event>>(p);
636            }
637            return Ok(None);
638        }
639        if promote.is_requested() {
640            info!("promotion triggered while disconnected");
641            return take_pipeline_for_promotion(&mut pipeline, &mut exchange, &mut journal_writer);
642        }
643
644        // --- Connect and authenticate ---
645        info!(primary = %primary_addr, "connecting to primary as replica");
646
647        let stream = match TcpStream::connect(primary_addr) {
648            Ok(s) => s,
649            Err(e) => {
650                warn!(
651                    error = %e,
652                    backoff_secs = backoff.as_secs(),
653                    "failed to connect to primary — retrying"
654                );
655                sleep_then_double_backoff(&mut backoff, shutdown, promote);
656                continue;
657            }
658        };
659        if let Err(e) = stream.set_nodelay(true) {
660            warn!(error = %e, "failed to set TCP_NODELAY on replica receive socket");
661        }
662        {
663            use std::os::unix::io::AsRawFd;
664            let busy_poll: libc::c_int = 50;
665            let rc = unsafe {
666                libc::setsockopt(
667                    stream.as_raw_fd(),
668                    libc::SOL_SOCKET,
669                    libc::SO_BUSY_POLL,
670                    &busy_poll as *const libc::c_int as *const libc::c_void,
671                    std::mem::size_of::<libc::c_int>() as libc::socklen_t,
672                )
673            };
674            if rc != 0 {
675                let err = std::io::Error::last_os_error();
676                warn!(error = %err, "failed to set SO_BUSY_POLL on replica receive socket");
677            }
678        }
679        stream.set_read_timeout(Some(std::time::Duration::from_secs(5)))?;
680
681        let mut reader = stream.try_clone()?;
682        let mut tcp_writer = stream;
683
684        // `reader`/`tcp_writer` are clones of one socket; auth is sequential so
685        // a single handle works. Use `reader` (carries the read timeout above).
686        if let Err(e) = authenticate_with_primary(&mut reader, signing_key) {
687            // A read timeout means no challenge arrived: the target
688            // holds the port but is not serving — typically a
689            // not-yet-promoted replica (the listener is bound from boot)
690            // or a primary mid-start. Only other errors suggest a real
691            // credential problem. SO_RCVTIMEO surfaces as WouldBlock on
692            // Linux; match TimedOut too for portability.
693            if matches!(
694                e.kind(),
695                std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
696            ) {
697                warn!(error = %e, "no auth challenge from primary (not serving yet?) — retrying");
698            } else {
699                warn!(error = %e, "authentication failed — retrying");
700            }
701            // Drop the socket before sleeping — held through the
702            // backoff it would sit in the target's accept backlog, and
703            // a just-promoted primary would waste a replica slot (and
704            // its 10s auth timeout) on the zombie.
705            drop(reader);
706            drop(tcp_writer);
707            // Back off before redialing — without the sleep this loop
708            // hammers a primary that keeps refusing us.
709            sleep_then_double_backoff(&mut backoff, shutdown, promote);
710            continue;
711        }
712        info!("authenticated with primary");
713        // The primary is demonstrably alive from here until this
714        // session ends — the raft driver must not auto-promote past it.
715        primary_link_up.store(true, Ordering::Release);
716
717        // --- Handshake ---
718        // Advertise our fencing epoch. If we are ahead of the primary it
719        // sees this and self-demotes (it's a stale ex-primary); see
720        // `crate::fence` and the sender's handshake handling.
721        let handshake = Handshake {
722            last_sequence,
723            chain_hash,
724            epoch: fence_state.epoch(),
725        };
726        send_buf.clear();
727        encode_handshake(&handshake, &mut send_buf);
728        tcp_writer.write_all(&send_buf)?;
729        tcp_writer.flush()?;
730        send_buf.clear();
731
732        // --- Protocol negotiation ---
733        // `session_start` is the resume point this streaming session
734        // continues from — the value that anchors the receiver's
735        // sequence-contiguity gate. Derived from local knowledge (our
736        // own handshake, or the verified snapshot), never from the
737        // wire.
738        let response_frame = read_frame(&mut reader, MAX_CONTROL_FRAME)?;
739        let response = decode_primary_message(&response_frame)?;
740        let (stream_lineage, session_start) = match response {
741            PrimaryMessage::StreamStart {
742                start_sequence,
743                segment_start_sequence,
744                anchor_hash,
745                epoch,
746                durability_mode,
747            } => {
748                // Fence: refuse to follow a primary whose epoch is behind
749                // ours — following its (divergent) lineage on top of our
750                // more-current state would corrupt the journal. Disconnect
751                // and retry with backoff; the operator's logs flag the
752                // misdirected `--replica-of`. Our handshake (already sent)
753                // carries our higher epoch, so the stale primary also fences
754                // itself on its side.
755                let our_epoch = fence_state.epoch();
756                if fence_state.refuses_primary(epoch) {
757                    warn!(
758                        primary_epoch = epoch,
759                        our_epoch,
760                        "primary is behind our fencing epoch — refusing to follow stale primary"
761                    );
762                    // Drop the socket before sleeping — see the auth
763                    // failure arm above.
764                    drop(reader);
765                    drop(tcp_writer);
766                    sleep_then_double_backoff(&mut backoff, shutdown, promote);
767                    continue;
768                }
769                // Adopt the primary's epoch immediately; streamed `EpochBump`s
770                // keep it current thereafter. Likewise the acking mode
771                // (heartbeats keep it current mid-session).
772                fence_state.observe_epoch(epoch);
773                primary_acking_mode.store(durability_mode, Ordering::Release);
774                info!(start_sequence, epoch, durability_mode, "streaming started");
775                ((segment_start_sequence, anchor_hash), last_sequence)
776            }
777            ref resync @ (PrimaryMessage::NeedSnapshot | PrimaryMessage::HashMismatch) => {
778                let divergent = matches!(resync, PrimaryMessage::HashMismatch);
779                // `map_err` drops `Send + Sync` by coercion — the `?` `From`
780                // bridge doesn't apply directly across the two boxed trait
781                // objects (`Box<dyn Error + Send + Sync>` → `Box<dyn Error>`).
782                let decision = handle_resync_verdict(
783                    divergent,
784                    &mut TcpFrameSource {
785                        reader: &mut reader,
786                    },
787                    &mut pipeline,
788                    &mut exchange,
789                    &mut journal_writer,
790                    journal_path,
791                    &snapshot_path,
792                    &fence_state,
793                    control,
794                    &mut last_sequence,
795                    &mut chain_hash,
796                )
797                .map_err(|e| -> Box<dyn std::error::Error> { e })?;
798                match decision {
799                    ResyncDecision::Ready {
800                        segment_start_sequence,
801                        anchor_hash,
802                        resume_sequence,
803                    } => ((segment_start_sequence, anchor_hash), resume_sequence),
804                    ResyncDecision::Retry => {
805                        sleep_then_double_backoff(&mut backoff, shutdown, promote);
806                        continue;
807                    }
808                }
809            }
810            _ => {
811                return Err(format!("unexpected response: {response:?}").into());
812            }
813        };
814
815        // --- Create journal for fresh replica ---
816        // The StreamStart lineage gives the segment header identity
817        // (starting sequence + chain anchor) the primary's own journal
818        // lineage began with; creating the local segment from the same
819        // identity makes the replica's segment byte-identical to the
820        // primary's, and adopted `Rotate` boundaries keep it that way
821        // across rotations (bitwise mirror).
822        if pipeline.is_none() && journal_writer.is_none() {
823            let (lineage_start, lineage_anchor) = stream_lineage;
824            let writer =
825                BufferedWriter::create_continuing(journal_path, lineage_start, lineage_anchor)?;
826            let mut fresh = factory.empty();
827            factory.apply_operator_policy(&mut fresh);
828            exchange = Some(fresh);
829            journal_writer = Some(writer);
830        }
831
832        // --- Build pipeline if absent ---
833        if pipeline.is_none() {
834            let cur_exchange = exchange.take().expect("exchange initialized");
835            let cur_writer = journal_writer.take().expect("journal_writer initialized");
836            pipeline = Some(build_replica_pipeline_with_threads::<A>(
837                cur_exchange,
838                cur_writer,
839                cores,
840                snapshot_interval_ms,
841                snapshot_path.clone(),
842                group_commit_delay,
843                busy_spin,
844                Arc::clone(&fence_state),
845                Arc::clone(pipeline_healthy),
846            )?);
847        }
848
849        // --- Streaming session ---
850        let result = {
851            let p = pipeline.as_mut().expect("pipeline must exist by here");
852            let input_producer = &mut p.input_producer;
853            let journal_cursor = p.journal_cursor.as_ref();
854            let stream_marks = &p.stream_marks;
855            let journal_failed = &p.journal_failed;
856            std::thread::scope(|s| {
857                let handle = std::thread::Builder::new()
858                    .name("replica-receiver".into())
859                    .spawn_scoped(s, || {
860                        melin_app::affinity::pin_thread("replica-receiver", cores.reader);
861                        let mut transport = match UringTransport::new(&tcp_writer) {
862                            Ok(t) => t,
863                            Err(e) => {
864                                tracing::error!(error = %e, "UringTransport init failed");
865                                // Synthetic result: streaming never started,
866                                // so `heard_from_primary` stays false and the
867                                // reconnect keeps its escalated backoff — this
868                                // failure is local (io_uring setup), and a
869                                // persistent one must not redial at 1 Hz.
870                                return super::receiver_transport::StreamingResult {
871                                    exit: SessionExit::Disconnected,
872                                    heard_from_primary: false,
873                                };
874                            }
875                        };
876                        streaming_loop::<UringTransport, A::Event>(
877                            &mut transport,
878                            input_producer,
879                            journal_cursor,
880                            shutdown,
881                            control,
882                            pipeline_depth,
883                            busy_spin,
884                            session_start,
885                            Vec::with_capacity(MAX_DATA_FRAME + 4),
886                            None,
887                            stream_marks,
888                            journal_failed,
889                        )
890                    })
891                    .expect("spawn replica-receiver thread");
892                handle.join().expect("replica-receiver thread panicked")
893            })
894        };
895
896        primary_link_up.store(false, Ordering::Release);
897        match handle_session_exit(
898            result,
899            &mut pipeline,
900            &mut divergence_resyncs,
901            &mut backoff,
902            last_sequence,
903            journal_path,
904            &snapshot_path,
905            factory.as_ref(),
906            &fence_state,
907            shutdown,
908            promote,
909            // Kernel TCP needs no explicit close — the `TcpStream` is
910            // dropped on the next loop turn when a fresh connection
911            // replaces it.
912            || {},
913        ) {
914            AfterSession::Return(r) => return r,
915            AfterSession::Resync {
916                exchange: ex,
917                journal_writer: wr,
918                last_sequence: seq,
919                chain_hash: hash,
920            } => {
921                exchange = ex;
922                journal_writer = wr;
923                last_sequence = seq;
924                chain_hash = hash;
925                continue;
926            }
927            AfterSession::Reconnect => {}
928        }
929    }
930}
931
932// ---------------------------------------------------------------------------
933// Tests
934// ---------------------------------------------------------------------------
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939    use crate::uring_teardown::DRAIN_TIMEOUT;
940    use std::io::Read;
941    use std::net::TcpListener;
942    use std::time::{Duration, Instant};
943
944    use melin_transport_core::replication::protocol::{ReplicaMessage, decode_replica_message};
945
946    /// Connected localhost pair: (transport side, peer side).
947    fn socket_pair() -> (TcpStream, TcpStream) {
948        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
949        let addr = listener.local_addr().unwrap();
950        let client = TcpStream::connect(addr).unwrap();
951        let (server, _) = listener.accept().unwrap();
952        (client, server)
953    }
954
955    fn ack(seq: u64) -> Ack {
956        Ack {
957            acked_sequence: seq,
958            in_memory_sequence: seq,
959        }
960    }
961
962    /// Drive `poll_recv` until every accepted ack has reached the wire.
963    fn flush_acks(transport: &mut UringTransport) {
964        let mut recv_buf = Vec::new();
965        let deadline = Instant::now() + Duration::from_secs(5);
966        while transport.ack_in_flight() {
967            transport.poll_recv(&mut recv_buf).unwrap();
968            assert!(Instant::now() < deadline, "ack send never completed");
969            std::hint::spin_loop();
970        }
971    }
972
973    /// Read `expect` length-prefixed ack frames from the peer socket.
974    fn read_acks(peer: &mut TcpStream, expect: usize) -> Vec<Ack> {
975        peer.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
976        let mut buf = Vec::new();
977        let mut chunk = [0u8; 256];
978        let mut acks = Vec::new();
979        while acks.len() < expect {
980            let n = peer.read(&mut chunk).expect("peer read");
981            assert!(n > 0, "peer closed before all acks arrived");
982            buf.extend_from_slice(&chunk[..n]);
983            // Parse complete frames.
984            loop {
985                if buf.len() < 4 {
986                    break;
987                }
988                let len = u32::from_le_bytes(buf[0..4].try_into().unwrap()) as usize;
989                if buf.len() < 4 + len {
990                    break;
991                }
992                match decode_replica_message(&buf[4..4 + len]).expect("decodable frame") {
993                    ReplicaMessage::Ack(a) => acks.push(a),
994                    other => panic!("expected Ack frame, got {other:?}"),
995                }
996                buf.drain(..4 + len);
997            }
998        }
999        assert!(buf.is_empty(), "trailing bytes after expected acks");
1000        acks
1001    }
1002
1003    #[test]
1004    fn coalesces_to_latest_ack_while_send_in_flight() {
1005        let (client, mut peer) = socket_pair();
1006        let mut transport = UringTransport::new(&client).unwrap();
1007
1008        // First ack goes straight onto the wire path; the next two are
1009        // accepted while it is in flight (the flag clears only when the
1010        // CQE is reaped in poll_recv, so this is deterministic) and
1011        // coalesce — only the newest survives.
1012        assert!(transport.send_ack(&ack(1)).unwrap());
1013        assert!(transport.ack_in_flight());
1014        assert!(transport.send_ack(&ack(2)).unwrap());
1015        assert!(transport.send_ack(&ack(3)).unwrap());
1016
1017        flush_acks(&mut transport);
1018
1019        // Exactly two frames, in cursor order: ack(2) was subsumed.
1020        let acks = read_acks(&mut peer, 2);
1021        let seqs: Vec<u64> = acks.iter().map(|a| a.acked_sequence).collect();
1022        assert_eq!(seqs, vec![1, 3]);
1023    }
1024
1025    #[test]
1026    fn sequential_acks_all_reach_the_wire_in_order() {
1027        let (client, mut peer) = socket_pair();
1028        let mut transport = UringTransport::new(&client).unwrap();
1029
1030        for seq in 1..=3 {
1031            assert!(transport.send_ack(&ack(seq)).unwrap());
1032            flush_acks(&mut transport);
1033        }
1034
1035        let acks = read_acks(&mut peer, 3);
1036        let seqs: Vec<u64> = acks.iter().map(|a| a.acked_sequence).collect();
1037        assert_eq!(seqs, vec![1, 2, 3]);
1038    }
1039
1040    /// The multishot RECV armed at construction stays armed for the
1041    /// whole session, so every ordinary session exit drops the
1042    /// transport with the kernel still holding pointers into the buffer
1043    /// pool. `Drop` has to quiesce it — and promptly: a drain that
1044    /// cannot settle only gives up on the deadline, so finishing well
1045    /// inside it is what proves the buffers were released rather than
1046    /// leaked.
1047    #[test]
1048    fn drop_quiesces_the_multishot_recv() {
1049        let (client, _peer) = socket_pair();
1050        let mut transport = UringTransport::new(&client).unwrap();
1051        // `new` only pushes the multishot RECV; the first poll submits
1052        // it. Teardown from that state is what a real session does.
1053        let mut recv_buf = Vec::new();
1054        transport.poll_recv(&mut recv_buf).unwrap();
1055        assert!(transport.multishot_active);
1056
1057        let started = Instant::now();
1058        drop(transport);
1059        let elapsed = started.elapsed();
1060        assert!(
1061            elapsed < DRAIN_TIMEOUT / 4,
1062            "teardown took {elapsed:?}, close to the {DRAIN_TIMEOUT:?} deadline"
1063        );
1064    }
1065
1066    /// A failing session bails out of `poll_recv` part-way through a
1067    /// batch, abandoning CQEs that were already reaped out of the
1068    /// completion queue. Their kernel-ownership flags still have to be
1069    /// recorded: otherwise teardown waits out its whole deadline for a
1070    /// SEND completion that was consumed here and can never arrive
1071    /// again, and then leaks the buffer pool.
1072    #[test]
1073    fn abandoned_cqes_still_settle_kernel_ownership() {
1074        let (client, _peer) = socket_pair();
1075        let mut transport = UringTransport::new(&client).unwrap();
1076        let mut recv_buf = Vec::new();
1077        transport.poll_recv(&mut recv_buf).unwrap();
1078
1079        // Stands in for a SEND whose CQE landed in the batch behind a
1080        // RECV CQE that failed the session.
1081        transport.ack_in_flight = true;
1082        transport.settle_unprocessed(&[(TOKEN_SEND, 13, 0)]);
1083        assert!(
1084            !transport.ack_in_flight,
1085            "an abandoned SEND completion must release `ack_buf`"
1086        );
1087
1088        // A RECV that still carries F_MORE leaves the arm live — the
1089        // kernel may write into the pool again, so teardown must wait.
1090        transport.settle_unprocessed(&[(TOKEN_RECV, 128, IORING_CQE_F_MORE)]);
1091        assert!(
1092            transport.multishot_active,
1093            "F_MORE must leave the multishot armed"
1094        );
1095        // Only the terminal completion retires it.
1096        transport.settle_unprocessed(&[(TOKEN_RECV, 0, 0)]);
1097        assert!(!transport.multishot_active);
1098
1099        // The real arm submitted by `poll_recv` above is still live in
1100        // the kernel; restore the flag the assertion just cleared so
1101        // `Drop` drains it instead of freeing the pool underneath it.
1102        transport.multishot_active = true;
1103    }
1104
1105    /// Same, for the other kernel-visible buffer: an ack SEND pushed
1106    /// but never submitted, so the drain has to flush the SQ before it
1107    /// can wait for anything.
1108    #[test]
1109    fn drop_quiesces_a_pending_ack_send() {
1110        let (client, _peer) = socket_pair();
1111        let mut transport = UringTransport::new(&client).unwrap();
1112        assert!(transport.send_ack(&ack(7)).unwrap());
1113        assert!(transport.ack_in_flight());
1114
1115        let started = Instant::now();
1116        drop(transport);
1117        let elapsed = started.elapsed();
1118        assert!(
1119            elapsed < DRAIN_TIMEOUT / 4,
1120            "teardown took {elapsed:?}, close to the {DRAIN_TIMEOUT:?} deadline"
1121        );
1122    }
1123
1124    /// Scripted-primary scaffolding shared by the end-to-end receiver
1125    /// tests below: a no-op application over a trivial event, the
1126    /// primary-side frame helpers, and common fixtures. Gated like its
1127    /// consumers so a `no-persist` build carries no dead test code.
1128    #[cfg(not(feature = "no-persist"))]
1129    mod scripted {
1130        use super::*;
1131        use melin_app::app_factory::AppFactory;
1132        use melin_app::{AppEvent, Application, ApplyCtx, CodecError, RejectReason};
1133
1134        // Re-export so the sibling test modules can name the generic
1135        // slot type without reaching into transport-core themselves.
1136        pub(super) use melin_transport_core::pipeline::InputSlot;
1137
1138        #[derive(Debug, Clone, Copy, PartialEq)]
1139        pub(super) struct EvtAdd(pub(super) u64);
1140
1141        impl AppEvent for EvtAdd {
1142            fn encoded_size(&self) -> usize {
1143                8
1144            }
1145            fn encode(&self, buf: &mut [u8]) -> usize {
1146                buf[..8].copy_from_slice(&self.0.to_le_bytes());
1147                8
1148            }
1149            fn decode(buf: &[u8]) -> Result<Self, CodecError> {
1150                if buf.len() < 8 {
1151                    return Err(CodecError::Truncated);
1152                }
1153                Ok(EvtAdd(u64::from_le_bytes(buf[..8].try_into().expect("8"))))
1154            }
1155            fn is_query(&self) -> bool {
1156                false
1157            }
1158        }
1159
1160        #[derive(Debug, Clone, Copy)]
1161        pub(super) struct Rpt;
1162
1163        pub(super) struct App;
1164
1165        impl Application for App {
1166            type Event = EvtAdd;
1167            type Report = Rpt;
1168            type QueryResponse = Rpt;
1169            const APP_VERSION: u16 = 1;
1170
1171            fn apply(&mut self, _e: EvtAdd, _ctx: &ApplyCtx, _out: &mut Vec<Rpt>) -> Option<Rpt> {
1172                None
1173            }
1174            fn tick(&mut self, _now_ns: u64, _out: &mut Vec<Rpt>) {}
1175            fn check_request_seq(&mut self, _key_hash: u64, _seq: u64) -> bool {
1176                true
1177            }
1178            fn build_reject(_e: &EvtAdd, _r: RejectReason) -> Rpt {
1179                Rpt
1180            }
1181            fn snapshot<W: std::io::Write>(&self, _w: &mut W) -> std::io::Result<()> {
1182                Ok(())
1183            }
1184            fn restore<R: std::io::Read>(_r: &mut R) -> std::io::Result<Self> {
1185                Ok(App)
1186            }
1187        }
1188
1189        pub(super) struct Factory;
1190
1191        impl AppFactory for Factory {
1192            type App = App;
1193            fn empty(&self) -> App {
1194                App
1195            }
1196            fn prefault(&self, _app: &mut App) {}
1197        }
1198
1199        /// Deterministic replica signing-key bytes; [`replica_auth`]
1200        /// builds the matching authorized-keys table.
1201        pub(super) const REPLICA_KEY: [u8; 32] = [0xFC; 32];
1202
1203        /// The authorized-keys table listing [`REPLICA_KEY`]'s public
1204        /// half.
1205        pub(super) fn replica_auth() -> melin_app::auth::AuthorizedKeys {
1206            let key = ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY);
1207            let pub_b64 = base64::Engine::encode(
1208                &base64::engine::general_purpose::STANDARD,
1209                key.verifying_key().to_bytes(),
1210            );
1211            melin_app::auth::AuthorizedKeys::parse(&format!("replication {pub_b64} test-replica\n"))
1212                .expect("parse keys")
1213        }
1214
1215        /// All-zero (unpinned sentinel) core assignment for every stage.
1216        pub(super) fn unpinned_cores() -> crate::server::PipelineCores {
1217            crate::server::PipelineCores {
1218                journal: 0,
1219                matching: 0,
1220                response: 0,
1221                reader: 0,
1222                repl_sender: 0,
1223                event_publisher: 0,
1224                shadow: 0,
1225                repl_handler_0: 0,
1226                repl_handler_1: 0,
1227                journal_prep: 0,
1228                journal_disk: 0,
1229            }
1230        }
1231
1232        /// Read one length-prefixed `ReplicaMessage` frame.
1233        pub(super) fn read_replica_msg(stream: &mut TcpStream) -> ReplicaMessage {
1234            let mut len_buf = [0u8; 4];
1235            stream.read_exact(&mut len_buf).expect("frame length");
1236            let len = u32::from_le_bytes(len_buf) as usize;
1237            let mut payload = vec![0u8; len];
1238            stream.read_exact(&mut payload).expect("frame payload");
1239            decode_replica_message(&payload).expect("decodable replica frame")
1240        }
1241
1242        /// Skip frames until an `Ack` at or past `seq` arrives.
1243        pub(super) fn wait_for_ack(stream: &mut TcpStream, seq: u64) {
1244            loop {
1245                if let ReplicaMessage::Ack(a) = read_replica_msg(stream)
1246                    && a.acked_sequence >= seq
1247                {
1248                    return;
1249                }
1250            }
1251        }
1252
1253        /// Accept with a deadline so a replica that fails to (re)connect
1254        /// fails the test instead of hanging it.
1255        pub(super) fn accept_within(listener: &std::net::TcpListener, secs: u64) -> TcpStream {
1256            let deadline = Instant::now() + Duration::from_secs(secs);
1257            loop {
1258                match listener.accept() {
1259                    Ok((s, _)) => {
1260                        s.set_read_timeout(Some(Duration::from_secs(10)))
1261                            .expect("read timeout");
1262                        return s;
1263                    }
1264                    Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
1265                        assert!(Instant::now() < deadline, "replica never connected");
1266                        std::thread::sleep(Duration::from_millis(10));
1267                    }
1268                    Err(e) => panic!("accept failed: {e}"),
1269                }
1270            }
1271        }
1272    }
1273
1274    // -----------------------------------------------------------------
1275    // In-process divergence resync — end to end against a scripted
1276    // primary. Needs hash-chain (divergence is a chain verdict) and
1277    // real persistence (the resync re-derives state from disk).
1278    // -----------------------------------------------------------------
1279    #[cfg(all(feature = "hash-chain", not(feature = "no-persist")))]
1280    mod divergence_resync {
1281        use super::super::super::auth::authenticate_replica;
1282        use super::scripted::*;
1283        use super::*;
1284        use melin_journal::{BufferedWriter, JournalEvent, JournalWrite};
1285        use melin_transport_core::cursors::WireSeq;
1286        use melin_transport_core::replication::catchup::{lineage_origin, snapshot_transfer_with};
1287        use melin_transport_core::replication::protocol::{
1288            encode_hash_mismatch, encode_rotate, encode_stream_start,
1289        };
1290        use std::sync::Arc;
1291        use std::sync::atomic::AtomicBool;
1292
1293        /// A scripted primary brings a fresh replica up, streams one
1294        /// event, then announces a rotation with a deliberately wrong
1295        /// tail hash. The replica's journal stage detects divergence —
1296        /// `run_receiver` must repair it WITHOUT exiting: tear the
1297        /// pipeline down, re-derive its handshake position from disk,
1298        /// reconnect, take the HashMismatch verdict, archive the forked
1299        /// lineage, and complete the snapshot + segment-seed resync,
1300        /// then resume streaming on the rebased lineage.
1301        #[test]
1302        fn mid_stream_divergence_resyncs_in_process() {
1303            let dir = tempfile::tempdir().expect("tempdir");
1304
1305            // --- The scripted primary's journal + snapshot, served by
1306            // the real sender-side transfer code in session 2. Entries
1307            // 1..=5, rotation after 2, snapshot at 4 (mid-live-segment).
1308            let primary_journal = dir.path().join("primary.journal");
1309            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
1310            let mut chain_at_4 = [0u8; 32];
1311            for v in 1..=5u64 {
1312                w.append(&JournalEvent::App(EvtAdd(v))).expect("append");
1313                if v == 2 {
1314                    w.rotate_segment().expect("rotate");
1315                }
1316                if v == 4 {
1317                    chain_at_4 = w.chain_hash().expect("chain");
1318                }
1319            }
1320            drop(w);
1321            melin_transport_core::snapshot::save::<App>(
1322                &App,
1323                WireSeq::new(4),
1324                chain_at_4,
1325                0,
1326                &primary_journal.with_extension("snapshot"),
1327            )
1328            .expect("save snapshot");
1329            let (lineage_start, lineage_anchor) =
1330                lineage_origin(&primary_journal).expect("lineage");
1331
1332            // --- Auth: one replica key, authorized.
1333            let authorized_keys = replica_auth();
1334
1335            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1336            listener.set_nonblocking(true).expect("nonblocking");
1337            let addr = listener.local_addr().expect("addr");
1338
1339            // --- Replica under test.
1340            let replica_journal = dir.path().join("replica.journal");
1341            let replica_snapshot = dir.path().join("replica.snapshot");
1342            let shutdown = Arc::new(AtomicBool::new(false));
1343            let control = crate::replication::ReplicaControlPlane::new();
1344            let cores = unpinned_cores();
1345            let replica = {
1346                let journal = replica_journal.clone();
1347                let shutdown = Arc::clone(&shutdown);
1348                let control = control.clone();
1349                std::thread::spawn(move || -> Result<bool, String> {
1350                    run_receiver::<App>(
1351                        addr,
1352                        &journal,
1353                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
1354                        &shutdown,
1355                        &control,
1356                        3_600_000,
1357                        replica_snapshot,
1358                        cores,
1359                        Duration::ZERO,
1360                        64,
1361                        false,
1362                        Arc::new(Factory),
1363                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
1364                    )
1365                    // ReceiverResult's error is !Send — stringify for join().
1366                    .map(|state| state.is_none())
1367                    .map_err(|e| e.to_string())
1368                })
1369            };
1370
1371            // --- Session 1: fresh sync, one event, then the poisoned
1372            // rotation announce.
1373            let mut s1 = accept_within(&listener, 30);
1374            let mut s1r = s1.try_clone().expect("clone");
1375            authenticate_replica(&mut s1r, &authorized_keys).expect("auth 1");
1376            match read_replica_msg(&mut s1) {
1377                ReplicaMessage::Handshake(h) => {
1378                    assert_eq!(h.last_sequence, 0, "fresh replica handshake")
1379                }
1380                other => panic!("expected Handshake, got {other:?}"),
1381            }
1382            let mut buf = Vec::new();
1383            encode_stream_start(0, lineage_start, lineage_anchor, 0, 1, &mut buf);
1384            s1.write_all(&buf).expect("StreamStart 1");
1385            buf.clear();
1386            melin_transport_core::replication_wire::encode_input_batch(
1387                &[InputSlot::<EvtAdd> {
1388                    connection_id: 0,
1389                    key_hash: 0,
1390                    request_seq: 0,
1391                    sequence: 1,
1392                    timestamp_ns: 1,
1393                    event: JournalEvent::App(EvtAdd(1)),
1394                    // `()` without latency-trace, a timestamp with it.
1395                    publish_ts: Default::default(),
1396                    recv_ts: Default::default(),
1397                }],
1398                &mut buf,
1399            );
1400            s1.write_all(&buf).expect("InputBatch");
1401            wait_for_ack(&mut s1, 1);
1402
1403            // Wrong tail hash at the announced boundary — the replica's
1404            // local chain at sequence 1 cannot match this.
1405            buf.clear();
1406            encode_rotate(1, &[0xEE; 32], &mut buf);
1407            s1.write_all(&buf).expect("Rotate");
1408
1409            // --- Session 2: the SAME process reconnects with its
1410            // position re-derived from the forked on-disk journal.
1411            let mut s2 = accept_within(&listener, 30);
1412            let mut s2r = s2.try_clone().expect("clone");
1413            authenticate_replica(&mut s2r, &authorized_keys).expect("auth 2");
1414            match read_replica_msg(&mut s2) {
1415                ReplicaMessage::Handshake(h) => assert_eq!(
1416                    h.last_sequence, 1,
1417                    "post-divergence handshake must carry the recovered journal position"
1418                ),
1419                other => panic!("expected Handshake, got {other:?}"),
1420            }
1421            // The journal stage's divergence failure latched the health
1422            // mirror before the teardown that led to this reconnect, and
1423            // the replacement pipeline is not built until after the resync
1424            // verdict — so at this deterministic point the replica must be
1425            // reporting unhealthy.
1426            assert!(
1427                !control.pipeline_healthy.load(Ordering::Acquire),
1428                "the divergence must have latched the health mirror false"
1429            );
1430            buf.clear();
1431            encode_hash_mismatch(&mut buf);
1432            s2.write_all(&buf).expect("HashMismatch");
1433            let transfer_shutdown = AtomicBool::new(false);
1434            let mut publish = |b: &[u8]| -> std::io::Result<()> {
1435                s2.write_all(b)?;
1436                s2.flush()
1437            };
1438            // Real sender-side resync: SnapshotBegin → chunks →
1439            // SegmentSeedBegin → seed → StreamStart → catch-up (entry 5).
1440            let end = snapshot_transfer_with::<EvtAdd>(
1441                &primary_journal,
1442                &mut publish,
1443                &transfer_shutdown,
1444                1,
1445            )
1446            .expect("snapshot transfer");
1447            assert_eq!(
1448                end,
1449                melin_transport_core::replication::catchup::CatchUpResult::Ok(5)
1450            );
1451            let mut s2_acks = s2.try_clone().expect("clone");
1452            wait_for_ack(&mut s2_acks, 5);
1453
1454            // Streaming resumed on the rebuilt pipeline — the rebuild
1455            // must have reset the health mirror.
1456            assert!(
1457                control.pipeline_healthy.load(Ordering::Acquire),
1458                "the resync rebuild must reset the health mirror true"
1459            );
1460
1461            // --- Clean shutdown; the receiver must return Ok(None) —
1462            // the divergence never escaped as a process-fatal error.
1463            shutdown.store(true, Ordering::Relaxed);
1464            let result = replica.join().expect("replica thread panicked");
1465            assert_eq!(result, Ok(true), "receiver must exit cleanly via shutdown");
1466
1467            // The forked lineage was archived (never deleted), and the
1468            // live journal is the re-seeded one.
1469            assert!(
1470                dir.path().join("replica.journal.divergent.0").exists(),
1471                "divergent lineage must be archived"
1472            );
1473            assert!(replica_journal.exists(), "re-seeded live journal");
1474        }
1475
1476        /// A resync whose snapshot transfer drops mid-flight must retry
1477        /// as a fresh replica with NO leftover in-memory state. On the
1478        /// in-process divergence repair path `recover_replica_state`
1479        /// leaves `exchange`/`journal_writer` populated; the resync arm
1480        /// archives the live journal (renaming it aside) before the
1481        /// transfer. If the transfer then fails and those handles are not
1482        /// nulled, the stale writer — its backing file now under the
1483        /// `.divergent.<n>` archive — survives the fresh-replica create
1484        /// gate (`journal_writer.is_none()` false) and is rebuilt into the
1485        /// next pipeline, so the live journal is never recreated and the
1486        /// new session streams onto an archived-away inode. This pins the
1487        /// fix (mirrors the DPDK receiver): after the failed transfer the
1488        /// retry creates a fresh live journal from the StreamStart lineage.
1489        #[test]
1490        fn resync_transfer_failure_rebuilds_clean_not_over_stale_writer() {
1491            let dir = tempfile::tempdir().expect("tempdir");
1492
1493            // Minimal scripted primary — only its lineage identity is used
1494            // (session 3 streams from genesis; the dropped session 2 never
1495            // reaches the snapshot body).
1496            let primary_journal = dir.path().join("primary.journal");
1497            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
1498            for v in 1..=2u64 {
1499                w.append(&JournalEvent::App(EvtAdd(v))).expect("append");
1500            }
1501            drop(w);
1502            let (lineage_start, lineage_anchor) =
1503                lineage_origin(&primary_journal).expect("lineage");
1504
1505            let authorized_keys = replica_auth();
1506
1507            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1508            listener.set_nonblocking(true).expect("nonblocking");
1509            let addr = listener.local_addr().expect("addr");
1510
1511            let replica_journal = dir.path().join("replica.journal");
1512            let replica_snapshot = dir.path().join("replica.snapshot");
1513            let shutdown = Arc::new(AtomicBool::new(false));
1514            let control = crate::replication::ReplicaControlPlane::new();
1515            let cores = unpinned_cores();
1516            let replica = {
1517                let journal = replica_journal.clone();
1518                let shutdown = Arc::clone(&shutdown);
1519                let control = control.clone();
1520                std::thread::spawn(move || -> Result<bool, String> {
1521                    run_receiver::<App>(
1522                        addr,
1523                        &journal,
1524                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
1525                        &shutdown,
1526                        &control,
1527                        3_600_000,
1528                        replica_snapshot,
1529                        cores,
1530                        Duration::ZERO,
1531                        64,
1532                        false,
1533                        Arc::new(Factory),
1534                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
1535                    )
1536                    .map(|state| state.is_none())
1537                    .map_err(|e| e.to_string())
1538                })
1539            };
1540
1541            // Helper: stream a single event (seq 1) over an established
1542            // session and wait for its ack.
1543            let stream_one = |s: &mut TcpStream, buf: &mut Vec<u8>| {
1544                buf.clear();
1545                encode_stream_start(0, lineage_start, lineage_anchor, 0, 1, buf);
1546                s.write_all(buf).expect("StreamStart");
1547                buf.clear();
1548                melin_transport_core::replication_wire::encode_input_batch(
1549                    &[InputSlot::<EvtAdd> {
1550                        connection_id: 0,
1551                        key_hash: 0,
1552                        request_seq: 0,
1553                        sequence: 1,
1554                        timestamp_ns: 1,
1555                        event: JournalEvent::App(EvtAdd(1)),
1556                        publish_ts: Default::default(),
1557                        recv_ts: Default::default(),
1558                    }],
1559                    buf,
1560                );
1561                s.write_all(buf).expect("InputBatch");
1562            };
1563
1564            // --- Session 1: fresh sync, event 1, poisoned rotation —
1565            // the journal stage detects divergence and the receiver
1566            // repairs in-process (`recover_replica_state` repopulates
1567            // `exchange`/`journal_writer`).
1568            let mut buf = Vec::new();
1569            let mut s1 = accept_within(&listener, 30);
1570            let mut s1r = s1.try_clone().expect("clone");
1571            authenticate_replica(&mut s1r, &authorized_keys).expect("auth 1");
1572            match read_replica_msg(&mut s1) {
1573                ReplicaMessage::Handshake(h) => assert_eq!(h.last_sequence, 0, "fresh"),
1574                other => panic!("expected Handshake, got {other:?}"),
1575            }
1576            stream_one(&mut s1, &mut buf);
1577            wait_for_ack(&mut s1, 1);
1578            buf.clear();
1579            encode_rotate(1, &[0xEE; 32], &mut buf);
1580            s1.write_all(&buf).expect("Rotate");
1581
1582            // --- Session 2: reconnect at the recovered position (seq 1),
1583            // verdict HashMismatch (archives the live journal aside), then
1584            // DROP the connection before SnapshotBegin — the transfer
1585            // fails and the receiver must retry as a fresh replica.
1586            let mut s2 = accept_within(&listener, 30);
1587            let mut s2r = s2.try_clone().expect("clone");
1588            authenticate_replica(&mut s2r, &authorized_keys).expect("auth 2");
1589            match read_replica_msg(&mut s2) {
1590                ReplicaMessage::Handshake(h) => assert_eq!(
1591                    h.last_sequence, 1,
1592                    "reconnect carries the recovered forked position"
1593                ),
1594                other => panic!("expected Handshake, got {other:?}"),
1595            }
1596            buf.clear();
1597            encode_hash_mismatch(&mut buf);
1598            s2.write_all(&buf).expect("HashMismatch");
1599            s2.flush().expect("flush");
1600            // Both fds reference the same socket (try_clone dups) — drop
1601            // both to send FIN so the replica's SnapshotBegin read hits EOF.
1602            drop(s2);
1603            drop(s2r);
1604
1605            // --- Session 3: the receiver reconnects as a FRESH replica
1606            // (the archived lineage's position is meaningless, so
1607            // `last_sequence` is 0). It must recreate a live journal from
1608            // the StreamStart lineage, NOT rebuild over the stale writer.
1609            let mut s3 = accept_within(&listener, 30);
1610            let mut s3r = s3.try_clone().expect("clone");
1611            authenticate_replica(&mut s3r, &authorized_keys).expect("auth 3");
1612            match read_replica_msg(&mut s3) {
1613                ReplicaMessage::Handshake(h) => assert_eq!(
1614                    h.last_sequence, 0,
1615                    "after a failed transfer the retry handshakes as fresh"
1616                ),
1617                other => panic!("expected Handshake, got {other:?}"),
1618            }
1619            stream_one(&mut s3, &mut buf);
1620            wait_for_ack(&mut s3, 1);
1621
1622            shutdown.store(true, Ordering::Relaxed);
1623            let result = replica.join().expect("replica thread panicked");
1624            assert_eq!(result, Ok(true), "receiver exits cleanly via shutdown");
1625
1626            // The forked lineage stays archived, and a fresh live journal
1627            // was recreated from the lineage. With the bug, the stale
1628            // writer would be reused and this path would not exist (its
1629            // inode stranded under `.divergent.0`).
1630            assert!(
1631                dir.path().join("replica.journal.divergent.0").exists(),
1632                "forked lineage archived, never deleted"
1633            );
1634            assert!(
1635                replica_journal.exists(),
1636                "fresh live journal recreated after the failed transfer"
1637            );
1638        }
1639
1640        /// The in-process repair budget is one per process lifetime: a
1641        /// second mid-stream divergence is systematic (corruption or a
1642        /// serious bug that the first re-seed did not cure) and the
1643        /// receiver must exit hard instead of looping — each repair
1644        /// cycle archives a full journal copy, and a repair loop would
1645        /// fill the disk while masking the underlying fault.
1646        #[test]
1647        fn second_mid_stream_divergence_exits_hard() {
1648            let dir = tempfile::tempdir().expect("tempdir");
1649
1650            let primary_journal = dir.path().join("primary.journal");
1651            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
1652            let mut chain_at_4 = [0u8; 32];
1653            for v in 1..=5u64 {
1654                w.append(&JournalEvent::App(EvtAdd(v))).expect("append");
1655                if v == 2 {
1656                    w.rotate_segment().expect("rotate");
1657                }
1658                if v == 4 {
1659                    chain_at_4 = w.chain_hash().expect("chain");
1660                }
1661            }
1662            drop(w);
1663            melin_transport_core::snapshot::save::<App>(
1664                &App,
1665                WireSeq::new(4),
1666                chain_at_4,
1667                0,
1668                &primary_journal.with_extension("snapshot"),
1669            )
1670            .expect("save snapshot");
1671            let (lineage_start, lineage_anchor) =
1672                lineage_origin(&primary_journal).expect("lineage");
1673
1674            let authorized_keys = replica_auth();
1675
1676            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1677            listener.set_nonblocking(true).expect("nonblocking");
1678            let addr = listener.local_addr().expect("addr");
1679
1680            let replica_journal = dir.path().join("replica.journal");
1681            let replica_snapshot = dir.path().join("replica.snapshot");
1682            let shutdown = Arc::new(AtomicBool::new(false));
1683            let control = crate::replication::ReplicaControlPlane::new();
1684            let cores = unpinned_cores();
1685            let replica = {
1686                let journal = replica_journal.clone();
1687                let shutdown = Arc::clone(&shutdown);
1688                let control = control.clone();
1689                std::thread::spawn(move || -> Result<bool, String> {
1690                    run_receiver::<App>(
1691                        addr,
1692                        &journal,
1693                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
1694                        &shutdown,
1695                        &control,
1696                        3_600_000,
1697                        replica_snapshot,
1698                        cores,
1699                        Duration::ZERO,
1700                        64,
1701                        false,
1702                        Arc::new(Factory),
1703                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
1704                    )
1705                    .map(|state| state.is_none())
1706                    .map_err(|e| e.to_string())
1707                })
1708            };
1709
1710            // Session 1: fresh sync, one event, poisoned rotation.
1711            let mut s1 = accept_within(&listener, 30);
1712            let mut s1r = s1.try_clone().expect("clone");
1713            authenticate_replica(&mut s1r, &authorized_keys).expect("auth 1");
1714            match read_replica_msg(&mut s1) {
1715                ReplicaMessage::Handshake(h) => assert_eq!(h.last_sequence, 0),
1716                other => panic!("expected Handshake, got {other:?}"),
1717            }
1718            let mut buf = Vec::new();
1719            encode_stream_start(0, lineage_start, lineage_anchor, 0, 1, &mut buf);
1720            s1.write_all(&buf).expect("StreamStart 1");
1721            buf.clear();
1722            melin_transport_core::replication_wire::encode_input_batch(
1723                &[InputSlot::<EvtAdd> {
1724                    connection_id: 0,
1725                    key_hash: 0,
1726                    request_seq: 0,
1727                    sequence: 1,
1728                    timestamp_ns: 1,
1729                    event: JournalEvent::App(EvtAdd(1)),
1730                    publish_ts: Default::default(),
1731                    recv_ts: Default::default(),
1732                }],
1733                &mut buf,
1734            );
1735            s1.write_all(&buf).expect("InputBatch");
1736            wait_for_ack(&mut s1, 1);
1737            buf.clear();
1738            encode_rotate(1, &[0xEE; 32], &mut buf);
1739            s1.write_all(&buf).expect("Rotate 1");
1740
1741            // Session 2: in-process repair (the one allowed), then a
1742            // SECOND poisoned rotation after streaming resumes.
1743            let mut s2 = accept_within(&listener, 30);
1744            let mut s2r = s2.try_clone().expect("clone");
1745            authenticate_replica(&mut s2r, &authorized_keys).expect("auth 2");
1746            match read_replica_msg(&mut s2) {
1747                ReplicaMessage::Handshake(h) => assert_eq!(h.last_sequence, 1),
1748                other => panic!("expected Handshake, got {other:?}"),
1749            }
1750            buf.clear();
1751            encode_hash_mismatch(&mut buf);
1752            s2.write_all(&buf).expect("HashMismatch");
1753            let transfer_shutdown = AtomicBool::new(false);
1754            {
1755                let mut publish = |b: &[u8]| -> std::io::Result<()> {
1756                    s2.write_all(b)?;
1757                    s2.flush()
1758                };
1759                snapshot_transfer_with::<EvtAdd>(
1760                    &primary_journal,
1761                    &mut publish,
1762                    &transfer_shutdown,
1763                    1,
1764                )
1765                .expect("snapshot transfer");
1766            }
1767            buf.clear();
1768            melin_transport_core::replication_wire::encode_input_batch(
1769                &[InputSlot::<EvtAdd> {
1770                    connection_id: 0,
1771                    key_hash: 0,
1772                    request_seq: 0,
1773                    sequence: 6,
1774                    timestamp_ns: 6,
1775                    event: JournalEvent::App(EvtAdd(6)),
1776                    publish_ts: Default::default(),
1777                    recv_ts: Default::default(),
1778                }],
1779                &mut buf,
1780            );
1781            s2.write_all(&buf).expect("InputBatch 6");
1782            let mut s2_acks = s2.try_clone().expect("clone");
1783            wait_for_ack(&mut s2_acks, 6);
1784            buf.clear();
1785            encode_rotate(6, &[0xEE; 32], &mut buf);
1786            s2.write_all(&buf).expect("Rotate 2");
1787
1788            // No third connection: the receiver must give up and exit
1789            // with an error naming the recurrence.
1790            let result = replica.join().expect("replica thread panicked");
1791            let err = result.expect_err("second divergence must exit hard");
1792            assert!(
1793                err.contains("divergence recurred"),
1794                "error must name the recurrence: {err}"
1795            );
1796        }
1797
1798        // -------------------------------------------------------------
1799        // Promotion teardown helper (`take_pipeline_for_promotion`) — the
1800        // no-live-pipeline decision branches. With no pipeline to tear
1801        // down it hands back the warm state held in the receiver's locals
1802        // (a clean promotion) or errors when none is present. Colocated
1803        // here for the `App` + `BufferedWriter` fixtures above; the
1804        // Clean-pipeline branch needs a live pipeline (failover IT).
1805        // -------------------------------------------------------------
1806        type PromoteHandles =
1807            crate::replication::ReplicaPipelineHandles<App, BufferedWriter<EvtAdd>>;
1808
1809        #[test]
1810        fn promotion_hands_back_local_state_when_present() {
1811            let dir = tempfile::tempdir().unwrap();
1812            let mut pipeline: Option<PromoteHandles> = None;
1813            let mut exchange = Some(App);
1814            let mut journal_writer =
1815                Some(BufferedWriter::<EvtAdd>::create(&dir.path().join("p.journal")).unwrap());
1816
1817            let result = crate::replication::take_pipeline_for_promotion(
1818                &mut pipeline,
1819                &mut exchange,
1820                &mut journal_writer,
1821            );
1822
1823            assert!(matches!(result, Ok(Some(_))), "warm state must be promoted");
1824            assert!(
1825                exchange.is_none() && journal_writer.is_none(),
1826                "state must be moved into the result"
1827            );
1828        }
1829
1830        #[test]
1831        fn promotion_errs_when_no_local_state() {
1832            let mut pipeline: Option<PromoteHandles> = None;
1833            let mut exchange: Option<App> = None;
1834            let mut journal_writer: Option<BufferedWriter<EvtAdd>> = None;
1835
1836            let result = crate::replication::take_pipeline_for_promotion(
1837                &mut pipeline,
1838                &mut exchange,
1839                &mut journal_writer,
1840            );
1841
1842            // Not `expect_err` — the Ok payload (App) isn't `Debug`.
1843            let err = match result {
1844                Err(e) => e,
1845                Ok(_) => panic!("a promote with nothing to promote must error"),
1846            };
1847            assert!(
1848                err.to_string().contains("no local state available"),
1849                "{err}"
1850            );
1851        }
1852
1853        #[test]
1854        fn promotion_errs_on_partial_state() {
1855            // Exchange present, writer missing — not a usable hand-off.
1856            let mut pipeline: Option<PromoteHandles> = None;
1857            let mut exchange = Some(App);
1858            let mut journal_writer: Option<BufferedWriter<EvtAdd>> = None;
1859
1860            let result = crate::replication::take_pipeline_for_promotion(
1861                &mut pipeline,
1862                &mut exchange,
1863                &mut journal_writer,
1864            );
1865
1866            assert!(result.is_err(), "partial state is not a valid promotion");
1867        }
1868    }
1869
1870    // -----------------------------------------------------------------
1871    // Shutdown during reconnect backoff — end to end against a scripted
1872    // primary. Needs real persistence (the pipeline journals the one
1873    // streamed event); no chain verdicts involved, so no hash-chain.
1874    // -----------------------------------------------------------------
1875    #[cfg(not(feature = "no-persist"))]
1876    mod backoff_shutdown {
1877        use super::super::super::auth::authenticate_replica;
1878        use super::scripted::*;
1879        use super::*;
1880        use melin_journal::{BufferedWriter, JournalEvent, JournalWrite};
1881        use melin_transport_core::replication::catchup::lineage_origin;
1882        use melin_transport_core::replication::protocol::encode_stream_start;
1883        use std::sync::Arc;
1884        use std::sync::atomic::AtomicBool;
1885
1886        /// Names of every live thread in this process, via
1887        /// `/proc/self/task` (Linux-only, like the runtime itself).
1888        fn live_thread_names() -> Vec<String> {
1889            std::fs::read_dir("/proc/self/task")
1890                .expect("read /proc/self/task")
1891                .filter_map(|entry| {
1892                    // A thread may exit between readdir and the comm
1893                    // read — skipping it is the correct outcome.
1894                    let entry = entry.ok()?;
1895                    let name = std::fs::read_to_string(entry.path().join("comm")).ok()?;
1896                    Some(name.trim().to_string())
1897                })
1898                .collect()
1899        }
1900
1901        /// Shutdown while the receiver waits out a reconnect backoff must
1902        /// tear the live pipeline down — join every stage thread — rather
1903        /// than abandon it. Pins the fix for the connect-failure arm that
1904        /// returned without teardown (stages left detached mid-run, the
1905        /// journal writer never closed).
1906        #[test]
1907        fn shutdown_during_reconnect_backoff_joins_pipeline_threads() {
1908            let dir = tempfile::tempdir().expect("tempdir");
1909
1910            // Minimal primary journal — only its lineage identity is
1911            // needed for the StreamStart the fresh replica accepts.
1912            let primary_journal = dir.path().join("primary.journal");
1913            let mut w = BufferedWriter::<EvtAdd>::create(&primary_journal).expect("create");
1914            w.append(&JournalEvent::App(EvtAdd(1))).expect("append");
1915            drop(w);
1916            let (lineage_start, lineage_anchor) =
1917                lineage_origin(&primary_journal).expect("lineage");
1918
1919            let authorized_keys = replica_auth();
1920            let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1921            listener.set_nonblocking(true).expect("nonblocking");
1922            let addr = listener.local_addr().expect("addr");
1923
1924            let shutdown = Arc::new(AtomicBool::new(false));
1925            let control = crate::replication::ReplicaControlPlane::new();
1926            let replica = {
1927                let journal = dir.path().join("replica.journal");
1928                let snapshot = dir.path().join("replica.snapshot");
1929                let shutdown = Arc::clone(&shutdown);
1930                let control = control.clone();
1931                std::thread::spawn(move || -> Result<bool, String> {
1932                    run_receiver::<App>(
1933                        addr,
1934                        &journal,
1935                        &ed25519_dalek::SigningKey::from_bytes(&REPLICA_KEY),
1936                        &shutdown,
1937                        &control,
1938                        3_600_000,
1939                        snapshot,
1940                        unpinned_cores(),
1941                        Duration::ZERO,
1942                        64,
1943                        false,
1944                        Arc::new(Factory),
1945                        Arc::new(melin_transport_core::fence::FenceState::new(0)),
1946                    )
1947                    // ReceiverResult's error is !Send — stringify for join().
1948                    .map(|state| state.is_none())
1949                    .map_err(|e| e.to_string())
1950                })
1951            };
1952
1953            // Session 1: bring the pipeline up for real — stream one
1954            // event and wait for its ack (acked ⇒ journaled ⇒ the stage
1955            // threads exist).
1956            let mut s1 = accept_within(&listener, 30);
1957            let mut s1r = s1.try_clone().expect("clone");
1958            authenticate_replica(&mut s1r, &authorized_keys).expect("auth");
1959            match read_replica_msg(&mut s1) {
1960                ReplicaMessage::Handshake(h) => {
1961                    assert_eq!(h.last_sequence, 0, "fresh replica handshake")
1962                }
1963                other => panic!("expected Handshake, got {other:?}"),
1964            }
1965            let mut buf = Vec::new();
1966            encode_stream_start(0, lineage_start, lineage_anchor, 0, 1, &mut buf);
1967            s1.write_all(&buf).expect("StreamStart");
1968            buf.clear();
1969            melin_transport_core::replication_wire::encode_input_batch(
1970                &[InputSlot::<EvtAdd> {
1971                    connection_id: 0,
1972                    key_hash: 0,
1973                    request_seq: 0,
1974                    sequence: 1,
1975                    timestamp_ns: 1,
1976                    event: JournalEvent::App(EvtAdd(1)),
1977                    publish_ts: Default::default(),
1978                    recv_ts: Default::default(),
1979                }],
1980                &mut buf,
1981            );
1982            s1.write_all(&buf).expect("InputBatch");
1983            wait_for_ack(&mut s1, 1);
1984
1985            // Kill the primary completely — both socket halves and the
1986            // listener — so every reconnect attempt is refused and the
1987            // receiver settles into the connect-failure backoff.
1988            drop(s1);
1989            drop(s1r);
1990            drop(listener);
1991            // Past the 1s post-disconnect backoff, inside the 2s
1992            // connect-failure backoff sleep. (Any other point in the
1993            // reconnect loop must satisfy the assertions too — this
1994            // timing just targets the arm that historically leaked.)
1995            std::thread::sleep(Duration::from_millis(1500));
1996
1997            shutdown.store(true, Ordering::Relaxed);
1998            let deadline = Instant::now() + Duration::from_secs(10);
1999            while !replica.is_finished() {
2000                assert!(
2001                    Instant::now() < deadline,
2002                    "receiver did not exit after shutdown"
2003                );
2004                std::thread::sleep(Duration::from_millis(20));
2005            }
2006            let result = replica.join().expect("replica thread panicked");
2007            assert_eq!(result, Ok(true), "receiver must exit cleanly via shutdown");
2008
2009            // The stage threads are named at spawn, and a leaked stage
2010            // spins forever — absence within the deadline proves the
2011            // teardown joined them. Deadline-poll rather than one-shot:
2012            // under plain `cargo test`, concurrent tests' stages may be
2013            // momentarily alive, but those exit on their own.
2014            let deadline = Instant::now() + Duration::from_secs(10);
2015            loop {
2016                let names = live_thread_names();
2017                if !names
2018                    .iter()
2019                    .any(|n| n == "journal" || n == "matching" || n == "drain")
2020                {
2021                    break;
2022                }
2023                assert!(
2024                    Instant::now() < deadline,
2025                    "pipeline threads still alive after shutdown: {names:?}"
2026                );
2027                std::thread::sleep(Duration::from_millis(50));
2028            }
2029        }
2030    }
2031}