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)
});
if self.state_of(id) == "running" {
self.ok(&["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 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 write_config(&self, config: &str) {
std::fs::write(self.root.join("cfg/qex.toml"), config).unwrap();
}
}
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 the_passthrough_option_gives_the_exit_code_of_the_job() {
let h = Harness::with_default_config("pass");
let id = h.submit(&["submit", "--", "sh", "-c", "exit 42"]);
let out = h.qex(&["wait", &id, "--passthrough"]);
assert_eq!(out.status.code(), Some(42));
}
#[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(124),
"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(1), "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_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"] {
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 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[hooks]\non_stop = [\"true\"]\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}"
);
}
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, "--passthrough"]);
assert_eq!(
out.status.code(),
wait.status.code(),
"`qex run` and `qex wait --passthrough` 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}"
);
assert!(
info["config_error"].is_null(),
"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}"
);
}