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