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;