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