use crate::config::Config;
#[cfg(target_os = "linux")]
use crate::config::EnforceMode;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub enum Availability {
Available(PathBuf),
Unavailable(String),
}
#[cfg(target_os = "linux")]
pub fn availability() -> Availability {
use std::os::unix::fs::MetadataExt;
let Ok(text) = std::fs::read_to_string("/proc/self/cgroup") else {
return Availability::Unavailable("this system does not use cgroup v2".into());
};
let Some(rel) = text
.lines()
.find_map(|l| l.strip_prefix("0::"))
.map(|s| s.trim().to_string())
else {
return Availability::Unavailable("this system does not use cgroup v2".into());
};
let dir = PathBuf::from("/sys/fs/cgroup").join(rel.trim_start_matches('/'));
let Ok(meta) = std::fs::metadata(&dir) else {
return Availability::Unavailable(format!("qex cannot read {}", dir.display()));
};
let uid = unsafe { libc::getuid() };
if meta.uid() != uid {
return Availability::Unavailable(format!(
"the cgroup {} belongs to the user {}, so qex cannot make a cgroup for a job. \
Start the coordinator with systemd, or set [enforce] mode = \"off\".",
dir.display(),
meta.uid()
));
}
let controllers = std::fs::read_to_string(dir.join("cgroup.controllers")).unwrap_or_default();
if !controllers.split_whitespace().any(|c| c == "memory") {
return Availability::Unavailable(format!(
"the cgroup {} does not have the memory controller, so qex cannot limit the memory",
dir.display()
));
}
Availability::Available(dir)
}
#[cfg(not(target_os = "linux"))]
pub fn availability() -> Availability {
Availability::Unavailable(
"this system cannot limit the memory of a job; qex uses the claims for the queue only"
.into(),
)
}
#[cfg(target_os = "linux")]
pub fn create_job_cgroup(cfg: &Config, id: &uuid::Uuid, mem_claim: u64) -> Result<PathBuf, String> {
if !cfg.enforce.mode.is_on() {
return Err("the config file sets [enforce] mode = \"off\"".into());
}
let base = match availability() {
Availability::Available(b) => b,
Availability::Unavailable(reason) => return Err(reason),
};
let leaf = base.join("qex-main");
if std::fs::read_to_string(base.join("cgroup.procs"))
.map(|s| !s.trim().is_empty())
.unwrap_or(false)
{
std::fs::create_dir_all(&leaf)
.map_err(|e| format!("qex could not make {}: {e}", leaf.display()))?;
move_processes(&base, &leaf)?;
}
std::fs::write(base.join("cgroup.subtree_control"), b"+memory").map_err(|e| {
format!(
"qex could not give the memory controller to {}: {e}",
base.display()
)
})?;
let dir = base.join(format!("qex-{id}"));
std::fs::create_dir_all(&dir)
.map_err(|e| format!("qex could not make {}: {e}", dir.display()))?;
if !dir.join("memory.max").exists() {
std::fs::remove_dir(&dir).ok();
return Err(format!(
"the cgroup {} has no memory.max file, so this system cannot limit the memory",
dir.display()
));
}
match cfg.enforce.mode {
EnforceMode::Soft => {
let max = (mem_claim as f64 * cfg.enforce.mem_overcommit) as u64;
write_limit(&dir, "memory.high", mem_claim)?;
write_limit(&dir, "memory.max", max)?;
}
EnforceMode::Hard => {
write_limit(&dir, "memory.max", mem_claim)?;
}
EnforceMode::Off => unreachable!("this function tests the mode above"),
}
Ok(dir)
}
#[cfg(target_os = "linux")]
fn write_limit(dir: &Path, file: &str, value: u64) -> Result<(), String> {
std::fs::write(dir.join(file), value.to_string())
.map_err(|e| format!("qex could not write {}/{file}: {e}", dir.display()))
}
#[cfg(target_os = "linux")]
fn move_processes(from: &Path, to: &Path) -> Result<(), String> {
let text = std::fs::read_to_string(from.join("cgroup.procs"))
.map_err(|e| format!("qex could not read {}/cgroup.procs: {e}", from.display()))?;
for line in text.lines() {
let pid = line.trim();
if pid.is_empty() {
continue;
}
std::fs::write(to.join("cgroup.procs"), pid).ok();
}
Ok(())
}
#[cfg(not(target_os = "linux"))]
pub fn create_job_cgroup(_cfg: &Config, _id: &uuid::Uuid, _mem: u64) -> Result<PathBuf, String> {
Err("this system cannot limit the memory of a job".into())
}
pub fn add_process(cgroup: &Path, pid: i32) -> Result<(), String> {
std::fs::write(cgroup.join("cgroup.procs"), pid.to_string())
.map_err(|e| format!("qex could not put the process {pid} in the cgroup: {e}"))
}
pub fn leave_cgroup(cgroup: &Path) {
if let Some(parent) = cgroup.parent() {
let pid = std::process::id().to_string();
std::fs::write(parent.join("cgroup.procs"), &pid).ok();
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct OomCounts {
pub oom: u64,
pub oom_kill: u64,
}
pub fn read_oom_counts(cgroup: &Path) -> Option<OomCounts> {
let text = std::fs::read_to_string(cgroup.join("memory.events")).ok()?;
let count = |name: &str| -> u64 {
text.lines()
.filter_map(|l| l.strip_prefix(name))
.filter_map(|n| n.trim().parse::<u64>().ok())
.next()
.unwrap_or(0)
};
Some(OomCounts {
oom: count("oom "),
oom_kill: count("oom_kill "),
})
}
pub fn classify_oom(cgroup: &Path, qex_made_cgroup: bool, before: OomCounts) -> Option<OomScope> {
let now = read_oom_counts(cgroup)?;
if now.oom_kill <= before.oom_kill {
return None;
}
if !qex_made_cgroup {
return Some(OomScope::Session);
}
if now.oom > before.oom {
Some(OomScope::Job)
} else {
Some(OomScope::Machine)
}
}
#[cfg(target_os = "linux")]
pub fn kill_cgroup(cgroup: &Path) -> bool {
std::fs::write(cgroup.join("cgroup.kill"), b"1").is_ok()
}
#[cfg(not(target_os = "linux"))]
pub fn kill_cgroup(_cgroup: &Path) -> bool {
false
}
pub fn remove_cgroup(cgroup: &Path) {
std::fs::remove_dir(cgroup).ok();
}
pub fn job_cgroup_path(job_dir: &Path) -> Option<PathBuf> {
let text = std::fs::read_to_string(job_dir.join("cgroup")).ok()?;
let path = PathBuf::from(text.trim());
path.exists().then_some(path)
}
pub fn record_cgroup_path(job_dir: &Path, cgroup: &Path) {
std::fs::write(job_dir.join("cgroup"), cgroup.to_string_lossy().as_bytes()).ok();
}
#[cfg(target_os = "linux")]
pub fn own_cgroup() -> Option<PathBuf> {
let text = std::fs::read_to_string("/proc/self/cgroup").ok()?;
let rel = text.lines().find_map(|l| l.strip_prefix("0::"))?.trim();
let dir = PathBuf::from("/sys/fs/cgroup").join(rel.trim_start_matches('/'));
dir.exists().then_some(dir)
}
#[cfg(not(target_os = "linux"))]
pub fn own_cgroup() -> Option<PathBuf> {
None
}
pub struct OomWatch {
cgroup: Option<PathBuf>,
qex_made_cgroup: bool,
before: OomCounts,
}
impl OomWatch {
pub fn start(job_cgroup: Option<&Path>) -> Self {
let cgroup = job_cgroup.map(|p| p.to_path_buf()).or_else(own_cgroup);
let before = cgroup
.as_deref()
.and_then(read_oom_counts)
.unwrap_or_default();
Self {
cgroup,
qex_made_cgroup: job_cgroup.is_some(),
before,
}
}
pub fn record(&self, job_dir: &Path) {
let Some(cgroup) = self.cgroup.as_deref() else {
return;
};
if let Some(scope) = classify_oom(cgroup, self.qex_made_cgroup, self.before) {
mark_oom(job_dir, scope);
}
}
}
pub fn was_oom_killed(job_dir: &Path) -> bool {
oom_evidence(job_dir).is_some()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OomScope {
Job,
Machine,
Session,
}
impl OomScope {
fn as_str(self) -> &'static str {
match self {
Self::Job => "job",
Self::Machine => "machine",
Self::Session => "session",
}
}
fn strength(self) -> u8 {
match self {
Self::Job => 2,
Self::Machine => 1,
Self::Session => 0,
}
}
}
pub fn oom_evidence(job_dir: &Path) -> Option<OomScope> {
if let Ok(text) = std::fs::read_to_string(job_dir.join("oom")) {
return Some(match text.trim() {
"job" => OomScope::Job,
"machine" => OomScope::Machine,
_ => OomScope::Session,
});
}
None
}
pub fn mark_oom(job_dir: &Path, scope: OomScope) {
if let Some(held) = oom_evidence(job_dir) {
if held.strength() > scope.strength() {
return;
}
}
std::fs::write(job_dir.join("oom"), scope.as_str().as_bytes()).ok();
}
pub fn clear_oom(job_dir: &Path) {
std::fs::remove_file(job_dir.join("oom")).ok();
}
pub fn mark_user_kill(job_dir: &Path) {
std::fs::write(job_dir.join("killed-by-user"), b"1").ok();
}
pub fn was_user_killed(job_dir: &Path) -> bool {
job_dir.join("killed-by-user").exists()
}
pub fn clear_user_kill(job_dir: &Path) {
std::fs::remove_file(job_dir.join("killed-by-user")).ok();
}
pub fn oom_evidence_is_available() -> bool {
own_cgroup().is_some()
}
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
const REEXEC_VAR: &str = "QEX_SYSTEMD_STARTED";
#[cfg(target_os = "linux")]
pub fn restart_with_systemd(cfg: &Config) -> bool {
if !cfg.enforce.mode.is_on() || !cfg.enforce.use_systemd {
return false;
}
if matches!(availability(), Availability::Available(_)) {
return false;
}
if std::env::var_os(REEXEC_VAR).is_some() {
return false;
}
if !systemd_is_available() {
return false;
}
let Ok(exe) = crate::paths::program_path() else {
return false;
};
let result = std::process::Command::new("systemd-run")
.args([
"--user",
"--quiet",
"--collect",
"--property=Delegate=yes",
"--property=Description=qex coordinator",
"--unit",
])
.arg(format!("qex-{}", std::process::id()))
.arg(&exe)
.arg("daemon")
.env(REEXEC_VAR, "1")
.status();
match result {
Ok(status) if status.success() => true,
Ok(status) => {
eprintln!(
"qex: systemd-run gave the code {:?}, so qex continues without a memory limit",
status.code()
);
false
}
Err(e) => {
eprintln!(
"qex: qex could not run systemd-run ({e}), so it continues without a memory limit"
);
false
}
}
}
#[cfg(not(target_os = "linux"))]
pub fn restart_with_systemd(_cfg: &Config) -> bool {
false
}
#[cfg(target_os = "linux")]
fn systemd_is_available() -> bool {
std::process::Command::new("systemctl")
.args(["--user", "is-system-running"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn startup_warning(cfg: &Config) -> Option<String> {
if !cfg.enforce.mode.is_on() {
return None;
}
match availability() {
Availability::Available(_) => None,
Availability::Unavailable(reason) => Some(format!(
"the config file sets [enforce] mode = \"{:?}\", but qex cannot apply a memory \
limit: {reason}. qex continues, and it uses the claims for the queue only.",
cfg.enforce.mode
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_out_of_memory_record_is_read_back() {
let dir = std::env::temp_dir().join(format!("qex-oom-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).unwrap();
assert!(
!was_oom_killed(&dir),
"a new job has no out-of-memory record"
);
mark_oom(&dir, OomScope::Session);
assert!(was_oom_killed(&dir));
assert_eq!(oom_evidence(&dir), Some(OomScope::Session));
mark_oom(&dir, OomScope::Job);
assert_eq!(oom_evidence(&dir), Some(OomScope::Job));
mark_oom(&dir, OomScope::Session);
assert_eq!(oom_evidence(&dir), Some(OomScope::Job));
clear_oom(&dir);
assert_eq!(oom_evidence(&dir), None);
std::fs::write(dir.join("oom"), b"1").unwrap();
assert_eq!(oom_evidence(&dir), Some(OomScope::Session));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_mark_of_a_kill_by_a_command_can_be_cleared() {
let dir = std::env::temp_dir().join(format!("qex-userkill-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).unwrap();
assert!(!was_user_killed(&dir));
mark_user_kill(&dir);
assert!(was_user_killed(&dir));
clear_user_kill(&dir);
assert!(!was_user_killed(&dir));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_default_mode_gives_no_warning() {
let cfg = Config::default();
assert_eq!(cfg.enforce.mode, crate::config::EnforceMode::Off);
assert!(startup_warning(&cfg).is_none());
}
#[test]
fn a_mode_that_cannot_operate_gives_a_warning() {
let cfg: Config = toml::from_str("[enforce]\nmode = \"hard\"\n").unwrap();
match availability() {
Availability::Available(_) => {
assert!(startup_warning(&cfg).is_none());
}
Availability::Unavailable(_) => {
let warning =
startup_warning(&cfg).expect("qex must warn about a limit it cannot apply");
assert!(warning.contains("mode"), "got: {warning}");
assert!(
warning.contains("queue only"),
"the warning must say what qex does instead: {warning}"
);
}
}
}
fn a_cgroup_with_events(name: &str, events: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("qex-events-{}-{name}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("memory.events"), events.as_bytes()).unwrap();
dir
}
#[test]
fn a_kill_at_the_limit_of_the_job_names_the_job() {
let dir = a_cgroup_with_events("atlimit", "low 0\nhigh 0\nmax 3\noom 1\noom_kill 1\n");
assert_eq!(
classify_oom(&dir, true, OomCounts::default()),
Some(OomScope::Job)
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_kill_that_the_machine_made_names_the_machine() {
let dir = a_cgroup_with_events("machine", "low 0\nhigh 0\nmax 0\noom 0\noom_kill 1\n");
assert_eq!(
classify_oom(&dir, true, OomCounts::default()),
Some(OomScope::Machine)
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_cgroup_with_no_kill_gives_no_answer() {
let dir = a_cgroup_with_events("nokill", "low 0\nhigh 0\nmax 0\noom 0\noom_kill 0\n");
assert_eq!(classify_oom(&dir, true, OomCounts::default()), None);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_limit_that_stopped_no_process_gives_no_answer() {
let dir = a_cgroup_with_events("noproc", "low 0\nhigh 0\nmax 5\noom 2\noom_kill 0\n");
assert_eq!(classify_oom(&dir, true, OomCounts::default()), None);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_cgroup_with_no_events_file_gives_no_answer() {
let dir = std::env::temp_dir().join(format!("qex-events-{}-none", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(classify_oom(&dir, true, OomCounts::default()), None);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_file_with_no_counts_gives_no_answer() {
let dir = a_cgroup_with_events("odd", "this file holds no count\n\noom_kill\n");
assert_eq!(classify_oom(&dir, true, OomCounts::default()), None);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_count_that_qex_cannot_read_is_zero() {
let dir = a_cgroup_with_events("unread", "oom what\noom_kill later\n");
assert_eq!(
read_oom_counts(&dir),
Some(OomCounts {
oom: 0,
oom_kill: 0
}),
"a count that qex cannot read must not become evidence"
);
assert_eq!(classify_oom(&dir, true, OomCounts::default()), None);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_counter_of_the_session_names_the_session() {
let dir = a_cgroup_with_events("session", "low 0\nhigh 0\nmax 3\noom 1\noom_kill 1\n");
assert_eq!(
classify_oom(&dir, false, OomCounts::default()),
Some(OomScope::Session)
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_count_from_before_the_job_gives_no_answer() {
let dir = a_cgroup_with_events("before", "low 0\nhigh 0\nmax 0\noom 0\noom_kill 4\n");
assert_eq!(
classify_oom(
&dir,
false,
OomCounts {
oom: 0,
oom_kill: 4
}
),
None
);
assert_eq!(
classify_oom(
&dir,
false,
OomCounts {
oom: 0,
oom_kill: 3
}
),
Some(OomScope::Session)
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_strongest_evidence_of_an_attempt_stays() {
let dir = std::env::temp_dir().join(format!("qex-strength-{}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).unwrap();
mark_oom(&dir, OomScope::Machine);
assert_eq!(oom_evidence(&dir), Some(OomScope::Machine));
mark_oom(&dir, OomScope::Session);
assert_eq!(oom_evidence(&dir), Some(OomScope::Machine));
mark_oom(&dir, OomScope::Job);
assert_eq!(oom_evidence(&dir), Some(OomScope::Job));
mark_oom(&dir, OomScope::Machine);
assert_eq!(oom_evidence(&dir), Some(OomScope::Job));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_count_of_an_earlier_attempt_does_not_name_the_claim() {
let dir = a_cgroup_with_events("reused", "oom 1\noom_kill 1\n");
assert_eq!(
classify_oom(&dir, true, OomCounts::default()),
Some(OomScope::Job)
);
let before = read_oom_counts(&dir).unwrap();
std::fs::write(dir.join("memory.events"), b"oom 1\noom_kill 2\n").unwrap();
assert_eq!(
classify_oom(&dir, true, before),
Some(OomScope::Machine),
"a count of an earlier attempt must not name the claim of this attempt"
);
std::fs::write(dir.join("memory.events"), b"oom 2\noom_kill 3\n").unwrap();
assert_eq!(
classify_oom(&dir, true, before),
Some(OomScope::Job),
"a rise above the count of the earlier attempt must name the claim"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_answer_of_an_attempt_reaches_the_record() {
let job = std::env::temp_dir().join(format!("qex-record-{}", std::process::id()));
std::fs::remove_dir_all(&job).ok();
std::fs::create_dir_all(&job).unwrap();
let cgroup = a_cgroup_with_events("record", "oom 0\noom_kill 0\n");
record_cgroup_path(&job, &cgroup);
let watch = OomWatch::start(Some(&cgroup));
std::fs::write(cgroup.join("memory.events"), b"oom 0\noom_kill 1\n").unwrap();
watch.record(&job);
assert!(
job.join("oom").exists(),
"the supervisor must write the record, and not leave the answer in the cgroup"
);
assert_eq!(
oom_evidence(&job),
Some(OomScope::Machine),
"the answer must reach the record of the job"
);
std::fs::write(cgroup.join("memory.events"), b"oom 1\noom_kill 2\n").unwrap();
watch.record(&job);
assert_eq!(oom_evidence(&job), Some(OomScope::Job));
std::fs::remove_dir_all(&job).ok();
std::fs::remove_dir_all(&cgroup).ok();
}
#[test]
fn the_watch_holds_the_counts_of_the_start_of_the_attempt() {
let job = std::env::temp_dir().join(format!("qex-watch2-{}", std::process::id()));
std::fs::remove_dir_all(&job).ok();
std::fs::create_dir_all(&job).unwrap();
let cgroup = a_cgroup_with_events("watch2", "oom 1\noom_kill 1\n");
let watch = OomWatch::start(Some(&cgroup));
std::fs::write(cgroup.join("memory.events"), b"oom 1\noom_kill 2\n").unwrap();
watch.record(&job);
assert_eq!(
oom_evidence(&job),
Some(OomScope::Machine),
"a rise of an earlier attempt must not name the claim of this attempt"
);
std::fs::remove_dir_all(&job).ok();
std::fs::remove_dir_all(&cgroup).ok();
}
#[test]
fn the_watch_owns_a_cgroup_only_when_this_attempt_made_it() {
let dir = a_cgroup_with_events("owned", "oom 0\noom_kill 0\n");
let made = OomWatch::start(Some(&dir));
assert!(
made.qex_made_cgroup,
"a cgroup that this attempt made belongs to this attempt"
);
let not_made = OomWatch::start(None);
assert!(
!not_made.qex_made_cgroup,
"with no cgroup of its own, qex cannot name the job that the kernel stopped"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn the_evidence_comes_from_the_record_and_not_from_the_cgroup() {
let job = std::env::temp_dir().join(format!("qex-nofall-{}", std::process::id()));
std::fs::remove_dir_all(&job).ok();
std::fs::create_dir_all(&job).unwrap();
let cgroup = a_cgroup_with_events("nofall", "oom 1\noom_kill 1\n");
record_cgroup_path(&job, &cgroup);
assert_eq!(
oom_evidence(&job),
None,
"a count with no record must give no answer"
);
std::fs::remove_dir_all(&job).ok();
std::fs::remove_dir_all(&cgroup).ok();
}
#[test]
fn the_availability_test_gives_an_answer() {
match availability() {
Availability::Available(path) => {
assert!(
path.exists(),
"the cgroup path must exist: {}",
path.display()
);
}
Availability::Unavailable(reason) => {
assert!(!reason.is_empty(), "the reason must not be empty");
}
}
}
}