use super::{CANCEL_FILE, CANCELLING_FILE, CLAIM_FILE, Container, EXIT_FILE, JobStatus, PID_FILE};
#[must_use]
pub fn quote(text: &str) -> String {
format!("'{}'", text.replace('\'', r"'\''"))
}
pub const GROUP_SIGNAL: &str = "group_signal() { kill -s \"$1\" -- -\"$2\" 2>/dev/null || kill -s \"$1\" -\"$2\" 2>/dev/null; }\n";
#[must_use]
pub fn job_script(script: &str) -> String {
format!(
"echo $$ > {PID_FILE}.tmp && mv -f {PID_FILE}.tmp {PID_FILE}\n(\n{script}\n)\ncode=$?\necho \"$code\" > {EXIT_FILE}.tmp && mv -f {EXIT_FILE}.tmp {EXIT_FILE}\nexit \"$code\"\n"
)
}
#[must_use]
pub fn status_script(dir: &str, pid: u32) -> String {
format!(
"{GROUP_SIGNAL}\
cd -- {dir} 2>/dev/null || {{ echo lost; exit 0; }}\n\
pid={pid}\n\
[ \"$pid\" -gt 1 ] || exit 1\n\
alive=0\n\
group_signal 0 \"$pid\" && alive=1\n\
if [ -f {CANCEL_FILE} ]; then echo cancelled\n\
elif [ -f {CANCELLING_FILE} ] && [ \"$alive\" -eq 1 ]; then echo running\n\
elif [ -f {EXIT_FILE} ]; then echo \"exited $(cat {EXIT_FILE})\"\n\
elif [ -f {CANCELLING_FILE} ]; then echo cancelled\n\
elif [ \"$alive\" -eq 1 ]; then echo running\n\
else echo lost; fi\n",
dir = quote(dir)
)
}
#[must_use]
pub fn claim_script(dir: &str, owner: &str) -> String {
format!(
"mkdir -p -- {dir} || exit 1\n\
if (set -C; printf '%s\\n' {owner} > {marker}) 2>/dev/null; then echo claimed\n\
elif [ -f {marker} ]; then\n\
if [ \"$(cat {marker})\" = {owner} ]; then echo claimed; else echo taken; fi\n\
else echo \"cannot create {CLAIM_FILE} in the run directory\" >&2; exit 1; fi\n",
dir = quote(dir),
owner = quote(owner),
marker = quote(&format!("{dir}/{CLAIM_FILE}"))
)
}
#[must_use]
pub fn parse_status(output: &str) -> Option<JobStatus> {
let output = output.trim();
match output {
"running" => Some(JobStatus::Running),
"cancelled" => Some(JobStatus::Cancelled),
"lost" => Some(JobStatus::Lost),
_ => output
.strip_prefix("exited ")
.and_then(|code| code.trim().parse().ok())
.map(JobStatus::Exited),
}
}
#[must_use]
pub fn cancel_script(dir: &str, pid: u32, container: Option<&Container>) -> String {
let stop = container.map_or_else(String::new, |container| {
format!(
"{} stop -t 30 {} >/dev/null 2>&1\n",
container.engine.command(),
quote(&container.name)
)
});
format!(
"{GROUP_SIGNAL}\
cd -- {dir} || exit 1\n\
pid={pid}\n\
[ \"$pid\" -gt 1 ] || exit 1\n\
if [ -f {EXIT_FILE} ] || [ -f {CANCEL_FILE} ]; then exit 0; fi\n\
: > {CANCELLING_FILE}.tmp && mv -f {CANCELLING_FILE}.tmp {CANCELLING_FILE}\n\
{stop}\
group_signal TERM \"$pid\"\n\
i=0\n\
while group_signal 0 \"$pid\" && [ \"$i\" -lt 10 ]; do sleep 1; i=$((i + 1)); done\n\
group_signal KILL \"$pid\"\n\
i=0\n\
while group_signal 0 \"$pid\"; do\n\
[ \"$i\" -lt 10 ] || {{ echo \"process group $pid is still present after SIGKILL (its leader may be waiting for its parent process to reap it); the job stays marked cancelling, retry the cancel\" >&2; exit 1; }}\n\
sleep 1; i=$((i + 1)); done\n\
: > {CANCEL_FILE}\n\
exit 0\n",
dir = quote(dir)
)
}
#[cfg(test)]
mod tests {
use std::fmt;
use std::fs;
use std::os::unix::process::CommandExt;
use std::process::Command;
use std::sync::OnceLock;
use tempfile::tempdir;
use crate::config::Engine;
use super::*;
type TestResult = Result<(), Box<dyn std::error::Error>>;
#[derive(Clone, Copy)]
struct Shell {
program: &'static str,
args: &'static [&'static str],
}
impl Shell {
fn run(self, script: &str) -> Command {
let mut command = Command::new(self.program);
command.args(self.args).arg("-c").arg(script);
command
}
}
impl fmt::Display for Shell {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}", self.program)?;
for arg in self.args {
write!(formatter, " {arg}")?;
}
Ok(())
}
}
const CANDIDATES: [Shell; 3] = [
Shell {
program: "sh",
args: &[],
},
Shell {
program: "dash",
args: &[],
},
Shell {
program: "busybox",
args: &["sh"],
},
];
fn shells() -> &'static [Shell] {
static SHELLS: OnceLock<Vec<Shell>> = OnceLock::new();
SHELLS.get_or_init(|| {
CANDIDATES
.into_iter()
.filter(|shell| {
let available = shell.run(":").status().is_ok_and(|status| status.success());
if !available {
eprintln!("skipped: {shell} is not installed");
}
available
})
.collect()
})
}
fn group_alive(shell: Shell, pid: u32) -> Result<bool, Box<dyn std::error::Error>> {
Ok(shell
.run(&format!("{GROUP_SIGNAL}group_signal 0 {pid}"))
.status()?
.success())
}
fn kill_group(shell: Shell, pid: u32) -> TestResult {
shell
.run(&format!("{GROUP_SIGNAL}group_signal KILL {pid}"))
.status()?;
Ok(())
}
fn spawn_ignoring_term(
shell: Shell,
) -> Result<std::process::Child, Box<dyn std::error::Error>> {
use std::io::BufRead as _;
let mut child = shell
.run("trap '' TERM; echo ready; sleep 30")
.process_group(0)
.stdout(std::process::Stdio::piped())
.spawn()?;
let mut line = String::new();
if let Some(stdout) = child.stdout.take() {
std::io::BufReader::new(stdout).read_line(&mut line)?;
}
if line.trim() != "ready" {
child.kill()?;
child.wait()?;
return Err(format!("{shell}: the job did not start: {line:?}").into());
}
Ok(child)
}
#[test]
fn quoting_survives_single_quotes() {
assert_eq!(quote("a b"), "'a b'");
assert_eq!(quote("it's"), r"'it'\''s'");
assert_eq!(quote(""), "''");
}
#[test]
fn statuses_parse() {
assert_eq!(parse_status("running\n"), Some(JobStatus::Running));
assert_eq!(parse_status("exited 0\n"), Some(JobStatus::Exited(0)));
assert_eq!(parse_status("exited 137"), Some(JobStatus::Exited(137)));
assert_eq!(parse_status("cancelled"), Some(JobStatus::Cancelled));
assert_eq!(parse_status("lost"), Some(JobStatus::Lost));
assert_eq!(parse_status("exited x"), None);
assert_eq!(parse_status(""), None);
}
#[test]
fn cancel_stops_the_container_first() {
let container = Container {
engine: Engine::Podman,
name: "overbrainer-r1".to_string(),
};
let script = cancel_script("/w/r1", 42, Some(&container));
let stop = script.find("podman stop -t 30 'overbrainer-r1'");
let term = script.find("group_signal TERM \"$pid\"");
assert!(stop.is_some() && term.is_some() && stop < term, "{script}");
assert!(!cancel_script("/w/r1", 42, None).contains(" stop "));
}
#[test]
fn job_script_wraps_the_script_and_records_pid_and_exit_code() {
assert_eq!(
job_script("true && false"),
"echo $$ > job.pid.tmp && mv -f job.pid.tmp job.pid\n(\ntrue && false\n)\ncode=$?\necho \"$code\" > exit_code.tmp && mv -f exit_code.tmp exit_code\nexit \"$code\"\n"
);
}
#[test]
fn job_script_runs_with_a_trailing_comment_in_the_script() -> TestResult {
for &shell in shells() {
let dir = tempdir()?;
let script = job_script("true # a trailing comment, not a closing paren");
let output = shell.run(&script).current_dir(dir.path()).output()?;
assert!(
output.status.success(),
"{shell}: {}",
String::from_utf8_lossy(&output.stderr)
);
let pid = fs::read_to_string(dir.path().join(PID_FILE))?;
assert!(pid.trim().parse::<u32>().is_ok(), "{shell}: {pid:?}");
assert_eq!(fs::read_to_string(dir.path().join(EXIT_FILE))?.trim(), "0");
}
Ok(())
}
#[test]
fn job_script_records_a_non_zero_exit_code() -> TestResult {
for &shell in shells() {
let dir = tempdir()?;
let output = shell
.run(&job_script("exit 7"))
.current_dir(dir.path())
.output()?;
assert_eq!(output.status.code(), Some(7), "{shell}");
assert_eq!(fs::read_to_string(dir.path().join(EXIT_FILE))?.trim(), "7");
}
Ok(())
}
#[test]
fn status_reports_lost_when_the_directory_is_gone() -> TestResult {
for &shell in shells() {
let script = status_script("/no/such/dir/overbrainer-test", 2);
let output = shell.run(&script).output()?;
assert!(
output.status.success(),
"{shell}: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
parse_status(&String::from_utf8_lossy(&output.stdout)),
Some(JobStatus::Lost),
"{shell}"
);
}
Ok(())
}
#[test]
fn status_rejects_pid_0_and_1_without_signalling() -> TestResult {
for &shell in shells() {
let dir = tempdir()?;
for pid in [0, 1] {
let script = status_script(&dir.path().display().to_string(), pid);
let output = shell.run(&script).output()?;
assert_eq!(output.status.code(), Some(1), "{shell}");
assert!(output.stdout.is_empty(), "{shell}");
}
}
Ok(())
}
#[test]
fn status_reports_running_for_a_live_process_group() -> TestResult {
for &shell in shells() {
let mut child = Command::new("sleep").arg("5").process_group(0).spawn()?;
let pid = child.id();
let dir = tempdir()?;
let output = shell
.run(&status_script(&dir.path().display().to_string(), pid))
.output()?;
let status = parse_status(&String::from_utf8_lossy(&output.stdout));
child.kill()?;
child.wait()?;
assert_eq!(status, Some(JobStatus::Running), "{shell}");
}
Ok(())
}
#[test]
fn status_reports_exited_with_no_cancel_markers() -> TestResult {
for &shell in shells() {
let mut child = Command::new("true").process_group(0).spawn()?;
let pid = child.id();
child.wait()?;
let dir = tempdir()?;
fs::write(dir.path().join(EXIT_FILE), "42\n")?;
let output = shell
.run(&status_script(&dir.path().display().to_string(), pid))
.output()?;
assert_eq!(
parse_status(&String::from_utf8_lossy(&output.stdout)),
Some(JobStatus::Exited(42)),
"{shell}"
);
}
Ok(())
}
#[test]
fn status_stays_running_when_an_exit_code_appears_mid_cancel() -> TestResult {
for &shell in shells() {
let mut child = Command::new("sleep").arg("5").process_group(0).spawn()?;
let pid = child.id();
let dir = tempdir()?;
fs::write(dir.path().join(CANCELLING_FILE), "")?;
fs::write(dir.path().join(EXIT_FILE), "143\n")?;
let output = shell
.run(&status_script(&dir.path().display().to_string(), pid))
.output()?;
let status = parse_status(&String::from_utf8_lossy(&output.stdout));
child.kill()?;
child.wait()?;
assert_eq!(status, Some(JobStatus::Running), "{shell}");
}
Ok(())
}
#[test]
fn status_reports_exited_for_an_abandoned_cancelling_marker() -> TestResult {
for &shell in shells() {
let mut child = Command::new("true").process_group(0).spawn()?;
let pid = child.id();
child.wait()?;
let dir = tempdir()?;
fs::write(dir.path().join(CANCELLING_FILE), "")?;
fs::write(dir.path().join(EXIT_FILE), "0\n")?;
let output = shell
.run(&status_script(&dir.path().display().to_string(), pid))
.output()?;
assert_eq!(
parse_status(&String::from_utf8_lossy(&output.stdout)),
Some(JobStatus::Exited(0)),
"{shell}"
);
}
Ok(())
}
#[test]
fn status_prefers_cancelled_even_with_a_live_recycled_pid() -> TestResult {
for &shell in shells() {
let mut child = Command::new("sleep").arg("5").process_group(0).spawn()?;
let pid = child.id();
let dir = tempdir()?;
fs::write(dir.path().join(CANCEL_FILE), "")?;
let output = shell
.run(&status_script(&dir.path().display().to_string(), pid))
.output()?;
let status = parse_status(&String::from_utf8_lossy(&output.stdout));
child.kill()?;
child.wait()?;
assert_eq!(status, Some(JobStatus::Cancelled), "{shell}");
}
Ok(())
}
#[test]
fn cancel_rejects_pid_0_and_1_without_signalling() -> TestResult {
for &shell in shells() {
let dir = tempdir()?;
for pid in [0, 1] {
let script = cancel_script(&dir.path().display().to_string(), pid, None);
let output = shell.run(&script).output()?;
assert_eq!(output.status.code(), Some(1), "{shell}");
assert!(!dir.path().join(CANCEL_FILE).exists(), "{shell}");
}
}
Ok(())
}
#[test]
fn cancel_exits_without_signalling_when_the_directory_is_gone() -> TestResult {
for &shell in shells() {
let script = cancel_script("/no/such/dir/overbrainer-test", 42, None);
let output = shell.run(&script).output()?;
assert_eq!(output.status.code(), Some(1), "{shell}");
}
Ok(())
}
#[test]
fn cancel_does_nothing_when_the_job_already_exited() -> TestResult {
for &shell in shells() {
let dir = tempdir()?;
fs::write(dir.path().join(EXIT_FILE), "0\n")?;
let mut child = Command::new("sleep").arg("5").spawn()?;
let pid = child.id();
let script = cancel_script(&dir.path().display().to_string(), pid, None);
let output = shell.run(&script).output()?;
let untouched = child.try_wait()?.is_none();
child.kill()?;
child.wait()?;
assert!(
output.status.success(),
"{shell}: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(!dir.path().join(CANCEL_FILE).exists(), "{shell}");
assert!(
untouched,
"{shell}: cancel must not signal a finished job's possibly recycled pid"
);
}
Ok(())
}
#[test]
fn cancel_does_nothing_when_already_cancelled() -> TestResult {
for &shell in shells() {
let child = Command::new("sleep").arg("5").process_group(0).spawn()?;
let pid = child.id();
let reaper = std::thread::spawn(move || {
let mut child = child;
child.wait()
});
let dir = tempdir()?;
fs::write(dir.path().join(CANCEL_FILE), "")?;
let script = cancel_script(&dir.path().display().to_string(), pid, None);
let output = shell.run(&script).output()?;
let still_alive = group_alive(shell, pid)?;
kill_group(shell, pid)?;
reaper.join().map_err(|_| "reaper thread panicked")??;
assert!(
output.status.success(),
"{shell}: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
still_alive,
"{shell}: cancel must not re-signal an already cancelled job's process group"
);
}
Ok(())
}
#[test]
fn status_stays_running_while_a_cancel_is_in_progress() -> TestResult {
for &shell in shells() {
let child = spawn_ignoring_term(shell)?;
let pid = child.id();
let reaper = std::thread::spawn(move || {
let mut child = child;
child.wait()
});
let dir = tempdir()?;
let cancel_dir = dir.path().display().to_string();
let canceller = std::thread::spawn(move || {
shell.run(&cancel_script(&cancel_dir, pid, None)).output()
});
std::thread::sleep(std::time::Duration::from_secs(2));
let mid_output = shell
.run(&status_script(&dir.path().display().to_string(), pid))
.output()?;
assert_eq!(
parse_status(&String::from_utf8_lossy(&mid_output.stdout)),
Some(JobStatus::Running),
"{shell}: status must stay running while the group is still being stopped"
);
let cancel_output = canceller
.join()
.map_err(|_| "canceller thread panicked")??;
assert!(
cancel_output.status.success(),
"{shell}: {}",
String::from_utf8_lossy(&cancel_output.stderr)
);
assert!(dir.path().join(CANCEL_FILE).exists(), "{shell}");
reaper.join().map_err(|_| "reaper thread panicked")??;
}
Ok(())
}
#[test]
fn cancel_kills_the_process_group_before_the_cancelled_marker_appears() -> TestResult {
for &shell in shells() {
let child = spawn_ignoring_term(shell)?;
let pid = child.id();
let reaper = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(12));
let mut child = child;
child.wait()
});
let dir = tempdir()?;
let cancel_dir = dir.path().display().to_string();
let canceller = std::thread::spawn(move || {
shell.run(&cancel_script(&cancel_dir, pid, None)).output()
});
let marker_path = dir.path().join(CANCEL_FILE);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while !marker_path.exists() {
if std::time::Instant::now() > deadline {
return Err("timed out waiting for the cancelled marker".into());
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
let alive = group_alive(shell, pid)?;
let cancel_output = canceller
.join()
.map_err(|_| "canceller thread panicked")??;
reaper.join().map_err(|_| "reaper thread panicked")??;
assert!(
!alive,
"{shell}: the cancelled marker appeared while the process group still existed"
);
assert!(
cancel_output.status.success(),
"{shell}: {}",
String::from_utf8_lossy(&cancel_output.stderr)
);
}
Ok(())
}
#[test]
fn status_never_reports_lost_during_a_normal_cancel() -> TestResult {
for &shell in shells() {
let child = Command::new("sleep").arg("10").process_group(0).spawn()?;
let pid = child.id();
let reaper = std::thread::spawn(move || {
let mut child = child;
child.wait()
});
let dir = tempdir()?;
let cancel_dir = dir.path().display().to_string();
let canceller = std::thread::spawn(move || {
shell.run(&cancel_script(&cancel_dir, pid, None)).output()
});
let status_dir = dir.path().display().to_string();
let mut saw_cancelled = false;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
loop {
if std::time::Instant::now() > deadline {
return Err("timed out waiting for the cancel to finish".into());
}
let output = shell.run(&status_script(&status_dir, pid)).output()?;
let status = parse_status(&String::from_utf8_lossy(&output.stdout));
assert_ne!(
status,
Some(JobStatus::Lost),
"{shell}: status must never read lost while a cancel is in progress"
);
if status == Some(JobStatus::Cancelled) {
saw_cancelled = true;
}
if canceller.is_finished() && saw_cancelled {
break;
}
std::thread::sleep(std::time::Duration::from_millis(2));
}
let cancel_output = canceller
.join()
.map_err(|_| "canceller thread panicked")??;
assert!(
cancel_output.status.success(),
"{shell}: {}",
String::from_utf8_lossy(&cancel_output.stderr)
);
reaper.join().map_err(|_| "reaper thread panicked")??;
}
Ok(())
}
#[test]
fn a_run_directory_is_claimed_once() -> TestResult {
for &shell in shells() {
let dir = tempdir()?;
let run = dir.path().join("a b/run_1");
let run = run.to_string_lossy();
let claim = |owner: &str| -> Result<String, Box<dyn std::error::Error>> {
let output = shell.run(&claim_script(&run, owner)).output()?;
assert!(output.status.success(), "{shell}");
Ok(String::from_utf8(output.stdout)?)
};
let owner = "ssh-ed25519 AAAA'x";
assert_eq!(claim(owner)?, "claimed\n", "{shell}");
assert_eq!(claim(owner)?, "claimed\n", "{shell}");
assert_eq!(claim("other")?, "taken\n", "{shell}");
assert_eq!(
fs::read_to_string(dir.path().join("a b/run_1").join(CLAIM_FILE))?,
format!("{owner}\n"),
"{shell}"
);
let blocked = dir.path().join("file");
fs::write(&blocked, b"")?;
let failed = shell
.run(&claim_script(&blocked.join("run").to_string_lossy(), owner))
.output()?;
assert!(!failed.status.success(), "{shell}");
assert!(failed.stdout.is_empty(), "{shell}");
}
Ok(())
}
}