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
//! Tests for the shared kill-on-timeout subprocess runner (#7965).
//!
//! What is NEW at this layer, and covered nowhere else: the return shape the
//! `gh` adapter throws away — a non-zero exit is `Ok` here, carrying both
//! streams — and the typed timeout the hygiene sweep branches on. The
//! process-group kill and the pipe drain are covered here directly rather than
//! only through `run_with_timeout_kills_the_whole_process_group` in
//! `worktree_reclaim_tests.rs`, because this module now owns them and the `gh`
//! adapter could be retired without taking the guarantee with it.
use std::process::Command;
use std::time::{Duration, Instant};
use super::{BoundedError, clamp_to_deadline, run_bounded, run_bounded_with_input, with_deadline};
/// A command that exits non-zero is `Ok` here, with both streams captured.
///
/// Why this is the assertion: the `gh` adapter treats a non-zero exit as a
/// failure, but the hygiene sweep must tell "git refused the fast-forward" (a
/// decision, logged and survivable) from "git never answered" (the #7965 hang).
/// Folding both into `Err` at this layer would erase that distinction.
#[test]
fn run_bounded_captures_stdout_and_status() {
let mut cmd = Command::new("sh");
cmd.args(["-c", "echo out; echo err >&2; exit 3"]);
let out = run_bounded(cmd, Duration::from_secs(5)).expect("a non-zero exit is not an error");
assert_eq!(out.status.code(), Some(3));
assert_eq!(out.stdout.trim(), "out");
assert_eq!(out.stderr.trim(), "err");
}
/// Output larger than one pipe buffer still comes back, and does not deadlock.
///
/// Why: polling `try_wait` without draining the pipes wedges on any command
/// whose output exceeds the buffer — `git status --porcelain` in a dirty
/// checkout, `gh pr list` with 400 pull requests. This is the drain threads'
/// regression test.
#[test]
fn run_bounded_drains_a_pipe_larger_than_its_buffer() {
let mut cmd = Command::new("sh");
cmd.args([
"-c",
"i=0; while [ $i -lt 20000 ]; do echo 0123456789; i=$((i+1)); done",
]);
let out = run_bounded(cmd, Duration::from_secs(20)).expect("a chatty child must not deadlock");
assert!(out.status.success(), "{:?}", out.status);
assert_eq!(out.stdout.lines().count(), 20_000);
}
/// A child that outlives its budget is killed and reported as a TIMEOUT.
///
/// 🔴 #7965 REGRESSION: the variant matters, not just the failure. The hygiene
/// sweep logs a timeout as "this base was abandoned" and keeps going; a spawn
/// error means git is missing. Collapsing them would hide a wedged fetch behind
/// a misleading message.
#[test]
fn run_bounded_kills_a_hung_child() {
let mut cmd = Command::new("sleep");
cmd.arg("30");
let started = Instant::now();
let err = run_bounded(cmd, Duration::from_millis(300)).expect_err("a hung child must fail");
assert!(matches!(err, BoundedError::TimedOut), "{err}");
assert!(
started.elapsed() < Duration::from_secs(10),
"the timeout must actually fire; took {:?}",
started.elapsed()
);
}
/// 🔴 #6867 REGRESSION, restated at this layer: killing the child must reap its
/// GRANDCHILDREN too.
///
/// Why here as well as through the `gh` adapter: the hygiene sweep's wedge is a
/// `git fetch` whose transport helper (`ssh`, a credential helper) is a
/// grandchild holding the same pipes. `child.kill()` signals one pid and leaves
/// it running, reparented to launchd — which is how #6867 accumulated ~200 orphan
/// pairs in a couple of hours. The stand-in is a shell that backgrounds a long
/// `sleep` and then blocks: one process the runner knows about, one it does not.
#[test]
fn run_bounded_kills_the_whole_process_group() {
let tmp = tempfile::tempdir().expect("tempdir");
let pidfile = tmp.path().join("grandchild.pid");
// The grandchild's fds are redirected so it does not hold the runner's
// stdout pipe open after its parent dies.
let script = format!(
"sleep 300 >/dev/null 2>&1 & echo $! > {}; sleep 300",
pidfile.display()
);
let mut cmd = Command::new("sh");
cmd.args(["-c", &script]);
let err = run_bounded(cmd, Duration::from_millis(500)).expect_err("a hung child must fail");
assert!(matches!(err, BoundedError::TimedOut), "{err}");
let pid: i32 = std::fs::read_to_string(&pidfile)
.expect("the shell must have recorded its background child's pid")
.trim()
.parse()
.expect("a pid");
// The grandchild is reparented on its parent's death, so a killed one is
// reaped promptly and `kill(pid, 0)` starts reporting ESRCH.
let deadline = Instant::now() + Duration::from_secs(5);
while alive(pid) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
}
let still_there = alive(pid);
if still_there {
// Never leave a 300-second sleep behind, whatever the verdict.
// SAFETY: `pid` was read from a child this test itself started.
unsafe { libc::kill(pid, libc::SIGKILL) };
}
assert!(
!still_there,
"the grandchild (pid {pid}) survived its parent's timeout — the process group \
was not killed (#6867)"
);
}
/// Is `pid` still a live process?
///
/// SAFETY: `kill(pid, 0)` sends no signal; it only probes for the process's
/// existence and permission to signal it.
#[cfg(unix)]
fn alive(pid: i32) -> bool {
unsafe { libc::kill(pid, 0) == 0 }
}
/// A binary that is not on PATH is a spawn failure, not a timeout.
#[test]
fn run_bounded_reports_a_spawn_failure() {
let cmd = Command::new("definitely-not-a-real-binary-7965");
let err = run_bounded(cmd, Duration::from_secs(5)).expect_err("an unspawnable command fails");
assert!(matches!(err, BoundedError::Spawn(_)), "{err}");
assert!(err.to_string().contains("could not be run"), "{err}");
}
/// Input reaches the child's stdin and the child sees EOF after it (#8306).
#[test]
fn run_bounded_with_input_feeds_stdin() {
let out = run_bounded_with_input(
Command::new("cat"),
Some(b"patch\n".to_vec()),
Duration::from_secs(10),
)
.expect("cat echoes its input and exits at EOF");
assert!(out.status.success(), "{:?}", out.status);
assert_eq!(out.stdout, "patch\n");
}
/// 🔴 #8306 REGRESSION: a child that exits while something it started still
/// holds stdout open is an ERROR, never an empty `Ok`.
///
/// Why: before #8306 the drain wait's expiry became `unwrap_or_default()`, an
/// empty stdout — which `git status --porcelain` callers read as "clean". The
/// stand-in backgrounds a `cat` that blocks opening a FIFO while holding the
/// inherited stdout, then exits; the test releases the FIFO afterwards.
#[cfg(unix)]
#[test]
fn run_bounded_reports_a_pipe_held_open_after_exit() {
use std::os::unix::fs::OpenOptionsExt;
let tmp = tempfile::tempdir().expect("tempdir");
let fifo = tmp.path().join("gate");
let made = Command::new("mkfifo").arg(&fifo).status();
assert!(made.is_ok_and(|s| s.success()), "mkfifo");
let script = format!("(exec cat \"{}\") & echo partial", fifo.display());
let mut cmd = Command::new("sh");
cmd.args(["-c", &script]);
let result = run_bounded(cmd, Duration::from_secs(10));
// Release the backgrounded `cat` whatever the verdict: a write-open lets its
// blocked open complete and the close hands it EOF.
let _ = std::fs::OpenOptions::new()
.write(true)
.custom_flags(libc::O_NONBLOCK)
.open(&fifo);
let err = result.expect_err("a held-open stdout must not read as complete output");
assert!(matches!(err, BoundedError::PipeHeldOpen("stdout")), "{err}");
}
/// 🔴 #8306 REGRESSION: the grandchild holding the pipe is KILLED before
/// `PipeHeldOpen` is returned, not left running.
///
/// Why: the pipe holder is a grandchild in the child's process group — the git
/// transport or credential-helper shape #6867 names. Returning the error without
/// killing the group leaked it forever on every destructive call site. The
/// stand-in is the same FIFO-blocked `cat`, whose pid the shell records; the
/// test never opens the FIFO before the verdict, so only the runner's kill can
/// end it. The closing write-open must then find no reader (`ENXIO`).
#[cfg(unix)]
#[test]
fn run_bounded_kills_the_pipe_holder_it_reports_held_open() {
use std::os::unix::fs::OpenOptionsExt;
let tmp = tempfile::tempdir().expect("tempdir");
let fifo = tmp.path().join("gate");
let pidfile = tmp.path().join("holder.pid");
let made = Command::new("mkfifo").arg(&fifo).status();
assert!(made.is_ok_and(|s| s.success()), "mkfifo");
let script = format!(
"(exec cat \"{}\") & echo $! > \"{}\"; echo partial",
fifo.display(),
pidfile.display()
);
let mut cmd = Command::new("sh");
cmd.args(["-c", &script]);
let err = run_bounded(cmd, Duration::from_secs(10)).expect_err("the pipe is held open");
assert!(matches!(err, BoundedError::PipeHeldOpen("stdout")), "{err}");
let pid: i32 = std::fs::read_to_string(&pidfile)
.expect("the shell must have recorded the pipe holder's pid")
.trim()
.parse()
.expect("a pid");
// Reparented on its parent's exit, a killed holder is reaped promptly.
let deadline = Instant::now() + Duration::from_secs(5);
while alive(pid) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(50));
}
let still_there = alive(pid);
if still_there {
// SAFETY: `pid` was read from a process this test itself started.
unsafe { libc::kill(pid, libc::SIGKILL) };
}
assert!(
!still_there,
"the pipe holder (pid {pid}) outlived the PipeHeldOpen return — the process \
group was not killed (#8306)"
);
let probe = std::fs::OpenOptions::new()
.write(true)
.custom_flags(libc::O_NONBLOCK)
.open(&fifo);
assert_eq!(
probe.err().and_then(|e| e.raw_os_error()),
Some(libc::ENXIO),
"the FIFO still has a reader after the runner returned"
);
}
/// 🔴 #8301: a thread deadline caps a child's own budget, and once it has
/// passed nothing more is spawned.
///
/// Why: the merged-PR survey ran 78 minutes as a loop of children that each
/// honoured its own budget; only a shared deadline bounds the loop. The second
/// call must fail without spawning, as a timeout, so every caller keeps the
/// worktree.
#[test]
fn a_deadline_cuts_a_child_short_and_refuses_the_next_one() {
let started = Instant::now();
let deadline = started + Duration::from_millis(300);
let (first, second) = with_deadline(deadline, || {
let mut cmd = Command::new("sleep");
cmd.arg("30");
let first = run_bounded(cmd, Duration::from_secs(30));
let second = run_bounded(Command::new("true"), Duration::from_secs(30));
(first, second)
});
assert!(matches!(first, Err(BoundedError::TimedOut)), "{first:?}");
assert!(matches!(second, Err(BoundedError::TimedOut)), "{second:?}");
assert!(
started.elapsed() < Duration::from_secs(10),
"the deadline, not the 30 s budget, must end the child; took {:?}",
started.elapsed()
);
// The guard restored the thread: no deadline is left in force.
assert_eq!(
clamp_to_deadline(Duration::from_secs(5)),
Some(Duration::from_secs(5))
);
}