Skip to main content

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