git_policy_cli/bounded_query.rs
1//! What:
2//! Run one read-only Git query with a deadline and 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 commit and the branch's push lock for good.
6//! A query that outlives its deadline is stopped and reported, so the commit stays saved.
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! const { exitCode, stdout, stderr } = await nanoSpawn(gitPath, args, { cwd, signal: AbortSignal.timeout(deadlineMs) });
11//! ```
12
13/// The failure type of the auto-push coordination.
14use super::auto_push_single_flight::SingleFlightError;
15/// The deadline runner shared with the push, and its stop causes.
16use super::bounded_push::{MAX_PUSH_OUTPUT, StopCause, push_deadline, stop_push, wait_until_done};
17/// Owned OS strings of the child environment.
18use std::ffi::OsString;
19/// The anonymous output files and their reading back.
20use std::fs::File;
21use std::io::{Read, Seek, SeekFrom};
22/// `Command` describes the query; `Stdio` connects its streams.
23use std::process::{Command, ExitStatus, Stdio};
24/// Time spans.
25use std::time::Duration;
26
27/// The longest a query may run whatever the push deadline is:
28/// queries read local repository state and finish in milliseconds.
29pub const QUERY_CEILING: Duration = Duration::from_secs(120);
30
31/// What:
32/// What a finished query printed and how it ended.
33/// Why:
34/// Callers read an exit code as an answer ("not an ancestor") or a failure, never both.
35///
36/// In TS you'd write (pseudocode):
37/// ```ts
38/// type QueryOutput = { code: number | undefined; stdout: Uint8Array; stderr: Uint8Array };
39/// ```
40#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct QueryOutput {
42 /// Exit code, or nothing when a signal ended the process.
43 pub code: Option<i32>,
44 /// Complete standard output, up to the output cap.
45 pub stdout: Vec<u8>,
46 /// Complete standard error, up to the output cap.
47 pub stderr: Vec<u8>,
48}
49
50/// What:
51/// The deadline of a query: the push deadline, but never more than the ceiling.
52/// Why:
53/// One setting bounds every Git child of the auto-push, and a long push deadline must not stretch queries.
54///
55/// In TS you'd write (pseudocode):
56/// ```ts
57/// Math.min(pushDeadlineMs(env), QUERY_CEILING_MS)
58/// ```
59pub fn query_deadline(environment: &[(OsString, OsString)]) -> Duration {
60 return push_deadline(environment).min(QUERY_CEILING);
61}
62
63/// Read one output file back from its start, up to the output cap.
64///
65/// In TS you'd write (pseudocode):
66/// ```ts
67/// await readFile(path)
68/// ```
69fn read_back(file: &mut File) -> Result<Vec<u8>, SingleFlightError> {
70 file.seek(SeekFrom::Start(0))
71 .map_err(SingleFlightError::Start)?;
72 let mut bytes: Vec<u8> = Vec::new();
73 file.take(MAX_PUSH_OUTPUT)
74 .read_to_end(&mut bytes)
75 .map_err(SingleFlightError::Start)?;
76 return Ok(bytes);
77}
78
79/// What:
80/// Run a query command to its end or its deadline.
81/// Why:
82/// Both streams go to anonymous files, so no reader thread can outlive a stopped query.
83/// A query past its deadline is a coordination failure, not an answer.
84///
85/// In TS you'd write (pseudocode):
86/// ```ts
87/// async function runBoundedQuery({ command, arguments, deadlineMs, graceMs }): Promise<QueryOutput>;
88/// ```
89pub fn run_bounded_query(
90 mut command: Command,
91 arguments: &str,
92 deadline: Duration,
93 grace: Duration,
94) -> Result<QueryOutput, SingleFlightError> {
95 let mut stdout: File = tempfile::tempfile().map_err(SingleFlightError::Start)?;
96 let mut stderr: File = tempfile::tempfile().map_err(SingleFlightError::Start)?;
97 let for_stdout: File = stdout.try_clone().map_err(SingleFlightError::Start)?;
98 let for_stderr: File = stderr.try_clone().map_err(SingleFlightError::Start)?;
99 command
100 .stdin(Stdio::null())
101 .stdout(Stdio::from(for_stdout))
102 .stderr(Stdio::from(for_stderr));
103 let mut child = command.spawn().map_err(SingleFlightError::Start)?;
104 drop(command);
105 let ended: Result<ExitStatus, StopCause> =
106 wait_until_done(&mut child, &[&stdout, &stderr], deadline)?;
107 return match ended {
108 Ok(status) => Ok(QueryOutput {
109 code: status.code(),
110 stdout: read_back(&mut stdout)?,
111 stderr: read_back(&mut stderr)?,
112 }),
113 Err(cause) => {
114 stop_push(&mut child, grace);
115 Err(match cause {
116 StopCause::Deadline => SingleFlightError::TimedOut {
117 arguments: arguments.to_owned(),
118 seconds: deadline.as_secs(),
119 },
120 StopCause::Flood => SingleFlightError::Git {
121 arguments: arguments.to_owned(),
122 code: None,
123 stderr: format!(
124 "the query wrote more than {MAX_PUSH_OUTPUT} bytes and was stopped"
125 ),
126 },
127 })
128 }
129 };
130}
131
132/// Query deadlines are controlled in the test module.
133#[cfg(test)]
134#[path = "bounded_query_tests.rs"]
135mod tests;