Skip to main content

melin_server_runtime/
response.rs

1//! io_uring-based response stage — routes matching output to connections via
2//! `IORING_OP_SEND`.
3//!
4//! Replaces the blocking `write(2)` + `BufWriter` flush path with batched
5//! io_uring sends. Instead of N `write(2)` syscalls (one per dirty connection
6//! on flush), we submit N SEND SQEs in a single `io_uring_enter` call.
7//!
8//! Same SPSC consumption and journal cursor gating as `response.rs`.
9//! Runs on a dedicated OS thread.
10
11use std::os::unix::io::RawFd;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
14use std::sync::mpsc;
15use std::time::{Duration, Instant};
16
17use io_uring::{IoUring, opcode, types};
18use rustc_hash::FxHashMap;
19use tracing::{debug, error};
20
21use melin_pipeline::ring;
22use melin_pipeline::wait::WaitStrategy;
23
24use crate::ack_policy::{AckPolicy, Blocker, CursorView, EvalStatus, MAX_CLUSTER_SIZE, Policy};
25use crate::halt::{Refusal, RefusalQueue};
26use crate::replication::ReplicationMetrics;
27use melin_app::Application;
28use melin_app::amortized_timer::AmortizedTimer;
29use melin_transport_core::pipeline::{OutputPayload, OutputSlot, StageUtilization};
30#[cfg(feature = "latency-trace")]
31use melin_transport_core::trace;
32use melin_transport_core::{DurableWireSeqCursor, WireSeq};
33
34use melin_wire_protocol::control::TransportResponse;
35use melin_wire_protocol::control_codec;
36
37/// Maximum number of output slots consumed per batch.
38const MAX_BATCH: usize = 1024;
39
40/// Bound on one encoded response frame, length prefix included.
41///
42/// The response stage encodes every report and query response into a
43/// stack buffer of this size. An application whose `ResponseEncoder`
44/// needs more does not get a larger buffer: the encode fails, the reply
45/// is dropped, and the failure is logged at `error!` — so an application
46/// should check its widest frame against this bound at compile time
47/// rather than discover it under load. Public for that reason; see the
48/// re-export in the crate root.
49pub const MAX_RESPONSE_BUF: usize = 512;
50
51/// io_uring submission queue depth for sends. Must be ≥ max concurrent
52/// connections to avoid SQ overflow when all connections are dirty.
53/// Power of 2 for io_uring alignment. 4096 supports 1024+ client
54/// benchmarks where all connections flush simultaneously.
55const RING_SIZE: u32 = 4096;
56
57/// Maximum accumulated send buffer per connection (64 KiB). If a client
58/// falls behind and the buffer exceeds this, the connection is dropped.
59/// 64 KiB holds ~500 response frames — well beyond any reasonable lag.
60const MAX_SEND_BUF: usize = 64 * 1024;
61
62/// Minimum interval between SEND retries to a connection whose socket
63/// buffer is full (`MSG_DONTWAIT` completed with `EAGAIN`). The stage
64/// busy-spins, so without pacing a blocked connection would cost one
65/// futile `io_uring_enter` per loop iteration. 100 µs adds no meaningful
66/// delivery delay: a full socket buffer drains at the client's read
67/// cadence (millisecond scale), and healthy connections are never paced.
68const BLOCKED_RETRY_INTERVAL: Duration = Duration::from_micros(100);
69
70/// Byte-threshold flush trigger for the consumed (busy) path — the
71/// still-open half of finding 4 in
72/// `docs/internal/latency-audit-2026-07.md`. Under sustained load with
73/// an open durability gate, neither the idle-path flush (SPSC never
74/// empties) nor the gate-path flush (no wait) runs, so without this
75/// trigger responses sit in `send_buf` until `MAX_SEND_BUF` *drops* the
76/// connection instead of serving it. Roughly one TCP MSS (1460 for
77/// standard Ethernet, minus margin): once a connection has a full
78/// segment buffered, flushing early costs nothing in wire efficiency,
79/// and the bound keeps `send_buf` ~45 flushes away from the disconnect
80/// cap instead of zero.
81const FLUSH_BYTES_THRESHOLD: usize = 1400;
82
83/// Slot-count flush trigger: how many appended slots may pass between
84/// flushes before one is forced, regardless of how few bytes any single
85/// connection has buffered.
86///
87/// [`FLUSH_BYTES_THRESHOLD`] bounds a *connection's* buffered bytes but
88/// not their *age*: the interval to the next flush is the time the
89/// busiest connection needs to accumulate an MSS, which grows with
90/// client count. At 4 clients that is microseconds; at 100 clients each
91/// sending ~40 B responses at a modest rate it is milliseconds, and every
92/// connection's already-encoded responses wait that long. Counting slots
93/// instead makes the bound depend on the stage's own throughput: one
94/// extra `io_uring_enter` per 256 responses is well amortized (~0.1 % of
95/// the per-slot cost at 1 M/s) and caps the wait at the time to encode
96/// 256 responses — tens of microseconds — however the load is spread.
97const FLUSH_SLOT_INTERVAL: usize = 256;
98
99/// The consumed path's flush cadence: both triggers plus the counter
100/// they share.
101///
102/// A struct rather than loose locals because the age counter has to be
103/// restarted by *every* flush site (consumed path, pre-gate-wait, idle),
104/// and a missed reset silently shortens the bound. One `on_flush` call
105/// per site is checkable by eye.
106#[derive(Default)]
107struct FlushCadence {
108    /// Slots appended since the last flush — the age bound.
109    slots_since_flush: usize,
110    /// Latched by the byte trigger, cleared by [`Self::on_flush`].
111    due: bool,
112}
113
114impl FlushCadence {
115    /// Record one appended slot on a connection that now holds
116    /// `buffered_bytes` unflushed.
117    #[inline]
118    fn on_append(&mut self, buffered_bytes: usize) {
119        self.slots_since_flush += 1;
120        self.due |= buffered_bytes >= FLUSH_BYTES_THRESHOLD;
121    }
122
123    /// Whether a flush must run before the next slot is encoded.
124    #[inline]
125    fn is_due(&self) -> bool {
126        self.due || self.slots_since_flush >= FLUSH_SLOT_INTERVAL
127    }
128
129    /// Restart both triggers. Call immediately after any `flush_sends`.
130    #[inline]
131    fn on_flush(&mut self) {
132        self.due = false;
133        self.slots_since_flush = 0;
134    }
135}
136
137/// How long a connection's socket may accept *zero* bytes while it has
138/// undelivered data before it is dropped. Partial progress restarts the
139/// clock — a slow-but-draining client is `MAX_SEND_BUF`'s problem, not
140/// this one's. Complements `MAX_SEND_BUF`, which only catches clients
141/// that lag while new responses keep *arriving* — a client that stops
142/// reading during a quiet period accumulates almost nothing (heartbeats
143/// only) and would otherwise pin its buffered bytes and its slot
144/// indefinitely.
145const BLOCKED_SEND_TIMEOUT: Duration = Duration::from_secs(5);
146
147pub use crate::ControlEvent;
148
149/// Encoder type alias: response encoder bound to the application's
150/// `Report` / `QueryResponse` types. Hides the long
151/// `dyn ResponseEncoder<Report = ..., Query = ...>` at call sites.
152pub type ResponseEncoderArc<A> = Arc<
153    dyn melin_app::encoder::ResponseEncoder<
154            Report = <A as Application>::Report,
155            Query = <A as Application>::QueryResponse,
156        >,
157>;
158
159/// Configuration and shared state for the response stage.
160pub struct Response<A: Application> {
161    /// Highest wire seq durably persisted on this node's journal.
162    /// In the same sequence space as `OutputSlot.wire_seq` and the
163    /// replica metrics (`metrics.in_memory_sequence` /
164    /// `metrics.acked_sequence`), so the durability gate can compare
165    /// these values numerically and the comparison is meaningful
166    /// regardless of `starting_sequence` (fresh vs recovered primary).
167    /// Typed [`DurableWireSeqCursor`] so the gate cannot be wired to a
168    /// ring-space counter — the pre-v14 bug class. Updated by the
169    /// journal stage after every fsync batch via `set_last_seq_publisher`.
170    pub journal_persisted_wire_seq: DurableWireSeqCursor,
171    /// Operator-selected ack policy, published through a shared
172    /// [`AtomicU8`](std::sync::atomic::AtomicU8) so the admin `ACK-POLICY` command can swap it at
173    /// runtime without restarting the node. The response stage reads
174    /// this once per gate iteration with a relaxed load (cheaper than a
175    /// `Mutex` or refcounted `Arc<Policy>` snapshot) and rebuilds its
176    /// local [`Policy`] when the byte changes. See
177    /// [`crate::ack_policy::AckPolicy::as_u8`] for the encoding.
178    pub ack_policy: Arc<std::sync::atomic::AtomicU8>,
179    /// Per-slot replica cursors. `None` for standalone deployments
180    /// (no replication wiring) — the policy then evaluates against the
181    /// primary alone.
182    pub replication_metrics: Option<Arc<ReplicationMetrics>>,
183    /// Per-slot replica active flags. Only "true" slots are included in
184    /// the cursor view fed to `Policy::evaluate`, so disconnected slots
185    /// don't pollute the view with stale zero cursors. When the
186    /// resulting view is too small to satisfy a clause, the policy
187    /// reports degraded and the gate stalls.
188    /// Mirrors `replication_metrics` — `None` in standalone.
189    pub replica_active: Option<[Arc<AtomicBool>; 2]>,
190    pub heartbeat_interval: Option<Duration>,
191    /// How this thread waits — on an empty output ring, and inside the
192    /// durability gate on the journal-disk and replication threads.
193    pub wait: WaitStrategy,
194    pub utilization: Arc<StageUtilization>,
195    /// Wire encoder for application-shaped payloads. Constructed
196    /// once at boot (`Arc::new(ExchangeResponseEncoder)`) and shared
197    /// with the DPDK response stage.
198    pub encoder: ResponseEncoderArc<A>,
199    /// Node fencing state. When latched (a higher epoch was observed), the
200    /// stage exits *without* the best-effort flush — in-flight responses
201    /// for orders on a superseded epoch must not be acknowledged. Fencing
202    /// co-sets `shutdown`, so the latch is only consulted on the shutdown
203    /// path (zero steady-state cost). See `crate::fence`.
204    pub fence_state: Arc<melin_transport_core::fence::FenceState>,
205    /// Live-connection count shared with the accept loop's
206    /// `max_connections` gate. Incremented there after auth; decremented
207    /// here — the response stage owns the connection map, so "an entry
208    /// left the map" is the one place a connection verifiably dies
209    /// exactly once, whichever side (reader disconnect or a drop
210    /// decision here) initiated it.
211    pub active_connections: Arc<AtomicU64>,
212    /// Writes the reader refused while the node was halted. Each reply is
213    /// sent once everything published before the refused request has been
214    /// answered, so a client sees its replies in request order — see
215    /// [`crate::halt`].
216    pub refusals: RefusalQueue<A::Report>,
217    /// Test seam: called once per iteration right after the control
218    /// channel has been drained, so a test can hold the stage there and
219    /// choose what lands while it waits. The drain runs after the
220    /// output ring is read, and the seam guards that order: were the
221    /// drain moved back in front of the read, the seam would sit
222    /// between the two, and a `Connected` landing there would be
223    /// missed by the read that follows — which `registration_race`
224    /// checks. `None` runs the stage as production does; the field
225    /// itself is compiled out of non-test builds, so the loop pays
226    /// nothing.
227    #[cfg(test)]
228    pub pause_after_control_drain: Option<Box<dyn FnMut() + Send>>,
229}
230
231/// Per-connection state for batched io_uring sends.
232struct ConnectionEntry {
233    fd: RawFd,
234    /// Owns the write half of the socket to keep the fd alive.
235    _owner: Box<dyn Send>,
236    /// Accumulates encoded response frames between flushes.
237    /// The full wire frame (length prefix + payload) is appended here.
238    /// Vec's internal data pointer is heap-stable, so io_uring SEND SQEs
239    /// referencing `as_ptr()` remain valid even if the HashMap relocates
240    /// this struct. No SEND outlives its `flush_sends` call (every SQE
241    /// carries `MSG_DONTWAIT`, and the flush reaps all completions before
242    /// returning), so the Vec is never mutated while the kernel holds its
243    /// pointer.
244    send_buf: Vec<u8>,
245    /// Last time data was sent to this connection. Used for heartbeat scheduling.
246    last_send: Instant,
247    /// When this connection's socket last made zero forward progress:
248    /// set on `EAGAIN`, reset to the flush timestamp on a partial send
249    /// (bytes moved), cleared when the buffer fully drains. `None` while
250    /// the socket is accepting everything. Drives retry pacing and the
251    /// [`BLOCKED_SEND_TIMEOUT`] drop.
252    blocked_since: Option<Instant>,
253    /// Last SEND attempt. Read only while blocked, to pace retries at
254    /// [`BLOCKED_RETRY_INTERVAL`].
255    last_send_attempt: Instant,
256    /// Whether this connection is on the dirty list. A flag on the entry
257    /// rather than set membership: the append path already holds the
258    /// entry (and just wrote its `send_buf`), so the dedup test is a
259    /// byte in a cache line that is hot, instead of a hash + probe on
260    /// every one of the ~2 frames per request. See `dirty_connections`.
261    dirty: bool,
262}
263
264/// Run the io_uring response stage loop. Blocks the calling thread until shutdown.
265///
266/// Consumes from the output SPSC, waits for durability confirmation, and
267/// sends responses via io_uring SEND.
268///
269/// Ack gating: every gate iteration reads the journal cursor (this
270/// node's persisted) plus per-slot replica cursors (in-memory and
271/// persisted) from `replication_metrics` and feeds them through the
272/// configured [`Policy`]. See `evaluate_durability`.
273pub fn run<A: Application>(
274    mut consumer: ring::Consumer<OutputSlot<A::Report, A::QueryResponse>>,
275    control_rx: mpsc::Receiver<ControlEvent>,
276    config: Response<A>,
277    shutdown: &AtomicBool,
278) {
279    let Response {
280        journal_persisted_wire_seq,
281        ack_policy,
282        replication_metrics,
283        replica_active,
284        heartbeat_interval,
285        wait,
286        utilization,
287        encoder,
288        fence_state,
289        active_connections,
290        mut refusals,
291        #[cfg(test)]
292        mut pause_after_control_drain,
293    } = config;
294    // Resolve the starting policy from the shared atomic and derive the
295    // local Policy. The atomic is the single source of truth across the
296    // process lifetime; the response thread keeps a thread-local copy
297    // for cheap per-iteration use and rebuilds it when an admin
298    // `ACK-POLICY` command swaps the atomic. Initialise as DiskAndRam
299    // (the default policy) if the atomic ever holds a corrupted byte —
300    // better than panicking on a degraded process and matches the
301    // default operators see at boot.
302    let mut active_policy = AckPolicy::from_u8(
303        ack_policy.load(std::sync::atomic::Ordering::Relaxed),
304    )
305    .unwrap_or_else(|| {
306        tracing::error!(
307            "ack_policy atomic held a corrupted byte at startup; defaulting to disk+ram"
308        );
309        AckPolicy::DiskAndRam
310    });
311    let mut policy = active_policy.to_policy();
312    // SINGLE_ISSUER: created and submitted from this thread only — the
313    // kernel skips SQ locking and rejects cross-thread submission with
314    // EEXIST instead of racing. Matches the journal/replication rings.
315    let mut ring: IoUring = IoUring::builder()
316        .setup_single_issuer()
317        .build(RING_SIZE)
318        .expect("failed to create io_uring instance for response stage");
319
320    // Connection table: maps connection IDs to their state.
321    //
322    // `FxHashMap` rather than std's SipHash-1-3 map: this is looked up
323    // once per output slot on the hot path, and the keys are
324    // server-generated monotonic connection ids — a client cannot choose
325    // them, so the HashDoS resistance SipHash buys is worth nothing here
326    // while its ~20 ns/lookup is not. Pre-sized for a reasonable number
327    // of concurrent clients.
328    let mut connections: FxHashMap<u64, ConnectionEntry> =
329        FxHashMap::with_capacity_and_hasher(256, Default::default());
330
331    let mut encode_buf = [0u8; MAX_RESPONSE_BUF];
332
333    // Cached durability position to avoid atomic reads on every slot.
334    // Initialised below from the policy's startup evaluation; updated
335    // via `evaluate_durability` on every gate iteration.
336    let mut cached_durable_pos: u64;
337
338    // Degradation logger. Tracks transitions, suppresses sub-second
339    // flap noise, and drives the `/healthz` `policy_degraded` gauge.
340    // See `DegradationLogger` for the full state machine. Initialised
341    // below from the policy's startup evaluation so an unsatisfiable
342    // policy (e.g. a primary that just lost both replicas while
343    // running `disk+ram` or `two-disks`) is visible immediately
344    // on `/healthz` and in the journal.
345    let startup_now = Instant::now();
346    let mut last_policy_check = startup_now;
347    /// Re-emit interval for the "still degraded" reminder.
348    const DEGRADED_LOG_INTERVAL: Duration = Duration::from_secs(5);
349    /// Cadence at which the idle path re-evaluates the policy. Bounds
350    /// the lag between a connection-state change and the `/healthz`
351    /// gauge / warn-log reflecting it. Cheap (a handful of atomic
352    /// loads + the policy evaluator) at this rate.
353    const POLICY_CHECK_INTERVAL: Duration = Duration::from_secs(1);
354    /// Cadence at which the gate-wait spin folds elapsed time into the
355    /// degraded-duration counter while the durability gate is stalled.
356    /// Tighter than the idle cadence — it bounds the boundary error when
357    /// a degradation begins or flips mid-wedge, which matters most for
358    /// short stalls. The accrual tick is gated by this period, but the
359    /// clock read behind it is gated by the `AmortizedTimer` mask, so the
360    /// effective resolution is `max(this, the mask's clock-read cadence)`.
361    /// The mask reads the clock ~every 6.5 ms at this spin's rate
362    /// (`AmortizedTimer::CHECK_MASK` is `2^16`), finer than the 10 ms
363    /// here, so this cadence is the binding one and is realized in full.
364    const GATE_ACCRUAL_INTERVAL: Duration = Duration::from_millis(10);
365    /// Cadence at which the idle spin does its housekeeping *clock read*.
366    /// The heartbeat scan, the policy re-check and the trace-stats flush
367    /// each keep their own (coarser) interval; this only bounds how
368    /// often the loop asks the clock what time it is, so it must stay at
369    /// or below the finest of them (`trace::IDLE_FLUSH_INTERVAL`, 100 ms).
370    const IDLE_HOUSEKEEPING_INTERVAL: Duration = Duration::from_millis(10);
371
372    // Initial evaluation so the cached durable position and the
373    // `/healthz` gauge reflect the cluster's startup shape before
374    // the first batch arrives.
375    let mut degraded_logger;
376    {
377        let journal_pos = journal_persisted_wire_seq.load();
378        let metrics_ref = replication_metrics.as_deref();
379        let active_ref = replica_active.as_ref();
380        let status = evaluate_durability(&policy, journal_pos, metrics_ref, active_ref);
381        cached_durable_pos = status.durable_pos;
382        utilization
383            .policy_degraded
384            .store(status.degraded, Ordering::Relaxed);
385        degraded_logger = if status.degraded {
386            DegradationLogger::new_starting_degraded(startup_now, &policy)
387        } else {
388            DegradationLogger::new(startup_now)
389        };
390    }
391
392    // Stage histograms registered with the global registry — see
393    // `melin_transport_core::trace`. The four breakdown stages
394    // (journal-wait, replica-wait, encode, egress) feed the bench's
395    // tick-to-trade decomposition; spsc/dispatch/server-e2e are kept
396    // alongside as overall sanity checks.
397    #[cfg(feature = "latency-trace")]
398    let mut spsc_rec =
399        trace::register_stage("response: SPSC wakeup (matching publish → response consume)");
400    #[cfg(feature = "latency-trace")]
401    let mut dispatch_rec = trace::register_stage("response: dispatch (consume → socket write)");
402    #[cfg(feature = "latency-trace")]
403    let mut server_e2e_rec = trace::register_stage("server e2e (reader recv → response flush)");
404    // Tick-to-trade breakdown: per-slot wait observed for each
405    // durability path (recorded only when the gate actually held us
406    // up — cache-hit paths skip to avoid inflating the metric with
407    // crossings that happened before we noticed). Encode is wall-time
408    // around `encode_transport_response`. Egress wraps a `flush_sends`
409    // call (one sample per io_uring flush, batching many slots).
410    // Gated on `tick-to-trade`, not `latency-trace`, because these
411    // stages roughly double the hot-path mutex traffic vs the lighter
412    // 4-stage mode.
413    #[cfg(feature = "tick-to-trade")]
414    let mut journal_wait_rec =
415        trace::register_stage("response: journal-wait (match_complete → journal cursor crossed)");
416    #[cfg(feature = "tick-to-trade")]
417    let mut replica_wait_rec = trace::register_stage(
418        "response: replica-wait (match_complete → replication cursor crossed)",
419    );
420    #[cfg(feature = "tick-to-trade")]
421    let mut encode_rec = trace::register_stage("response: encode (per-kind wire encoding)");
422    #[cfg(feature = "tick-to-trade")]
423    let mut egress_rec = trace::register_stage("response: egress (flush_sends elapsed)");
424    // Paces the idle-path recorder flush. Without it, every sample this
425    // thread recorded stays in its thread-local buffer once traffic
426    // stops — which is exactly when the bench scrapes /stats-dump.
427    #[cfg(feature = "latency-trace")]
428    let mut last_stats_flush = Instant::now();
429
430    // Server-side end-to-end samples awaiting a flush, tagged with the
431    // connection whose bytes they measure.
432    //
433    // The stage encodes into `send_buf` and hands the bytes to the
434    // kernel later, so closing the sample at encode time would omit
435    // every microsecond a response spends buffered — the interval this
436    // stage most needs to be honest about, since that is exactly where a
437    // deferred flush hides. Holding each frame's `recv_ts` until the
438    // flush completes measures reader-recv → kernel, which is what the
439    // stage name claims and what the DPDK path already records (it
440    // samples after `tx_producers.flush()`).
441    //
442    // The connection id is what lets a connection dropped without a
443    // flush have its samples thrown away rather than closed — see
444    // `discard_e2e_samples`. Two rules keep the queue honest, and every
445    // mutation of `dirty_connections` obeys one of them:
446    //
447    // - a clear-by-flush closes the queue (`close_e2e_samples`),
448    // - a removal-by-drop discards that connection's entries
449    //   (`discard_e2e_samples`).
450    //
451    // Together they give the invariant "queue non-empty implies some
452    // connection is dirty", which is what makes the flush sites
453    // sufficient drain points.
454    //
455    // `Vec` rather than a per-connection map: entries are appended and
456    // then walked wholesale, order is irrelevant, and the dropped-
457    // connection lists they are matched against are almost always empty.
458    // A flat append is the cheapest thing that does that. Capacity is a
459    // starting point, not a bound — under saturation the output ring
460    // never empties, so the queue grows until something forces a flush.
461    //
462    // Only compiled under `latency-trace`; production builds carry
463    // neither the buffer nor the pushes.
464    #[cfg(feature = "latency-trace")]
465    let mut pending_e2e: Vec<(u64, trace::MonoTraceInstant)> = Vec::with_capacity(MAX_BATCH);
466
467    // Connections with buffered (unflushed) writes, carried across
468    // batches.
469    //
470    // `Vec<u64>` paired with `ConnectionEntry::dirty` rather than a
471    // `HashSet`: membership is never *queried*, only iterated and
472    // rebuilt, so the set's only contribution on the hot path was
473    // deduplicating repeat appends — which the entry flag does with a
474    // branch instead of a hash. Pre-sized to the connection table.
475    let mut dirty_connections: Vec<u64> = Vec::with_capacity(256);
476
477    // Flush triggers for the consumed path. Lives across batches because
478    // the regime the age bound exists for is a ring that never empties:
479    // the byte threshold is per connection, so with many low-rate
480    // clients no single `send_buf` reaches an MSS for milliseconds while
481    // the stage encodes continuously.
482    let mut flush = FlushCadence::default();
483
484    // Connections to remove after flush (send errors).
485    let mut to_remove: Vec<u64> = Vec::new();
486
487    // Pre-allocated CQE collection buffer. Must collect CQEs before
488    // processing because the CQ borrow must end before mutating connections.
489    // Pre-sized to RING_SIZE to avoid per-iteration heap allocation.
490    let mut cqes: Vec<(u64, i32)> = Vec::with_capacity(RING_SIZE as usize);
491
492    // Pre-encode the heartbeat response frame once. Full wire frame
493    // (length prefix + tag) for direct append to send_buf.
494    let heartbeat_wire_frame = {
495        let mut buf = [0u8; 8];
496        let written =
497            control_codec::encode_transport_response(&TransportResponse::Heartbeat, &mut buf)
498                .expect("heartbeat encodes");
499        buf[..written].to_vec()
500    };
501
502    // Pre-encode the ServerBusy frame the same way. Sent on behalf of
503    // the reader (`ControlEvent::PipelineBusy`) — this stage owns all
504    // egress on a client socket, so the busy notice appends to
505    // `send_buf` like any other frame instead of racing our sends from
506    // the reader thread.
507    let server_busy_wire_frame = {
508        let mut buf = [0u8; 8];
509        let written =
510            control_codec::encode_transport_response(&TransportResponse::ServerBusy, &mut buf)
511                .expect("ServerBusy encodes");
512        buf[..written].to_vec()
513    };
514
515    // Pre-encode the BatchEnd terminator once. Unlike the two above this
516    // one is on the hot path — every request ends with it — and its
517    // bytes are a constant, so re-encoding it per request was pure
518    // overhead.
519    let batch_end_wire_frame = {
520        let mut buf = [0u8; 8];
521        let written =
522            control_codec::encode_transport_response(&TransportResponse::BatchEnd, &mut buf)
523                .expect("BatchEnd encodes");
524        buf[..written].to_vec()
525    };
526
527    // Coarse timestamp for heartbeat scan — avoids Instant::now() on every spin.
528    let mut last_heartbeat_scan = Instant::now();
529
530    // Gates the idle path's housekeeping clock read. The idle spin used
531    // to call `Instant::now()` twice per iteration (heartbeat scan gate,
532    // policy re-check) to decide it had nothing to do — the same vDSO
533    // tax the DPDK stage removed with this timer.
534    let mut idle_housekeeping_timer = AmortizedTimer::new();
535
536    let mut waiter = wait.waiter();
537
538    let mut busy_count: u64 = 0;
539    let mut idle_count: u64 = 0;
540
541    // Paces accrual ticks inside the durability gate-wait spin so the
542    // degraded-duration counter keeps advancing during a hard stall.
543    // Declared once at function scope (not per gate entry) so the normal
544    // gated path — entered briefly whenever durability lags by a few µs —
545    // pays no extra `Instant::now()`; the amortized mask only reads the
546    // clock once per ~1 M cumulative spin iterations.
547    let mut gate_accrual_timer = AmortizedTimer::new();
548
549    // Input sequence of the last output slot handled. Refused writes are
550    // released only where it changes — between two events' replies, never
551    // inside one. `u64::MAX` is no event's sequence.
552    let mut last_input_seq = u64::MAX;
553
554    'run: loop {
555        // Observe runtime policy swaps from the admin `ACK-POLICY`
556        // command. Relaxed load (single writer is the admin handler,
557        // single reader is this thread). When the byte changes,
558        // rebuild the local Policy and reset the cached durable
559        // position so the next gate evaluation starts from a clean
560        // slate under the new shape; log the transition for the audit
561        // trail. An unknown byte is treated as memory corruption: we
562        // log and keep the prior policy rather than silently downgrading.
563        let observed_byte = ack_policy.load(Ordering::Relaxed);
564        if observed_byte != active_policy.as_u8() {
565            match AckPolicy::from_u8(observed_byte) {
566                Some(next) => {
567                    tracing::info!(
568                        prev = active_policy.as_str(),
569                        next = next.as_str(),
570                        "ack policy swapped at runtime"
571                    );
572                    active_policy = next;
573                    policy = active_policy.to_policy();
574                    // The fresh policy may evaluate degraded/undegraded
575                    // differently against the same cluster shape; let
576                    // the next gate evaluation re-derive.
577                    cached_durable_pos = 0;
578                    // Re-seed the degradation logger so a transition
579                    // out of (or into) degraded under the new policy
580                    // surfaces immediately rather than waiting for the
581                    // sustained-state hold to roll over. Flushes accrual
582                    // first so pre-swap degraded time isn't dropped.
583                    degraded_logger.reseed(&utilization, Instant::now());
584                }
585                None => {
586                    tracing::error!(
587                        byte = observed_byte,
588                        "ack_policy atomic held a corrupted byte; retaining prior policy"
589                    );
590                }
591            }
592        }
593
594        if shutdown.load(Ordering::Relaxed) {
595            // Fence: a superseded ex-primary must not acknowledge any
596            // further in-flight work — skip the best-effort flush so
597            // responses buffered for orders on the old epoch are dropped
598            // (the client sees a connection reset and reconciles on
599            // reconnect). Checked only here, not per slot: fencing always
600            // co-sets `shutdown` (`FenceState::fence_if_superseded` owns
601            // that invariant), and the gate wait comes straight back here
602            // on shutdown, so this branch is the first one a fenced node
603            // reaches and the steady-state loop pays nothing.
604            let flush = !fence_state.is_fenced();
605            // Best-effort flush before shutdown.
606            if flush && !dirty_connections.is_empty() {
607                flush_sends(
608                    &mut ring,
609                    &mut connections,
610                    &mut dirty_connections,
611                    &mut to_remove,
612                    &mut cqes,
613                );
614                #[cfg(feature = "latency-trace")]
615                close_e2e_samples(&mut pending_e2e, &mut server_e2e_rec);
616            }
617            utilization.busy.store(busy_count, Ordering::Relaxed);
618            utilization.idle.store(idle_count, Ordering::Relaxed);
619            #[cfg(feature = "pipeline-stats")]
620            print_utilization("response", busy_count, idle_count);
621            return;
622        }
623
624        // Borrow output slots from the matching stage in place.
625        //
626        // `read_contiguous` rather than `consume_batch`: the latter
627        // memcpy'd every ready slot into a stack array before the first
628        // one was touched, and `OutputSlot` embeds the application's
629        // largest query response (~330 B for the exchange), so the head
630        // slot of a deep batch paid for the whole copy before it could
631        // be encoded. Borrowing costs nothing and the loop below reads
632        // each slot exactly once anyway.
633        //
634        // The progress counter now moves *after* the batch instead of
635        // before it (`consume_batch` committed up front), so the
636        // matching stage cannot reclaim these slots until the loop —
637        // durability gate waits included — is done with them. That is
638        // at most `MAX_BATCH` of a 1 M-slot ring, and the ring fills at
639        // the same rate either way.
640        //
641        // Refusals follow the order `RefusalQueue` documents: the idle
642        // bound before the read, the sync between the read and the
643        // control drain.
644        let idle_refusal_bound = refusals.idle_bound();
645        let slots = consumer.read_contiguous(MAX_BATCH);
646        let refusals_waiting = refusals.sync();
647
648        // Poll the control channel (non-blocking) for connect and
649        // disconnect — after the ring is read, never before. A reply
650        // always trails its connection's `Connected`: the accept loop
651        // queues that event before it registers the socket with the
652        // reader, so no request can be read, let alone answered, until
653        // the event is in the channel. Every slot in `slots` was
654        // published before the read above, so its `Connected` was queued
655        // before that, and this drain sees it. Drained first, the stage
656        // could read the ring after a `Connected` it had not seen and
657        // drop the reply — which it did, now and then, on a fresh node
658        // under load. `registration_race` in the tests holds the stage
659        // right after this drain to pin the order.
660        while let Ok(event) = control_rx.try_recv() {
661            match event {
662                ControlEvent::Connected {
663                    connection_id,
664                    fd,
665                    writer,
666                } => {
667                    // The writer keeps the fd alive — store it as the owner.
668                    let owner: Box<dyn Send> = Box::new(writer);
669                    connections.insert(
670                        connection_id,
671                        ConnectionEntry {
672                            fd,
673                            _owner: owner,
674                            send_buf: Vec::with_capacity(4096),
675                            last_send: Instant::now(),
676                            blocked_since: None,
677                            last_send_attempt: Instant::now(),
678                            dirty: false,
679                        },
680                    );
681                }
682                ControlEvent::Disconnected { connection_id } => {
683                    // Reader-initiated teardown: the reader already
684                    // closed its half. Decrement only if the entry was
685                    // actually present — a response-initiated drop
686                    // already removed it (and paid the decrement), and
687                    // this event is its echo.
688                    if connections.remove(&connection_id).is_some() {
689                        active_connections.fetch_sub(1, Ordering::Relaxed);
690                    }
691                    unmark_dirty(connection_id, &mut dirty_connections);
692                    // Anything this connection had buffered goes with
693                    // it, so its queued samples measure bytes that will
694                    // never be sent.
695                    #[cfg(feature = "latency-trace")]
696                    discard_e2e_samples(&mut pending_e2e, &[connection_id]);
697                }
698                ControlEvent::PipelineBusy { connection_id } => {
699                    if let Some(entry) = connections.get_mut(&connection_id) {
700                        // Overflow discipline: a busy notice to a peer
701                        // already at its buffer cap is not worth more
702                        // memory — skip it; the cap (or the blocked
703                        // timeout) is about to drop the connection
704                        // anyway. Missing entry likewise: best-effort.
705                        if entry.send_buf.len() + server_busy_wire_frame.len() <= MAX_SEND_BUF {
706                            entry.send_buf.extend_from_slice(&server_busy_wire_frame);
707                            entry.last_send = Instant::now();
708                            mark_dirty(entry, connection_id, &mut dirty_connections);
709                        }
710                    }
711                }
712            }
713        }
714
715        // Test seam: hold the stage right after the drain — see
716        // `Response::pause_after_control_drain`.
717        #[cfg(test)]
718        if let Some(pause) = pause_after_control_drain.as_mut() {
719            pause();
720        }
721
722        if slots.is_empty() {
723            // Every event published before a refusal up to the bound has
724            // been answered: send those refusals with this flush.
725            if let Some(bound) = idle_refusal_bound {
726                let now = Instant::now();
727                refusals.release(bound, |refusal| {
728                    append_refusal(
729                        refusal,
730                        &*encoder,
731                        &mut connections,
732                        &mut encode_buf,
733                        &batch_end_wire_frame,
734                        now,
735                        &mut dirty_connections,
736                        &mut to_remove,
737                        &mut flush,
738                    );
739                });
740                // A refusal can overflow its connection's buffer; drop
741                // those before the flush, as the batch end does.
742                #[cfg(feature = "latency-trace")]
743                discard_e2e_samples(&mut pending_e2e, &to_remove);
744                for conn_id in to_remove.drain(..) {
745                    if let Some(entry) = connections.remove(&conn_id) {
746                        teardown_dropped(entry, &active_connections);
747                    }
748                    unmark_dirty(conn_id, &mut dirty_connections);
749                }
750            }
751
752            // SPSC is empty — flush all dirty connections via io_uring.
753            // This is the response-data egress path; heartbeat flushes
754            // below aren't sampled because they're admin traffic, not
755            // on the client RTT path.
756            if !dirty_connections.is_empty() {
757                #[cfg(feature = "tick-to-trade")]
758                let egress_start = trace::mono_trace_ns();
759                flush_sends(
760                    &mut ring,
761                    &mut connections,
762                    &mut dirty_connections,
763                    &mut to_remove,
764                    &mut cqes,
765                );
766                flush.on_flush();
767                #[cfg(feature = "tick-to-trade")]
768                egress_rec.record_elapsed(egress_start, trace::mono_trace_ns());
769                #[cfg(feature = "latency-trace")]
770                close_e2e_samples(&mut pending_e2e, &mut server_e2e_rec);
771                for conn_id in to_remove.drain(..) {
772                    if let Some(entry) = connections.remove(&conn_id) {
773                        teardown_dropped(entry, &active_connections);
774                    }
775                }
776            }
777
778            // Everything below is periodic housekeeping on
779            // sub-second-to-second cadences, and each check needs the
780            // current time only to discover it has nothing to do. One
781            // amortized tick per idle iteration replaces the two
782            // unconditional `Instant::now()` calls that decision used to
783            // cost; the inner intervals are unchanged, so cadences are
784            // the same (see `IDLE_HOUSEKEEPING_INTERVAL`).
785            //
786            // The timer's iteration mask is only engaged once the stage
787            // has been idle for a sustained stretch (the waiter resets
788            // on every consumed batch). That is deliberate: the mask
789            // trades cadence for clock reads, and it should only do so
790            // when clock reads are the loop's whole cost. A stage that
791            // is nearly saturated reaches this path a handful of times
792            // between batches — there the read is already rare, and
793            // masking it would stretch the one-second heartbeat scan
794            // into minutes. A yielding thread never masks either: the
795            // yield syscall dwarfs a vDSO read.
796            //
797            // "Busy-spinning, and has been for a while": under
798            // spin-then-yield a loop past its budget is no longer
799            // spinning, so the conjunction is only ever true under
800            // `BusySpin`.
801            if idle_housekeeping_timer
802                .tick(
803                    IDLE_HOUSEKEEPING_INTERVAL,
804                    waiter.spinning() && waiter.past_spin_budget(),
805                )
806                .is_some()
807            {
808                let now = Instant::now();
809
810                // Send heartbeats to idle connections. Only checked
811                // during idle periods (SPSC empty) to avoid overhead on
812                // the hot path.
813                //
814                // No end-to-end samples to close here: the sample
815                // queue's invariant is that a queued entry implies a
816                // dirty connection, so the flush above — which closes
817                // the queue whenever anything was dirty — leaves it
818                // empty. Anything dirtied from here on is heartbeat
819                // frames, which carry no samples.
820                if let Some(interval) = heartbeat_interval
821                    // Coarse gate: only scan at most once per second.
822                    && now.duration_since(last_heartbeat_scan) >= Duration::from_secs(1)
823                {
824                    last_heartbeat_scan = now;
825                    for (&conn_id, entry) in connections.iter_mut() {
826                        if heartbeat_due(entry, now, interval, heartbeat_wire_frame.len()) {
827                            entry.send_buf.extend_from_slice(&heartbeat_wire_frame);
828                            entry.last_send = now;
829                            mark_dirty(entry, conn_id, &mut dirty_connections);
830                        }
831                    }
832                    // Flush the heartbeat sends immediately.
833                    if !dirty_connections.is_empty() {
834                        flush_sends(
835                            &mut ring,
836                            &mut connections,
837                            &mut dirty_connections,
838                            &mut to_remove,
839                            &mut cqes,
840                        );
841                        flush.on_flush();
842                        for conn_id in to_remove.drain(..) {
843                            if let Some(entry) = connections.remove(&conn_id) {
844                                teardown_dropped(entry, &active_connections);
845                            }
846                        }
847                    }
848                }
849
850                // Re-evaluate the ack policy on a slow timer so
851                // the `policy_degraded` flag and the periodic warn track
852                // the cluster's real state even on idle / quiet venues.
853                // The gate-open block also calls `update_degraded_state`
854                // after each consumed batch; this is the equivalent for
855                // the no-batch path.
856                if now.duration_since(last_policy_check) >= POLICY_CHECK_INTERVAL {
857                    last_policy_check = now;
858                    let journal_pos = journal_persisted_wire_seq.load();
859                    let metrics_ref = replication_metrics.as_deref();
860                    let active_ref = replica_active.as_ref();
861                    let status = evaluate_durability(&policy, journal_pos, metrics_ref, active_ref);
862                    degraded_logger.tick(
863                        &policy,
864                        &utilization,
865                        status.degraded,
866                        now,
867                        DEGRADED_LOG_INTERVAL,
868                    );
869                    // Cache the position so the next batch's gate sees a
870                    // fresh value rather than spinning from a stale cache.
871                    cached_durable_pos = status.durable_pos;
872                }
873
874                // Hand buffered latency samples to the stats registry
875                // while we have nothing better to do. Reuses the
876                // housekeeping clock read, so the spin path picks up no
877                // extra `clock_gettime`.
878                #[cfg(feature = "latency-trace")]
879                if now.duration_since(last_stats_flush) >= trace::IDLE_FLUSH_INTERVAL {
880                    last_stats_flush = now;
881                    spsc_rec.flush();
882                    dispatch_rec.flush();
883                    server_e2e_rec.flush();
884                    #[cfg(feature = "tick-to-trade")]
885                    {
886                        journal_wait_rec.flush();
887                        replica_wait_rec.flush();
888                        encode_rec.flush();
889                        egress_rec.flush();
890                    }
891                }
892            }
893
894            idle_count += 1;
895            if idle_count.is_multiple_of(1024) {
896                utilization.busy.store(busy_count, Ordering::Relaxed);
897                utilization.idle.store(idle_count, Ordering::Relaxed);
898            }
899            waiter.idle();
900            continue;
901        }
902        waiter.reset();
903        busy_count += 1;
904
905        #[cfg(feature = "latency-trace")]
906        let consume_ts = trace::mono_trace_ns();
907
908        // Wait for durability confirmation before sending responses.
909        //
910        // Gate on `wire_seq`, not `input_seq`. `input_seq` is in
911        // local-consumer space (the matching cursor on the input ring,
912        // starts at 0 in this process) while replica metrics and the
913        // primary's `journal_persisted_wire_seq` live in wire-seq space
914        // (allocated by the journal stage starting at
915        // `starting_sequence`). A `needed` derived from `input_seq` and
916        // compared against wire-seq cursors only works when
917        // `starting_sequence == 1`; a recovered primary (or any process
918        // whose journal already has prior content pushing
919        // `starting_sequence` above 1) would silently open the gate
920        // ahead of the replica's actual replicated state.
921        //
922        // Every cursor in the policy view (`journal_persisted_wire_seq`,
923        // `metrics.in_memory_sequence`, `metrics.acked_sequence`)
924        // carries "highest wire seq known to be in that state on node
925        // X". A slot's `wire_seq` is therefore the *exact* wire seq the
926        // gate must see — not `+1` — for that slot's event to be
927        // considered durable. The legacy `+1` was load-bearing only
928        // because `input_seq` was off by `starting_sequence - 1` from
929        // wire seq; with the wire-seq stamp it would over-shoot by one
930        // event and make the gate stall an extra round-trip per
931        // response.
932        //
933        // The gate is evaluated **per slot**, not once per batch against
934        // the batch's maximum `wire_seq`. Batch-max gating made every
935        // response in a batch wait for the newest event in it to become
936        // durable, so the oldest slot paid the durability latency of an
937        // event sequenced up to `MAX_BATCH` positions after it — a
938        // head-of-line block bounded only by the batch size, and one
939        // that bites hardest exactly when the stage has caught up to the
940        // durability frontier under load. Per-slot gating walks that
941        // frontier instead: each response is released as soon as its own
942        // event is durable.
943        //
944        // The extra waits are self-limiting. Slots arrive FIFO with
945        // monotonic `wire_seq`, and the journal publishes its cursor per
946        // fsync batch, so one wait typically advances the cursor past a
947        // whole run of following slots and they fall through without
948        // spinning. The number of waits (and so of flushes) tracks real
949        // durability boundaries, not the slot count. Correctness does
950        // not depend on that ordering — an unsorted batch would simply
951        // wait more often.
952        let batch_now = Instant::now();
953
954        // `flush` (see `FlushCadence`) is fed at append time and checked
955        // after every slot, so the flush runs *within* the batch.
956        // Deferring it to the end of the batch would reopen the defect
957        // the byte threshold exists to close: one MAX_BATCH batch of
958        // large frames (anything over ~64 bytes average) can push a
959        // healthy connection from empty past `MAX_SEND_BUF` before a
960        // batch-end flush ever ran, tearing it down with nothing
961        // written. A flag written at append time rather than a per-slot
962        // scan of the dirty set: the scan would cost a hash lookup per
963        // dirty connection on the hot path.
964        for slot in slots {
965            #[cfg(feature = "latency-trace")]
966            spsc_rec.record_elapsed(slot.match_complete_ts, consume_ts);
967
968            // First slot of an event: every event before it has been
969            // answered, so refusals stamped up to its sequence go first.
970            // Ahead of the gate, which holds this event, not them. A
971            // refusal due here was visible before the read, so the
972            // iteration's sync decides for the whole batch.
973            let first_of_event = slot.input_seq != last_input_seq;
974            last_input_seq = slot.input_seq;
975            if refusals_waiting && first_of_event {
976                refusals.release(slot.input_seq, |refusal| {
977                    append_refusal(
978                        refusal,
979                        &*encoder,
980                        &mut connections,
981                        &mut encode_buf,
982                        &batch_end_wire_frame,
983                        batch_now,
984                        &mut dirty_connections,
985                        &mut to_remove,
986                        &mut flush,
987                    );
988                });
989            }
990
991            // Per-slot journal-wait / replica-wait tracker. See
992            // `GateCrossTracker` for the rationale (only records cursors
993            // that were actually on the critical path). Both the
994            // tracker's replica cursor and the attribution counters
995            // below are derived from the policy in force rather than a
996            // fixed level, so "which subsystem to optimize" answers for
997            // the deployment the operator actually configured. A slot
998            // that never waits leaves the tracker unobserved, and an
999            // unobserved tracker reports no crossing — so the fast path
1000            // records nothing rather than a zero-length sample.
1001            #[cfg(feature = "tick-to-trade")]
1002            let mut gate_tracker = GateCrossTracker::new(slot.wire_seq);
1003
1004            if slot_needs_gate(slot, cached_durable_pos) {
1005                let needed = slot.wire_seq;
1006
1007                // Drain buffered sends before blocking, never after.
1008                //
1009                // The steady-state flush happens on the `count == 0`
1010                // path — once the output ring is empty. That batches
1011                // many responses behind one `io_uring_enter`, which is
1012                // the right trade on the fast path. But it means a
1013                // response that has already cleared its own durability
1014                // gate stays in `send_buf` while the loop blocks on a
1015                // later event's gate, so a client waits out an fsync +
1016                // replica round-trip for an event whose durability was
1017                // confirmed before that event was even sequenced.
1018                //
1019                // Flushing here costs nothing in latency terms: it only
1020                // fires when we are about to spin on the gate anyway, so
1021                // the syscall overlaps time that would otherwise be
1022                // dead. Slots whose gate is already satisfied fall
1023                // straight through and keep their batching.
1024                //
1025                // Traced builds only: this delays the tracker's first
1026                // `observe` by the flush duration, so a cursor that
1027                // crosses mid-flush is attributed late (or, if it
1028                // crosses past `needed`, not attributed at all). Bounded
1029                // by one flush and confined to the tick-to-trade
1030                // breakdown — the gate's own behaviour is unchanged.
1031                if !dirty_connections.is_empty() {
1032                    #[cfg(feature = "tick-to-trade")]
1033                    let egress_start = trace::mono_trace_ns();
1034                    flush_sends(
1035                        &mut ring,
1036                        &mut connections,
1037                        &mut dirty_connections,
1038                        &mut to_remove,
1039                        &mut cqes,
1040                    );
1041                    flush.on_flush();
1042                    #[cfg(feature = "tick-to-trade")]
1043                    egress_rec.record_elapsed(egress_start, trace::mono_trace_ns());
1044                    #[cfg(feature = "latency-trace")]
1045                    close_e2e_samples(&mut pending_e2e, &mut server_e2e_rec);
1046                    // This slot and later ones addressed to a dropped
1047                    // connection are skipped by the
1048                    // `connections.get_mut` lookup below, so removing
1049                    // here is safe.
1050                    for conn_id in to_remove.drain(..) {
1051                        if let Some(entry) = connections.remove(&conn_id) {
1052                            teardown_dropped(entry, &active_connections);
1053                        }
1054                    }
1055                }
1056
1057                // The gate waits on the journal-disk thread and the
1058                // replication handlers — the exact threads a small box
1059                // co-schedules with this one, so it waits the way every
1060                // other wait does. A fresh waiter per gate entry: the
1061                // spin budget is meant to cover one durability lag, not
1062                // to carry over from the idle loop.
1063                let mut gate_waiter = wait.waiter();
1064                loop {
1065                    // Inside the gate-wait loop, also observe a
1066                    // policy swap. Without this, a batch whose gate
1067                    // becomes structurally unsatisfiable (e.g. all
1068                    // replicas die while a non-bypass slot is in
1069                    // flight under `DiskAndRam`) would wedge the response
1070                    // stage forever, even if an operator sends the
1071                    // remediating `ACK-POLICY disk` — the outer loop
1072                    // observation never gets a chance to run. The
1073                    // relaxed load is ~1 cycle on x86; cheaper than
1074                    // the wait below.
1075                    let observed_byte = ack_policy.load(Ordering::Relaxed);
1076                    if observed_byte != active_policy.as_u8()
1077                        && let Some(next) = AckPolicy::from_u8(observed_byte)
1078                    {
1079                        tracing::info!(
1080                            prev = active_policy.as_str(),
1081                            next = next.as_str(),
1082                            "ack policy swapped during gate wait"
1083                        );
1084                        active_policy = next;
1085                        policy = active_policy.to_policy();
1086                        // Flush accrual before re-seeding so the wedged-
1087                        // degraded interval up to the swap isn't dropped.
1088                        degraded_logger.reseed(&utilization, Instant::now());
1089                    }
1090
1091                    // Observe shutdown here too, for the same reason: a
1092                    // gate that cannot open — every replica gone under a
1093                    // policy that needs one — held this thread until a
1094                    // replica returned, and the shutdown sequence joins
1095                    // it without a timeout. An operator restarting the
1096                    // degraded node, or a fence (which co-sets
1097                    // `shutdown`), hung the process. Straight back to the
1098                    // top of the loop, where the shutdown branch decides
1099                    // what to flush: this slot's reply, which the policy
1100                    // never confirmed, is not appended, and the rest of
1101                    // the batch goes with it. Those clients see a reset,
1102                    // as after a crash, and reconcile on reconnect.
1103                    if shutdown.load(Ordering::Relaxed) {
1104                        continue 'run;
1105                    }
1106
1107                    let journal_pos = journal_persisted_wire_seq.load();
1108                    let metrics_ref = replication_metrics.as_deref();
1109                    let active_ref = replica_active.as_ref();
1110
1111                    // The cross-tracker (traced builds only) samples the
1112                    // replica cursor itself rather than sharing the
1113                    // evaluation's read below: computing a standalone
1114                    // replica cursor unconditionally spent four Acquire
1115                    // loads per spin iteration on `ReplicationMetrics`,
1116                    // the same cache line the replication sender writes
1117                    // on every ack and every completed SEND. Gate
1118                    // attribution does not re-read at all — it comes out
1119                    // of `evaluate_gate`, from the same snapshot that
1120                    // opens the gate.
1121                    #[cfg(feature = "tick-to-trade")]
1122                    gate_tracker.observe(
1123                        journal_pos.get(),
1124                        // The level the *active policy* gates replicas
1125                        // on — in-memory under `disk+ram`, persisted under
1126                        // `two-disks`. `None` when no clause is
1127                        // replica-supplied (`disk`) and, transiently,
1128                        // when the binding replica drops out of the
1129                        // cursor view mid-wait. Passed through as-is so
1130                        // the tracker can tell "no replica wait to
1131                        // measure" from "the replica caught up".
1132                        policy_replica_cursor(&policy, journal_pos, metrics_ref, active_ref),
1133                        trace::mono_trace_ns(),
1134                    );
1135
1136                    let (status, blocker) =
1137                        evaluate_gate(&policy, needed, journal_pos, metrics_ref, active_ref);
1138                    cached_durable_pos = status.durable_pos;
1139                    utilization
1140                        .policy_degraded
1141                        .store(status.degraded, Ordering::Relaxed);
1142
1143                    // Accrue degraded time while wedged. The post-gate
1144                    // tick attributes the whole wait to a single state, so
1145                    // without this a healthy→degraded flip during the
1146                    // wedge would be mis-charged. While the gate waiter
1147                    // spins the clock read behind the tick is mask-gated,
1148                    // landing only every ~65 k iterations
1149                    // (`CHECK_MASK = 2^16`) regardless of the period
1150                    // below; once it yields, each iteration already pays
1151                    // a syscall and the read is unmasked.
1152                    if gate_accrual_timer
1153                        .tick(GATE_ACCRUAL_INTERVAL, gate_waiter.spinning())
1154                        .is_some()
1155                    {
1156                        degraded_logger.tick(
1157                            &policy,
1158                            &utilization,
1159                            status.degraded,
1160                            Instant::now(),
1161                            DEGRADED_LOG_INTERVAL,
1162                        );
1163                    }
1164
1165                    if cached_durable_pos >= needed {
1166                        // Attribution: which subsystem supplied the
1167                        // binding cursor, from the same snapshot that
1168                        // opened the gate and against the policy
1169                        // actually in force. Relaxed is fine — health
1170                        // reads are infrequent.
1171                        //
1172                        // `None` is unreachable here: `needed >= 1`
1173                        // inside this loop, and a degraded evaluation
1174                        // pins `durable_pos` to 0, so an open gate
1175                        // implies the policy was satisfiable and
1176                        // attribution has a verdict. The no-op arm
1177                        // keeps a metrics-only path from ever
1178                        // panicking regardless.
1179                        match blocker {
1180                            Some(Blocker::Journal) => {
1181                                utilization.gate_journal.fetch_add(1, Ordering::Relaxed);
1182                            }
1183                            Some(Blocker::Replication) => {
1184                                utilization.gate_replication.fetch_add(1, Ordering::Relaxed);
1185                            }
1186                            None => {}
1187                        }
1188                        break;
1189                    }
1190                    gate_waiter.idle();
1191                }
1192            }
1193
1194            // Per-slot durability-gate breakdown. Recorded only when the
1195            // gate actually held us up (the tracker captured a cross).
1196            // The cross timestamp is this slot's own `wire_seq`, so the
1197            // sample measures the wait that slot really paid — under
1198            // batch-max gating it was the batch maximum's crossing,
1199            // which overstated every slot but the last by up to the
1200            // batch's matching span.
1201            #[cfg(feature = "tick-to-trade")]
1202            if let Some(ts) = gate_tracker.journal_crossed() {
1203                journal_wait_rec.record_elapsed(slot.match_complete_ts, ts);
1204            }
1205            #[cfg(feature = "tick-to-trade")]
1206            if let Some(ts) = gate_tracker.replica_crossed() {
1207                replica_wait_rec.record_elapsed(slot.match_complete_ts, ts);
1208            }
1209
1210            // Each slot expands to at most two wire frames: the
1211            // application payload (Report / Query / EngineError) and
1212            // an optional trailing `BatchEnd` when
1213            // `is_last_in_request` is set. Application-shaped
1214            // payloads go through the encoder; transport-shaped
1215            // frames (EngineError, BatchEnd) are encoded by the
1216            // runtime directly.
1217            if let Some(entry) = connections.get_mut(&slot.connection_id) {
1218                // Frame 1: application payload (if any). BatchEnd
1219                // payloads carry no body — the terminator below
1220                // handles them via `is_last_in_request`.
1221                let payload_result: Option<Result<usize, &'static str>> = match slot.payload {
1222                    OutputPayload::Report(ref report) => {
1223                        Some(encoder.encode_report(report, &mut encode_buf))
1224                    }
1225                    OutputPayload::QueryResponse(ref q) => {
1226                        Some(encoder.encode_query(q, &mut encode_buf))
1227                    }
1228                    OutputPayload::EngineError => Some(
1229                        control_codec::encode_transport_response(
1230                            &TransportResponse::EngineError,
1231                            &mut encode_buf,
1232                        )
1233                        .map_err(|_| "encode error"),
1234                    ),
1235                    OutputPayload::BatchEnd => None,
1236                };
1237
1238                // Frame 2: the pre-encoded BatchEnd terminator, appended
1239                // with the payload in one call.
1240                let trailer: &[u8] = if slot.is_last_in_request {
1241                    &batch_end_wire_frame
1242                } else {
1243                    &[]
1244                };
1245
1246                #[cfg(feature = "tick-to-trade")]
1247                let encode_start = trace::mono_trace_ns();
1248                // Underscored: with both frames in one call there is no
1249                // second append to skip, so the outcome is purely
1250                // informational — `append_frames` has already queued an
1251                // overflowing connection for removal. Only the traced
1252                // build reads it, to avoid measuring bytes that were
1253                // never buffered.
1254                let _outcome = append_frames(
1255                    payload_result,
1256                    trailer,
1257                    slot.connection_id,
1258                    entry,
1259                    &encode_buf,
1260                    batch_now,
1261                    &mut dirty_connections,
1262                    &mut to_remove,
1263                );
1264                #[cfg(feature = "tick-to-trade")]
1265                encode_rec.record_elapsed(encode_start, trace::mono_trace_ns());
1266
1267                // Queue the server-side end-to-end sample: reader recv ->
1268                // response flush. Only a request's last slot carries this
1269                // measurement; queued after the append so a dropped
1270                // connection doesn't skew the metric, and closed by
1271                // whichever flush ships the bytes.
1272                #[cfg(feature = "latency-trace")]
1273                if slot.is_last_in_request && matches!(_outcome, AppendOutcome::Continue) {
1274                    pending_e2e.push((slot.connection_id, slot.recv_ts));
1275                }
1276
1277                flush.on_append(entry.send_buf.len());
1278            } else if slot.connection_id != 0 {
1279                // A reply for a connection this stage does not hold: it
1280                // was dropped earlier in this batch, or went away before
1281                // its reply was encoded. The drain above runs after the
1282                // ring is read, so a connection that is merely new is
1283                // never a miss. Logged so a lost reply leaves a trace.
1284                // Connection 0 is a server-originated event — a seed, a
1285                // tick — whose reports have no client to go to, and is
1286                // skipped without a word.
1287                debug!(
1288                    connection_id = slot.connection_id,
1289                    wire_seq = slot.wire_seq,
1290                    "reply dropped: connection not registered"
1291                );
1292            }
1293
1294            // Consumed-path flush — the still-open half of July-audit
1295            // finding 4, plus its age bound (August audit finding 3).
1296            // Under sustained load with an open gate this is the ONLY
1297            // flush that runs: the idle path needs an empty SPSC, the
1298            // gate path needs a durability wait, and heartbeats need
1299            // idleness. Without it, delivery on a busy stretch
1300            // degenerates to `MAX_SEND_BUF` evicting the very clients
1301            // being served. Trigger-gated so light traffic keeps full
1302            // batching; once a connection holds ~an MSS — or the stage
1303            // has encoded `FLUSH_SLOT_INTERVAL` responses since the last
1304            // flush, whichever comes first — the extra submit no longer
1305            // costs wire efficiency. MSG_DONTWAIT flushes cannot block,
1306            // so this adds no head-of-line exposure (the hazard that
1307            // deferred this trigger in July). Runs between slots, not
1308            // after the batch, so `MAX_SEND_BUF` can never trip on a
1309            // healthy connection within a single batch — a blocked peer
1310            // re-arms the flag on its later slots, but its paced retry
1311            // inside `flush_sends` keeps that cheap.
1312            if flush.is_due() {
1313                flush.on_flush();
1314                // Overflow-dropped connections leave first so the flush
1315                // skips them — same order as the batch-end cleanup, and
1316                // their queued samples go with them (they measure bytes
1317                // that will never be flushed). Later slots addressed to
1318                // them are skipped by the `connections.get_mut` miss
1319                // above.
1320                #[cfg(feature = "latency-trace")]
1321                discard_e2e_samples(&mut pending_e2e, &to_remove);
1322                for conn_id in to_remove.drain(..) {
1323                    if let Some(entry) = connections.remove(&conn_id) {
1324                        teardown_dropped(entry, &active_connections);
1325                    }
1326                    unmark_dirty(conn_id, &mut dirty_connections);
1327                }
1328                #[cfg(feature = "tick-to-trade")]
1329                let egress_start = trace::mono_trace_ns();
1330                flush_sends(
1331                    &mut ring,
1332                    &mut connections,
1333                    &mut dirty_connections,
1334                    &mut to_remove,
1335                    &mut cqes,
1336                );
1337                #[cfg(feature = "tick-to-trade")]
1338                egress_rec.record_elapsed(egress_start, trace::mono_trace_ns());
1339                #[cfg(feature = "latency-trace")]
1340                close_e2e_samples(&mut pending_e2e, &mut server_e2e_rec);
1341                for conn_id in to_remove.drain(..) {
1342                    if let Some(entry) = connections.remove(&conn_id) {
1343                        teardown_dropped(entry, &active_connections);
1344                    }
1345                }
1346            }
1347        }
1348
1349        // The borrowed slots are dead here, so the batch can be handed
1350        // back to the producer. Publishing progress is deliberately the
1351        // last thing the iteration does with the ring: every slot has
1352        // cleared its durability gate and been encoded by now.
1353        consumer.commit();
1354
1355        // Remove connections that exceeded the send buffer limit. Like
1356        // the disconnect handler, this un-dirties a connection without
1357        // flushing it, so it has to discard that connection's queued
1358        // samples too — otherwise they outlive the bytes they measure
1359        // and land on some later, unrelated flush.
1360        #[cfg(feature = "latency-trace")]
1361        discard_e2e_samples(&mut pending_e2e, &to_remove);
1362        for conn_id in to_remove.drain(..) {
1363            if let Some(entry) = connections.remove(&conn_id) {
1364                teardown_dropped(entry, &active_connections);
1365            }
1366            unmark_dirty(conn_id, &mut dirty_connections);
1367        }
1368
1369        // Log degradation transitions / re-emit the reminder. Same
1370        // logger the idle path uses; transitions are gated on a
1371        // sustained-state hold so sub-second flap doesn't spam.
1372        //
1373        // Ticked after dispatch rather than before it, and off a fresh
1374        // clock read: with the gate now evaluated per slot, a batch can
1375        // span several waits, and `batch_now` was taken before the first
1376        // of them. The accrual inside each wait already charges degraded
1377        // time as it elapses; this tick decides transitions, so it wants
1378        // the state and the timestamp as of the end of the batch.
1379        let ticked_at = Instant::now();
1380        let degraded_now = utilization.policy_degraded.load(Ordering::Relaxed);
1381        degraded_logger.tick(
1382            &policy,
1383            &utilization,
1384            degraded_now,
1385            ticked_at,
1386            DEGRADED_LOG_INTERVAL,
1387        );
1388        // Bump the idle-path's check timestamp so we don't double-
1389        // tick the logger when traffic stops.
1390        last_policy_check = ticked_at;
1391
1392        #[cfg(feature = "latency-trace")]
1393        dispatch_rec.record_elapsed(consume_ts, trace::mono_trace_ns());
1394    }
1395}
1396
1397/// Close every queued server-side end-to-end sample against the flush
1398/// that just completed. Call immediately after `flush_sends`.
1399///
1400/// Closes the whole queue unconditionally, including entries for a
1401/// connection the flush then dropped on a send error. That is
1402/// deliberate: the stage measures "reader recv → response flush", which
1403/// ends when the bytes are handed to the kernel, not when the peer
1404/// acknowledges them. A failed SEND still produces a sample whose
1405/// duration is the real one for everything this stage controls.
1406/// Contrast [`discard_e2e_samples`], where no flush happens at all.
1407///
1408/// One clock read serves the whole queue: the samples all end at the
1409/// same flush, so reading per entry would only measure the drain loop.
1410#[cfg(feature = "latency-trace")]
1411fn close_e2e_samples(
1412    pending: &mut Vec<(u64, trace::MonoTraceInstant)>,
1413    rec: &mut trace::StageRecorder,
1414) {
1415    let flushed_at = trace::mono_trace_ns();
1416    for (_, recv_ts) in pending.drain(..) {
1417        rec.record_elapsed(recv_ts, flushed_at);
1418    }
1419}
1420
1421/// Drop queued samples belonging to connections going away *without* a
1422/// flush — the disconnect handler and the send-buffer-overflow drain.
1423///
1424/// Their buffered bytes are discarded along with the connection, so
1425/// there is no flush to close the samples against and nothing to
1426/// measure. Left queued, they would instead be timed against some later,
1427/// unrelated connection's flush: a millisecond-scale phantom in a
1428/// microsecond histogram, landing squarely in the tail this stage exists
1429/// to report.
1430///
1431/// Not to be used after `flush_sends`. `dropped` there can hold a
1432/// connection queued by an earlier append overflow whose buffered bytes
1433/// the flush nonetheless shipped — discarding would lose a legitimate
1434/// sample.
1435///
1436/// Linear scan per entry, but `dropped` is empty on every iteration that
1437/// loses no connection, which is essentially all of them, and the early
1438/// return keeps that case free.
1439#[cfg(feature = "latency-trace")]
1440fn discard_e2e_samples(pending: &mut Vec<(u64, trace::MonoTraceInstant)>, dropped: &[u64]) {
1441    if dropped.is_empty() {
1442        return;
1443    }
1444    pending.retain(|(conn_id, _)| !dropped.contains(conn_id));
1445}
1446
1447/// Whether this slot must wait on the durability gate before its
1448/// response is encoded: its own event is not yet known to be durable.
1449///
1450/// Every slot is subject to it. The one reply a halted node sends without
1451/// waiting — the refusal of a write — never enters the output ring (see
1452/// [`crate::halt`]).
1453///
1454/// Shared by both response stages, so the io_uring and DPDK paths cannot
1455/// drift apart on it.
1456#[inline]
1457pub(crate) fn slot_needs_gate<R: Copy, Q: Copy>(
1458    slot: &OutputSlot<R, Q>,
1459    cached_durable_pos: u64,
1460) -> bool {
1461    cached_durable_pos < slot.wire_seq
1462}
1463
1464/// Tear down a connection the *response stage* decided to drop (send
1465/// error, `MAX_SEND_BUF` overflow, blocked-send timeout).
1466///
1467/// Removing the map entry alone only drops this stage's dup of the
1468/// socket — the reader still holds its own dup, so the socket would
1469/// stay fully open: the dropped client's requests keep flowing through
1470/// the reader (whose idle timeout never fires while the client keeps
1471/// sending) with no response ever delivered, and the accept loop's
1472/// `max_connections` permit stays pinned forever. `shutdown(2)` reaches
1473/// every dup: the reader's multishot RECV completes with 0, it tears
1474/// down its slab entry and emits `Disconnected` — which finds this
1475/// entry already gone, so the permit is released exactly once.
1476fn teardown_dropped(entry: ConnectionEntry, active_connections: &AtomicU64) {
1477    // Best-effort: ENOTCONN just means the peer tore the socket down
1478    // first — dropping `entry` below still reclaims our dup.
1479    unsafe {
1480        libc::shutdown(entry.fd, libc::SHUT_RDWR);
1481    }
1482    active_connections.fetch_sub(1, Ordering::Relaxed);
1483}
1484
1485/// Outcome of a slot's append. `Continue` means the slot's bytes are
1486/// buffered (or there were none); `ConnectionDropped` means the
1487/// connection's send buffer overflowed and the connection has been
1488/// queued for removal — nothing further should be appended for it.
1489#[derive(Clone, Copy)]
1490enum AppendOutcome {
1491    Continue,
1492    ConnectionDropped,
1493}
1494
1495/// Whether the idle-path heartbeat scan should append a frame to this
1496/// connection: idle for a full interval — and able to receive it.
1497///
1498/// The two guards close the one append path that used to bypass
1499/// `MAX_SEND_BUF`. A blocked peer's socket is full, so a heartbeat
1500/// would only sit in `send_buf` — and an idle client that trickle-read
1501/// a few bytes per interval kept resetting the blocked clock while
1502/// unchecked heartbeat appends grew its buffer without bound, pinning
1503/// its connection permit on ever-growing memory. Skipping blocked
1504/// peers (they detect liveness by draining, not by new frames) and
1505/// cap-checking the append bounds the buffer; a peer that never drains
1506/// is then `BLOCKED_SEND_TIMEOUT`'s or `MAX_SEND_BUF`'s problem, as
1507/// designed. Residual: a trickling client still holds its permit while
1508/// it drains — bounded memory, and the reader's idle timeout covers
1509/// clients that stop sending entirely.
1510/// Put a connection on the dirty list if it isn't already there.
1511/// Idempotent — the entry flag is the dedup that the old `HashSet`
1512/// membership provided.
1513#[inline]
1514fn mark_dirty(entry: &mut ConnectionEntry, connection_id: u64, dirty: &mut Vec<u64>) {
1515    if !entry.dirty {
1516        entry.dirty = true;
1517        dirty.push(connection_id);
1518    }
1519}
1520
1521/// Take a connection off the dirty list — teardown paths only. `retain`
1522/// rather than a swap-remove scan because the ordering is cheap to keep
1523/// and this runs once per dropped connection, never per frame.
1524fn unmark_dirty(connection_id: u64, dirty: &mut Vec<u64>) {
1525    dirty.retain(|&id| id != connection_id);
1526}
1527
1528fn heartbeat_due(
1529    entry: &ConnectionEntry,
1530    now: Instant,
1531    interval: Duration,
1532    frame_len: usize,
1533) -> bool {
1534    now.duration_since(entry.last_send) >= interval
1535        && entry.blocked_since.is_none()
1536        && entry.send_buf.len() + frame_len <= MAX_SEND_BUF
1537}
1538
1539/// Copy a slot's wire bytes into the connection's send buffer with
1540/// overflow checking and dirty tracking: the encoded payload (from the
1541/// `ResponseEncoder` for application shapes, or
1542/// `encode_transport_response` for transport ones) followed by an
1543/// optional pre-encoded `trailer`.
1544///
1545/// One call per slot rather than one per frame. A request's last slot
1546/// carries a `BatchEnd` terminator behind its payload, and appending
1547/// them separately paid the cap check, the `last_send` stamp and the
1548/// dirty marking twice for every request — plus a re-encode of a frame
1549/// whose bytes never change.
1550#[allow(clippy::too_many_arguments)]
1551fn append_frames(
1552    payload: Option<Result<usize, &'static str>>,
1553    trailer: &[u8],
1554    connection_id: u64,
1555    entry: &mut ConnectionEntry,
1556    encode_buf: &[u8],
1557    batch_now: Instant,
1558    dirty_connections: &mut Vec<u64>,
1559    to_remove: &mut Vec<u64>,
1560) -> AppendOutcome {
1561    // An encode failure is this server's bug, not the client's — log it
1562    // and carry on with the trailer, exactly as when the two frames were
1563    // appended by separate calls.
1564    let written = match payload {
1565        Some(Ok(n)) => n,
1566        Some(Err(reason)) => {
1567            tracing::error!(connection_id, reason, "encode error");
1568            0
1569        }
1570        None => 0,
1571    };
1572    let total = written + trailer.len();
1573    if total == 0 {
1574        return AppendOutcome::Continue;
1575    }
1576
1577    // Drop slow clients whose send buffer has grown too large. This
1578    // prevents unbounded memory growth from a single laggy connection
1579    // causing allocator pressure and tail latency spikes. Checked
1580    // against both frames together, so a request is never delivered
1581    // without its terminator.
1582    if entry.send_buf.len() + total > MAX_SEND_BUF {
1583        debug!(
1584            connection_id,
1585            send_buf_len = entry.send_buf.len(),
1586            "send buffer exceeded limit, dropping connection"
1587        );
1588        to_remove.push(connection_id);
1589        return AppendOutcome::ConnectionDropped;
1590    }
1591
1592    // Append the full wire frames to the connection's send buffer.
1593    // The encoder writes [length(4) | payload], which is the complete
1594    // wire format — no extra framing needed.
1595    entry.send_buf.extend_from_slice(&encode_buf[..written]);
1596    entry.send_buf.extend_from_slice(trailer);
1597    entry.last_send = batch_now;
1598    mark_dirty(entry, connection_id, dirty_connections);
1599    AppendOutcome::Continue
1600}
1601
1602/// Append the reply to a write refused at ingress — the application's
1603/// rejection, then the request terminator — to its connection's send
1604/// buffer. A connection that has gone away loses nothing it can still
1605/// receive.
1606///
1607/// No durability gate: nothing was published, so there is nothing to wait
1608/// for. The caller has already waited for what comes before it on the
1609/// connection (see [`crate::halt`]).
1610#[allow(clippy::too_many_arguments)]
1611fn append_refusal<R: Copy, Q: Copy>(
1612    refusal: Refusal<R>,
1613    encoder: &dyn melin_app::encoder::ResponseEncoder<Report = R, Query = Q>,
1614    connections: &mut FxHashMap<u64, ConnectionEntry>,
1615    encode_buf: &mut [u8],
1616    batch_end_wire_frame: &[u8],
1617    now: Instant,
1618    dirty_connections: &mut Vec<u64>,
1619    to_remove: &mut Vec<u64>,
1620    flush: &mut FlushCadence,
1621) {
1622    let Some(entry) = connections.get_mut(&refusal.connection_id) else {
1623        debug!(
1624            connection_id = refusal.connection_id,
1625            "refusal dropped: connection not registered"
1626        );
1627        return;
1628    };
1629    let payload = encoder.encode_report(&refusal.report, encode_buf);
1630    append_frames(
1631        Some(payload),
1632        batch_end_wire_frame,
1633        refusal.connection_id,
1634        entry,
1635        encode_buf,
1636        now,
1637        dirty_connections,
1638        to_remove,
1639    );
1640    flush.on_append(entry.send_buf.len());
1641}
1642
1643/// Each dirty connection's accumulated send buffer is sent in a single
1644/// SEND operation, and the flush never blocks on a slow peer.
1645///
1646/// Every SEND carries `MSG_DONTWAIT`, so a full socket buffer completes
1647/// immediately with `EAGAIN` instead of parking the operation until the
1648/// peer reads. That matters because io_uring never surfaces `EAGAIN` for
1649/// a plain SEND — it arms an internal poll and withholds the CQE, which
1650/// is what let one zero-window client wedge this stage (and with it every
1651/// client's acks) behind the `submit_and_wait` below.
1652///
1653/// Undelivered bytes (EAGAIN or a partial send) stay in the connection's
1654/// `send_buf` and the connection stays in `dirty`; retries are paced by
1655/// [`BLOCKED_RETRY_INTERVAL`] and bounded by [`BLOCKED_SEND_TIMEOUT`].
1656/// `MAX_SEND_BUF` remains the growth cap while new responses accumulate.
1657///
1658/// On return, `dirty` holds exactly the connections that still have
1659/// undelivered bytes and were not queued in `to_remove` — callers must
1660/// not clear it. Failed connections are collected in `to_remove` for the
1661/// caller to purge from `connections`.
1662fn flush_sends(
1663    ring: &mut IoUring,
1664    connections: &mut FxHashMap<u64, ConnectionEntry>,
1665    dirty: &mut Vec<u64>,
1666    to_remove: &mut Vec<u64>,
1667    cqes: &mut Vec<(u64, i32)>,
1668) {
1669    // One clock read per flush, shared by the retry-pacing and
1670    // blocked-timeout decisions. Cheap next to the submit syscall below,
1671    // and flushes are already batched (one per drained SPSC batch or
1672    // idle-loop pass, not one per response).
1673    let now = Instant::now();
1674
1675    // Submit SEND SQEs for all dirty connections not in retry backoff.
1676    let mut pending: usize = 0;
1677    for &conn_id in dirty.iter() {
1678        let Some(entry) = connections.get_mut(&conn_id) else {
1679            continue;
1680        };
1681        if entry.send_buf.is_empty() {
1682            continue;
1683        }
1684        if let Some(blocked_at) = entry.blocked_since {
1685            // A peer that has refused bytes for the full timeout is not
1686            // coming back for them — reclaim the slot. Client-caused,
1687            // hence debug (see the log-level convention).
1688            if now.duration_since(blocked_at) >= BLOCKED_SEND_TIMEOUT {
1689                debug!(
1690                    connection_id = conn_id,
1691                    pending_bytes = entry.send_buf.len(),
1692                    blocked_ms = now.duration_since(blocked_at).as_millis() as u64,
1693                    "socket blocked past timeout, dropping connection"
1694                );
1695                to_remove.push(conn_id);
1696                continue;
1697            }
1698            if now.duration_since(entry.last_send_attempt) < BLOCKED_RETRY_INTERVAL {
1699                continue;
1700            }
1701        }
1702        entry.last_send_attempt = now;
1703
1704        let sqe = opcode::Send::new(
1705            types::Fd(entry.fd),
1706            entry.send_buf.as_ptr(),
1707            entry.send_buf.len() as u32,
1708        )
1709        .flags(libc::MSG_DONTWAIT)
1710        .build()
1711        .user_data(conn_id);
1712
1713        unsafe {
1714            ring.submission()
1715                .push(&sqe)
1716                .expect("io_uring SQ full — increase RING_SIZE");
1717        }
1718        pending += 1;
1719    }
1720
1721    if pending > 0 {
1722        // With MSG_DONTWAIT every SEND completes during submission
1723        // (success, partial, or EAGAIN — the flag sets the kernel's
1724        // no-wait path, so nothing is deferred to a poll retry). The
1725        // wait is bounded by op execution, never by a peer.
1726        //
1727        // EINTR must retry, not return: a signal (profilers use them
1728        // routinely) can interrupt the CQE wait *after* the submit phase
1729        // consumed the SQEs — the inline completions are already posted
1730        // by then. Returning without reaping would leave stale CQEs that
1731        // the next flush drains against *updated* buffer contents:
1732        // a delivered prefix gets re-sent and `drain(..sent)` double-
1733        // drains. The retry submits an empty SQ and finds the CQ already
1734        // holding `pending` entries, so it cannot stall.
1735        loop {
1736            match ring.submit_and_wait(pending) {
1737                Ok(_) => break,
1738                Err(ref e) if e.raw_os_error() == Some(libc::EINTR) => continue,
1739                Err(e) => {
1740                    // Submit-phase failure (ENOMEM class — EBUSY cannot
1741                    // happen: the CQ is RING_SIZE deep and fully reaped
1742                    // every flush, so at most `pending` ≤ RING_SIZE
1743                    // entries are ever outstanding). An error here means
1744                    // the SQEs were NOT consumed: continuing would leave
1745                    // them queued for the next flush's submit, by which
1746                    // time their addr fields can point at drained or
1747                    // reallocated send_bufs — garbage on client sockets,
1748                    // on the ack path. The kernel is out of resources
1749                    // and the stage cannot proceed without corruption
1750                    // risk: fail loudly and let the accept loop's
1751                    // pipeline-death detection take the server down.
1752                    error!(error = %e, "io_uring submit_and_wait failed in response stage");
1753                    panic!("response stage io_uring submit failed: {e}");
1754                }
1755            }
1756        }
1757
1758        // Drain completions into pre-allocated buffer. Must collect to
1759        // release CQ borrow before mutating connections.
1760        cqes.clear();
1761        cqes.extend(ring.completion().map(|cqe| (cqe.user_data(), cqe.result())));
1762
1763        for &(conn_id, result) in cqes.iter() {
1764            let Some(entry) = connections.get_mut(&conn_id) else {
1765                continue;
1766            };
1767            if result == -libc::EAGAIN {
1768                // Socket buffer full. Keep the bytes; the connection
1769                // stays dirty and retries at the paced cadence.
1770                if entry.blocked_since.is_none() {
1771                    entry.blocked_since = Some(now);
1772                }
1773                continue;
1774            }
1775            if result < 0 {
1776                debug!(
1777                    connection_id = conn_id,
1778                    error = result,
1779                    "send error, dropping connection"
1780                );
1781                to_remove.push(conn_id);
1782                continue;
1783            }
1784
1785            let sent = result as usize;
1786            if sent == 0 {
1787                // Unreachable for SOCK_STREAM sends of len > 0 (the
1788                // kernel reports -EAGAIN instead), but keep the
1789                // zero-progress invariant literal: 0 bytes must not
1790                // restart the blocked clock via the partial branch.
1791                if entry.blocked_since.is_none() {
1792                    entry.blocked_since = Some(now);
1793                }
1794            } else if sent >= entry.send_buf.len() {
1795                entry.send_buf.clear();
1796                entry.blocked_since = None;
1797            } else {
1798                // Partial send — the socket buffer filled mid-copy. An
1799                // immediate retry would only report EAGAIN, so keep the
1800                // remainder for the paced retry. Bytes moved, though:
1801                // restart the blocked clock from this progress, so only
1802                // a peer accepting *zero* bytes for the full timeout is
1803                // dropped — a slow-but-draining client keeps its
1804                // connection (until `MAX_SEND_BUF` says otherwise).
1805                entry.send_buf.drain(..sent);
1806                entry.blocked_since = Some(now);
1807            }
1808        }
1809    }
1810
1811    // Keep exactly the connections that still hold undelivered bytes and
1812    // survived this flush. Runs even when nothing was submitted so a
1813    // blocked-timeout drop above still leaves the dirty list consistent.
1814    // The entry flag is cleared in lockstep — it is what keeps a
1815    // connection off the list until its next append.
1816    dirty.retain(|conn_id| {
1817        let keep = !to_remove.contains(conn_id)
1818            && connections
1819                .get(conn_id)
1820                .is_some_and(|e| !e.send_buf.is_empty());
1821        if !keep && let Some(entry) = connections.get_mut(conn_id) {
1822            entry.dirty = false;
1823        }
1824        keep
1825    });
1826}
1827
1828/// Read the live gate cursors and lend them to `f` as a [`CursorView`].
1829///
1830/// The view contains the primary followed by every *currently connected*
1831/// replica slot. Disconnected slots are omitted rather than entered with
1832/// zero cursors, so `CursorView::len` reflects how many nodes are
1833/// actually available to satisfy a clause — too few and the policy
1834/// reports degraded and the gate stalls. Node 0 is always the primary;
1835/// [`Policy::attribute_blocker`] relies on that index convention to tell
1836/// journal from replication.
1837///
1838/// The primary's in-memory cursor is modeled as `u64::MAX` because the
1839/// response stage only gates events the matching engine has already
1840/// processed — those are trivially in-memory on the primary.
1841///
1842/// Scoped-borrow shape rather than a returned view: `CursorView` holds a
1843/// slice, so it cannot outlive the array behind it. Lending it to a
1844/// closure keeps that array on this frame and off every caller's
1845/// signature.
1846#[inline]
1847fn with_cursor_view<R>(
1848    journal_pos: WireSeq,
1849    metrics: Option<&ReplicationMetrics>,
1850    replica_active: Option<&[Arc<AtomicBool>; 2]>,
1851    f: impl FnOnce(&CursorView<'_>) -> R,
1852) -> R {
1853    // Fixed-size array rather than a `Vec`: the cluster is capped at
1854    // MAX_CLUSTER_SIZE and this runs on the gate path, so a heap
1855    // allocation would be pure overhead. Raw `u64` in wire-seq space —
1856    // `journal_pos` leaves the type system here, alongside the replica
1857    // metrics gauges (the `Ack` frame's wire-seq fields verbatim).
1858    let mut nodes = [[0u64; 2]; MAX_CLUSTER_SIZE as usize];
1859    nodes[0] = [u64::MAX, journal_pos.get()];
1860    let mut len = 1;
1861    if let (Some(m), Some(active)) = (metrics, replica_active) {
1862        for (i, slot_active) in active.iter().enumerate() {
1863            // Skip inactive slots up-front.
1864            if !slot_active.load(Ordering::Acquire) {
1865                continue;
1866            }
1867            let in_mem = m.in_memory_sequence[i].load(Ordering::Acquire);
1868            let persisted = m.acked_sequence[i].load(Ordering::Acquire);
1869            nodes[len] = [in_mem, persisted];
1870            len += 1;
1871        }
1872    }
1873    f(&CursorView::new(&nodes[..len]))
1874}
1875
1876/// Highest sequence at which the policy is satisfied by the live
1877/// cursors, plus whether the cluster shape can satisfy it at all.
1878#[inline]
1879pub(crate) fn evaluate_durability(
1880    policy: &Policy,
1881    journal_pos: WireSeq,
1882    metrics: Option<&ReplicationMetrics>,
1883    replica_active: Option<&[Arc<AtomicBool>; 2]>,
1884) -> EvalStatus {
1885    with_cursor_view(journal_pos, metrics, replica_active, |view| {
1886        policy.evaluate_with_status(view)
1887    })
1888}
1889
1890/// One-snapshot gate evaluation: the policy's durable position plus,
1891/// when that position opens the gate (`>= needed`), which subsystem
1892/// supplied the binding cursor.
1893///
1894/// Both answers come from the *same* [`CursorView`]. Re-reading the
1895/// replica cursors for attribution after the evaluation that opened the
1896/// gate would rank a later snapshot — the replica side can advance
1897/// between the two reads and flip the verdict to a subsystem that was
1898/// not the one the gate actually opened on.
1899///
1900/// The blocker is `None` while the gate stays closed (attribution is
1901/// only read at the open, and ranking the clauses for it on every spin
1902/// iteration would be wasted work). At an open with `needed >= 1` it is
1903/// always `Some`: a policy left unsatisfiable by the cluster shape pins
1904/// `durable_pos` to 0, which cannot reach `needed`.
1905///
1906/// The attribution itself replaces the old "compare the journal cursor
1907/// against the minimum replica *persisted* cursor" heuristic, which
1908/// reported replication as the blocker under `disk` whenever a replica
1909/// merely lagged — a replica binds `persisted>=1` only when its fsync
1910/// leads the journal's — and read the persisted level under
1911/// `disk+ram`, which gates on in-memory.
1912#[inline]
1913pub(crate) fn evaluate_gate(
1914    policy: &Policy,
1915    needed: u64,
1916    journal_pos: WireSeq,
1917    metrics: Option<&ReplicationMetrics>,
1918    replica_active: Option<&[Arc<AtomicBool>; 2]>,
1919) -> (EvalStatus, Option<Blocker>) {
1920    with_cursor_view(journal_pos, metrics, replica_active, |view| {
1921        let status = policy.evaluate_with_status(view);
1922        let blocker = if status.durable_pos >= needed {
1923            policy.attribute_blocker(view)
1924        } else {
1925            None
1926        };
1927        (status, blocker)
1928    })
1929}
1930
1931/// The replica-side cursor the active policy is waiting on, for the
1932/// `tick-to-trade` replica-wait histogram. `None` when no clause is
1933/// replica-supplied (e.g. `disk`).
1934#[cfg(feature = "tick-to-trade")]
1935#[inline]
1936pub(crate) fn policy_replica_cursor(
1937    policy: &Policy,
1938    journal_pos: WireSeq,
1939    metrics: Option<&ReplicationMetrics>,
1940    replica_active: Option<&[Arc<AtomicBool>; 2]>,
1941) -> Option<u64> {
1942    with_cursor_view(journal_pos, metrics, replica_active, |view| {
1943        policy.replica_gate_cursor(view)
1944    })
1945}
1946
1947/// Hold-time before a state transition is committed to the log.
1948/// Suppresses log spam when a replica flaps faster than this — only
1949/// transitions that hold for at least this long emit warn/info
1950/// entries. The `/healthz` gauge updates immediately regardless,
1951/// so dashboards and alerts still see real-time state.
1952const DEGRADED_FLAP_HOLD: Duration = Duration::from_secs(1);
1953
1954/// Tracks degradation state across calls and emits warn/info logs
1955/// with sustained-state gating + a periodic heartbeat re-emit.
1956///
1957/// The hot path calls [`Self::tick`] every gate iteration / idle
1958/// poll with the current `degraded` value and the wall clock. The
1959/// logger handles:
1960///
1961/// - Updating the `policy_degraded` health gauge immediately.
1962/// - Suppressing log lines for transitions that don't hold for at
1963///   least [`DEGRADED_FLAP_HOLD`] — a replica flapping at sub-second
1964///   cadence produces no log noise, only a quietly-updating gauge.
1965/// - Emitting a warn at the moment a sustained degraded state
1966///   crosses the hold threshold, plus a periodic re-emit every
1967///   `heartbeat_interval` while it persists.
1968/// - Emitting an info when a sustained healthy state crosses the
1969///   hold threshold (the cluster is back to its target shape and
1970///   stayed there long enough that we trust the recovery).
1971pub(crate) struct DegradationLogger {
1972    /// Last value passed to `tick`; what we'd log about if it stayed
1973    /// at this value past the hold threshold.
1974    pending_state: bool,
1975    /// When `pending_state` first appeared. Reset on every flip.
1976    pending_since: Instant,
1977    /// Whether the current pending state has been logged yet. Only
1978    /// the *first* log per sustained streak crosses; subsequent
1979    /// re-emits while degraded are heartbeat warns.
1980    pending_logged: bool,
1981    /// When the last warn fired. Drives the periodic re-emit while
1982    /// degraded.
1983    last_log: Option<Instant>,
1984    /// Wall clock of the previous accrual. The interval since this
1985    /// instant is attributed to the state observed then (`pending_state`)
1986    /// and accumulated into the `policy_degraded_nanos` counter. Advanced
1987    /// by [`Self::accrue`] — reached on every `tick` (post-gate, idle, and
1988    /// the gate-wait pacing tick) and on `reseed`.
1989    last_tick: Instant,
1990}
1991
1992impl DegradationLogger {
1993    pub(crate) fn new(now: Instant) -> Self {
1994        Self {
1995            pending_state: false,
1996            pending_since: now,
1997            pending_logged: true, // healthy is the assumed initial state; nothing to log
1998            last_log: None,
1999            last_tick: now,
2000        }
2001    }
2002
2003    /// Use when the policy is known to start in a degraded state
2004    /// (e.g. a primary under `disk+ram` with no replica yet
2005    /// connected). Logs a startup warn immediately and treats the
2006    /// state as already-logged so the next tick doesn't re-emit.
2007    pub(crate) fn new_starting_degraded(now: Instant, policy: &Policy) -> Self {
2008        tracing::warn!(
2009            policy = %policy,
2010            "ack policy starts degraded — fewer connected nodes than the target count"
2011        );
2012        Self {
2013            pending_state: true,
2014            pending_since: now,
2015            pending_logged: true,
2016            last_log: Some(now),
2017            last_tick: now,
2018        }
2019    }
2020
2021    /// Charge the interval `[last_tick, now]` to the degraded-duration
2022    /// counter when `prev_degraded` (the state observed at the previous
2023    /// tick) was degraded, then advance `last_tick`. Drives the
2024    /// `_seconds_total` counter so `rate()` reflects time-in-degraded
2025    /// continuously, even mid-incident, rather than only stepping on
2026    /// recovery. Shared by [`Self::tick`] and [`Self::reseed`] so accrual
2027    /// is never skipped on a logger lifecycle boundary.
2028    #[inline]
2029    fn accrue(&mut self, utilization: &StageUtilization, prev_degraded: bool, now: Instant) {
2030        if prev_degraded {
2031            // `duration_since` saturates to zero when `now < last_tick`;
2032            // `now` is always >= `last_tick` here (single-thread monotonic
2033            // clock), matching the plain `duration_since` used elsewhere
2034            // in this file.
2035            let elapsed = now.duration_since(self.last_tick);
2036            // `as_nanos()` is u128; one inter-tick interval is sub-second
2037            // to a few seconds, far below the u64 nanos ceiling (~584
2038            // years), so the cast can't truncate. See the field doc on
2039            // `StageUtilization::policy_degraded_nanos`.
2040            utilization
2041                .policy_degraded_nanos
2042                .fetch_add(elapsed.as_nanos() as u64, Ordering::Relaxed);
2043        }
2044        self.last_tick = now;
2045    }
2046
2047    /// Flush in-flight degraded accrual up to `now`, then reset to a
2048    /// fresh healthy-start logger. Called on a runtime ack-policy
2049    /// swap, which is a logger lifecycle boundary: without flushing
2050    /// first, the pre-swap degraded interval — on the gate-wait path the
2051    /// entire wedge — would be silently dropped. The new policy's actual
2052    /// degraded state is re-derived by the next `tick`, so the brief
2053    /// swap-to-next-tick window is attributed healthy; that residual is
2054    /// bounded by one tick interval, the counter's inherent granularity.
2055    pub(crate) fn reseed(&mut self, utilization: &StageUtilization, now: Instant) {
2056        let prev_state = self.pending_state;
2057        self.accrue(utilization, prev_state, now);
2058        *self = Self::new(now);
2059    }
2060
2061    /// Update the gauge + emit transition/heartbeat logs as needed.
2062    /// Cheap on the hot path: one atomic store, a few branches, one
2063    /// `Instant::duration_since`.
2064    pub(crate) fn tick(
2065        &mut self,
2066        policy: &Policy,
2067        utilization: &StageUtilization,
2068        degraded_now: bool,
2069        now: Instant,
2070        heartbeat_interval: Duration,
2071    ) {
2072        // Charge the just-elapsed interval to the state observed at the
2073        // *previous* tick. Snapshot it up front, before the flap-hold
2074        // bookkeeping below mutates `pending_state`, so accrual stays
2075        // correct even if that block is later reordered.
2076        let prev_state = self.pending_state;
2077        self.accrue(utilization, prev_state, now);
2078
2079        utilization
2080            .policy_degraded
2081            .store(degraded_now, Ordering::Relaxed);
2082
2083        if degraded_now != self.pending_state {
2084            // State changed — start a new hold window. Don't log
2085            // until / unless this new state stays long enough.
2086            self.pending_state = degraded_now;
2087            self.pending_since = now;
2088            self.pending_logged = false;
2089            return;
2090        }
2091
2092        // State held. If we haven't yet logged this streak's onset,
2093        // and it's been pending for at least the flap-hold time,
2094        // emit the transition message and mark logged.
2095        if !self.pending_logged && now.duration_since(self.pending_since) >= DEGRADED_FLAP_HOLD {
2096            if degraded_now {
2097                tracing::warn!(
2098                    policy = %policy,
2099                    "ack policy operating degraded — fewer connected nodes than the target count, response gate stalled until the cluster recovers or the policy is swapped"
2100                );
2101            } else {
2102                tracing::info!(
2103                    policy = %policy,
2104                    "ack policy returned to target shape"
2105                );
2106            }
2107            self.pending_logged = true;
2108            self.last_log = Some(now);
2109            return;
2110        }
2111
2112        // Heartbeat re-emit while a degraded state persists.
2113        if degraded_now
2114            && self.pending_logged
2115            && self
2116                .last_log
2117                .is_none_or(|t| now.duration_since(t) >= heartbeat_interval)
2118        {
2119            tracing::warn!(
2120                policy = %policy,
2121                "ack policy still degraded — fewer connected nodes than the target count"
2122            );
2123            self.last_log = Some(now);
2124        }
2125    }
2126}
2127
2128/// Tracks per-cursor "first observed transition from below to >= needed"
2129/// inside the durability gate loop, to drive the journal-wait /
2130/// replica-wait histograms in the bench's tick-to-trade decomposition.
2131///
2132/// A sample is recorded only for cursors that were strictly below
2133/// `needed` at the loop's first observation. Cursors already past at
2134/// entry were not on the critical path for this batch, so attributing
2135/// "wait time" to them would inflate the metric with cursor-poll
2136/// observation timestamps that have nothing to do with how long the
2137/// stage actually held us up.
2138///
2139/// `now_ns` is taken as a parameter rather than read internally so
2140/// tests can supply deterministic timestamps. The caller's hot path
2141/// reads `trace::mono_trace_ns()` once per gate iteration and feeds it in.
2142#[cfg(feature = "tick-to-trade")]
2143pub(crate) struct GateCrossTracker {
2144    needed: u64,
2145    journal_crossed_ts: Option<trace::MonoTraceInstant>,
2146    replica_crossed_ts: Option<trace::MonoTraceInstant>,
2147    journal_was_below: bool,
2148    replica_was_below: bool,
2149    first: bool,
2150}
2151
2152#[cfg(feature = "tick-to-trade")]
2153impl GateCrossTracker {
2154    pub(crate) fn new(needed: u64) -> Self {
2155        Self {
2156            needed,
2157            journal_crossed_ts: None,
2158            replica_crossed_ts: None,
2159            journal_was_below: false,
2160            replica_was_below: false,
2161            first: true,
2162        }
2163    }
2164
2165    /// `replica_pos` is the replica-side cursor the *active policy*
2166    /// gates on — see [`policy_replica_cursor`]. `None` means no replica
2167    /// currently supplies a binding cursor, which happens permanently
2168    /// under `disk` and transiently when the binding replica drops out
2169    /// of the cursor view part-way through a wait.
2170    ///
2171    /// Both cases must leave `replica_crossed_ts` alone. Treating
2172    /// `None` as an infinite cursor would satisfy the crossing test and
2173    /// latch the timestamp at the *disconnect*, reporting a replica
2174    /// wait that ended when the link died rather than when the replica
2175    /// caught up — understating exactly the failover runs the histogram
2176    /// exists to measure. On the first observe it additionally leaves
2177    /// `replica_was_below` false, so a policy that never waits on a
2178    /// replica records no sample at all.
2179    pub(crate) fn observe(
2180        &mut self,
2181        journal_pos: u64,
2182        replica_pos: Option<u64>,
2183        now_ns: trace::MonoTraceInstant,
2184    ) {
2185        if self.first {
2186            self.journal_was_below = journal_pos < self.needed;
2187            self.replica_was_below = replica_pos.is_some_and(|p| p < self.needed);
2188            self.first = false;
2189        }
2190        if self.journal_was_below && self.journal_crossed_ts.is_none() && journal_pos >= self.needed
2191        {
2192            self.journal_crossed_ts = Some(now_ns);
2193        }
2194        if self.replica_was_below
2195            && self.replica_crossed_ts.is_none()
2196            && replica_pos.is_some_and(|p| p >= self.needed)
2197        {
2198            self.replica_crossed_ts = Some(now_ns);
2199        }
2200    }
2201
2202    pub(crate) fn journal_crossed(&self) -> Option<trace::MonoTraceInstant> {
2203        self.journal_crossed_ts
2204    }
2205
2206    pub(crate) fn replica_crossed(&self) -> Option<trace::MonoTraceInstant> {
2207        self.replica_crossed_ts
2208    }
2209}
2210
2211/// Print busy/idle utilization for a pipeline stage on shutdown.
2212#[cfg(feature = "pipeline-stats")]
2213fn print_utilization(stage: &str, busy: u64, idle: u64) {
2214    let total = busy + idle;
2215    if total == 0 {
2216        tracing::info!(stage, "no iterations recorded");
2217        return;
2218    }
2219    let pct = (busy as f64 / total as f64) * 100.0;
2220    tracing::info!(
2221        stage,
2222        pct_busy = format_args!("{pct:.2}%"),
2223        busy,
2224        idle,
2225        total,
2226        "pipeline utilization",
2227    );
2228}
2229
2230#[cfg(test)]
2231mod tests {
2232    #[cfg(feature = "tick-to-trade")]
2233    use super::GateCrossTracker;
2234    use super::{
2235        Blocker, DegradationLogger, OutputSlot, WireSeq, evaluate_durability, evaluate_gate,
2236        slot_needs_gate,
2237    };
2238    use crate::ack_policy::{AckPolicy, Clause, Level, Policy};
2239    use crate::replication::ReplicationMetrics;
2240
2241    /// Queued end-to-end samples survive a flush that dropped nothing,
2242    /// and are closed (drained) by it.
2243    #[cfg(feature = "latency-trace")]
2244    #[test]
2245    fn e2e_samples_survive_until_a_flush_closes_them() {
2246        let mut pending = vec![(1u64, 10u64), (2, 20), (1, 30)];
2247        super::discard_e2e_samples(&mut pending, &[]);
2248        assert_eq!(pending.len(), 3, "an empty drop list must lose nothing");
2249
2250        let mut rec = melin_transport_core::trace::register_stage("test::response_e2e_close");
2251        super::close_e2e_samples(&mut pending, &mut rec);
2252        assert!(pending.is_empty(), "a flush closes the whole queue");
2253    }
2254
2255    /// The defect this pairing exists to prevent: a dropped connection's
2256    /// buffered bytes are discarded with it, so its samples must not
2257    /// survive to be timed against some later connection's flush.
2258    #[cfg(feature = "latency-trace")]
2259    #[test]
2260    fn e2e_samples_for_a_dropped_connection_are_discarded() {
2261        let mut pending = vec![(1u64, 10u64), (2, 20), (1, 30), (3, 40)];
2262        super::discard_e2e_samples(&mut pending, &[1, 3]);
2263        assert_eq!(
2264            pending,
2265            vec![(2, 20)],
2266            "only the surviving connection's samples remain"
2267        );
2268
2269        // Dropping the last live connection empties the queue, which is
2270        // what restores "queue non-empty implies something is dirty" —
2271        // the invariant the flush sites rely on to be sufficient drain
2272        // points.
2273        super::discard_e2e_samples(&mut pending, &[2]);
2274        assert!(pending.is_empty());
2275    }
2276
2277    /// Output slot carrying only the fields the gate decision reads.
2278    /// `()` for the report/query types keeps the fixture independent of
2279    /// any concrete application.
2280    fn slot(wire_seq: u64) -> OutputSlot<(), ()> {
2281        OutputSlot {
2282            wire_seq,
2283            ..Default::default()
2284        }
2285    }
2286
2287    #[test]
2288    fn slot_gates_until_its_own_sequence_is_durable() {
2289        assert!(slot_needs_gate(&slot(10), 0));
2290        assert!(slot_needs_gate(&slot(10), 9));
2291    }
2292
2293    #[test]
2294    fn slot_clears_at_its_own_sequence_not_the_batch_maximum() {
2295        // The cursor reaching exactly this slot's `wire_seq` is enough —
2296        // an off-by-one here would stall every response an extra
2297        // durability round-trip. A later slot in the same batch needing
2298        // more must not hold this one back.
2299        assert!(!slot_needs_gate(&slot(10), 10));
2300        assert!(!slot_needs_gate(&slot(10), 11));
2301    }
2302
2303    /// Build a [`Policy`] from a mini DSL: one or more
2304    /// `"<level>>=<count>"` clauses joined with `&&`. Test-only
2305    /// ergonomics — production builds policies via
2306    /// [`AckPolicy::to_policy`].
2307    fn parse(s: &str) -> Result<Policy, String> {
2308        let mut clauses = Vec::new();
2309        for raw in s.split("&&") {
2310            let token = raw.trim();
2311            let (lvl, rhs) = token
2312                .split_once(">=")
2313                .ok_or_else(|| format!("clause `{token}` missing `>=`"))?;
2314            let level = match lvl.trim() {
2315                "persisted" => Level::Persisted,
2316                "in_memory" => Level::InMemory,
2317                other => return Err(format!("unknown level `{other}`")),
2318            };
2319            let count: u8 = rhs.trim().parse().map_err(|e| format!("bad count: {e}"))?;
2320            clauses.push(Clause { count, level });
2321        }
2322        Policy::new(clauses).map_err(|e| e.to_string())
2323    }
2324    use melin_transport_core::pipeline::StageUtilization;
2325    use std::sync::Arc;
2326    use std::sync::atomic::{AtomicBool, Ordering};
2327    use std::time::{Duration, Instant};
2328
2329    /// Build a `ReplicationMetrics` with both slots populated. Tests
2330    /// that need to simulate a disconnected slot use [`flags`] to mark
2331    /// it inactive — its cursors are then ignored regardless of value.
2332    fn metrics(slot0: (u64, u64), slot1: (u64, u64)) -> Arc<ReplicationMetrics> {
2333        let m = Arc::new(ReplicationMetrics::default());
2334        m.in_memory_sequence[0].store(slot0.0, Ordering::Relaxed);
2335        m.acked_sequence[0].store(slot0.1, Ordering::Relaxed);
2336        m.in_memory_sequence[1].store(slot1.0, Ordering::Relaxed);
2337        m.acked_sequence[1].store(slot1.1, Ordering::Relaxed);
2338        m
2339    }
2340
2341    /// Build a `[active; 2]` flags array.
2342    fn flags(slot0_active: bool, slot1_active: bool) -> [Arc<AtomicBool>; 2] {
2343        [
2344            Arc::new(AtomicBool::new(slot0_active)),
2345            Arc::new(AtomicBool::new(slot1_active)),
2346        ]
2347    }
2348
2349    /// Both replicas active — the common healthy-cluster case.
2350    fn both_active() -> [Arc<AtomicBool>; 2] {
2351        flags(true, true)
2352    }
2353
2354    // --- Standalone (no replicas wired) ---
2355
2356    #[test]
2357    fn standalone_persisted_one_gates_on_journal() {
2358        // No metrics → only the primary is in the view. `persisted>=1`
2359        // is satisfied by the primary alone at journal_pos.
2360        let p = parse("persisted>=1").unwrap();
2361        assert_eq!(
2362            evaluate_durability(&p, WireSeq::new(500), None, None).durable_pos,
2363            500
2364        );
2365    }
2366
2367    #[test]
2368    fn standalone_strict_persisted_two_never_opens() {
2369        // `persisted>=2` on a standalone primary stays at 0: the
2370        // operator asked for two copies and there is only one. The
2371        // policy surfaces as degraded so the operator sees the gate
2372        // is stalled because the cluster can't meet the policy.
2373        let p = parse("persisted>=2").unwrap();
2374        let r = evaluate_durability(&p, WireSeq::new(500), None, None);
2375        assert_eq!(r.durable_pos, 0);
2376        assert!(
2377            r.degraded,
2378            "policy structurally unsatisfiable on this shape → degraded",
2379        );
2380    }
2381
2382    // --- 2 replicas connected ---
2383
2384    #[test]
2385    fn quorum_both_replicas_ahead_of_journal() {
2386        // Both replicas persisted past journal. `persisted>=2` returns
2387        // the 2nd-largest persisted across {primary, slot0, slot1}.
2388        let p = parse("persisted>=2").unwrap();
2389        let m = metrics((100, 100), (120, 120));
2390        let a = both_active();
2391        assert_eq!(
2392            evaluate_durability(&p, WireSeq::new(50), Some(&m), Some(&a)).durable_pos,
2393            100
2394        );
2395    }
2396
2397    #[test]
2398    fn quorum_journal_ahead_of_both_replicas() {
2399        // Journal at 500, replicas at 100/120. 2nd-largest persisted = 120.
2400        let p = parse("persisted>=2").unwrap();
2401        let m = metrics((100, 100), (120, 120));
2402        let a = both_active();
2403        assert_eq!(
2404            evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a)).durable_pos,
2405            120
2406        );
2407    }
2408
2409    #[test]
2410    fn quorum_journal_between_slow_and_fast_replica() {
2411        // {primary=150, slot0_persisted=50, slot1_persisted=200}.
2412        // 2nd-largest = 150 (primary itself).
2413        let p = parse("persisted>=2").unwrap();
2414        let m = metrics((50, 50), (200, 200));
2415        let a = both_active();
2416        assert_eq!(
2417            evaluate_durability(&p, WireSeq::new(150), Some(&m), Some(&a)).durable_pos,
2418            150
2419        );
2420    }
2421
2422    #[test]
2423    fn ram_quorum_gates_on_replica_memory_not_on_any_disk() {
2424        // `ram` (`in_memory>=2`): the journal position must not
2425        // bind. Journal at 0 — nothing persisted anywhere on the
2426        // primary — while the replicas hold 100/120 in memory. The
2427        // 2nd-largest in-memory across {primary=MAX, 100, 120} is 120:
2428        // the gate is exactly the fastest replica's RAM receipt.
2429        let p = AckPolicy::Ram.to_policy();
2430        let m = metrics((100, 0), (120, 0));
2431        let a = both_active();
2432        let r = evaluate_durability(&p, WireSeq::new(0), Some(&m), Some(&a));
2433        assert_eq!(r.durable_pos, 120);
2434        assert!(!r.degraded);
2435    }
2436
2437    #[test]
2438    fn ram_quorum_stalls_with_no_replica_connected() {
2439        // Fail-closed: with only the primary in the view, `in_memory>=2`
2440        // is structurally unsatisfiable however far the journal is.
2441        let p = AckPolicy::Ram.to_policy();
2442        let r = evaluate_durability(&p, WireSeq::new(500), None, None);
2443        assert_eq!(r.durable_pos, 0);
2444        assert!(r.degraded);
2445    }
2446
2447    // --- Single replica connected ---
2448
2449    #[test]
2450    fn single_replica_strict_persisted_two_requires_both_survivors() {
2451        // Slot 0 connected, slot 1 disconnected. View = {primary, slot0}.
2452        // Strict `persisted>=2`: 2nd-largest of the 2-row view =
2453        // min(primary, slot0). Strictly stronger than legacy auto-
2454        // degrade-to-1-node in the same shape.
2455        let p = parse("persisted>=2").unwrap();
2456        let m = metrics((100, 100), (999, 999)); // slot 1 cursors ignored
2457        let a = flags(true, false);
2458        assert_eq!(
2459            evaluate_durability(&p, WireSeq::new(50), Some(&m), Some(&a)).durable_pos,
2460            50
2461        );
2462        assert_eq!(
2463            evaluate_durability(&p, WireSeq::new(200), Some(&m), Some(&a)).durable_pos,
2464            100
2465        );
2466    }
2467
2468    #[test]
2469    fn single_replica_persisted_two_requires_both_survivors() {
2470        // 2-node view (primary + surviving replica). `persisted>=2` is
2471        // satisfiable; the gate opens at the slower of the two and the
2472        // policy is not degraded.
2473        let p = parse("persisted>=2").unwrap();
2474        let m = metrics((100, 100), (999, 999));
2475        let a = flags(true, false);
2476        let r = evaluate_durability(&p, WireSeq::new(50), Some(&m), Some(&a));
2477        assert_eq!(r.durable_pos, 50);
2478        assert!(!r.degraded);
2479    }
2480
2481    #[test]
2482    fn both_replicas_disconnected_strict_stalls() {
2483        // View has only the primary. Strict `persisted>=2` cannot be
2484        // satisfied — operator opted out of degrade.
2485        let p = parse("persisted>=2").unwrap();
2486        let m = metrics((999, 999), (999, 999));
2487        let a = flags(false, false);
2488        assert_eq!(
2489            evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a)).durable_pos,
2490            0
2491        );
2492    }
2493
2494    #[test]
2495    fn both_replicas_disconnected_strict_stalls_and_flags_degraded() {
2496        // With `persisted>=2` and both replicas down, the cursor view
2497        // collapses to {primary}: the clause's count (=2) exceeds the
2498        // view size, so the gate stays at 0 and the policy flags
2499        // degraded. Note the readers' separate halt at
2500        // `replicas_connected==0` refuses new writes before they reach
2501        // the gate; this verifies the gate semantics in isolation.
2502        let p = parse("persisted>=2").unwrap();
2503        let m = metrics((999, 999), (999, 999));
2504        let a = flags(false, false);
2505        let r = evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a));
2506        assert_eq!(r.durable_pos, 0);
2507        assert!(r.degraded);
2508    }
2509
2510    // --- Mixed-level policies ---
2511
2512    #[test]
2513    fn persisted_one_and_in_memory_two() {
2514        // "Leader persists, plus one other node has it in memory" —
2515        // the cheap-but-non-zero durability target. Slot 0 has it in
2516        // memory, slot 1 disconnected.
2517        let p = parse("persisted>=1 && in_memory>=2").unwrap();
2518        // primary persisted=50, slot0 in_mem=80 / persisted=20.
2519        // persisted>=1: max(50, 20, 0) = 50.
2520        // in_memory>=2: primary in_mem=u64::MAX (always), slot0_eff=max(80, 20)=80,
2521        //               slot1=0. 2nd-largest = 80.
2522        // min(50, 80) = 50.
2523        let m = metrics((80, 20), (999, 999));
2524        let a = flags(true, false);
2525        assert_eq!(
2526            evaluate_durability(&p, WireSeq::new(50), Some(&m), Some(&a)).durable_pos,
2527            50
2528        );
2529    }
2530
2531    // --- Edge: journal at 0 ---
2532
2533    #[test]
2534    fn journal_at_zero_with_replicas_persisted_one() {
2535        // Journal hasn't fsynced anything; both replicas have. With
2536        // `persisted>=1` the gate opens at the fastest replica.
2537        let p = parse("persisted>=1").unwrap();
2538        let m = metrics((100, 100), (200, 200));
2539        let a = both_active();
2540        assert_eq!(
2541            evaluate_durability(&p, WireSeq::new(0), Some(&m), Some(&a)).durable_pos,
2542            200
2543        );
2544    }
2545
2546    // --- gate-blocker attribution ---
2547    //
2548    // The attribution must follow the policy actually in force. The
2549    // previous heuristic compared the journal cursor against the
2550    // minimum replica *persisted* cursor unconditionally, which
2551    // reported replication as the blocker under `disk` whenever a
2552    // replica lagged (a replica binds `persisted>=1` only when its fsync
2553    // leads) and read the wrong level under `disk+ram` (which gates
2554    // replicas on in-memory).
2555    //
2556    // Attribution comes out of `evaluate_gate` alongside the durable
2557    // position, computed from the same cursor snapshot, and is `None`
2558    // while the gate stays closed — so each test passes a `needed` at
2559    // or below the scenario's durable position to model the iteration
2560    // on which the gate opens.
2561
2562    #[test]
2563    fn disk_policy_credits_journal_when_replicas_lag() {
2564        // `persisted>=1` is satisfied by the highest persisted cursor
2565        // in the cluster — here the primary's. A connected replica lagging
2566        // far behind is irrelevant to the gate, so it must not be
2567        // credited as the blocker. This is the case the old heuristic
2568        // got flatly wrong: journal_pos (500) > repl_min (10) sent it
2569        // down the `else` branch every single time.
2570        let p = parse("persisted>=1").unwrap();
2571        let m = metrics((10, 10), (10, 10));
2572        let a = both_active();
2573        assert_eq!(
2574            evaluate_gate(&p, 500, WireSeq::new(500), Some(&m), Some(&a)).1,
2575            Some(Blocker::Journal)
2576        );
2577    }
2578
2579    #[test]
2580    fn disk_policy_credits_replication_when_a_replica_fsync_leads() {
2581        // The mirror: policies count copies, not nodes. With the
2582        // primary's disk stalled at 10 and a replica persisted through
2583        // 500, `persisted>=1` opens on the replica's copy — and the
2584        // attribution must say so, or an operator reading the gate
2585        // counters would conclude the primary's disk was keeping up.
2586        let p = parse("persisted>=1").unwrap();
2587        let m = metrics((500, 500), (10, 10));
2588        let a = both_active();
2589        assert_eq!(
2590            evaluate_gate(&p, 500, WireSeq::new(10), Some(&m), Some(&a)).1,
2591            Some(Blocker::Replication)
2592        );
2593    }
2594
2595    #[test]
2596    fn disk_and_ram_reads_replica_in_memory_not_persisted() {
2597        // `persisted>=1 && in_memory>=2`. The replica has the event in
2598        // memory (400) well ahead of its own fsync (100), and the
2599        // primary's journal is behind that in-memory cursor (300).
2600        // The binding clause is therefore the primary's persisted
2601        // cursor → journal. Comparing against the replica's *persisted*
2602        // cursor (100) would have said replication.
2603        let p = parse("persisted>=1 && in_memory>=2").unwrap();
2604        let m = metrics((400, 100), (0, 0));
2605        let a = flags(true, false);
2606        assert_eq!(
2607            evaluate_gate(&p, 300, WireSeq::new(300), Some(&m), Some(&a)).1,
2608            Some(Blocker::Journal)
2609        );
2610
2611        // Same policy, replica in-memory now the laggard → replication.
2612        // The gate opens at the replica's in-memory cursor (200).
2613        let m = metrics((200, 100), (0, 0));
2614        assert_eq!(
2615            evaluate_gate(&p, 200, WireSeq::new(300), Some(&m), Some(&a)).1,
2616            Some(Blocker::Replication)
2617        );
2618    }
2619
2620    #[test]
2621    fn disk_and_ram_is_satisfied_by_the_faster_replica() {
2622        // `in_memory>=2` needs the primary plus *one* replica, so the
2623        // best replica binds, not the worst. Replica 0 holds the event
2624        // in memory at 400 with its own fsync trailing at 250 (behind
2625        // the primary, as the physical ordering requires); replica 1
2626        // lags badly at 10. The gate is not held up by replication —
2627        // the primary's persisted cursor (300) is the binding term.
2628        // Taking the min across replicas would have blamed the slow
2629        // slot.
2630        let p = parse("persisted>=1 && in_memory>=2").unwrap();
2631        let m = metrics((400, 250), (10, 10));
2632        let a = both_active();
2633        assert_eq!(
2634            evaluate_gate(&p, 300, WireSeq::new(300), Some(&m), Some(&a)).1,
2635            Some(Blocker::Journal)
2636        );
2637    }
2638
2639    #[test]
2640    fn two_disks_uses_second_largest_persisted() {
2641        // `persisted>=2` is met by the primary plus the *best* replica's
2642        // persisted cursor. With the primary ahead at 900 and replicas
2643        // at 400 and 10, the clause resolves at 400 — the fast replica —
2644        // not at the minimum of 10.
2645        let p = parse("persisted>=2").unwrap();
2646        let m = metrics((400, 400), (10, 10));
2647        let a = both_active();
2648        assert_eq!(
2649            evaluate_durability(&p, WireSeq::new(900), Some(&m), Some(&a)).durable_pos,
2650            400,
2651            "clause takes the second-largest persisted cursor, not the minimum"
2652        );
2653        // Replication binds, which is the norm for this policy: the
2654        // primary persists before it ships, so it is normally ahead of
2655        // every replica.
2656        assert_eq!(
2657            evaluate_gate(&p, 400, WireSeq::new(900), Some(&m), Some(&a)).1,
2658            Some(Blocker::Replication)
2659        );
2660    }
2661
2662    #[test]
2663    fn standalone_credits_the_journal() {
2664        // No replication wired: the primary is the only node, so the
2665        // journal is the only thing the gate can be waiting on.
2666        let p = parse("persisted>=1").unwrap();
2667        assert_eq!(
2668            evaluate_gate(&p, 500, WireSeq::new(500), None, None).1,
2669            Some(Blocker::Journal)
2670        );
2671    }
2672
2673    #[test]
2674    fn unsatisfiable_shape_never_opens_so_never_attributes() {
2675        // `persisted>=2` with no replica connected: the shape pins
2676        // `durable_pos` to 0, so the gate cannot open and no blocker is
2677        // ever produced — the stall is a missing node, not either
2678        // subsystem's progress. `policy_degraded` is the metric for it.
2679        let p = parse("persisted>=2").unwrap();
2680        let (status, blocker) = evaluate_gate(&p, 1, WireSeq::new(500), None, None);
2681        assert!(status.degraded);
2682        assert_eq!(status.durable_pos, 0);
2683        assert_eq!(blocker, None);
2684    }
2685
2686    #[test]
2687    fn closed_gate_yields_no_attribution() {
2688        // The policy is satisfiable and healthy, but the batch needs a
2689        // sequence the cursors haven't reached — the gate stays closed
2690        // and `evaluate_gate` must not spend a ranking pass (nor name a
2691        // blocker) on an iteration that keeps spinning.
2692        let p = parse("persisted>=1").unwrap();
2693        let (status, blocker) = evaluate_gate(&p, 501, WireSeq::new(500), None, None);
2694        assert!(!status.degraded);
2695        assert_eq!(status.durable_pos, 500);
2696        assert_eq!(blocker, None);
2697    }
2698
2699    #[test]
2700    fn attribution_skips_disconnected_slots() {
2701        // Slot 1 is disconnected, so its (very advanced) cursors must
2702        // not satisfy `in_memory>=2`; only slot 0 counts, and it lags.
2703        // The gate opens at slot 0's in-memory cursor (100).
2704        let p = parse("persisted>=1 && in_memory>=2").unwrap();
2705        let m = metrics((100, 100), (999, 999));
2706        let a = flags(true, false);
2707        assert_eq!(
2708            evaluate_gate(&p, 100, WireSeq::new(500), Some(&m), Some(&a)).1,
2709            Some(Blocker::Replication)
2710        );
2711    }
2712
2713    /// Fresh-cluster catch-up: a replica that handshakes at sequence
2714    /// 0 (the legitimate genesis case, not a stale-flag race) must be
2715    /// included in the cursor view with its zero cursors so the policy
2716    /// behaves the same way it would for a 1-replica deployment that
2717    /// has just produced its first batch. The disconnect-race
2718    /// mitigations (B1 seed-on-connect + B2 reorder) keep this from
2719    /// being conflated with the stale-flag-paired-with-zero-cursor
2720    /// case under normal cluster lifecycles.
2721    #[test]
2722    fn fresh_cluster_zero_cursors_included_in_view() {
2723        let p = parse("persisted>=2").unwrap();
2724        // Both replicas just handshook at seq 0, primary also at 0
2725        // (fresh cluster, no events yet). View = 3 nodes; the clause's
2726        // count (=2) is met by the view size, so the policy is not
2727        // degraded and the gate sits at the 2nd-largest persisted = 0.
2728        let m = metrics((0, 0), (0, 0));
2729        let a = both_active();
2730        let r = evaluate_durability(&p, WireSeq::new(0), Some(&m), Some(&a));
2731        assert_eq!(r.durable_pos, 0);
2732        assert!(
2733            !r.degraded,
2734            "all 3 nodes present, view meets clause target — should not flag degraded"
2735        );
2736    }
2737
2738    // -- Race-window regression tests --
2739    //
2740    // The replication senders fix two memory-ordering issues at the
2741    // active-flag transition points:
2742    //
2743    //   B1 (`a84540a`): seed `metrics.{acked,in_memory}_sequence[i]`
2744    //   to `handshake.last_sequence` BEFORE setting active_flag=true
2745    //   on reconnect. Without this, the gate would observe (active=
2746    //   true, cursor=0) for ~1 RTT after a replica catch-up completed,
2747    //   pinning any multi-node clause — and thus the gate — to 0.
2748    //
2749    //   B2 (`8888732`): zero `metrics.{acked,in_memory}_sequence[i]`
2750    //   BEFORE setting active_flag=false on disconnect. Without this,
2751    //   a weak-memory reader could observe (active=true, cursor=0)
2752    //   for one iteration during the disconnect window.
2753    //
2754    // Both fixes are in the senders, but the gate's *behaviour* under
2755    // the race-window inputs is tested here. The intent is to lock in
2756    // the invariant: even under a hypothetical (active=true,cursor=0)
2757    // observation, the gate must not produce a spuriously-open answer
2758    // that would cause a client to be told "your event is durable"
2759    // when it isn't. Stalling-briefly is safe; opening-spuriously is
2760    // not.
2761
2762    #[test]
2763    fn race_b1_post_seed_gate_doesnt_freeze_on_reconnect() {
2764        // Post-B1-fix state: replica reconnected, cursors seeded to
2765        // `handshake.last_sequence` (480) before active flipped to
2766        // true. Primary kept moving and is at 500. The gate's view
2767        // is now [primary=500, slot=480]; the durable position dips
2768        // from 500 (primary alone, degraded) to 480 (both nodes).
2769        //
2770        // The dip is correct, not a bug: once a 2nd node is
2771        // connected, durability is bounded by the slower of the two.
2772        // Events 481-500 were already served as durable on primary
2773        // alone — they aren't unsent. New responses for seq>500 wait
2774        // until slot acks; we just don't freeze at 0.
2775        let p = parse("persisted>=2").unwrap();
2776        let m = metrics((480, 480), (999, 999));
2777        let a = flags(true, false);
2778        let r = evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a));
2779        assert_eq!(
2780            r.durable_pos, 480,
2781            "post-seed reconnect should produce a coherent gate position equal to the slower node, not freeze at 0"
2782        );
2783    }
2784
2785    #[test]
2786    fn race_b1_pre_seed_freeze_is_what_the_fix_avoids() {
2787        // Pre-B1-fix state: cursors at 0, active=true. The gate sees
2788        // [primary=500, slot=[0,0]] and 2nd-largest persisted = 0.
2789        // The gate WOULD freeze at 0. This test documents the bug
2790        // the seeding fix is designed to avoid; the senders ensure
2791        // this state is never observed in production.
2792        let p = parse("persisted>=2").unwrap();
2793        let m = metrics((0, 0), (999, 999));
2794        let a = flags(true, false);
2795        let r = evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a));
2796        assert_eq!(
2797            r.durable_pos, 0,
2798            "the gate behaviour under (active=true, cursor=0) — if the senders ever fail to seed before flipping active, this is the freeze the operator would see"
2799        );
2800    }
2801
2802    #[test]
2803    fn race_b2_disconnect_window_doesnt_open_gate_spuriously() {
2804        // Simulates the B2 race window: a weak-memory reader observes
2805        // (active=true, cursor=0) for one iteration during the
2806        // disconnect transition. The slot legitimately has cursor=0
2807        // because the disconnect handler just zeroed the metrics.
2808        //
2809        // Critical invariant: the gate must NOT produce a higher
2810        // durable_pos than it would with the slot correctly excluded.
2811        // Specifically: with primary at 500, slot stale-zero-included,
2812        // the gate must not "see" the primary alone and open at 500
2813        // — that would let a client be told a seq is durable when
2814        // only the primary has it under a `persisted>=2` policy that
2815        // demands 2 nodes.
2816        let p = parse("persisted>=2").unwrap();
2817        let m = metrics((0, 0), (999, 999));
2818        let a = flags(true, false);
2819        let r = evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a));
2820        // 2nd-largest persisted across {primary=500, slot=0} = 0.
2821        // Gate stalls. ✓
2822        assert_eq!(r.durable_pos, 0);
2823
2824        // Post-disconnect (active=false): view shrinks to {primary}.
2825        // `persisted>=2` is structurally unsatisfiable on a 1-node
2826        // view, so the gate stays at 0 AND surfaces degraded. The
2827        // readers' `replicas_connected==0` halt is what stops
2828        // accepting new orders; the gate side's job is just to keep
2829        // the existing in-flight orders stalled and the alert lit.
2830        let a_disconnected = flags(false, false);
2831        let r_after = evaluate_durability(&p, WireSeq::new(500), Some(&m), Some(&a_disconnected));
2832        assert_eq!(r_after.durable_pos, 0);
2833        assert!(
2834            r_after.degraded,
2835            "post-disconnect view of size 1 cannot meet persisted>=2 → degraded"
2836        );
2837    }
2838
2839    #[test]
2840    fn race_invariant_zero_cursor_never_opens_gate_above_slower_node() {
2841        // Property under both B1 and B2 race windows: for any slot
2842        // observed at cursor=0 with active=true, the gate cannot
2843        // produce a durable_pos that exceeds what an honest 2-node
2844        // evaluation would give. Spot-check a handful of primary
2845        // positions to lock the invariant.
2846        let p = parse("persisted>=2").unwrap();
2847        let m = metrics((0, 0), (999, 999));
2848        let a = flags(true, false);
2849        for primary_pos in [0, 1, 100, 500, 1_000_000_000_u64] {
2850            let r = evaluate_durability(&p, WireSeq::new(primary_pos), Some(&m), Some(&a));
2851            // 2nd-largest of {primary_pos, 0} = 0 for any primary > 0.
2852            // For primary_pos = 0, also 0. So always 0.
2853            assert_eq!(
2854                r.durable_pos, 0,
2855                "race-window observation must not open the gate above 0 for any primary position (got {} for primary_pos={primary_pos})",
2856                r.durable_pos
2857            );
2858        }
2859    }
2860
2861    // ------------------------------------------------------------------
2862    // GateCrossTracker — per-cursor "first transition from below to
2863    // crossed" inside the gate loop, used by the journal-wait /
2864    // replica-wait histograms.
2865    // ------------------------------------------------------------------
2866
2867    #[cfg(feature = "tick-to-trade")]
2868    #[test]
2869    fn gate_cross_tracker_records_journal_when_strictly_below() {
2870        // Journal starts at 5 (< 10), repl_min already at 100.
2871        // Journal crosses on the second observation. Replica was already
2872        // past at entry, so no replica sample.
2873        let mut t = GateCrossTracker::new(10);
2874        t.observe(5, Some(100), 1_000);
2875        t.observe(15, Some(100), 2_000);
2876        assert_eq!(t.journal_crossed(), Some(2_000));
2877        assert_eq!(t.replica_crossed(), None);
2878    }
2879
2880    #[cfg(feature = "tick-to-trade")]
2881    #[test]
2882    fn gate_cross_tracker_records_replica_when_strictly_below() {
2883        // Mirror image: journal already past, replica below at entry.
2884        let mut t = GateCrossTracker::new(10);
2885        t.observe(50, Some(5), 1_000);
2886        t.observe(50, Some(12), 2_000);
2887        assert_eq!(t.journal_crossed(), None);
2888        assert_eq!(t.replica_crossed(), Some(2_000));
2889    }
2890
2891    #[cfg(feature = "tick-to-trade")]
2892    #[test]
2893    fn gate_cross_tracker_records_both_when_both_below() {
2894        // Both below at entry, both cross independently.
2895        let mut t = GateCrossTracker::new(100);
2896        t.observe(50, Some(60), 1_000); // both below
2897        t.observe(105, Some(60), 2_000); // journal crosses
2898        t.observe(105, Some(110), 3_000); // replica crosses
2899        assert_eq!(t.journal_crossed(), Some(2_000));
2900        assert_eq!(t.replica_crossed(), Some(3_000));
2901    }
2902
2903    #[cfg(feature = "tick-to-trade")]
2904    #[test]
2905    fn gate_cross_tracker_skips_cursor_already_past_at_entry() {
2906        // Both cursors already >= needed at first observation —
2907        // neither was on the critical path. No samples.
2908        let mut t = GateCrossTracker::new(10);
2909        t.observe(50, Some(100), 1_000);
2910        // Even later observations don't backfill: was_below is sticky.
2911        t.observe(60, Some(110), 2_000);
2912        assert_eq!(t.journal_crossed(), None);
2913        assert_eq!(t.replica_crossed(), None);
2914    }
2915
2916    #[cfg(feature = "tick-to-trade")]
2917    #[test]
2918    fn gate_cross_tracker_first_observation_only_for_cross_decision() {
2919        // A cursor that goes back below `needed` after first iteration
2920        // (impossible in practice — cursors are monotonic — but we
2921        // verify the first-iteration snapshot is what gates the
2922        // sample). Journal: 50 < 10 false → was_below=false → no sample.
2923        let mut t = GateCrossTracker::new(10);
2924        t.observe(50, Some(5), 1_000); // journal already past, replica below
2925        t.observe(20, Some(12), 2_000); // both >= needed now
2926        // Journal: was_below=false at entry → still no sample.
2927        assert_eq!(t.journal_crossed(), None);
2928        // Replica: was_below=true at entry, crosses on iter 2 → sample.
2929        assert_eq!(t.replica_crossed(), Some(2_000));
2930    }
2931
2932    #[cfg(feature = "tick-to-trade")]
2933    #[test]
2934    fn gate_cross_tracker_holds_first_cross_only() {
2935        // Once a cross is recorded, later observations don't
2936        // overwrite — the metric is "when did it first cross", not
2937        // "when was it last below".
2938        let mut t = GateCrossTracker::new(10);
2939        t.observe(5, Some(100), 1_000);
2940        t.observe(15, Some(100), 2_000); // first cross
2941        t.observe(25, Some(100), 3_000); // would otherwise re-record
2942        assert_eq!(t.journal_crossed(), Some(2_000));
2943    }
2944
2945    #[cfg(feature = "tick-to-trade")]
2946    #[test]
2947    fn gate_cross_tracker_ignores_replica_dropping_out_mid_wait() {
2948        // The regression this signature exists for. The replica is
2949        // behind at entry (was_below latches true), then disconnects
2950        // part-way through the wait so no replica supplies a binding
2951        // cursor. That must not be read as "caught up" — the histogram
2952        // would otherwise report a wait that ended at the disconnect.
2953        let mut t = GateCrossTracker::new(100);
2954        t.observe(50, Some(60), 1_000); // replica behind
2955        t.observe(105, None, 2_000); // replica drops out mid-wait
2956        t.observe(105, None, 3_000); // still gone
2957        assert_eq!(t.journal_crossed(), Some(2_000));
2958        assert_eq!(t.replica_crossed(), None);
2959        // Reconnecting past `needed` still records the real crossing.
2960        t.observe(105, Some(150), 4_000);
2961        assert_eq!(t.replica_crossed(), Some(4_000));
2962    }
2963
2964    #[cfg(feature = "tick-to-trade")]
2965    #[test]
2966    fn gate_cross_tracker_records_nothing_when_no_replica_clause() {
2967        // `local` never binds on a replica, so `policy_replica_cursor`
2968        // returns `None` for the whole wait: was_below stays false and
2969        // no replica sample is produced.
2970        let mut t = GateCrossTracker::new(100);
2971        t.observe(50, None, 1_000);
2972        t.observe(105, None, 2_000);
2973        assert_eq!(t.journal_crossed(), Some(2_000));
2974        assert_eq!(t.replica_crossed(), None);
2975    }
2976
2977    // -- DegradationLogger flap-suppression --
2978    //
2979    // The logger gates transition logs on a sustained-state hold so a
2980    // replica flapping at sub-second cadence doesn't spam the journal.
2981    // These tests don't observe the logs themselves (tracing is
2982    // process-global and brittle to capture in unit tests); they
2983    // assert the underlying state machine via the `policy_degraded`
2984    // gauge, which the logger updates on every tick regardless of
2985    // log emission.
2986
2987    fn logger_test_policy() -> crate::ack_policy::Policy {
2988        parse("persisted>=2").unwrap()
2989    }
2990
2991    /// Tick the logger N times at `step` intervals, alternating
2992    /// `degraded` per call. Returns the gauge value after the last
2993    /// tick — useful for asserting that flap cycles don't leak the
2994    /// AtomicBool into a wrong terminal state.
2995    fn drive_logger(
2996        logger: &mut DegradationLogger,
2997        utilization: &StageUtilization,
2998        policy: &crate::ack_policy::Policy,
2999        start: Instant,
3000        states: &[bool],
3001        step: Duration,
3002    ) -> bool {
3003        for (i, &state) in states.iter().enumerate() {
3004            let now = start + step.checked_mul(i as u32).unwrap_or(Duration::ZERO);
3005            logger.tick(policy, utilization, state, now, Duration::from_secs(5));
3006        }
3007        utilization.policy_degraded.load(Ordering::Relaxed)
3008    }
3009
3010    #[test]
3011    fn logger_gauge_tracks_state_immediately() {
3012        // The /healthz gauge reflects the *latest* state on every
3013        // tick — this is what dashboards / alerts read. Sustained-
3014        // state gating only affects the warn/info log emission.
3015        let p = logger_test_policy();
3016        let utilization = StageUtilization::new();
3017        let now = Instant::now();
3018        let mut logger = DegradationLogger::new(now);
3019
3020        logger.tick(&p, &utilization, true, now, Duration::from_secs(5));
3021        assert!(utilization.policy_degraded.load(Ordering::Relaxed));
3022
3023        logger.tick(
3024            &p,
3025            &utilization,
3026            false,
3027            now + Duration::from_millis(50),
3028            Duration::from_secs(5),
3029        );
3030        assert!(!utilization.policy_degraded.load(Ordering::Relaxed));
3031    }
3032
3033    #[test]
3034    fn logger_starting_degraded_marks_initial_state_logged() {
3035        // `new_starting_degraded` emits the startup warn and treats
3036        // the state as already-logged so the next tick at the same
3037        // cluster shape doesn't re-emit instantly. The gauge starts
3038        // at 1.
3039        let p = logger_test_policy();
3040        let utilization = StageUtilization::new();
3041        let now = Instant::now();
3042        let mut logger = DegradationLogger::new_starting_degraded(now, &p);
3043        // The logger doesn't write the gauge from the constructor —
3044        // first tick does. Tick at the same state to settle the
3045        // gauge. No new log line should fire (state hasn't changed).
3046        logger.tick(
3047            &p,
3048            &utilization,
3049            true,
3050            now + Duration::from_millis(10),
3051            Duration::from_secs(5),
3052        );
3053        assert!(utilization.policy_degraded.load(Ordering::Relaxed));
3054    }
3055
3056    #[test]
3057    fn logger_accumulates_degraded_seconds_only_while_degraded() {
3058        // The `policy_degraded_nanos` counter must advance by exactly
3059        // the wall-clock time spent in the degraded state and stay flat
3060        // while healthy. Each tick attributes the interval since the
3061        // previous tick to the state observed at that previous tick.
3062        let p = logger_test_policy();
3063        let utilization = StageUtilization::new();
3064        let start = Instant::now();
3065        let mut logger = DegradationLogger::new(start);
3066        let sec = Duration::from_secs(1);
3067
3068        // t0: healthy -> degraded. The zero-length interval before the
3069        // transition is attributed to the healthy start: no accrual.
3070        logger.tick(&p, &utilization, true, start, Duration::from_secs(5));
3071        assert_eq!(utilization.policy_degraded_nanos.load(Ordering::Relaxed), 0);
3072
3073        // t0..t1 spent degraded -> +1s.
3074        logger.tick(&p, &utilization, true, start + sec, Duration::from_secs(5));
3075        // t1..t2 still degraded, then flips healthy at the tick -> +1s.
3076        logger.tick(
3077            &p,
3078            &utilization,
3079            false,
3080            start + 2 * sec,
3081            Duration::from_secs(5),
3082        );
3083        assert_eq!(
3084            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
3085            2 * sec.as_nanos() as u64
3086        );
3087
3088        // t2..t3 spent healthy -> counter holds flat.
3089        logger.tick(
3090            &p,
3091            &utilization,
3092            false,
3093            start + 3 * sec,
3094            Duration::from_secs(5),
3095        );
3096        assert_eq!(
3097            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
3098            2 * sec.as_nanos() as u64
3099        );
3100    }
3101
3102    #[test]
3103    fn logger_starting_degraded_accrues_from_construction() {
3104        // A primary that boots already-degraded (e.g. `disk+ram` with no
3105        // replica yet) must accrue from construction, not from the first
3106        // observed transition — `new_starting_degraded` seeds the
3107        // degraded state so the first tick's interval counts.
3108        let p = logger_test_policy();
3109        let utilization = StageUtilization::new();
3110        let start = Instant::now();
3111        let mut logger = DegradationLogger::new_starting_degraded(start, &p);
3112
3113        logger.tick(
3114            &p,
3115            &utilization,
3116            true,
3117            start + Duration::from_secs(1),
3118            Duration::from_secs(5),
3119        );
3120        assert_eq!(
3121            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
3122            Duration::from_secs(1).as_nanos() as u64
3123        );
3124    }
3125
3126    #[test]
3127    fn logger_reseed_flushes_in_flight_degraded_accrual() {
3128        // A runtime policy swap re-seeds the logger. On the gate-wait path
3129        // the swap can land after many seconds of wedged-degraded time
3130        // with no intervening tick, so `reseed` must flush that interval
3131        // before resetting — otherwise the whole wedge is silently lost,
3132        // which is exactly the incident the metric exists to measure.
3133        let p = logger_test_policy();
3134        let utilization = StageUtilization::new();
3135        let start = Instant::now();
3136        let sec = Duration::from_secs(1);
3137
3138        let mut logger = DegradationLogger::new_starting_degraded(start, &p);
3139        logger.tick(&p, &utilization, true, start, Duration::from_secs(5));
3140        assert_eq!(utilization.policy_degraded_nanos.load(Ordering::Relaxed), 0);
3141
3142        // 30s wedged-degraded with no tick (frozen spin), then swap.
3143        logger.reseed(&utilization, start + 30 * sec);
3144        assert_eq!(
3145            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
3146            30 * sec.as_nanos() as u64
3147        );
3148
3149        // Post-reseed the logger is healthy-start; a healthy interval
3150        // does not accrue, and `last_tick` was rebased to the swap so
3151        // the next interval isn't double-counted.
3152        logger.tick(
3153            &p,
3154            &utilization,
3155            false,
3156            start + 31 * sec,
3157            Duration::from_secs(5),
3158        );
3159        assert_eq!(
3160            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
3161            30 * sec.as_nanos() as u64
3162        );
3163    }
3164
3165    #[test]
3166    fn logger_starting_degraded_then_first_tick_healthy_counts_construction_interval() {
3167        // `new_starting_degraded` seeds the degraded state, so the
3168        // construction→first-tick interval is attributed degraded even
3169        // when the first observed state is already healthy (the cluster
3170        // recovered before the first tick). Locks this intentional edge.
3171        let p = logger_test_policy();
3172        let utilization = StageUtilization::new();
3173        let start = Instant::now();
3174        let mut logger = DegradationLogger::new_starting_degraded(start, &p);
3175
3176        logger.tick(
3177            &p,
3178            &utilization,
3179            false,
3180            start + Duration::from_secs(2),
3181            Duration::from_secs(5),
3182        );
3183        assert_eq!(
3184            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
3185            Duration::from_secs(2).as_nanos() as u64
3186        );
3187    }
3188
3189    #[test]
3190    fn logger_counter_matches_total_degraded_time_across_flaps() {
3191        // The cumulative counter must equal exactly the wall time spent
3192        // in degraded states across a flap sequence — the guarantee
3193        // `rate(...degraded_seconds_total)` dashboards depend on.
3194        let p = logger_test_policy();
3195        let utilization = StageUtilization::new();
3196        let start = Instant::now();
3197        let step = Duration::from_millis(500);
3198        let mut logger = DegradationLogger::new(start);
3199
3200        // State observed at tick i (fired at start + i*step). The state
3201        // set at tick i holds over [tick i, tick i+1], so degraded wall
3202        // time = the spans where the *prior* tick's state was true:
3203        // [t1,t2], [t2,t3], [t4,t5] = 3 * step.
3204        let states = [false, true, true, false, true, false];
3205        for (i, &s) in states.iter().enumerate() {
3206            logger.tick(
3207                &p,
3208                &utilization,
3209                s,
3210                start + step * i as u32,
3211                Duration::from_secs(5),
3212            );
3213        }
3214
3215        assert_eq!(
3216            utilization.policy_degraded_nanos.load(Ordering::Relaxed),
3217            (3 * step).as_nanos() as u64
3218        );
3219    }
3220
3221    #[test]
3222    fn logger_handles_rapid_flap_without_panic() {
3223        // Drive the logger through 100 alternating flips at 100ms
3224        // each (faster than the 1s flap-hold). The state machine
3225        // must remain coherent — no panics, gauge tracks final
3226        // state, and `pending_logged` doesn't get stuck.
3227        let p = logger_test_policy();
3228        let utilization = StageUtilization::new();
3229        let now = Instant::now();
3230        let mut logger = DegradationLogger::new(now);
3231        let states: Vec<bool> = (0..100u32).map(|i| i.is_multiple_of(2)).collect();
3232        let final_state = drive_logger(
3233            &mut logger,
3234            &utilization,
3235            &p,
3236            now,
3237            &states,
3238            Duration::from_millis(100),
3239        );
3240        // 100 states starting at i=0 → final state is i=99 → odd → false.
3241        assert!(!final_state);
3242    }
3243
3244    #[test]
3245    fn logger_sustained_degraded_eventually_settles() {
3246        // After a sustained-true state, the logger should be in the
3247        // "logged the onset" mode. Drive 5 ticks of degraded=true at
3248        // 500ms intervals — total 2s, well past the 1s flap-hold.
3249        // Last tick should leave gauge=1 and the heartbeat re-emit
3250        // window primed (last_log set).
3251        let p = logger_test_policy();
3252        let utilization = StageUtilization::new();
3253        let now = Instant::now();
3254        let mut logger = DegradationLogger::new(now);
3255        let final_state = drive_logger(
3256            &mut logger,
3257            &utilization,
3258            &p,
3259            now,
3260            &[true; 5],
3261            Duration::from_millis(500),
3262        );
3263        assert!(final_state);
3264    }
3265
3266    #[test]
3267    fn logger_recovery_to_healthy_settles_gauge() {
3268        // Sustained degraded → sustained healthy. Gauge should end at 0.
3269        let p = logger_test_policy();
3270        let utilization = StageUtilization::new();
3271        let now = Instant::now();
3272        let mut logger = DegradationLogger::new_starting_degraded(now, &p);
3273        let mut states = vec![true; 5]; // 2.5s degraded
3274        states.extend(vec![false; 5]); // 2.5s healthy
3275        let final_state = drive_logger(
3276            &mut logger,
3277            &utilization,
3278            &p,
3279            now,
3280            &states,
3281            Duration::from_millis(500),
3282        );
3283        assert!(!final_state);
3284    }
3285
3286    /// Dirty tracking: an entry flag for dedup plus a flat list for
3287    /// iteration, replacing the per-append `HashSet` insert.
3288    mod dirty_tracking {
3289        use super::super::{mark_dirty, unmark_dirty};
3290        use super::flush_sends::entry_for;
3291        use std::os::unix::net::UnixStream;
3292
3293        fn entry() -> super::super::ConnectionEntry {
3294            let (tx, _rx) = UnixStream::pair().expect("socketpair");
3295            let mut e = entry_for(tx, b"payload");
3296            e.dirty = false;
3297            e
3298        }
3299
3300        #[test]
3301        fn repeat_appends_enqueue_a_connection_once() {
3302            let mut e = entry();
3303            let mut dirty = Vec::new();
3304            for _ in 0..8 {
3305                mark_dirty(&mut e, 42, &mut dirty);
3306            }
3307            assert_eq!(dirty, vec![42], "the flag is the dedup");
3308            assert!(e.dirty);
3309        }
3310
3311        #[test]
3312        fn teardown_takes_a_connection_off_the_list() {
3313            let mut a = entry();
3314            let mut b = entry();
3315            let mut dirty = Vec::new();
3316            mark_dirty(&mut a, 1, &mut dirty);
3317            mark_dirty(&mut b, 2, &mut dirty);
3318            unmark_dirty(1, &mut dirty);
3319            assert_eq!(dirty, vec![2], "order of the survivors is preserved");
3320        }
3321    }
3322
3323    /// One append per slot: the encoded payload plus the request's
3324    /// pre-encoded `BatchEnd` terminator, sharing a single cap check and
3325    /// a single dirty marking.
3326    mod append_frames {
3327        use super::super::{AppendOutcome, ConnectionEntry, MAX_SEND_BUF, append_frames};
3328        use super::flush_sends::entry_for;
3329        use std::os::unix::net::UnixStream;
3330        use std::time::Instant;
3331
3332        const TRAILER: &[u8] = &[0xEE, 0xEE];
3333
3334        fn entry(prefill: usize) -> ConnectionEntry {
3335            let (tx, _rx) = UnixStream::pair().expect("socketpair");
3336            let mut e = entry_for(tx, &vec![0u8; prefill]);
3337            e.dirty = false;
3338            e
3339        }
3340
3341        #[test]
3342        fn payload_and_terminator_land_in_one_append() {
3343            let mut e = entry(0);
3344            let mut dirty = Vec::new();
3345            let mut to_remove = Vec::new();
3346            let encoded = [1u8, 2, 3, 4, 0xFF];
3347
3348            let outcome = append_frames(
3349                Some(Ok(4)),
3350                TRAILER,
3351                7,
3352                &mut e,
3353                &encoded,
3354                Instant::now(),
3355                &mut dirty,
3356                &mut to_remove,
3357            );
3358
3359            assert!(matches!(outcome, AppendOutcome::Continue));
3360            assert_eq!(
3361                e.send_buf,
3362                vec![1, 2, 3, 4, 0xEE, 0xEE],
3363                "payload then terminator, nothing past the encoder's length"
3364            );
3365            assert_eq!(dirty, vec![7], "one dirty marking for the pair");
3366            assert!(to_remove.is_empty());
3367        }
3368
3369        #[test]
3370        fn a_slot_with_no_bytes_does_not_dirty_the_connection() {
3371            // `OutputPayload::BatchEnd` mid-request: no payload, no
3372            // terminator. Marking it dirty would queue an empty send.
3373            let mut e = entry(0);
3374            let mut dirty = Vec::new();
3375            let mut to_remove = Vec::new();
3376
3377            let outcome = append_frames(
3378                None,
3379                &[],
3380                7,
3381                &mut e,
3382                &[],
3383                Instant::now(),
3384                &mut dirty,
3385                &mut to_remove,
3386            );
3387
3388            assert!(matches!(outcome, AppendOutcome::Continue));
3389            assert!(e.send_buf.is_empty());
3390            assert!(dirty.is_empty());
3391            assert!(!e.dirty);
3392        }
3393
3394        #[test]
3395        fn the_cap_is_checked_against_both_frames_together() {
3396            // Payload alone fits; payload + terminator does not. The
3397            // connection must be dropped *before* the payload lands —
3398            // never delivered without its terminator.
3399            let mut e = entry(MAX_SEND_BUF - 5);
3400            let mut dirty = Vec::new();
3401            let mut to_remove = Vec::new();
3402            let encoded = [1u8, 2, 3, 4];
3403
3404            let outcome = append_frames(
3405                Some(Ok(4)),
3406                TRAILER,
3407                7,
3408                &mut e,
3409                &encoded,
3410                Instant::now(),
3411                &mut dirty,
3412                &mut to_remove,
3413            );
3414
3415            assert!(matches!(outcome, AppendOutcome::ConnectionDropped));
3416            assert_eq!(to_remove, vec![7]);
3417            assert_eq!(e.send_buf.len(), MAX_SEND_BUF - 5, "no partial append");
3418            assert!(dirty.is_empty(), "a dropped connection is not queued");
3419        }
3420
3421        #[test]
3422        fn an_encode_failure_still_terminates_the_request() {
3423            // The payload is this server's bug; the client still needs
3424            // the terminator or it waits forever for the batch to end.
3425            let mut e = entry(0);
3426            let mut dirty = Vec::new();
3427            let mut to_remove = Vec::new();
3428
3429            let outcome = append_frames(
3430                Some(Err("encode error")),
3431                TRAILER,
3432                7,
3433                &mut e,
3434                &[],
3435                Instant::now(),
3436                &mut dirty,
3437                &mut to_remove,
3438            );
3439
3440            assert!(matches!(outcome, AppendOutcome::Continue));
3441            assert_eq!(e.send_buf, TRAILER);
3442            assert_eq!(dirty, vec![7]);
3443        }
3444    }
3445
3446    /// The consumed path's two flush triggers. The byte threshold bounds
3447    /// a connection's buffered *bytes*; the slot interval bounds their
3448    /// *age*, which is what many-low-rate-client deployments need (2026-08
3449    /// network audit, finding 3).
3450    mod flush_cadence {
3451        use super::super::{FLUSH_BYTES_THRESHOLD, FLUSH_SLOT_INTERVAL, FlushCadence};
3452
3453        #[test]
3454        fn byte_threshold_fires_on_a_single_fat_connection() {
3455            let mut c = FlushCadence::default();
3456            c.on_append(FLUSH_BYTES_THRESHOLD - 1);
3457            assert!(!c.is_due());
3458            c.on_append(FLUSH_BYTES_THRESHOLD);
3459            assert!(c.is_due());
3460        }
3461
3462        #[test]
3463        fn slot_interval_bounds_the_age_of_tiny_buffers() {
3464            // Every connection stays far below an MSS — the byte
3465            // trigger never fires, so before the age bound these
3466            // responses sat in `send_buf` until the ring went idle.
3467            let mut c = FlushCadence::default();
3468            for _ in 0..FLUSH_SLOT_INTERVAL - 1 {
3469                c.on_append(40);
3470                assert!(!c.is_due());
3471            }
3472            c.on_append(40);
3473            assert!(
3474                c.is_due(),
3475                "age bound must fire on the {FLUSH_SLOT_INTERVAL}th slot"
3476            );
3477        }
3478
3479        #[test]
3480        fn flushing_restarts_both_triggers() {
3481            let mut c = FlushCadence::default();
3482            for _ in 0..FLUSH_SLOT_INTERVAL {
3483                c.on_append(FLUSH_BYTES_THRESHOLD);
3484            }
3485            assert!(c.is_due());
3486            c.on_flush();
3487            assert!(
3488                !c.is_due(),
3489                "a flush restarts the byte flag and the counter"
3490            );
3491            // And the age bound counts from the flush, not from start.
3492            for _ in 0..FLUSH_SLOT_INTERVAL - 1 {
3493                c.on_append(1);
3494            }
3495            assert!(!c.is_due());
3496            c.on_append(1);
3497            assert!(c.is_due());
3498        }
3499    }
3500
3501    /// Regression tests for the slow-client head-of-line block fixed in
3502    /// the 2026-08 io_uring audit (docs/internal/io-uring-audit-2026-08.md,
3503    /// finding 1): `flush_sends` used to `submit_and_wait` on SENDs
3504    /// without `MSG_DONTWAIT`, and io_uring holds such a SEND's CQE back
3505    /// until the peer reads — so one zero-window client stalled every
3506    /// client's acks. These use real sockets and a real ring: the defect
3507    /// was kernel-level behaviour a mock would not reproduce.
3508    mod heartbeat {
3509        use super::super::{MAX_SEND_BUF, heartbeat_due};
3510        use super::flush_sends::entry_for;
3511        use std::os::unix::net::UnixStream;
3512        use std::time::{Duration, Instant};
3513
3514        const INTERVAL: Duration = Duration::from_secs(1);
3515        const FRAME_LEN: usize = 13;
3516
3517        /// Backdate `last_send` a full interval so only the receive-
3518        /// ability guards decide. `checked_sub` because a young
3519        /// monotonic clock can't be backdated (same pattern as the
3520        /// flush_sends tests).
3521        fn idle_entry(payload: &[u8]) -> Option<super::super::ConnectionEntry> {
3522            let (tx, _rx) = UnixStream::pair().unwrap();
3523            let mut entry = entry_for(tx, payload);
3524            entry.last_send = Instant::now().checked_sub(INTERVAL * 2)?;
3525            Some(entry)
3526        }
3527
3528        #[test]
3529        fn idle_unblocked_connection_gets_a_heartbeat() {
3530            let Some(entry) = idle_entry(&[]) else {
3531                eprintln!("monotonic clock too young to backdate; skipping");
3532                return;
3533            };
3534            assert!(heartbeat_due(&entry, Instant::now(), INTERVAL, FRAME_LEN));
3535        }
3536
3537        #[test]
3538        fn recently_active_connection_is_skipped() {
3539            let (tx, _rx) = UnixStream::pair().unwrap();
3540            let entry = entry_for(tx, &[]);
3541            assert!(
3542                !heartbeat_due(&entry, Instant::now(), INTERVAL, FRAME_LEN),
3543                "a connection inside the interval must not be pinged"
3544            );
3545        }
3546
3547        /// A blocked peer's socket is full — the frame would only grow
3548        /// `send_buf`. This is one half of the fix for the unbounded
3549        /// heartbeat growth on trickle-reading clients.
3550        #[test]
3551        fn blocked_connection_is_skipped() {
3552            let Some(mut entry) = idle_entry(&[]) else {
3553                eprintln!("monotonic clock too young to backdate; skipping");
3554                return;
3555            };
3556            entry.blocked_since = Some(Instant::now());
3557            assert!(!heartbeat_due(&entry, Instant::now(), INTERVAL, FRAME_LEN));
3558        }
3559
3560        /// The other half: heartbeats respect `MAX_SEND_BUF` like every
3561        /// other append — this was the one path that bypassed the cap.
3562        #[test]
3563        fn append_never_exceeds_the_send_buffer_cap() {
3564            let payload = vec![0u8; MAX_SEND_BUF - FRAME_LEN + 1];
3565            let Some(entry) = idle_entry(&payload) else {
3566                eprintln!("monotonic clock too young to backdate; skipping");
3567                return;
3568            };
3569            assert!(!heartbeat_due(&entry, Instant::now(), INTERVAL, FRAME_LEN));
3570
3571            let payload = vec![0u8; MAX_SEND_BUF - FRAME_LEN];
3572            let Some(entry) = idle_entry(&payload) else {
3573                eprintln!("monotonic clock too young to backdate; skipping");
3574                return;
3575            };
3576            assert!(
3577                heartbeat_due(&entry, Instant::now(), INTERVAL, FRAME_LEN),
3578                "a frame that exactly fits is allowed"
3579            );
3580        }
3581    }
3582
3583    mod flush_sends {
3584        use super::super::{
3585            BLOCKED_RETRY_INTERVAL, BLOCKED_SEND_TIMEOUT, ConnectionEntry, flush_sends,
3586        };
3587        use io_uring::IoUring;
3588        use rustc_hash::FxHashMap;
3589        use std::io::Read as _;
3590        use std::os::unix::io::{AsRawFd, RawFd};
3591        use std::os::unix::net::UnixStream;
3592        use std::time::{Duration, Instant};
3593
3594        pub(super) fn entry_for(stream: UnixStream, payload: &[u8]) -> ConnectionEntry {
3595            ConnectionEntry {
3596                fd: stream.as_raw_fd(),
3597                _owner: Box::new(stream),
3598                send_buf: payload.to_vec(),
3599                last_send: Instant::now(),
3600                blocked_since: None,
3601                last_send_attempt: Instant::now(),
3602                // Every entry these tests build is handed to `flush_sends`
3603                // on the dirty list, so the flag has to agree with it.
3604                dirty: true,
3605            }
3606        }
3607
3608        /// Fill `fd`'s socket send buffer with non-blocking writes until
3609        /// the kernel refuses more — the state a zero-window / non-reading
3610        /// client leaves a connection in.
3611        fn fill_socket(fd: RawFd) {
3612            let junk = [0u8; 4096];
3613            loop {
3614                let n =
3615                    unsafe { libc::send(fd, junk.as_ptr().cast(), junk.len(), libc::MSG_DONTWAIT) };
3616                if n < 0 {
3617                    let err = std::io::Error::last_os_error();
3618                    assert_eq!(
3619                        err.raw_os_error(),
3620                        Some(libc::EAGAIN),
3621                        "unexpected errno while filling socket"
3622                    );
3623                    break;
3624                }
3625            }
3626        }
3627
3628        /// The defect this module exists to prevent: a peer that stops
3629        /// reading must cost the flush nothing, and healthy peers on the
3630        /// same flush must still be delivered. Against the pre-fix code
3631        /// this test never returns.
3632        #[test]
3633        fn slow_peer_does_not_block_flush_and_healthy_peer_is_delivered() {
3634            let mut ring = IoUring::new(64).unwrap();
3635            let (slow_tx, _slow_rx) = UnixStream::pair().unwrap();
3636            let (fast_tx, mut fast_rx) = UnixStream::pair().unwrap();
3637            fill_socket(slow_tx.as_raw_fd());
3638
3639            let mut connections = FxHashMap::default();
3640            connections.insert(1u64, entry_for(slow_tx, &[0xAA; 1024]));
3641            connections.insert(2u64, entry_for(fast_tx, b"hello"));
3642            let mut dirty: Vec<u64> = vec![1, 2];
3643            let mut to_remove = Vec::new();
3644            let mut cqes = Vec::new();
3645
3646            let start = Instant::now();
3647            flush_sends(
3648                &mut ring,
3649                &mut connections,
3650                &mut dirty,
3651                &mut to_remove,
3652                &mut cqes,
3653            );
3654            // Generous bound to stay unflaky under CI load — the fixed
3655            // path completes in microseconds, the broken one only when
3656            // the slow peer reads (here: never).
3657            assert!(
3658                start.elapsed() < Duration::from_secs(1),
3659                "flush blocked on a slow peer"
3660            );
3661
3662            assert!(to_remove.is_empty(), "nobody dropped on first refusal");
3663            let slow = &connections[&1];
3664            assert!(
3665                !slow.send_buf.is_empty(),
3666                "slow peer keeps its undelivered bytes"
3667            );
3668            assert!(slow.blocked_since.is_some(), "slow peer marked blocked");
3669            assert!(dirty.contains(&1), "slow peer stays dirty for retry");
3670            assert!(slow.dirty, "and its flag agrees with the list");
3671
3672            assert!(connections[&2].send_buf.is_empty(), "healthy peer drained");
3673            assert!(!dirty.contains(&2), "healthy peer leaves the dirty set");
3674            // Flag and list must clear together: a stale `true` would
3675            // keep the connection off the list forever, since `mark_dirty`
3676            // only pushes when the flag is down.
3677            assert!(!connections[&2].dirty, "and its flag clears with it");
3678            fast_rx
3679                .set_read_timeout(Some(Duration::from_secs(1)))
3680                .unwrap();
3681            let mut buf = [0u8; 5];
3682            fast_rx.read_exact(&mut buf).unwrap();
3683            assert_eq!(&buf, b"hello", "healthy peer's bytes reached the socket");
3684        }
3685
3686        /// A payload larger than the socket buffer must be partially
3687        /// delivered, the remainder kept, and the connection treated as
3688        /// blocked (an immediate retry would only report EAGAIN).
3689        #[test]
3690        fn partial_send_keeps_remainder_and_marks_blocked() {
3691            let mut ring = IoUring::new(64).unwrap();
3692            let (tx, _rx) = UnixStream::pair().unwrap();
3693            // Shrink the send buffer so the payload cannot fit in one go.
3694            let sz: libc::c_int = 8192;
3695            let rc = unsafe {
3696                libc::setsockopt(
3697                    tx.as_raw_fd(),
3698                    libc::SOL_SOCKET,
3699                    libc::SO_SNDBUF,
3700                    (&raw const sz).cast(),
3701                    size_of::<libc::c_int>() as libc::socklen_t,
3702                )
3703            };
3704            assert_eq!(rc, 0, "setsockopt(SO_SNDBUF) failed");
3705            let payload = vec![0x55u8; 512 * 1024];
3706
3707            // Enter the flush as an already-blocked peer with a stale
3708            // (but unexpired) block timestamp and an expired pacing
3709            // window, so the partial delivery below must *restart* the
3710            // blocked clock — the behaviour that keeps a slow-but-
3711            // draining client alive past BLOCKED_SEND_TIMEOUT.
3712            let Some(stale_block) = Instant::now().checked_sub(BLOCKED_SEND_TIMEOUT / 2) else {
3713                eprintln!("monotonic clock too young to backdate; skipping");
3714                return;
3715            };
3716            let mut entry = entry_for(tx, &payload);
3717            entry.blocked_since = Some(stale_block);
3718            entry.last_send_attempt = stale_block;
3719
3720            let mut connections = FxHashMap::default();
3721            connections.insert(1u64, entry);
3722            let mut dirty: Vec<u64> = vec![1];
3723            let mut to_remove = Vec::new();
3724            let mut cqes = Vec::new();
3725
3726            flush_sends(
3727                &mut ring,
3728                &mut connections,
3729                &mut dirty,
3730                &mut to_remove,
3731                &mut cqes,
3732            );
3733
3734            let entry = &connections[&1];
3735            assert!(entry.send_buf.len() < payload.len(), "some bytes were sent");
3736            assert!(!entry.send_buf.is_empty(), "remainder kept for retry");
3737            let refreshed = entry.blocked_since.expect("partial send marks blocked");
3738            assert!(
3739                refreshed > stale_block,
3740                "forward progress restarts the blocked clock"
3741            );
3742            assert!(dirty.contains(&1));
3743            assert!(to_remove.is_empty());
3744        }
3745
3746        /// A connection blocked past `BLOCKED_SEND_TIMEOUT` is dropped —
3747        /// the guard for clients that stop reading during quiet periods,
3748        /// where `MAX_SEND_BUF` never trips because nothing accumulates.
3749        #[test]
3750        fn blocked_past_timeout_is_dropped() {
3751            let mut ring = IoUring::new(64).unwrap();
3752            let (tx, _rx) = UnixStream::pair().unwrap();
3753            fill_socket(tx.as_raw_fd());
3754
3755            let mut entry = entry_for(tx, &[1u8; 64]);
3756            // `checked_sub`: a plain `-` panics when the monotonic clock
3757            // is younger than the timeout (fresh-boot CI microVMs).
3758            let Some(expired) = Instant::now().checked_sub(BLOCKED_SEND_TIMEOUT) else {
3759                eprintln!("monotonic clock too young to backdate; skipping");
3760                return;
3761            };
3762            entry.blocked_since = Some(expired);
3763            let mut connections = FxHashMap::default();
3764            connections.insert(7u64, entry);
3765            let mut dirty: Vec<u64> = vec![7];
3766            let mut to_remove = Vec::new();
3767            let mut cqes = Vec::new();
3768
3769            flush_sends(
3770                &mut ring,
3771                &mut connections,
3772                &mut dirty,
3773                &mut to_remove,
3774                &mut cqes,
3775            );
3776
3777            assert_eq!(to_remove, vec![7], "timed-out peer queued for removal");
3778            assert!(!dirty.contains(&7), "dropped peer leaves the dirty set");
3779        }
3780
3781        /// A blocked connection retried a moment ago is skipped — the
3782        /// pacing that keeps a busy-spinning stage from probing a full
3783        /// socket once per loop iteration.
3784        #[test]
3785        fn blocked_retry_is_paced() {
3786            let mut ring = IoUring::new(64).unwrap();
3787            let (tx, _rx) = UnixStream::pair().unwrap();
3788            fill_socket(tx.as_raw_fd());
3789
3790            let mut entry = entry_for(tx, &[1u8; 64]);
3791            entry.blocked_since = Some(Instant::now());
3792            // A future attempt timestamp makes the pacing skip
3793            // deterministic: `duration_since` saturates to zero, so no
3794            // CI-load preemption between here and the flush's own clock
3795            // read can open the 100 µs window and flake the assertion.
3796            entry.last_send_attempt = Instant::now() + Duration::from_secs(1);
3797            let attempted_at = entry.last_send_attempt;
3798            let mut connections = FxHashMap::default();
3799            connections.insert(3u64, entry);
3800            let mut dirty: Vec<u64> = vec![3];
3801            let mut to_remove = Vec::new();
3802            let mut cqes = Vec::new();
3803
3804            flush_sends(
3805                &mut ring,
3806                &mut connections,
3807                &mut dirty,
3808                &mut to_remove,
3809                &mut cqes,
3810            );
3811
3812            assert_eq!(
3813                connections[&3].last_send_attempt, attempted_at,
3814                "no SEND submitted inside the pacing window"
3815            );
3816            assert!(dirty.contains(&3), "paced peer stays dirty");
3817            assert!(to_remove.is_empty());
3818
3819            // Outside the pacing window the retry happens: backdate the
3820            // last attempt and verify the attempt timestamp advances.
3821            let Some(backdated) = Instant::now().checked_sub(BLOCKED_RETRY_INTERVAL * 2) else {
3822                eprintln!("monotonic clock too young to backdate; skipping");
3823                return;
3824            };
3825            connections.get_mut(&3).unwrap().last_send_attempt = backdated;
3826            flush_sends(
3827                &mut ring,
3828                &mut connections,
3829                &mut dirty,
3830                &mut to_remove,
3831                &mut cqes,
3832            );
3833            assert!(
3834                connections[&3].last_send_attempt > backdated,
3835                "retry submitted once the pacing window elapsed"
3836            );
3837        }
3838
3839        /// Review finding F1 (io-uring-audit-2026-08.md): a
3840        /// response-initiated drop must tear down the *whole* socket and
3841        /// release the `max_connections` permit. The reader holds its
3842        /// own dup of the socket, so merely dropping this stage's entry
3843        /// (closing our dup) leaves the socket open — the EOF below must
3844        /// arrive *despite* a live second dup, which only shutdown(2)
3845        /// achieves.
3846        #[test]
3847        fn teardown_shuts_the_socket_despite_other_dups_and_releases_the_permit() {
3848            use std::sync::atomic::{AtomicU64, Ordering};
3849
3850            let (tx, mut peer) = UnixStream::pair().unwrap();
3851            // Simulates the reader's half: still open across the drop.
3852            let reader_dup = tx.try_clone().unwrap();
3853            let entry = entry_for(tx, &[]);
3854            let active = AtomicU64::new(1);
3855
3856            super::super::teardown_dropped(entry, &active);
3857
3858            assert_eq!(active.load(Ordering::Relaxed), 0, "permit released");
3859            peer.set_read_timeout(Some(Duration::from_secs(1))).unwrap();
3860            let mut buf = [0u8; 1];
3861            assert_eq!(
3862                peer.read(&mut buf).expect("EOF, not a timeout"),
3863                0,
3864                "peer observes EOF even though another dup is still open"
3865            );
3866            drop(reader_dup);
3867        }
3868    }
3869
3870    /// The stage learns of a connection from its control channel and of
3871    /// that connection's replies from the output ring. A reply always
3872    /// trails its `Connected`, so reading the ring before draining the
3873    /// channel is enough for every slot read to find its connection.
3874    /// Drained first, the stage could read the ring after a `Connected`
3875    /// it had not seen — the accept loop queues that event only after
3876    /// the client holds `ServerReady`, so a first request is often on
3877    /// the wire before the stage has heard of its connection — and drop
3878    /// the reply. Seen as a first reply lost on a fresh node under
3879    /// full-suite load. The seam sits right
3880    /// after the drain: with the drain after the read that is harmless,
3881    /// with the drain before it the seam is the gap, and a `Connected`
3882    /// landing there is missed by the read that follows. Holding the
3883    /// stage at the seam makes the interleaving the test's rather than
3884    /// the scheduler's.
3885    mod registration_race {
3886        use std::io::{Read, Write};
3887        use std::os::unix::io::AsRawFd;
3888        use std::os::unix::net::UnixStream;
3889        use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
3890        use std::sync::{Arc, mpsc};
3891        use std::thread;
3892        use std::time::Duration;
3893
3894        use counter_server::{Counter, CounterQuery, CounterReport, ResponseEncoder};
3895        use melin_pipeline::ring::DisruptorBuilder;
3896        use melin_pipeline::wait::WaitStrategy;
3897        use melin_transport_core::fence::FenceState;
3898        use melin_transport_core::pipeline::{OutputPayload, OutputSlot, StageUtilization};
3899        use melin_transport_core::{DurableWireSeqCursor, WireSeq};
3900        use melin_wire_protocol::blocking::BlockingFrameWriter;
3901
3902        use crate::ControlEvent;
3903        use crate::ack_policy::AckPolicy;
3904        use crate::response::{Response, run};
3905
3906        /// `CounterReport::Ack` on the wire: len(4) + tag(1) + value(8).
3907        const FRAME_LEN: usize = 13;
3908        /// Long enough that a genuine loss is distinguishable from
3909        /// scheduler noise on a loaded box, short enough to fail in
3910        /// bounded time.
3911        const READ_TIMEOUT: Duration = Duration::from_secs(5);
3912
3913        #[test]
3914        fn a_reply_that_lands_with_its_connection_is_delivered() {
3915            let (mut producer, mut consumers) =
3916                DisruptorBuilder::<OutputSlot<CounterReport, CounterQuery>>::new(64)
3917                    .add_consumer()
3918                    .build(WaitStrategy::SpinThenYield);
3919            let consumer = consumers.pop().expect("one consumer was requested");
3920
3921            let (server_sock, mut client_sock) = UnixStream::pair().expect("socketpair");
3922            let server_fd = server_sock.as_raw_fd();
3923            let writer = BlockingFrameWriter::new(Box::new(server_sock) as Box<dyn Write + Send>);
3924            client_sock
3925                .set_read_timeout(Some(READ_TIMEOUT))
3926                .expect("set read timeout");
3927
3928            // The stage reports reaching the gap on `parked` and waits on
3929            // `resume` — once, on its first iteration.
3930            let (parked_tx, parked_rx) = mpsc::channel::<()>();
3931            let (resume_tx, resume_rx) = mpsc::channel::<()>();
3932            let mut held = false;
3933            let pause = move || {
3934                if !held {
3935                    held = true;
3936                    parked_tx.send(()).expect("the test is waiting");
3937                    resume_rx.recv().expect("the test resumes the stage");
3938                }
3939            };
3940
3941            let (control_tx, control_rx) = mpsc::channel();
3942            let shutdown = AtomicBool::new(false);
3943            let config = Response::<Counter> {
3944                // The journal is already past the one event, so the gate
3945                // is open and delivery hinges on registration alone.
3946                journal_persisted_wire_seq: DurableWireSeqCursor::detached(WireSeq::new(1)),
3947                ack_policy: Arc::new(AtomicU8::new(AckPolicy::Disk.as_u8())),
3948                replication_metrics: None,
3949                replica_active: None,
3950                heartbeat_interval: None,
3951                wait: WaitStrategy::SpinThenYield,
3952                utilization: Arc::new(StageUtilization::default()),
3953                encoder: Arc::new(ResponseEncoder),
3954                fence_state: Arc::new(FenceState::new(0)),
3955                active_connections: Arc::new(AtomicU64::new(0)),
3956                // No reader: nothing is ever refused.
3957                refusals: crate::halt::refusal_channel(Arc::new(
3958                    melin_pipeline::padding::CachePadded::new(AtomicU64::new(0)),
3959                ))
3960                .1,
3961                pause_after_control_drain: Some(Box::new(pause)),
3962            };
3963
3964            thread::scope(|scope| {
3965                let stage = scope.spawn(|| run::<Counter>(consumer, control_rx, config, &shutdown));
3966
3967                // The stage has drained an empty control channel and is
3968                // held at the seam. Now both arrive: read after drain,
3969                // the next iteration finds the slot and then its
3970                // connection; drain after read, the read would find the
3971                // slot and the connection would still be in the channel.
3972                parked_rx.recv().expect("the stage reaches the seam");
3973                control_tx
3974                    .send(ControlEvent::Connected {
3975                        connection_id: 1,
3976                        fd: server_fd,
3977                        writer,
3978                    })
3979                    .expect("stage is running");
3980                producer.publish(OutputSlot {
3981                    connection_id: 1,
3982                    wire_seq: 1,
3983                    payload: OutputPayload::Report(CounterReport::Ack { new_value: 7 }),
3984                    // No trailing `BatchEnd`: one frame on the wire.
3985                    is_last_in_request: false,
3986                    ..Default::default()
3987                });
3988                resume_tx.send(()).expect("the stage is parked");
3989
3990                let mut frame = [0u8; FRAME_LEN];
3991                let delivered = client_sock.read_exact(&mut frame);
3992                shutdown.store(true, Ordering::Relaxed);
3993                stage.join().expect("stage panicked");
3994                delivered.expect(
3995                    "the reply was dropped: the control channel was drained before the ring was read",
3996                );
3997                assert_eq!(
3998                    u64::from_le_bytes(frame[5..].try_into().expect("8 bytes")),
3999                    7,
4000                    "the ack carries the counter's value"
4001                );
4002            });
4003        }
4004    }
4005
4006    /// A write refused at ingress is answered in request order: after the
4007    /// replies to everything published before it — even while one of
4008    /// those is held by the durability gate — and before anything
4009    /// published after it. Replies carry no request id, so a refusal sent
4010    /// early would be paired with the wrong request.
4011    mod refusal_order {
4012        use std::io::{ErrorKind, Read, Write};
4013        use std::os::unix::io::AsRawFd;
4014        use std::os::unix::net::UnixStream;
4015        use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering};
4016        use std::sync::{Arc, mpsc};
4017        use std::thread;
4018        use std::time::Duration;
4019
4020        use counter_server::{
4021            Counter, CounterQuery, CounterReport, ResponseEncoder, TAG_RESP_ACK, TAG_RESP_REJECTED,
4022        };
4023        use melin_pipeline::padding::CachePadded;
4024        use melin_pipeline::ring::DisruptorBuilder;
4025        use melin_pipeline::wait::WaitStrategy;
4026        use melin_transport_core::fence::FenceState;
4027        use melin_transport_core::pipeline::{OutputPayload, OutputSlot, StageUtilization};
4028        use melin_transport_core::{DurableWireSeqCursor, WireSeq};
4029        use melin_wire_protocol::blocking::BlockingFrameWriter;
4030        use melin_wire_protocol::control_codec::TAG_BATCH_END;
4031
4032        use crate::ControlEvent;
4033        use crate::ack_policy::AckPolicy;
4034        use crate::halt::{Refusal, RefusalQueue, refusal_channel};
4035        use crate::response::{Response, run};
4036
4037        /// Bounds a read that must succeed; generous for a loaded box.
4038        const READ_TIMEOUT: Duration = Duration::from_secs(5);
4039        /// How long the test watches for a frame that must not arrive yet.
4040        const QUIET: Duration = Duration::from_millis(100);
4041
4042        /// Read one frame and return its tag.
4043        pub(super) fn read_tag(sock: &mut UnixStream) -> std::io::Result<u8> {
4044            let mut len = [0u8; 4];
4045            sock.read_exact(&mut len)?;
4046            let mut body = vec![0u8; u32::from_le_bytes(len) as usize];
4047            sock.read_exact(&mut body)?;
4048            Ok(body[0])
4049        }
4050
4051        /// Read one frame's tag into `seen`; false if none arrived.
4052        fn read_into(sock: &mut UnixStream, seen: &mut Vec<u8>) -> bool {
4053            match read_tag(sock) {
4054                Ok(tag) => {
4055                    seen.push(tag);
4056                    true
4057                }
4058                Err(_) => false,
4059            }
4060        }
4061
4062        #[test]
4063        fn a_refusal_waits_for_earlier_replies_and_precedes_later_ones() {
4064            let (mut producer, mut consumers) =
4065                DisruptorBuilder::<OutputSlot<CounterReport, CounterQuery>>::new(64)
4066                    .add_consumer()
4067                    .build(WaitStrategy::SpinThenYield);
4068            let consumer = consumers.pop().expect("one consumer was requested");
4069
4070            let (connected, mut client_sock) = connection(1);
4071
4072            // The test plays the matching stage: it publishes the one
4073            // event's reply, then moves the progress past it.
4074            let matching_progress = Arc::new(CachePadded::new(AtomicU64::new(0)));
4075            let (mut refusals, refusal_rx) = refusal_channel(Arc::clone(&matching_progress));
4076            // The event's gate is shut until the test opens it.
4077            let journal_cursor = DurableWireSeqCursor::detached(WireSeq::new(0));
4078
4079            let (control_tx, control_rx) = mpsc::channel();
4080            control_tx.send(connected).expect("channel open");
4081            let shutdown = AtomicBool::new(false);
4082            let config = config(
4083                journal_cursor.clone(),
4084                Arc::new(FenceState::new(0)),
4085                refusal_rx,
4086                None,
4087            );
4088
4089            // What a reader does for: refused write, accepted write,
4090            // refused write — refusals visible before the event published
4091            // after them. All in place before the stage starts, so its
4092            // first iteration reads the event with both refusals behind
4093            // it: the first goes out ahead of the event (the batch path),
4094            // the second once the ring is empty (the idle path).
4095            refusals.try_send(refusal(1, 0)).expect("queue has room");
4096            refusals.try_send(refusal(1, 1)).expect("queue has room");
4097            refusals.flush();
4098            producer.publish(OutputSlot {
4099                connection_id: 1,
4100                input_seq: 0,
4101                wire_seq: 1,
4102                payload: OutputPayload::Report(CounterReport::Ack { new_value: 7 }),
4103                is_last_in_request: true,
4104                ..Default::default()
4105            });
4106            matching_progress.get().store(1, Ordering::Release);
4107
4108            thread::scope(|scope| {
4109                let stage = scope.spawn(|| run::<Counter>(consumer, control_rx, config, &shutdown));
4110
4111                let mut seen = Vec::new();
4112
4113                // The first refusal came before the held event: it goes
4114                // out while the gate is shut.
4115                let first = read_into(&mut client_sock, &mut seen)
4116                    && read_into(&mut client_sock, &mut seen);
4117
4118                // The second came after it: nothing more until the gate
4119                // opens.
4120                client_sock
4121                    .set_read_timeout(Some(QUIET))
4122                    .expect("set read timeout");
4123                let mut probe = [0u8; 1];
4124                // Anything but a timeout — bytes, or the stage closing the
4125                // socket — means something went out early.
4126                let early = !matches!(
4127                    client_sock.read(&mut probe),
4128                    Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut)
4129                );
4130                client_sock
4131                    .set_read_timeout(Some(READ_TIMEOUT))
4132                    .expect("set read timeout");
4133
4134                journal_cursor.store(WireSeq::new(1));
4135                let rest = !early && (0..4).all(|_| read_into(&mut client_sock, &mut seen));
4136
4137                // Stop the stage before asserting, so a failure cannot
4138                // leave the scope waiting on a spinning thread.
4139                shutdown.store(true, Ordering::Relaxed);
4140                stage.join().expect("stage panicked");
4141
4142                assert!(first, "the refusal ahead of the held event never arrived");
4143                assert!(
4144                    !early,
4145                    "the later refusal overtook the reply the gate was holding"
4146                );
4147                assert!(rest, "replies missing after the gate opened: {seen:x?}");
4148                assert_eq!(
4149                    seen,
4150                    [
4151                        TAG_RESP_REJECTED,
4152                        TAG_BATCH_END,
4153                        TAG_RESP_ACK,
4154                        TAG_BATCH_END,
4155                        TAG_RESP_REJECTED,
4156                        TAG_BATCH_END,
4157                    ],
4158                    "replies out of request order"
4159                );
4160            });
4161        }
4162
4163        /// A refusal reaches the stage only with its connection registered.
4164        /// The reader refuses a new connection's first request as soon as
4165        /// that connection's `Connected` is queued, so a refusal made
4166        /// visible while the stage is past its control drain can belong to
4167        /// a connection the stage has not heard of. Released in that
4168        /// iteration it would be dropped, and the client left without a
4169        /// reply.
4170        ///
4171        /// The seam holds the stage after its drain twice. The first hold
4172        /// lands connection 1's refusal, which the next iteration's idle
4173        /// path is then due to release; the second hold, in that iteration,
4174        /// lands connection 2 and its refusal just ahead of the release.
4175        #[test]
4176        fn a_refusal_is_not_released_ahead_of_its_connection() {
4177            let (_producer, mut consumers) =
4178                DisruptorBuilder::<OutputSlot<CounterReport, CounterQuery>>::new(64)
4179                    .add_consumer()
4180                    .build(WaitStrategy::SpinThenYield);
4181            let consumer = consumers.pop().expect("one consumer was requested");
4182            let (connected_1, mut client_1) = connection(1);
4183            let (connected_2, mut client_2) = connection(2);
4184
4185            // Nothing is ever published, so no refusal waits on an event.
4186            let progress = Arc::new(CachePadded::new(AtomicU64::new(0)));
4187            let (mut refusals, refusal_rx) = refusal_channel(progress);
4188            let (control_tx, control_rx) = mpsc::channel();
4189            control_tx.send(connected_1).expect("channel open");
4190
4191            let (parked_tx, parked_rx) = mpsc::channel::<()>();
4192            let (resume_tx, resume_rx) = mpsc::channel::<()>();
4193            let mut holds = 0;
4194            let pause = move || {
4195                if holds < 2 {
4196                    holds += 1;
4197                    parked_tx.send(()).expect("the test is waiting");
4198                    resume_rx.recv().expect("the test resumes the stage");
4199                }
4200            };
4201            let shutdown = AtomicBool::new(false);
4202            let config = config(
4203                DurableWireSeqCursor::detached(WireSeq::new(0)),
4204                Arc::new(FenceState::new(0)),
4205                refusal_rx,
4206                Some(Box::new(pause)),
4207            );
4208
4209            thread::scope(|scope| {
4210                let stage = scope.spawn(|| run::<Counter>(consumer, control_rx, config, &shutdown));
4211
4212                parked_rx.recv().expect("the stage reaches the seam");
4213                refusals.try_send(refusal(1, 0)).expect("queue has room");
4214                refusals.flush();
4215                resume_tx.send(()).expect("the stage is parked");
4216
4217                parked_rx.recv().expect("the stage reaches the seam again");
4218                control_tx.send(connected_2).expect("stage is running");
4219                refusals.try_send(refusal(2, 0)).expect("queue has room");
4220                refusals.flush();
4221                resume_tx.send(()).expect("the stage is parked");
4222
4223                let mut seen_1 = Vec::new();
4224                let mut seen_2 = Vec::new();
4225                let got_1 = (0..2).all(|_| read_into(&mut client_1, &mut seen_1));
4226                let got_2 = (0..2).all(|_| read_into(&mut client_2, &mut seen_2));
4227                shutdown.store(true, Ordering::Relaxed);
4228                stage.join().expect("stage panicked");
4229
4230                assert!(got_1, "connection 1's refusal never arrived: {seen_1:x?}");
4231                assert!(
4232                    got_2,
4233                    "connection 2's refusal was dropped: released before its connection registered"
4234                );
4235                for seen in [seen_1, seen_2] {
4236                    assert_eq!(seen, [TAG_RESP_REJECTED, TAG_BATCH_END]);
4237                }
4238            });
4239        }
4240
4241        /// A registered connection: its `Connected` event, and the client's
4242        /// end of the socket.
4243        pub(super) fn connection(connection_id: u64) -> (ControlEvent, UnixStream) {
4244            let (server_sock, client_sock) = UnixStream::pair().expect("socketpair");
4245            client_sock
4246                .set_read_timeout(Some(READ_TIMEOUT))
4247                .expect("set read timeout");
4248            let event = ControlEvent::Connected {
4249                connection_id,
4250                fd: server_sock.as_raw_fd(),
4251                writer: BlockingFrameWriter::new(Box::new(server_sock) as Box<dyn Write + Send>),
4252            };
4253            (event, client_sock)
4254        }
4255
4256        fn refusal(connection_id: u64, input_seq: u64) -> Refusal<CounterReport> {
4257            Refusal {
4258                connection_id,
4259                input_seq,
4260                report: CounterReport::Rejected,
4261            }
4262        }
4263
4264        /// A stage gating on `journal_cursor` under `disk`, fed `refusals`.
4265        pub(super) fn config(
4266            journal_cursor: DurableWireSeqCursor,
4267            fence_state: Arc<FenceState>,
4268            refusals: RefusalQueue<CounterReport>,
4269            pause_after_control_drain: Option<Box<dyn FnMut() + Send>>,
4270        ) -> Response<Counter> {
4271            Response::<Counter> {
4272                journal_persisted_wire_seq: journal_cursor,
4273                ack_policy: Arc::new(AtomicU8::new(AckPolicy::Disk.as_u8())),
4274                replication_metrics: None,
4275                replica_active: None,
4276                heartbeat_interval: None,
4277                wait: WaitStrategy::SpinThenYield,
4278                utilization: Arc::new(StageUtilization::default()),
4279                encoder: Arc::new(ResponseEncoder),
4280                fence_state,
4281                active_connections: Arc::new(AtomicU64::new(0)),
4282                refusals,
4283                pause_after_control_drain,
4284            }
4285        }
4286    }
4287
4288    /// A node stopped while a reply is held by the ack policy. The gate
4289    /// may never open — every replica gone under a policy that needs one —
4290    /// and the shutdown sequence joins the stage without a timeout, so the
4291    /// stage must leave the gate on shutdown. It leaves without sending
4292    /// the held reply, which the policy never confirmed. A fence co-sets
4293    /// shutdown and takes the same exit.
4294    mod stop_while_gated {
4295        use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
4296        use std::sync::{Arc, mpsc};
4297        use std::thread;
4298        use std::time::{Duration, Instant};
4299
4300        use counter_server::{Counter, CounterQuery, CounterReport, TAG_RESP_ACK};
4301        use melin_pipeline::padding::CachePadded;
4302        use melin_pipeline::ring::DisruptorBuilder;
4303        use melin_pipeline::wait::WaitStrategy;
4304        use melin_transport_core::fence::FenceState;
4305        use melin_transport_core::pipeline::{OutputPayload, OutputSlot};
4306        use melin_transport_core::{DurableWireSeqCursor, WireSeq};
4307        use melin_wire_protocol::control_codec::TAG_BATCH_END;
4308
4309        use super::refusal_order::{config, connection, read_tag};
4310        use crate::halt::refusal_channel;
4311        use crate::response::run;
4312
4313        /// How long the stage gets to exit once told to; generous for a
4314        /// loaded box.
4315        const EXIT_TIMEOUT: Duration = Duration::from_secs(10);
4316
4317        fn stop_while_gated(fenced: bool) {
4318            let (mut producer, mut consumers) =
4319                DisruptorBuilder::<OutputSlot<CounterReport, CounterQuery>>::new(64)
4320                    .add_consumer()
4321                    .build(WaitStrategy::SpinThenYield);
4322            let consumer = consumers.pop().expect("one consumer was requested");
4323            let (connected, mut client) = connection(1);
4324            let (control_tx, control_rx) = mpsc::channel();
4325            control_tx.send(connected).expect("channel open");
4326
4327            // The journal confirms the first event and never the second.
4328            let journal_cursor = DurableWireSeqCursor::detached(WireSeq::new(1));
4329            let fence = Arc::new(FenceState::new(0));
4330            let refusals = refusal_channel(Arc::new(CachePadded::new(AtomicU64::new(0)))).1;
4331            let config = config(journal_cursor, Arc::clone(&fence), refusals, None);
4332            for wire_seq in [1, 2] {
4333                producer.publish(OutputSlot {
4334                    connection_id: 1,
4335                    input_seq: wire_seq - 1,
4336                    wire_seq,
4337                    payload: OutputPayload::Report(CounterReport::Ack {
4338                        new_value: wire_seq,
4339                    }),
4340                    is_last_in_request: true,
4341                    ..Default::default()
4342                });
4343            }
4344
4345            // Not a scoped thread: a stage that never leaves the gate
4346            // would hang a scoped join, and that hang is the failure this
4347            // test exists to catch. The flag is leaked for the same
4348            // reason — the thread may outlive the test, and dies with the
4349            // process.
4350            let shutdown: &'static AtomicBool = Box::leak(Box::new(AtomicBool::new(false)));
4351            let stage =
4352                thread::spawn(move || run::<Counter>(consumer, control_rx, config, shutdown));
4353
4354            // The confirmed reply goes out before the stage blocks on the
4355            // gate.
4356            let mut seen = Vec::new();
4357            for _ in 0..2 {
4358                seen.push(read_tag(&mut client).expect("the confirmed reply arrives"));
4359            }
4360            assert_eq!(seen, [TAG_RESP_ACK, TAG_BATCH_END]);
4361
4362            if fenced {
4363                assert!(fence.fence(), "first latch");
4364            }
4365            shutdown.store(true, Ordering::Relaxed);
4366            let deadline = Instant::now() + EXIT_TIMEOUT;
4367            while !stage.is_finished() {
4368                assert!(
4369                    Instant::now() < deadline,
4370                    "the stage did not leave the gate on shutdown"
4371                );
4372                thread::sleep(Duration::from_millis(10));
4373            }
4374            stage.join().expect("stage panicked");
4375
4376            // The held reply never went out: the stage closed the
4377            // connection with nothing more on it.
4378            assert!(
4379                read_tag(&mut client).is_err(),
4380                "a reply the policy never confirmed was sent"
4381            );
4382        }
4383
4384        #[test]
4385        fn a_stopped_stage_leaves_the_gate_and_drops_the_held_reply() {
4386            stop_while_gated(false);
4387        }
4388
4389        #[test]
4390        fn a_fenced_stage_leaves_the_gate_and_drops_the_held_reply() {
4391            stop_while_gated(true);
4392        }
4393    }
4394}