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