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