Skip to main content

sozu_lib/protocol/udp/
manager.rs

1//! The sans-io [`UdpManager`]: admission, flow table, LB request, timers.
2//!
3//! `UdpManager` owns the flow table (`HashMap<FlowKey, FlowId>` over a
4//! `slab::Slab<UdpFlow>`), admission ("allocate nothing for unknown / over-cap
5//! / invalid datagrams"), the flow-table cap and shedding, pluggable flow-key
6//! extraction ([`FlowKeyExtractor`]), the LB-selection *request* for new flows
7//! (it emits [`Output::SelectBackend`]; the shell does the actual LB), and the
8//! timer scheduling: a **single armed manager-wide deadline** plus per-flow
9//! **generation tokens** so a stale expiry can never close a refreshed flow.
10//!
11//! Pure: every entry point that depends on time takes `now: Instant`; the hash
12//! seed is injected at construction. The shell drives the manager with
13//! [`ManagerInput`] and drains [`Output`] via [`poll_output`].
14
15use std::{
16    collections::{HashMap, VecDeque},
17    hash::{Hash, Hasher},
18    net::SocketAddr,
19    time::Instant,
20};
21
22use slab::Slab;
23
24use crate::protocol::udp::{
25    ClusterConfig, ConfigEvent, DropReason, FlowId, FlowKey, ManagerInput, MetricEvent, Output,
26    Transmit,
27    flow::{CloseReason, FlowPhase, UdpFlow},
28    proxy_protocol::prepend_dgram_header,
29};
30
31/// Extracts a [`FlowKey`] from an admitted client datagram. The default
32/// [`SourceTupleExtractor`] keys on the real client source address (2-tuple
33/// source-IP or 4-tuple source-IP+port per cluster config). The trait is the
34/// only seam for alternative keying (e.g. a QUIC-CID extractor, a non-goal);
35/// the 4-tuple impl is the only one in scope.
36pub trait FlowKeyExtractor {
37    /// Compute the flow key for a datagram from `src`. Returns `None` to reject
38    /// the datagram (the manager then emits `Drop(Invalid)` and allocates
39    /// nothing). `cfg` is the listener's active cluster config.
40    fn flow_key(&self, src: SocketAddr, payload: &[u8], cfg: &ClusterConfig) -> Option<FlowKey>;
41}
42
43/// The in-scope flow-key extractor: keys on the real client source address,
44/// honouring the cluster's `affinity_with_port` knob (4-tuple vs 2-tuple).
45#[derive(Clone, Copy, Debug, Default)]
46pub struct SourceTupleExtractor;
47
48impl FlowKeyExtractor for SourceTupleExtractor {
49    fn flow_key(&self, src: SocketAddr, payload: &[u8], cfg: &ClusterConfig) -> Option<FlowKey> {
50        // "Silence is a virtue": an empty datagram is not a valid flow trigger.
51        if payload.is_empty() {
52            return None;
53        }
54        Some(FlowKey::from_src(src, cfg.affinity_with_port))
55    }
56}
57
58/// The pure UDP flow manager. Generic over the flow-key extractor so the seam
59/// stays type-checked; the shell instantiates it with [`SourceTupleExtractor`].
60pub struct UdpManager<E: FlowKeyExtractor = SourceTupleExtractor> {
61    /// `FlowKey -> FlowId` lookup for tracked-flow reuse (two-tier selection).
62    table: HashMap<FlowKey, FlowId>,
63    /// Slab of admitted flows; the slot index is the [`FlowId`].
64    flows: Slab<UdpFlow>,
65    /// Flow-table cap. New flows beyond this are shed (drop + metric).
66    max_flows: usize,
67    /// Maximum accepted rx datagram size; larger datagrams are dropped as
68    /// truncated.
69    max_rx_datagram_size: usize,
70    /// Active cluster routing + per-cluster knobs for *new* flows.
71    cluster: ClusterConfig,
72    /// Per-worker hash seed, injected once at construction; persisted across
73    /// reconfig for stable affinity.
74    hash_seed: u64,
75    /// Pluggable flow-key extractor.
76    extractor: E,
77    /// Draining: admit no new flows; let existing ones reach teardown.
78    draining: bool,
79
80    /// FIFO of outputs the shell drains via [`poll_output`].
81    outputs: VecDeque<Output>,
82    /// The single armed manager-wide deadline currently reflected to the shell
83    /// via the last `ArmTimer`. `None` means no timer is armed.
84    armed_deadline: Option<Instant>,
85
86    /// High-water mark of every `max_flows` cap ever set (construction +
87    /// `SetMaxFlows`). The live cap can be shrunk below `flows.len()` by a
88    /// `SetMaxFlows`, so `flows.len() <= max_flows` is NOT an invariant; but a
89    /// flow can only ever have been admitted under *some* cap that was in force
90    /// at admission, so `flows.len() <= max_flows_high_water` always holds.
91    /// Debug-only — only read by [`check_invariants`](Self::check_invariants).
92    #[cfg(debug_assertions)]
93    max_flows_high_water: usize,
94}
95
96impl UdpManager<SourceTupleExtractor> {
97    /// Construct a manager with the default 4-tuple/2-tuple source extractor.
98    pub fn new(
99        cluster: ClusterConfig,
100        max_flows: usize,
101        max_rx_datagram_size: usize,
102        hash_seed: u64,
103    ) -> Self {
104        Self::with_extractor(
105            cluster,
106            max_flows,
107            max_rx_datagram_size,
108            hash_seed,
109            SourceTupleExtractor,
110        )
111    }
112}
113
114impl<E: FlowKeyExtractor> UdpManager<E> {
115    /// Construct a manager with a custom flow-key extractor.
116    pub fn with_extractor(
117        cluster: ClusterConfig,
118        max_flows: usize,
119        max_rx_datagram_size: usize,
120        hash_seed: u64,
121        extractor: E,
122    ) -> Self {
123        UdpManager {
124            table: HashMap::new(),
125            flows: Slab::new(),
126            max_flows,
127            max_rx_datagram_size,
128            cluster,
129            hash_seed,
130            extractor,
131            draining: false,
132            outputs: VecDeque::new(),
133            armed_deadline: None,
134            #[cfg(debug_assertions)]
135            max_flows_high_water: max_flows,
136        }
137    }
138
139    // ---- introspection (used by log macros / tests / the shell) ------------
140
141    /// Number of currently admitted flows. Mirrors `udp.active_flows`.
142    pub fn flow_count(&self) -> usize {
143        self.flows.len()
144    }
145
146    /// The configured flow-table cap.
147    pub fn max_flows(&self) -> usize {
148        self.max_flows
149    }
150
151    /// Whether the listener is draining.
152    pub fn is_draining(&self) -> bool {
153        self.draining
154    }
155
156    /// Whether the active cluster config keys flows on the 4-tuple (source
157    /// IP + port) rather than source IP only. The shell needs this to mirror
158    /// the manager's flow keying for its `SendToBackend` socket resolution.
159    pub fn affinity_with_port(&self) -> bool {
160        self.cluster.affinity_with_port
161    }
162
163    /// Borrow a flow by id (for the shell's access log on close).
164    pub fn flow(&self, flow: FlowId) -> Option<&UdpFlow> {
165        self.flows.get(flow)
166    }
167
168    // ---- input -------------------------------------------------------------
169
170    /// Feed one input into the manager. Pure: `now` is injected.
171    pub fn handle_input(&mut self, input: ManagerInput<'_>, now: Instant) {
172        match input {
173            ManagerInput::ClientDatagram { src, payload } => {
174                self.on_client_datagram(src, payload, now)
175            }
176            ManagerInput::BackendDatagram { flow, payload } => {
177                self.on_backend_datagram(flow, payload, now)
178            }
179            ManagerInput::Config(event) => self.on_config(event, now),
180            ManagerInput::BackendResolved {
181                flow,
182                backend,
183                addr,
184            } => self.on_backend_resolved(flow, backend, addr, now),
185        }
186        // Post-condition: the public method ran to completion under the caller's
187        // lock, so every structural invariant must hold again.
188        self.debug_assert_invariants();
189    }
190
191    /// Tear down a single flow on demand, emitting the same outputs a normal
192    /// idle close produces — `Output::Metric(MetricEvent::FlowEvicted)` then
193    /// `Output::CloseFlow(flow)` — so the shell draining `poll_output()`
194    /// decrements `udp.active_flows` and frees the upstream socket exactly once.
195    ///
196    /// The shell calls this when it cannot establish a flow it just admitted:
197    /// the upstream `connect()` failed (EMFILE / connection refused) or no
198    /// backend resolved. Without it the flow would sit `AwaitingBackend` /
199    /// `Established` pinning a `max_flows` slot for the full idle timeout.
200    ///
201    /// Idempotent: a missing flow or one already `Closing` is a no-op — no
202    /// double-evict, no gauge underflow. Works in either `AwaitingBackend` or
203    /// `Established`. `now` is accepted for signature symmetry with the other
204    /// time-driven entry points (the teardown itself is time-independent).
205    pub fn abort_flow(&mut self, flow: FlowId, _now: Instant, reason: CloseReason) {
206        self.close_flow(flow, reason);
207        self.debug_assert_invariants();
208    }
209
210    /// Tear down EVERY live flow, emitting — for each — the same outputs a
211    /// normal idle close produces (`Output::Metric(MetricEvent::FlowEvicted)`
212    /// then `Output::CloseFlow(flow)`), so the shell draining `poll_output()`
213    /// decrements `udp.active_flows` and frees each upstream socket exactly once
214    /// per flow.
215    ///
216    /// The shell calls this on listener remove / deactivate / soft-stop before
217    /// dropping the manager, so the active-flows gauge does not leak. Draining
218    /// the flow table + slab to zero; a flow already `Closing` is skipped
219    /// (idempotent, no double-evict, no underflow). After this returns,
220    /// `flow_count() == 0` and no timer is armed.
221    pub fn close_all(&mut self, now: Instant) {
222        // Snapshot the live ids first: `close_flow` mutates the slab, so we must
223        // not iterate it while closing. `Closing` slots are already gone from
224        // the slab (`close_flow` removes them), so every id here is live.
225        let live: Vec<FlowId> = self.flows.iter().map(|(id, _)| id).collect();
226        for flow_id in live {
227            self.abort_flow(flow_id, now, CloseReason::Drain);
228        }
229        // Post: the table and slab are drained to zero and no timer is armed.
230        #[cfg(debug_assertions)]
231        {
232            debug_assert_eq!(self.flow_count(), 0, "close_all must drain every flow");
233            debug_assert!(
234                self.table.is_empty(),
235                "close_all must clear every table entry"
236            );
237            debug_assert!(
238                self.armed_deadline.is_none(),
239                "close_all must leave no armed timer"
240            );
241        }
242        self.debug_assert_invariants();
243    }
244
245    fn on_client_datagram(&mut self, src: SocketAddr, payload: &[u8], now: Instant) {
246        // Over-size check first — never allocate for a truncated datagram.
247        if payload.len() > self.max_rx_datagram_size {
248            self.drop_datagram(DropReason::Truncated);
249            return;
250        }
251        // No backend cluster configured → nothing to route to.
252        if self.cluster.cluster.is_empty() {
253            self.drop_datagram(DropReason::NoBackend);
254            return;
255        }
256        // Extract the flow key; rejection allocates nothing.
257        let key = match self.extractor.flow_key(src, payload, &self.cluster) {
258            Some(key) => key,
259            None => {
260                self.drop_datagram(DropReason::Invalid);
261                return;
262            }
263        };
264
265        // Tracked flow → reuse its backend (two-tier selection).
266        if let Some(&flow_id) = self.table.get(&key) {
267            self.forward_on_existing_flow(flow_id, payload, now);
268            return;
269        }
270
271        // New flow. Draining or at cap → shed, allocate nothing.
272        // Drop / shed paths ("silence is a virtue"): a reject must allocate
273        // nothing, so `flows.len()` is unchanged across it. Snapshot the count
274        // to pair-assert that on every early return below. Not debug-gated: the
275        // `debug_assert_eq!`s that read it compile in release too (execution only
276        // is gated); the read is dead there and the optimizer drops the binding.
277        let flows_before_admit = self.flows.len();
278        if self.draining {
279            self.drop_datagram(DropReason::Shed);
280            debug_assert_eq!(
281                self.flows.len(),
282                flows_before_admit,
283                "drain shed must allocate no flow"
284            );
285            return;
286        }
287        if self.flows.len() >= self.max_flows {
288            self.outputs
289                .push_back(Output::Metric(MetricEvent::FlowShed));
290            self.drop_datagram(DropReason::Shed);
291            debug_assert_eq!(
292                self.flows.len(),
293                flows_before_admit,
294                "cap shed must allocate no flow"
295            );
296            return;
297        }
298
299        // Admit. Pre-conditions (positive + negative space): we are on the admit
300        // path, so there is room under the live cap AND the key is NOT already
301        // tracked (a tracked key would have been served above without a new
302        // slot — a double-insert would orphan the previous flow and leak a slab
303        // slot).
304        debug_assert!(
305            self.flows.len() < self.max_flows,
306            "admit path entered while at/over the cap"
307        );
308        debug_assert!(
309            !self.table.contains_key(&key),
310            "admit path entered for a key already in the table (would orphan a flow)"
311        );
312
313        // Admit: one slab slot + one copy of the payload (the design's single
314        // admission copy). The flow is parked AwaitingBackend with the datagram
315        // buffered until the shell resolves a backend. `requests_seen` stays 0:
316        // the datagram is only buffered here, and is counted as a forward when
317        // it is actually flushed in `on_backend_resolved` — so `requests`
318        // measures real forwards, not buffered-and-maybe-discarded datagrams.
319        let mut flow = UdpFlow::new(src, self.cluster.clone(), now);
320        flow.pending_payload = Some(payload.to_vec());
321        let key_hash = self.affinity_hash(&flow);
322        let flow_id = self.flows.insert(flow);
323        self.table.insert(key, flow_id);
324
325        // Post (admission): exactly one slot was added and the key now maps to
326        // it. Pairs the pre-conditions above (grew by exactly 1, not 0 or 2).
327        debug_assert_eq!(
328            self.flows.len(),
329            flows_before_admit + 1,
330            "admission must add exactly one flow"
331        );
332        debug_assert_eq!(
333            self.table.get(&key),
334            Some(&flow_id),
335            "admission must map the key to the new flow"
336        );
337
338        self.outputs
339            .push_back(Output::Metric(MetricEvent::FlowCreated));
340        self.outputs.push_back(Output::SelectBackend {
341            flow: flow_id,
342            cluster: self.cluster.cluster.clone(),
343            key: key_hash,
344        });
345        // Arm the idle timer for the freshly-admitted flow.
346        self.reschedule();
347    }
348
349    fn forward_on_existing_flow(&mut self, flow_id: FlowId, payload: &[u8], now: Instant) {
350        let Some(flow) = self.flows.get_mut(flow_id) else {
351            // Table/slab desync should be impossible, but never panic on it.
352            self.drop_datagram(DropReason::UnknownFlow);
353            return;
354        };
355
356        match flow.phase {
357            FlowPhase::AwaitingBackend => {
358                // Backend not yet resolved: buffer (newest-wins, one slot) and
359                // refresh idle ONLY. Do not count this toward `requests` — the
360                // previously-buffered datagram is now discarded and was never
361                // forwarded. The single surviving buffered datagram is counted
362                // when it is actually flushed in `on_backend_resolved`.
363                flow.pending_payload = Some(payload.to_vec());
364                flow.touch(flow.config.front_timeout, now);
365                self.reschedule();
366            }
367            FlowPhase::Established => {
368                flow.on_client_datagram(now);
369                let backend = flow
370                    .backend_addr
371                    .expect("Established flow always has a backend address");
372                let mut out = payload.to_vec();
373                if flow.take_proxy_protocol() {
374                    prepend_dgram_header(&mut out, flow.client, backend);
375                }
376                let teardown = flow.teardown_reason();
377                self.outputs
378                    .push_back(Output::Metric(MetricEvent::DatagramIn(payload.len())));
379                self.outputs.push_back(Output::SendToBackend(Transmit {
380                    dst: backend,
381                    segment_size: None,
382                    payload: out,
383                }));
384                if let Some(reason) = teardown {
385                    self.close_flow(flow_id, reason);
386                } else {
387                    self.reschedule();
388                }
389            }
390            FlowPhase::Closing => {
391                // Racing datagram against a teardown already decided: drop it.
392                self.drop_datagram(DropReason::Shed);
393            }
394        }
395    }
396
397    fn on_backend_resolved(
398        &mut self,
399        flow_id: FlowId,
400        backend: String,
401        addr: SocketAddr,
402        now: Instant,
403    ) {
404        let Some(flow) = self.flows.get_mut(flow_id) else {
405            // Flow was reaped (idle/drain) before the shell resolved a backend.
406            self.drop_datagram(DropReason::UnknownFlow);
407            return;
408        };
409        if flow.phase != FlowPhase::AwaitingBackend {
410            // Duplicate / late resolution; ignore without allocating.
411            return;
412        }
413        flow.backend_id = Some(backend);
414        flow.backend_addr = Some(addr);
415        flow.set_phase(FlowPhase::Established);
416
417        // Open the connected upstream socket (the shell registers
418        // upstream_token -> flow for NAT return).
419        self.outputs.push_back(Output::OpenUpstream {
420            flow: flow_id,
421            backend: addr,
422        });
423
424        // Flush the buffered first datagram, if any. This is the real forward
425        // site for the admission datagram, so count it toward `requests` here
426        // (refreshing the front idle deadline at the same time) — not at
427        // admission, where it was only buffered.
428        let pending = flow.pending_payload.take();
429        if let Some(mut payload) = pending {
430            let payload_len = payload.len();
431            flow.on_client_datagram(now);
432            if flow.take_proxy_protocol() {
433                prepend_dgram_header(&mut payload, flow.client, addr);
434            }
435            let teardown = flow.teardown_reason();
436            self.outputs
437                .push_back(Output::Metric(MetricEvent::DatagramIn(payload_len)));
438            self.outputs.push_back(Output::SendToBackend(Transmit {
439                dst: addr,
440                segment_size: None,
441                payload,
442            }));
443            if let Some(reason) = teardown {
444                self.close_flow(flow_id, reason);
445                return;
446            }
447            // `on_client_datagram` already refreshed the deadline + generation.
448            self.reschedule();
449            return;
450        }
451        // No buffered datagram: refresh idle and re-arm (phase changed; the
452        // deadline was set at `new()`).
453        let _gen = self
454            .flows
455            .get_mut(flow_id)
456            .map(|f| f.touch(self.cluster.front_timeout, now));
457        self.reschedule();
458    }
459
460    fn on_backend_datagram(&mut self, flow_id: FlowId, payload: &[u8], now: Instant) {
461        if payload.len() > self.max_rx_datagram_size {
462            self.drop_datagram(DropReason::Truncated);
463            return;
464        }
465        let Some(flow) = self.flows.get_mut(flow_id) else {
466            self.drop_datagram(DropReason::UnknownFlow);
467            return;
468        };
469        if flow.phase != FlowPhase::Established {
470            self.drop_datagram(DropReason::UnknownFlow);
471            return;
472        }
473        flow.on_backend_datagram(now);
474        let client = flow.client;
475        let teardown = flow.teardown_reason();
476        self.outputs
477            .push_back(Output::Metric(MetricEvent::DatagramOut(payload.len())));
478        self.outputs.push_back(Output::SendToClient(Transmit {
479            dst: client,
480            segment_size: None,
481            payload: payload.to_vec(),
482        }));
483        if let Some(reason) = teardown {
484            self.close_flow(flow_id, reason);
485        } else {
486            self.reschedule();
487        }
488    }
489
490    fn on_config(&mut self, event: ConfigEvent, _now: Instant) {
491        match event {
492            ConfigEvent::SetCluster(cfg) => self.cluster = cfg,
493            ConfigEvent::SetMaxFlows(n) => {
494                self.max_flows = n;
495                // Track the high-water mark: a `SetMaxFlows` may shrink the live
496                // cap below `flows.len()` (documented), so the only durable cap
497                // bound is the largest cap ever in force.
498                #[cfg(debug_assertions)]
499                {
500                    self.max_flows_high_water = self.max_flows_high_water.max(n);
501                }
502            }
503            ConfigEvent::SetMaxRxDatagramSize(n) => self.max_rx_datagram_size = n,
504            ConfigEvent::Drain => self.draining = true,
505        }
506    }
507
508    // ---- timers ------------------------------------------------------------
509
510    /// Fire all flows whose idle deadline has elapsed at `now`. A flow is only
511    /// closed if its generation token still matches the scheduled deadline —
512    /// generation mismatch means the flow saw traffic and was rescheduled, so
513    /// the stale expiry is ignored (defeats the busy-loop / stale-close bug).
514    pub fn handle_timeout(&mut self, now: Instant) {
515        // Collect due flow ids first to avoid borrowing the slab while mutating.
516        let due: Vec<FlowId> = self
517            .flows
518            .iter()
519            .filter(|(_, flow)| flow.idle_deadline <= now)
520            .map(|(id, _)| id)
521            .collect();
522        for flow_id in due {
523            // Re-check under current state (a flow may have been closed by an
524            // earlier iteration's teardown, though here ids are disjoint).
525            if let Some(flow) = self.flows.get(flow_id)
526                && flow.idle_deadline <= now
527                && flow.phase != FlowPhase::Closing
528            {
529                self.close_flow(flow_id, CloseReason::Idle);
530            }
531        }
532        self.reschedule();
533
534        // Strict-advance guard: after firing every flow due at `now`, the next
535        // armed deadline (if any) MUST be strictly greater than `now`. A
536        // deadline `<= now` would make the shell immediately re-fire and spin —
537        // the canonical sans-io busy-loop bug. This is the real reason the
538        // generation tokens + `reschedule` exist.
539        #[cfg(debug_assertions)]
540        if let Some(next) = self.armed_deadline {
541            debug_assert!(
542                next > now,
543                "UdpManager::poll_timeout must strictly advance past a firing: \
544                 armed {next:?} <= fired_at {now:?} (busy-loop)"
545            );
546        }
547
548        // Post: every flow due at `now` was reaped — no live flow may retain a
549        // deadline `<= now`. Pair with the strict-advance guard above: that one
550        // proves the next *armed* deadline advanced, this one proves no *flow*
551        // was left behind due (a leak the armed-deadline check alone misses,
552        // since min() over an empty set is None regardless of stragglers).
553        #[cfg(debug_assertions)]
554        for (id, flow) in self.flows.iter() {
555            debug_assert!(
556                flow.idle_deadline > now,
557                "FlowId {id} still due after handle_timeout: deadline {:?} <= now {now:?}",
558                flow.idle_deadline,
559            );
560        }
561        self.debug_assert_invariants();
562    }
563
564    /// The next manager-wide deadline, or `None` if no flow is armed. After a
565    /// [`handle_timeout`] at deadline `d`, the value returned here is guaranteed
566    /// `> d` (or `None`) — the strict-advance invariant `handle_timeout` asserts
567    /// in debug builds, which is what stops the shell busy-looping.
568    pub fn poll_timeout(&self) -> Option<Instant> {
569        self.armed_deadline
570    }
571
572    /// Drain the next queued output, or `None` when the queue is empty.
573    pub fn poll_output(&mut self) -> Option<Output> {
574        self.outputs.pop_front()
575    }
576
577    // ---- internals ---------------------------------------------------------
578
579    /// Recompute the earliest flow deadline and emit `ArmTimer` only when it
580    /// changes, so the shell re-arms its wheel exactly once per real change.
581    fn reschedule(&mut self) {
582        let next = self
583            .flows
584            .iter()
585            .filter(|(_, f)| f.phase != FlowPhase::Closing)
586            .map(|(_, f)| f.idle_deadline)
587            .min();
588        if next != self.armed_deadline {
589            self.armed_deadline = next;
590            if let Some(deadline) = next {
591                self.outputs.push_back(Output::ArmTimer(deadline));
592            }
593        }
594    }
595
596    /// Tear down a flow: remove it from the table + slab, emit `CloseFlow` and
597    /// the eviction metric. Idempotent — a missing flow is a no-op (never an
598    /// underflow). Re-arms the manager timer.
599    fn close_flow(&mut self, flow_id: FlowId, _reason: CloseReason) {
600        let Some(flow) = self.flows.get_mut(flow_id) else {
601            return;
602        };
603        if flow.phase == FlowPhase::Closing {
604            return;
605        }
606        flow.set_phase(FlowPhase::Closing);
607        let key = FlowKey::from_src(flow.client, self.cluster.affinity_with_port);
608        // Remove the table entry only if it still points at this flow; a
609        // recreated flow under the same key must not be unmapped.
610        if self.table.get(&key) == Some(&flow_id) {
611            self.table.remove(&key);
612        } else {
613            // The flow was keyed when admitted; recompute via its own config to
614            // be robust to a mid-flow affinity change.
615            let own_key = FlowKey::from_src(flow.client, flow.config.affinity_with_port);
616            if self.table.get(&own_key) == Some(&flow_id) {
617                self.table.remove(&own_key);
618            }
619        }
620        self.flows.remove(flow_id);
621        self.outputs
622            .push_back(Output::Metric(MetricEvent::FlowEvicted));
623        self.outputs.push_back(Output::CloseFlow(flow_id));
624        self.reschedule();
625
626        // Post: the slot is gone from the slab AND no table key still maps to it
627        // (a stale table key would dangle — caught by check_invariants (1), but
628        // assert it here too so the local failure points at close_flow). Pair:
629        // positive = removed from slab; negative = no key still references it.
630        #[cfg(debug_assertions)]
631        {
632            debug_assert!(
633                !self.flows.contains(flow_id),
634                "close_flow left FlowId {flow_id} in the slab"
635            );
636            debug_assert!(
637                self.table.values().all(|&id| id != flow_id),
638                "close_flow left a table entry mapping to the removed FlowId {flow_id}"
639            );
640        }
641    }
642
643    /// Emit a drop with its by-reason metric. Allocates nothing per the
644    /// "silence is a virtue" posture.
645    fn drop_datagram(&mut self, reason: DropReason) {
646        // "Silence is a virtue": a drop allocates no flow and frees none — the
647        // slab is untouched across the reject. Snapshot + pair-assert so a future
648        // edit that accidentally mutates the slab on a drop path is caught loudly.
649        // Not debug-gated: the `debug_assert_eq!` reads it in release too (only
650        // execution is gated); dead there, dropped by the optimizer.
651        let flows_before_drop = self.flows.len();
652        self.outputs
653            .push_back(Output::Metric(MetricEvent::DatagramDropped(reason)));
654        self.outputs.push_back(Output::Drop(reason));
655        debug_assert_eq!(
656            self.flows.len(),
657            flows_before_drop,
658            "a drop must allocate nothing and free nothing (flows.len() unchanged)"
659        );
660    }
661
662    /// Affinity hash for a flow: `hash(seed, affinity_key)`. The shell feeds
663    /// this into HRW (`max hash`) / Maglev (`key % M`) / RR (ignores it).
664    fn affinity_hash(&self, flow: &UdpFlow) -> u64 {
665        let mut hasher = std::collections::hash_map::DefaultHasher::new();
666        self.hash_seed.hash(&mut hasher);
667        if flow.config.affinity_with_port {
668            flow.client.hash(&mut hasher);
669        } else {
670            flow.client.ip().hash(&mut hasher);
671        }
672        hasher.finish()
673    }
674
675    /// TigerStyle invariant sweep (TigerBeetle / FoundationDB style). A single
676    /// full check of every structural invariant the manager must preserve,
677    /// asserted at the END of every public mutating method via
678    /// [`debug_assert_invariants`](Self::debug_assert_invariants). Compiled out
679    /// entirely in release (`#[cfg(debug_assertions)]`); on in every test / e2e /
680    /// fuzz / dev build. It must NEVER change runtime behavior — it only reads.
681    ///
682    /// The right place for the sweep is a *post-condition*: these public methods
683    /// run to completion under the caller's lock, so the table/slab/timer state
684    /// is fully reconciled by the time the method returns.
685    #[cfg(debug_assertions)]
686    fn check_invariants(&self) {
687        use std::collections::HashSet;
688
689        // (3) flow_count() is exactly the slab population.
690        debug_assert_eq!(
691            self.flow_count(),
692            self.flows.len(),
693            "flow_count() must equal flows.len()"
694        );
695
696        // (1) Table -> slab consistency + (2) table injectivity: every FlowId in
697        // the table points at a live slab slot, and no two keys share a FlowId.
698        let mut seen_ids: HashSet<FlowId> = HashSet::with_capacity(self.table.len());
699        for (key, &id) in self.table.iter() {
700            debug_assert!(
701                self.flows.contains(id),
702                "table key {key:?} maps to FlowId {id} absent from the slab (dangling key)"
703            );
704            debug_assert!(
705                seen_ids.insert(id),
706                "table injectivity violated: FlowId {id} is the target of two distinct FlowKeys"
707            );
708        }
709        // Pair (negative space): a live flow that is reachable from the table is
710        // mapped exactly once — there is never a live flow with two table keys.
711        debug_assert!(
712            seen_ids.len() <= self.flows.len(),
713            "more distinct table targets than live flows"
714        );
715
716        // Per-flow invariants over the slab.
717        let mut min_live_deadline: Option<Instant> = None;
718        let mut live_count = 0usize;
719        for (id, flow) in self.flows.iter() {
720            // (4) No slab flow is Closing. close_flow sets Closing then removes
721            // the slot in the same call, so a Closing flow must never persist.
722            // Pair: positive space — every live flow is Awaiting or Established.
723            debug_assert_ne!(
724                flow.phase,
725                FlowPhase::Closing,
726                "FlowId {id} persists in the slab while Closing (close_flow must remove it)"
727            );
728            debug_assert!(
729                matches!(
730                    flow.phase,
731                    FlowPhase::AwaitingBackend | FlowPhase::Established
732                ),
733                "FlowId {id} has an unexpected live phase {:?}",
734                flow.phase
735            );
736
737            // (5) Established <=> backend_addr.is_some(); AwaitingBackend <=>
738            // backend_addr.is_none() (positive + negative space).
739            match flow.phase {
740                FlowPhase::Established => debug_assert!(
741                    flow.backend_addr.is_some(),
742                    "Established FlowId {id} has no backend address"
743                ),
744                FlowPhase::AwaitingBackend => debug_assert!(
745                    flow.backend_addr.is_none(),
746                    "AwaitingBackend FlowId {id} already carries a backend address"
747                ),
748                FlowPhase::Closing => {}
749            }
750
751            // (7) Counters within caps OR a teardown is due. A flow whose cap is
752            // exhausted must report a teardown reason; an exhausted flow that
753            // reports None would be an immortal flow (the cap silently lost).
754            if flow.requests_exhausted() || flow.responses_exhausted() {
755                debug_assert!(
756                    flow.teardown_reason().is_some(),
757                    "FlowId {id} exhausted a cap (req {}/{}, resp {}/{}) but reports no teardown",
758                    flow.requests_seen,
759                    flow.config.requests,
760                    flow.responses_seen,
761                    flow.config.responses,
762                );
763            } else {
764                // Pair (negative): a flow within both caps must NOT report a
765                // cap-driven teardown (idle teardown is handled by the timer,
766                // not teardown_reason()).
767                debug_assert!(
768                    flow.teardown_reason().is_none(),
769                    "FlowId {id} reports a teardown while within both caps"
770                );
771            }
772
773            live_count += 1;
774            min_live_deadline = Some(match min_live_deadline {
775                Some(d) => d.min(flow.idle_deadline),
776                None => flow.idle_deadline,
777            });
778        }
779
780        // The high-water cap bounds the live population at all times (the live
781        // cap itself can be shrunk below flows.len() by SetMaxFlows, so we assert
782        // against the largest cap ever in force, not max_flows).
783        debug_assert!(
784            self.flows.len() <= self.max_flows_high_water,
785            "live flows {} exceed the high-water cap {}",
786            self.flows.len(),
787            self.max_flows_high_water,
788        );
789
790        // (6) Timer coherence: armed_deadline is Some IFF at least one (non-
791        // Closing) live flow exists, and when Some it equals the minimum
792        // idle_deadline over live flows. Closing flows are never in the slab
793        // (invariant 4), so "live" == "slab" here.
794        debug_assert_eq!(
795            self.armed_deadline.is_some(),
796            live_count > 0,
797            "timer coherence: armed_deadline.is_some() ({}) must match having live flows ({})",
798            self.armed_deadline.is_some(),
799            live_count > 0,
800        );
801        if let Some(armed) = self.armed_deadline {
802            debug_assert_eq!(
803                Some(armed),
804                min_live_deadline,
805                "timer coherence: armed deadline {armed:?} must equal the minimum live idle deadline {min_live_deadline:?}"
806            );
807        }
808    }
809
810    /// Run the full [`check_invariants`](Self::check_invariants) sweep, but only
811    /// in debug builds. A thin wrapper so call sites read as one line and so the
812    /// whole sweep — and any read it performs — is dead-stripped in release.
813    #[inline]
814    fn debug_assert_invariants(&self) {
815        #[cfg(debug_assertions)]
816        self.check_invariants();
817    }
818}
819
820#[cfg(test)]
821mod tests {
822    use std::{
823        net::{IpAddr, Ipv4Addr},
824        time::Duration,
825    };
826
827    use super::*;
828
829    fn cluster(name: &str) -> ClusterConfig {
830        ClusterConfig {
831            cluster: name.to_owned(),
832            front_timeout: Duration::from_secs(30),
833            back_timeout: Duration::from_secs(30),
834            ..Default::default()
835        }
836    }
837
838    fn client(n: u8, port: u16) -> SocketAddr {
839        SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 0, 0, n)), port)
840    }
841
842    fn backend() -> SocketAddr {
843        SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 5300)
844    }
845
846    /// Drain all outputs into a Vec for assertions.
847    fn drain(mgr: &mut UdpManager) -> Vec<Output> {
848        let mut out = Vec::new();
849        while let Some(o) = mgr.poll_output() {
850            out.push(o);
851        }
852        out
853    }
854
855    #[test]
856    fn unknown_datagram_allocates_nothing_when_no_cluster() {
857        let mut mgr = UdpManager::new(ClusterConfig::default(), 16, 65535, 0xABCD);
858        let now = Instant::now();
859        mgr.handle_input(
860            ManagerInput::ClientDatagram {
861                src: client(1, 1000),
862                payload: b"hi",
863            },
864            now,
865        );
866        assert_eq!(mgr.flow_count(), 0);
867        let outs = drain(&mut mgr);
868        assert!(
869            outs.iter()
870                .any(|o| matches!(o, Output::Drop(DropReason::NoBackend)))
871        );
872        assert!(
873            !outs
874                .iter()
875                .any(|o| matches!(o, Output::SelectBackend { .. }))
876        );
877    }
878
879    #[test]
880    fn empty_datagram_is_invalid() {
881        let mut mgr = UdpManager::new(cluster("dns"), 16, 65535, 1);
882        mgr.handle_input(
883            ManagerInput::ClientDatagram {
884                src: client(1, 1000),
885                payload: b"",
886            },
887            Instant::now(),
888        );
889        assert_eq!(mgr.flow_count(), 0);
890        let outs = drain(&mut mgr);
891        assert!(
892            outs.iter()
893                .any(|o| matches!(o, Output::Drop(DropReason::Invalid)))
894        );
895    }
896
897    #[test]
898    fn truncated_datagram_dropped_before_admission() {
899        let mut mgr = UdpManager::new(cluster("dns"), 16, 4, 1);
900        mgr.handle_input(
901            ManagerInput::ClientDatagram {
902                src: client(1, 1000),
903                payload: b"toolong",
904            },
905            Instant::now(),
906        );
907        assert_eq!(mgr.flow_count(), 0);
908        let outs = drain(&mut mgr);
909        assert!(
910            outs.iter()
911                .any(|o| matches!(o, Output::Drop(DropReason::Truncated)))
912        );
913    }
914
915    #[test]
916    fn new_flow_requests_backend_then_forwards_buffered_datagram() {
917        let mut mgr = UdpManager::new(cluster("dns"), 16, 65535, 7);
918        let now = Instant::now();
919        let src = client(1, 1000);
920        mgr.handle_input(
921            ManagerInput::ClientDatagram {
922                src,
923                payload: b"query",
924            },
925            now,
926        );
927        assert_eq!(mgr.flow_count(), 1);
928        let outs = drain(&mut mgr);
929        let select = outs
930            .iter()
931            .find_map(|o| match o {
932                Output::SelectBackend { flow, cluster, .. } => Some((*flow, cluster.clone())),
933                _ => None,
934            })
935            .expect("SelectBackend emitted");
936        assert_eq!(select.1, "dns");
937        // No SendToBackend yet — backend not resolved.
938        assert!(!outs.iter().any(|o| matches!(o, Output::SendToBackend(_))));
939
940        mgr.handle_input(
941            ManagerInput::BackendResolved {
942                flow: select.0,
943                backend: "b1".to_owned(),
944                addr: backend(),
945            },
946            now,
947        );
948        let outs = drain(&mut mgr);
949        assert!(
950            outs.iter()
951                .any(|o| matches!(o, Output::OpenUpstream { .. }))
952        );
953        let sent = outs
954            .iter()
955            .find_map(|o| match o {
956                Output::SendToBackend(t) => Some(t.clone()),
957                _ => None,
958            })
959            .expect("buffered datagram flushed on resolve");
960        assert_eq!(sent.dst, backend());
961        assert_eq!(sent.payload, b"query");
962    }
963
964    #[test]
965    fn tracked_flow_reuses_backend() {
966        let mut mgr = UdpManager::new(cluster("dns"), 16, 65535, 7);
967        let now = Instant::now();
968        let src = client(1, 1000);
969        mgr.handle_input(
970            ManagerInput::ClientDatagram {
971                src,
972                payload: b"q1",
973            },
974            now,
975        );
976        let select_flow = drain(&mut mgr)
977            .into_iter()
978            .find_map(|o| match o {
979                Output::SelectBackend { flow, .. } => Some(flow),
980                _ => None,
981            })
982            .unwrap();
983        mgr.handle_input(
984            ManagerInput::BackendResolved {
985                flow: select_flow,
986                backend: "b1".to_owned(),
987                addr: backend(),
988            },
989            now,
990        );
991        drain(&mut mgr);
992
993        // Second datagram from same source: no new SelectBackend, direct send.
994        mgr.handle_input(
995            ManagerInput::ClientDatagram {
996                src,
997                payload: b"q2",
998            },
999            now,
1000        );
1001        assert_eq!(mgr.flow_count(), 1);
1002        let outs = drain(&mut mgr);
1003        assert!(
1004            !outs
1005                .iter()
1006                .any(|o| matches!(o, Output::SelectBackend { .. }))
1007        );
1008        assert!(
1009            outs.iter()
1010                .any(|o| matches!(o, Output::SendToBackend(t) if t.payload == b"q2"))
1011        );
1012    }
1013
1014    #[test]
1015    fn responses_one_closes_flow_after_single_reply() {
1016        let mut cfg = cluster("dns");
1017        cfg.responses = 1;
1018        let mut mgr = UdpManager::new(cfg, 16, 65535, 7);
1019        let now = Instant::now();
1020        let src = client(1, 1000);
1021        mgr.handle_input(ManagerInput::ClientDatagram { src, payload: b"q" }, now);
1022        let flow = drain(&mut mgr)
1023            .into_iter()
1024            .find_map(|o| match o {
1025                Output::SelectBackend { flow, .. } => Some(flow),
1026                _ => None,
1027            })
1028            .unwrap();
1029        mgr.handle_input(
1030            ManagerInput::BackendResolved {
1031                flow,
1032                backend: "b1".to_owned(),
1033                addr: backend(),
1034            },
1035            now,
1036        );
1037        drain(&mut mgr);
1038
1039        // Single backend reply closes the flow.
1040        mgr.handle_input(
1041            ManagerInput::BackendDatagram {
1042                flow,
1043                payload: b"answer",
1044            },
1045            now,
1046        );
1047        let outs = drain(&mut mgr);
1048        assert!(
1049            outs.iter()
1050                .any(|o| matches!(o, Output::SendToClient(t) if t.payload == b"answer"))
1051        );
1052        assert!(
1053            outs.iter()
1054                .any(|o| matches!(o, Output::CloseFlow(f) if *f == flow))
1055        );
1056        assert_eq!(mgr.flow_count(), 0);
1057    }
1058
1059    #[test]
1060    fn requests_cap_closes_flow() {
1061        let mut cfg = cluster("syslog");
1062        cfg.requests = 2;
1063        let mut mgr = UdpManager::new(cfg, 16, 65535, 7);
1064        let now = Instant::now();
1065        let src = client(1, 1000);
1066        mgr.handle_input(ManagerInput::ClientDatagram { src, payload: b"1" }, now);
1067        let flow = drain(&mut mgr)
1068            .into_iter()
1069            .find_map(|o| match o {
1070                Output::SelectBackend { flow, .. } => Some(flow),
1071                _ => None,
1072            })
1073            .unwrap();
1074        mgr.handle_input(
1075            ManagerInput::BackendResolved {
1076                flow,
1077                backend: "b1".to_owned(),
1078                addr: backend(),
1079            },
1080            now,
1081        );
1082        drain(&mut mgr);
1083        // requests_seen is now 1 (the admission datagram). Second hits the cap.
1084        mgr.handle_input(ManagerInput::ClientDatagram { src, payload: b"2" }, now);
1085        let outs = drain(&mut mgr);
1086        assert!(outs.iter().any(|o| matches!(o, Output::CloseFlow(_))));
1087        assert_eq!(mgr.flow_count(), 0);
1088    }
1089
1090    #[test]
1091    fn flow_table_full_sheds_new_flow() {
1092        let mut mgr = UdpManager::new(cluster("dns"), 1, 65535, 7);
1093        let now = Instant::now();
1094        mgr.handle_input(
1095            ManagerInput::ClientDatagram {
1096                src: client(1, 1000),
1097                payload: b"a",
1098            },
1099            now,
1100        );
1101        assert_eq!(mgr.flow_count(), 1);
1102        drain(&mut mgr);
1103        // Second distinct source over cap → shed.
1104        mgr.handle_input(
1105            ManagerInput::ClientDatagram {
1106                src: client(2, 1000),
1107                payload: b"b",
1108            },
1109            now,
1110        );
1111        assert_eq!(mgr.flow_count(), 1);
1112        let outs = drain(&mut mgr);
1113        assert!(
1114            outs.iter()
1115                .any(|o| matches!(o, Output::Metric(MetricEvent::FlowShed)))
1116        );
1117        assert!(
1118            outs.iter()
1119                .any(|o| matches!(o, Output::Drop(DropReason::Shed)))
1120        );
1121    }
1122
1123    #[test]
1124    fn idle_timeout_closes_flow() {
1125        let mut cfg = cluster("dns");
1126        cfg.front_timeout = Duration::from_secs(10);
1127        let mut mgr = UdpManager::new(cfg, 16, 65535, 7);
1128        let now = Instant::now();
1129        mgr.handle_input(
1130            ManagerInput::ClientDatagram {
1131                src: client(1, 1000),
1132                payload: b"q",
1133            },
1134            now,
1135        );
1136        drain(&mut mgr);
1137        let deadline = mgr.poll_timeout().expect("timer armed");
1138        assert!(deadline >= now + Duration::from_secs(10));
1139        // Fire after the deadline.
1140        mgr.handle_timeout(now + Duration::from_secs(11));
1141        let outs = drain(&mut mgr);
1142        assert!(outs.iter().any(|o| matches!(o, Output::CloseFlow(_))));
1143        assert_eq!(mgr.flow_count(), 0);
1144        assert!(mgr.poll_timeout().is_none());
1145    }
1146
1147    #[test]
1148    fn idle_race_resolved_by_generation_token() {
1149        // A datagram refreshes the deadline; the stale expiry must NOT close.
1150        let mut cfg = cluster("dns");
1151        cfg.front_timeout = Duration::from_secs(10);
1152        let mut mgr = UdpManager::new(cfg, 16, 65535, 7);
1153        let now = Instant::now();
1154        let src = client(1, 1000);
1155        mgr.handle_input(ManagerInput::ClientDatagram { src, payload: b"q" }, now);
1156        let flow = drain(&mut mgr)
1157            .into_iter()
1158            .find_map(|o| match o {
1159                Output::SelectBackend { flow, .. } => Some(flow),
1160                _ => None,
1161            })
1162            .unwrap();
1163        mgr.handle_input(
1164            ManagerInput::BackendResolved {
1165                flow,
1166                backend: "b1".to_owned(),
1167                addr: backend(),
1168            },
1169            now,
1170        );
1171        drain(&mut mgr);
1172        let gen0 = mgr.flow(flow).unwrap().timer_gen;
1173        // Datagram at t=5 refreshes deadline to t=15 and bumps generation.
1174        let t5 = now + Duration::from_secs(5);
1175        mgr.handle_input(
1176            ManagerInput::ClientDatagram {
1177                src,
1178                payload: b"q2",
1179            },
1180            t5,
1181        );
1182        drain(&mut mgr);
1183        let gen1 = mgr.flow(flow).unwrap().timer_gen;
1184        assert_ne!(gen0, gen1, "generation token must bump on touch");
1185        // Stale expiry at the original t=10 deadline must NOT close (deadline is
1186        // now t=15).
1187        mgr.handle_timeout(now + Duration::from_secs(10));
1188        assert_eq!(mgr.flow_count(), 1, "refreshed flow survives stale expiry");
1189        // The real deadline at t=15 closes it.
1190        mgr.handle_timeout(now + Duration::from_secs(16));
1191        assert_eq!(mgr.flow_count(), 0);
1192    }
1193
1194    #[test]
1195    fn drain_sheds_new_flows_but_keeps_existing() {
1196        let mut mgr = UdpManager::new(cluster("dns"), 16, 65535, 7);
1197        let now = Instant::now();
1198        let src = client(1, 1000);
1199        mgr.handle_input(ManagerInput::ClientDatagram { src, payload: b"q" }, now);
1200        drain(&mut mgr);
1201        mgr.handle_input(ManagerInput::Config(ConfigEvent::Drain), now);
1202        // New flow shed.
1203        mgr.handle_input(
1204            ManagerInput::ClientDatagram {
1205                src: client(2, 1000),
1206                payload: b"q",
1207            },
1208            now,
1209        );
1210        assert_eq!(mgr.flow_count(), 1, "existing flow kept, new flow shed");
1211        let outs = drain(&mut mgr);
1212        assert!(
1213            outs.iter()
1214                .any(|o| matches!(o, Output::Drop(DropReason::Shed)))
1215        );
1216    }
1217
1218    #[test]
1219    fn reconfig_midflow_preserves_existing_flow_contract() {
1220        let mut cfg = cluster("dns");
1221        cfg.responses = 0;
1222        let mut mgr = UdpManager::new(cfg, 16, 65535, 7);
1223        let now = Instant::now();
1224        let src = client(1, 1000);
1225        mgr.handle_input(ManagerInput::ClientDatagram { src, payload: b"q" }, now);
1226        let flow = drain(&mut mgr)
1227            .into_iter()
1228            .find_map(|o| match o {
1229                Output::SelectBackend { flow, .. } => Some(flow),
1230                _ => None,
1231            })
1232            .unwrap();
1233        mgr.handle_input(
1234            ManagerInput::BackendResolved {
1235                flow,
1236                backend: "b1".to_owned(),
1237                addr: backend(),
1238            },
1239            now,
1240        );
1241        drain(&mut mgr);
1242        // Reconfigure to responses=1; the live flow keeps responses=0 (captured).
1243        let mut newcfg = cluster("dns");
1244        newcfg.responses = 1;
1245        mgr.handle_input(ManagerInput::Config(ConfigEvent::SetCluster(newcfg)), now);
1246        // A reply does NOT close the existing flow (it captured responses=0).
1247        mgr.handle_input(
1248            ManagerInput::BackendDatagram {
1249                flow,
1250                payload: b"reply",
1251            },
1252            now,
1253        );
1254        assert_eq!(mgr.flow_count(), 1);
1255    }
1256
1257    #[test]
1258    fn reaper_drains_active_flows_to_zero() {
1259        let mut cfg = cluster("dns");
1260        cfg.front_timeout = Duration::from_secs(5);
1261        let mut mgr = UdpManager::new(cfg, 64, 65535, 7);
1262        let now = Instant::now();
1263        for n in 1..=10u8 {
1264            mgr.handle_input(
1265                ManagerInput::ClientDatagram {
1266                    src: client(n, 1000),
1267                    payload: b"q",
1268                },
1269                now,
1270            );
1271        }
1272        assert_eq!(mgr.flow_count(), 10);
1273        drain(&mut mgr);
1274        // Reaper after all idle deadlines pass.
1275        mgr.handle_timeout(now + Duration::from_secs(6));
1276        assert_eq!(mgr.flow_count(), 0, "reaper drains every flow, no leak");
1277        let outs = drain(&mut mgr);
1278        let closes = outs
1279            .iter()
1280            .filter(|o| matches!(o, Output::CloseFlow(_)))
1281            .count();
1282        assert_eq!(closes, 10);
1283        assert!(mgr.poll_timeout().is_none(), "no armed timer after drain");
1284    }
1285
1286    #[test]
1287    fn proxy_protocol_first_datagram_only() {
1288        let mut cfg = cluster("dns");
1289        cfg.send_proxy_protocol = true;
1290        cfg.proxy_protocol_every_datagram = false;
1291        let mut mgr = UdpManager::new(cfg, 16, 65535, 7);
1292        let now = Instant::now();
1293        let src = client(1, 1000);
1294        mgr.handle_input(
1295            ManagerInput::ClientDatagram {
1296                src,
1297                payload: b"q1",
1298            },
1299            now,
1300        );
1301        let flow = drain(&mut mgr)
1302            .into_iter()
1303            .find_map(|o| match o {
1304                Output::SelectBackend { flow, .. } => Some(flow),
1305                _ => None,
1306            })
1307            .unwrap();
1308        mgr.handle_input(
1309            ManagerInput::BackendResolved {
1310                flow,
1311                backend: "b1".to_owned(),
1312                addr: backend(),
1313            },
1314            now,
1315        );
1316        // First flushed datagram carries the PPv2 prefix.
1317        let first = drain(&mut mgr)
1318            .into_iter()
1319            .find_map(|o| match o {
1320                Output::SendToBackend(t) => Some(t.payload),
1321                _ => None,
1322            })
1323            .unwrap();
1324        assert!(first.len() > 2, "PPv2 header prepended to first datagram");
1325        assert_eq!(&first[..4], &[0x0D, 0x0A, 0x0D, 0x0A]);
1326        assert_eq!(first[12], 0x21);
1327        assert_eq!(first[13], 0x12);
1328        assert_eq!(&first[first.len() - 2..], b"q1");
1329
1330        // Second datagram: NO prefix.
1331        mgr.handle_input(
1332            ManagerInput::ClientDatagram {
1333                src,
1334                payload: b"q2",
1335            },
1336            now,
1337        );
1338        let second = drain(&mut mgr)
1339            .into_iter()
1340            .find_map(|o| match o {
1341                Output::SendToBackend(t) => Some(t.payload),
1342                _ => None,
1343            })
1344            .unwrap();
1345        assert_eq!(second, b"q2", "no PPv2 prefix on subsequent datagrams");
1346    }
1347
1348    /// Helper: admit a flow from `src`, resolve it to `backend()`, and return
1349    /// its FlowId. Drains the manager between steps. Leaves the flow
1350    /// `Established`.
1351    fn establish(mgr: &mut UdpManager, src: SocketAddr, now: Instant) -> FlowId {
1352        mgr.handle_input(ManagerInput::ClientDatagram { src, payload: b"q" }, now);
1353        let flow = drain(mgr)
1354            .into_iter()
1355            .find_map(|o| match o {
1356                Output::SelectBackend { flow, .. } => Some(flow),
1357                _ => None,
1358            })
1359            .unwrap();
1360        mgr.handle_input(
1361            ManagerInput::BackendResolved {
1362                flow,
1363                backend: "b1".to_owned(),
1364                addr: backend(),
1365            },
1366            now,
1367        );
1368        drain(mgr);
1369        flow
1370    }
1371
1372    #[test]
1373    fn close_all_evicts_every_flow_exactly_once() {
1374        let mut cfg = cluster("dns");
1375        cfg.front_timeout = Duration::from_secs(30);
1376        let mut mgr = UdpManager::new(cfg, 64, 65535, 7);
1377        let now = Instant::now();
1378        // N flows: a mix of Established and AwaitingBackend.
1379        const N: usize = 6;
1380        let mut flow_ids = Vec::new();
1381        for n in 1..=4u8 {
1382            flow_ids.push(establish(&mut mgr, client(n, 1000), now));
1383        }
1384        // Two flows left AwaitingBackend (admitted, not resolved).
1385        for n in 5..=6u8 {
1386            mgr.handle_input(
1387                ManagerInput::ClientDatagram {
1388                    src: client(n, 1000),
1389                    payload: b"q",
1390                },
1391                now,
1392            );
1393            let flow = drain(&mut mgr)
1394                .into_iter()
1395                .find_map(|o| match o {
1396                    Output::SelectBackend { flow, .. } => Some(flow),
1397                    _ => None,
1398                })
1399                .unwrap();
1400            flow_ids.push(flow);
1401        }
1402        assert_eq!(mgr.flow_count(), N);
1403
1404        mgr.close_all(now);
1405        let outs = drain(&mut mgr);
1406        let evicted = outs
1407            .iter()
1408            .filter(|o| matches!(o, Output::Metric(MetricEvent::FlowEvicted)))
1409            .count();
1410        let closed = outs
1411            .iter()
1412            .filter(|o| matches!(o, Output::CloseFlow(_)))
1413            .count();
1414        assert_eq!(evicted, N, "one FlowEvicted per live flow");
1415        assert_eq!(closed, N, "one CloseFlow per live flow");
1416        assert_eq!(mgr.flow_count(), 0, "flow table + slab drained to zero");
1417        assert!(
1418            mgr.poll_timeout().is_none(),
1419            "no armed timer after close_all"
1420        );
1421    }
1422
1423    #[test]
1424    fn close_all_is_idempotent_and_empty_safe() {
1425        let mut mgr = UdpManager::new(cluster("dns"), 16, 65535, 7);
1426        let now = Instant::now();
1427        // Empty manager: close_all is a no-op, no outputs.
1428        mgr.close_all(now);
1429        assert!(drain(&mut mgr).is_empty());
1430        assert_eq!(mgr.flow_count(), 0);
1431
1432        // Now one flow; close it twice. The second pass emits nothing (no
1433        // double-evict / underflow).
1434        establish(&mut mgr, client(1, 1000), now);
1435        assert_eq!(mgr.flow_count(), 1);
1436        mgr.close_all(now);
1437        let first = drain(&mut mgr);
1438        assert_eq!(
1439            first
1440                .iter()
1441                .filter(|o| matches!(o, Output::CloseFlow(_)))
1442                .count(),
1443            1
1444        );
1445        mgr.close_all(now);
1446        assert!(drain(&mut mgr).is_empty(), "second close_all emits nothing");
1447        assert_eq!(mgr.flow_count(), 0);
1448    }
1449
1450    #[test]
1451    fn abort_flow_closes_established_flow() {
1452        let mut mgr = UdpManager::new(cluster("dns"), 16, 65535, 7);
1453        let now = Instant::now();
1454        let flow = establish(&mut mgr, client(1, 1000), now);
1455        assert_eq!(mgr.flow_count(), 1);
1456
1457        mgr.abort_flow(flow, now, CloseReason::Aborted);
1458        let outs = drain(&mut mgr);
1459        assert!(
1460            outs.iter()
1461                .any(|o| matches!(o, Output::Metric(MetricEvent::FlowEvicted)))
1462        );
1463        assert!(
1464            outs.iter()
1465                .any(|o| matches!(o, Output::CloseFlow(f) if *f == flow))
1466        );
1467        assert_eq!(mgr.flow_count(), 0);
1468
1469        // Idempotent: aborting again emits nothing, no underflow.
1470        mgr.abort_flow(flow, now, CloseReason::Aborted);
1471        assert!(drain(&mut mgr).is_empty());
1472        assert_eq!(mgr.flow_count(), 0);
1473    }
1474
1475    #[test]
1476    fn abort_flow_closes_awaiting_backend_flow() {
1477        // Simulates udp_connect failing / no backend resolving: the flow never
1478        // leaves AwaitingBackend and must still free its slot immediately.
1479        let mut mgr = UdpManager::new(cluster("dns"), 16, 65535, 7);
1480        let now = Instant::now();
1481        mgr.handle_input(
1482            ManagerInput::ClientDatagram {
1483                src: client(1, 1000),
1484                payload: b"q",
1485            },
1486            now,
1487        );
1488        let flow = drain(&mut mgr)
1489            .into_iter()
1490            .find_map(|o| match o {
1491                Output::SelectBackend { flow, .. } => Some(flow),
1492                _ => None,
1493            })
1494            .unwrap();
1495        assert_eq!(mgr.flow_count(), 1);
1496        assert_eq!(mgr.flow(flow).unwrap().phase, FlowPhase::AwaitingBackend);
1497
1498        mgr.abort_flow(flow, now, CloseReason::Aborted);
1499        let outs = drain(&mut mgr);
1500        assert!(
1501            outs.iter()
1502                .any(|o| matches!(o, Output::Metric(MetricEvent::FlowEvicted)))
1503        );
1504        assert!(
1505            outs.iter()
1506                .any(|o| matches!(o, Output::CloseFlow(f) if *f == flow))
1507        );
1508        assert_eq!(mgr.flow_count(), 0, "AwaitingBackend slot freed by abort");
1509        assert!(mgr.poll_timeout().is_none());
1510        // The freed key is reusable: a new datagram from the same source admits
1511        // a fresh flow rather than colliding with the aborted one.
1512        mgr.handle_input(
1513            ManagerInput::ClientDatagram {
1514                src: client(1, 1000),
1515                payload: b"q2",
1516            },
1517            now,
1518        );
1519        assert_eq!(mgr.flow_count(), 1);
1520    }
1521
1522    #[test]
1523    fn abort_flow_unknown_id_is_noop() {
1524        let mut mgr = UdpManager::new(cluster("dns"), 16, 65535, 7);
1525        let now = Instant::now();
1526        mgr.abort_flow(999, now, CloseReason::Aborted);
1527        assert!(drain(&mut mgr).is_empty());
1528        assert_eq!(mgr.flow_count(), 0);
1529    }
1530
1531    #[test]
1532    fn requests_counts_forwards_not_buffered_during_await() {
1533        // requests = 2. A burst of client datagrams arrives while the flow is
1534        // still AwaitingBackend; all but the newest are discarded (newest-wins)
1535        // and never forwarded — so they must NOT count toward `requests`. After
1536        // resolve, the single buffered datagram is forwarded (count = 1). Only a
1537        // SECOND real forward (post-establish) should reach the cap.
1538        let mut cfg = cluster("syslog");
1539        cfg.requests = 2;
1540        let mut mgr = UdpManager::new(cfg, 16, 65535, 7);
1541        let now = Instant::now();
1542        let src = client(1, 1000);
1543
1544        // Admission datagram → AwaitingBackend, buffered (not counted yet).
1545        mgr.handle_input(ManagerInput::ClientDatagram { src, payload: b"1" }, now);
1546        let flow = drain(&mut mgr)
1547            .into_iter()
1548            .find_map(|o| match o {
1549                Output::SelectBackend { flow, .. } => Some(flow),
1550                _ => None,
1551            })
1552            .unwrap();
1553        // Burst during await: 3 more datagrams, all overwriting the one slot.
1554        for p in [b"2".as_slice(), b"3", b"4"] {
1555            mgr.handle_input(ManagerInput::ClientDatagram { src, payload: p }, now);
1556        }
1557        drain(&mut mgr);
1558        // Still alive, still awaiting — the burst did NOT trip the cap.
1559        assert_eq!(mgr.flow_count(), 1, "await burst must not close the flow");
1560        assert_eq!(
1561            mgr.flow(flow).unwrap().requests_seen,
1562            0,
1563            "buffered-only datagrams must not count toward requests"
1564        );
1565
1566        // Resolve: the single surviving buffered datagram is forwarded (count 1).
1567        mgr.handle_input(
1568            ManagerInput::BackendResolved {
1569                flow,
1570                backend: "b1".to_owned(),
1571                addr: backend(),
1572            },
1573            now,
1574        );
1575        let outs = drain(&mut mgr);
1576        assert!(
1577            outs.iter()
1578                .any(|o| matches!(o, Output::SendToBackend(t) if t.payload == b"4")),
1579            "newest buffered datagram is the one forwarded"
1580        );
1581        assert!(
1582            !outs.iter().any(|o| matches!(o, Output::CloseFlow(_))),
1583            "one forward must not reach requests=2"
1584        );
1585        assert_eq!(mgr.flow_count(), 1);
1586        assert_eq!(mgr.flow(flow).unwrap().requests_seen, 1);
1587
1588        // A real second forward (Established) reaches the cap and closes.
1589        mgr.handle_input(ManagerInput::ClientDatagram { src, payload: b"5" }, now);
1590        let outs = drain(&mut mgr);
1591        assert!(
1592            outs.iter()
1593                .any(|o| matches!(o, Output::SendToBackend(t) if t.payload == b"5"))
1594        );
1595        assert!(
1596            outs.iter().any(|o| matches!(o, Output::CloseFlow(_))),
1597            "second real forward reaches requests=2"
1598        );
1599        assert_eq!(mgr.flow_count(), 0);
1600    }
1601
1602    // ---- quickcheck property tests (zero sockets, injected Instants) -------
1603
1604    use quickcheck::{Arbitrary, Gen, quickcheck};
1605
1606    /// One step of an abstract client-side workload: a datagram from one of a
1607    /// bounded set of sources, or a clock advance that fires the reaper.
1608    #[derive(Clone, Debug)]
1609    enum Step {
1610        /// Client datagram from source `id % 8`.
1611        Client(u8),
1612        /// Advance the injected clock by `secs` seconds and fire the reaper.
1613        Tick(u8),
1614    }
1615
1616    impl Arbitrary for Step {
1617        fn arbitrary(g: &mut Gen) -> Self {
1618            if bool::arbitrary(g) {
1619                Step::Client(u8::arbitrary(g))
1620            } else {
1621                Step::Tick(u8::arbitrary(g) % 40)
1622            }
1623        }
1624    }
1625
1626    /// Property: across any interleaving of datagrams and clock ticks, the flow
1627    /// count never exceeds `max_flows`, and a final long tick reaps every flow
1628    /// back to zero — no leak, no gauge underflow (`CloseFlow` count ==
1629    /// `FlowCreated` count). The busy-loop strict-advance invariant is asserted
1630    /// inside `handle_timeout` itself (debug builds), so every `Tick` exercises
1631    /// it for free.
1632    #[test]
1633    fn prop_flow_invariants() {
1634        fn prop(steps: Vec<Step>) -> bool {
1635            const MAX_FLOWS: usize = 4;
1636            let mut cfg = cluster("dns");
1637            cfg.front_timeout = Duration::from_secs(10);
1638            let mut mgr = UdpManager::new(cfg, MAX_FLOWS, 65535, 0x5EED);
1639            let base = Instant::now();
1640            let mut now = base;
1641            let mut created = 0usize;
1642            let mut closed = 0usize;
1643
1644            for step in steps {
1645                match step {
1646                    Step::Client(id) => {
1647                        let src = client(id % 8, 9000 + (id % 8) as u16);
1648                        mgr.handle_input(ManagerInput::ClientDatagram { src, payload: b"q" }, now);
1649                    }
1650                    Step::Tick(secs) => {
1651                        now += Duration::from_secs(secs as u64);
1652                        mgr.handle_timeout(now);
1653                    }
1654                }
1655                // Drain outputs, tallying create/close.
1656                while let Some(out) = mgr.poll_output() {
1657                    match out {
1658                        Output::Metric(MetricEvent::FlowCreated) => created += 1,
1659                        Output::CloseFlow(_) => closed += 1,
1660                        _ => {}
1661                    }
1662                }
1663                // The cap is never exceeded.
1664                if mgr.flow_count() > MAX_FLOWS {
1665                    return false;
1666                }
1667            }
1668
1669            // Final long tick must reap everything: drains to zero, no timer.
1670            now += Duration::from_secs(60);
1671            mgr.handle_timeout(now);
1672            while let Some(out) = mgr.poll_output() {
1673                if let Output::CloseFlow(_) = out {
1674                    closed += 1;
1675                }
1676            }
1677            mgr.flow_count() == 0 && mgr.poll_timeout().is_none() && created == closed
1678        }
1679        quickcheck(prop as fn(Vec<Step>) -> bool);
1680    }
1681
1682    /// Property: an idle-timeout race is always resolved by generation tokens —
1683    /// for any refresh strictly inside the timeout window, a stale expiry at the
1684    /// original deadline never closes a refreshed flow, and the refreshed
1685    /// deadline eventually does.
1686    #[test]
1687    fn prop_generation_token_defeats_stale_close() {
1688        fn prop(refresh_offset: u8) -> bool {
1689            let timeout = 20u64;
1690            // Refresh at 1..=timeout-1 seconds (strictly inside the window).
1691            let offset = 1 + (refresh_offset as u64 % (timeout - 1));
1692            let mut cfg = cluster("dns");
1693            cfg.front_timeout = Duration::from_secs(timeout);
1694            let mut mgr = UdpManager::new(cfg, 8, 65535, 1);
1695            let now = Instant::now();
1696            let src = client(1, 1000);
1697            mgr.handle_input(ManagerInput::ClientDatagram { src, payload: b"q" }, now);
1698            while mgr.poll_output().is_some() {}
1699
1700            // Refresh inside the window: bumps the generation, pushes the
1701            // deadline forward.
1702            let refreshed_at = now + Duration::from_secs(offset);
1703            mgr.handle_input(
1704                ManagerInput::ClientDatagram {
1705                    src,
1706                    payload: b"q2",
1707                },
1708                refreshed_at,
1709            );
1710            while mgr.poll_output().is_some() {}
1711
1712            // Stale expiry at the ORIGINAL deadline must not close the flow.
1713            mgr.handle_timeout(now + Duration::from_secs(timeout));
1714            while mgr.poll_output().is_some() {}
1715            if mgr.flow_count() != 1 {
1716                return false;
1717            }
1718            // The refreshed deadline eventually closes it.
1719            mgr.handle_timeout(refreshed_at + Duration::from_secs(timeout + 1));
1720            while mgr.poll_output().is_some() {}
1721            mgr.flow_count() == 0
1722        }
1723        quickcheck(prop as fn(u8) -> bool);
1724    }
1725}