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