192
let (shell, flag, substituted) = ("cmd", "/C", context.substitute(cmd, windows_quote));
193
194
log::debug!("Running hook: {substituted}");
195
- let mut child = match Command::new(shell)
195
+ let mut command = Command::new(shell);
196
+ command
197
.arg(flag)
198
.arg(&substituted)
199
.current_dir(cwd)
200
.envs(context.env_vars())
201
.stdout(Stdio::piped())
201
- .stderr(Stdio::piped())
202
- .spawn()
203
- {
202
+ .stderr(Stdio::piped());
203
+ // Put the hook in its own process group so a timeout kill can take out the
204
+ // whole tree. Killing just the shell is not enough: `sh -c` may fork
205
+ // rather than exec (dash does), and the surviving grandchild would both
206
+ // keep running and hold the pipe write ends open, blocking the drains
207
+ // below until it exits on its own.
208
+ #[cfg(unix)]
209
+ std::os::unix::process::CommandExt::process_group(&mut command, 0);
210
+ let mut child = match command.spawn() {
211
Ok(child) => child,
212
Err(err) => {
213
log::warn!("Failed to run hook: {err}");
215
}
216
};
217
218
+ #[cfg(unix)]
219
+ fn kill_hook(child: &mut std::process::Child) {
220
+ // SIGKILL the process group (negative pid); the child is its leader.
221
+ // Fall back to killing the child alone if that fails.
222
+ if unsafe { libc::kill(-(child.id() as i32), libc::SIGKILL) } != 0 {
223
+ let _ = child.kill();
224
+ }
225
+ let _ = child.wait();
226
+ }
227
+ #[cfg(windows)]
228
+ fn kill_hook(child: &mut std::process::Child) {
229
+ let _ = child.kill();
230
+ let _ = child.wait();
231
+ }
232
+
233
// Drain stdout/stderr on their own threads so a hook that writes more than
234
// the pipe buffer holds (a build, say) keeps making progress instead of
213
- // blocking on a full pipe and looking like a hang. The main thread polls
214
- // for exit and enforces the deadline.
215
- fn drain<R: Read + Send + 'static>(pipe: Option<R>) -> std::thread::JoinHandle<Vec<u8>> {
235
+ // blocking on a full pipe and looking like a hang. The threads report back
236
+ // over a channel rather than being joined: a receive can be bounded, while
237
+ // a join could block forever on a pipe a detached grandchild still holds.
238
+ let (out_tx, out_rx) = std::sync::mpsc::channel::<(u8, Vec<u8>)>();
239
+ let drain = |tag: u8, pipe: Option<Box<dyn Read + Send>>| {
240
+ let tx = out_tx.clone();
241
std::thread::spawn(move || {
242
let mut buf = Vec::new();
243
if let Some(mut p) = pipe {
244
let _ = p.read_to_end(&mut buf);
245
}
221
- buf
222
- })
223
- }
224
- let stdout_handle = drain(child.stdout.take());
225
- let stderr_handle = drain(child.stderr.take());
246
+ let _ = tx.send((tag, buf));
247
+ });
248
+ };
249
+ drain(0, child.stdout.take().map(|p| Box::new(p) as _));
250
+ drain(1, child.stderr.take().map(|p| Box::new(p) as _));
251
+ drop(out_tx);
252
253
let deadline = Instant::now() + timeout;
254
let status = loop {
256
Ok(Some(status)) => break Some(status),
257
Ok(None) => {
258
if Instant::now() >= deadline {
233
- let _ = child.kill();
234
- let _ = child.wait();
259
+ kill_hook(&mut child);
260
break None;
261
}
262
std::thread::sleep(Duration::from_millis(50));
263
}
264
Err(err) => {
265
log::warn!("Failed to wait on hook: {err}");
241
- let _ = child.kill();
242
- let _ = child.wait();
266
+ kill_hook(&mut child);
267
break None;
268
}
269
}
270
};
271
248
- // The child has exited (or been killed), so both pipes are closed and the
249
- // drain threads finish promptly.
250
- let stdout = stdout_handle.join().unwrap_or_default();
251
- let stderr = stderr_handle.join().unwrap_or_default();
272
+ // Collect what the drains read, bounded: if something in the hook's tree
273
+ // outlived the kill (or daemonized past the process group) and still holds
274
+ // a pipe open, give up on its output after a short grace instead of
275
+ // hanging the caller on it.
276
+ let mut stdout = Vec::new();
277
+ let mut stderr = Vec::new();
278
+ let grace = Duration::from_secs(1);
279
+ for _ in 0..2 {
280
+ match out_rx.recv_timeout(grace) {
281
+ Ok((0, buf)) => stdout = buf,
282
+ Ok((_, buf)) => stderr = buf,
283
+ Err(_) => {
284
+ log::debug!("Hook output pipe still open after exit; skipping its output");
285
+ break;
286
+ }
287
+ }
288
+ }
289
290
match status {
291
None => log::warn!(
368
#[cfg(unix)]
369
#[test]
370
fn hook_that_hangs_is_killed_at_the_timeout() {
334
- // A hook that would sleep far past the deadline must be killed near it,
335
- // not block the caller for the full sleep.
371
+ // A hook that would sleep far past the deadline must be killed near
372
+ // it, not block the caller for the full sleep. The two-command form
373
+ // forces the shell to fork `sleep` rather than exec it (what dash does
374
+ // even for a single command), so this also proves the kill takes out
375
+ // the whole process group: a surviving grandchild would hold the
376
+ // output pipes open and stall the caller until its own exit.
377
let context = HookContext::new();
378
let start = Instant::now();
379
run_hook(
339
- "sleep 30",
380
+ "sleep 30; echo done",
381
&context,
382
Path::new("."),
383
Duration::from_millis(300),