1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
//! Read-only Git subprocesses that a caller's deadline actually owns.
//!
//! `tokio::process::Command::output()` under a `timeout` looks bounded and is
//! not: cancelling the future drops the handle, and the `git` process keeps
//! running with nobody waiting on it. For `cflx client wait` that is the whole
//! bug — a `git ls-remote` against an unreachable remote outlives the operation
//! that asked for it, so the caller's `--timeout` bounds only the *reply*, not
//! the work.
//!
//! So the deadline is passed down to the spawn site instead of wrapped around
//! it. On expiry the child is signalled and then reaped here, before the caller
//! is told the deadline passed.
//!
//! Every command routed through this module is read-only by construction of its
//! caller; nothing here fetches, writes a ref, or touches a working tree.
use std::path::Path;
use std::process::{Output, Stdio};
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
use tokio::time::Instant;
/// What became of one bounded Git invocation.
#[derive(Debug)]
pub enum GitOutcome {
/// The command ran to completion.
Finished(Output),
/// The deadline passed first; the child was terminated and reaped.
DeadlineExpired,
}
/// How a caller bounds the Git children it spawns.
///
/// The three variants exist because "how long may this child run" and "how long
/// may the caller's operation run" are different questions, and only one of them
/// has a `timeout` outcome attached to it.
#[derive(Debug, Clone, Copy)]
pub enum GitDeadline {
/// No bound at all, for callers that supervise the child some other way.
Unbounded,
/// One shared instant for every child: the caller's whole-operation deadline.
///
/// Expiry is the *operation's* answer, so a caller holding one of these
/// reports its own timeout rather than retrying.
Operation(Instant),
/// A fresh finite budget for each child, for a caller with no operation
/// deadline.
///
/// "Wait as long as it takes" is a promise about the operation. Turning it
/// into an unkillable `git ls-remote` against an unreachable remote would be
/// a different promise entirely, so each child still gets its own bound —
/// and its expiry means only that this attempt gave up, never that the
/// operation did.
PerChild(Duration),
}
impl GitDeadline {
/// The instant the next child must not outlive.
fn next(self) -> Option<Instant> {
match self {
Self::Unbounded => None,
Self::Operation(at) => Some(at),
Self::PerChild(budget) => Some(Instant::now() + budget),
}
}
/// Whether an expiry means the caller's whole operation ran out of time.
///
/// This is the distinction that keeps the `timeout` outcome reserved for an
/// explicitly configured deadline: a per-child expiry killed one `git`, and
/// proves nothing about how long the caller has been waiting.
pub fn is_operation_deadline(self) -> bool {
matches!(self, Self::Operation(_))
}
}
/// Run one read-only `git` command under `deadline`.
///
/// `Ok(GitOutcome::DeadlineExpired)` means the child was killed and waited for,
/// so no `git` process outlives this call. `Err` is reserved for a `git` that
/// could not be spawned or whose pipes could not be read — the same failure the
/// unbounded `output()` reports.
pub async fn run_git(
repo_root: &Path,
args: &[&str],
deadline: GitDeadline,
) -> std::io::Result<GitOutcome> {
let mut command = Command::new("git");
command
.args(args)
.current_dir(repo_root)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// Belt and braces: an unwind or an early return between spawn and reap must
// not leave the child behind either.
command.kill_on_drop(true);
// Resolved before the spawn so a per-child budget measures the child's own
// life rather than whatever the spawn itself cost.
let deadline = deadline.next();
let mut child = command.spawn()?;
let Some(deadline) = deadline else {
return child.wait_with_output().await.map(GitOutcome::Finished);
};
// The pipes are drained concurrently with the wait, exactly as
// `wait_with_output` does, because a child that fills a pipe buffer while
// nobody reads it would deadlock instead of finishing.
let mut stdout_pipe = child.stdout.take();
let mut stderr_pipe = child.stderr.take();
let mut stdout = Vec::new();
let mut stderr = Vec::new();
// Bound in its own statement so the timeout future — which holds the only
// mutable borrow of `child` — is dropped before the expiry branch reaps it.
let completed = {
let collect = async {
let read_out = async {
match stdout_pipe.as_mut() {
Some(pipe) => pipe.read_to_end(&mut stdout).await.map(|_| ()),
None => Ok(()),
}
};
let read_err = async {
match stderr_pipe.as_mut() {
Some(pipe) => pipe.read_to_end(&mut stderr).await.map(|_| ()),
None => Ok(()),
}
};
let (out, err) = tokio::join!(read_out, read_err);
out?;
err?;
child.wait().await
};
tokio::time::timeout_at(deadline, collect).await
};
match completed {
Ok(status) => Ok(GitOutcome::Finished(Output {
status: status?,
stdout,
stderr,
})),
Err(_elapsed) => {
// Signal, then wait: `start_kill` only asks, and a child that is
// never waited for stays a zombie owned by this process.
let _ = child.start_kill();
let _ = child.wait().await;
Ok(GitOutcome::DeadlineExpired)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[tokio::test]
async fn a_completed_command_reports_its_own_streams_and_status() {
let tmp = tempfile::tempdir().unwrap();
let outcome = run_git(tmp.path(), &["--version"], GitDeadline::Unbounded)
.await
.unwrap();
let GitOutcome::Finished(output) = outcome else {
panic!("`git --version` must finish");
};
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).contains("git version"));
}
#[tokio::test]
async fn a_deadline_that_already_passed_reports_expiry_rather_than_output() {
let tmp = tempfile::tempdir().unwrap();
// Already elapsed, so the outcome is decided by the deadline and not by
// how fast this machine runs `git`.
let deadline = Instant::now() - Duration::from_secs(1);
let outcome = run_git(tmp.path(), &["--version"], GitDeadline::Operation(deadline))
.await
.unwrap();
assert!(
matches!(outcome, GitOutcome::DeadlineExpired),
"{outcome:?}"
);
}
#[tokio::test]
async fn a_generous_deadline_does_not_disturb_a_fast_command() {
let tmp = tempfile::tempdir().unwrap();
let deadline = Instant::now() + Duration::from_secs(30);
let outcome = run_git(tmp.path(), &["--version"], GitDeadline::Operation(deadline))
.await
.unwrap();
assert!(matches!(outcome, GitOutcome::Finished(_)), "{outcome:?}");
}
#[tokio::test]
async fn a_missing_git_is_an_error_rather_than_a_silent_expiry() {
let tmp = tempfile::tempdir().unwrap();
// A path that cannot be a working directory fails at spawn, which is the
// same class of failure a missing `git` produces.
let outcome = run_git(
&tmp.path().join("absent"),
&["--version"],
GitDeadline::Unbounded,
)
.await;
assert!(outcome.is_err());
}
#[tokio::test]
async fn a_per_child_budget_bounds_each_invocation_from_its_own_start() {
let tmp = tempfile::tempdir().unwrap();
// Each call gets the full budget rather than sharing one instant, so a
// second invocation cannot inherit an already-elapsed deadline from the
// first the way `Operation` deliberately does.
for _ in 0..2 {
let outcome = run_git(
tmp.path(),
&["--version"],
GitDeadline::PerChild(Duration::from_secs(30)),
)
.await
.unwrap();
assert!(matches!(outcome, GitOutcome::Finished(_)), "{outcome:?}");
}
}
#[tokio::test]
async fn a_per_child_budget_terminates_and_reaps_a_child_that_never_finishes() {
// A `git://` endpoint that accepts and then says nothing, so `ls-remote`
// blocks on the ref advertisement instead of on anything this test
// controls. The child is only reachable through its own budget.
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let (connected_tx, connected_rx) = tokio::sync::oneshot::channel();
let accepting = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("git must connect");
let _ = connected_tx.send(());
// Held, not dropped: closing it would end the child for the wrong
// reason.
std::future::pending::<()>().await;
drop(stream);
});
let tmp = tempfile::tempdir().unwrap();
let url = format!("git://127.0.0.1:{port}/stalled.git");
let outcome = run_git(
tmp.path(),
&["ls-remote", &url],
GitDeadline::PerChild(Duration::from_millis(200)),
)
.await
.unwrap();
assert!(
matches!(outcome, GitOutcome::DeadlineExpired),
"a per-child budget must expire a child that never finishes: {outcome:?}"
);
// `run_git` returning at all proves the reap happened: it waits on the
// child after signalling it, so a surviving `git` would still be blocking
// this call.
connected_rx
.await
.expect("git must have reached the stalled endpoint");
accepting.abort();
}
#[test]
fn only_a_shared_operation_deadline_claims_the_operation_ran_out_of_time() {
assert!(GitDeadline::Operation(Instant::now()).is_operation_deadline());
assert!(!GitDeadline::PerChild(Duration::from_secs(1)).is_operation_deadline());
assert!(!GitDeadline::Unbounded.is_operation_deadline());
}
}