moq_native/accept.rs
1//! Accept-loop health for the listeners that perform a real `accept(2)`.
2//!
3//! A listener loop cannot treat every `accept` failure the same way. The errnos
4//! `accept` returns mix two families that want opposite handling, and getting them
5//! backwards is not symmetric: pausing after a dead connection punishes the
6//! connections queued behind it, while retrying instantly after fd exhaustion is a
7//! hot loop burning the CPU the process needs in order to recover. [`Failure`] is
8//! that classification and [`Health`] applies it, so a listener's failure arm is
9//! one call rather than a policy each caller re-derives.
10//!
11//! [`Health`] is also the only thing that leaves the process when a listener goes
12//! dark. The loops here never give up (an accept loop is a process-lifetime
13//! supervisor with nobody to return an error to), so a node can be unable to accept
14//! a single TCP connection while every other signal looks healthy. The cumulative
15//! counters are the load-bearing half rather than [`stalled`](Health::stalled): a
16//! process with no descriptors left cannot serve a metrics scrape either, so the
17//! episode is often only visible once it is over, and a gauge read after recovery
18//! reads a healthy nothing while a counter still shows the jump.
19//!
20//! Only a listener that performs a real `accept(2)` has anything to report here.
21//! The QUIC backends multiplex every session over one UDP socket, so they never
22//! call `accept` and exhaustion cannot reach them; registering one would publish a
23//! permanently-zero counter, which reads as a watch that is passing when it is
24//! really a watch that can never fire.
25
26use std::{
27 io,
28 sync::{
29 Arc,
30 atomic::{AtomicU64, Ordering},
31 },
32 time::{Duration, Instant},
33};
34
35/// Delay after an accept failure that is not one connection's fault, escalating
36/// while they continue and reset by the next successful accept.
37///
38/// Capped in seconds rather than minutes: the loop is a supervisor that must stay
39/// responsive to a resource being returned, so it keeps asking at a rate that
40/// recovers promptly without spinning.
41const RETRY_MIN: Duration = Duration::from_millis(100);
42const RETRY_MAX: Duration = Duration::from_secs(5);
43
44/// What a failed `accept(2)` means for the listener that saw it.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46#[non_exhaustive]
47pub enum Failure {
48 /// One pending connection died on its way to us: the peer reset it before we
49 /// dequeued it, a firewall rule dropped it, its handshake timed out.
50 ///
51 /// Ordinary traffic, and never a fault of the listener. The queue entry is
52 /// consumed, so the very next `accept` makes progress and delaying it would only
53 /// punish the connections queued behind the dead one, at whatever rate a remote
54 /// peer chooses to supply them.
55 ///
56 /// How reachable that is depends on the platform. Linux surfaces a new socket's
57 /// already-pending network errors through `accept` (which BSD does not, and which
58 /// is why the list is as long as it is), but drops a connection reset before
59 /// `accept` from the queue silently rather than reporting `ECONNABORTED`. So the
60 /// simplest flood is louder on BSD than on Linux; the handling is the same either
61 /// way.
62 Connection,
63 /// A resource `accept` needs is exhausted process-wide or host-wide: the process
64 /// fd table (`EMFILE`), the system-wide one (`ENFILE`), or kernel memory for the
65 /// new socket (`ENOBUFS`/`ENOMEM`).
66 ///
67 /// The connection stays queued, so the listener is instantly readable and
68 /// instantly failing until something outside this loop returns the resource.
69 Exhausted,
70 /// An errno we don't recognize. Worth backing off on, never worth claiming a
71 /// cause for: an unrecognized failure on ordinary traffic could be driven by a
72 /// remote peer, so escalating on it hands out a remote lever.
73 Unknown,
74}
75
76impl Failure {
77 /// Every class, for a caller enumerating [`Health::failures`] into a metrics
78 /// endpoint.
79 ///
80 /// A slice rather than an array: the length of an array is part of the type, so
81 /// `[Failure; 3]` would make adding a class a breaking change for every caller
82 /// that named the type, defeating the `#[non_exhaustive]` above.
83 pub const ALL: &'static [Failure] = &[Failure::Connection, Failure::Exhausted, Failure::Unknown];
84
85 /// Classify a failed `accept(2)`.
86 ///
87 /// Unrecognized is [`Unknown`](Self::Unknown), never [`Connection`](Self::Connection):
88 /// the default has to be the one that paces, because guessing "per connection" on a
89 /// listener-wide failure is the mistake that spins.
90 pub fn classify(err: &io::Error) -> Self {
91 match err.raw_os_error() {
92 Some(code) if exhausted(code) => Self::Exhausted,
93 Some(code) if per_connection(code) => Self::Connection,
94 _ => Self::Unknown,
95 }
96 }
97
98 /// The stable lowercase name used in logs and metric labels.
99 pub const fn as_str(self) -> &'static str {
100 match self {
101 Self::Connection => "connection",
102 Self::Exhausted => "exhausted",
103 Self::Unknown => "unknown",
104 }
105 }
106}
107
108impl std::fmt::Display for Failure {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 f.write_str(self.as_str())
111 }
112}
113
114/// Whether `accept` itself had nothing to work with, so the failure persists until
115/// something outside the accept loop returns the resource.
116#[cfg(unix)]
117fn exhausted(code: i32) -> bool {
118 [libc::EMFILE, libc::ENFILE, libc::ENOBUFS, libc::ENOMEM].contains(&code)
119}
120
121/// Whether the failure belongs to the one connection being dequeued rather than to
122/// the listener.
123///
124/// The bulk of the list is the "already-pending network error on the new socket" set
125/// `accept(2)` documents on Linux, which is a Linux behavior specifically (BSD holds
126/// the error until the socket is used). Each reports the state of that connection,
127/// never of the listener, so each is safe to retry at once.
128#[cfg(unix)]
129fn per_connection(code: i32) -> bool {
130 let common = [
131 libc::ECONNABORTED,
132 libc::ECONNRESET,
133 libc::ETIMEDOUT,
134 // A netfilter/firewall rule rejected this connection.
135 libc::EPERM,
136 libc::EPROTO,
137 libc::ENOPROTOOPT,
138 libc::EOPNOTSUPP,
139 libc::EHOSTDOWN,
140 libc::EHOSTUNREACH,
141 libc::ENETDOWN,
142 libc::ENETUNREACH,
143 // Not a connection error at all: the syscall was interrupted by a signal.
144 // Grouped here because it wants the same handling (retry at once) and says
145 // nothing about the listener.
146 libc::EINTR,
147 ]
148 .contains(&code);
149
150 // `ENONET` rounds out the pending-error set above, and exists only where that set
151 // does: it is absent from the BSD/Apple headers entirely.
152 #[cfg(any(target_os = "linux", target_os = "android"))]
153 let common = common || code == libc::ENONET;
154
155 common
156}
157
158/// Winsock reports these under `WSAE*` codes rather than errnos. Spelled out because
159/// the constants live in `windows-sys`, a large dependency to take on for four
160/// integers; they are fixed by the Winsock ABI.
161///
162/// Deliberately narrower than the unix set. `accept` on Windows documents far fewer
163/// per-connection failures, and the ones that look analogous are not: `WSAENETDOWN`
164/// is "the network subsystem has failed", which is listener-wide and would spin if
165/// retried at once. Anything not listed here lands in [`Failure::Unknown`], which
166/// paces, so the narrow list fails safe.
167#[cfg(windows)]
168fn exhausted(code: i32) -> bool {
169 [
170 10024, // WSAEMFILE: no more socket descriptors available
171 10055, // WSAENOBUFS: no buffer space available
172 ]
173 .contains(&code)
174}
175
176#[cfg(windows)]
177fn per_connection(code: i32) -> bool {
178 [
179 10053, // WSAECONNABORTED: software caused connection abort
180 10054, // WSAECONNRESET: the peer terminated the indicated connection
181 ]
182 .contains(&code)
183}
184
185/// No error table for a platform we have never run a listener on. Everything is
186/// [`Failure::Unknown`], which paces and warns rather than claiming a cause.
187#[cfg(not(any(unix, windows)))]
188fn exhausted(_code: i32) -> bool {
189 false
190}
191
192#[cfg(not(any(unix, windows)))]
193fn per_connection(_code: i32) -> bool {
194 false
195}
196
197/// One listener's accept-loop health: how to react to a failure, and what a
198/// supervisor outside the process can read back.
199///
200/// Cheap to clone; every clone shares one listener's state. The listeners here
201/// build their own and hand out a clone through their `accept_health()` method (as
202/// does the relay's web server), while an embedder driving its own listener
203/// constructs one with [`new`](Self::new).
204///
205/// This owns the backoff rather than each loop inlining its own, which is the one
206/// place that indirection earns its keep: the classification above is the whole
207/// point of the pacing, and five listeners that each re-derive it will not agree
208/// for long.
209#[derive(Clone)]
210pub struct Health(Arc<Inner>);
211
212struct Inner {
213 listener: &'static str,
214 connection: AtomicU64,
215 exhausted: AtomicU64,
216 unknown: AtomicU64,
217 state: parking_lot::Mutex<State>,
218}
219
220struct State {
221 /// When the current run of exhaustion failures began, if one is in progress.
222 stall: Option<Instant>,
223 /// Consecutive failures since the last successful accept.
224 consecutive: u64,
225 /// The next un-jittered delay, escalating while accepts keep failing.
226 delay: Duration,
227}
228
229impl Health {
230 /// Track a listener reported under `listener` (the name used in logs and as a
231 /// metric label).
232 pub fn new(listener: &'static str) -> Self {
233 Self(Arc::new(Inner {
234 listener,
235 connection: AtomicU64::new(0),
236 exhausted: AtomicU64::new(0),
237 unknown: AtomicU64::new(0),
238 state: parking_lot::Mutex::new(State {
239 stall: None,
240 consecutive: 0,
241 delay: RETRY_MIN,
242 }),
243 }))
244 }
245
246 /// The name this listener is reported under.
247 pub fn listener(&self) -> &'static str {
248 self.0.listener
249 }
250
251 /// An accept succeeded: the listener is serving, so any stall is over and the
252 /// next failure starts from the shortest delay again.
253 pub fn accepted(&self) {
254 let mut state = self.0.state.lock();
255 if state.stall.take().is_some() {
256 tracing::info!(listener = self.0.listener, "listener is accepting again");
257 }
258 state.consecutive = 0;
259 state.delay = RETRY_MIN;
260 }
261
262 /// An accept failed: classify it, count it, log it, and return how long the loop
263 /// should wait before asking again.
264 ///
265 /// `None` means retry at once, which is what a [`Failure::Connection`] wants:
266 /// the queue entry was consumed, so the listener has already made progress.
267 #[must_use = "an accept failure that is not one connection's fault must be paced, or the loop spins"]
268 pub fn failed(&self, err: &io::Error) -> Option<Duration> {
269 let failure = Failure::classify(err);
270 self.counter(failure).fetch_add(1, Ordering::Relaxed);
271
272 if failure == Failure::Connection {
273 // Ordinary traffic. Warning per occurrence would drown the log the moment
274 // a scanner shows up, and there is nothing for an operator to do.
275 tracing::debug!(listener = self.0.listener, %err, "dropped a connection before accepting it");
276 return None;
277 }
278
279 let mut state = self.0.state.lock();
280 state.consecutive += 1;
281 let delay = jitter(state.delay);
282 state.delay = (state.delay * 2).min(RETRY_MAX);
283
284 let stalled = match failure {
285 Failure::Exhausted => Some(state.stall.get_or_insert_with(Instant::now).elapsed()),
286 _ => None,
287 };
288
289 tracing::warn!(
290 listener = self.0.listener,
291 %err,
292 class = failure.as_str(),
293 consecutive = state.consecutive,
294 stalled_secs = stalled.map(|stalled| stalled.as_secs()),
295 retry_in_ms = delay.as_millis(),
296 "accept failed; the listener is not serving new connections"
297 );
298
299 Some(delay)
300 }
301
302 /// Failed accepts of this class since the process started.
303 ///
304 /// Cumulative and never reset, so a scrape that lands after the episode still
305 /// sees it. Classes are counted apart rather than totalled because they are not
306 /// comparable: [`Failure::Connection`] tracks how much junk traffic the node is
307 /// fielding, while a non-zero [`Failure::Exhausted`] means the process ran out of
308 /// something it needs to serve anyone.
309 pub fn failures(&self, failure: Failure) -> u64 {
310 self.counter(failure).load(Ordering::Relaxed)
311 }
312
313 /// How long the listener has been unable to accept, when a
314 /// [`Failure::Exhausted`] stall is in progress.
315 ///
316 /// Only a successful accept clears it, so a listener with no traffic holds its
317 /// last value rather than claiming a recovery it has no evidence for.
318 pub fn stalled(&self) -> Option<Duration> {
319 self.0.state.lock().stall.map(|since| since.elapsed())
320 }
321
322 fn counter(&self, failure: Failure) -> &AtomicU64 {
323 match failure {
324 Failure::Connection => &self.0.connection,
325 Failure::Exhausted => &self.0.exhausted,
326 Failure::Unknown => &self.0.unknown,
327 }
328 }
329}
330
331impl std::fmt::Debug for Health {
332 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333 f.debug_struct("Health")
334 .field("listener", &self.0.listener)
335 .field("connection", &self.failures(Failure::Connection))
336 .field("exhausted", &self.failures(Failure::Exhausted))
337 .field("unknown", &self.failures(Failure::Unknown))
338 .field("stalled", &self.stalled())
339 .finish()
340 }
341}
342
343/// Equal jitter: half the delay is a firm floor, so a flapping listener always
344/// waits a meaningful amount, while the random half keeps a fleet that failed on
345/// the same tick from retrying in lockstep.
346fn jitter(delay: Duration) -> Duration {
347 use rand::RngExt as _;
348 delay.mul_f64(0.5 + rand::rng().random::<f64>() / 2.0)
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 fn io(code: i32) -> io::Error {
356 io::Error::from_raw_os_error(code)
357 }
358
359 #[cfg(unix)]
360 #[test]
361 fn classifies_exhaustion_apart_from_dead_connections() {
362 // The whole point: a peer that reset before we accepted it is ordinary
363 // traffic, so it must never read as exhaustion. Counting it as one hands a
364 // remote peer a lever on whatever the exhaustion signal drives.
365 for code in [
366 libc::ECONNABORTED,
367 libc::ECONNRESET,
368 libc::EPERM,
369 libc::EPROTO,
370 libc::ETIMEDOUT,
371 libc::EHOSTUNREACH,
372 libc::ENETDOWN,
373 libc::EINTR,
374 ] {
375 assert_eq!(Failure::classify(&io(code)), Failure::Connection, "errno {code}");
376 }
377
378 for code in [libc::EMFILE, libc::ENFILE, libc::ENOBUFS, libc::ENOMEM] {
379 assert_eq!(Failure::classify(&io(code)), Failure::Exhausted, "errno {code}");
380 }
381
382 // Unrecognized is Unknown, not Exhausted: a new failure mode earns a backoff
383 // and a warning, never a claim about the cause.
384 assert_eq!(Failure::classify(&io(libc::EINVAL)), Failure::Unknown);
385 assert_eq!(Failure::classify(&io::Error::other("never seen")), Failure::Unknown);
386 }
387
388 /// `ENONET` belongs to the pending-error set the `classifies_*` test covers, and
389 /// exists only on Linux, so it needs its own gate. Without it the errno lands in
390 /// `Unknown` and a firewalled connection costs the listener a backoff.
391 #[cfg(any(target_os = "linux", target_os = "android"))]
392 #[test]
393 fn linux_pending_network_errors_are_per_connection() {
394 assert_eq!(Failure::classify(&io(libc::ENONET)), Failure::Connection);
395 }
396
397 /// Not compiled on a unix host; the Windows gates in `just rs windows` are the
398 /// only thing that builds this.
399 #[cfg(windows)]
400 #[test]
401 fn windows_subsystem_failure_is_not_per_connection() {
402 // WSAENETDOWN is "the network subsystem has failed", which is listener-wide.
403 // Retrying it at once is the spin this module exists to prevent, so it must
404 // fall through to Unknown (which paces) rather than read as one dead peer.
405 assert_eq!(Failure::classify(&io(10050)), Failure::Unknown);
406 assert_eq!(Failure::classify(&io(10054)), Failure::Connection);
407 assert_eq!(Failure::classify(&io(10024)), Failure::Exhausted);
408 }
409
410 #[cfg(unix)]
411 #[test]
412 fn a_dead_connection_never_pauses_the_listener() {
413 let health = Health::new("test");
414
415 // A pause here would let a peer opening and resetting connections in bulk
416 // hold the listener at the retry cap, starving legitimate ones.
417 assert_eq!(health.failed(&io(libc::ECONNABORTED)), None);
418 assert_eq!(health.failures(Failure::Connection), 1);
419 assert_eq!(health.stalled(), None, "a dead connection is not a stall");
420 }
421
422 #[cfg(unix)]
423 #[test]
424 fn exhaustion_escalates_and_caps() {
425 let health = Health::new("test");
426
427 // Equal jitter, so each delay lands in [d/2, d] for the un-jittered d.
428 for expected in [RETRY_MIN, RETRY_MIN * 2, RETRY_MIN * 4] {
429 let delay = health.failed(&io(libc::EMFILE)).expect("exhaustion must pace");
430 assert!(
431 delay >= expected / 2 && delay <= expected,
432 "{delay:?} outside {expected:?}"
433 );
434 }
435
436 // Keep failing and it settles at the cap rather than climbing into minutes:
437 // the loop has to stay responsive to the resource coming back.
438 for _ in 0..20 {
439 let delay = health.failed(&io(libc::EMFILE)).expect("exhaustion must pace");
440 assert!(delay <= RETRY_MAX);
441 }
442
443 assert!(health.stalled().is_some(), "exhaustion is a sustained condition");
444 assert_eq!(health.failures(Failure::Exhausted), 23);
445 assert_eq!(health.failures(Failure::Connection), 0);
446 }
447
448 #[cfg(unix)]
449 #[test]
450 fn a_successful_accept_ends_the_stall_and_the_backoff() {
451 let health = Health::new("test");
452 for _ in 0..5 {
453 let _ = health.failed(&io(libc::EMFILE));
454 }
455 assert!(health.stalled().is_some());
456
457 health.accepted();
458 assert_eq!(health.stalled(), None);
459
460 // Back to the shortest delay: the next episode is a new one, not a
461 // continuation of a schedule that already climbed to the cap.
462 let delay = health.failed(&io(libc::EMFILE)).expect("exhaustion must pace");
463 assert!(delay <= RETRY_MIN);
464
465 // The counters do NOT reset. They are what a scrape landing after the
466 // episode has to see, since the process could not answer one during it.
467 assert_eq!(health.failures(Failure::Exhausted), 6);
468 }
469}