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