Skip to main content

rightkit_child_process/
restart.rs

1use std::{collections::VecDeque, error::Error, fmt, time::Duration};
2
3#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4pub enum RestartMode {
5    Automatic,
6    OnDemand,
7    Disabled,
8}
9
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub struct RestartPolicy {
12    mode: RestartMode,
13    max_restarts: u32,
14    window: Duration,
15    base_backoff: Duration,
16    max_backoff: Duration,
17    consecutive: Option<ConsecutiveFailures>,
18    reset_budget_on_ready: bool,
19}
20
21/// Cap on restarts granted in a row without the replacement ever reporting ready.
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23pub struct ConsecutiveFailures {
24    /// Restarts in a row (no `RestartTracker::ready` between them) after which the cap applies.
25    pub cap: u32,
26    /// What the cap does: the next restart waits this long instead of the exponential backoff and a
27    /// new consecutive count starts. `Duration::ZERO` ends the run (`RestartOutcome::Exhausted`).
28    pub cooldown: Duration,
29}
30
31impl RestartPolicy {
32    /// Add a consecutive-failure cap on top of the rolling-window budget (HeardRight's rule: a
33    /// replacement that never becomes ready is not retried at backoff speed forever). The rolling
34    /// budget still applies first: when it is spent the outcome is `Exhausted` whatever the cap says.
35    /// `cap` must be at least 1.
36    pub fn with_consecutive_failures(
37        mut self,
38        cap: u32,
39        cooldown: Duration,
40    ) -> Result<Self, InvalidRestartPolicy> {
41        if cap == 0 {
42            return Err(InvalidRestartPolicy);
43        }
44        self.consecutive = Some(ConsecutiveFailures { cap, cooldown });
45        Ok(self)
46    }
47    /// When a generation reports ready ([`RestartTracker::ready`]), also forget the restarts counted
48    /// in the rolling window, so the next crash starts again at the base backoff with a full budget.
49    /// Off by default: a crash loop that reaches ready each time is then still limited by the window.
50    pub fn with_reset_budget_on_ready(mut self) -> Self {
51        self.reset_budget_on_ready = true;
52        self
53    }
54    pub fn consecutive_failures(&self) -> Option<ConsecutiveFailures> {
55        self.consecutive
56    }
57    pub fn resets_budget_on_ready(&self) -> bool {
58        self.reset_budget_on_ready
59    }
60    pub fn mode(&self) -> RestartMode {
61        self.mode
62    }
63    pub fn max_restarts(&self) -> u32 {
64        self.max_restarts
65    }
66    pub fn window(&self) -> Duration {
67        self.window
68    }
69    pub fn base_backoff(&self) -> Duration {
70        self.base_backoff
71    }
72    pub fn max_backoff(&self) -> Duration {
73        self.max_backoff
74    }
75}
76
77impl Default for RestartPolicy {
78    /// ScrapeRight's crash-recovery plan: restart automatically, at most 3
79    /// unexpected restarts per rolling 60 s window, backoff 250 ms doubling to 5 s.
80    fn default() -> Self {
81        Self {
82            mode: RestartMode::Automatic,
83            max_restarts: 3,
84            window: Duration::from_secs(60),
85            base_backoff: Duration::from_millis(250),
86            max_backoff: Duration::from_secs(5),
87            consecutive: None,
88            reset_budget_on_ready: false,
89        }
90    }
91}
92
93#[derive(Clone, Copy, Debug, Eq, PartialEq)]
94pub struct InvalidRestartPolicy;
95
96impl fmt::Display for InvalidRestartPolicy {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        f.write_str("restart window must be non-zero, max backoff must cover base backoff, and a consecutive-failure cap must be at least 1")
99    }
100}
101
102impl Error for InvalidRestartPolicy {}
103
104impl RestartPolicy {
105    pub fn new(
106        mode: RestartMode,
107        max_restarts: u32,
108        window: Duration,
109        base_backoff: Duration,
110        max_backoff: Duration,
111    ) -> Result<Self, InvalidRestartPolicy> {
112        if window.is_zero() || max_backoff < base_backoff {
113            return Err(InvalidRestartPolicy);
114        }
115        Ok(Self {
116            mode,
117            max_restarts,
118            window,
119            base_backoff,
120            max_backoff,
121            consecutive: None,
122            reset_budget_on_ready: false,
123        })
124    }
125}
126
127#[derive(Clone, Copy, Debug, Eq, PartialEq)]
128pub struct Generation(u64);
129
130#[derive(Clone, Copy, Debug, Eq, PartialEq)]
131pub struct RestartPermit {
132    epoch: u64,
133    delay: Duration,
134    not_before: Duration,
135}
136
137impl RestartPermit {
138    pub fn delay(&self) -> Duration {
139        self.delay
140    }
141}
142
143#[derive(Clone, Copy, Debug, Eq, PartialEq)]
144pub enum RestartOutcome {
145    Restart(RestartPermit),
146    AwaitingDemand,
147    Exhausted,
148    IgnoredStale,
149    Stopped,
150}
151
152#[derive(Clone, Debug)]
153pub struct RestartTracker {
154    policy: RestartPolicy,
155    active: Option<Generation>,
156    next_generation: u64,
157    restart_times: VecDeque<Duration>,
158    consecutive: u32,
159    permit_epoch: u64,
160    awaiting_demand: bool,
161    shutdown: bool,
162}
163
164impl RestartTracker {
165    pub fn new(policy: RestartPolicy) -> Self {
166        Self {
167            policy,
168            active: None,
169            next_generation: 0,
170            restart_times: VecDeque::new(),
171            consecutive: 0,
172            permit_epoch: 0,
173            awaiting_demand: false,
174            shutdown: false,
175        }
176    }
177
178    pub fn started(&mut self, _at: Duration) -> Generation {
179        self.next_generation = self.next_generation.saturating_add(1);
180        self.permit_epoch = self.permit_epoch.saturating_add(1);
181        self.awaiting_demand = false;
182        let generation = Generation(self.next_generation);
183        self.active = Some(generation);
184        generation
185    }
186
187    pub fn exited(&mut self, generation: Generation, at: Duration) -> RestartOutcome {
188        if self.shutdown {
189            return RestartOutcome::Stopped;
190        }
191        if self.active != Some(generation) {
192            return RestartOutcome::IgnoredStale;
193        }
194        self.active = None;
195        match self.policy.mode {
196            RestartMode::Automatic => self.schedule(at),
197            RestartMode::OnDemand => {
198                self.awaiting_demand = true;
199                RestartOutcome::AwaitingDemand
200            }
201            RestartMode::Disabled => RestartOutcome::Stopped,
202        }
203    }
204
205    pub fn request_restart(&mut self, at: Duration) -> RestartOutcome {
206        if self.shutdown || self.policy.mode == RestartMode::Disabled {
207            return RestartOutcome::Stopped;
208        }
209        if self.policy.mode == RestartMode::OnDemand && !self.awaiting_demand {
210            return RestartOutcome::AwaitingDemand;
211        }
212        self.awaiting_demand = false;
213        self.schedule(at)
214    }
215
216    /// The running `generation` reported ready: the consecutive-failure count restarts from zero
217    /// and, when the policy says so ([`RestartPolicy::with_reset_budget_on_ready`]), so does the
218    /// rolling budget. A stale generation changes nothing.
219    pub fn ready(&mut self, generation: Generation) {
220        if self.active != Some(generation) {
221            return;
222        }
223        self.consecutive = 0;
224        if self.policy.reset_budget_on_ready {
225            self.restart_times.clear();
226        }
227    }
228
229    /// Restarts granted in a row since the last [`Self::ready`] (diagnostics).
230    pub fn consecutive_failures(&self) -> u32 {
231        self.consecutive
232    }
233
234    /// Restarts granted inside the rolling window ending at `at` (diagnostics).
235    pub fn restarts_in_window(&self, at: Duration) -> u32 {
236        let window_start = at.saturating_sub(self.policy.window);
237        self.restart_times
238            .iter()
239            .filter(|t| **t >= window_start)
240            .count() as u32
241    }
242
243    /// Forget the restart budget (a user-initiated start after a terminal stop).
244    pub fn reset_budget(&mut self) {
245        self.restart_times.clear();
246        self.consecutive = 0;
247        self.awaiting_demand = false;
248        self.shutdown = false;
249    }
250
251    pub fn permit_valid(&self, permit: &RestartPermit, at: Duration) -> bool {
252        !self.shutdown && permit.epoch == self.permit_epoch && at >= permit.not_before
253    }
254
255    pub fn shutdown(&mut self) {
256        self.shutdown = true;
257        self.active = None;
258        self.awaiting_demand = false;
259        self.permit_epoch = self.permit_epoch.saturating_add(1);
260    }
261
262    fn schedule(&mut self, at: Duration) -> RestartOutcome {
263        let window_start = at.saturating_sub(self.policy.window);
264        while self
265            .restart_times
266            .front()
267            .is_some_and(|restart| *restart < window_start)
268        {
269            self.restart_times.pop_front();
270        }
271        if self.restart_times.len() >= self.policy.max_restarts as usize {
272            return RestartOutcome::Exhausted;
273        }
274
275        let capped = match self.policy.consecutive {
276            Some(c) if self.consecutive >= c.cap => {
277                if c.cooldown.is_zero() {
278                    return RestartOutcome::Exhausted;
279                }
280                Some(c.cooldown)
281            }
282            _ => None,
283        };
284        let delay = match capped {
285            Some(cooldown) => {
286                // The cap was served by this cooldown; a new consecutive count starts.
287                self.consecutive = 0;
288                cooldown
289            }
290            None => {
291                let exponent = u32::try_from(self.restart_times.len()).unwrap_or(u32::MAX);
292                let multiplier = 1_u32.checked_shl(exponent.min(31)).unwrap_or(u32::MAX);
293                self.policy
294                    .base_backoff
295                    .checked_mul(multiplier)
296                    .unwrap_or(self.policy.max_backoff)
297                    .min(self.policy.max_backoff)
298            }
299        };
300        self.consecutive = self.consecutive.saturating_add(1);
301        self.restart_times.push_back(at);
302        self.permit_epoch = self.permit_epoch.saturating_add(1);
303        let permit = RestartPermit {
304            epoch: self.permit_epoch,
305            delay,
306            not_before: at.checked_add(delay).unwrap_or(Duration::MAX),
307        };
308        RestartOutcome::Restart(permit)
309    }
310}