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,
extra_env: Vec<(String, String)>,
}
fn describe_stream(name: &str, bytes: &[u8]) -> String {
const KEEP: usize = 20;
let raw = String::from_utf8_lossy(bytes);
let text = raw.trim_end_matches('\n');
if text.is_empty() {
return format!("{name}: no output");
}
let lines: Vec<&str> = text.lines().collect();
if lines.len() <= KEEP {
return format!("{name}:\n{}", lines.join("\n"));
}
format!(
"{name}: the last {KEEP} lines of {}, and {} more above:\n{}",
lines.len(),
lines.len() - KEEP,
lines[lines.len() - KEEP..].join("\n")
)
}
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,
extra_env: Vec::new(),
}
}
fn with_default_config(name: &str) -> Self {
Self::new(
name,
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
)
}
fn command(&self, args: &[&str]) -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_qex"));
cmd.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")
.envs(self.extra_env.iter().map(|(k, v)| (k.as_str(), v.as_str())));
cmd
}
fn qex(&self, args: &[&str]) -> Output {
self.command(args)
.output()
.unwrap_or_else(|e| panic!("`qex {}` did not start: {e}", args.join(" ")))
}
fn qex_within(&self, args: &[&str], limit: Duration) -> Output {
let shown = args.join(" ");
let mut child = self
.command(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap_or_else(|e| panic!("`qex {shown}` did not start: {e}"));
let mut out_pipe = child
.stdout
.take()
.unwrap_or_else(|| panic!("`qex {shown}` gives no stdout"));
let mut err_pipe = child
.stderr
.take()
.unwrap_or_else(|| panic!("`qex {shown}` gives no stderr"));
let read_out = std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = std::io::Read::read_to_end(&mut out_pipe, &mut buf);
buf
});
let read_err = std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = std::io::Read::read_to_end(&mut err_pipe, &mut buf);
buf
});
let started = Instant::now();
let status = loop {
match child
.try_wait()
.unwrap_or_else(|e| panic!("the test cannot read `qex {shown}`: {e}"))
{
Some(status) => break status,
None if started.elapsed() >= limit => {
child.kill().ok();
child.wait().ok();
let out = read_out.join().unwrap_or_default();
let err = read_err.join().unwrap_or_default();
panic!(
"`qex {shown}` gave no answer in {limit:?}. A wait that \
nothing can satisfy is the fault that qex removes.\n{}\n{}",
describe_stream("stdout", &out),
describe_stream("stderr", &err),
);
}
None => std::thread::sleep(Duration::from_millis(20)),
}
};
Output {
status,
stdout: read_out.join().unwrap_or_default(),
stderr: read_err.join().unwrap_or_default(),
}
}
fn ok(&self, args: &[&str]) -> String {
let out = self.qex(args);
assert!(
out.status.success(),
"the command `qex {}` failed with the code {:?}\nstdout: {}\nstderr: {}",
args.join(" "),
out.status.code(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
fn submit(&self, args: &[&str]) -> String {
let id = self.ok(args);
assert_eq!(id.lines().count(), 1, "submit must write the id only: {id}");
assert!(
id.parse::<uuid::Uuid>().is_ok(),
"submit must write a job id, and it wrote: {id}"
);
id
}
fn status_json(&self, id: &str) -> serde_json::Value {
let text = self.ok(&["status", id, "--json"]);
serde_json::from_str(&text).expect("the status output is not valid JSON")
}
fn list_json(&self) -> Vec<serde_json::Value> {
let text = self.ok(&["list", "--json"]);
serde_json::from_str(&text).expect("the list output is not valid JSON")
}
fn state_of(&self, id: &str) -> String {
self.status_json(id)["state"].as_str().unwrap().to_string()
}
fn until(&self, what: &str, limit: Duration, mut test: impl FnMut() -> bool) {
let deadline = Instant::now() + limit;
while Instant::now() < deadline {
if test() {
return;
}
std::thread::sleep(Duration::from_millis(200));
}
let jobs = String::from_utf8_lossy(&self.qex(&["list"]).stdout).to_string();
let info = String::from_utf8_lossy(&self.qex(&["info", "--no-start"]).stdout).to_string();
let mut detail = String::new();
for job in self.list_json() {
let state = job["state"].as_str().unwrap_or("");
if matches!(
state,
"completed" | "failed" | "killed" | "cancelled" | "skipped"
) {
continue;
}
let id = job["id"].as_str().unwrap_or("").to_string();
let sup = job["supervisor_pid"].as_i64();
let alive = match sup {
Some(pid) => {
if unsafe { libc::kill(pid as i32, 0) } == 0 {
"alive"
} else {
"DEAD"
}
}
None => "none",
};
detail.push_str(&format!(
"\njob {id} state={state} supervisor={sup:?} ({alive})\n"
));
let dir = self.root.join("state/qex/jobs").join(&id);
for file in ["status.json", "supervisor.log", "stderr.log"] {
if let Ok(text) = std::fs::read(dir.join(file)) {
let text = String::from_utf8_lossy(&text);
let text = text.trim();
if !text.is_empty() {
detail.push_str(&format!(" --- {file} ---\n {:.900}\n", text));
}
}
}
if let Some(pid) = sup {
if let Ok(out) = std::process::Command::new("ps")
.args(["-o", "pid=,stat=,wchan:20=,args=", "-p", &pid.to_string()])
.output()
{
detail.push_str(&format!(
" --- ps ---\n {}\n",
String::from_utf8_lossy(&out.stdout).trim()
));
}
}
}
panic!(
"qex did not reach this condition in {limit:?}: {what}\n\n\
--- qex list ---\n{jobs}\n--- qex info ---\n{info}\n{detail}"
);
}
fn has_started(&self, id: &str) -> bool {
!matches!(self.state_of(id).as_str(), "queued" | "starting")
}
fn stop(&self, id: &str) {
self.until("the job starts", Duration::from_secs(45), || {
self.has_started(id)
});
self.qex(&["kill", id, "--grace", "1s"]);
}
fn coordinator_pid(&self) -> i32 {
let text = self.ok(&["info", "--json"]);
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
v["pid"].as_i64().unwrap() as i32
}
fn job_dir(&self, id: &str) -> PathBuf {
self.root.join("state/qex/jobs").join(id)
}
#[allow(clippy::zombie_processes)]
fn spawn(&self, args: &[&str]) -> std::process::Child {
Command::new(env!("CARGO_BIN_EXE_qex"))
.args(args)
.env("XDG_CONFIG_HOME", self.root.join("cfg"))
.env("XDG_STATE_HOME", self.root.join("state"))
.env("XDG_RUNTIME_DIR", self.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("qex did not start")
}
#[allow(clippy::zombie_processes)]
fn run_bg(&self, args: &[&str]) -> (std::process::Child, String) {
let id_file = self.root.join(format!(
"run-{}.id",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let id_path = id_file.to_str().unwrap().to_string();
let mut all: Vec<&str> = vec!["run", "--id-file", &id_path];
all.extend_from_slice(args);
let child = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(&all)
.env("XDG_CONFIG_HOME", self.root.join("cfg"))
.env("XDG_STATE_HOME", self.root.join("state"))
.env("XDG_RUNTIME_DIR", self.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("qex run did not start");
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if let Ok(text) = std::fs::read_to_string(&id_file) {
let id = text.trim().to_string();
if id.parse::<uuid::Uuid>().is_ok() {
return (child, id);
}
}
std::thread::sleep(Duration::from_millis(100));
}
panic!("`qex run` did not write its id file in 30 seconds");
}
fn qex_stdin(&self, args: &[&str], input: &str) -> Output {
use std::io::Write;
let mut child = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(args)
.env("XDG_CONFIG_HOME", self.root.join("cfg"))
.env("XDG_STATE_HOME", self.root.join("state"))
.env("XDG_RUNTIME_DIR", self.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("qex did not start");
child
.stdin
.take()
.unwrap()
.write_all(input.as_bytes())
.unwrap();
child.wait_with_output().expect("qex did not stop")
}
fn write_config(&self, config: &str) {
std::fs::write(self.root.join("cfg/qex.toml"), config).unwrap();
}
fn hook_origin(&self, id: &str) -> String {
let text = std::fs::read_to_string(self.job_dir(id).join("hook.ran"))
.unwrap_or_else(|e| panic!("hook.ran is not there for the job {id}: {e}"));
text.split_whitespace()
.next_back()
.unwrap_or_default()
.to_string()
}
fn hook_lines(&self) -> Vec<String> {
match std::fs::read_to_string(self.root.join("hook.txt")) {
Ok(text) => text.lines().map(|l| l.trim().to_string()).collect(),
Err(_) => Vec::new(),
}
}
}
impl Drop for Harness {
fn drop(&mut self) {
let out = self.qex(&["info", "--json"]);
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&out.stdout) {
if let Some(pid) = v["pid"].as_i64() {
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
}
}
std::fs::remove_dir_all(&self.root).ok();
}
}
#[test]
fn a_job_that_succeeds_gives_the_exit_code_zero() {
let h = Harness::with_default_config("ok");
let id = h.submit(&["submit", "--", "true"]);
let out = h.qex(&["wait", &id]);
assert_eq!(out.status.code(), Some(0));
assert_eq!(h.state_of(&id), "completed");
}
#[test]
fn a_job_that_fails_gives_the_exit_code_one() {
let h = Harness::with_default_config("fail");
let id = h.submit(&["submit", "--", "false"]);
let out = h.qex(&["wait", &id]);
assert_eq!(out.status.code(), Some(1));
let status = h.status_json(&id);
assert_eq!(status["state"], "failed");
assert_eq!(status["exit_code"], 1);
}
#[test]
fn a_wait_gives_the_exit_code_of_the_job() {
let h = Harness::with_default_config("pass");
let id = h.submit(&["submit", "--", "sh", "-c", "exit 42"]);
assert_eq!(h.qex(&["wait", &id]).status.code(), Some(42));
}
#[test]
fn a_job_code_that_qex_keeps_gives_the_sentinel() {
let h = Harness::with_default_config("band124");
let id = h.submit(&["submit", "--", "sh", "-c", "exit 124"]);
let out = h.qex(&["wait", &id]);
assert_eq!(
out.status.code(),
Some(97),
"a job that exits 124 must not look like a wait that reached its limit"
);
assert_eq!(h.status_json(&id)["exit_code"], 124);
}
#[test]
fn the_boundaries_of_the_band_hold() {
let h = Harness::with_default_config("bandedge");
let low = h.submit(&["submit", "--", "sh", "-c", "exit 96"]);
assert_eq!(h.qex(&["wait", &low]).status.code(), Some(96));
let sentinel = h.submit(&["submit", "--", "sh", "-c", "exit 97"]);
assert_eq!(h.qex(&["wait", &sentinel]).status.code(), Some(97));
assert_eq!(h.status_json(&sentinel)["exit_code"], 97);
}
#[test]
fn a_signal_that_stopped_the_job_gives_its_own_code() {
let h = Harness::with_default_config("jobsignal");
let id = h.submit(&["submit", "--", "sh", "-c", "kill -SEGV $$"]);
let out = h.qex(&["wait", &id]);
assert_eq!(
out.status.code(),
Some(98),
"a signal in the job must not give `128 + N`, which qex keeps for itself"
);
let status = h.status_json(&id);
assert_eq!(
status["signal"],
libc::SIGSEGV,
"the record names the signal"
);
}
#[test]
fn a_signal_to_the_wait_gives_the_code_of_a_broken_wait() {
let h = Harness::with_default_config("waitsig");
let id = h.submit(&["submit", "--", "sleep", "30"]);
h.until("the job starts", Duration::from_secs(45), || {
h.has_started(&id)
});
let child = h.spawn(&["wait", &id]);
std::thread::sleep(Duration::from_millis(800));
unsafe {
libc::kill(child.id() as i32, libc::SIGINT);
}
let out = child.wait_with_output().expect("the wait did not stop");
assert_eq!(
out.status.code(),
Some(122),
"a wait that a signal stopped must not give 130"
);
let said = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
said.contains("qex status") && said.contains("--wait"),
"the message must give the command that attaches again: {said}"
);
assert_eq!(
h.state_of(&id),
"running",
"a signal to the wait must not stop the job"
);
h.stop(&id);
}
#[test]
fn follow_obeys_the_time_limit_of_the_reader() {
let h = Harness::with_default_config("followlimit");
let id = h.submit(&["submit", "--", "sleep", "30"]);
h.until("the job starts", Duration::from_secs(45), || {
h.has_started(&id)
});
let started = Instant::now();
let out = h.qex(&["status", &id, "--follow", "--timeout", "2s"]);
assert_eq!(
out.status.code(),
Some(124),
"a wait that reaches its limit gives 124: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
started.elapsed() < Duration::from_secs(20),
"`--follow` ignored the limit of the reader"
);
assert_eq!(
h.state_of(&id),
"running",
"the limit of the reader must not stop the job"
);
h.stop(&id);
}
#[test]
fn the_help_of_submit_says_that_the_id_goes_to_stderr() {
let h = Harness::with_default_config("submithelp");
let text = String::from_utf8_lossy(&h.qex(&["submit", "--help"]).stdout).to_string();
let wait_part = text
.split("--wait")
.nth(1)
.expect("`--wait` must be in the help of `qex submit`")
.to_string();
assert!(
wait_part.contains("STDERR") || wait_part.contains("stderr"),
"the help must say that the id goes to stderr: {wait_part:.600}"
);
}
#[test]
fn submit_with_a_wait_gives_the_code_of_the_job_and_writes_the_id_first() {
let h = Harness::with_default_config("submitwait");
let id_file = h.root.join("job.id");
let path = id_file.to_str().unwrap().to_string();
let child = h.spawn(&[
"submit",
"--wait",
"--id-file",
&path,
"--",
"sh",
"-c",
"sleep 3; exit 7",
]);
let deadline = Instant::now() + Duration::from_secs(30);
let mut id = String::new();
while Instant::now() < deadline {
if let Ok(text) = std::fs::read_to_string(&id_file) {
if text.trim().parse::<uuid::Uuid>().is_ok() {
id = text.trim().to_string();
break;
}
}
std::thread::sleep(Duration::from_millis(100));
}
assert!(
!id.is_empty(),
"`--id-file` must reach the disk before the wait begins"
);
assert!(
!matches!(h.state_of(&id).as_str(), "completed" | "failed"),
"the test must read the id file while the job still operates"
);
let out = child.wait_with_output().expect("the command did not stop");
assert_eq!(
out.status.code(),
Some(7),
"`qex submit --wait` must give the exit code of the job: {}",
String::from_utf8_lossy(&out.stderr)
);
let said = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
said.contains("exit code: 7") && said.contains("failed"),
"`--wait` must end with the record of the job: {said}"
);
assert!(String::from_utf8_lossy(&out.stderr).contains(&id));
}
#[test]
fn submit_with_a_wait_and_qex_run_give_one_code() {
let h = Harness::with_default_config("waitagree");
for code in [0, 3, 96] {
let command = format!("exit {code}");
let submit = h.qex(&["submit", "--wait", "--", "sh", "-c", &command]);
let (child, _) = h.run_bg(&["--", "sh", "-c", &command]);
let run = wait_run(child, "the job gives its own exit code");
assert_eq!(
submit.status.code(),
run.status.code(),
"`qex submit --wait` and `qex run` gave two codes for the code {code}"
);
assert_eq!(submit.status.code(), Some(code));
}
}
#[test]
fn a_usage_error_of_a_command_that_speaks_for_a_job_uses_the_band() {
let h = Harness::with_default_config("usage");
for args in [
vec!["status"],
vec!["wait"],
vec!["submit", "--wait", "--cpu", "not-a-number", "--", "true"],
] {
let out = h.qex(&args);
assert_eq!(
out.status.code(),
Some(121),
"`qex {}` must give a code from the band: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
let out = h.qex(&["list", "--no-such-option"]);
assert_eq!(out.status.code(), Some(2));
}
#[test]
fn status_with_follow_attaches_to_a_job_and_gives_its_output() {
let h = Harness::with_default_config("follow");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"echo first; sleep 1; echo second; exit 5",
]);
let out = h.qex(&["status", &id, "--follow"]);
assert_eq!(
out.status.code(),
Some(5),
"`--follow` must give the exit code of the job"
);
let said = String::from_utf8_lossy(&out.stdout).to_string();
assert_eq!(
said, "first\nsecond\n",
"stdout must hold the output of the job and no text of qex: {said:?}"
);
}
#[test]
fn submit_with_follow_is_qex_run() {
let h = Harness::with_default_config("longrun");
let follow = h.qex(&["submit", "--follow", "--", "sh", "-c", "echo hello; exit 4"]);
let run = h.qex(&["run", "--", "sh", "-c", "echo hello; exit 4"]);
assert_eq!(follow.status.code(), run.status.code());
assert_eq!(follow.status.code(), Some(4));
assert_eq!(
String::from_utf8_lossy(&follow.stdout),
String::from_utf8_lossy(&run.stdout)
);
assert_eq!(String::from_utf8_lossy(&follow.stdout).trim(), "hello");
}
#[test]
fn quiet_gives_the_exit_code_and_no_text() {
let h = Harness::with_default_config("quiet");
let id = h.submit(&["submit", "--", "sh", "-c", "echo noise; exit 3"]);
let out = h.qex(&["status", &id, "--wait", "--quiet"]);
assert_eq!(out.status.code(), Some(3));
assert_eq!(String::from_utf8_lossy(&out.stdout), "");
let out = h.qex(&["wait", &id, "--quiet"]);
assert_eq!(out.status.code(), Some(3));
assert_eq!(String::from_utf8_lossy(&out.stdout), "");
let out = h.qex(&["status", &id, "--quiet"]);
assert_eq!(out.status.code(), Some(3));
assert_eq!(String::from_utf8_lossy(&out.stdout), "");
let running = h.submit(&["submit", "--", "sleep", "30"]);
h.until("the job starts", Duration::from_secs(45), || {
h.has_started(&running)
});
let out = h.qex(&["status", &running, "--quiet"]);
assert_eq!(
out.status.code(),
Some(100),
"a job that did not stop has no result, and this reader set no wait"
);
h.stop(&running);
}
#[test]
fn quiet_on_a_pipeline_reports_the_stage_that_failed() {
let h = Harness::with_default_config("quietpipe");
let file = h.root.join("pl.toml");
std::fs::write(
&file,
"[[jobs]]\nname = \"one\"\ncommand = [\"true\"]\n\
[[jobs]]\nname = \"two\"\nneeds = [\"one\"]\ncommand = [\"sh\", \"-c\", \"exit 6\"]\n",
)
.unwrap();
let group = h.ok(&["pipeline", file.to_str().unwrap()]);
h.qex(&["wait", &group, "--timeout", "45s"]);
for args in [
vec!["status", &group, "--quiet"],
vec!["status", &group, "--wait", "--quiet"],
vec!["wait", &group, "--quiet"],
] {
let out = h.qex(&args);
assert_eq!(
out.status.code(),
Some(6),
"`qex {}` must give the code of the stage that failed",
args.join(" ")
);
}
}
#[test]
fn a_wait_for_many_jobs_says_why_the_later_job_waits() {
let h = Harness::new(
"manyreason",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let first = h.submit(&["submit", "--cpu", "2", "--mem", "100MB", "--", "sleep", "6"]);
h.until("the first job starts", Duration::from_secs(45), || {
h.has_started(&first)
});
let holder = h.submit(&[
"submit", "--name", "holder", "--cpu", "2", "--mem", "100MB", "--", "sleep", "25",
]);
let second = h.submit(&[
"submit", "--cpu", "1", "--mem", "100MB", "--needs", &holder, "--", "true",
]);
let child = h.spawn(&["wait", &first, &second, "--timeout", "30s"]);
std::thread::sleep(Duration::from_secs(12));
unsafe {
libc::kill(child.id() as i32, libc::SIGINT);
}
let out = child.wait_with_output().expect("the wait did not stop");
let said = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
said.contains(&holder[..8]) || said.contains("holder"),
"the wait must say that the second job waits for the holder: {said}"
);
h.stop(&holder);
h.qex(&["cancel", &second]);
}
#[test]
fn quiet_keeps_the_faults_of_the_wait() {
let h = Harness::new(
"quietfault",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let holder = h.submit(&[
"submit", "--cpu", "2", "--mem", "100MB", "--", "sleep", "20",
]);
h.until("the first job starts", Duration::from_secs(45), || {
h.has_started(&holder)
});
let waiter = h.submit(&["submit", "--cpu", "2", "--mem", "100MB", "--", "true"]);
h.until("the second job waits", Duration::from_secs(30), || {
!h.status_json(&waiter)["blocked_reason"].is_null()
});
let reason = h.status_json(&waiter)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
for args in [
vec!["wait", &waiter, "--quiet", "--timeout", "3s"],
vec!["status", &waiter, "--wait", "--quiet", "--timeout", "3s"],
] {
let out = h.qex(&args);
assert_eq!(out.status.code(), Some(124), "`qex {}`", args.join(" "));
assert_eq!(
String::from_utf8_lossy(&out.stdout),
"",
"`--quiet` must write nothing on stdout"
);
let said = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
!said.contains(&reason),
"`--quiet` must not narrate the queue: {said}"
);
assert!(
said.contains("time limit"),
"`--quiet` must keep the fault of the wait: {said}"
);
}
let out = h.qex(&["wait", "0f0f0f0f", "--quiet"]);
assert_eq!(out.status.code(), Some(127));
assert!(
!String::from_utf8_lossy(&out.stderr).is_empty(),
"`--quiet` must say that there is no such job"
);
h.stop(&holder);
h.qex(&["cancel", &waiter]);
}
#[test]
fn follow_gives_the_code_of_the_job_and_not_the_limit_of_the_reader() {
let h = Harness::with_default_config("followrace");
for _ in 0..6 {
let id = h.submit(&["submit", "--", "sh", "-c", "echo out; exit 5"]);
let out = h.qex(&["status", &id, "--follow", "--timeout", "1s"]);
assert_eq!(
out.status.code(),
Some(5),
"a job that stopped must give its own code: {}",
String::from_utf8_lossy(&out.stderr)
);
}
}
#[test]
fn a_limit_with_no_wait_is_refused() {
let h = Harness::with_default_config("nolimit");
let id = h.submit(&["submit", "--", "true"]);
h.qex(&["wait", &id, "--timeout", "30s"]);
let out = h.qex(&["status", &id, "--timeout", "5s"]);
assert_eq!(out.status.code(), Some(121));
let said = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
said.contains("--wait") && said.contains("--follow"),
"the message must give the options that wait: {said}"
);
}
#[test]
fn wait_with_next_says_why_a_job_waits() {
let h = Harness::new(
"nextreason",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let holder = h.submit(&[
"submit", "--cpu", "2", "--mem", "100MB", "--", "sleep", "20",
]);
h.until("the first job starts", Duration::from_secs(45), || {
h.has_started(&holder)
});
let waiter = h.submit(&["submit", "--cpu", "2", "--mem", "100MB", "--", "true"]);
h.until("the second job waits", Duration::from_secs(30), || {
!h.status_json(&waiter)["blocked_reason"].is_null()
});
let reason = h.status_json(&waiter)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
let out = h.qex(&["wait", "--next", &waiter, "--timeout", "4s"]);
let said = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
said.contains(&reason),
"`--next` must say why the job waits `{reason}`: {said}"
);
h.stop(&holder);
h.qex(&["cancel", &waiter]);
}
#[test]
fn qex_inside_bubblewrap_names_the_sandbox() {
if Command::new("bwrap")
.arg("--version")
.output()
.map(|o| !o.status.success())
.unwrap_or(true)
{
return;
}
let h = Harness::with_default_config("bwrap");
let state = h.root.join("state");
std::fs::create_dir_all(state.join("qex/run")).unwrap();
let out = Command::new("bwrap")
.args([
"--bind",
"/",
"/",
"--dev",
"/dev",
"--proc",
"/proc",
"--ro-bind",
state.to_str().unwrap(),
state.to_str().unwrap(),
"--setenv",
"XDG_STATE_HOME",
state.to_str().unwrap(),
"--setenv",
"XDG_CONFIG_HOME",
h.root.join("cfg").to_str().unwrap(),
"--",
env!("CARGO_BIN_EXE_qex"),
"list",
])
.output()
.expect("bwrap did not start");
let said = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
said.contains("docs/sandbox.md"),
"qex inside a sandbox must give the page for a person: {said}"
);
let cause = said
.find(state.to_str().unwrap())
.expect("the message must name the directory");
let page = said
.find("docs/sandbox.md")
.expect("the message must give the page");
assert!(
cause < page,
"the message must give the fault before the remedy: {said}"
);
}
#[test]
fn a_job_that_does_not_exist_answers_at_once_with_no_coordinator() {
let h = Harness::with_default_config("nojobfast");
h.ok(&["info", "--json"]);
let pid = h.coordinator_pid();
unsafe {
libc::kill(pid, libc::SIGKILL);
}
h.until("the coordinator is gone", Duration::from_secs(10), || {
(unsafe { libc::kill(pid, 0) }) != 0
});
let missing = "3f2b1c0d-0000-4000-8000-000000000000";
let started = Instant::now();
let out = h.qex(&["wait", missing, "--quiet", "--timeout", "60s"]);
assert_eq!(out.status.code(), Some(127));
assert!(
started.elapsed() < Duration::from_secs(8),
"the answer took {:?}, and the record was ready at once",
started.elapsed()
);
}
#[test]
fn an_id_file_that_fails_does_not_lose_the_job() {
let h = Harness::with_default_config("idfilefail");
let locked = h.root.join("locked");
std::fs::create_dir_all(&locked).unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o555)).unwrap();
if unsafe { libc::geteuid() } == 0 {
return;
}
let path = locked.join("job.id");
let out = h.qex(&[
"submit",
"--wait",
"--id-file",
path.to_str().unwrap(),
"--",
"sh",
"-c",
"exit 4",
]);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).ok();
assert_eq!(
out.status.code(),
Some(4),
"a file that failed must not stop the wait: {}",
String::from_utf8_lossy(&out.stderr)
);
let said = String::from_utf8_lossy(&out.stderr).to_string();
let id = said
.lines()
.find_map(|l| l.strip_prefix("qex: job "))
.expect("the id must reach stderr before anything can fail");
assert!(
id.trim().parse::<uuid::Uuid>().is_ok(),
"the line must hold a job id: {id}"
);
assert!(
said.contains("did not reach the disk"),
"the fault of the file must be loud: {said}"
);
assert_eq!(h.status_json(id.trim())["exit_code"], 4);
}
#[test]
fn version_check_asks_the_service_and_says_what_it_found() {
let h = Harness::with_default_config("updatecheck");
let answer = h.root.join("latest.json");
std::fs::write(&answer, r#"{"tag_name": "v9.9.9"}"#).unwrap();
h.write_config(&format!(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[update]\nurl = \"file://{}\"\n",
answer.display()
));
let out = h.qex(&["version", "--check"]);
assert_eq!(out.status.code(), Some(0), "the answer arrived");
let said = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
said.contains("9.9.9"),
"the newest release must be named: {said}"
);
assert!(
said.contains(&answer.display().to_string()),
"the service that answered must be named: {said}"
);
let value: serde_json::Value =
serde_json::from_slice(&h.qex(&["version", "--check", "--json"]).stdout).unwrap();
assert!(value["version"].is_string(), "got: {value}");
assert!(value["coordinator"].is_object(), "got: {value}");
let update = &value["update"];
assert_eq!(update["newest"], "9.9.9");
assert!(update["error"].is_null());
let development = update["development"]
.as_bool()
.unwrap_or_else(|| panic!("`update.development` must be a boolean: {update}"));
if development {
assert!(
said.contains("development build"),
"a development build must be named as one: {said}"
);
assert_eq!(
update["newer"], false,
"a development build takes no place in the order"
);
} else {
assert!(
said.contains("A newer release exists"),
"a release below 9.9.9 must be told about it: {said}"
);
assert_eq!(update["newer"], true, "9.9.9 is above every release of qex");
}
}
#[test]
fn version_check_says_what_stopped_it() {
let h = Harness::with_default_config("updatefail");
h.write_config(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[update]\nurl = \"file:///qex-no-such-file-9e3a\"\n",
);
let out = h.qex(&["version", "--check"]);
assert_eq!(
out.status.code(),
Some(1),
"qex could not ask, and that is the only case that gives 1"
);
let said = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
said.contains("could not ask"),
"the message must say that qex could not ask: {said}"
);
assert!(
said.contains("still operates") || said.contains("Nothing changed"),
"the message must say that nothing changed: {said}"
);
let id = h.submit(&["submit", "--", "true"]);
assert_eq!(h.qex(&["wait", &id]).status.code(), Some(0));
}
#[test]
fn never_asks_nothing_and_writes_nothing() {
let h = Harness::with_default_config("updatenever");
h.write_config(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[update]\ncheck = \"never\"\nurl = \"file:///qex-no-such-file-9e3a\"\n",
);
let id = h.submit(&["submit", "--", "true"]);
assert_eq!(h.qex(&["wait", &id]).status.code(), Some(0));
let record = h.root.join("state/qex/update.json");
let deadline = Instant::now() + Duration::from_secs(6);
while Instant::now() < deadline {
assert!(
!record.exists(),
"`never` must write no file: {}",
record.display()
);
std::thread::sleep(Duration::from_millis(250));
}
assert!(h.qex(&["info", "--no-start"]).status.success());
}
#[test]
fn a_fresh_install_asks_nothing_and_says_nothing() {
let h = Harness::with_default_config("updatefirst");
let answer = h.root.join("latest.json");
std::fs::write(&answer, r#"{"tag_name": "v9.9.9"}"#).unwrap();
h.write_config(&format!(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[update]\ncheck = \"300s\"\nurl = \"file://{}\"\n",
answer.display()
));
let id = h.submit(&["submit", "--", "true"]);
let out = h.qex(&["wait", &id]);
assert_eq!(out.status.code(), Some(0));
assert!(
!String::from_utf8_lossy(&out.stderr).contains("newer qex"),
"a fresh install must say nothing about a release"
);
h.until(
"the coordinator wrote the record",
Duration::from_secs(45),
|| h.root.join("state/qex/update.json").exists(),
);
let value: serde_json::Value =
serde_json::from_slice(&std::fs::read(h.root.join("state/qex/update.json")).unwrap())
.unwrap();
assert!(value["last_checked"].as_u64().unwrap_or(0) > 0);
assert!(
value["newest"].is_null(),
"the first run must ask nothing: {value}"
);
}
#[test]
fn status_with_wait_ends_with_the_record_of_the_job() {
let h = Harness::with_default_config("statusrec");
let id = h.submit(&["submit", "--", "sh", "-c", "echo bad >&2; exit 9"]);
let out = h.qex(&["status", &id, "--wait"]);
assert_eq!(out.status.code(), Some(9));
let said = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
said.contains("exit code: 9") && said.contains("bad"),
"the record must hold the code and the error output: {said}"
);
}
#[test]
fn a_wait_says_why_the_job_does_not_start() {
let h = Harness::new(
"waitreason",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let holder = h.submit(&["submit", "--cpu", "2", "--", "sleep", "20"]);
h.until("the first job starts", Duration::from_secs(45), || {
h.has_started(&holder)
});
let waiter = h.submit(&["submit", "--cpu", "2", "--", "true"]);
h.until("the second job waits", Duration::from_secs(30), || {
!h.status_json(&waiter)["blocked_reason"].is_null()
});
let reason = h.status_json(&waiter)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(!reason.is_empty(), "the record must hold a reason");
let child = h.spawn(&["wait", &waiter, "--timeout", "6s"]);
std::thread::sleep(Duration::from_secs(5));
unsafe {
libc::kill(child.id() as i32, libc::SIGINT);
}
let out = child.wait_with_output().expect("the wait did not stop");
let said = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
said.contains(&reason),
"the wait must give the reason of the record `{reason}`: {said}"
);
h.stop(&holder);
h.qex(&["cancel", &waiter]);
}
#[test]
fn a_coordinator_that_stops_while_the_wait_opens_gives_no_answer_about_the_job() {
let h = Harness::with_default_config("openrace");
for step in 0..8 {
let id = h.submit(&["submit", "--", "sh", "-c", "sleep 1; exit 0"]);
h.until("the job starts", Duration::from_secs(45), || {
h.state_of(&id) == "running"
});
let child = h.spawn(&["wait", &id, "--timeout", "30s"]);
std::thread::sleep(Duration::from_millis(2 + step * 4));
let pid = h.coordinator_pid();
unsafe {
libc::kill(pid, libc::SIGKILL);
}
let out = child.wait_with_output().expect("the wait did not stop");
let code = out.status.code();
assert!(
code == Some(0),
"a coordinator that stopped gave the code {code:?} about a job that succeeded: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(h.state_of(&id), "completed");
}
}
#[test]
fn a_wait_survives_a_coordinator_that_stops() {
let h = Harness::with_default_config("waitcrash");
let id = h.submit(&["submit", "--", "sh", "-c", "sleep 6; exit 0"]);
h.until("the job starts", Duration::from_secs(45), || {
h.state_of(&id) == "running"
});
let child = h.spawn(&["wait", &id, "--timeout", "60s"]);
std::thread::sleep(Duration::from_millis(800));
let pid = h.coordinator_pid();
unsafe {
libc::kill(pid, libc::SIGKILL);
}
h.until("the coordinator is gone", Duration::from_secs(10), || {
(unsafe { libc::kill(pid, 0) }) != 0
});
let out = child.wait_with_output().expect("the wait did not stop");
assert_eq!(
out.status.code(),
Some(0),
"a coordinator that stops must not report a failure of the job: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(h.state_of(&id), "completed");
}
#[test]
fn wait_with_next_names_the_jobs_that_have_no_watcher() {
let h = Harness::with_default_config("anyrest");
let fast = h.submit(&["submit", "--", "true"]);
let slow = h.submit(&["submit", "--", "sleep", "20"]);
let out = h.qex(&["wait", "--next", &fast, &slow]);
assert_eq!(out.status.code(), Some(0));
let said = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
said.contains(&slow) && said.contains("qex wait --next"),
"`--next` must name the job that stays, and the command that waits for it: {said}"
);
h.stop(&slow);
}
#[test]
fn a_wait_that_reaches_its_limit_gives_the_code_124() {
let h = Harness::with_default_config("waitlimit");
let id = h.submit(&["submit", "--", "sleep", "30"]);
let out = h.qex(&["wait", &id, "--timeout", "2s"]);
assert_eq!(out.status.code(), Some(124));
assert_eq!(
h.state_of(&id),
"running",
"a wait that reaches its limit must not stop the job"
);
h.ok(&["kill", &id, "--grace", "1s"]);
}
#[test]
fn a_missing_job_gives_the_code_127() {
let h = Harness::with_default_config("missing");
let out = h.qex(&["wait", "3f5a1c2e-0000-4000-8000-000000000000"]);
assert_eq!(out.status.code(), Some(127));
}
#[test]
fn the_output_of_a_job_is_recorded() {
let h = Harness::with_default_config("logs");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"echo to-stdout; echo to-stderr >&2",
]);
h.ok(&["wait", &id]);
let both = h.ok(&["logs", &id]);
assert!(both.contains("to-stdout"), "the standard output is missing");
assert!(both.contains("to-stderr"), "the standard error is missing");
let out_only = h.ok(&["logs", &id, "--stdout"]);
assert!(out_only.contains("to-stdout"));
assert!(!out_only.contains("to-stderr"), "the streams are mixed");
}
#[test]
fn many_submissions_at_once_start_one_coordinator_only() {
let h = Harness::with_default_config("race");
let exe = env!("CARGO_BIN_EXE_qex");
let mut children = Vec::new();
for _ in 0..20 {
children.push(
Command::new(exe)
.args(["submit", "--", "true"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.spawn()
.expect("qex did not start"),
);
}
for mut c in children {
let status = c.wait().unwrap();
assert!(status.success(), "one submission failed during the race");
}
assert_eq!(h.list_json().len(), 20, "qex lost a job during the race");
let dirs = std::fs::read_dir(h.root.join("state/qex/jobs"))
.unwrap()
.count();
assert_eq!(dirs, 20);
}
#[test]
fn a_second_submission_with_one_key_gives_the_first_job_and_starts_no_job() {
let h = Harness::with_default_config("dedupe");
let first = h.submit(&["submit", "--dedupe-key", "build:/x", "--", "sleep", "30"]);
let out = h.qex(&["submit", "--dedupe-key", "build:/x", "--", "sleep", "30"]);
assert_eq!(
out.status.code(),
Some(0),
"the second submission must exit with the code 0, so that \
`ID=$(qex submit ...)` operates"
);
let second = String::from_utf8_lossy(&out.stdout).trim().to_string();
assert_eq!(
second, first,
"the second submission must give the first id"
);
let message = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
message.contains("started no job"),
"the message must say what happened: {message}"
);
assert!(
message.contains("build_x"),
"the message must name the key: {message}"
);
assert_eq!(h.list_json().len(), 1, "qex started a second job");
assert_eq!(
std::fs::read_dir(h.root.join("state/qex/jobs"))
.unwrap()
.count(),
1,
"qex wrote a second job record"
);
assert_eq!(h.status_json(&first)["dedupe_key"], "build_x");
let evil = h.submit(&["submit", "--dedupe-key", "x\u{1b}[2Jy", "--", "true"]);
let shown = h.status_json(&evil)["dedupe_key"]
.as_str()
.unwrap()
.to_string();
assert!(
!shown.contains('\u{1b}'),
"the ESC byte reached the reader: {shown:?}"
);
h.stop(&first);
}
#[test]
fn many_submissions_with_one_key_at_once_make_one_job() {
let h = Harness::with_default_config("dedupe-race");
let exe = env!("CARGO_BIN_EXE_qex");
let mut children = Vec::new();
for _ in 0..20 {
children.push(
Command::new(exe)
.args(["submit", "--dedupe-key", "one", "--", "sleep", "30"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.expect("qex did not start"),
);
}
let mut ids = Vec::new();
for c in children {
let out = c.wait_with_output().unwrap();
assert!(
out.status.success(),
"one submission failed during the race: {}",
String::from_utf8_lossy(&out.stderr)
);
ids.push(String::from_utf8_lossy(&out.stdout).trim().to_string());
}
let first = ids[0].clone();
assert!(
ids.iter().all(|id| *id == first),
"the submissions gave more than one id: {ids:?}"
);
assert_eq!(h.list_json().len(), 1, "the race made more than one job");
assert_eq!(
std::fs::read_dir(h.root.join("state/qex/jobs"))
.unwrap()
.count(),
1,
"the race wrote more than one job record"
);
h.stop(&first);
}
#[test]
fn a_job_that_stopped_frees_its_key_and_a_window_keeps_it() {
let h = Harness::with_default_config("dedupe-free");
let first = h.submit(&["submit", "--dedupe-key", "k", "--", "true"]);
h.ok(&["wait", &first]);
let second = h.submit(&["submit", "--dedupe-key", "k", "--", "true"]);
assert_ne!(
second, first,
"a job that stopped must not hold its key, or the work never runs again"
);
h.ok(&["wait", &second]);
let third = h.submit(&[
"submit",
"--dedupe-key",
"k",
"--dedupe-window",
"1h",
"--",
"true",
]);
assert_eq!(
third, second,
"the window must keep the key of the job that succeeded"
);
let failed = h.submit(&["submit", "--dedupe-key", "bad", "--", "false"]);
h.qex(&["wait", &failed]);
let again = h.submit(&[
"submit",
"--dedupe-key",
"bad",
"--dedupe-window",
"1h",
"--",
"false",
]);
assert_ne!(
again, failed,
"a job that failed must not hold its key, or a second run is not possible"
);
h.qex(&["wait", &again]);
}
#[test]
fn a_key_is_free_the_moment_that_its_job_stops() {
let h = Harness::with_default_config("dedupe-refresh");
for attempt in 0..10 {
let key = format!("fresh-{attempt}");
let first = h.submit(&["submit", "--dedupe-key", &key, "--", "true"]);
let file = h
.root
.join("state/qex/jobs")
.join(&first)
.join("status.json");
let limit = Instant::now() + Duration::from_secs(30);
loop {
if let Ok(text) = std::fs::read_to_string(&file) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
if v["state"] == "completed" {
break;
}
}
}
assert!(Instant::now() < limit, "the job never stopped");
std::thread::sleep(Duration::from_millis(1));
}
let second = h.submit(&["submit", "--dedupe-key", &key, "--", "true"]);
assert_ne!(
second, first,
"attempt {attempt}: the key still held a job that had stopped, so this \
submission started no work and gave the id of the finished job"
);
h.ok(&["wait", &second]);
}
}
#[test]
fn qex_clean_frees_the_key_with_the_record() {
let h = Harness::with_default_config("dedupe-clean");
let first = h.submit(&["submit", "--dedupe-key", "c", "--", "true"]);
h.ok(&["wait", &first]);
h.ok(&["clean", &first]);
let second = h.submit(&["submit", "--dedupe-key", "c", "--", "true"]);
assert_ne!(
second, first,
"the key still names the deleted job, so no later submission can start the work"
);
let out = h.qex(&["status", &second, "--json"]);
assert!(
out.status.success(),
"`qex status` cannot answer the id that the submission gave: {}",
String::from_utf8_lossy(&out.stderr)
);
h.ok(&["wait", &second]);
}
#[test]
fn qex_gc_frees_the_key_with_the_record() {
let h = Harness::with_default_config("dedupe-gc");
let first = h.submit(&["submit", "--dedupe-key", "g", "--", "true"]);
h.ok(&["wait", &first]);
h.ok(&["gc", "--older-than", "0s"]);
let second = h.submit(&["submit", "--dedupe-key", "g", "--", "true"]);
assert_ne!(second, first, "gc deleted the record and left the key");
h.ok(&["wait", &second]);
}
#[test]
fn qex_rerun_of_a_keyed_job_starts_a_new_job() {
let h = Harness::with_default_config("dedupe-rerun");
let first = h.submit(&["submit", "--dedupe-key", "rr", "--", "sleep", "30"]);
h.until("the job starts", Duration::from_secs(45), || {
h.has_started(&first)
});
let out = h.ok(&["rerun", &first]);
let second = out.split_whitespace().last().unwrap().to_string();
assert_ne!(
second, first,
"rerun gave the id of the first job, so it started nothing: {out}"
);
assert_eq!(h.list_json().len(), 2, "rerun started no second job");
assert!(
h.status_json(&second)["dedupe_key"].is_null(),
"the job of a rerun must hold no key"
);
h.stop(&first);
h.stop(&second);
}
#[test]
fn a_restart_gives_the_key_to_the_job_that_still_operates() {
let h = Harness::with_default_config("dedupe-recover-order");
let old = h.submit(&["submit", "--dedupe-key", "two", "--", "true"]);
h.ok(&["wait", &old]);
let live = h.submit(&["submit", "--dedupe-key", "two", "--", "sleep", "30"]);
h.until("the job starts", Duration::from_secs(45), || {
h.has_started(&live)
});
let pid = h.coordinator_pid();
unsafe {
libc::kill(pid, libc::SIGKILL);
}
h.until("the coordinator stops", Duration::from_secs(30), || {
let alive = unsafe { libc::kill(pid, 0) } == 0;
!alive
});
let again = h.submit(&["submit", "--dedupe-key", "two", "--", "sleep", "30"]);
assert_eq!(
again, live,
"the key went to the job that stopped, so qex started a second copy of the work"
);
assert_eq!(
h.list_json().len(),
2,
"qex started a third job, so the key did not hold the job that operates"
);
h.stop(&live);
}
#[test]
fn a_key_goes_at_the_end_of_the_window_and_not_after_it() {
let h = Harness::with_default_config("dedupe-edge");
let first = h.submit(&[
"submit",
"--dedupe-key",
"e",
"--dedupe-window",
"1",
"--",
"true",
]);
h.ok(&["wait", &first]);
let finished = h.status_json(&first)["finished_at"].as_u64().unwrap();
loop {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
if now.saturating_sub(finished) >= 1 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
let second = h.submit(&[
"submit",
"--dedupe-key",
"e",
"--dedupe-window",
"1",
"--",
"true",
]);
assert_ne!(
second, first,
"the key must go at the end of the window, and not one second after it"
);
h.ok(&["wait", &second]);
}
#[test]
fn submit_json_says_if_this_command_started_the_work() {
let h = Harness::with_default_config("dedupe-json");
let text = h.ok(&["submit", "--json", "--dedupe-key", "j", "--", "sleep", "30"]);
let first: serde_json::Value = serde_json::from_str(&text).expect("the output is not JSON");
assert_eq!(first["deduplicated"], false);
let id = first["id"].as_str().unwrap().to_string();
let text = h.ok(&["submit", "--json", "--dedupe-key", "j", "--", "sleep", "30"]);
let second: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(second["deduplicated"], true);
assert_eq!(second["id"], first["id"]);
h.stop(&id);
}
#[test]
fn a_signal_to_a_deduplicated_run_stops_the_wait_and_not_the_job() {
let h = Harness::with_default_config("dedupe-run");
let owner = h.submit(&["submit", "--dedupe-key", "shared", "--", "sleep", "30"]);
h.until("the job starts", Duration::from_secs(45), || {
h.has_started(&owner)
});
let err_path = h.root.join("run.err");
let mut child = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["run", "--dedupe-key", "shared", "--", "sleep", "30"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.stdout(std::process::Stdio::null())
.stderr(std::fs::File::create(&err_path).unwrap())
.spawn()
.expect("qex did not start");
h.until(
"qex run attaches to the job",
Duration::from_secs(45),
|| {
std::fs::read_to_string(&err_path)
.map(|t| t.contains("did not start it"))
.unwrap_or(false)
},
);
unsafe {
libc::kill(child.id() as i32, libc::SIGTERM);
}
let code = child.wait().unwrap().code();
assert_eq!(
code,
Some(122),
"the wait must give the code that says `the job continues`"
);
let state = h.state_of(&owner);
assert_eq!(
state, "running",
"a signal to the second agent stopped the job of the first agent"
);
let message = std::fs::read_to_string(&err_path).unwrap();
assert!(
message.contains(&format!("qex kill {owner}")),
"the message must give the way to stop the job: {message}"
);
h.stop(&owner);
}
#[test]
fn a_signal_to_a_run_that_started_its_job_stops_the_job() {
let h = Harness::with_default_config("run-signal");
let out_path = h.root.join("run.out");
let mut child = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["run", "--", "sh", "-c", "echo ready; sleep 30"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.stdout(std::fs::File::create(&out_path).unwrap())
.stderr(std::process::Stdio::null())
.spawn()
.expect("qex did not start");
h.until("the job writes its output", Duration::from_secs(45), || {
std::fs::read_to_string(&out_path)
.map(|t| t.contains("ready"))
.unwrap_or(false)
});
unsafe {
libc::kill(child.id() as i32, libc::SIGTERM);
}
child.wait().unwrap();
let id = h.list_json()[0]["id"].as_str().unwrap().to_string();
h.until("the job stops", Duration::from_secs(45), || {
h.state_of(&id) == "killed"
});
}
#[test]
fn a_key_stays_with_its_job_after_the_coordinator_stops() {
let h = Harness::with_default_config("dedupe-restart");
let first = h.submit(&["submit", "--dedupe-key", "r", "--", "sleep", "30"]);
h.until("the job starts", Duration::from_secs(45), || {
h.has_started(&first)
});
let pid = h.coordinator_pid();
unsafe {
libc::kill(pid, libc::SIGKILL);
}
h.until("the coordinator stops", Duration::from_secs(30), || {
let alive = unsafe { libc::kill(pid, 0) } == 0;
!alive
});
let second = h.submit(&["submit", "--dedupe-key", "r", "--", "sleep", "30"]);
assert_eq!(
second, first,
"a new coordinator lost the key, and it started a second copy of the work"
);
assert_eq!(h.list_json().len(), 1);
h.stop(&first);
}
#[test]
fn the_budget_limits_the_jobs_that_operate_together() {
let h = Harness::new(
"budget",
"[budget]\ncpu = \"4\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let ids: Vec<String> = (0..3)
.map(|_| {
h.submit(&[
"submit", "--cpu", "2", "--mem", "128MB", "--", "sleep", "300",
])
})
.collect();
h.until("two jobs operate", Duration::from_secs(45), || {
h.list_json()
.iter()
.filter(|j| j["state"] == "running")
.count()
== 2
});
let mut peak = 0;
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
let running = h
.list_json()
.iter()
.filter(|j| j["state"] == "running")
.count();
peak = peak.max(running);
std::thread::sleep(Duration::from_millis(100));
}
assert!(peak > 0, "no job ever started");
assert!(
peak <= 2,
"the budget of 4 cores must hold two jobs of 2 cores, and {peak} jobs operated together"
);
for id in &ids {
h.qex(&["kill", id, "--grace", "1s"]);
h.qex(&["cancel", id]);
}
}
#[test]
fn a_job_that_is_too_large_runs_when_the_queue_is_empty() {
let h = Harness::new(
"oversized",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\noversized = \"run-when-idle\"\nsettle = \"1s\"\n",
);
let small = h.submit(&[
"submit", "--cpu", "2", "--mem", "128MB", "--", "sleep", "300",
]);
h.until("the small job starts", Duration::from_secs(45), || {
h.state_of(&small) == "running"
});
let out = h.qex(&[
"submit", "--cpu", "64", "--mem", "64GB", "--", "echo", "big",
]);
assert!(out.status.success());
let big = String::from_utf8_lossy(&out.stdout).trim().to_string();
let warning = String::from_utf8_lossy(&out.stderr);
assert!(
warning.contains("64 cores") && warning.contains("budget"),
"qex must warn at the submission: {warning}"
);
assert!(
big.parse::<uuid::Uuid>().is_ok(),
"stdout must hold the id only"
);
assert_eq!(h.state_of(&big), "queued");
let reason = h.status_json(&big)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(!reason.is_empty(), "qex must give the reason for the wait");
h.ok(&["kill", &small, "--grace", "1s"]);
h.until("the large job stops", Duration::from_secs(30), || {
h.state_of(&big) == "completed"
});
let status = h.status_json(&big);
assert_eq!(status["forced"], true, "qex must mark a forced job");
assert!(
status["forced_reason"]
.as_str()
.unwrap_or("")
.contains("budget"),
"the reason must name the budget"
);
assert!(h.ok(&["logs", &big]).contains("big"), "the job did not run");
}
#[test]
fn the_reject_policy_refuses_a_job_that_is_too_large() {
let h = Harness::new(
"reject",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[queue]\noversized = \"reject\"\n",
);
let out = h.qex(&["submit", "--cpu", "64", "--", "true"]);
assert!(!out.status.success(), "qex must refuse this job");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("64 cores"),
"the error must name the claim: {err}"
);
}
#[test]
fn a_kill_stops_every_process_of_a_job() {
let h = Harness::with_default_config("kill");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"sleep 60 & sleep 60 & sleep 60 & wait",
]);
h.until("the job starts", Duration::from_secs(45), || {
h.state_of(&id) == "running"
});
let pid = h.status_json(&id)["pid"].as_i64().unwrap() as i32;
assert!(
count_in_group(pid) >= 2,
"the job did not start its children"
);
h.ok(&["kill", &id, "--grace", "1s"]);
h.until(
"every process of the job stops",
Duration::from_secs(20),
|| count_in_group(pid) == 0,
);
h.until(
"the record shows the job stopped",
Duration::from_secs(20),
|| h.state_of(&id) == "killed",
);
}
#[cfg(target_os = "linux")]
fn count_in_group(pgid: i32) -> usize {
let Ok(entries) = std::fs::read_dir("/proc") else {
return 0;
};
let mut count = 0;
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Ok(pid) = name.parse::<i32>() else {
continue;
};
if unsafe { libc::getpgid(pid) } == pgid {
count += 1;
}
}
count
}
#[cfg(not(target_os = "linux"))]
fn count_in_group(pgid: i32) -> usize {
let Ok(out) = std::process::Command::new("ps")
.args(["-A", "-o", "pgid="])
.output()
else {
return 0;
};
String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|line| line.trim().parse::<i32>() == Ok(pgid))
.count()
}
#[test]
fn a_job_survives_the_failure_of_the_coordinator() {
let h = Harness::with_default_config("crash");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"sleep 10; echo survived; exit 7",
]);
h.until("the job starts", Duration::from_secs(45), || {
h.state_of(&id) == "running"
});
let pid = h.coordinator_pid();
unsafe {
libc::kill(pid, libc::SIGKILL);
}
std::thread::sleep(Duration::from_millis(300));
assert!(
unsafe { libc::kill(pid, 0) } != 0,
"the coordinator did not stop"
);
let out = h.qex(&["wait", &id, "--timeout", "30s"]);
assert_eq!(out.status.code(), Some(7), "the job exits with the code 7");
let status = h.status_json(&id);
assert_eq!(status["state"], "failed");
assert_eq!(status["exit_code"], 7);
assert!(h.ok(&["logs", &id]).contains("survived"));
}
#[test]
fn a_job_that_reaches_its_time_limit_has_the_state_timeout() {
let h = Harness::with_default_config("jobtimeout");
let id = h.submit(&["submit", "--timeout", "1s", "--", "sleep", "60"]);
h.until("the job stops", Duration::from_secs(30), || {
h.state_of(&id) == "timeout"
});
}
#[test]
fn a_job_that_never_starts_gives_up_and_says_why() {
let h = Harness::new(
"queuelimit",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\noversized = \"queue\"\n",
);
let id = h.submit(&[
"submit",
"--cpu",
"64",
"--max-queue-time",
"3s",
"--",
"echo",
"never",
]);
h.until("the job gives up", Duration::from_secs(45), || {
h.state_of(&id) == "expired"
});
let status = h.status_json(&id);
assert!(
status["started_at"].is_null(),
"a job that expired must never have a start time: {status}"
);
assert!(
status["exit_code"].is_null(),
"a job that expired has no exit code: {status}"
);
let error = status["error"].as_str().unwrap_or("");
assert!(
error.contains("did not start"),
"the text must say that the job never ran: {error}"
);
assert!(
error.contains("--max-queue-time"),
"the text must name the limit: {error}"
);
assert!(
error.contains("budget") || error.contains("cores"),
"the text must name the wait: {error}"
);
let out = h.qex(&["wait", &id, "--timeout", "30s"]);
assert_eq!(
out.status.code(),
Some(123),
"stdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let after = h.submit(&["submit", "--needs", &id, "--", "echo", "after"]);
h.until(
"the job behind it gives up",
Duration::from_secs(45),
|| h.state_of(&after) == "skipped",
);
let status = h.status_json(&after);
let text = format!(
"{} {}",
status["error"].as_str().unwrap_or(""),
status["blocked_reason"].as_str().unwrap_or("")
);
assert!(
text.contains("expired"),
"the text must name the state of the job that it needed: {text}"
);
assert!(
!text.contains("qex logs"),
"an expired job wrote no log, so the text must not name one: {text}"
);
}
#[test]
fn a_job_receives_the_environment_and_the_directory_of_the_shell() {
let h = Harness::with_default_config("env");
let dir = h.root.join("workdir");
std::fs::create_dir_all(&dir).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["submit", "--", "sh", "-c", "pwd; echo MARK=$QEX_TEST_MARK"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.env("QEX_TEST_MARK", "captured")
.current_dir(&dir)
.output()
.unwrap();
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
h.ok(&["wait", &id]);
let logs = h.ok(&["logs", &id]);
assert!(
logs.contains("MARK=captured"),
"the job must receive the environment of the shell: {logs}"
);
assert!(
logs.contains(dir.canonicalize().unwrap().to_str().unwrap()),
"the job must operate in the directory of the shell: {logs}"
);
}
#[test]
fn the_environment_mode_none_removes_the_variables_of_the_shell() {
let h = Harness::with_default_config("envnone");
let out = Command::new(env!("CARGO_BIN_EXE_qex"))
.args([
"submit",
"--no-env-capture",
"--env",
"KEPT=yes",
"--",
"sh",
"-c",
"echo MARK=$QEX_TEST_MARK KEPT=$KEPT",
])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.env("QEX_TEST_MARK", "leaked")
.output()
.unwrap();
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
h.ok(&["wait", &id]);
let logs = h.ok(&["logs", &id]);
assert!(
logs.contains("MARK= "),
"a variable of the shell leaked: {logs}"
);
assert!(
logs.contains("KEPT=yes"),
"the --env value is missing: {logs}"
);
}
#[test]
fn the_job_files_are_private() {
use std::os::unix::fs::PermissionsExt;
let h = Harness::with_default_config("modes");
let id = h.submit(&["submit", "--", "true"]);
h.ok(&["wait", &id]);
let dir = h.job_dir(&id);
let dir_mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777;
assert_eq!(dir_mode, 0o700, "the job directory must be private");
let spec_mode = std::fs::metadata(dir.join("spec.json"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(spec_mode, 0o600, "the job specification must be private");
let status = h.ok(&["status", &id, "--json"]);
assert!(
!status.contains("\"env\""),
"the status output must hide the environment: {status}"
);
}
#[test]
fn a_command_line_that_holds_the_word_qex_does_not_confuse_qex() {
let h = Harness::with_default_config("selfmatch");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"echo pretending to be qex daemon supervise; sleep 30",
]);
h.until("the job starts", Duration::from_secs(45), || {
h.state_of(&id) == "running"
});
let coordinator = h.coordinator_pid();
let job_pid = h.status_json(&id)["pid"].as_i64().unwrap() as i32;
assert_ne!(coordinator, job_pid);
h.ok(&["kill", &id, "--grace", "1s"]);
h.until("the job stops", Duration::from_secs(20), || {
h.state_of(&id) == "killed"
});
assert_eq!(h.coordinator_pid(), coordinator);
}
#[test]
fn cancel_removes_a_job_from_the_queue() {
let h = Harness::new(
"cancel",
"[budget]\ncpu = \"1\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let first = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "300",
]);
h.until("the first job starts", Duration::from_secs(45), || {
h.state_of(&first) == "running"
});
let second = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "5"]);
assert_eq!(h.state_of(&second), "queued");
h.ok(&["cancel", &second]);
assert_eq!(h.state_of(&second), "cancelled");
let out = h.qex(&["cancel", &first]);
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("kill"),
"the error must give the correct command: {err}"
);
h.ok(&["kill", &first, "--grace", "1s"]);
}
#[test]
fn clean_deletes_the_record_of_a_job_that_stopped() {
let h = Harness::with_default_config("clean");
let id = h.submit(&["submit", "--", "true"]);
h.ok(&["wait", &id]);
assert!(h.job_dir(&id).exists());
h.ok(&["clean", &id]);
assert!(!h.job_dir(&id).exists(), "qex did not delete the directory");
assert!(h.list_json().is_empty());
}
#[test]
fn clean_refuses_a_job_that_operates() {
let h = Harness::with_default_config("cleanrun");
let id = h.submit(&["submit", "--", "sleep", "30"]);
h.until("the job starts", Duration::from_secs(45), || {
h.state_of(&id) == "running"
});
let out = h.qex(&["clean", &id]);
assert!(!out.status.success(), "qex must refuse this command");
assert!(h.job_dir(&id).exists(), "the record must stay");
h.ok(&["kill", &id, "--grace", "1s"]);
}
#[test]
fn a_command_that_does_not_exist_gives_a_clear_message() {
let h = Harness::with_default_config("nocmd");
let id = h.submit(&["submit", "--", "this-program-does-not-exist"]);
h.until("the job stops", Duration::from_secs(45), || {
h.status_json(&id)["state"]
.as_str()
.map(|s| s == "failed")
.unwrap_or(false)
});
let reason = h.status_json(&id)["error"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
reason.contains("this-program-does-not-exist"),
"the message must name the program: {reason}"
);
assert!(
reason.contains("PATH"),
"the message must give the correction: {reason}"
);
}
#[test]
fn a_command_accepts_the_first_characters_of_an_id() {
let h = Harness::with_default_config("shortid");
let id = h.submit(&["submit", "--", "true"]);
h.ok(&["wait", &id]);
let short = &id[..8];
assert_eq!(h.state_of(short), "completed");
}
#[test]
fn the_first_screen_points_to_the_topic_for_agents() {
let h = Harness::with_default_config("help");
let text = h.ok(&[]);
assert!(
text.contains("qex help agents"),
"the first screen must name the topic for agents"
);
let agents = h.ok(&["help", "agents"]);
assert!(
agents.contains("pgrep"),
"the topic must warn about the pgrep fault"
);
assert!(
agents.contains("qex wait"),
"the topic must give the solution"
);
}
#[test]
fn the_schemas_are_valid_json() {
let h = Harness::with_default_config("schema");
for name in ["job", "status", "pipeline", "event"] {
let text = h.ok(&["schema", name]);
serde_json::from_str::<serde_json::Value>(&text)
.unwrap_or_else(|e| panic!("the schema `{name}` is not valid JSON: {e}"));
}
}
#[test]
fn the_schema_accepts_every_claim_source_that_qex_writes() {
let h = Harness::with_default_config("claimsource");
let schema: serde_json::Value = serde_json::from_str(&h.ok(&["schema", "status"])).unwrap();
let permitted: Vec<String> = schema["properties"]["claim_source"]["enum"]
.as_array()
.expect("the schema must give the values of `claim_source`")
.iter()
.map(|v| v.as_str().expect("each value is a string").to_string())
.collect();
let input = h.root.join("lines.txt");
std::fs::write(&input, "alpha\nbeta\n").unwrap();
let mut seen: Vec<String> = Vec::new();
for _ in 0..2 {
let group = h.ok(&[
"submit",
"--each-line",
input.to_str().unwrap(),
"--",
"echo",
"{}",
]);
let text = h.ok(&["list", "--group", group.trim(), "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
for job in &jobs {
let id = job["id"].as_str().unwrap().to_string();
h.ok(&["wait", &id, "--timeout", "60s"]);
let source = h.status_json(&id)["claim_source"]
.as_str()
.expect("every record names where its claim came from")
.to_string();
if !seen.contains(&source) {
seen.push(source);
}
}
}
assert!(
seen.contains(&"fan-out".to_string()),
"the second run of a fan-out must give the claim source `fan-out`, and it gave {seen:?}"
);
for source in &seen {
assert!(
permitted.contains(source),
"qex writes the claim source `{source}`, and the shipped schema permits {permitted:?} \
only. An agent that tests a record against this schema refuses a record that is \
correct."
);
}
}
#[test]
fn a_job_file_describes_a_job() {
let h = Harness::with_default_config("jobfile");
let file = h.root.join("job.toml");
std::fs::write(
&file,
"name = \"from-file\"\n\
command = [\"sh\", \"-c\", \"echo from-the-file\"]\n\
tags = [\"test\"]\n\
[resources]\n\
cpu = 2\n\
mem = \"256MB\"\n\
[env]\n\
FILE_VAR = \"present\"\n",
)
.unwrap();
let id = h.submit(&["submit", "--job", file.to_str().unwrap()]);
h.ok(&["wait", &id]);
let status = h.status_json(&id);
assert_eq!(status["name"], "from-file");
assert_eq!(status["cpu"], 2);
assert_eq!(status["mem"], 256 * 1024 * 1024);
assert_eq!(status["tags"][0], "test");
assert!(h.ok(&["logs", &id]).contains("from-the-file"));
}
#[test]
fn the_claim_word_guess_gives_one_half_of_the_budget() {
let h = Harness::new(
"guess",
"[budget]\ncpu = \"8\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let a = h.submit(&[
"submit", "--cpu", "guess", "--mem", "guess", "--", "sleep", "300",
]);
let status = h.status_json(&a);
assert_eq!(status["cpu"], 4, "one half of 8 cores is 4 cores");
assert_eq!(
status["mem"],
2u64 * 1024 * 1024 * 1024,
"one half of 4GB is 2GB"
);
let b = h.submit(&[
"submit", "--cpu", "half", "--mem", "half", "--", "sleep", "300",
]);
h.until("both jobs operate", Duration::from_secs(45), || {
h.list_json()
.iter()
.filter(|j| j["state"] == "running")
.count()
== 2
});
let c = h.submit(&["submit", "--cpu", "guess", "--mem", "guess", "--", "true"]);
assert_eq!(h.state_of(&c), "queued");
h.ok(&["kill", &a, "--grace", "1s"]);
h.ok(&["kill", &b, "--grace", "1s"]);
h.ok(&["wait", &c, "--timeout", "30s"]);
}
#[test]
fn the_claim_word_full_gives_the_whole_budget() {
let h = Harness::new(
"full",
"[budget]\ncpu = \"4\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let big = h.submit(&[
"submit", "--cpu", "full", "--mem", "max", "--", "sleep", "3",
]);
let status = h.status_json(&big);
assert_eq!(status["cpu"], 4);
assert_eq!(status["mem"], 2u64 * 1024 * 1024 * 1024);
assert_eq!(
status["forced"], false,
"a job that asks for the budget is a normal job, and qex must not force it"
);
h.until("the full job starts", Duration::from_secs(45), || {
h.state_of(&big) == "running"
});
let other = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
assert_eq!(h.state_of(&other), "queued");
h.ok(&["kill", &big, "--grace", "1s"]);
h.ok(&["wait", &other, "--timeout", "30s"]);
}
#[test]
fn a_job_file_accepts_the_claim_words() {
let h = Harness::new(
"guessfile",
"[budget]\ncpu = \"8\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let file = h.root.join("guess.toml");
std::fs::write(
&file,
"command = [\"true\"]\n[resources]\ncpu = \"guess\"\nmem = \"half\"\n",
)
.unwrap();
let id = h.submit(&["submit", "--job", file.to_str().unwrap()]);
let status = h.status_json(&id);
assert_eq!(status["cpu"], 4);
assert_eq!(status["mem"], 2u64 * 1024 * 1024 * 1024);
h.ok(&["wait", &id, "--timeout", "30s"]);
}
#[test]
fn a_dead_supervisor_does_not_leave_the_job_alive() {
let h = Harness::with_default_config("orphan");
let id = h.submit(&["submit", "--", "sleep", "120"]);
h.until("the job starts", Duration::from_secs(45), || {
h.state_of(&id) == "running"
});
let job_pid = h.status_json(&id)["pid"].as_i64().unwrap() as i32;
let supervisor_pid = h.status_json(&id)["supervisor_pid"]
.as_i64()
.expect("the status must record the supervisor") as i32;
unsafe {
libc::kill(supervisor_pid, libc::SIGKILL);
}
h.until(
"the job reaches a final state",
Duration::from_secs(30),
|| {
h.status_json(&id)["state"]
.as_str()
.map(|s| s != "running" && s != "starting")
.unwrap_or(false)
},
);
h.until("the job process stops", Duration::from_secs(30), || {
let rc = unsafe { libc::kill(job_pid, 0) };
rc != 0
});
let info = h.ok(&["info", "--json"]);
let v: serde_json::Value = serde_json::from_str(&info).unwrap();
assert_eq!(v["cpu_claimed"].as_u64(), Some(0));
}
#[test]
fn a_job_that_stops_at_its_time_limit_keeps_its_result() {
let h = Harness::with_default_config("timerrace");
let mut ids = Vec::new();
for i in 0..12 {
let sleep = format!("0.{:03}", 995 + i);
ids.push(h.submit(&["submit", "--timeout", "1s", "--", "sleep", &sleep]));
}
for id in &ids {
h.qex(&["wait", id, "--timeout", "60s"]);
let s = h.status_json(id);
let state = s["state"].as_str().unwrap();
let code = s["exit_code"].as_i64();
if state == "timeout" {
assert_ne!(
code,
Some(0),
"the job stopped with the code 0, so its state must not be `timeout`: {s}"
);
}
if code == Some(0) {
assert_eq!(
state, "completed",
"a job that stopped with the code 0 must be `completed`: {s}"
);
}
}
}
#[test]
fn a_wait_returns_when_the_job_reaches_its_queue_limit() {
let h = Harness::new(
"queuewait",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\noversized = \"queue\"\n",
);
let id = h.submit(&[
"submit",
"--cpu",
"64",
"--max-queue-time",
"3s",
"--",
"echo",
"never",
]);
let start = std::time::Instant::now();
let out = h.qex(&["wait", &id, "--timeout", "60s"]);
let elapsed = start.elapsed();
assert_eq!(
out.status.code(),
Some(123),
"stdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
elapsed < Duration::from_secs(10),
"`qex wait` returned after {elapsed:?}, and the limit is 3s. The \
scheduler expired the job and signalled no waiter."
);
}
#[test]
fn the_config_summary_names_the_queue_limit() {
let h = Harness::new(
"cfgqueuelimit",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let out = h.ok(&["config", "show"]);
assert!(
out.contains("queue limit:"),
"the summary must hold the queue limit line: {out}"
);
assert!(
out.contains("no limit"),
"with no value the line must say that a job waits with no end: {out}"
);
let h = Harness::new(
"cfgqueuelimit2",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[defaults]\nmax_queue_time = \"30m\"\n",
);
let out = h.ok(&["config", "show"]);
assert!(
out.contains("queue limit:") && out.contains("30m"),
"the summary must give the value that qex uses: {out}"
);
assert!(
out.contains("expired"),
"the line must say what happens to a job that waits longer: {out}"
);
}
#[test]
fn a_wait_returns_when_the_job_that_it_needs_gives_up_in_the_queue() {
let h = Harness::new(
"queuedep",
"[budget]\ncpu = \"2\"\nmem = \"8GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\noversized = \"queue\"\n",
);
let first = h.submit(&[
"submit",
"--cpu",
"64",
"--max-queue-time",
"3s",
"--",
"echo",
"never",
]);
let second = h.submit(&["submit", "--needs", &first, "--", "echo", "after"]);
let start = std::time::Instant::now();
let out = h.qex(&["wait", &second, "--timeout", "60s"]);
let elapsed = start.elapsed();
assert_eq!(
out.status.code(),
Some(126),
"a job that did not run because a job that it needs failed gives 126. \
stdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
elapsed < Duration::from_secs(10),
"`qex wait` returned after {elapsed:?}, and the limit of the job that \
this job needs is 3s. The scheduler skipped this job and signalled no \
waiter."
);
}
#[test]
fn a_job_that_started_at_its_queue_limit_keeps_its_result() {
let h = Harness::new(
"queuerace",
"[budget]\ncpu = \"1\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let mut ids = Vec::new();
for _ in 0..10 {
ids.push(h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--max-queue-time",
"2s",
"--",
"sleep",
"0.4",
]));
}
for id in &ids {
h.qex(&["wait", id, "--timeout", "60s"]);
let s = h.status_json(id);
let state = s["state"].as_str().unwrap();
if state == "expired" {
assert!(
s["started_at"].is_null(),
"a job that started must never be `expired`: {s}"
);
assert!(
s["exit_code"].is_null(),
"a job with an exit code must never be `expired`: {s}"
);
}
if s["exit_code"].as_i64() == Some(0) {
assert_eq!(
state, "completed",
"a job that stopped with the code 0 must be `completed`: {s}"
);
}
}
}
#[test]
fn logs_shows_output_that_is_not_utf8() {
let h = Harness::with_default_config("badbytes");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
r#"printf 'FIRST-LINE\n'; printf 'BAD\377\376\n'; printf 'LAST-LINE\n'"#,
]);
h.ok(&["wait", &id, "--timeout", "30s"]);
let logs = h.ok(&["logs", &id]);
assert!(
logs.contains("FIRST-LINE") && logs.contains("LAST-LINE"),
"one byte that is not UTF-8 hid the whole output: {logs:?}"
);
let json = h.ok(&["logs", &id, "--json"]);
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(v["stdout"].as_str().unwrap().contains("LAST-LINE"));
}
#[test]
fn a_spawn_failure_uses_the_error_field() {
let h = Harness::with_default_config("spawnfail");
let id = h.submit(&["submit", "--", "this-program-does-not-exist"]);
h.until("the job stops", Duration::from_secs(45), || {
h.state_of(&id) == "failed"
});
let s = h.status_json(&id);
assert!(
s["error"]
.as_str()
.unwrap_or("")
.contains("this-program-does-not-exist"),
"the error field must name the program: {s}"
);
assert!(
s["blocked_reason"].is_null(),
"a job that failed waits for nothing: {s}"
);
}
#[test]
fn every_queued_job_gives_a_reason() {
let h = Harness::new(
"reasons",
"[budget]\ncpu = \"2\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let running = h.submit(&[
"submit", "--cpu", "2", "--mem", "64MB", "--", "sleep", "300",
]);
h.until("the first job starts", Duration::from_secs(45), || {
h.state_of(&running) == "running"
});
let a = h.submit(&["submit", "--cpu", "2", "--mem", "64MB", "--", "true"]);
let b = h.submit(&["submit", "--cpu", "2", "--mem", "64MB", "--", "true"]);
h.until("both jobs give a reason", Duration::from_secs(45), || {
let ra = h.status_json(&a)["blocked_reason"]
.as_str()
.map(String::from);
let rb = h.status_json(&b)["blocked_reason"]
.as_str()
.map(String::from);
ra.is_some() && rb.is_some()
});
h.ok(&["kill", &running, "--grace", "1s"]);
}
#[test]
fn the_status_records_the_command_and_the_directory() {
let h = Harness::with_default_config("cmdfield");
let id = h.submit(&["submit", "--", "echo", "hello", "world"]);
h.ok(&["wait", &id, "--timeout", "30s"]);
let s = h.status_json(&id);
let command: Vec<String> = serde_json::from_value(s["command"].clone()).unwrap();
assert_eq!(command, vec!["echo", "hello", "world"]);
assert!(!s["cwd"].as_str().unwrap().is_empty());
}
#[test]
fn info_can_test_for_a_coordinator_without_starting_one() {
let h = Harness::with_default_config("nostart");
let out = h.qex(&["info", "--no-start", "--json"]);
assert!(!out.status.success(), "there is no coordinator yet");
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(v["running"], false);
assert!(
!h.root.join("state/qex/run/s").exists(),
"the command started a coordinator"
);
let id = h.submit(&["submit", "--", "true"]);
h.ok(&["wait", &id, "--timeout", "30s"]);
let out = h.qex(&["info", "--no-start", "--json"]);
assert!(out.status.success());
}
#[test]
fn a_claim_of_zero_cores_is_refused() {
let h = Harness::with_default_config("zeroclaim");
let out = h.qex(&["submit", "--cpu", "0", "--", "true"]);
assert!(
!out.status.success(),
"qex must refuse a claim of zero cores"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("1 core"),
"the error must give the correction: {err}"
);
}
#[test]
fn an_unknown_job_gives_the_same_code_for_each_form_of_the_name() {
let h = Harness::with_default_config("codes");
for name in ["3f5a1c2e-0000-4000-8000-000000000000", "not-a-uuid"] {
for command in ["status", "wait"] {
let out = h.qex(&[command, name]);
assert_eq!(
out.status.code(),
Some(127),
"`qex {command} {name}` must give the code 127"
);
}
}
}
#[test]
fn a_failed_stage_stops_the_stages_after_it() {
let h = Harness::with_default_config("pipeline");
let build = h.submit(&[
"submit",
"--name",
"build",
"--",
"sh",
"-c",
"echo compiling; echo 'error: undefined symbol' >&2; exit 2",
]);
let test = h.submit(&["submit", "--name", "test", "--needs", &build, "--", "true"]);
let ship = h.submit(&["submit", "--name", "ship", "--needs", &test, "--", "true"]);
let out = h.qex(&["wait", &ship, "--timeout", "60s"]);
assert_eq!(out.status.code(), Some(126));
assert_eq!(h.state_of(&build), "failed");
assert_eq!(h.state_of(&test), "skipped");
assert_eq!(h.state_of(&ship), "skipped");
let s = h.status_json(&ship);
assert_eq!(
s["caused_by"].as_str(),
Some(build.as_str()),
"the last stage must name the build, and not the test: {s}"
);
assert!(
s["error"].as_str().unwrap_or("").contains("build"),
"the reason must name the stage that failed: {s}"
);
let failed = h
.list_json()
.iter()
.filter(|j| j["state"] == "failed")
.count();
assert_eq!(failed, 1, "a pipeline must report one failure only");
let logs = h.ok(&["logs", &build]);
assert!(logs.contains("undefined symbol"));
}
#[test]
fn a_pipeline_that_succeeds_runs_each_stage_in_order() {
let h = Harness::with_default_config("pipeok");
let build = h.submit(&["submit", "--name", "build", "--", "sh", "-c", "echo one"]);
let test = h.submit(&[
"submit", "--name", "test", "--needs", &build, "--", "sh", "-c", "echo two",
]);
let ship = h.submit(&[
"submit",
"--name",
"ship",
"--needs",
&test,
"--",
"sh",
"-c",
"echo three",
]);
let out = h.qex(&["wait", &ship, "--timeout", "60s"]);
assert_eq!(out.status.code(), Some(0));
for id in [&build, &test, &ship] {
assert_eq!(h.state_of(id), "completed");
}
let s1 = h.status_json(&build);
let s3 = h.status_json(&ship);
assert!(
s3["started_at"].as_u64().unwrap() >= s1["finished_at"].as_u64().unwrap(),
"the last stage started before the first stage stopped"
);
let names: Vec<String> = h
.list_json()
.iter()
.map(|j| j["name"].as_str().unwrap().to_string())
.collect();
assert_eq!(names, vec!["build", "test", "ship"]);
}
#[test]
fn an_after_job_runs_when_the_job_before_it_fails() {
let h = Harness::with_default_config("afterjob");
let build = h.submit(&["submit", "--name", "build", "--", "sh", "-c", "exit 3"]);
let cleanup = h.submit(&[
"submit",
"--name",
"cleanup",
"--after",
&build,
"--",
"sh",
"-c",
"echo cleaned",
]);
let out = h.qex(&["wait", &cleanup, "--timeout", "60s"]);
assert_eq!(out.status.code(), Some(0), "an --after job must run");
assert_eq!(h.state_of(&build), "failed");
assert_eq!(h.state_of(&cleanup), "completed");
assert!(h.ok(&["logs", &cleanup]).contains("cleaned"));
}
#[test]
fn a_job_that_waits_for_another_job_does_not_hold_capacity() {
let h = Harness::new(
"depcapacity",
"[budget]\ncpu = \"2\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let slow = h.submit(&[
"submit", "--name", "slow", "--cpu", "1", "--mem", "64MB", "--", "sleep", "4",
]);
let waiter = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--needs", &slow, "--", "true",
]);
let free = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
let out = h.qex(&["wait", &free, "--timeout", "20s"]);
assert_eq!(
out.status.code(),
Some(0),
"a job with no dependency must not wait for a job that has one"
);
h.ok(&["wait", &waiter, "--timeout", "60s"]);
h.ok(&["wait", &slow, "--timeout", "60s"]);
}
#[test]
fn a_dependency_that_does_not_exist_is_refused() {
let h = Harness::with_default_config("nodep");
let out = h.qex(&["submit", "--needs", "no-such-job", "--", "true"]);
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("no-such-job"),
"the error must name the value: {err}"
);
}
#[test]
fn clean_keeps_a_job_that_another_job_needs() {
let h = Harness::with_default_config("cleandep");
let first = h.submit(&[
"submit",
"--name",
"first",
"--",
"sh",
"-c",
"sleep 2; exit 1",
]);
let second = h.submit(&["submit", "--needs", &first, "--", "true"]);
let out = h.qex(&["clean", &first]);
assert!(!out.status.success(), "qex must keep this job");
let out = h.qex(&["wait", &second, "--timeout", "60s"]);
assert_eq!(out.status.code(), Some(126));
assert_eq!(h.state_of(&second), "skipped");
}
#[test]
fn clean_accepts_a_state_name() {
let h = Harness::with_default_config("cleanword");
let good = h.submit(&["submit", "--", "true"]);
let bad = h.submit(&["submit", "--", "false"]);
h.ok(&["wait", &good, "--timeout", "30s"]);
h.qex(&["wait", &bad, "--timeout", "30s"]);
h.ok(&["clean", "completed"]);
let states: Vec<String> = h
.list_json()
.iter()
.map(|j| j["state"].as_str().unwrap().to_string())
.collect();
assert_eq!(states, vec!["failed"], "qex deleted the wrong jobs");
}
fn waits_for_the_test(gate: &std::path::Path) -> String {
let root = gate
.parent()
.expect("the gate file is in the directory of the test");
format!(
"i=0; while [ ! -f {gate} ]; do \
[ -d {root} ] || exit 0; \
i=$((i+1)); [ $i -gt 2400 ] && exit 0; \
sleep 0.05; done",
gate = gate.display(),
root = root.display()
)
}
fn release(gate: &std::path::Path) {
std::fs::write(gate, "go").expect("the test makes its own gate file");
}
#[test]
fn clean_keeps_every_record_of_a_pipeline_that_operates() {
let h = Harness::with_default_config("cleanchain");
let gate = h.root.join("gate");
let waiting = waits_for_the_test(&gate);
let a = h.submit(&["submit", "--name", "c-a", "--mem", "64MB", "--", "true"]);
let b = h.submit(&[
"submit", "--name", "c-b", "--mem", "64MB", "--needs", &a, "--", "true",
]);
let c = h.submit(&[
"submit", "--name", "c-c", "--mem", "64MB", "--needs", &b, "--", "sh", "-c", &waiting,
]);
h.until("the last stage operates", Duration::from_secs(30), || {
h.state_of(&c) == "running"
});
h.ok(&["clean", "--state", "done"]);
let ids: Vec<String> = h
.list_json()
.iter()
.map(|j| j["id"].as_str().unwrap().to_string())
.collect();
assert!(
ids.contains(&b),
"the stage that the running stage waits for must stay: {ids:?}"
);
assert!(
ids.contains(&a),
"the FIRST stage must stay: a walk of one step loses it, and its work happened: {ids:?}"
);
h.ok(&["kill", &c]);
}
#[test]
fn the_message_names_the_work_that_holds_a_record() {
let h = Harness::new(
"cleanwho",
"[budget]\ncpu = \"4\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let gate = h.root.join("gate");
let waiting = waits_for_the_test(&gate);
let dep = h.submit(&["submit", "--name", "m-dep", "--mem", "64MB", "--", "true"]);
h.ok(&["wait", &dep, "--timeout", "30s"]);
let holder = h.submit(&[
"submit", "--name", "m-holder", "--mem", "64MB", "--needs", &dep, "--", "sh", "-c",
&waiting,
]);
let lone_one = h.submit(&[
"submit",
"--name",
"m-lone-one",
"--mem",
"64MB",
"--",
"sh",
"-c",
&waiting,
]);
let lone_two = h.submit(&[
"submit",
"--name",
"m-lone-two",
"--mem",
"64MB",
"--",
"sh",
"-c",
&waiting,
]);
for id in [&holder, &lone_one, &lone_two] {
h.until("each job operates", Duration::from_secs(45), || {
h.state_of(id) == "running"
});
}
let out = h.ok(&["clean", "--state", "done"]);
assert!(
out.contains(&holder[..8]),
"the message must name the job that holds the record: {out}"
);
assert!(
!out.contains(&lone_one[..8]),
"the message must not name a job that holds nothing: {out}"
);
assert!(
!out.contains(&lone_two[..8]),
"the message must not name a job that holds nothing: {out}"
);
release(&gate);
}
#[test]
fn gc_counts_only_the_records_that_the_age_selected() {
let h = Harness::with_default_config("gccount");
let gate = h.root.join("gate");
let waiting = waits_for_the_test(&gate);
let a = h.submit(&["submit", "--name", "g-a", "--mem", "64MB", "--", "true"]);
h.ok(&["wait", &a, "--timeout", "30s"]);
let b = h.submit(&[
"submit", "--name", "g-b", "--mem", "64MB", "--needs", &a, "--", "sh", "-c", &waiting,
]);
h.until("the second job operates", Duration::from_secs(30), || {
h.state_of(&b) == "running"
});
let out = h.ok(&["gc", "--older-than", "1h"]);
assert!(
!out.contains("stayed"),
"the count must hold the records that the age selected, and it said: {out}"
);
release(&gate);
}
#[test]
fn the_coordinator_refuses_a_deletion_that_a_chain_needs() {
let h = Harness::with_default_config("cleandeep");
let gate = h.root.join("gate");
let waiting = waits_for_the_test(&gate);
let a = h.submit(&["submit", "--name", "d-a", "--mem", "64MB", "--", "true"]);
let b = h.submit(&[
"submit", "--name", "d-b", "--mem", "64MB", "--needs", &a, "--", "true",
]);
let c = h.submit(&[
"submit", "--name", "d-c", "--mem", "64MB", "--needs", &b, "--", "sh", "-c", &waiting,
]);
h.until("the last stage operates", Duration::from_secs(30), || {
h.state_of(&c) == "running"
});
let out = h.qex(&["clean", &a]);
let text = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
!out.status.success(),
"qex must refuse this deletion and say so with a code: {text}"
);
assert!(
text.contains("is needed by"),
"the refusal must name the job to wait for: {text}"
);
assert!(
h.list_json().iter().any(|j| j["id"] == a),
"the record must stay"
);
release(&gate);
}
#[test]
fn the_coordinator_keeps_a_stage_of_a_pipeline_that_operates() {
let h = Harness::with_default_config("cleanbranch");
let gate = h.root.join("gate");
let file = h.root.join("p.toml");
std::fs::write(
&file,
format!(
"[[jobs]]\nname = \"s1\"\ncommand = [\"true\"]\n\
[jobs.resources]\nmem = \"64MB\"\n\
[[jobs]]\nname = \"s2b\"\ncommand = [\"true\"]\nneeds = [\"s1\"]\n\
[jobs.resources]\nmem = \"64MB\"\n\
[[jobs]]\nname = \"s3\"\ncommand = [\"sh\", \"-c\", \"{}\"]\nneeds = [\"s1\"]\n\
[jobs.resources]\nmem = \"64MB\"\n",
waits_for_the_test(&gate)
),
)
.expect("the test writes its own pipeline file");
h.ok(&["pipeline", file.to_str().unwrap()]);
h.until("the last stage operates", Duration::from_secs(45), || {
h.list_json()
.iter()
.any(|j| j["name"] == "s3" && j["state"] == "running")
});
let branch = h
.list_json()
.iter()
.find(|j| j["name"] == "s2b")
.map(|j| j["id"].as_str().unwrap().to_string())
.expect("the branch stage has a record");
let out = h.qex(&["clean", &branch]);
let text = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
!out.status.success(),
"qex must refuse to delete a stage of a pipeline that operates: {text}"
);
assert!(
h.list_json().iter().any(|j| j["id"] == branch),
"the record of the branch stage must stay"
);
release(&gate);
}
#[test]
fn clean_counts_only_the_records_that_the_reader_asked_for() {
let h = Harness::with_default_config("cleancount");
let gate = h.root.join("gate");
let waiting = waits_for_the_test(&gate);
let a = h.submit(&["submit", "--name", "n-a", "--mem", "64MB", "--", "true"]);
h.ok(&["wait", &a, "--timeout", "30s"]);
let b = h.submit(&[
"submit", "--name", "n-b", "--mem", "64MB", "--needs", &a, "--", "sh", "-c", &waiting,
]);
h.until("the second job operates", Duration::from_secs(30), || {
h.state_of(&b) == "running"
});
let out = h.qex(&["clean", &b]);
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
!text.contains("stayed"),
"the count must hold the records that the reader asked for, and it said: {text}"
);
release(&gate);
}
#[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 a_command_that_waits_answers_at_once_for_a_job_that_does_not_exist() {
let h = Harness::with_default_config("waitnojob");
let unknown = "11111111-2222-3333-4444-666666666666";
let forms: [&[&str]; 5] = [
&["wait", unknown, "--timeout", "10s"],
&["wait", unknown, "--next", "--timeout", "10s"],
&["status", unknown, "--wait", "--timeout", "10s", "--no-logs"],
&[
"status",
unknown,
"--follow",
"--timeout",
"10s",
"--no-logs",
],
&["logs", unknown, "--follow"],
];
for form in forms {
let started = Instant::now();
let out = h.qex_within(form, Duration::from_secs(30));
let took = started.elapsed();
assert_eq!(
out.status.code(),
Some(127),
"`qex {}` must give the code 127, and it gave this: {}",
form.join(" "),
String::from_utf8_lossy(&out.stderr)
);
assert!(
took < Duration::from_secs(8),
"`qex {}` took {took:?}, and the answer was ready at once",
form.join(" ")
);
}
}
#[test]
fn a_wait_for_a_record_that_clean_deleted_says_the_work_happened() {
let h = Harness::with_default_config("waitcleaned");
let id = h.submit(&["submit", "--name", "gone", "--", "true"]);
h.until("the job stops", Duration::from_secs(45), || {
h.state_of(&id) == "completed"
});
h.ok(&["clean", &id]);
let started = Instant::now();
let out = h.qex_within(&["wait", &id, "--timeout", "10s"], Duration::from_secs(30));
let took = started.elapsed();
let said = String::from_utf8_lossy(&out.stderr).to_string();
assert_eq!(out.status.code(), Some(127), "the answer is 127: {said}");
assert!(
took < Duration::from_secs(8),
"the wait took {took:?}, and the answer was ready at once"
);
assert!(
said.contains("HAPPENED"),
"the message must say that the work happened, so that an agent does \
not repeat it: {said}"
);
assert!(
said.contains("gone"),
"the message must name the job: {said}"
);
}
#[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("deleted 0 records"), "nothing must go: {out}");
assert!(
out.contains("record stayed") && out.contains("needs a record"),
"the message must give the reason: {out}"
);
assert!(
out.contains(&second[..8]),
"the message must name the work that holds the record: {out}"
);
assert!(
h.list_json().iter().any(|j| j["id"] == first),
"the record of the first job must stay"
);
h.ok(&["kill", &blocker, "--grace", "1s"]);
}
#[test]
fn du_reports_the_space_that_qex_holds() {
let h = Harness::with_default_config("du");
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"i=0; while [ $i -lt 500 ]; do echo padding-line-$i; i=$((i+1)); done",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let text = h.ok(&["du", "--json"]);
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(v["total_bytes"].as_u64().unwrap() > 0);
assert!(v["jobs_bytes"].as_u64().unwrap() > 0);
assert_eq!(v["largest"][0]["id"].as_str(), Some(id.as_str()));
}
#[test]
fn a_long_runtime_directory_still_works() {
let long = std::env::temp_dir()
.join(format!("qex-long-{}", std::process::id()))
.join("a-directory-with-a-very-long-name")
.join("another-directory-with-a-long-name")
.join("and-one-more-to-pass-the-limit-of-sun-path");
let h = Harness::with_default_config("longpath");
std::fs::create_dir_all(&long).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["submit", "--", "echo", "long-path-ok"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", &long)
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.unwrap();
assert!(
out.status.success(),
"qex failed with a long runtime directory: {}",
String::from_utf8_lossy(&out.stderr)
);
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
let wait = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["wait", &id, "--timeout", "30s"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", &long)
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.unwrap();
assert_eq!(wait.status.code(), Some(0));
let info = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["info", "--json"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", &long)
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.unwrap();
if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&info.stdout) {
if let Some(pid) = v["pid"].as_i64() {
unsafe {
libc::kill(pid as i32, libc::SIGKILL);
}
}
}
std::fs::remove_dir_all(long.ancestors().nth(3).unwrap()).ok();
}
#[test]
fn the_status_gives_the_measured_use_of_a_job() {
let h = Harness::with_default_config("usage");
let id = h.submit(&[
"submit",
"--mem",
"512MB",
"--",
"sh",
"-c",
"head -c 8000000 /dev/zero > /dev/null",
]);
h.ok(&["wait", &id]);
let status = h.status_json(&id);
let rss = status["usage"]["max_rss"].as_u64().unwrap();
assert!(rss > 0, "qex must measure the memory of a job");
assert!(
rss < 512 * 1024 * 1024,
"the measurement {rss} is larger than the claim, so the unit is wrong"
);
}
#[test]
fn a_job_that_starts_again_never_shows_the_attempt_that_failed() {
let h = Harness::with_default_config("retrylatch");
let counter = h.root.join("attempts");
let script = format!(
"n=$(cat {c} 2>/dev/null || echo 0); n=$((n+1)); echo $n > {c}; \
if [ $n -lt 2 ]; then exit 3; fi; sleep 5",
c = counter.display()
);
let id = h.submit(&["submit", "--retries", "4", "--", "sh", "-c", &script]);
let record = h.root.join("state/qex/jobs").join(&id).join("status.json");
let deadline = Instant::now() + Duration::from_secs(90);
let mut saw_a_later_attempt = false;
let mut samples = 0u64;
loop {
if let Ok(text) = std::fs::read_to_string(&record) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
samples += 1;
let state = v["state"].as_str().unwrap_or("");
let attempts = v["attempts"].as_u64().unwrap_or(0);
assert_ne!(
state, "failed",
"the record must never hold the state of an attempt that starts \
again; it held `failed` at attempt {attempts}"
);
if state == "running" && attempts > 1 {
saw_a_later_attempt = true;
}
if state == "completed" {
break;
}
}
}
assert!(
Instant::now() < deadline,
"the job did not finish after {samples} samples"
);
}
assert!(
saw_a_later_attempt,
"the test must see an attempt after the first one, or it tests nothing"
);
assert!(
samples > 100,
"the test must sample the record often enough to meet the window; it took \
{samples} samples"
);
let status = h.status_json(&id);
assert_eq!(status["state"], "completed", "got: {status}");
assert_eq!(status["attempts"], 2, "got: {status}");
assert_eq!(status["exit_code"], 0, "got: {status}");
}
#[test]
fn a_config_fault_in_the_record_of_a_job_stays_short() {
let good = "[budget]\ncpu = \"1\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n";
let h = Harness::new("cfgrec", good);
let occupier = h.submit(&[
"submit", "--cpu", "1", "--mem", "128MB", "--", "sleep", "300",
]);
h.until("the first job operates", Duration::from_secs(45), || {
h.has_started(&occupier)
});
let victim = h.submit(&["submit", "--cpu", "1", "--mem", "128MB", "--", "true"]);
assert_eq!(
h.state_of(&victim),
"queued",
"the budget of one core must hold the second job in the queue"
);
h.write_config(&format!(
"{good}\n[telemetry]\nendpoint = \"https://example.invalid\"\n"
));
h.qex(&["kill", &occupier, "--grace", "1s"]);
h.until("the second job stops", Duration::from_secs(60), || {
h.state_of(&victim) == "completed"
});
let status = h.ok(&["status", &victim]);
assert!(
status.contains("NO LIMIT OPERATES"),
"the supervisor did not meet the config fault, so this test measured \
nothing: {status}"
);
assert!(
!status.contains("coordinator"),
"the record of a job must hold the short form of a config fault. The \
supervisor asked for the long form, which belongs to a person at a \
terminal and not to the `error:` field of a job that already ran: \
{status}"
);
}
#[test]
fn a_job_that_writes_more_than_the_limit_keeps_the_head_and_the_tail() {
let h = Harness::new(
"logcap",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[logs]\nmax_bytes = \"64KB\"\n",
);
let id = h.submit(&["submit", "--", "sh", "-c", "seq 1 500000"]);
let wait = h.qex(&["wait", &id, "--timeout", "60s"]);
assert_eq!(
wait.status.code(),
Some(0),
"the limit on the output must not fail the job"
);
assert_eq!(h.state_of(&id), "completed");
let path = h.job_dir(&id).join("stdout.log");
let size = std::fs::metadata(&path).unwrap().len();
assert!(
size <= 64 * 1024,
"the file holds {size} bytes, and the limit is 65536"
);
let text = std::fs::read_to_string(&path).unwrap();
assert!(
text.lines().any(|l| l == "1"),
"the first line went, and it holds the start of the job"
);
assert!(
text.lines().any(|l| l == "500000"),
"the last line went, and it holds the end of the job"
);
assert!(
!text.lines().any(|l| l == "250000"),
"the middle must go, and it stayed"
);
assert!(
text.contains("are not in this file"),
"the file must say what went: {:.400}",
text
);
assert!(
!h.job_dir(&id).join("stdout.log.tail").exists(),
"the file that held the last output stayed"
);
let status = h.status_json(&id);
let dropped = &status["logs_dropped"];
assert!(
dropped["stdout_bytes"].as_u64().unwrap() > 0,
"the record must say how many bytes went: {status}"
);
assert!(
dropped["stdout_lines"].as_u64().unwrap() > 1000,
"the record must say how many lines went: {status}"
);
assert_eq!(dropped["limit"].as_u64().unwrap(), 64 * 1024);
let notes = text.lines().filter(|l| l.starts_with("[qex]")).count() as u64;
let kept = text.matches('\n').count() as u64 - notes;
assert_eq!(
kept + dropped["stdout_lines"].as_u64().unwrap(),
500_000,
"the file holds {kept} line(s) and the record says that {} went. Together they \
must be the 500000 lines that the job wrote.",
dropped["stdout_lines"]
);
let note_bytes: u64 = text
.lines()
.filter(|l| l.starts_with("[qex]"))
.map(|l| l.len() as u64 + 1)
.sum();
let kept_bytes = text.len() as u64 - note_bytes;
assert_eq!(
kept_bytes + dropped["stdout_bytes"].as_u64().unwrap(),
3_388_895,
"the file holds {kept_bytes} byte(s) of the job and the record says that {} went. \
Together they must be the 3388895 bytes that `seq 1 500000` writes.",
dropped["stdout_bytes"]
);
let head = &text[..text
.find("[qex]")
.expect("the file must hold a note of qex")];
for line in head.lines() {
let n: u64 = line
.parse()
.unwrap_or_else(|_| panic!("the head holds `{line}`, which is not a whole line"));
assert!((1..=500_000).contains(&n), "`{line}` is not in the output");
}
let head_budget: u64 = 64 * 1024 / 4;
let cut_cost = head_budget - head.len() as u64;
assert!(
cut_cost < 64,
"the cut back to a line end removed {cut_cost} bytes of the head budget of \
{head_budget}. No line of this output is longer than seven bytes, so the cut back \
must move to the LAST line end before the cut, and not to the first one of the \
window."
);
let logs = h.qex(&["logs", &id, "--stdout", "--tail", "5"]);
let notice = String::from_utf8_lossy(&logs.stderr);
assert!(
notice.contains("qex removed"),
"`qex logs` must say what went: {notice}"
);
let json = h.ok(&["logs", &id, "--stdout", "--json"]);
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(value["stdout_dropped_lines"].as_u64().unwrap() > 1000);
}
#[test]
fn a_job_that_writes_one_enormous_line_keeps_its_end() {
let h = Harness::new(
"logcap-line",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[logs]\nmax_bytes = \"64KB\"\n",
);
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"dd if=/dev/zero bs=1M count=8 2>/dev/null | tr '\\0' 'A'; printf THE-VERY-END",
]);
let wait = h.qex(&["wait", &id, "--timeout", "60s"]);
assert_eq!(wait.status.code(), Some(0));
let path = h.job_dir(&id).join("stdout.log");
let size = std::fs::metadata(&path).unwrap().len();
assert!(size <= 64 * 1024, "the file holds {size} bytes");
let text = std::fs::read_to_string(&path).unwrap();
assert!(
text.ends_with("THE-VERY-END"),
"the end of the output went, and the file holds the head only"
);
assert!(
text.contains("middle of a line"),
"the file must say that the last part is not a whole line"
);
}
#[test]
fn a_retry_that_fits_the_limit_keeps_the_output_of_both_attempts() {
let h = Harness::new(
"logcap-retry",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[logs]\nmax_bytes = \"1MB\"\n",
);
let id = h.submit(&[
"submit",
"--retries",
"1",
"--",
"sh",
"-c",
"seq 1 20000; echo THE-LAST-LINE; exit 1",
]);
h.qex(&["wait", &id, "--timeout", "60s"]);
let text = std::fs::read_to_string(h.job_dir(&id).join("stdout.log")).unwrap();
assert!(
!text.contains("[qex]"),
"qex wrote a note about the limit, and the output fits in the limit"
);
assert_eq!(
text.lines().filter(|l| *l == "THE-LAST-LINE").count(),
2,
"each attempt must keep its output"
);
assert!(
text.contains("--- attempt 2 ---"),
"the mark between the attempts went"
);
assert_eq!(
h.status_json(&id)["logs_dropped"],
serde_json::Value::Null,
"the record says that qex removed output, and it removed nothing"
);
}
#[test]
fn follow_does_not_lose_the_output_of_a_job_that_passes_the_limit() {
let h = Harness::new(
"logcap-follow",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[logs]\nmax_bytes = \"64KB\"\n",
);
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"seq 1 12000; sleep 2; seq 12001 13000; sleep 2; echo THE-FINAL-LINE",
]);
let out = h.qex(&["logs", &id, "--stdout", "--follow"]);
assert_eq!(out.status.code(), Some(0));
let lines = String::from_utf8_lossy(&out.stdout).into_owned();
let notice = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
lines.contains("THE-FINAL-LINE"),
"the follower lost each line after the limit. It gave:\n{:.600}\n--- stderr ---\n{}",
lines,
notice
);
assert!(
notice.contains("removed"),
"the follower must say that qex removed output. It said: {notice}"
);
}
#[test]
fn a_follower_that_starts_after_the_limit_still_learns_what_went() {
let h = Harness::new(
"logcap-late",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[logs]\nmax_bytes = \"64KB\"\n",
);
let id = h.submit(&["submit", "--", "sh", "-c", "seq 1 500000; sleep 5"]);
let tail = h.job_dir(&id).join("stdout.log.tail");
h.until(
"the output passes the limit",
Duration::from_secs(60),
|| tail.exists(),
);
let out = h.qex(&["logs", &id, "--stdout", "--follow"]);
assert_eq!(out.status.code(), Some(0));
let lines = String::from_utf8_lossy(&out.stdout).into_owned();
let notice = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
lines.contains("\n500000\n"),
"the follower lost the end of the output: {:.400}",
lines
);
assert!(
notice.contains("from the middle of this stream"),
"the follower must give the count of the output that went, and it said: {notice}"
);
}
#[test]
fn a_job_that_stops_runs_the_stop_hook_one_time_with_its_result() {
let h = Harness::with_default_config("hookone");
let mark = h.root.join("hook.txt");
h.write_config(&format!(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[hooks]\non_stop = [\"sh\", \"-c\", \
\"echo \\\"$QEX_JOB_ID $QEX_STATE $QEX_EXIT_CODE $QEX_JOB_NAME\\\" >> {}\"]\n",
mark.display()
));
let id = h.submit(&["submit", "--name", "report", "--", "sh", "-c", "exit 7"]);
let out = h.qex(&["wait", &id]);
assert_eq!(out.status.code(), Some(7));
assert_eq!(h.state_of(&id), "failed");
h.until(
"the stop hook wrote its line",
Duration::from_secs(30),
|| !h.hook_lines().is_empty(),
);
let lines = h.hook_lines();
assert_eq!(lines.len(), 1, "the hook must run one time: {lines:?}");
assert_eq!(
lines[0],
format!("{id} failed 7 report"),
"the hook must receive the id, the state and the exit code"
);
assert!(h.job_dir(&id).join("hook.ran").exists());
assert_eq!(
h.hook_origin(&id),
"supervisor",
"the supervisor of a job that ran must be the process that notifies"
);
std::thread::sleep(Duration::from_secs(2));
assert_eq!(h.hook_lines().len(), 1, "the hook ran more than one time");
}
#[test]
fn a_stop_hook_that_hangs_holds_neither_the_job_nor_the_queue() {
let h = Harness::with_default_config("hookhang");
let mark = h.root.join("hook.txt");
h.write_config(&format!(
"[budget]\ncpu = \"1\"\nmem = \"512MB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[hooks]\ntimeout = \"2s\"\n\
on_stop = [\"sh\", \"-c\", \"echo hanging >> {}; sleep 300\"]\n",
mark.display()
));
let first = h.submit(&["submit", "--cpu", "1", "--mem", "128MB", "--", "true"]);
let out = h.qex(&["wait", &first, "--timeout", "30s"]);
assert_eq!(
out.status.code(),
Some(0),
"a hook that hangs must not hold the job in a state that is not final"
);
assert_eq!(h.state_of(&first), "completed");
h.until("the stop hook started", Duration::from_secs(30), || {
!h.hook_lines().is_empty()
});
let info = h.ok(&["info", "--json"]);
assert!(
info.contains("\"pid\""),
"the coordinator must answer: {info}"
);
let second = h.submit(&["submit", "--cpu", "1", "--mem", "128MB", "--", "true"]);
let out = h.qex(&["wait", &second, "--timeout", "30s"]);
assert_eq!(
out.status.code(),
Some(0),
"a hook that hangs must not delay the next job"
);
assert_eq!(h.state_of(&second), "completed");
}
#[test]
fn the_configured_states_select_the_jobs_that_run_the_stop_hook() {
let h = Harness::with_default_config("hookstates");
let mark = h.root.join("hook.txt");
h.write_config(&format!(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[hooks]\non_stop_states = [\"skipped\"]\n\
on_stop = [\"sh\", \"-c\", \"echo \\\"$QEX_STATE $QEX_JOB_NAME\\\" >> {}\"]\n",
mark.display()
));
let build = h.submit(&["submit", "--name", "build", "--", "false"]);
let test = h.submit(&["submit", "--name", "test", "--needs", &build, "--", "true"]);
h.qex(&["wait", &build]);
h.until("the second job is skipped", Duration::from_secs(30), || {
h.state_of(&test) == "skipped"
});
h.until(
"the stop hook wrote its line",
Duration::from_secs(30),
|| !h.hook_lines().is_empty(),
);
std::thread::sleep(Duration::from_secs(1));
let lines = h.hook_lines();
assert_eq!(
lines,
vec!["skipped test".to_string()],
"the filter must select the state `skipped` only"
);
assert_eq!(
h.hook_origin(&test),
"coordinator",
"a job that never ran has no supervisor, so the coordinator notifies"
);
}
#[test]
fn a_job_whose_supervisor_stopped_still_runs_the_stop_hook_one_time() {
let h = Harness::with_default_config("hookdead");
let mark = h.root.join("hook.txt");
h.write_config(&format!(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[hooks]\non_stop = [\"sh\", \"-c\", \"echo \\\"$QEX_STATE\\\" >> {}\"]\n",
mark.display()
));
let id = h.submit(&["submit", "--name", "long", "--", "sleep", "60"]);
h.until("the job operates", Duration::from_secs(45), || {
h.status_json(&id)["supervisor_pid"].as_i64().is_some() && h.state_of(&id) == "running"
});
let supervisor = h.status_json(&id)["supervisor_pid"].as_i64().unwrap() as i32;
unsafe {
libc::kill(supervisor, libc::SIGKILL);
}
h.until("the job is failed", Duration::from_secs(45), || {
h.state_of(&id) == "failed"
});
h.until(
"the stop hook wrote its line",
Duration::from_secs(30),
|| !h.hook_lines().is_empty(),
);
std::thread::sleep(Duration::from_secs(2));
assert_eq!(
h.hook_lines(),
vec!["failed".to_string()],
"the hook must run one time for a job that lost its supervisor"
);
assert_eq!(
h.hook_origin(&id),
"coordinator",
"with no supervisor, the coordinator must be the process that notifies"
);
let text = h.ok(&["logs", &id, "--hook"]);
assert!(
text.contains("qex: the stop hook"),
"`qex logs --hook` must give the verdict of qex: {text}"
);
}
#[test]
fn a_stop_hook_that_the_user_deletes_does_not_run_again() {
let h = Harness::with_default_config("hookstale");
let mark = h.root.join("hook.txt");
let base = "[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n";
h.write_config(&format!(
"{base}[hooks]\non_stop = [\"sh\", \"-c\", \"echo \\\"$QEX_JOB_NAME\\\" >> {}\"]\n",
mark.display()
));
let first = h.submit(&["submit", "--name", "first", "--", "true"]);
h.ok(&["wait", &first]);
h.until(
"the stop hook wrote its line",
Duration::from_secs(30),
|| !h.hook_lines().is_empty(),
);
assert_eq!(h.hook_lines(), vec!["first".to_string()]);
h.write_config(base);
let second = h.submit(&["submit", "--name", "second", "--", "true"]);
h.ok(&["wait", &second]);
std::thread::sleep(Duration::from_secs(2));
assert_eq!(
h.hook_lines(),
vec!["first".to_string()],
"a hook that the user deleted must not run"
);
assert!(
!h.job_dir(&second).join("hook.ran").exists(),
"qex must not record a run of a hook that does not exist"
);
h.write_config(&format!(
"{base}[hooks]\non_stop_states = [\"skipped\"]\n\
on_stop = [\"sh\", \"-c\", \"echo \\\"$QEX_STATE\\\" >> {}\"]\n",
mark.display()
));
let build = h.submit(&["submit", "--name", "build", "--", "false"]);
let dependent = h.submit(&["submit", "--name", "dep", "--needs", &build, "--", "true"]);
h.qex(&["wait", &build]);
h.until(
"the dependent job is skipped",
Duration::from_secs(30),
|| h.state_of(&dependent) == "skipped",
);
h.until(
"the new hook wrote its line",
Duration::from_secs(30),
|| h.hook_lines().len() > 1,
);
assert_eq!(
h.hook_lines(),
vec!["first".to_string(), "skipped".to_string()],
"a hook that the user adds must run on the next job that stops"
);
}
#[test]
fn a_job_notifies_from_its_supervisor_when_no_coordinator_operates() {
let h = Harness::with_default_config("hooknocoord");
let mark = h.root.join("hook.txt");
h.write_config(&format!(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[hooks]\non_stop = [\"sh\", \"-c\", \
\"echo \\\"$QEX_STATE $QEX_JOB_NAME\\\" >> {}\"]\n",
mark.display()
));
let id = h.submit(&["submit", "--name", "alone", "--", "sleep", "5"]);
h.until("the job operates", Duration::from_secs(45), || {
h.state_of(&id) == "running" && h.status_json(&id)["supervisor_pid"].as_i64().is_some()
});
let coordinator = h.coordinator_pid();
unsafe {
libc::kill(coordinator, libc::SIGKILL);
}
let deadline = Instant::now() + Duration::from_secs(30);
while unsafe { libc::kill(coordinator, 0) } == 0 {
assert!(
Instant::now() < deadline,
"the coordinator {coordinator} did not stop"
);
std::thread::sleep(Duration::from_millis(50));
}
h.until(
"the stop hook wrote its line",
Duration::from_secs(60),
|| !h.hook_lines().is_empty(),
);
std::thread::sleep(Duration::from_secs(1));
assert_eq!(
h.hook_lines(),
vec!["completed alone".to_string()],
"a job must give one message when no coordinator operates"
);
assert_eq!(
h.hook_origin(&id),
"supervisor",
"with no coordinator, only the supervisor can have notified"
);
}
#[test]
fn a_job_that_gives_up_waiting_runs_the_stop_hook() {
let h = Harness::new(
"hookexpire",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\noversized = \"queue\"\n",
);
let mark = h.root.join("hook.txt");
h.write_config(&format!(
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\noversized = \"queue\"\n\
[hooks]\non_stop = [\"sh\", \"-c\", \
\"echo \\\"$QEX_STATE $QEX_JOB_NAME\\\" >> {}\"]\n",
mark.display()
));
let id = h.submit(&[
"submit",
"--name",
"waiter",
"--cpu",
"64",
"--max-queue-time",
"3s",
"--",
"echo",
"never",
]);
h.until("the job gives up", Duration::from_secs(45), || {
h.state_of(&id) == "expired"
});
h.until(
"the stop hook wrote its line",
Duration::from_secs(30),
|| !h.hook_lines().is_empty(),
);
std::thread::sleep(Duration::from_secs(1));
assert_eq!(
h.hook_lines(),
vec!["expired waiter".to_string()],
"a job that gave up waiting must give one message"
);
assert_eq!(
h.hook_origin(&id),
"coordinator",
"such a job never had a supervisor, so the coordinator notifies"
);
}
#[test]
fn a_job_whose_command_does_not_exist_still_runs_the_stop_hook() {
let h = Harness::with_default_config("hooknocmd");
let mark = h.root.join("hook.txt");
h.write_config(&format!(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[hooks]\non_stop = [\"sh\", \"-c\", \
\"echo \\\"$QEX_STATE $QEX_JOB_NAME\\\" >> {}\"]\n",
mark.display()
));
let id = h.submit(&["submit", "--name", "typo", "--", "qex-no-such-program"]);
h.until("the job failed", Duration::from_secs(45), || {
h.state_of(&id) == "failed"
});
h.until(
"the stop hook wrote its line",
Duration::from_secs(30),
|| !h.hook_lines().is_empty(),
);
std::thread::sleep(Duration::from_secs(1));
assert_eq!(
h.hook_lines(),
vec!["failed typo".to_string()],
"a job whose command does not exist must give one message"
);
assert_eq!(
h.hook_origin(&id),
"supervisor",
"the supervisor must notify for a command that does not exist"
);
}
#[test]
fn a_cancelled_job_runs_the_stop_hook_when_the_config_file_asks_for_it() {
let h = Harness::with_default_config("hookcancel");
let mark = h.root.join("hook.txt");
h.write_config(&format!(
"[budget]\ncpu = \"1\"\nmem = \"512MB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[hooks]\non_stop_states = [\"cancelled\"]\n\
on_stop = [\"sh\", \"-c\", \
\"echo \\\"$QEX_STATE $QEX_JOB_NAME\\\" >> {}\"]\n",
mark.display()
));
let occupier = h.submit(&[
"submit", "--cpu", "1", "--mem", "128MB", "--", "sleep", "300",
]);
h.until("the first job operates", Duration::from_secs(45), || {
h.has_started(&occupier)
});
let waiting = h.submit(&[
"submit", "--name", "later", "--cpu", "1", "--mem", "128MB", "--", "true",
]);
assert_eq!(
h.state_of(&waiting),
"queued",
"the budget of one core must hold this job in the queue"
);
h.qex(&["cancel", &waiting]);
h.until("the job is cancelled", Duration::from_secs(30), || {
h.state_of(&waiting) == "cancelled"
});
h.until(
"the stop hook wrote its line",
Duration::from_secs(30),
|| !h.hook_lines().is_empty(),
);
std::thread::sleep(Duration::from_secs(1));
assert_eq!(
h.hook_lines(),
vec!["cancelled later".to_string()],
"a cancelled job must give the message that the config file asks for"
);
assert_eq!(
h.hook_origin(&waiting),
"coordinator",
"a job that never ran has no supervisor, so the coordinator notifies"
);
h.qex(&["kill", &occupier, "--grace", "1s"]);
}
#[test]
fn qex_logs_hook_says_when_there_was_no_stop_hook() {
let h = Harness::with_default_config("hooknone");
let id = h.submit(&["submit", "--", "true"]);
h.ok(&["wait", &id]);
let out = h.qex(&["logs", &id, "--hook"]);
assert_eq!(
out.status.code(),
Some(0),
"this is not a fault of the user"
);
let notice = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
notice.contains("ran no stop hook"),
"the reader must learn that there was no hook: {notice}"
);
assert!(
String::from_utf8_lossy(&out.stdout).is_empty(),
"the standard output must hold the log only"
);
}
#[test]
fn each_line_gives_one_job_for_each_line_in_one_group() {
let h = Harness::with_default_config("eachline");
let input = h.root.join("inputs.txt");
std::fs::write(&input, "alpha\nbeta\ngamma\n").unwrap();
let ids = h.root.join("ids.env");
let group = h.ok(&[
"submit",
"--each-line",
input.to_str().unwrap(),
"--id-file",
ids.to_str().unwrap(),
"--",
"echo",
"value={}",
]);
assert_eq!(
group.lines().count(),
1,
"stdout must hold the group id only: {group}"
);
assert!(
group.parse::<uuid::Uuid>().is_ok(),
"stdout must hold a group id, and it holds: {group}"
);
let text = h.ok(&["list", "--group", &group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_eq!(jobs.len(), 3, "three lines must give three jobs: {text}");
assert_eq!(h.list_json().len(), 3, "no other job may exist");
for job in &jobs {
assert_eq!(
job["group_name"].as_str(),
Some("inputs"),
"each job must carry the name of its group: {job}"
);
}
let text = h.ok(&["list", "--group", "inputs", "--json"]);
let by_name: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_eq!(
by_name.len(),
3,
"`qex list --group inputs` must give the 3 jobs: {text}"
);
let text = h.ok(&["list", "--group", &group[..8], "--json"]);
let by_prefix: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_eq!(by_prefix.len(), 3, "the start of the group id must name it");
let mut seen: Vec<String> = Vec::new();
for job in &jobs {
let id = job["id"].as_str().unwrap();
h.ok(&["wait", id, "--timeout", "60s"]);
assert_eq!(h.state_of(id), "completed");
seen.push(h.ok(&["logs", id, "--stdout"]).trim().to_string());
}
seen.sort();
assert_eq!(seen, vec!["value=alpha", "value=beta", "value=gamma"]);
let names: Vec<String> = jobs
.iter()
.map(|j| j["name"].as_str().unwrap().to_string())
.collect();
assert!(
names.contains(&"echo-1-alpha".to_string()),
"got the names: {names:?}"
);
let text = std::fs::read_to_string(&ids).unwrap();
assert!(text.contains(&format!("group={group}")), "got: {text}");
assert_eq!(text.lines().count(), 4, "group and three jobs: {text}");
}
#[test]
fn a_line_with_a_space_a_quotation_mark_and_a_semicolon_is_one_argument() {
let h = Harness::with_default_config("eachsafe");
let mark = h.root.join("SHELL-RAN");
let line = format!(
"a b\"; touch {}; echo $HOME `id` $(id) 'x'",
mark.to_str().unwrap()
);
let input = h.root.join("inputs.txt");
std::fs::write(&input, format!("{line}\n")).unwrap();
let group = h.ok(&[
"submit",
"--each-line",
input.to_str().unwrap(),
"--",
"printf",
"[%s]",
"{}",
]);
let text = h.ok(&["list", "--group", &group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_eq!(jobs.len(), 1);
let id = jobs[0]["id"].as_str().unwrap();
h.ok(&["wait", id, "--timeout", "60s"]);
let out = h.ok(&["logs", id, "--stdout"]);
assert_eq!(
out.trim(),
format!("[{line}]"),
"the line must become exactly one argument"
);
let status = h.status_json(id);
let command: Vec<String> = status["command"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert_eq!(command, vec!["printf", "[%s]", &line]);
assert!(
!mark.exists(),
"a shell read the line and made the file {}",
mark.display()
);
}
#[test]
fn a_line_never_puts_a_control_character_into_a_job_name() {
let h = Harness::with_default_config("eachname");
let line = "\u{1b}[31mBOOM\u{1b}[0m \u{1b}[2J \u{1b}]0;title\u{7}";
let input = h.root.join("inputs.txt");
std::fs::write(&input, format!("{line}\nsecond\n")).unwrap();
let out = h.qex(&[
"submit",
"--each-line",
input.to_str().unwrap(),
"--",
"echo",
"{}",
]);
assert!(out.status.success());
let group = String::from_utf8_lossy(&out.stdout).trim().to_string();
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
!err.contains('\u{1b}') && !err.contains('\u{7}'),
"the submission output holds a control character: {err:?}"
);
let text = h.ok(&["list", "--group", &group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_eq!(jobs.len(), 2);
for job in &jobs {
let name = job["name"].as_str().unwrap();
assert!(
name.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-'),
"a job name holds a character that must not reach a terminal: {name:?}"
);
}
let listing = h.ok(&["list"]);
assert!(
!listing.contains('\u{1b}') && !listing.contains('\u{7}'),
"`qex list` holds a control character: {listing:?}"
);
let id = jobs[0]["id"].as_str().unwrap();
let status = h.status_json(id);
assert_eq!(
status["command"][1].as_str().unwrap(),
line,
"the job must receive the line as the file holds it"
);
}
#[test]
fn a_placeholder_in_the_program_position_gives_a_clean_name() {
let h = Harness::with_default_config("eachprog");
let input = h.root.join("inputs.txt");
std::fs::write(&input, "\u{1b}[31mBOOM\u{1b}[0m x\nsecond\n").unwrap();
let out = h.qex(&["submit", "--each-line", input.to_str().unwrap(), "--", "{}"]);
assert!(out.status.success());
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
!err.contains('\u{1b}'),
"the base of the name holds an escape: {err:?}"
);
let names: Vec<String> = h
.list_json()
.iter()
.map(|j| j["name"].as_str().unwrap().to_string())
.collect();
assert_eq!(names.len(), 2);
for name in &names {
assert!(
name.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-'),
"the base of the name holds a character that must not reach a terminal: {name:?}"
);
}
assert!(
!h.ok(&["list"]).contains('\u{1b}'),
"`qex list` holds an escape"
);
}
#[test]
fn an_input_with_a_bad_line_submits_no_job_at_all() {
let h = Harness::with_default_config("eachbad");
let input = h.root.join("inputs.txt");
let mut bytes: Vec<u8> = Vec::new();
for i in 1..=90 {
bytes.extend_from_slice(format!("line{i}\n").as_bytes());
}
bytes.extend_from_slice(b"bad \xff line\n");
std::fs::write(&input, &bytes).unwrap();
let out = h.qex(&[
"submit",
"--each-line",
input.to_str().unwrap(),
"--",
"echo",
"{}",
]);
assert!(!out.status.success(), "a bad line must give an error");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("line 91"), "the error must say where: {err}");
assert!(
h.list_json().is_empty(),
"the 90 correct lines must not become jobs"
);
let out = h.qex(&[
"submit",
"--each-line",
input.to_str().unwrap(),
"--",
"echo",
"the same",
]);
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("holds no `{}`"), "got: {err}");
assert!(h.list_json().is_empty());
}
#[test]
fn an_empty_line_and_a_comment_line_give_no_job_and_qex_reports_them() {
let h = Harness::with_default_config("eachskip");
let input = h.root.join("inputs.txt");
std::fs::write(&input, "# a note\n\nalpha\r\n \n#\nbeta").unwrap();
let out = h.qex(&[
"submit",
"--each-line",
input.to_str().unwrap(),
"--",
"echo",
"{}",
]);
assert!(out.status.success());
let group = String::from_utf8_lossy(&out.stdout).trim().to_string();
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("2 empty lines") && err.contains("2 comment lines"),
"qex must say what it passed over: {err}"
);
let text = h.ok(&["list", "--group", &group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_eq!(jobs.len(), 2, "two lines give a job: {text}");
let mut seen: Vec<String> = Vec::new();
for job in &jobs {
let id = job["id"].as_str().unwrap();
h.ok(&["wait", id, "--timeout", "60s"]);
seen.push(h.ok(&["logs", id, "--stdout"]).trim().to_string());
}
seen.sort();
assert_eq!(seen, vec!["alpha", "beta"]);
}
#[test]
fn each_line_reads_the_lines_from_standard_input() {
let h = Harness::with_default_config("eachstdin");
let out = h.qex_stdin(
&["submit", "--each-line", "-", "--", "echo", "{}"],
"one\ntwo\n",
);
assert!(
out.status.success(),
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let group = String::from_utf8_lossy(&out.stdout).trim().to_string();
assert!(group.parse::<uuid::Uuid>().is_ok(), "got: {group}");
let text = h.ok(&["list", "--group", &group, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&text).unwrap();
assert_eq!(jobs.len(), 2);
}
#[test]
fn every_option_of_a_fan_out_reaches_every_job() {
let h = Harness::with_default_config("eachline-options");
let gate = h.submit(&["submit", "--name", "gate", "--", "sleep", "300"]);
let input = h.root.join("inputs.txt");
std::fs::write(&input, "alpha\nbeta\n").unwrap();
let work = h.root.join("work");
std::fs::create_dir_all(&work).unwrap();
let out = h.qex(&[
"submit",
"--each-line",
input.to_str().unwrap(),
"--needs",
"gate",
"--max-queue-time",
"20m",
"--nice",
"7",
"--cwd",
work.to_str().unwrap(),
"--timeout",
"99s",
"--priority",
"5",
"--env",
"FOO=bar",
"--lock",
"db",
"--retries",
"2",
"--cpu",
"1",
"--mem",
"256MB",
"--no-limit-env-hints",
"--tag",
"batch",
"--",
"echo",
"{}",
]);
assert!(
out.status.success(),
"the fan-out must operate: {}",
String::from_utf8_lossy(&out.stderr)
);
let group = String::from_utf8_lossy(&out.stdout).trim().to_string();
let jobs: Vec<serde_json::Value> = h
.list_json()
.into_iter()
.filter(|j| j["group"].as_str() == Some(group.as_str()))
.collect();
assert_eq!(jobs.len(), 2, "the group must hold the 2 jobs of the file");
let template = vec!["echo".to_string(), "{}".to_string()];
for job in &jobs {
let id = job["id"].as_str().unwrap();
let text = std::fs::read_to_string(h.job_dir(id).join("spec.json")).unwrap();
let spec: serde_json::Value = serde_json::from_str(&text).unwrap();
let needs: Vec<String> = spec["needs"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert_eq!(
needs,
vec![gate.clone()],
"the job {id} must wait for the gate"
);
assert_eq!(spec["max_queue_time"], serde_json::json!(1200), "job {id}");
assert_eq!(spec["nice"], serde_json::json!(7), "job {id}");
assert_eq!(
spec["tags"],
serde_json::json!(["batch"]),
"the tag must reach the job {id}"
);
assert_eq!(
spec["cwd"].as_str(),
work.canonicalize().unwrap().to_str(),
"the job {id} must run in the directory that the user gave"
);
assert_eq!(spec["timeout"], serde_json::json!(99), "job {id}");
assert_eq!(spec["priority"], serde_json::json!(5), "job {id}");
assert_eq!(spec["env"]["FOO"], serde_json::json!("bar"), "job {id}");
assert_eq!(spec["locks"], serde_json::json!(["db"]), "job {id}");
assert_eq!(spec["retries"], serde_json::json!(2), "job {id}");
assert_eq!(
spec["learn_key"],
serde_json::json!(template),
"the job {id} must measure against the template"
);
assert_ne!(
spec["learn_key"], spec["command"],
"the command of the line must not become the key of the record"
);
let env = spec["env"].as_object().unwrap();
assert!(
!env.contains_key("QEX_CPU") && !env.contains_key("QEX_MEM"),
"`--no-limit-env-hints` must reach the job {id}: {env:?}"
);
}
let out = h.qex(&[
"submit",
"--each-line",
input.to_str().unwrap(),
"--needs",
"gate",
"--cpu",
"1",
"--mem",
"256MB",
"--",
"echo",
"{}",
]);
assert!(out.status.success());
let other = String::from_utf8_lossy(&out.stdout).trim().to_string();
let with_hints: Vec<serde_json::Value> = h
.list_json()
.into_iter()
.filter(|j| j["group"].as_str() == Some(other.as_str()))
.collect();
assert_eq!(with_hints.len(), 2);
for job in &with_hints {
let id = job["id"].as_str().unwrap();
let text = std::fs::read_to_string(h.job_dir(id).join("spec.json")).unwrap();
let spec: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
spec["env"]["QEX_CPU"],
serde_json::json!("1"),
"with no `--no-limit-env-hints`, the job {id} must hear its claim"
);
}
let mut commands: Vec<String> = jobs
.iter()
.map(|j| {
let id = j["id"].as_str().unwrap();
let text = std::fs::read_to_string(h.job_dir(id).join("spec.json")).unwrap();
let spec: serde_json::Value = serde_json::from_str(&text).unwrap();
spec["command"][1].as_str().unwrap().to_string()
})
.collect();
commands.sort();
assert_eq!(commands, vec!["alpha".to_string(), "beta".to_string()]);
h.qex(&["kill", &gate]);
}
#[test]
fn a_fan_out_refuses_the_options_that_hold_a_meaning_for_one_job() {
let h = Harness::with_default_config("eachline-refuse");
let input = h.root.join("inputs.txt");
std::fs::write(&input, "alpha\nbeta\ngamma\n").unwrap();
let path = input.to_str().unwrap().to_string();
for (option, value, must_say) in [
("--dedupe-key", Some("nightly"), "A key holds one job"),
("--dedupe-window", Some("1h"), "A key holds one job"),
("--json", None, "--id-file"),
] {
let mut args: Vec<&str> = vec!["submit", "--each-line", &path];
args.push(option);
if let Some(v) = value {
args.push(v);
}
args.extend_from_slice(&["--", "echo", "{}"]);
let out = h.qex(&args);
assert!(
!out.status.success(),
"`{option}` must not submit a fan-out"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains(option),
"the error must name the option `{option}`: {err}"
);
assert!(
err.contains(must_say),
"the error must say why, and what to do: {err}"
);
assert!(
!err.contains(" "),
"the message holds the indentation of the source in place of a line break: {err:?}"
);
assert!(
h.list_json().is_empty(),
"`{option}` refused the fan-out and still left a job in the queue"
);
}
let out = h.qex(&["submit", "--each-line", &path, "--", "echo", "{}"]);
assert!(
out.status.success(),
"the fan-out without those options must operate: {}",
String::from_utf8_lossy(&out.stderr)
);
assert_eq!(h.list_json().len(), 3, "3 lines must give 3 jobs");
}
#[test]
fn a_fan_out_above_the_limit_is_refused_with_no_prompt() {
let h = Harness::with_default_config("eachlimit");
let input = h.root.join("inputs.txt");
let text: String = (1..=20).map(|i| format!("line{i}\n")).collect();
std::fs::write(&input, text).unwrap();
let out = h.qex(&[
"submit",
"--each-line",
input.to_str().unwrap(),
"--max-jobs",
"5",
"--",
"echo",
"{}",
]);
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("--max-jobs 20"), "got: {err}");
assert!(h.list_json().is_empty(), "no job may start");
}
fn _unused(_: &Path) {}
fn wait_run(mut child: std::process::Child, what: &str) -> Output {
let deadline = Instant::now() + Duration::from_secs(60);
loop {
match child.try_wait().unwrap() {
Some(_) => return child.wait_with_output().unwrap(),
None => {
if Instant::now() >= deadline {
child.kill().ok();
panic!("`qex run` did not stop in 60 seconds: {what}");
}
std::thread::sleep(Duration::from_millis(200));
}
}
}
}
#[test]
fn qex_run_gives_125_when_a_different_command_kills_the_job() {
let h = Harness::with_default_config("runkill");
let (child, id) = h.run_bg(&["--", "sleep", "60"]);
h.until(
"the job of `qex run` starts",
Duration::from_secs(30),
|| h.has_started(&id),
);
let kill = h.qex(&["kill", &id, "--grace", "1s"]);
assert!(kill.status.success(), "`qex kill` failed");
let out = wait_run(child, "a different command killed the job");
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(
out.status.code(),
Some(125),
"`qex run` must give 125 when something stopped the job: {err}"
);
assert!(
err.contains("did not send it"),
"`qex run` must say that it did not stop the job: {err}"
);
}
#[test]
fn qex_run_gives_125_when_a_different_command_cancels_the_queued_job() {
let h = Harness::with_default_config("runcancel");
let blocker = h.submit(&["submit", "--", "sleep", "60"]);
let (child, id) = h.run_bg(&["--needs", &blocker, "--", "echo", "never"]);
h.until(
"the job of `qex run` waits",
Duration::from_secs(30),
|| h.state_of(&id) == "queued",
);
let cancel = h.qex(&["cancel", &id]);
assert!(cancel.status.success(), "`qex cancel` failed");
let out = wait_run(child, "a different command cancelled the job");
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(
out.status.code(),
Some(125),
"`qex run` must give 125 for a job that left the queue: {err}"
);
assert!(
err.contains("removed the job"),
"`qex run` must say that the job left the queue: {err}"
);
h.qex(&["kill", &blocker, "--grace", "1s"]);
}
#[test]
fn qex_run_gives_the_exit_code_of_a_job_that_ran() {
let h = Harness::with_default_config("runcode");
for code in [0, 1, 7] {
let command = format!("exit {code}");
let (child, id) = h.run_bg(&["--", "sh", "-c", &command]);
let out = wait_run(child, "the job gives its own exit code");
assert_eq!(
out.status.code(),
Some(code),
"`qex run` must give the exit code {code} of the job: {}",
String::from_utf8_lossy(&out.stderr)
);
let wait = h.qex(&["wait", &id]);
assert_eq!(
out.status.code(),
wait.status.code(),
"`qex run` and `qex wait` gave two codes for one job"
);
}
}
#[test]
fn qex_run_and_qex_wait_give_the_same_code_for_the_same_job() {
let h = Harness::with_default_config("runagree");
let (child, killed) = h.run_bg(&["--", "sleep", "60"]);
h.until(
"the job of `qex run` starts",
Duration::from_secs(30),
|| h.has_started(&killed),
);
h.qex(&["kill", &killed, "--grace", "1s"]);
let stopped = wait_run(child, "the job that a kill stopped");
let (child, failed) = h.run_bg(&["--", "sh", "-c", "exit 1"]);
let ran = wait_run(child, "the job that failed");
let (child, limited) = h.run_bg(&["--timeout", "1s", "--", "sleep", "60"]);
let timed_out = wait_run(child, "the job that reached its time limit");
assert_eq!(
stopped.status.code(),
h.qex(&["wait", &killed]).status.code(),
"`qex run` and `qex wait` gave two codes for a job that something stopped"
);
assert_eq!(
timed_out.status.code(),
h.qex(&["wait", &limited]).status.code(),
"`qex run` and `qex wait` gave two codes for a job that reached its time limit"
);
assert_eq!(
ran.status.code(),
h.qex(&["wait", &failed]).status.code(),
"`qex run` and `qex wait` gave two codes for a job that failed"
);
assert_eq!(ran.status.code(), Some(1), "a job that failed gives 1");
assert_ne!(
stopped.status.code(),
Some(1),
"a job that something stopped must not give the code of a job that failed"
);
assert_ne!(
timed_out.status.code(),
Some(1),
"a job that reached its time limit must not give the code of a job that failed"
);
}
#[test]
fn qex_run_says_when_this_command_stopped_the_job() {
let h = Harness::with_default_config("runctrlc");
let (child, id) = h.run_bg(&["--", "sleep", "60"]);
h.until(
"the job of `qex run` starts",
Duration::from_secs(30),
|| h.has_started(&id),
);
let pid = child.id() as i32;
assert_eq!(unsafe { libc::kill(pid, libc::SIGINT) }, 0);
let out = wait_run(child, "Ctrl-C stopped the job");
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(
out.status.code(),
Some(125),
"a job that Ctrl-C stopped gives 125: {err}"
);
assert!(
err.contains("this command stopped the job"),
"`qex run` must say that IT stopped the job: {err}"
);
assert!(
!err.contains("did not send it"),
"`qex run` must not blame a different command for its own kill: {err}"
);
}
#[test]
fn ctrl_c_removes_the_job_of_qex_run_from_the_queue() {
let h = Harness::with_default_config("runctrlcq");
let blocker = h.submit(&["submit", "--", "sleep", "60"]);
let (child, id) = h.run_bg(&["--needs", &blocker, "--", "echo", "never"]);
h.until(
"the job of `qex run` waits",
Duration::from_secs(30),
|| h.state_of(&id) == "queued",
);
let pid = child.id() as i32;
assert_eq!(unsafe { libc::kill(pid, libc::SIGINT) }, 0);
let out = wait_run(child, "Ctrl-C removed the job from the queue");
let err = String::from_utf8_lossy(&out.stderr);
assert_eq!(
out.status.code(),
Some(125),
"a job that left the queue gives 125: {err}"
);
assert_eq!(
h.state_of(&id),
"cancelled",
"Ctrl-C must take the job out of the queue: {err}"
);
assert!(
err.contains("this command removed the job"),
"`qex run` must say that IT removed the job from the queue: {err}"
);
h.qex(&["kill", &blocker, "--grace", "1s"]);
}
#[test]
fn a_change_to_the_configuration_reaches_the_coordinator() {
let h = Harness::new(
"reload",
"[budget]\ncpu = \"8\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
h.ok(&["list"]);
let info = h.ok(&["info"]);
assert!(info.contains("of 8 in use"), "the budget must be 8: {info}");
std::fs::write(
h.root.join("cfg/qex.toml"),
"[budget]\ncpu = \"2\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
)
.unwrap();
h.until(
"the coordinator reads the file again",
Duration::from_secs(45),
|| h.ok(&["info"]).contains("of 2 in use"),
);
std::fs::write(h.root.join("cfg/qex.toml"), "[budget]\ncpu = \"two\"\n").unwrap();
h.until("qex reports the fault", Duration::from_secs(45), || {
let out = h.qex(&["info"]);
String::from_utf8_lossy(&out.stderr).contains("cannot read it")
});
let info = h.ok(&["info"]);
assert!(
info.contains("of 2 in use"),
"the coordinator must keep the values that it had: {info}"
);
let json: serde_json::Value =
serde_json::from_slice(&h.qex(&["info", "--json"]).stdout).unwrap();
assert!(
json["config_error"].is_string(),
"`qex info --json` must carry the fault: {json}"
);
std::fs::write(h.root.join("cfg/qex.toml"), "[budget\n").unwrap();
h.until(
"qex reports the fault of the form",
Duration::from_secs(45),
|| String::from_utf8_lossy(&h.qex(&["info"]).stderr).contains("TOML parse error"),
);
let err = String::from_utf8_lossy(&h.qex(&["info"]).stderr).to_string();
for line in err.lines() {
assert!(
line.starts_with("qex:"),
"every line of the warning must carry the prefix: {err}"
);
}
std::fs::write(
h.root.join("cfg/qex.toml"),
"[budget]\ncpu = \"5\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
)
.unwrap();
h.until(
"the coordinator takes the corrected file",
Duration::from_secs(45),
|| h.ok(&["info"]).contains("of 5 in use"),
);
let err = String::from_utf8_lossy(&h.qex(&["info"]).stderr).to_string();
assert!(
!err.contains("cannot read it"),
"the warning must go away when the file is correct: {err}"
);
}
#[test]
fn an_empty_or_missing_configuration_does_not_become_the_default_values() {
let file = "[budget]\ncpu = \"2\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n";
let h = Harness::new("reload-gone", file);
let path = h.root.join("cfg/qex.toml");
h.ok(&["list"]);
let info = h.ok(&["info"]);
assert!(info.contains("of 2 in use"), "the budget must be 2: {info}");
std::fs::write(&path, "").unwrap();
h.until(
"qex reports the empty file",
Duration::from_secs(45),
|| String::from_utf8_lossy(&h.qex(&["info"]).stderr).contains("the file is empty"),
);
let info = h.ok(&["info"]);
assert!(
info.contains("of 2 in use"),
"an empty file must not change the budget: {info}"
);
std::fs::remove_file(&path).unwrap();
std::thread::sleep(Duration::from_secs(2));
let info = h.ok(&["info"]);
assert!(
info.contains("of 2 in use"),
"a file that is gone must not change the budget: {info}"
);
let temp = h.root.join("cfg/.qex.toml.new");
std::fs::write(&temp, file.replace("cpu = \"2\"", "cpu = \"6\"")).unwrap();
std::fs::rename(&temp, &path).unwrap();
h.until(
"the coordinator reads the file that the rename put there",
Duration::from_secs(45),
|| h.ok(&["info"]).contains("of 6 in use"),
);
}
#[test]
fn a_write_that_is_not_finished_does_not_change_the_budget() {
let file = "[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[budget]\ncpu = \"2\"\nmem = \"1GB\"\n";
let h = Harness::new("reload-partial", file);
let path = h.root.join("cfg/qex.toml");
h.ok(&["list"]);
let info: serde_json::Value =
serde_json::from_slice(&h.qex(&["info", "--json"]).stdout).unwrap();
assert_eq!(info["mem_budget"].as_u64(), Some(1024 * 1024 * 1024));
let stop = Arc::new(AtomicBool::new(false));
let writer = {
let (path, stop, file) = (path.clone(), stop.clone(), file.to_string());
std::thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
let mut f = std::fs::File::create(&path).unwrap();
for line in file.lines() {
use std::io::Write;
writeln!(f, "{line}").unwrap();
f.flush().unwrap();
std::thread::sleep(Duration::from_millis(2));
}
drop(f);
std::thread::sleep(Duration::from_millis(20));
}
})
};
let deadline = Instant::now() + Duration::from_secs(8);
let mut looks = 0;
while Instant::now() < deadline {
let info: serde_json::Value =
serde_json::from_slice(&h.qex(&["info", "--json"]).stdout).unwrap();
looks += 1;
assert_eq!(
info["mem_budget"].as_u64(),
Some(1024 * 1024 * 1024),
"a write that is not finished must never give the default budget: {info}"
);
assert_eq!(
info["cpu_budget"].as_u64(),
Some(2),
"a write that is not finished must never give the default budget: {info}"
);
let reported = info["config_error"].as_str().unwrap_or_default();
assert!(
reported.is_empty() || reported.contains("waits for it"),
"the file is correct each time it is complete: {info}"
);
}
stop.store(true, Ordering::Relaxed);
writer.join().unwrap();
assert!(
looks > 20,
"the test must look many times, and it looked {looks}"
);
}
#[test]
fn a_file_that_goes_back_and_forth_does_not_change_the_budget() {
let whole = "[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[budget]\ncpu = \"2\"\nmem = \"1GB\"\n";
let half = whole.strip_suffix("mem = \"1GB\"\n").unwrap().to_string();
let h = Harness::new("reload-alternating", whole);
let path = h.root.join("cfg/qex.toml");
h.ok(&["list"]);
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&h.qex(&["info", "--json"]).stdout).unwrap()
["mem_budget"]
.as_u64(),
Some(1024 * 1024 * 1024)
);
let stop = Arc::new(AtomicBool::new(false));
let writer = {
let (path, stop, whole) = (path.clone(), stop.clone(), whole.to_string());
let temp = h.root.join("cfg/.qex.toml.new");
std::thread::spawn(move || {
let mut turn = false;
while !stop.load(Ordering::Relaxed) {
let text = if turn { &whole } else { &half };
std::fs::write(&temp, text).unwrap();
std::fs::rename(&temp, &path).unwrap();
turn = !turn;
std::thread::sleep(Duration::from_millis(300));
}
std::fs::write(&temp, &whole).unwrap();
std::fs::rename(&temp, &path).unwrap();
})
};
let deadline = Instant::now() + Duration::from_secs(12);
let mut looks = 0;
let mut fault = None;
while Instant::now() < deadline {
let out = h.qex(&["info", "--json"]);
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&out.stdout) {
looks += 1;
if info["mem_budget"].as_u64() != Some(1024 * 1024 * 1024) {
fault = Some(format!("{} looks, then {info}", looks));
break;
}
}
}
stop.store(true, Ordering::Relaxed);
writer.join().unwrap();
assert!(
fault.is_none(),
"a file that goes back and forth must not change the budget: {}",
fault.unwrap_or_default()
);
assert!(
looks > 20,
"the test must look many times, and it looked {looks}"
);
}
#[test]
fn a_write_that_is_not_finished_does_not_change_the_budget_under_load() {
let file = "[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[budget]\ncpu = \"2\"\nmem = \"1GB\"\n";
let h = Harness::new("reload-partial-load", file);
let path = h.root.join("cfg/qex.toml");
h.ok(&["list"]);
let stop = Arc::new(AtomicBool::new(false));
let load = {
let (root, stop) = (h.root.clone(), stop.clone());
std::thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["submit", "--", "true"])
.env("XDG_CONFIG_HOME", root.join("cfg"))
.env("XDG_STATE_HOME", root.join("state"))
.env("XDG_RUNTIME_DIR", root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.ok();
}
})
};
let writer = {
let (path, stop, file) = (path.clone(), stop.clone(), file.to_string());
std::thread::spawn(move || {
let half = file.find("[budget]").unwrap();
while !stop.load(Ordering::Relaxed) {
std::fs::write(&path, &file[..half]).unwrap();
std::thread::sleep(Duration::from_millis(300));
std::fs::write(&path, &file).unwrap();
std::thread::sleep(Duration::from_millis(700));
}
})
};
let deadline = Instant::now() + Duration::from_secs(12);
let mut looks = 0;
let mut fault = None;
while Instant::now() < deadline {
let out = h.qex(&["info", "--json"]);
if let Ok(info) = serde_json::from_slice::<serde_json::Value>(&out.stdout) {
looks += 1;
if info["mem_budget"].as_u64() != Some(1024 * 1024 * 1024)
|| info["cpu_budget"].as_u64() != Some(2)
{
fault = Some(info.to_string());
break;
}
}
}
stop.store(true, Ordering::Relaxed);
writer.join().unwrap();
load.join().unwrap();
assert!(
fault.is_none(),
"a write that is not finished must never change the budget, and a busy \
coordinator is where that fault lives: {}",
fault.unwrap_or_default()
);
assert!(
looks > 20,
"the test must look many times, and it looked {looks}"
);
}
#[test]
fn a_command_refuses_a_configuration_path_that_is_not_a_regular_file() {
let file = "[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n";
let h = Harness::new("client-not-a-file", file);
let path = h.root.join("cfg/qex.toml");
assert!(h.ok(&["config", "show"]).contains("2 cores"));
std::fs::remove_file(&path).unwrap();
let made = Command::new("mkfifo").arg(&path).status();
if !made.map(|s| s.success()).unwrap_or(false) {
std::fs::create_dir(&path).unwrap();
}
for args in [vec!["config", "show"], vec!["submit", "--", "true"]] {
let child = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(&args)
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let out = wait_for_child(
child,
Duration::from_secs(20),
"a path that is not a regular file",
);
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
!out.status.success(),
"`qex {}` must stop: {err}",
args.join(" ")
);
assert!(
err.contains("not a regular file"),
"`qex {}` must name the cause: {err}",
args.join(" ")
);
}
}
#[test]
fn a_configuration_path_that_is_not_a_regular_file_does_not_stop_the_coordinator() {
let file = "[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n";
let h = Harness::new("reload-not-a-file", file);
let path = h.root.join("cfg/qex.toml");
h.ok(&["list"]);
assert!(h.ok(&["info"]).contains("of 2 in use"));
std::fs::remove_file(&path).unwrap();
std::fs::create_dir(&path).unwrap();
h.until(
"qex reports the type of the path",
Duration::from_secs(45),
|| String::from_utf8_lossy(&h.qex(&["info"]).stderr).contains("not a regular file"),
);
assert!(
h.ok(&["info"]).contains("of 2 in use"),
"a path that is not a regular file must not change the budget"
);
std::fs::remove_dir(&path).unwrap();
let made = Command::new("mkfifo").arg(&path).status();
if made.map(|s| s.success()).unwrap_or(false) {
for _ in 0..6 {
let child = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["info"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let out = wait_for_child(child, Duration::from_secs(20), "a FIFO at the config path");
assert!(
String::from_utf8_lossy(&out.stdout).contains("of 2 in use"),
"the coordinator must answer and keep its budget: {}",
String::from_utf8_lossy(&out.stdout)
);
std::thread::sleep(Duration::from_millis(400));
}
std::fs::remove_file(&path).unwrap();
}
std::fs::write(&path, file.replace("cpu = \"2\"", "cpu = \"4\"")).unwrap();
h.until(
"the coordinator takes the file that replaced the FIFO",
Duration::from_secs(45),
|| h.ok(&["info"]).contains("of 4 in use"),
);
}
fn wait_for_child(mut child: std::process::Child, limit: Duration, what: &str) -> Output {
let deadline = Instant::now() + limit;
loop {
match child.try_wait().unwrap() {
Some(_) => return child.wait_with_output().unwrap(),
None => {
if Instant::now() >= deadline {
child.kill().ok();
panic!("`qex info` gave no answer in {limit:?}: {what}");
}
std::thread::sleep(Duration::from_millis(100));
}
}
}
}
#[test]
fn the_completions_hold_the_commands_of_qex() {
let h = Harness::with_default_config("completions");
for shell in ["bash", "zsh", "fish"] {
let out = h.ok(&["completions", shell]);
assert!(!out.is_empty(), "{shell} gave nothing");
for command in ["submit", "wait", "status", "logs", "kill", "watchers"] {
assert!(
out.contains(command),
"the {shell} completions must name `{command}`"
);
}
}
let out = h.qex(&["completions", "not-a-shell"]);
assert!(!out.status.success(), "an unknown shell must give an error");
let bash = h.ok(&["completions", "bash"]);
let jobs_part = &bash[bash.find("_qex_jobs()").expect("bash needs `_qex_jobs`")..];
assert!(
!jobs_part
.lines()
.any(|l| !l.trim_start().starts_with('#') && l.contains("compgen")),
"the bash completions must not expand the names again: {jobs_part}"
);
assert!(
jobs_part.contains("while IFS= read -r candidate"),
"the bash completions must read the names as lines"
);
assert!(
jobs_part.contains("printf -v candidate '%q'"),
"the bash completions must make each name safe for the command line"
);
assert!(
jobs_part.contains(r#"case "$candidate" in "~"*) candidate="\\$candidate" ;; esac"#),
"the bash completions must make a leading `~` safe as well"
);
assert!(
!jobs_part
.lines()
.any(|l| !l.trim_start().starts_with('#') && l.contains("compopt")),
"the bash completions must not treat a job name as a file name"
);
assert!(
bash.contains("complete -F _qex_with_jobs "),
"bash must give `_qex_with_jobs` to the shell, and not `_qex`: {bash}"
);
assert!(
bash.contains("BASH_VERSINFO[0]}\" -eq 4 && \"${BASH_VERSINFO[1]}\" -ge 4")
&& bash.contains("complete -F _qex_with_jobs -o bashdefault -o default qex"),
"the registration must test the version of bash before it uses `-o nosort`: {bash}"
);
assert!(
bash.contains("complete -F _qex_with_jobs -o nosort -o bashdefault -o default qex"),
"a bash that has `-o nosort` must get it: {bash}"
);
assert!(
bash.contains("_qex_with_jobs() {") && bash.contains(" _qex_jobs\n}"),
"`_qex_with_jobs` must run `_qex` and then add the jobs"
);
let guard = bash
.lines()
.find(|l| l.contains("in *\" $prev \"*)"))
.expect("bash needs the guard for an option value");
for valued in ["--signal", "--grace", "--timeout", "--tail", "-C"] {
assert!(
guard.contains(&format!(" {valued} ")),
"`{valued}` takes a value, so it must be in the guard: {guard}"
);
}
for flag in ["--json", "--show-env", "--no-logs", "--all"] {
assert!(
!guard.contains(&format!(" {flag} ")),
"`{flag}` takes no value, so the word after it is a job: {guard}"
);
}
let zsh = h.ok(&["completions", "zsh"]);
for (line, what) in [
("':id -- The job id, or the start of the id", "ids"),
("'*::ids -- The job ids to wait for", "ids"),
("'*::ids -- The job ids to stop", "active"),
("'*::ids -- The job ids to remove from the queue", "queued"),
("'*::ids -- The job ids to delete", "ids"),
] {
assert!(
zsh.contains(&format!("{line}: _qex_jobs {what}' \\")),
"the zsh job argument must offer the jobs: {line}"
);
}
assert!(
!zsh.contains(":_qex_jobs "),
"a zsh action needs a space before the name of the function"
);
assert!(
zsh.contains("]:SIGNAL:_default'"),
"the value of `--signal` must not become a job"
);
let helper = zsh.find("_qex_jobs()").expect("zsh needs `_qex_jobs`");
let hand_over = zsh.find("compdef _qex qex").expect("zsh needs `compdef`");
assert!(
zsh.starts_with("#compdef qex"),
"zsh needs `#compdef` first"
);
assert!(
helper < hand_over,
"`_qex_jobs` must exist before zsh gets the completion"
);
for shell in ["bash", "zsh", "fish", "elvish", "powershell"] {
let out = h.ok(&["completions", shell]);
for hidden in ["daemon", "supervise", "__complete"] {
for line in out.lines() {
let trimmed = line.trim_start();
let offers = trimmed.starts_with("opts=\"")
|| trimmed.starts_with(&format!("'{hidden}:"))
|| trimmed.starts_with("cand ")
|| trimmed.starts_with("[CompletionResult]::new(")
|| (trimmed.starts_with("complete -c qex")
&& trimmed.contains("__fish_use_subcommand"));
if !offers {
continue;
}
assert!(
!line
.split(|c: char| c.is_whitespace() || c == '"' || c == '\'')
.any(|word| word == hidden),
"the {shell} completions must not offer `{hidden}`: {line}"
);
}
}
}
for shell in ["bash", "zsh", "fish"] {
let out = h.ok(&["completions", shell]);
assert!(
out.contains("qex __complete"),
"the {shell} completions must ask qex for the ids"
);
}
let fish = h.ok(&["completions", "fish"]);
for (commands, what) in [
("status wait logs rerun clean", "ids"),
("kill", "active"),
("cancel", "queued"),
] {
assert!(
fish.contains(&format!(
"complete -c qex -n \"__fish_seen_subcommand_from {commands}\" \
-f -a \"(qex __complete {what})\""
)),
"fish must offer `{what}` after `{commands}`: {fish}"
);
}
}
#[test]
fn the_completion_candidates_start_no_coordinator() {
let h = Harness::with_default_config("candidates");
let empty = h.ok(&["__complete", "ids"]);
assert!(empty.is_empty(), "an empty state must give no candidate");
let info = h.qex(&["info", "--no-start"]);
let text = String::from_utf8_lossy(&info.stdout);
assert!(
text.contains("no coordinator"),
"TAB must not start a coordinator: {text}"
);
let running = h.submit(&[
"submit", "--name", "holder", "--cpu", "1", "--mem", "64MB", "--lock", "one", "--",
"sleep", "300",
]);
let queued = h.submit(&[
"submit", "--name", "waiter", "--cpu", "1", "--mem", "64MB", "--lock", "one", "--", "true",
]);
h.until("the first job operates", Duration::from_secs(45), || {
h.state_of(&running) == "running"
});
let all = h.ok(&["__complete", "ids"]);
for want in [running.as_str(), queued.as_str(), "holder", "waiter"] {
assert!(all.lines().any(|l| l == want), "`ids` must hold {want}");
}
let active = h.ok(&["__complete", "active"]);
assert!(active.lines().any(|l| l == running));
assert!(
!active.lines().any(|l| l == queued),
"`active` must not hold a job that waits: {active}"
);
let waiting = h.ok(&["__complete", "queued"]);
assert!(waiting.lines().any(|l| l == queued));
assert!(
!waiting.lines().any(|l| l == running),
"`queued` must not hold a job that operates: {waiting}"
);
let pairs = [
("deploy prod$(id)", "deploy_prod_id_"),
("cost $HOME", "cost_HOME"),
("a; touch", "a_touch"),
("two\nlines", "two_lines"),
("two\tparts", "two_parts"),
("esc\u{1b}[2Jname", "esc_2Jname"),
("src/main", "src_main"),
("a:b", "a_b"),
("build-*", "build-_"),
("-version", "_version"),
("caf\u{e9}", "caf_"),
("plain-name_1.2", "plain-name_1.2"),
];
for (name, safe) in pairs {
let id = h.submit(&["submit", &format!("--name={name}"), "--", "true"]);
assert_eq!(
h.status_json(&id)["name"].as_str(),
Some(safe),
"qex must show {safe:?} for the name {name:?}"
);
let all = h.ok(&["__complete", "ids"]);
assert!(
all.lines().any(|l| l == safe),
"the list must offer {safe:?} for the name {name:?}: {all}"
);
if safe != name {
assert!(
!all.lines().any(|l| l == name),
"the list must not offer the name {name:?} itself: {all}"
);
}
assert_eq!(
h.status_json(safe)["id"].as_str(),
Some(id.as_str()),
"`qex status {safe}` must find the job named {name:?}"
);
let found = if name.starts_with('-') {
h.qex(&["status", "--", name])
} else {
h.qex(&["status", name])
};
assert!(
found.status.success(),
"`qex status` must still find the job by its stored name {name:?}"
);
}
let long = "y".repeat(200);
h.submit(&["submit", &format!("--name={long}"), "--", "true"]);
let all = h.ok(&["__complete", "ids"]);
assert!(
all.lines().any(|l| l == "y".repeat(128)),
"a long name must stop at 128 characters"
);
assert!(
all.lines().all(|l| l.chars().count() <= 128),
"no candidate may be longer than 128 characters"
);
h.submit(&["submit", "--name=x y", "--", "true"]);
h.submit(&["submit", "--name=x_y", "--", "true"]);
let out = h.qex(&["status", "x_y"]);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
!out.status.success(),
"an ambiguous name must give an error"
);
assert!(
err.contains("`x_y` names 2 jobs"),
"the error must say how many jobs the word names: {err}"
);
assert!(
err.contains("Give the id of the job that you want"),
"the error must say what the reader must do: {err}"
);
assert_eq!(
err.lines().filter(|l| l.starts_with(" ")).count(),
2,
"the error must list the two jobs: {err}"
);
assert_eq!(
err.lines().filter(|l| l.ends_with(" x_y")).count(),
2,
"the error must name each job: {err}"
);
let all = h.ok(&["__complete", "ids"]);
assert_eq!(
all.lines().filter(|l| *l == "x_y").count(),
1,
"one safe form gives one candidate: {all}"
);
h.ok(&["kill", &running, "--grace", "1s"]);
}
#[test]
fn every_output_shows_the_safe_name() {
let h = Harness::with_default_config("safename");
let stored = "deploy prod$(id)";
let safe = "deploy_prod_id_";
let id = h.submit(&["submit", &format!("--name={stored}"), "--", "true"]);
let esc = "esc\u{1b}[2Jbad";
let esc_id = h.submit(&["submit", &format!("--name={esc}"), "--", "true"]);
h.until("both jobs stop", Duration::from_secs(45), || {
h.state_of(&id) == "completed" && h.state_of(&esc_id) == "completed"
});
let record = h.root.join("state/qex/jobs").join(&id).join("status.json");
let text = std::fs::read_to_string(&record).expect("the record must exist");
let value: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
value["name"].as_str(),
Some(stored),
"the record on the disk must keep the name that the user gave"
);
let list = h.ok(&["list"]);
assert!(list.contains(safe), "`qex list` must show {safe}: {list}");
let status = h.ok(&["status", &id]);
assert!(
status.contains(&format!("name: {safe}")),
"`qex status` must show {safe}: {status}"
);
assert_eq!(
h.status_json(&id)["name"].as_str(),
Some(safe),
"the JSON of `qex status` must hold the safe name"
);
let listed = h.list_json();
assert!(
listed
.iter()
.any(|j| j["name"].as_str() == Some(safe) && j["id"].as_str() == Some(id.as_str())),
"the JSON of `qex list` must hold the safe name: {listed:?}"
);
for out in [
h.ok(&["wait", &id]),
h.ok(&["wait", &id, "--json"]),
h.ok(&["du", "--json"]),
h.ok(&["gc", "--dry-run", "--older-than", "0s", "--json"]),
h.ok(&["__complete", "ids"]),
] {
assert!(
!out.contains(stored),
"an output showed the stored name: {out}"
);
}
for args in [
vec!["list"],
vec!["list", "--json"],
vec!["status", &esc_id],
vec!["status", &esc_id, "--json"],
vec!["wait", &esc_id],
vec!["wait", &esc_id, "--json"],
vec!["du", "--json"],
vec!["du"],
vec!["gc", "--dry-run", "--older-than", "0s", "--json"],
vec!["__complete", "ids"],
vec!["top", "--once"],
] {
let out = h.qex(&args);
for stream in [&out.stdout, &out.stderr] {
assert!(
!stream.windows(6).any(|w| w == b"esc\x1b[2"),
"`qex {}` wrote the ESC byte of a job name",
args.join(" ")
);
}
}
let holder = h.submit(&[
"submit",
&format!("--name={esc}"),
"--cpu",
"1",
"--mem",
"64MB",
"--lock",
"one",
"--",
"sleep",
"300",
]);
let waiter = h.submit(&[
"submit", "--name", "waiter", "--cpu", "1", "--mem", "64MB", "--lock", "one", "--", "true",
]);
h.until(
"the second job waits for the lock",
Duration::from_secs(45),
|| h.status_json(&waiter)["blocked_reason"].is_string(),
);
let broken = h.submit(&[
"submit",
&format!("--name={esc}"),
"--",
"sh",
"-c",
"exit 3",
]);
let dependent = h.submit(&["submit", "--needs", &broken, "--", "true"]);
h.until(
"the dependent job is skipped",
Duration::from_secs(45),
|| h.state_of(&dependent) == "skipped",
);
let gone = h.submit(&["submit", &format!("--name={esc}"), "--", "true"]);
h.until("that job stops", Duration::from_secs(45), || {
h.state_of(&gone) == "completed"
});
h.ok(&["clean", &gone]);
for args in [
vec!["list"],
vec!["list", "--json"],
vec!["status", &waiter],
vec!["status", &waiter, "--json"],
vec!["status", &dependent],
vec!["status", &dependent, "--json"],
vec!["status", &gone],
vec!["top", "--once"],
] {
let out = h.qex(&args);
for stream in [&out.stdout, &out.stderr] {
assert!(
!stream.windows(6).any(|w| w == b"esc\x1b[2"),
"`qex {}` wrote the ESC byte of a job name",
args.join(" ")
);
}
}
let reason = h.status_json(&waiter)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
reason.contains("esc_2Jbad"),
"the sentence must name the job that holds the lock: {reason}"
);
let failed = h.status_json(&dependent)["error"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
failed.contains("esc_2Jbad"),
"the sentence must name the job that failed: {failed}"
);
let missing = String::from_utf8_lossy(&h.qex(&["status", &gone]).stderr).to_string();
assert!(
missing.contains("esc_2Jbad"),
"the sentence must name the job whose record is gone: {missing}"
);
let needs_holder = h.submit(&["submit", "--needs", &holder, "--", "true"]);
h.until("that job waits", Duration::from_secs(45), || {
h.status_json(&needs_holder)["blocked_reason"].is_string()
});
let reason = h.status_json(&needs_holder)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
reason.contains("esc_2Jbad") && !reason.contains('\u{1b}'),
"the sentence must name the job that this one waits for, safely: {reason}"
);
let done = h.submit(&["submit", "--", "true"]);
h.until("a job to delete stops", Duration::from_secs(45), || {
h.state_of(&done) == "completed"
});
let waiting_esc = h.submit(&[
"submit",
&format!("--name={esc}"),
"--needs",
&done,
"--cpu",
"1",
"--mem",
"64MB",
"--lock",
"one",
"--",
"true",
]);
h.until("that job waits too", Duration::from_secs(45), || {
h.state_of(&waiting_esc) == "queued"
});
let refused = h.qex(&["clean", &done]);
let text = String::from_utf8_lossy(&refused.stderr).to_string();
assert!(
text.contains("is needed by"),
"`qex clean` must refuse a record that a job in the queue needs: {text}"
);
assert!(
text.contains("esc_2Jbad") && !text.contains('\u{1b}'),
"that sentence must name the waiting job, safely: {text}"
);
h.ok(&["clean", &broken]);
let after = h.status_json(&dependent)["error"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
after.contains("esc_2Jbad") && !after.contains('\u{1b}'),
"the sentence must name the deleted job, safely: {after}"
);
let file = h.root.join("p.toml");
std::fs::write(
&file,
"name = \"my grp\\u001B[2Jbad\"\n\n [[jobs]]\nname = \"stg\\u001B[2Jbad\"\ncommand = [\"true\"]\n",
)
.unwrap();
let id_file = h.root.join("ids.json");
let made = h.qex(&[
"pipeline",
file.to_str().unwrap(),
"--json",
"--id-file",
id_file.to_str().unwrap(),
]);
assert!(made.status.success(), "the pipeline must start");
let started: serde_json::Value = serde_json::from_slice(&made.stdout).unwrap();
assert_eq!(
started["group_name"].as_str(),
Some("my_grp_2Jbad"),
"`qex pipeline --json` must give the safe group name: {started}"
);
let file2 = h.root.join("p2.toml");
std::fs::write(
&file2,
"name = \"two grp\"\n\n [[jobs]]\nname = \"stg\\u001B[2Jbad\"\ncommand = [\"true\"]\n",
)
.unwrap();
let echo = h.qex(&["pipeline", file2.to_str().unwrap()]);
assert!(
!echo.stderr.windows(6).any(|w| w == b"stg\x1b[2"),
"`qex pipeline` wrote the ESC byte of a stage name: {}",
String::from_utf8_lossy(&echo.stderr)
);
assert!(
String::from_utf8_lossy(&echo.stderr).contains("stg_2Jbad"),
"`qex pipeline` must still name each stage: {}",
String::from_utf8_lossy(&echo.stderr)
);
let listed = h.list_json();
let group = listed
.iter()
.filter_map(|j| j["group_name"].as_str())
.find(|n| n.contains("grp"))
.expect("the pipeline must give the group a name")
.to_string();
assert_eq!(
group, "my_grp_2Jbad",
"the group name must reach a reader in its safe form"
);
let ids: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&id_file).unwrap()).unwrap();
assert_eq!(
ids["group_name"].as_str(),
Some(group.as_str()),
"the id file must give the same value as `qex list --json`: {ids}"
);
for word in [group.as_str(), "my grp\u{1b}[2Jbad"] {
let out = h.ok(&["list", "--group", word, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap();
assert_eq!(
jobs.len(),
1,
"`qex list --group {word:?}` must find the job of the pipeline: {out}"
);
assert!(
!out.contains('\u{1b}'),
"no output holds an ESC byte: {out}"
);
}
let gid = listed
.iter()
.find(|j| j["group_name"].as_str() == Some(group.as_str()))
.unwrap()["group"]
.as_str()
.unwrap()
.to_string();
let out = h.ok(&["list", "--group", &gid, "--json"]);
let jobs: Vec<serde_json::Value> = serde_json::from_str(&out).unwrap();
assert_eq!(jobs.len(), 1, "`qex list --group <id>` must still work");
let log = std::fs::read(h.root.join("state/qex/run/daemon.log")).unwrap_or_default();
assert!(
!log.windows(6).any(|w| w == b"esc\x1b[2"),
"the log of the coordinator wrote the ESC byte of a job name"
);
assert!(
log.windows(9).any(|w| w == b"esc_2Jbad"),
"the log of the coordinator must still name the job"
);
h.ok(&["kill", &holder, "--grace", "1s"]);
let long_name = "a-very-long-name-for-one-job";
let long_id = h.submit(&["submit", "--name", long_name, "--", "true"]);
let table = h.ok(&["list"]);
assert!(
!table.contains(long_name),
"the table stops the name at 16 characters: {table}"
);
assert_eq!(
h.status_json(long_name)["id"].as_str(),
Some(long_id.as_str()),
"the whole name must find the job"
);
assert_eq!(
h.list_json()
.iter()
.find(|j| j["id"].as_str() == Some(long_id.as_str()))
.unwrap()["name"]
.as_str(),
Some(long_name),
"`qex list --json` must give the whole name"
);
let from_list = listed
.iter()
.find(|j| j["id"].as_str() == Some(id.as_str()))
.unwrap()["name"]
.as_str()
.unwrap()
.to_string();
assert_eq!(
h.status_json(&from_list)["id"].as_str(),
Some(id.as_str()),
"the name that `qex list` shows must find the job"
);
assert_eq!(
h.status_json(stored)["id"].as_str(),
Some(id.as_str()),
"the stored name must still find the job"
);
}
#[test]
fn bash_keeps_a_hostile_candidate_in_one_word() {
let h = Harness::with_default_config("bashquote");
let script = h.root.join("qex.bash");
std::fs::write(&script, h.ok(&["completions", "bash"])).unwrap();
let bait = h.root.join("BAIT");
let hostile = [
format!("bait$(touch {})", bait.display()),
format!("tick`touch {}`", bait.display()),
format!("semi; touch {}", bait.display()),
format!("pipe | touch {}", bait.display()),
format!("amp & touch {}", bait.display()),
"has space inside".to_string(),
"quote\"double".to_string(),
"quote'single".to_string(),
"glob-*".to_string(),
"[abc]".to_string(),
"back\\slash".to_string(),
"trailing\\".to_string(),
"${IFS}brace".to_string(),
"$HOME".to_string(),
"~/tilde".to_string(),
"!hist".to_string(),
"esc\u{1b}[2Jname".to_string(),
"caf\u{e9} \u{65e5}\u{672c}".to_string(),
"x".repeat(2002),
];
let bin = h.root.join("bin");
std::fs::create_dir_all(&bin).unwrap();
let stand_in = bin.join("qex");
std::fs::write(
&stand_in,
format!(
"#!/usr/bin/env bash\n\
if [ \"$1\" = __complete ]; then cat {answers}; exit 0; fi\n\
exec {real} \"$@\"\n",
answers = h.root.join("answers").display(),
real = env!("CARGO_BIN_EXE_qex"),
),
)
.unwrap();
std::fs::write(h.root.join("answers"), format!("{}\n", hostile.join("\n"))).unwrap();
std::process::Command::new("chmod")
.args(["+x", stand_in.to_str().unwrap()])
.status()
.unwrap();
let ask = |prefix: &str, tail: &str| -> String {
let program = format!(
"source {script}\n\
COMP_WORDS=(qex status '{prefix}')\n\
COMP_CWORD=2\n\
COMP_LINE='qex status {prefix}'\n\
COMP_POINT=${{#COMP_LINE}}\n\
COMPREPLY=()\n\
_qex_jobs 2>/dev/null\n\
{tail}\n",
script = script.display(),
);
let out = Command::new("bash")
.arg("-c")
.arg(&program)
.env("PATH", format!("{}:/usr/bin:/bin", bin.display()))
.output()
.expect("bash did not start");
String::from_utf8_lossy(&out.stdout).to_string()
};
for name in &hostile {
std::fs::write(h.root.join("answers"), format!("{name}\n")).unwrap();
let read = ask(
"",
"eval \"set -- ${COMPREPLY[0]}\" 2>/dev/null; printf '%s\\n' \"$#\"; printf '%s' \"$1\"",
);
let mut lines = read.splitn(2, '\n');
assert_eq!(
lines.next(),
Some("1"),
"the candidate for {name:?} must be ONE argument: {read:?}"
);
assert_eq!(
lines.next(),
Some(name.as_str()),
"the argument must be the name itself, for {name:?}"
);
}
assert!(!bait.exists(), "a candidate RAN: {}", bait.display());
std::fs::write(h.root.join("answers"), "-version\n--json\n").unwrap();
let reply = ask("-", "printf '%s\\n' \"${COMPREPLY[@]}\"");
assert!(
!reply.lines().any(|l| l == "-version"),
"a candidate must not be offered where an option goes: {reply}"
);
}
#[test]
fn a_real_bash_offers_the_jobs_and_runs_no_name() {
let h = Harness::with_default_config("bashcomp");
let running = h.submit(&[
"submit", "--name", "holder", "--cpu", "1", "--mem", "64MB", "--lock", "one", "--",
"sleep", "300",
]);
let queued = h.submit(&[
"submit", "--name", "waiter", "--cpu", "1", "--mem", "64MB", "--lock", "one", "--", "true",
]);
h.until("the first job operates", Duration::from_secs(45), || {
h.state_of(&running) == "running"
});
let bait = h.root.join("BAIT");
h.submit(&[
"submit",
"--name",
&format!("bait$(touch {})", bait.display()),
"--",
"true",
]);
h.submit(&["submit", "--name", "two words", "--", "true"]);
let script = h.root.join("qex.bash");
std::fs::write(&script, h.ok(&["completions", "bash"])).unwrap();
let ask_with = |prelude: &str, line: &[&str], tail: &str| -> String {
let words = line
.iter()
.map(|w| format!("'{}'", w.replace('\'', "'\\''")))
.collect::<Vec<_>>()
.join(" ");
let last = line.len() - 1;
let program = format!(
"{prelude}\n\
source {script}\n\
COMP_WORDS=({words})\n\
COMP_CWORD={last}\n\
COMP_LINE='{comp_line}'\n\
COMP_POINT=${{#COMP_LINE}}\n\
COMPREPLY=()\n\
_qex_with_jobs qex \"${{COMP_WORDS[{last}]}}\" 2>/dev/null\n\
{tail}\n",
script = script.display(),
comp_line = line.join(" ").replace('\'', "'\\''"),
);
let bin = Path::new(env!("CARGO_BIN_EXE_qex")).parent().unwrap();
let out = Command::new("bash")
.arg("-c")
.arg(&program)
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.env(
"PATH",
format!(
"{}:{}",
bin.display(),
std::env::var("PATH").unwrap_or_default()
),
)
.output()
.expect("bash did not start");
assert!(
out.status.success(),
"bash failed: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8_lossy(&out.stdout).to_string()
};
let out = Command::new("bash")
.arg("-c")
.arg(format!(
"set -e\nsource {}\ncomplete -p qex\n",
script.display()
))
.output()
.expect("bash did not start");
let bound = String::from_utf8_lossy(&out.stdout).to_string();
let noise = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
out.status.success(),
"sourcing the completions failed: {noise}"
);
assert_eq!(
noise, "",
"sourcing the completions must write nothing: {noise}"
);
assert!(
bound.contains("-F _qex_with_jobs"),
"the shell must call `_qex_with_jobs` after TAB, and it holds: {bound}"
);
let ask = |line: &[&str]| ask_with("", line, "printf '%s\\n' \"${COMPREPLY[@]}\"");
let read_back = |line: &[&str]| {
ask_with(
"",
line,
"eval \"set -- ${COMPREPLY[0]}\" 2>/dev/null; printf '%s\\n' \"$#\" \"$@\"",
)
};
check_the_bash_candidates(&ask, &read_back, &running, &queued, &bait);
h.ok(&["kill", &running, "--grace", "1s"]);
}
#[allow(clippy::type_complexity)]
fn check_the_bash_candidates(
ask: &dyn Fn(&[&str]) -> String,
read_back: &dyn Fn(&[&str]) -> String,
running: &str,
queued: &str,
bait: &Path,
) {
let reply = ask(&["qex", "status", ""]);
for want in [running, queued, "holder", "waiter"] {
assert!(
reply.lines().any(|l| l == want),
"bash must offer {want}: {reply}"
);
}
let reply = ask(&["qex", "status", "hol"]);
assert_eq!(reply.trim(), "holder", "bash must complete the name");
let reply = ask(&["qex", "kill", ""]);
assert!(reply.lines().any(|l| l == running), "kill: {reply}");
assert!(!reply.lines().any(|l| l == queued), "kill: {reply}");
let reply = ask(&["qex", "cancel", ""]);
assert!(reply.lines().any(|l| l == queued), "cancel: {reply}");
assert!(!reply.lines().any(|l| l == running), "cancel: {reply}");
let reply = ask(&["qex", "kill", "--signal", ""]);
assert!(
!reply.lines().any(|l| l == running),
"`qex kill --signal <TAB>` must not offer a job: {reply}"
);
for flag in ["--json", "--show-env"] {
let reply = ask(&["qex", "status", flag, ""]);
assert!(
reply.lines().any(|l| l == "holder"),
"`qex status {flag} <TAB>` must still offer a job: {reply}"
);
}
let reply = ask(&["qex", "clean", ""]);
assert!(
reply.lines().any(|l| l == "holder"),
"`qex clean <TAB>` must offer a job: {reply}"
);
let reply = ask(&["qex", "status", "two"]);
assert_eq!(reply.trim(), "two_words", "the safe form only: {reply}");
let read = read_back(&["qex", "status", "two"]);
assert_eq!(
read.trim(),
"1\ntwo_words",
"the completed word must be ONE argument of qex: {read}"
);
let reply = ask(&["qex", "status", "bait"]);
assert_eq!(reply.lines().count(), 1, "one candidate only: {reply}");
assert!(
!reply.contains("$("),
"the list must hold the safe form: {reply}"
);
let read = read_back(&["qex", "status", "bait"]);
assert_eq!(read.lines().next(), Some("1"), "one argument only: {read}");
assert!(
!bait.exists(),
"a job name RAN when bash asked for the candidates: {}",
bait.display()
);
}
#[test]
fn a_job_gives_way_to_the_work_of_a_person() {
let h = Harness::with_default_config("polite");
let id = h.submit(&["submit", "--", "sh", "-c", "ps -o ni= -p $$"]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"10",
"a job must be polite by default, and this one was not: {out}"
);
let id = h.submit(&["submit", "--nice", "0", "--", "sh", "-c", "ps -o ni= -p $$"]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(out.trim(), "0", "`--nice 0` must reach the job: {out}");
let id = h.submit(&["submit", "--nice", "-5", "--", "true"]);
h.ok(&["wait", &id, "--timeout", "45s"]);
assert_eq!(
h.status_json(&id)["state"],
"completed",
"a nice value that the machine refuses must not stop the job"
);
}
#[test]
#[cfg(target_os = "linux")]
fn every_politeness_value_reaches_the_job_and_its_children() {
for (io, expect) in [
("idle", "idle"),
("best-effort", "best-effort: prio 4"),
("none", "none: prio 0"),
] {
let h = Harness::new(
&format!("polite-{io}"),
&format!(
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[politeness]\nnice = 12\nio = \"{io}\"\noom_score_adj = 500\n"
),
);
let probe = "report() { \
echo \"$1 nice=$(awk '{print $19}' /proc/$2/stat) \
oom=$(cat /proc/$2/oom_score_adj) io=$(ionice -p $2)\"; }; \
report parent $$; sh -c 'report() { \
echo \"$1 nice=$(awk \"{print \\$19}\" /proc/$2/stat) \
oom=$(cat /proc/$2/oom_score_adj) io=$(ionice -p $2)\"; }; \
report child $$'";
let shown = h.ok(&["config", "show"]);
assert!(
shown.contains("nice 12") && shown.contains(&format!("io {io}")),
"`qex config show` must name the politeness values: {shown}"
);
let id = h.submit(&["submit", "--", "sh", "-c", probe]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
for who in ["parent", "child"] {
let line = out
.lines()
.find(|l| l.starts_with(who))
.unwrap_or_else(|| panic!("the job gave no {who} line: {out}"));
assert!(
line.contains("nice=12"),
"the {who} must take `[politeness] nice`: {line}"
);
assert!(
line.contains("oom=500"),
"the {who} must take `[politeness] oom_score_adj`: {line}"
);
assert!(
line.contains(expect),
"the {who} must take `io = \"{io}\"` as `{expect}`: {line}"
);
}
}
}
#[test]
#[cfg(target_os = "linux")]
fn a_politeness_value_with_a_fault_at_the_start_gives_the_default_values() {
let good = "[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[politeness]\nnice = 12\n";
let h = Harness::new("polite-late", good);
let probe = "awk '{print $19}' /proc/$$/stat";
let first = h.submit(&["submit", "--", "sh", "-c", probe]);
h.ok(&["wait", &first, "--timeout", "45s"]);
assert_eq!(h.ok(&["logs", &first, "--stdout"]).trim(), "12");
std::fs::write(
h.root.join("cfg/qex.toml"),
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[politeness]\nnice = 100\n",
)
.unwrap();
let out = h.qex(&["rerun", &first]);
assert!(out.status.success(), "`qex rerun` must still start the job");
let again = String::from_utf8_lossy(&out.stdout).trim().to_string();
h.ok(&["wait", &again, "--timeout", "45s"]);
let status = h.status_json(&again);
assert_eq!(
status["state"], "completed",
"a config file with a fault must not stop the job: {status}"
);
assert_eq!(
h.ok(&["logs", &again, "--stdout"]).trim(),
"10",
"the job must take the DEFAULT nice value, and not the value with the fault"
);
let error = status["error"].as_str().unwrap_or("");
assert!(
error.contains("[politeness] nice"),
"the record of the job must name the fault: {status}"
);
}
#[test]
fn a_job_is_told_the_size_of_its_claim() {
let h = Harness::new(
"claimenv",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[submit]\nenv_capture = \"minimal\"\n",
);
let id = h.submit(&[
"submit",
"--",
"sh",
"-c",
"echo \"[$QEX_CPU][$GOMAXPROCS][$NODE_OPTIONS]\"",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"[][][]",
"a job that made no claim must be told nothing: {out}"
);
let id = h.submit(&[
"submit",
"--cpu",
"2",
"--mem",
"2GB",
"--",
"sh",
"-c",
"echo \"$QEX_CPU $QEX_MEM_MB $GOMAXPROCS $OMP_NUM_THREADS\"",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"2 2048 2 2",
"the job must see its own claim: {out}"
);
let id = h.submit(&[
"submit",
"--mem",
"2GB",
"--",
"sh",
"-c",
"echo \"[$QEX_CPU][$GOMAXPROCS]\"",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"[][]",
"`--mem` with no `--cpu` must write nothing: {out}"
);
let id = h.submit(&[
"submit",
"--cpu",
"2",
"--",
"sh",
"-c",
"echo \"[$QEX_CPU][$GOMAXPROCS]\"",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"[][]",
"`--cpu` with no `--mem` must write nothing: {out}"
);
let id = h.submit(&[
"submit",
"--cpu",
"2",
"--mem",
"2GB",
"--env",
"GOMAXPROCS=9",
"--",
"sh",
"-c",
"echo \"$GOMAXPROCS $OMP_NUM_THREADS\"",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"9 2",
"`--env` must win, and the rest must still arrive: {out}"
);
let id = h.submit(&[
"submit",
"--cpu",
"2",
"--mem",
"2GB",
"--no-limit-env-hints",
"--",
"sh",
"-c",
"echo \"[$QEX_CPU][$GOMAXPROCS]\"",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(out.trim(), "[][]", "the option must write nothing: {out}");
let file = h.root.join("off.toml");
std::fs::write(
&file,
"name = \"off\"\n\
command = [\"sh\", \"-c\", \"echo \\\"[$QEX_CPU][$GOMAXPROCS]\\\"\"]\n\
no_limit_env_hints = true\n\n\
[resources]\ncpu = 2\nmem = \"2GB\"\n",
)
.unwrap();
let id = h.submit(&["submit", "--job", file.to_str().unwrap()]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(out.trim(), "[][]", "the job file must turn it off: {out}");
let file = h.root.join("on.toml");
std::fs::write(
&file,
"name = \"on\"\n\
command = [\"sh\", \"-c\", \"echo \\\"[$QEX_CPU][$GOMAXPROCS]\\\"\"]\n\n\
[resources]\ncpu = 2\nmem = \"2GB\"\n",
)
.unwrap();
let id = h.submit(&["submit", "--job", file.to_str().unwrap()]);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"[2][2]",
"the job file must get the claim: {out}"
);
let pipeline = h.root.join("p.toml");
std::fs::write(
&pipeline,
"[[jobs]]\nname = \"stage-on\"\n\
command = [\"sh\", \"-c\", \"echo \\\"[$QEX_CPU][$GOMAXPROCS]\\\"\"]\n\
[jobs.resources]\ncpu = 2\nmem = \"2GB\"\n\n\
[[jobs]]\nname = \"stage-off\"\n\
command = [\"sh\", \"-c\", \"echo \\\"[$QEX_CPU][$GOMAXPROCS]\\\"\"]\n\
no_limit_env_hints = true\n\
[jobs.resources]\ncpu = 2\nmem = \"2GB\"\n",
)
.unwrap();
let ids_file = h.root.join("stage-ids.json");
h.ok(&[
"pipeline",
pipeline.to_str().unwrap(),
"--id-file",
ids_file.to_str().unwrap(),
]);
let ids: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&ids_file).unwrap()).unwrap();
for (stage, want) in [("stage-on", "[2][2]"), ("stage-off", "[][]")] {
let id = ids["jobs"][stage].as_str().unwrap().to_string();
h.ok(&["wait", &id, "--timeout", "60s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
want,
"the stage {stage} must give {want}: {out}"
);
}
let (child, id) = h.run_bg(&[
"--cpu",
"2",
"--mem",
"2GB",
"--no-limit-env-hints",
"--",
"sh",
"-c",
"echo \"[$QEX_CPU][$GOMAXPROCS]\"",
]);
wait_run(child, "`qex run` with --no-limit-env-hints");
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"[][]",
"`qex run --no-limit-env-hints` must write nothing: {out}"
);
let (child, id) = h.run_bg(&[
"--cpu",
"2",
"--mem",
"2GB",
"--",
"sh",
"-c",
"echo \"[$QEX_CPU][$GOMAXPROCS]\"",
]);
wait_run(child, "`qex run` with a claim");
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"[2][2]",
"`qex run` must give the claim to the job: {out}"
);
}
#[test]
fn the_config_file_controls_the_claim_in_the_environment() {
let show = [
"sh",
"-c",
"echo \"[$QEX_CPU][$GOMAXPROCS][$JAVA_TOOL_OPTIONS][$MAKEFLAGS]\"",
];
let h = Harness::new(
"claimsoff",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[submit]\nenv_capture = \"minimal\"\n\
[claims]\nexport_env = false\n",
);
let mut args = vec!["submit", "--cpu", "2", "--mem", "2GB", "--"];
args.extend_from_slice(&show);
let id = h.submit(&args);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"[][][][]",
"`export_env = false` must write nothing: {out}"
);
let shown = h.ok(&["config", "show"]);
assert!(
shown.contains("claim in job: no; [claims] export_env = false"),
"`qex config show` must report that the claim is off: {shown}"
);
let h = Harness::new(
"claimsnone",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[submit]\nenv_capture = \"none\"\n",
);
let shown = h.ok(&["config", "show"]);
assert!(
shown.contains("claim in job: no; [submit] env_capture"),
"`env_capture = none` must report that the claim is off: {shown}"
);
let mut args = vec!["submit", "--cpu", "2", "--mem", "2GB", "--"];
args.extend_from_slice(&show);
let id = h.submit(&args);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"[][][][]",
"and the job must in fact receive nothing: {out}"
);
let h = Harness::new(
"claimsdefault",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let shown = h.ok(&["config", "show"]);
assert!(
shown.contains("claim in job: yes, with --cpu and --mem together"),
"the default configuration must report that the claim reaches the job: {shown}"
);
let h = Harness::new(
"claimsalso",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[submit]\nenv_capture = \"minimal\"\n\
[claims]\nalso = [\"java\", \"make\"]\n",
);
let mut args = vec!["submit", "--cpu", "2", "--mem", "2GB", "--"];
args.extend_from_slice(&show);
let id = h.submit(&args);
h.ok(&["wait", &id, "--timeout", "45s"]);
let out = h.ok(&["logs", &id, "--stdout"]);
assert_eq!(
out.trim(),
"[2][2][-XX:ActiveProcessorCount=2 -Xmx1536m][-j2]",
"`also` must add the two variables: {out}"
);
let shown = h.ok(&["config", "show"]);
assert!(
shown.contains("also java, make"),
"`qex config show` must name the hints that operate: {shown}"
);
let h = Harness::new(
"claimsbad",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[claims]\nalso = [\"jvm\"]\n",
);
let out = h.qex(&["submit", "--cpu", "2", "--mem", "2GB", "--", "true"]);
assert!(
!out.status.success(),
"an unknown name must stop the submit"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("jvm"),
"the message must name the value: {err}"
);
assert!(
err.contains("size of the machine"),
"the message must say why it matters: {err}"
);
assert!(
err.contains("`java`") && err.contains("`make`"),
"the message must give the remedy: {err}"
);
}
fn events_reader(h: &Harness, args: &[&str]) -> std::process::Child {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_qex"));
cmd.arg("events")
.args(args)
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
cmd.spawn().expect("qex events did not start")
}
fn events_lines(child: std::process::Child) -> Vec<serde_json::Value> {
let out = child.wait_with_output().expect("the reader did not stop");
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.map(|line| {
serde_json::from_str(line).unwrap_or_else(|e| {
panic!("the stream must give one JSON object for each line: {e}\nline: {line}")
})
})
.collect()
}
#[test]
fn the_event_stream_reports_each_change_of_state_in_order() {
let h = Harness::with_default_config("events");
let reader = events_reader(&h, &["--json", "--timeout", "12s"]);
std::thread::sleep(Duration::from_millis(500));
let id = h.submit(&["submit", "--name", "streamed", "--", "sh", "-c", "sleep 2"]);
h.ok(&["wait", &id]);
let lines = events_lines(reader);
assert!(!lines.is_empty(), "the stream gave no line");
assert_eq!(
lines[0]["event"], "stream",
"the first line must name the coordinator: {:?}",
lines[0]
);
let mine: Vec<&serde_json::Value> = lines
.iter()
.filter(|l| l["event"] == "job" && l["id"] == id.as_str() && l["change"] == "state")
.collect();
let states: Vec<&str> = mine.iter().map(|l| l["state"].as_str().unwrap()).collect();
assert_eq!(
states,
vec!["queued", "starting", "running", "completed"],
"the stream must give each change of state in order: {states:?}"
);
let seqs: Vec<u64> = mine.iter().map(|l| l["seq"].as_u64().unwrap()).collect();
assert!(
seqs.windows(2).all(|w| w[1] > w[0]),
"the numbers must increase: {seqs:?}"
);
let last = mine.last().unwrap();
assert_eq!(last["previous"], "running");
assert_eq!(last["job"]["exit_code"], 0);
assert_eq!(last["job"]["name"], "streamed");
}
#[test]
fn the_stream_shows_the_safe_name() {
let h = Harness::with_default_config("eventsname");
let reader = events_reader(&h, &["--json", "--timeout", "20s"]);
let plain = events_reader(&h, &["--timeout", "20s"]);
std::thread::sleep(Duration::from_millis(500));
let esc = "esc\u{1b}[2Jbad";
let id = h.submit(&["submit", &format!("--name={esc}"), "--", "true"]);
h.ok(&["wait", &id]);
let text = plain.wait_with_output().expect("the reader did not stop");
for stream in [&text.stdout, &text.stderr] {
assert!(
!stream.windows(6).any(|w| w == b"esc\x1b[2"),
"`qex events` wrote the ESC byte of a job name"
);
}
assert!(
String::from_utf8_lossy(&text.stdout).contains("esc_2Jbad"),
"`qex events` must show the safe name: {}",
String::from_utf8_lossy(&text.stdout)
);
let shown = h.status_json(&id)["name"].as_str().unwrap().to_string();
let lines = events_lines(reader);
let mine: Vec<&serde_json::Value> = lines
.iter()
.filter(|l| l["event"] == "job" && l["id"] == id.as_str())
.collect();
assert!(!mine.is_empty(), "the stream gave no event for the job");
for line in mine {
assert_eq!(
line["name"].as_str(),
Some(shown.as_str()),
"the stream and `qex status --json` must give one name: {line}"
);
assert_eq!(
line["job"]["name"].as_str(),
Some(shown.as_str()),
"the record in the stream must hold the safe name: {line}"
);
}
}
#[test]
fn the_stream_shows_no_control_byte_in_a_sentence() {
let h = Harness::new(
"eventsreason",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let plain = events_reader(&h, &["--timeout", "20s"]);
let json = events_reader(&h, &["--json", "--timeout", "20s"]);
std::thread::sleep(Duration::from_millis(500));
let lock = "lk\u{1b}[2Jbad";
let holder = h.submit(&[
"submit", "--name", "holder", "--lock", lock, "--", "sleep", "10",
]);
h.until("the holder runs", Duration::from_secs(30), || {
h.state_of(&holder) == "running"
});
let waiter = h.submit(&[
"submit",
"--name",
"waiter",
"--lock",
lock,
"--max-queue-time",
"3s",
"--",
"true",
]);
h.qex(&["wait", &waiter, "--timeout", "40s"]);
h.qex(&["kill", &holder]);
let text = plain.wait_with_output().expect("the reader did not stop");
for stream in [&text.stdout, &text.stderr] {
assert!(
!stream.windows(5).any(|w| w == b"lk\x1b[2"),
"`qex events` wrote the ESC byte of a lock name"
);
}
let lines = events_lines(json);
let mine: Vec<&serde_json::Value> = lines
.iter()
.filter(|l| l["event"] == "job" && l["id"] == waiter.as_str())
.collect();
assert!(!mine.is_empty(), "the stream gave no event for the job");
let mut saw_the_lock = false;
for line in mine {
for field in ["blocked_reason", "error"] {
if let Some(sentence) = line["job"][field].as_str() {
if sentence.contains("lk") {
saw_the_lock = true;
}
assert!(
!sentence.chars().any(|c| c.is_control()),
"the field `{field}` holds a control byte: {sentence:?}"
);
}
}
}
assert!(
saw_the_lock,
"the test proved nothing: no sentence carried the lock name"
);
}
#[test]
fn a_live_reader_receives_the_new_events_only() {
let h = Harness::with_default_config("eventsnow");
let old = h.submit(&["submit", "--name", "before", "--", "true"]);
h.ok(&["wait", &old]);
let reader = events_reader(&h, &["--json", "--since", "now", "--timeout", "12s"]);
std::thread::sleep(Duration::from_millis(500));
let new = h.submit(&["submit", "--name", "after", "--", "true"]);
h.ok(&["wait", &new]);
let lines = events_lines(reader);
assert!(
lines.iter().any(|l| l["id"] == new.as_str()),
"the live stream gave no event for the job that started after it"
);
assert!(
!lines.iter().any(|l| l["id"] == old.as_str()),
"`--since now` gave the events of a job that stopped before the reader \
connected: {lines:?}"
);
}
#[test]
fn the_reader_stops_after_the_number_of_events_that_it_asked_for() {
let h = Harness::with_default_config("eventscount");
let reader = events_reader(&h, &["--json", "--count", "2", "--timeout", "20s"]);
std::thread::sleep(Duration::from_millis(500));
let id = h.submit(&["submit", "--name", "counted", "--", "true"]);
h.ok(&["wait", &id]);
let out = reader.wait_with_output().expect("the reader did not stop");
assert_eq!(
out.status.code(),
Some(0),
"a reader that read its count stops with 0: {}",
String::from_utf8_lossy(&out.stderr)
);
let text = String::from_utf8_lossy(&out.stdout);
let lines: Vec<serde_json::Value> = text
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
let counted = lines
.iter()
.filter(|l| l["event"] == "job" || l["event"] == "gap")
.count();
assert_eq!(
counted, 2,
"the reader must stop after two events, and it wrote: {text}"
);
assert_eq!(
lines[0]["event"], "stream",
"the header must not count as an event: {text}"
);
}
#[test]
fn the_stream_reports_the_terminal_states_that_no_supervisor_reports() {
let h = Harness::new(
"eventsterm",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\noversized = \"queue\"\n",
);
let reader = events_reader(&h, &["--json", "--timeout", "25s"]);
std::thread::sleep(Duration::from_millis(500));
let first = h.submit(&[
"submit",
"--cpu",
"64",
"--max-queue-time",
"3s",
"--",
"echo",
"never",
]);
let second = h.submit(&["submit", "--needs", &first, "--", "echo", "after"]);
h.qex(&["wait", &second, "--timeout", "60s"]);
let lines = events_lines(reader);
let state_of = |id: &str| -> Vec<String> {
lines
.iter()
.filter(|l| l["event"] == "job" && l["id"] == id && l["change"] == "state")
.map(|l| l["state"].as_str().unwrap().to_string())
.collect()
};
let one = state_of(&first);
assert!(
one.contains(&"expired".to_string()),
"the stream must report the job that gave up waiting: {one:?}"
);
let two = state_of(&second);
assert!(
two.contains(&"skipped".to_string()),
"the stream must report the job that did not run: {two:?}"
);
let expired = lines
.iter()
.find(|l| l["event"] == "job" && l["id"] == first.as_str() && l["state"] == "expired")
.unwrap();
assert!(
expired["job"]["error"]
.as_str()
.unwrap_or("")
.contains("--max-queue-time"),
"the record must name the limit: {expired}"
);
assert!(
!expired["job"]["finished_at"].is_null(),
"a terminal record must hold the time when the job stopped: {expired}"
);
}
#[test]
fn a_reader_continues_from_the_number_that_it_read() {
let h = Harness::with_default_config("eventssince");
let id = h.submit(&["submit", "--name", "again", "--", "true"]);
h.ok(&["wait", &id]);
let all = events_lines(events_reader(
&h,
&["--json", "--since", "start", "--timeout", "3s"],
));
let jobs: Vec<&serde_json::Value> = all.iter().filter(|l| l["event"] == "job").collect();
assert!(jobs.len() >= 2, "the stream must hold the events: {all:?}");
let stream_id = all[0]["stream_id"].as_str().unwrap().to_string();
let first = jobs[0]["seq"].as_u64().unwrap();
let after = events_lines(events_reader(
&h,
&[
"--json",
"--since",
&format!("{stream_id}:{first}"),
"--timeout",
"3s",
],
));
let got: Vec<u64> = after
.iter()
.filter(|l| l["event"] == "job")
.map(|l| l["seq"].as_u64().unwrap())
.collect();
let expected: Vec<u64> = jobs
.iter()
.map(|l| l["seq"].as_u64().unwrap())
.filter(|s| *s > first)
.collect();
assert_eq!(got, expected, "the reader must continue after its number");
assert!(
!got.contains(&first),
"the reader must not read one event a second time"
);
}
#[test]
fn the_stream_counts_the_events_that_it_dropped() {
let h = Harness::with_default_config("eventsgap");
let mut cmd = Command::new(env!("CARGO_BIN_EXE_qex"));
cmd.args(["submit", "--name", "gap0", "--", "true"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.env("QEX_EVENTS_RETAINED", "3");
let out = cmd.output().expect("qex did not start");
assert!(out.status.success(), "the first submission failed");
let first = String::from_utf8_lossy(&out.stdout).trim().to_string();
h.ok(&["wait", &first]);
for i in 1..4 {
let id = h.submit(&["submit", "--name", &format!("gap{i}"), "--", "true"]);
h.ok(&["wait", &id]);
}
let lines = events_lines(events_reader(
&h,
&["--json", "--since", "start", "--timeout", "3s"],
));
let gap = lines
.iter()
.find(|l| l["event"] == "gap")
.unwrap_or_else(|| panic!("the stream must report the gap: {lines:?}"));
assert!(
gap["missed"].as_u64().unwrap() > 0,
"the gap must count the events that went away: {gap:?}"
);
assert!(
lines.iter().any(|l| l["event"] == "job"),
"the stream must continue after a gap: {lines:?}"
);
}
#[test]
fn the_coordinator_retires_under_a_reader_and_says_goodbye() {
let h = Harness::new(
"eventsbye",
"[peers]\nenabled = false\n[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let mut cmd = Command::new(env!("CARGO_BIN_EXE_qex"));
cmd.args(["events", "--json", "--timeout", "60s"])
.env("XDG_CONFIG_HOME", h.root.join("cfg"))
.env("XDG_STATE_HOME", h.root.join("state"))
.env("XDG_RUNTIME_DIR", h.root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "3")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let reader = cmd.spawn().expect("qex events did not start");
let out = reader.wait_with_output().expect("the reader did not stop");
assert_eq!(
out.status.code(),
Some(0),
"an orderly stop is not a failure: {}",
String::from_utf8_lossy(&out.stderr)
);
let text = String::from_utf8_lossy(&out.stdout);
let last: serde_json::Value = serde_json::from_str(text.lines().last().unwrap_or("")).unwrap();
assert_eq!(
last["event"], "bye",
"the coordinator must say why the stream ends: {text}"
);
assert!(
last["reason"].as_str().unwrap().contains("no job operates"),
"the goodbye must give the reason: {last:?}"
);
}
#[test]
fn a_coordinator_that_has_no_event_stream_refuses_the_command() {
use std::io::{BufRead, BufReader, Write};
let root = std::env::temp_dir().join(format!("qxold{}", std::process::id()));
let run = root.join("state/qex/run");
std::fs::create_dir_all(&run).unwrap();
std::fs::create_dir_all(root.join("cfg")).unwrap();
let socket = run.join("s");
let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
let server = std::thread::spawn(move || {
let Ok((stream, _)) = listener.accept() else {
return;
};
let mut writer = stream.try_clone().unwrap();
for line in BufReader::new(stream).lines() {
let Ok(line) = line else { return };
let answer = if line.contains("\"info\"") {
serde_json::json!({
"result": "info", "pid": 4321, "version": "0.7.1",
"started_at": 0, "program_replaced": false,
"jobs_running": 0, "jobs_queued": 0,
"cpu_budget": 1, "mem_budget": 1, "cpu_claimed": 0, "mem_claimed": 0,
})
} else if line.contains("\"capabilities\"") {
serde_json::json!({ "result": "capabilities", "names": ["locks", "retries"] })
} else {
serde_json::json!({
"result": "error", "kind": "internal",
"message": "qex could not read this request",
})
};
writeln!(writer, "{answer}").ok();
writer.flush().ok();
}
});
let out = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["events", "--json", "--timeout", "10s"])
.env("XDG_CONFIG_HOME", root.join("cfg"))
.env("XDG_STATE_HOME", root.join("state"))
.env("XDG_RUNTIME_DIR", root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.expect("qex did not start");
let err = String::from_utf8_lossy(&out.stderr).to_string();
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
drop(server);
std::fs::remove_dir_all(&root).ok();
assert_eq!(
out.status.code(),
Some(1),
"the command must fail. stdout: {stdout} stderr: {err}"
);
assert!(
err.contains("cannot obey `qex events`"),
"the message must say what happened: {err}"
);
assert!(
err.contains("kill 4321"),
"the message must give the remedy: {err}"
);
assert!(
stdout.is_empty(),
"a refused command must give no stream: {stdout}"
);
}
#[test]
fn a_pipeline_with_an_option_on_a_later_stage_is_refused() {
use std::io::{BufRead, BufReader, Write};
use std::sync::{Arc, Mutex};
let root = std::env::temp_dir().join(format!("qxpipecap{}", std::process::id()));
std::fs::remove_dir_all(&root).ok();
let run = root.join("state/qex/run");
std::fs::create_dir_all(&run).unwrap();
std::fs::create_dir_all(root.join("cfg")).unwrap();
let socket = run.join("s");
let file = root.join("p.toml");
std::fs::write(
&file,
"[[jobs]]\nname = \"build\"\ncommand = [\"true\"]\n\n\
[[jobs]]\nname = \"test\"\ncommand = [\"true\"]\nneeds = [\"build\"]\nnice = 19\n",
)
.unwrap();
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
let recorder = Arc::clone(&seen);
let server = std::thread::spawn(move || {
let Ok((stream, _)) = listener.accept() else {
return;
};
let mut writer = stream.try_clone().unwrap();
for line in BufReader::new(stream).lines() {
let Ok(line) = line else { return };
recorder.lock().unwrap().push(line.clone());
let answer = if line.contains("\"info\"") {
serde_json::json!({
"result": "info", "pid": 4321, "version": "0.7.1",
"started_at": 0, "program_replaced": false,
"jobs_running": 0, "jobs_queued": 0,
"cpu_budget": 1, "mem_budget": 1, "cpu_claimed": 0, "mem_claimed": 0,
})
} else if line.contains("\"capabilities\"") {
serde_json::json!({
"result": "capabilities",
"names": ["dependencies", "groups", "locks", "retries"],
})
} else {
serde_json::json!({
"result": "error", "kind": "internal",
"message": "qex could not read this request",
})
};
writeln!(writer, "{answer}").ok();
writer.flush().ok();
}
});
let out = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["pipeline", file.to_str().unwrap()])
.env("XDG_CONFIG_HOME", root.join("cfg"))
.env("XDG_STATE_HOME", root.join("state"))
.env("XDG_RUNTIME_DIR", root.join("run"))
.env("QEX_IDLE_EXIT_SECS", "120")
.output()
.expect("qex did not start");
let err = String::from_utf8_lossy(&out.stderr).to_string();
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let requests = seen.lock().unwrap().clone();
drop(server);
std::fs::remove_dir_all(&root).ok();
assert_ne!(
out.status.code(),
Some(0),
"the command must fail. stdout: {stdout} stderr: {err}"
);
assert!(
err.contains("--nice"),
"the message must name the option: {err}"
);
assert!(
err.contains("`test`"),
"the message must name the stage that the reader corrects: {err}"
);
assert!(
!requests.iter().any(|r| r.contains("\"submit\"")),
"no job of the file may reach the queue: {requests:?}"
);
assert!(
stdout.is_empty(),
"a refused pipeline must give no job id: {stdout}"
);
}
#[test]
fn a_rerun_of_a_job_that_holds_a_lock_is_refused_by_an_earlier_coordinator() {
use std::io::{BufRead, BufReader, Write};
use std::sync::{Arc, Mutex};
let h = Harness::with_default_config("reruncap");
let id = h.submit(&["submit", "--lock", "deploy", "--mem", "64MB", "--", "true"]);
h.ok(&["wait", &id]);
let info = h.ok(&["info", "--no-start", "--json"]);
let pid: i32 = serde_json::from_str::<serde_json::Value>(&info).unwrap()["pid"]
.as_i64()
.expect("the coordinator must report its pid") as i32;
unsafe { libc::kill(pid, libc::SIGTERM) };
let socket = h.root.join("state/qex/run/s");
for _ in 0..200 {
if !socket.exists() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
std::fs::remove_file(&socket).ok();
let raw = std::fs::read_to_string(h.root.join(format!("state/qex/jobs/{id}/status.json")))
.expect("the record of the job must exist");
let status: serde_json::Value =
serde_json::from_str(&raw).expect("the record must hold one job");
let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
let recorder = Arc::clone(&seen);
let server = std::thread::spawn(move || {
let Ok((stream, _)) = listener.accept() else {
return;
};
let mut writer = stream.try_clone().unwrap();
for line in BufReader::new(stream).lines() {
let Ok(line) = line else { return };
recorder.lock().unwrap().push(line.clone());
let answer = if line.contains("\"info\"") {
serde_json::json!({
"result": "info", "pid": 4321, "version": "0.7.1",
"started_at": 0, "program_replaced": false,
"jobs_running": 0, "jobs_queued": 0,
"cpu_budget": 1, "mem_budget": 1, "cpu_claimed": 0, "mem_claimed": 0,
})
.to_string()
} else if line.contains("\"capabilities\"") {
serde_json::json!({
"result": "capabilities",
"names": ["dependencies", "groups", "retries", "politeness"],
})
.to_string()
} else if line.contains("\"list\"") {
serde_json::json!({ "result": "jobs", "jobs": [status] }).to_string()
} else {
serde_json::json!({
"result": "error", "kind": "internal",
"message": "qex could not read this request",
})
.to_string()
};
writeln!(writer, "{answer}").ok();
writer.flush().ok();
}
});
let out = Command::new(env!("CARGO_BIN_EXE_qex"))
.args(["rerun", &id])
.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")
.output()
.expect("qex did not start");
let err = String::from_utf8_lossy(&out.stderr).to_string();
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let requests = seen.lock().unwrap().clone();
drop(server);
assert_ne!(
out.status.code(),
Some(0),
"the command must fail. stdout: {stdout} stderr: {err}"
);
assert!(
err.contains("--lock"),
"the message must name the option: {err}"
);
assert!(
!requests.iter().any(|r| r.contains("\"submit\"")),
"no job may reach the queue: {requests:?}"
);
}
#[test]
fn a_number_from_a_coordinator_that_stopped_gives_a_gap() {
let h = Harness::with_default_config("eventsrestart");
for i in 0..3 {
let id = h.submit(&["submit", "--name", &format!("old{i}"), "--", "true"]);
h.ok(&["wait", &id]);
}
let before = events_lines(events_reader(
&h,
&["--json", "--since", "now", "--timeout", "2s"],
));
let old_stream = before[0]["stream_id"].as_str().unwrap().to_string();
let pid = h.coordinator_pid();
unsafe {
libc::kill(pid, libc::SIGKILL);
}
h.until("the coordinator goes away", Duration::from_secs(20), || {
(unsafe { libc::kill(pid, 0) }) != 0
});
let lines = events_lines(events_reader(
&h,
&[
"--json",
"--since",
&format!("{old_stream}:2"),
"--timeout",
"3s",
],
));
assert_ne!(
lines[0]["stream_id"].as_str().unwrap(),
old_stream,
"the new coordinator must have a stream of its own"
);
let gap = lines
.iter()
.find(|l| l["event"] == "gap")
.unwrap_or_else(|| {
panic!("a number from a stream that stopped must give a gap: {lines:?}")
});
assert!(
gap["missed"].is_null(),
"qex cannot count across two streams, and it must not invent a number: {gap:?}"
);
assert!(
gap["reason"].as_str().unwrap().contains(&old_stream),
"the reason must name the stream that gave the number: {gap:?}"
);
let seqs: Vec<u64> = lines
.iter()
.filter(|l| l["event"] == "job")
.map(|l| l["seq"].as_u64().unwrap())
.collect();
assert_eq!(
seqs.first(),
Some(&1),
"the stream must continue from the first event that the new coordinator holds: {seqs:?}"
);
}
#[cfg(target_os = "linux")]
#[test]
fn a_reader_that_goes_away_leaves_no_thread_behind() {
let h = Harness::with_default_config("eventsfds");
let pid = h.coordinator_pid();
let count = |what: &str| {
std::fs::read_dir(format!("/proc/{pid}/{what}"))
.map(|d| d.count())
.unwrap_or(0)
};
let threads = count("task");
let handles = count("fd");
assert!(threads > 0, "this test needs /proc");
for _ in 0..8 {
let reader = events_reader(&h, &["--json", "--since", "now", "--timeout", "1s"]);
reader.wait_with_output().expect("the reader did not stop");
}
h.until(
"the coordinator releases the threads of the readers that stopped",
Duration::from_secs(20),
|| count("task") <= threads + 1 && count("fd") <= handles + 2,
);
}
#[test]
fn a_paused_queue_starts_no_job_and_the_jobs_that_operate_continue() {
let h = Harness::with_default_config("pausequeue");
let running = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "300",
]);
h.until("the first job operates", Duration::from_secs(45), || {
h.state_of(&running) == "running"
});
h.ok(&["pause", "queue"]);
let waiter = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
let deadline = Instant::now() + Duration::from_secs(4);
while Instant::now() < deadline {
assert_eq!(
h.state_of(&waiter),
"queued",
"a paused queue must start no job"
);
std::thread::sleep(Duration::from_millis(300));
}
assert_eq!(
h.state_of(&running),
"running",
"a pause must not stop the job that already operates"
);
h.ok(&["resume", "queue"]);
h.until("the job starts again", Duration::from_secs(45), || {
h.has_started(&waiter)
});
h.ok(&["kill", &running, "--grace", "1s"]);
}
#[test]
fn the_pause_survives_a_coordinator_that_stops() {
let h = Harness::with_default_config("pausesurvives");
h.ok(&["pause", "queue", "--reason", "recording a demo"]);
let first = h.ok(&["info", "--no-start", "--json"]);
let first: serde_json::Value = serde_json::from_str(&first).unwrap();
let first = first["pid"].as_i64().unwrap() as i32;
unsafe {
libc::kill(first, libc::SIGKILL);
}
h.until("the coordinator stopped", Duration::from_secs(30), || {
let alive = unsafe { libc::kill(first, 0) } == 0;
!alive
});
let id = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
let second = h.ok(&["info", "--no-start", "--json"]);
let second: serde_json::Value = serde_json::from_str(&second).unwrap();
assert_eq!(second["queue_state"], "paused", "the pause did not survive");
assert_eq!(second["paused_reason"], "recording a demo");
assert_ne!(
second["pid"].as_i64().unwrap() as i32,
first,
"the test must measure a NEW coordinator"
);
let deadline = Instant::now() + Duration::from_secs(4);
while Instant::now() < deadline {
assert_eq!(
h.state_of(&id),
"queued",
"the new coordinator must start no job"
);
std::thread::sleep(Duration::from_millis(300));
}
h.ok(&["resume"]);
h.until("the job starts again", Duration::from_secs(45), || {
h.has_started(&id)
});
}
#[test]
fn a_person_gets_a_lock_when_the_job_that_holds_it_stops() {
let h = Harness::with_default_config("pauselock");
let holder = h.submit(&[
"submit", "--lock", "gpu0", "--cpu", "1", "--mem", "64MB", "--", "sleep", "6",
]);
h.until("the job holds the lock", Duration::from_secs(45), || {
h.state_of(&holder) == "running"
});
let out = h.ok(&["pause", "lock", "gpu0"]);
assert!(
out.contains("holds the lock"),
"the answer must name the job that holds the lock now: {out}"
);
let waiter = h.submit(&[
"submit", "--lock", "gpu0", "--cpu", "1", "--mem", "64MB", "--", "true",
]);
h.until("the first job stopped", Duration::from_secs(60), || {
h.state_of(&holder) == "completed"
});
h.until(
"the lock belongs to the person",
Duration::from_secs(30),
|| h.ok(&["pause"]).contains("it is yours now"),
);
let deadline = Instant::now() + Duration::from_secs(3);
while Instant::now() < deadline {
assert_eq!(
h.state_of(&waiter),
"queued",
"no job may take a lock that a person holds"
);
std::thread::sleep(Duration::from_millis(300));
}
let reason = h.status_json(&waiter)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
reason.contains("which a person holds"),
"the reason must name the person: {reason}"
);
h.ok(&["resume", "lock", "gpu0"]);
h.until("the job takes the lock", Duration::from_secs(45), || {
h.has_started(&waiter)
});
}
#[test]
fn a_job_that_waits_for_a_pause_says_the_pause() {
let h = Harness::with_default_config("pausereason");
h.ok(&["pause", "queue", "--reason", "recording a demo"]);
let out = h.qex(&["submit", "--", "true"]);
assert!(out.status.success());
let id = String::from_utf8_lossy(&out.stdout).trim().to_string();
let warning = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
warning.contains("the queue is paused"),
"the submission must warn immediately: {warning}"
);
h.until("the job has a reason", Duration::from_secs(30), || {
!h.status_json(&id)["blocked_reason"].is_null()
});
let reason = h.status_json(&id)["blocked_reason"]
.as_str()
.unwrap()
.to_string();
assert!(
reason.contains("the queue is paused"),
"the reason must give the pause: {reason}"
);
assert!(
reason.contains("recording a demo"),
"the reason must give the text of --reason: {reason}"
);
assert!(
!reason.contains("waits for"),
"the pause replaces the capacity reason, and does not stand beside it: {reason}"
);
h.ok(&["resume"]);
}
#[test]
fn a_pause_with_a_time_ends_by_itself() {
let h = Harness::with_default_config("pausefor");
h.ok(&["pause", "queue", "--for", "10s"]);
let id = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
assert_eq!(
h.state_of(&id),
"queued",
"the job must wait while the pause lasts"
);
h.until(
"the job starts when the pause ends",
Duration::from_secs(60),
|| h.has_started(&id),
);
assert!(
h.ok(&["pause"]).contains("queue: running"),
"the pause must go away by itself"
);
}
#[test]
fn a_failed_dependency_is_still_skipped_while_the_queue_is_paused() {
let h = Harness::with_default_config("pausedeps");
let failer = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "false"]);
let out = h.qex(&["wait", &failer]);
assert_eq!(out.status.code(), Some(1));
h.ok(&["pause", "queue"]);
let skipped = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--needs", &failer, "--", "true",
]);
h.until("the job is skipped", Duration::from_secs(45), || {
h.state_of(&skipped) == "skipped"
});
let out = h.qex(&["wait", &skipped, "--timeout", "10s"]);
assert_eq!(
out.status.code(),
Some(126),
"`qex wait` must give an answer while the queue is paused"
);
h.ok(&["resume"]);
}
#[test]
fn a_retry_starts_no_new_attempt_while_the_queue_is_paused() {
let h = Harness::with_default_config("pauseretry");
let id = h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--retries",
"3",
"--",
"sh",
"-c",
"echo attempt; sleep 2; exit 1",
]);
h.until(
"the first attempt operates",
Duration::from_secs(45),
|| h.state_of(&id) == "running",
);
h.ok(&["pause", "queue"]);
std::thread::sleep(Duration::from_secs(5));
let log = h.job_dir(&id).join("stdout.log");
let count = |path: &Path| {
std::fs::read_to_string(path)
.unwrap_or_default()
.matches("attempt")
.count()
};
let after_the_pause = count(&log);
let deadline = Instant::now() + Duration::from_secs(6);
while Instant::now() < deadline {
assert_eq!(
count(&log),
after_the_pause,
"a paused queue must start no attempt of a job with --retries"
);
std::thread::sleep(Duration::from_millis(400));
}
h.ok(&["resume"]);
h.until("the next attempt starts", Duration::from_secs(45), || {
count(&log) > after_the_pause
});
h.ok(&["kill", &id, "--grace", "1s"]);
}
#[test]
fn a_retry_does_not_take_a_lock_that_a_person_holds() {
let h = Harness::with_default_config("pauseretrylock");
let id = h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--lock",
"gpu0",
"--retries",
"3",
"--",
"sh",
"-c",
"echo attempt; sleep 2; exit 1",
]);
h.until(
"the first attempt operates",
Duration::from_secs(45),
|| h.state_of(&id) == "running",
);
h.ok(&["pause", "lock", "gpu0"]);
std::thread::sleep(Duration::from_secs(5));
let log = h.job_dir(&id).join("stdout.log");
let count = |path: &Path| {
std::fs::read_to_string(path)
.unwrap_or_default()
.matches("attempt")
.count()
};
let after_the_pause = count(&log);
let deadline = Instant::now() + Duration::from_secs(6);
while Instant::now() < deadline {
assert_eq!(
count(&log),
after_the_pause,
"a person holds the lock, so no attempt of that job may start"
);
std::thread::sleep(Duration::from_millis(400));
}
h.ok(&["resume", "lock", "gpu0"]);
h.until("the next attempt starts", Duration::from_secs(45), || {
count(&log) > after_the_pause
});
h.stop(&id);
}
#[test]
fn a_pause_record_that_qex_cannot_read_holds_the_queue() {
let h = Harness::with_default_config("pausebroken");
h.ok(&["pause", "queue"]);
let file = h.root.join("state/qex/run/paused.json");
assert!(file.exists(), "the pause must be a file");
h.ok(&["resume"]);
let text = h.ok(&["info", "--no-start", "--json"]);
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
let pid = v["pid"].as_i64().unwrap() as i32;
unsafe {
libc::kill(pid, libc::SIGKILL);
}
h.until("the coordinator stopped", Duration::from_secs(30), || {
let alive = unsafe { libc::kill(pid, 0) } == 0;
!alive
});
std::fs::write(&file, "{\"queue\": {\"paused_at\": ").unwrap();
let id = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
assert_eq!(
h.state_of(&id),
"queued",
"a record that qex cannot read must hold the queue"
);
std::thread::sleep(Duration::from_millis(300));
}
let info = h.ok(&["info", "--no-start", "--json"]);
let info: serde_json::Value = serde_json::from_str(&info).unwrap();
assert_eq!(info["queue_state"], "paused-by-fault");
let words = h.ok(&["pause"]);
assert!(
words.contains("could not read"),
"the report must say what happened: {words}"
);
assert!(
words.contains("qex resume queue"),
"the report must give the remedy: {words}"
);
let reason = h.status_json(&id)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
reason.contains("could not read"),
"the job must give the true reason: {reason}"
);
h.ok(&["resume"]);
h.until("the job starts again", Duration::from_secs(45), || {
h.has_started(&id)
});
}
#[test]
fn a_pause_for_zero_is_refused() {
let h = Harness::with_default_config("pausezero");
let out = h.qex(&["pause", "queue", "--for", "0"]);
assert!(!out.status.success(), "`--for 0` must not give a pause");
let words = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
words.contains("qex resume queue"),
"the error must give the remedy: {words}"
);
assert!(h.ok(&["pause"]).contains("nothing is paused"));
}
#[test]
fn a_second_pause_keeps_the_end_of_the_first() {
let h = Harness::with_default_config("pausetwice");
h.ok(&["pause", "queue", "--for", "30m", "--reason", "a video call"]);
h.ok(&["pause", "queue"]);
let words = h.ok(&["pause"]);
assert!(
!words.contains("NO END"),
"the second command must not remove the end: {words}"
);
assert!(
words.contains("a video call"),
"the second command must not remove the reason: {words}"
);
h.ok(&["resume"]);
}
#[test]
fn a_pause_does_not_expire_a_job_that_has_a_queue_limit() {
let h = Harness::with_default_config("pauseexpire");
h.ok(&["pause", "queue"]);
let id = h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--max-queue-time",
"3s",
"--",
"true",
]);
let deadline = Instant::now() + Duration::from_secs(8);
while Instant::now() < deadline {
let state = h.state_of(&id);
assert_eq!(
state, "queued",
"a paused queue must not expire a job; the job became `{state}`"
);
std::thread::sleep(Duration::from_millis(300));
}
let credited = h.status_json(&id)["queue_pause_secs"].as_u64().unwrap_or(0);
assert_eq!(
credited, 0,
"the credit belongs to the END of the pause, not to each second of it"
);
h.ok(&["resume", "queue"]);
let credited = h.status_json(&id)["queue_pause_secs"].as_u64().unwrap_or(0);
assert!(
credited >= 5,
"the resume must give the paused time back; got {credited} seconds"
);
h.until(
"the job runs after the resume",
Duration::from_secs(45),
|| h.state_of(&id) == "completed",
);
assert_eq!(
h.state_of(&id),
"completed",
"the job must run, and not expire"
);
}
#[test]
fn the_limit_still_expires_a_job_after_the_pause_ends() {
let h = Harness::with_default_config("pauseexpire2");
let holder = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "300",
]);
h.until("the first job operates", Duration::from_secs(45), || {
h.state_of(&holder) == "running"
});
h.ok(&["pause", "queue"]);
let id = h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--needs",
&holder,
"--max-queue-time",
"4s",
"--",
"true",
]);
std::thread::sleep(Duration::from_secs(7));
assert_eq!(
h.state_of(&id),
"queued",
"the pause must hold the clock of the limit"
);
h.ok(&["resume", "queue"]);
h.until("the job reaches its limit", Duration::from_secs(45), || {
h.state_of(&id) == "expired"
});
h.ok(&["kill", &holder, "--grace", "1s"]);
}
#[test]
fn a_wait_behind_a_pause_says_the_pause() {
let h = Harness::with_default_config("pausewait");
h.ok(&["pause", "queue", "--reason", "recording a demo"]);
let id = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
let out = h.qex(&["wait", &id, "--timeout", "3s"]);
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
err.contains("THE QUEUE IS PAUSED"),
"the wait must say that the queue is paused: {err}"
);
assert!(
err.contains("recording a demo"),
"the wait must give the reason of the pause: {err}"
);
assert!(
err.contains("qex resume queue"),
"the wait must give the command that ends the pause: {err}"
);
assert_eq!(
out.status.code(),
Some(124),
"the wait still reaches its own limit and gives 124"
);
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
!stdout.contains("PAUSED"),
"the pause belongs on stderr: {stdout}"
);
h.ok(&["resume"]);
}
#[test]
fn a_pause_shows_no_control_byte_of_a_reason_or_a_lock_name() {
let h = Harness::with_default_config("pausebytes");
h.ok(&["pause", "queue", "--reason", "esc\x1b[2Jbad"]);
h.ok(&["pause", "lock", "esc\x1b[2Jlock"]);
let id = h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--lock",
"esc\x1b[2Jlock",
"--",
"true",
]);
h.until(
"the job takes the reason of the pause",
Duration::from_secs(45),
|| !h.status_json(&id)["blocked_reason"].is_null(),
);
for args in [
vec!["pause"],
vec!["info"],
vec!["list"],
vec!["status", id.as_str()],
] {
let out = h.qex(&args);
let mut stream = out.stdout.clone();
stream.extend_from_slice(&out.stderr);
assert!(
!stream.windows(4).any(|w| w == b"\x1b[2J"),
"`qex {}` wrote the ESC byte of a pause",
args.join(" ")
);
}
let shown = h.ok(&["pause"]);
assert!(
shown.contains("esc [2Jbad"),
"`qex pause` must keep the reason, without the control byte: {shown}"
);
assert!(
shown.contains("esc_2Jlock"),
"`qex pause` must keep the lock name, in its safe form: {shown}"
);
h.ok(&["resume", "lock", "esc\x1b[2Jlock"]);
h.ok(&["resume"]);
}
#[test]
fn an_oversized_job_in_a_paused_queue_says_the_pause() {
let h = Harness::new(
"pauseoversized",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\noversized = \"run-when-idle\"\nsettle = \"1s\"\n",
);
h.ok(&["pause", "queue", "--reason", "recording a demo"]);
let id = h.submit(&["submit", "--cpu", "4", "--mem", "128MB", "--", "true"]);
let reason = h.status_json(&id)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
reason.contains("the queue is paused"),
"the record must give the pause as the reason: {reason}"
);
assert!(
!reason.contains("the budget is"),
"the record must not send the reader to the budget: {reason}"
);
let out = h.qex(&["submit", "--cpu", "4", "--mem", "128MB", "--", "true"]);
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(
err.contains("the queue is paused"),
"the submission must name the pause: {err}"
);
assert!(
err.contains("the budget is"),
"the submission must still name the claim: {err}"
);
h.ok(&["resume"]);
}
#[test]
fn every_command_names_the_same_pauser_and_not_the_coordinator() {
let h = Harness::with_default_config("pausewho");
h.ok(&["pause", "queue", "--reason", "recording a demo"]);
let coordinator = h.ok(&["info", "--no-start", "--json"]);
let coordinator: serde_json::Value = serde_json::from_str(&coordinator).unwrap();
let coordinator_pid = coordinator["pid"].as_i64().expect("the coordinator pid");
let pauser = coordinator["paused_by_pid"]
.as_i64()
.expect("`qex info --json` must give the pid that asked for the pause");
assert_ne!(
pauser, coordinator_pid,
"the pauser is the CLI process, and never the coordinator"
);
assert!(pauser > 0, "a pid of 0 is not a process: {pauser}");
for args in [
vec!["pause"],
vec!["info"],
vec!["top", "--once"],
vec!["list"],
] {
let out = h.qex(&args);
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert!(
text.contains(&format!("pid {pauser}")),
"`qex {}` must name the process that paused: {text}",
args.join(" ")
);
assert!(
!text.contains(&format!("by pid {coordinator_pid}")),
"`qex {}` must not name the coordinator as the pauser: {text}",
args.join(" ")
);
assert!(
!text.contains("by an unknown process"),
"this coordinator reports the pid, so no report may say unknown: {text}",
);
}
h.ok(&["resume"]);
}
#[test]
fn a_pause_that_ends_while_the_coordinator_is_down_still_gives_the_time_back() {
let h = Harness::with_default_config("pausedown");
let holder = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "300",
]);
h.until("the first job operates", Duration::from_secs(45), || {
h.state_of(&holder) == "running"
});
h.ok(&["pause", "queue", "--for", "8s"]);
let id = h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--needs",
&holder,
"--max-queue-time",
"4s",
"--",
"true",
]);
let info = h.ok(&["info", "--no-start", "--json"]);
let info: serde_json::Value = serde_json::from_str(&info).unwrap();
let pid = info["pid"].as_i64().expect("the coordinator pid") as i32;
unsafe {
libc::kill(pid, libc::SIGKILL);
}
std::thread::sleep(Duration::from_secs(12));
let credited = h.status_json(&id)["queue_pause_secs"].as_u64().unwrap_or(0);
assert!(
credited >= 6,
"a pause that ended while no coordinator operated must still give the \
time back; got {credited} seconds"
);
assert!(
credited <= 10,
"the credit must be the length of the PAUSE, and not the time until \
the restart; got {credited} seconds"
);
h.ok(&["kill", &holder, "--grace", "1s"]);
}
#[test]
fn a_job_that_already_waited_learns_the_pause() {
let h = Harness::new(
"pausealready",
"[budget]
cpu = \"1\"
mem = \"1GB\"
\
[peers]
enabled = false
\
[system]
reserve_mem = \"0\"
max_pressure = 100
",
);
let holder = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "300",
]);
h.until("the first job operates", Duration::from_secs(45), || {
h.state_of(&holder) == "running"
});
let waiter = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
h.until(
"the second job has a reason",
Duration::from_secs(45),
|| !h.status_json(&waiter)["blocked_reason"].is_null(),
);
let before = h.status_json(&waiter)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
!before.contains("paused"),
"the job must wait for something other than a pause first: {before}"
);
h.ok(&["pause", "queue", "--reason", "recording a demo"]);
h.until("the job learns the pause", Duration::from_secs(30), || {
h.status_json(&waiter)["blocked_reason"]
.as_str()
.unwrap_or("")
.contains("the queue is paused")
});
let after = h.status_json(&waiter)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
after.contains("qex resume queue"),
"the reason must give the remedy: {after}"
);
h.ok(&["resume"]);
h.ok(&["kill", &holder, "--grace", "1s"]);
}
struct OomJob {
control: PathBuf,
}
impl OomJob {
fn new(h: &Harness) -> Self {
let control = h.root.join("control");
std::fs::create_dir_all(&control).unwrap();
Self { control }
}
fn release(&self, h: &Harness, id: &str) {
let dir = h.root.join("state/qex/jobs").join(id);
assert!(
dir.is_dir(),
"the job directory {} is missing",
dir.display()
);
std::fs::write(self.control.join("dir"), dir.to_string_lossy().as_bytes()).unwrap();
}
}
#[test]
fn a_job_that_a_user_killed_is_not_retried_and_teaches_the_learner_nothing() {
let h = Harness::new(
"userkill",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[budget]\ncpu = \"2\"\nmem = \"1GB\"\n",
);
let job = OomJob::new(&h);
let control = h.root.join("control");
let script = format!(
"until [ -f {c}/dir ]; do sleep 0.1; done; echo job > \"$(cat {c}/dir)/oom\"; \
echo ready > {c}/ready; sleep 60",
c = control.display()
);
let id = h.submit(&["submit", "--mem", "128MB", "--", "bash", "-c", &script]);
h.until("the job operates", Duration::from_secs(30), || {
h.state_of(&id) == "running"
});
job.release(&h, &id);
h.until("the job made the record", Duration::from_secs(30), || {
control.join("ready").exists()
});
h.ok(&["kill", &id, "--signal", "KILL", "--grace", "1s"]);
h.until("the job stops", Duration::from_secs(30), || {
h.status_json(&id)["state"]
.as_str()
.map(|s| s != "running" && s != "starting")
.unwrap_or(false)
});
let s = h.status_json(&id);
assert_eq!(s["state"], "killed", "a command stopped this job: {s}");
assert_eq!(s["attempts"], 1, "qex must not start the job again: {s}");
assert!(
s.get("oom_raises").is_none(),
"the record must hold no count of raises: {s}"
);
assert_eq!(
s["mem"].as_u64().unwrap(),
128 * 1024 * 1024,
"the claim must not change: {s}"
);
let store = h.root.join("state/qex/usage.json");
assert!(
!store.exists(),
"a job that a command stopped must teach the learner nothing, and the store holds: {}",
std::fs::read_to_string(&store).unwrap_or_default()
);
}
#[test]
fn no_shipped_word_promises_the_limit_that_went() {
let refused = [
("mem_overcommit", "the multiplier for a second memory limit"),
("use_systemd", "a coordinator that restarts for a cgroup"),
("on_oom", "the count of raises after a kill for memory"),
("GOMEMLIMIT", "a memory hint for Go"),
("max-old-space-size", "a heap limit for node"),
("mode = \"soft\"", "a mode that went"),
("mode = \"hard\"", "a mode that went"),
("lower bound", "a measurement that qex no longer writes"),
("lower-bound", "the same, in the words of the file"),
("cgroup of the job", "a cgroup that qex no longer makes"),
("raises the claim", "a correction that qex no longer makes"),
("raise the claim", "the same"),
("[retry]", "a section that went"),
("the new claim", "a claim that qex no longer makes"),
("claim that failed", "the same, in the words of a record"),
];
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let mut pages: Vec<std::path::PathBuf> = vec![
root.join("README.md"),
root.join("skills/qex/SKILL.md"),
root.join("CONTRIBUTING.md"),
root.join("SECURITY.md"),
root.join("src/help.rs"),
root.join("src/cli.rs"),
root.join("src/schema.rs"),
root.join("src/job.rs"),
];
let mut docs: Vec<std::path::PathBuf> = std::fs::read_dir(root.join("docs"))
.expect("the docs directory must be readable")
.map(|e| {
e.expect("each entry of the docs directory must be readable")
.path()
})
.filter(|p| p.extension().is_some_and(|x| x == "md"))
.collect();
docs.sort();
assert!(
docs.len() >= 6,
"the gate must read every page of docs/, and it found {}: {docs:?}",
docs.len()
);
pages.append(&mut docs);
for page in pages {
let text = std::fs::read_to_string(&page)
.unwrap_or_else(|e| panic!("the gate must read {}: {e}", page.display()));
for (word, what) in refused {
assert!(
!text.contains(word),
"{} names `{word}` ({what}). qex limits no job, so no shipped word may \
promise that it does.",
page.display()
);
}
}
}
#[test]
fn a_config_that_names_a_key_that_went_says_what_to_do() {
for (section, text, must_say, must_not_say) in [
(
"[enforce]",
"mem_overcommit = 1.5",
"Delete `mem_overcommit`",
None,
),
(
"[enforce]",
"use_systemd = true",
"Delete `use_systemd`",
None,
),
("[retry]", "on_oom = 2", "Delete `retry`", None),
("[enforce]", "mode = \"hard\"", "Use `cooperative`", None),
(
"[enforce]",
"mode = \"retry\"",
"Use `cooperative`",
Some("Delete `retry`"),
),
] {
let h = Harness::new("wentkey", &format!("{section}\n{text}\n"));
let out = h.qex(&["config", "show"]);
let said = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
assert_ne!(out.status.code(), Some(0), "qex must refuse: {said}");
assert!(
said.contains("does not limit a job"),
"the answer must say what qex does now, for `{section} {text}`: {said}"
);
assert!(
said.contains(must_say),
"the answer must give the step `{must_say}`, for `{section} {text}`: {said}"
);
if let Some(forbidden) = must_not_say {
assert!(
!said.contains(forbidden),
"the answer must not say `{forbidden}`, because the file holds no such \
key, for `{section} {text}`: {said}"
);
}
}
}
#[test]
fn config_show_gives_the_values_in_force_in_both_forms() {
for (name, mode, budget, reserve, peers, label) in [
(
"forcecoop",
"cooperative",
"75%",
"2GB",
true,
"mode: cooperative",
),
(
"forcealone",
"single-user",
"90%",
"512MB",
false,
"mode: single-user",
),
] {
let h = Harness::new(name, &format!("[enforce]\nmode = \"{mode}\"\n"));
let out = h.ok(&["config", "show", "--json"]);
let cfg: serde_json::Value =
serde_json::from_str(&out).unwrap_or_else(|e| panic!("{name}: {e}: {out}"));
assert_eq!(
cfg["budget"]["cpu"], budget,
"{mode}: the JSON form must give the budget in force: {out}"
);
assert_eq!(
cfg["budget"]["mem"], budget,
"{mode}: the JSON form must give the budget in force: {out}"
);
assert_eq!(
cfg["system"]["reserve_mem"], reserve,
"{mode}: the JSON form must give the reserve in force: {out}"
);
assert_eq!(
cfg["peers"]["enabled"], peers,
"{mode}: the JSON form must say if qex looks for peers: {out}"
);
let text = h.ok(&["config", "show"]);
assert!(
text.contains(label),
"{mode}: the text form must name the mode as `{label}`: {text}"
);
}
}
#[test]
fn a_rerun_takes_the_claim_of_the_specification_and_not_of_the_record() {
let h = Harness::new("rerunclaim", "[peers]\nenabled = false\n");
let first = h.submit(&["submit", "--cpu", "1", "--mem", "300MB", "--", "true"]);
h.ok(&["wait", &first, "--timeout", "60s"]);
let record = h.root.join(format!("state/qex/jobs/{first}/status.json"));
let text = std::fs::read_to_string(&record).expect("the record must be readable");
let mut value: serde_json::Value =
serde_json::from_str(&text).expect("the record must be JSON");
value["mem"] = serde_json::json!(2u64 << 30);
value["cpu"] = serde_json::json!(4);
value["claim_source"] = serde_json::json!("learned");
std::fs::write(&record, value.to_string()).expect("the record must be writable");
let back: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&record).expect("the record must be read"))
.expect("the record must be JSON");
assert_eq!(
back["mem"], 2147483648u64,
"this test needs a record whose claim is above the specification: {back}"
);
let out = h.ok(&["rerun", &first]);
let second = out.split_whitespace().last().unwrap().to_string();
h.ok(&["wait", &second, "--timeout", "60s"]);
let s = h.status_json(&second);
assert_eq!(
s["mem"], 314572800u64,
"the rerun must take the 300MB of the specification, and not the claim of the \
record: {s}"
);
assert_eq!(
s["cpu"], 1,
"the rerun must take the cores of the specification: {s}"
);
assert_eq!(
s["claim_source"], "explicit",
"the claim came from a person, so the record must not say that a job measured it: {s}"
);
}
#[test]
fn qex_makes_no_cgroup_for_a_job() {
let h = Harness::new(
"nocgroup",
"[peers]\nenabled = false\n[enforce]\nmode = \"single-user\"\n",
);
let id = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
h.ok(&["wait", &id, "--timeout", "60s"]);
let dir = h.root.join("state/qex/jobs").join(&id);
assert!(
!dir.join("cgroup").exists(),
"a job must have no cgroup file: {}",
dir.display()
);
let s = h.status_json(&id);
assert_eq!(s["state"], "completed", "got: {s}");
}
#[test]
fn no_hint_in_the_environment_of_a_job_carries_memory() {
let h = Harness::new("nomemhint", "[peers]\nenabled = false\n");
let id = h.submit(&[
"submit",
"--cpu",
"2",
"--mem",
"512MB",
"--",
"sh",
"-c",
"echo \"GOMAXPROCS=$GOMAXPROCS QEX_MEM=$QEX_MEM \
GOMEMLIMIT=[$GOMEMLIMIT] NODE_OPTIONS=[$NODE_OPTIONS]\"",
]);
h.ok(&["wait", &id, "--timeout", "60s"]);
let out = h.qex(&["logs", &id]);
let said = String::from_utf8_lossy(&out.stdout).to_string();
assert!(
said.contains("GOMAXPROCS=2"),
"a core hint must reach the job: {said}"
);
assert!(
said.contains("QEX_MEM=536870912"),
"the claim must reach the job as a value to read: {said}"
);
assert!(
said.contains("GOMEMLIMIT=[]"),
"qex must write no memory limit for Go: {said}"
);
assert!(
said.contains("NODE_OPTIONS=[]"),
"qex must write no heap limit for node: {said}"
);
}
#[test]
fn a_kill_for_memory_is_reported_and_qex_acts_on_it_in_no_way() {
let h = Harness::new(
"oomsession",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[budget]\ncpu = \"2\"\nmem = \"1GB\"\n",
);
let job = OomJob::new(&h);
let control = h.root.join("control");
let script = format!(
"until [ -f {c}/dir ]; do sleep 0.1; done; echo 1 > \"$(cat {c}/dir)/oom\"; \
kill -9 $$",
c = control.display()
);
let id = h.submit(&["submit", "--mem", "128MB", "--", "bash", "-c", &script]);
job.release(&h, &id);
let out = h.qex(&["wait", &id, "--timeout", "60s"]);
assert_eq!(out.status.code(), Some(99), "got: {out:?}");
let s = h.status_json(&id);
assert_eq!(s["state"], "oom", "got: {s}");
assert_eq!(s["attempts"], 1, "qex must not start the job again: {s}");
assert_eq!(
s["mem"].as_u64().unwrap(),
128 * 1024 * 1024,
"the claim must not change: {s}"
);
let note = s["error"].as_str().unwrap_or("");
assert!(
note.contains("no count that belongs to this job alone"),
"the record must say what qex holds: {note}"
);
assert!(
note.contains("THE CLAIM CAN BE CORRECT"),
"the record must not send a reader to raise a claim that was right: {note}"
);
assert!(
!note.contains("[enforce] mode"),
"no setting gives qex a count for one job, so the record must name none: {note}"
);
let store = h.root.join("state/qex/usage.json");
assert!(
!store.exists(),
"a kill for memory must teach the learner nothing, and the store holds: {}",
std::fs::read_to_string(&store).unwrap_or_default()
);
}
#[test]
fn a_mark_from_one_attempt_does_not_decide_the_next_attempt() {
let h = Harness::new(
"oommarks",
"[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[budget]\ncpu = \"2\"\nmem = \"1GB\"\n",
);
let c = h.root.join("control");
std::fs::create_dir_all(&c).unwrap();
let script = format!(
"n=$(cat {c}/n 2>/dev/null || echo 0); n=$((n+1)); echo $n > {c}/n; \
if [ $n -eq 1 ]; then trap 'exit 3' TERM; touch {c}/ready; sleep 60 & wait; fi; \
exit 0",
c = c.display()
);
let id = h.submit(&[
"submit",
"--retries",
"2",
"--mem",
"128MB",
"--cpu",
"1",
"--",
"bash",
"-c",
&script,
]);
h.until("attempt 1 operates", Duration::from_secs(30), || {
c.join("ready").exists()
});
h.ok(&["kill", &id, "--signal", "TERM", "--grace", "30s"]);
h.ok(&["wait", &id, "--timeout", "90s"]);
let s = h.status_json(&id);
assert_eq!(
s["state"], "completed",
"the mark of attempt 1 must not decide attempt 2: {s}"
);
assert_eq!(s["attempts"], 2, "one kill, then one run: {s}");
assert_eq!(
s["retries_left"], 1,
"attempt 1 must spend one `--retries` credit: {s}"
);
}
fn shared_peer_dir(tag: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("qxpeers-{}-{tag}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o1777)).unwrap();
dir
}
fn peer_config(dir: &Path, cpu: &str, max_bypass: u32) -> String {
format!(
"[budget]\ncpu = \"{cpu}\"\nmem = \"2GB\"\n\
[peers]\nenabled = true\ndir = \"{}\"\nstale_after = \"1h\"\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\nmax_bypass = {max_bypass}\n",
dir.display()
)
}
fn blocked_reason(h: &Harness, id: &str) -> String {
h.status_json(id)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string()
}
#[test]
fn a_job_that_another_user_holds_back_does_not_park_the_jobs_behind_it() {
let dir = shared_peer_dir("hol-a");
let config = peer_config(&dir, "4", 2);
let other = Harness::new("holpeerb", &config);
let mine = Harness::new("holpeera", &config);
let held = other.submit(&[
"submit", "--cpu", "3", "--mem", "64MB", "--", "sleep", "300",
]);
other.until(
"the other user's job starts",
Duration::from_secs(45),
|| other.state_of(&held) == "running",
);
let big = mine.submit(&["submit", "--cpu", "4", "--mem", "64MB", "--", "true"]);
mine.until(
"the job at the front names the other user",
Duration::from_secs(45),
|| blocked_reason(&mine, &big).contains("another user holds capacity"),
);
let a = mine.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
let b = mine.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
mine.until(
"the two small jobs behind the front of the queue run",
Duration::from_secs(60),
|| mine.state_of(&a) == "completed" && mine.state_of(&b) == "completed",
);
assert_eq!(mine.state_of(&big), "queued");
let reason = blocked_reason(&mine, &big);
assert!(
reason.contains("another user holds capacity") && reason.contains("no known end"),
"the reason must name the other user: {reason}"
);
assert!(
!reason.contains("front of the queue"),
"the job at the front must not read a position sentence: {reason}"
);
let info: serde_json::Value = serde_json::from_str(&mine.ok(&["info", "--json"])).unwrap();
assert_eq!(info["queue_state"], "waits-for-peer", "info: {info}");
assert!(
info["peer_cpu"].as_u64().unwrap_or(0) >= 3,
"qex info must report the cores of the other user: {info}"
);
other.ok(&["kill", &held, "--grace", "1s"]);
mine.qex(&["cancel", &big]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_job_behind_a_job_that_keeps_no_capacity_gets_its_own_reason() {
let dir = shared_peer_dir("hol-own");
let config = peer_config(&dir, "4", 2);
let other = Harness::new("holownb", &config);
let mine = Harness::new("holowna", &config);
let held = other.submit(&[
"submit", "--cpu", "3", "--mem", "64MB", "--", "sleep", "300",
]);
other.until(
"the other user's job starts",
Duration::from_secs(45),
|| other.state_of(&held) == "running",
);
let big = mine.submit(&[
"submit", "--cpu", "4", "--mem", "64MB", "--", "sleep", "300",
]);
mine.until(
"the job at the front names the other user",
Duration::from_secs(45),
|| blocked_reason(&mine, &big).contains("another user holds capacity"),
);
let a = mine.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "300",
]);
let b = mine.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "300",
]);
mine.until(
"the first job behind starts",
Duration::from_secs(45),
|| mine.state_of(&a) == "running",
);
mine.until(
"the second job behind gives a reason of its own",
Duration::from_secs(45),
|| !blocked_reason(&mine, &b).is_empty(),
);
let reason = blocked_reason(&mine, &b);
assert!(
reason.contains("another user holds capacity"),
"the job behind must learn the true cause: {reason}"
);
assert!(
!reason.contains("front of the queue"),
"a job behind a job that keeps no capacity must not read a position: {reason}"
);
other.ok(&["kill", &held, "--grace", "1s"]);
for id in [&big, &a, &b] {
mine.qex(&["kill", id, "--grace", "1s"]);
mine.qex(&["cancel", id]);
}
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_job_blocked_by_a_peer_becomes_unpassable_when_the_holder_changes() {
let dir = shared_peer_dir("hol-b");
let config = peer_config(&dir, "4", 2);
let other = Harness::new("holcarryb", &config);
let mine = Harness::new("holcarrya", &config);
let held = other.submit(&[
"submit", "--cpu", "2", "--mem", "64MB", "--", "sleep", "300",
]);
other.until(
"the other user's job starts",
Duration::from_secs(45),
|| other.state_of(&held) == "running",
);
let big = mine.submit(&[
"submit", "--cpu", "3", "--mem", "64MB", "--", "sleep", "300",
]);
mine.until(
"the job at the front names the other user",
Duration::from_secs(45),
|| blocked_reason(&mine, &big).contains("another user holds capacity"),
);
assert_eq!(
mine.status_json(&big)["passed_by"].as_u64(),
Some(0),
"no job passed it yet"
);
let small: Vec<String> = (0..3)
.map(|_| {
mine.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "300",
])
})
.collect();
mine.until(
"two small jobs pass the job at the front",
Duration::from_secs(60),
|| mine.state_of(&small[0]) == "running" && mine.state_of(&small[1]) == "running",
);
mine.until(
"the queue keeps the capacity",
Duration::from_secs(45),
|| {
let info: serde_json::Value =
serde_json::from_str(&mine.ok(&["info", "--json"])).unwrap_or_default();
info["queue_state"] == "held"
},
);
assert_eq!(
mine.status_json(&big)["passed_by"].as_u64(),
Some(2),
"the count must carry across the change of the holder, and not start again"
);
other.ok(&["kill", &held, "--grace", "1s"]);
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
assert_eq!(
mine.state_of(&small[2]),
"queued",
"a small job passed the job at the front after the other user released"
);
std::thread::sleep(Duration::from_millis(200));
}
assert_eq!(
mine.status_json(&big)["passed_by"].as_u64(),
Some(2),
"the release of the other user must not reset the count"
);
let reason = blocked_reason(&mine, &small[2]);
assert!(
reason.contains("qex keeps the capacity for that job"),
"the job behind must read WHY qex holds it: {reason}"
);
assert!(
blocked_reason(&mine, &big).contains("qex starts no other job before this one"),
"the job at the front must say that it keeps the capacity"
);
mine.ok(&["kill", &small[0], "--grace", "1s"]);
mine.until(
"the job at the front starts",
Duration::from_secs(45),
|| mine.has_started(&big),
);
for id in &small {
mine.qex(&["kill", id, "--grace", "1s"]);
mine.qex(&["cancel", id]);
}
mine.qex(&["kill", &big, "--grace", "1s"]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_stream_of_small_jobs_does_not_pass_a_large_job_for_ever() {
let h = Harness::new(
"holstarve",
"[budget]\ncpu = \"4\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\nmax_bypass = 2\n",
);
let holder = h.submit(&[
"submit", "--cpu", "2", "--mem", "64MB", "--", "sleep", "300",
]);
h.until("the first job starts", Duration::from_secs(45), || {
h.state_of(&holder) == "running"
});
let big = h.submit(&["submit", "--cpu", "4", "--mem", "64MB", "--", "true"]);
let small: Vec<String> = (0..6)
.map(|_| h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]))
.collect();
let done = |h: &Harness| -> usize { small.iter().filter(|id| h.has_started(id)).count() };
h.until(
"two small jobs pass the large job",
Duration::from_secs(60),
|| done(&h) >= 2,
);
let deadline = Instant::now() + Duration::from_secs(4);
while Instant::now() < deadline {
assert!(
done(&h) <= 2,
"more than 2 small jobs passed the large job, so the bypass has no bound"
);
std::thread::sleep(Duration::from_millis(200));
}
assert_eq!(h.state_of(&big), "queued");
let info: serde_json::Value = serde_json::from_str(&h.ok(&["info", "--json"])).unwrap();
assert!(
info["last_start_at"].as_u64().is_some(),
"qex info must report the time of the last start: {info}"
);
h.ok(&["kill", &holder, "--grace", "1s"]);
h.until("the large job runs", Duration::from_secs(45), || {
h.state_of(&big) == "completed"
});
h.until("each small job runs", Duration::from_secs(60), || {
done(&h) == 6
});
let record = h.status_json(&big);
assert_eq!(
record["passed_by"].as_u64(),
Some(0),
"a job that started waits for nothing: {record}"
);
assert!(
record["blocked_since"].is_null(),
"a job that started holds no blocked_since: {record}"
);
}
#[test]
fn max_bypass_zero_lets_no_job_pass_the_front_of_the_queue() {
let h = Harness::new(
"holstrict",
"[budget]\ncpu = \"4\"\nmem = \"2GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\nmax_bypass = 0\n",
);
let holder = h.submit(&[
"submit", "--cpu", "2", "--mem", "64MB", "--", "sleep", "300",
]);
h.until("the first job starts", Duration::from_secs(45), || {
h.state_of(&holder) == "running"
});
let big = h.submit(&["submit", "--cpu", "4", "--mem", "64MB", "--", "true"]);
let small: Vec<String> = (0..3)
.map(|_| h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]))
.collect();
h.until("the queue is held", Duration::from_secs(45), || {
let info: serde_json::Value =
serde_json::from_str(&h.ok(&["info", "--json"])).unwrap_or_default();
info["queue_state"] == "held"
});
let deadline = Instant::now() + Duration::from_secs(4);
while Instant::now() < deadline {
for id in &small {
assert_eq!(
h.state_of(id),
"queued",
"with max_bypass = 0 no job may pass the job at the front"
);
}
std::thread::sleep(Duration::from_millis(200));
}
assert_eq!(
h.status_json(&big)["passed_by"].as_u64(),
Some(0),
"no job passed the job at the front"
);
h.ok(&["kill", &holder, "--grace", "1s"]);
h.until("each job runs", Duration::from_secs(60), || {
h.state_of(&big) == "completed" && small.iter().all(|id| h.has_started(id))
});
}
#[test]
fn a_job_that_the_config_parks_does_not_park_the_jobs_behind_it() {
let h = Harness::new(
"holparked",
"[budget]\ncpu = \"2\"\nmem = \"1GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[queue]\noversized = \"queue\"\n",
);
let big = h.submit(&["submit", "--cpu", "64", "--mem", "64MB", "--", "true"]);
let behind: Vec<String> = (0..3)
.map(|_| h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]))
.collect();
h.until(
"every job behind the large job runs",
Duration::from_secs(60),
|| behind.iter().all(|id| h.state_of(id) == "completed"),
);
assert_eq!(h.state_of(&big), "queued");
let reason = blocked_reason(&h, &big);
assert!(
reason.contains("keeps this job in the queue"),
"the reason must name the config file: {reason}"
);
assert!(
reason.contains("starts the jobs behind it"),
"the reason must say that the queue continues: {reason}"
);
}
fn pool_config(devices: &str, extra: &str) -> String {
format!(
"[budget]\ncpu = \"8\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[[pool]]\nname = \"gpu\"\nsize = \"vram\"\ndevices = [{devices}]\n\
env = \"CUDA_VISIBLE_DEVICES\"\n{extra}"
)
}
#[test]
fn a_machine_with_no_gpu_schedules_a_gpu_claim_from_the_configuration() {
let h = Harness::new("gpupromise", &pool_config("\"24GB\", \"24GB\"", ""));
let id = h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--gpu",
"1",
"--",
"sh",
"-c",
"echo cuda=$CUDA_VISIBLE_DEVICES; echo qex=$QEX_GPU_DEVICES; echo vram=$QEX_GPU_VRAM",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
assert_eq!(h.state_of(&id), "completed");
let out = h.ok(&["logs", &id, "--stdout"]);
assert!(
out.contains("cuda=0"),
"the pool must write its variable: {out}"
);
assert!(
out.contains("qex=0"),
"every indexed pool gets QEX_..._DEVICES: {out}"
);
assert!(
out.contains(&format!("vram={}", 24u64 << 30)),
"a whole device gives its capacity: {out}"
);
let status = h.status_json(&id);
assert_eq!(status["assigned"]["gpu"]["devices"][0], 0);
assert_eq!(status["assigned"]["gpu"]["units"], 1);
}
#[test]
fn two_gpu_jobs_get_different_devices_and_a_third_waits() {
let h = Harness::new("gpushare", &pool_config("\"24GB\", \"24GB\"", ""));
let ids: Vec<String> = (0..3)
.map(|i| {
h.submit(&[
"submit",
"--cpu",
"1",
"--mem",
"64MB",
"--gpu",
"1",
"--name",
&format!("g{i}"),
"--",
"sleep",
"60",
])
})
.collect();
h.until("two GPU jobs operate", Duration::from_secs(45), || {
ids.iter().filter(|id| h.state_of(id) == "running").count() == 2
});
let running: Vec<serde_json::Value> = ids
.iter()
.map(|id| h.status_json(id))
.filter(|s| s["state"] == "running")
.collect();
let mut devices: Vec<i64> = running
.iter()
.map(|s| s["assigned"]["gpu"]["devices"][0].as_i64().unwrap())
.collect();
devices.sort_unstable();
assert_eq!(
devices,
vec![0, 1],
"two jobs must hold two different devices"
);
let waiting = ids
.iter()
.find(|id| h.state_of(id) == "queued")
.expect("one job must wait");
let reason = h.status_json(waiting)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(
reason.contains("gpu"),
"the reason must name the pool: {reason}"
);
for id in &ids {
h.qex(&["kill", id, "--grace", "1s"]);
}
}
#[test]
fn a_vram_claim_above_the_largest_device_is_refused_at_the_submission() {
let h = Harness::new(
"vramsum",
&pool_config("\"24GB\", \"24GB\", \"24GB\", \"24GB\"", ""),
);
let out = h.qex(&[
"submit", "--cpu", "1", "--mem", "64MB", "--gpu", "2", "--vram", "40GB", "--", "true",
]);
assert!(!out.status.success(), "qex must refuse this job");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("never start"),
"the message must say that the job can never start: {err}"
);
assert!(
err.contains("24GB"),
"the message must name the largest device: {err}"
);
assert!(
h.list_json().is_empty(),
"a refused job must leave no record"
);
}
#[test]
fn a_gpu_claim_above_the_pool_total_is_refused_whatever_the_oversized_policy() {
let h = Harness::new(
"gputoobig",
&pool_config(
"\"24GB\", \"24GB\"",
"[queue]\noversized = \"run-when-idle\"\n",
),
);
let out = h.qex(&[
"submit", "--cpu", "1", "--mem", "64MB", "--gpu", "8", "--", "true",
]);
assert!(!out.status.success(), "qex must refuse this job");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("never start"), "got: {err}");
}
#[test]
fn a_gpu_assignment_survives_a_coordinator_that_stops_and_starts() {
let h = Harness::new("gpurecover", &pool_config("\"24GB\"", ""));
let long = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--gpu", "1", "--", "sleep", "60",
]);
h.until("the GPU job operates", Duration::from_secs(45), || {
h.state_of(&long) == "running"
});
let device = h.status_json(&long)["assigned"]["gpu"]["devices"][0]
.as_i64()
.unwrap();
let pid = h.coordinator_pid();
unsafe {
libc::kill(pid, libc::SIGKILL);
}
h.until("the coordinator stops", Duration::from_secs(20), || {
(unsafe { libc::kill(pid, 0) }) != 0
});
let second = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--gpu", "1", "--", "true",
]);
assert_ne!(h.coordinator_pid(), pid, "a new coordinator must operate");
assert_eq!(
h.status_json(&long)["assigned"]["gpu"]["devices"][0]
.as_i64()
.unwrap(),
device,
"the assignment must not change when a coordinator restarts"
);
std::thread::sleep(Duration::from_secs(2));
assert_eq!(
h.state_of(&second),
"queued",
"the only device is in use, so the second job must wait"
);
h.qex(&["kill", &long, "--grace", "1s"]);
}
#[test]
fn a_lock_needs_no_configuration_and_still_excludes() {
let h = Harness::new(
"lockstill",
"[budget]\ncpu = \"8\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n",
);
let first = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--lock", "target", "--", "sleep", "60",
]);
h.until("the first job operates", Duration::from_secs(45), || {
h.state_of(&first) == "running"
});
let second = h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--lock", "target", "--", "true",
]);
std::thread::sleep(Duration::from_secs(2));
assert_eq!(
h.state_of(&second),
"queued",
"two jobs with one lock name must never operate together"
);
let reason = h.status_json(&second)["blocked_reason"]
.as_str()
.unwrap_or("")
.to_string();
assert!(reason.contains("lock `target`"), "got: {reason}");
let free = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
h.ok(&["wait", &free, "--timeout", "45s"]);
assert_eq!(h.state_of(&free), "completed");
h.qex(&["kill", &first, "--grace", "1s"]);
h.ok(&["wait", &second, "--timeout", "45s"]);
assert_eq!(h.state_of(&second), "completed");
}
#[test]
fn a_counted_pool_lets_n_jobs_operate_and_makes_the_next_one_wait() {
let h = Harness::new(
"netpool",
"[budget]\ncpu = \"8\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[[pool]]\nname = \"net\"\ncount = 2\n",
);
let ids: Vec<String> = (0..3)
.map(|_| {
h.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--claim", "net=1", "--", "sleep", "60",
])
})
.collect();
h.until(
"two jobs of the pool operate",
Duration::from_secs(45),
|| ids.iter().filter(|id| h.state_of(id) == "running").count() == 2,
);
std::thread::sleep(Duration::from_secs(2));
assert_eq!(
ids.iter().filter(|id| h.state_of(id) == "running").count(),
2,
"a pool of 2 must never hold 3 jobs"
);
for id in &ids {
h.qex(&["kill", id, "--grace", "1s"]);
}
}
#[test]
fn an_undeclared_pool_is_a_lock_and_two_units_of_it_are_refused() {
let h = Harness::with_default_config("undeclared");
let one = h.submit(&["submit", "--claim", "thing=1", "--", "true"]);
h.ok(&["wait", &one, "--timeout", "45s"]);
assert_eq!(h.state_of(&one), "completed");
let out = h.qex(&["submit", "--claim", "thing=2", "--", "true"]);
assert!(
!out.status.success(),
"qex must refuse 2 of an undeclared pool"
);
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("lock of size 1"), "got: {err}");
assert!(
err.contains("[[pool]]"),
"the message must give the remedy: {err}"
);
}
#[test]
fn a_pool_with_no_devices_gives_the_job_its_count() {
let h = Harness::new(
"netenv",
"[budget]\ncpu = \"8\"\nmem = \"4GB\"\n\
[peers]\nenabled = false\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[[pool]]\nname = \"net\"\ncount = 4\n",
);
let plain = h.submit(&["submit", "--", "sh", "-c", "echo net=[$QEX_CLAIM_NET]"]);
h.ok(&["wait", &plain, "--timeout", "45s"]);
assert!(
h.ok(&["logs", &plain, "--stdout"]).contains("net=[]"),
"a job with no claim must get no variable"
);
let id = h.submit(&[
"submit",
"--claim",
"net=2",
"--",
"sh",
"-c",
"echo net=[$QEX_CLAIM_NET]",
]);
h.ok(&["wait", &id, "--timeout", "45s"]);
assert_eq!(h.state_of(&id), "completed");
let out = h.ok(&["logs", &id, "--stdout"]);
assert!(
out.contains("net=[2]"),
"a counted claim must reach the job: {out}"
);
}
#[test]
fn a_pool_claim_that_is_refused_gives_the_dedupe_key_back() {
let h = Harness::new("pooldedupe", &pool_config("\"24GB\", \"24GB\"", ""));
let out = h.qex(&[
"submit",
"--dedupe-key",
"poolkey",
"--gpu",
"8",
"--",
"true",
]);
assert!(
!out.status.success(),
"a claim of 8 of a pool of 2 must be refused"
);
let id = h.submit(&["submit", "--dedupe-key", "poolkey", "--", "true"]);
h.ok(&["wait", &id, "--timeout", "45s"]);
assert_eq!(
h.state_of(&id),
"completed",
"the key of a refused job must be free"
);
}
#[test]
fn a_job_that_sets_the_device_variable_itself_is_refused() {
let h = Harness::new("cudaconflict", &pool_config("\"24GB\"", ""));
let out = h.qex(&[
"submit",
"--gpu",
"1",
"--env",
"CUDA_VISIBLE_DEVICES=0",
"--",
"true",
]);
assert!(!out.status.success(), "qex must refuse this job");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("would disagree"), "got: {err}");
}
#[test]
fn two_users_do_not_both_get_the_one_device() {
let dir = shared_peer_dir("gpu-share");
let config = format!(
"[budget]\ncpu = \"8\"\nmem = \"4GB\"\n\
[peers]\nenabled = true\ndir = \"{}\"\nstale_after = \"1h\"\n\
[system]\nreserve_mem = \"0\"\nmax_pressure = 100\n\
[[pool]]\nname = \"gpu\"\nsize = \"vram\"\ndevices = [\"24GB\"]\n\
env = \"CUDA_VISIBLE_DEVICES\"\n",
dir.display()
);
let other = Harness::new("gpupeerb", &config);
let mine = Harness::new("gpupeera", &config);
let held = other.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--gpu", "1", "--", "sleep", "300",
]);
other.until(
"the other user's GPU job starts",
Duration::from_secs(45),
|| other.state_of(&held) == "running",
);
assert_eq!(
other.status_json(&held)["assigned"]["gpu"]["devices"][0],
0,
"the other user must hold the device 0"
);
let info: serde_json::Value = serde_json::from_str(&mine.ok(&["info", "--json"])).unwrap();
assert_eq!(info["jobs_running"], 0, "my queue must be empty: {info}");
let want = mine.submit(&[
"submit", "--cpu", "1", "--mem", "64MB", "--gpu", "1", "--", "true",
]);
mine.until(
"the GPU job names the other user",
Duration::from_secs(45),
|| blocked_reason(&mine, &want).contains("another user holds the pool"),
);
assert_eq!(mine.state_of(&want), "queued");
assert!(
mine.status_json(&want)["assigned"]["gpu"].is_null(),
"a job that waits has received no device: {}",
mine.status_json(&want)
);
let reason = blocked_reason(&mine, &want);
assert!(
reason.contains("`gpu`") && reason.contains("no known end"),
"the reason must name the pool and say that qex cannot schedule the end: {reason}"
);
assert!(
!reason.contains("front of the queue"),
"a wait on another user must not read as a queue position: {reason}"
);
let behind = mine.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "true"]);
mine.until(
"a job behind the GPU job still starts",
Duration::from_secs(45),
|| mine.state_of(&behind) == "completed",
);
let info: serde_json::Value = serde_json::from_str(&mine.ok(&["info", "--json"])).unwrap();
assert_eq!(info["queue_state"], "waits-for-peer", "info: {info}");
assert_eq!(
info["pools"][0]["devices"][0]["peer"], true,
"qex info must mark the device that another user holds: {info}"
);
other.ok(&["kill", &held, "--grace", "1s"]);
mine.until("the device comes free", Duration::from_secs(60), || {
mine.state_of(&want) == "completed"
});
assert_eq!(
mine.status_json(&want)["assigned"]["gpu"]["devices"][0],
0,
"the one device must go to this user once the other user stops"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn info_reports_the_pools_and_their_devices() {
let h = Harness::new("poolinfo", &pool_config("\"24GB\", \"16GB\"", ""));
let text = h.ok(&["info", "--json"]);
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
let pools = v["pools"].as_array().expect("info must report the pools");
assert_eq!(pools.len(), 1);
assert_eq!(pools[0]["name"], "gpu");
assert_eq!(pools[0]["total"], 2);
assert_eq!(pools[0]["devices"][1]["capacity"], 16u64 << 30);
let human = h.ok(&["info"]);
assert!(human.contains("pool gpu"), "got: {human}");
}
struct OpenSockets(Vec<libc::c_int>);
impl Drop for OpenSockets {
fn drop(&mut self) {
for fd in self.0.drain(..) {
unsafe { libc::close(fd) };
}
}
}
fn a_socket_that_never_answers(path: &Path) -> Option<OpenSockets> {
use std::os::unix::ffi::OsStrExt;
let bytes = path.as_os_str().as_bytes();
let mut address: libc::sockaddr_un = unsafe { std::mem::zeroed() };
assert!(
bytes.len() < address.sun_path.len(),
"the path of the test socket is too long"
);
address.sun_family = libc::AF_UNIX as libc::sa_family_t;
for (slot, byte) in address.sun_path.iter_mut().zip(bytes) {
*slot = *byte as libc::c_char;
}
let size = std::mem::size_of::<libc::sockaddr_un>() as libc::socklen_t;
let target = &address as *const libc::sockaddr_un as *const libc::sockaddr;
let mut open = Vec::new();
let can_wait = unsafe {
let listener = libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0);
assert!(listener >= 0, "the test cannot open a socket");
open.push(listener);
assert_eq!(
libc::bind(listener, target, size),
0,
"the test cannot bind {}",
path.display()
);
assert_eq!(libc::listen(listener, 1), 0, "the test cannot listen");
let mut full = false;
for _ in 0..256 {
let client = libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0);
assert!(client >= 0, "the test cannot open a socket");
let flags = libc::fcntl(client, libc::F_GETFL);
libc::fcntl(client, libc::F_SETFL, flags | libc::O_NONBLOCK);
if libc::connect(client, target, size) != 0 {
let error = std::io::Error::last_os_error().raw_os_error();
libc::close(client);
full = matches!(error, Some(libc::EAGAIN) | Some(libc::EINPROGRESS));
break;
}
open.push(client);
}
full
};
let sockets = OpenSockets(open);
if !can_wait {
return None;
}
Some(sockets)
}
fn a_held_pid_file(path: &Path) -> std::fs::File {
use std::io::Write as _;
use std::os::unix::io::AsRawFd;
let mut file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)
.unwrap();
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
assert_eq!(rc, 0, "the test cannot lock the pid file");
write!(file, "{}", std::process::id()).unwrap();
file.flush().unwrap();
file
}
#[test]
fn a_socket_that_never_answers_does_not_stop_a_command() {
let mut h = Harness::with_default_config("stucksock");
let tmp = h.root.join("t");
std::fs::create_dir_all(&tmp).unwrap();
let uid = unsafe { libc::getuid() };
let stuck = tmp.join(format!("qex-{uid}-stuck"));
std::fs::create_dir_all(&stuck).unwrap();
let Some(_open) = a_socket_that_never_answers(&stuck.join("s")) else {
eprintln!(
"this test did not run: this system gives a refusal, and not a wait, for a \
socket with a full queue"
);
return;
};
h.extra_env
.push(("TMPDIR".into(), tmp.display().to_string()));
let out = h.qex(&["info", "--json"]);
let deadline = Instant::now() + Duration::from_secs(5);
let mut survived = true;
while Instant::now() < deadline {
if !stuck.exists() {
survived = false;
break;
}
std::thread::sleep(Duration::from_millis(50));
}
assert!(
out.status.success(),
"the command failed: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
survived,
"the sweep deleted the directory of a coordinator that operates"
);
}
#[test]
fn a_coordinator_holds_the_lock_on_its_pid_file() {
use std::os::unix::io::AsRawFd;
let h = Harness::with_default_config("pidfile");
let info: serde_json::Value = serde_json::from_str(&h.ok(&["info", "--json"])).unwrap();
let pid = info["pid"].as_i64().expect("info must give the pid");
let path = h.root.join("state/qex/run/pid");
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.expect("the coordinator must make a pid file beside its socket");
let taken = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
if taken == 0 {
unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN) };
}
assert_ne!(
taken, 0,
"the coordinator must hold the lock on its pid file while it operates"
);
let written = std::fs::read_to_string(&path).unwrap();
assert_eq!(
written.trim().parse::<i64>().unwrap(),
pid,
"the pid file must name the coordinator that operates"
);
}
#[test]
fn a_socket_with_no_answer_stops_a_new_coordinator() {
let mut h = Harness::with_default_config("ownstuck");
h.extra_env.push(("QEX_IDLE_EXIT_SECS".into(), "1".into()));
let run = h.root.join("state/qex/run");
std::fs::create_dir_all(&run).unwrap();
let socket = run.join("s");
let Some(_open) = a_socket_that_never_answers(&socket) else {
eprintln!(
"this test did not run: this system gives a refusal, and not a wait, for a \
socket with a full queue"
);
return;
};
let before = std::fs::symlink_metadata(&socket).unwrap();
let out = h.qex(&["daemon"]);
let after = std::fs::symlink_metadata(&socket);
assert!(
out.status.success(),
"the coordinator must stop with no fault: {}",
String::from_utf8_lossy(&out.stderr)
);
let after = after.expect("the coordinator deleted a socket that it could not test");
assert_eq!(
std::os::unix::fs::MetadataExt::ino(&before),
std::os::unix::fs::MetadataExt::ino(&after),
"the coordinator deleted the socket of a different coordinator and bound its own"
);
}
#[test]
fn a_symbolic_link_with_no_target_does_not_stop_a_coordinator() {
let h = Harness::with_default_config("danglink");
let run = h.root.join("state/qex/run");
std::fs::create_dir_all(&run).unwrap();
std::os::unix::fs::symlink(h.root.join("no-such-target"), run.join("s")).unwrap();
let info: serde_json::Value = serde_json::from_str(&h.ok(&["info", "--json"])).unwrap();
assert!(
info["pid"].as_i64().unwrap_or(0) > 0,
"a coordinator must start when a link with no target holds the socket path"
);
assert!(
std::os::unix::fs::FileTypeExt::is_socket(
&std::fs::symlink_metadata(run.join("s"))
.unwrap()
.file_type()
),
"the coordinator must replace the link with its own socket"
);
}
#[test]
fn a_pid_file_that_qex_cannot_open_names_the_way_out() {
use std::os::unix::fs::PermissionsExt;
if unsafe { libc::getuid() } == 0 {
return;
}
let mut h = Harness::with_default_config("badpid");
h.extra_env.push(("QEX_IDLE_EXIT_SECS".into(), "1".into()));
let run = h.root.join("state/qex/run");
std::fs::create_dir_all(&run).unwrap();
let pid = run.join("pid");
std::fs::write(&pid, b"").unwrap();
std::fs::set_permissions(&pid, std::fs::Permissions::from_mode(0o000)).unwrap();
let out = h.qex(&["daemon"]);
let made_a_socket = run.join("s").exists();
std::fs::set_permissions(&pid, std::fs::Permissions::from_mode(0o600)).unwrap();
assert!(
out.status.success(),
"the coordinator must stop with no fault: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!made_a_socket,
"qex cannot test the file, so it must not start a second coordinator"
);
let log = String::from_utf8_lossy(&out.stdout);
assert!(
log.contains(&pid.display().to_string()),
"the message must name the file, and it says: {log}"
);
assert!(
log.contains("Delete that file if no coordinator operates"),
"the message must name the one step that clears this state, and it says: {log}"
);
}
#[test]
fn a_socket_that_answers_stops_a_new_coordinator() {
let h = Harness::with_default_config("ownanswer");
let info: serde_json::Value = serde_json::from_str(&h.ok(&["info", "--json"])).unwrap();
let first = info["pid"].as_i64().expect("info must give the pid");
let run = h.root.join("state/qex/run");
std::fs::remove_file(run.join("pid")).unwrap();
let out = h.qex(&["daemon"]);
assert!(
out.status.success(),
"the coordinator must stop with no fault: {}",
String::from_utf8_lossy(&out.stderr)
);
let log = String::from_utf8_lossy(&out.stdout);
assert!(
log.contains("a different coordinator operates"),
"the log must say that a different coordinator operates, and it says: {log}"
);
let after: serde_json::Value = serde_json::from_str(&h.ok(&["info", "--json"])).unwrap();
assert_eq!(
after["pid"].as_i64(),
Some(first),
"a second coordinator took the queue of the first one"
);
}
#[test]
fn a_lock_with_no_socket_file_stops_a_new_coordinator() {
let mut h = Harness::with_default_config("nosocket");
h.extra_env.push(("QEX_IDLE_EXIT_SECS".into(), "1".into()));
let run = h.root.join("state/qex/run");
std::fs::create_dir_all(&run).unwrap();
let socket = run.join("s");
let held = a_held_pid_file(&run.join("pid"));
let out = h.qex(&["daemon"]);
let made_a_socket = socket.exists();
drop(held);
assert!(
out.status.success(),
"the coordinator must stop with no fault: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!made_a_socket,
"a second coordinator started and bound a socket while a different process \
held the lock"
);
let log = String::from_utf8_lossy(&out.stdout);
assert!(
log.contains("a different coordinator operates"),
"the log must say that a different coordinator operates, and it says: {log}"
);
}
#[test]
fn a_locked_pid_file_stops_a_new_coordinator() {
let mut h = Harness::with_default_config("ownpid");
h.extra_env.push(("QEX_IDLE_EXIT_SECS".into(), "1".into()));
let run = h.root.join("state/qex/run");
std::fs::create_dir_all(&run).unwrap();
let socket = run.join("s");
std::fs::write(&socket, b"").unwrap();
let held = a_held_pid_file(&run.join("pid"));
let out = h.qex(&["daemon"]);
let kind = std::fs::symlink_metadata(&socket).map(|m| m.file_type());
drop(held);
assert!(
out.status.success(),
"the coordinator must stop with no fault: {}",
String::from_utf8_lossy(&out.stderr)
);
let kind = kind.expect("the coordinator deleted the socket of a process that operates");
assert!(
!std::os::unix::fs::FileTypeExt::is_socket(&kind),
"the coordinator bound its own socket while a different process holds the lock"
);
}
#[test]
fn a_message_names_what_a_command_wrote() {
assert_eq!(describe_stream("stdout", b""), "stdout: no output");
assert_eq!(describe_stream("stderr", b"\n\n\n"), "stderr: no output");
assert_eq!(
describe_stream("stdout", b"one\ntwo\n"),
"stdout:\none\ntwo"
);
let many: String = (1..=50).map(|i| format!("line-{i}\n")).collect();
let text = describe_stream("stdout", many.as_bytes());
assert!(
text.starts_with("stdout: the last 20 lines of 50, and 30 more above:"),
"the message must name both counts: {text}"
);
let lines: Vec<&str> = text.lines().skip(1).collect();
assert_eq!(lines.len(), 20, "the message must hold 20 lines: {text}");
assert_eq!(lines[0], "line-31", "the first line kept: {text}");
assert_eq!(lines[19], "line-50", "the last line kept: {text}");
assert!(
!text.contains("line-30\n"),
"a line above the last 20 must not appear: {text}"
);
}
#[test]
fn a_kill_of_qex_run_cancels_a_job_that_never_started() {
let h = Harness::new("runkill", "[budget]\ncpu = \"1\"\n");
let blocker = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "30"]);
h.until("the blocker operates", Duration::from_secs(30), || {
h.state_of(&blocker) == "running"
});
let (mut child, id) = h.run_bg(&["--cpu", "1", "--mem", "64MB", "--", "sleep", "5"]);
h.until("the job of qex run waits", Duration::from_secs(30), || {
h.state_of(&id) == "queued"
});
unsafe { libc::kill(child.id() as i32, libc::SIGKILL) };
child.wait().unwrap();
h.until(
"the coordinator cancels the job of a command that stopped",
Duration::from_secs(30),
|| h.state_of(&id) == "cancelled",
);
let status = h.status_json(&id);
let error = status["error"].as_str().unwrap_or("");
assert!(
error.contains("the command that started this job stopped"),
"the record must say why qex cancelled the job: {status}"
);
h.ok(&["kill", &blocker]);
}
#[test]
fn a_kill_of_qex_run_leaves_a_job_that_operates() {
let h = Harness::new("runkillrun", "");
let (mut child, id) = h.run_bg(&["--cpu", "1", "--mem", "64MB", "--", "sleep", "12"]);
h.until("the job operates", Duration::from_secs(30), || {
h.state_of(&id) == "running"
});
unsafe { libc::kill(child.id() as i32, libc::SIGKILL) };
child.wait().unwrap();
std::thread::sleep(Duration::from_secs(3));
let state = h.state_of(&id);
assert!(
state == "running" || state == "completed",
"a job that operates must continue when its reader stops, and it is `{state}`"
);
h.ok(&["kill", &id]);
}
#[test]
fn a_kill_of_submit_with_a_wait_leaves_the_job_in_the_queue() {
let h = Harness::new("subwaitkill", "[budget]\ncpu = \"1\"\n");
let blocker = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "30"]);
h.until("the blocker operates", Duration::from_secs(30), || {
h.state_of(&blocker) == "running"
});
let id_file = h.root.join("subwait.id");
let id_path = id_file.to_str().unwrap().to_string();
let mut child = h.spawn(&[
"submit",
"--wait",
"--id-file",
&id_path,
"--cpu",
"1",
"--mem",
"64MB",
"--",
"sleep",
"5",
]);
let deadline = Instant::now() + Duration::from_secs(30);
let id = loop {
if let Ok(text) = std::fs::read_to_string(&id_file) {
let id = text.trim().to_string();
if id.parse::<uuid::Uuid>().is_ok() {
break id;
}
}
assert!(Instant::now() < deadline, "submit --wait wrote no id");
std::thread::sleep(Duration::from_millis(100));
};
h.until("the job waits", Duration::from_secs(30), || {
h.state_of(&id) == "queued"
});
unsafe { libc::kill(child.id() as i32, libc::SIGKILL) };
child.wait().unwrap();
std::thread::sleep(Duration::from_secs(3));
assert_eq!(
h.state_of(&id),
"queued",
"a job of `qex submit --wait` must wait for a reader that attaches again"
);
h.ok(&["cancel", &id]);
h.ok(&["kill", &blocker]);
}
#[test]
fn the_ownership_of_a_job_survives_a_new_coordinator() {
let h = Harness::new("ownagain", "[budget]\ncpu = \"1\"\n");
let blocker = h.submit(&["submit", "--cpu", "1", "--mem", "64MB", "--", "sleep", "60"]);
h.until("the blocker operates", Duration::from_secs(30), || {
h.state_of(&blocker) == "running"
});
let (mut child, id) = h.run_bg(&["--cpu", "1", "--mem", "64MB", "--", "sleep", "5"]);
h.until("the job of qex run waits", Duration::from_secs(30), || {
h.state_of(&id) == "queued"
});
let info: serde_json::Value =
serde_json::from_str(&h.ok(&["info", "--no-start", "--json"])).unwrap();
let pid = info["pid"].as_i64().unwrap() as i32;
unsafe { libc::kill(pid, libc::SIGKILL) };
let killed_at = Instant::now();
h.until("a new coordinator answers", Duration::from_secs(40), || {
h.state_of(&id) == "queued" && {
let fresh: serde_json::Value =
serde_json::from_str(&h.ok(&["info", "--json"])).unwrap();
fresh["pid"].as_i64().unwrap() as i32 != pid
}
});
let new_coordinator_took = killed_at.elapsed();
std::thread::sleep(Duration::from_secs(3));
unsafe { libc::kill(child.id() as i32, libc::SIGKILL) };
let said = {
use std::io::Read as _;
let mut text = String::new();
if let Some(mut pipe) = child.stderr.take() {
pipe.read_to_string(&mut text).ok();
}
text
};
child.wait().unwrap();
let deadline = Instant::now() + Duration::from_secs(40);
let mut cancelled = false;
while Instant::now() < deadline {
if h.state_of(&id) == "cancelled" {
cancelled = true;
break;
}
std::thread::sleep(Duration::from_millis(200));
}
if !cancelled {
let log = std::fs::read_to_string(h.root.join("state/qex/run/daemon.log"))
.unwrap_or_else(|e| format!("(no log: {e})"));
let noticed = said.contains("the coordinator stopped");
let refused = said.contains("keeps the job");
let about_ownership: Vec<&str> = said
.lines()
.filter(|l| l.contains("keeps the job") || l.contains("the coordinator stopped"))
.collect();
panic!(
"the new coordinator did not cancel the job of a command that stopped.\n\
\n--- the job ---\nstate: {}\n\
\n--- the time from the kill of the first coordinator to the answer of the \
second ---\n{:?}\n\
\n--- did the client MEET the loss and reconnect? ---\n{}\n\
\n--- did the new coordinator refuse the ownership? ---\n{}\n\
\n--- the lines about the ownership ---\n{}\n\
\n--- stderr of the `qex run` that had to ask again ---\n{}\n\
\n--- the log of the coordinator ---\n{}\n\
\n--- qex list ---\n{}",
h.state_of(&id),
new_coordinator_took,
noticed,
refused,
if about_ownership.is_empty() {
"none".to_string()
} else {
about_ownership.join("\n")
},
if said.trim().is_empty() {
"no output".to_string()
} else {
said
},
log,
h.ok(&["list"]),
);
}
h.ok(&["kill", &blocker]);
}
const COORDINATOR_LIMIT_SECS: u64 = 3;
fn qex_ceiling_variable() -> String {
"QEX_COORDINATOR_CEILING_SECS".to_string()
}
#[test]
fn a_socket_that_gives_no_answer_does_not_stop_a_command() {
let mut h = Harness::with_default_config("nocoordans");
h.extra_env.push((qex_ceiling_variable(), "3".to_string()));
let run = h.root.join("state/qex/run");
std::fs::create_dir_all(&run).unwrap();
let Some(_open) = a_socket_that_never_answers(&run.join("s")) else {
eprintln!(
"this test did not run: this system gives a refusal, and not a wait, for a \
socket with a full queue"
);
return;
};
let out = h.qex_within(&["list"], Duration::from_secs(COORDINATOR_LIMIT_SECS + 20));
assert_eq!(
out.status.code(),
Some(124),
"a command that reached the limit for a coordinator gives 124: {}",
String::from_utf8_lossy(&out.stderr)
);
let said = String::from_utf8_lossy(&out.stderr);
assert!(
said.contains("gave no answer"),
"the message must name what qex tried: {said}"
);
assert!(
said.contains("qex info --no-start"),
"the message must name the step for the reader: {said}"
);
}
#[test]
fn a_spawn_lock_that_nobody_gives_back_does_not_stop_a_command() {
use std::os::unix::io::AsRawFd;
let mut h = Harness::with_default_config("nocoordlock");
h.extra_env.push((qex_ceiling_variable(), "3".to_string()));
let run = h.root.join("state/qex/run");
std::fs::create_dir_all(&run).unwrap();
let path = run.join("spawn.lock");
let held = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&path)
.unwrap();
let rc = unsafe { libc::flock(held.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
assert_eq!(rc, 0, "the test cannot hold the spawn lock");
let out = h.qex_within(&["list"], Duration::from_secs(COORDINATOR_LIMIT_SECS + 20));
assert_eq!(
out.status.code(),
Some(124),
"a command that reached the limit for the lock gives 124: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("held the lock"),
"the message must name the lock: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn a_socket_that_gives_no_answer_starts_no_second_coordinator() {
let mut h = Harness::with_default_config("nosecond");
h.extra_env.push((qex_ceiling_variable(), "3".to_string()));
let run = h.root.join("state/qex/run");
std::fs::create_dir_all(&run).unwrap();
let socket = run.join("s");
let Some(_open) = a_socket_that_never_answers(&socket) else {
eprintln!(
"this test did not run: this system gives a refusal, and not a wait, for a \
socket with a full queue"
);
return;
};
let out = h.qex_within(
&["submit", "--", "true"],
Duration::from_secs(COORDINATOR_LIMIT_SECS + 20),
);
assert_eq!(out.status.code(), Some(124));
assert!(
socket.exists(),
"the socket of the process that holds it must stay"
);
assert!(
!h.root.join("state/qex/run/daemon.log").exists()
|| std::fs::read_to_string(h.root.join("state/qex/run/daemon.log"))
.unwrap_or_default()
.is_empty(),
"no coordinator of this command may have started"
);
}
#[test]
fn a_wait_does_not_take_the_spawn_lock_for_a_notice() {
use std::os::unix::io::AsRawFd;
let h = Harness::with_default_config("waitnolock");
let run = h.root.join("state/qex/run");
std::fs::create_dir_all(&run).unwrap();
let held = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(run.join("spawn.lock"))
.unwrap();
let rc = unsafe { libc::flock(held.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
assert_eq!(rc, 0, "the test cannot hold the spawn lock");
let start = Instant::now();
let out = h.qex_within(
&["wait", "11111111-2222-3333-4444-555555555555"],
Duration::from_secs(COORDINATOR_LIMIT_SECS + 20),
);
let took = start.elapsed();
assert_eq!(
out.status.code(),
Some(127),
"a wait for an id that names nothing gives 127: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
took < Duration::from_secs(COORDINATOR_LIMIT_SECS),
"the wait must not take the spawn lock: it took {took:?}"
);
}
#[test]
fn a_page_that_qex_could_not_fill_gives_124_for_a_query() {
let mut h = Harness::with_default_config("topquery");
h.extra_env.push((qex_ceiling_variable(), "3".to_string()));
let run = h.root.join("state/qex/run");
std::fs::create_dir_all(&run).unwrap();
let Some(_open) = a_socket_that_never_answers(&run.join("s")) else {
eprintln!(
"this test did not run: this system gives a refusal, and not a wait, for a \
socket with a full queue"
);
return;
};
let out = h.qex_within(
&["top", "--once"],
Duration::from_secs(COORDINATOR_LIMIT_SECS + 20),
);
assert_eq!(
out.status.code(),
Some(124),
"a query that qex could not answer gives 124: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
String::from_utf8_lossy(&out.stdout).contains("budget"),
"the page must still be written: {}",
String::from_utf8_lossy(&out.stdout)
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("gave no answer"),
"the cause must reach the reader: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn a_page_with_no_coordinator_gives_zero_for_a_query() {
let h = Harness::with_default_config("topnocoord");
let out = h.qex_within(&["top", "--once"], Duration::from_secs(30));
assert_eq!(
out.status.code(),
Some(0),
"no coordinator is an answer, so the query succeeded: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(String::from_utf8_lossy(&out.stdout).contains("budget"));
}
struct ASocketThatAcceptsAndSaysNothing {
_listener: std::os::unix::net::UnixListener,
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
thread: Option<std::thread::JoinHandle<()>>,
}
impl Drop for ASocketThatAcceptsAndSaysNothing {
fn drop(&mut self) {
self.stop.store(true, std::sync::atomic::Ordering::SeqCst);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
fn a_socket_that_accepts_and_says_nothing(path: &Path) -> ASocketThatAcceptsAndSaysNothing {
let listener = std::os::unix::net::UnixListener::bind(path).unwrap();
listener.set_nonblocking(true).unwrap();
let copy = listener.try_clone().unwrap();
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = stop.clone();
let thread = std::thread::spawn(move || {
let mut held = Vec::new();
while !flag.load(std::sync::atomic::Ordering::SeqCst) {
match copy.accept() {
Ok((stream, _)) => held.push(stream),
Err(_) => std::thread::sleep(Duration::from_millis(20)),
}
}
});
ASocketThatAcceptsAndSaysNothing {
_listener: listener,
stop,
thread: Some(thread),
}
}
#[test]
fn a_coordinator_that_answers_nothing_does_not_stop_a_command() {
let h = Harness::with_default_config("readstall");
let run = h.root.join("state/qex/run");
std::fs::create_dir_all(&run).unwrap();
let _silent = a_socket_that_accepts_and_says_nothing(&run.join("s"));
let start = Instant::now();
let out = h.qex_within(&["wait", "--timeout", "3s", "abc"], Duration::from_secs(60));
let took = start.elapsed();
assert!(
took < Duration::from_secs(30),
"the read must end: it took {took:?}"
);
assert_ne!(
out.status.code(),
Some(127),
"a coordinator that did not answer is NOT `no such job`: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn a_coordinator_that_answers_nothing_is_not_no_such_job() {
let mut h = Harness::with_default_config("notnosuchjob");
h.extra_env.push((qex_ceiling_variable(), "3".to_string()));
let run = h.root.join("state/qex/run");
std::fs::create_dir_all(&run).unwrap();
let _silent = a_socket_that_accepts_and_says_nothing(&run.join("s"));
for form in [
vec!["status", "abc"],
vec!["logs", "abc"],
vec!["cancel", "abc"],
vec!["kill", "abc"],
] {
let out = h.qex_within(&form, Duration::from_secs(60));
assert_ne!(
out.status.code(),
Some(127),
"`qex {}` said that a job does not exist, and the coordinator gave no answer: {}",
form.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
}
struct ASlowCoordinator {
stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
thread: Option<std::thread::JoinHandle<()>>,
events: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
}
impl ASlowCoordinator {
fn what_it_did(&self) -> String {
self.events
.lock()
.unwrap_or_else(|e| e.into_inner())
.join("\n")
}
}
impl Drop for ASlowCoordinator {
fn drop(&mut self) {
self.stop.store(true, std::sync::atomic::Ordering::SeqCst);
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
fn a_coordinator_that_answers_late(front: &Path, real: &Path, delay: Duration) -> ASlowCoordinator {
use std::io::{BufRead, BufReader, Write};
let listener = std::os::unix::net::UnixListener::bind(front).unwrap();
listener.set_nonblocking(true).unwrap();
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = stop.clone();
let real = real.to_path_buf();
let held_one = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let events: std::sync::Arc<std::sync::Mutex<Vec<String>>> =
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let log = events.clone();
let began = Instant::now();
let thread = std::thread::spawn(move || {
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
while !flag.load(std::sync::atomic::Ordering::SeqCst) {
match listener.accept() {
Ok((front_side, _)) => {
front_side.set_nonblocking(false).unwrap();
let real = real.clone();
let first = held_one.clone();
let note = log.clone();
note.lock()
.unwrap_or_else(|e| e.into_inner())
.push(format!("{:?} accepted a connection", began.elapsed()));
workers.push(std::thread::spawn(move || {
let Ok(back) = std::os::unix::net::UnixStream::connect(&real) else {
return;
};
let mut from_qex = BufReader::new(front_side.try_clone().unwrap());
let mut to_real = back.try_clone().unwrap();
let mut from_real = BufReader::new(back);
let mut to_qex = front_side;
let mut line = String::new();
let mut served = 0usize;
loop {
line.clear();
match from_qex.read_line(&mut line) {
Ok(0) => {
note.lock().unwrap_or_else(|e| e.into_inner()).push(format!(
"{:?} the client closed after {} request(s)",
began.elapsed(),
served
));
return;
}
Ok(_) => {}
Err(e) => {
note.lock().unwrap_or_else(|e| e.into_inner()).push(format!(
"{:?} READING THE REQUEST FAILED after {} request(s): {e}",
began.elapsed(),
served
));
return;
}
}
served += 1;
if to_real.write_all(line.as_bytes()).is_err() {
note.lock().unwrap_or_else(|e| e.into_inner()).push(format!(
"{:?} FORWARDING REQUEST {} FAILED",
began.elapsed(),
served
));
return;
}
to_real.flush().ok();
let mut answer = String::new();
match from_real.read_line(&mut answer) {
Ok(0) | Err(_) => {
note.lock().unwrap_or_else(|e| e.into_inner()).push(format!(
"{:?} THE REAL COORDINATOR GAVE NO ANSWER TO REQUEST {}",
began.elapsed(),
served
));
return;
}
Ok(_) => {}
}
let held = !first.swap(true, std::sync::atomic::Ordering::SeqCst);
if held {
std::thread::sleep(delay);
}
note.lock().unwrap_or_else(|e| e.into_inner()).push(format!(
"{:?} answered request {} with {} bytes, held: {}",
began.elapsed(),
served,
answer.len(),
held
));
if to_qex.write_all(answer.as_bytes()).is_err() {
note.lock().unwrap_or_else(|e| e.into_inner()).push(format!(
"{:?} WRITING ANSWER {} FAILED",
began.elapsed(),
served
));
return;
}
to_qex.flush().ok();
}
}));
}
Err(_) => std::thread::sleep(Duration::from_millis(20)),
}
}
for w in workers {
let _ = w.join();
}
});
ASlowCoordinator {
stop,
thread: Some(thread),
events,
}
}
#[test]
fn a_coordinator_that_answers_late_is_answered_and_not_refused() {
let mut h = Harness::with_default_config("slowcoord");
h.extra_env
.push(("QEX_SAY_WAITING_AFTER_SECS".into(), "1".into()));
let first = h.qex(&["submit", "--", "sh", "-c", "exit 7"]);
assert!(first.status.success(), "the test needs a real coordinator");
let id = String::from_utf8_lossy(&first.stdout).trim().to_string();
h.qex(&["wait", &id]);
let run = h.root.join("state/qex/run");
let real = run.join("real");
std::fs::rename(run.join("s"), &real).unwrap();
let _slow = a_coordinator_that_answers_late(&run.join("s"), &real, Duration::from_secs(3));
let started = Instant::now();
let out = h.qex_within(&["status", &id, "--json"], Duration::from_secs(60));
let took = started.elapsed();
let what_the_proxy_did = _slow.what_it_did();
assert!(
!what_the_proxy_did.contains("READING THE REQUEST FAILED")
&& !what_the_proxy_did.contains("FORWARDING REQUEST")
&& !what_the_proxy_did.contains("WRITING ANSWER")
&& !what_the_proxy_did.contains("GAVE NO ANSWER"),
"THE PROXY OF THIS TEST FAILED, and qex is not the cause:\n{what_the_proxy_did}"
);
if out.status.code() != Some(0) {
use std::os::unix::process::ExitStatusExt as _;
panic!(
"a coordinator that answers late must be answered.\n\
\ncode: {:?} signal: {:?} wall time: {:?}\n\
\n--- what the proxy did ---\n{}\n\
\n--- stdout ---\n{}\n--- stderr ---\n{}",
out.status.code(),
out.status.signal(),
took,
_slow.what_it_did(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
let said = String::from_utf8_lossy(&out.stdout);
let record: serde_json::Value = serde_json::from_str(&said)
.unwrap_or_else(|e| panic!("the answer must be whole and correct: {e}: {said}"));
assert_eq!(
record["exit_code"], 7,
"the answer must belong to the question that qex asked: {said}"
);
}
fn a_coordinator_that_cuts_its_answer(front: &Path, real: &Path) -> ASlowCoordinator {
use std::io::{BufRead, BufReader, Write};
let listener = std::os::unix::net::UnixListener::bind(front).unwrap();
listener.set_nonblocking(true).unwrap();
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = stop.clone();
let real = real.to_path_buf();
let thread = std::thread::spawn(move || {
let mut workers: Vec<std::thread::JoinHandle<()>> = Vec::new();
while !flag.load(std::sync::atomic::Ordering::SeqCst) {
match listener.accept() {
Ok((front_side, _)) => {
front_side.set_nonblocking(false).unwrap();
let real = real.clone();
let mine = flag.clone();
workers.push(std::thread::spawn(move || {
let Ok(back) = std::os::unix::net::UnixStream::connect(&real) else {
return;
};
let mut from_qex = BufReader::new(front_side.try_clone().unwrap());
let mut to_real = back.try_clone().unwrap();
let mut from_real = BufReader::new(back);
let mut to_qex = front_side;
let mut line = String::new();
if from_qex.read_line(&mut line).unwrap_or(0) > 0 {
if to_real.write_all(line.as_bytes()).is_err() {
return;
}
to_real.flush().ok();
let mut answer = String::new();
if from_real.read_line(&mut answer).unwrap_or(0) == 0 {
return;
}
let mut at = answer.len() / 2;
while at > 0 && !answer.is_char_boundary(at) {
at -= 1;
}
let half = &answer.as_bytes()[..at];
if to_qex.write_all(half).is_err() {
return;
}
to_qex.flush().ok();
while !mine.load(std::sync::atomic::Ordering::SeqCst) {
std::thread::sleep(Duration::from_millis(50));
}
}
}));
}
Err(_) => std::thread::sleep(Duration::from_millis(20)),
}
}
for w in workers {
let _ = w.join();
}
});
ASlowCoordinator {
stop,
thread: Some(thread),
events: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
}
}
#[test]
fn an_answer_that_never_finishes_does_not_stop_a_command() {
let mut h = Harness::with_default_config("cutanswer");
h.extra_env.push((qex_ceiling_variable(), "4".to_string()));
let first = h.qex(&["submit", "--", "true"]);
assert!(first.status.success(), "the test needs a real coordinator");
let id = String::from_utf8_lossy(&first.stdout).trim().to_string();
h.qex(&["wait", &id]);
let run = h.root.join("state/qex/run");
let real = run.join("real");
std::fs::rename(run.join("s"), &real).unwrap();
let _cut = a_coordinator_that_cuts_its_answer(&run.join("s"), &real);
let start = Instant::now();
let out = h.qex_within(&["list", "--json"], Duration::from_secs(60));
let took = start.elapsed();
assert!(
took < Duration::from_secs(30),
"a cut answer must reach a limit: it took {took:?}"
);
assert_eq!(
out.status.code(),
Some(124),
"a cut answer reaches the limit of a wait: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
String::from_utf8_lossy(&out.stderr).contains("did not finish it"),
"the message must say that the answer began and did not finish: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn a_coordinator_that_closes_the_connection_does_not_kill_the_command() {
use std::io::{BufRead, BufReader, Write};
let mut h = Harness::with_default_config("closemid");
h.extra_env.push((qex_ceiling_variable(), "5".to_string()));
let run = h.root.join("state/qex/run");
std::fs::create_dir_all(&run).unwrap();
let listener = std::os::unix::net::UnixListener::bind(run.join("s")).unwrap();
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = stop.clone();
let door = std::thread::spawn(move || {
while !flag.load(std::sync::atomic::Ordering::SeqCst) {
let Ok((client, _)) = listener.accept() else {
return;
};
let mut reader = BufReader::new(client.try_clone().unwrap());
let mut writer = client;
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) > 0 {
let _ = writer.write_all(b"{\"kind\":\"error\",\"message\":\"go away\"}\n");
let _ = writer.flush();
}
drop(writer);
drop(reader);
}
});
let out = h.qex_within(&["list"], Duration::from_secs(40));
stop.store(true, std::sync::atomic::Ordering::SeqCst);
std::os::unix::net::UnixStream::connect(run.join("s")).ok();
let _ = door.join();
use std::os::unix::process::ExitStatusExt as _;
assert!(
out.status.signal().is_none(),
"a coordinator that closed the connection stopped the command with the signal {:?}. \
A write to the socket of the coordinator must give an error, and not a signal.\n\
stdout: {}\nstderr: {}",
out.status.signal(),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
assert!(
!String::from_utf8_lossy(&out.stderr).is_empty(),
"the reader must get a cause, and not silence"
);
}
#[test]
fn a_command_that_asks_about_a_coordinator_does_not_report_absence_for_silence() {
let mut h = Harness::with_default_config("askscoord");
h.extra_env.push((qex_ceiling_variable(), "3".to_string()));
let run = h.root.join("state/qex/run");
std::fs::create_dir_all(&run).unwrap();
let Some(_open) = a_socket_that_never_answers(&run.join("s")) else {
eprintln!(
"this test did not run: this system gives a refusal, and not a wait, for a \
socket with a full queue"
);
return;
};
for form in [vec!["version"], vec!["version", "--json"], vec!["pause"]] {
let out = h.qex_within(&form, Duration::from_secs(60));
assert_eq!(
out.status.code(),
Some(124),
"`qex {}` must say that it stopped waiting, and not that no coordinator \
operates: {}",
form.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
}