git_policy_cli/bounded_push.rs
1//! What:
2//! Run the auto-push's real `git push` with a deadline,
3//! and derive the wait limits the push coordination uses.
4//! Why:
5//! A push whose connection stalls would otherwise hold the branch's push lock forever,
6//! and every later commit on that branch would wait behind it.
7//! The push is stopped when its deadline passes,
8//! the commit stays saved locally,
9//! and the failure is recorded like any other failed push.
10//!
11//! In TS you'd write (pseudocode):
12//! ```ts
13//! const { exitCode, output } = await nanoSpawn(gitPath, pushArgs, { cwd, signal: AbortSignal.timeout(deadlineMs) });
14//! ```
15
16/// The attempt record a finished or stopped push produces.
17use super::auto_push_single_flight::{PushAttempt, SingleFlightError};
18/// The diagnostic log of the wrapper.
19use super::diagnostic_log::debug;
20/// Starts that ride out a short shortage of processes.
21use super::start_retry::RetryStart;
22/// Owned OS strings of the child environment.
23use std::ffi::{OsStr, OsString};
24/// `File` is the shared anonymous output file; `Read`, `Seek` and `SeekFrom` read it back from its start.
25use std::fs::File;
26use std::io::{Read, Seek, SeekFrom};
27/// `Child` is the running push; `Command` describes it; `Stdio` connects its streams.
28use std::process::{Child, Command, ExitStatus, Stdio};
29/// Time spans and the monotonic clock.
30use std::time::{Duration, Instant};
31
32/// Tag used for diagnostics from this module.
33const TAG: &str = "cli-git";
34
35/// Environment variable that sets the push deadline in whole seconds.
36pub const PUSH_DEADLINE_VARIABLE: &str = "CLI_GIT_PUSH_DEADLINE_SECONDS";
37
38/// Environment variable that sets how long to wait for another invocation's push lock, in whole seconds.
39pub const LOCK_WAIT_VARIABLE: &str = "CLI_GIT_PUSH_LOCK_WAIT_SECONDS";
40
41/// How long a push may run when the variable is absent or unusable.
42pub const DEFAULT_PUSH_DEADLINE: Duration = Duration::from_secs(600);
43
44/// How long a stopped push gets to end after the polite stop signal before it is killed.
45pub const STOP_GRACE: Duration = Duration::from_secs(5);
46
47/// Extra time a waiter gives the lock holder beyond the holder's whole push,
48/// so a holder that runs to its own deadline is outwaited.
49pub const LOCK_WAIT_MARGIN: Duration = Duration::from_secs(60);
50
51/// The most output bytes kept from one push;
52/// a push that writes more is stopped.
53pub const MAX_PUSH_OUTPUT: u64 = 4 * 1024 * 1024;
54
55/// Exit code recorded for a push that this wrapper stopped,
56/// the convention of `timeout(1)`.
57pub const STOPPED_EXIT_CODE: i64 = 124;
58
59/// Lowest transfer speed in bytes per second Git tolerates on an HTTP transfer.
60pub const LOW_SPEED_LIMIT: &str = "1000";
61
62/// Seconds the transfer may stay below the lowest speed before Git aborts it.
63pub const LOW_SPEED_TIME: &str = "60";
64
65/// Pause between two looks at the running push.
66const POLL_PAUSE: Duration = Duration::from_millis(10);
67
68/// What:
69/// Read a positive whole number of seconds from one environment value.
70/// Why:
71/// A zero, negative, fractional, empty or non-text value is no setting at all,
72/// never "no limit",
73/// so a typo cannot remove a bound.
74///
75/// In TS you'd write (pseudocode):
76/// ```ts
77/// const seconds = Number(value); return Number.isInteger(seconds) && seconds > 0 ? seconds * 1000 : undefined;
78/// ```
79pub fn positive_seconds(name: &str, value: &OsStr) -> Option<Duration> {
80 let parsed: Option<u64> = match value.to_str() {
81 Some(text) => text.trim().parse::<u64>().ok(),
82 None => None,
83 };
84 return match parsed {
85 Some(count) if count > 0 => Some(Duration::from_secs(count)),
86 Some(_) | None => {
87 debug(
88 TAG,
89 &format!("{name} is not a positive whole number; ignoring it"),
90 );
91 None
92 }
93 };
94}
95
96/// What:
97/// Read a positive whole number of seconds from the child environment.
98/// Why:
99/// The last of repeated entries wins,
100/// as in an environment block.
101///
102/// In TS you'd write (pseudocode):
103/// ```ts
104/// positiveSeconds(env[name])
105/// ```
106fn seconds_variable(environment: &[(OsString, OsString)], name: &str) -> Option<Duration> {
107 let mut found: Option<&OsString> = None;
108 for (key, value) in environment {
109 if key.to_str() == Some(name) {
110 found = Some(value);
111 }
112 }
113 let value: &OsString = found?;
114 return positive_seconds(name, value.as_os_str());
115}
116
117/// What:
118/// The push deadline: the environment's value, or the default.
119/// Why:
120/// A person may need a longer deadline on a slow link or a shorter one on a flaky one.
121///
122/// In TS you'd write (pseudocode):
123/// ```ts
124/// const deadlineMs = secondsOf(env.CLI_GIT_PUSH_DEADLINE_SECONDS) ?? DEFAULT_MS;
125/// ```
126pub fn push_deadline(environment: &[(OsString, OsString)]) -> Duration {
127 return seconds_variable(environment, PUSH_DEADLINE_VARIABLE).unwrap_or(DEFAULT_PUSH_DEADLINE);
128}
129
130/// What:
131/// How long a process waits for another invocation's push lock:
132/// the environment's value, or the push deadline plus the stop grace plus a margin.
133/// Why:
134/// The holder may run its whole push deadline and then needs the stop grace to end,
135/// so the default waiter outlasts a holder that is merely slow before it reports the lock as stuck.
136///
137/// In TS you'd write (pseudocode):
138/// ```ts
139/// const lockWaitMs = secondsOf(env.CLI_GIT_PUSH_LOCK_WAIT_SECONDS) ?? pushDeadlineMs + STOP_GRACE_MS + LOCK_WAIT_MARGIN_MS;
140/// ```
141pub fn lock_wait_limit(environment: &[(OsString, OsString)]) -> Duration {
142 if let Some(limit) = seconds_variable(environment, LOCK_WAIT_VARIABLE) {
143 return limit;
144 }
145 return push_deadline(environment)
146 .saturating_add(STOP_GRACE)
147 .saturating_add(LOCK_WAIT_MARGIN);
148}
149
150/// What:
151/// Whether the environment already sets a name.
152/// Why:
153/// The low-speed defaults must not override a person's own setting.
154///
155/// In TS you'd write (pseudocode):
156/// ```ts
157/// name in env
158/// ```
159fn sets(environment: &[(OsString, OsString)], name: &str) -> bool {
160 for (key, _) in environment {
161 if key.to_str() == Some(name) {
162 return true;
163 }
164 }
165 return false;
166}
167
168/// What:
169/// Add Git's low-speed abort to a push command unless the environment sets it.
170/// Why:
171/// Git ends an HTTP transfer that stays under the limit for the time,
172/// which also ends its helper processes;
173/// the push deadline is only the backstop for transports this does not cover.
174/// Git lets these variables override `http.lowSpeedLimit` and `http.lowSpeedTime` from its configuration,
175/// so only a person's environment variables are kept, not their configuration.
176///
177/// In TS you'd write (pseudocode):
178/// ```ts
179/// env.GIT_HTTP_LOW_SPEED_LIMIT ??= '1000'; env.GIT_HTTP_LOW_SPEED_TIME ??= '60';
180/// ```
181pub fn apply_low_speed_defaults(command: &mut Command, environment: &[(OsString, OsString)]) {
182 if !sets(environment, "GIT_HTTP_LOW_SPEED_LIMIT") {
183 command.env("GIT_HTTP_LOW_SPEED_LIMIT", LOW_SPEED_LIMIT);
184 }
185 if !sets(environment, "GIT_HTTP_LOW_SPEED_TIME") {
186 command.env("GIT_HTTP_LOW_SPEED_TIME", LOW_SPEED_TIME);
187 }
188}
189
190/// Remove exactly one trailing `\n` or `\r\n`, as the captured output of the incumbent does.
191///
192/// In TS you'd write (pseudocode):
193/// ```ts
194/// output.endsWith('\n') ? output.slice(0, output.endsWith('\r\n') ? -2 : -1) : output
195/// ```
196pub fn strip_one_line_break(text: &str) -> &str {
197 let without_lf = match text.strip_suffix('\n') {
198 Some(stripped) => stripped,
199 None => return text,
200 };
201 return without_lf.strip_suffix('\r').unwrap_or(without_lf);
202}
203
204/// Ask a running push to end,
205/// so Git can stop the helper processes it started.
206///
207/// In TS you'd write (pseudocode):
208/// ```ts
209/// child.kill('SIGTERM');
210/// ```
211#[cfg(unix)]
212fn ask_to_stop(child: &Child) {
213 let Ok(pid) = libc::pid_t::try_from(child.id()) else {
214 return;
215 };
216 // SAFETY: `kill` sends one signal to the PID of a child this module started and has not reaped;
217 // a failure is ignored because the child may have ended.
218 unsafe { libc::kill(pid, libc::SIGTERM) };
219}
220
221/// Platforms without a polite stop signal go straight to the kill.
222// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
223#[cfg(not(unix))]
224fn ask_to_stop(_child: &Child) {}
225
226/// Whether part of the grace is still left after the stop request.
227///
228/// In TS you'd write (pseudocode):
229/// ```ts
230/// asked < grace
231/// ```
232fn grace_remains(asked: Duration, grace: Duration) -> bool {
233 return asked < grace;
234}
235
236/// Whether an output of this size is a flood: only a size past the cap is.
237///
238/// In TS you'd write (pseudocode):
239/// ```ts
240/// size > MAX_PUSH_OUTPUT
241/// ```
242fn is_flood(size: u64) -> bool {
243 return size > MAX_PUSH_OUTPUT;
244}
245
246/// What:
247/// Stop a running push: ask politely,
248/// wait the grace,
249/// then kill and collect it.
250/// Why:
251/// Git's own signal handling ends the helpers it started,
252/// and a push that ignores the request cannot outlive the kill.
253///
254/// In TS you'd write (pseudocode):
255/// ```ts
256/// child.kill('SIGTERM'); await Promise.race([exited, delay(graceMs)]) || child.kill('SIGKILL');
257/// ```
258pub fn stop_push(child: &mut Child, grace: Duration) {
259 ask_to_stop(child);
260 let asked: Instant = Instant::now();
261 while grace_remains(asked.elapsed(), grace) {
262 match child.try_wait() {
263 Ok(Some(_)) | Err(_) => return,
264 Ok(None) => std::thread::sleep(POLL_PAUSE),
265 }
266 }
267 // The child is being abandoned; its exit is not reported, only the cause.
268 let _killed = child.kill();
269 let _collected = child.wait();
270}
271
272/// Read the shared output file back from its start,
273/// keeping at most the output cap so a flood is never held in memory.
274///
275/// In TS you'd write (pseudocode):
276/// ```ts
277/// await readFile(outputPath, 'utf8')
278/// ```
279fn read_output(file: &mut File) -> Result<String, SingleFlightError> {
280 file.seek(SeekFrom::Start(0))
281 .map_err(SingleFlightError::Start)?;
282 let mut bytes: Vec<u8> = Vec::new();
283 file.take(MAX_PUSH_OUTPUT)
284 .read_to_end(&mut bytes)
285 .map_err(SingleFlightError::Start)?;
286 return Ok(String::from_utf8_lossy(&bytes).into_owned());
287}
288
289/// Why a push or query was stopped.
290pub enum StopCause {
291 /// The deadline passed.
292 Deadline,
293 /// The output grew past the cap.
294 Flood,
295}
296
297/// What:
298/// Look at a running push until it ends,
299/// its deadline passes or its output floods.
300/// Why:
301/// `std` has no timed wait,
302/// so the parent looks with `try_wait`,
303/// which never blocks.
304///
305/// In TS you'd write (pseudocode):
306/// ```ts
307/// await Promise.race([exited, deadline, flood]);
308/// ```
309pub fn wait_until_done(
310 child: &mut Child,
311 outputs: &[&File],
312 deadline: Duration,
313) -> Result<Result<ExitStatus, StopCause>, SingleFlightError> {
314 let started: Instant = Instant::now();
315 loop {
316 if let Some(status) = child.try_wait().map_err(SingleFlightError::Start)? {
317 return Ok(Ok(status));
318 }
319 for output in outputs {
320 let size: u64 = match output.metadata() {
321 Ok(metadata) => metadata.len(),
322 Err(_) => u64::MAX,
323 };
324 if is_flood(size) {
325 return Ok(Err(StopCause::Flood));
326 }
327 }
328 let elapsed: Duration = started.elapsed();
329 if elapsed >= deadline {
330 return Ok(Err(StopCause::Deadline));
331 }
332 std::thread::sleep(POLL_PAUSE.min(deadline.saturating_sub(elapsed)));
333 }
334}
335
336/// What:
337/// Run a push command to its end or its deadline and capture its interleaved output.
338/// Why:
339/// Both streams share one anonymous file so lines keep the order Git wrote them,
340/// and a file needs no reader thread that a stuck helper could keep alive.
341/// A stopped push becomes a failed attempt that says so,
342/// so the commit stays saved and the failure is recorded for later invocations.
343///
344/// In TS you'd write (pseudocode):
345/// ```ts
346/// async function runBoundedPush({ command, deadlineMs, graceMs }): Promise<PushAttempt>;
347/// ```
348pub fn run_bounded_push(
349 mut command: Command,
350 deadline: Duration,
351 grace: Duration,
352) -> Result<PushAttempt, SingleFlightError> {
353 let mut output: File = tempfile::tempfile().map_err(SingleFlightError::Start)?;
354 let for_stdout: File = output.try_clone().map_err(SingleFlightError::Start)?;
355 let for_stderr: File = output.try_clone().map_err(SingleFlightError::Start)?;
356 command
357 .stdin(Stdio::null())
358 .stdout(Stdio::from(for_stdout))
359 .stderr(Stdio::from(for_stderr));
360 let mut child: Child = command.spawn_retrying().map_err(SingleFlightError::Start)?;
361 drop(command);
362 let ended: Result<ExitStatus, StopCause> = wait_until_done(&mut child, &[&output], deadline)?;
363 if ended.is_err() {
364 stop_push(&mut child, grace);
365 }
366 let captured: String = read_output(&mut output)?;
367 let shown: &str = strip_one_line_break(captured.as_str());
368 return Ok(match ended {
369 Ok(status) => PushAttempt {
370 exit_code: i64::from(status.code().unwrap_or(1)),
371 output: shown.to_owned(),
372 },
373 Err(cause) => {
374 let reason: String = match cause {
375 StopCause::Deadline => format!(
376 "cli-git: git push did not finish within {} seconds and was stopped; \
377 set {PUSH_DEADLINE_VARIABLE} to allow longer. Hint: if you were pushing over a slow or \
378 offline connection, your commit is already saved; push again when the connection works.",
379 deadline.as_secs()
380 ),
381 StopCause::Flood => format!(
382 "cli-git: git push wrote more than {MAX_PUSH_OUTPUT} bytes of output and was stopped. Hint: if a hook or the \
383 remote prints this much, run `git ls-remote` to see whether your commits arrived, and read the \
384 hook's own output by running it directly before pushing again."
385 ),
386 };
387 debug(TAG, &reason);
388 let joined: String = if shown.is_empty() {
389 reason
390 } else {
391 format!("{shown}\n{reason}")
392 };
393 PushAttempt {
394 exit_code: STOPPED_EXIT_CODE,
395 output: joined,
396 }
397 }
398 });
399}
400
401/// Bounded push behavior is controlled in the test module.
402#[cfg(test)]
403#[path = "bounded_push_tests.rs"]
404mod tests;