Skip to main content

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}