use crate::daemon::{log, Coordinator};
use crate::job::{self, JobState, Usage};
use crate::paths;
use crate::sys;
use anyhow::{Context, Result};
use std::os::unix::process::CommandExt;
use std::sync::Arc;
use std::time::Duration;
pub fn spawn(id: uuid::Uuid) -> Result<i32> {
let exe = paths::program_path()?;
let dir = paths::job_dir(&id)?;
let log_path = dir.join("supervisor.log");
use std::os::unix::fs::OpenOptionsExt;
let log_file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.mode(0o600)
.open(&log_path)
.with_context(|| format!("opening {}", log_path.display()))?;
let log_err = log_file
.try_clone()
.context("copying the log file handle")?;
let mut cmd = std::process::Command::new(exe);
cmd.arg("supervise")
.arg(id.to_string())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::from(log_file))
.stderr(std::process::Stdio::from(log_err))
.current_dir("/");
unsafe {
cmd.pre_exec(|| {
if libc::setsid() == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
let child = cmd.spawn().context("starting the supervisor")?;
Ok(child.id() as i32)
}
pub fn record_supervisor_pid(id: &uuid::Uuid, pid: i32) {
let Ok(dir) = paths::job_dir(id) else { return };
crate::job::write_atomic(
&dir.join("supervisor.pid"),
pid.to_string().as_bytes(),
0o600,
)
.ok();
}
pub fn supervisor_pid_of(dir: &std::path::Path) -> Option<i32> {
std::fs::read_to_string(dir.join("supervisor.pid"))
.ok()?
.trim()
.parse()
.ok()
}
pub fn reap(coord: Arc<Coordinator>, id: uuid::Uuid, pid: i32) {
let mut wait_status: libc::c_int = 0;
let rc = unsafe { libc::waitpid(pid, &mut wait_status, 0) };
if rc < 0 {
let e = std::io::Error::last_os_error();
if e.raw_os_error() == Some(libc::ECHILD) {
watch_until_gone(pid);
} else {
log(&format!(
"qex could not wait for the supervisor {pid} of the job {id}: {e}"
));
}
}
let dir = match paths::job_dir(&id) {
Ok(d) => d,
Err(_) => return,
};
let mut state = coord.state.lock().unwrap();
if let Some(job) = state.jobs.get_mut(&id) {
job.supervisor_pid = None;
match job::read_status(&dir) {
Ok(status) if status.state.is_terminal() => {
job.status = status;
}
other => {
let job_pid = other.ok().and_then(|s| s.pid).or(job.status.pid);
let mut note = "the supervisor stopped without a result".to_string();
if let Some(text) = supervisor_log_tail(&dir) {
note.push_str(&format!(". The supervisor said: {text}"));
}
if let Some(pid) = job_pid {
if sys::pid_alive(pid) {
log(&format!(
"the supervisor of the job {id} stopped, and the job {pid} \
continues; qex stops the job now"
));
stop_process_group(pid);
note.push_str("; qex stopped the job process");
}
}
job.status.state = JobState::Failed;
job.status.finished_at = Some(sys::now_secs());
job.status.error = Some(note);
job.status.blocked_reason = None;
let status = job.status.clone();
job::write_status(&dir, &status).ok();
log(&format!("the supervisor of the job {id} left no result"));
}
}
}
drop(state);
coord.notify();
}
fn watch_until_gone(pid: i32) {
while sys::pid_alive(pid) {
std::thread::sleep(Duration::from_millis(500));
}
}
fn stop_process_group(pid: i32) {
unsafe {
libc::killpg(pid, libc::SIGTERM);
}
for _ in 0..20 {
std::thread::sleep(Duration::from_millis(100));
if !sys::pid_alive(pid) {
return;
}
}
unsafe {
libc::killpg(pid, libc::SIGKILL);
}
}
pub fn main(id: uuid::Uuid) -> Result<i32> {
let dir = paths::job_dir(&id)?;
let spec = job::read_spec(&dir).context("reading the job specification")?;
let mut status = job::read_status(&dir).context("reading the job status")?;
status.supervisor_pid = Some(std::process::id() as i32);
job::write_status(&dir, &status).context("writing the job status")?;
let again = status.attempts > 0;
let stdout = create_private(&dir.join("stdout.log"), again)
.context("opening the standard output file of the job")?;
let stderr = create_private(&dir.join("stderr.log"), again)
.context("opening the standard error file of the job")?;
if again {
use std::io::Write;
let mark = format!("\n--- attempt {} ---\n", status.attempts + 1);
(&stdout).write_all(mark.as_bytes()).ok();
(&stderr).write_all(mark.as_bytes()).ok();
}
let mut cmd = std::process::Command::new(&spec.command[0]);
cmd.args(&spec.command[1..])
.current_dir(&spec.cwd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::from(stdout))
.stderr(std::process::Stdio::from(stderr))
.env_clear()
.envs(&spec.env);
unsafe {
cmd.pre_exec(|| {
if libc::setpgid(0, 0) == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
let mut config_fault: Option<String> = None;
let cfg = match crate::config::Config::load_for_job_record() {
Ok(cfg) => cfg,
Err(e) => {
let message = format!(
"qex could not read the configuration ({e}). This job uses the default values, \
SO NO LIMIT OPERATES. Correct the file, and start the job again with \
`qex rerun {id}`. Run `qex config show` for the complete message."
);
log(&message);
eprintln!("{message}");
config_fault = Some(message);
crate::config::Config::default()
}
};
let mut cgroup_dir: Option<std::path::PathBuf> = None;
let mut enforce_warning: Option<String> = None;
if cfg.enforce.mode.is_on() {
match crate::enforce::create_job_cgroup(&cfg, &id, spec.mem) {
Ok(cgroup) => match crate::enforce::add_process(&cgroup, std::process::id() as i32) {
Ok(()) => {
crate::enforce::record_cgroup_path(&dir, &cgroup);
cgroup_dir = Some(cgroup);
}
Err(e) => {
enforce_warning = Some(e);
crate::enforce::remove_cgroup(&cgroup);
}
},
Err(e) => {
enforce_warning = Some(e);
}
}
}
if let Some(warning) = &enforce_warning {
eprintln!("qex: the memory limit is not active for this job: {warning}");
status.error = Some(format!(
"the memory limit is not active for this job: {warning}"
));
}
if let Some(fault) = &config_fault {
status.error = Some(fault.clone());
}
let _ = &cgroup_dir;
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => {
let message = format!(
"qex could not start `{}`: {e}. Test the program name and the PATH value.",
spec.command[0]
);
eprintln!("{message}");
status.state = JobState::Failed;
status.finished_at = Some(sys::now_secs());
status.error = Some(message);
status.blocked_reason = None;
job::write_status(&dir, &status)?;
return Ok(1);
}
};
let watch_cgroup = cgroup_dir.clone().or_else(crate::enforce::own_cgroup);
let oom_before = watch_cgroup
.as_ref()
.map(|c| crate::enforce::oom_count(c))
.unwrap_or(0);
let pid = child.id() as i32;
status.state = JobState::Running;
status.pid = Some(pid);
status.supervisor_pid = Some(std::process::id() as i32);
status.started_at = Some(sys::now_secs());
status.attempts += 1;
job::write_status(&dir, &status)?;
let outcome = Arc::new(std::sync::atomic::AtomicU8::new(RACE_OPEN));
if let Some(limit) = spec.timeout {
let outcome = Arc::clone(&outcome);
std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(limit));
if outcome
.compare_exchange(
RACE_OPEN,
RACE_TIMER,
std::sync::atomic::Ordering::SeqCst,
std::sync::atomic::Ordering::SeqCst,
)
.is_err()
{
return;
}
unsafe {
libc::killpg(pid, libc::SIGTERM);
}
std::thread::sleep(Duration::from_secs(10));
unsafe {
libc::killpg(pid, libc::SIGKILL);
}
});
}
let reserved = match wait_without_reaping(pid) {
Ok(()) => true,
Err(e) => {
log(&format!(
"the wait for the job {id} (pid {pid}) failed: {e}. qex sends no signal to that \
process group, because the machine can give that pid to another process."
));
false
}
};
let _ = outcome.compare_exchange(
RACE_OPEN,
RACE_JOB,
std::sync::atomic::Ordering::SeqCst,
std::sync::atomic::Ordering::SeqCst,
);
if reserved {
unsafe {
libc::killpg(pid, libc::SIGKILL);
}
}
if let Some(cgroup) = crate::enforce::job_cgroup_path(&dir) {
if crate::enforce::cgroup_had_oom(&cgroup) {
crate::enforce::mark_oom(&dir);
}
crate::enforce::kill_cgroup(&cgroup);
}
if let Some(cgroup) = &watch_cgroup {
if crate::enforce::oom_count(cgroup) > oom_before {
crate::enforce::mark_oom(&dir);
}
}
let exit = child.wait().context("waiting for the job")?;
let usage = read_usage();
if let Some(cgroup) = crate::enforce::job_cgroup_path(&dir) {
crate::enforce::leave_cgroup(&cgroup);
crate::enforce::remove_cgroup(&cgroup);
}
let signal = exit_signal(&exit);
let code = exit.code();
let timed_out = outcome.load(std::sync::atomic::Ordering::SeqCst) == RACE_TIMER;
status.state = classify(&spec, code, signal, timed_out, &dir);
status.exit_code = code;
status.signal = signal;
status.finished_at = Some(sys::now_secs());
status.usage = usage;
status.pid = None;
status.last_pid = Some(pid);
let retrying = status.state == JobState::Failed && status.retries_left > 0;
if retrying {
status.retries_left -= 1;
status.state = JobState::Queued;
status.error = Some(format!(
"attempt {} failed with the exit code {}; qex starts the job again",
status.attempts,
code.unwrap_or(-1)
));
status.finished_at = None;
}
job::write_status(&dir, &status)?;
if retrying {
log(&format!(
"job {id} failed and starts again; {} attempt(s) left",
status.retries_left
));
std::thread::sleep(Duration::from_secs(1));
return main(id);
}
crate::usage::record(&spec, &status);
Ok(code.unwrap_or(0))
}
fn create_private(path: &std::path::Path, append: bool) -> std::io::Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(!append)
.append(append)
.mode(0o600)
.open(path)
}
const RACE_OPEN: u8 = 0;
const RACE_JOB: u8 = 1;
const RACE_TIMER: u8 = 2;
fn supervisor_log_tail(dir: &std::path::Path) -> Option<String> {
const KEEP: usize = 3;
const LIMIT: usize = 400;
let raw = std::fs::read(dir.join("supervisor.log")).ok()?;
let text = String::from_utf8_lossy(&raw);
let lines: Vec<&str> = text
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect();
if lines.is_empty() {
return None;
}
let start = lines.len().saturating_sub(KEEP);
let mut joined = lines[start..].join(" / ");
if joined.chars().count() > LIMIT {
joined = joined.chars().take(LIMIT).collect::<String>() + "...";
}
Some(joined)
}
fn wait_without_reaping(pid: i32) -> std::io::Result<()> {
let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
loop {
let result = unsafe {
libc::waitid(
libc::P_PID,
pid as libc::id_t,
&mut info,
libc::WEXITED | libc::WNOWAIT,
)
};
if result == 0 {
return Ok(());
}
let e = std::io::Error::last_os_error();
if e.kind() == std::io::ErrorKind::Interrupted {
continue;
}
return Err(e);
}
}
fn classify(
_spec: &crate::spec::JobSpec,
code: Option<i32>,
signal: Option<i32>,
timed_out: bool,
dir: &std::path::Path,
) -> JobState {
if code == Some(0) {
return JobState::Completed;
}
if timed_out {
return JobState::Timeout;
}
if signal == Some(libc::SIGKILL) && crate::enforce::was_oom_killed(dir) {
return JobState::Oom;
}
match (code, signal) {
(Some(0), _) => JobState::Completed,
(Some(_), _) => JobState::Failed,
(None, Some(libc::SIGTERM)) | (None, Some(libc::SIGKILL)) => JobState::Killed,
(None, Some(_)) => JobState::Failed,
(None, None) => JobState::Failed,
}
}
fn exit_signal(exit: &std::process::ExitStatus) -> Option<i32> {
use std::os::unix::process::ExitStatusExt;
exit.signal()
}
fn read_usage() -> Usage {
let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::getrusage(libc::RUSAGE_CHILDREN, &mut ru) };
if rc != 0 {
return Usage::default();
}
#[cfg(target_os = "linux")]
let max_rss = (ru.ru_maxrss as u64).saturating_mul(1024);
#[cfg(not(target_os = "linux"))]
let max_rss = ru.ru_maxrss as u64;
let cpu_secs = ru.ru_utime.tv_sec as f64
+ ru.ru_utime.tv_usec as f64 / 1e6
+ ru.ru_stime.tv_sec as f64
+ ru.ru_stime.tv_usec as f64 / 1e6;
Usage { max_rss, cpu_secs }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::spec::JobSpec;
#[test]
fn the_last_words_of_the_supervisor_reach_the_record() {
let dir = std::env::temp_dir().join(format!("qex-tail-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(supervisor_log_tail(&dir), None);
std::fs::write(dir.join("supervisor.log"), b"\n \n").unwrap();
assert_eq!(supervisor_log_tail(&dir), None);
std::fs::write(
dir.join("supervisor.log"),
b"one\ntwo\nthree\nfour\nError: renaming status.json into place\n",
)
.unwrap();
let tail = supervisor_log_tail(&dir).unwrap();
assert!(tail.contains("renaming status.json"), "got: {tail}");
assert!(!tail.contains("one"), "the oldest lines must go: {tail}");
std::fs::write(dir.join("supervisor.log"), "x".repeat(5000).as_bytes()).unwrap();
let tail = supervisor_log_tail(&dir).unwrap();
assert!(tail.chars().count() <= 405, "the text must have a limit");
assert!(!tail.contains('\n'), "the text must be one line");
std::fs::write(dir.join("supervisor.log"), b"bad \xff\xfe byte").unwrap();
assert!(supervisor_log_tail(&dir).unwrap().contains("bad"));
std::fs::remove_dir_all(&dir).ok();
}
fn spec() -> JobSpec {
JobSpec {
id: uuid::Uuid::new_v4(),
name: "t".into(),
cwd: "/".into(),
command: vec!["true".into()],
env: Default::default(),
cpu: 1,
mem: 1 << 30,
timeout: None,
tags: vec![],
priority: 0,
env_capture: crate::config::EnvCapture::None,
claim_source: "explicit".into(),
group: None,
group_name: None,
locks: vec![],
retries: 0,
needs: vec![],
after: vec![],
submitted_at: 0,
}
}
#[test]
fn the_exit_code_gives_the_final_state() {
let dir = std::path::Path::new("/nonexistent");
assert_eq!(
classify(&spec(), Some(0), None, false, dir),
JobState::Completed
);
assert_eq!(
classify(&spec(), Some(1), None, false, dir),
JobState::Failed
);
assert_eq!(
classify(&spec(), Some(127), None, false, dir),
JobState::Failed
);
}
#[test]
fn a_signal_gives_the_state_killed() {
let dir = std::path::Path::new("/nonexistent");
assert_eq!(
classify(&spec(), None, Some(libc::SIGTERM), false, dir),
JobState::Killed
);
assert_eq!(
classify(&spec(), None, Some(libc::SIGKILL), false, dir),
JobState::Killed
);
}
#[test]
fn a_time_limit_gives_the_state_timeout() {
let dir = std::path::Path::new("/nonexistent");
assert_eq!(
classify(&spec(), None, Some(libc::SIGTERM), true, dir),
JobState::Timeout
);
}
#[test]
fn a_fault_signal_gives_the_state_failed() {
let dir = std::path::Path::new("/nonexistent");
assert_eq!(
classify(&spec(), None, Some(libc::SIGSEGV), false, dir),
JobState::Failed
);
}
#[test]
fn the_use_measurement_gives_a_value_after_a_child_stops() {
std::process::Command::new("sh")
.args(["-c", "head -c 4000000 /dev/zero > /dev/null"])
.status()
.expect("the test could not start a child process");
let usage = read_usage();
assert!(usage.max_rss > 0, "the memory measurement gave zero");
assert!(
usage.max_rss > 64 * 1024 && usage.max_rss < 8 * (1 << 30),
"the memory measurement {} is not plausible; test the unit",
crate::units::format_size(usage.max_rss)
);
assert!(usage.cpu_secs >= 0.0);
}
}