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();
}
}
#[cfg(target_os = "linux")]
pub fn cgroup_had_oom(cgroup: &Path) -> bool {
let Ok(text) = std::fs::read_to_string(cgroup.join("memory.events")) else {
return false;
};
text.lines()
.filter_map(|l| l.strip_prefix("oom_kill "))
.filter_map(|n| n.trim().parse::<u64>().ok())
.any(|n| n > 0)
}
#[cfg(not(target_os = "linux"))]
pub fn cgroup_had_oom(_cgroup: &Path) -> bool {
false
}
#[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
}
#[cfg(target_os = "linux")]
pub fn oom_count(cgroup: &Path) -> u64 {
let Ok(text) = std::fs::read_to_string(cgroup.join("memory.events")) else {
return 0;
};
text.lines()
.filter_map(|l| l.strip_prefix("oom_kill "))
.filter_map(|n| n.trim().parse::<u64>().ok())
.next()
.unwrap_or(0)
}
#[cfg(not(target_os = "linux"))]
pub fn oom_count(_cgroup: &Path) -> u64 {
0
}
pub fn was_oom_killed(job_dir: &Path) -> bool {
if job_dir.join("oom").exists() {
return true;
}
match job_cgroup_path(job_dir) {
Some(cgroup) => cgroup_had_oom(&cgroup),
None => false,
}
}
pub fn mark_oom(job_dir: &Path) {
std::fs::write(job_dir.join("oom"), b"1").ok();
}
#[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::create_dir_all(&dir).unwrap();
assert!(
!was_oom_killed(&dir),
"a new job has no out-of-memory record"
);
mark_oom(&dir);
assert!(was_oom_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}"
);
}
}
}
#[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");
}
}
}
}