Skip to main content

sozu_lib/protocol/udp/
flow.rs

1//! Per-flow UDP state machine (sans-io).
2//!
3//! A [`UdpFlow`] is the per-admitted-flow half of the two-level split. It owns
4//! the three-knob teardown counters (`responses` / `requests` / idle), the idle
5//! and lifetime deadlines, PPv2-first-datagram bookkeeping, the real (pre-NAT)
6//! client address, the chosen backend, and the forward/return decisions. A
7//! stale wheel expiry cannot close a flow that has since seen traffic because
8//! [`UdpManager::handle_timeout`](crate::protocol::udp::manager::UdpManager::handle_timeout)
9//! revalidates each flow's `idle_deadline` against `now`; the `timer_gen`
10//! counter every `touch` advances takes no part in that decision.
11//!
12//! No socket, no clock, no rand: every time-dependent method takes `now:
13//! Instant`. The manager owns the slab; this type is the slot payload.
14
15use std::{net::SocketAddr, time::Instant};
16
17use crate::protocol::udp::{BackendId, ClusterConfig};
18
19/// Why a flow reached teardown. Surfaces in the access log on close.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum CloseReason {
22    /// Idle timeout elapsed (no datagram in either direction).
23    Idle,
24    /// The configured `responses` count was reached (e.g. DNS = 1 reply).
25    ResponsesReached,
26    /// The configured `requests` count was reached.
27    RequestsReached,
28    /// The listener is draining.
29    Drain,
30    /// The flow was aborted by the shell before it could serve traffic — the
31    /// upstream `connect()` failed (EMFILE / refused) or no backend resolved.
32    /// Distinct from `Idle` so the access log shows the flow never established,
33    /// rather than implying it timed out.
34    Aborted,
35}
36
37/// The lifecycle of a flow w.r.t. its backend. A flow is admitted already
38/// [`Established`](FlowPhase::Established) — selection happens inside the
39/// admitting call now, so there is no window in which a flow exists without a
40/// backend — and is reaped on teardown.
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum FlowPhase {
43    /// Backend selected and `OpenUpstream` requested; datagrams flow both ways.
44    Established,
45    /// Marked for teardown; the manager will emit `CloseFlow` and free the slot.
46    Closing,
47}
48
49/// Per-admitted-flow state. Slab slot payload owned by the manager.
50#[derive(Clone, Debug)]
51pub struct UdpFlow {
52    /// Real (pre-NAT) client source address — the symmetric NAT return target
53    /// and the PPv2 source address.
54    pub client: SocketAddr,
55    /// Backend id, set when the core selects the flow's backend at admission.
56    pub backend_id: Option<String>,
57    /// Backend address, set when the core selects the flow's backend at admission.
58    pub backend_addr: Option<SocketAddr>,
59    /// Lifecycle phase.
60    pub phase: FlowPhase,
61    /// Captured per-cluster knobs (responses/requests/timeouts/PPv2). Captured
62    /// at admission so a mid-flow reconfig does not change a live flow's
63    /// teardown contract (stable affinity).
64    pub config: ClusterConfig,
65
66    /// Client datagrams forwarded so far (counts toward `requests`).
67    pub requests_seen: u32,
68    /// Backend replies returned so far (counts toward `responses`).
69    pub responses_seen: u32,
70
71    /// Absolute idle deadline; reset on every datagram in either direction.
72    pub idle_deadline: Instant,
73    /// Refresh counter. Incremented every time the idle deadline is pushed
74    /// back. No expiry path reads it: a wheel expiry closes the flow only when
75    /// `idle_deadline` is still `<= now` (deadline revalidation in
76    /// `UdpManager::handle_timeout`), which is what keeps a stale expiry from
77    /// closing a refreshed flow.
78    pub timer_gen: u64,
79
80    /// True until the first upstream datagram is sent; gates PPv2 prefixing
81    /// when `proxy_protocol_every_datagram` is false (first-datagram-only).
82    pub first_upstream_pending: bool,
83}
84
85impl UdpFlow {
86    /// Create a flow for `client` on the backend admission just selected, with
87    /// its idle deadline armed `front_timeout` from `now`.
88    ///
89    /// The backend arrives at construction because selection happens before
90    /// the flow exists: a flow is never without one.
91    pub fn new(
92        client: SocketAddr,
93        config: ClusterConfig,
94        backend_id: BackendId,
95        backend_addr: SocketAddr,
96        now: Instant,
97    ) -> Self {
98        let idle_deadline = now + config.front_timeout;
99        UdpFlow {
100            client,
101            backend_id: Some(backend_id),
102            backend_addr: Some(backend_addr),
103            phase: FlowPhase::Established,
104            first_upstream_pending: config.send_proxy_protocol,
105            config,
106            requests_seen: 0,
107            responses_seen: 0,
108            idle_deadline,
109            timer_gen: 0,
110        }
111    }
112
113    /// Push the idle deadline back to `now + timeout` and advance `timer_gen`.
114    /// The later deadline is what invalidates an in-flight wheel expiry for the
115    /// old one: `UdpManager::handle_timeout` revalidates `idle_deadline <= now`
116    /// before closing. Returns the new `timer_gen`; no caller consumes it.
117    pub fn touch(&mut self, timeout: std::time::Duration, now: Instant) -> u64 {
118        // A `touch` advances `timer_gen` as a refresh counter. Snapshot the old
119        // value and pair-assert (positive: the new value is returned; negative:
120        // it differs from the old, even across the wrapping boundary).
121        // Not `#[cfg(debug_assertions)]`-gated: `debug_assert_ne!` compiles its
122        // arguments in every profile (it only gates *execution*), so a snapshot
123        // it reads must exist in release too. The read is dead code in release and
124        // the optimizer drops the binding — zero cost, but it must still compile.
125        let old_gen = self.timer_gen;
126        self.idle_deadline = now + timeout;
127        self.timer_gen = self.timer_gen.wrapping_add(1);
128        debug_assert_ne!(
129            self.timer_gen, old_gen,
130            "touch must advance the timer_gen refresh counter"
131        );
132        self.timer_gen
133    }
134
135    /// Record that one client datagram was actually *forwarded* upstream: bump
136    /// the `requests` counter and refresh the front idle deadline. Returns the
137    /// new `timer_gen`. Call this only at a real forward site, so
138    /// `requests` measures datagrams that actually reached a backend. Use
139    /// [`touch`](Self::touch) for an idle refresh that is not a forward.
140    pub fn on_client_datagram(&mut self, now: Instant) -> u64 {
141        // A real forward is only ever recorded on an Established flow. A flow
142        // is admitted Established, so the only way to fail this is a forward
143        // on a flow already torn down.
144        debug_assert_eq!(
145            self.phase,
146            FlowPhase::Established,
147            "on_client_datagram (a real forward) requires an Established flow"
148        );
149        // `requests_seen` is monotonic non-decreasing (a forward only ever bumps
150        // it) and saturates rather than wraps — so the count never silently
151        // resets to a small value and re-arms an already-exhausted cap.
152        // Always declared (not debug-gated): the `debug_assert!` below compiles
153        // this read in release too; dead there, dropped by the optimizer.
154        let before = self.requests_seen;
155        self.requests_seen = self.requests_seen.saturating_add(1);
156        debug_assert!(
157            self.requests_seen >= before,
158            "requests_seen must be monotonic non-decreasing"
159        );
160        self.touch(self.config.front_timeout, now)
161    }
162
163    /// Record that one backend reply was returned; refresh the back idle
164    /// deadline. Returns the new `timer_gen`.
165    pub fn on_backend_datagram(&mut self, now: Instant) -> u64 {
166        // A backend reply can only arrive on an Established flow: the upstream
167        // socket is opened from the admitting call, and a reply on a flow
168        // already torn down is the only way this can fail.
169        debug_assert_eq!(
170            self.phase,
171            FlowPhase::Established,
172            "on_backend_datagram requires an Established flow"
173        );
174        // `responses_seen` is monotonic non-decreasing and saturating.
175        // Always declared (not debug-gated): the `debug_assert!` below compiles
176        // this read in release too; dead there, dropped by the optimizer.
177        let before = self.responses_seen;
178        self.responses_seen = self.responses_seen.saturating_add(1);
179        debug_assert!(
180            self.responses_seen >= before,
181            "responses_seen must be monotonic non-decreasing"
182        );
183        self.touch(self.config.back_timeout, now)
184    }
185
186    /// Transition the flow to `next`, asserting the move is legal. One edge
187    /// remains: `Established → Closing`, a live flow torn down. A flow is
188    /// admitted `Established`, so there is no forward edge left, and every
189    /// other transition — including a self-loop — is a bug.
190    ///
191    /// The idempotent-close case does not reach here: `UdpManager::close_flow`
192    /// returns early on a flow already `Closing` rather than re-setting it.
193    ///
194    /// Debug-only guard — the assignment itself is unconditional so release
195    /// behavior is identical.
196    pub fn set_phase(&mut self, next: FlowPhase) {
197        #[cfg(debug_assertions)]
198        {
199            // One legal edge remains: a live flow is torn down exactly once.
200            // A flow is admitted Established, so there is no forward edge left
201            // to allow — anything else, including a self-loop, is a bug and
202            // falls through to `false` rather than being listed as permitted.
203            let legal = matches!(
204                (self.phase, next),
205                (FlowPhase::Established, FlowPhase::Closing)
206            );
207            debug_assert!(
208                legal,
209                "illegal flow phase transition {:?} -> {next:?}",
210                self.phase,
211            );
212        }
213        self.phase = next;
214    }
215
216    /// Whether the `requests` knob has been exhausted (`0` = unlimited).
217    pub fn requests_exhausted(&self) -> bool {
218        self.config.requests != 0 && self.requests_seen >= self.config.requests
219    }
220
221    /// Whether the `responses` knob has been exhausted (`0` = unlimited). A DNS
222    /// flow with `responses = 1` closes after its single reply.
223    pub fn responses_exhausted(&self) -> bool {
224        self.config.responses != 0 && self.responses_seen >= self.config.responses
225    }
226
227    /// The teardown reason if any knob is exhausted, else `None`. Idle is
228    /// handled separately by the manager via the timer wheel.
229    pub fn teardown_reason(&self) -> Option<CloseReason> {
230        let reason = if self.responses_exhausted() {
231            Some(CloseReason::ResponsesReached)
232        } else if self.requests_exhausted() {
233            Some(CloseReason::RequestsReached)
234        } else {
235            None
236        };
237        // Pair-assert the boundary (positive + negative space): a teardown reason
238        // is returned IFF at least one cap is truly exhausted, and `None` is
239        // returned IFF neither cap is. A reason without an exhausted cap (or an
240        // exhausted cap with no reason) is a silent correctness bug — the flow
241        // would close early or never close on its cap.
242        #[cfg(debug_assertions)]
243        {
244            let exhausted = self.responses_exhausted() || self.requests_exhausted();
245            debug_assert_eq!(
246                reason.is_some(),
247                exhausted,
248                "teardown_reason boundary: Some={} but exhausted={} (req {}/{}, resp {}/{})",
249                reason.is_some(),
250                exhausted,
251                self.requests_seen,
252                self.config.requests,
253                self.responses_seen,
254                self.config.responses,
255            );
256        }
257        reason
258    }
259
260    /// Whether this upstream datagram should carry a PPv2 DGRAM prefix, given
261    /// the cluster's first-datagram-only vs every-datagram policy. Marks the
262    /// first-datagram bookkeeping as consumed.
263    pub fn take_proxy_protocol(&mut self) -> bool {
264        if !self.config.send_proxy_protocol {
265            return false;
266        }
267        if self.config.proxy_protocol_every_datagram {
268            return true;
269        }
270        // First-datagram-only: prefix exactly once.
271        if self.first_upstream_pending {
272            self.first_upstream_pending = false;
273            true
274        } else {
275            false
276        }
277    }
278}