git_policy_cli/push/bounded_query.rs
1//! What:
2//! Run one read-only Git query that is ended only when it shows no sign of work, with separate output streams.
3//! Why:
4//! The auto-push asks Git a handful of questions before it pushes,
5//! and a question that never returns would hold the service's push for good.
6//! A query that shows no sign of work for the quiet window is stopped and reported, so the commit stays saved. A query that
7//! works is never stopped for being slow: no total time bounds it (owner, 2026-11-03: "Stop only on no progress"; the
8//! coordinator's scope addition to picks 4 and 9 of `doc/planning/cli-git-shipped-limits-audit.md`, open to the owner's
9//! veto). Before this, a query shared the push's wall-clock stop and the journal showed a `git status` stopped at
10//! 2026-11-03T08:28 with `outcome=retrying push_ms=97398`. The longest quiet gap of every query is kept in the ledger.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! const { exitCode, stdout, stderr } = await nanoSpawn(gitPath, args, { cwd, signal: quietFor(windowMs) }); // quiet, not slow
15//! ```
16
17/// The failure type of the auto-push coordination.
18use crate::auto_push_single_flight::SingleFlightError;
19/// The deadline runner shared with the push, and its stop causes.
20use crate::bounded_push::{MAX_PUSH_OUTPUT, Patience, StopCause, push_deadline, stop_push, wait_until_done};
21/// The longest quiet gap of each step, kept for the timing records.
22use crate::quiet_ledger;
23/// The lock files a stopped query leaves, and the ledger that finds them.
24use crate::stopped_git_locks::LockLedger;
25/// Starts that ride out a short shortage of processes.
26/// The anonymous output files of the query, made in the per-user scratch directory.
27use crate::scratch_location::scratch_file;
28use crate::start_retry::RetryStart;
29/// Owned OS strings of the child environment.
30use std::ffi::OsString;
31/// The anonymous output files and their reading back.
32use std::fs::File;
33use std::io::{Read, Seek, SeekFrom};
34/// `Command` describes the query; `Stdio` connects its streams.
35use std::process::{Command, ExitStatus, Stdio};
36/// Time spans.
37use std::time::Duration;
38
39/// What:
40/// What a finished query printed and how it ended.
41/// Why:
42/// Callers read an exit code as an answer ("not an ancestor") or a failure, never both.
43///
44/// In TS you'd write (pseudocode):
45/// ```ts
46/// type QueryOutput = { code: number | undefined; stdout: Uint8Array; stderr: Uint8Array };
47/// ```
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct QueryOutput {
50 /// Exit code, or nothing when a signal ended the process.
51 pub code: Option<i32>,
52 /// Complete standard output, up to the output cap.
53 pub stdout: Vec<u8>,
54 /// Complete standard error, up to the output cap.
55 pub stderr: Vec<u8>,
56}
57
58/// What:
59/// The quiet window of a query: the push's stall window.
60/// Why:
61/// One setting bounds how long every Git child of the auto-push may show no sign of work. The cap of 120 seconds a query
62/// used to have is gone with the wall-clock stop it belonged to.
63///
64/// In TS you'd write (pseudocode):
65/// ```ts
66/// pushStallMs(env)
67/// ```
68pub fn query_window(environment: &[(OsString, OsString)]) -> Duration {
69 return push_deadline(environment);
70}
71
72/// Read one output file back from its start, up to the output cap.
73///
74/// In TS you'd write (pseudocode):
75/// ```ts
76/// await readFile(path)
77/// ```
78fn read_back(file: &mut File) -> Result<Vec<u8>, SingleFlightError> {
79 file.seek(SeekFrom::Start(0))
80 .map_err(SingleFlightError::Start)?;
81 let mut bytes: Vec<u8> = Vec::new();
82 file.take(MAX_PUSH_OUTPUT)
83 .read_to_end(&mut bytes)
84 .map_err(SingleFlightError::Start)?;
85 return Ok(bytes);
86}
87
88/// What:
89/// Run a query command to its end, or until it has shown no sign of work for the quiet window.
90/// Why:
91/// Both streams go to anonymous files, so no reader thread can outlive a stopped query.
92/// A query that was stopped for being quiet is a coordination failure, not an answer.
93///
94/// In TS you'd write (pseudocode):
95/// ```ts
96/// async function runBoundedQuery({ command, arguments, windowMs, graceMs }): Promise<QueryOutput>;
97/// ```
98pub fn run_bounded_query(
99 mut command: Command,
100 arguments: &str,
101 window: Duration,
102 grace: Duration,
103 environment: &[(OsString, OsString)],
104) -> Result<QueryOutput, SingleFlightError> {
105 let mut stdout: File = scratch_file(environment).map_err(SingleFlightError::Start)?;
106 let mut stderr: File = scratch_file(environment).map_err(SingleFlightError::Start)?;
107 let for_stdout: File = stdout.try_clone().map_err(SingleFlightError::Start)?;
108 let for_stderr: File = stderr.try_clone().map_err(SingleFlightError::Start)?;
109 command
110 .stdin(Stdio::null())
111 .stdout(Stdio::from(for_stdout))
112 .stderr(Stdio::from(for_stderr));
113 // Taken before the query starts, so a lock born earlier is never taken for the query's.
114 let locks = LockLedger::before_start(&command);
115 let mut child = command.spawn_retrying().map_err(SingleFlightError::Start)?;
116 drop(command);
117 let mut quiet: Option<Duration> = None;
118 let ended: Result<ExitStatus, StopCause> =
119 wait_until_done(&mut child, &[&stdout, &stderr], Patience::Progress { stall: window }, &mut quiet)?;
120 let step: String = format!("git {}", arguments.split_whitespace().next().unwrap_or_default());
121 if let Some(gap) = quiet {
122 quiet_ledger::note(step.as_str(), gap, ended.is_err());
123 }
124 return match ended {
125 Ok(status) => Ok(QueryOutput {
126 code: status.code(),
127 stdout: read_back(&mut stdout)?,
128 stderr: read_back(&mut stderr)?,
129 }),
130 Err(cause) => {
131 stop_push(child, grace, locks);
132 Err(match cause {
133 // A query waits on its signs of work, so only a stall reaches here; a deadline is the same wait.
134 StopCause::Deadline | StopCause::Stalled(_) => SingleFlightError::TimedOut {
135 arguments: arguments.to_owned(),
136 seconds: window.as_secs(),
137 },
138 StopCause::Flood => SingleFlightError::Git {
139 arguments: arguments.to_owned(),
140 code: None,
141 stderr: format!(
142 "the query wrote more than {MAX_PUSH_OUTPUT} bytes and was stopped"
143 ),
144 },
145 })
146 }
147 };
148}
149
150/// Query windows are controlled in the test module.
151#[cfg(test)]
152#[path = "bounded_query_tests.rs"]
153mod tests;