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