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