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