melin_server_runtime/reader.rs
1//! io_uring-based multiplexed reader with multishot RECV.
2//!
3//! Uses `IORING_OP_RECV` with `IORING_RECV_MULTISHOT` — a single SQE per
4//! connection produces multiple CQEs as data arrives, eliminating the
5//! resubmission overhead of standard RECV. Combined with a ring-mapped
6//! provided-buffer ring (`IOSQE_BUFFER_SELECT` + `buf_ring`), the kernel
7//! selects a buffer from a shared pool for each recv, and consumed
8//! buffers are recycled with a shared-memory store — no per-recv
9//! ProvideBuffers SQE/CQE round trip. Requires kernel ≥ 5.19.
10//!
11//! Uses a single reader thread — io_uring is efficient enough for hundreds
12//! of connections. New connections are registered via eventfd wakeup.
13//!
14//! Connection state is stored in a slab (index-stable Vec) so that io_uring
15//! user_data carries a slab index, not an fd. This avoids fd-reuse races
16//! where a recycled fd number could match a stale CQE.
17
18use std::collections::HashMap;
19use std::net::SocketAddr;
20use std::os::unix::io::{AsRawFd, FromRawFd, OwnedFd, RawFd};
21use std::sync::Arc;
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::sync::mpsc;
24use std::thread::JoinHandle;
25use std::time::{Duration, Instant};
26
27use io_uring::{IoUring, opcode, types};
28use tracing::{debug, error};
29
30use crate::ControlEvent;
31use crate::buf_ring::BufRing;
32use crate::halt::{HaltGate, RefusalSender};
33use melin_app::Application;
34use melin_app::auth::Permission;
35use melin_app::decoder::RequestDecoder;
36
37/// Decoder type alias: request decoder bound to the application's `Event`
38/// type. Mirrors [`crate::response::ResponseEncoderArc`]; hides
39/// the `dyn RequestDecoder<Event = …>` spelling at call sites that thread
40/// the decoder through several functions.
41pub type RequestDecoderArc<A> = Arc<dyn RequestDecoder<Event = <A as Application>::Event>>;
42use melin_app::unix_epoch_nanos;
43use melin_pipeline::ring;
44use melin_transport_core::pipeline::InputSlot;
45
46/// Size of each provided buffer. 4 KiB accommodates multiple frames per
47/// recv (frames are typically <100 bytes).
48const BUF_SIZE: usize = 4096;
49
50/// Number of provided buffers in the shared pool. Must be a power of two
51/// (buf_ring ABI) and large enough for concurrent in-flight recvs across
52/// all connections. On exhaustion the kernel completes the multishot
53/// with `ENOBUFS` (no data consumed) and the loop re-arms it after the
54/// drain's recycles refill the ring. 2048 supports up to ~1024
55/// connections per reader thread; recycling is now a shared-memory store
56/// (no SQE), so raising this no longer interacts with `RING_SIZE`.
57const NUM_BUFFERS: u16 = 2048;
58
59/// Buffer group ID for the provided recv buffer pool.
60const BUF_GROUP_ID: u16 = 0;
61
62use crate::client_frames::MAX_FRAME_SIZE;
63
64/// io_uring submission queue depth. Power of 2, sized for up to ~1024
65/// connections per reader thread (multishot RECVs + eventfd read; buffer
66/// recycling goes through the buf_ring, not the SQ).
67const RING_SIZE: u32 = 4096;
68
69/// User data sentinel for the eventfd read SQE.
70const EVENTFD_TOKEN: u64 = u64::MAX;
71
72/// User data sentinel for legacy ProvideBuffers CQEs (fallback recycle
73/// path only). Best-effort re-provisions: we log errors but don't act
74/// on success.
75const PROVIDE_BUFS_TOKEN: u64 = u64::MAX - 1;
76
77/// User data sentinel for AsyncCancel SQEs (connection teardown and the
78/// shutdown quiesce). The completion carries no actionable information:
79/// `ENOENT`/`EALREADY` just mean the target op already finished.
80const CANCEL_TOKEN: u64 = u64::MAX - 3;
81
82/// User data sentinel for the tick timeout SQE. The reader arms a single
83/// `IORING_OP_TIMEOUT` per cadence so `submit_and_wait` returns at the tick
84/// deadline even when no client traffic is flowing. The CQE itself carries
85/// no information; the loop body checks `Instant::now()` against the next
86/// deadline and emits the actual `JournalEvent::Tick`.
87const TICK_TIMEOUT_TOKEN: u64 = u64::MAX - 2;
88
89/// CQE flag: buffer ID is valid in upper 16 bits of flags.
90const IORING_CQE_F_BUFFER: u32 = 1 << 0;
91
92/// CQE flag: more completions coming from this multishot operation.
93const IORING_CQE_F_MORE: u32 = 1 << 1;
94
95/// Bit shift to extract buffer ID from CQE flags.
96const IORING_CQE_BUFFER_SHIFT: u32 = 16;
97
98use melin_wire_protocol::control::ConnectionId;
99
100/// Command sent from the accept loop to a reader thread.
101pub struct ReaderRegistration<R> {
102 pub connection_id: ConnectionId,
103 pub reader: R,
104 pub addr: SocketAddr,
105 /// Permission level established during the auth handshake.
106 pub permission: Permission,
107 /// FxHash of the client's Ed25519 public key. Stored per-connection
108 /// and copied into every InputSlot for per-key idempotency dedup.
109 pub key_hash: u64,
110}
111
112/// Handle for the accept loop to register connections with the io_uring reader.
113pub struct UringReaderHandle<R> {
114 tx: mpsc::Sender<ReaderRegistration<R>>,
115 /// Wakeup eventfd, shared with the reader thread.
116 ///
117 /// `Arc<OwnedFd>` rather than a `RawFd` so the descriptor cannot be
118 /// closed while the other side still holds it: the close happens in
119 /// `OwnedFd`'s `Drop`, which runs when the last holder goes away, and
120 /// no code here is able to call `close` early because none of it owns
121 /// a raw descriptor to close.
122 ///
123 /// The reader thread used to close this itself while this handle was
124 /// still writing wakeups to it — a use-after-close whose real danger
125 /// was descriptor reuse, not `EBADF`: once the number is free, a
126 /// journal segment or an accepted connection can be assigned it, and
127 /// the next wakeup writes eight bytes into that instead. Found by
128 /// ThreadSanitizer.
129 event_fd: Arc<OwnedFd>,
130 join_handle: Option<JoinHandle<()>>,
131 shutdown: Arc<AtomicBool>,
132}
133
134impl<R> UringReaderHandle<R> {
135 /// Register a new connection with the reader thread.
136 ///
137 /// If the reader thread's channel is dead (thread panicked), logs an
138 /// error and signals shutdown so the server can restart cleanly.
139 pub fn register(&mut self, registration: ReaderRegistration<R>) {
140 if self.tx.send(registration).is_ok() {
141 // Signal the eventfd to wake the reader from io_uring_enter.
142 let val: u64 = 1;
143 unsafe {
144 libc::write(
145 self.event_fd.as_raw_fd(),
146 &val as *const u64 as *const libc::c_void,
147 8,
148 );
149 }
150 } else {
151 error!("reader thread dead, cannot register connection");
152 self.shutdown.store(true, Ordering::Relaxed);
153 }
154 }
155
156 /// Signal the reader thread to shut down and wake it from io_uring_enter.
157 pub fn shutdown(&self) {
158 self.shutdown.store(true, Ordering::Relaxed);
159 let val: u64 = 1;
160 unsafe {
161 libc::write(
162 self.event_fd.as_raw_fd(),
163 &val as *const u64 as *const libc::c_void,
164 8,
165 );
166 }
167 }
168
169 /// Join the reader thread. Call after `shutdown()`.
170 pub fn join(mut self) {
171 if let Some(handle) = self.join_handle.take()
172 && let Err(panic) = handle.join()
173 {
174 let msg = panic
175 .downcast_ref::<&str>()
176 .copied()
177 .or_else(|| panic.downcast_ref::<String>().map(|s| s.as_str()))
178 .unwrap_or("<non-string panic>");
179 error!(message = msg, "reader thread panicked");
180 }
181 }
182}
183
184/// Spawn the io_uring reader thread. Returns a handle for registering
185/// connections.
186///
187/// One reader thread serves every TCP connection on the server. io_uring
188/// with multishot RECV multiplexes thousands of sockets efficiently and the
189/// matching stage is the throughput limit, so adding more reader threads
190/// would not raise throughput — it would only re-introduce contention on
191/// the input ring's multi-producer cursor.
192///
193/// `tick_cadence: Some(d)` makes the reader the engine's tick generator: it
194/// arms an `IORING_OP_TIMEOUT` so `submit_and_wait` returns at the tick
195/// deadline even when no client traffic is flowing, then publishes a
196/// `JournalEvent::Tick { now_ns }` onto the same input ring it uses for
197/// client requests. Pass `None` to disable the tick (useful for benchmarks
198/// that don't exercise time-driven features).
199///
200/// While `halt` refuses writes, the reader answers them through
201/// `refusals` instead of publishing them — see [`crate::halt`].
202#[allow(clippy::too_many_arguments)]
203pub fn spawn_reader<A: Application, R: AsRawFd + Send + 'static>(
204 producer: ring::Producer<InputSlot<A::Event>>,
205 decoder: Arc<dyn RequestDecoder<Event = A::Event>>,
206 halt: HaltGate,
207 refusals: RefusalSender<A::Report>,
208 control_tx: mpsc::Sender<ControlEvent>,
209 core: usize,
210 connection_timeout: Option<Duration>,
211 tick_cadence: Option<Duration>,
212 shutdown: Arc<AtomicBool>,
213) -> UringReaderHandle<R>
214where
215 A::Event: Send + Sync + 'static,
216 A::Report: Send + 'static,
217{
218 let (tx, rx) = mpsc::channel();
219
220 let raw_event_fd = unsafe { libc::eventfd(0, libc::EFD_NONBLOCK) };
221 assert!(raw_event_fd >= 0, "eventfd creation failed");
222
223 // SAFETY: `eventfd` returned a fresh descriptor (asserted non-negative
224 // above) that nothing else owns, so transferring ownership to `OwnedFd`
225 // is sound. From here the raw number is never closed by hand — the
226 // descriptor lives exactly as long as the last `Arc` holder.
227 let event_fd = Arc::new(unsafe { OwnedFd::from_raw_fd(raw_event_fd) });
228 let wakeup_fd = Arc::clone(&event_fd);
229 let shutdown_clone = Arc::clone(&shutdown);
230
231 let handle = std::thread::Builder::new()
232 .name("uring-reader".into())
233 .spawn(move || {
234 // `core == 0` is the unpinned sentinel, which puts the thread
235 // on the process's home CPU mask — see the `melin_app::affinity`
236 // module docs.
237 melin_app::affinity::pin_thread("uring-reader", core);
238 reader_loop::<A, R>(
239 rx,
240 wakeup_fd,
241 producer,
242 &*decoder,
243 &halt,
244 refusals,
245 &control_tx,
246 connection_timeout,
247 tick_cadence,
248 &shutdown_clone,
249 );
250 })
251 .expect("failed to spawn uring reader thread");
252
253 UringReaderHandle {
254 tx,
255 event_fd,
256 join_handle: Some(handle),
257 shutdown,
258 }
259}
260
261// ---------------------------------------------------------------------------
262// Slab-based connection storage
263// ---------------------------------------------------------------------------
264
265/// Per-connection state for multishot io_uring recv + incremental frame parsing.
266struct ConnectionEntry<R> {
267 connection_id: u64,
268 addr: SocketAddr,
269 /// Permission level from auth handshake. Checked per-request on
270 /// the reader thread (cold path), zero cost on the matching engine.
271 permission: Permission,
272 /// FxHash of the client's Ed25519 public key. Copied into every
273 /// InputSlot for per-key idempotency dedup.
274 key_hash: u64,
275 /// Owned reader — keeps the fd alive. Dropping closes the fd.
276 _reader: R,
277 fd: RawFd,
278 /// Accumulated bytes not yet parsed into complete frames.
279 /// Grows when partial frames arrive, shrinks when frames are consumed.
280 parse_buf: Vec<u8>,
281 /// True if a multishot RecvMulti is currently active for this connection.
282 /// Multishot stays active until the kernel clears IORING_CQE_F_MORE
283 /// (e.g., buffer pool exhaustion, socket error, or EOF).
284 multishot_active: bool,
285 /// Last time any data was received on this connection. Used for
286 /// idle timeout detection.
287 last_activity: Instant,
288 /// Teardown has begun (malformed frame or idle timeout) while the
289 /// multishot RECV was still armed. The slab index must stay
290 /// allocated until the armed op's terminal CQE arrives — freeing it
291 /// early lets the LIFO free list hand the index to a new
292 /// registration while the kernel can still post CQEs carrying it,
293 /// and the old peer's bytes would be parsed under the new
294 /// connection's identity and permissions (audit review F1). CQEs
295 /// for a dying entry recycle their buffers and are otherwise
296 /// discarded.
297 dying: bool,
298}
299
300/// Index-stable allocator for connection state. Slab indices are used as
301/// io_uring user_data, avoiding fd-reuse races.
302struct ConnectionSlab<R> {
303 entries: Vec<Option<ConnectionEntry<R>>>,
304 /// Recycled indices for O(1) allocation.
305 free: Vec<usize>,
306}
307
308impl<R> ConnectionSlab<R> {
309 fn new() -> Self {
310 Self {
311 entries: Vec::with_capacity(256),
312 free: Vec::new(),
313 }
314 }
315
316 /// Insert a connection, returning its stable slab index.
317 fn insert(&mut self, entry: ConnectionEntry<R>) -> usize {
318 if let Some(idx) = self.free.pop() {
319 self.entries[idx] = Some(entry);
320 idx
321 } else {
322 let idx = self.entries.len();
323 self.entries.push(Some(entry));
324 idx
325 }
326 }
327
328 fn get_mut(&mut self, idx: usize) -> Option<&mut ConnectionEntry<R>> {
329 self.entries.get_mut(idx).and_then(|e| e.as_mut())
330 }
331
332 /// Remove and return a connection entry, recycling its index.
333 fn remove(&mut self, idx: usize) -> Option<ConnectionEntry<R>> {
334 if let Some(slot) = self.entries.get_mut(idx) {
335 let removed = slot.take();
336 if removed.is_some() {
337 self.free.push(idx);
338 }
339 removed
340 } else {
341 None
342 }
343 }
344}
345
346// ---------------------------------------------------------------------------
347// Main loop
348// ---------------------------------------------------------------------------
349
350/// How the reader should approach the kernel before draining the CQ.
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352enum RingEntry {
353 /// Completions are visible and there is nothing to hand over — drain
354 /// from the mmap'd ring without a syscall.
355 Skip,
356 /// SQEs to submit, but completions are already visible: hand them
357 /// over and come straight back rather than asking to wait.
358 Submit,
359 /// Nothing to process yet — submit whatever is queued and park.
360 SubmitAndWait,
361}
362
363/// The reader's enter policy. Two rules: never park while completions
364/// are already visible, and never skip the syscall while SQEs are
365/// waiting to be handed over.
366#[inline]
367fn ring_entry(sq_pending: usize, cq_ready: bool) -> RingEntry {
368 match (sq_pending > 0, cq_ready) {
369 (false, true) => RingEntry::Skip,
370 (true, true) => RingEntry::Submit,
371 (_, false) => RingEntry::SubmitAndWait,
372 }
373}
374
375/// Main io_uring reader loop. Runs until channel disconnection.
376///
377/// When `tick_cadence` is `Some`, the loop also generates the engine's
378/// scheduler ticks — see [`spawn_reader`] for the rationale.
379#[allow(clippy::too_many_arguments)]
380fn reader_loop<A: Application, R: AsRawFd>(
381 command_rx: mpsc::Receiver<ReaderRegistration<R>>,
382 // Shared with `UringReaderHandle`. Taken by value so this thread keeps
383 // the descriptor alive for as long as it is armed in the ring, and
384 // releases it by dropping the `Arc` rather than by closing the fd.
385 wakeup_fd: Arc<OwnedFd>,
386 mut producer: ring::Producer<InputSlot<A::Event>>,
387 decoder: &dyn RequestDecoder<Event = A::Event>,
388 halt: &HaltGate,
389 mut refusals: RefusalSender<A::Report>,
390 control_tx: &mpsc::Sender<ControlEvent>,
391 connection_timeout: Option<Duration>,
392 tick_cadence: Option<Duration>,
393 shutdown: &AtomicBool,
394) {
395 // Kernel-referenced memory is declared BEFORE the io_uring so it
396 // drops AFTER the ring on every exit path, including panic unwind
397 // (locals drop in reverse declaration order). The kernel holds live
398 // references into all three for as long as the ring fd is open:
399 // armed multishot RECVs select entries from the buf_ring and write
400 // into the pool, and the armed eventfd READ writes its buffer.
401
402 // Eventfd read buffer — boxed for pointer stability across SQE lifetimes.
403 let mut eventfd_buf: Box<[u8; 8]> = Box::new([0u8; 8]);
404
405 // Shared buffer pool for provided buffers. Contiguous allocation of
406 // NUM_BUFFERS × BUF_SIZE bytes. The kernel selects a buffer from this
407 // pool for each recv completion, identified by buffer ID in the CQE.
408 let mut buffer_pool = vec![0u8; NUM_BUFFERS as usize * BUF_SIZE].into_boxed_slice();
409
410 // Ring-mapped provided-buffer ring: recycling a consumed buffer is a
411 // shared-memory store, not a ProvideBuffers SQE — see `buf_ring`.
412 // Allocated unconditionally (32 KiB) so its declaration precedes the
413 // io_uring's even when the legacy fallback below ends up in use.
414 let mut buf_ring = BufRing::new(NUM_BUFFERS, buffer_pool.as_mut_ptr(), BUF_SIZE);
415
416 // SINGLE_ISSUER: this thread creates the ring and is the only one
417 // that ever submits — lets the kernel skip SQ locking, and turns any
418 // future cross-thread submission bug into an immediate EEXIST
419 // instead of a silent race. Same rationale as the journal and
420 // replication rings. (COOP_TASKRUN/DEFER_TASKRUN deliberately not
421 // set — see the journal ring's measured rationale in
422 // melin-transport-core::pipeline.)
423 let mut ring: IoUring = IoUring::builder()
424 .setup_single_issuer()
425 .build(RING_SIZE)
426 .expect("failed to create io_uring instance");
427
428 // Prefer the buf_ring; fall back to legacy ProvideBuffers SQEs if
429 // the kernel rejects the registration. Not theoretical: PBUF_RING
430 // needs kernel ≥ 5.19, and some virtualized hosts filter newer
431 // io_uring register opcodes (EINVAL) while reporting a modern
432 // uname. The fallback costs one SQE + one CQE per received chunk —
433 // degraded but fully functional, hence warn (see log conventions).
434 let use_buf_ring = match buf_ring.register(&ring, BUF_GROUP_ID) {
435 Ok(()) => true,
436 Err(e) => {
437 tracing::warn!(
438 error = %e,
439 "buf_ring registration rejected — falling back to legacy \
440 ProvideBuffers recycling (kernel < 5.19, or a hypervisor \
441 filtering io_uring register opcodes)"
442 );
443 register_buffer_pool(&mut ring, buffer_pool.as_mut_ptr());
444 false
445 }
446 };
447
448 let mut slab = ConnectionSlab::<R>::new();
449 // Reverse map for cleanup when a connection's fd needs removal.
450 // HashMap for O(1) lookup by fd. Sized for typical connection counts.
451 let mut fd_to_slab: HashMap<RawFd, usize> = HashMap::with_capacity(256);
452
453 // Pre-allocated CQE collection buffer. We must collect CQEs before
454 // processing because the CQ borrow must end before pushing new SQEs.
455 // Stores (user_data, result, flags) — flags needed for buffer ID and
456 // multishot continuation. Sized to the CQ depth (2× the SQ) so even
457 // a maximal drain never reallocates mid-loop.
458 let mut cqes: Vec<(u64, i32, u32)> = Vec::with_capacity(RING_SIZE as usize * 2);
459
460 // Submit the initial eventfd read so we wake on first connection.
461 // `eventfd_armed` mirrors whether that READ is pushed/armed — the
462 // kernel owes its CQE and writes `eventfd_buf` when it lands — and
463 // is maintained at every push/consume site so the teardown quiesce
464 // below can prove when the buffer is free of kernel references.
465 push_eventfd_read(&mut ring, wakeup_fd.as_raw_fd(), eventfd_buf.as_mut_ptr());
466 let mut eventfd_armed = true;
467
468 // Stage histograms via the global registry. `publish` is the
469 // narrow ring-publish call cost (lightweight, gated on
470 // `latency-trace`); `ingest` is the full per-frame reader cost
471 // and feeds the bench's tick-to-trade decomposition (heavier,
472 // gated on `tick-to-trade`).
473 #[cfg(feature = "latency-trace")]
474 let mut publish_rec =
475 melin_transport_core::trace::register_stage("reader: publish (decode → disruptor publish)");
476 #[cfg(feature = "tick-to-trade")]
477 let mut ingest_rec =
478 melin_transport_core::trace::register_stage("reader: ingest (recv_ts → publish complete)");
479 // Paces the recorder flush at the tail of each loop iteration. This
480 // thread parks in `submit_and_wait` when there is no traffic, so
481 // without an explicit flush its samples never reach the registry.
482 #[cfg(feature = "latency-trace")]
483 let mut last_stats_flush = Instant::now();
484
485 // Coarse gate for timeout scanning — avoids scanning on every
486 // submit_and_wait return during high throughput.
487 let mut last_timeout_scan = Instant::now();
488 // Pre-allocated buffer for stale connection indices to avoid
489 // heap allocation inside the hot loop.
490 let mut stale: Vec<usize> = Vec::new();
491
492 // Tick generator state. `next_tick_deadline` is the monotonic instant the
493 // next `JournalEvent::Tick` should fire. `last_tick_ns` enforces strict
494 // monotonicity on the wall-clock timestamps published in those events
495 // (NTP can step the wall clock backwards). `tick_armed` tracks whether
496 // an `IORING_OP_TIMEOUT` SQE is currently pending; we keep at most one.
497 //
498 // `tick_ts` lives across loop iterations because the kernel reads its
499 // bytes via the SQE's addr field at submit time, not at push time. If
500 // we declared it inside the `if !tick_armed` arm-timeout block, the
501 // value would be dropped before the `submit_and_wait` below — the
502 // kernel would then read freed stack memory. (See `md-gateway` for the
503 // same pattern: it stores Timespec as a long-lived struct field.)
504 let tick_enabled = tick_cadence.is_some();
505 let cadence = tick_cadence.unwrap_or(Duration::ZERO);
506 let mut next_tick_deadline = Instant::now() + cadence;
507 let mut last_tick_ns: u64 = 0;
508 let mut tick_armed = false;
509 // Arm the very first timeout here, before entering the loop. This both
510 // (a) makes the initial `tick_ts` value actually read by the kernel
511 // (silencing the unused-assignment lint, since rustc cannot see kernel
512 // pointer reads) and (b) ensures the first `submit_and_wait` returns at
513 // the cadence even if no client traffic ever arrives.
514 let mut tick_ts = types::Timespec::new()
515 .sec(cadence.as_secs())
516 .nsec(cadence.subsec_nanos());
517 if tick_enabled {
518 let sqe = opcode::Timeout::new(&tick_ts)
519 .build()
520 .user_data(TICK_TIMEOUT_TOKEN);
521 unsafe {
522 ring.submission()
523 .push(&sqe)
524 .expect("io_uring SQ full while arming initial tick timeout");
525 }
526 tick_armed = true;
527 tracing::info!(
528 cadence_ms = cadence.as_millis() as u64,
529 "tick generator integrated into reader thread"
530 );
531 }
532
533 loop {
534 if shutdown.load(Ordering::Relaxed) {
535 break;
536 }
537
538 // Tick generator: emit any due tick before sleeping again. Done before
539 // the timeout-arm so that a freshly-emitted tick re-arms a timeout for
540 // the *new* deadline.
541 if tick_enabled {
542 let now = Instant::now();
543 if now >= next_tick_deadline {
544 let raw_now_ns = unix_epoch_nanos();
545 let now_ns = melin_transport_core::tick::clamp_monotonic(raw_now_ns, last_tick_ns);
546 last_tick_ns = now_ns;
547 melin_transport_core::tick::publish_tick(&mut producer, now_ns);
548 // Catch up rather than burst-emit if we fell badly behind.
549 let elapsed = Instant::now().saturating_duration_since(next_tick_deadline);
550 next_tick_deadline = if elapsed > cadence {
551 Instant::now() + cadence
552 } else {
553 next_tick_deadline + cadence
554 };
555 // The previous timeout (if any) is now stale; let it fire and
556 // be ignored, then arm a new one below.
557 tick_armed = false;
558 }
559
560 if !tick_armed {
561 let remaining = next_tick_deadline.saturating_duration_since(Instant::now());
562 // Update the loop-scoped Timespec in place. The kernel reads
563 // it via the SQE's addr pointer on submit_and_wait below, so
564 // the binding must outlive that call (it does — outer scope).
565 tick_ts = types::Timespec::new()
566 .sec(remaining.as_secs())
567 .nsec(remaining.subsec_nanos());
568 let sqe = opcode::Timeout::new(&tick_ts)
569 .build()
570 .user_data(TICK_TIMEOUT_TOKEN);
571 unsafe {
572 ring.submission()
573 .push(&sqe)
574 .expect("io_uring SQ full while arming tick timeout");
575 }
576 tick_armed = true;
577 }
578 }
579
580 // Enter the kernel only when there is a reason to.
581 //
582 // Under load the CQ usually already holds completions by the
583 // time we get here: in the ring's default task-work mode the
584 // kernel posts them while this thread is in userspace parsing.
585 // Reading that is pure userspace — the CQ is mmap'd and
586 // `completion()` loads the tail with `Acquire` — so when there
587 // is also nothing to hand over, the `io_uring_enter` is a
588 // ~200 ns mode switch that buys nothing. The replication sender
589 // measured the same skip at ~6 % of its thread's cycles
590 // (`tcp_sender.rs`); this loop makes one enter per drain.
591 //
592 // When there ARE SQEs to submit we still enter, but we do not
593 // ask the kernel to *wait* if completions are already visible.
594 // Only a genuinely empty CQ parks the thread.
595 //
596 // Note this is the half of the io_uring-audit item that pairs
597 // *against* `DEFER_TASKRUN`: deferred task work only runs on an
598 // enter with GETEVENTS, so under that flag the CQ would be empty
599 // here and the skip would simply never fire.
600 let sq_pending = ring.submission().len();
601 let cq_ready = !ring.completion().is_empty();
602 let entered = match ring_entry(sq_pending, cq_ready) {
603 RingEntry::Skip => Ok(0),
604 RingEntry::Submit => ring.submit(),
605 RingEntry::SubmitAndWait => ring.submit_and_wait(1),
606 };
607 match entered {
608 Ok(_) => {}
609 Err(ref e) if e.raw_os_error() == Some(libc::EINTR) => continue,
610 Err(e) => {
611 error!(error = %e, "io_uring submit/wait error");
612 break;
613 }
614 }
615
616 // Drain all available CQEs into the pre-allocated buffer.
617 // Must collect before processing because the CQ borrow must end
618 // before we can push new SQEs to the SQ.
619 cqes.clear();
620 cqes.extend(
621 ring.completion()
622 .map(|cqe| (cqe.user_data(), cqe.result(), cqe.flags())),
623 );
624
625 let batch_now = Instant::now();
626 // One wall-clock read per CQE batch instead of per request. The
627 // reader can see 4–6 M requests/s at peak; a per-request
628 // `unix_epoch_nanos()` was ~2.8 % of the primary's cycles
629 // (vDSO `clock_gettime(CLOCK_REALTIME)`). All requests in the
630 // same batch share the timestamp — precision loss is bounded
631 // by the CQE-drain cadence (tens of µs under load) and order
632 // timestamps are used for reporting, not matching (the engine
633 // orders by sequence, not time).
634 let batch_wall_ns = unix_epoch_nanos();
635
636 for &(token, result, flags) in &cqes {
637 // ── Tick timeout ──
638 // The CQE is just a wakeup signal — the actual tick emission
639 // happens at the top of the next loop iteration via the
640 // deadline check, so the time the tick is stamped with reflects
641 // unix_epoch_nanos at fire time, not at submit time.
642 if token == TICK_TIMEOUT_TOKEN {
643 tick_armed = false;
644 continue;
645 }
646
647 // ── Legacy ProvideBuffers completion (fallback mode only) ──
648 if token == PROVIDE_BUFS_TOKEN {
649 if result < 0 {
650 error!(error = result, "ProvideBuffers failed");
651 }
652 continue;
653 }
654
655 // ── AsyncCancel completion ──
656 if token == CANCEL_TOKEN {
657 // Nothing to do: the cancelled op's own terminal CQE
658 // drives the state machine; ENOENT/EALREADY just mean
659 // it already finished on its own.
660 continue;
661 }
662
663 // ── Eventfd wakeup ──
664 if token == EVENTFD_TOKEN {
665 // This CQE is the armed READ completing; the re-arm at
666 // the end of the branch replaces it before anything can
667 // observe the gap, so `eventfd_armed` stays `true`
668 // through the pair. The teardown drain clears it for
669 // the one case where a completion is never processed
670 // here (a CQE still queued when the loop breaks).
671 if result >= 0 {
672 // Process all pending registrations.
673 while let Ok(reg) = command_rx.try_recv() {
674 let fd = reg.reader.as_raw_fd();
675 let entry = ConnectionEntry {
676 connection_id: reg.connection_id.0,
677 addr: reg.addr,
678 permission: reg.permission,
679 key_hash: reg.key_hash,
680 fd,
681 _reader: reg.reader,
682 parse_buf: Vec::with_capacity(MAX_FRAME_SIZE + 4),
683 multishot_active: false,
684 last_activity: Instant::now(),
685 dying: false,
686 };
687 let idx = slab.insert(entry);
688 fd_to_slab.insert(fd, idx);
689
690 // Submit multishot RECV for this connection.
691 push_recv_multi(&mut ring, &mut slab, idx);
692 }
693 } else {
694 error!(error = result, "eventfd read error");
695 }
696
697 // Re-submit eventfd read for the next wakeup
698 // (`eventfd_armed` stays `true` — see above).
699 push_eventfd_read(&mut ring, wakeup_fd.as_raw_fd(), eventfd_buf.as_mut_ptr());
700 continue;
701 }
702
703 // ── Connection multishot RECV completion ──
704
705 let slab_idx = token as usize;
706 let has_more = (flags & IORING_CQE_F_MORE) != 0;
707
708 // A cleared F_MORE means THIS armed op posts no further
709 // CQEs — record it before any branch, so no early `continue`
710 // below can wedge the connection with a stale `true`.
711 if !has_more && let Some(entry) = slab.get_mut(slab_idx) {
712 entry.multishot_active = false;
713 }
714
715 // Extract the buffer (if any) before acting on `result`: a
716 // buffer can ride ANY recv CQE, and one that is not recycled
717 // is leaked from the pool forever. On 6.8 the kernel
718 // recycles internally before posting error/EOF CQEs, but the
719 // supported floor is 5.19, where that guarantee could not be
720 // established — the defensive recycle is one branch.
721 let buf_id = if (flags & IORING_CQE_F_BUFFER) != 0 {
722 Some((flags >> IORING_CQE_BUFFER_SHIFT) as usize)
723 } else {
724 None
725 };
726
727 // Dying connection: teardown began while its multishot was
728 // still armed (see `begin_teardown`). Consume its CQEs
729 // without parsing — the bytes belong to a repudiated peer —
730 // but keep recycling their buffers; free the slab index only
731 // at the terminal CQE, so it cannot be handed to a new
732 // registration while the kernel can still post CQEs carrying
733 // it. Checked before the disconnect branch so the cancel's
734 // -ECANCELED completion lands here, not there (which would
735 // emit a second Disconnected event).
736 if slab.get_mut(slab_idx).is_some_and(|e| e.dying) {
737 if let Some(bid) = buf_id {
738 recycle_buffer(&mut ring, &mut buf_ring, use_buf_ring, &buffer_pool, bid);
739 }
740 if !has_more && let Some(dead) = slab.remove(slab_idx) {
741 debug!(
742 connection_id = dead.connection_id,
743 "teardown complete, slab index released"
744 );
745 }
746 continue;
747 }
748
749 if result == -libc::ENOBUFS {
750 // Pool exhausted at buffer-selection time: the multishot
751 // terminated WITHOUT reading — no data was lost, it sits
752 // in the socket buffer. This is not a client error and
753 // must not disconnect (a bare `result <= 0 ⇒ remove`
754 // here dropped innocent clients under burst). Re-arm:
755 // recycles from data CQEs earlier in this drain have
756 // already refilled the buf_ring (publication is
757 // immediate), and the re-arm SQE submits after the whole
758 // drain, so the retry finds buffers.
759 if let Some(bid) = buf_id {
760 // No buffer accompanies a selection failure by
761 // definition; recycle defensively if one appears.
762 recycle_buffer(&mut ring, &mut buf_ring, use_buf_ring, &buffer_pool, bid);
763 }
764 if has_more {
765 // Future-kernel guard: should the kernel ever keep
766 // the multishot alive across ENOBUFS, arming a second
767 // one would interleave two delivery streams into the
768 // same parse buffer.
769 continue;
770 }
771 push_recv_multi(&mut ring, &mut slab, slab_idx);
772 continue;
773 }
774
775 if result <= 0 {
776 // Disconnect (0) or error (negative errno) — terminal
777 // for the armed op, so the index is immediately safe to
778 // free. Recycle any buffer riding the CQE first.
779 if let Some(bid) = buf_id {
780 recycle_buffer(&mut ring, &mut buf_ring, use_buf_ring, &buffer_pool, bid);
781 }
782 if let Some(removed) = slab.remove(slab_idx) {
783 if result == 0 {
784 debug!(
785 connection_id = removed.connection_id,
786 addr = %removed.addr,
787 "client disconnected"
788 );
789 } else {
790 debug!(
791 connection_id = removed.connection_id,
792 addr = %removed.addr,
793 error = result,
794 "recv error"
795 );
796 }
797 fd_to_slab.remove(&removed.fd);
798 let _ = control_tx.send(ControlEvent::Disconnected {
799 connection_id: removed.connection_id,
800 });
801 }
802 continue;
803 }
804
805 let n = result as usize;
806
807 // Trace timestamp: the moment the kernel handed us this recv's
808 // bytes. Captured once per CQE — not per frame — and stamped
809 // onto every InputSlot parsed from this buffer below, so the
810 // reader-ingest and server-e2e stages measure from true wire
811 // receipt (frame decode included) rather than re-sampling after
812 // each decode (which excluded decode and drifted forward for
813 // later frames in a multi-frame recv).
814 #[allow(clippy::let_unit_value)] // ZST when latency-trace is off
815 let recv_ts = melin_transport_core::trace::mono_trace_ns();
816
817 // Data CQEs always carry a buffer with buffer-select recvs.
818 // Defensive: nothing to copy or recycle without one, but the
819 // multishot bookkeeping already ran above, so a terminal
820 // no-buffer CQE re-arms instead of wedging the connection.
821 let Some(buf_id) = buf_id else {
822 debug!(slab_idx, "recv CQE without buffer flag");
823 if !has_more {
824 push_recv_multi(&mut ring, &mut slab, slab_idx);
825 }
826 continue;
827 };
828
829 // Feed received bytes into the frame parser from the shared pool.
830 let action = if let Some(entry) = slab.get_mut(slab_idx) {
831 // Any successful recv resets the idle timeout.
832 entry.last_activity = batch_now;
833
834 // Copy received data from the shared buffer pool into the
835 // connection's parse buffer.
836 let buf_start = buf_id * BUF_SIZE;
837 entry
838 .parse_buf
839 .extend_from_slice(&buffer_pool[buf_start..buf_start + n]);
840
841 // Extract and publish complete frames.
842 let drop_conn = process_frames::<A, R>(
843 entry,
844 &mut producer,
845 decoder,
846 halt,
847 &mut refusals,
848 control_tx,
849 batch_wall_ns,
850 recv_ts,
851 #[cfg(feature = "latency-trace")]
852 &mut publish_rec,
853 #[cfg(feature = "tick-to-trade")]
854 &mut ingest_rec,
855 );
856 if drop_conn {
857 Action::Remove
858 } else if !has_more {
859 // Multishot terminated (buffer pool exhaustion or kernel
860 // decision) but connection is healthy — resubmit.
861 Action::Resubmit
862 } else {
863 Action::None
864 }
865 } else {
866 // Stale CQE for a removed connection — ignore.
867 Action::None
868 };
869
870 // Recycle the consumed buffer. Must happen after the copy-out
871 // above — from this line on the kernel may write fresh recv
872 // data into the slot.
873 recycle_buffer(&mut ring, &mut buf_ring, use_buf_ring, &buffer_pool, buf_id);
874
875 match action {
876 Action::Remove => {
877 // Deferred teardown, NOT an immediate slab free — the
878 // multishot may still be armed and its index must not
879 // be reused until the terminal CQE (audit review F1).
880 begin_teardown(&mut ring, &mut slab, &mut fd_to_slab, control_tx, slab_idx);
881 }
882 Action::Resubmit => {
883 push_recv_multi(&mut ring, &mut slab, slab_idx);
884 }
885 Action::None => {}
886 }
887 }
888
889 // Scan for idle connections that have exceeded the timeout.
890 // Coarse gate: only scan once per second to avoid unnecessary
891 // iteration during high-throughput phases when submit_and_wait
892 // returns immediately with CQEs.
893 if let Some(timeout) = connection_timeout {
894 let now = Instant::now();
895 if now.duration_since(last_timeout_scan) >= Duration::from_secs(1) {
896 last_timeout_scan = now;
897 stale.clear();
898 for (idx, slot) in slab.entries.iter().enumerate() {
899 // Dying entries are already mid-teardown: their
900 // `last_activity` stays stale by design, and a second
901 // teardown would emit a duplicate Disconnected.
902 if let Some(entry) = slot
903 && !entry.dying
904 && now.duration_since(entry.last_activity) > timeout
905 {
906 debug!(
907 connection_id = entry.connection_id,
908 addr = %entry.addr,
909 "connection timed out"
910 );
911 stale.push(idx);
912 }
913 }
914 for &idx in &stale {
915 begin_teardown(&mut ring, &mut slab, &mut fd_to_slab, control_tx, idx);
916 }
917 }
918 }
919
920 // Hand buffered latency samples to the stats registry before
921 // parking in `submit_and_wait` at the top of the next
922 // iteration — once traffic stops this thread can sleep in the
923 // kernel indefinitely, which is precisely when the bench
924 // scrapes /stats-dump. Reuses the per-batch clock read.
925 #[cfg(feature = "latency-trace")]
926 if batch_now.duration_since(last_stats_flush)
927 >= melin_transport_core::trace::IDLE_FLUSH_INTERVAL
928 {
929 last_stats_flush = batch_now;
930 publish_rec.flush();
931 #[cfg(feature = "tick-to-trade")]
932 ingest_rec.flush();
933 }
934 }
935
936 // ── Teardown quiesce ────────────────────────────────────────────
937 // Armed operations hold kernel pointers into the buffer pool, the
938 // buf_ring memory, and the eventfd buffer. Closing the ring fd only
939 // triggers ASYNCHRONOUS cancellation (the kernel's ring-exit work),
940 // which can still be touching those allocations after this function
941 // returns and frees them — the same corruption class fixed on both
942 // replication transports (see `crate::uring_teardown`). Quiesce
943 // *provably*: wake every armed operation, then reap CQEs until none
944 // is owed, and leak the buffers if that cannot be shown within the
945 // deadline. "CQ went quiet" is not proof — a wake that silently
946 // failed leaves armed ops and a quiet CQ.
947 //
948 // The wake cannot rely on the cancel opcode alone: some hosts
949 // filter io_uring opcodes (the reason the buf_ring path has a
950 // legacy fallback), and a filtered cancel completes with EINVAL
951 // while the armed operations live on. So every operation that has
952 // to be proven down gets a wake it must answer: `shutdown(2)` on
953 // each connection socket completes its multishot RECV with EOF,
954 // and a wakeup write completes the armed eventfd READ. The cancel
955 // is still pushed as an accelerator where the host honours it. The
956 // tick timeout is not in the proof at all — see `owed` below.
957 // Panic unwind skips all of this and accepts the (tiny,
958 // process-is-dying) exit-work window; declaration order still
959 // closes the ring fd before the frees.
960 let proven = {
961 let cancel_all = opcode::AsyncCancel2::new(types::CancelBuilder::any())
962 .build()
963 .user_data(CANCEL_TOKEN);
964 unsafe {
965 // Ignore a full SQ: the wakes below do not depend on it.
966 let _ = ring.submission().push(&cancel_all);
967 }
968 for entry in slab.entries.iter().flatten() {
969 crate::uring_teardown::wake_pending_ops(entry.fd);
970 }
971 // Completes the armed eventfd READ into `eventfd_buf` (still
972 // alive). Best-effort: on failure the cancel is the fallback.
973 let wake: u64 = 1;
974 unsafe {
975 libc::write(
976 wakeup_fd.as_raw_fd(),
977 &wake as *const u64 as *const libc::c_void,
978 8,
979 );
980 }
981 // Flush SQEs pushed but never submitted (re-arms from the last
982 // batch, the cancel above): until they reach the kernel no CQE
983 // is coming for them, but their armed-state flags are already
984 // set, so abandoning them in the SQ would stall the proof
985 // below. On a submit error the flags stay set and the deadline
986 // routes to the leak path — the conservative outcome.
987 loop {
988 match ring.submit() {
989 Ok(_) => break,
990 Err(ref e) if e.raw_os_error() == Some(libc::EINTR) => continue,
991 Err(_) => break,
992 }
993 }
994 let mut backoff = crate::uring_teardown::DrainBackoff::new();
995 loop {
996 for cqe in ring.completion() {
997 match cqe.user_data() {
998 EVENTFD_TOKEN => eventfd_armed = false,
999 // None of these touch the allocations freed below,
1000 // so reaping them settles nothing: cancel results
1001 // carry no information, legacy ProvideBuffers
1002 // completions only registered addresses, and the
1003 // tick timeout reads nothing after submit (see
1004 // `owed`).
1005 PROVIDE_BUFS_TOKEN | CANCEL_TOKEN | TICK_TIMEOUT_TOKEN => {}
1006 // Connection tokens are slab indices.
1007 idx => {
1008 // A terminal CQE retires that connection's arm.
1009 // Data CQEs landing mid-teardown are discarded
1010 // and their buffers deliberately not recycled —
1011 // nothing re-arms, so the pool cannot be
1012 // selected from again once every arm is down.
1013 if (cqe.flags() & IORING_CQE_F_MORE) == 0
1014 && let Some(entry) = slab.get_mut(idx as usize)
1015 {
1016 entry.multishot_active = false;
1017 }
1018 }
1019 }
1020 }
1021 // `tick_armed` is deliberately absent: an armed
1022 // `IORING_OP_TIMEOUT` holds no pointer into any of the
1023 // allocations below. The kernel copies its `Timespec` out
1024 // of `tick_ts` when the SQE is *submitted* (which is why
1025 // that binding only has to outlive the submit — see its
1026 // declaration), and the ring's own exit work retires the
1027 // timer. Waiting for it would prove nothing and cost
1028 // plenty: nothing here can wake a timeout, so the only
1029 // things that retire one are it firing at its own cadence
1030 // — `--tick-interval-ms`, operator-set and unbounded — or
1031 // the cancel above, which is exactly what a host that
1032 // filters opcodes refuses. Gating the proof on it would
1033 // hand those hosts a full-deadline stall and a spurious
1034 // "leaking" warning on every clean shutdown.
1035 let owed = eventfd_armed || slab.entries.iter().flatten().any(|e| e.multishot_active);
1036 if !owed {
1037 break true;
1038 }
1039 if !backoff.wait() {
1040 break false;
1041 }
1042 }
1043 };
1044 if !proven {
1045 // The kernel may still write the pool or the eventfd buffer, or
1046 // read buf_ring entries. Leak all three rather than hand the
1047 // allocator memory the kernel still touches — a bounded one-off
1048 // cost on a thread that is exiting anyway. `mem::forget` on the
1049 // `BufRing` also skips its ring-memory dealloc, which is the
1050 // point.
1051 // Report what was still owed, not `tick_armed` — naming a
1052 // timer that was never part of the proof would point the next
1053 // reader at the wrong operation.
1054 tracing::warn!(
1055 eventfd_armed,
1056 armed_connections = slab
1057 .entries
1058 .iter()
1059 .flatten()
1060 .filter(|e| e.multishot_active)
1061 .count(),
1062 "io_uring teardown drain did not complete; leaking reader buffers"
1063 );
1064 std::mem::forget(buffer_pool);
1065 std::mem::forget(buf_ring);
1066 std::mem::forget(eventfd_buf);
1067 }
1068
1069 // No `close` here. This thread shares the eventfd with
1070 // `UringReaderHandle`, which writes wakeups to it, and closing it from
1071 // this side left that write racing a reused descriptor number. Dropping
1072 // the `Arc` at the end of this scope releases our share; the descriptor
1073 // itself closes when the handle drops too.
1074}
1075
1076/// What to do after processing a RECV CQE.
1077enum Action {
1078 /// Multishot terminated but connection healthy — resubmit RecvMulti.
1079 Resubmit,
1080 /// Connection should be torn down (malformed frame). Handled via
1081 /// `begin_teardown`, which reads what it needs from the slab entry.
1082 Remove,
1083 /// Multishot still active — nothing to do.
1084 None,
1085}
1086
1087// ---------------------------------------------------------------------------
1088// SQE helpers
1089// ---------------------------------------------------------------------------
1090
1091/// Register the provided buffer pool via a legacy ProvideBuffers op —
1092/// the fallback when buf_ring registration is rejected. Submits
1093/// synchronously and panics on failure — called once at startup, and
1094/// only after the preferred path already failed.
1095fn register_buffer_pool(ring: &mut IoUring, pool_ptr: *mut u8) {
1096 let sqe = opcode::ProvideBuffers::new(pool_ptr, BUF_SIZE as i32, NUM_BUFFERS, BUF_GROUP_ID, 0)
1097 .build()
1098 .user_data(PROVIDE_BUFS_TOKEN);
1099
1100 unsafe {
1101 ring.submission()
1102 .push(&sqe)
1103 .expect("io_uring SQ full during buffer pool registration");
1104 }
1105
1106 ring.submit_and_wait(1)
1107 .expect("io_uring submit failed during buffer pool registration");
1108
1109 // Check the completion result.
1110 let cqe = ring
1111 .completion()
1112 .next()
1113 .expect("no CQE after ProvideBuffers");
1114 assert!(cqe.result() >= 0, "ProvideBuffers failed: {}", cqe.result());
1115}
1116
1117/// Re-provide a single consumed buffer back to the pool (legacy fallback
1118/// mode). Pushed to SQ without immediate submission — batched with the
1119/// next submit_and_wait. Safe against SQ overflow only because at most
1120/// `NUM_BUFFERS` (< `RING_SIZE`) recycles can accumulate per drain.
1121fn re_provide_buffer(ring: &mut IoUring, pool_ptr: *mut u8, buf_id: usize) {
1122 let buf_ptr = unsafe { pool_ptr.add(buf_id * BUF_SIZE) };
1123 let sqe = opcode::ProvideBuffers::new(buf_ptr, BUF_SIZE as i32, 1, BUF_GROUP_ID, buf_id as u16)
1124 .build()
1125 .user_data(PROVIDE_BUFS_TOKEN);
1126
1127 unsafe {
1128 ring.submission()
1129 .push(&sqe)
1130 .expect("io_uring SQ full — increase RING_SIZE");
1131 }
1132}
1133
1134/// Return a consumed buffer to the shared pool, in whichever recycle
1135/// mode registration selected.
1136///
1137/// Hard assert, not debug: the bid comes from a kernel CQE, this path
1138/// runs per CQE (off the per-frame budget), and an out-of-range bid
1139/// handed back to the kernel would point a future recv's DMA at
1140/// arbitrary heap — the one failure mode worth an unconditional branch.
1141fn recycle_buffer(
1142 ring: &mut IoUring,
1143 buf_ring: &mut BufRing,
1144 use_buf_ring: bool,
1145 buffer_pool: &[u8],
1146 buf_id: usize,
1147) {
1148 assert!(
1149 buf_id < NUM_BUFFERS as usize,
1150 "kernel returned out-of-pool buffer id {buf_id}"
1151 );
1152 if use_buf_ring {
1153 buf_ring.push(buf_id as u16);
1154 } else {
1155 re_provide_buffer(ring, buffer_pool.as_ptr() as *mut u8, buf_id);
1156 }
1157}
1158
1159/// Begin tearing down a connection the reader decided to drop while its
1160/// multishot RECV may still be armed (malformed frame, idle timeout).
1161///
1162/// The slab index must NOT be freed yet: the kernel can still post CQEs
1163/// carrying it, and the slab's LIFO free list would hand the index to
1164/// the next registration — the old peer's bytes would then be parsed
1165/// under the new connection's identity, key hash, and permissions
1166/// (order-flow injection; audit review F1). Instead: sever the peer,
1167/// mark the entry dying, cancel the armed op, and let the terminal CQE
1168/// free the index (the `dying` branch of the CQE loop). When no op is
1169/// armed there is nothing that can post — free immediately.
1170fn begin_teardown<R>(
1171 ring: &mut IoUring,
1172 slab: &mut ConnectionSlab<R>,
1173 fd_to_slab: &mut HashMap<RawFd, usize>,
1174 control_tx: &mpsc::Sender<ControlEvent>,
1175 idx: usize,
1176) {
1177 let Some(entry) = slab.get_mut(idx) else {
1178 return;
1179 };
1180 if entry.dying {
1181 return;
1182 }
1183 let connection_id = entry.connection_id;
1184 let fd = entry.fd;
1185 // Sever the peer now. The fd itself must stay open until the armed
1186 // op completes (the kernel holds a file reference for it anyway);
1187 // shutdown(2) stops both directions immediately. Best-effort:
1188 // ENOTCONN just means the peer already went away.
1189 unsafe {
1190 libc::shutdown(fd, libc::SHUT_RDWR);
1191 }
1192 fd_to_slab.remove(&fd);
1193 // Dropped error: a dead control channel means the response stage is
1194 // gone and the server is shutting down.
1195 let _ = control_tx.send(ControlEvent::Disconnected { connection_id });
1196
1197 if entry.multishot_active {
1198 entry.dying = true;
1199 let sqe = opcode::AsyncCancel::new(idx as u64)
1200 .build()
1201 .user_data(CANCEL_TOKEN);
1202 unsafe {
1203 ring.submission()
1204 .push(&sqe)
1205 .expect("io_uring SQ full — increase RING_SIZE");
1206 }
1207 } else {
1208 // No armed op ⇒ no future CQEs can carry this index.
1209 slab.remove(idx);
1210 }
1211}
1212
1213/// Push a multishot RECV SQE for a connection. The kernel will produce
1214/// CQEs continuously until EOF, error, or buffer pool exhaustion —
1215/// no resubmission needed unless multishot terminates.
1216fn push_recv_multi<R>(ring: &mut IoUring, slab: &mut ConnectionSlab<R>, idx: usize) {
1217 let entry = match slab.get_mut(idx) {
1218 Some(e) => e,
1219 None => return,
1220 };
1221
1222 if entry.multishot_active {
1223 return;
1224 }
1225
1226 let sqe = opcode::RecvMulti::new(types::Fd(entry.fd), BUF_GROUP_ID)
1227 .build()
1228 .user_data(idx as u64);
1229
1230 unsafe {
1231 ring.submission()
1232 .push(&sqe)
1233 .expect("io_uring SQ full — increase RING_SIZE");
1234 }
1235 entry.multishot_active = true;
1236}
1237
1238/// Push a READ SQE for the eventfd (wakeup notification).
1239fn push_eventfd_read(ring: &mut IoUring, wakeup_fd: RawFd, buf: *mut u8) {
1240 let sqe = opcode::Read::new(types::Fd(wakeup_fd), buf, 8)
1241 .build()
1242 .user_data(EVENTFD_TOKEN);
1243
1244 unsafe {
1245 ring.submission()
1246 .push(&sqe)
1247 .expect("io_uring SQ full — increase RING_SIZE");
1248 }
1249}
1250
1251// ---------------------------------------------------------------------------
1252// Frame parsing
1253// ---------------------------------------------------------------------------
1254
1255/// Extract complete frames from the connection's parse buffer, decode them,
1256/// and publish to the disruptor. Returns `true` if the connection should be
1257/// dropped (e.g., oversized frame).
1258/// Extract complete frames from `conn.parse_buf` and publish them as
1259/// `InputSlot`s. `batch_wall_ns` is the wall-clock timestamp captured
1260/// once per CQE batch by the caller (see `reader_loop`); all non-query
1261/// requests published in this call share it, sparing the reader a
1262/// per-request `clock_gettime(CLOCK_REALTIME)` on the hot path. Returns
1263/// `true` if the connection should be dropped.
1264#[allow(clippy::too_many_arguments)]
1265fn process_frames<A: Application, R>(
1266 conn: &mut ConnectionEntry<R>,
1267 producer: &mut ring::Producer<InputSlot<A::Event>>,
1268 decoder: &dyn RequestDecoder<Event = A::Event>,
1269 halt: &HaltGate,
1270 refusals: &mut RefusalSender<A::Report>,
1271 control_tx: &mpsc::Sender<ControlEvent>,
1272 batch_wall_ns: u64,
1273 recv_ts: melin_transport_core::trace::MonoTraceInstant,
1274 #[cfg(feature = "latency-trace")] publish_rec: &mut melin_transport_core::trace::StageRecorder,
1275 #[cfg(feature = "tick-to-trade")] ingest_rec: &mut melin_transport_core::trace::StageRecorder,
1276) -> bool {
1277 use crate::client_frames::{FrameAction, process_client_frames};
1278
1279 let action = process_client_frames::<A>(
1280 &mut conn.parse_buf,
1281 conn.connection_id,
1282 conn.key_hash,
1283 conn.permission,
1284 producer,
1285 decoder,
1286 halt,
1287 refusals,
1288 batch_wall_ns,
1289 recv_ts,
1290 #[cfg(feature = "latency-trace")]
1291 publish_rec,
1292 #[cfg(feature = "tick-to-trade")]
1293 ingest_rec,
1294 );
1295
1296 match action {
1297 FrameAction::Continue => false,
1298 FrameAction::Disconnect => true,
1299 FrameAction::PipelineFull => {
1300 debug!(
1301 connection_id = conn.connection_id,
1302 "pipeline full, routing ServerBusy via response stage"
1303 );
1304 // The response stage owns ALL egress on a client socket. A
1305 // reader-side send here — however non-blocking — races the
1306 // response stage's own writes and can land between the two
1307 // halves of a partially-flushed response frame, permanently
1308 // desyncing the client's length-prefix framing (audit review
1309 // F3). It also kept a client-socket syscall on the ingress
1310 // thread. Routing through the control channel makes the
1311 // notice an ordinary send_buf append over there.
1312 //
1313 // Error deliberately dropped: a dead control channel means
1314 // the response stage is gone and the server is shutting
1315 // down — same reasoning as the Disconnected sends below.
1316 let _ = control_tx.send(ControlEvent::PipelineBusy {
1317 connection_id: conn.connection_id,
1318 });
1319 false
1320 }
1321 }
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326 //! Unit tests for [`process_frames`]. The function has four exit paths
1327 //! (normal end, partial parse-buf, pipeline-full, oversize-frame), each
1328 //! with subtle batch-commit ordering requirements. These tests pin that
1329 //! behaviour against a synthetic decoder so refactors of the batch path
1330 //! (e.g. moving the batch up to span the whole CQE drain) can't silently
1331 //! regress the "earlier frames must be visible before ServerBusy /
1332 //! disconnect" guarantees.
1333 use super::*;
1334 use melin_app::auth::Permission;
1335 use melin_app::decoder::{Decoded, RequestDecoder};
1336 use melin_app::{AppEvent, Application, ApplyCtx, CodecError, RejectReason};
1337 use melin_journal::JournalEvent;
1338 use melin_pipeline::ring::DisruptorBuilder;
1339 use std::io::{ErrorKind, Read};
1340 use std::os::unix::net::UnixStream;
1341 use std::sync::atomic::AtomicU64;
1342 use std::time::Duration;
1343
1344 /// The reader's enter policy. Each case is a syscall-per-drain
1345 /// decision on the hot path, and getting one wrong is either a lost
1346 /// wakeup (skipping with an empty CQ) or unsubmitted SQEs (skipping
1347 /// with a non-empty SQ) — both silent.
1348 mod ring_entry {
1349 use super::super::{RingEntry, ring_entry};
1350
1351 #[test]
1352 fn visible_completions_and_nothing_to_submit_need_no_syscall() {
1353 assert_eq!(ring_entry(0, true), RingEntry::Skip);
1354 }
1355
1356 #[test]
1357 fn queued_sqes_are_always_handed_over() {
1358 // Never skip with work in the SQ: those entries would sit
1359 // there until some later iteration happened to enter.
1360 assert_eq!(ring_entry(1, true), RingEntry::Submit);
1361 assert_eq!(ring_entry(8, false), RingEntry::SubmitAndWait);
1362 }
1363
1364 #[test]
1365 fn an_empty_cq_is_the_only_thing_that_parks_the_thread() {
1366 assert_eq!(ring_entry(0, false), RingEntry::SubmitAndWait);
1367 // ...and visible work never does, whatever the SQ holds.
1368 for sq in [0, 1, 64] {
1369 assert_ne!(ring_entry(sq, true), RingEntry::SubmitAndWait);
1370 }
1371 }
1372 }
1373
1374 /// Minimal `AppEvent` for these tests. `Copy` is required by `AppEvent`;
1375 /// the on-wire codec is unused because [`TagDecoder`] never invokes it
1376 /// (frames are interpreted directly from their tag byte).
1377 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1378 enum TestEvent {
1379 Cmd(u8),
1380 Query,
1381 }
1382
1383 impl AppEvent for TestEvent {
1384 const MAX_ENCODED_SIZE: usize = 2;
1385
1386 fn encoded_size(&self) -> usize {
1387 // Unused — the tests never round-trip through encode/decode.
1388 2
1389 }
1390 fn encode(&self, _buf: &mut [u8]) -> usize {
1391 unreachable!("process_frames does not encode app events")
1392 }
1393 fn decode(_buf: &[u8]) -> Result<Self, CodecError> {
1394 unreachable!("process_frames does not decode app events directly")
1395 }
1396 fn is_query(&self) -> bool {
1397 matches!(self, TestEvent::Query)
1398 }
1399 }
1400
1401 /// Placeholder `Application` impl. `process_frames` calls only
1402 /// `build_reject`, for a write refused while halted; the rest
1403 /// `unreachable!`.
1404 #[derive(Default)]
1405 struct TestApp;
1406
1407 /// What [`TestApp::build_reject`] returns: the refused event and why,
1408 /// so a test can tell refusals apart.
1409 type TestReport = (TestEvent, RejectReason);
1410
1411 impl Application for TestApp {
1412 type Event = TestEvent;
1413 type Report = TestReport;
1414 type QueryResponse = ();
1415 type Sizing = ();
1416 const APP_VERSION: u16 = 0;
1417 fn apply(
1418 &mut self,
1419 _event: TestEvent,
1420 _ctx: &ApplyCtx,
1421 _out: &mut Vec<TestReport>,
1422 ) -> Option<()> {
1423 unreachable!()
1424 }
1425 fn tick(&mut self, _now_ns: u64, _out: &mut Vec<TestReport>) {
1426 unreachable!()
1427 }
1428 fn check_request_seq(&mut self, _key_hash: u64, _seq: u64) -> bool {
1429 unreachable!()
1430 }
1431 fn build_reject(event: &TestEvent, reason: RejectReason) -> TestReport {
1432 (*event, reason)
1433 }
1434 fn snapshot<W: std::io::Write>(&self, _w: &mut W) -> std::io::Result<()> {
1435 unreachable!()
1436 }
1437 fn restore<R: Read>(_r: &mut R) -> std::io::Result<Self> {
1438 unreachable!()
1439 }
1440 }
1441
1442 /// Stateless decoder that maps a frame's single payload byte to a
1443 /// [`Decoded`] outcome. Lets each test feed a precise mix of permitted,
1444 /// filtered, denied, and decode-error frames without standing up the
1445 /// real wire codec.
1446 ///
1447 /// Tag mapping (`0x00..=0xFB` map 1:1 to a Permitted seq, reserving the
1448 /// top four byte values for the non-Permitted outcomes):
1449 /// * `0xFC` -> `Filter`
1450 /// * `0xFD` -> `PermissionDenied`
1451 /// * `0xFE` -> `DecodeError`
1452 /// * `0xFF` -> `Permitted` with `is_query == true`
1453 /// * `0x00..=0xFB` -> `Permitted` with `request_seq == byte`
1454 struct TagDecoder;
1455
1456 impl RequestDecoder for TagDecoder {
1457 type Event = TestEvent;
1458 fn decode(&self, bytes: &[u8], _permission: Permission) -> Decoded<TestEvent> {
1459 match bytes.first().copied() {
1460 None => Decoded::DecodeError("empty payload"),
1461 Some(0xFC) => Decoded::Filter,
1462 Some(0xFD) => Decoded::PermissionDenied("denied"),
1463 Some(0xFE) => Decoded::DecodeError("bad"),
1464 Some(0xFF) => Decoded::Permitted {
1465 request_seq: 0xFF,
1466 event: TestEvent::Query,
1467 },
1468 Some(b) => Decoded::Permitted {
1469 request_seq: b as u64,
1470 event: TestEvent::Cmd(b),
1471 },
1472 }
1473 }
1474 }
1475
1476 /// One-byte payload framed as `[u32 LE length=1][byte]`.
1477 fn frame(byte: u8) -> [u8; 5] {
1478 let mut f = [0u8; 5];
1479 f[..4].copy_from_slice(&1u32.to_le_bytes());
1480 f[4] = byte;
1481 f
1482 }
1483
1484 /// Length prefix announcing an oversize frame. No payload bytes follow —
1485 /// `process_frames` decides on the prefix alone, before waiting for the
1486 /// body.
1487 fn oversize_prefix() -> [u8; 4] {
1488 ((MAX_FRAME_SIZE as u32) + 1).to_le_bytes()
1489 }
1490
1491 /// Test fixture bundle. Grouped into a struct rather than returned as a
1492 /// 4-tuple to keep clippy happy (`type_complexity`) and to give each
1493 /// field a name at call sites.
1494 struct Fixture {
1495 conn: ConnectionEntry<UnixStream>,
1496 producer: ring::Producer<InputSlot<TestEvent>>,
1497 consumer: ring::Consumer<InputSlot<TestEvent>>,
1498 /// Client-side end of the socket pair — read from this to inspect
1499 /// any `ServerBusy` bytes the function under test writes.
1500 peer: UnixStream,
1501 }
1502
1503 /// Build a fresh fixture: a `ConnectionEntry` backed by a `UnixStream`
1504 /// pair plus a single-consumer disruptor of the requested capacity.
1505 fn make_fixture(ring_capacity: usize) -> Fixture {
1506 let (server_side, peer) = UnixStream::pair().expect("UnixStream::pair");
1507 // Short read timeout on the peer so assertions of "no ServerBusy
1508 // written" return promptly instead of hanging the test.
1509 peer.set_read_timeout(Some(Duration::from_millis(50)))
1510 .expect("set_read_timeout");
1511
1512 let entry = ConnectionEntry::<UnixStream> {
1513 connection_id: 7,
1514 addr: "127.0.0.1:1".parse().expect("addr parses"),
1515 permission: Permission::Trader,
1516 key_hash: 0xC0FFEE_u64,
1517 fd: server_side.as_raw_fd(),
1518 _reader: server_side,
1519 parse_buf: Vec::with_capacity(64),
1520 multishot_active: false,
1521 last_activity: Instant::now(),
1522 dying: false,
1523 };
1524
1525 let (producer, mut consumers) =
1526 DisruptorBuilder::<InputSlot<TestEvent>>::new(ring_capacity)
1527 .add_consumer()
1528 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
1529 let consumer = consumers.pop().expect("consumer present");
1530
1531 Fixture {
1532 conn: entry,
1533 producer,
1534 consumer,
1535 peer,
1536 }
1537 }
1538
1539 /// Invoke `process_frames::<TestApp, UnixStream>`, threading the
1540 /// feature-gated histogram args when the relevant features are on so
1541 /// the call compiles in every `cargo test` configuration.
1542 ///
1543 /// Returns the disconnect flag plus the control-channel receiver —
1544 /// pipeline-full now surfaces as a `PipelineBusy` event for the
1545 /// response stage rather than reader-side socket bytes, so busy
1546 /// assertions read the channel, not the peer.
1547 fn run_process_frames(
1548 conn: &mut ConnectionEntry<UnixStream>,
1549 producer: &mut ring::Producer<InputSlot<TestEvent>>,
1550 ) -> (bool, mpsc::Receiver<ControlEvent>) {
1551 let (mut refusals, _queue) = refusal_channel();
1552 run_process_frames_with(conn, producer, &gate(None, false), &mut refusals)
1553 }
1554
1555 /// A halt gate: `replicas` connected (`None` for standalone), fenced
1556 /// or not.
1557 fn gate(replicas: Option<u32>, fenced: bool) -> HaltGate {
1558 gate_counting_into(replicas, fenced, Arc::new(AtomicU64::new(0)))
1559 }
1560
1561 /// [`gate`], counting what it refuses into `refused`.
1562 fn gate_counting_into(
1563 replicas: Option<u32>,
1564 fenced: bool,
1565 refused: Arc<AtomicU64>,
1566 ) -> HaltGate {
1567 let fence = Arc::new(melin_transport_core::fence::FenceState::new(0));
1568 if fenced {
1569 fence.fence();
1570 }
1571 HaltGate::new(
1572 replicas.map(|count| Arc::new(std::sync::atomic::AtomicU32::new(count))),
1573 fence,
1574 refused,
1575 )
1576 }
1577
1578 fn refused_count(refused: &AtomicU64) -> u64 {
1579 refused.load(std::sync::atomic::Ordering::Relaxed)
1580 }
1581
1582 /// A refusal queue whose idle bound is never consulted by these tests.
1583 fn refusal_channel() -> (
1584 RefusalSender<TestReport>,
1585 crate::halt::RefusalQueue<TestReport>,
1586 ) {
1587 crate::halt::refusal_channel(Arc::new(melin_pipeline::padding::CachePadded::new(
1588 std::sync::atomic::AtomicU64::new(0),
1589 )))
1590 }
1591
1592 /// [`run_process_frames`] against a given halt gate and refusal queue.
1593 fn run_process_frames_with(
1594 conn: &mut ConnectionEntry<UnixStream>,
1595 producer: &mut ring::Producer<InputSlot<TestEvent>>,
1596 halt: &HaltGate,
1597 refusals: &mut RefusalSender<TestReport>,
1598 ) -> (bool, mpsc::Receiver<ControlEvent>) {
1599 #[cfg(feature = "latency-trace")]
1600 let mut publish_rec = melin_transport_core::trace::register_stage("test: publish");
1601 #[cfg(feature = "tick-to-trade")]
1602 let mut ingest_rec = melin_transport_core::trace::register_stage("test: ingest");
1603
1604 #[allow(clippy::let_unit_value)] // ZST when latency-trace is off
1605 let recv_ts = melin_transport_core::trace::mono_trace_ns();
1606
1607 let (control_tx, control_rx) = mpsc::channel();
1608 let disconnect = process_frames::<TestApp, UnixStream>(
1609 conn,
1610 producer,
1611 &TagDecoder,
1612 halt,
1613 refusals,
1614 &control_tx,
1615 0xDEAD_BEEF,
1616 recv_ts,
1617 #[cfg(feature = "latency-trace")]
1618 &mut publish_rec,
1619 #[cfg(feature = "tick-to-trade")]
1620 &mut ingest_rec,
1621 );
1622 (disconnect, control_rx)
1623 }
1624
1625 /// Count `PipelineBusy` events for the fixture connection (id 7)
1626 /// queued on the control channel.
1627 fn busy_events(rx: &mpsc::Receiver<ControlEvent>) -> usize {
1628 let mut n = 0;
1629 while let Ok(event) = rx.try_recv() {
1630 if matches!(event, ControlEvent::PipelineBusy { connection_id } if connection_id == 7) {
1631 n += 1;
1632 }
1633 }
1634 n
1635 }
1636
1637 /// Drain `consumer` into a Vec of `(seq, slot)` until it yields `None`.
1638 /// Used to assert exact event sequences after `process_frames` returns.
1639 fn drain(
1640 consumer: &mut ring::Consumer<InputSlot<TestEvent>>,
1641 ) -> Vec<(u64, InputSlot<TestEvent>)> {
1642 let mut out = Vec::new();
1643 while let Some(pair) = consumer.try_consume() {
1644 out.push(pair);
1645 }
1646 out
1647 }
1648
1649 /// Try to read exactly 5 bytes (the ServerBusy frame size) from the
1650 /// peer. Returns `Some(bytes)` if the read completes within the peer's
1651 /// configured timeout, `None` if it times out (i.e. nothing was sent).
1652 fn read_server_busy(peer: &mut UnixStream) -> Option<[u8; 5]> {
1653 let mut buf = [0u8; 5];
1654 match peer.read_exact(&mut buf) {
1655 Ok(()) => Some(buf),
1656 Err(e) if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut => None,
1657 Err(e) => panic!("unexpected peer read error: {e}"),
1658 }
1659 }
1660
1661 #[test]
1662 fn process_frames_publishes_all_frames_after_single_commit() {
1663 // Capacity > number of frames — every frame must succeed and become
1664 // visible to the consumer after the trailing `batch.commit()`.
1665 let Fixture {
1666 mut conn,
1667 mut producer,
1668 mut consumer,
1669 mut peer,
1670 } = make_fixture(16);
1671 for byte in [0x01, 0x02, 0x03, 0x04, 0x05] {
1672 conn.parse_buf.extend_from_slice(&frame(byte));
1673 }
1674
1675 let (disconnect, control_rx) = run_process_frames(&mut conn, &mut producer);
1676 assert!(!disconnect, "no oversize frame ⇒ no disconnect");
1677
1678 let events = drain(&mut consumer);
1679 assert_eq!(events.len(), 5, "all 5 frames must be visible");
1680 for (i, (seq, slot)) in events.iter().enumerate() {
1681 assert_eq!(*seq, i as u64, "seq monotonic from 0");
1682 assert_eq!(slot.connection_id, 7);
1683 assert_eq!(slot.key_hash, 0xC0FFEE_u64);
1684 let byte = (i + 1) as u8;
1685 assert_eq!(slot.request_seq, byte as u64);
1686 assert_eq!(slot.event, JournalEvent::App(TestEvent::Cmd(byte)));
1687 // Non-query event ⇒ inherits the caller-supplied wall-clock.
1688 assert_eq!(slot.timestamp_ns, 0xDEAD_BEEF);
1689 }
1690 // Parse buffer fully consumed.
1691 assert!(conn.parse_buf.is_empty());
1692 // No Full happened: no busy event, and the reader never writes
1693 // the client socket (egress belongs to the response stage).
1694 assert_eq!(
1695 busy_events(&control_rx),
1696 0,
1697 "no PipelineBusy on the happy path"
1698 );
1699 assert!(
1700 read_server_busy(&mut peer).is_none(),
1701 "reader must never write the client socket"
1702 );
1703 }
1704
1705 /// A halted reader publishes no write: each is refused, stamped with
1706 /// the input sequence it would have taken, while queries still go
1707 /// through. Nothing refused can reach the journal, so replay cannot
1708 /// apply a write the client was told failed.
1709 #[test]
1710 fn a_halted_reader_refuses_writes_before_publishing_and_still_publishes_queries() {
1711 let Fixture {
1712 mut conn,
1713 mut producer,
1714 mut consumer,
1715 ..
1716 } = make_fixture(16);
1717 // Write, query, write, query.
1718 for byte in [0x01, 0xFF, 0x02, 0xFF] {
1719 conn.parse_buf.extend_from_slice(&frame(byte));
1720 }
1721 let (mut refusals, mut queue) = refusal_channel();
1722 let counted = Arc::new(AtomicU64::new(0));
1723
1724 let (disconnect, control_rx) = run_process_frames_with(
1725 &mut conn,
1726 &mut producer,
1727 &gate_counting_into(Some(0), false, Arc::clone(&counted)),
1728 &mut refusals,
1729 );
1730 assert!(!disconnect);
1731 assert_eq!(busy_events(&control_rx), 0);
1732 assert!(conn.parse_buf.is_empty(), "every frame consumed");
1733 assert_eq!(
1734 refused_count(&counted),
1735 2,
1736 "the two writes are counted, the queries are not"
1737 );
1738
1739 let published: Vec<_> = drain(&mut consumer)
1740 .into_iter()
1741 .map(|(seq, slot)| (seq, slot.event))
1742 .collect();
1743 assert_eq!(
1744 published,
1745 [
1746 (0, JournalEvent::App(TestEvent::Query)),
1747 (1, JournalEvent::App(TestEvent::Query)),
1748 ],
1749 "only the queries enter the pipeline"
1750 );
1751
1752 let mut refused = Vec::new();
1753 assert!(queue.sync(), "the refusals are flushed");
1754 queue.release(u64::MAX, |r| refused.push(r));
1755 let expect = |input_seq, byte| crate::halt::Refusal {
1756 connection_id: 7,
1757 input_seq,
1758 report: (TestEvent::Cmd(byte), RejectReason::ReplicaDisconnected),
1759 };
1760 assert_eq!(
1761 refused,
1762 [expect(0, 0x01), expect(1, 0x02)],
1763 "each write is stamped with the sequence of the query that followed it"
1764 );
1765 }
1766
1767 /// A fenced node closes the connection, whatever its replica count:
1768 /// nothing is published, nothing is refused, nothing is counted.
1769 #[test]
1770 fn a_fenced_reader_closes_the_connection() {
1771 let Fixture {
1772 mut conn,
1773 mut producer,
1774 mut consumer,
1775 ..
1776 } = make_fixture(16);
1777 for byte in [0x01, 0xFF] {
1778 conn.parse_buf.extend_from_slice(&frame(byte));
1779 }
1780 let (mut refusals, mut queue) = refusal_channel();
1781 let counted = Arc::new(AtomicU64::new(0));
1782
1783 let (disconnect, control_rx) = run_process_frames_with(
1784 &mut conn,
1785 &mut producer,
1786 &gate_counting_into(Some(1), true, Arc::clone(&counted)),
1787 &mut refusals,
1788 );
1789
1790 assert!(disconnect, "the connection is dropped");
1791 assert_eq!(busy_events(&control_rx), 0);
1792 assert!(
1793 drain(&mut consumer).is_empty(),
1794 "not even the query is published"
1795 );
1796 assert!(!queue.sync(), "no refusal is queued");
1797 assert_eq!(refused_count(&counted), 0);
1798 }
1799
1800 /// Refusals that cannot be queued shed load the way a full input ring
1801 /// does, and the write is still not published.
1802 #[test]
1803 fn a_full_refusal_queue_is_pipeline_busy() {
1804 let Fixture {
1805 mut conn,
1806 mut producer,
1807 mut consumer,
1808 ..
1809 } = make_fixture(16);
1810 conn.parse_buf.extend_from_slice(&frame(0x01));
1811 let (mut refusals, _queue) = refusal_channel();
1812 let filler = crate::halt::Refusal {
1813 connection_id: 1,
1814 input_seq: 0,
1815 report: (TestEvent::Cmd(0), RejectReason::ReplicaDisconnected),
1816 };
1817 while refusals.try_send(filler).is_ok() {}
1818 let counted = Arc::new(AtomicU64::new(0));
1819
1820 let (disconnect, control_rx) = run_process_frames_with(
1821 &mut conn,
1822 &mut producer,
1823 &gate_counting_into(Some(0), false, Arc::clone(&counted)),
1824 &mut refusals,
1825 );
1826
1827 assert!(!disconnect);
1828 assert_eq!(busy_events(&control_rx), 1);
1829 assert!(drain(&mut consumer).is_empty());
1830 assert_eq!(
1831 refused_count(&counted),
1832 1,
1833 "a shed write still counts as refused by the halt"
1834 );
1835 }
1836
1837 /// The caller stamps `recv_ts` once per recv (at the kernel-return
1838 /// site) and every frame parsed from that buffer must carry that exact
1839 /// value. Guards against a regression to per-frame `recv_ts` capture,
1840 /// which excluded decode from the reader-ingest / server-e2e windows
1841 /// and drifted forward for later frames in a multi-frame recv. Gated on
1842 /// `latency-trace` because `recv_ts` is a real `u64` only then (`()`
1843 /// otherwise, leaving nothing to assert).
1844 #[cfg(feature = "latency-trace")]
1845 #[test]
1846 fn process_frames_stamps_every_slot_with_caller_recv_ts() {
1847 let Fixture {
1848 mut conn,
1849 mut producer,
1850 mut consumer,
1851 ..
1852 } = make_fixture(16);
1853 for byte in [0x01, 0x02, 0x03, 0x04] {
1854 conn.parse_buf.extend_from_slice(&frame(byte));
1855 }
1856
1857 // A recognizable sentinel the caller would have captured at the
1858 // recv site; distinct from the wall-clock stamp (0xDEAD_BEEF).
1859 const RECV_TS: u64 = 0x5EED_5EED;
1860
1861 let mut publish_rec = melin_transport_core::trace::register_stage("test: publish recv_ts");
1862 #[cfg(feature = "tick-to-trade")]
1863 let mut ingest_rec = melin_transport_core::trace::register_stage("test: ingest recv_ts");
1864
1865 let (control_tx, _control_rx) = mpsc::channel();
1866 let (mut refusals, _queue) = refusal_channel();
1867 let disconnect = process_frames::<TestApp, UnixStream>(
1868 &mut conn,
1869 &mut producer,
1870 &TagDecoder,
1871 &gate(None, false),
1872 &mut refusals,
1873 &control_tx,
1874 0xDEAD_BEEF,
1875 RECV_TS,
1876 &mut publish_rec,
1877 #[cfg(feature = "tick-to-trade")]
1878 &mut ingest_rec,
1879 );
1880 assert!(!disconnect);
1881
1882 let events = drain(&mut consumer);
1883 assert_eq!(events.len(), 4, "all frames published");
1884 for (_seq, slot) in &events {
1885 assert_eq!(
1886 slot.recv_ts, RECV_TS,
1887 "every frame from one recv shares the single caller-supplied recv_ts"
1888 );
1889 }
1890 }
1891
1892 #[test]
1893 fn process_frames_rotates_batch_at_commit_every_cap() {
1894 // Push more events than `COMMIT_EVERY` (= 16) into a recv-cycle.
1895 // The cap must trigger at least one mid-loop commit so the
1896 // consumer sees the first capacity-many events before the
1897 // remainder lands. Validates the visibility-delay cap from the
1898 // perf branch — without it, all 32 events would commit together
1899 // and the first frame would wait for the 32nd to decode.
1900 //
1901 // Capacity 64 leaves room for the entire input (no Full); ring
1902 // backpressure is exercised separately in
1903 // `process_frames_partial_commit_then_server_busy_when_pipeline_full`.
1904 let Fixture {
1905 mut conn,
1906 mut producer,
1907 mut consumer,
1908 ..
1909 } = make_fixture(64);
1910 const EVENT_COUNT: usize = 32;
1911 for i in 0..EVENT_COUNT {
1912 // Use bytes 1..=32 (each ≤ 0xFB so TagDecoder yields
1913 // `Permitted` with `request_seq == byte`).
1914 conn.parse_buf.extend_from_slice(&frame((i + 1) as u8));
1915 }
1916
1917 let (disconnect, _control_rx) = run_process_frames(&mut conn, &mut producer);
1918 assert!(!disconnect, "no oversize / no Full ⇒ no disconnect");
1919
1920 let events = drain(&mut consumer);
1921 assert_eq!(events.len(), EVENT_COUNT, "every event visible");
1922 for (i, (seq, slot)) in events.iter().enumerate() {
1923 assert_eq!(*seq, i as u64, "seq contiguous across batch rotations");
1924 let byte = (i + 1) as u8;
1925 assert_eq!(slot.event, JournalEvent::App(TestEvent::Cmd(byte)));
1926 }
1927 assert!(conn.parse_buf.is_empty());
1928 }
1929
1930 #[test]
1931 fn process_frames_query_event_skips_wall_clock_stamp() {
1932 // `AppEvent::is_query` events bypass the journal stamp — verify
1933 // the timestamp is zeroed even when a non-zero batch_wall_ns was
1934 // supplied.
1935 let Fixture {
1936 mut conn,
1937 mut producer,
1938 mut consumer,
1939 ..
1940 } = make_fixture(8);
1941 conn.parse_buf.extend_from_slice(&frame(0xFF)); // tag → Query
1942
1943 let (disconnect, _control_rx) = run_process_frames(&mut conn, &mut producer);
1944 assert!(!disconnect);
1945
1946 let events = drain(&mut consumer);
1947 assert_eq!(events.len(), 1);
1948 let (_, slot) = &events[0];
1949 assert_eq!(slot.event, JournalEvent::App(TestEvent::Query));
1950 assert_eq!(
1951 slot.timestamp_ns, 0,
1952 "query events must skip the wall-clock stamp"
1953 );
1954 }
1955
1956 #[test]
1957 fn process_frames_partial_commit_then_server_busy_when_pipeline_full() {
1958 // Ring capacity 4 + 6 frames ⇒ first 4 commit, 5th triggers Full,
1959 // 6th is never reached because the loop breaks on Full. Validates:
1960 // * `Err(Full)` does not roll back the prior 4 (single commit
1961 // happens before the ServerBusy write).
1962 // * The frame that triggered Full is silently dropped — its bytes
1963 // are compacted out of `parse_buf` along with every earlier
1964 // frame, mirroring pre-batch behaviour.
1965 // * Exactly one PipelineBusy event is routed to the response
1966 // stage, and nothing is written to the socket from here.
1967 let Fixture {
1968 mut conn,
1969 mut producer,
1970 mut consumer,
1971 mut peer,
1972 } = make_fixture(4);
1973 for byte in [0x01, 0x02, 0x03, 0x04, 0x05, 0x06] {
1974 conn.parse_buf.extend_from_slice(&frame(byte));
1975 }
1976
1977 let (disconnect, control_rx) = run_process_frames(&mut conn, &mut producer);
1978 assert!(!disconnect, "Full does not drop the connection");
1979
1980 let events = drain(&mut consumer);
1981 assert_eq!(
1982 events.len(),
1983 4,
1984 "only the first capacity-many frames are visible"
1985 );
1986 for (i, (_, slot)) in events.iter().enumerate() {
1987 let byte = (i + 1) as u8;
1988 assert_eq!(slot.event, JournalEvent::App(TestEvent::Cmd(byte)));
1989 }
1990
1991 // The busy notice goes to the response stage — never directly
1992 // onto the socket, where it could tear a partially-flushed
1993 // response frame (audit review F3).
1994 assert_eq!(
1995 busy_events(&control_rx),
1996 1,
1997 "exactly one PipelineBusy routed"
1998 );
1999 assert!(
2000 read_server_busy(&mut peer).is_none(),
2001 "reader must never write the client socket"
2002 );
2003
2004 // The frame that triggered Full (0x05) had its bytes consumed by
2005 // the loop's `cursor +=` before `try_push_with` ran; the 6th frame
2006 // is never inspected because the loop broke. Compaction shifts
2007 // the unprocessed tail (the 6th frame's bytes) to the front.
2008 assert_eq!(
2009 conn.parse_buf,
2010 frame(0x06).to_vec(),
2011 "the 6th frame remains in parse_buf for the next recv-cycle"
2012 );
2013 }
2014
2015 /// Regression for the 2026-08 io_uring audit, finding 2 and review
2016 /// finding F3 (docs/internal/io-uring-audit-2026-08.md): the
2017 /// pipeline-full path must not touch the client socket at all. The
2018 /// original defect was a blocking `write(2)` of ServerBusy that
2019 /// parked the reader thread whenever the offender's socket buffer
2020 /// was also full — stalling ingress for *every* connection exactly
2021 /// on the overload path (against that code this test never
2022 /// returns). The busy notice now routes to the response stage,
2023 /// which also closes the frame-tearing race of any reader-side
2024 /// send. The socket-buffer fill stays: it proves the path is
2025 /// insensitive to socket state.
2026 #[test]
2027 fn pipeline_full_never_touches_the_client_socket() {
2028 let Fixture {
2029 mut conn,
2030 mut producer,
2031 mut consumer,
2032 mut peer,
2033 } = make_fixture(2);
2034
2035 // Fill the server→peer direction until the kernel refuses more —
2036 // the state a non-reading client leaves the connection in.
2037 let junk = [0u8; 4096];
2038 loop {
2039 let n = unsafe {
2040 libc::send(
2041 conn.fd,
2042 junk.as_ptr().cast(),
2043 junk.len(),
2044 libc::MSG_DONTWAIT,
2045 )
2046 };
2047 if n < 0 {
2048 assert_eq!(
2049 std::io::Error::last_os_error().raw_os_error(),
2050 Some(libc::EAGAIN),
2051 "unexpected errno while filling socket"
2052 );
2053 break;
2054 }
2055 }
2056
2057 // Capacity 2 + 3 frames ⇒ the third triggers Full ⇒ ServerBusy.
2058 conn.parse_buf.extend_from_slice(&frame(0x01));
2059 conn.parse_buf.extend_from_slice(&frame(0x02));
2060 conn.parse_buf.extend_from_slice(&frame(0x03));
2061
2062 let start = std::time::Instant::now();
2063 let (disconnect, control_rx) = run_process_frames(&mut conn, &mut producer);
2064 // Generous bound to stay unflaky under CI load — the fixed path
2065 // returns in microseconds, the broken one only when the peer
2066 // reads (here: never).
2067 assert!(
2068 start.elapsed() < Duration::from_secs(1),
2069 "pipeline-full path blocked on a full socket"
2070 );
2071 assert!(!disconnect, "Full does not drop the connection");
2072 assert_eq!(
2073 drain(&mut consumer).len(),
2074 2,
2075 "the frames that fit are still published"
2076 );
2077 assert_eq!(
2078 busy_events(&control_rx),
2079 1,
2080 "busy notice routed to response stage"
2081 );
2082 // Nothing beyond the pre-fill junk arrives: drain it, then the
2083 // read must time out rather than yield a reader-written frame.
2084 let mut sink = [0u8; 4096];
2085 loop {
2086 match peer.read(&mut sink) {
2087 Ok(0) => panic!("unexpected EOF"),
2088 Ok(_) => continue,
2089 Err(e) if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut => {
2090 break;
2091 }
2092 Err(e) => panic!("unexpected peer read error: {e}"),
2093 }
2094 }
2095 }
2096
2097 #[test]
2098 fn process_frames_oversize_commits_prior_frames_then_signals_disconnect() {
2099 // Two valid frames followed by an oversize length prefix must:
2100 // * publish the two valid frames (commit-before-break) so the
2101 // pipeline observes them even though we're about to tear the
2102 // connection down,
2103 // * return `true` so the caller drops the connection,
2104 // * NOT emit PipelineBusy (that is reserved for pipeline-full).
2105 let Fixture {
2106 mut conn,
2107 mut producer,
2108 mut consumer,
2109 mut peer,
2110 } = make_fixture(16);
2111 conn.parse_buf.extend_from_slice(&frame(0x01));
2112 conn.parse_buf.extend_from_slice(&frame(0x02));
2113 conn.parse_buf.extend_from_slice(&oversize_prefix());
2114
2115 let (disconnect, control_rx) = run_process_frames(&mut conn, &mut producer);
2116 assert!(disconnect, "oversize frame must request disconnect");
2117
2118 let events = drain(&mut consumer);
2119 assert_eq!(
2120 events.len(),
2121 2,
2122 "prior frames are committed before the break"
2123 );
2124 assert_eq!(events[0].1.event, JournalEvent::App(TestEvent::Cmd(0x01)));
2125 assert_eq!(events[1].1.event, JournalEvent::App(TestEvent::Cmd(0x02)));
2126
2127 assert_eq!(
2128 busy_events(&control_rx),
2129 0,
2130 "PipelineBusy is emitted on Full, not on oversize"
2131 );
2132 assert!(
2133 read_server_busy(&mut peer).is_none(),
2134 "reader must never write the client socket"
2135 );
2136 }
2137
2138 #[test]
2139 fn process_frames_filters_denied_and_decode_errors_advance_cursor() {
2140 // Mixed batch: Permitted, Filter, PermissionDenied, DecodeError,
2141 // Permitted. Only the two Permitted frames must reach the
2142 // consumer; all bytes are consumed (parse_buf fully drains).
2143 let Fixture {
2144 mut conn,
2145 mut producer,
2146 mut consumer,
2147 ..
2148 } = make_fixture(16);
2149 for byte in [0x01, 0xFC, 0xFD, 0xFE, 0x02] {
2150 conn.parse_buf.extend_from_slice(&frame(byte));
2151 }
2152
2153 let (disconnect, _control_rx) = run_process_frames(&mut conn, &mut producer);
2154 assert!(!disconnect);
2155
2156 let events = drain(&mut consumer);
2157 assert_eq!(events.len(), 2);
2158 assert_eq!(events[0].1.event, JournalEvent::App(TestEvent::Cmd(0x01)));
2159 assert_eq!(events[1].1.event, JournalEvent::App(TestEvent::Cmd(0x02)));
2160 assert!(
2161 conn.parse_buf.is_empty(),
2162 "all bytes advanced past compaction"
2163 );
2164 }
2165
2166 #[test]
2167 fn process_frames_preserves_partial_trailing_frame() {
2168 // One complete frame followed by a truncated length prefix. The
2169 // complete frame must publish; the partial bytes must survive
2170 // compaction at the front of `parse_buf` for the next recv-cycle.
2171 let Fixture {
2172 mut conn,
2173 mut producer,
2174 mut consumer,
2175 ..
2176 } = make_fixture(16);
2177 conn.parse_buf.extend_from_slice(&frame(0x42));
2178 // Three of four length-prefix bytes — `cursor + 4 <= len()` is
2179 // false, so the loop breaks before consuming anything from the
2180 // partial.
2181 conn.parse_buf.extend_from_slice(&[0xDE, 0xAD, 0xBE]);
2182
2183 let (disconnect, _control_rx) = run_process_frames(&mut conn, &mut producer);
2184 assert!(!disconnect);
2185
2186 let events = drain(&mut consumer);
2187 assert_eq!(events.len(), 1);
2188 assert_eq!(events[0].1.event, JournalEvent::App(TestEvent::Cmd(0x42)));
2189 assert_eq!(
2190 conn.parse_buf,
2191 vec![0xDE, 0xAD, 0xBE],
2192 "partial length prefix preserved for next recv-cycle"
2193 );
2194 }
2195
2196 #[test]
2197 fn process_frames_empty_buffer_is_noop() {
2198 // No bytes in parse_buf ⇒ loop never enters; commit is the
2199 // documented zero-slot no-op; no busy event; no disconnect.
2200 let Fixture {
2201 mut conn,
2202 mut producer,
2203 mut consumer,
2204 mut peer,
2205 } = make_fixture(4);
2206
2207 let (disconnect, control_rx) = run_process_frames(&mut conn, &mut producer);
2208 assert!(!disconnect);
2209 assert_eq!(drain(&mut consumer).len(), 0);
2210 assert!(conn.parse_buf.is_empty());
2211 assert_eq!(busy_events(&control_rx), 0);
2212 assert!(read_server_busy(&mut peer).is_none());
2213 }
2214
2215 /// End-to-end soak through the real reader loop: 4 connections
2216 /// concurrently write 10 000 frames each, one `write(2)` per frame,
2217 /// so the receive path churns through the shared buffer pool and
2218 /// its recycle machinery under genuine concurrency (buf_ring where
2219 /// the kernel supports it, legacy ProvideBuffers otherwise — this
2220 /// test is the coverage for whichever mode the host engages).
2221 ///
2222 /// The failure signatures of a recycle bug are exactly what is
2223 /// asserted: a torn or misordered frame (a buffer reused while the
2224 /// kernel still owned it), a lost frame, or a spurious disconnect
2225 /// (`ENOBUFS` mishandled as a client error).
2226 #[test]
2227 fn reader_loop_soak_delivers_every_frame_in_order() {
2228 use std::io::Write as _;
2229
2230 const CONNS: u64 = 4;
2231 const FRAMES: u32 = 10_000;
2232
2233 // Capacity ≥ total frames: even if this thread is descheduled
2234 // and stops draining, the ring cannot fill, so the reader never
2235 // sheds load (which would legitimately drop frames and turn a
2236 // CI hiccup into a false failure).
2237 let (producer, mut consumers) = DisruptorBuilder::<InputSlot<TestEvent>>::new(65536)
2238 .add_consumer()
2239 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
2240 let mut consumer = consumers.pop().expect("consumer");
2241 let (control_tx, control_rx) = mpsc::channel();
2242 let shutdown = Arc::new(AtomicBool::new(false));
2243
2244 let mut handle = spawn_reader::<TestApp, UnixStream>(
2245 producer,
2246 Arc::new(TagDecoder),
2247 gate(None, false),
2248 refusal_channel().0,
2249 control_tx,
2250 0, // "do not pin" sentinel
2251 None, // no idle timeout — a CI stall must not disconnect
2252 None, // no tick generator
2253 Arc::clone(&shutdown),
2254 );
2255
2256 let mut writers = Vec::new();
2257 for id in 0..CONNS {
2258 let (client, server_side) = UnixStream::pair().expect("socketpair");
2259 handle.register(ReaderRegistration {
2260 connection_id: ConnectionId(id),
2261 reader: server_side,
2262 addr: "127.0.0.1:1".parse().expect("addr"),
2263 permission: Permission::Trader,
2264 key_hash: id,
2265 });
2266 writers.push(std::thread::spawn(move || {
2267 let mut client = client;
2268 for i in 0..FRAMES {
2269 let byte = (i % 200 + 1) as u8;
2270 client.write_all(&frame(byte)).expect("client write");
2271 // Brief pauses fragment the stream so the reader
2272 // sees many small recvs (heavy buffer churn) rather
2273 // than a few large coalesced ones.
2274 if i % 512 == 0 {
2275 std::thread::sleep(Duration::from_micros(200));
2276 }
2277 }
2278 client // keep the socket alive until after the drain
2279 }));
2280 }
2281
2282 // Drain until every frame arrived or the deadline passes. The
2283 // deadline is generous — the run takes well under a second on
2284 // an idle machine — because a genuine recycle deadlock must
2285 // fail loudly, not hang CI.
2286 let deadline = Instant::now() + Duration::from_secs(60);
2287 let mut per_conn_count = [0u32; CONNS as usize];
2288 let mut total: u64 = 0;
2289 while total < CONNS * FRAMES as u64 {
2290 assert!(
2291 Instant::now() < deadline,
2292 "soak stalled: {total} of {} frames after 60s ({per_conn_count:?})",
2293 CONNS * FRAMES as u64
2294 );
2295 while let Some((_seq, slot)) = consumer.try_consume() {
2296 let conn = slot.connection_id as usize;
2297 assert!(conn < CONNS as usize, "unknown connection id");
2298 let expected = (per_conn_count[conn] % 200 + 1) as u8;
2299 assert_eq!(
2300 slot.event,
2301 JournalEvent::App(TestEvent::Cmd(expected)),
2302 "conn {conn} frame {} out of order or corrupted",
2303 per_conn_count[conn]
2304 );
2305 per_conn_count[conn] += 1;
2306 total += 1;
2307 }
2308 std::hint::spin_loop();
2309 }
2310
2311 // Any control event at this point is a defect: sockets are
2312 // still alive (held by the writer join handles) and the ring
2313 // never filled. Every arm diverges, so inspecting the first
2314 // queued event suffices.
2315 if let Ok(event) = control_rx.try_recv() {
2316 match event {
2317 ControlEvent::Disconnected { connection_id } => {
2318 panic!("spurious disconnect of connection {connection_id}")
2319 }
2320 ControlEvent::PipelineBusy { connection_id } => {
2321 panic!("spurious pipeline-full for connection {connection_id}")
2322 }
2323 ControlEvent::Connected { .. } => {
2324 unreachable!("reader never sends Connected")
2325 }
2326 }
2327 }
2328
2329 let _clients: Vec<UnixStream> = writers
2330 .into_iter()
2331 .map(|w| w.join().expect("writer thread"))
2332 .collect();
2333 handle.shutdown();
2334 handle.join();
2335 }
2336
2337 /// Shutdown with live, armed connections must quiesce the ring
2338 /// provably and promptly. The armed multishot RECVs sit on sockets
2339 /// whose peers never disconnect, so nothing completes them on its
2340 /// own — the teardown's wakes (per-connection `shutdown(2)`, the
2341 /// eventfd write) are what retire them, including on hosts that
2342 /// filter the AsyncCancel opcode, where the old cancel-then-quiet
2343 /// teardown could conclude "done" with arms still live against the
2344 /// soon-freed pool. A drain that cannot prove quiescence only
2345 /// returns at the full deadline (and leaks the buffers), so
2346 /// returning well inside it is the proof this test can observe.
2347 #[test]
2348 fn shutdown_quiesces_armed_connections_promptly() {
2349 use std::io::Write as _;
2350
2351 const CONNS: u64 = 3;
2352 let (producer, mut consumers) = DisruptorBuilder::<InputSlot<TestEvent>>::new(1024)
2353 .add_consumer()
2354 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
2355 let mut consumer = consumers.pop().expect("consumer");
2356 let (control_tx, _control_rx) = mpsc::channel();
2357 let shutdown = Arc::new(AtomicBool::new(false));
2358 let mut handle = spawn_reader::<TestApp, UnixStream>(
2359 producer,
2360 Arc::new(TagDecoder),
2361 gate(None, false),
2362 refusal_channel().0,
2363 control_tx,
2364 0, // "do not pin" sentinel
2365 None, // no idle timeout
2366 None, // no tick generator
2367 Arc::clone(&shutdown),
2368 );
2369
2370 let mut clients = Vec::new();
2371 for id in 0..CONNS {
2372 let (mut client, server_side) = UnixStream::pair().expect("socketpair");
2373 handle.register(ReaderRegistration {
2374 connection_id: ConnectionId(id),
2375 reader: server_side,
2376 addr: "127.0.0.1:1".parse().expect("addr"),
2377 permission: Permission::Trader,
2378 key_hash: id,
2379 });
2380 // One delivered frame per connection proves its multishot
2381 // is armed and active before the shutdown fires.
2382 client.write_all(&frame(7)).expect("client write");
2383 clients.push(client);
2384 }
2385 let deadline = Instant::now() + Duration::from_secs(30);
2386 let mut seen = 0u64;
2387 while seen < CONNS {
2388 assert!(
2389 Instant::now() < deadline,
2390 "frames never arrived ({seen}/{CONNS})"
2391 );
2392 while consumer.try_consume().is_some() {
2393 seen += 1;
2394 }
2395 std::hint::spin_loop();
2396 }
2397
2398 // The clients stay alive across the shutdown: no peer
2399 // disconnect can complete the armed RECVs — only the teardown's
2400 // own wakes can.
2401 let started = Instant::now();
2402 handle.shutdown();
2403 handle.join();
2404 let elapsed = started.elapsed();
2405 assert!(
2406 elapsed < crate::uring_teardown::DRAIN_TIMEOUT / 4,
2407 "reader teardown took {elapsed:?}, close to the drain deadline — \
2408 armed operations were not woken"
2409 );
2410 drop(clients);
2411 }
2412
2413 /// Regression for audit review F1
2414 /// (docs/internal/io-uring-audit-2026-08.md): a connection torn down
2415 /// while its multishot RECV is still armed must not have its slab
2416 /// index reused while the kernel can still post CQEs carrying it.
2417 /// Pre-fix, the LIFO free list handed client A's index straight to
2418 /// client B; A's socket stayed open (the armed op holds a file
2419 /// reference past the fd close), and A's continued bytes were parsed
2420 /// under B's connection id, key hash, and permissions — order-flow
2421 /// injection under someone else's identity. Post-fix, A's entry dies
2422 /// in place until the cancelled op's terminal CQE, so B gets a fresh
2423 /// index and A's post-teardown bytes are discarded.
2424 #[test]
2425 fn torn_down_connection_cannot_inject_frames_into_a_reused_slot() {
2426 use std::io::Write as _;
2427
2428 let (producer, mut consumers) = DisruptorBuilder::<InputSlot<TestEvent>>::new(65536)
2429 .add_consumer()
2430 .build(melin_pipeline::wait::WaitStrategy::SpinThenYield);
2431 let mut consumer = consumers.pop().expect("consumer");
2432 let (control_tx, control_rx) = mpsc::channel();
2433 let shutdown = Arc::new(AtomicBool::new(false));
2434 let mut handle = spawn_reader::<TestApp, UnixStream>(
2435 producer,
2436 Arc::new(TagDecoder),
2437 gate(None, false),
2438 refusal_channel().0,
2439 control_tx,
2440 0,
2441 None,
2442 None,
2443 Arc::clone(&shutdown),
2444 );
2445
2446 const A_ID: u64 = 100;
2447 const B_ID: u64 = 200;
2448
2449 // Client A: valid frames (0x01..=0x08), an oversize prefix (the
2450 // teardown trigger), then a sustained stream of would-be
2451 // injection frames from A's byte range.
2452 let (a_client, a_server) = UnixStream::pair().expect("socketpair");
2453 handle.register(ReaderRegistration {
2454 connection_id: ConnectionId(A_ID),
2455 reader: a_server,
2456 addr: "127.0.0.1:1".parse().expect("addr"),
2457 permission: Permission::Trader,
2458 key_hash: A_ID,
2459 });
2460 let a_writer = std::thread::spawn(move || {
2461 let mut a = a_client;
2462 for byte in 0x01..=0x08u8 {
2463 let _ = a.write_all(&frame(byte));
2464 }
2465 let _ = a.write_all(&oversize_prefix());
2466 // Keep writing after the reader repudiates us. Write errors
2467 // (EPIPE once teardown's shutdown(2) lands) are the fix
2468 // doing its job — ignore them and keep trying.
2469 let stop = Instant::now() + Duration::from_millis(300);
2470 while Instant::now() < stop {
2471 let _ = a.write_all(&frame(0x05));
2472 std::thread::sleep(Duration::from_micros(50));
2473 }
2474 a
2475 });
2476
2477 // Wait for A's teardown announcement, then register B — the
2478 // instant a buggy free list would hand B the still-armed index.
2479 let deadline = Instant::now() + Duration::from_secs(10);
2480 loop {
2481 match control_rx.try_recv() {
2482 Ok(ControlEvent::Disconnected { connection_id }) if connection_id == A_ID => break,
2483 Ok(_) => {}
2484 Err(_) => {
2485 assert!(Instant::now() < deadline, "no Disconnected for A");
2486 std::thread::sleep(Duration::from_millis(1));
2487 }
2488 }
2489 }
2490
2491 const B_FRAMES: u32 = 2_000;
2492 let (b_client, b_server) = UnixStream::pair().expect("socketpair");
2493 handle.register(ReaderRegistration {
2494 connection_id: ConnectionId(B_ID),
2495 reader: b_server,
2496 addr: "127.0.0.1:2".parse().expect("addr"),
2497 permission: Permission::Trader,
2498 key_hash: B_ID,
2499 });
2500 let b_writer = std::thread::spawn(move || {
2501 let mut b = b_client;
2502 for i in 0..B_FRAMES {
2503 let byte = 0x21 + (i % 0x10) as u8; // 0x21..=0x30, disjoint from A
2504 b.write_all(&frame(byte)).expect("B write");
2505 if i % 128 == 0 {
2506 std::thread::sleep(Duration::from_micros(100));
2507 }
2508 }
2509 b
2510 });
2511
2512 // Drain until all of B's frames arrive. Every B-attributed slot
2513 // must carry B's key hash and a B-range byte; anything else is
2514 // the injection this test exists to prevent.
2515 let deadline = Instant::now() + Duration::from_secs(30);
2516 let mut b_seen = 0u32;
2517 while b_seen < B_FRAMES {
2518 assert!(
2519 Instant::now() < deadline,
2520 "stalled: {b_seen}/{B_FRAMES} B frames"
2521 );
2522 while let Some((_seq, slot)) = consumer.try_consume() {
2523 match slot.event {
2524 JournalEvent::App(TestEvent::Cmd(byte)) => {
2525 if slot.connection_id == B_ID {
2526 assert_eq!(slot.key_hash, B_ID, "B slot with foreign key hash");
2527 assert!(
2528 (0x21..=0x30).contains(&byte),
2529 "byte {byte:#x} injected into B's stream"
2530 );
2531 b_seen += 1;
2532 } else {
2533 assert_eq!(slot.connection_id, A_ID, "unknown connection id");
2534 assert!(
2535 (0x01..=0x08).contains(&byte),
2536 "unexpected byte {byte:#x} on A"
2537 );
2538 }
2539 }
2540 ref other => panic!("unexpected event {other:?}"),
2541 }
2542 }
2543 std::hint::spin_loop();
2544 }
2545
2546 let _a = a_writer.join().expect("A writer");
2547 let _b = b_writer.join().expect("B writer");
2548 handle.shutdown();
2549 handle.join();
2550 }
2551}