epics_libcom_rs/runtime/accept.rs
1//! Backoff for a server accept loop, shared by every accept loop in the
2//! workspace.
3//!
4//! # The defect this exists to close
5//!
6//! `accept()` can fail without the listener being broken — `EMFILE`/`ENFILE`
7//! when the fd table is full, `ENOMEM`/`ENOBUFS` under memory pressure. On
8//! those the pending connection **stays queued**, so a loop that logs and
9//! immediately retries spins at 100% CPU and floods the log, at exactly the
10//! moment the machine has no resources to spare. Two of this workspace's
11//! accept loops did that.
12//!
13//! C `rsrv` has the shape: `epicsThreadSleep(15.0); continue;` at all three of
14//! its failure points (`caservertask.c:92`, `:102`, `:118`).
15//!
16//! # Why the retry/give-up decision is not made from the error
17//!
18//! The obvious design is to classify the `io::Error` — retry `EMFILE`, return
19//! on `EBADF`. **Measured on this host, that is not expressible:**
20//!
21//! | errno | `io::ErrorKind` |
22//! |---|---|
23//! | `EBADF` (9) — listener fd is gone | `Uncategorized` |
24//! | `ENOTSOCK` (88) — not a socket | `Uncategorized` |
25//! | `EMFILE` (24) — fd table full, *transient* | `Uncategorized` |
26//! | `EINVAL` (22) | `InvalidInput` |
27//!
28//! The fatal cases and the transient one collapse into the same variant, so
29//! `ErrorKind` cannot separate them. Matching `raw_os_error()` instead would
30//! work per-platform, but the one kind that *is* distinguishable —
31//! `InvalidInput` — is precisely what RTEMS returns spuriously from socket
32//! calls while its libc omits the BSD `sin_len` byte, so a rule keyed on it
33//! would make every RTEMS accept loop quit on its first call.
34//!
35//! So there is no decision to make: **the loop always retries**, exactly as C
36//! does. The only thing this type computes is how long to wait first.
37//!
38//! # There is no give-up, and that is C parity
39//!
40//! An earlier revision returned after 64 consecutive failures. That was a
41//! deliberate deviation and it was wrong: C `rsrv` has no such path
42//! (`caservertask.c:82-121` — `errlogPrintf`, `epicsThreadSleep(15.0)`,
43//! `continue`, at every one of its three failure points, with
44//! `cantProceed("Unreachable. Perpetual thread.")` after the loop to say so).
45//! On target the deviation meant an IOC that had seen a minute of `EMFILE`
46//! stopped accepting **for the life of the process** while every other part of
47//! it went on looking healthy — the worst available outcome, and unrecoverable
48//! without a reboot, where C would have resumed the moment an fd freed.
49//!
50//! What is kept from that revision is the geometric backoff: [`FIRST`] to
51//! [`CEILING`], which recovers from a transient blip about 15× faster than C's
52//! flat 15 s while still capping the retry rate once saturated.
53//!
54//! # Saying so without a subscriber
55//!
56//! C prints on *every* failed accept, through `errlogPrintf`, which reaches
57//! the console whatever the log configuration. Ours does both: a `tracing`
58//! event per failure for a host with a subscriber (C parity, exactly), and an
59//! `errlog` line on the 1st, 2nd, 4th, 8th … consecutive failure, which
60//! reaches an RTEMS console that has no subscriber at all
61//! ([`crate::runtime::log::errlog_sev_printf`]). Powers of two because the
62//! backoff ceiling is 1 s where C's is 15 s: printing every failure at our
63//! cadence would be 15× C's console traffic on a serial line. It needs no
64//! clock, and it cannot suppress the first occurrence.
65
66use std::time::Duration;
67
68/// Delay after the first failure. Doubles per consecutive failure.
69pub const FIRST: Duration = Duration::from_millis(25);
70
71/// Longest delay between two accept attempts. Also the steady-state log rate
72/// while a listener stays broken: one line per ceiling.
73pub const CEILING: Duration = Duration::from_secs(1);
74
75/// Consecutive-failure counter for one accept loop.
76///
77/// Hold one per loop, call [`accepted`](Self::accepted) on every success and
78/// [`failed`](Self::failed) on every failure.
79#[derive(Debug, Clone, Default)]
80pub struct AcceptBackoff {
81 consecutive: u32,
82}
83
84impl AcceptBackoff {
85 pub const fn new() -> Self {
86 Self { consecutive: 0 }
87 }
88
89 /// A connection was accepted: the listener works, so forget the history.
90 ///
91 /// Only `accept()` itself feeds this counter, and on RTEMS that is not
92 /// where a loaded server actually fails. Measured under qemu at the
93 /// connection ceiling: `accept()` **succeeds** and the per-connection
94 /// thread spawn then fails with `EAGAIN`, 64 times in a row, without a
95 /// single `accept()` failure — because this call has already run by then.
96 /// So the backoff describes a broken *listener*, not a full *server*; the
97 /// refusal path announces the latter.
98 pub fn accepted(&mut self) {
99 self.consecutive = 0;
100 }
101
102 /// An `accept()` failed. Returns how long to wait before the next attempt.
103 ///
104 /// There is no other outcome: the loop retries forever, as C does. The
105 /// caller does the waiting, because only it knows whether it is a thread
106 /// (`thread::sleep`) or a task (`tokio::time::sleep`).
107 ///
108 /// Announces the failure on the way past — see the module docs on why an
109 /// `errlog` line at powers of two, and not the `tracing` event alone,
110 /// is what an RTEMS console can actually receive.
111 ///
112 /// `#[must_use]`: dropping the delay is the original defect — a failure
113 /// that is not waited on is the hot spin.
114 #[must_use = "an accept failure must be backed off, never ignored"]
115 pub fn failed(&mut self) -> Duration {
116 self.consecutive = self.consecutive.saturating_add(1);
117 if self.consecutive.is_power_of_two() {
118 crate::runtime::log::errlog_sev_printf(
119 crate::runtime::log::ErrlogSevEnum::Major,
120 &format!(
121 "accept failed {} time(s) in a row; the server is not taking \
122 connections and keeps retrying",
123 self.consecutive
124 ),
125 );
126 }
127 // Doubling, saturating at the ceiling. `checked_shl`-free: the shift
128 // amount is clamped so it can never reach the width of the type.
129 let shift = (self.consecutive - 1).min(32);
130 FIRST
131 .saturating_mul(1u32.checked_shl(shift).unwrap_or(u32::MAX))
132 .min(CEILING)
133 }
134
135 /// Failures since the last success. Diagnostics and tests.
136 pub fn consecutive_failures(&self) -> u32 {
137 self.consecutive
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144
145 #[test]
146 fn the_first_failure_waits_and_does_not_spin() {
147 let mut b = AcceptBackoff::new();
148 assert_eq!(b.failed(), FIRST);
149 }
150
151 #[test]
152 fn the_delay_doubles_then_saturates_at_the_ceiling() {
153 let mut b = AcceptBackoff::new();
154 let seen: Vec<Duration> = (0..10).map(|_| b.failed()).collect();
155 assert_eq!(seen[0], FIRST);
156 assert_eq!(seen[1], FIRST * 2);
157 assert_eq!(seen[2], FIRST * 4);
158 // Monotone, and never past the ceiling.
159 for w in seen.windows(2) {
160 assert!(w[1] >= w[0], "backoff went backwards: {seen:?}");
161 }
162 assert_eq!(*seen.last().unwrap(), CEILING);
163 }
164
165 /// The boundary that keeps a busy server alive under sustained `EMFILE`:
166 /// one accepted connection erases the whole failure history.
167 #[test]
168 fn one_success_resets_the_history() {
169 let mut b = AcceptBackoff::new();
170 for _ in 0..64 {
171 let _ = b.failed();
172 }
173 assert_eq!(b.consecutive_failures(), 64);
174 b.accepted();
175 assert_eq!(b.consecutive_failures(), 0);
176 assert_eq!(b.failed(), FIRST);
177 }
178
179 /// The boundary this type exists to hold after the give-up path was
180 /// removed: C `rsrv` never leaves its accept loop
181 /// (`caservertask.c:82-121`, and `cantProceed("Unreachable. Perpetual
182 /// thread.")` after it). Past the old 64-failure budget, and far past it,
183 /// there must still be a delay to wait — never an exit.
184 #[test]
185 fn the_loop_still_retries_long_after_the_old_give_up_budget() {
186 let mut b = AcceptBackoff::new();
187 for _ in 0..1_000 {
188 let d = b.failed();
189 assert!(
190 d > Duration::ZERO && d <= CEILING,
191 "a failure past the old budget must still yield a bounded wait, got {d:?}"
192 );
193 }
194 assert_eq!(b.consecutive_failures(), 1_000);
195 }
196
197 /// The counter must not wrap into a zero delay after a very long outage.
198 #[test]
199 fn a_saturated_counter_still_waits_the_ceiling() {
200 let mut b = AcceptBackoff {
201 consecutive: u32::MAX - 1,
202 };
203 assert_eq!(b.failed(), CEILING);
204 assert_eq!(b.failed(), CEILING, "saturating_add must not wrap to 0");
205 assert_eq!(b.consecutive_failures(), u32::MAX);
206 }
207
208 /// The console announcement is bounded and cannot be silenced at the
209 /// first occurrence — the property the RTEMS console needs, since the
210 /// per-failure `tracing` event reaches nothing there.
211 #[test]
212 fn the_console_announcement_is_first_then_powers_of_two() {
213 let announced: Vec<u32> = (1..=64u32).filter(|n| n.is_power_of_two()).collect();
214 assert_eq!(announced, vec![1, 2, 4, 8, 16, 32, 64]);
215 }
216}