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;