git_policy_cli/push/start_retry.rs
1//! What:
2//! Starting a child process again when the operating system refuses it for lack of processes.
3//! Why:
4//! A host that has run out of processes (a process limit, a cgroup limit) refuses a start with `EAGAIN`,
5//! which Rust reports as `ErrorKind::WouldBlock`.
6//! A refused start started nothing, so starting again is always safe,
7//! and such a shortage usually ends within moments.
8//! Without this the first refusal ends a commit that the next attempt would have finished.
9//! The wait is bounded, so the wrapper still never stalls: a shortage that outlasts the budget fails with the
10//! same error as before plus how often the start was refused.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! for (let delay = 10; ; delay = Math.min(delay * 2, 500)) { try { return start(); } catch (error) { if (!isEagain(error) || waited > 5000) throw error; await wait(delay); } }
15//! ```
16
17/// Diagnostic output to standard error for each refused start.
18use crate::diagnostic_log::warn;
19/// Error kinds, and the result type of a start.
20use std::io::{Error, ErrorKind, Result};
21/// The child process types a start produces.
22use std::process::{Child, Command, ExitStatus, Output};
23/// A span of time.
24use std::time::Duration;
25
26/// How long a refused start may wait in total, summed over its pauses.
27pub const START_RETRY_BUDGET: Duration = Duration::from_secs(5);
28
29/// The first pause after a refusal.
30const FIRST_PAUSE: Duration = Duration::from_millis(10);
31
32/// The longest single pause; the pauses double until they reach it.
33const LONGEST_PAUSE: Duration = Duration::from_millis(500);
34
35/// Log tag of the retry warnings.
36const TAG: &str = "startRetry";
37
38/// What:
39/// Run `start` until it is not refused for lack of processes, pausing between tries.
40/// Why:
41/// Only `WouldBlock` is retried; every other error is the caller's, at once.
42/// The budget counts the pauses, which dominate the time a refused start takes,
43/// so a test can drive it with a recording pause and no real waiting.
44/// The final error keeps its kind and says how often the start was refused.
45///
46/// In TS you'd write (pseudocode):
47/// ```ts
48/// await retryRefusedStart({ what, budgetMs, pause, start });
49/// ```
50pub fn retry_refused_start_with<T>(
51 what: &str,
52 budget: Duration,
53 pause: &mut dyn FnMut(Duration),
54 start: &mut dyn FnMut() -> Result<T>,
55) -> Result<T> {
56 // `mut` lets the loop count its refusals and the time it has paused.
57 let mut refusals: u32 = 0;
58 let mut paused: Duration = Duration::ZERO;
59 let mut delay: Duration = FIRST_PAUSE;
60 loop {
61 let error: Error = match start() {
62 Ok(started) => return Ok(started),
63 Err(error) if error.kind() == ErrorKind::WouldBlock => error,
64 Err(error) => return Err(error),
65 };
66 refusals += 1;
67 if paused + delay > budget {
68 return Err(Error::new(
69 ErrorKind::WouldBlock,
70 format!("{error}; the start was refused {refusals} times over {} ms", paused.as_millis()),
71 ));
72 }
73 warn(
74 TAG,
75 format!(
76 "starting {what} was refused for lack of processes ({error}); trying again in {} ms",
77 delay.as_millis()
78 )
79 .as_str(),
80 );
81 pause(delay);
82 paused += delay;
83 delay = (delay * 2).min(LONGEST_PAUSE);
84 }
85}
86
87/// What:
88/// Run `start`, pausing for real between refused tries within the standard budget.
89/// Why:
90/// The production entry; tests use `retry_refused_start_with`.
91///
92/// In TS you'd write (pseudocode):
93/// ```ts
94/// await retryRefusedStart({ what, start });
95/// ```
96pub fn retry_refused_start<T>(what: &str, start: &mut dyn FnMut() -> Result<T>) -> Result<T> {
97 return retry_refused_start_with(what, START_RETRY_BUDGET, &mut |delay: Duration| { std::thread::sleep(delay); }, start);
98}
99
100/// Starts of a command that ride out a short shortage of processes.
101pub trait RetryStart {
102 /// Start the child without waiting for it.
103 fn spawn_retrying(&mut self) -> Result<Child>;
104 /// Start the child and collect its output.
105 fn output_retrying(&mut self) -> Result<Output>;
106 /// Start the child with inherited streams and wait for it.
107 fn status_retrying(&mut self) -> Result<ExitStatus>;
108}
109
110/// The three ways of starting a command, each retried when the start is refused.
111impl RetryStart for Command {
112 /// `Command::spawn`, retried.
113 fn spawn_retrying(&mut self) -> Result<Child> {
114 let what: String = self.get_program().to_string_lossy().into_owned();
115 return retry_refused_start(what.as_str(), &mut || { return self.spawn(); });
116 }
117
118 /// `Command::output`, retried.
119 /// A refusal comes only from the start, so no child has run when it is retried.
120 fn output_retrying(&mut self) -> Result<Output> {
121 let what: String = self.get_program().to_string_lossy().into_owned();
122 return retry_refused_start(what.as_str(), &mut || { return self.output(); });
123 }
124
125 /// `Command::status`, retried.
126 fn status_retrying(&mut self) -> Result<ExitStatus> {
127 let what: String = self.get_program().to_string_lossy().into_owned();
128 return retry_refused_start(what.as_str(), &mut || { return self.status(); });
129 }
130}
131
132/// What:
133/// Replace this process with the command, trying again when the replacement is refused for lack of processes.
134/// Returns only the error that ended the attempts.
135/// Why:
136/// `exec` returns only when it failed, and a failed `exec` replaced nothing.
137///
138/// In TS you'd write (pseudocode):
139/// ```ts
140/// // process.execve(command) with the same retry
141/// ```
142#[cfg(unix)]
143pub fn exec_retrying(command: &mut Command) -> Error {
144 /// The Unix extension that adds `exec` to a command.
145 use std::os::unix::process::CommandExt;
146 let what: String = command.get_program().to_string_lossy().into_owned();
147 let refused: Result<()> = retry_refused_start(what.as_str(), &mut || { return Err(command.exec()); });
148 match refused {
149 Err(error) => return error,
150 // `exec` never returns success; the closure above only ever returns an error.
151 Ok(()) => return Error::other("exec returned without an error"),
152 }
153}
154
155/// Retry controls stay out of the release executable.
156#[cfg(test)]
157#[path = "start_retry_tests.rs"]
158mod tests;