Skip to main content

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}