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 // Extract the buffer ID from the CQE flags. The kernel sets
574 // IORING_CQE_F_BUFFER and encodes the buffer ID in bits 16-31.
575 let buf_id = if (flags & IORING_CQE_F_BUFFER) != 0 {
576 (flags >> IORING_CQE_BUFFER_SHIFT) as usize
577 } else {
578 // Should not happen with provided buffers — defensive skip.
579 debug!(slab_idx, "recv CQE without buffer flag");
580 continue;
581 };
582
583 // Feed received bytes into the frame parser from the shared pool.
584 let action = if let Some(entry) = slab.get_mut(slab_idx) {
585 if !has_more {
586 entry.multishot_active = false;
587 }
588
589 // Any successful recv resets the idle timeout.
590 entry.last_activity = batch_now;
591
592 // Copy received data from the shared buffer pool into the
593 // connection's parse buffer.
594 let buf_start = buf_id * BUF_SIZE;
595 entry
596 .parse_buf
597 .extend_from_slice(&buffer_pool[buf_start..buf_start + n]);
598
599 // Extract and publish complete frames.
600 let drop_conn = process_frames::<A, R>(
601 entry,
602 &mut producer,
603 decoder,
604 &server_busy_frame,
605 batch_wall_ns,
606 #[cfg(feature = "latency-trace")]
607 &mut publish_rec,
608 #[cfg(feature = "tick-to-trade")]
609 &mut ingest_rec,
610 );
611 if drop_conn {
612 Action::Remove {
613 connection_id: entry.connection_id,
614 fd: entry.fd,
615 }
616 } else if !has_more {
617 // Multishot terminated (buffer pool exhaustion or kernel
618 // decision) but connection is healthy — resubmit.
619 Action::Resubmit
620 } else {
621 Action::None
622 }
623 } else {
624 // Stale CQE for a removed connection — ignore.
625 Action::None
626 };
627
628 // Re-provide the consumed buffer back to the pool. Must happen
629 // after we've copied the data out. Pushed to SQ and submitted
630 // on the next submit_and_wait.
631 re_provide_buffer(&mut ring, buffer_pool.as_mut_ptr(), buf_id);
632
633 match action {
634 Action::Remove { connection_id, fd } => {
635 slab.remove(slab_idx);
636 fd_to_slab.remove(&fd);
637 let _ = control_tx.send(ControlEvent::Disconnected { connection_id });
638 }
639 Action::Resubmit => {
640 push_recv_multi(&mut ring, &mut slab, slab_idx);
641 }
642 Action::None => {}
643 }
644 }
645
646 // Scan for idle connections that have exceeded the timeout.
647 // Coarse gate: only scan once per second to avoid unnecessary
648 // iteration during high-throughput phases when submit_and_wait
649 // returns immediately with CQEs.
650 if let Some(timeout) = connection_timeout {
651 let now = Instant::now();
652 if now.duration_since(last_timeout_scan) >= Duration::from_secs(1) {
653 last_timeout_scan = now;
654 stale.clear();
655 for (idx, slot) in slab.entries.iter().enumerate() {
656 if let Some(entry) = slot
657 && now.duration_since(entry.last_activity) > timeout
658 {
659 debug!(
660 connection_id = entry.connection_id,
661 addr = %entry.addr,
662 "connection timed out"
663 );
664 stale.push((idx, entry.connection_id, entry.fd));
665 }
666 }
667 for &(idx, connection_id, fd) in &stale {
668 slab.remove(idx);
669 fd_to_slab.remove(&fd);
670 let _ = control_tx.send(ControlEvent::Disconnected { connection_id });
671 }
672 }
673 }
674 }
675
676 unsafe {
677 libc::close(wakeup_fd);
678 }
679}
680
681/// What to do after processing a RECV CQE.
682enum Action {
683 /// Multishot terminated but connection healthy — resubmit RecvMulti.
684 Resubmit,
685 /// Connection should be removed (malformed frame).
686 Remove { connection_id: u64, fd: RawFd },
687 /// Multishot still active — nothing to do.
688 None,
689}
690
691// ---------------------------------------------------------------------------
692// SQE helpers
693// ---------------------------------------------------------------------------
694
695/// Register the provided buffer pool with io_uring via ProvideBuffers.
696/// Submits synchronously and panics on failure — called once at startup.
697fn register_buffer_pool(ring: &mut IoUring, pool_ptr: *mut u8) {
698 let sqe = opcode::ProvideBuffers::new(pool_ptr, BUF_SIZE as i32, NUM_BUFFERS, BUF_GROUP_ID, 0)
699 .build()
700 .user_data(PROVIDE_BUFS_TOKEN);
701
702 unsafe {
703 ring.submission()
704 .push(&sqe)
705 .expect("io_uring SQ full during buffer pool registration");
706 }
707
708 ring.submit_and_wait(1)
709 .expect("io_uring submit failed during buffer pool registration");
710
711 // Check the completion result.
712 let cqe = ring
713 .completion()
714 .next()
715 .expect("no CQE after ProvideBuffers");
716 assert!(cqe.result() >= 0, "ProvideBuffers failed: {}", cqe.result());
717}
718
719/// Re-provide a single consumed buffer back to the pool. Pushed to SQ
720/// without immediate submission — batched with the next submit_and_wait.
721fn re_provide_buffer(ring: &mut IoUring, pool_ptr: *mut u8, buf_id: usize) {
722 let buf_ptr = unsafe { pool_ptr.add(buf_id * BUF_SIZE) };
723 let sqe = opcode::ProvideBuffers::new(buf_ptr, BUF_SIZE as i32, 1, BUF_GROUP_ID, buf_id as u16)
724 .build()
725 .user_data(PROVIDE_BUFS_TOKEN);
726
727 unsafe {
728 ring.submission()
729 .push(&sqe)
730 .expect("io_uring SQ full — increase RING_SIZE");
731 }
732}
733
734/// Push a multishot RECV SQE for a connection. The kernel will produce
735/// CQEs continuously until EOF, error, or buffer pool exhaustion —
736/// no resubmission needed unless multishot terminates.
737fn push_recv_multi<R>(ring: &mut IoUring, slab: &mut ConnectionSlab<R>, idx: usize) {
738 let entry = match slab.get_mut(idx) {
739 Some(e) => e,
740 None => return,
741 };
742
743 if entry.multishot_active {
744 return;
745 }
746
747 let sqe = opcode::RecvMulti::new(types::Fd(entry.fd), BUF_GROUP_ID)
748 .build()
749 .user_data(idx as u64);
750
751 unsafe {
752 ring.submission()
753 .push(&sqe)
754 .expect("io_uring SQ full — increase RING_SIZE");
755 }
756 entry.multishot_active = true;
757}
758
759/// Push a READ SQE for the eventfd (wakeup notification).
760fn push_eventfd_read(ring: &mut IoUring, wakeup_fd: RawFd, buf: *mut u8) {
761 let sqe = opcode::Read::new(types::Fd(wakeup_fd), buf, 8)
762 .build()
763 .user_data(EVENTFD_TOKEN);
764
765 unsafe {
766 ring.submission()
767 .push(&sqe)
768 .expect("io_uring SQ full — increase RING_SIZE");
769 }
770}
771
772// ---------------------------------------------------------------------------
773// Frame parsing
774// ---------------------------------------------------------------------------
775
776/// Extract complete frames from the connection's parse buffer, decode them,
777/// and publish to the disruptor. Returns `true` if the connection should be
778/// dropped (e.g., oversized frame).
779/// Extract complete frames from `conn.parse_buf` and publish them as
780/// `InputSlot`s. `batch_wall_ns` is the wall-clock timestamp captured
781/// once per CQE batch by the caller (see `reader_loop`); all non-query
782/// requests published in this call share it, sparing the reader a
783/// per-request `clock_gettime(CLOCK_REALTIME)` on the hot path. Returns
784/// `true` if the connection should be dropped.
785fn process_frames<A: Application, R>(
786 conn: &mut ConnectionEntry<R>,
787 producer: &mut ring::Producer<InputSlot<A::Event>>,
788 decoder: &dyn RequestDecoder<Event = A::Event>,
789 server_busy_frame: &[u8; 5],
790 batch_wall_ns: u64,
791 #[cfg(feature = "latency-trace")] publish_rec: &mut melin_transport_core::trace::StageRecorder,
792 #[cfg(feature = "tick-to-trade")] ingest_rec: &mut melin_transport_core::trace::StageRecorder,
793) -> bool {
794 use crate::client_frames::{FrameAction, process_client_frames};
795
796 let action = process_client_frames(
797 &mut conn.parse_buf,
798 conn.connection_id,
799 conn.key_hash,
800 conn.permission,
801 producer,
802 decoder,
803 batch_wall_ns,
804 #[cfg(feature = "latency-trace")]
805 publish_rec,
806 #[cfg(feature = "tick-to-trade")]
807 ingest_rec,
808 );
809
810 match action {
811 FrameAction::Continue => false,
812 FrameAction::Disconnect => true,
813 FrameAction::PipelineFull => {
814 debug!(
815 connection_id = conn.connection_id,
816 "pipeline full, sending ServerBusy"
817 );
818 // Best-effort: if the write fails, the client will timeout.
819 let n = unsafe {
820 libc::write(
821 conn.fd,
822 server_busy_frame.as_ptr().cast(),
823 server_busy_frame.len(),
824 )
825 };
826 if n != server_busy_frame.len() as isize {
827 debug!(
828 connection_id = conn.connection_id,
829 written = n,
830 "ServerBusy write incomplete"
831 );
832 }
833 false
834 }
835 }
836}
837
838#[cfg(test)]
839mod tests {
840 //! Unit tests for [`process_frames`]. The function has four exit paths
841 //! (normal end, partial parse-buf, pipeline-full, oversize-frame), each
842 //! with subtle batch-commit ordering requirements. These tests pin that
843 //! behaviour against a synthetic decoder so refactors of the batch path
844 //! (e.g. moving the batch up to span the whole CQE drain) can't silently
845 //! regress the "earlier frames must be visible before ServerBusy /
846 //! disconnect" guarantees.
847 use super::*;
848 use melin_app::auth::Permission;
849 use melin_app::decoder::{Decoded, RequestDecoder};
850 use melin_app::{AppEvent, Application, ApplyCtx, CodecError, RejectReason};
851 use melin_journal::JournalEvent;
852 use melin_pipeline::ring::DisruptorBuilder;
853 use std::io::{ErrorKind, Read};
854 use std::os::unix::net::UnixStream;
855 use std::time::Duration;
856
857 /// Minimal `AppEvent` for these tests. `Copy` is required by `AppEvent`;
858 /// the on-wire codec is unused because [`TagDecoder`] never invokes it
859 /// (frames are interpreted directly from their tag byte).
860 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
861 enum TestEvent {
862 Cmd(u8),
863 Query,
864 }
865
866 impl AppEvent for TestEvent {
867 fn encoded_size(&self) -> usize {
868 // Unused — the tests never round-trip through encode/decode.
869 2
870 }
871 fn encode(&self, _buf: &mut [u8]) -> usize {
872 unreachable!("process_frames does not encode app events")
873 }
874 fn decode(_buf: &[u8]) -> Result<Self, CodecError> {
875 unreachable!("process_frames does not decode app events directly")
876 }
877 fn is_query(&self) -> bool {
878 matches!(self, TestEvent::Query)
879 }
880 }
881
882 /// Placeholder `Application` impl. `process_frames` is generic over `A`
883 /// only to constrain `A::Event` — none of the trait methods are called
884 /// from the function under test, so they all `unreachable!`.
885 struct TestApp;
886
887 impl Application for TestApp {
888 type Event = TestEvent;
889 type Report = ();
890 type QueryResponse = ();
891 const APP_VERSION: u16 = 0;
892 fn apply(&mut self, _event: TestEvent, _ctx: &ApplyCtx, _out: &mut Vec<()>) -> Option<()> {
893 unreachable!()
894 }
895 fn tick(&mut self, _now_ns: u64, _out: &mut Vec<()>) {
896 unreachable!()
897 }
898 fn check_request_seq(&mut self, _key_hash: u64, _seq: u64) -> bool {
899 unreachable!()
900 }
901 fn build_reject(_event: &TestEvent, _reason: RejectReason) -> () {
902 unreachable!()
903 }
904 fn snapshot<W: std::io::Write>(&self, _w: &mut W) -> std::io::Result<()> {
905 unreachable!()
906 }
907 fn restore<R: Read>(_r: &mut R) -> std::io::Result<Self> {
908 unreachable!()
909 }
910 }
911
912 /// Stateless decoder that maps a frame's single payload byte to a
913 /// [`Decoded`] outcome. Lets each test feed a precise mix of permitted,
914 /// filtered, denied, and decode-error frames without standing up the
915 /// real wire codec.
916 ///
917 /// Tag mapping (`0x00..=0xFB` map 1:1 to a Permitted seq, reserving the
918 /// top four byte values for the non-Permitted outcomes):
919 /// * `0xFC` -> `Filter`
920 /// * `0xFD` -> `PermissionDenied`
921 /// * `0xFE` -> `DecodeError`
922 /// * `0xFF` -> `Permitted` with `is_query == true`
923 /// * `0x00..=0xFB` -> `Permitted` with `request_seq == byte`
924 struct TagDecoder;
925
926 impl RequestDecoder for TagDecoder {
927 type Event = TestEvent;
928 fn decode(&self, bytes: &[u8], _permission: Permission) -> Decoded<TestEvent> {
929 match bytes.first().copied() {
930 None => Decoded::DecodeError("empty payload"),
931 Some(0xFC) => Decoded::Filter,
932 Some(0xFD) => Decoded::PermissionDenied("denied"),
933 Some(0xFE) => Decoded::DecodeError("bad"),
934 Some(0xFF) => Decoded::Permitted {
935 request_seq: 0xFF,
936 event: TestEvent::Query,
937 },
938 Some(b) => Decoded::Permitted {
939 request_seq: b as u64,
940 event: TestEvent::Cmd(b),
941 },
942 }
943 }
944 }
945
946 /// 5-byte ServerBusy placeholder. The real reader passes an encoded
947 /// `ResponseKind::ServerBusy`, but `process_frames` writes the bytes
948 /// verbatim — distinct sentinel bytes make peer-side assertions
949 /// unambiguous.
950 const TEST_SERVER_BUSY: [u8; 5] = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE];
951
952 /// One-byte payload framed as `[u32 LE length=1][byte]`.
953 fn frame(byte: u8) -> [u8; 5] {
954 let mut f = [0u8; 5];
955 f[..4].copy_from_slice(&1u32.to_le_bytes());
956 f[4] = byte;
957 f
958 }
959
960 /// Length prefix announcing an oversize frame. No payload bytes follow —
961 /// `process_frames` decides on the prefix alone, before waiting for the
962 /// body.
963 fn oversize_prefix() -> [u8; 4] {
964 ((MAX_FRAME_SIZE as u32) + 1).to_le_bytes()
965 }
966
967 /// Test fixture bundle. Grouped into a struct rather than returned as a
968 /// 4-tuple to keep clippy happy (`type_complexity`) and to give each
969 /// field a name at call sites.
970 struct Fixture {
971 conn: ConnectionEntry<UnixStream>,
972 producer: ring::Producer<InputSlot<TestEvent>>,
973 consumer: ring::Consumer<InputSlot<TestEvent>>,
974 /// Client-side end of the socket pair — read from this to inspect
975 /// any `ServerBusy` bytes the function under test writes.
976 peer: UnixStream,
977 }
978
979 /// Build a fresh fixture: a `ConnectionEntry` backed by a `UnixStream`
980 /// pair plus a single-consumer disruptor of the requested capacity.
981 fn make_fixture(ring_capacity: usize) -> Fixture {
982 let (server_side, peer) = UnixStream::pair().expect("UnixStream::pair");
983 // Short read timeout on the peer so assertions of "no ServerBusy
984 // written" return promptly instead of hanging the test.
985 peer.set_read_timeout(Some(Duration::from_millis(50)))
986 .expect("set_read_timeout");
987
988 let entry = ConnectionEntry::<UnixStream> {
989 connection_id: 7,
990 addr: "127.0.0.1:1".parse().expect("addr parses"),
991 permission: Permission::Trader,
992 key_hash: 0xC0FFEE_u64,
993 fd: server_side.as_raw_fd(),
994 _reader: server_side,
995 parse_buf: Vec::with_capacity(64),
996 multishot_active: false,
997 last_activity: Instant::now(),
998 };
999
1000 let (producer, mut consumers) =
1001 DisruptorBuilder::<InputSlot<TestEvent>>::new(ring_capacity)
1002 .add_consumer()
1003 .build();
1004 let consumer = consumers.pop().expect("consumer present");
1005
1006 Fixture {
1007 conn: entry,
1008 producer,
1009 consumer,
1010 peer,
1011 }
1012 }
1013
1014 /// Invoke `process_frames::<TestApp, UnixStream>`, threading the
1015 /// feature-gated histogram args when the relevant features are on so
1016 /// the call compiles in every `cargo test` configuration.
1017 fn run_process_frames(
1018 conn: &mut ConnectionEntry<UnixStream>,
1019 producer: &mut ring::Producer<InputSlot<TestEvent>>,
1020 ) -> bool {
1021 #[cfg(feature = "latency-trace")]
1022 let mut publish_rec = melin_transport_core::trace::register_stage("test: publish");
1023 #[cfg(feature = "tick-to-trade")]
1024 let mut ingest_rec = melin_transport_core::trace::register_stage("test: ingest");
1025
1026 process_frames::<TestApp, UnixStream>(
1027 conn,
1028 producer,
1029 &TagDecoder,
1030 &TEST_SERVER_BUSY,
1031 0xDEAD_BEEF,
1032 #[cfg(feature = "latency-trace")]
1033 &mut publish_rec,
1034 #[cfg(feature = "tick-to-trade")]
1035 &mut ingest_rec,
1036 )
1037 }
1038
1039 /// Drain `consumer` into a Vec of `(seq, slot)` until it yields `None`.
1040 /// Used to assert exact event sequences after `process_frames` returns.
1041 fn drain(
1042 consumer: &mut ring::Consumer<InputSlot<TestEvent>>,
1043 ) -> Vec<(u64, InputSlot<TestEvent>)> {
1044 let mut out = Vec::new();
1045 while let Some(pair) = consumer.try_consume() {
1046 out.push(pair);
1047 }
1048 out
1049 }
1050
1051 /// Try to read exactly 5 bytes (the ServerBusy frame size) from the
1052 /// peer. Returns `Some(bytes)` if the read completes within the peer's
1053 /// configured timeout, `None` if it times out (i.e. nothing was sent).
1054 fn read_server_busy(peer: &mut UnixStream) -> Option<[u8; 5]> {
1055 let mut buf = [0u8; 5];
1056 match peer.read_exact(&mut buf) {
1057 Ok(()) => Some(buf),
1058 Err(e) if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut => None,
1059 Err(e) => panic!("unexpected peer read error: {e}"),
1060 }
1061 }
1062
1063 #[test]
1064 fn process_frames_publishes_all_frames_after_single_commit() {
1065 // Capacity > number of frames — every frame must succeed and become
1066 // visible to the consumer after the trailing `batch.commit()`.
1067 let Fixture {
1068 mut conn,
1069 mut producer,
1070 mut consumer,
1071 mut peer,
1072 } = make_fixture(16);
1073 for byte in [0x01, 0x02, 0x03, 0x04, 0x05] {
1074 conn.parse_buf.extend_from_slice(&frame(byte));
1075 }
1076
1077 let disconnect = run_process_frames(&mut conn, &mut producer);
1078 assert!(!disconnect, "no oversize frame ⇒ no disconnect");
1079
1080 let events = drain(&mut consumer);
1081 assert_eq!(events.len(), 5, "all 5 frames must be visible");
1082 for (i, (seq, slot)) in events.iter().enumerate() {
1083 assert_eq!(*seq, i as u64, "seq monotonic from 0");
1084 assert_eq!(slot.connection_id, 7);
1085 assert_eq!(slot.key_hash, 0xC0FFEE_u64);
1086 let byte = (i + 1) as u8;
1087 assert_eq!(slot.request_seq, byte as u64);
1088 assert_eq!(slot.event, JournalEvent::App(TestEvent::Cmd(byte)));
1089 // Non-query event ⇒ inherits the caller-supplied wall-clock.
1090 assert_eq!(slot.timestamp_ns, 0xDEAD_BEEF);
1091 }
1092 // Parse buffer fully consumed.
1093 assert!(conn.parse_buf.is_empty());
1094 // No ServerBusy on the wire — no Full happened.
1095 assert!(
1096 read_server_busy(&mut peer).is_none(),
1097 "ServerBusy must not be sent on the happy path"
1098 );
1099 }
1100
1101 #[test]
1102 fn process_frames_rotates_batch_at_commit_every_cap() {
1103 // Push more events than `COMMIT_EVERY` (= 16) into a recv-cycle.
1104 // The cap must trigger at least one mid-loop commit so the
1105 // consumer sees the first capacity-many events before the
1106 // remainder lands. Validates the visibility-delay cap from the
1107 // perf branch — without it, all 32 events would commit together
1108 // and the first frame would wait for the 32nd to decode.
1109 //
1110 // Capacity 64 leaves room for the entire input (no Full); ring
1111 // backpressure is exercised separately in
1112 // `process_frames_partial_commit_then_server_busy_when_pipeline_full`.
1113 let Fixture {
1114 mut conn,
1115 mut producer,
1116 mut consumer,
1117 ..
1118 } = make_fixture(64);
1119 const EVENT_COUNT: usize = 32;
1120 for i in 0..EVENT_COUNT {
1121 // Use bytes 1..=32 (each ≤ 0xFB so TagDecoder yields
1122 // `Permitted` with `request_seq == byte`).
1123 conn.parse_buf.extend_from_slice(&frame((i + 1) as u8));
1124 }
1125
1126 let disconnect = run_process_frames(&mut conn, &mut producer);
1127 assert!(!disconnect, "no oversize / no Full ⇒ no disconnect");
1128
1129 let events = drain(&mut consumer);
1130 assert_eq!(events.len(), EVENT_COUNT, "every event visible");
1131 for (i, (seq, slot)) in events.iter().enumerate() {
1132 assert_eq!(*seq, i as u64, "seq contiguous across batch rotations");
1133 let byte = (i + 1) as u8;
1134 assert_eq!(slot.event, JournalEvent::App(TestEvent::Cmd(byte)));
1135 }
1136 assert!(conn.parse_buf.is_empty());
1137 }
1138
1139 #[test]
1140 fn process_frames_query_event_skips_wall_clock_stamp() {
1141 // `AppEvent::is_query` events bypass the journal stamp — verify
1142 // the timestamp is zeroed even when a non-zero batch_wall_ns was
1143 // supplied.
1144 let Fixture {
1145 mut conn,
1146 mut producer,
1147 mut consumer,
1148 ..
1149 } = make_fixture(8);
1150 conn.parse_buf.extend_from_slice(&frame(0xFF)); // tag → Query
1151
1152 let disconnect = run_process_frames(&mut conn, &mut producer);
1153 assert!(!disconnect);
1154
1155 let events = drain(&mut consumer);
1156 assert_eq!(events.len(), 1);
1157 let (_, slot) = &events[0];
1158 assert_eq!(slot.event, JournalEvent::App(TestEvent::Query));
1159 assert_eq!(
1160 slot.timestamp_ns, 0,
1161 "query events must skip the wall-clock stamp"
1162 );
1163 }
1164
1165 #[test]
1166 fn process_frames_partial_commit_then_server_busy_when_pipeline_full() {
1167 // Ring capacity 4 + 6 frames ⇒ first 4 commit, 5th triggers Full,
1168 // 6th is never reached because the loop breaks on Full. Validates:
1169 // * `Err(Full)` does not roll back the prior 4 (single commit
1170 // happens before the ServerBusy write).
1171 // * The frame that triggered Full is silently dropped — its bytes
1172 // are compacted out of `parse_buf` along with every earlier
1173 // frame, mirroring pre-batch behaviour.
1174 // * ServerBusy is written exactly once to the peer.
1175 let Fixture {
1176 mut conn,
1177 mut producer,
1178 mut consumer,
1179 mut peer,
1180 } = make_fixture(4);
1181 for byte in [0x01, 0x02, 0x03, 0x04, 0x05, 0x06] {
1182 conn.parse_buf.extend_from_slice(&frame(byte));
1183 }
1184
1185 let disconnect = run_process_frames(&mut conn, &mut producer);
1186 assert!(!disconnect, "Full does not drop the connection");
1187
1188 let events = drain(&mut consumer);
1189 assert_eq!(
1190 events.len(),
1191 4,
1192 "only the first capacity-many frames are visible"
1193 );
1194 for (i, (_, slot)) in events.iter().enumerate() {
1195 let byte = (i + 1) as u8;
1196 assert_eq!(slot.event, JournalEvent::App(TestEvent::Cmd(byte)));
1197 }
1198
1199 // ServerBusy is delivered to the peer.
1200 let busy = read_server_busy(&mut peer).expect("ServerBusy frame written");
1201 assert_eq!(busy, TEST_SERVER_BUSY);
1202
1203 // The frame that triggered Full (0x05) had its bytes consumed by
1204 // the loop's `cursor +=` before `try_push_with` ran; the 6th frame
1205 // is never inspected because the loop broke. Compaction shifts
1206 // the unprocessed tail (the 6th frame's bytes) to the front.
1207 assert_eq!(
1208 conn.parse_buf,
1209 frame(0x06).to_vec(),
1210 "the 6th frame remains in parse_buf for the next recv-cycle"
1211 );
1212 }
1213
1214 #[test]
1215 fn process_frames_oversize_commits_prior_frames_then_signals_disconnect() {
1216 // Two valid frames followed by an oversize length prefix must:
1217 // * publish the two valid frames (commit-before-break) so the
1218 // pipeline observes them even though we're about to tear the
1219 // connection down,
1220 // * return `true` so the caller drops the connection,
1221 // * NOT write ServerBusy (that is reserved for pipeline-full).
1222 let Fixture {
1223 mut conn,
1224 mut producer,
1225 mut consumer,
1226 mut peer,
1227 } = make_fixture(16);
1228 conn.parse_buf.extend_from_slice(&frame(0x01));
1229 conn.parse_buf.extend_from_slice(&frame(0x02));
1230 conn.parse_buf.extend_from_slice(&oversize_prefix());
1231
1232 let disconnect = run_process_frames(&mut conn, &mut producer);
1233 assert!(disconnect, "oversize frame must request disconnect");
1234
1235 let events = drain(&mut consumer);
1236 assert_eq!(
1237 events.len(),
1238 2,
1239 "prior frames are committed before the break"
1240 );
1241 assert_eq!(events[0].1.event, JournalEvent::App(TestEvent::Cmd(0x01)));
1242 assert_eq!(events[1].1.event, JournalEvent::App(TestEvent::Cmd(0x02)));
1243
1244 assert!(
1245 read_server_busy(&mut peer).is_none(),
1246 "ServerBusy is sent on Full, not on oversize"
1247 );
1248 }
1249
1250 #[test]
1251 fn process_frames_filters_denied_and_decode_errors_advance_cursor() {
1252 // Mixed batch: Permitted, Filter, PermissionDenied, DecodeError,
1253 // Permitted. Only the two Permitted frames must reach the
1254 // consumer; all bytes are consumed (parse_buf fully drains).
1255 let Fixture {
1256 mut conn,
1257 mut producer,
1258 mut consumer,
1259 ..
1260 } = make_fixture(16);
1261 for byte in [0x01, 0xFC, 0xFD, 0xFE, 0x02] {
1262 conn.parse_buf.extend_from_slice(&frame(byte));
1263 }
1264
1265 let disconnect = run_process_frames(&mut conn, &mut producer);
1266 assert!(!disconnect);
1267
1268 let events = drain(&mut consumer);
1269 assert_eq!(events.len(), 2);
1270 assert_eq!(events[0].1.event, JournalEvent::App(TestEvent::Cmd(0x01)));
1271 assert_eq!(events[1].1.event, JournalEvent::App(TestEvent::Cmd(0x02)));
1272 assert!(
1273 conn.parse_buf.is_empty(),
1274 "all bytes advanced past compaction"
1275 );
1276 }
1277
1278 #[test]
1279 fn process_frames_preserves_partial_trailing_frame() {
1280 // One complete frame followed by a truncated length prefix. The
1281 // complete frame must publish; the partial bytes must survive
1282 // compaction at the front of `parse_buf` for the next recv-cycle.
1283 let Fixture {
1284 mut conn,
1285 mut producer,
1286 mut consumer,
1287 ..
1288 } = make_fixture(16);
1289 conn.parse_buf.extend_from_slice(&frame(0x42));
1290 // Three of four length-prefix bytes — `cursor + 4 <= len()` is
1291 // false, so the loop breaks before consuming anything from the
1292 // partial.
1293 conn.parse_buf.extend_from_slice(&[0xDE, 0xAD, 0xBE]);
1294
1295 let disconnect = run_process_frames(&mut conn, &mut producer);
1296 assert!(!disconnect);
1297
1298 let events = drain(&mut consumer);
1299 assert_eq!(events.len(), 1);
1300 assert_eq!(events[0].1.event, JournalEvent::App(TestEvent::Cmd(0x42)));
1301 assert_eq!(
1302 conn.parse_buf,
1303 vec![0xDE, 0xAD, 0xBE],
1304 "partial length prefix preserved for next recv-cycle"
1305 );
1306 }
1307
1308 #[test]
1309 fn process_frames_empty_buffer_is_noop() {
1310 // No bytes in parse_buf ⇒ loop never enters; commit is the
1311 // documented zero-slot no-op; no ServerBusy; no disconnect.
1312 let Fixture {
1313 mut conn,
1314 mut producer,
1315 mut consumer,
1316 mut peer,
1317 } = make_fixture(4);
1318
1319 let disconnect = run_process_frames(&mut conn, &mut producer);
1320 assert!(!disconnect);
1321 assert_eq!(drain(&mut consumer).len(), 0);
1322 assert!(conn.parse_buf.is_empty());
1323 assert!(read_server_busy(&mut peer).is_none());
1324 }
1325}