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