use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
struct Harness {
root: PathBuf,
}
impl Harness {
fn new(name: &str, config: &str) -> Self {
let root = std::env::temp_dir().join(format!(
"qx{}-{}-{}",
std::process::id(),
name,
Instant::now().elapsed().subsec_nanos()
));
std::fs::create_dir_all(root.join("cfg")).unwrap();
std::fs::create_dir_all(root.join("state")).unwrap();
std::fs::create_dir_all(root.join("run")).unwrap();
std::fs::write(root.join("cfg/qex.toml"), config).unwrap();
Self { root }
}
fn with_default_config(name: &str) -> Self {
Self::new(
name,
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
)
}
fn qex(&self, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_qex"))
.args(args)
.env("XDG_CONFIG_HOME", self.root.join("cfg"))
.env("XDG_STATE_HOME", self.root.join("state"))
.env("XDG_RUNTIME_DIR", self.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.expect("qex did not start")
}
fn ok(&self, args: &[&str]) -> String {
let out = self.qex(args);
assert!(
out.status.success(),
"the command `qex {}` failed with the code {:?}\nstdout: {}\nstderr: {}",
args.join(" "),
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn submit(&self, args: &[&str]) -> String {
let id = self.ok(args);
assert_eq!(id.lines().count(), 1, "submit must write the id only: {id}");
assert!(
id.parse::<uuid::Uuid>().is_ok(),
"submit must write a job id, and it wrote: {id}"
);
id
}
fn status_json(&self, id: &str) -> serde_json::Value {
let text = self.ok(&["status", id, "--json"]);
serde_json::from_str(&text).expect("the status output is not valid JSON")
}
fn list_json(&self) -> Vec<serde_json::Value> {
let text = self.ok(&["list", "--json"]);
serde_json::from_str(&text).expect("the list output is not valid JSON")
}
fn state_of(&self, id: &str) -> String {
self.status_json(id)["state"].as_str().unwrap().to_string()
}
fn until(&self, what: &str, limit: Duration, mut test: impl FnMut() -> bool) {
let deadline = Instant::now() + limit;
while Instant::now() < deadline {
if test() {
return;
}
std::thread::sleep(Duration::from_millis(200));
}
let jobs = String::from_utf8_lossy(&self.qex(&["list"]).stdout).to_string();
let info = String::from_utf8_lossy(&self.qex(&["info", "--no-start"]).stdout).to_string();
let mut detail = String::new();
for job in self.list_json() {
let state = job["state"].as_str().unwrap_or("");
if matches!(
state,
"completed" | "failed" | "killed" | "cancelled" | "skipped"
) {
continue;
}
let id = job["id"].as_str().unwrap_or("").to_string();
let sup = job["supervisor_pid"].as_i64();
let alive = match sup {
Some(pid) => {
if unsafe { libc::kill(pid as i32, 0) } == 0 {
"alive"
} else {
"DEAD"
}
}
None => "none",
};
detail.push_str(&format!(
"\njob {id} state={state} supervisor={sup:?} ({alive})\n"
));
let dir = self.root.join("state/qex/jobs").join(&id);
for file in ["status.json", "supervisor.log", "stderr.log"] {
if let Ok(text) = std::fs::read(dir.join(file)) {
let text = String::from_utf8_lossy(&text);
let text = text.trim();
if !text.is_empty() {
detail.push_str(&format!(" --- {file} ---\n {:.900}\n", text));
}
}
}
if let Some(pid) = sup {
if let Ok(out) = std::process::Command::new("ps")
.args(["-o", "pid=,stat=,wchan:20=,args=", "-p", &pid.to_string()])
.output()
{
detail.push_str(&format!(
" --- ps ---\n {}\n",
String::from_utf8_lossy(&out.stdout).trim()
));
}
}
}
panic!(
"qex did not reach this condition in {limit:?}: {what}\n\n\
--- qex list ---\n{jobs}\n--- qex info ---\n{info}\n{detail}"
);
}
fn has_started(&self, id: &str) -> bool {
!matches!(self.state_of(id).as_str(), "queued" | "starting")
}
fn stop(&self, id: &str) {
self.until("the job starts", Duration::from_secs(45), || {
self.has_started(id)
});
self.qex(&["kill", id, "--grace", "1s"]);
}
fn coordinator_pid(&self) -> i32 {
let text = self.ok(&["info", "--json"]);
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
v["pid"].as_i64().unwrap() as i32
}
fn job_dir(&self, id: &str) -> PathBuf {
self.root.join("state/qex/jobs").join(id)
}
#[allow(clippy::zombie_processes)]
fn spawn(&self, args: &[&str]) -> std::process::Child {
Command::new(env!("CARGO_BIN_EXE_qex"))
.args(args)
.env("XDG_CONFIG_HOME", self.root.join("cfg"))
.env("XDG_STATE_HOME", self.root.join("state"))
.env("XDG_RUNTIME_DIR", self.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("qex did not start")
}
#[allow(clippy::zombie_processes)]
fn run_bg(&self, args: &[&str]) -> (std::process::Child, String) {
let id_file = self.root.join(format!(
"run-{}.id",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let id_path = id_file.to_str().unwrap().to_string();
let mut all: Vec<&str> = vec!["run", "--id-file", &id_path];
all.extend_from_slice(args);
let child = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(&all)
.env("XDG_CONFIG_HOME", self.root.join("cfg"))
.env("XDG_STATE_HOME", self.root.join("state"))
.env("XDG_RUNTIME_DIR", self.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("qex run did not start");
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if let Ok(text) = std::fs::read_to_string(&id_file) {
let id = text.trim().to_string();
if id.parse::<uuid::Uuid>().is_ok() {
return (child, id);
}
}
std::thread::sleep(Duration::from_millis(100));
}
panic!("`qex run` did not write its id file in 30 seconds");
}
fn qex_stdin(&self, args: &[&str], input: &str) -> Output {
use std::io::Write;
let mut child = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(args)
.env("XDG_CONFIG_HOME", self.root.join("cfg"))
.env("XDG_STATE_HOME", self.root.join("state"))
.env("XDG_RUNTIME_DIR", self.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("qex did not start");
child
.stdin
.take()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
child.wait_with_output().expect("qex did not stop")
}
fn write_config(&self, config: &str) {
std::fs::write(self.root.join("cfg/qex.toml"), config).unwrap();
}
fn hook_origin(&self, id: &str) -> String {
let text = std::fs::read_to_string(self.job_dir(id).join("hook.ran"))
.unwrap_or_else(|e| panic!("hook.ran is not there for the job {id}: {e}"));
text.split_whitespace()
.next_back()
.unwrap_or_default()
.to_string()
}
fn hook_lines(&self) -> Vec<String> {
match std::fs::read_to_string(self.root.join("hook.txt")) {
Ok(text) => text.lines().map(|l| l.trim().to_string()).collect(),
Err(_) => Vec::new(),
}
}
}
impl Drop for Harness {
fn drop(&mut self) {
let out = self.qex(&["info", "--json"]);
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out.stdout) {
if let Some(pid) = v["pid"].as_i64() {
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
}
}
std::fs::remove_dir_all(&self.root).ok();
}
}
#[test]
fn a_job_that_succeeds_gives_the_exit_code_zero() {
let h = Harness::with_default_config("ok");
let id = h.submit(&["submit", "--", "true"]);
let out = h.qex(&["wait", &id]);
assert_eq!(out.status.code(), Some(0));
assert_eq!(h.state_of(&id), "completed");
}
#[test]
fn a_job_that_fails_gives_the_exit_code_one() {
let h = Harness::with_default_config("fail");
let id = h.submit(&["submit", "--", "false"]);
let out = h.qex(&["wait", &id]);
assert_eq!(out.status.code(), Some(1));
let status = h.status_json(&id);
assert_eq!(status["state"], "failed");
assert_eq!(status["exit_code"], 1);
}
#[test]
fn a_wait_gives_the_exit_code_of_the_job() {
let h = Harness::with_default_config("pass");
let id = h.submit(&["submit", "--", "sh", "-c", "exit 42"]);
assert_eq!(h.qex(&["wait", &id]).status.code(), Some(42));
}
#[test]
fn a_job_code_that_qex_keeps_gives_the_sentinel() {
let h = Harness::with_default_config("band124");
let id = h.submit(&["submit", "--", "sh", "-c", "exit 124"]);
let out = h.qex(&["wait", &id]);
assert_eq!(
out.status.code(),
Some(97),
"a job that exits 124 must not look like a wait that reached its limit"
);
assert_eq!(h.status_json(&id)["exit_code"], 124);
}
#[test]
fn the_boundaries_of_the_band_hold() {
let h = Harness::with_default_config("bandedge");
let low = h.submit(&["submit", "--", "sh", "-c", "exit 96"]);
assert_eq!(h.qex(&["wait", &low]).status.code(), Some(96));
let sentinel = h.submit(&["submit", "--", "sh", "-c", "exit 97"]);
assert_eq!(h.qex(&["wait", &sentinel]).status.code(), Some(97));
assert_eq!(h.status_json(&sentinel)["exit_code"], 97);
}
#[test]
fn a_signal_that_stopped_the_job_gives_its_own_code() {
let h = Harness::with_default_config("jobsignal");
let id = h.submit(&["submit", "--", "sh", "-c", "kill -SEGV $$"]);
let out = h.qex(&["wait", &id]);
assert_eq!(
out.status.code(),
Some(98),
"a signal in the job must not give `128 + N`, which qex keeps for itself"
);
let status = h.status_json(&id);
assert_eq!(
status["signal"],
libc::SIGSEGV,
"the record names the signal"
);
}
#[test]
fn a_signal_to_the_wait_gives_the_code_of_a_broken_wait() {
let h = Harness::with_default_config("waitsig");
let id = h.submit(&["submit", "--", "sleep", "30"]);
h.until("the job starts", Duration::from_secs(45), || {
h.has_started(&id)
});
let child = h.spawn(&["wait", &id]);
std::thread::sleep(Duration::from_millis(800));
unsafe {
libc::kill(child.id() as i32, libc::SIGINT);
}
let out = child.wait_with_output().expect("the wait did not stop");
assert_eq!(
out.status.code(),
Some(122),
"a wait that a signal stopped must not give 130"
);
let said = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
said.contains("qex status") && said.contains("--wait"),
"the message must give the command that attaches again: {said}"
);
assert_eq!(
h.state_of(&id),
"running",
"a signal to the wait must not stop the job"
);
h.stop(&id);
}
#[test]
fn follow_obeys_the_time_limit_of_the_reader() {
let h = Harness::with_default_config("followlimit");
let id = h.submit(&["submit", "--", "sleep", "30"]);
h.until("the job starts", Duration::from_secs(45), || {
h.has_started(&id)
});
let started = Instant::now();
let out = h.qex(&["status", &id, "--follow", "--timeout", "2s"]);
assert_eq!(
out.status.code(),
Some(124),
"a wait that reaches its limit gives 124: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
started.elapsed() < Duration::from_secs(20),
"`--follow` ignored the limit of the reader"
);
assert_eq!(
h.state_of(&id),
"running",
"the limit of the reader must not stop the job"
);
h.stop(&id);
}
#[test]
fn the_help_of_submit_says_that_the_id_goes_to_stderr() {
let h = Harness::with_default_config("submithelp");
let text = String::from_utf8_lossy(&h.qex(&["submit", "--help"]).stdout).to_string();
let wait_part = text
.split("--wait")
.nth(1)
.expect("`--wait` must be in the help of `qex submit`")
.to_string();
assert!(
wait_part.contains("STDERR") || wait_part.contains("stderr"),
"the help must say that the id goes to stderr: {wait_part:.600}"
);
}
#[test]
fn submit_with_a_wait_gives_the_code_of_the_job_and_writes_the_id_first() {
let h = Harness::with_default_config("submitwait");
let id_file = h.root.join("job.id");
let path = id_file.to_str().unwrap().to_string();
let child = h.spawn(&[
"submit",
"--wait",
"--id-file",
&path,
"--",
"sh",
"-c",
"sleep 3; exit 7",
]);
let deadline = Instant::now() + Duration::from_secs(30);
let mut id = String::new();
while Instant::now() < deadline {
if let Ok(text) = std::fs::read_to_string(&id_file) {
if text.trim().parse::<uuid::Uuid>().is_ok() {
id = text.trim().to_string();
break;
}
}
std::thread::sleep(Duration::from_millis(100));
}
assert!(
!id.is_empty(),
"`--id-file` must reach the disk before the wait begins"
);
assert!(
!matches!(h.state_of(&id).as_str(), "completed" | "failed"),
"the test must read the id file while the job still operates"
);
let out = child.wait_with_output().expect("the command did not stop");
assert_eq!(
out.status.code(),
Some(7),
"`qex submit --wait` must give the exit code of the job: {}",
String::from_utf8_lossy(&out.stderr)
);
let said = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
said.contains("exit code: 7") && said.contains("failed"),
"`--wait` must end with the record of the job: {said}"
);
assert!(String::from_utf8_lossy(&out.stderr).contains(&id));
}
#[test]
fn submit_with_a_wait_and_qex_run_give_one_code() {
let h = Harness::with_default_config("waitagree");
for code in [0, 3, 96] {
let command = format!("exit {code}");
let submit = h.qex(&["submit", "--wait", "--", "sh", "-c", &command]);
let (child, _) = h.run_bg(&["--", "sh", "-c", &command]);
let run = wait_run(child, "the job gives its own exit code");
assert_eq!(
submit.status.code(),
run.status.code(),
"`qex submit --wait` and `qex run` gave two codes for the code {code}"
);
assert_eq!(submit.status.code(), Some(code));
}
}
#[test]
fn a_usage_error_of_a_command_that_speaks_for_a_job_uses_the_band() {
let h = Harness::with_default_config("usage");
for args in [
vec!["status"],
vec!["wait"],
vec!["submit", "--wait", "--cpu", "not-a-number", "--", "true"],
] {
let out = h.qex(&args);
assert_eq!(
out.status.code(),
Some(121),
"`qex {}` must give a code from the band: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
let out = h.qex(&["list", "--no-such-option"]);
assert_eq!(out.status.code(), Some(2));
}
#[test]
fn status_with_follow_attaches_to_a_job_and_gives_its_output() {
let h = Harness::with_default_config("follow");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"echo first; sleep 1; echo second; exit 5",
]);
let out = h.qex(&["status", &id, "--follow"]);
assert_eq!(
out.status.code(),
Some(5),
"`--follow` must give the exit code of the job"
);
let said = String::from_utf8_lossy(&out.stdout).to_string();
assert_eq!(
said, "first\nsecond\n",
"stdout must hold the output of the job and no text of qex: {said:?}"
);
}
#[test]
fn submit_with_follow_is_qex_run() {
let h = Harness::with_default_config("longrun");
let follow = h.qex(&["submit", "--follow", "--", "sh", "-c", "echo hello; exit 4"]);
let run = h.qex(&["run", "--", "sh", "-c", "echo hello; exit 4"]);
assert_eq!(follow.status.code(), run.status.code());
assert_eq!(follow.status.code(), Some(4));
assert_eq!(
String::from_utf8_lossy(&follow.stdout),
String::from_utf8_lossy(&run.stdout)
);
assert_eq!(String::from_utf8_lossy(&follow.stdout).trim(), "hello");
}
#[test]
fn quiet_gives_the_exit_code_and_no_text() {
let h = Harness::with_default_config("quiet");
let id = h.submit(&["submit", "--", "sh", "-c", "echo noise; exit 3"]);
let out = h.qex(&["status", &id, "--wait", "--quiet"]);
assert_eq!(out.status.code(), Some(3));
assert_eq!(String::from_utf8_lossy(&out.stdout), "");
let out = h.qex(&["wait", &id, "--quiet"]);
assert_eq!(out.status.code(), Some(3));
assert_eq!(String::from_utf8_lossy(&out.stdout), "");
let out = h.qex(&["status", &id, "--quiet"]);
assert_eq!(out.status.code(), Some(3));
assert_eq!(String::from_utf8_lossy(&out.stdout), "");
let running = h.submit(&["submit", "--", "sleep", "30"]);
h.until("the job starts", Duration::from_secs(45), || {
h.has_started(&running)
});
let out = h.qex(&["status", &running, "--quiet"]);
assert_eq!(
out.status.code(),
Some(100),
"a job that did not stop has no result, and this reader set no wait"
);
h.stop(&running);
}
#[test]
fn quiet_on_a_pipeline_reports_the_stage_that_failed() {
let h = Harness::with_default_config("quietpipe");
let file = h.root.join("pl.toml");
std::fs::write(
&file,
"[[jobs]]\nname = \"one\"\ncommand = [\"true\"]\n\
[[jobs]]\nname = \"two\"\nneeds = [\"one\"]\ncommand = [\"sh\", \"-c\", \"exit 6\"]\n",
)
.unwrap();
let group = h.ok(&["pipeline", file.to_str().unwrap()]);
h.qex(&["wait", &group, "--timeout", "45s"]);
for args in [
vec!["status", &group, "--quiet"],
vec!["status", &group, "--wait", "--quiet"],
vec!["wait", &group, "--quiet"],
] {
let out = h.qex(&args);
assert_eq!(
out.status.code(),
Some(6),
"`qex {}` must give the code of the stage that failed",
args.join(" ")
);
}
}
#[test]
fn a_wait_for_many_jobs_says_why_the_later_job_waits() {
let h = Harness::new(
"manyreason",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let first = h.submit(&["submit", "--cpu", "2", "--mem", "100MB", "--", "sleep", "6"]);
h.until("the first job starts", Duration::from_secs(45), || {
h.has_started(&first)
});
let holder = h.submit(&[
"submit", "--name", "holder", "--cpu", "2", "--mem", "100MB", "--", "sleep", "25",
]);
let second = h.submit(&[
"submit", "--cpu", "1", "--mem", "100MB", "--needs", &holder, "--", "true",
]);
let child = h.spawn(&["wait", &first, &second, "--timeout", "30s"]);
std::thread::sleep(Duration::from_secs(12));
unsafe {
libc::kill(child.id() as i32, libc::SIGINT);
}
let out = child.wait_with_output().expect("the wait did not stop");
let said = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
said.contains(&holder[..8]) || said.contains("holder"),
"the wait must say that the second job waits for the holder: {said}"
);
h.stop(&holder);
h.qex(&["cancel", &second]);
}
#[test]
fn quiet_keeps_the_faults_of_the_wait() {
let h = Harness::new(
"quietfault",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let holder = h.submit(&[
"submit", "--cpu", "2", "--mem", "100MB", "--", "sleep", "20",
]);
h.until("the first job starts", Duration::from_secs(45), || {
h.has_started(&holder)
});
let waiter = h.submit(&["submit", "--cpu", "2", "--mem", "100MB", "--", "true"]);
h.until("the second job waits", Duration::from_secs(30), || {
!h.status_json(&waiter)["blocked_reason"].is_null()
});
let reason = h.status_json(&waiter)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
for args in [
vec!["wait", &waiter, "--quiet", "--timeout", "3s"],
vec!["status", &waiter, "--wait", "--quiet", "--timeout", "3s"],
] {
let out = h.qex(&args);
assert_eq!(out.status.code(), Some(124), "`qex {}`", args.join(" "));
assert_eq!(
String::from_utf8_lossy(&out.stdout),
"",
"`--quiet` must write nothing on stdout"
);
let said = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
!said.contains(&reason),
"`--quiet` must not narrate the queue: {said}"
);
assert!(
said.contains("time limit"),
"`--quiet` must keep the fault of the wait: {said}"
);
}
let out = h.qex(&["wait", "0f0f0f0f", "--quiet"]);
assert_eq!(out.status.code(), Some(127));
assert!(
!String::from_utf8_lossy(&out.stderr).is_empty(),
"`--quiet` must say that there is no such job"
);
h.stop(&holder);
h.qex(&["cancel", &waiter]);
}
#[test]
fn follow_gives_the_code_of_the_job_and_not_the_limit_of_the_reader() {
let h = Harness::with_default_config("followrace");
for _ in 0..6 {
let id = h.submit(&["submit", "--", "sh", "-c", "echo out; exit 5"]);
let out = h.qex(&["status", &id, "--follow", "--timeout", "1s"]);
assert_eq!(
out.status.code(),
Some(5),
"a job that stopped must give its own code: {}",
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn a_limit_with_no_wait_is_refused() {
let h = Harness::with_default_config("nolimit");
let id = h.submit(&["submit", "--", "true"]);
h.qex(&["wait", &id, "--timeout", "30s"]);
let out = h.qex(&["status", &id, "--timeout", "5s"]);
assert_eq!(out.status.code(), Some(121));
let said = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
said.contains("--wait") && said.contains("--follow"),
"the message must give the options that wait: {said}"
);
}
#[test]
fn wait_with_next_says_why_a_job_waits() {
let h = Harness::new(
"nextreason",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let holder = h.submit(&[
"submit", "--cpu", "2", "--mem", "100MB", "--", "sleep", "20",
]);
h.until("the first job starts", Duration::from_secs(45), || {
h.has_started(&holder)
});
let waiter = h.submit(&["submit", "--cpu", "2", "--mem", "100MB", "--", "true"]);
h.until("the second job waits", Duration::from_secs(30), || {
!h.status_json(&waiter)["blocked_reason"].is_null()
});
let reason = h.status_json(&waiter)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
let out = h.qex(&["wait", "--next", &waiter, "--timeout", "4s"]);
let said = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
said.contains(&reason),
"`--next` must say why the job waits `{reason}`: {said}"
);
h.stop(&holder);
h.qex(&["cancel", &waiter]);
}
#[test]
fn qex_inside_bubblewrap_names_the_sandbox() {
if Command::new("bwrap")
.arg("--version")
.output()
.map(|o| !o.status.success())
.unwrap_or(true)
{
return;
}
let h = Harness::with_default_config("bwrap");
let state = h.root.join("state");
std::fs::create_dir_all(state.join("qex/run")).unwrap();
let out = Command::new("bwrap")
.args([
"--bind",
"/",
"/",
"--dev",
"/dev",
"--proc",
"/proc",
"--ro-bind",
state.to_str().unwrap(),
state.to_str().unwrap(),
"--setenv",
"XDG_STATE_HOME",
state.to_str().unwrap(),
"--setenv",
"XDG_CONFIG_HOME",
h.root.join("cfg").to_str().unwrap(),
"--",
env!("CARGO_BIN_EXE_qex"),
"list",
])
.output()
.expect("bwrap did not start");
let said = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
said.contains("docs/sandbox.md"),
"qex inside a sandbox must give the page for a person: {said}"
);
let cause = said
.find(state.to_str().unwrap())
.expect("the message must name the directory");
let page = said
.find("docs/sandbox.md")
.expect("the message must give the page");
assert!(
cause < page,
"the message must give the fault before the remedy: {said}"
);
}
#[test]
fn a_job_that_does_not_exist_answers_at_once_with_no_coordinator() {
let h = Harness::with_default_config("nojobfast");
h.ok(&["info", "--json"]);
let pid = h.coordinator_pid();
unsafe {
libc::kill(pid, libc::SIGKILL);
}
h.until("the coordinator is gone", Duration::from_secs(10), || {
(unsafe { libc::kill(pid, 0) }) != 0
});
let missing = "3f2b1c0d-0000-4000-8000-000000000000";
let started = Instant::now();
let out = h.qex(&["wait", missing, "--quiet", "--timeout", "60s"]);
assert_eq!(out.status.code(), Some(127));
assert!(
started.elapsed() < Duration::from_secs(8),
"the answer took {:?}, and the record was ready at once",
started.elapsed()
);
}
#[test]
fn an_id_file_that_fails_does_not_lose_the_job() {
let h = Harness::with_default_config("idfilefail");
let locked = h.root.join("locked");
std::fs::create_dir_all(&locked).unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o555)).unwrap();
if unsafe { libc::geteuid() } == 0 {
return;
}
let path = locked.join("job.id");
let out = h.qex(&[
"submit",
"--wait",
"--id-file",
path.to_str().unwrap(),
"--",
"sh",
"-c",
"exit 4",
]);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).ok();
assert_eq!(
out.status.code(),
Some(4),
"a file that failed must not stop the wait: {}",
String::from_utf8_lossy(&out.stderr)
);
let said = String::from_utf8_lossy(&out.stderr).to_string();
let id = said
.lines()
.find_map(|l| l.strip_prefix("qex: job "))
.expect("the id must reach stderr before anything can fail");
assert!(
id.trim().parse::<uuid::Uuid>().is_ok(),
"the line must hold a job id: {id}"
);
assert!(
said.contains("did not reach the disk"),
"the fault of the file must be loud: {said}"
);
assert_eq!(h.status_json(id.trim())["exit_code"], 4);
}
#[test]
fn version_check_asks_the_service_and_says_what_it_found() {
let h = Harness::with_default_config("updatecheck");
let answer = h.root.join("latest.json");
std::fs::write(&answer, r#"{"tag_name": "v9.9.9"}"#).unwrap();
h.write_config(&format!(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[update]\nurl = \"file://{}\"\n",
answer.display()
));
let out = h.qex(&["version", "--check"]);
assert_eq!(out.status.code(), Some(0), "the answer arrived");
let said = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
said.contains("9.9.9"),
"the newest release must be named: {said}"
);
assert!(
said.contains(&answer.display().to_string()),
"the service that answered must be named: {said}"
);
let value: serde_json::Value =
serde_json::from_slice(&h.qex(&["version", "--check", "--json"]).stdout).unwrap();
assert!(value["version"].is_string(), "got: {value}");
assert!(value["coordinator"].is_object(), "got: {value}");
let update = &value["update"];
assert_eq!(update["newest"], "9.9.9");
assert!(update["error"].is_null());
let development = update["development"]
.as_bool()
.unwrap_or_else(|| panic!("`update.development` must be a boolean: {update}"));
if development {
assert!(
said.contains("development build"),
"a development build must be named as one: {said}"
);
assert_eq!(
update["newer"], false,
"a development build takes no place in the order"
);
} else {
assert!(
said.contains("A newer release exists"),
"a release below 9.9.9 must be told about it: {said}"
);
assert_eq!(update["newer"], true, "9.9.9 is above every release of qex");
}
}
#[test]
fn version_check_says_what_stopped_it() {
let h = Harness::with_default_config("updatefail");
h.write_config(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[update]\nurl = \"file:///qex-no-such-file-9e3a\"\n",
);
let out = h.qex(&["version", "--check"]);
assert_eq!(
out.status.code(),
Some(1),
"qex could not ask, and that is the only case that gives 1"
);
let said = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
said.contains("could not ask"),
"the message must say that qex could not ask: {said}"
);
assert!(
said.contains("still operates") || said.contains("Nothing changed"),
"the message must say that nothing changed: {said}"
);
let id = h.submit(&["submit", "--", "true"]);
assert_eq!(h.qex(&["wait", &id]).status.code(), Some(0));
}
#[test]
fn never_asks_nothing_and_writes_nothing() {
let h = Harness::with_default_config("updatenever");
h.write_config(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[update]\ncheck = \"never\"\nurl = \"file:///qex-no-such-file-9e3a\"\n",
);
let id = h.submit(&["submit", "--", "true"]);
assert_eq!(h.qex(&["wait", &id]).status.code(), Some(0));
let record = h.root.join("state/qex/update.json");
let deadline = Instant::now() + Duration::from_secs(6);
while Instant::now() < deadline {
assert!(
!record.exists(),
"`never` must write no file: {}",
record.display()
);
std::thread::sleep(Duration::from_millis(250));
}
assert!(h.qex(&["info", "--no-start"]).status.success());
}
#[test]
fn a_fresh_install_asks_nothing_and_says_nothing() {
let h = Harness::with_default_config("updatefirst");
let answer = h.root.join("latest.json");
std::fs::write(&answer, r#"{"tag_name": "v9.9.9"}"#).unwrap();
h.write_config(&format!(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[update]\ncheck = \"300s\"\nurl = \"file://{}\"\n",
answer.display()
));
let id = h.submit(&["submit", "--", "true"]);
let out = h.qex(&["wait", &id]);
assert_eq!(out.status.code(), Some(0));
assert!(
!String::from_utf8_lossy(&out.stderr).contains("newer qex"),
"a fresh install must say nothing about a release"
);
h.until(
"the coordinator wrote the record",
Duration::from_secs(45),
|| h.root.join("state/qex/update.json").exists(),
);
let value: serde_json::Value =
serde_json::from_slice(&std::fs::read(h.root.join("state/qex/update.json")).unwrap())
.unwrap();
assert!(value["last_checked"].as_u64().unwrap_or(0) > 0);
assert!(
value["newest"].is_null(),
"the first run must ask nothing: {value}"
);
}
#[test]
fn status_with_wait_ends_with_the_record_of_the_job() {
let h = Harness::with_default_config("statusrec");
let id = h.submit(&["submit", "--", "sh", "-c", "echo bad >&2; exit 9"]);
let out = h.qex(&["status", &id, "--wait"]);
assert_eq!(out.status.code(), Some(9));
let said = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
said.contains("exit code: 9") && said.contains("bad"),
"the record must hold the code and the error output: {said}"
);
}
#[test]
fn a_wait_says_why_the_job_does_not_start() {
let h = Harness::new(
"waitreason",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let holder = h.submit(&["submit", "--cpu", "2", "--", "sleep", "20"]);
h.until("the first job starts", Duration::from_secs(45), || {
h.has_started(&holder)
});
let waiter = h.submit(&["submit", "--cpu", "2", "--", "true"]);
h.until("the second job waits", Duration::from_secs(30), || {
!h.status_json(&waiter)["blocked_reason"].is_null()
});
let reason = h.status_json(&waiter)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(!reason.is_empty(), "the record must hold a reason");
let child = h.spawn(&["wait", &waiter, "--timeout", "6s"]);
std::thread::sleep(Duration::from_secs(5));
unsafe {
libc::kill(child.id() as i32, libc::SIGINT);
}
let out = child.wait_with_output().expect("the wait did not stop");
let said = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
said.contains(&reason),
"the wait must give the reason of the record `{reason}`: {said}"
);
h.stop(&holder);
h.qex(&["cancel", &waiter]);
}
#[test]
fn a_coordinator_that_stops_while_the_wait_opens_gives_no_answer_about_the_job() {
let h = Harness::with_default_config("openrace");
for step in 0..8 {
let id = h.submit(&["submit", "--", "sh", "-c", "sleep 1; exit 0"]);
h.until("the job starts", Duration::from_secs(45), || {
h.state_of(&id) == "running"
});
let child = h.spawn(&["wait", &id, "--timeout", "30s"]);
std::thread::sleep(Duration::from_millis(2 + step * 4));
let pid = h.coordinator_pid();
unsafe {
libc::kill(pid, libc::SIGKILL);
}
let out = child.wait_with_output().expect("the wait did not stop");
let code = out.status.code();
assert!(
code == Some(0),
"a coordinator that stopped gave the code {code:?} about a job that succeeded: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(h.state_of(&id), "completed");
}
}
#[test]
fn a_wait_survives_a_coordinator_that_stops() {
let h = Harness::with_default_config("waitcrash");
let id = h.submit(&["submit", "--", "sh", "-c", "sleep 6; exit 0"]);
h.until("the job starts", Duration::from_secs(45), || {
h.state_of(&id) == "running"
});
let child = h.spawn(&["wait", &id, "--timeout", "60s"]);
std::thread::sleep(Duration::from_millis(800));
let pid = h.coordinator_pid();
unsafe {
libc::kill(pid, libc::SIGKILL);
}
h.until("the coordinator is gone", Duration::from_secs(10), || {
(unsafe { libc::kill(pid, 0) }) != 0
});
let out = child.wait_with_output().expect("the wait did not stop");
assert_eq!(
out.status.code(),
Some(0),
"a coordinator that stops must not report a failure of the job: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(h.state_of(&id), "completed");
}
#[test]
fn wait_with_next_names_the_jobs_that_have_no_watcher() {
let h = Harness::with_default_config("anyrest");
let fast = h.submit(&["submit", "--", "true"]);
let slow = h.submit(&["submit", "--", "sleep", "20"]);
let out = h.qex(&["wait", "--next", &fast, &slow]);
assert_eq!(out.status.code(), Some(0));
let said = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
said.contains(&slow) && said.contains("qex wait --next"),
"`--next` must name the job that stays, and the command that waits for it: {said}"
);
h.stop(&slow);
}
#[test]
fn a_wait_that_reaches_its_limit_gives_the_code_124() {
let h = Harness::with_default_config("waitlimit");
let id = h.submit(&["submit", "--", "sleep", "30"]);
let out = h.qex(&["wait", &id, "--timeout", "2s"]);
assert_eq!(out.status.code(), Some(124));
assert_eq!(
h.state_of(&id),
"running",
"a wait that reaches its limit must not stop the job"
);
h.ok(&["kill", &id, "--grace", "1s"]);
}
#[test]
fn a_missing_job_gives_the_code_127() {
let h = Harness::with_default_config("missing");
let out = h.qex(&["wait", "3f5a1c2e-0000-4000-8000-000000000000"]);
assert_eq!(out.status.code(), Some(127));
}
#[test]
fn the_output_of_a_job_is_recorded() {
let h = Harness::with_default_config("logs");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"echo to-stdout; echo to-stderr >&2",
]);
h.ok(&["wait", &id]);
let both = h.ok(&["logs", &id]);
assert!(both.contains("to-stdout"), "the standard output is missing");
assert!(both.contains("to-stderr"), "the standard error is missing");
let out_only = h.ok(&["logs", &id, "--stdout"]);
assert!(out_only.contains("to-stdout"));
assert!(!out_only.contains("to-stderr"), "the streams are mixed");
}
#[test]
fn many_submissions_at_once_start_one_coordinator_only() {
let h = Harness::with_default_config("race");
let exe = env!("CARGO_BIN_EXE_qex");
let mut children = Vec::new();
for _ in 0..20 {
children.push(
Command::new(exe)
.args(["submit", "--", "true"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.spawn()
.expect("qex did not start"),
);
}
for mut c in children {
let status = c.wait().unwrap();
assert!(status.success(), "one submission failed during the race");
}
assert_eq!(h.list_json().len(), 20, "qex lost a job during the race");
let dirs = std::fs::read_dir(h.root.join("state/qex/jobs"))
.unwrap()
.count();
assert_eq!(dirs, 20);
}
#[test]
fn a_second_submission_with_one_key_gives_the_first_job_and_starts_no_job() {
let h = Harness::with_default_config("dedupe");
let first = h.submit(&["submit", "--dedupe-key", "build:/x", "--", "sleep", "30"]);
let out = h.qex(&["submit", "--dedupe-key", "build:/x", "--", "sleep", "30"]);
assert_eq!(
out.status.code(),
Some(0),
"the second submission must exit with the code 0, so that \
`ID=$(qex submit ...)` operates"
);
let second = String::from_utf8_lossy(&out.stdout).trim().to_string();
assert_eq!(
second, first,
"the second submission must give the first id"
);
let message = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
message.contains("started no job"),
"the message must say what happened: {message}"
);
assert!(
message.contains("build_x"),
"the message must name the key: {message}"
);
assert_eq!(h.list_json().len(), 1, "qex started a second job");
assert_eq!(
std::fs::read_dir(h.root.join("state/qex/jobs"))
.unwrap()
.count(),
1,
"qex wrote a second job record"
);
assert_eq!(h.status_json(&first)["dedupe_key"], "build_x");
let evil = h.submit(&["submit", "--dedupe-key", "x\u{1b}[2Jy", "--", "true"]);
let shown = h.status_json(&evil)["dedupe_key"]
.as_str()
.unwrap()
.to_string();
assert!(
!shown.contains('\u{1b}'),
"the ESC byte reached the reader: {shown:?}"
);
h.stop(&first);
}
#[test]
fn many_submissions_with_one_key_at_once_make_one_job() {
let h = Harness::with_default_config("dedupe-race");
let exe = env!("CARGO_BIN_EXE_qex");
let mut children = Vec::new();
for _ in 0..20 {
children.push(
Command::new(exe)
.args(["submit", "--dedupe-key", "one", "--", "sleep", "30"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("qex did not start"),
);
}
let mut ids = Vec::new();
for c in children {
let out = c.wait_with_output().unwrap();
assert!(
out.status.success(),
"one submission failed during the race: {}",
String::from_utf8_lossy(&out.stderr)
);
ids.push(String::from_utf8_lossy(&out.stdout).trim().to_string());
}
let first = ids[0].clone();
assert!(
ids.iter().all(|id| *id == first),
"the submissions gave more than one id: {ids:?}"
);
assert_eq!(h.list_json().len(), 1, "the race made more than one job");
assert_eq!(
std::fs::read_dir(h.root.join("state/qex/jobs"))
.unwrap()
.count(),
1,
"the race wrote more than one job record"
);
h.stop(&first);
}
#[test]
fn a_job_that_stopped_frees_its_key_and_a_window_keeps_it() {
let h = Harness::with_default_config("dedupe-free");
let first = h.submit(&["submit", "--dedupe-key", "k", "--", "true"]);
h.ok(&["wait", &first]);
let second = h.submit(&["submit", "--dedupe-key", "k", "--", "true"]);
assert_ne!(
second, first,
"a job that stopped must not hold its key, or the work never runs again"
);
h.ok(&["wait", &second]);
let third = h.submit(&[
"submit",
"--dedupe-key",
"k",
"--dedupe-window",
"1h",
"--",
"true",
]);
assert_eq!(
third, second,
"the window must keep the key of the job that succeeded"
);
let failed = h.submit(&["submit", "--dedupe-key", "bad", "--", "false"]);
h.qex(&["wait", &failed]);
let again = h.submit(&[
"submit",
"--dedupe-key",
"bad",
"--dedupe-window",
"1h",
"--",
"false",
]);
assert_ne!(
again, failed,
"a job that failed must not hold its key, or a second run is not possible"
);
h.qex(&["wait", &again]);
}
#[test]
fn a_key_is_free_the_moment_that_its_job_stops() {
let h = Harness::with_default_config("dedupe-refresh");
for attempt in 0..10 {
let key = format!("fresh-{attempt}");
let first = h.submit(&["submit", "--dedupe-key", &key, "--", "true"]);
let file = h
.root
.join("state/qex/jobs")
.join(&first)
.join("status.json");
let limit = Instant::now() + Duration::from_secs(30);
loop {
if let Ok(text) = std::fs::read_to_string(&file) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
if v["state"] == "completed" {
break;
}
}
}
assert!(Instant::now() < limit, "the job never stopped");
std::thread::sleep(Duration::from_millis(1));
}
let second = h.submit(&["submit", "--dedupe-key", &key, "--", "true"]);
assert_ne!(
second, first,
"attempt {attempt}: the key still held a job that had stopped, so this \
submission started no work and gave the id of the finished job"
);
h.ok(&["wait", &second]);
}
}
#[test]
fn qex_clean_frees_the_key_with_the_record() {
let h = Harness::with_default_config("dedupe-clean");
let first = h.submit(&["submit", "--dedupe-key", "c", "--", "true"]);
h.ok(&["wait", &first]);
h.ok(&["clean", &first]);
let second = h.submit(&["submit", "--dedupe-key", "c", "--", "true"]);
assert_ne!(
second, first,
"the key still names the deleted job, so no later submission can start the work"
);
let out = h.qex(&["status", &second, "--json"]);
assert!(
out.status.success(),
"`qex status` cannot answer the id that the submission gave: {}",
String::from_utf8_lossy(&out.stderr)
);
h.ok(&["wait", &second]);
}
#[test]
fn qex_gc_frees_the_key_with_the_record() {
let h = Harness::with_default_config("dedupe-gc");
let first = h.submit(&["submit", "--dedupe-key", "g", "--", "true"]);
h.ok(&["wait", &first]);
h.ok(&["gc", "--older-than", "0s"]);
let second = h.submit(&["submit", "--dedupe-key", "g", "--", "true"]);
assert_ne!(second, first, "gc deleted the record and left the key");
h.ok(&["wait", &second]);
}
#[test]
fn qex_rerun_of_a_keyed_job_starts_a_new_job() {
let h = Harness::with_default_config("dedupe-rerun");
let first = h.submit(&["submit", "--dedupe-key", "rr", "--", "sleep", "30"]);
h.until("the job starts", Duration::from_secs(45), || {
h.has_started(&first)
});
let out = h.ok(&["rerun", &first]);
let second = out.split_whitespace().last().unwrap().to_string();
assert_ne!(
second, first,
"rerun gave the id of the first job, so it started nothing: {out}"
);
assert_eq!(h.list_json().len(), 2, "rerun started no second job");
assert!(
h.status_json(&second)["dedupe_key"].is_null(),
"the job of a rerun must hold no key"
);
h.stop(&first);
h.stop(&second);
}
#[test]
fn a_restart_gives_the_key_to_the_job_that_still_operates() {
let h = Harness::with_default_config("dedupe-recover-order");
let old = h.submit(&["submit", "--dedupe-key", "two", "--", "true"]);
h.ok(&["wait", &old]);
let live = h.submit(&["submit", "--dedupe-key", "two", "--", "sleep", "30"]);
h.until("the job starts", Duration::from_secs(45), || {
h.has_started(&live)
});
let pid = h.coordinator_pid();
unsafe {
libc::kill(pid, libc::SIGKILL);
}
h.until("the coordinator stops", Duration::from_secs(30), || {
let alive = unsafe { libc::kill(pid, 0) } == 0;
!alive
});
let again = h.submit(&["submit", "--dedupe-key", "two", "--", "sleep", "30"]);
assert_eq!(
again, live,
"the key went to the job that stopped, so qex started a second copy of the work"
);
assert_eq!(
h.list_json().len(),
2,
"qex started a third job, so the key did not hold the job that operates"
);
h.stop(&live);
}
#[test]
fn a_key_goes_at_the_end_of_the_window_and_not_after_it() {
let h = Harness::with_default_config("dedupe-edge");
let first = h.submit(&[
"submit",
"--dedupe-key",
"e",
"--dedupe-window",
"1",
"--",
"true",
]);
h.ok(&["wait", &first]);
let finished = h.status_json(&first)["finished_at"].as_u64().unwrap();
loop {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
if now.saturating_sub(finished) >= 1 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
let second = h.submit(&[
"submit",
"--dedupe-key",
"e",
"--dedupe-window",
"1",
"--",
"true",
]);
assert_ne!(
second, first,
"the key must go at the end of the window, and not one second after it"
);
h.ok(&["wait", &second]);
}
#[test]
fn submit_json_says_if_this_command_started_the_work() {
let h = Harness::with_default_config("dedupe-json");
let text = h.ok(&["submit", "--json", "--dedupe-key", "j", "--", "sleep", "30"]);
let first: serde_json::Value = serde_json::from_str(&text).expect("the output is not JSON");
assert_eq!(first["deduplicated"], false);
let id = first["id"].as_str().unwrap().to_string();
let text = h.ok(&["submit", "--json", "--dedupe-key", "j", "--", "sleep", "30"]);
let second: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(second["deduplicated"], true);
assert_eq!(second["id"], first["id"]);
h.stop(&id);
}
#[test]
fn a_signal_to_a_deduplicated_run_stops_the_wait_and_not_the_job() {
let h = Harness::with_default_config("dedupe-run");
let owner = h.submit(&["submit", "--dedupe-key", "shared", "--", "sleep", "30"]);
h.until("the job starts", Duration::from_secs(45), || {
h.has_started(&owner)
});
let err_path = h.root.join("run.err");
let mut child = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["run", "--dedupe-key", "shared", "--", "sleep", "30"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.stdout(std::process::Stdio::null())
.stderr(std::fs::File::create(&err_path).unwrap())
.spawn()
.expect("qex did not start");
h.until(
"qex run attaches to the job",
Duration::from_secs(45),
|| {
std::fs::read_to_string(&err_path)
.map(|t| t.contains("did not start it"))
.unwrap_or(false)
},
);
unsafe {
libc::kill(child.id() as i32, libc::SIGTERM);
}
let code = child.wait().unwrap().code();
assert_eq!(
code,
Some(122),
"the wait must give the code that says `the job continues`"
);
let state = h.state_of(&owner);
assert_eq!(
state, "running",
"a signal to the second agent stopped the job of the first agent"
);
let message = std::fs::read_to_string(&err_path).unwrap();
assert!(
message.contains(&format!("qex kill {owner}")),
"the message must give the way to stop the job: {message}"
);
h.stop(&owner);
}
#[test]
fn a_signal_to_a_run_that_started_its_job_stops_the_job() {
let h = Harness::with_default_config("run-signal");
let out_path = h.root.join("run.out");
let mut child = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["run", "--", "sh", "-c", "echo ready; sleep 30"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.stdout(std::fs::File::create(&out_path).unwrap())
.stderr(std::process::Stdio::null())
.spawn()
.expect("qex did not start");
h.until("the job writes its output", Duration::from_secs(45), || {
std::fs::read_to_string(&out_path)
.map(|t| t.contains("ready"))
.unwrap_or(false)
});
unsafe {
libc::kill(child.id() as i32, libc::SIGTERM);
}
child.wait().unwrap();
let id = h.list_json()[0]["id"].as_str().unwrap().to_string();
h.until("the job stops", Duration::from_secs(45), || {
h.state_of(&id) == "killed"
});
}
#[test]
fn a_key_stays_with_its_job_after_the_coordinator_stops() {
let h = Harness::with_default_config("dedupe-restart");
let first = h.submit(&["submit", "--dedupe-key", "r", "--", "sleep", "30"]);
h.until("the job starts", Duration::from_secs(45), || {
h.has_started(&first)
});
let pid = h.coordinator_pid();
unsafe {
libc::kill(pid, libc::SIGKILL);
}
h.until("the coordinator stops", Duration::from_secs(30), || {
let alive = unsafe { libc::kill(pid, 0) } == 0;
!alive
});
let second = h.submit(&["submit", "--dedupe-key", "r", "--", "sleep", "30"]);
assert_eq!(
second, first,
"a new coordinator lost the key, and it started a second copy of the work"
);
assert_eq!(h.list_json().len(), 1);
h.stop(&first);
}
#[test]
fn the_budget_limits_the_jobs_that_operate_together() {
let h = Harness::new(
"budget",
"[budget]\ncpu = \"4\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let ids: Vec<String> = (0..3)
.map(|_| {
h.submit(&[
"submit", "--cpu", "2", "--mem", "128MB", "--", "sleep", "300",
])
})
.collect();
h.until("two jobs operate", Duration::from_secs(45), || {
h.list_json()
.iter()
.filter(|j| j["state"] == "running")
.count()
== 2
});
let mut peak = 0;
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
let running = h
.list_json()
.iter()
.filter(|j| j["state"] == "running")
.count();
peak = peak.max(running);
std::thread::sleep(Duration::from_millis(100));
}
assert!(peak > 0, "no job ever started");
assert!(
peak <= 2,
"the budget of 4 cores must hold two jobs of 2 cores, and {peak} jobs operated together"
);
for id in &ids {
h.qex(&["kill", id, "--grace", "1s"]);
h.qex(&["cancel", id]);
}
}
#[test]
fn a_job_that_is_too_large_runs_when_the_queue_is_empty() {
let h = Harness::new(
"oversized",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\noversized = \"run-when-idle\"\nsettle = \"1s\"\n",
);
let small = h.submit(&[
"submit", "--cpu", "2", "--mem", "128MB", "--", "sleep", "300",
]);
h.until("the small job starts", Duration::from_secs(45), || {
h.state_of(&small) == "running"
});
let out = h.qex(&[
"submit", "--cpu", "64", "--mem", "64GB", "--", "echo", "big",
]);
assert!(out.status.success());
let big = String::from_utf8_lossy(&out.stdout).trim().to_string();
let warning = String::from_utf8_lossy(&out.stderr);
assert!(
warning.contains("64 cores") && warning.contains("budget"),
"qex must warn at the submission: {warning}"
);
assert!(
big.parse::<uuid::Uuid>().is_ok(),
"stdout must hold the id only"
);
assert_eq!(h.state_of(&big), "queued");
let reason = h.status_json(&big)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(!reason.is_empty(), "qex must give the reason for the wait");
h.ok(&["kill", &small, "--grace", "1s"]);
h.until("the large job stops", Duration::from_secs(30), || {
h.state_of(&big) == "completed"
});
let status = h.status_json(&big);
assert_eq!(status["forced"], true, "qex must mark a forced job");
assert!(
status["forced_reason"]
.as_str()
.unwrap_or("")
.contains("budget"),
"the reason must name the budget"
);
assert!(h.ok(&["logs", &big]).contains("big"), "the job did not run");
}
#[test]
fn the_reject_policy_refuses_a_job_that_is_too_large() {
let h = Harness::new(
"reject",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[queue]\noversized = \"reject\"\n",
);
let out = h.qex(&["submit", "--cpu", "64", "--", "true"]);
assert!(!out.status.success(), "qex must refuse this job");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("64 cores"),
"the error must name the claim: {err}"
);
}
#[test]
fn a_kill_stops_every_process_of_a_job() {
let h = Harness::with_default_config("kill");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"sleep 60 & sleep 60 & sleep 60 & wait",
]);
h.until("the job starts", Duration::from_secs(45), || {
h.state_of(&id) == "running"
});
let pid = h.status_json(&id)["pid"].as_i64().unwrap() as i32;
assert!(
count_in_group(pid) >= 2,
"the job did not start its children"
);
h.ok(&["kill", &id, "--grace", "1s"]);
h.until(
"every process of the job stops",
Duration::from_secs(20),
|| count_in_group(pid) == 0,
);
h.until(
"the record shows the job stopped",
Duration::from_secs(20),
|| h.state_of(&id) == "killed",
);
}
#[cfg(target_os = "linux")]
fn count_in_group(pgid: i32) -> usize {
let Ok(entries) = std::fs::read_dir("/proc") else {
return 0;
};
let mut count = 0;
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Ok(pid) = name.parse::<i32>() else {
continue;
};
if unsafe { libc::getpgid(pid) } == pgid {
count += 1;
}
}
count
}
#[cfg(not(target_os = "linux"))]
fn count_in_group(pgid: i32) -> usize {
let Ok(out) = std::process::Command::new("ps")
.args(["-A", "-o", "pgid="])
.output()
else {
return 0;
};
String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|line| line.trim().parse::<i32>() == Ok(pgid))
.count()
}
#[test]
fn a_job_survives_the_failure_of_the_coordinator() {
let h = Harness::with_default_config("crash");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"sleep 10; echo survived; exit 7",
]);
h.until("the job starts", Duration::from_secs(45), || {
h.state_of(&id) == "running"
});
let pid = h.coordinator_pid();
unsafe {
libc::kill(pid, libc::SIGKILL);
}
std::thread::sleep(Duration::from_millis(300));
assert!(
unsafe { libc::kill(pid, 0) } != 0,
"the coordinator did not stop"
);
let out = h.qex(&["wait", &id, "--timeout", "30s"]);
assert_eq!(out.status.code(), Some(7), "the job exits with the code 7");
let status = h.status_json(&id);
assert_eq!(status["state"], "failed");
assert_eq!(status["exit_code"], 7);
assert!(h.ok(&["logs", &id]).contains("survived"));
}
#[test]
fn a_job_that_reaches_its_time_limit_has_the_state_timeout() {
let h = Harness::with_default_config("jobtimeout");
let id = h.submit(&["submit", "--timeout", "1s", "--", "sleep", "60"]);
h.until("the job stops", Duration::from_secs(30), || {
h.state_of(&id) == "timeout"
});
}
#[test]
fn a_job_that_never_starts_gives_up_and_says_why() {
let h = Harness::new(
"queuelimit",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\noversized = \"queue\"\n",
);
let id = h.submit(&[
"submit",
"--cpu",
"64",
"--max-queue-time",
"3s",
"--",
"echo",
"never",
]);
h.until("the job gives up", Duration::from_secs(45), || {
h.state_of(&id) == "expired"
});
let status = h.status_json(&id);
assert!(
status["started_at"].is_null(),
"a job that expired must never have a start time: {status}"
);
assert!(
status["exit_code"].is_null(),
"a job that expired has no exit code: {status}"
);
let error = status["error"].as_str().unwrap_or("");
assert!(
error.contains("did not start"),
"the text must say that the job never ran: {error}"
);
assert!(
error.contains("--max-queue-time"),
"the text must name the limit: {error}"
);
assert!(
error.contains("budget") || error.contains("cores"),
"the text must name the wait: {error}"
);
let out = h.qex(&["wait", &id, "--timeout", "30s"]);
assert_eq!(
out.status.code(),
Some(123),
"stdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let after = h.submit(&["submit", "--needs", &id, "--", "echo", "after"]);
h.until(
"the job behind it gives up",
Duration::from_secs(45),
|| h.state_of(&after) == "skipped",
);
let status = h.status_json(&after);
let text = format!(
"{} {}",
status["error"].as_str().unwrap_or(""),
status["blocked_reason"].as_str().unwrap_or("")
);
assert!(
text.contains("expired"),
"the text must name the state of the job that it needed: {text}"
);
assert!(
!text.contains("qex logs"),
"an expired job wrote no log, so the text must not name one: {text}"
);
}
#[test]
fn a_job_receives_the_environment_and_the_directory_of_the_shell() {
let h = Harness::with_default_config("env");
let dir = h.root.join("workdir");
std::fs::create_dir_all(&dir).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["submit", "--", "sh", "-c", "pwd; echo MARK=$QEX_TEST_MARK"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.env("QEX_TEST_MARK", "captured")
.current_dir(&dir)
.output()
.unwrap();
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
h.ok(&["wait", &id]);
let logs = h.ok(&["logs", &id]);
assert!(
logs.contains("MARK=captured"),
"the job must receive the environment of the shell: {logs}"
);
assert!(
logs.contains(dir.canonicalize().unwrap().to_str().unwrap()),
"the job must operate in the directory of the shell: {logs}"
);
}
#[test]
fn the_environment_mode_none_removes_the_variables_of_the_shell() {
let h = Harness::with_default_config("envnone");
let out = Command::new(env!("CARGO_BIN_EXE_qex"))
.args([
"submit",
"--no-env-capture",
"--env",
"KEPT=yes",
"--",
"sh",
"-c",
"echo MARK=$QEX_TEST_MARK KEPT=$KEPT",
])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.env("QEX_TEST_MARK", "leaked")
.output()
.unwrap();
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
h.ok(&["wait", &id]);
let logs = h.ok(&["logs", &id]);
assert!(
logs.contains("MARK= "),
"a variable of the shell leaked: {logs}"
);
assert!(
logs.contains("KEPT=yes"),
"the --env value is missing: {logs}"
);
}
#[test]
fn the_job_files_are_private() {
use std::os::unix::fs::PermissionsExt;
let h = Harness::with_default_config("modes");
let id = h.submit(&["submit", "--", "true"]);
h.ok(&["wait", &id]);
let dir = h.job_dir(&id);
let dir_mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
assert_eq!(dir_mode, 0o700, "the job directory must be private");
let spec_mode = std::fs::metadata(dir.join("spec.json"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(spec_mode, 0o600, "the job specification must be private");
let status = h.ok(&["status", &id, "--json"]);
assert!(
!status.contains("\"env\""),
"the status output must hide the environment: {status}"
);
}
#[test]
fn a_command_line_that_holds_the_word_qex_does_not_confuse_qex() {
let h = Harness::with_default_config("selfmatch");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"echo pretending to be qex daemon supervise; sleep 30",
]);
h.until("the job starts", Duration::from_secs(45), || {
h.state_of(&id) == "running"
});
let coordinator = h.coordinator_pid();
let job_pid = h.status_json(&id)["pid"].as_i64().unwrap() as i32;
assert_ne!(coordinator, job_pid);
h.ok(&["kill", &id, "--grace", "1s"]);
h.until("the job stops", Duration::from_secs(20), || {
h.state_of(&id) == "killed"
});
assert_eq!(h.coordinator_pid(), coordinator);
}
#[test]
fn cancel_removes_a_job_from_the_queue() {
let h = Harness::new(
"cancel",
"[budget]\ncpu = \"1\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let first = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "300",
]);
h.until("the first job starts", Duration::from_secs(45), || {
h.state_of(&first) == "running"
});
let second = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "5"]);
assert_eq!(h.state_of(&second), "queued");
h.ok(&["cancel", &second]);
assert_eq!(h.state_of(&second), "cancelled");
let out = h.qex(&["cancel", &first]);
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("kill"),
"the error must give the correct command: {err}"
);
h.ok(&["kill", &first, "--grace", "1s"]);
}
#[test]
fn clean_deletes_the_record_of_a_job_that_stopped() {
let h = Harness::with_default_config("clean");
let id = h.submit(&["submit", "--", "true"]);
h.ok(&["wait", &id]);
assert!(h.job_dir(&id).exists());
h.ok(&["clean", &id]);
assert!(!h.job_dir(&id).exists(), "qex did not delete the directory");
assert!(h.list_json().is_empty());
}
#[test]
fn clean_refuses_a_job_that_operates() {
let h = Harness::with_default_config("cleanrun");
let id = h.submit(&["submit", "--", "sleep", "30"]);
h.until("the job starts", Duration::from_secs(45), || {
h.state_of(&id) == "running"
});
let out = h.qex(&["clean", &id]);
assert!(!out.status.success(), "qex must refuse this command");
assert!(h.job_dir(&id).exists(), "the record must stay");
h.ok(&["kill", &id, "--grace", "1s"]);
}
#[test]
fn a_command_that_does_not_exist_gives_a_clear_message() {
let h = Harness::with_default_config("nocmd");
let id = h.submit(&["submit", "--", "this-program-does-not-exist"]);
h.until("the job stops", Duration::from_secs(45), || {
h.status_json(&id)["state"]
.as_str()
.map(|s| s == "failed")
.unwrap_or(false)
});
let reason = h.status_json(&id)["error"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
reason.contains("this-program-does-not-exist"),
"the message must name the program: {reason}"
);
assert!(
reason.contains("PATH"),
"the message must give the correction: {reason}"
);
}
#[test]
fn a_command_accepts_the_first_characters_of_an_id() {
let h = Harness::with_default_config("shortid");
let id = h.submit(&["submit", "--", "true"]);
h.ok(&["wait", &id]);
let short = &id[..8];
assert_eq!(h.state_of(short), "completed");
}
#[test]
fn the_first_screen_points_to_the_topic_for_agents() {
let h = Harness::with_default_config("help");
let text = h.ok(&[]);
assert!(
text.contains("qex help agents"),
"the first screen must name the topic for agents"
);
let agents = h.ok(&["help", "agents"]);
assert!(
agents.contains("pgrep"),
"the topic must warn about the pgrep fault"
);
assert!(
agents.contains("qex wait"),
"the topic must give the solution"
);
}
#[test]
fn the_schemas_are_valid_json() {
let h = Harness::with_default_config("schema");
for name in ["job", "status", "pipeline", "event"] {
let text = h.ok(&["schema", name]);
serde_json::from_str::<serde_json::Value>(&text)
.unwrap_or_else(|e| panic!("the schema `{name}` is not valid JSON: {e}"));
}
}
#[test]
fn a_job_file_describes_a_job() {
let h = Harness::with_default_config("jobfile");
let file = h.root.join("job.toml");
std::fs::write(
&file,
"name = \"from-file\"\n\
command = [\"sh\", \"-c\", \"echo from-the-file\"]\n\
tags = [\"test\"]\n\
[resources]\n\
cpu = 2\n\
mem = \"256MB\"\n\
[env]\n\
FILE_VAR = \"present\"\n",
)
.unwrap();
let id = h.submit(&["submit", "--job", file.to_str().unwrap()]);
h.ok(&["wait", &id]);
let status = h.status_json(&id);
assert_eq!(status["name"], "from-file");
assert_eq!(status["cpu"], 2);
assert_eq!(status["mem"], 256 * 1024 * 1024);
assert_eq!(status["tags"][0], "test");
assert!(h.ok(&["logs", &id]).contains("from-the-file"));
}
#[test]
fn the_claim_word_guess_gives_one_half_of_the_budget() {
let h = Harness::new(
"guess",
"[budget]\ncpu = \"8\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let a = h.submit(&[
"submit", "--cpu", "guess", "--mem", "guess", "--", "sleep", "300",
]);
let status = h.status_json(&a);
assert_eq!(status["cpu"], 4, "one half of 8 cores is 4 cores");
assert_eq!(
status["mem"],
2u64 * 1024 * 1024 * 1024,
"one half of 4GB is 2GB"
);
let b = h.submit(&[
"submit", "--cpu", "half", "--mem", "half", "--", "sleep", "300",
]);
h.until("both jobs operate", Duration::from_secs(45), || {
h.list_json()
.iter()
.filter(|j| j["state"] == "running")
.count()
== 2
});
let c = h.submit(&["submit", "--cpu", "guess", "--mem", "guess", "--", "true"]);
assert_eq!(h.state_of(&c), "queued");
h.ok(&["kill", &a, "--grace", "1s"]);
h.ok(&["kill", &b, "--grace", "1s"]);
h.ok(&["wait", &c, "--timeout", "30s"]);
}
#[test]
fn the_claim_word_full_gives_the_whole_budget() {
let h = Harness::new(
"full",
"[budget]\ncpu = \"4\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let big = h.submit(&[
"submit", "--cpu", "full", "--mem", "max", "--", "sleep", "3",
]);
let status = h.status_json(&big);
assert_eq!(status["cpu"], 4);
assert_eq!(status["mem"], 2u64 * 1024 * 1024 * 1024);
assert_eq!(
status["forced"], false,
"a job that asks for the budget is a normal job, and qex must not force it"
);
h.until("the full job starts", Duration::from_secs(45), || {
h.state_of(&big) == "running"
});
let other = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
assert_eq!(h.state_of(&other), "queued");
h.ok(&["kill", &big, "--grace", "1s"]);
h.ok(&["wait", &other, "--timeout", "30s"]);
}
#[test]
fn a_job_file_accepts_the_claim_words() {
let h = Harness::new(
"guessfile",
"[budget]\ncpu = \"8\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let file = h.root.join("guess.toml");
std::fs::write(
&file,
"command = [\"true\"]\n[resources]\ncpu = \"guess\"\nmem = \"half\"\n",
)
.unwrap();
let id = h.submit(&["submit", "--job", file.to_str().unwrap()]);
let status = h.status_json(&id);
assert_eq!(status["cpu"], 4);
assert_eq!(status["mem"], 2u64 * 1024 * 1024 * 1024);
h.ok(&["wait", &id, "--timeout", "30s"]);
}
#[test]
fn a_dead_supervisor_does_not_leave_the_job_alive() {
let h = Harness::with_default_config("orphan");
let id = h.submit(&["submit", "--", "sleep", "120"]);
h.until("the job starts", Duration::from_secs(45), || {
h.state_of(&id) == "running"
});
let job_pid = h.status_json(&id)["pid"].as_i64().unwrap() as i32;
let supervisor_pid = h.status_json(&id)["supervisor_pid"]
.as_i64()
.expect("the status must record the supervisor") as i32;
unsafe {
libc::kill(supervisor_pid, libc::SIGKILL);
}
h.until(
"the job reaches a final state",
Duration::from_secs(30),
|| {
h.status_json(&id)["state"]
.as_str()
.map(|s| s != "running" && s != "starting")
.unwrap_or(false)
},
);
h.until("the job process stops", Duration::from_secs(30), || {
let rc = unsafe { libc::kill(job_pid, 0) };
rc != 0
});
let info = h.ok(&["info", "--json"]);
let v: serde_json::Value = serde_json::from_str(&info).unwrap();
assert_eq!(v["cpu_claimed"].as_u64(), Some(0));
}
#[test]
fn a_job_that_stops_at_its_time_limit_keeps_its_result() {
let h = Harness::with_default_config("timerrace");
let mut ids = Vec::new();
for i in 0..12 {
let sleep = format!("0.{:03}", 995 + i);
ids.push(h.submit(&["submit", "--timeout", "1s", "--", "sleep", &sleep]));
}
for id in &ids {
h.qex(&["wait", id, "--timeout", "60s"]);
let s = h.status_json(id);
let state = s["state"].as_str().unwrap();
let code = s["exit_code"].as_i64();
if state == "timeout" {
assert_ne!(
code,
Some(0),
"the job stopped with the code 0, so its state must not be `timeout`: {s}"
);
}
if code == Some(0) {
assert_eq!(
state, "completed",
"a job that stopped with the code 0 must be `completed`: {s}"
);
}
}
}
#[test]
fn a_wait_returns_when_the_job_reaches_its_queue_limit() {
let h = Harness::new(
"queuewait",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\noversized = \"queue\"\n",
);
let id = h.submit(&[
"submit",
"--cpu",
"64",
"--max-queue-time",
"3s",
"--",
"echo",
"never",
]);
let start = std::time::Instant::now();
let out = h.qex(&["wait", &id, "--timeout", "60s"]);
let elapsed = start.elapsed();
assert_eq!(
out.status.code(),
Some(123),
"stdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
elapsed < Duration::from_secs(10),
"`qex wait` returned after {elapsed:?}, and the limit is 3s. The \
scheduler expired the job and signalled no waiter."
);
}
#[test]
fn the_config_summary_names_the_queue_limit() {
let h = Harness::new(
"cfgqueuelimit",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let out = h.ok(&["config", "show"]);
assert!(
out.contains("queue limit:"),
"the summary must hold the queue limit line: {out}"
);
assert!(
out.contains("no limit"),
"with no value the line must say that a job waits with no end: {out}"
);
let h = Harness::new(
"cfgqueuelimit2",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[defaults]\nmax_queue_time = \"30m\"\n",
);
let out = h.ok(&["config", "show"]);
assert!(
out.contains("queue limit:") && out.contains("30m"),
"the summary must give the value that qex uses: {out}"
);
assert!(
out.contains("expired"),
"the line must say what happens to a job that waits longer: {out}"
);
}
#[test]
fn a_wait_returns_when_the_job_that_it_needs_gives_up_in_the_queue() {
let h = Harness::new(
"queuedep",
"[budget]\ncpu = \"2\"\nmem = \"8GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\noversized = \"queue\"\n",
);
let first = h.submit(&[
"submit",
"--cpu",
"64",
"--max-queue-time",
"3s",
"--",
"echo",
"never",
]);
let second = h.submit(&["submit", "--needs", &first, "--", "echo", "after"]);
let start = std::time::Instant::now();
let out = h.qex(&["wait", &second, "--timeout", "60s"]);
let elapsed = start.elapsed();
assert_eq!(
out.status.code(),
Some(126),
"a job that did not run because a job that it needs failed gives 126. \
stdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
elapsed < Duration::from_secs(10),
"`qex wait` returned after {elapsed:?}, and the limit of the job that \
this job needs is 3s. The scheduler skipped this job and signalled no \
waiter."
);
}
#[test]
fn a_job_that_started_at_its_queue_limit_keeps_its_result() {
let h = Harness::new(
"queuerace",
"[budget]\ncpu = \"1\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let mut ids = Vec::new();
for _ in 0..10 {
ids.push(h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--max-queue-time",
"2s",
"--",
"sleep",
"0.4",
]));
}
for id in &ids {
h.qex(&["wait", id, "--timeout", "60s"]);
let s = h.status_json(id);
let state = s["state"].as_str().unwrap();
if state == "expired" {
assert!(
s["started_at"].is_null(),
"a job that started must never be `expired`: {s}"
);
assert!(
s["exit_code"].is_null(),
"a job with an exit code must never be `expired`: {s}"
);
}
if s["exit_code"].as_i64() == Some(0) {
assert_eq!(
state, "completed",
"a job that stopped with the code 0 must be `completed`: {s}"
);
}
}
}
#[test]
fn logs_shows_output_that_is_not_utf8() {
let h = Harness::with_default_config("badbytes");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
r#"printf 'FIRST-LINE\n'; printf 'BAD\377\376\n'; printf 'LAST-LINE\n'"#,
]);
h.ok(&["wait", &id, "--timeout", "30s"]);
let logs = h.ok(&["logs", &id]);
assert!(
logs.contains("FIRST-LINE") && logs.contains("LAST-LINE"),
"one byte that is not UTF-8 hid the whole output: {logs:?}"
);
let json = h.ok(&["logs", &id, "--json"]);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(v["stdout"].as_str().unwrap().contains("LAST-LINE"));
}
#[test]
fn a_spawn_failure_uses_the_error_field() {
let h = Harness::with_default_config("spawnfail");
let id = h.submit(&["submit", "--", "this-program-does-not-exist"]);
h.until("the job stops", Duration::from_secs(45), || {
h.state_of(&id) == "failed"
});
let s = h.status_json(&id);
assert!(
s["error"]
.as_str()
.unwrap_or("")
.contains("this-program-does-not-exist"),
"the error field must name the program: {s}"
);
assert!(
s["blocked_reason"].is_null(),
"a job that failed waits for nothing: {s}"
);
}
#[test]
fn every_queued_job_gives_a_reason() {
let h = Harness::new(
"reasons",
"[budget]\ncpu = \"2\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let running = h.submit(&[
"submit", "--cpu", "2", "--mem", "64MB", "--", "sleep", "300",
]);
h.until("the first job starts", Duration::from_secs(45), || {
h.state_of(&running) == "running"
});
let a = h.submit(&["submit", "--cpu", "2", "--mem", "64MB", "--", "true"]);
let b = h.submit(&["submit", "--cpu", "2", "--mem", "64MB", "--", "true"]);
h.until("both jobs give a reason", Duration::from_secs(45), || {
let ra = h.status_json(&a)["blocked_reason"]
.as_str()
.map(String::from);
let rb = h.status_json(&b)["blocked_reason"]
.as_str()
.map(String::from);
ra.is_some() && rb.is_some()
});
h.ok(&["kill", &running, "--grace", "1s"]);
}
#[test]
fn the_status_records_the_command_and_the_directory() {
let h = Harness::with_default_config("cmdfield");
let id = h.submit(&["submit", "--", "echo", "hello", "world"]);
h.ok(&["wait", &id, "--timeout", "30s"]);
let s = h.status_json(&id);
let command: Vec<String> = serde_json::from_value(s["command"].clone()).unwrap();
assert_eq!(command, vec!["echo", "hello", "world"]);
assert!(!s["cwd"].as_str().unwrap().is_empty());
}
#[test]
fn info_can_test_for_a_coordinator_without_starting_one() {
let h = Harness::with_default_config("nostart");
let out = h.qex(&["info", "--no-start", "--json"]);
assert!(!out.status.success(), "there is no coordinator yet");
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["running"], false);
assert!(
!h.root.join("state/qex/run/s").exists(),
"the command started a coordinator"
);
let id = h.submit(&["submit", "--", "true"]);
h.ok(&["wait", &id, "--timeout", "30s"]);
let out = h.qex(&["info", "--no-start", "--json"]);
assert!(out.status.success());
}
#[test]
fn a_claim_of_zero_cores_is_refused() {
let h = Harness::with_default_config("zeroclaim");
let out = h.qex(&["submit", "--cpu", "0", "--", "true"]);
assert!(
!out.status.success(),
"qex must refuse a claim of zero cores"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("1 core"),
"the error must give the correction: {err}"
);
}
#[test]
fn an_unknown_job_gives_the_same_code_for_each_form_of_the_name() {
let h = Harness::with_default_config("codes");
for name in ["3f5a1c2e-0000-4000-8000-000000000000", "not-a-uuid"] {
for command in ["status", "wait"] {
let out = h.qex(&[command, name]);
assert_eq!(
out.status.code(),
Some(127),
"`qex {command} {name}` must give the code 127"
);
}
}
}
#[test]
fn a_failed_stage_stops_the_stages_after_it() {
let h = Harness::with_default_config("pipeline");
let build = h.submit(&[
"submit",
"--name",
"build",
"--",
"sh",
"-c",
"echo compiling; echo 'error: undefined symbol' >&2; exit 2",
]);
let test = h.submit(&["submit", "--name", "test", "--needs", &build, "--", "true"]);
let ship = h.submit(&["submit", "--name", "ship", "--needs", &test, "--", "true"]);
let out = h.qex(&["wait", &ship, "--timeout", "60s"]);
assert_eq!(out.status.code(), Some(126));
assert_eq!(h.state_of(&build), "failed");
assert_eq!(h.state_of(&test), "skipped");
assert_eq!(h.state_of(&ship), "skipped");
let s = h.status_json(&ship);
assert_eq!(
s["caused_by"].as_str(),
Some(build.as_str()),
"the last stage must name the build, and not the test: {s}"
);
assert!(
s["error"].as_str().unwrap_or("").contains("build"),
"the reason must name the stage that failed: {s}"
);
let failed = h
.list_json()
.iter()
.filter(|j| j["state"] == "failed")
.count();
assert_eq!(failed, 1, "a pipeline must report one failure only");
let logs = h.ok(&["logs", &build]);
assert!(logs.contains("undefined symbol"));
}
#[test]
fn a_pipeline_that_succeeds_runs_each_stage_in_order() {
let h = Harness::with_default_config("pipeok");
let build = h.submit(&["submit", "--name", "build", "--", "sh", "-c", "echo one"]);
let test = h.submit(&[
"submit", "--name", "test", "--needs", &build, "--", "sh", "-c", "echo two",
]);
let ship = h.submit(&[
"submit",
"--name",
"ship",
"--needs",
&test,
"--",
"sh",
"-c",
"echo three",
]);
let out = h.qex(&["wait", &ship, "--timeout", "60s"]);
assert_eq!(out.status.code(), Some(0));
for id in [&build, &test, &ship] {
assert_eq!(h.state_of(id), "completed");
}
let s1 = h.status_json(&build);
let s3 = h.status_json(&ship);
assert!(
s3["started_at"].as_u64().unwrap() >= s1["finished_at"].as_u64().unwrap(),
"the last stage started before the first stage stopped"
);
let names: Vec<String> = h
.list_json()
.iter()
.map(|j| j["name"].as_str().unwrap().to_string())
.collect();
assert_eq!(names, vec!["build", "test", "ship"]);
}
#[test]
fn an_after_job_runs_when_the_job_before_it_fails() {
let h = Harness::with_default_config("afterjob");
let build = h.submit(&["submit", "--name", "build", "--", "sh", "-c", "exit 3"]);
let cleanup = h.submit(&[
"submit",
"--name",
"cleanup",
"--after",
&build,
"--",
"sh",
"-c",
"echo cleaned",
]);
let out = h.qex(&["wait", &cleanup, "--timeout", "60s"]);
assert_eq!(out.status.code(), Some(0), "an --after job must run");
assert_eq!(h.state_of(&build), "failed");
assert_eq!(h.state_of(&cleanup), "completed");
assert!(h.ok(&["logs", &cleanup]).contains("cleaned"));
}
#[test]
fn a_job_that_waits_for_another_job_does_not_hold_capacity() {
let h = Harness::new(
"depcapacity",
"[budget]\ncpu = \"2\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let slow = h.submit(&[
"submit", "--name", "slow", "--cpu", "1", "--mem", "64MB", "--", "sleep", "4",
]);
let waiter = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--needs", &slow, "--", "true",
]);
let free = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
let out = h.qex(&["wait", &free, "--timeout", "20s"]);
assert_eq!(
out.status.code(),
Some(0),
"a job with no dependency must not wait for a job that has one"
);
h.ok(&["wait", &waiter, "--timeout", "60s"]);
h.ok(&["wait", &slow, "--timeout", "60s"]);
}
#[test]
fn a_dependency_that_does_not_exist_is_refused() {
let h = Harness::with_default_config("nodep");
let out = h.qex(&["submit", "--needs", "no-such-job", "--", "true"]);
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("no-such-job"),
"the error must name the value: {err}"
);
}
#[test]
fn clean_keeps_a_job_that_another_job_needs() {
let h = Harness::with_default_config("cleandep");
let first = h.submit(&[
"submit",
"--name",
"first",
"--",
"sh",
"-c",
"sleep 2; exit 1",
]);
let second = h.submit(&["submit", "--needs", &first, "--", "true"]);
let out = h.qex(&["clean", &first]);
assert!(!out.status.success(), "qex must keep this job");
let out = h.qex(&["wait", &second, "--timeout", "60s"]);
assert_eq!(out.status.code(), Some(126));
assert_eq!(h.state_of(&second), "skipped");
}
#[test]
fn clean_accepts_a_state_name() {
let h = Harness::with_default_config("cleanword");
let good = h.submit(&["submit", "--", "true"]);
let bad = h.submit(&["submit", "--", "false"]);
h.ok(&["wait", &good, "--timeout", "30s"]);
h.qex(&["wait", &bad, "--timeout", "30s"]);
h.ok(&["clean", "completed"]);
let states: Vec<String> = h
.list_json()
.iter()
.map(|j| j["state"].as_str().unwrap().to_string())
.collect();
assert_eq!(states, vec!["failed"], "qex deleted the wrong jobs");
}
#[test]
fn clean_refuses_a_word_that_names_work_and_a_state() {
let h = Harness::with_default_config("cleanboth");
let named = h.submit(&["submit", "--name", "completed", "--", "true"]);
let other = h.submit(&["submit", "--name", "other", "--", "true"]);
h.ok(&["wait", &named, "--timeout", "45s"]);
h.ok(&["wait", &other, "--timeout", "45s"]);
let out = h.qex(&["clean", "completed"]);
assert_eq!(
out.status.code(),
Some(127),
"the word has two readings, so the command must refuse it\n\
stdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("name of a job") && err.contains("name of a state"),
"the message must name both readings: {err}"
);
assert!(
err.contains("--state completed"),
"the message must give the way to ask for the state: {err}"
);
assert_eq!(
h.list_json().len(),
2,
"the refusal must leave every record where it was"
);
h.ok(&["clean", &named]);
h.ok(&["clean", &other]);
assert_eq!(h.list_json().len(), 0, "no job may carry the name now");
let file = h.root.join("completed.toml");
std::fs::write(&file, "[[jobs]]\nname = \"stage\"\ncommand = [\"true\"]\n").unwrap();
let group = h.ok(&["pipeline", file.to_str().unwrap()]);
h.qex(&["wait", &group, "--timeout", "45s"]);
let out = h.qex(&["clean", "completed"]);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("name of a pipeline"),
"the message must name the PIPELINE reading: {err}"
);
assert_eq!(
h.list_json().len(),
1,
"the refusal must leave the stage of the pipeline: {err}"
);
assert_eq!(
out.status.code(),
Some(127),
"a pipeline reads the same way"
);
}
#[test]
fn a_job_file_accepts_dependencies() {
let h = Harness::with_default_config("depfile");
let first = h.submit(&["submit", "--name", "first", "--", "sh", "-c", "exit 1"]);
let file = h.root.join("second.toml");
std::fs::write(
&file,
format!("command = [\"true\"]\nname = \"second\"\nneeds = [\"{first}\"]\n"),
)
.unwrap();
let second = h.submit(&["submit", "--job", file.to_str().unwrap()]);
let out = h.qex(&["wait", &second, "--timeout", "60s"]);
assert_eq!(out.status.code(), Some(126));
assert_eq!(h.state_of(&second), "skipped");
assert_eq!(
h.status_json(&second)["caused_by"].as_str(),
Some(first.as_str())
);
}
#[test]
fn a_dependency_with_an_unknown_uuid_is_refused() {
let h = Harness::with_default_config("depuuid");
let out = h.qex(&[
"submit",
"--needs",
"11111111-2222-3333-4444-555555555555",
"--",
"true",
]);
assert!(
!out.status.success(),
"qex must refuse an unknown dependency"
);
assert!(h.list_json().is_empty(), "qex must not accept the job");
}
#[test]
fn a_name_must_be_live_but_an_id_need_only_exist() {
let h = Harness::with_default_config("depnameid");
let first = h.submit(&["submit", "--name", "build", "--", "true"]);
h.ok(&["wait", &first, "--timeout", "45s"]);
assert_eq!(h.state_of(&first), "completed");
for option in ["--needs", "--after"] {
let out = h.qex(&["submit", option, "build", "--", "true"]);
assert!(
!out.status.success(),
"{option} with a name that gives a job which stopped must be refused"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("already stopped"),
"the error must say that the job stopped: {err}"
);
assert!(
err.contains("earlier run"),
"the error must name the usual cause: {err}"
);
}
for option in ["--needs", "--after"] {
let out = h.qex(&["submit", option, &first, "--", "true"]);
assert!(
out.status.success(),
"{option} with an id must be accepted: {}",
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn a_dependency_that_failed_is_accepted_and_makes_the_job_skipped() {
let h = Harness::with_default_config("depfailed");
let first = h.submit(&["submit", "--name", "first", "--", "false"]);
h.qex(&["wait", &first, "--timeout", "30s"]);
assert_eq!(h.state_of(&first), "failed");
let second = h.submit(&["submit", "--needs", &first, "--", "true"]);
let out = h.qex(&["wait", &second, "--timeout", "45s"]);
assert_eq!(out.status.code(), Some(126));
assert_eq!(h.state_of(&second), "skipped");
assert_eq!(
h.status_json(&second)["caused_by"].as_str(),
Some(first.as_str())
);
}
#[test]
fn a_failed_dependency_is_seen_behind_a_blocked_queue() {
let h = Harness::new(
"depblocked",
"[budget]\ncpu = \"2\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let failer = h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--name",
"failer",
"--",
"sh",
"-c",
"sleep 2; exit 1",
]);
let blocker = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--name", "blocker", "--", "sleep", "60",
]);
h.until("both jobs started", Duration::from_secs(45), || {
h.has_started(&failer) && h.state_of(&blocker) == "running"
});
h.submit(&[
"submit", "--cpu", "2", "--mem", "64MB", "--name", "mid", "--", "true",
]);
let skipped = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--needs", &failer, "--", "true",
]);
h.until("the job is skipped", Duration::from_secs(45), || {
h.state_of(&skipped) == "skipped"
});
let out = h.qex(&["wait", &skipped, "--timeout", "10s"]);
assert_eq!(out.status.code(), Some(126));
h.ok(&["kill", &blocker, "--grace", "1s"]);
}
#[test]
fn a_job_that_operates_says_running_and_gives_its_pid() {
let h = Harness::with_default_config("onewriter");
let ids: Vec<String> = (0..6)
.map(|_| {
h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "300",
])
})
.collect();
for id in &ids {
h.until("the job says running", Duration::from_secs(45), || {
h.state_of(id) == "running"
});
let status = h.status_json(id);
assert!(
status["pid"].as_i64().is_some(),
"a job that operates must give its pid: {status}"
);
std::thread::sleep(Duration::from_millis(300));
assert_eq!(
h.state_of(id),
"running",
"the record must not return to an earlier state"
);
h.ok(&["kill", id, "--grace", "1s"]);
}
}
#[test]
fn every_command_gives_one_code_for_a_job_that_does_not_exist() {
let h = Harness::with_default_config("codes2");
let unknown = "11111111-2222-3333-4444-555555555555";
for command in ["status", "wait", "logs", "kill", "cancel"] {
let out = h.qex(&[command, unknown]);
assert_eq!(
out.status.code(),
Some(127),
"`qex {command}` must give the code 127 for a job that does not exist"
);
}
}
#[test]
fn clean_keeps_the_cause_readable_for_the_jobs_that_it_leaves() {
let h = Harness::with_default_config("cleancause");
let first = h.submit(&[
"submit",
"--name",
"first",
"--",
"sh",
"-c",
"sleep 1; exit 1",
]);
let second = h.submit(&[
"submit", "--name", "second", "--needs", &first, "--", "true",
]);
h.until("the second job is skipped", Duration::from_secs(45), || {
h.state_of(&second) == "skipped"
});
h.ok(&["clean", &first]);
let s = h.status_json(&second);
let error = s["error"].as_str().unwrap_or("");
assert!(
error.contains("first"),
"the record must still name the job that failed: {error}"
);
assert!(
!error.contains("qex logs"),
"the record must not send the reader to a log that is deleted: {error}"
);
assert!(s["caused_by"].is_null(), "the id points at nothing now");
}
#[test]
fn logs_shows_the_last_lines_by_default() {
let h = Harness::with_default_config("logcap");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"i=0; while [ $i -lt 2000 ]; do echo line-$i; i=$((i+1)); done",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let result = h.qex(&["logs", &id, "--stdout"]);
let out = String::from_utf8_lossy(&result.stdout);
let notice = String::from_utf8_lossy(&result.stderr);
assert!(
out.lines().count() < 600,
"the default output must be short, and it had {} lines",
out.lines().count()
);
assert!(out.contains("line-1999"), "the last line must be there");
assert!(
notice.contains("not shown"),
"qex must say that it hid the earlier lines: {notice}"
);
let full = h.ok(&["logs", &id, "--stdout", "--all"]);
assert!(full.contains("line-0"), "--all must give the first line");
assert!(full.lines().count() >= 2000);
}
#[test]
fn the_status_of_a_job_that_failed_holds_its_error_output() {
let h = Harness::with_default_config("statuslogs");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"echo normal; echo 'BOOM: it broke' >&2; exit 3",
]);
h.qex(&["wait", &id, "--timeout", "45s"]);
let text = h.ok(&["status", &id]);
assert!(
text.contains("BOOM: it broke"),
"the status must hold the error output: {text}"
);
let v = h.status_json(&id);
assert!(v["logs"]["stderr"]["text"]
.as_str()
.unwrap()
.contains("BOOM"));
let good = h.submit(&["submit", "--", "sh", "-c", "echo quiet"]);
h.ok(&["wait", &good, "--timeout", "45s"]);
assert!(h.status_json(&good)["logs"].is_null());
let text = h.ok(&["status", &id, "--no-logs"]);
assert!(!text.contains("BOOM"), "--no-logs must remove the output");
}
#[test]
fn the_status_of_a_failure_gives_both_streams() {
let h = Harness::with_default_config("bothstreams");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"echo 'exact 27793/27793 OK'; echo 'FAIL: 64087 mismatched' >&2; exit 1",
]);
h.qex(&["wait", &id, "--timeout", "45s"]);
let text = h.ok(&["status", &id]);
assert!(
text.contains("FAIL: 64087"),
"the status must hold the error output: {text}"
);
assert!(
text.contains("27793/27793"),
"the status must also hold the standard output, or the reader sees a \
failure with no result: {text}"
);
let v = h.status_json(&id);
assert!(v["logs"]["stderr"]["text"]
.as_str()
.unwrap()
.contains("FAIL"));
assert!(v["logs"]["stdout"]["text"]
.as_str()
.unwrap()
.contains("27793"));
let only = h.ok(&["status", &id, "--stderr"]);
assert!(only.contains("FAIL"));
assert!(
!only.contains("27793"),
"--stderr must give one stream only"
);
}
#[test]
fn the_log_options_select_the_lines() {
let h = Harness::with_default_config("logopts");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"i=1; while [ $i -le 300 ]; do echo line-$i; i=$((i+1)); done",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let head = h.ok(&["logs", &id, "--stdout", "--head", "3"]);
assert_eq!(head.lines().next().unwrap(), "line-1");
assert_eq!(head.lines().count(), 3);
let range = h.ok(&["logs", &id, "--stdout", "--lines", "100:102"]);
assert_eq!(range, "line-100\nline-101\nline-102");
let numbered = h.ok(&["logs", &id, "--stdout", "--head", "1", "--number"]);
assert!(numbered.contains("1 line-1"), "got: {numbered}");
let out = h.qex(&[
"logs",
&id,
"--stdout",
"--grep",
"line-1[0-9]$",
"--max-matches",
"3",
]);
let found = String::from_utf8_lossy(&out.stdout);
let notice = String::from_utf8_lossy(&out.stderr);
assert!(notice.contains("10 line(s) match"), "got: {notice}");
assert!(found.contains("line-10") && found.contains("line-12"));
assert!(!found.contains("line-13"), "the limit must hold");
for line in found.lines() {
assert!(
line.starts_with("line-"),
"stdout must hold log lines only: {line}"
);
}
let s = h.ok(&["status", &id, "--stdout", "--head", "2"]);
assert!(s.contains("line-1") && s.contains("line-2"));
assert!(!s.contains("line-3"), "the head limit must hold in status");
}
#[test]
fn follow_leads_with_the_last_lines_only() {
let h = Harness::with_default_config("followtail");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"i=1; while [ $i -le 200 ]; do echo old-$i; i=$((i+1)); done; sleep 2; echo NEW-A",
]);
h.until(
"the job writes its first lines",
Duration::from_secs(45),
|| {
h.job_dir(&id)
.join("stdout.log")
.metadata()
.map(|m| m.len() > 100)
.unwrap_or(false)
},
);
let out = h.ok(&["logs", &id, "--stdout", "--tail", "3", "--follow"]);
assert!(
out.contains("NEW-A"),
"follow must give the new lines: {out}"
);
assert!(
!out.contains("old-1\n"),
"follow must not write the whole file"
);
assert!(
out.lines().count() <= 6,
"got {} lines",
out.lines().count()
);
}
#[test]
fn a_second_job_of_one_command_uses_the_measurement_of_the_first() {
let h = Harness::with_default_config("learn");
let program = [
"sh",
"-c",
"head -c 40000000 /dev/zero | tail -c 1 > /dev/null",
];
let first = h.submit(&[&["submit", "--name", "one", "--"], &program[..]].concat());
h.ok(&["wait", &first, "--timeout", "60s"]);
let s1 = h.status_json(&first);
assert_eq!(
s1["claim_source"], "default",
"the first job has no measurement to use"
);
let used = s1["usage"]["max_rss"].as_u64().unwrap();
assert!(used > 0);
let second = h.submit(&[&["submit", "--name", "two", "--"], &program[..]].concat());
let s2 = h.status_json(&second);
assert_eq!(
s2["claim_source"], "learned",
"the second job must use the measurement: {s2}"
);
let claimed = s2["mem"].as_u64().unwrap();
assert!(
claimed >= used,
"the claim {claimed} must not be below the measurement {used}"
);
assert!(
claimed < s1["mem"].as_u64().unwrap(),
"the claim must be below the default, or the measurement gave nothing"
);
h.ok(&["wait", &second, "--timeout", "60s"]);
}
#[test]
fn a_claim_from_the_user_wins_over_a_measurement() {
let h = Harness::with_default_config("learnwins");
let first = h.submit(&["submit", "--", "true"]);
h.ok(&["wait", &first, "--timeout", "45s"]);
let second = h.submit(&["submit", "--cpu", "2", "--mem", "1GB", "--", "true"]);
let s = h.status_json(&second);
assert_eq!(s["claim_source"], "explicit");
assert_eq!(s["cpu"], 2);
assert_eq!(s["mem"], 1024u64 * 1024 * 1024);
h.ok(&["wait", &second, "--timeout", "45s"]);
}
#[test]
fn a_job_that_did_not_complete_is_not_a_measurement() {
let h = Harness::with_default_config("learnfail");
let program = ["sh", "-c", "exit 1"];
let first = h.submit(&[&["submit", "--"], &program[..]].concat());
h.qex(&["wait", &first, "--timeout", "45s"]);
assert_eq!(h.state_of(&first), "failed");
let second = h.submit(&[&["submit", "--"], &program[..]].concat());
assert_eq!(
h.status_json(&second)["claim_source"],
"default",
"a job that failed must not become a measurement"
);
h.qex(&["wait", &second, "--timeout", "45s"]);
}
#[test]
fn a_replacement_of_the_program_does_not_stop_the_jobs() {
let h = Harness::with_default_config("skew");
let copy = h.root.join("qex-copy");
std::fs::copy(env!("CARGO_BIN_EXE_qex"), ©).unwrap();
let run = |args: &[&str], exe: &std::path::Path| -> Output {
Command::new(exe)
.args(args)
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.expect("qex did not start")
};
let first = run(&["submit", "--", "true"], ©);
assert!(first.status.success());
let id = String::from_utf8_lossy(&first.stdout).trim().to_string();
run(&["wait", &id, "--timeout", "45s"], ©);
std::fs::remove_file(©).unwrap();
std::fs::copy(env!("CARGO_BIN_EXE_qex"), ©).unwrap();
let after = run(&["submit", "--", "sh", "-c", "echo it-ran"], ©);
assert!(
after.status.success(),
"the submission failed after the replacement: {}",
String::from_utf8_lossy(&after.stderr)
);
let id2 = String::from_utf8_lossy(&after.stdout).trim().to_string();
let waited = run(&["wait", &id2, "--timeout", "45s"], ©);
assert_eq!(
waited.status.code(),
Some(0),
"the job did not run after the replacement: {}",
String::from_utf8_lossy(&waited.stderr)
);
let info = run(&["info", "--no-start", "--json"], ©);
let v: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap();
#[cfg(target_os = "linux")]
assert_eq!(v["program_replaced"], true);
if let Some(pid) = v["pid"].as_i64() {
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
}
}
#[test]
fn a_closed_pipe_does_not_give_a_panic() {
let h = Harness::with_default_config("pipe");
for _ in 0..5 {
h.submit(&["submit", "--", "true"]);
}
let out = Command::new("sh")
.arg("-c")
.arg(format!("{} list | head -2", env!("CARGO_BIN_EXE_qex")))
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(
!err.contains("panicked"),
"a closed pipe gave a panic: {err}"
);
assert!(!err.contains("Broken pipe"), "got: {err}");
}
#[test]
fn the_id_file_holds_the_id() {
let h = Harness::with_default_config("idfile");
let file = h.root.join("job.id");
let id = h.submit(&["submit", "--id-file", file.to_str().unwrap(), "--", "true"]);
let written = std::fs::read_to_string(&file).unwrap();
assert_eq!(written.trim(), id, "the file must hold the id");
h.ok(&["wait", written.trim(), "--timeout", "45s"]);
}
#[test]
fn an_id_file_in_a_temporary_directory_gives_a_warning() {
let h = Harness::with_default_config("idtmp");
let out = h.qex(&[
"submit",
"--id-file",
h.root.join("job.id").to_str().unwrap(),
"--",
"true",
]);
assert!(out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("does not last"),
"an id file in a temporary directory must give a warning; got: {err}"
);
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
assert!(
id.parse::<uuid::Uuid>().is_ok(),
"stdout must hold the id only, and it held: {id}"
);
let lasting = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join("qex-id-file-test");
std::fs::create_dir_all(&lasting).unwrap();
let out = h.qex(&[
"submit",
"--id-file",
lasting.join("job.id").to_str().unwrap(),
"--",
"true",
]);
assert!(out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
!err.contains("does not last"),
"an id file in a directory that lasts must give no warning; got: {err}"
);
std::fs::remove_dir_all(&lasting).ok();
}
#[test]
fn the_id_file_of_a_pipeline_holds_every_stage() {
let h = Harness::with_default_config("pipeidfile");
let pipeline = h.root.join("ci.toml");
std::fs::write(
&pipeline,
"[[jobs]]\nname = \"build\"\ncommand = [\"true\"]\n\n\
[[jobs]]\nname = \"test\"\ncommand = [\"true\"]\nneeds = [\"build\"]\n",
)
.unwrap();
let env_file = h.root.join("ids.env");
let group = h.ok(&[
"pipeline",
pipeline.to_str().unwrap(),
"--id-file",
env_file.to_str().unwrap(),
]);
let text = std::fs::read_to_string(&env_file).unwrap();
assert!(text.contains(&format!("group={group}")), "got: {text}");
assert!(
text.contains("build="),
"the build stage is missing: {text}"
);
assert!(text.contains("test="), "the test stage is missing: {text}");
for line in text.lines() {
let (_, id) = line.split_once('=').unwrap();
assert!(id.parse::<uuid::Uuid>().is_ok(), "not an id: {line}");
}
let json_file = h.root.join("ids.json");
h.ok(&[
"pipeline",
pipeline.to_str().unwrap(),
"--id-file",
json_file.to_str().unwrap(),
]);
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&json_file).unwrap()).unwrap();
assert!(v["group"].as_str().is_some());
assert!(v["jobs"]["build"].as_str().is_some());
assert!(v["jobs"]["test"].as_str().is_some());
}
#[test]
fn the_group_id_of_a_pipeline_is_a_handle() {
let h = Harness::with_default_config("grouphandle");
let pipeline = h.root.join("ci.toml");
std::fs::write(
&pipeline,
"[[jobs]]\nname = \"build\"\ncommand = [\"true\"]\n\n\
[[jobs]]\nname = \"test\"\ncommand = [\"true\"]\nneeds = [\"build\"]\n",
)
.unwrap();
let group = h.ok(&["pipeline", pipeline.to_str().unwrap()]);
assert!(group.parse::<uuid::Uuid>().is_ok(), "got: {group}");
let out = h.ok(&["wait", &group, "--timeout", "60s"]);
assert!(out.contains("completed"), "got: {out}");
assert_eq!(out.lines().count(), 2, "each stage gives one line: {out}");
let build_id = h.status_json("build")["id"].as_str().unwrap().to_string();
let out = h.ok(&["wait", &group, &build_id, "--timeout", "60s"]);
assert_eq!(
out.lines().count(),
2,
"the pipeline and one of its stages give two jobs, not three: {out}"
);
let text = h.ok(&["status", &group, "--json"]);
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
let stages = v.as_array().expect("a pipeline gives an array");
assert_eq!(stages.len(), 2, "got: {text}");
assert_eq!(stages[0]["name"], "build");
assert_eq!(stages[1]["name"], "test");
let out = h.qex(&["list", "--group", &group]);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
!err.contains("pipelines"),
"one run of two stages is one pipeline: {err}"
);
let one = h.ok(&["status", "build", "--json"]);
let v: serde_json::Value = serde_json::from_str(&one).unwrap();
assert!(v.is_object(), "one job must give one object: {one}");
let out = h.qex(&["status", &group]);
assert!(out.status.success());
let text = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
text.contains("\n\nid:"),
"an empty line must separate the stages: {text}"
);
assert!(
text.starts_with("id:"),
"the answer must not open with an empty line: {text:?}"
);
let out = h.qex(&["logs", &group]);
assert_eq!(out.status.code(), Some(127), "a pipeline is not one job");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("takes one job"),
"the message must say that this command reads one job: {err}"
);
assert!(
err.contains("build") && err.contains("test"),
"the message must name every stage: {err}"
);
let solo = h.root.join("solo.toml");
std::fs::write(
&solo,
"name = \"solo\"\n\n[[jobs]]\nname = \"only\"\ncommand = [\"true\"]\n",
)
.unwrap();
let one_stage = h.ok(&["pipeline", solo.to_str().unwrap()]);
let out = h.qex(&["logs", &one_stage]);
assert_eq!(
out.status.code(),
Some(127),
"a pipeline of one stage is still a pipeline: {}",
String::from_utf8_lossy(&out.stdout)
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("takes one job") && err.contains("only"),
"the message must name the stage: {err}"
);
h.ok(&["clean", &group]);
let left = h.ok(&["list", "--group", &group]);
assert!(left.contains("no jobs"), "got: {left}");
let broken = h.root.join("broken.toml");
std::fs::write(
&broken,
"name = \"broken\"\n\n\
[[jobs]]\nname = \"bad\"\ncommand = [\"false\"]\n\n\
[[jobs]]\nname = \"after\"\ncommand = [\"true\"]\nneeds = [\"bad\"]\n",
)
.unwrap();
let bad_group = h.ok(&["pipeline", broken.to_str().unwrap()]);
let out = h.qex(&["status", &bad_group, "--wait", "--timeout", "60s"]);
assert_eq!(
out.status.code(),
Some(1),
"the code must name the stage that FAILED, and not the stage that qex \
skipped\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let out = h.qex(&["status", "no-such-thing"]);
assert_eq!(out.status.code(), Some(127));
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("pipeline"), "got: {err}");
}
#[test]
fn a_word_that_names_two_pipelines_is_refused() {
let h = Harness::with_default_config("grouptwice");
let pipeline = h.root.join("twice.toml");
std::fs::write(
&pipeline,
"[[jobs]]\nname = \"one\"\ncommand = [\"sleep\", \"30\"]\n",
)
.unwrap();
let first = h.ok(&["pipeline", pipeline.to_str().unwrap()]);
let second = h.ok(&["pipeline", pipeline.to_str().unwrap()]);
assert_ne!(first, second);
for command in [
vec!["kill", "twice"],
vec!["cancel", "twice"],
vec!["status", "twice"],
vec!["clean", "twice"],
vec!["wait", "twice", "--timeout", "5s"],
] {
let out = h.qex(&command);
assert_eq!(
out.status.code(),
Some(127),
"`qex {}` must refuse the word",
command.join(" ")
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("2 pipelines"),
"`qex {}` must say how many runs the word names, and it said: {err}",
command.join(" ")
);
}
let out = h.qex(&["list", "--group", "twice"]);
assert!(out.status.success(), "`qex list --group` must not refuse");
let table = String::from_utf8_lossy(&out.stdout);
assert_eq!(
table.lines().filter(|l| l.contains("one")).count(),
2,
"the table must hold the stage of both runs: {table}"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("2 pipelines"),
"the warning must say how many runs the word names: {err}"
);
assert!(
err.contains(first.as_str()) && err.contains(second.as_str()),
"the warning must give the group id of each run: {err}"
);
let out = h.qex(&["list", "--group", "twice", "--json"]);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
!err.contains("2 pipelines"),
"the warning is for a person, and `--json` is for a machine: {err}"
);
let out = h.qex(&["list", "--group", &first]);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
!err.contains("pipelines"),
"one run must give no warning: {err}"
);
for group in [&first, &second] {
let text = h.ok(&["list", "--group", group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_eq!(jobs.len(), 1, "the run {group} must still hold its stage");
assert_ne!(jobs[0]["state"], "killed", "the run {group} must continue");
}
for group in [&first, &second] {
h.until(
"the stage of the run holds a process",
Duration::from_secs(30),
|| {
let text = h.ok(&["list", "--group", group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
jobs.iter().all(|j| j["pid"].as_u64().is_some())
},
);
h.ok(&["kill", group]);
}
for group in [&first, &second] {
let text = h.ok(&["list", "--group", group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_eq!(jobs.len(), 1);
}
}
#[test]
fn a_kill_of_a_group_stops_a_stage_that_waits_in_the_queue() {
let h = Harness::new(
"groupqueued",
"[budget]\ncpu = \"1\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let pipeline = h.root.join("both.toml");
std::fs::write(
&pipeline,
"[[jobs]]\nname = \"first\"\ncommand = [\"sleep\", \"60\"]\n\n\
[[jobs]]\nname = \"second\"\ncommand = [\"sleep\", \"60\"]\n",
)
.unwrap();
let group = h.ok(&["pipeline", pipeline.to_str().unwrap()]);
let mut ready = false;
for _ in 0..100 {
let text = h.ok(&["list", "--group", &group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
ready = jobs.iter().any(|j| j["state"] == "running")
&& jobs.iter().any(|j| j["state"] == "queued");
if ready {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(ready, "one stage must operate and one must wait");
let text = h.ok(&["list", "--group", &group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
let queued = jobs.iter().find(|j| j["state"] == "queued").unwrap()["id"]
.as_str()
.unwrap()
.to_string();
let out = h.qex(&["kill", &queued]);
assert_ne!(
out.status.code(),
Some(0),
"`qex kill $ID` for one job that waits must give a fault"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("cancel"),
"the fault must name `qex cancel`: {err}"
);
assert_eq!(
h.state_of(&queued),
"queued",
"that job must stay in the queue"
);
let out = h.qex(&["cancel", &group]);
assert_ne!(
out.status.code(),
Some(0),
"a stage that operates cannot leave the queue, and the command must say so\n\
stdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let out = h.qex(&["kill", &group]);
assert_eq!(
out.status.code(),
Some(0),
"stdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
for _ in 0..40 {
let text = h.ok(&["list", "--group", &group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
for j in &jobs {
assert_ne!(
j["state"], "queued",
"a stage that waits must leave the queue: {j}"
);
}
if jobs.iter().all(|j| j["state"] != "running") {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
let text = h.ok(&["list", "--group", &group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_eq!(jobs.len(), 2);
for j in &jobs {
assert!(
j["state"] == "killed" || j["state"] == "cancelled",
"every stage must stop: {j}"
);
}
}
#[test]
fn a_kill_of_a_group_accepts_a_stage_that_already_stopped() {
let h = Harness::with_default_config("groupdone");
let pipeline = h.root.join("mixed.toml");
std::fs::write(
&pipeline,
"[[jobs]]\nname = \"quick\"\ncommand = [\"true\"]\n\n\
[[jobs]]\nname = \"slow\"\ncommand = [\"sleep\", \"60\"]\nneeds = [\"quick\"]\n",
)
.unwrap();
let group = h.ok(&["pipeline", pipeline.to_str().unwrap()]);
let mut ready = false;
for _ in 0..100 {
let text = h.ok(&["list", "--group", &group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
ready = jobs.iter().any(|j| j["state"] == "completed")
&& jobs.iter().any(|j| j["state"] == "running");
if ready {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert!(ready, "one stage must stop and one must operate");
let out = h.qex(&["kill", &group]);
assert_eq!(
out.status.code(),
Some(0),
"a stage that already stopped is not a fault when the user named the whole \
pipeline\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let text = h.ok(&["list", "--group", &group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
let quick = jobs
.iter()
.find(|j| j["name"] == "quick")
.unwrap()
.get("id")
.unwrap()
.as_str()
.unwrap()
.to_string();
let out = h.qex(&["kill", &quick]);
assert_ne!(
out.status.code(),
Some(0),
"one job that already stopped must still give a fault"
);
let out = h.qex(&["cancel", &quick]);
assert_ne!(
out.status.code(),
Some(0),
"`qex cancel $ID` for one job that already stopped must give a fault\n\
stdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn a_kill_of_a_group_reaches_every_stage() {
let h = Harness::with_default_config("groupkill");
let pipeline = h.root.join("slow.toml");
std::fs::write(
&pipeline,
"[[jobs]]\nname = \"one\"\ncommand = [\"sleep\", \"60\"]\n\n\
[[jobs]]\nname = \"two\"\ncommand = [\"sleep\", \"60\"]\n",
)
.unwrap();
let group = h.ok(&["pipeline", pipeline.to_str().unwrap()]);
let mut running = 0;
for _ in 0..100 {
let text = h.ok(&["list", "--group", &group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
running = jobs.iter().filter(|j| j["state"] == "running").count();
if running == 2 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
assert_eq!(running, 2, "both stages must operate before the kill");
let out = h.qex(&["status", &group, "--wait", "--timeout", "1s"]);
assert_eq!(out.status.code(), Some(124), "the wait reached its limit");
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(
err.matches("reached its time limit").count(),
1,
"the command stops at the first stage that reaches the limit: {err}"
);
h.ok(&["kill", &group]);
let out = h.qex(&["wait", &group, "--timeout", "60s"]);
assert_ne!(out.status.code(), Some(0));
let text = h.ok(&["list", "--group", &group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_eq!(jobs.len(), 2);
for j in &jobs {
assert_eq!(j["state"], "killed", "got: {j}");
}
}
#[test]
fn needs_a_group_waits_for_every_stage() {
let h = Harness::with_default_config("needsgroup");
let pipeline = h.root.join("stages.toml");
std::fs::write(
&pipeline,
"name = \"waves\"\n\n\
[[jobs]]\nname = \"quick\"\ncommand = [\"true\"]\n\n\
[[jobs]]\nname = \"slow\"\ncommand = [\"sleep\", \"60\"]\nneeds = [\"quick\"]\n",
)
.unwrap();
let group = h.ok(&["pipeline", pipeline.to_str().unwrap()]);
h.until(
"one stage must stop and one must operate",
Duration::from_secs(30),
|| {
let text = h.ok(&["list", "--group", &group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
jobs.iter().any(|j| j["state"] == "completed")
&& jobs.iter().any(|j| j["state"] == "running")
},
);
let out = h.qex(&["submit", "--needs", "waves", "--", "true"]);
assert!(
out.status.success(),
"`--needs <pipeline name>` must be accepted while a stage operates: {}",
String::from_utf8_lossy(&out.stderr)
);
let last = h.submit(&["submit", "--needs", &group, "--", "true"]);
let status = h.status_json(&last);
assert_eq!(
status["needs"].as_array().map(|n| n.len()),
Some(2),
"`--needs $GROUP` must name every stage: {status}"
);
assert_eq!(
status["state"].as_str(),
Some("queued"),
"the job must wait while a stage of the pipeline operates: {status}"
);
h.ok(&["kill", &group]);
h.qex(&["wait", &last, "--timeout", "60s"]);
assert_ne!(
h.state_of(&last),
"queued",
"the job must leave the queue when every stage stopped"
);
let out = h.qex(&["submit", "--needs", "waves", "--", "true"]);
assert!(
!out.status.success(),
"a pipeline that stopped must be refused by name"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("every stage already stopped"),
"the error must say that the pipeline stopped: {err}"
);
assert!(
err.contains("GROUP=$(qex pipeline"),
"the error must give the remedy: {err}"
);
let out = h.qex(&["submit", "--needs", &group, "--", "true"]);
assert!(
out.status.success(),
"a group id must be accepted whatever the state of its stages: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn the_directory_filters_select_the_right_jobs() {
let h = Harness::with_default_config("dirs");
let project = h.root.join("project");
let inner = project.join("inner");
let other = h.root.join("other");
for d in [&project, &inner, &other] {
std::fs::create_dir_all(d).unwrap();
}
let run_in = |dir: &std::path::Path, name: &str| -> String {
let out = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["submit", "--name", name, "--", "true"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("QEX_IDLE_EXIT_SECS", "120")
.current_dir(dir)
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let top = run_in(&project, "top");
let deep = run_in(&inner, "deep");
let away = run_in(&other, "away");
for id in [&top, &deep, &away] {
h.ok(&["wait", id, "--timeout", "45s"]);
}
let names = |args: &[&str], dir: &std::path::Path| -> Vec<String> {
let out = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(args)
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("QEX_IDLE_EXIT_SECS", "120")
.current_dir(dir)
.output()
.unwrap();
let jobs: Vec<serde_json::Value> = serde_json::from_slice(&out.stdout).unwrap_or_default();
jobs.iter()
.map(|j| j["name"].as_str().unwrap().to_string())
.collect()
};
assert_eq!(names(&["list", "--json", "--cwd"], &project), vec!["top"]);
let under = names(&["list", "--json", "--under"], &project);
assert!(under.contains(&"top".to_string()) && under.contains(&"deep".to_string()));
assert!(
!under.contains(&"away".to_string()),
"a different directory must not appear"
);
let out = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["clean", "--under"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("QEX_IDLE_EXIT_SECS", "120")
.current_dir(&project)
.output()
.unwrap();
assert!(out.status.success());
let left: Vec<String> = h
.list_json()
.iter()
.map(|j| j["name"].as_str().unwrap().to_string())
.collect();
assert_eq!(left, vec!["away"], "the other directory must stay");
}
#[test]
fn clean_auto_keeps_the_recent_jobs() {
let h = Harness::with_default_config("auto");
let id = h.submit(&["submit", "--", "true"]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.qex(&["clean", "--auto"]);
assert!(out.status.success());
assert_eq!(
h.list_json().len(),
1,
"a job that stopped a moment ago must stay: {}",
String::from_utf8_lossy(&out.stdout)
);
}
#[test]
fn a_dependency_of_a_queued_job_is_not_finished() {
let h = Harness::new(
"depclean",
"[budget]\ncpu = \"1\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let first = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
h.ok(&["wait", &first, "--timeout", "45s"]);
assert_eq!(h.state_of(&first), "completed");
let blocker = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "20"]);
h.until("the blocker starts", Duration::from_secs(45), || {
h.state_of(&blocker) == "running"
});
let second = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--needs", &first, "--", "true",
]);
assert_eq!(h.state_of(&second), "queued");
let out = h.ok(&["clean", "--all"]);
assert!(out.contains("0 job(s)"), "nothing must go: {out}");
assert!(
out.contains("still needs them"),
"the message must give the reason: {out}"
);
assert!(
h.list_json().iter().any(|j| j["id"] == first),
"the record of the first job must stay"
);
h.ok(&["kill", &blocker, "--grace", "1s"]);
}
#[test]
fn du_reports_the_space_that_qex_holds() {
let h = Harness::with_default_config("du");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"i=0; while [ $i -lt 500 ]; do echo padding-line-$i; i=$((i+1)); done",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let text = h.ok(&["du", "--json"]);
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(v["total_bytes"].as_u64().unwrap() > 0);
assert!(v["jobs_bytes"].as_u64().unwrap() > 0);
assert_eq!(v["largest"][0]["id"].as_str(), Some(id.as_str()));
}
#[test]
fn a_long_runtime_directory_still_works() {
let long = std::env::temp_dir()
.join(format!("qex-long-{}", std::process::id()))
.join("a-directory-with-a-very-long-name")
.join("another-directory-with-a-long-name")
.join("and-one-more-to-pass-the-limit-of-sun-path");
let h = Harness::with_default_config("longpath");
std::fs::create_dir_all(&long).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["submit", "--", "echo", "long-path-ok"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", &long)
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.unwrap();
assert!(
out.status.success(),
"qex failed with a long runtime directory: {}",
String::from_utf8_lossy(&out.stderr)
);
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
let wait = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["wait", &id, "--timeout", "30s"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", &long)
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.unwrap();
assert_eq!(wait.status.code(), Some(0));
let info = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["info", "--json"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", &long)
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.unwrap();
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&info.stdout) {
if let Some(pid) = v["pid"].as_i64() {
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
}
}
std::fs::remove_dir_all(long.ancestors().nth(3).unwrap()).ok();
}
#[test]
fn the_status_gives_the_measured_use_of_a_job() {
let h = Harness::with_default_config("usage");
let id = h.submit(&[
"submit",
"--mem",
"512MB",
"--",
"sh",
"-c",
"head -c 8000000 /dev/zero > /dev/null",
]);
h.ok(&["wait", &id]);
let status = h.status_json(&id);
let rss = status["usage"]["max_rss"].as_u64().unwrap();
assert!(rss > 0, "qex must measure the memory of a job");
assert!(
rss < 512 * 1024 * 1024,
"the measurement {rss} is larger than the claim, so the unit is wrong"
);
}
#[test]
fn a_job_that_starts_again_never_shows_the_attempt_that_failed() {
let h = Harness::with_default_config("retrylatch");
let counter = h.root.join("attempts");
let script = format!(
"n=$(cat {c} 2>/dev/null || echo 0); n=$((n+1)); echo $n > {c}; \
if [ $n -lt 2 ]; then exit 3; fi; sleep 5",
c = counter.display()
);
let id = h.submit(&["submit", "--retries", "4", "--", "sh", "-c", &script]);
let record = h.root.join("state/qex/jobs").join(&id).join("status.json");
let deadline = Instant::now() + Duration::from_secs(90);
let mut saw_a_later_attempt = false;
let mut samples = 0u64;
loop {
if let Ok(text) = std::fs::read_to_string(&record) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
samples += 1;
let state = v["state"].as_str().unwrap_or("");
let attempts = v["attempts"].as_u64().unwrap_or(0);
assert_ne!(
state, "failed",
"the record must never hold the state of an attempt that starts \
again; it held `failed` at attempt {attempts}"
);
if state == "running" && attempts > 1 {
saw_a_later_attempt = true;
}
if state == "completed" {
break;
}
}
}
assert!(
Instant::now() < deadline,
"the job did not finish after {samples} samples"
);
}
assert!(
saw_a_later_attempt,
"the test must see an attempt after the first one, or it tests nothing"
);
assert!(
samples > 100,
"the test must sample the record often enough to meet the window; it took \
{samples} samples"
);
let status = h.status_json(&id);
assert_eq!(status["state"], "completed", "got: {status}");
assert_eq!(status["attempts"], 2, "got: {status}");
assert_eq!(status["exit_code"], 0, "got: {status}");
}
#[test]
fn a_config_fault_in_the_record_of_a_job_stays_short() {
let good = "[budget]\ncpu = \"1\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n";
let h = Harness::new("cfgrec", good);
let occupier = h.submit(&[
"submit", "--cpu", "1", "--mem", "128MB", "--", "sleep", "300",
]);
h.until("the first job operates", Duration::from_secs(45), || {
h.has_started(&occupier)
});
let victim = h.submit(&["submit", "--cpu", "1", "--mem", "128MB", "--", "true"]);
assert_eq!(
h.state_of(&victim),
"queued",
"the budget of one core must hold the second job in the queue"
);
h.write_config(&format!(
"{good}\n[telemetry]\nendpoint = \"https://example.invalid\"\n"
));
h.qex(&["kill", &occupier, "--grace", "1s"]);
h.until("the second job stops", Duration::from_secs(60), || {
h.state_of(&victim) == "completed"
});
let status = h.ok(&["status", &victim]);
assert!(
status.contains("NO LIMIT OPERATES"),
"the supervisor did not meet the config fault, so this test measured \
nothing: {status}"
);
assert!(
!status.contains("coordinator"),
"the record of a job must hold the short form of a config fault. The \
supervisor asked for the long form, which belongs to a person at a \
terminal and not to the `error:` field of a job that already ran: \
{status}"
);
}
#[test]
fn a_job_that_writes_more_than_the_limit_keeps_the_head_and_the_tail() {
let h = Harness::new(
"logcap",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[logs]\nmax_bytes = \"64KB\"\n",
);
let id = h.submit(&["submit", "--", "sh", "-c", "seq 1 500000"]);
let wait = h.qex(&["wait", &id, "--timeout", "60s"]);
assert_eq!(
wait.status.code(),
Some(0),
"the limit on the output must not fail the job"
);
assert_eq!(h.state_of(&id), "completed");
let path = h.job_dir(&id).join("stdout.log");
let size = std::fs::metadata(&path).unwrap().len();
assert!(
size <= 64 * 1024,
"the file holds {size} bytes, and the limit is 65536"
);
let text = std::fs::read_to_string(&path).unwrap();
assert!(
text.lines().any(|l| l == "1"),
"the first line went, and it holds the start of the job"
);
assert!(
text.lines().any(|l| l == "500000"),
"the last line went, and it holds the end of the job"
);
assert!(
!text.lines().any(|l| l == "250000"),
"the middle must go, and it stayed"
);
assert!(
text.contains("are not in this file"),
"the file must say what went: {:.400}",
text
);
assert!(
!h.job_dir(&id).join("stdout.log.tail").exists(),
"the file that held the last output stayed"
);
let status = h.status_json(&id);
let dropped = &status["logs_dropped"];
assert!(
dropped["stdout_bytes"].as_u64().unwrap() > 0,
"the record must say how many bytes went: {status}"
);
assert!(
dropped["stdout_lines"].as_u64().unwrap() > 1000,
"the record must say how many lines went: {status}"
);
assert_eq!(dropped["limit"].as_u64().unwrap(), 64 * 1024);
let notes = text.lines().filter(|l| l.starts_with("[qex]")).count() as u64;
let kept = text.matches('\n').count() as u64 - notes;
assert_eq!(
kept + dropped["stdout_lines"].as_u64().unwrap(),
500_000,
"the file holds {kept} line(s) and the record says that {} went. Together they \
must be the 500000 lines that the job wrote.",
dropped["stdout_lines"]
);
let note_bytes: u64 = text
.lines()
.filter(|l| l.starts_with("[qex]"))
.map(|l| l.len() as u64 + 1)
.sum();
let kept_bytes = text.len() as u64 - note_bytes;
assert_eq!(
kept_bytes + dropped["stdout_bytes"].as_u64().unwrap(),
3_388_895,
"the file holds {kept_bytes} byte(s) of the job and the record says that {} went. \
Together they must be the 3388895 bytes that `seq 1 500000` writes.",
dropped["stdout_bytes"]
);
let head = &text[..text
.find("[qex]")
.expect("the file must hold a note of qex")];
for line in head.lines() {
let n: u64 = line
.parse()
.unwrap_or_else(|_| panic!("the head holds `{line}`, which is not a whole line"));
assert!((1..=500_000).contains(&n), "`{line}` is not in the output");
}
let head_budget: u64 = 64 * 1024 / 4;
let cut_cost = head_budget - head.len() as u64;
assert!(
cut_cost < 64,
"the cut back to a line end removed {cut_cost} bytes of the head budget of \
{head_budget}. No line of this output is longer than seven bytes, so the cut back \
must move to the LAST line end before the cut, and not to the first one of the \
window."
);
let logs = h.qex(&["logs", &id, "--stdout", "--tail", "5"]);
let notice = String::from_utf8_lossy(&logs.stderr);
assert!(
notice.contains("qex removed"),
"`qex logs` must say what went: {notice}"
);
let json = h.ok(&["logs", &id, "--stdout", "--json"]);
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(value["stdout_dropped_lines"].as_u64().unwrap() > 1000);
}
#[test]
fn a_job_that_writes_one_enormous_line_keeps_its_end() {
let h = Harness::new(
"logcap-line",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[logs]\nmax_bytes = \"64KB\"\n",
);
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"dd if=/dev/zero bs=1M count=8 2>/dev/null | tr '\\0' 'A'; printf THE-VERY-END",
]);
let wait = h.qex(&["wait", &id, "--timeout", "60s"]);
assert_eq!(wait.status.code(), Some(0));
let path = h.job_dir(&id).join("stdout.log");
let size = std::fs::metadata(&path).unwrap().len();
assert!(size <= 64 * 1024, "the file holds {size} bytes");
let text = std::fs::read_to_string(&path).unwrap();
assert!(
text.ends_with("THE-VERY-END"),
"the end of the output went, and the file holds the head only"
);
assert!(
text.contains("middle of a line"),
"the file must say that the last part is not a whole line"
);
}
#[test]
fn a_retry_that_fits_the_limit_keeps_the_output_of_both_attempts() {
let h = Harness::new(
"logcap-retry",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[logs]\nmax_bytes = \"1MB\"\n",
);
let id = h.submit(&[
"submit",
"--retries",
"1",
"--",
"sh",
"-c",
"seq 1 20000; echo THE-LAST-LINE; exit 1",
]);
h.qex(&["wait", &id, "--timeout", "60s"]);
let text = std::fs::read_to_string(h.job_dir(&id).join("stdout.log")).unwrap();
assert!(
!text.contains("[qex]"),
"qex wrote a note about the limit, and the output fits in the limit"
);
assert_eq!(
text.lines().filter(|l| *l == "THE-LAST-LINE").count(),
2,
"each attempt must keep its output"
);
assert!(
text.contains("--- attempt 2 ---"),
"the mark between the attempts went"
);
assert_eq!(
h.status_json(&id)["logs_dropped"],
serde_json::Value::Null,
"the record says that qex removed output, and it removed nothing"
);
}
#[test]
fn follow_does_not_lose_the_output_of_a_job_that_passes_the_limit() {
let h = Harness::new(
"logcap-follow",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[logs]\nmax_bytes = \"64KB\"\n",
);
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"seq 1 12000; sleep 2; seq 12001 13000; sleep 2; echo THE-FINAL-LINE",
]);
let out = h.qex(&["logs", &id, "--stdout", "--follow"]);
assert_eq!(out.status.code(), Some(0));
let lines = String::from_utf8_lossy(&out.stdout).into_owned();
let notice = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
lines.contains("THE-FINAL-LINE"),
"the follower lost each line after the limit. It gave:\n{:.600}\n--- stderr ---\n{}",
lines,
notice
);
assert!(
notice.contains("removed"),
"the follower must say that qex removed output. It said: {notice}"
);
}
#[test]
fn a_follower_that_starts_after_the_limit_still_learns_what_went() {
let h = Harness::new(
"logcap-late",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[logs]\nmax_bytes = \"64KB\"\n",
);
let id = h.submit(&["submit", "--", "sh", "-c", "seq 1 500000; sleep 5"]);
let tail = h.job_dir(&id).join("stdout.log.tail");
h.until(
"the output passes the limit",
Duration::from_secs(60),
|| tail.exists(),
);
let out = h.qex(&["logs", &id, "--stdout", "--follow"]);
assert_eq!(out.status.code(), Some(0));
let lines = String::from_utf8_lossy(&out.stdout).into_owned();
let notice = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
lines.contains("\n500000\n"),
"the follower lost the end of the output: {:.400}",
lines
);
assert!(
notice.contains("from the middle of this stream"),
"the follower must give the count of the output that went, and it said: {notice}"
);
}
#[test]
fn a_job_that_stops_runs_the_stop_hook_one_time_with_its_result() {
let h = Harness::with_default_config("hookone");
let mark = h.root.join("hook.txt");
h.write_config(&format!(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[hooks]\non_stop = [\"sh\", \"-c\", \
\"echo \\\"$QEX_JOB_ID $QEX_STATE $QEX_EXIT_CODE $QEX_JOB_NAME\\\" >> {}\"]\n",
mark.display()
));
let id = h.submit(&["submit", "--name", "report", "--", "sh", "-c", "exit 7"]);
let out = h.qex(&["wait", &id]);
assert_eq!(out.status.code(), Some(7));
assert_eq!(h.state_of(&id), "failed");
h.until(
"the stop hook wrote its line",
Duration::from_secs(30),
|| !h.hook_lines().is_empty(),
);
let lines = h.hook_lines();
assert_eq!(lines.len(), 1, "the hook must run one time: {lines:?}");
assert_eq!(
lines[0],
format!("{id} failed 7 report"),
"the hook must receive the id, the state and the exit code"
);
assert!(h.job_dir(&id).join("hook.ran").exists());
assert_eq!(
h.hook_origin(&id),
"supervisor",
"the supervisor of a job that ran must be the process that notifies"
);
std::thread::sleep(Duration::from_secs(2));
assert_eq!(h.hook_lines().len(), 1, "the hook ran more than one time");
}
#[test]
fn a_stop_hook_that_hangs_holds_neither_the_job_nor_the_queue() {
let h = Harness::with_default_config("hookhang");
let mark = h.root.join("hook.txt");
h.write_config(&format!(
"[budget]\ncpu = \"1\"\nmem = \"512MB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[hooks]\ntimeout = \"2s\"\n\
on_stop = [\"sh\", \"-c\", \"echo hanging >> {}; sleep 300\"]\n",
mark.display()
));
let first = h.submit(&["submit", "--cpu", "1", "--mem", "128MB", "--", "true"]);
let out = h.qex(&["wait", &first, "--timeout", "30s"]);
assert_eq!(
out.status.code(),
Some(0),
"a hook that hangs must not hold the job in a state that is not final"
);
assert_eq!(h.state_of(&first), "completed");
h.until("the stop hook started", Duration::from_secs(30), || {
!h.hook_lines().is_empty()
});
let info = h.ok(&["info", "--json"]);
assert!(
info.contains("\"pid\""),
"the coordinator must answer: {info}"
);
let second = h.submit(&["submit", "--cpu", "1", "--mem", "128MB", "--", "true"]);
let out = h.qex(&["wait", &second, "--timeout", "30s"]);
assert_eq!(
out.status.code(),
Some(0),
"a hook that hangs must not delay the next job"
);
assert_eq!(h.state_of(&second), "completed");
}
#[test]
fn the_configured_states_select_the_jobs_that_run_the_stop_hook() {
let h = Harness::with_default_config("hookstates");
let mark = h.root.join("hook.txt");
h.write_config(&format!(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[hooks]\non_stop_states = [\"skipped\"]\n\
on_stop = [\"sh\", \"-c\", \"echo \\\"$QEX_STATE $QEX_JOB_NAME\\\" >> {}\"]\n",
mark.display()
));
let build = h.submit(&["submit", "--name", "build", "--", "false"]);
let test = h.submit(&["submit", "--name", "test", "--needs", &build, "--", "true"]);
h.qex(&["wait", &build]);
h.until("the second job is skipped", Duration::from_secs(30), || {
h.state_of(&test) == "skipped"
});
h.until(
"the stop hook wrote its line",
Duration::from_secs(30),
|| !h.hook_lines().is_empty(),
);
std::thread::sleep(Duration::from_secs(1));
let lines = h.hook_lines();
assert_eq!(
lines,
vec!["skipped test".to_string()],
"the filter must select the state `skipped` only"
);
assert_eq!(
h.hook_origin(&test),
"coordinator",
"a job that never ran has no supervisor, so the coordinator notifies"
);
}
#[test]
fn a_job_whose_supervisor_stopped_still_runs_the_stop_hook_one_time() {
let h = Harness::with_default_config("hookdead");
let mark = h.root.join("hook.txt");
h.write_config(&format!(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[hooks]\non_stop = [\"sh\", \"-c\", \"echo \\\"$QEX_STATE\\\" >> {}\"]\n",
mark.display()
));
let id = h.submit(&["submit", "--name", "long", "--", "sleep", "60"]);
h.until("the job operates", Duration::from_secs(45), || {
h.status_json(&id)["supervisor_pid"].as_i64().is_some() && h.state_of(&id) == "running"
});
let supervisor = h.status_json(&id)["supervisor_pid"].as_i64().unwrap() as i32;
unsafe {
libc::kill(supervisor, libc::SIGKILL);
}
h.until("the job is failed", Duration::from_secs(45), || {
h.state_of(&id) == "failed"
});
h.until(
"the stop hook wrote its line",
Duration::from_secs(30),
|| !h.hook_lines().is_empty(),
);
std::thread::sleep(Duration::from_secs(2));
assert_eq!(
h.hook_lines(),
vec!["failed".to_string()],
"the hook must run one time for a job that lost its supervisor"
);
assert_eq!(
h.hook_origin(&id),
"coordinator",
"with no supervisor, the coordinator must be the process that notifies"
);
let text = h.ok(&["logs", &id, "--hook"]);
assert!(
text.contains("qex: the stop hook"),
"`qex logs --hook` must give the verdict of qex: {text}"
);
}
#[test]
fn a_stop_hook_that_the_user_deletes_does_not_run_again() {
let h = Harness::with_default_config("hookstale");
let mark = h.root.join("hook.txt");
let base = "[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n";
h.write_config(&format!(
"{base}[hooks]\non_stop = [\"sh\", \"-c\", \"echo \\\"$QEX_JOB_NAME\\\" >> {}\"]\n",
mark.display()
));
let first = h.submit(&["submit", "--name", "first", "--", "true"]);
h.ok(&["wait", &first]);
h.until(
"the stop hook wrote its line",
Duration::from_secs(30),
|| !h.hook_lines().is_empty(),
);
assert_eq!(h.hook_lines(), vec!["first".to_string()]);
h.write_config(base);
let second = h.submit(&["submit", "--name", "second", "--", "true"]);
h.ok(&["wait", &second]);
std::thread::sleep(Duration::from_secs(2));
assert_eq!(
h.hook_lines(),
vec!["first".to_string()],
"a hook that the user deleted must not run"
);
assert!(
!h.job_dir(&second).join("hook.ran").exists(),
"qex must not record a run of a hook that does not exist"
);
h.write_config(&format!(
"{base}[hooks]\non_stop_states = [\"skipped\"]\n\
on_stop = [\"sh\", \"-c\", \"echo \\\"$QEX_STATE\\\" >> {}\"]\n",
mark.display()
));
let build = h.submit(&["submit", "--name", "build", "--", "false"]);
let dependent = h.submit(&["submit", "--name", "dep", "--needs", &build, "--", "true"]);
h.qex(&["wait", &build]);
h.until(
"the dependent job is skipped",
Duration::from_secs(30),
|| h.state_of(&dependent) == "skipped",
);
h.until(
"the new hook wrote its line",
Duration::from_secs(30),
|| h.hook_lines().len() > 1,
);
assert_eq!(
h.hook_lines(),
vec!["first".to_string(), "skipped".to_string()],
"a hook that the user adds must run on the next job that stops"
);
}
#[test]
fn a_job_notifies_from_its_supervisor_when_no_coordinator_operates() {
let h = Harness::with_default_config("hooknocoord");
let mark = h.root.join("hook.txt");
h.write_config(&format!(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[hooks]\non_stop = [\"sh\", \"-c\", \
\"echo \\\"$QEX_STATE $QEX_JOB_NAME\\\" >> {}\"]\n",
mark.display()
));
let id = h.submit(&["submit", "--name", "alone", "--", "sleep", "5"]);
h.until("the job operates", Duration::from_secs(45), || {
h.state_of(&id) == "running" && h.status_json(&id)["supervisor_pid"].as_i64().is_some()
});
let coordinator = h.coordinator_pid();
unsafe {
libc::kill(coordinator, libc::SIGKILL);
}
let deadline = Instant::now() + Duration::from_secs(30);
while unsafe { libc::kill(coordinator, 0) } == 0 {
assert!(
Instant::now() < deadline,
"the coordinator {coordinator} did not stop"
);
std::thread::sleep(Duration::from_millis(50));
}
h.until(
"the stop hook wrote its line",
Duration::from_secs(60),
|| !h.hook_lines().is_empty(),
);
std::thread::sleep(Duration::from_secs(1));
assert_eq!(
h.hook_lines(),
vec!["completed alone".to_string()],
"a job must give one message when no coordinator operates"
);
assert_eq!(
h.hook_origin(&id),
"supervisor",
"with no coordinator, only the supervisor can have notified"
);
}
#[test]
fn a_job_that_gives_up_waiting_runs_the_stop_hook() {
let h = Harness::new(
"hookexpire",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\noversized = \"queue\"\n",
);
let mark = h.root.join("hook.txt");
h.write_config(&format!(
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\noversized = \"queue\"\n\
[hooks]\non_stop = [\"sh\", \"-c\", \
\"echo \\\"$QEX_STATE $QEX_JOB_NAME\\\" >> {}\"]\n",
mark.display()
));
let id = h.submit(&[
"submit",
"--name",
"waiter",
"--cpu",
"64",
"--max-queue-time",
"3s",
"--",
"echo",
"never",
]);
h.until("the job gives up", Duration::from_secs(45), || {
h.state_of(&id) == "expired"
});
h.until(
"the stop hook wrote its line",
Duration::from_secs(30),
|| !h.hook_lines().is_empty(),
);
std::thread::sleep(Duration::from_secs(1));
assert_eq!(
h.hook_lines(),
vec!["expired waiter".to_string()],
"a job that gave up waiting must give one message"
);
assert_eq!(
h.hook_origin(&id),
"coordinator",
"such a job never had a supervisor, so the coordinator notifies"
);
}
#[test]
fn a_job_whose_command_does_not_exist_still_runs_the_stop_hook() {
let h = Harness::with_default_config("hooknocmd");
let mark = h.root.join("hook.txt");
h.write_config(&format!(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[hooks]\non_stop = [\"sh\", \"-c\", \
\"echo \\\"$QEX_STATE $QEX_JOB_NAME\\\" >> {}\"]\n",
mark.display()
));
let id = h.submit(&["submit", "--name", "typo", "--", "qex-no-such-program"]);
h.until("the job failed", Duration::from_secs(45), || {
h.state_of(&id) == "failed"
});
h.until(
"the stop hook wrote its line",
Duration::from_secs(30),
|| !h.hook_lines().is_empty(),
);
std::thread::sleep(Duration::from_secs(1));
assert_eq!(
h.hook_lines(),
vec!["failed typo".to_string()],
"a job whose command does not exist must give one message"
);
assert_eq!(
h.hook_origin(&id),
"supervisor",
"the supervisor must notify for a command that does not exist"
);
}
#[test]
fn a_cancelled_job_runs_the_stop_hook_when_the_config_file_asks_for_it() {
let h = Harness::with_default_config("hookcancel");
let mark = h.root.join("hook.txt");
h.write_config(&format!(
"[budget]\ncpu = \"1\"\nmem = \"512MB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[hooks]\non_stop_states = [\"cancelled\"]\n\
on_stop = [\"sh\", \"-c\", \
\"echo \\\"$QEX_STATE $QEX_JOB_NAME\\\" >> {}\"]\n",
mark.display()
));
let occupier = h.submit(&[
"submit", "--cpu", "1", "--mem", "128MB", "--", "sleep", "300",
]);
h.until("the first job operates", Duration::from_secs(45), || {
h.has_started(&occupier)
});
let waiting = h.submit(&[
"submit", "--name", "later", "--cpu", "1", "--mem", "128MB", "--", "true",
]);
assert_eq!(
h.state_of(&waiting),
"queued",
"the budget of one core must hold this job in the queue"
);
h.qex(&["cancel", &waiting]);
h.until("the job is cancelled", Duration::from_secs(30), || {
h.state_of(&waiting) == "cancelled"
});
h.until(
"the stop hook wrote its line",
Duration::from_secs(30),
|| !h.hook_lines().is_empty(),
);
std::thread::sleep(Duration::from_secs(1));
assert_eq!(
h.hook_lines(),
vec!["cancelled later".to_string()],
"a cancelled job must give the message that the config file asks for"
);
assert_eq!(
h.hook_origin(&waiting),
"coordinator",
"a job that never ran has no supervisor, so the coordinator notifies"
);
h.qex(&["kill", &occupier, "--grace", "1s"]);
}
#[test]
fn qex_logs_hook_says_when_there_was_no_stop_hook() {
let h = Harness::with_default_config("hooknone");
let id = h.submit(&["submit", "--", "true"]);
h.ok(&["wait", &id]);
let out = h.qex(&["logs", &id, "--hook"]);
assert_eq!(
out.status.code(),
Some(0),
"this is not a fault of the user"
);
let notice = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
notice.contains("ran no stop hook"),
"the reader must learn that there was no hook: {notice}"
);
assert!(
String::from_utf8_lossy(&out.stdout).is_empty(),
"the standard output must hold the log only"
);
}
#[test]
fn each_line_gives_one_job_for_each_line_in_one_group() {
let h = Harness::with_default_config("eachline");
let input = h.root.join("inputs.txt");
std::fs::write(&input, "alpha\nbeta\ngamma\n").unwrap();
let ids = h.root.join("ids.env");
let group = h.ok(&[
"submit",
"--each-line",
input.to_str().unwrap(),
"--id-file",
ids.to_str().unwrap(),
"--",
"echo",
"value={}",
]);
assert_eq!(
group.lines().count(),
1,
"stdout must hold the group id only: {group}"
);
assert!(
group.parse::<uuid::Uuid>().is_ok(),
"stdout must hold a group id, and it holds: {group}"
);
let text = h.ok(&["list", "--group", &group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_eq!(jobs.len(), 3, "three lines must give three jobs: {text}");
assert_eq!(h.list_json().len(), 3, "no other job may exist");
for job in &jobs {
assert_eq!(
job["group_name"].as_str(),
Some("inputs"),
"each job must carry the name of its group: {job}"
);
}
let text = h.ok(&["list", "--group", "inputs", "--json"]);
let by_name: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_eq!(
by_name.len(),
3,
"`qex list --group inputs` must give the 3 jobs: {text}"
);
let text = h.ok(&["list", "--group", &group[..8], "--json"]);
let by_prefix: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_eq!(by_prefix.len(), 3, "the start of the group id must name it");
let mut seen: Vec<String> = Vec::new();
for job in &jobs {
let id = job["id"].as_str().unwrap();
h.ok(&["wait", id, "--timeout", "60s"]);
assert_eq!(h.state_of(id), "completed");
seen.push(h.ok(&["logs", id, "--stdout"]).trim().to_string());
}
seen.sort();
assert_eq!(seen, vec!["value=alpha", "value=beta", "value=gamma"]);
let names: Vec<String> = jobs
.iter()
.map(|j| j["name"].as_str().unwrap().to_string())
.collect();
assert!(
names.contains(&"echo-1-alpha".to_string()),
"got the names: {names:?}"
);
let text = std::fs::read_to_string(&ids).unwrap();
assert!(text.contains(&format!("group={group}")), "got: {text}");
assert_eq!(text.lines().count(), 4, "group and three jobs: {text}");
}
#[test]
fn a_line_with_a_space_a_quotation_mark_and_a_semicolon_is_one_argument() {
let h = Harness::with_default_config("eachsafe");
let mark = h.root.join("SHELL-RAN");
let line = format!(
"a b\"; touch {}; echo $HOME `id` $(id) 'x'",
mark.to_str().unwrap()
);
let input = h.root.join("inputs.txt");
std::fs::write(&input, format!("{line}\n")).unwrap();
let group = h.ok(&[
"submit",
"--each-line",
input.to_str().unwrap(),
"--",
"printf",
"[%s]",
"{}",
]);
let text = h.ok(&["list", "--group", &group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_eq!(jobs.len(), 1);
let id = jobs[0]["id"].as_str().unwrap();
h.ok(&["wait", id, "--timeout", "60s"]);
let out = h.ok(&["logs", id, "--stdout"]);
assert_eq!(
out.trim(),
format!("[{line}]"),
"the line must become exactly one argument"
);
let status = h.status_json(id);
let command: Vec<String> = status["command"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert_eq!(command, vec!["printf", "[%s]", &line]);
assert!(
!mark.exists(),
"a shell read the line and made the file {}",
mark.display()
);
}
#[test]
fn a_line_never_puts_a_control_character_into_a_job_name() {
let h = Harness::with_default_config("eachname");
let line = "\u{1b}[31mBOOM\u{1b}[0m \u{1b}[2J \u{1b}]0;title\u{7}";
let input = h.root.join("inputs.txt");
std::fs::write(&input, format!("{line}\nsecond\n")).unwrap();
let out = h.qex(&[
"submit",
"--each-line",
input.to_str().unwrap(),
"--",
"echo",
"{}",
]);
assert!(out.status.success());
let group = String::from_utf8_lossy(&out.stdout).trim().to_string();
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
!err.contains('\u{1b}') && !err.contains('\u{7}'),
"the submission output holds a control character: {err:?}"
);
let text = h.ok(&["list", "--group", &group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_eq!(jobs.len(), 2);
for job in &jobs {
let name = job["name"].as_str().unwrap();
assert!(
name.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-'),
"a job name holds a character that must not reach a terminal: {name:?}"
);
}
let listing = h.ok(&["list"]);
assert!(
!listing.contains('\u{1b}') && !listing.contains('\u{7}'),
"`qex list` holds a control character: {listing:?}"
);
let id = jobs[0]["id"].as_str().unwrap();
let status = h.status_json(id);
assert_eq!(
status["command"][1].as_str().unwrap(),
line,
"the job must receive the line as the file holds it"
);
}
#[test]
fn a_placeholder_in_the_program_position_gives_a_clean_name() {
let h = Harness::with_default_config("eachprog");
let input = h.root.join("inputs.txt");
std::fs::write(&input, "\u{1b}[31mBOOM\u{1b}[0m x\nsecond\n").unwrap();
let out = h.qex(&["submit", "--each-line", input.to_str().unwrap(), "--", "{}"]);
assert!(out.status.success());
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
!err.contains('\u{1b}'),
"the base of the name holds an escape: {err:?}"
);
let names: Vec<String> = h
.list_json()
.iter()
.map(|j| j["name"].as_str().unwrap().to_string())
.collect();
assert_eq!(names.len(), 2);
for name in &names {
assert!(
name.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-'),
"the base of the name holds a character that must not reach a terminal: {name:?}"
);
}
assert!(
!h.ok(&["list"]).contains('\u{1b}'),
"`qex list` holds an escape"
);
}
#[test]
fn an_input_with_a_bad_line_submits_no_job_at_all() {
let h = Harness::with_default_config("eachbad");
let input = h.root.join("inputs.txt");
let mut bytes: Vec<u8> = Vec::new();
for i in 1..=90 {
bytes.extend_from_slice(format!("line{i}\n").as_bytes());
}
bytes.extend_from_slice(b"bad \xff line\n");
std::fs::write(&input, &bytes).unwrap();
let out = h.qex(&[
"submit",
"--each-line",
input.to_str().unwrap(),
"--",
"echo",
"{}",
]);
assert!(!out.status.success(), "a bad line must give an error");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("line 91"), "the error must say where: {err}");
assert!(
h.list_json().is_empty(),
"the 90 correct lines must not become jobs"
);
let out = h.qex(&[
"submit",
"--each-line",
input.to_str().unwrap(),
"--",
"echo",
"the same",
]);
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("holds no `{}`"), "got: {err}");
assert!(h.list_json().is_empty());
}
#[test]
fn an_empty_line_and_a_comment_line_give_no_job_and_qex_reports_them() {
let h = Harness::with_default_config("eachskip");
let input = h.root.join("inputs.txt");
std::fs::write(&input, "# a note\n\nalpha\r\n \n#\nbeta").unwrap();
let out = h.qex(&[
"submit",
"--each-line",
input.to_str().unwrap(),
"--",
"echo",
"{}",
]);
assert!(out.status.success());
let group = String::from_utf8_lossy(&out.stdout).trim().to_string();
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("2 empty lines") && err.contains("2 comment lines"),
"qex must say what it passed over: {err}"
);
let text = h.ok(&["list", "--group", &group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_eq!(jobs.len(), 2, "two lines give a job: {text}");
let mut seen: Vec<String> = Vec::new();
for job in &jobs {
let id = job["id"].as_str().unwrap();
h.ok(&["wait", id, "--timeout", "60s"]);
seen.push(h.ok(&["logs", id, "--stdout"]).trim().to_string());
}
seen.sort();
assert_eq!(seen, vec!["alpha", "beta"]);
}
#[test]
fn each_line_reads_the_lines_from_standard_input() {
let h = Harness::with_default_config("eachstdin");
let out = h.qex_stdin(
&["submit", "--each-line", "-", "--", "echo", "{}"],
"one\ntwo\n",
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let group = String::from_utf8_lossy(&out.stdout).trim().to_string();
assert!(group.parse::<uuid::Uuid>().is_ok(), "got: {group}");
let text = h.ok(&["list", "--group", &group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_eq!(jobs.len(), 2);
}
#[test]
fn every_option_of_a_fan_out_reaches_every_job() {
let h = Harness::with_default_config("eachline-options");
let gate = h.submit(&["submit", "--name", "gate", "--", "sleep", "300"]);
let input = h.root.join("inputs.txt");
std::fs::write(&input, "alpha\nbeta\n").unwrap();
let work = h.root.join("work");
std::fs::create_dir_all(&work).unwrap();
let out = h.qex(&[
"submit",
"--each-line",
input.to_str().unwrap(),
"--needs",
"gate",
"--max-queue-time",
"20m",
"--nice",
"7",
"--cwd",
work.to_str().unwrap(),
"--timeout",
"99s",
"--priority",
"5",
"--env",
"FOO=bar",
"--lock",
"db",
"--retries",
"2",
"--cpu",
"1",
"--mem",
"256MB",
"--no-limit-env-hints",
"--tag",
"batch",
"--",
"echo",
"{}",
]);
assert!(
out.status.success(),
"the fan-out must operate: {}",
String::from_utf8_lossy(&out.stderr)
);
let group = String::from_utf8_lossy(&out.stdout).trim().to_string();
let jobs: Vec<serde_json::Value> = h
.list_json()
.into_iter()
.filter(|j| j["group"].as_str() == Some(group.as_str()))
.collect();
assert_eq!(jobs.len(), 2, "the group must hold the 2 jobs of the file");
let template = vec!["echo".to_string(), "{}".to_string()];
for job in &jobs {
let id = job["id"].as_str().unwrap();
let text = std::fs::read_to_string(h.job_dir(id).join("spec.json")).unwrap();
let spec: serde_json::Value = serde_json::from_str(&text).unwrap();
let needs: Vec<String> = spec["needs"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert_eq!(
needs,
vec![gate.clone()],
"the job {id} must wait for the gate"
);
assert_eq!(spec["max_queue_time"], serde_json::json!(1200), "job {id}");
assert_eq!(spec["nice"], serde_json::json!(7), "job {id}");
assert_eq!(
spec["tags"],
serde_json::json!(["batch"]),
"the tag must reach the job {id}"
);
assert_eq!(
spec["cwd"].as_str(),
work.canonicalize().unwrap().to_str(),
"the job {id} must run in the directory that the user gave"
);
assert_eq!(spec["timeout"], serde_json::json!(99), "job {id}");
assert_eq!(spec["priority"], serde_json::json!(5), "job {id}");
assert_eq!(spec["env"]["FOO"], serde_json::json!("bar"), "job {id}");
assert_eq!(spec["locks"], serde_json::json!(["db"]), "job {id}");
assert_eq!(spec["retries"], serde_json::json!(2), "job {id}");
assert_eq!(
spec["learn_key"],
serde_json::json!(template),
"the job {id} must measure against the template"
);
assert_ne!(
spec["learn_key"], spec["command"],
"the command of the line must not become the key of the record"
);
let env = spec["env"].as_object().unwrap();
assert!(
!env.contains_key("QEX_CPU") && !env.contains_key("QEX_MEM"),
"`--no-limit-env-hints` must reach the job {id}: {env:?}"
);
}
let out = h.qex(&[
"submit",
"--each-line",
input.to_str().unwrap(),
"--needs",
"gate",
"--cpu",
"1",
"--mem",
"256MB",
"--",
"echo",
"{}",
]);
assert!(out.status.success());
let other = String::from_utf8_lossy(&out.stdout).trim().to_string();
let with_hints: Vec<serde_json::Value> = h
.list_json()
.into_iter()
.filter(|j| j["group"].as_str() == Some(other.as_str()))
.collect();
assert_eq!(with_hints.len(), 2);
for job in &with_hints {
let id = job["id"].as_str().unwrap();
let text = std::fs::read_to_string(h.job_dir(id).join("spec.json")).unwrap();
let spec: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
spec["env"]["QEX_CPU"],
serde_json::json!("1"),
"with no `--no-limit-env-hints`, the job {id} must hear its claim"
);
}
let mut commands: Vec<String> = jobs
.iter()
.map(|j| {
let id = j["id"].as_str().unwrap();
let text = std::fs::read_to_string(h.job_dir(id).join("spec.json")).unwrap();
let spec: serde_json::Value = serde_json::from_str(&text).unwrap();
spec["command"][1].as_str().unwrap().to_string()
})
.collect();
commands.sort();
assert_eq!(commands, vec!["alpha".to_string(), "beta".to_string()]);
h.qex(&["kill", &gate]);
}
#[test]
fn a_fan_out_refuses_the_options_that_hold_a_meaning_for_one_job() {
let h = Harness::with_default_config("eachline-refuse");
let input = h.root.join("inputs.txt");
std::fs::write(&input, "alpha\nbeta\ngamma\n").unwrap();
let path = input.to_str().unwrap().to_string();
for (option, value, must_say) in [
("--dedupe-key", Some("nightly"), "A key holds one job"),
("--dedupe-window", Some("1h"), "A key holds one job"),
("--json", None, "--id-file"),
] {
let mut args: Vec<&str> = vec!["submit", "--each-line", &path];
args.push(option);
if let Some(v) = value {
args.push(v);
}
args.extend_from_slice(&["--", "echo", "{}"]);
let out = h.qex(&args);
assert!(
!out.status.success(),
"`{option}` must not submit a fan-out"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains(option),
"the error must name the option `{option}`: {err}"
);
assert!(
err.contains(must_say),
"the error must say why, and what to do: {err}"
);
assert!(
!err.contains(" "),
"the message holds the indentation of the source in place of a line break: {err:?}"
);
assert!(
h.list_json().is_empty(),
"`{option}` refused the fan-out and still left a job in the queue"
);
}
let out = h.qex(&["submit", "--each-line", &path, "--", "echo", "{}"]);
assert!(
out.status.success(),
"the fan-out without those options must operate: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(h.list_json().len(), 3, "3 lines must give 3 jobs");
}
#[test]
fn a_fan_out_above_the_limit_is_refused_with_no_prompt() {
let h = Harness::with_default_config("eachlimit");
let input = h.root.join("inputs.txt");
let text: String = (1..=20).map(|i| format!("line{i}\n")).collect();
std::fs::write(&input, text).unwrap();
let out = h.qex(&[
"submit",
"--each-line",
input.to_str().unwrap(),
"--max-jobs",
"5",
"--",
"echo",
"{}",
]);
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("--max-jobs 20"), "got: {err}");
assert!(h.list_json().is_empty(), "no job may start");
}
fn _unused(_: &Path) {}
fn wait_run(mut child: std::process::Child, what: &str) -> Output {
let deadline = Instant::now() + Duration::from_secs(60);
loop {
match child.try_wait().unwrap() {
Some(_) => return child.wait_with_output().unwrap(),
None => {
if Instant::now() >= deadline {
child.kill().ok();
panic!("`qex run` did not stop in 60 seconds: {what}");
}
std::thread::sleep(Duration::from_millis(200));
}
}
}
}
#[test]
fn qex_run_gives_125_when_a_different_command_kills_the_job() {
let h = Harness::with_default_config("runkill");
let (child, id) = h.run_bg(&["--", "sleep", "60"]);
h.until(
"the job of `qex run` starts",
Duration::from_secs(30),
|| h.has_started(&id),
);
let kill = h.qex(&["kill", &id, "--grace", "1s"]);
assert!(kill.status.success(), "`qex kill` failed");
let out = wait_run(child, "a different command killed the job");
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(
out.status.code(),
Some(125),
"`qex run` must give 125 when something stopped the job: {err}"
);
assert!(
err.contains("did not send it"),
"`qex run` must say that it did not stop the job: {err}"
);
}
#[test]
fn qex_run_gives_125_when_a_different_command_cancels_the_queued_job() {
let h = Harness::with_default_config("runcancel");
let blocker = h.submit(&["submit", "--", "sleep", "60"]);
let (child, id) = h.run_bg(&["--needs", &blocker, "--", "echo", "never"]);
h.until(
"the job of `qex run` waits",
Duration::from_secs(30),
|| h.state_of(&id) == "queued",
);
let cancel = h.qex(&["cancel", &id]);
assert!(cancel.status.success(), "`qex cancel` failed");
let out = wait_run(child, "a different command cancelled the job");
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(
out.status.code(),
Some(125),
"`qex run` must give 125 for a job that left the queue: {err}"
);
assert!(
err.contains("removed the job"),
"`qex run` must say that the job left the queue: {err}"
);
h.qex(&["kill", &blocker, "--grace", "1s"]);
}
#[test]
fn qex_run_gives_the_exit_code_of_a_job_that_ran() {
let h = Harness::with_default_config("runcode");
for code in [0, 1, 7] {
let command = format!("exit {code}");
let (child, id) = h.run_bg(&["--", "sh", "-c", &command]);
let out = wait_run(child, "the job gives its own exit code");
assert_eq!(
out.status.code(),
Some(code),
"`qex run` must give the exit code {code} of the job: {}",
String::from_utf8_lossy(&out.stderr)
);
let wait = h.qex(&["wait", &id]);
assert_eq!(
out.status.code(),
wait.status.code(),
"`qex run` and `qex wait` gave two codes for one job"
);
}
}
#[test]
fn qex_run_and_qex_wait_give_the_same_code_for_the_same_job() {
let h = Harness::with_default_config("runagree");
let (child, killed) = h.run_bg(&["--", "sleep", "60"]);
h.until(
"the job of `qex run` starts",
Duration::from_secs(30),
|| h.has_started(&killed),
);
h.qex(&["kill", &killed, "--grace", "1s"]);
let stopped = wait_run(child, "the job that a kill stopped");
let (child, failed) = h.run_bg(&["--", "sh", "-c", "exit 1"]);
let ran = wait_run(child, "the job that failed");
let (child, limited) = h.run_bg(&["--timeout", "1s", "--", "sleep", "60"]);
let timed_out = wait_run(child, "the job that reached its time limit");
assert_eq!(
stopped.status.code(),
h.qex(&["wait", &killed]).status.code(),
"`qex run` and `qex wait` gave two codes for a job that something stopped"
);
assert_eq!(
timed_out.status.code(),
h.qex(&["wait", &limited]).status.code(),
"`qex run` and `qex wait` gave two codes for a job that reached its time limit"
);
assert_eq!(
ran.status.code(),
h.qex(&["wait", &failed]).status.code(),
"`qex run` and `qex wait` gave two codes for a job that failed"
);
assert_eq!(ran.status.code(), Some(1), "a job that failed gives 1");
assert_ne!(
stopped.status.code(),
Some(1),
"a job that something stopped must not give the code of a job that failed"
);
assert_ne!(
timed_out.status.code(),
Some(1),
"a job that reached its time limit must not give the code of a job that failed"
);
}
#[test]
fn qex_run_says_when_this_command_stopped_the_job() {
let h = Harness::with_default_config("runctrlc");
let (child, id) = h.run_bg(&["--", "sleep", "60"]);
h.until(
"the job of `qex run` starts",
Duration::from_secs(30),
|| h.has_started(&id),
);
let pid = child.id() as i32;
assert_eq!(unsafe { libc::kill(pid, libc::SIGINT) }, 0);
let out = wait_run(child, "Ctrl-C stopped the job");
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(
out.status.code(),
Some(125),
"a job that Ctrl-C stopped gives 125: {err}"
);
assert!(
err.contains("this command stopped the job"),
"`qex run` must say that IT stopped the job: {err}"
);
assert!(
!err.contains("did not send it"),
"`qex run` must not blame a different command for its own kill: {err}"
);
}
#[test]
fn ctrl_c_removes_the_job_of_qex_run_from_the_queue() {
let h = Harness::with_default_config("runctrlcq");
let blocker = h.submit(&["submit", "--", "sleep", "60"]);
let (child, id) = h.run_bg(&["--needs", &blocker, "--", "echo", "never"]);
h.until(
"the job of `qex run` waits",
Duration::from_secs(30),
|| h.state_of(&id) == "queued",
);
let pid = child.id() as i32;
assert_eq!(unsafe { libc::kill(pid, libc::SIGINT) }, 0);
let out = wait_run(child, "Ctrl-C removed the job from the queue");
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(
out.status.code(),
Some(125),
"a job that left the queue gives 125: {err}"
);
assert_eq!(
h.state_of(&id),
"cancelled",
"Ctrl-C must take the job out of the queue: {err}"
);
assert!(
err.contains("this command removed the job"),
"`qex run` must say that IT removed the job from the queue: {err}"
);
h.qex(&["kill", &blocker, "--grace", "1s"]);
}
#[test]
fn a_change_to_the_configuration_reaches_the_coordinator() {
let h = Harness::new(
"reload",
"[budget]\ncpu = \"8\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
h.ok(&["list"]);
let info = h.ok(&["info"]);
assert!(info.contains("of 8 in use"), "the budget must be 8: {info}");
std::fs::write(
h.root.join("cfg/qex.toml"),
"[budget]\ncpu = \"2\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
)
.unwrap();
h.until(
"the coordinator reads the file again",
Duration::from_secs(45),
|| h.ok(&["info"]).contains("of 2 in use"),
);
std::fs::write(h.root.join("cfg/qex.toml"), "[budget]\ncpu = \"two\"\n").unwrap();
h.until("qex reports the fault", Duration::from_secs(45), || {
let out = h.qex(&["info"]);
String::from_utf8_lossy(&out.stderr).contains("cannot read it")
});
let info = h.ok(&["info"]);
assert!(
info.contains("of 2 in use"),
"the coordinator must keep the values that it had: {info}"
);
let json: serde_json::Value =
serde_json::from_slice(&h.qex(&["info", "--json"]).stdout).unwrap();
assert!(
json["config_error"].is_string(),
"`qex info --json` must carry the fault: {json}"
);
std::fs::write(h.root.join("cfg/qex.toml"), "[budget\n").unwrap();
h.until(
"qex reports the fault of the form",
Duration::from_secs(45),
|| String::from_utf8_lossy(&h.qex(&["info"]).stderr).contains("TOML parse error"),
);
let err = String::from_utf8_lossy(&h.qex(&["info"]).stderr).to_string();
for line in err.lines() {
assert!(
line.starts_with("qex:"),
"every line of the warning must carry the prefix: {err}"
);
}
std::fs::write(
h.root.join("cfg/qex.toml"),
"[budget]\ncpu = \"5\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
)
.unwrap();
h.until(
"the coordinator takes the corrected file",
Duration::from_secs(45),
|| h.ok(&["info"]).contains("of 5 in use"),
);
let err = String::from_utf8_lossy(&h.qex(&["info"]).stderr).to_string();
assert!(
!err.contains("cannot read it"),
"the warning must go away when the file is correct: {err}"
);
}
#[test]
fn an_empty_or_missing_configuration_does_not_become_the_default_values() {
let file = "[budget]\ncpu = \"2\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n";
let h = Harness::new("reload-gone", file);
let path = h.root.join("cfg/qex.toml");
h.ok(&["list"]);
let info = h.ok(&["info"]);
assert!(info.contains("of 2 in use"), "the budget must be 2: {info}");
std::fs::write(&path, "").unwrap();
h.until(
"qex reports the empty file",
Duration::from_secs(45),
|| String::from_utf8_lossy(&h.qex(&["info"]).stderr).contains("the file is empty"),
);
let info = h.ok(&["info"]);
assert!(
info.contains("of 2 in use"),
"an empty file must not change the budget: {info}"
);
std::fs::remove_file(&path).unwrap();
std::thread::sleep(Duration::from_secs(2));
let info = h.ok(&["info"]);
assert!(
info.contains("of 2 in use"),
"a file that is gone must not change the budget: {info}"
);
let temp = h.root.join("cfg/.qex.toml.new");
std::fs::write(&temp, file.replace("cpu = \"2\"", "cpu = \"6\"")).unwrap();
std::fs::rename(&temp, &path).unwrap();
h.until(
"the coordinator reads the file that the rename put there",
Duration::from_secs(45),
|| h.ok(&["info"]).contains("of 6 in use"),
);
}
#[test]
fn a_write_that_is_not_finished_does_not_change_the_budget() {
let file = "[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[budget]\ncpu = \"2\"\nmem = \"1GB\"\n";
let h = Harness::new("reload-partial", file);
let path = h.root.join("cfg/qex.toml");
h.ok(&["list"]);
let info: serde_json::Value =
serde_json::from_slice(&h.qex(&["info", "--json"]).stdout).unwrap();
assert_eq!(info["mem_budget"].as_u64(), Some(1024 * 1024 * 1024));
let stop = Arc::new(AtomicBool::new(false));
let writer = {
let (path, stop, file) = (path.clone(), stop.clone(), file.to_string());
std::thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
let mut f = std::fs::File::create(&path).unwrap();
for line in file.lines() {
use std::io::Write;
writeln!(f, "{line}").unwrap();
f.flush().unwrap();
std::thread::sleep(Duration::from_millis(2));
}
drop(f);
std::thread::sleep(Duration::from_millis(20));
}
})
};
let deadline = Instant::now() + Duration::from_secs(8);
let mut looks = 0;
while Instant::now() < deadline {
let info: serde_json::Value =
serde_json::from_slice(&h.qex(&["info", "--json"]).stdout).unwrap();
looks += 1;
assert_eq!(
info["mem_budget"].as_u64(),
Some(1024 * 1024 * 1024),
"a write that is not finished must never give the default budget: {info}"
);
assert_eq!(
info["cpu_budget"].as_u64(),
Some(2),
"a write that is not finished must never give the default budget: {info}"
);
let reported = info["config_error"].as_str().unwrap_or_default();
assert!(
reported.is_empty() || reported.contains("waits for it"),
"the file is correct each time it is complete: {info}"
);
}
stop.store(true, Ordering::Relaxed);
writer.join().unwrap();
assert!(
looks > 20,
"the test must look many times, and it looked {looks}"
);
}
#[test]
fn a_file_that_goes_back_and_forth_does_not_change_the_budget() {
let whole = "[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[budget]\ncpu = \"2\"\nmem = \"1GB\"\n";
let half = whole.strip_suffix("mem = \"1GB\"\n").unwrap().to_string();
let h = Harness::new("reload-alternating", whole);
let path = h.root.join("cfg/qex.toml");
h.ok(&["list"]);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&h.qex(&["info", "--json"]).stdout).unwrap()
["mem_budget"]
.as_u64(),
Some(1024 * 1024 * 1024)
);
let stop = Arc::new(AtomicBool::new(false));
let writer = {
let (path, stop, whole) = (path.clone(), stop.clone(), whole.to_string());
let temp = h.root.join("cfg/.qex.toml.new");
std::thread::spawn(move || {
let mut turn = false;
while !stop.load(Ordering::Relaxed) {
let text = if turn { &whole } else { &half };
std::fs::write(&temp, text).unwrap();
std::fs::rename(&temp, &path).unwrap();
turn = !turn;
std::thread::sleep(Duration::from_millis(300));
}
std::fs::write(&temp, &whole).unwrap();
std::fs::rename(&temp, &path).unwrap();
})
};
let deadline = Instant::now() + Duration::from_secs(12);
let mut looks = 0;
let mut fault = None;
while Instant::now() < deadline {
let out = h.qex(&["info", "--json"]);
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&out.stdout) {
looks += 1;
if info["mem_budget"].as_u64() != Some(1024 * 1024 * 1024) {
fault = Some(format!("{} looks, then {info}", looks));
break;
}
}
}
stop.store(true, Ordering::Relaxed);
writer.join().unwrap();
assert!(
fault.is_none(),
"a file that goes back and forth must not change the budget: {}",
fault.unwrap_or_default()
);
assert!(
looks > 20,
"the test must look many times, and it looked {looks}"
);
}
#[test]
fn a_write_that_is_not_finished_does_not_change_the_budget_under_load() {
let file = "[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[budget]\ncpu = \"2\"\nmem = \"1GB\"\n";
let h = Harness::new("reload-partial-load", file);
let path = h.root.join("cfg/qex.toml");
h.ok(&["list"]);
let stop = Arc::new(AtomicBool::new(false));
let load = {
let (root, stop) = (h.root.clone(), stop.clone());
std::thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["submit", "--", "true"])
.env("XDG_CONFIG_HOME", root.join("cfg"))
.env("XDG_STATE_HOME", root.join("state"))
.env("XDG_RUNTIME_DIR", root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.ok();
}
})
};
let writer = {
let (path, stop, file) = (path.clone(), stop.clone(), file.to_string());
std::thread::spawn(move || {
let half = file.find("[budget]").unwrap();
while !stop.load(Ordering::Relaxed) {
std::fs::write(&path, &file[..half]).unwrap();
std::thread::sleep(Duration::from_millis(300));
std::fs::write(&path, &file).unwrap();
std::thread::sleep(Duration::from_millis(700));
}
})
};
let deadline = Instant::now() + Duration::from_secs(12);
let mut looks = 0;
let mut fault = None;
while Instant::now() < deadline {
let out = h.qex(&["info", "--json"]);
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&out.stdout) {
looks += 1;
if info["mem_budget"].as_u64() != Some(1024 * 1024 * 1024)
|| info["cpu_budget"].as_u64() != Some(2)
{
fault = Some(info.to_string());
break;
}
}
}
stop.store(true, Ordering::Relaxed);
writer.join().unwrap();
load.join().unwrap();
assert!(
fault.is_none(),
"a write that is not finished must never change the budget, and a busy \
coordinator is where that fault lives: {}",
fault.unwrap_or_default()
);
assert!(
looks > 20,
"the test must look many times, and it looked {looks}"
);
}
#[test]
fn a_command_refuses_a_configuration_path_that_is_not_a_regular_file() {
let file = "[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n";
let h = Harness::new("client-not-a-file", file);
let path = h.root.join("cfg/qex.toml");
assert!(h.ok(&["config", "show"]).contains("2 cores"));
std::fs::remove_file(&path).unwrap();
let made = Command::new("mkfifo").arg(&path).status();
if !made.map(|s| s.success()).unwrap_or(false) {
std::fs::create_dir(&path).unwrap();
}
for args in [vec!["config", "show"], vec!["submit", "--", "true"]] {
let child = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(&args)
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let out = wait_for_child(
child,
Duration::from_secs(20),
"a path that is not a regular file",
);
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
!out.status.success(),
"`qex {}` must stop: {err}",
args.join(" ")
);
assert!(
err.contains("not a regular file"),
"`qex {}` must name the cause: {err}",
args.join(" ")
);
}
}
#[test]
fn a_configuration_path_that_is_not_a_regular_file_does_not_stop_the_coordinator() {
let file = "[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n";
let h = Harness::new("reload-not-a-file", file);
let path = h.root.join("cfg/qex.toml");
h.ok(&["list"]);
assert!(h.ok(&["info"]).contains("of 2 in use"));
std::fs::remove_file(&path).unwrap();
std::fs::create_dir(&path).unwrap();
h.until(
"qex reports the type of the path",
Duration::from_secs(45),
|| String::from_utf8_lossy(&h.qex(&["info"]).stderr).contains("not a regular file"),
);
assert!(
h.ok(&["info"]).contains("of 2 in use"),
"a path that is not a regular file must not change the budget"
);
std::fs::remove_dir(&path).unwrap();
let made = Command::new("mkfifo").arg(&path).status();
if made.map(|s| s.success()).unwrap_or(false) {
for _ in 0..6 {
let child = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["info"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let out = wait_for_child(child, Duration::from_secs(20), "a FIFO at the config path");
assert!(
String::from_utf8_lossy(&out.stdout).contains("of 2 in use"),
"the coordinator must answer and keep its budget: {}",
String::from_utf8_lossy(&out.stdout)
);
std::thread::sleep(Duration::from_millis(400));
}
std::fs::remove_file(&path).unwrap();
}
std::fs::write(&path, file.replace("cpu = \"2\"", "cpu = \"4\"")).unwrap();
h.until(
"the coordinator takes the file that replaced the FIFO",
Duration::from_secs(45),
|| h.ok(&["info"]).contains("of 4 in use"),
);
}
fn wait_for_child(mut child: std::process::Child, limit: Duration, what: &str) -> Output {
let deadline = Instant::now() + limit;
loop {
match child.try_wait().unwrap() {
Some(_) => return child.wait_with_output().unwrap(),
None => {
if Instant::now() >= deadline {
child.kill().ok();
panic!("`qex info` gave no answer in {limit:?}: {what}");
}
std::thread::sleep(Duration::from_millis(100));
}
}
}
}
#[test]
fn the_completions_hold_the_commands_of_qex() {
let h = Harness::with_default_config("completions");
for shell in ["bash", "zsh", "fish"] {
let out = h.ok(&["completions", shell]);
assert!(!out.is_empty(), "{shell} gave nothing");
for command in ["submit", "wait", "status", "logs", "kill", "watchers"] {
assert!(
out.contains(command),
"the {shell} completions must name `{command}`"
);
}
}
let out = h.qex(&["completions", "not-a-shell"]);
assert!(!out.status.success(), "an unknown shell must give an error");
let bash = h.ok(&["completions", "bash"]);
let jobs_part = &bash[bash.find("_qex_jobs()").expect("bash needs `_qex_jobs`")..];
assert!(
!jobs_part
.lines()
.any(|l| !l.trim_start().starts_with('#') && l.contains("compgen")),
"the bash completions must not expand the names again: {jobs_part}"
);
assert!(
jobs_part.contains("while IFS= read -r candidate"),
"the bash completions must read the names as lines"
);
assert!(
jobs_part.contains("printf -v candidate '%q'"),
"the bash completions must make each name safe for the command line"
);
assert!(
jobs_part.contains(r#"case "$candidate" in "~"*) candidate="\\$candidate" ;; esac"#),
"the bash completions must make a leading `~` safe as well"
);
assert!(
!jobs_part
.lines()
.any(|l| !l.trim_start().starts_with('#') && l.contains("compopt")),
"the bash completions must not treat a job name as a file name"
);
assert!(
bash.contains("complete -F _qex_with_jobs "),
"bash must give `_qex_with_jobs` to the shell, and not `_qex`: {bash}"
);
assert!(
bash.contains("BASH_VERSINFO[0]}\" -eq 4 && \"${BASH_VERSINFO[1]}\" -ge 4")
&& bash.contains("complete -F _qex_with_jobs -o bashdefault -o default qex"),
"the registration must test the version of bash before it uses `-o nosort`: {bash}"
);
assert!(
bash.contains("complete -F _qex_with_jobs -o nosort -o bashdefault -o default qex"),
"a bash that has `-o nosort` must get it: {bash}"
);
assert!(
bash.contains("_qex_with_jobs() {") && bash.contains(" _qex_jobs\n}"),
"`_qex_with_jobs` must run `_qex` and then add the jobs"
);
let guard = bash
.lines()
.find(|l| l.contains("in *\" $prev \"*)"))
.expect("bash needs the guard for an option value");
for valued in ["--signal", "--grace", "--timeout", "--tail", "-C"] {
assert!(
guard.contains(&format!(" {valued} ")),
"`{valued}` takes a value, so it must be in the guard: {guard}"
);
}
for flag in ["--json", "--show-env", "--no-logs", "--all"] {
assert!(
!guard.contains(&format!(" {flag} ")),
"`{flag}` takes no value, so the word after it is a job: {guard}"
);
}
let zsh = h.ok(&["completions", "zsh"]);
for (line, what) in [
("':id -- The job id, or the start of the id", "ids"),
("'*::ids -- The job ids to wait for", "ids"),
("'*::ids -- The job ids to stop", "active"),
("'*::ids -- The job ids to remove from the queue", "queued"),
("'*::ids -- The job ids to delete", "ids"),
] {
assert!(
zsh.contains(&format!("{line}: _qex_jobs {what}' \\")),
"the zsh job argument must offer the jobs: {line}"
);
}
assert!(
!zsh.contains(":_qex_jobs "),
"a zsh action needs a space before the name of the function"
);
assert!(
zsh.contains("]:SIGNAL:_default'"),
"the value of `--signal` must not become a job"
);
let helper = zsh.find("_qex_jobs()").expect("zsh needs `_qex_jobs`");
let hand_over = zsh.find("compdef _qex qex").expect("zsh needs `compdef`");
assert!(
zsh.starts_with("#compdef qex"),
"zsh needs `#compdef` first"
);
assert!(
helper < hand_over,
"`_qex_jobs` must exist before zsh gets the completion"
);
for shell in ["bash", "zsh", "fish", "elvish", "powershell"] {
let out = h.ok(&["completions", shell]);
for hidden in ["daemon", "supervise", "__complete"] {
for line in out.lines() {
let trimmed = line.trim_start();
let offers = trimmed.starts_with("opts=\"")
|| trimmed.starts_with(&format!("'{hidden}:"))
|| trimmed.starts_with("cand ")
|| trimmed.starts_with("[CompletionResult]::new(")
|| (trimmed.starts_with("complete -c qex")
&& trimmed.contains("__fish_use_subcommand"));
if !offers {
continue;
}
assert!(
!line
.split(|c: char| c.is_whitespace() || c == '"' || c == '\'')
.any(|word| word == hidden),
"the {shell} completions must not offer `{hidden}`: {line}"
);
}
}
}
for shell in ["bash", "zsh", "fish"] {
let out = h.ok(&["completions", shell]);
assert!(
out.contains("qex __complete"),
"the {shell} completions must ask qex for the ids"
);
}
let fish = h.ok(&["completions", "fish"]);
for (commands, what) in [
("status wait logs rerun clean", "ids"),
("kill", "active"),
("cancel", "queued"),
] {
assert!(
fish.contains(&format!(
"complete -c qex -n \"__fish_seen_subcommand_from {commands}\" \
-f -a \"(qex __complete {what})\""
)),
"fish must offer `{what}` after `{commands}`: {fish}"
);
}
}
#[test]
fn the_completion_candidates_start_no_coordinator() {
let h = Harness::with_default_config("candidates");
let empty = h.ok(&["__complete", "ids"]);
assert!(empty.is_empty(), "an empty state must give no candidate");
let info = h.qex(&["info", "--no-start"]);
let text = String::from_utf8_lossy(&info.stdout);
assert!(
text.contains("no coordinator"),
"TAB must not start a coordinator: {text}"
);
let running = h.submit(&[
"submit", "--name", "holder", "--cpu", "1", "--mem", "64MB", "--lock", "one", "--",
"sleep", "300",
]);
let queued = h.submit(&[
"submit", "--name", "waiter", "--cpu", "1", "--mem", "64MB", "--lock", "one", "--", "true",
]);
h.until("the first job operates", Duration::from_secs(45), || {
h.state_of(&running) == "running"
});
let all = h.ok(&["__complete", "ids"]);
for want in [running.as_str(), queued.as_str(), "holder", "waiter"] {
assert!(all.lines().any(|l| l == want), "`ids` must hold {want}");
}
let active = h.ok(&["__complete", "active"]);
assert!(active.lines().any(|l| l == running));
assert!(
!active.lines().any(|l| l == queued),
"`active` must not hold a job that waits: {active}"
);
let waiting = h.ok(&["__complete", "queued"]);
assert!(waiting.lines().any(|l| l == queued));
assert!(
!waiting.lines().any(|l| l == running),
"`queued` must not hold a job that operates: {waiting}"
);
let pairs = [
("deploy prod$(id)", "deploy_prod_id_"),
("cost $HOME", "cost_HOME"),
("a; touch", "a_touch"),
("two\nlines", "two_lines"),
("two\tparts", "two_parts"),
("esc\u{1b}[2Jname", "esc_2Jname"),
("src/main", "src_main"),
("a:b", "a_b"),
("build-*", "build-_"),
("-version", "_version"),
("caf\u{e9}", "caf_"),
("plain-name_1.2", "plain-name_1.2"),
];
for (name, safe) in pairs {
let id = h.submit(&["submit", &format!("--name={name}"), "--", "true"]);
assert_eq!(
h.status_json(&id)["name"].as_str(),
Some(safe),
"qex must show {safe:?} for the name {name:?}"
);
let all = h.ok(&["__complete", "ids"]);
assert!(
all.lines().any(|l| l == safe),
"the list must offer {safe:?} for the name {name:?}: {all}"
);
if safe != name {
assert!(
!all.lines().any(|l| l == name),
"the list must not offer the name {name:?} itself: {all}"
);
}
assert_eq!(
h.status_json(safe)["id"].as_str(),
Some(id.as_str()),
"`qex status {safe}` must find the job named {name:?}"
);
let found = if name.starts_with('-') {
h.qex(&["status", "--", name])
} else {
h.qex(&["status", name])
};
assert!(
found.status.success(),
"`qex status` must still find the job by its stored name {name:?}"
);
}
let long = "y".repeat(200);
h.submit(&["submit", &format!("--name={long}"), "--", "true"]);
let all = h.ok(&["__complete", "ids"]);
assert!(
all.lines().any(|l| l == "y".repeat(128)),
"a long name must stop at 128 characters"
);
assert!(
all.lines().all(|l| l.chars().count() <= 128),
"no candidate may be longer than 128 characters"
);
h.submit(&["submit", "--name=x y", "--", "true"]);
h.submit(&["submit", "--name=x_y", "--", "true"]);
let out = h.qex(&["status", "x_y"]);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
!out.status.success(),
"an ambiguous name must give an error"
);
assert!(
err.contains("`x_y` names 2 jobs"),
"the error must say how many jobs the word names: {err}"
);
assert!(
err.contains("Give the id of the job that you want"),
"the error must say what the reader must do: {err}"
);
assert_eq!(
err.lines().filter(|l| l.starts_with(" ")).count(),
2,
"the error must list the two jobs: {err}"
);
assert_eq!(
err.lines().filter(|l| l.ends_with(" x_y")).count(),
2,
"the error must name each job: {err}"
);
let all = h.ok(&["__complete", "ids"]);
assert_eq!(
all.lines().filter(|l| *l == "x_y").count(),
1,
"one safe form gives one candidate: {all}"
);
h.ok(&["kill", &running, "--grace", "1s"]);
}
#[test]
fn every_output_shows_the_safe_name() {
let h = Harness::with_default_config("safename");
let stored = "deploy prod$(id)";
let safe = "deploy_prod_id_";
let id = h.submit(&["submit", &format!("--name={stored}"), "--", "true"]);
let esc = "esc\u{1b}[2Jbad";
let esc_id = h.submit(&["submit", &format!("--name={esc}"), "--", "true"]);
h.until("both jobs stop", Duration::from_secs(45), || {
h.state_of(&id) == "completed" && h.state_of(&esc_id) == "completed"
});
let record = h.root.join("state/qex/jobs").join(&id).join("status.json");
let text = std::fs::read_to_string(&record).expect("the record must exist");
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
value["name"].as_str(),
Some(stored),
"the record on the disk must keep the name that the user gave"
);
let list = h.ok(&["list"]);
assert!(list.contains(safe), "`qex list` must show {safe}: {list}");
let status = h.ok(&["status", &id]);
assert!(
status.contains(&format!("name: {safe}")),
"`qex status` must show {safe}: {status}"
);
assert_eq!(
h.status_json(&id)["name"].as_str(),
Some(safe),
"the JSON of `qex status` must hold the safe name"
);
let listed = h.list_json();
assert!(
listed
.iter()
.any(|j| j["name"].as_str() == Some(safe) && j["id"].as_str() == Some(id.as_str())),
"the JSON of `qex list` must hold the safe name: {listed:?}"
);
for out in [
h.ok(&["wait", &id]),
h.ok(&["wait", &id, "--json"]),
h.ok(&["du", "--json"]),
h.ok(&["gc", "--dry-run", "--older-than", "0s", "--json"]),
h.ok(&["__complete", "ids"]),
] {
assert!(
!out.contains(stored),
"an output showed the stored name: {out}"
);
}
for args in [
vec!["list"],
vec!["list", "--json"],
vec!["status", &esc_id],
vec!["status", &esc_id, "--json"],
vec!["wait", &esc_id],
vec!["wait", &esc_id, "--json"],
vec!["du", "--json"],
vec!["du"],
vec!["gc", "--dry-run", "--older-than", "0s", "--json"],
vec!["__complete", "ids"],
vec!["top", "--once"],
] {
let out = h.qex(&args);
for stream in [&out.stdout, &out.stderr] {
assert!(
!stream.windows(6).any(|w| w == b"esc\x1b[2"),
"`qex {}` wrote the ESC byte of a job name",
args.join(" ")
);
}
}
let holder = h.submit(&[
"submit",
&format!("--name={esc}"),
"--cpu",
"1",
"--mem",
"64MB",
"--lock",
"one",
"--",
"sleep",
"300",
]);
let waiter = h.submit(&[
"submit", "--name", "waiter", "--cpu", "1", "--mem", "64MB", "--lock", "one", "--", "true",
]);
h.until(
"the second job waits for the lock",
Duration::from_secs(45),
|| h.status_json(&waiter)["blocked_reason"].is_string(),
);
let broken = h.submit(&[
"submit",
&format!("--name={esc}"),
"--",
"sh",
"-c",
"exit 3",
]);
let dependent = h.submit(&["submit", "--needs", &broken, "--", "true"]);
h.until(
"the dependent job is skipped",
Duration::from_secs(45),
|| h.state_of(&dependent) == "skipped",
);
let gone = h.submit(&["submit", &format!("--name={esc}"), "--", "true"]);
h.until("that job stops", Duration::from_secs(45), || {
h.state_of(&gone) == "completed"
});
h.ok(&["clean", &gone]);
for args in [
vec!["list"],
vec!["list", "--json"],
vec!["status", &waiter],
vec!["status", &waiter, "--json"],
vec!["status", &dependent],
vec!["status", &dependent, "--json"],
vec!["status", &gone],
vec!["top", "--once"],
] {
let out = h.qex(&args);
for stream in [&out.stdout, &out.stderr] {
assert!(
!stream.windows(6).any(|w| w == b"esc\x1b[2"),
"`qex {}` wrote the ESC byte of a job name",
args.join(" ")
);
}
}
let reason = h.status_json(&waiter)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
reason.contains("esc_2Jbad"),
"the sentence must name the job that holds the lock: {reason}"
);
let failed = h.status_json(&dependent)["error"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
failed.contains("esc_2Jbad"),
"the sentence must name the job that failed: {failed}"
);
let missing = String::from_utf8_lossy(&h.qex(&["status", &gone]).stderr).to_string();
assert!(
missing.contains("esc_2Jbad"),
"the sentence must name the job whose record is gone: {missing}"
);
let needs_holder = h.submit(&["submit", "--needs", &holder, "--", "true"]);
h.until("that job waits", Duration::from_secs(45), || {
h.status_json(&needs_holder)["blocked_reason"].is_string()
});
let reason = h.status_json(&needs_holder)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
reason.contains("esc_2Jbad") && !reason.contains('\u{1b}'),
"the sentence must name the job that this one waits for, safely: {reason}"
);
let done = h.submit(&["submit", "--", "true"]);
h.until("a job to delete stops", Duration::from_secs(45), || {
h.state_of(&done) == "completed"
});
let waiting_esc = h.submit(&[
"submit",
&format!("--name={esc}"),
"--needs",
&done,
"--cpu",
"1",
"--mem",
"64MB",
"--lock",
"one",
"--",
"true",
]);
h.until("that job waits too", Duration::from_secs(45), || {
h.state_of(&waiting_esc) == "queued"
});
let refused = h.qex(&["clean", &done]);
let text = String::from_utf8_lossy(&refused.stderr).to_string();
assert!(
text.contains("is needed by"),
"`qex clean` must refuse a record that a job in the queue needs: {text}"
);
assert!(
text.contains("esc_2Jbad") && !text.contains('\u{1b}'),
"that sentence must name the waiting job, safely: {text}"
);
h.ok(&["clean", &broken]);
let after = h.status_json(&dependent)["error"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
after.contains("esc_2Jbad") && !after.contains('\u{1b}'),
"the sentence must name the deleted job, safely: {after}"
);
let file = h.root.join("p.toml");
std::fs::write(
&file,
"name = \"my grp\\u001B[2Jbad\"\n\n [[jobs]]\nname = \"stg\\u001B[2Jbad\"\ncommand = [\"true\"]\n",
)
.unwrap();
let id_file = h.root.join("ids.json");
let made = h.qex(&[
"pipeline",
file.to_str().unwrap(),
"--json",
"--id-file",
id_file.to_str().unwrap(),
]);
assert!(made.status.success(), "the pipeline must start");
let started: serde_json::Value = serde_json::from_slice(&made.stdout).unwrap();
assert_eq!(
started["group_name"].as_str(),
Some("my_grp_2Jbad"),
"`qex pipeline --json` must give the safe group name: {started}"
);
let file2 = h.root.join("p2.toml");
std::fs::write(
&file2,
"name = \"two grp\"\n\n [[jobs]]\nname = \"stg\\u001B[2Jbad\"\ncommand = [\"true\"]\n",
)
.unwrap();
let echo = h.qex(&["pipeline", file2.to_str().unwrap()]);
assert!(
!echo.stderr.windows(6).any(|w| w == b"stg\x1b[2"),
"`qex pipeline` wrote the ESC byte of a stage name: {}",
String::from_utf8_lossy(&echo.stderr)
);
assert!(
String::from_utf8_lossy(&echo.stderr).contains("stg_2Jbad"),
"`qex pipeline` must still name each stage: {}",
String::from_utf8_lossy(&echo.stderr)
);
let listed = h.list_json();
let group = listed
.iter()
.filter_map(|j| j["group_name"].as_str())
.find(|n| n.contains("grp"))
.expect("the pipeline must give the group a name")
.to_string();
assert_eq!(
group, "my_grp_2Jbad",
"the group name must reach a reader in its safe form"
);
let ids: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&id_file).unwrap()).unwrap();
assert_eq!(
ids["group_name"].as_str(),
Some(group.as_str()),
"the id file must give the same value as `qex list --json`: {ids}"
);
for word in [group.as_str(), "my grp\u{1b}[2Jbad"] {
let out = h.ok(&["list", "--group", word, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap();
assert_eq!(
jobs.len(),
1,
"`qex list --group {word:?}` must find the job of the pipeline: {out}"
);
assert!(
!out.contains('\u{1b}'),
"no output holds an ESC byte: {out}"
);
}
let gid = listed
.iter()
.find(|j| j["group_name"].as_str() == Some(group.as_str()))
.unwrap()["group"]
.as_str()
.unwrap()
.to_string();
let out = h.ok(&["list", "--group", &gid, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap();
assert_eq!(jobs.len(), 1, "`qex list --group <id>` must still work");
let log = std::fs::read(h.root.join("state/qex/run/daemon.log")).unwrap_or_default();
assert!(
!log.windows(6).any(|w| w == b"esc\x1b[2"),
"the log of the coordinator wrote the ESC byte of a job name"
);
assert!(
log.windows(9).any(|w| w == b"esc_2Jbad"),
"the log of the coordinator must still name the job"
);
h.ok(&["kill", &holder, "--grace", "1s"]);
let long_name = "a-very-long-name-for-one-job";
let long_id = h.submit(&["submit", "--name", long_name, "--", "true"]);
let table = h.ok(&["list"]);
assert!(
!table.contains(long_name),
"the table stops the name at 16 characters: {table}"
);
assert_eq!(
h.status_json(long_name)["id"].as_str(),
Some(long_id.as_str()),
"the whole name must find the job"
);
assert_eq!(
h.list_json()
.iter()
.find(|j| j["id"].as_str() == Some(long_id.as_str()))
.unwrap()["name"]
.as_str(),
Some(long_name),
"`qex list --json` must give the whole name"
);
let from_list = listed
.iter()
.find(|j| j["id"].as_str() == Some(id.as_str()))
.unwrap()["name"]
.as_str()
.unwrap()
.to_string();
assert_eq!(
h.status_json(&from_list)["id"].as_str(),
Some(id.as_str()),
"the name that `qex list` shows must find the job"
);
assert_eq!(
h.status_json(stored)["id"].as_str(),
Some(id.as_str()),
"the stored name must still find the job"
);
}
#[test]
fn bash_keeps_a_hostile_candidate_in_one_word() {
let h = Harness::with_default_config("bashquote");
let script = h.root.join("qex.bash");
std::fs::write(&script, h.ok(&["completions", "bash"])).unwrap();
let bait = h.root.join("BAIT");
let hostile = [
format!("bait$(touch {})", bait.display()),
format!("tick`touch {}`", bait.display()),
format!("semi; touch {}", bait.display()),
format!("pipe | touch {}", bait.display()),
format!("amp & touch {}", bait.display()),
"has space inside".to_string(),
"quote\"double".to_string(),
"quote'single".to_string(),
"glob-*".to_string(),
"[abc]".to_string(),
"back\\slash".to_string(),
"trailing\\".to_string(),
"${IFS}brace".to_string(),
"$HOME".to_string(),
"~/tilde".to_string(),
"!hist".to_string(),
"esc\u{1b}[2Jname".to_string(),
"caf\u{e9} \u{65e5}\u{672c}".to_string(),
"x".repeat(2002),
];
let bin = h.root.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let stand_in = bin.join("qex");
std::fs::write(
&stand_in,
format!(
"#!/usr/bin/env bash\n\
if [ \"$1\" = __complete ]; then cat {answers}; exit 0; fi\n\
exec {real} \"$@\"\n",
answers = h.root.join("answers").display(),
real = env!("CARGO_BIN_EXE_qex"),
),
)
.unwrap();
std::fs::write(h.root.join("answers"), format!("{}\n", hostile.join("\n"))).unwrap();
std::process::Command::new("chmod")
.args(["+x", stand_in.to_str().unwrap()])
.status()
.unwrap();
let ask = |prefix: &str, tail: &str| -> String {
let program = format!(
"source {script}\n\
COMP_WORDS=(qex status '{prefix}')\n\
COMP_CWORD=2\n\
COMP_LINE='qex status {prefix}'\n\
COMP_POINT=${{#COMP_LINE}}\n\
COMPREPLY=()\n\
_qex_jobs 2>/dev/null\n\
{tail}\n",
script = script.display(),
);
let out = Command::new("bash")
.arg("-c")
.arg(&program)
.env("PATH", format!("{}:/usr/bin:/bin", bin.display()))
.output()
.expect("bash did not start");
String::from_utf8_lossy(&out.stdout).to_string()
};
for name in &hostile {
std::fs::write(h.root.join("answers"), format!("{name}\n")).unwrap();
let read = ask(
"",
"eval \"set -- ${COMPREPLY[0]}\" 2>/dev/null; printf '%s\\n' \"$#\"; printf '%s' \"$1\"",
);
let mut lines = read.splitn(2, '\n');
assert_eq!(
lines.next(),
Some("1"),
"the candidate for {name:?} must be ONE argument: {read:?}"
);
assert_eq!(
lines.next(),
Some(name.as_str()),
"the argument must be the name itself, for {name:?}"
);
}
assert!(!bait.exists(), "a candidate RAN: {}", bait.display());
std::fs::write(h.root.join("answers"), "-version\n--json\n").unwrap();
let reply = ask("-", "printf '%s\\n' \"${COMPREPLY[@]}\"");
assert!(
!reply.lines().any(|l| l == "-version"),
"a candidate must not be offered where an option goes: {reply}"
);
}
#[test]
fn a_real_bash_offers_the_jobs_and_runs_no_name() {
let h = Harness::with_default_config("bashcomp");
let running = h.submit(&[
"submit", "--name", "holder", "--cpu", "1", "--mem", "64MB", "--lock", "one", "--",
"sleep", "300",
]);
let queued = h.submit(&[
"submit", "--name", "waiter", "--cpu", "1", "--mem", "64MB", "--lock", "one", "--", "true",
]);
h.until("the first job operates", Duration::from_secs(45), || {
h.state_of(&running) == "running"
});
let bait = h.root.join("BAIT");
h.submit(&[
"submit",
"--name",
&format!("bait$(touch {})", bait.display()),
"--",
"true",
]);
h.submit(&["submit", "--name", "two words", "--", "true"]);
let script = h.root.join("qex.bash");
std::fs::write(&script, h.ok(&["completions", "bash"])).unwrap();
let ask_with = |prelude: &str, line: &[&str], tail: &str| -> String {
let words = line
.iter()
.map(|w| format!("'{}'", w.replace('\'', "'\\''")))
.collect::<Vec<_>>()
.join(" ");
let last = line.len() - 1;
let program = format!(
"{prelude}\n\
source {script}\n\
COMP_WORDS=({words})\n\
COMP_CWORD={last}\n\
COMP_LINE='{comp_line}'\n\
COMP_POINT=${{#COMP_LINE}}\n\
COMPREPLY=()\n\
_qex_with_jobs qex \"${{COMP_WORDS[{last}]}}\" 2>/dev/null\n\
{tail}\n",
script = script.display(),
comp_line = line.join(" ").replace('\'', "'\\''"),
);
let bin = Path::new(env!("CARGO_BIN_EXE_qex")).parent().unwrap();
let out = Command::new("bash")
.arg("-c")
.arg(&program)
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.env(
"PATH",
format!(
"{}:{}",
bin.display(),
std::env::var("PATH").unwrap_or_default()
),
)
.output()
.expect("bash did not start");
assert!(
out.status.success(),
"bash failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).to_string()
};
let out = Command::new("bash")
.arg("-c")
.arg(format!(
"set -e\nsource {}\ncomplete -p qex\n",
script.display()
))
.output()
.expect("bash did not start");
let bound = String::from_utf8_lossy(&out.stdout).to_string();
let noise = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
out.status.success(),
"sourcing the completions failed: {noise}"
);
assert_eq!(
noise, "",
"sourcing the completions must write nothing: {noise}"
);
assert!(
bound.contains("-F _qex_with_jobs"),
"the shell must call `_qex_with_jobs` after TAB, and it holds: {bound}"
);
let ask = |line: &[&str]| ask_with("", line, "printf '%s\\n' \"${COMPREPLY[@]}\"");
let read_back = |line: &[&str]| {
ask_with(
"",
line,
"eval \"set -- ${COMPREPLY[0]}\" 2>/dev/null; printf '%s\\n' \"$#\" \"$@\"",
)
};
check_the_bash_candidates(&ask, &read_back, &running, &queued, &bait);
h.ok(&["kill", &running, "--grace", "1s"]);
}
#[allow(clippy::type_complexity)]
fn check_the_bash_candidates(
ask: &dyn Fn(&[&str]) -> String,
read_back: &dyn Fn(&[&str]) -> String,
running: &str,
queued: &str,
bait: &Path,
) {
let reply = ask(&["qex", "status", ""]);
for want in [running, queued, "holder", "waiter"] {
assert!(
reply.lines().any(|l| l == want),
"bash must offer {want}: {reply}"
);
}
let reply = ask(&["qex", "status", "hol"]);
assert_eq!(reply.trim(), "holder", "bash must complete the name");
let reply = ask(&["qex", "kill", ""]);
assert!(reply.lines().any(|l| l == running), "kill: {reply}");
assert!(!reply.lines().any(|l| l == queued), "kill: {reply}");
let reply = ask(&["qex", "cancel", ""]);
assert!(reply.lines().any(|l| l == queued), "cancel: {reply}");
assert!(!reply.lines().any(|l| l == running), "cancel: {reply}");
let reply = ask(&["qex", "kill", "--signal", ""]);
assert!(
!reply.lines().any(|l| l == running),
"`qex kill --signal <TAB>` must not offer a job: {reply}"
);
for flag in ["--json", "--show-env"] {
let reply = ask(&["qex", "status", flag, ""]);
assert!(
reply.lines().any(|l| l == "holder"),
"`qex status {flag} <TAB>` must still offer a job: {reply}"
);
}
let reply = ask(&["qex", "clean", ""]);
assert!(
reply.lines().any(|l| l == "holder"),
"`qex clean <TAB>` must offer a job: {reply}"
);
let reply = ask(&["qex", "status", "two"]);
assert_eq!(reply.trim(), "two_words", "the safe form only: {reply}");
let read = read_back(&["qex", "status", "two"]);
assert_eq!(
read.trim(),
"1\ntwo_words",
"the completed word must be ONE argument of qex: {read}"
);
let reply = ask(&["qex", "status", "bait"]);
assert_eq!(reply.lines().count(), 1, "one candidate only: {reply}");
assert!(
!reply.contains("$("),
"the list must hold the safe form: {reply}"
);
let read = read_back(&["qex", "status", "bait"]);
assert_eq!(read.lines().next(), Some("1"), "one argument only: {read}");
assert!(
!bait.exists(),
"a job name RAN when bash asked for the candidates: {}",
bait.display()
);
}
#[test]
fn a_job_gives_way_to_the_work_of_a_person() {
let h = Harness::with_default_config("polite");
let id = h.submit(&["submit", "--", "sh", "-c", "ps -o ni= -p $$"]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"10",
"a job must be polite by default, and this one was not: {out}"
);
let id = h.submit(&["submit", "--nice", "0", "--", "sh", "-c", "ps -o ni= -p $$"]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(out.trim(), "0", "`--nice 0` must reach the job: {out}");
let id = h.submit(&["submit", "--nice", "-5", "--", "true"]);
h.ok(&["wait", &id, "--timeout", "45s"]);
assert_eq!(
h.status_json(&id)["state"],
"completed",
"a nice value that the machine refuses must not stop the job"
);
}
#[test]
#[cfg(target_os = "linux")]
fn every_politeness_value_reaches_the_job_and_its_children() {
for (io, expect) in [
("idle", "idle"),
("best-effort", "best-effort: prio 4"),
("none", "none: prio 0"),
] {
let h = Harness::new(
&format!("polite-{io}"),
&format!(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[politeness]\nnice = 12\nio = \"{io}\"\noom_score_adj = 500\n"
),
);
let probe = "report() { \
echo \"$1 nice=$(awk '{print $19}' /proc/$2/stat) \
oom=$(cat /proc/$2/oom_score_adj) io=$(ionice -p $2)\"; }; \
report parent $$; sh -c 'report() { \
echo \"$1 nice=$(awk \"{print \\$19}\" /proc/$2/stat) \
oom=$(cat /proc/$2/oom_score_adj) io=$(ionice -p $2)\"; }; \
report child $$'";
let shown = h.ok(&["config", "show"]);
assert!(
shown.contains("nice 12") && shown.contains(&format!("io {io}")),
"`qex config show` must name the politeness values: {shown}"
);
let id = h.submit(&["submit", "--", "sh", "-c", probe]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
for who in ["parent", "child"] {
let line = out
.lines()
.find(|l| l.starts_with(who))
.unwrap_or_else(|| panic!("the job gave no {who} line: {out}"));
assert!(
line.contains("nice=12"),
"the {who} must take `[politeness] nice`: {line}"
);
assert!(
line.contains("oom=500"),
"the {who} must take `[politeness] oom_score_adj`: {line}"
);
assert!(
line.contains(expect),
"the {who} must take `io = \"{io}\"` as `{expect}`: {line}"
);
}
}
}
#[test]
#[cfg(target_os = "linux")]
fn a_politeness_value_with_a_fault_at_the_start_gives_the_default_values() {
let good = "[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[politeness]\nnice = 12\n";
let h = Harness::new("polite-late", good);
let probe = "awk '{print $19}' /proc/$$/stat";
let first = h.submit(&["submit", "--", "sh", "-c", probe]);
h.ok(&["wait", &first, "--timeout", "45s"]);
assert_eq!(h.ok(&["logs", &first, "--stdout"]).trim(), "12");
std::fs::write(
h.root.join("cfg/qex.toml"),
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[politeness]\nnice = 100\n",
)
.unwrap();
let out = h.qex(&["rerun", &first]);
assert!(out.status.success(), "`qex rerun` must still start the job");
let again = String::from_utf8_lossy(&out.stdout).trim().to_string();
h.ok(&["wait", &again, "--timeout", "45s"]);
let status = h.status_json(&again);
assert_eq!(
status["state"], "completed",
"a config file with a fault must not stop the job: {status}"
);
assert_eq!(
h.ok(&["logs", &again, "--stdout"]).trim(),
"10",
"the job must take the DEFAULT nice value, and not the value with the fault"
);
let error = status["error"].as_str().unwrap_or("");
assert!(
error.contains("[politeness] nice"),
"the record of the job must name the fault: {status}"
);
}
#[test]
fn a_job_is_told_the_size_of_its_claim() {
let h = Harness::new(
"claimenv",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[submit]\nenv_capture = \"minimal\"\n",
);
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"echo \"[$QEX_CPU][$GOMAXPROCS][$NODE_OPTIONS]\"",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"[][][]",
"a job that made no claim must be told nothing: {out}"
);
let id = h.submit(&[
"submit",
"--cpu",
"2",
"--mem",
"2GB",
"--",
"sh",
"-c",
"echo \"$QEX_CPU $QEX_MEM_MB $GOMAXPROCS $OMP_NUM_THREADS\"",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"2 2048 2 2",
"the job must see its own claim: {out}"
);
let id = h.submit(&[
"submit",
"--mem",
"2GB",
"--",
"sh",
"-c",
"echo \"[$QEX_CPU][$GOMAXPROCS]\"",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"[][]",
"`--mem` with no `--cpu` must write nothing: {out}"
);
let id = h.submit(&[
"submit",
"--cpu",
"2",
"--",
"sh",
"-c",
"echo \"[$QEX_CPU][$GOMAXPROCS]\"",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"[][]",
"`--cpu` with no `--mem` must write nothing: {out}"
);
let id = h.submit(&[
"submit",
"--cpu",
"2",
"--mem",
"2GB",
"--env",
"GOMAXPROCS=9",
"--",
"sh",
"-c",
"echo \"$GOMAXPROCS $OMP_NUM_THREADS\"",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"9 2",
"`--env` must win, and the rest must still arrive: {out}"
);
let id = h.submit(&[
"submit",
"--cpu",
"2",
"--mem",
"2GB",
"--no-limit-env-hints",
"--",
"sh",
"-c",
"echo \"[$QEX_CPU][$GOMAXPROCS]\"",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(out.trim(), "[][]", "the option must write nothing: {out}");
let file = h.root.join("off.toml");
std::fs::write(
&file,
"name = \"off\"\n\
command = [\"sh\", \"-c\", \"echo \\\"[$QEX_CPU][$GOMAXPROCS]\\\"\"]\n\
no_limit_env_hints = true\n\n\
[resources]\ncpu = 2\nmem = \"2GB\"\n",
)
.unwrap();
let id = h.submit(&["submit", "--job", file.to_str().unwrap()]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(out.trim(), "[][]", "the job file must turn it off: {out}");
let file = h.root.join("on.toml");
std::fs::write(
&file,
"name = \"on\"\n\
command = [\"sh\", \"-c\", \"echo \\\"[$QEX_CPU][$GOMAXPROCS]\\\"\"]\n\n\
[resources]\ncpu = 2\nmem = \"2GB\"\n",
)
.unwrap();
let id = h.submit(&["submit", "--job", file.to_str().unwrap()]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"[2][2]",
"the job file must get the claim: {out}"
);
let pipeline = h.root.join("p.toml");
std::fs::write(
&pipeline,
"[[jobs]]\nname = \"stage-on\"\n\
command = [\"sh\", \"-c\", \"echo \\\"[$QEX_CPU][$GOMAXPROCS]\\\"\"]\n\
[jobs.resources]\ncpu = 2\nmem = \"2GB\"\n\n\
[[jobs]]\nname = \"stage-off\"\n\
command = [\"sh\", \"-c\", \"echo \\\"[$QEX_CPU][$GOMAXPROCS]\\\"\"]\n\
no_limit_env_hints = true\n\
[jobs.resources]\ncpu = 2\nmem = \"2GB\"\n",
)
.unwrap();
let ids_file = h.root.join("stage-ids.json");
h.ok(&[
"pipeline",
pipeline.to_str().unwrap(),
"--id-file",
ids_file.to_str().unwrap(),
]);
let ids: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&ids_file).unwrap()).unwrap();
for (stage, want) in [("stage-on", "[2][2]"), ("stage-off", "[][]")] {
let id = ids["jobs"][stage].as_str().unwrap().to_string();
h.ok(&["wait", &id, "--timeout", "60s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
want,
"the stage {stage} must give {want}: {out}"
);
}
let (child, id) = h.run_bg(&[
"--cpu",
"2",
"--mem",
"2GB",
"--no-limit-env-hints",
"--",
"sh",
"-c",
"echo \"[$QEX_CPU][$GOMAXPROCS]\"",
]);
wait_run(child, "`qex run` with --no-limit-env-hints");
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"[][]",
"`qex run --no-limit-env-hints` must write nothing: {out}"
);
let (child, id) = h.run_bg(&[
"--cpu",
"2",
"--mem",
"2GB",
"--",
"sh",
"-c",
"echo \"[$QEX_CPU][$GOMAXPROCS]\"",
]);
wait_run(child, "`qex run` with a claim");
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"[2][2]",
"`qex run` must give the claim to the job: {out}"
);
}
#[test]
fn the_config_file_controls_the_claim_in_the_environment() {
let show = [
"sh",
"-c",
"echo \"[$QEX_CPU][$GOMAXPROCS][$JAVA_TOOL_OPTIONS][$MAKEFLAGS]\"",
];
let h = Harness::new(
"claimsoff",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[submit]\nenv_capture = \"minimal\"\n\
[claims]\nexport_env = false\n",
);
let mut args = vec!["submit", "--cpu", "2", "--mem", "2GB", "--"];
args.extend_from_slice(&show);
let id = h.submit(&args);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"[][][][]",
"`export_env = false` must write nothing: {out}"
);
let shown = h.ok(&["config", "show"]);
assert!(
shown.contains("claim in job: no; [claims] export_env = false"),
"`qex config show` must report that the claim is off: {shown}"
);
let h = Harness::new(
"claimsnone",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[submit]\nenv_capture = \"none\"\n",
);
let shown = h.ok(&["config", "show"]);
assert!(
shown.contains("claim in job: no; [submit] env_capture"),
"`env_capture = none` must report that the claim is off: {shown}"
);
let mut args = vec!["submit", "--cpu", "2", "--mem", "2GB", "--"];
args.extend_from_slice(&show);
let id = h.submit(&args);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"[][][][]",
"and the job must in fact receive nothing: {out}"
);
let h = Harness::new(
"claimsdefault",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let shown = h.ok(&["config", "show"]);
assert!(
shown.contains("claim in job: yes, with --cpu and --mem together"),
"the default configuration must report that the claim reaches the job: {shown}"
);
let h = Harness::new(
"claimsalso",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[submit]\nenv_capture = \"minimal\"\n\
[claims]\nalso = [\"java\", \"make\"]\n",
);
let mut args = vec!["submit", "--cpu", "2", "--mem", "2GB", "--"];
args.extend_from_slice(&show);
let id = h.submit(&args);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"[2][2][-XX:ActiveProcessorCount=2 -Xmx1536m][-j2]",
"`also` must add the two variables: {out}"
);
let shown = h.ok(&["config", "show"]);
assert!(
shown.contains("also java, make"),
"`qex config show` must name the hints that operate: {shown}"
);
let h = Harness::new(
"claimsbad",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[claims]\nalso = [\"jvm\"]\n",
);
let out = h.qex(&["submit", "--cpu", "2", "--mem", "2GB", "--", "true"]);
assert!(
!out.status.success(),
"an unknown name must stop the submit"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("jvm"),
"the message must name the value: {err}"
);
assert!(
err.contains("size of the machine"),
"the message must say why it matters: {err}"
);
assert!(
err.contains("`java`") && err.contains("`make`"),
"the message must give the remedy: {err}"
);
}
fn events_reader(h: &Harness, args: &[&str]) -> std::process::Child {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_qex"));
cmd.arg("events")
.args(args)
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
cmd.spawn().expect("qex events did not start")
}
fn events_lines(child: std::process::Child) -> Vec<serde_json::Value> {
let out = child.wait_with_output().expect("the reader did not stop");
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.map(|line| {
serde_json::from_str(line).unwrap_or_else(|e| {
panic!("the stream must give one JSON object for each line: {e}\nline: {line}")
})
})
.collect()
}
#[test]
fn the_event_stream_reports_each_change_of_state_in_order() {
let h = Harness::with_default_config("events");
let reader = events_reader(&h, &["--json", "--timeout", "12s"]);
std::thread::sleep(Duration::from_millis(500));
let id = h.submit(&["submit", "--name", "streamed", "--", "sh", "-c", "sleep 2"]);
h.ok(&["wait", &id]);
let lines = events_lines(reader);
assert!(!lines.is_empty(), "the stream gave no line");
assert_eq!(
lines[0]["event"], "stream",
"the first line must name the coordinator: {:?}",
lines[0]
);
let mine: Vec<&serde_json::Value> = lines
.iter()
.filter(|l| l["event"] == "job" && l["id"] == id.as_str() && l["change"] == "state")
.collect();
let states: Vec<&str> = mine.iter().map(|l| l["state"].as_str().unwrap()).collect();
assert_eq!(
states,
vec!["queued", "starting", "running", "completed"],
"the stream must give each change of state in order: {states:?}"
);
let seqs: Vec<u64> = mine.iter().map(|l| l["seq"].as_u64().unwrap()).collect();
assert!(
seqs.windows(2).all(|w| w[1] > w[0]),
"the numbers must increase: {seqs:?}"
);
let last = mine.last().unwrap();
assert_eq!(last["previous"], "running");
assert_eq!(last["job"]["exit_code"], 0);
assert_eq!(last["job"]["name"], "streamed");
}
#[test]
fn the_stream_shows_the_safe_name() {
let h = Harness::with_default_config("eventsname");
let reader = events_reader(&h, &["--json", "--timeout", "20s"]);
let plain = events_reader(&h, &["--timeout", "20s"]);
std::thread::sleep(Duration::from_millis(500));
let esc = "esc\u{1b}[2Jbad";
let id = h.submit(&["submit", &format!("--name={esc}"), "--", "true"]);
h.ok(&["wait", &id]);
let text = plain.wait_with_output().expect("the reader did not stop");
for stream in [&text.stdout, &text.stderr] {
assert!(
!stream.windows(6).any(|w| w == b"esc\x1b[2"),
"`qex events` wrote the ESC byte of a job name"
);
}
assert!(
String::from_utf8_lossy(&text.stdout).contains("esc_2Jbad"),
"`qex events` must show the safe name: {}",
String::from_utf8_lossy(&text.stdout)
);
let shown = h.status_json(&id)["name"].as_str().unwrap().to_string();
let lines = events_lines(reader);
let mine: Vec<&serde_json::Value> = lines
.iter()
.filter(|l| l["event"] == "job" && l["id"] == id.as_str())
.collect();
assert!(!mine.is_empty(), "the stream gave no event for the job");
for line in mine {
assert_eq!(
line["name"].as_str(),
Some(shown.as_str()),
"the stream and `qex status --json` must give one name: {line}"
);
assert_eq!(
line["job"]["name"].as_str(),
Some(shown.as_str()),
"the record in the stream must hold the safe name: {line}"
);
}
}
#[test]
fn the_stream_shows_no_control_byte_in_a_sentence() {
let h = Harness::new(
"eventsreason",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let plain = events_reader(&h, &["--timeout", "20s"]);
let json = events_reader(&h, &["--json", "--timeout", "20s"]);
std::thread::sleep(Duration::from_millis(500));
let lock = "lk\u{1b}[2Jbad";
let holder = h.submit(&[
"submit", "--name", "holder", "--lock", lock, "--", "sleep", "10",
]);
h.until("the holder runs", Duration::from_secs(30), || {
h.state_of(&holder) == "running"
});
let waiter = h.submit(&[
"submit",
"--name",
"waiter",
"--lock",
lock,
"--max-queue-time",
"3s",
"--",
"true",
]);
h.qex(&["wait", &waiter, "--timeout", "40s"]);
h.qex(&["kill", &holder]);
let text = plain.wait_with_output().expect("the reader did not stop");
for stream in [&text.stdout, &text.stderr] {
assert!(
!stream.windows(5).any(|w| w == b"lk\x1b[2"),
"`qex events` wrote the ESC byte of a lock name"
);
}
let lines = events_lines(json);
let mine: Vec<&serde_json::Value> = lines
.iter()
.filter(|l| l["event"] == "job" && l["id"] == waiter.as_str())
.collect();
assert!(!mine.is_empty(), "the stream gave no event for the job");
let mut saw_the_lock = false;
for line in mine {
for field in ["blocked_reason", "error"] {
if let Some(sentence) = line["job"][field].as_str() {
if sentence.contains("lk") {
saw_the_lock = true;
}
assert!(
!sentence.chars().any(|c| c.is_control()),
"the field `{field}` holds a control byte: {sentence:?}"
);
}
}
}
assert!(
saw_the_lock,
"the test proved nothing: no sentence carried the lock name"
);
}
#[test]
fn a_live_reader_receives_the_new_events_only() {
let h = Harness::with_default_config("eventsnow");
let old = h.submit(&["submit", "--name", "before", "--", "true"]);
h.ok(&["wait", &old]);
let reader = events_reader(&h, &["--json", "--since", "now", "--timeout", "12s"]);
std::thread::sleep(Duration::from_millis(500));
let new = h.submit(&["submit", "--name", "after", "--", "true"]);
h.ok(&["wait", &new]);
let lines = events_lines(reader);
assert!(
lines.iter().any(|l| l["id"] == new.as_str()),
"the live stream gave no event for the job that started after it"
);
assert!(
!lines.iter().any(|l| l["id"] == old.as_str()),
"`--since now` gave the events of a job that stopped before the reader \
connected: {lines:?}"
);
}
#[test]
fn the_reader_stops_after_the_number_of_events_that_it_asked_for() {
let h = Harness::with_default_config("eventscount");
let reader = events_reader(&h, &["--json", "--count", "2", "--timeout", "20s"]);
std::thread::sleep(Duration::from_millis(500));
let id = h.submit(&["submit", "--name", "counted", "--", "true"]);
h.ok(&["wait", &id]);
let out = reader.wait_with_output().expect("the reader did not stop");
assert_eq!(
out.status.code(),
Some(0),
"a reader that read its count stops with 0: {}",
String::from_utf8_lossy(&out.stderr)
);
let text = String::from_utf8_lossy(&out.stdout);
let lines: Vec<serde_json::Value> = text
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
let counted = lines
.iter()
.filter(|l| l["event"] == "job" || l["event"] == "gap")
.count();
assert_eq!(
counted, 2,
"the reader must stop after two events, and it wrote: {text}"
);
assert_eq!(
lines[0]["event"], "stream",
"the header must not count as an event: {text}"
);
}
#[test]
fn the_stream_reports_the_terminal_states_that_no_supervisor_reports() {
let h = Harness::new(
"eventsterm",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\noversized = \"queue\"\n",
);
let reader = events_reader(&h, &["--json", "--timeout", "25s"]);
std::thread::sleep(Duration::from_millis(500));
let first = h.submit(&[
"submit",
"--cpu",
"64",
"--max-queue-time",
"3s",
"--",
"echo",
"never",
]);
let second = h.submit(&["submit", "--needs", &first, "--", "echo", "after"]);
h.qex(&["wait", &second, "--timeout", "60s"]);
let lines = events_lines(reader);
let state_of = |id: &str| -> Vec<String> {
lines
.iter()
.filter(|l| l["event"] == "job" && l["id"] == id && l["change"] == "state")
.map(|l| l["state"].as_str().unwrap().to_string())
.collect()
};
let one = state_of(&first);
assert!(
one.contains(&"expired".to_string()),
"the stream must report the job that gave up waiting: {one:?}"
);
let two = state_of(&second);
assert!(
two.contains(&"skipped".to_string()),
"the stream must report the job that did not run: {two:?}"
);
let expired = lines
.iter()
.find(|l| l["event"] == "job" && l["id"] == first.as_str() && l["state"] == "expired")
.unwrap();
assert!(
expired["job"]["error"]
.as_str()
.unwrap_or("")
.contains("--max-queue-time"),
"the record must name the limit: {expired}"
);
assert!(
!expired["job"]["finished_at"].is_null(),
"a terminal record must hold the time when the job stopped: {expired}"
);
}
#[test]
fn a_reader_continues_from_the_number_that_it_read() {
let h = Harness::with_default_config("eventssince");
let id = h.submit(&["submit", "--name", "again", "--", "true"]);
h.ok(&["wait", &id]);
let all = events_lines(events_reader(
&h,
&["--json", "--since", "start", "--timeout", "3s"],
));
let jobs: Vec<&serde_json::Value> = all.iter().filter(|l| l["event"] == "job").collect();
assert!(jobs.len() >= 2, "the stream must hold the events: {all:?}");
let stream_id = all[0]["stream_id"].as_str().unwrap().to_string();
let first = jobs[0]["seq"].as_u64().unwrap();
let after = events_lines(events_reader(
&h,
&[
"--json",
"--since",
&format!("{stream_id}:{first}"),
"--timeout",
"3s",
],
));
let got: Vec<u64> = after
.iter()
.filter(|l| l["event"] == "job")
.map(|l| l["seq"].as_u64().unwrap())
.collect();
let expected: Vec<u64> = jobs
.iter()
.map(|l| l["seq"].as_u64().unwrap())
.filter(|s| *s > first)
.collect();
assert_eq!(got, expected, "the reader must continue after its number");
assert!(
!got.contains(&first),
"the reader must not read one event a second time"
);
}
#[test]
fn the_stream_counts_the_events_that_it_dropped() {
let h = Harness::with_default_config("eventsgap");
let mut cmd = Command::new(env!("CARGO_BIN_EXE_qex"));
cmd.args(["submit", "--name", "gap0", "--", "true"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.env("QEX_EVENTS_RETAINED", "3");
let out = cmd.output().expect("qex did not start");
assert!(out.status.success(), "the first submission failed");
let first = String::from_utf8_lossy(&out.stdout).trim().to_string();
h.ok(&["wait", &first]);
for i in 1..4 {
let id = h.submit(&["submit", "--name", &format!("gap{i}"), "--", "true"]);
h.ok(&["wait", &id]);
}
let lines = events_lines(events_reader(
&h,
&["--json", "--since", "start", "--timeout", "3s"],
));
let gap = lines
.iter()
.find(|l| l["event"] == "gap")
.unwrap_or_else(|| panic!("the stream must report the gap: {lines:?}"));
assert!(
gap["missed"].as_u64().unwrap() > 0,
"the gap must count the events that went away: {gap:?}"
);
assert!(
lines.iter().any(|l| l["event"] == "job"),
"the stream must continue after a gap: {lines:?}"
);
}
#[test]
fn the_coordinator_retires_under_a_reader_and_says_goodbye() {
let h = Harness::new(
"eventsbye",
"[peers]\nenabled = false\n[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let mut cmd = Command::new(env!("CARGO_BIN_EXE_qex"));
cmd.args(["events", "--json", "--timeout", "60s"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "3")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let reader = cmd.spawn().expect("qex events did not start");
let out = reader.wait_with_output().expect("the reader did not stop");
assert_eq!(
out.status.code(),
Some(0),
"an orderly stop is not a failure: {}",
String::from_utf8_lossy(&out.stderr)
);
let text = String::from_utf8_lossy(&out.stdout);
let last: serde_json::Value = serde_json::from_str(text.lines().last().unwrap_or("")).unwrap();
assert_eq!(
last["event"], "bye",
"the coordinator must say why the stream ends: {text}"
);
assert!(
last["reason"].as_str().unwrap().contains("no job operates"),
"the goodbye must give the reason: {last:?}"
);
}
#[test]
fn a_coordinator_that_has_no_event_stream_refuses_the_command() {
use std::io::{BufRead, BufReader, Write};
let root = std::env::temp_dir().join(format!("qxold{}", std::process::id()));
let run = root.join("state/qex/run");
std::fs::create_dir_all(&run).unwrap();
std::fs::create_dir_all(root.join("cfg")).unwrap();
let socket = run.join("s");
let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
let server = std::thread::spawn(move || {
let Ok((stream, _)) = listener.accept() else {
return;
};
let mut writer = stream.try_clone().unwrap();
for line in BufReader::new(stream).lines() {
let Ok(line) = line else { return };
let answer = if line.contains("\"info\"") {
serde_json::json!({
"result": "info", "pid": 4321, "version": "0.7.1",
"started_at": 0, "program_replaced": false,
"jobs_running": 0, "jobs_queued": 0,
"cpu_budget": 1, "mem_budget": 1, "cpu_claimed": 0, "mem_claimed": 0,
})
} else if line.contains("\"capabilities\"") {
serde_json::json!({ "result": "capabilities", "names": ["locks", "retries"] })
} else {
serde_json::json!({
"result": "error", "kind": "internal",
"message": "qex could not read this request",
})
};
writeln!(writer, "{answer}").ok();
writer.flush().ok();
}
});
let out = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["events", "--json", "--timeout", "10s"])
.env("XDG_CONFIG_HOME", root.join("cfg"))
.env("XDG_STATE_HOME", root.join("state"))
.env("XDG_RUNTIME_DIR", root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.expect("qex did not start");
let err = String::from_utf8_lossy(&out.stderr).to_string();
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
drop(server);
std::fs::remove_dir_all(&root).ok();
assert_eq!(
out.status.code(),
Some(1),
"the command must fail. stdout: {stdout} stderr: {err}"
);
assert!(
err.contains("cannot obey `qex events`"),
"the message must say what happened: {err}"
);
assert!(
err.contains("kill 4321"),
"the message must give the remedy: {err}"
);
assert!(
stdout.is_empty(),
"a refused command must give no stream: {stdout}"
);
}
#[test]
fn a_number_from_a_coordinator_that_stopped_gives_a_gap() {
let h = Harness::with_default_config("eventsrestart");
for i in 0..3 {
let id = h.submit(&["submit", "--name", &format!("old{i}"), "--", "true"]);
h.ok(&["wait", &id]);
}
let before = events_lines(events_reader(
&h,
&["--json", "--since", "now", "--timeout", "2s"],
));
let old_stream = before[0]["stream_id"].as_str().unwrap().to_string();
let pid = h.coordinator_pid();
unsafe {
libc::kill(pid, libc::SIGKILL);
}
h.until("the coordinator goes away", Duration::from_secs(20), || {
(unsafe { libc::kill(pid, 0) }) != 0
});
let lines = events_lines(events_reader(
&h,
&[
"--json",
"--since",
&format!("{old_stream}:2"),
"--timeout",
"3s",
],
));
assert_ne!(
lines[0]["stream_id"].as_str().unwrap(),
old_stream,
"the new coordinator must have a stream of its own"
);
let gap = lines
.iter()
.find(|l| l["event"] == "gap")
.unwrap_or_else(|| {
panic!("a number from a stream that stopped must give a gap: {lines:?}")
});
assert!(
gap["missed"].is_null(),
"qex cannot count across two streams, and it must not invent a number: {gap:?}"
);
assert!(
gap["reason"].as_str().unwrap().contains(&old_stream),
"the reason must name the stream that gave the number: {gap:?}"
);
let seqs: Vec<u64> = lines
.iter()
.filter(|l| l["event"] == "job")
.map(|l| l["seq"].as_u64().unwrap())
.collect();
assert_eq!(
seqs.first(),
Some(&1),
"the stream must continue from the first event that the new coordinator holds: {seqs:?}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn a_reader_that_goes_away_leaves_no_thread_behind() {
let h = Harness::with_default_config("eventsfds");
let pid = h.coordinator_pid();
let count = |what: &str| {
std::fs::read_dir(format!("/proc/{pid}/{what}"))
.map(|d| d.count())
.unwrap_or(0)
};
let threads = count("task");
let handles = count("fd");
assert!(threads > 0, "this test needs /proc");
for _ in 0..8 {
let reader = events_reader(&h, &["--json", "--since", "now", "--timeout", "1s"]);
reader.wait_with_output().expect("the reader did not stop");
}
h.until(
"the coordinator releases the threads of the readers that stopped",
Duration::from_secs(20),
|| count("task") <= threads + 1 && count("fd") <= handles + 2,
);
}
#[test]
fn a_paused_queue_starts_no_job_and_the_jobs_that_operate_continue() {
let h = Harness::with_default_config("pausequeue");
let running = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "300",
]);
h.until("the first job operates", Duration::from_secs(45), || {
h.state_of(&running) == "running"
});
h.ok(&["pause", "queue"]);
let waiter = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
let deadline = Instant::now() + Duration::from_secs(4);
while Instant::now() < deadline {
assert_eq!(
h.state_of(&waiter),
"queued",
"a paused queue must start no job"
);
std::thread::sleep(Duration::from_millis(300));
}
assert_eq!(
h.state_of(&running),
"running",
"a pause must not stop the job that already operates"
);
h.ok(&["resume", "queue"]);
h.until("the job starts again", Duration::from_secs(45), || {
h.has_started(&waiter)
});
h.ok(&["kill", &running, "--grace", "1s"]);
}
#[test]
fn the_pause_survives_a_coordinator_that_stops() {
let h = Harness::with_default_config("pausesurvives");
h.ok(&["pause", "queue", "--reason", "recording a demo"]);
let first = h.ok(&["info", "--no-start", "--json"]);
let first: serde_json::Value = serde_json::from_str(&first).unwrap();
let first = first["pid"].as_i64().unwrap() as i32;
unsafe {
libc::kill(first, libc::SIGKILL);
}
h.until("the coordinator stopped", Duration::from_secs(30), || {
let alive = unsafe { libc::kill(first, 0) } == 0;
!alive
});
let id = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
let second = h.ok(&["info", "--no-start", "--json"]);
let second: serde_json::Value = serde_json::from_str(&second).unwrap();
assert_eq!(second["queue_state"], "paused", "the pause did not survive");
assert_eq!(second["paused_reason"], "recording a demo");
assert_ne!(
second["pid"].as_i64().unwrap() as i32,
first,
"the test must measure a NEW coordinator"
);
let deadline = Instant::now() + Duration::from_secs(4);
while Instant::now() < deadline {
assert_eq!(
h.state_of(&id),
"queued",
"the new coordinator must start no job"
);
std::thread::sleep(Duration::from_millis(300));
}
h.ok(&["resume"]);
h.until("the job starts again", Duration::from_secs(45), || {
h.has_started(&id)
});
}
#[test]
fn a_person_gets_a_lock_when_the_job_that_holds_it_stops() {
let h = Harness::with_default_config("pauselock");
let holder = h.submit(&[
"submit", "--lock", "gpu0", "--cpu", "1", "--mem", "64MB", "--", "sleep", "6",
]);
h.until("the job holds the lock", Duration::from_secs(45), || {
h.state_of(&holder) == "running"
});
let out = h.ok(&["pause", "lock", "gpu0"]);
assert!(
out.contains("holds the lock"),
"the answer must name the job that holds the lock now: {out}"
);
let waiter = h.submit(&[
"submit", "--lock", "gpu0", "--cpu", "1", "--mem", "64MB", "--", "true",
]);
h.until("the first job stopped", Duration::from_secs(60), || {
h.state_of(&holder) == "completed"
});
h.until(
"the lock belongs to the person",
Duration::from_secs(30),
|| h.ok(&["pause"]).contains("it is yours now"),
);
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
assert_eq!(
h.state_of(&waiter),
"queued",
"no job may take a lock that a person holds"
);
std::thread::sleep(Duration::from_millis(300));
}
let reason = h.status_json(&waiter)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
reason.contains("which a person holds"),
"the reason must name the person: {reason}"
);
h.ok(&["resume", "lock", "gpu0"]);
h.until("the job takes the lock", Duration::from_secs(45), || {
h.has_started(&waiter)
});
}
#[test]
fn a_job_that_waits_for_a_pause_says_the_pause() {
let h = Harness::with_default_config("pausereason");
h.ok(&["pause", "queue", "--reason", "recording a demo"]);
let out = h.qex(&["submit", "--", "true"]);
assert!(out.status.success());
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
let warning = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
warning.contains("the queue is paused"),
"the submission must warn immediately: {warning}"
);
h.until("the job has a reason", Duration::from_secs(30), || {
!h.status_json(&id)["blocked_reason"].is_null()
});
let reason = h.status_json(&id)["blocked_reason"]
.as_str()
.unwrap()
.to_string();
assert!(
reason.contains("the queue is paused"),
"the reason must give the pause: {reason}"
);
assert!(
reason.contains("recording a demo"),
"the reason must give the text of --reason: {reason}"
);
assert!(
!reason.contains("waits for"),
"the pause replaces the capacity reason, and does not stand beside it: {reason}"
);
h.ok(&["resume"]);
}
#[test]
fn a_pause_with_a_time_ends_by_itself() {
let h = Harness::with_default_config("pausefor");
h.ok(&["pause", "queue", "--for", "10s"]);
let id = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
assert_eq!(
h.state_of(&id),
"queued",
"the job must wait while the pause lasts"
);
h.until(
"the job starts when the pause ends",
Duration::from_secs(60),
|| h.has_started(&id),
);
assert!(
h.ok(&["pause"]).contains("queue: running"),
"the pause must go away by itself"
);
}
#[test]
fn a_failed_dependency_is_still_skipped_while_the_queue_is_paused() {
let h = Harness::with_default_config("pausedeps");
let failer = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "false"]);
let out = h.qex(&["wait", &failer]);
assert_eq!(out.status.code(), Some(1));
h.ok(&["pause", "queue"]);
let skipped = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--needs", &failer, "--", "true",
]);
h.until("the job is skipped", Duration::from_secs(45), || {
h.state_of(&skipped) == "skipped"
});
let out = h.qex(&["wait", &skipped, "--timeout", "10s"]);
assert_eq!(
out.status.code(),
Some(126),
"`qex wait` must give an answer while the queue is paused"
);
h.ok(&["resume"]);
}
#[test]
fn a_retry_starts_no_new_attempt_while_the_queue_is_paused() {
let h = Harness::with_default_config("pauseretry");
let id = h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--retries",
"3",
"--",
"sh",
"-c",
"echo attempt; sleep 2; exit 1",
]);
h.until(
"the first attempt operates",
Duration::from_secs(45),
|| h.state_of(&id) == "running",
);
h.ok(&["pause", "queue"]);
std::thread::sleep(Duration::from_secs(5));
let log = h.job_dir(&id).join("stdout.log");
let count = |path: &Path| {
std::fs::read_to_string(path)
.unwrap_or_default()
.matches("attempt")
.count()
};
let after_the_pause = count(&log);
let deadline = Instant::now() + Duration::from_secs(6);
while Instant::now() < deadline {
assert_eq!(
count(&log),
after_the_pause,
"a paused queue must start no attempt of a job with --retries"
);
std::thread::sleep(Duration::from_millis(400));
}
h.ok(&["resume"]);
h.until("the next attempt starts", Duration::from_secs(45), || {
count(&log) > after_the_pause
});
h.ok(&["kill", &id, "--grace", "1s"]);
}
#[test]
fn a_retry_does_not_take_a_lock_that_a_person_holds() {
let h = Harness::with_default_config("pauseretrylock");
let id = h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--lock",
"gpu0",
"--retries",
"3",
"--",
"sh",
"-c",
"echo attempt; sleep 2; exit 1",
]);
h.until(
"the first attempt operates",
Duration::from_secs(45),
|| h.state_of(&id) == "running",
);
h.ok(&["pause", "lock", "gpu0"]);
std::thread::sleep(Duration::from_secs(5));
let log = h.job_dir(&id).join("stdout.log");
let count = |path: &Path| {
std::fs::read_to_string(path)
.unwrap_or_default()
.matches("attempt")
.count()
};
let after_the_pause = count(&log);
let deadline = Instant::now() + Duration::from_secs(6);
while Instant::now() < deadline {
assert_eq!(
count(&log),
after_the_pause,
"a person holds the lock, so no attempt of that job may start"
);
std::thread::sleep(Duration::from_millis(400));
}
h.ok(&["resume", "lock", "gpu0"]);
h.until("the next attempt starts", Duration::from_secs(45), || {
count(&log) > after_the_pause
});
h.stop(&id);
}
#[test]
fn a_pause_record_that_qex_cannot_read_holds_the_queue() {
let h = Harness::with_default_config("pausebroken");
h.ok(&["pause", "queue"]);
let file = h.root.join("state/qex/run/paused.json");
assert!(file.exists(), "the pause must be a file");
h.ok(&["resume"]);
let text = h.ok(&["info", "--no-start", "--json"]);
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
let pid = v["pid"].as_i64().unwrap() as i32;
unsafe {
libc::kill(pid, libc::SIGKILL);
}
h.until("the coordinator stopped", Duration::from_secs(30), || {
let alive = unsafe { libc::kill(pid, 0) } == 0;
!alive
});
std::fs::write(&file, "{\"queue\": {\"paused_at\": ").unwrap();
let id = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
assert_eq!(
h.state_of(&id),
"queued",
"a record that qex cannot read must hold the queue"
);
std::thread::sleep(Duration::from_millis(300));
}
let info = h.ok(&["info", "--no-start", "--json"]);
let info: serde_json::Value = serde_json::from_str(&info).unwrap();
assert_eq!(info["queue_state"], "paused-by-fault");
let words = h.ok(&["pause"]);
assert!(
words.contains("could not read"),
"the report must say what happened: {words}"
);
assert!(
words.contains("qex resume queue"),
"the report must give the remedy: {words}"
);
let reason = h.status_json(&id)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
reason.contains("could not read"),
"the job must give the true reason: {reason}"
);
h.ok(&["resume"]);
h.until("the job starts again", Duration::from_secs(45), || {
h.has_started(&id)
});
}
#[test]
fn a_pause_for_zero_is_refused() {
let h = Harness::with_default_config("pausezero");
let out = h.qex(&["pause", "queue", "--for", "0"]);
assert!(!out.status.success(), "`--for 0` must not give a pause");
let words = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
words.contains("qex resume queue"),
"the error must give the remedy: {words}"
);
assert!(h.ok(&["pause"]).contains("nothing is paused"));
}
#[test]
fn a_second_pause_keeps_the_end_of_the_first() {
let h = Harness::with_default_config("pausetwice");
h.ok(&["pause", "queue", "--for", "30m", "--reason", "a video call"]);
h.ok(&["pause", "queue"]);
let words = h.ok(&["pause"]);
assert!(
!words.contains("NO END"),
"the second command must not remove the end: {words}"
);
assert!(
words.contains("a video call"),
"the second command must not remove the reason: {words}"
);
h.ok(&["resume"]);
}
#[test]
fn a_pause_does_not_expire_a_job_that_has_a_queue_limit() {
let h = Harness::with_default_config("pauseexpire");
h.ok(&["pause", "queue"]);
let id = h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--max-queue-time",
"3s",
"--",
"true",
]);
let deadline = Instant::now() + Duration::from_secs(8);
while Instant::now() < deadline {
let state = h.state_of(&id);
assert_eq!(
state, "queued",
"a paused queue must not expire a job; the job became `{state}`"
);
std::thread::sleep(Duration::from_millis(300));
}
let credited = h.status_json(&id)["queue_pause_secs"].as_u64().unwrap_or(0);
assert_eq!(
credited, 0,
"the credit belongs to the END of the pause, not to each second of it"
);
h.ok(&["resume", "queue"]);
let credited = h.status_json(&id)["queue_pause_secs"].as_u64().unwrap_or(0);
assert!(
credited >= 5,
"the resume must give the paused time back; got {credited} seconds"
);
h.until(
"the job runs after the resume",
Duration::from_secs(45),
|| h.state_of(&id) == "completed",
);
assert_eq!(
h.state_of(&id),
"completed",
"the job must run, and not expire"
);
}
#[test]
fn the_limit_still_expires_a_job_after_the_pause_ends() {
let h = Harness::with_default_config("pauseexpire2");
let holder = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "300",
]);
h.until("the first job operates", Duration::from_secs(45), || {
h.state_of(&holder) == "running"
});
h.ok(&["pause", "queue"]);
let id = h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--needs",
&holder,
"--max-queue-time",
"4s",
"--",
"true",
]);
std::thread::sleep(Duration::from_secs(7));
assert_eq!(
h.state_of(&id),
"queued",
"the pause must hold the clock of the limit"
);
h.ok(&["resume", "queue"]);
h.until("the job reaches its limit", Duration::from_secs(45), || {
h.state_of(&id) == "expired"
});
h.ok(&["kill", &holder, "--grace", "1s"]);
}
#[test]
fn a_wait_behind_a_pause_says_the_pause() {
let h = Harness::with_default_config("pausewait");
h.ok(&["pause", "queue", "--reason", "recording a demo"]);
let id = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
let out = h.qex(&["wait", &id, "--timeout", "3s"]);
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
err.contains("THE QUEUE IS PAUSED"),
"the wait must say that the queue is paused: {err}"
);
assert!(
err.contains("recording a demo"),
"the wait must give the reason of the pause: {err}"
);
assert!(
err.contains("qex resume queue"),
"the wait must give the command that ends the pause: {err}"
);
assert_eq!(
out.status.code(),
Some(124),
"the wait still reaches its own limit and gives 124"
);
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
!stdout.contains("PAUSED"),
"the pause belongs on stderr: {stdout}"
);
h.ok(&["resume"]);
}
#[test]
fn a_pause_shows_no_control_byte_of_a_reason_or_a_lock_name() {
let h = Harness::with_default_config("pausebytes");
h.ok(&["pause", "queue", "--reason", "esc\x1b[2Jbad"]);
h.ok(&["pause", "lock", "esc\x1b[2Jlock"]);
let id = h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--lock",
"esc\x1b[2Jlock",
"--",
"true",
]);
h.until(
"the job takes the reason of the pause",
Duration::from_secs(45),
|| !h.status_json(&id)["blocked_reason"].is_null(),
);
for args in [
vec!["pause"],
vec!["info"],
vec!["list"],
vec!["status", id.as_str()],
] {
let out = h.qex(&args);
let mut stream = out.stdout.clone();
stream.extend_from_slice(&out.stderr);
assert!(
!stream.windows(4).any(|w| w == b"\x1b[2J"),
"`qex {}` wrote the ESC byte of a pause",
args.join(" ")
);
}
let shown = h.ok(&["pause"]);
assert!(
shown.contains("esc [2Jbad"),
"`qex pause` must keep the reason, without the control byte: {shown}"
);
assert!(
shown.contains("esc_2Jlock"),
"`qex pause` must keep the lock name, in its safe form: {shown}"
);
h.ok(&["resume", "lock", "esc\x1b[2Jlock"]);
h.ok(&["resume"]);
}
#[test]
fn an_oversized_job_in_a_paused_queue_says_the_pause() {
let h = Harness::new(
"pauseoversized",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\noversized = \"run-when-idle\"\nsettle = \"1s\"\n",
);
h.ok(&["pause", "queue", "--reason", "recording a demo"]);
let id = h.submit(&["submit", "--cpu", "4", "--mem", "128MB", "--", "true"]);
let reason = h.status_json(&id)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
reason.contains("the queue is paused"),
"the record must give the pause as the reason: {reason}"
);
assert!(
!reason.contains("the budget is"),
"the record must not send the reader to the budget: {reason}"
);
let out = h.qex(&["submit", "--cpu", "4", "--mem", "128MB", "--", "true"]);
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
err.contains("the queue is paused"),
"the submission must name the pause: {err}"
);
assert!(
err.contains("the budget is"),
"the submission must still name the claim: {err}"
);
h.ok(&["resume"]);
}
#[test]
fn every_command_names_the_same_pauser_and_not_the_coordinator() {
let h = Harness::with_default_config("pausewho");
h.ok(&["pause", "queue", "--reason", "recording a demo"]);
let coordinator = h.ok(&["info", "--no-start", "--json"]);
let coordinator: serde_json::Value = serde_json::from_str(&coordinator).unwrap();
let coordinator_pid = coordinator["pid"].as_i64().expect("the coordinator pid");
let pauser = coordinator["paused_by_pid"]
.as_i64()
.expect("`qex info --json` must give the pid that asked for the pause");
assert_ne!(
pauser, coordinator_pid,
"the pauser is the CLI process, and never the coordinator"
);
assert!(pauser > 0, "a pid of 0 is not a process: {pauser}");
for args in [
vec!["pause"],
vec!["info"],
vec!["top", "--once"],
vec!["list"],
] {
let out = h.qex(&args);
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
text.contains(&format!("pid {pauser}")),
"`qex {}` must name the process that paused: {text}",
args.join(" ")
);
assert!(
!text.contains(&format!("by pid {coordinator_pid}")),
"`qex {}` must not name the coordinator as the pauser: {text}",
args.join(" ")
);
assert!(
!text.contains("by an unknown process"),
"this coordinator reports the pid, so no report may say unknown: {text}",
);
}
h.ok(&["resume"]);
}
#[test]
fn a_pause_that_ends_while_the_coordinator_is_down_still_gives_the_time_back() {
let h = Harness::with_default_config("pausedown");
let holder = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "300",
]);
h.until("the first job operates", Duration::from_secs(45), || {
h.state_of(&holder) == "running"
});
h.ok(&["pause", "queue", "--for", "8s"]);
let id = h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--needs",
&holder,
"--max-queue-time",
"4s",
"--",
"true",
]);
let info = h.ok(&["info", "--no-start", "--json"]);
let info: serde_json::Value = serde_json::from_str(&info).unwrap();
let pid = info["pid"].as_i64().expect("the coordinator pid") as i32;
unsafe {
libc::kill(pid, libc::SIGKILL);
}
std::thread::sleep(Duration::from_secs(12));
let credited = h.status_json(&id)["queue_pause_secs"].as_u64().unwrap_or(0);
assert!(
credited >= 6,
"a pause that ended while no coordinator operated must still give the \
time back; got {credited} seconds"
);
assert!(
credited <= 10,
"the credit must be the length of the PAUSE, and not the time until \
the restart; got {credited} seconds"
);
h.ok(&["kill", &holder, "--grace", "1s"]);
}
#[test]
fn a_job_that_already_waited_learns_the_pause() {
let h = Harness::new(
"pausealready",
"[budget]
cpu = \"1\"
mem = \"1GB\"
\
[peers]
enabled = false
\
[system]
reserve_mem = \"0\"
max_pressure = 100
",
);
let holder = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "300",
]);
h.until("the first job operates", Duration::from_secs(45), || {
h.state_of(&holder) == "running"
});
let waiter = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
h.until(
"the second job has a reason",
Duration::from_secs(45),
|| !h.status_json(&waiter)["blocked_reason"].is_null(),
);
let before = h.status_json(&waiter)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
!before.contains("paused"),
"the job must wait for something other than a pause first: {before}"
);
h.ok(&["pause", "queue", "--reason", "recording a demo"]);
h.until("the job learns the pause", Duration::from_secs(30), || {
h.status_json(&waiter)["blocked_reason"]
.as_str()
.unwrap_or("")
.contains("the queue is paused")
});
let after = h.status_json(&waiter)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
after.contains("qex resume queue"),
"the reason must give the remedy: {after}"
);
h.ok(&["resume"]);
h.ok(&["kill", &holder, "--grace", "1s"]);
}
struct OomJob {
control: PathBuf,
}
impl OomJob {
fn new(h: &Harness) -> Self {
let control = h.root.join("control");
std::fs::create_dir_all(&control).unwrap();
Self { control }
}
fn script(&self, kills: u32) -> String {
let c = self.control.display();
format!(
"until [ -f {c}/dir ]; do sleep 0.1; done; \
n=$(cat {c}/n 2>/dev/null || echo 0); n=$((n+1)); echo $n > {c}/n; \
echo attempt $n; \
if [ $n -le {kills} ]; then echo job > \"$(cat {c}/dir)/oom\"; kill -9 $$; fi; \
exit 0"
)
}
fn release(&self, h: &Harness, id: &str) {
let dir = h.root.join("state/qex/jobs").join(id);
assert!(
dir.is_dir(),
"the job directory {} is missing",
dir.display()
);
std::fs::write(self.control.join("dir"), dir.to_string_lossy().as_bytes()).unwrap();
}
fn attempts(&self) -> u32 {
std::fs::read_to_string(self.control.join("n"))
.map(|s| s.trim().parse().unwrap_or(0))
.unwrap_or(0)
}
}
#[test]
fn a_job_that_the_kernel_stops_for_memory_runs_again_with_a_larger_claim() {
let h = Harness::new(
"oomretry",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[budget]\ncpu = \"2\"\nmem = \"1GB\"\n",
);
let job = OomJob::new(&h);
let id = h.submit(&[
"submit",
"--mem",
"128MB",
"--cpu",
"1",
"--",
"bash",
"-c",
&job.script(1),
]);
job.release(&h, &id);
h.ok(&["wait", &id, "--timeout", "60s"]);
let s = h.status_json(&id);
assert_eq!(
s["state"], "completed",
"the second attempt must succeed: {s}"
);
assert_eq!(s["attempts"], 2, "qex must start the job again: {s}");
assert_eq!(job.attempts(), 2, "the job itself must run two times");
assert_eq!(s["oom_raises"], 1, "qex must count the raise: {s}");
assert_eq!(
s["mem"].as_u64().unwrap(),
256 * 1024 * 1024,
"the claim must double: {s}"
);
assert_eq!(s["claim_source"], "raised", "the claim came from qex: {s}");
let note = s["error"].as_str().unwrap_or("");
assert!(
note.contains("kernel stopped") && note.contains("TOO SMALL"),
"the record must say that the claim was too small: {note}"
);
assert!(
note.contains("128MB") && note.contains("256MB"),
"the record must give both claims: {note}"
);
let text = h.ok(&["status", &id]);
assert!(
text.contains("qex raised it"),
"the claim line must say that qex raised the claim: {text}"
);
assert!(
text.contains("note:") && !text.contains("error:"),
"a job that succeeded gives a note and not an error: {text}"
);
let logs = h.ok(&["logs", &id]);
assert!(
logs.contains("attempt 1") && logs.contains("attempt 2"),
"the log must hold every attempt: {logs}"
);
let again = h.ok(&["rerun", &id]);
h.ok(&["wait", &again, "--timeout", "60s"]);
let s = h.status_json(&again);
assert_eq!(
s["mem"].as_u64().unwrap(),
256 * 1024 * 1024,
"a rerun must use the claim in force, and not the first claim: {s}"
);
let next = h.submit(&["submit", "--", "bash", "-c", &job.script(1)]);
h.ok(&["wait", &next, "--timeout", "60s"]);
let s = h.status_json(&next);
assert_eq!(s["claim_source"], "learned", "got: {s}");
assert!(
s["mem"].as_u64().unwrap() > 128 * 1024 * 1024,
"the claim must be above the claim that the kernel stopped: {s}"
);
}
#[test]
fn a_job_that_a_user_killed_is_not_retried_and_teaches_the_learner_nothing() {
let h = Harness::new(
"userkill",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[budget]\ncpu = \"2\"\nmem = \"1GB\"\n",
);
let job = OomJob::new(&h);
let control = h.root.join("control");
let script = format!(
"until [ -f {c}/dir ]; do sleep 0.1; done; echo job > \"$(cat {c}/dir)/oom\"; \
echo ready > {c}/ready; sleep 60",
c = control.display()
);
let id = h.submit(&["submit", "--mem", "128MB", "--", "bash", "-c", &script]);
h.until("the job operates", Duration::from_secs(30), || {
h.state_of(&id) == "running"
});
job.release(&h, &id);
h.until("the job made the record", Duration::from_secs(30), || {
control.join("ready").exists()
});
h.ok(&["kill", &id, "--signal", "KILL", "--grace", "1s"]);
h.until("the job stops", Duration::from_secs(30), || {
h.status_json(&id)["state"]
.as_str()
.map(|s| s != "running" && s != "starting")
.unwrap_or(false)
});
let s = h.status_json(&id);
assert_eq!(s["state"], "killed", "a command stopped this job: {s}");
assert_eq!(s["attempts"], 1, "qex must not start the job again: {s}");
assert_eq!(s["oom_raises"], 0, "qex must not raise the claim: {s}");
assert_eq!(
s["mem"].as_u64().unwrap(),
128 * 1024 * 1024,
"the claim must not change: {s}"
);
let store = h.root.join("state/qex/usage.json");
assert!(
!store.exists(),
"a job that a command stopped must teach the learner nothing, and the store holds: {}",
std::fs::read_to_string(&store).unwrap_or_default()
);
}
#[test]
fn a_claim_that_stays_too_small_stops_at_the_limit() {
let h = Harness::new(
"oomlimit",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[retry]\non_oom = 2\ngrowth = 2.0\n",
);
let job = OomJob::new(&h);
let id = h.submit(&[
"submit",
"--mem",
"128MB",
"--cpu",
"1",
"--",
"bash",
"-c",
&job.script(9),
]);
job.release(&h, &id);
let out = h.qex(&["wait", &id, "--timeout", "90s"]);
assert_eq!(out.status.code(), Some(99), "got: {out:?}");
let s = h.status_json(&id);
assert_eq!(s["state"], "oom", "got: {s}");
assert_eq!(s["attempts"], 3, "one attempt and two raises: {s}");
assert_eq!(s["oom_raises"], 2, "got: {s}");
assert_eq!(
s["mem"].as_u64().unwrap(),
512 * 1024 * 1024,
"the claim doubles two times: {s}"
);
let note = s["error"].as_str().unwrap_or("");
assert!(
note.contains("TOO SMALL") && note.contains("--mem"),
"the record must say what the user must do: {note}"
);
assert!(
note.contains("128MB") && note.contains("512MB"),
"the record must give the first claim and the last one: {note}"
);
}
#[test]
fn a_kill_for_memory_with_no_limit_reports_but_does_not_run_the_job_again() {
let h = Harness::new(
"oomsession",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[budget]\ncpu = \"2\"\nmem = \"1GB\"\n",
);
let job = OomJob::new(&h);
let control = h.root.join("control");
let script = format!(
"until [ -f {c}/dir ]; do sleep 0.1; done; echo session > \"$(cat {c}/dir)/oom\"; \
kill -9 $$",
c = control.display()
);
let id = h.submit(&["submit", "--mem", "128MB", "--", "bash", "-c", &script]);
job.release(&h, &id);
let out = h.qex(&["wait", &id, "--timeout", "60s"]);
assert_eq!(out.status.code(), Some(99), "got: {out:?}");
let s = h.status_json(&id);
assert_eq!(s["state"], "oom", "got: {s}");
assert_eq!(s["attempts"], 1, "qex must not start the job again: {s}");
assert_eq!(s["oom_raises"], 0, "qex must not raise the claim: {s}");
assert_eq!(
s["mem"].as_u64().unwrap(),
128 * 1024 * 1024,
"the claim must not change: {s}"
);
let note = s["error"].as_str().unwrap_or("");
assert!(
note.contains("did NOT start the job again"),
"the record must say that qex did not act: {note}"
);
assert!(
note.contains("enforce") && note.contains("--mem"),
"the record must say what the user can do: {note}"
);
let store = h.root.join("state/qex/usage.json");
assert!(
!store.exists(),
"a kill that qex cannot connect to this job must teach it nothing, and the store holds: {}",
std::fs::read_to_string(&store).unwrap_or_default()
);
}
#[test]
fn a_raised_claim_waits_for_capacity_in_the_queue() {
let h = Harness::new(
"oomreadmit",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[budget]\ncpu = \"4\"\nmem = \"1GB\"\n",
);
let job = OomJob::new(&h);
let control = h.root.join("control");
let holder = h.submit(&[
"submit",
"--name",
"holder",
"--mem",
"400MB",
"--cpu",
"1",
"--",
"bash",
"-c",
&format!(
"until [ -f {c}/stop ]; do sleep 0.2; done",
c = control.display()
),
]);
let id = h.submit(&[
"submit",
"--name",
"grower",
"--mem",
"600MB",
"--cpu",
"1",
"--",
"bash",
"-c",
&job.script(1),
]);
h.until("both jobs operate", Duration::from_secs(30), || {
h.state_of(&holder) == "running" && h.state_of(&id) == "running"
});
job.release(&h, &id);
h.until("qex raised the claim", Duration::from_secs(30), || {
h.status_json(&id)["oom_raises"].as_u64() == Some(1)
});
let s = h.status_json(&id);
assert_eq!(
s["mem"].as_u64().unwrap(),
1024 * 1024 * 1024,
"the raise must stop at the budget: {s}"
);
let deadline = Instant::now() + Duration::from_secs(4);
let mut saw_reason = false;
while Instant::now() < deadline {
let s = h.status_json(&id);
let state = s["state"].as_str().unwrap_or("");
assert_eq!(
state, "queued",
"the job must wait for capacity for its new claim: {s}"
);
assert_eq!(
h.state_of(&holder),
"running",
"the job that holds the budget must still operate"
);
if s["blocked_reason"]
.as_str()
.map(|r| r.contains("memory"))
.unwrap_or(false)
{
saw_reason = true;
}
std::thread::sleep(Duration::from_millis(200));
}
assert!(
saw_reason,
"the job must say that it waits for memory: {}",
h.status_json(&id)
);
std::fs::write(control.join("stop"), b"1").unwrap();
h.ok(&["wait", &holder, "--timeout", "60s"]);
h.ok(&["wait", &id, "--timeout", "60s"]);
let s = h.status_json(&id);
assert_eq!(s["state"], "completed", "got: {s}");
assert_eq!(s["attempts"], 2, "got: {s}");
}
#[test]
fn a_mark_from_one_attempt_does_not_decide_the_next_attempt() {
let h = Harness::new(
"oommarks",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[budget]\ncpu = \"2\"\nmem = \"1GB\"\n",
);
let job = OomJob::new(&h);
let c = h.root.join("control");
let script = format!(
"until [ -f {c}/dir ]; do sleep 0.1; done; \
n=$(cat {c}/n 2>/dev/null || echo 0); n=$((n+1)); echo $n > {c}/n; \
if [ $n -eq 1 ]; then trap 'exit 3' TERM; touch {c}/ready; sleep 60 & wait; fi; \
if [ $n -eq 2 ]; then echo job > \"$(cat {c}/dir)/oom\"; kill -9 $$; fi; \
exit 0",
c = c.display()
);
let id = h.submit(&[
"submit",
"--retries",
"2",
"--mem",
"128MB",
"--cpu",
"1",
"--",
"bash",
"-c",
&script,
]);
job.release(&h, &id);
h.until("attempt 1 operates", Duration::from_secs(30), || {
c.join("ready").exists()
});
h.ok(&["kill", &id, "--signal", "TERM", "--grace", "30s"]);
h.ok(&["wait", &id, "--timeout", "90s"]);
let s = h.status_json(&id);
assert_eq!(s["state"], "completed", "got: {s}");
assert_eq!(
s["attempts"], 3,
"one kill, one kill for memory, one run: {s}"
);
assert_eq!(
s["oom_raises"], 1,
"the mark of attempt 1 must not hide the kill for memory of attempt 2: {s}"
);
assert_eq!(
s["mem"].as_u64().unwrap(),
256 * 1024 * 1024,
"the claim must double after the kill for memory: {s}"
);
assert_eq!(
s["retries_left"], 1,
"a kill for memory must not spend a `--retries` credit: {s}"
);
}
#[test]
fn a_retry_after_a_kill_for_memory_is_never_expired_by_the_wait_limit() {
let h = Harness::new(
"oomqueuetime",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[budget]\ncpu = \"2\"\nmem = \"1GB\"\n",
);
let job = OomJob::new(&h);
let c = job.control.display();
let script = format!(
"until [ -f {c}/dir ]; do sleep 0.1; done; \
n=$(cat {c}/n 2>/dev/null || echo 0); n=$((n+1)); echo $n > {c}/n; \
echo attempt $n; \
if [ $n -le 1 ]; then sleep 7; echo job > \"$(cat {c}/dir)/oom\"; kill -9 $$; fi; \
exit 0"
);
let blocker = h.submit(&[
"submit", "--mem", "800MB", "--cpu", "1", "--", "sleep", "12",
]);
let id = h.submit(&[
"submit",
"--mem",
"128MB",
"--cpu",
"1",
"--max-queue-time",
"5s",
"--",
"bash",
"-c",
&script,
]);
job.release(&h, &id);
h.ok(&["wait", &id, "--timeout", "90s"]);
h.ok(&["wait", &blocker, "--timeout", "90s"]);
let s = h.status_json(&id);
assert_eq!(
job.attempts(),
2,
"the job itself must run two times, and it ran {} time(s): {s}",
job.attempts()
);
assert_ne!(
s["state"], "expired",
"a job that RAN for longer than its wait limit must not expire when qex gives it back \
to the queue: {s}"
);
assert_eq!(
s["state"], "completed",
"the second attempt must succeed: {s}"
);
assert_eq!(s["attempts"], 2, "qex must start the job again: {s}");
assert_eq!(
s["mem"].as_u64().unwrap(),
256 * 1024 * 1024,
"the claim must double: {s}"
);
}
#[test]
fn a_raised_claim_reaches_the_environment_of_the_job() {
let h = Harness::new(
"oomclaimenv",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[budget]\ncpu = \"2\"\nmem = \"1GB\"\n",
);
let job = OomJob::new(&h);
let c = job.control.display();
let script = format!(
"until [ -f {c}/dir ]; do sleep 0.1; done; \
n=$(cat {c}/n 2>/dev/null || echo 0); n=$((n+1)); echo $n > {c}/n; \
echo \"$n QEX_MEM=$QEX_MEM GOMEMLIMIT=$GOMEMLIMIT\" >> {c}/heard; \
if [ $n -le 1 ]; then echo job > \"$(cat {c}/dir)/oom\"; kill -9 $$; fi; \
exit 0"
);
let id = h.submit(&[
"submit",
"--mem",
"128MB",
"--cpu",
"1",
"--env-capture",
"minimal",
"--",
"bash",
"-c",
&script,
]);
job.release(&h, &id);
h.ok(&["wait", &id, "--timeout", "60s"]);
let s = h.status_json(&id);
assert_eq!(
s["state"], "completed",
"the second attempt must succeed: {s}"
);
let heard = std::fs::read_to_string(job.control.join("heard")).unwrap_or_default();
let lines: Vec<&str> = heard.lines().collect();
assert_eq!(lines.len(), 2, "the job must run two times: {heard}");
let first = 128 * 1024 * 1024u64;
let raised = 256 * 1024 * 1024u64;
assert!(
lines[0].contains(&format!("QEX_MEM={first}")),
"attempt 1 must hear the first claim: {heard}"
);
assert!(
lines[1].contains(&format!("QEX_MEM={raised}")),
"attempt 2 must hear the RAISED claim: {heard}"
);
assert!(
lines[1].contains(&format!("GOMEMLIMIT={raised}")),
"a runtime limit must move with the claim: {heard}"
);
}
fn shared_peer_dir(tag: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("qxpeers-{}-{tag}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o1777)).unwrap();
dir
}
fn peer_config(dir: &Path, cpu: &str, max_bypass: u32) -> String {
format!(
"[budget]\ncpu = \"{cpu}\"\nmem = \"2GB\"\n\
[peers]\nenabled = true\ndir = \"{}\"\nstale_after = \"1h\"\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\nmax_bypass = {max_bypass}\n",
dir.display()
)
}
fn blocked_reason(h: &Harness, id: &str) -> String {
h.status_json(id)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string()
}
#[test]
fn a_job_that_another_user_holds_back_does_not_park_the_jobs_behind_it() {
let dir = shared_peer_dir("hol-a");
let config = peer_config(&dir, "4", 2);
let other = Harness::new("holpeerb", &config);
let mine = Harness::new("holpeera", &config);
let held = other.submit(&[
"submit", "--cpu", "3", "--mem", "64MB", "--", "sleep", "300",
]);
other.until(
"the other user's job starts",
Duration::from_secs(45),
|| other.state_of(&held) == "running",
);
let big = mine.submit(&["submit", "--cpu", "4", "--mem", "64MB", "--", "true"]);
mine.until(
"the job at the front names the other user",
Duration::from_secs(45),
|| blocked_reason(&mine, &big).contains("another user holds capacity"),
);
let a = mine.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
let b = mine.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
mine.until(
"the two small jobs behind the front of the queue run",
Duration::from_secs(60),
|| mine.state_of(&a) == "completed" && mine.state_of(&b) == "completed",
);
assert_eq!(mine.state_of(&big), "queued");
let reason = blocked_reason(&mine, &big);
assert!(
reason.contains("another user holds capacity") && reason.contains("no known end"),
"the reason must name the other user: {reason}"
);
assert!(
!reason.contains("front of the queue"),
"the job at the front must not read a position sentence: {reason}"
);
let info: serde_json::Value = serde_json::from_str(&mine.ok(&["info", "--json"])).unwrap();
assert_eq!(info["queue_state"], "waits-for-peer", "info: {info}");
assert!(
info["peer_cpu"].as_u64().unwrap_or(0) >= 3,
"qex info must report the cores of the other user: {info}"
);
other.ok(&["kill", &held, "--grace", "1s"]);
mine.qex(&["cancel", &big]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_job_behind_a_job_that_keeps_no_capacity_gets_its_own_reason() {
let dir = shared_peer_dir("hol-own");
let config = peer_config(&dir, "4", 2);
let other = Harness::new("holownb", &config);
let mine = Harness::new("holowna", &config);
let held = other.submit(&[
"submit", "--cpu", "3", "--mem", "64MB", "--", "sleep", "300",
]);
other.until(
"the other user's job starts",
Duration::from_secs(45),
|| other.state_of(&held) == "running",
);
let big = mine.submit(&[
"submit", "--cpu", "4", "--mem", "64MB", "--", "sleep", "300",
]);
mine.until(
"the job at the front names the other user",
Duration::from_secs(45),
|| blocked_reason(&mine, &big).contains("another user holds capacity"),
);
let a = mine.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "300",
]);
let b = mine.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "300",
]);
mine.until(
"the first job behind starts",
Duration::from_secs(45),
|| mine.state_of(&a) == "running",
);
mine.until(
"the second job behind gives a reason of its own",
Duration::from_secs(45),
|| !blocked_reason(&mine, &b).is_empty(),
);
let reason = blocked_reason(&mine, &b);
assert!(
reason.contains("another user holds capacity"),
"the job behind must learn the true cause: {reason}"
);
assert!(
!reason.contains("front of the queue"),
"a job behind a job that keeps no capacity must not read a position: {reason}"
);
other.ok(&["kill", &held, "--grace", "1s"]);
for id in [&big, &a, &b] {
mine.qex(&["kill", id, "--grace", "1s"]);
mine.qex(&["cancel", id]);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_job_blocked_by_a_peer_becomes_unpassable_when_the_holder_changes() {
let dir = shared_peer_dir("hol-b");
let config = peer_config(&dir, "4", 2);
let other = Harness::new("holcarryb", &config);
let mine = Harness::new("holcarrya", &config);
let held = other.submit(&[
"submit", "--cpu", "2", "--mem", "64MB", "--", "sleep", "300",
]);
other.until(
"the other user's job starts",
Duration::from_secs(45),
|| other.state_of(&held) == "running",
);
let big = mine.submit(&[
"submit", "--cpu", "3", "--mem", "64MB", "--", "sleep", "300",
]);
mine.until(
"the job at the front names the other user",
Duration::from_secs(45),
|| blocked_reason(&mine, &big).contains("another user holds capacity"),
);
assert_eq!(
mine.status_json(&big)["passed_by"].as_u64(),
Some(0),
"no job passed it yet"
);
let small: Vec<String> = (0..3)
.map(|_| {
mine.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "300",
])
})
.collect();
mine.until(
"two small jobs pass the job at the front",
Duration::from_secs(60),
|| mine.state_of(&small[0]) == "running" && mine.state_of(&small[1]) == "running",
);
mine.until(
"the queue keeps the capacity",
Duration::from_secs(45),
|| {
let info: serde_json::Value =
serde_json::from_str(&mine.ok(&["info", "--json"])).unwrap_or_default();
info["queue_state"] == "held"
},
);
assert_eq!(
mine.status_json(&big)["passed_by"].as_u64(),
Some(2),
"the count must carry across the change of the holder, and not start again"
);
other.ok(&["kill", &held, "--grace", "1s"]);
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
assert_eq!(
mine.state_of(&small[2]),
"queued",
"a small job passed the job at the front after the other user released"
);
std::thread::sleep(Duration::from_millis(200));
}
assert_eq!(
mine.status_json(&big)["passed_by"].as_u64(),
Some(2),
"the release of the other user must not reset the count"
);
let reason = blocked_reason(&mine, &small[2]);
assert!(
reason.contains("qex keeps the capacity for that job"),
"the job behind must read WHY qex holds it: {reason}"
);
assert!(
blocked_reason(&mine, &big).contains("qex starts no other job before this one"),
"the job at the front must say that it keeps the capacity"
);
mine.ok(&["kill", &small[0], "--grace", "1s"]);
mine.until(
"the job at the front starts",
Duration::from_secs(45),
|| mine.has_started(&big),
);
for id in &small {
mine.qex(&["kill", id, "--grace", "1s"]);
mine.qex(&["cancel", id]);
}
mine.qex(&["kill", &big, "--grace", "1s"]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_stream_of_small_jobs_does_not_pass_a_large_job_for_ever() {
let h = Harness::new(
"holstarve",
"[budget]\ncpu = \"4\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\nmax_bypass = 2\n",
);
let holder = h.submit(&[
"submit", "--cpu", "2", "--mem", "64MB", "--", "sleep", "300",
]);
h.until("the first job starts", Duration::from_secs(45), || {
h.state_of(&holder) == "running"
});
let big = h.submit(&["submit", "--cpu", "4", "--mem", "64MB", "--", "true"]);
let small: Vec<String> = (0..6)
.map(|_| h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]))
.collect();
let done = |h: &Harness| -> usize { small.iter().filter(|id| h.has_started(id)).count() };
h.until(
"two small jobs pass the large job",
Duration::from_secs(60),
|| done(&h) >= 2,
);
let deadline = Instant::now() + Duration::from_secs(4);
while Instant::now() < deadline {
assert!(
done(&h) <= 2,
"more than 2 small jobs passed the large job, so the bypass has no bound"
);
std::thread::sleep(Duration::from_millis(200));
}
assert_eq!(h.state_of(&big), "queued");
let info: serde_json::Value = serde_json::from_str(&h.ok(&["info", "--json"])).unwrap();
assert!(
info["last_start_at"].as_u64().is_some(),
"qex info must report the time of the last start: {info}"
);
h.ok(&["kill", &holder, "--grace", "1s"]);
h.until("the large job runs", Duration::from_secs(45), || {
h.state_of(&big) == "completed"
});
h.until("each small job runs", Duration::from_secs(60), || {
done(&h) == 6
});
let record = h.status_json(&big);
assert_eq!(
record["passed_by"].as_u64(),
Some(0),
"a job that started waits for nothing: {record}"
);
assert!(
record["blocked_since"].is_null(),
"a job that started holds no blocked_since: {record}"
);
}
#[test]
fn max_bypass_zero_lets_no_job_pass_the_front_of_the_queue() {
let h = Harness::new(
"holstrict",
"[budget]\ncpu = \"4\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\nmax_bypass = 0\n",
);
let holder = h.submit(&[
"submit", "--cpu", "2", "--mem", "64MB", "--", "sleep", "300",
]);
h.until("the first job starts", Duration::from_secs(45), || {
h.state_of(&holder) == "running"
});
let big = h.submit(&["submit", "--cpu", "4", "--mem", "64MB", "--", "true"]);
let small: Vec<String> = (0..3)
.map(|_| h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]))
.collect();
h.until("the queue is held", Duration::from_secs(45), || {
let info: serde_json::Value =
serde_json::from_str(&h.ok(&["info", "--json"])).unwrap_or_default();
info["queue_state"] == "held"
});
let deadline = Instant::now() + Duration::from_secs(4);
while Instant::now() < deadline {
for id in &small {
assert_eq!(
h.state_of(id),
"queued",
"with max_bypass = 0 no job may pass the job at the front"
);
}
std::thread::sleep(Duration::from_millis(200));
}
assert_eq!(
h.status_json(&big)["passed_by"].as_u64(),
Some(0),
"no job passed the job at the front"
);
h.ok(&["kill", &holder, "--grace", "1s"]);
h.until("each job runs", Duration::from_secs(60), || {
h.state_of(&big) == "completed" && small.iter().all(|id| h.has_started(id))
});
}
#[test]
fn a_job_that_the_config_parks_does_not_park_the_jobs_behind_it() {
let h = Harness::new(
"holparked",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\noversized = \"queue\"\n",
);
let big = h.submit(&["submit", "--cpu", "64", "--mem", "64MB", "--", "true"]);
let behind: Vec<String> = (0..3)
.map(|_| h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]))
.collect();
h.until(
"every job behind the large job runs",
Duration::from_secs(60),
|| behind.iter().all(|id| h.state_of(id) == "completed"),
);
assert_eq!(h.state_of(&big), "queued");
let reason = blocked_reason(&h, &big);
assert!(
reason.contains("keeps this job in the queue"),
"the reason must name the config file: {reason}"
);
assert!(
reason.contains("starts the jobs behind it"),
"the reason must say that the queue continues: {reason}"
);
}
fn pool_config(devices: &str, extra: &str) -> String {
format!(
"[budget]\ncpu = \"8\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[[pool]]\nname = \"gpu\"\nsize = \"vram\"\ndevices = [{devices}]\n\
env = \"CUDA_VISIBLE_DEVICES\"\n{extra}"
)
}
#[test]
fn a_machine_with_no_gpu_schedules_a_gpu_claim_from_the_configuration() {
let h = Harness::new("gpupromise", &pool_config("\"24GB\", \"24GB\"", ""));
let id = h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--gpu",
"1",
"--",
"sh",
"-c",
"echo cuda=$CUDA_VISIBLE_DEVICES; echo qex=$QEX_GPU_DEVICES; echo vram=$QEX_GPU_VRAM",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
assert_eq!(h.state_of(&id), "completed");
let out = h.ok(&["logs", &id, "--stdout"]);
assert!(
out.contains("cuda=0"),
"the pool must write its variable: {out}"
);
assert!(
out.contains("qex=0"),
"every indexed pool gets QEX_..._DEVICES: {out}"
);
assert!(
out.contains(&format!("vram={}", 24u64 << 30)),
"a whole device gives its capacity: {out}"
);
let status = h.status_json(&id);
assert_eq!(status["assigned"]["gpu"]["devices"][0], 0);
assert_eq!(status["assigned"]["gpu"]["units"], 1);
}
#[test]
fn two_gpu_jobs_get_different_devices_and_a_third_waits() {
let h = Harness::new("gpushare", &pool_config("\"24GB\", \"24GB\"", ""));
let ids: Vec<String> = (0..3)
.map(|i| {
h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--gpu",
"1",
"--name",
&format!("g{i}"),
"--",
"sleep",
"60",
])
})
.collect();
h.until("two GPU jobs operate", Duration::from_secs(45), || {
ids.iter().filter(|id| h.state_of(id) == "running").count() == 2
});
let running: Vec<serde_json::Value> = ids
.iter()
.map(|id| h.status_json(id))
.filter(|s| s["state"] == "running")
.collect();
let mut devices: Vec<i64> = running
.iter()
.map(|s| s["assigned"]["gpu"]["devices"][0].as_i64().unwrap())
.collect();
devices.sort_unstable();
assert_eq!(
devices,
vec![0, 1],
"two jobs must hold two different devices"
);
let waiting = ids
.iter()
.find(|id| h.state_of(id) == "queued")
.expect("one job must wait");
let reason = h.status_json(waiting)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
reason.contains("gpu"),
"the reason must name the pool: {reason}"
);
for id in &ids {
h.qex(&["kill", id, "--grace", "1s"]);
}
}
#[test]
fn a_vram_claim_above_the_largest_device_is_refused_at_the_submission() {
let h = Harness::new(
"vramsum",
&pool_config("\"24GB\", \"24GB\", \"24GB\", \"24GB\"", ""),
);
let out = h.qex(&[
"submit", "--cpu", "1", "--mem", "64MB", "--gpu", "2", "--vram", "40GB", "--", "true",
]);
assert!(!out.status.success(), "qex must refuse this job");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("never start"),
"the message must say that the job can never start: {err}"
);
assert!(
err.contains("24GB"),
"the message must name the largest device: {err}"
);
assert!(
h.list_json().is_empty(),
"a refused job must leave no record"
);
}
#[test]
fn a_gpu_claim_above_the_pool_total_is_refused_whatever_the_oversized_policy() {
let h = Harness::new(
"gputoobig",
&pool_config(
"\"24GB\", \"24GB\"",
"[queue]\noversized = \"run-when-idle\"\n",
),
);
let out = h.qex(&[
"submit", "--cpu", "1", "--mem", "64MB", "--gpu", "8", "--", "true",
]);
assert!(!out.status.success(), "qex must refuse this job");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("never start"), "got: {err}");
}
#[test]
fn a_gpu_assignment_survives_a_coordinator_that_stops_and_starts() {
let h = Harness::new("gpurecover", &pool_config("\"24GB\"", ""));
let long = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--gpu", "1", "--", "sleep", "60",
]);
h.until("the GPU job operates", Duration::from_secs(45), || {
h.state_of(&long) == "running"
});
let device = h.status_json(&long)["assigned"]["gpu"]["devices"][0]
.as_i64()
.unwrap();
let pid = h.coordinator_pid();
unsafe {
libc::kill(pid, libc::SIGKILL);
}
h.until("the coordinator stops", Duration::from_secs(20), || {
(unsafe { libc::kill(pid, 0) }) != 0
});
let second = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--gpu", "1", "--", "true",
]);
assert_ne!(h.coordinator_pid(), pid, "a new coordinator must operate");
assert_eq!(
h.status_json(&long)["assigned"]["gpu"]["devices"][0]
.as_i64()
.unwrap(),
device,
"the assignment must not change when a coordinator restarts"
);
std::thread::sleep(Duration::from_secs(2));
assert_eq!(
h.state_of(&second),
"queued",
"the only device is in use, so the second job must wait"
);
h.qex(&["kill", &long, "--grace", "1s"]);
}
#[test]
fn a_lock_needs_no_configuration_and_still_excludes() {
let h = Harness::new(
"lockstill",
"[budget]\ncpu = \"8\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let first = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--lock", "target", "--", "sleep", "60",
]);
h.until("the first job operates", Duration::from_secs(45), || {
h.state_of(&first) == "running"
});
let second = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--lock", "target", "--", "true",
]);
std::thread::sleep(Duration::from_secs(2));
assert_eq!(
h.state_of(&second),
"queued",
"two jobs with one lock name must never operate together"
);
let reason = h.status_json(&second)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(reason.contains("lock `target`"), "got: {reason}");
let free = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
h.ok(&["wait", &free, "--timeout", "45s"]);
assert_eq!(h.state_of(&free), "completed");
h.qex(&["kill", &first, "--grace", "1s"]);
h.ok(&["wait", &second, "--timeout", "45s"]);
assert_eq!(h.state_of(&second), "completed");
}
#[test]
fn a_counted_pool_lets_n_jobs_operate_and_makes_the_next_one_wait() {
let h = Harness::new(
"netpool",
"[budget]\ncpu = \"8\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[[pool]]\nname = \"net\"\ncount = 2\n",
);
let ids: Vec<String> = (0..3)
.map(|_| {
h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--claim", "net=1", "--", "sleep", "60",
])
})
.collect();
h.until(
"two jobs of the pool operate",
Duration::from_secs(45),
|| ids.iter().filter(|id| h.state_of(id) == "running").count() == 2,
);
std::thread::sleep(Duration::from_secs(2));
assert_eq!(
ids.iter().filter(|id| h.state_of(id) == "running").count(),
2,
"a pool of 2 must never hold 3 jobs"
);
for id in &ids {
h.qex(&["kill", id, "--grace", "1s"]);
}
}
#[test]
fn an_undeclared_pool_is_a_lock_and_two_units_of_it_are_refused() {
let h = Harness::with_default_config("undeclared");
let one = h.submit(&["submit", "--claim", "thing=1", "--", "true"]);
h.ok(&["wait", &one, "--timeout", "45s"]);
assert_eq!(h.state_of(&one), "completed");
let out = h.qex(&["submit", "--claim", "thing=2", "--", "true"]);
assert!(
!out.status.success(),
"qex must refuse 2 of an undeclared pool"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("lock of size 1"), "got: {err}");
assert!(
err.contains("[[pool]]"),
"the message must give the remedy: {err}"
);
}
#[test]
fn a_pool_with_no_devices_gives_the_job_its_count() {
let h = Harness::new(
"netenv",
"[budget]\ncpu = \"8\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[[pool]]\nname = \"net\"\ncount = 4\n",
);
let plain = h.submit(&["submit", "--", "sh", "-c", "echo net=[$QEX_CLAIM_NET]"]);
h.ok(&["wait", &plain, "--timeout", "45s"]);
assert!(
h.ok(&["logs", &plain, "--stdout"]).contains("net=[]"),
"a job with no claim must get no variable"
);
let id = h.submit(&[
"submit",
"--claim",
"net=2",
"--",
"sh",
"-c",
"echo net=[$QEX_CLAIM_NET]",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
assert_eq!(h.state_of(&id), "completed");
let out = h.ok(&["logs", &id, "--stdout"]);
assert!(
out.contains("net=[2]"),
"a counted claim must reach the job: {out}"
);
}
#[test]
fn a_pool_claim_that_is_refused_gives_the_dedupe_key_back() {
let h = Harness::new("pooldedupe", &pool_config("\"24GB\", \"24GB\"", ""));
let out = h.qex(&[
"submit",
"--dedupe-key",
"poolkey",
"--gpu",
"8",
"--",
"true",
]);
assert!(
!out.status.success(),
"a claim of 8 of a pool of 2 must be refused"
);
let id = h.submit(&["submit", "--dedupe-key", "poolkey", "--", "true"]);
h.ok(&["wait", &id, "--timeout", "45s"]);
assert_eq!(
h.state_of(&id),
"completed",
"the key of a refused job must be free"
);
}
#[test]
fn a_job_that_sets_the_device_variable_itself_is_refused() {
let h = Harness::new("cudaconflict", &pool_config("\"24GB\"", ""));
let out = h.qex(&[
"submit",
"--gpu",
"1",
"--env",
"CUDA_VISIBLE_DEVICES=0",
"--",
"true",
]);
assert!(!out.status.success(), "qex must refuse this job");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("would disagree"), "got: {err}");
}
#[test]
fn two_users_do_not_both_get_the_one_device() {
let dir = shared_peer_dir("gpu-share");
let config = format!(
"[budget]\ncpu = \"8\"\nmem = \"4GB\"\n\
[peers]\nenabled = true\ndir = \"{}\"\nstale_after = \"1h\"\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[[pool]]\nname = \"gpu\"\nsize = \"vram\"\ndevices = [\"24GB\"]\n\
env = \"CUDA_VISIBLE_DEVICES\"\n",
dir.display()
);
let other = Harness::new("gpupeerb", &config);
let mine = Harness::new("gpupeera", &config);
let held = other.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--gpu", "1", "--", "sleep", "300",
]);
other.until(
"the other user's GPU job starts",
Duration::from_secs(45),
|| other.state_of(&held) == "running",
);
assert_eq!(
other.status_json(&held)["assigned"]["gpu"]["devices"][0],
0,
"the other user must hold the device 0"
);
let info: serde_json::Value = serde_json::from_str(&mine.ok(&["info", "--json"])).unwrap();
assert_eq!(info["jobs_running"], 0, "my queue must be empty: {info}");
let want = mine.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--gpu", "1", "--", "true",
]);
mine.until(
"the GPU job names the other user",
Duration::from_secs(45),
|| blocked_reason(&mine, &want).contains("another user holds the pool"),
);
assert_eq!(mine.state_of(&want), "queued");
assert!(
mine.status_json(&want)["assigned"]["gpu"].is_null(),
"a job that waits has received no device: {}",
mine.status_json(&want)
);
let reason = blocked_reason(&mine, &want);
assert!(
reason.contains("`gpu`") && reason.contains("no known end"),
"the reason must name the pool and say that qex cannot schedule the end: {reason}"
);
assert!(
!reason.contains("front of the queue"),
"a wait on another user must not read as a queue position: {reason}"
);
let behind = mine.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
mine.until(
"a job behind the GPU job still starts",
Duration::from_secs(45),
|| mine.state_of(&behind) == "completed",
);
let info: serde_json::Value = serde_json::from_str(&mine.ok(&["info", "--json"])).unwrap();
assert_eq!(info["queue_state"], "waits-for-peer", "info: {info}");
assert_eq!(
info["pools"][0]["devices"][0]["peer"], true,
"qex info must mark the device that another user holds: {info}"
);
other.ok(&["kill", &held, "--grace", "1s"]);
mine.until("the device comes free", Duration::from_secs(60), || {
mine.state_of(&want) == "completed"
});
assert_eq!(
mine.status_json(&want)["assigned"]["gpu"]["devices"][0],
0,
"the one device must go to this user once the other user stops"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn info_reports_the_pools_and_their_devices() {
let h = Harness::new("poolinfo", &pool_config("\"24GB\", \"16GB\"", ""));
let text = h.ok(&["info", "--json"]);
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
let pools = v["pools"].as_array().expect("info must report the pools");
assert_eq!(pools.len(), 1);
assert_eq!(pools[0]["name"], "gpu");
assert_eq!(pools[0]["total"], 2);
assert_eq!(pools[0]["devices"][1]["capacity"], 16u64 << 30);
let human = h.ok(&["info"]);
assert!(human.contains("pool gpu"), "got: {human}");
}