use std::io;
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::{Duration, Instant};
use nix::sys::signal::{Signal, killpg};
use nix::unistd::Pid;
use crate::error::Error;
pub(crate) fn run_git_within(dir: &Path, args: &[&str], budget: Duration) -> Result<String, Error> {
let mut child = Command::new("git")
.current_dir(dir)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.process_group(0)
.spawn()
.map_err(Error::at(dir))?;
let out = drain(child.stdout.take());
let err = drain(child.stderr.take());
let deadline = Instant::now() + budget;
let status = loop {
match child.try_wait() {
Err(source) => {
let _ = child.kill();
return Err(Error::Io {
path: dir.to_owned(),
source,
});
}
Ok(Some(status)) => break Some(status),
Ok(None) => {}
}
let left = deadline.saturating_duration_since(Instant::now());
if left.is_zero() {
abandon(&mut child);
break None;
}
thread::sleep(POLL.min(left));
};
let Some(status) = status else {
return Err(Error::Git {
command: format!("git {}", args.join(" ")),
status: None,
stderr: format!(
"no answer within {budget:?}; abandoned so the other targets keep deploying"
),
});
};
let remaining = || deadline.saturating_duration_since(Instant::now());
let (Ok(stdout), Ok(stderr)) = (out.recv_timeout(remaining()), err.recv_timeout(remaining()))
else {
drop(out);
drop(err);
return Err(Error::Git {
command: format!("git {}", args.join(" ")),
status: None,
stderr: format!(
"exited, but something it started still held its output after \
{budget:?}; abandoned so the other targets keep deploying"
),
});
};
decode(dir, args, status, stdout, stderr)
}
pub(crate) fn kill_group(pid: u32) {
if pid == 0 {
return;
}
if let Ok(pid) = i32::try_from(pid) {
let _ = killpg(Pid::from_raw(pid), Signal::SIGKILL);
}
}
fn abandon(child: &mut std::process::Child) {
kill_group(child.id());
let _ = child.kill();
let _ = child.wait();
}
fn drain<R: io::Read + Send + 'static>(pipe: Option<R>) -> mpsc::Receiver<Vec<u8>> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mut buf = Vec::new();
if let Some(mut pipe) = pipe {
let _ = pipe.read_to_end(&mut buf);
}
let _ = tx.send(buf);
});
rx
}
pub(crate) async fn off_thread<T, F>(work: F) -> Result<T, Error>
where
T: Send + 'static,
F: FnOnce() -> Result<T, Error> + Send + 'static,
{
match tokio::task::spawn_blocking(work).await {
Ok(result) => result,
Err(join) => match join.try_into_panic() {
Ok(payload) => std::panic::resume_unwind(payload),
Err(join) => panic!("a blocking task was cancelled: {join}"),
},
}
}
const POLL: Duration = Duration::from_millis(50);
pub(crate) fn run_git(dir: &Path, args: &[&str]) -> Result<String, Error> {
let output = Command::new("git")
.current_dir(dir)
.args(args)
.output()
.map_err(Error::at(dir))?;
decode(dir, args, output.status, output.stdout, output.stderr)
}
fn decode(
dir: &Path,
args: &[&str],
status: std::process::ExitStatus,
stdout: Vec<u8>,
stderr: Vec<u8>,
) -> Result<String, Error> {
if !status.success() {
return Err(Error::Git {
command: format!("git {}", args.join(" ")),
status: status.code(),
stderr: String::from_utf8_lossy(&stderr).into_owned(),
});
}
String::from_utf8(stdout).map_err(|err| Error::Io {
path: dir.to_owned(),
source: io::Error::other(err),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn off_thread_answers_with_the_result_and_re_raises_a_panic() {
let answered = off_thread(|| Ok::<_, Error>(41 + 1))
.await
.expect("passes through");
assert_eq!(answered, 42);
let refused = off_thread(|| Err::<(), _>(Error::Config("no".to_owned())))
.await
.expect_err("passes the error through");
assert!(matches!(refused, Error::Config(_)));
let caught = std::panic::AssertUnwindSafe(off_thread(|| -> Result<(), Error> {
panic!("inside the pool");
}));
let outcome = futures_catch(caught).await;
assert!(outcome.is_err(), "the panic must come out as a panic");
}
async fn futures_catch<F: std::future::Future>(
fut: std::panic::AssertUnwindSafe<F>,
) -> Result<F::Output, Box<dyn std::any::Any + Send>> {
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
struct Catch<F>(Pin<Box<F>>);
impl<F: Future> Future for Catch<F> {
type Output = Result<F::Output, Box<dyn std::any::Any + Send>>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let inner = &mut self.get_mut().0;
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
inner.as_mut().poll(cx)
})) {
Ok(Poll::Pending) => Poll::Pending,
Ok(Poll::Ready(v)) => Poll::Ready(Ok(v)),
Err(payload) => Poll::Ready(Err(payload)),
}
}
}
Catch(Box::pin(fut.0)).await
}
#[test]
fn a_chatty_subprocess_is_not_mistaken_for_a_hung_one() {
let dir = tempfile::tempdir().expect("tempdir");
run_git(dir.path(), &["init", "-q", "-b", "main"]).expect("init");
run_git(dir.path(), &["config", "user.email", "t@example.invalid"]).expect("email");
run_git(dir.path(), &["config", "user.name", "t"]).expect("name");
std::fs::write(dir.path().join("big.txt"), "x".repeat(400_000)).expect("big file");
run_git(dir.path(), &["add", "big.txt"]).expect("add");
run_git(dir.path(), &["commit", "-q", "-m", "big"]).expect("commit");
let out = run_git_within(
dir.path(),
&["show", "HEAD:big.txt"],
Duration::from_secs(20),
)
.expect("a chatty command must not be mistaken for a hung one");
assert!(
out.len() > 100_000,
"the fixture must exceed a pipe buffer or this proves nothing, got {} bytes",
out.len()
);
}
#[test]
fn output_is_collected_within_the_budget_even_when_git_succeeds() {
let dir = tempfile::tempdir().expect("tempdir");
let started = Instant::now();
let _ = run_git_within(
dir.path(),
&["-c", "alias.fork=!sh -c 'sleep 8 &'", "fork"],
Duration::from_millis(600),
);
let elapsed = started.elapsed();
assert!(
elapsed < Duration::from_secs(4),
"git exited at once; collecting its output must not wait out what it \
forked. Took {elapsed:?} against a 600ms budget"
);
}
#[test]
fn a_grandchild_cannot_extend_the_budget() {
let dir = tempfile::tempdir().expect("tempdir");
let started = Instant::now();
let err = run_git_within(
dir.path(),
&["-c", "alias.hang=!sh -c 'sleep 30 &' && sleep 30", "hang"],
Duration::from_millis(600),
)
.expect_err("must not succeed");
let elapsed = started.elapsed();
assert!(
format!("{err}").contains("no answer within"),
"must fail on the budget: {err}"
);
assert!(
elapsed < Duration::from_secs(5),
"must return near the budget, not wait out the grandchild; took {elapsed:?}"
);
}
#[test]
fn a_git_subprocess_that_never_answers_is_abandoned() {
let dir = tempfile::tempdir().expect("tempdir");
run_git(dir.path(), &["init", "-q"]).expect("init");
let err = run_git_within(
dir.path(),
&[
"fetch",
"git://10.255.255.1/x",
"+refs/heads/*:refs/heads/*",
],
Duration::from_millis(400),
)
.expect_err("an unanswered fetch must not hang");
let said = format!("{err}");
assert!(
said.contains("no answer within"),
"must fail on the budget, not on something else: {said}"
);
}
}