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 provided buffer
6//! groups (`IOSQE_BUFFER_SELECT`), the kernel selects a buffer from a
7//! shared pool for each recv, replacing per-connection buffer allocations.
8//!
9//! Uses a single reader thread — io_uring is efficient enough for hundreds
10//! of connections. New connections are registered via eventfd wakeup.
11//!
12//! Connection state is stored in a slab (index-stable Vec) so that io_uring
13//! user_data carries a slab index, not an fd. This avoids fd-reuse races
14//! where a recycled fd number could match a stale CQE.
15
16use std::collections::HashMap;
17use std::net::SocketAddr;
18use std::os::unix::io::{AsRawFd, RawFd};
19use std::sync::Arc;
20use std::sync::atomic::{AtomicBool, Ordering};
21use std::sync::mpsc;
22use std::thread::JoinHandle;
23use std::time::{Duration, Instant};
24
25use io_uring::{IoUring, opcode, types};
26use tracing::{debug, error};
27
28use crate::ControlEvent;
29use melin_app::Application;
30use melin_app::auth::Permission;
31use melin_app::decoder::RequestDecoder;
32
33/// Decoder type alias: request decoder bound to the application's `Event`
34/// type. Mirrors [`crate::response::ResponseEncoderArc`]; hides
35/// the `dyn RequestDecoder<Event = …>` spelling at call sites that thread
36/// the decoder through several functions.
37pub type RequestDecoderArc<A> = Arc<dyn RequestDecoder<Event = <A as Application>::Event>>;
38use melin_app::unix_epoch_nanos;
39use melin_pipeline::ring;
40use melin_transport_core::pipeline::InputSlot;
41use melin_wire_protocol::control::TransportResponse;
42use melin_wire_protocol::control_codec;
43
44/// Size of each provided buffer. 4 KiB accommodates multiple frames per
45/// recv (frames are typically <100 bytes).
46const BUF_SIZE: usize = 4096;
47
48/// Number of provided buffers in the shared pool. Must be large enough
49/// to handle concurrent in-flight recvs across all connections. When the
50/// pool is exhausted, multishot terminates and is resubmitted after buffers
51/// are re-provided. 2048 supports up to ~1024 connections per reader
52/// thread with headroom for burst re-provision lag.
53const NUM_BUFFERS: u16 = 2048;
54
55/// Buffer group ID for the provided recv buffer pool.
56const BUF_GROUP_ID: u16 = 0;
57
58use crate::client_frames::MAX_FRAME_SIZE;
59
60/// io_uring submission queue depth. Power of 2, sized for up to ~1024
61/// connections per reader thread (multishot RECVs + eventfd read +
62/// buffer re-provisions).
63const RING_SIZE: u32 = 4096;
64
65/// User data sentinel for the eventfd read SQE.
66const EVENTFD_TOKEN: u64 = u64::MAX;
67
68/// User data sentinel for ProvideBuffers CQEs. These are best-effort
69/// re-provisions — we log errors but don't act on success.
70const PROVIDE_BUFS_TOKEN: u64 = u64::MAX - 1;
71
72/// User data sentinel for the tick timeout SQE. The reader arms a single
73/// `IORING_OP_TIMEOUT` per cadence so `submit_and_wait` returns at the tick
74/// deadline even when no client traffic is flowing. The CQE itself carries
75/// no information; the loop body checks `Instant::now()` against the next
76/// deadline and emits the actual `JournalEvent::Tick`.
77const TICK_TIMEOUT_TOKEN: u64 = u64::MAX - 2;
78
79/// CQE flag: buffer ID is valid in upper 16 bits of flags.
80const IORING_CQE_F_BUFFER: u32 = 1 << 0;
81
82/// CQE flag: more completions coming from this multishot operation.
83const IORING_CQE_F_MORE: u32 = 1 << 1;
84
85/// Bit shift to extract buffer ID from CQE flags.
86const IORING_CQE_BUFFER_SHIFT: u32 = 16;
87
88use melin_wire_protocol::control::ConnectionId;
89
90/// Command sent from the accept loop to a reader thread.
91pub struct ReaderRegistration<R> {
92    pub connection_id: ConnectionId,
93    pub reader: R,
94    pub addr: SocketAddr,
95    /// Permission level established during the auth handshake.
96    pub permission: Permission,
97    /// FxHash of the client's Ed25519 public key. Stored per-connection
98    /// and copied into every InputSlot for per-key idempotency dedup.
99    pub key_hash: u64,
100}
101
102/// Handle for the accept loop to register connections with the io_uring reader.
103pub struct UringReaderHandle<R> {
104    tx: mpsc::Sender<ReaderRegistration<R>>,
105    event_fd: RawFd,
106    join_handle: Option<JoinHandle<()>>,
107    shutdown: Arc<AtomicBool>,
108}
109
110impl<R> UringReaderHandle<R> {
111    /// Register a new connection with the reader thread.
112    ///
113    /// If the reader thread's channel is dead (thread panicked), logs an
114    /// error and signals shutdown so the server can restart cleanly.
115    pub fn register(&mut self, registration: ReaderRegistration<R>) {
116        if self.tx.send(registration).is_ok() {
117            // Signal the eventfd to wake the reader from io_uring_enter.
118            let val: u64 = 1;
119            unsafe {
120                libc::write(self.event_fd, &val as *const u64 as *const libc::c_void, 8);
121            }
122        } else {
123            error!("reader thread dead, cannot register connection");
124            self.shutdown.store(true, Ordering::Relaxed);
125        }
126    }
127
128    /// Signal the reader thread to shut down and wake it from io_uring_enter.
129    pub fn shutdown(&self) {
130        self.shutdown.store(true, Ordering::Relaxed);
131        let val: u64 = 1;
132        unsafe {
133            libc::write(self.event_fd, &val as *const u64 as *const libc::c_void, 8);
134        }
135    }
136
137    /// Join the reader thread. Call after `shutdown()`.
138    pub fn join(mut self) {
139        if let Some(handle) = self.join_handle.take()
140            && let Err(panic) = handle.join()
141        {
142            let msg = panic
143                .downcast_ref::<&str>()
144                .copied()
145                .or_else(|| panic.downcast_ref::<String>().map(|s| s.as_str()))
146                .unwrap_or("<non-string panic>");
147            error!(message = msg, "reader thread panicked");
148        }
149    }
150}
151
152/// Spawn the io_uring reader thread. Returns a handle for registering
153/// connections.
154///
155/// One reader thread serves every TCP connection on the server. io_uring
156/// with multishot RECV multiplexes thousands of sockets efficiently and the
157/// matching stage is the throughput limit, so adding more reader threads
158/// would not raise throughput — it would only re-introduce contention on
159/// the input ring's multi-producer cursor.
160///
161/// `tick_cadence: Some(d)` makes the reader the engine's tick generator: it
162/// arms an `IORING_OP_TIMEOUT` so `submit_and_wait` returns at the tick
163/// deadline even when no client traffic is flowing, then publishes a
164/// `JournalEvent::Tick { now_ns }` onto the same input ring it uses for
165/// client requests. Pass `None` to disable the tick (useful for benchmarks
166/// that don't exercise time-driven features).
167pub fn spawn_reader<A: Application, R: AsRawFd + Send + 'static>(
168    producer: ring::Producer<InputSlot<A::Event>>,
169    decoder: Arc<dyn RequestDecoder<Event = A::Event>>,
170    control_tx: mpsc::Sender<ControlEvent>,
171    core: usize,
172    connection_timeout: Option<Duration>,
173    tick_cadence: Option<Duration>,
174    shutdown: Arc<AtomicBool>,
175) -> UringReaderHandle<R>
176where
177    A::Event: Send + Sync + 'static,
178{
179    let (tx, rx) = mpsc::channel();
180
181    let event_fd = unsafe { libc::eventfd(0, libc::EFD_NONBLOCK) };
182    assert!(event_fd >= 0, "eventfd creation failed");
183
184    let wakeup_fd = event_fd;
185    let shutdown_clone = Arc::clone(&shutdown);
186
187    let handle = std::thread::Builder::new()
188        .name("uring-reader".into())
189        .spawn(move || {
190            // `core == 0` is the "do not pin" sentinel — see
191            // `crate::affinity` module docs.
192            if core == 0 {
193                tracing::info!(thread = "uring-reader", "thread left unpinned (core 0 sentinel)");
194            } else {
195                match melin_app::affinity::pin_to_core(core) {
196                    Ok(c) => {
197                        tracing::info!(thread = "uring-reader", core = c, "pinned to core")
198                    }
199                    Err(e) => tracing::warn!(thread = "uring-reader", core = core, error = %e, "failed to pin"),
200                }
201            }
202            reader_loop::<A, R>(
203                rx,
204                wakeup_fd,
205                producer,
206                &*decoder,
207                &control_tx,
208                connection_timeout,
209                tick_cadence,
210                &shutdown_clone,
211            );
212        })
213        .expect("failed to spawn uring reader thread");
214
215    UringReaderHandle {
216        tx,
217        event_fd,
218        join_handle: Some(handle),
219        shutdown,
220    }
221}
222
223// ---------------------------------------------------------------------------
224// Slab-based connection storage
225// ---------------------------------------------------------------------------
226
227/// Per-connection state for multishot io_uring recv + incremental frame parsing.
228struct ConnectionEntry<R> {
229    connection_id: u64,
230    addr: SocketAddr,
231    /// Permission level from auth handshake. Checked per-request on
232    /// the reader thread (cold path), zero cost on the matching engine.
233    permission: Permission,
234    /// FxHash of the client's Ed25519 public key. Copied into every
235    /// InputSlot for per-key idempotency dedup.
236    key_hash: u64,
237    /// Owned reader — keeps the fd alive. Dropping closes the fd.
238    _reader: R,
239    fd: RawFd,
240    /// Accumulated bytes not yet parsed into complete frames.
241    /// Grows when partial frames arrive, shrinks when frames are consumed.
242    parse_buf: Vec<u8>,
243    /// True if a multishot RecvMulti is currently active for this connection.
244    /// Multishot stays active until the kernel clears IORING_CQE_F_MORE
245    /// (e.g., buffer pool exhaustion, socket error, or EOF).
246    multishot_active: bool,
247    /// Last time any data was received on this connection. Used for
248    /// idle timeout detection.
249    last_activity: Instant,
250}
251
252/// Index-stable allocator for connection state. Slab indices are used as
253/// io_uring user_data, avoiding fd-reuse races.
254struct ConnectionSlab<R> {
255    entries: Vec<Option<ConnectionEntry<R>>>,
256    /// Recycled indices for O(1) allocation.
257    free: Vec<usize>,
258}
259
260impl<R> ConnectionSlab<R> {
261    fn new() -> Self {
262        Self {
263            entries: Vec::with_capacity(256),
264            free: Vec::new(),
265        }
266    }
267
268    /// Insert a connection, returning its stable slab index.
269    fn insert(&mut self, entry: ConnectionEntry<R>) -> usize {
270        if let Some(idx) = self.free.pop() {
271            self.entries[idx] = Some(entry);
272            idx
273        } else {
274            let idx = self.entries.len();
275            self.entries.push(Some(entry));
276            idx
277        }
278    }
279
280    fn get_mut(&mut self, idx: usize) -> Option<&mut ConnectionEntry<R>> {
281        self.entries.get_mut(idx).and_then(|e| e.as_mut())
282    }
283
284    /// Remove and return a connection entry, recycling its index.
285    fn remove(&mut self, idx: usize) -> Option<ConnectionEntry<R>> {
286        if let Some(slot) = self.entries.get_mut(idx) {
287            let removed = slot.take();
288            if removed.is_some() {
289                self.free.push(idx);
290            }
291            removed
292        } else {
293            None
294        }
295    }
296}
297
298// ---------------------------------------------------------------------------
299// Main loop
300// ---------------------------------------------------------------------------
301
302/// Main io_uring reader loop. Runs until channel disconnection.
303///
304/// When `tick_cadence` is `Some`, the loop also generates the engine's
305/// scheduler ticks — see [`spawn_reader`] for the rationale.
306fn reader_loop<A: Application, R: AsRawFd>(
307    command_rx: mpsc::Receiver<ReaderRegistration<R>>,
308    wakeup_fd: RawFd,
309    mut producer: ring::Producer<InputSlot<A::Event>>,
310    decoder: &dyn RequestDecoder<Event = A::Event>,
311    control_tx: &mpsc::Sender<ControlEvent>,
312    connection_timeout: Option<Duration>,
313    tick_cadence: Option<Duration>,
314    shutdown: &AtomicBool,
315) {
316    let mut ring = IoUring::new(RING_SIZE).expect("failed to create io_uring instance");
317
318    // Pre-encode the ServerBusy response frame (length prefix + tag = 5 bytes).
319    let server_busy_frame = {
320        let mut buf = [0u8; 8];
321        let n = control_codec::encode_transport_response(&TransportResponse::ServerBusy, &mut buf)
322            .expect("ServerBusy encodes");
323        let mut frame = [0u8; 5];
324        frame.copy_from_slice(&buf[..n]);
325        frame
326    };
327
328    let mut slab = ConnectionSlab::<R>::new();
329    // Reverse map for cleanup when a connection's fd needs removal.
330    // HashMap for O(1) lookup by fd. Sized for typical connection counts.
331    let mut fd_to_slab: HashMap<RawFd, usize> = HashMap::with_capacity(256);
332
333    // Eventfd read buffer — boxed for pointer stability across SQE lifetimes.
334    let mut eventfd_buf: Box<[u8; 8]> = Box::new([0u8; 8]);
335
336    // Shared buffer pool for provided buffers. Contiguous allocation of
337    // NUM_BUFFERS × BUF_SIZE bytes. The kernel selects a buffer from this
338    // pool for each recv completion, identified by buffer ID in the CQE.
339    let mut buffer_pool = vec![0u8; NUM_BUFFERS as usize * BUF_SIZE].into_boxed_slice();
340
341    // Pre-allocated CQE collection buffer. We must collect CQEs before
342    // processing because the CQ borrow must end before pushing new SQEs.
343    // Stores (user_data, result, flags) — flags needed for buffer ID and
344    // multishot continuation.
345    let mut cqes: Vec<(u64, i32, u32)> = Vec::with_capacity(RING_SIZE as usize);
346
347    // Register the provided buffer pool with io_uring.
348    register_buffer_pool(&mut ring, buffer_pool.as_mut_ptr());
349
350    // Submit the initial eventfd read so we wake on first connection.
351    push_eventfd_read(&mut ring, wakeup_fd, eventfd_buf.as_mut_ptr());
352
353    // Stage histograms via the global registry. `publish` is the
354    // narrow ring-publish call cost (lightweight, gated on
355    // `latency-trace`); `ingest` is the full per-frame reader cost
356    // and feeds the bench's tick-to-trade decomposition (heavier,
357    // gated on `tick-to-trade`).
358    #[cfg(feature = "latency-trace")]
359    let mut publish_rec =
360        melin_transport_core::trace::register_stage("reader: publish (decode → disruptor publish)");
361    #[cfg(feature = "tick-to-trade")]
362    let mut ingest_rec =
363        melin_transport_core::trace::register_stage("reader: ingest (recv_ts → publish complete)");
364
365    // Coarse gate for timeout scanning — avoids scanning on every
366    // submit_and_wait return during high throughput.
367    let mut last_timeout_scan = Instant::now();
368    // Pre-allocated buffer for stale connection indices to avoid
369    // heap allocation inside the hot loop.
370    let mut stale: Vec<(usize, u64, RawFd)> = Vec::new();
371
372    // Tick generator state. `next_tick_deadline` is the monotonic instant the
373    // next `JournalEvent::Tick` should fire. `last_tick_ns` enforces strict
374    // monotonicity on the wall-clock timestamps published in those events
375    // (NTP can step the wall clock backwards). `tick_armed` tracks whether
376    // an `IORING_OP_TIMEOUT` SQE is currently pending; we keep at most one.
377    //
378    // `tick_ts` lives across loop iterations because the kernel reads its
379    // bytes via the SQE's addr field at submit time, not at push time. If
380    // we declared it inside the `if !tick_armed` arm-timeout block, the
381    // value would be dropped before the `submit_and_wait` below — the
382    // kernel would then read freed stack memory. (See `md-gateway` for the
383    // same pattern: it stores Timespec as a long-lived struct field.)
384    let tick_enabled = tick_cadence.is_some();
385    let cadence = tick_cadence.unwrap_or(Duration::ZERO);
386    let mut next_tick_deadline = Instant::now() + cadence;
387    let mut last_tick_ns: u64 = 0;
388    let mut tick_armed = false;
389    // Arm the very first timeout here, before entering the loop. This both
390    // (a) makes the initial `tick_ts` value actually read by the kernel
391    // (silencing the unused-assignment lint, since rustc cannot see kernel
392    // pointer reads) and (b) ensures the first `submit_and_wait` returns at
393    // the cadence even if no client traffic ever arrives.
394    let mut tick_ts = types::Timespec::new()
395        .sec(cadence.as_secs())
396        .nsec(cadence.subsec_nanos());
397    if tick_enabled {
398        let sqe = opcode::Timeout::new(&tick_ts)
399            .build()
400            .user_data(TICK_TIMEOUT_TOKEN);
401        unsafe {
402            ring.submission()
403                .push(&sqe)
404                .expect("io_uring SQ full while arming initial tick timeout");
405        }
406        tick_armed = true;
407        tracing::info!(
408            cadence_ms = cadence.as_millis() as u64,
409            "tick generator integrated into reader thread"
410        );
411    }
412
413    loop {
414        if shutdown.load(Ordering::Relaxed) {
415            break;
416        }
417
418        // Tick generator: emit any due tick before sleeping again. Done before
419        // the timeout-arm so that a freshly-emitted tick re-arms a timeout for
420        // the *new* deadline.
421        if tick_enabled {
422            let now = Instant::now();
423            if now >= next_tick_deadline {
424                let raw_now_ns = unix_epoch_nanos();
425                let now_ns = melin_transport_core::tick::clamp_monotonic(raw_now_ns, last_tick_ns);
426                last_tick_ns = now_ns;
427                melin_transport_core::tick::publish_tick(&mut producer, now_ns);
428                // Catch up rather than burst-emit if we fell badly behind.
429                let elapsed = Instant::now().saturating_duration_since(next_tick_deadline);
430                next_tick_deadline = if elapsed > cadence {
431                    Instant::now() + cadence
432                } else {
433                    next_tick_deadline + cadence
434                };
435                // The previous timeout (if any) is now stale; let it fire and
436                // be ignored, then arm a new one below.
437                tick_armed = false;
438            }
439
440            if !tick_armed {
441                let remaining = next_tick_deadline.saturating_duration_since(Instant::now());
442                // Update the loop-scoped Timespec in place. The kernel reads
443                // it via the SQE's addr pointer on submit_and_wait below, so
444                // the binding must outlive that call (it does — outer scope).
445                tick_ts = types::Timespec::new()
446                    .sec(remaining.as_secs())
447                    .nsec(remaining.subsec_nanos());
448                let sqe = opcode::Timeout::new(&tick_ts)
449                    .build()
450                    .user_data(TICK_TIMEOUT_TOKEN);
451                unsafe {
452                    ring.submission()
453                        .push(&sqe)
454                        .expect("io_uring SQ full while arming tick timeout");
455                }
456                tick_armed = true;
457            }
458        }
459
460        // Submit any pending SQEs and block until at least 1 CQE is ready.
461        match ring.submit_and_wait(1) {
462            Ok(_) => {}
463            Err(ref e) if e.raw_os_error() == Some(libc::EINTR) => continue,
464            Err(e) => {
465                error!(error = %e, "io_uring submit_and_wait error");
466                break;
467            }
468        }
469
470        // Drain all available CQEs into the pre-allocated buffer.
471        // Must collect before processing because the CQ borrow must end
472        // before we can push new SQEs to the SQ.
473        cqes.clear();
474        cqes.extend(
475            ring.completion()
476                .map(|cqe| (cqe.user_data(), cqe.result(), cqe.flags())),
477        );
478
479        let batch_now = Instant::now();
480        // One wall-clock read per CQE batch instead of per request. The
481        // reader can see 4–6 M requests/s at peak; a per-request
482        // `unix_epoch_nanos()` was ~2.8 % of the primary's cycles
483        // (vDSO `clock_gettime(CLOCK_REALTIME)`). All requests in the
484        // same batch share the timestamp — precision loss is bounded
485        // by the CQE-drain cadence (tens of µs under load) and order
486        // timestamps are used for reporting, not matching (the engine
487        // orders by sequence, not time).
488        let batch_wall_ns = unix_epoch_nanos();
489
490        for &(token, result, flags) in &cqes {
491            // ── Tick timeout ──
492            // The CQE is just a wakeup signal — the actual tick emission
493            // happens at the top of the next loop iteration via the
494            // deadline check, so the time the tick is stamped with reflects
495            // unix_epoch_nanos at fire time, not at submit time.
496            if token == TICK_TIMEOUT_TOKEN {
497                tick_armed = false;
498                continue;
499            }
500
501            // ── ProvideBuffers completion ──
502            if token == PROVIDE_BUFS_TOKEN {
503                if result < 0 {
504                    error!(error = result, "ProvideBuffers failed");
505                }
506                continue;
507            }
508
509            // ── Eventfd wakeup ──
510            if token == EVENTFD_TOKEN {
511                if result >= 0 {
512                    // Process all pending registrations.
513                    while let Ok(reg) = command_rx.try_recv() {
514                        let fd = reg.reader.as_raw_fd();
515                        let entry = ConnectionEntry {
516                            connection_id: reg.connection_id.0,
517                            addr: reg.addr,
518                            permission: reg.permission,
519                            key_hash: reg.key_hash,
520                            fd,
521                            _reader: reg.reader,
522                            parse_buf: Vec::with_capacity(MAX_FRAME_SIZE + 4),
523                            multishot_active: false,
524                            last_activity: Instant::now(),
525                        };
526                        let idx = slab.insert(entry);
527                        fd_to_slab.insert(fd, idx);
528
529                        // Submit multishot RECV for this connection.
530                        push_recv_multi(&mut ring, &mut slab, idx);
531                    }
532                } else {
533                    error!(error = result, "eventfd read error");
534                }
535
536                // Re-submit eventfd read for the next wakeup.
537                push_eventfd_read(&mut ring, wakeup_fd, eventfd_buf.as_mut_ptr());
538                continue;
539            }
540
541            // ── Connection multishot RECV completion ──
542
543            let slab_idx = token as usize;
544            let has_more = (flags & IORING_CQE_F_MORE) != 0;
545
546            if result <= 0 {
547                // Disconnect (0) or error (negative errno).
548                if let Some(removed) = slab.remove(slab_idx) {
549                    if result == 0 {
550                        debug!(
551                            connection_id = removed.connection_id,
552                            addr = %removed.addr,
553                            "client disconnected"
554                        );
555                    } else {
556                        debug!(
557                            connection_id = removed.connection_id,
558                            addr = %removed.addr,
559                            error = result,
560                            "recv error"
561                        );
562                    }
563                    fd_to_slab.remove(&removed.fd);
564                    let _ = control_tx.send(ControlEvent::Disconnected {
565                        connection_id: removed.connection_id,
566                    });
567                }
568                continue;
569            }
570
571            let n = result as usize;
572
573            // Trace timestamp: the moment the kernel handed us this recv's
574            // bytes. Captured once per CQE — not per frame — and stamped
575            // onto every InputSlot parsed from this buffer below, so the
576            // reader-ingest and server-e2e stages measure from true wire
577            // receipt (frame decode included) rather than re-sampling after
578            // each decode (which excluded decode and drifted forward for
579            // later frames in a multi-frame recv).
580            #[allow(clippy::let_unit_value)] // ZST when latency-trace is off
581            let recv_ts = melin_transport_core::trace::mono_trace_ns();
582
583            // Extract the buffer ID from the CQE flags. The kernel sets
584            // IORING_CQE_F_BUFFER and encodes the buffer ID in bits 16-31.
585            let buf_id = if (flags & IORING_CQE_F_BUFFER) != 0 {
586                (flags >> IORING_CQE_BUFFER_SHIFT) as usize
587            } else {
588                // Should not happen with provided buffers — defensive skip.
589                debug!(slab_idx, "recv CQE without buffer flag");
590                continue;
591            };
592
593            // Feed received bytes into the frame parser from the shared pool.
594            let action = if let Some(entry) = slab.get_mut(slab_idx) {
595                if !has_more {
596                    entry.multishot_active = false;
597                }
598
599                // Any successful recv resets the idle timeout.
600                entry.last_activity = batch_now;
601
602                // Copy received data from the shared buffer pool into the
603                // connection's parse buffer.
604                let buf_start = buf_id * BUF_SIZE;
605                entry
606                    .parse_buf
607                    .extend_from_slice(&buffer_pool[buf_start..buf_start + n]);
608
609                // Extract and publish complete frames.
610                let drop_conn = process_frames::<A, R>(
611                    entry,
612                    &mut producer,
613                    decoder,
614                    &server_busy_frame,
615                    batch_wall_ns,
616                    recv_ts,
617                    #[cfg(feature = "latency-trace")]
618                    &mut publish_rec,
619                    #[cfg(feature = "tick-to-trade")]
620                    &mut ingest_rec,
621                );
622                if drop_conn {
623                    Action::Remove {
624                        connection_id: entry.connection_id,
625                        fd: entry.fd,
626                    }
627                } else if !has_more {
628                    // Multishot terminated (buffer pool exhaustion or kernel
629                    // decision) but connection is healthy — resubmit.
630                    Action::Resubmit
631                } else {
632                    Action::None
633                }
634            } else {
635                // Stale CQE for a removed connection — ignore.
636                Action::None
637            };
638
639            // Re-provide the consumed buffer back to the pool. Must happen
640            // after we've copied the data out. Pushed to SQ and submitted
641            // on the next submit_and_wait.
642            re_provide_buffer(&mut ring, buffer_pool.as_mut_ptr(), buf_id);
643
644            match action {
645                Action::Remove { connection_id, fd } => {
646                    slab.remove(slab_idx);
647                    fd_to_slab.remove(&fd);
648                    let _ = control_tx.send(ControlEvent::Disconnected { connection_id });
649                }
650                Action::Resubmit => {
651                    push_recv_multi(&mut ring, &mut slab, slab_idx);
652                }
653                Action::None => {}
654            }
655        }
656
657        // Scan for idle connections that have exceeded the timeout.
658        // Coarse gate: only scan once per second to avoid unnecessary
659        // iteration during high-throughput phases when submit_and_wait
660        // returns immediately with CQEs.
661        if let Some(timeout) = connection_timeout {
662            let now = Instant::now();
663            if now.duration_since(last_timeout_scan) >= Duration::from_secs(1) {
664                last_timeout_scan = now;
665                stale.clear();
666                for (idx, slot) in slab.entries.iter().enumerate() {
667                    if let Some(entry) = slot
668                        && now.duration_since(entry.last_activity) > timeout
669                    {
670                        debug!(
671                            connection_id = entry.connection_id,
672                            addr = %entry.addr,
673                            "connection timed out"
674                        );
675                        stale.push((idx, entry.connection_id, entry.fd));
676                    }
677                }
678                for &(idx, connection_id, fd) in &stale {
679                    slab.remove(idx);
680                    fd_to_slab.remove(&fd);
681                    let _ = control_tx.send(ControlEvent::Disconnected { connection_id });
682                }
683            }
684        }
685    }
686
687    unsafe {
688        libc::close(wakeup_fd);
689    }
690}
691
692/// What to do after processing a RECV CQE.
693enum Action {
694    /// Multishot terminated but connection healthy — resubmit RecvMulti.
695    Resubmit,
696    /// Connection should be removed (malformed frame).
697    Remove { connection_id: u64, fd: RawFd },
698    /// Multishot still active — nothing to do.
699    None,
700}
701
702// ---------------------------------------------------------------------------
703// SQE helpers
704// ---------------------------------------------------------------------------
705
706/// Register the provided buffer pool with io_uring via ProvideBuffers.
707/// Submits synchronously and panics on failure — called once at startup.
708fn register_buffer_pool(ring: &mut IoUring, pool_ptr: *mut u8) {
709    let sqe = opcode::ProvideBuffers::new(pool_ptr, BUF_SIZE as i32, NUM_BUFFERS, BUF_GROUP_ID, 0)
710        .build()
711        .user_data(PROVIDE_BUFS_TOKEN);
712
713    unsafe {
714        ring.submission()
715            .push(&sqe)
716            .expect("io_uring SQ full during buffer pool registration");
717    }
718
719    ring.submit_and_wait(1)
720        .expect("io_uring submit failed during buffer pool registration");
721
722    // Check the completion result.
723    let cqe = ring
724        .completion()
725        .next()
726        .expect("no CQE after ProvideBuffers");
727    assert!(cqe.result() >= 0, "ProvideBuffers failed: {}", cqe.result());
728}
729
730/// Re-provide a single consumed buffer back to the pool. Pushed to SQ
731/// without immediate submission — batched with the next submit_and_wait.
732fn re_provide_buffer(ring: &mut IoUring, pool_ptr: *mut u8, buf_id: usize) {
733    let buf_ptr = unsafe { pool_ptr.add(buf_id * BUF_SIZE) };
734    let sqe = opcode::ProvideBuffers::new(buf_ptr, BUF_SIZE as i32, 1, BUF_GROUP_ID, buf_id as u16)
735        .build()
736        .user_data(PROVIDE_BUFS_TOKEN);
737
738    unsafe {
739        ring.submission()
740            .push(&sqe)
741            .expect("io_uring SQ full — increase RING_SIZE");
742    }
743}
744
745/// Push a multishot RECV SQE for a connection. The kernel will produce
746/// CQEs continuously until EOF, error, or buffer pool exhaustion —
747/// no resubmission needed unless multishot terminates.
748fn push_recv_multi<R>(ring: &mut IoUring, slab: &mut ConnectionSlab<R>, idx: usize) {
749    let entry = match slab.get_mut(idx) {
750        Some(e) => e,
751        None => return,
752    };
753
754    if entry.multishot_active {
755        return;
756    }
757
758    let sqe = opcode::RecvMulti::new(types::Fd(entry.fd), BUF_GROUP_ID)
759        .build()
760        .user_data(idx as u64);
761
762    unsafe {
763        ring.submission()
764            .push(&sqe)
765            .expect("io_uring SQ full — increase RING_SIZE");
766    }
767    entry.multishot_active = true;
768}
769
770/// Push a READ SQE for the eventfd (wakeup notification).
771fn push_eventfd_read(ring: &mut IoUring, wakeup_fd: RawFd, buf: *mut u8) {
772    let sqe = opcode::Read::new(types::Fd(wakeup_fd), buf, 8)
773        .build()
774        .user_data(EVENTFD_TOKEN);
775
776    unsafe {
777        ring.submission()
778            .push(&sqe)
779            .expect("io_uring SQ full — increase RING_SIZE");
780    }
781}
782
783// ---------------------------------------------------------------------------
784// Frame parsing
785// ---------------------------------------------------------------------------
786
787/// Extract complete frames from the connection's parse buffer, decode them,
788/// and publish to the disruptor. Returns `true` if the connection should be
789/// dropped (e.g., oversized frame).
790/// Extract complete frames from `conn.parse_buf` and publish them as
791/// `InputSlot`s. `batch_wall_ns` is the wall-clock timestamp captured
792/// once per CQE batch by the caller (see `reader_loop`); all non-query
793/// requests published in this call share it, sparing the reader a
794/// per-request `clock_gettime(CLOCK_REALTIME)` on the hot path. Returns
795/// `true` if the connection should be dropped.
796fn process_frames<A: Application, R>(
797    conn: &mut ConnectionEntry<R>,
798    producer: &mut ring::Producer<InputSlot<A::Event>>,
799    decoder: &dyn RequestDecoder<Event = A::Event>,
800    server_busy_frame: &[u8; 5],
801    batch_wall_ns: u64,
802    recv_ts: melin_transport_core::trace::MonoTraceInstant,
803    #[cfg(feature = "latency-trace")] publish_rec: &mut melin_transport_core::trace::StageRecorder,
804    #[cfg(feature = "tick-to-trade")] ingest_rec: &mut melin_transport_core::trace::StageRecorder,
805) -> bool {
806    use crate::client_frames::{FrameAction, process_client_frames};
807
808    let action = process_client_frames(
809        &mut conn.parse_buf,
810        conn.connection_id,
811        conn.key_hash,
812        conn.permission,
813        producer,
814        decoder,
815        batch_wall_ns,
816        recv_ts,
817        #[cfg(feature = "latency-trace")]
818        publish_rec,
819        #[cfg(feature = "tick-to-trade")]
820        ingest_rec,
821    );
822
823    match action {
824        FrameAction::Continue => false,
825        FrameAction::Disconnect => true,
826        FrameAction::PipelineFull => {
827            debug!(
828                connection_id = conn.connection_id,
829                "pipeline full, sending ServerBusy"
830            );
831            // Best-effort: if the write fails, the client will timeout.
832            let n = unsafe {
833                libc::write(
834                    conn.fd,
835                    server_busy_frame.as_ptr().cast(),
836                    server_busy_frame.len(),
837                )
838            };
839            if n != server_busy_frame.len() as isize {
840                debug!(
841                    connection_id = conn.connection_id,
842                    written = n,
843                    "ServerBusy write incomplete"
844                );
845            }
846            false
847        }
848    }
849}
850
851#[cfg(test)]
852mod tests {
853    //! Unit tests for [`process_frames`]. The function has four exit paths
854    //! (normal end, partial parse-buf, pipeline-full, oversize-frame), each
855    //! with subtle batch-commit ordering requirements. These tests pin that
856    //! behaviour against a synthetic decoder so refactors of the batch path
857    //! (e.g. moving the batch up to span the whole CQE drain) can't silently
858    //! regress the "earlier frames must be visible before ServerBusy /
859    //! disconnect" guarantees.
860    use super::*;
861    use melin_app::auth::Permission;
862    use melin_app::decoder::{Decoded, RequestDecoder};
863    use melin_app::{AppEvent, Application, ApplyCtx, CodecError, RejectReason};
864    use melin_journal::JournalEvent;
865    use melin_pipeline::ring::DisruptorBuilder;
866    use std::io::{ErrorKind, Read};
867    use std::os::unix::net::UnixStream;
868    use std::time::Duration;
869
870    /// Minimal `AppEvent` for these tests. `Copy` is required by `AppEvent`;
871    /// the on-wire codec is unused because [`TagDecoder`] never invokes it
872    /// (frames are interpreted directly from their tag byte).
873    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
874    enum TestEvent {
875        Cmd(u8),
876        Query,
877    }
878
879    impl AppEvent for TestEvent {
880        fn encoded_size(&self) -> usize {
881            // Unused — the tests never round-trip through encode/decode.
882            2
883        }
884        fn encode(&self, _buf: &mut [u8]) -> usize {
885            unreachable!("process_frames does not encode app events")
886        }
887        fn decode(_buf: &[u8]) -> Result<Self, CodecError> {
888            unreachable!("process_frames does not decode app events directly")
889        }
890        fn is_query(&self) -> bool {
891            matches!(self, TestEvent::Query)
892        }
893    }
894
895    /// Placeholder `Application` impl. `process_frames` is generic over `A`
896    /// only to constrain `A::Event` — none of the trait methods are called
897    /// from the function under test, so they all `unreachable!`.
898    struct TestApp;
899
900    impl Application for TestApp {
901        type Event = TestEvent;
902        type Report = ();
903        type QueryResponse = ();
904        const APP_VERSION: u16 = 0;
905        fn apply(&mut self, _event: TestEvent, _ctx: &ApplyCtx, _out: &mut Vec<()>) -> Option<()> {
906            unreachable!()
907        }
908        fn tick(&mut self, _now_ns: u64, _out: &mut Vec<()>) {
909            unreachable!()
910        }
911        fn check_request_seq(&mut self, _key_hash: u64, _seq: u64) -> bool {
912            unreachable!()
913        }
914        fn build_reject(_event: &TestEvent, _reason: RejectReason) -> () {
915            unreachable!()
916        }
917        fn snapshot<W: std::io::Write>(&self, _w: &mut W) -> std::io::Result<()> {
918            unreachable!()
919        }
920        fn restore<R: Read>(_r: &mut R) -> std::io::Result<Self> {
921            unreachable!()
922        }
923    }
924
925    /// Stateless decoder that maps a frame's single payload byte to a
926    /// [`Decoded`] outcome. Lets each test feed a precise mix of permitted,
927    /// filtered, denied, and decode-error frames without standing up the
928    /// real wire codec.
929    ///
930    /// Tag mapping (`0x00..=0xFB` map 1:1 to a Permitted seq, reserving the
931    /// top four byte values for the non-Permitted outcomes):
932    ///   * `0xFC` -> `Filter`
933    ///   * `0xFD` -> `PermissionDenied`
934    ///   * `0xFE` -> `DecodeError`
935    ///   * `0xFF` -> `Permitted` with `is_query == true`
936    ///   * `0x00..=0xFB` -> `Permitted` with `request_seq == byte`
937    struct TagDecoder;
938
939    impl RequestDecoder for TagDecoder {
940        type Event = TestEvent;
941        fn decode(&self, bytes: &[u8], _permission: Permission) -> Decoded<TestEvent> {
942            match bytes.first().copied() {
943                None => Decoded::DecodeError("empty payload"),
944                Some(0xFC) => Decoded::Filter,
945                Some(0xFD) => Decoded::PermissionDenied("denied"),
946                Some(0xFE) => Decoded::DecodeError("bad"),
947                Some(0xFF) => Decoded::Permitted {
948                    request_seq: 0xFF,
949                    event: TestEvent::Query,
950                },
951                Some(b) => Decoded::Permitted {
952                    request_seq: b as u64,
953                    event: TestEvent::Cmd(b),
954                },
955            }
956        }
957    }
958
959    /// 5-byte ServerBusy placeholder. The real reader passes an encoded
960    /// `ResponseKind::ServerBusy`, but `process_frames` writes the bytes
961    /// verbatim — distinct sentinel bytes make peer-side assertions
962    /// unambiguous.
963    const TEST_SERVER_BUSY: [u8; 5] = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE];
964
965    /// One-byte payload framed as `[u32 LE length=1][byte]`.
966    fn frame(byte: u8) -> [u8; 5] {
967        let mut f = [0u8; 5];
968        f[..4].copy_from_slice(&1u32.to_le_bytes());
969        f[4] = byte;
970        f
971    }
972
973    /// Length prefix announcing an oversize frame. No payload bytes follow —
974    /// `process_frames` decides on the prefix alone, before waiting for the
975    /// body.
976    fn oversize_prefix() -> [u8; 4] {
977        ((MAX_FRAME_SIZE as u32) + 1).to_le_bytes()
978    }
979
980    /// Test fixture bundle. Grouped into a struct rather than returned as a
981    /// 4-tuple to keep clippy happy (`type_complexity`) and to give each
982    /// field a name at call sites.
983    struct Fixture {
984        conn: ConnectionEntry<UnixStream>,
985        producer: ring::Producer<InputSlot<TestEvent>>,
986        consumer: ring::Consumer<InputSlot<TestEvent>>,
987        /// Client-side end of the socket pair — read from this to inspect
988        /// any `ServerBusy` bytes the function under test writes.
989        peer: UnixStream,
990    }
991
992    /// Build a fresh fixture: a `ConnectionEntry` backed by a `UnixStream`
993    /// pair plus a single-consumer disruptor of the requested capacity.
994    fn make_fixture(ring_capacity: usize) -> Fixture {
995        let (server_side, peer) = UnixStream::pair().expect("UnixStream::pair");
996        // Short read timeout on the peer so assertions of "no ServerBusy
997        // written" return promptly instead of hanging the test.
998        peer.set_read_timeout(Some(Duration::from_millis(50)))
999            .expect("set_read_timeout");
1000
1001        let entry = ConnectionEntry::<UnixStream> {
1002            connection_id: 7,
1003            addr: "127.0.0.1:1".parse().expect("addr parses"),
1004            permission: Permission::Trader,
1005            key_hash: 0xC0FFEE_u64,
1006            fd: server_side.as_raw_fd(),
1007            _reader: server_side,
1008            parse_buf: Vec::with_capacity(64),
1009            multishot_active: false,
1010            last_activity: Instant::now(),
1011        };
1012
1013        let (producer, mut consumers) =
1014            DisruptorBuilder::<InputSlot<TestEvent>>::new(ring_capacity)
1015                .add_consumer()
1016                .build();
1017        let consumer = consumers.pop().expect("consumer present");
1018
1019        Fixture {
1020            conn: entry,
1021            producer,
1022            consumer,
1023            peer,
1024        }
1025    }
1026
1027    /// Invoke `process_frames::<TestApp, UnixStream>`, threading the
1028    /// feature-gated histogram args when the relevant features are on so
1029    /// the call compiles in every `cargo test` configuration.
1030    fn run_process_frames(
1031        conn: &mut ConnectionEntry<UnixStream>,
1032        producer: &mut ring::Producer<InputSlot<TestEvent>>,
1033    ) -> bool {
1034        #[cfg(feature = "latency-trace")]
1035        let mut publish_rec = melin_transport_core::trace::register_stage("test: publish");
1036        #[cfg(feature = "tick-to-trade")]
1037        let mut ingest_rec = melin_transport_core::trace::register_stage("test: ingest");
1038
1039        #[allow(clippy::let_unit_value)] // ZST when latency-trace is off
1040        let recv_ts = melin_transport_core::trace::mono_trace_ns();
1041
1042        process_frames::<TestApp, UnixStream>(
1043            conn,
1044            producer,
1045            &TagDecoder,
1046            &TEST_SERVER_BUSY,
1047            0xDEAD_BEEF,
1048            recv_ts,
1049            #[cfg(feature = "latency-trace")]
1050            &mut publish_rec,
1051            #[cfg(feature = "tick-to-trade")]
1052            &mut ingest_rec,
1053        )
1054    }
1055
1056    /// Drain `consumer` into a Vec of `(seq, slot)` until it yields `None`.
1057    /// Used to assert exact event sequences after `process_frames` returns.
1058    fn drain(
1059        consumer: &mut ring::Consumer<InputSlot<TestEvent>>,
1060    ) -> Vec<(u64, InputSlot<TestEvent>)> {
1061        let mut out = Vec::new();
1062        while let Some(pair) = consumer.try_consume() {
1063            out.push(pair);
1064        }
1065        out
1066    }
1067
1068    /// Try to read exactly 5 bytes (the ServerBusy frame size) from the
1069    /// peer. Returns `Some(bytes)` if the read completes within the peer's
1070    /// configured timeout, `None` if it times out (i.e. nothing was sent).
1071    fn read_server_busy(peer: &mut UnixStream) -> Option<[u8; 5]> {
1072        let mut buf = [0u8; 5];
1073        match peer.read_exact(&mut buf) {
1074            Ok(()) => Some(buf),
1075            Err(e) if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut => None,
1076            Err(e) => panic!("unexpected peer read error: {e}"),
1077        }
1078    }
1079
1080    #[test]
1081    fn process_frames_publishes_all_frames_after_single_commit() {
1082        // Capacity > number of frames — every frame must succeed and become
1083        // visible to the consumer after the trailing `batch.commit()`.
1084        let Fixture {
1085            mut conn,
1086            mut producer,
1087            mut consumer,
1088            mut peer,
1089        } = make_fixture(16);
1090        for byte in [0x01, 0x02, 0x03, 0x04, 0x05] {
1091            conn.parse_buf.extend_from_slice(&frame(byte));
1092        }
1093
1094        let disconnect = run_process_frames(&mut conn, &mut producer);
1095        assert!(!disconnect, "no oversize frame ⇒ no disconnect");
1096
1097        let events = drain(&mut consumer);
1098        assert_eq!(events.len(), 5, "all 5 frames must be visible");
1099        for (i, (seq, slot)) in events.iter().enumerate() {
1100            assert_eq!(*seq, i as u64, "seq monotonic from 0");
1101            assert_eq!(slot.connection_id, 7);
1102            assert_eq!(slot.key_hash, 0xC0FFEE_u64);
1103            let byte = (i + 1) as u8;
1104            assert_eq!(slot.request_seq, byte as u64);
1105            assert_eq!(slot.event, JournalEvent::App(TestEvent::Cmd(byte)));
1106            // Non-query event ⇒ inherits the caller-supplied wall-clock.
1107            assert_eq!(slot.timestamp_ns, 0xDEAD_BEEF);
1108        }
1109        // Parse buffer fully consumed.
1110        assert!(conn.parse_buf.is_empty());
1111        // No ServerBusy on the wire — no Full happened.
1112        assert!(
1113            read_server_busy(&mut peer).is_none(),
1114            "ServerBusy must not be sent on the happy path"
1115        );
1116    }
1117
1118    /// The caller stamps `recv_ts` once per recv (at the kernel-return
1119    /// site) and every frame parsed from that buffer must carry that exact
1120    /// value. Guards against a regression to per-frame `recv_ts` capture,
1121    /// which excluded decode from the reader-ingest / server-e2e windows
1122    /// and drifted forward for later frames in a multi-frame recv. Gated on
1123    /// `latency-trace` because `recv_ts` is a real `u64` only then (`()`
1124    /// otherwise, leaving nothing to assert).
1125    #[cfg(feature = "latency-trace")]
1126    #[test]
1127    fn process_frames_stamps_every_slot_with_caller_recv_ts() {
1128        let Fixture {
1129            mut conn,
1130            mut producer,
1131            mut consumer,
1132            ..
1133        } = make_fixture(16);
1134        for byte in [0x01, 0x02, 0x03, 0x04] {
1135            conn.parse_buf.extend_from_slice(&frame(byte));
1136        }
1137
1138        // A recognizable sentinel the caller would have captured at the
1139        // recv site; distinct from the wall-clock stamp (0xDEAD_BEEF).
1140        const RECV_TS: u64 = 0x5EED_5EED;
1141
1142        let mut publish_rec = melin_transport_core::trace::register_stage("test: publish recv_ts");
1143        #[cfg(feature = "tick-to-trade")]
1144        let mut ingest_rec = melin_transport_core::trace::register_stage("test: ingest recv_ts");
1145
1146        let disconnect = process_frames::<TestApp, UnixStream>(
1147            &mut conn,
1148            &mut producer,
1149            &TagDecoder,
1150            &TEST_SERVER_BUSY,
1151            0xDEAD_BEEF,
1152            RECV_TS,
1153            &mut publish_rec,
1154            #[cfg(feature = "tick-to-trade")]
1155            &mut ingest_rec,
1156        );
1157        assert!(!disconnect);
1158
1159        let events = drain(&mut consumer);
1160        assert_eq!(events.len(), 4, "all frames published");
1161        for (_seq, slot) in &events {
1162            assert_eq!(
1163                slot.recv_ts, RECV_TS,
1164                "every frame from one recv shares the single caller-supplied recv_ts"
1165            );
1166        }
1167    }
1168
1169    #[test]
1170    fn process_frames_rotates_batch_at_commit_every_cap() {
1171        // Push more events than `COMMIT_EVERY` (= 16) into a recv-cycle.
1172        // The cap must trigger at least one mid-loop commit so the
1173        // consumer sees the first capacity-many events before the
1174        // remainder lands. Validates the visibility-delay cap from the
1175        // perf branch — without it, all 32 events would commit together
1176        // and the first frame would wait for the 32nd to decode.
1177        //
1178        // Capacity 64 leaves room for the entire input (no Full); ring
1179        // backpressure is exercised separately in
1180        // `process_frames_partial_commit_then_server_busy_when_pipeline_full`.
1181        let Fixture {
1182            mut conn,
1183            mut producer,
1184            mut consumer,
1185            ..
1186        } = make_fixture(64);
1187        const EVENT_COUNT: usize = 32;
1188        for i in 0..EVENT_COUNT {
1189            // Use bytes 1..=32 (each ≤ 0xFB so TagDecoder yields
1190            // `Permitted` with `request_seq == byte`).
1191            conn.parse_buf.extend_from_slice(&frame((i + 1) as u8));
1192        }
1193
1194        let disconnect = run_process_frames(&mut conn, &mut producer);
1195        assert!(!disconnect, "no oversize / no Full ⇒ no disconnect");
1196
1197        let events = drain(&mut consumer);
1198        assert_eq!(events.len(), EVENT_COUNT, "every event visible");
1199        for (i, (seq, slot)) in events.iter().enumerate() {
1200            assert_eq!(*seq, i as u64, "seq contiguous across batch rotations");
1201            let byte = (i + 1) as u8;
1202            assert_eq!(slot.event, JournalEvent::App(TestEvent::Cmd(byte)));
1203        }
1204        assert!(conn.parse_buf.is_empty());
1205    }
1206
1207    #[test]
1208    fn process_frames_query_event_skips_wall_clock_stamp() {
1209        // `AppEvent::is_query` events bypass the journal stamp — verify
1210        // the timestamp is zeroed even when a non-zero batch_wall_ns was
1211        // supplied.
1212        let Fixture {
1213            mut conn,
1214            mut producer,
1215            mut consumer,
1216            ..
1217        } = make_fixture(8);
1218        conn.parse_buf.extend_from_slice(&frame(0xFF)); // tag → Query
1219
1220        let disconnect = run_process_frames(&mut conn, &mut producer);
1221        assert!(!disconnect);
1222
1223        let events = drain(&mut consumer);
1224        assert_eq!(events.len(), 1);
1225        let (_, slot) = &events[0];
1226        assert_eq!(slot.event, JournalEvent::App(TestEvent::Query));
1227        assert_eq!(
1228            slot.timestamp_ns, 0,
1229            "query events must skip the wall-clock stamp"
1230        );
1231    }
1232
1233    #[test]
1234    fn process_frames_partial_commit_then_server_busy_when_pipeline_full() {
1235        // Ring capacity 4 + 6 frames ⇒ first 4 commit, 5th triggers Full,
1236        // 6th is never reached because the loop breaks on Full. Validates:
1237        //   * `Err(Full)` does not roll back the prior 4 (single commit
1238        //     happens before the ServerBusy write).
1239        //   * The frame that triggered Full is silently dropped — its bytes
1240        //     are compacted out of `parse_buf` along with every earlier
1241        //     frame, mirroring pre-batch behaviour.
1242        //   * ServerBusy is written exactly once to the peer.
1243        let Fixture {
1244            mut conn,
1245            mut producer,
1246            mut consumer,
1247            mut peer,
1248        } = make_fixture(4);
1249        for byte in [0x01, 0x02, 0x03, 0x04, 0x05, 0x06] {
1250            conn.parse_buf.extend_from_slice(&frame(byte));
1251        }
1252
1253        let disconnect = run_process_frames(&mut conn, &mut producer);
1254        assert!(!disconnect, "Full does not drop the connection");
1255
1256        let events = drain(&mut consumer);
1257        assert_eq!(
1258            events.len(),
1259            4,
1260            "only the first capacity-many frames are visible"
1261        );
1262        for (i, (_, slot)) in events.iter().enumerate() {
1263            let byte = (i + 1) as u8;
1264            assert_eq!(slot.event, JournalEvent::App(TestEvent::Cmd(byte)));
1265        }
1266
1267        // ServerBusy is delivered to the peer.
1268        let busy = read_server_busy(&mut peer).expect("ServerBusy frame written");
1269        assert_eq!(busy, TEST_SERVER_BUSY);
1270
1271        // The frame that triggered Full (0x05) had its bytes consumed by
1272        // the loop's `cursor +=` before `try_push_with` ran; the 6th frame
1273        // is never inspected because the loop broke. Compaction shifts
1274        // the unprocessed tail (the 6th frame's bytes) to the front.
1275        assert_eq!(
1276            conn.parse_buf,
1277            frame(0x06).to_vec(),
1278            "the 6th frame remains in parse_buf for the next recv-cycle"
1279        );
1280    }
1281
1282    #[test]
1283    fn process_frames_oversize_commits_prior_frames_then_signals_disconnect() {
1284        // Two valid frames followed by an oversize length prefix must:
1285        //   * publish the two valid frames (commit-before-break) so the
1286        //     pipeline observes them even though we're about to tear the
1287        //     connection down,
1288        //   * return `true` so the caller drops the connection,
1289        //   * NOT write ServerBusy (that is reserved for pipeline-full).
1290        let Fixture {
1291            mut conn,
1292            mut producer,
1293            mut consumer,
1294            mut peer,
1295        } = make_fixture(16);
1296        conn.parse_buf.extend_from_slice(&frame(0x01));
1297        conn.parse_buf.extend_from_slice(&frame(0x02));
1298        conn.parse_buf.extend_from_slice(&oversize_prefix());
1299
1300        let disconnect = run_process_frames(&mut conn, &mut producer);
1301        assert!(disconnect, "oversize frame must request disconnect");
1302
1303        let events = drain(&mut consumer);
1304        assert_eq!(
1305            events.len(),
1306            2,
1307            "prior frames are committed before the break"
1308        );
1309        assert_eq!(events[0].1.event, JournalEvent::App(TestEvent::Cmd(0x01)));
1310        assert_eq!(events[1].1.event, JournalEvent::App(TestEvent::Cmd(0x02)));
1311
1312        assert!(
1313            read_server_busy(&mut peer).is_none(),
1314            "ServerBusy is sent on Full, not on oversize"
1315        );
1316    }
1317
1318    #[test]
1319    fn process_frames_filters_denied_and_decode_errors_advance_cursor() {
1320        // Mixed batch: Permitted, Filter, PermissionDenied, DecodeError,
1321        // Permitted. Only the two Permitted frames must reach the
1322        // consumer; all bytes are consumed (parse_buf fully drains).
1323        let Fixture {
1324            mut conn,
1325            mut producer,
1326            mut consumer,
1327            ..
1328        } = make_fixture(16);
1329        for byte in [0x01, 0xFC, 0xFD, 0xFE, 0x02] {
1330            conn.parse_buf.extend_from_slice(&frame(byte));
1331        }
1332
1333        let disconnect = run_process_frames(&mut conn, &mut producer);
1334        assert!(!disconnect);
1335
1336        let events = drain(&mut consumer);
1337        assert_eq!(events.len(), 2);
1338        assert_eq!(events[0].1.event, JournalEvent::App(TestEvent::Cmd(0x01)));
1339        assert_eq!(events[1].1.event, JournalEvent::App(TestEvent::Cmd(0x02)));
1340        assert!(
1341            conn.parse_buf.is_empty(),
1342            "all bytes advanced past compaction"
1343        );
1344    }
1345
1346    #[test]
1347    fn process_frames_preserves_partial_trailing_frame() {
1348        // One complete frame followed by a truncated length prefix. The
1349        // complete frame must publish; the partial bytes must survive
1350        // compaction at the front of `parse_buf` for the next recv-cycle.
1351        let Fixture {
1352            mut conn,
1353            mut producer,
1354            mut consumer,
1355            ..
1356        } = make_fixture(16);
1357        conn.parse_buf.extend_from_slice(&frame(0x42));
1358        // Three of four length-prefix bytes — `cursor + 4 <= len()` is
1359        // false, so the loop breaks before consuming anything from the
1360        // partial.
1361        conn.parse_buf.extend_from_slice(&[0xDE, 0xAD, 0xBE]);
1362
1363        let disconnect = run_process_frames(&mut conn, &mut producer);
1364        assert!(!disconnect);
1365
1366        let events = drain(&mut consumer);
1367        assert_eq!(events.len(), 1);
1368        assert_eq!(events[0].1.event, JournalEvent::App(TestEvent::Cmd(0x42)));
1369        assert_eq!(
1370            conn.parse_buf,
1371            vec![0xDE, 0xAD, 0xBE],
1372            "partial length prefix preserved for next recv-cycle"
1373        );
1374    }
1375
1376    #[test]
1377    fn process_frames_empty_buffer_is_noop() {
1378        // No bytes in parse_buf ⇒ loop never enters; commit is the
1379        // documented zero-slot no-op; no ServerBusy; no disconnect.
1380        let Fixture {
1381            mut conn,
1382            mut producer,
1383            mut consumer,
1384            mut peer,
1385        } = make_fixture(4);
1386
1387        let disconnect = run_process_frames(&mut conn, &mut producer);
1388        assert!(!disconnect);
1389        assert_eq!(drain(&mut consumer).len(), 0);
1390        assert!(conn.parse_buf.is_empty());
1391        assert!(read_server_busy(&mut peer).is_none());
1392    }
1393}