use std::env;
use std::path::Path;
use std::time::Duration;
use std::os::unix::process::CommandExt as _;
use tokio::process::Command;
use tokio::time::timeout;
use crate::error::Error;
pub(crate) async fn run_in_group(
mut child: Command,
release: &Path,
budget: Duration,
) -> Result<(), Error> {
let mut holder = std::process::Command::new("sh");
holder
.args(["-c", "read _"])
.env_clear()
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.process_group(0);
if let Some(path) = env::var_os("PATH") {
holder.env("PATH", path);
}
let mut holder = holder.spawn().map_err(Error::at(release))?;
let group = holder.id();
let group_id = i32::try_from(group).map_err(|_| Error::Io {
path: release.to_owned(),
source: std::io::Error::other("the group holder's pid does not fit a pid_t"),
})?;
child.process_group(group_id);
let mut child = match child.spawn() {
Ok(child) => child,
Err(source) => {
crate::shared::kill_group(group);
let _ = holder.wait();
return Err(Error::Io {
path: release.to_owned(),
source,
});
}
};
let status = match timeout(budget, child.wait()).await {
Ok(waited) => waited,
Err(_) => {
abandon(&mut child, group).await;
let _ = holder.wait();
return Err(Error::BuildTimedOut { after: budget });
}
};
crate::shared::kill_group(group);
let _ = holder.wait();
let status = status.map_err(Error::at(release))?;
if !status.success() {
return Err(Error::Build {
status: status.code(),
});
}
Ok(())
}
async fn abandon(child: &mut tokio::process::Child, group: u32) {
crate::shared::kill_group(group);
let _ = child.kill().await;
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::super::test_support::build;
use super::super::{BuildSpec, run};
use super::*;
use crate::fixtures;
#[tokio::test]
async fn a_successful_build_does_not_leave_its_background_job_running() {
let rel = fixtures::fixture_release(&[]);
let spec = BuildSpec {
command: Some("sleep 400 & echo $! > bg.pid".into()),
env: BTreeMap::new(),
artifacts: vec![],
};
build(rel.path(), &spec).await.expect("this build exits 0");
let pid = std::fs::read_to_string(rel.path().join("bg.pid"))
.expect("the build wrote its background job's pid");
assert!(
!still_running(pid.trim()),
"the background job (pid {}) outlived the build that started it",
pid.trim()
);
}
#[tokio::test]
async fn a_build_that_never_finishes_is_abandoned() {
let rel = fixtures::fixture_release(&[]);
let spec = BuildSpec {
command: Some("sleep 600 & echo $! > descendant.pid; wait".into()),
env: BTreeMap::new(),
artifacts: vec![],
};
let err = run(
"web",
rel.path(),
&spec,
None,
&[],
fixtures::tempdir().path(),
Duration::from_secs(2),
)
.await
.expect_err("a build that never finishes must be abandoned");
assert!(
matches!(err, Error::BuildTimedOut { .. }),
"must say it timed out rather than that it failed: {err:?}"
);
assert!(
format!("{err}").contains("build_timeout"),
"must name the knob that changes it: {err}"
);
let pid = std::fs::read_to_string(rel.path().join("descendant.pid")).expect(
"the build never got far enough to write its descendant's pid, so this \
proves nothing about the group kill either way",
);
assert!(
!still_running(pid.trim()),
"the backgrounded descendant (pid {}) outlived the build it belonged to",
pid.trim()
);
}
fn still_running(pid: &str) -> bool {
for _ in 0..50 {
let alive = std::process::Command::new("kill")
.args(["-0", pid])
.status()
.expect("kill -0")
.success();
if !alive {
return false;
}
std::thread::sleep(Duration::from_millis(20));
}
true
}
#[tokio::test]
async fn a_failing_build_is_an_error() {
let rel = fixtures::fixture_release(&[]);
let spec = BuildSpec {
command: Some("exit 3".into()),
..Default::default()
};
assert!(build(rel.path(), &spec).await.is_err());
}
}