melin_server_runtime/replication/tcp_sender.rs
1//! TCP replication sender (primary side).
2//!
3//! Listens for replica connections, handles authentication, journal
4//! catch-up, snapshot transfer, and live streaming. The wire protocol
5//! is defined in `super::protocol`.
6
7use std::io::{self, Write};
8use std::net::{TcpListener, TcpStream};
9use std::sync::Arc;
10use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
11
12use tracing::{debug, error, info, warn};
13
14use melin_journal::replication::ReplicationConsumer;
15
16use super::auth::authenticate_replica;
17use super::{ReplicaCursors, ReplicaGate, ReplicationMetrics, SentHighWater};
18use crate::uring_teardown::{DrainBackoff, wake_pending_ops};
19use melin_app::Application;
20use melin_transport_core::replication::catchup::{
21 CatchUpResult, bridge_catchup_to_live, can_catch_up_from_journal, catch_up_from_journal,
22 preflight_snapshot_transfer, snapshot_transfer_with,
23};
24use melin_transport_core::replication::protocol::{
25 MAX_CONTROL_FRAME, ReplicaMessage, decode_replica_message, encode_hash_mismatch,
26 encode_heartbeat, encode_need_snapshot, encode_stream_start, read_frame,
27};
28use melin_transport_core::replication::validate::{
29 HandshakeValidation, validate_replica_handshake_settled,
30};
31
32// --- Replication Sender (Primary side) ---
33
34/// Owned state for the replication sender thread.
35/// A TCP listener guaranteed non-blocking: the only constructor sets
36/// the flag, so the guarantee is carried by the type. [`run_sender`]'s
37/// accept loop polls the shutdown flag between accepts — handed a
38/// blocking listener it would park inside `accept()` and only observe
39/// shutdown when the next replica happened to dial in. This newtype
40/// makes that state unrepresentable instead of documenting it away.
41pub struct ReplicationListener(TcpListener);
42
43impl ReplicationListener {
44 /// Wrap a bound listener, setting it non-blocking.
45 pub fn new(listener: TcpListener) -> std::io::Result<Self> {
46 listener.set_nonblocking(true)?;
47 Ok(Self(listener))
48 }
49}
50
51pub struct Sender {
52 /// Pre-bound replication listener, non-blocking by construction.
53 /// Bound by the caller at startup — not here — so a bad or stolen
54 /// `--replication-bind` port fails the boot with a clear error.
55 /// Binding on this thread meant a failure only killed the sender
56 /// silently, leaving a primary that looked healthy but could never
57 /// satisfy `disk+ram`/`two-disks` acks.
58 pub listener: ReplicationListener,
59 pub repl_consumer_1: ReplicationConsumer,
60 pub repl_consumer_2: ReplicationConsumer,
61 pub replica_slots: Arc<melin_transport_core::ReplicaSlotCursors>,
62 pub journal_path: std::path::PathBuf,
63 pub authorized_keys: Arc<melin_app::auth::AuthorizedKeys>,
64 pub evict_flags: [Arc<AtomicBool>; 2],
65 pub active_flags: [Arc<AtomicBool>; 2],
66 pub metrics: Arc<ReplicationMetrics>,
67 pub handler_cores: [usize; 2],
68 pub batch_size: usize,
69 pub heartbeat_secs: u64,
70 pub busy_spin: bool,
71 /// Node fencing state. Read to stamp the primary's epoch onto each
72 /// `StreamStart`, and to self-demote when a replica handshakes with a
73 /// higher epoch (this primary has been superseded). See `crate::fence`.
74 pub fence_state: Arc<melin_transport_core::fence::FenceState>,
75 /// The active ack policy, shared with the response gate and the
76 /// admin `ACK-POLICY` command. Stamped on every `StreamStart` and
77 /// `Heartbeat` so replicas judge auto-promotion against the policy
78 /// this primary actually acks under.
79 pub ack_policy: Arc<std::sync::atomic::AtomicU8>,
80}
81
82/// Run the replication sender. Listens for replica connections,
83/// streams journal data batches, processes acks, and updates the
84/// replication cursor.
85///
86/// Runs on a dedicated thread. Blocks until shutdown.
87pub fn run_sender<A: Application>(
88 config: Sender,
89 shutdown: &AtomicBool,
90 replica_ready: &AtomicBool,
91 replicas_connected: &AtomicU32,
92) {
93 let Sender {
94 listener,
95 repl_consumer_1,
96 repl_consumer_2,
97 replica_slots,
98 journal_path,
99 authorized_keys,
100 evict_flags,
101 active_flags,
102 metrics,
103 handler_cores,
104 batch_size,
105 heartbeat_secs,
106 busy_spin,
107 fence_state,
108 ack_policy,
109 } = config;
110 // Unwrap the invariant-carrying newtype — from here `listener` is a
111 // plain TcpListener, known non-blocking.
112 let ReplicationListener(listener) = listener;
113 match listener.local_addr() {
114 Ok(addr) => info!(%addr, "replication sender listening"),
115 // Best-effort log detail — the pre-bound listener works either way.
116 Err(_) => info!("replication sender listening"),
117 }
118
119 // Two replica slots, each with its own ring consumer and thread handle.
120 // The accept loop fills empty slots. When a replica disconnects, its
121 // slot becomes available for a new connection. The consumer is `None`
122 // while a handler thread owns it (moved into the thread, returned on exit).
123 struct ReplicaSlot {
124 consumer: Option<ReplicationConsumer>,
125 handle: Option<std::thread::JoinHandle<ReplicationConsumer>>,
126 }
127
128 let mut slots = [
129 ReplicaSlot {
130 consumer: Some(repl_consumer_1),
131 handle: None,
132 },
133 ReplicaSlot {
134 consumer: Some(repl_consumer_2),
135 handle: None,
136 },
137 ];
138
139 // Single owner of the per-replica progress cursors (per-slot acked
140 // positions and the gate's gauge pair). Shared by both handler
141 // threads — see `ReplicaCursors` for the ordering contract relative
142 // to the active flags.
143 let cursors = Arc::new(ReplicaCursors::new(replica_slots, Arc::clone(&metrics)));
144
145 // Per-slot "this connection authenticated" latch. The trading-halt gate
146 // (`replicas_connected`) is lifted by the handler thread only after auth
147 // succeeds, so the main loop must lower it on teardown only when the
148 // handler got that far — a connection that drops mid-auth never counted.
149 // Per-slot `Arc<AtomicBool>` mirrors `active_flags`/`evict_flags`; shared
150 // with the handler thread, which flips it true post-auth.
151 let authenticated_flags: [Arc<AtomicBool>; 2] = [
152 Arc::new(AtomicBool::new(false)),
153 Arc::new(AtomicBool::new(false)),
154 ];
155
156 loop {
157 if shutdown.load(Ordering::Relaxed) {
158 info!("replication sender shutting down");
159 // Wait for active replica threads to finish.
160 for slot in &mut slots {
161 if let Some(handle) = slot.handle.take()
162 && let Ok(consumer) = handle.join()
163 {
164 slot.consumer = Some(consumer);
165 }
166 }
167 return;
168 }
169
170 // Check eviction flags from the journal stage. When set, the
171 // journal stage timed out publishing to this slot's ring. We need
172 // to reclaim the consumer so the idle drain loop can clear the ring,
173 // allowing the journal stage to resume publishing.
174 for (i, slot) in slots.iter_mut().enumerate() {
175 if evict_flags[i].load(Ordering::Acquire) && slot.handle.is_some() {
176 metrics.evictions_total.fetch_add(1, Ordering::Relaxed);
177 warn!(
178 slot = i,
179 "evicting slow replica (ring backpressure timeout)"
180 );
181 // The handler thread checks shutdown — we can't signal it
182 // individually without adding per-slot flags. Instead, join
183 // the thread with a short timeout by checking is_finished.
184 // The handler's TCP read timeout (5s) will cause it to exit
185 // on the next iteration when it checks shutdown. But we
186 // want faster eviction, so we shutdown the TCP stream to
187 // unblock the read.
188 //
189 // For now, mark the slot and let it be collected below
190 // when the handler finishes naturally (TCP timeout or
191 // next send failure after the ring stops being fed).
192 }
193 }
194
195 // Disengage a finished slot's shared state. Both the clean-exit and
196 // panic arms below call this, so they cannot drift: a panicked handler
197 // must tear down exactly like a clean exit, or it leaks the trading-halt
198 // gate and — far worse — leaves the slot's progress cursors engaged,
199 // so the primary stops acking client requests even with a healthy
200 // surviving replica. Borrows only atomics (interior
201 // mutability), so it coexists with the `&mut` iteration over `slots`.
202 let disengage_slot = |slot_idx: usize| {
203 // Lower the gate only if this connection authenticated (and so
204 // lifted it); `swap` reads and resets the latch in one op, and the
205 // `join()` ordered the handler's post-auth writes before here.
206 // `lower` warns "trading halted" if this was the last replica.
207 if authenticated_flags[slot_idx].swap(false, Ordering::AcqRel) {
208 ReplicaGate::new(replicas_connected).lower();
209 }
210 // Disengage cursors BEFORE clearing the active flag — ordering
211 // contract B2 (see `ReplicaCursors`).
212 cursors.clear_on_disconnect(slot_idx);
213 metrics.catching_up[slot_idx].store(false, Ordering::Relaxed);
214 // Journal stage stops publishing to this ring.
215 active_flags[slot_idx].store(false, Ordering::Release);
216 };
217
218 // Collect finished replica threads (disconnected replicas).
219 for (i, slot) in slots.iter_mut().enumerate() {
220 if let Some(ref handle) = slot.handle
221 && handle.is_finished()
222 {
223 let handle = slot.handle.take().expect("just checked is_some");
224 match handle.join() {
225 Ok(mut consumer) => {
226 // Drop any unread entries before the consumer is
227 // stashed back in the slot. The journal stage may
228 // have published batches into the ring that the
229 // evicted handler never got to forward — if we
230 // left them in place, the NEXT handler on this
231 // slot would drain them to its replica and
232 // acknowledge with pre-eviction sequences. Those
233 // acks would stall the slot's acked-progress
234 // cursor at the old position and gate the
235 // primary's response stage at the slow-replica
236 // rate.
237 //
238 // Fast-forward to the producer cursor so the
239 // live-streaming loop starts with a clean ring.
240 consumer.skip_to_producer();
241 slot.consumer = Some(consumer);
242 disengage_slot(i);
243 // Clear eviction flag after reclaiming the consumer.
244 if evict_flags[i].load(Ordering::Relaxed) {
245 evict_flags[i].store(false, Ordering::Release);
246 warn!(slot = i, "evicted replica — ring ready for reconnection");
247 } else {
248 warn!(slot = i, "replica disconnected");
249 }
250 }
251 Err(_) => {
252 error!(slot = i, "replica handler thread panicked");
253 // Consumer is lost — the slot stays handle=None /
254 // consumer=None, so the accept loop skips it. With
255 // independent rings, only this slot's ring is affected;
256 // the other replica continues normally. Still disengage
257 // its shared state, or the gate leaks and the cursor
258 // freezes (see `disengage_slot`).
259 disengage_slot(i);
260 evict_flags[i].store(false, Ordering::Release);
261 }
262 }
263 }
264 }
265
266 // Find a slot that has a consumer available (not in use by a thread).
267 let empty_slot = slots
268 .iter()
269 .position(|s| s.handle.is_none() && s.consumer.is_some());
270
271 // Accept a connection if there's an empty slot.
272 if let Some(slot_idx) = empty_slot {
273 match listener.accept() {
274 Ok((stream, addr)) => {
275 info!(addr = %addr, slot = slot_idx, "replica connected");
276 if let Err(e) = stream.set_nodelay(true) {
277 debug!(error = %e, "failed to set TCP_NODELAY on replica connection");
278 }
279 // SO_BUSY_POLL on the sender side: the per-replica thread
280 // spins on `recv` for ack frames, so kernel busy-polling
281 // removes the softirq->wakeup handoff from the ack path.
282 if let Err(e) =
283 crate::server::set_busy_poll(&stream, crate::server::BUSY_POLL_US)
284 {
285 debug!(error = %e, "failed to set SO_BUSY_POLL on replica connection");
286 }
287
288 // The trading-halt gate is NOT lifted here — a bare connect
289 // hasn't proven a Replication key. The handler lifts it after
290 // auth succeeds (see `handle_replica_connection`).
291
292 // Take the consumer out of the slot for the handler thread.
293 // The slot's consumer becomes None while the thread owns it.
294 let consumer = slots[slot_idx]
295 .consumer
296 .take()
297 .expect("empty_slot check guarantees consumer is Some");
298
299 let slot_cursors = Arc::clone(&cursors);
300 let jpath = journal_path.clone();
301 let auth_keys = Arc::clone(&authorized_keys);
302 let slot_metrics = Arc::clone(&metrics);
303 let slot_active = Arc::clone(&active_flags[slot_idx]);
304 let slot_evict = Arc::clone(&evict_flags[slot_idx]);
305 let slot_fence = Arc::clone(&fence_state);
306 let slot_ack_policy = Arc::clone(&ack_policy);
307 let slot_authenticated = Arc::clone(&authenticated_flags[slot_idx]);
308 let handler_core = handler_cores[slot_idx];
309 let shutdown_flag = shutdown as *const AtomicBool as usize;
310 let ready_flag = replica_ready as *const AtomicBool as usize;
311 let connected_flag = replicas_connected as *const AtomicU32 as usize;
312 let handle = std::thread::Builder::new()
313 .name(format!("repl-{slot_idx}"))
314 .spawn(move || {
315 // Pin to a dedicated core if configured (> 0),
316 // otherwise clear inherited affinity from the
317 // accept thread so the OS can schedule freely.
318 //
319 // Both branches only work because the accept
320 // thread is left unpinned (see its spawn in
321 // `server.rs`). A child inherits its creator's
322 // affinity and scheduling policy at creation,
323 // and cannot fix that itself if the parent is
324 // pinned real-time and never yields — fixing
325 // itself requires running. Pin the accept
326 // thread and this handler may never start.
327 if handler_core > 0 {
328 match melin_app::affinity::pin_to_core(handler_core) {
329 Ok(c) => tracing::info!(
330 core = c,
331 slot = slot_idx,
332 "replication handler pinned to core"
333 ),
334 Err(e) => tracing::warn!(
335 error = e,
336 slot = slot_idx,
337 "failed to pin handler"
338 ),
339 }
340 } else if let Err(e) = melin_app::affinity::clear_affinity() {
341 tracing::warn!(error = e, "failed to clear handler affinity");
342 }
343 // Safety: shutdown and replica_ready outlive this thread
344 // (they're on the parent's stack, which blocks on join
345 // during shutdown).
346 let shutdown_ref = unsafe { &*(shutdown_flag as *const AtomicBool) };
347 let ready_ref = unsafe { &*(ready_flag as *const AtomicBool) };
348 // Safety: replicas_connected outlives this thread —
349 // same lifetime argument as shutdown/replica_ready
350 // above (the parent blocks on join during shutdown).
351 let connected_ref = unsafe { &*(connected_flag as *const AtomicU32) };
352 let ctx = SlotContext {
353 cursors: &slot_cursors,
354 journal_path: &jpath,
355 authorized_keys: &auth_keys,
356 shutdown: shutdown_ref,
357 replica_ready: ready_ref,
358 active_flag: &slot_active,
359 evict_flag: &slot_evict,
360 metrics: &slot_metrics,
361 fence_state: &slot_fence,
362 ack_policy: &slot_ack_policy,
363 slot_idx,
364 batch_size,
365 heartbeat_secs,
366 busy_spin,
367 replicas_connected: connected_ref,
368 authenticated: &slot_authenticated,
369 };
370 run_replica_slot::<A>(stream, consumer, &ctx)
371 })
372 .expect("spawn replica handler thread");
373 slots[slot_idx].handle = Some(handle);
374 }
375 Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
376 // No pending connection.
377 }
378 Err(e) => {
379 error!(error = %e, "replication accept error");
380 }
381 }
382 }
383
384 // No idle drain needed — the journal stage only publishes to
385 // rings where active_flag is true (set by handler on live loop
386 // entry, cleared on disconnect). Idle consumers stay empty.
387
388 // Supervisory cadence. Nothing on the data path runs in this loop
389 // — the ring consumers are moved into the handler threads — so the
390 // only cost is up to 50 ms to notice a new connection or a handler
391 // that has finished, both negligible against the journal catch-up
392 // that follows. In exchange the thread costs ~no CPU when idle,
393 // which is why it needs no core of its own.
394 std::thread::sleep(std::time::Duration::from_millis(50));
395 }
396}
397
398/// Per-slot state shared across the replica handler call chain
399/// (`run_replica_slot` → `handle_replica_connection` → `live_stream_uring`).
400struct SlotContext<'a> {
401 cursors: &'a Arc<ReplicaCursors>,
402 journal_path: &'a std::path::Path,
403 authorized_keys: &'a melin_app::auth::AuthorizedKeys,
404 shutdown: &'a AtomicBool,
405 replica_ready: &'a AtomicBool,
406 active_flag: &'a AtomicBool,
407 evict_flag: &'a AtomicBool,
408 metrics: &'a ReplicationMetrics,
409 fence_state: &'a melin_transport_core::fence::FenceState,
410 /// The primary's active ack policy — stamped on `StreamStart` and
411 /// `Heartbeat` (see `Sender::ack_policy`).
412 ack_policy: &'a std::sync::atomic::AtomicU8,
413 slot_idx: usize,
414 batch_size: usize,
415 heartbeat_secs: u64,
416 busy_spin: bool,
417 /// Trading-halt gate. The handler lifts it (`fetch_add`) only after auth
418 /// succeeds; the main loop lowers it on teardown, gated by `authenticated`.
419 replicas_connected: &'a AtomicU32,
420 /// Per-slot latch the handler sets true once this connection passes auth,
421 /// so the main loop knows whether this slot lifted the gate.
422 authenticated: &'a AtomicBool,
423}
424
425/// Handle a single replica connection on a dedicated thread.
426/// Returns the consumer when the connection ends (for slot reuse).
427fn run_replica_slot<A: Application>(
428 stream: TcpStream,
429 mut consumer: ReplicationConsumer,
430 ctx: &SlotContext<'_>,
431) -> ReplicationConsumer {
432 match handle_replica_connection::<A>(stream, &mut consumer, ctx) {
433 Ok(()) => info!("replica disconnected cleanly"),
434 Err(e) => warn!(error = %e, "replica connection error"),
435 }
436 consumer
437}
438
439fn handle_replica_connection<A: Application>(
440 stream: TcpStream,
441 repl_consumer: &mut ReplicationConsumer,
442 ctx: &SlotContext<'_>,
443) -> io::Result<()> {
444 let SlotContext {
445 cursors,
446 journal_path,
447 authorized_keys,
448 shutdown,
449 replica_ready,
450 active_flag,
451 evict_flag: _,
452 metrics,
453 fence_state,
454 ack_policy,
455 slot_idx,
456 batch_size: _,
457 heartbeat_secs,
458 busy_spin: _,
459 replicas_connected,
460 authenticated,
461 } = ctx;
462 let slot_idx = *slot_idx;
463 let heartbeat_secs = *heartbeat_secs;
464
465 let mut reader = stream.try_clone()?;
466 let mut writer = stream;
467
468 // Set a read timeout for the handshake and auth.
469 reader.set_read_timeout(Some(std::time::Duration::from_secs(10)))?;
470
471 // Authenticate before any data exchange. `reader` and `writer` are clones
472 // of the same socket; auth is sequential (write challenge, read response,
473 // write result) so a single handle suffices. Use `reader` — it carries the
474 // read timeout set above.
475 authenticate_replica(&mut reader, authorized_keys)?;
476 info!("replica authenticated");
477
478 // Auth passed — only now lift the trading-halt gate. The main loop lowers
479 // it on slot teardown, reading `authenticated` to know this connection
480 // lifted it. Set the latch after the increment so a post-join read of the
481 // latch implies the increment is visible too.
482 ReplicaGate::new(replicas_connected).lift();
483 authenticated.store(true, Ordering::Release);
484
485 // Read handshake.
486 let handshake_frame = read_frame(&mut reader, MAX_CONTROL_FRAME)?;
487 let handshake = match decode_replica_message(&handshake_frame)? {
488 ReplicaMessage::Handshake(h) => h,
489 ReplicaMessage::Ack(_) => {
490 return Err(io::Error::other("expected Handshake, got Ack"));
491 }
492 };
493
494 info!(
495 last_sequence = handshake.last_sequence,
496 replica_epoch = handshake.epoch,
497 "replica handshake received"
498 );
499
500 // Fence: a replica advertising an epoch higher than ours means a
501 // promotion happened that this node missed — we are a stale ex-primary.
502 // The policy (latch fenced + trigger shutdown) lives on `FenceState` so
503 // the kernel-TCP and DPDK senders cannot drift; the matching stage
504 // halts new client writes, the response gate stops acking, and the node
505 // winds down for the operator to restart it as a replica. Refuse this
506 // connection so we send no stale stream.
507 let our_epoch = fence_state.epoch();
508 if let Some(first_latch) = fence_state.fence_if_superseded(handshake.epoch, shutdown) {
509 if first_latch {
510 error!(
511 replica_epoch = handshake.epoch,
512 our_epoch,
513 "fenced: a replica advertises a higher epoch — this primary has been \
514 superseded; self-demoting and shutting down"
515 );
516 }
517 return Err(io::Error::other("fenced by higher-epoch replica"));
518 }
519
520 // Mark this slot as catching up. Cleared when entering the live loop.
521 metrics.catching_up[slot_idx].store(true, Ordering::Relaxed);
522
523 let mut send_buf = Vec::with_capacity(128);
524
525 // Chain validation: recompute our chain at the replica's claimed
526 // position and compare with the hash it reported. A mismatch (or a
527 // position beyond our tip) means its journal holds divergent
528 // history — streaming on top would silently fork the audit trail.
529 // Send HashMismatch (the replica archives its local journal on
530 // receipt) and route it through snapshot resync on this same
531 // connection.
532 let divergent = match validate_replica_handshake_settled(journal_path, &handshake)? {
533 HandshakeValidation::Ok => false,
534 HandshakeValidation::Divergent(kind) => {
535 // Alertable on /metrics: divergence outside an expected
536 // failover rejoin means corruption or a serious bug.
537 metrics.divergence_total.fetch_add(1, Ordering::Relaxed);
538 warn!(
539 last_sequence = handshake.last_sequence,
540 ?kind,
541 "replica journal divergent at handshake — routing through snapshot resync"
542 );
543 true
544 }
545 };
546
547 // Probe whether journal catch-up is possible before committing to
548 // a protocol path. This avoids sending StreamStart only to discover
549 // the journals are too old.
550 let can_catch_up =
551 !divergent && can_catch_up_from_journal(journal_path, handshake.last_sequence)?;
552
553 let mut publish = |buf: &[u8]| -> io::Result<()> {
554 writer.write_all(buf)?;
555 writer.flush()
556 };
557
558 // Cursor/stream floor for everything downstream. A divergent
559 // replica's claimed position is meaningless (its history forked —
560 // possibly ahead of our tip); the resync rebases it, so seed from 0
561 // like a fresh replica: the ack cursors understate until real acks
562 // arrive (safe), and the sent high-water seeds from the catch-up
563 // end instead of a bogus claim that would mark live ring chunks as
564 // already covered.
565 let stream_base = if divergent {
566 0
567 } else {
568 handshake.last_sequence
569 };
570
571 let catchup_end = if can_catch_up {
572 // The lineage origin (oldest segment's header identity) lets a
573 // fresh replica create a byte-identical journal before
574 // consuming the stream. Replicas with local state ignore it.
575 let (lineage_start, lineage_anchor) =
576 melin_transport_core::replication::catchup::lineage_origin(journal_path)?;
577 encode_stream_start(
578 handshake.last_sequence,
579 lineage_start,
580 lineage_anchor,
581 fence_state.epoch(),
582 ack_policy.load(Ordering::Relaxed),
583 &mut send_buf,
584 );
585 publish(&send_buf)?;
586 send_buf.clear();
587
588 match catch_up_from_journal::<A::Event>(
589 journal_path,
590 handshake.last_sequence,
591 &mut writer,
592 shutdown,
593 )? {
594 CatchUpResult::Ok(end) => end,
595 CatchUpResult::NeedSnapshot => {
596 return Err(io::Error::other("catch-up failed unexpectedly after probe"));
597 }
598 }
599 } else {
600 // Resync verdict precedes the snapshot data: `HashMismatch`
601 // tells the replica its journal is divergent (it archives its
602 // local lineage on receipt); plain `NeedSnapshot` is the
603 // too-far-behind rebase. The receiver expects `SnapshotBegin`
604 // as the very next frame after the verdict, so
605 // `snapshot_transfer_with` must follow immediately.
606 //
607 // Pre-flight before the verdict goes on the wire: the replica
608 // archives its lineage on receipt, so promising a snapshot we
609 // cannot produce (none configured, boundary segment pruned)
610 // would strand it with its history moved aside. Failing here
611 // drops the connection with the replica's journal intact.
612 preflight_snapshot_transfer(journal_path)?;
613 if divergent {
614 encode_hash_mismatch(&mut send_buf);
615 } else {
616 encode_need_snapshot(&mut send_buf);
617 }
618 publish(&send_buf)?;
619 send_buf.clear();
620 match snapshot_transfer_with::<A::Event>(
621 journal_path,
622 &mut publish,
623 shutdown,
624 ack_policy.load(Ordering::Relaxed),
625 )? {
626 CatchUpResult::Ok(end) => end,
627 CatchUpResult::NeedSnapshot => {
628 return Err(io::Error::other(
629 "catch-up failed even after snapshot transfer",
630 ));
631 }
632 }
633 };
634
635 // Engage this slot's cursors and seed the gauge pair the response
636 // gate's `evaluate_durability` reads. Must happen BEFORE the
637 // bridge's active_flag Release — see `ReplicaCursors` for the
638 // ordering contract.
639 cursors.seed_on_handshake(slot_idx, stream_base);
640
641 // Bridge into live streaming: activates the ring, re-reads from the
642 // journal the entries that fell into the activation window, then
643 // drains the ring into sequence-contiguity (back-filling from disk
644 // if a skipped entry hasn't flushed yet) before going live. Returns
645 // the slot's sent high-water — the bound the ack-sanity invariant
646 // checks acks against, advanced by every chunk the live loop
647 // forwards. The bridge closes the catch-up→live gap under load (the
648 // receiver's contiguity gate backstops only the rare quiescent
649 // corner). See `bridge_catchup_to_live`.
650 let mut sent = {
651 let mut publish = |buf: &[u8]| -> io::Result<()> {
652 writer.write_all(buf)?;
653 writer.flush()
654 };
655 bridge_catchup_to_live::<A::Event>(
656 journal_path,
657 stream_base,
658 catchup_end,
659 active_flag,
660 repl_consumer,
661 &mut publish,
662 shutdown,
663 )?
664 };
665
666 // Catch-up complete — replica is entering the live streaming loop.
667 metrics.catching_up[slot_idx].store(false, Ordering::Relaxed);
668
669 // Signal that this replica is ready to consume from the replication
670 // ring. The main thread waits on this before seeding test data.
671 // Must happen AFTER catch-up and overlap drain complete — otherwise
672 // seeding fills the replication ring faster than we can drain it,
673 // deadlocking the journal stage.
674 replica_ready.store(true, Ordering::Release);
675
676 let heartbeat_interval = std::time::Duration::from_secs(heartbeat_secs);
677 let mut last_send = std::time::Instant::now();
678
679 live_stream_uring(
680 writer,
681 repl_consumer,
682 ctx,
683 heartbeat_interval,
684 &mut send_buf,
685 &mut last_send,
686 &mut sent,
687 )
688}
689
690/// Quiescing owner of the live-stream ring: the ring itself, the RECV
691/// landing buffer the kernel writes ack bytes into, the SEND source
692/// buffer the kernel reads out of, and the number of operations the
693/// kernel still owes CQEs for.
694///
695/// Exists for its `Drop`. Returning from the loop — shutdown, eviction,
696/// an I/O error, or a panic's unwind — while a RECV or SEND is still in
697/// flight would release those buffers (and drop the ring) with the
698/// kernel holding pointers into them. Ring teardown on fd close is
699/// *asynchronous*: the kernel can complete a pending RECV after the
700/// allocation has been returned to the allocator, writing replica-ack
701/// bytes into freed heap. That corruption surfaced as malloc-metadata
702/// segfaults at sender-thread exit in the failover tests, and being a
703/// kernel-side store it is invisible to AddressSanitizer. The guard
704/// closes every exit path: wake pending operations by shutting the
705/// socket down, then reap CQEs until the kernel owes none, and only
706/// then let the buffers go.
707struct InflightUring<'a> {
708 ring: io_uring::IoUring,
709 /// RECV landing buffer for ack frames. `Vec` so the heap pointer
710 /// handed to the kernel stays stable however the guard itself
711 /// moves; sized 4 KiB (acks are 13 bytes, the kernel may coalesce
712 /// many).
713 recv_buf: Vec<u8>,
714 /// SEND source buffer, borrowed from `handle_replica_connection`
715 /// (it outlives the live-stream loop so it can be reused across
716 /// reconnects). Held by the guard purely so the drain covers it:
717 /// the kernel *reads* it for an in-flight SEND, so releasing it
718 /// early would splice freed heap into the replication stream.
719 send_buf: &'a mut Vec<u8>,
720 /// SQEs pushed minus CQEs reaped. Between a push and the following
721 /// `submit` the kernel does not yet owe those CQEs, so this is an
722 /// upper bound rather than an exact debt; [`Self::drain`] submits
723 /// before waiting, which makes the two converge. `u32` matches the
724 /// ring-depth scale (at most a RECV and a SEND are ever
725 /// outstanding here).
726 in_flight: u32,
727 /// Raw socket fd, for the `shutdown(2)` wake in `Drop`. Always
728 /// outlives the guard: the owning `TcpStream` is a parameter of
729 /// [`live_stream_uring`] and parameters drop after locals.
730 tcp_fd: std::os::unix::io::RawFd,
731}
732
733impl Drop for InflightUring<'_> {
734 fn drop(&mut self) {
735 wake_pending_ops(self.tcp_fd);
736 if !self.drain() {
737 // The drain could not be proven complete, so the kernel may
738 // still write into `recv_buf` or read out of `send_buf`.
739 // Leak both: a few KiB lost on a dying connection is safe,
740 // returning memory the kernel still touches to the
741 // allocator is not. `take` leaves each `Vec` empty, so the
742 // caller's `send_buf` stays a valid (if reallocating) Vec.
743 std::mem::forget(std::mem::take(&mut self.recv_buf));
744 std::mem::forget(std::mem::take(self.send_buf));
745 warn!(
746 in_flight = self.in_flight,
747 "io_uring teardown drain did not complete; leaking replication buffers"
748 );
749 }
750 }
751}
752
753impl InflightUring<'_> {
754 /// Reap CQEs until the kernel owes none, bounded by
755 /// [`crate::uring_teardown::DRAIN_TIMEOUT`]. `true` means
756 /// `in_flight` reached zero and the
757 /// buffers are provably free of kernel references; `false` means
758 /// the caller must not release them. See [`crate::uring_teardown`]
759 /// for why this polls rather than blocking in `submit_and_wait`.
760 fn drain(&mut self) -> bool {
761 // Flush SQEs that were pushed but never submitted: until they
762 // reach the kernel, no CQE is ever coming for them.
763 loop {
764 match self.ring.submit() {
765 Ok(_) => break,
766 Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
767 Err(_) => return false,
768 }
769 }
770 let mut backoff = DrainBackoff::new();
771 while self.in_flight > 0 {
772 for _ in self.ring.completion() {
773 self.in_flight = self.in_flight.saturating_sub(1);
774 }
775 if self.in_flight > 0 && !backoff.wait() {
776 return false;
777 }
778 }
779 true
780 }
781}
782
783/// Live streaming loop using async RECV/SEND via io_uring. A single RECV is always
784/// in-flight for ack frames; SEND is submitted when the replication ring
785/// has data. Both complete via the memory-mapped CQ with zero syscalls
786/// in the hot path. In-flight operations and both kernel-visible
787/// buffers are owned by an [`InflightUring`] guard, so no exit path can
788/// release memory the kernel is still reading or writing.
789fn live_stream_uring(
790 writer: TcpStream,
791 repl_consumer: &mut ReplicationConsumer,
792 ctx: &SlotContext<'_>,
793 heartbeat_interval: std::time::Duration,
794 send_buf: &mut Vec<u8>,
795 last_send: &mut std::time::Instant,
796 sent: &mut SentHighWater,
797) -> io::Result<()> {
798 let SlotContext {
799 cursors,
800 shutdown,
801 evict_flag,
802 metrics,
803 slot_idx,
804 batch_size,
805 busy_spin,
806 ack_policy,
807 // Only used during handshake/catch-up (handle_replica_connection).
808 journal_path: _,
809 authorized_keys: _,
810 replica_ready: _,
811 active_flag: _,
812 heartbeat_secs: _,
813 fence_state: _,
814 replicas_connected: _,
815 authenticated: _,
816 } = ctx;
817 let slot_idx = *slot_idx;
818 let batch_size = *batch_size;
819 let busy_spin = *busy_spin;
820
821 use io_uring::{IoUring, opcode, types};
822 use std::os::unix::io::AsRawFd;
823
824 const TOKEN_RECV: u64 = 0;
825 const TOKEN_SEND: u64 = 1;
826
827 let tcp_fd = writer.as_raw_fd();
828
829 let ring: IoUring = IoUring::builder()
830 .setup_single_issuer()
831 .build(8)
832 .map_err(|e| io::Error::other(format!("io_uring init failed: {e}")))?;
833
834 ring.submitter()
835 .register_files(&[tcp_fd])
836 .map_err(|e| io::Error::other(format!("io_uring register_files: {e}")))?;
837
838 // Pin io-wq workers to core 0 (keep them off pipeline cores).
839 {
840 let mut cpuset: libc::cpu_set_t = unsafe { std::mem::zeroed() };
841 unsafe { libc::CPU_SET(0, &mut cpuset) };
842 let _ = ring.submitter().register_iowq_aff(&cpuset);
843 }
844
845 // Ring, both kernel-visible buffers, and the in-flight count live
846 // in the quiescing guard from here on: every exit below (shutdown,
847 // eviction, `?`, unwind) must drain the kernel before any of these
848 // allocations can be released. The loop keeps using them through
849 // disjoint field borrows.
850 let mut uring = InflightUring {
851 ring,
852 recv_buf: vec![0u8; 4096],
853 send_buf,
854 in_flight: 0,
855 tcp_fd,
856 };
857 let (ring, recv_buf, send_buf) = (&mut uring.ring, &mut uring.recv_buf, &mut *uring.send_buf);
858 // Accumulation buffer for partial ack frame parsing.
859 let mut parse_buf: Vec<u8> = Vec::with_capacity(MAX_CONTROL_FRAME + 4);
860 // RECV is always resubmitted after CQE processing — no explicit
861 // tracking needed. The io_uring kernel guarantees ordering.
862 let mut send_in_flight = false;
863 let mut send_offset: usize = 0;
864 let mut idle_spins: u32 = 0;
865 let mut heartbeat_timer = melin_app::amortized_timer::AmortizedTimer::new();
866
867 // Diagnostic (RUST_LOG=debug): per-slot TCP_INFO snapshot once a
868 // second, slow-SEND detection (CQE elapsed >= threshold), and a
869 // TCP_INFO capture at the evict-exit point. Amortized so the
870 // per-iteration cost is a single `AND` + predictable branch.
871 let mut info_log_timer = melin_app::amortized_timer::AmortizedTimer::new();
872 let mut send_submit_ts: Option<std::time::Instant> = None;
873 const SLOW_SEND_THRESHOLD_MS: u128 = 5;
874
875 // Submit initial RECV.
876 let sqe = opcode::Recv::new(
877 types::Fixed(0),
878 recv_buf.as_mut_ptr(),
879 recv_buf.len() as u32,
880 )
881 .build()
882 .user_data(TOKEN_RECV);
883 // SAFETY: `recv_buf` is owned by the `InflightUring` guard, which
884 // drains this operation's CQE before the buffer can be freed on any
885 // exit path. The ring is single-threaded — only this task
886 // pushes/reaps on it.
887 unsafe { ring.submission().push(&sqe).expect("SQ full") };
888 uring.in_flight += 1;
889
890 loop {
891 // --- Check flags ---
892 if shutdown.load(Ordering::Relaxed) {
893 return Ok(());
894 }
895 if evict_flag.load(Ordering::Relaxed) {
896 // Capture the TCP state at the moment of eviction — the
897 // critical frame for comparing an evicted slot's teardown
898 // against the still-live slot's socket state when
899 // diagnosing post-eviction regressions.
900 super::log_tcp_info(tcp_fd, "evict_exit", slot_idx);
901 info!(slot = slot_idx, "handler exiting: evicted by journal stage");
902 return Ok(());
903 }
904
905 // --- Drain replication ring into send_buf (memory, non-blocking) ---
906 //
907 // Ring chunks are wire-ready `InputBatch` frames produced by the
908 // journal stage (phase 3 of the unified-pipeline plan), so the
909 // sender is a passthrough — no decode + re-encode here.
910 if !send_in_flight {
911 let mut coalesced = 0;
912 while coalesced < batch_size {
913 if let Some((meta, data)) = repl_consumer.try_read() {
914 send_buf.extend_from_slice(data);
915 repl_consumer.commit();
916 sent.advance(meta.end_sequence);
917 coalesced += 1;
918 } else {
919 break;
920 }
921 }
922
923 if coalesced > 0 {
924 // Submit SEND for the coalesced buffer.
925 let sqe =
926 opcode::Send::new(types::Fixed(0), send_buf.as_ptr(), send_buf.len() as u32)
927 .build()
928 .user_data(TOKEN_SEND);
929 // SAFETY: `send_buf` is owned by this task and pinned
930 // by `send_in_flight = true` below — it isn't mutated
931 // until the matching TOKEN_SEND CQE clears the flag,
932 // and the `InflightUring` guard drains the CQE before
933 // any exit path can release it. The ring is
934 // single-threaded.
935 unsafe { ring.submission().push(&sqe).expect("SQ full") };
936 uring.in_flight += 1;
937 send_in_flight = true;
938 send_offset = 0;
939 *last_send = std::time::Instant::now();
940 send_submit_ts = Some(*last_send);
941 idle_spins = 0;
942 heartbeat_timer = melin_app::amortized_timer::AmortizedTimer::new();
943 } else {
944 // Heartbeat check: amortized when spinning (mask keeps the
945 // clock read at ~10/s at 10M iter/s). In yield mode the loop
946 // already pays a syscall per iteration, so the clock read is
947 // free and must not be skipped — see AmortizedTimer docs.
948 let spinning = busy_spin || idle_spins < 1000;
949 if heartbeat_timer.tick(heartbeat_interval, spinning).is_some() {
950 encode_heartbeat(sent.get(), ack_policy.load(Ordering::Relaxed), send_buf);
951 let sqe = opcode::Send::new(
952 types::Fixed(0),
953 send_buf.as_ptr(),
954 send_buf.len() as u32,
955 )
956 .build()
957 .user_data(TOKEN_SEND);
958 // SAFETY: same as the coalesced send above —
959 // `send_buf` is owned and pinned by `send_in_flight`
960 // until the TOKEN_SEND CQE clears it, and the guard
961 // drains the CQE on every exit path.
962 unsafe { ring.submission().push(&sqe).expect("SQ full") };
963 uring.in_flight += 1;
964 send_in_flight = true;
965 send_offset = 0;
966 *last_send = std::time::Instant::now();
967 send_submit_ts = Some(*last_send);
968 }
969 }
970 }
971
972 // Periodic TCP_INFO dump — debug level. Amortized so the
973 // per-iteration cost is a single `AND` + predictable branch.
974 if info_log_timer
975 .tick(
976 std::time::Duration::from_secs(1),
977 busy_spin || idle_spins < 1000,
978 )
979 .is_some()
980 {
981 super::log_tcp_info(tcp_fd, "live_stream", slot_idx);
982 }
983
984 // --- Submit SQEs to kernel (non-blocking) ---
985 // Skip the syscall when no new SQEs were pushed — an empty
986 // io_uring_enter still costs ~200 ns of mode-switch overhead and
987 // showed up as 6 % of total CPU in profiles of the sender loop.
988 let pending = ring.submission().len();
989 if pending > 0 {
990 ring.submit()
991 .map_err(|e| io::Error::other(format!("io_uring submit: {e}")))?;
992 }
993
994 // --- Collect CQEs (must drain before pushing new SQEs) ---
995 // Collecting into a small stack array avoids the CQ borrow
996 // conflicting with SQ pushes during processing.
997 let mut cqes: [(u64, i32); 4] = [(0, 0); 4];
998 let mut cqe_count = 0;
999 for cqe in ring.completion() {
1000 // Every reaped CQE settles one operation the kernel owed —
1001 // counted even if it overflows the recording array (it
1002 // cannot: at most a RECV and a SEND are ever in flight).
1003 // `saturating_sub` keeps a miscount from wrapping into a
1004 // drain that never terminates; the assert catches the
1005 // miscount itself rather than letting it hide in release.
1006 debug_assert!(
1007 uring.in_flight > 0,
1008 "CQE reaped with no operation recorded in flight"
1009 );
1010 uring.in_flight = uring.in_flight.saturating_sub(1);
1011 if cqe_count < cqes.len() {
1012 cqes[cqe_count] = (cqe.user_data(), cqe.result());
1013 cqe_count += 1;
1014 }
1015 }
1016
1017 let any_cqe = cqe_count > 0;
1018 for &(token, result) in &cqes[..cqe_count] {
1019 idle_spins = 0;
1020 match token {
1021 TOKEN_RECV => {
1022 if result <= 0 {
1023 return Err(io::Error::other(format!(
1024 "replica disconnected (recv returned {result})"
1025 )));
1026 }
1027 let n = result as usize;
1028 parse_buf.extend_from_slice(&recv_buf[..n]);
1029
1030 // Extract complete ack frames from parse_buf.
1031 let mut cursor = 0;
1032 while cursor + 4 <= parse_buf.len() {
1033 let frame_len = u32::from_le_bytes(
1034 parse_buf[cursor..cursor + 4]
1035 .try_into()
1036 .expect("bounds checked: 4-byte slice"),
1037 ) as usize;
1038 if frame_len == 0 || frame_len > MAX_CONTROL_FRAME {
1039 return Err(io::Error::other(format!(
1040 "invalid ack frame length: {frame_len}"
1041 )));
1042 }
1043 if cursor + 4 + frame_len > parse_buf.len() {
1044 break; // Incomplete frame.
1045 }
1046 let payload = &parse_buf[cursor + 4..cursor + 4 + frame_len];
1047 if let Ok(ReplicaMessage::Ack(ack)) = decode_replica_message(payload) {
1048 // Eviction on violation: returning Err tears the
1049 // connection down; the accept loop's cleanup
1050 // disengages the cursors and frees the slot.
1051 cursors
1052 .record_ack(slot_idx, &ack, sent.get())
1053 .map_err(io::Error::other)?;
1054 metrics.ack_latency_us[slot_idx]
1055 .store(last_send.elapsed().as_micros() as u64, Ordering::Relaxed);
1056 }
1057 cursor += 4 + frame_len;
1058 }
1059 // Compact parse_buf.
1060 if cursor > 0 {
1061 let remaining = parse_buf.len() - cursor;
1062 parse_buf.copy_within(cursor.., 0);
1063 parse_buf.truncate(remaining);
1064 }
1065
1066 // Resubmit RECV.
1067 let sqe = opcode::Recv::new(
1068 types::Fixed(0),
1069 recv_buf.as_mut_ptr(),
1070 recv_buf.len() as u32,
1071 )
1072 .build()
1073 .user_data(TOKEN_RECV);
1074 // SAFETY: `recv_buf` is owned by the guard; only
1075 // one RECV is ever in flight (we just consumed the
1076 // previous CQE), and the guard drains this one on
1077 // every exit path. Ring is single-threaded.
1078 unsafe { ring.submission().push(&sqe).expect("SQ full") };
1079 uring.in_flight += 1;
1080 }
1081
1082 TOKEN_SEND => {
1083 if result < 0 {
1084 return Err(io::Error::other(format!("send error (returned {result})")));
1085 }
1086 let sent = result as usize;
1087 send_offset += sent;
1088 if send_offset >= send_buf.len() {
1089 // Fully sent. Measure end-to-end SEND latency: a
1090 // healthy io_uring TCP SEND completes in
1091 // microseconds; > threshold implies the kernel
1092 // waited on cwnd / peer window / retransmit.
1093 if let Some(ts) = send_submit_ts.take() {
1094 let elapsed = ts.elapsed();
1095 if elapsed.as_millis() >= SLOW_SEND_THRESHOLD_MS {
1096 tracing::debug!(
1097 slot = slot_idx,
1098 elapsed_us = elapsed.as_micros() as u64,
1099 bytes = send_buf.len(),
1100 "slow SEND completion"
1101 );
1102 super::log_tcp_info(tcp_fd, "slow_send", slot_idx);
1103 }
1104 }
1105 metrics.bytes_sent[slot_idx]
1106 .fetch_add(send_buf.len() as u64, Ordering::Relaxed);
1107 send_buf.clear();
1108 send_offset = 0;
1109 send_in_flight = false;
1110 } else {
1111 // Partial send — resubmit remainder.
1112 let sqe = opcode::Send::new(
1113 types::Fixed(0),
1114 send_buf[send_offset..].as_ptr(),
1115 (send_buf.len() - send_offset) as u32,
1116 )
1117 .build()
1118 .user_data(TOKEN_SEND);
1119 // SAFETY: `send_buf` is owned and still pinned
1120 // (`send_in_flight` stays true across partial
1121 // sends), and the guard drains the CQE on every
1122 // exit path; ring is single-threaded.
1123 unsafe { ring.submission().push(&sqe).expect("SQ full") };
1124 uring.in_flight += 1;
1125 }
1126 }
1127
1128 _ => {}
1129 }
1130 }
1131
1132 // --- Idle wait ---
1133 if !any_cqe && send_buf.is_empty() {
1134 if busy_spin || idle_spins < 1000 {
1135 idle_spins = idle_spins.wrapping_add(1);
1136 std::hint::spin_loop();
1137 } else {
1138 std::thread::yield_now();
1139 }
1140 }
1141 }
1142}
1143
1144#[cfg(test)]
1145mod tests {
1146 use super::*;
1147 use crate::uring_teardown::DRAIN_TIMEOUT;
1148 use io_uring::{IoUring, opcode, types};
1149
1150 /// Connected AF_UNIX stream pair standing in for the replication
1151 /// socket. Raw fds rather than `UnixStream` because the guard takes
1152 /// a `RawFd` and the test needs to outlive it.
1153 fn socketpair() -> (libc::c_int, libc::c_int) {
1154 let mut fds = [0 as libc::c_int; 2];
1155 // SAFETY: `fds` is a valid two-element array for the kernel to
1156 // fill in; the fds are owned by the caller from here.
1157 let rc = unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, fds.as_mut_ptr()) };
1158 assert_eq!(rc, 0, "socketpair: {}", io::Error::last_os_error());
1159 (fds[0], fds[1])
1160 }
1161
1162 /// SAFETY: both fds are owned by the caller and not used again.
1163 fn close_pair(a: libc::c_int, b: libc::c_int) {
1164 // Return value dropped deliberately: nothing can be done about
1165 // a failing close on a test teardown path.
1166 unsafe {
1167 libc::close(a);
1168 libc::close(b);
1169 }
1170 }
1171
1172 /// Guard over `fd` with a RECV already submitted.
1173 ///
1174 /// Panics rather than skipping if the host denies io_uring:
1175 /// [`live_stream_uring`] is the only live-stream path there is, so
1176 /// a host that cannot build this ring cannot replicate at all. A
1177 /// test that quietly passed there would report coverage for a
1178 /// teardown it never ran.
1179 fn guard_with_pending_recv(fd: libc::c_int, send_buf: &mut Vec<u8>) -> InflightUring<'_> {
1180 let ring = IoUring::builder()
1181 .setup_single_issuer()
1182 .build(8)
1183 .expect("io_uring is a hard requirement for replication");
1184 ring.submitter()
1185 .register_files(&[fd])
1186 .expect("register_files");
1187 let mut uring = InflightUring {
1188 ring,
1189 recv_buf: vec![0u8; 4096],
1190 send_buf,
1191 in_flight: 0,
1192 tcp_fd: fd,
1193 };
1194 let sqe = opcode::Recv::new(
1195 types::Fixed(0),
1196 uring.recv_buf.as_mut_ptr(),
1197 uring.recv_buf.len() as u32,
1198 )
1199 .build()
1200 .user_data(0);
1201 // SAFETY: `recv_buf` belongs to the guard being built here, and
1202 // the guard drains this operation before releasing it. The ring
1203 // is only ever touched from this thread.
1204 unsafe { uring.ring.submission().push(&sqe).expect("SQ full") };
1205 uring.in_flight = 1;
1206 uring.ring.submit().expect("submit initial RECV");
1207 uring
1208 }
1209
1210 /// The assumption the whole teardown design rests on: a
1211 /// `shutdown(2)` — not an AsyncCancel, which some hosts filter —
1212 /// is enough to make the kernel hand back a RECV that nobody is
1213 /// ever going to write to.
1214 #[test]
1215 fn drain_reaps_a_pending_recv_after_shutdown() {
1216 let (a, b) = socketpair();
1217 let mut send_buf = Vec::new();
1218 let mut uring = guard_with_pending_recv(a, &mut send_buf);
1219 // The peer never writes, so the only thing that can complete
1220 // this RECV is the shutdown.
1221 unsafe { libc::shutdown(a, libc::SHUT_RDWR) };
1222 assert!(
1223 uring.drain(),
1224 "drain hit the deadline with a RECV in flight"
1225 );
1226 assert_eq!(uring.in_flight, 0, "kernel still owes a CQE");
1227 drop(uring);
1228 close_pair(a, b);
1229 }
1230
1231 /// `Drop` must quiesce the ring on its own — including issuing the
1232 /// wake — and must return promptly rather than burning the whole
1233 /// [`DRAIN_TIMEOUT`], which is what a teardown that silently failed
1234 /// to wake the operation would look like.
1235 #[test]
1236 fn drop_quiesces_a_pending_recv_without_hanging() {
1237 let (a, b) = socketpair();
1238 let mut send_buf = vec![0u8; 64];
1239 let uring = guard_with_pending_recv(a, &mut send_buf);
1240 let started = std::time::Instant::now();
1241 drop(uring);
1242 let elapsed = started.elapsed();
1243 assert!(
1244 elapsed < DRAIN_TIMEOUT / 4,
1245 "teardown took {elapsed:?}, close to the {DRAIN_TIMEOUT:?} deadline"
1246 );
1247 // A clean drain releases the buffers; only a drain that cannot
1248 // prove the kernel is done leaks them.
1249 assert_eq!(send_buf.len(), 64, "send_buf leaked despite a clean drain");
1250 close_pair(a, b);
1251 }
1252}