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