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}