Skip to main content

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;