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//! Why:
4//!  A push whose connection stalls would otherwise hold the service's push of that branch forever,
5//!  and every later push of the repository would wait behind it.
6//!  The push is stopped when its deadline passes,
7//!  the commit stays saved locally,
8//!  and the failure is reported like any other failed push.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const { exitCode, output } = await nanoSpawn(gitPath, pushArgs, { cwd, signal: AbortSignal.timeout(deadlineMs) });
13//! ```
14
15/// The attempt record a finished or stopped push produces.
16use super::auto_push_single_flight::{PushAttempt, SingleFlightError};
17/// The diagnostic log of the wrapper.
18use super::diagnostic_log::debug;
19/// How to allow a push longer, in the words of the program that runs it.
20use super::push_deadline_advice::extend_clause;
21/// The helpers and hooks a stopped push leaves running.
22use super::process_tree::{TreeMember, descendants_of, kill_members, still_running, terminate_members};
23/// Starts that ride out a short shortage of processes.
24/// The anonymous output file of the push, made in the per-user scratch directory.
25use super::scratch_location::scratch_file;
26use super::start_retry::RetryStart;
27/// Owned OS strings of the child environment.
28use std::ffi::{OsStr, OsString};
29/// `File` is the shared anonymous output file; `Read`, `Seek` and `SeekFrom` read it back from its start.
30use std::fs::File;
31use std::io::{Read, Seek, SeekFrom};
32/// `Child` is the running push; `Command` describes it; `Stdio` connects its streams.
33use std::process::{Child, Command, ExitStatus, Stdio};
34/// Time spans and the monotonic clock.
35use std::time::{Duration, Instant};
36
37/// Tag used for diagnostics from this module.
38const TAG: &str = "cli-git";
39
40/// Environment variable that sets the push deadline in whole seconds.
41pub const PUSH_DEADLINE_VARIABLE: &str = "CLI_GIT_PUSH_DEADLINE_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(30);
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/// The most output bytes kept from one push;
50///  a push that writes more is stopped.
51pub const MAX_PUSH_OUTPUT: u64 = 4 * 1024 * 1024;
52
53/// Exit code recorded for a push that this wrapper stopped,
54///  the convention of `timeout(1)`.
55pub const STOPPED_EXIT_CODE: i64 = 124;
56
57/// Lowest transfer speed in bytes per second Git tolerates on an HTTP transfer.
58pub const LOW_SPEED_LIMIT: &str = "1000";
59
60/// Seconds the transfer may stay below the lowest speed before Git aborts it.
61pub const LOW_SPEED_TIME: &str = "60";
62
63/// Pause between two looks at the running push.
64const POLL_PAUSE: Duration = Duration::from_millis(10);
65
66/// What:
67///  Read a positive whole number of seconds from one environment value.
68/// Why:
69///   A zero, negative, fractional, empty or non-text value is no setting at all,
70///  never "no limit",
71///  so a typo cannot remove a bound.
72///
73/// In TS you'd write (pseudocode):
74/// ```ts
75/// const seconds = Number(value); return Number.isInteger(seconds) && seconds > 0 ? seconds * 1000 : undefined;
76/// ```
77pub fn positive_seconds(name: &str, value: &OsStr) -> Option<Duration> {
78    let parsed: Option<u64> = match value.to_str() {
79        Some(text) => text.trim().parse::<u64>().ok(),
80        None => None,
81    };
82    return match parsed {
83        Some(count) if count > 0 => Some(Duration::from_secs(count)),
84        Some(_) | None => {
85            debug(
86                TAG,
87                &format!("{name} is not a positive whole number; ignoring it"),
88            );
89            None
90        }
91    };
92}
93
94/// What:
95///  Read a positive whole number of seconds from the child environment.
96/// Why:
97///   The last of repeated entries wins,
98///  as in an environment block.
99///
100/// In TS you'd write (pseudocode):
101/// ```ts
102/// positiveSeconds(env[name])
103/// ```
104fn seconds_variable(environment: &[(OsString, OsString)], name: &str) -> Option<Duration> {
105    let mut found: Option<&OsString> = None;
106    for (key, value) in environment {
107        if key.to_str() == Some(name) {
108            found = Some(value);
109        }
110    }
111    let value: &OsString = found?;
112    return positive_seconds(name, value.as_os_str());
113}
114
115/// What:
116///  The push deadline: the environment's value, or the default.
117/// Why:
118///   A person may need a longer deadline on a slow link or a shorter one on a flaky one.
119///
120/// In TS you'd write (pseudocode):
121/// ```ts
122/// const deadlineMs = secondsOf(env.CLI_GIT_PUSH_DEADLINE_SECONDS) ?? DEFAULT_MS;
123/// ```
124pub fn push_deadline(environment: &[(OsString, OsString)]) -> Duration {
125    return seconds_variable(environment, PUSH_DEADLINE_VARIABLE).unwrap_or(DEFAULT_PUSH_DEADLINE);
126}
127
128/// What:
129///  Whether the environment already sets a name.
130/// Why:
131///   The low-speed defaults must not override a person's own setting.
132///
133/// In TS you'd write (pseudocode):
134/// ```ts
135/// name in env
136/// ```
137fn sets(environment: &[(OsString, OsString)], name: &str) -> bool {
138    for (key, _) in environment {
139        if key.to_str() == Some(name) {
140            return true;
141        }
142    }
143    return false;
144}
145
146/// What:
147///  Add Git's low-speed abort to a push command unless the environment sets it.
148/// Why:
149///   Git ends an HTTP transfer that stays under the limit for the time,
150///  which also ends its helper processes;
151///  the push deadline is only the backstop for transports this does not cover.
152///  Git lets these variables override `http.lowSpeedLimit` and `http.lowSpeedTime` from its configuration,
153///  so only a person's environment variables are kept, not their configuration.
154///
155/// In TS you'd write (pseudocode):
156/// ```ts
157/// env.GIT_HTTP_LOW_SPEED_LIMIT ??= '1000'; env.GIT_HTTP_LOW_SPEED_TIME ??= '60';
158/// ```
159pub fn apply_low_speed_defaults(command: &mut Command, environment: &[(OsString, OsString)]) {
160    if !sets(environment, "GIT_HTTP_LOW_SPEED_LIMIT") {
161        command.env("GIT_HTTP_LOW_SPEED_LIMIT", LOW_SPEED_LIMIT);
162    }
163    if !sets(environment, "GIT_HTTP_LOW_SPEED_TIME") {
164        command.env("GIT_HTTP_LOW_SPEED_TIME", LOW_SPEED_TIME);
165    }
166}
167
168/// Remove exactly one trailing `\n` or `\r\n`, as the captured output of the incumbent does.
169///
170/// In TS you'd write (pseudocode):
171/// ```ts
172/// output.endsWith('\n') ? output.slice(0, output.endsWith('\r\n') ? -2 : -1) : output
173/// ```
174pub fn strip_one_line_break(text: &str) -> &str {
175    let without_lf = match text.strip_suffix('\n') {
176        Some(stripped) => stripped,
177        None => return text,
178    };
179    return without_lf.strip_suffix('\r').unwrap_or(without_lf);
180}
181
182/// Ask a running push to end,
183///  so Git can stop the helper processes it started.
184///
185/// In TS you'd write (pseudocode):
186/// ```ts
187/// child.kill('SIGTERM');
188/// ```
189#[cfg(unix)]
190fn ask_to_stop(child: &Child) {
191    let Ok(pid) = libc::pid_t::try_from(child.id()) else {
192        return;
193    };
194    // SAFETY: `kill` sends one signal to the PID of a child this module started and has not reaped;
195    // a failure is ignored because the child may have ended.
196    unsafe { libc::kill(pid, libc::SIGTERM) };
197}
198
199/// Platforms without a polite stop signal go straight to the kill.
200// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
201#[cfg(not(unix))]
202fn ask_to_stop(_child: &Child) {}
203
204/// Whether part of the grace is still left after the stop request.
205///
206/// In TS you'd write (pseudocode):
207/// ```ts
208/// asked < grace
209/// ```
210fn grace_remains(asked: Duration, grace: Duration) -> bool {
211    return asked < grace;
212}
213
214/// Whether an output of this size is a flood: only a size past the cap is.
215///
216/// In TS you'd write (pseudocode):
217/// ```ts
218/// size > MAX_PUSH_OUTPUT
219/// ```
220fn is_flood(size: u64) -> bool {
221    return size > MAX_PUSH_OUTPUT;
222}
223
224/// What:
225///  Stop a running push: ask politely,
226///  wait the grace,
227///  then kill and collect it.
228/// Why:
229///   Git's own signal handling ends the helpers it started,
230///  and a push that ignores the request cannot outlive the kill.
231///
232/// In TS you'd write (pseudocode):
233/// ```ts
234/// child.kill('SIGTERM'); await Promise.race([exited, delay(graceMs)]) || child.kill('SIGKILL');
235/// ```
236pub fn stop_push(child: &mut Child, grace: Duration) {
237    // Read before the push is signalled: once it has exited, its helpers and hooks no longer point to it.
238    let below: Vec<TreeMember> = i32::try_from(child.id()).map(descendants_of).unwrap_or_default();
239    ask_to_stop(child);
240    terminate_members(below.as_slice());
241    let asked: Instant = Instant::now();
242    let mut ended: bool = false;
243    while !ended && grace_remains(asked.elapsed(), grace) {
244        match child.try_wait() {
245            Ok(Some(_)) | Err(_) => ended = true,
246            Ok(None) => std::thread::sleep(POLL_PAUSE),
247        }
248    }
249    if !ended {
250        // The child is being abandoned; its exit is not reported, only the cause.
251        let _killed = child.kill();
252        // A killed process that sits in uninterruptible I/O (a stalled disk or network file system) cannot be
253        //  collected until the kernel releases it, so the wait is bounded by the grace again and the child is then
254        //  left to be collected when this process ends, instead of holding the service's push for as long as the stall.
255        let killed_at: Instant = Instant::now();
256        while grace_remains(killed_at.elapsed(), grace) {
257            match child.try_wait() {
258                Ok(Some(_)) | Err(_) => break,
259                Ok(None) => std::thread::sleep(POLL_PAUSE),
260            }
261        }
262    }
263    // What the push started outlives it: its transport helper holds the connection and its hook may never exit
264    // (measured: 60 seconds for a helper, for good for a `git-lfs` that never answers). They were asked to end with
265    // the push, so they get what is left of the grace and are then killed.
266    while grace_remains(asked.elapsed(), grace) && below.iter().any(still_running) {
267        std::thread::sleep(POLL_PAUSE);
268    }
269    kill_members(below.as_slice());
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    environment: &[(OsString, OsString)],
353) -> Result<PushAttempt, SingleFlightError> {
354    let mut output: File = scratch_file(environment).map_err(SingleFlightError::Start)?;
355    let for_stdout: File = output.try_clone().map_err(SingleFlightError::Start)?;
356    let for_stderr: File = output.try_clone().map_err(SingleFlightError::Start)?;
357    command
358        .stdin(Stdio::null())
359        .stdout(Stdio::from(for_stdout))
360        .stderr(Stdio::from(for_stderr));
361    let mut child: Child = command.spawn_retrying().map_err(SingleFlightError::Start)?;
362    drop(command);
363    let ended: Result<ExitStatus, StopCause> = wait_until_done(&mut child, &[&output], deadline)?;
364    if ended.is_err() {
365        stop_push(&mut child, grace);
366    }
367    let captured: String = read_output(&mut output)?;
368    let shown: &str = strip_one_line_break(captured.as_str());
369    return Ok(match ended {
370        Ok(status) => PushAttempt {
371            exit_code: i64::from(status.code().unwrap_or(1)),
372            output: shown.to_owned(),
373        },
374        Err(cause) => {
375            let reason: String = match cause {
376                StopCause::Deadline => format!(
377                    "cli-git: git push did not finish within {} seconds and was stopped; \
378                     {}. Hint: if you were pushing over a slow or \
379                     offline connection, your commit is already saved; push again when the connection works.",
380                    deadline.as_secs(),
381                    extend_clause()
382                ),
383                StopCause::Flood => format!(
384                    "cli-git: git push wrote more than {MAX_PUSH_OUTPUT} bytes of output and was stopped. Hint: if a hook or the \
385                     remote prints this much, run `git ls-remote` to see whether your commits arrived, and read the \
386                     hook's own output by running it directly before pushing again."
387                ),
388            };
389            debug(TAG, &reason);
390            let joined: String = if shown.is_empty() {
391                reason
392            } else {
393                format!("{shown}\n{reason}")
394            };
395            PushAttempt {
396                exit_code: STOPPED_EXIT_CODE,
397                output: joined,
398            }
399        }
400    });
401}
402
403/// Bounded push behavior is controlled in the test module.
404#[cfg(test)]
405#[path = "bounded_push_tests.rs"]
406mod tests;