use std::{
path::{Path, PathBuf},
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
thread,
time::Duration,
};
pub const DEFAULT_MAX_PROCESSES: u64 = 128;
pub const DEFAULT_MAX_OPEN_FILES: u64 = 1024;
pub const DEFAULT_MAX_FILE_SIZE_BYTES: u64 = 4 * 1024 * 1024 * 1024;
pub const DEFAULT_CPU_SECONDS: u64 = 3600;
pub const DEFAULT_MEMORY_BYTES: u64 = 2 * 1024 * 1024 * 1024;
pub const DEFAULT_CGROUP_MEMORY_BYTES: u64 = 4 * 1024 * 1024 * 1024;
pub const DEFAULT_CGROUP_PIDS_MAX: u64 = 256;
pub const DEFAULT_CGROUP_CPU_PERCENT: u32 = 200;
pub const DEFAULT_CGROUP_IO_BYTES_PER_SEC: u64 = 100 * 1024 * 1024;
pub const DEFAULT_WORKDIR_MAX_BYTES: u64 = 10 * 1024 * 1024 * 1024;
pub(crate) const WORKDIR_CHECK_INTERVAL: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HostResourceLimits {
pub max_processes: u64,
pub max_open_files: u64,
pub max_file_size_bytes: u64,
pub cpu_seconds: u64,
pub memory_bytes: u64,
pub cgroup_memory_bytes: u64,
pub cgroup_pids_max: u64,
pub cgroup_cpu_percent: u32,
pub cgroup_io_bytes_per_sec: u64,
pub workdir_max_bytes: u64,
}
impl Default for HostResourceLimits {
fn default() -> Self {
Self {
max_processes: DEFAULT_MAX_PROCESSES,
max_open_files: DEFAULT_MAX_OPEN_FILES,
max_file_size_bytes: DEFAULT_MAX_FILE_SIZE_BYTES,
cpu_seconds: DEFAULT_CPU_SECONDS,
memory_bytes: DEFAULT_MEMORY_BYTES,
cgroup_memory_bytes: DEFAULT_CGROUP_MEMORY_BYTES,
cgroup_pids_max: DEFAULT_CGROUP_PIDS_MAX,
cgroup_cpu_percent: DEFAULT_CGROUP_CPU_PERCENT,
cgroup_io_bytes_per_sec: DEFAULT_CGROUP_IO_BYTES_PER_SEC,
workdir_max_bytes: DEFAULT_WORKDIR_MAX_BYTES,
}
}
}
impl HostResourceLimits {
#[must_use]
pub fn resolve(declared: Option<&murmur_artifact::ResourceCapabilities>) -> Self {
let defaults = Self::default();
let Some(declared) = declared else {
return defaults;
};
Self {
max_processes: declared.max_processes.unwrap_or(defaults.max_processes),
max_open_files: declared.max_open_files.unwrap_or(defaults.max_open_files),
max_file_size_bytes: declared
.max_file_size_bytes
.unwrap_or(defaults.max_file_size_bytes),
cpu_seconds: declared.cpu_seconds.unwrap_or(defaults.cpu_seconds),
memory_bytes: declared.memory_bytes.unwrap_or(defaults.memory_bytes),
cgroup_memory_bytes: declared
.cgroup_memory_bytes
.unwrap_or(defaults.cgroup_memory_bytes),
cgroup_pids_max: declared.cgroup_pids_max.unwrap_or(defaults.cgroup_pids_max),
cgroup_cpu_percent: declared
.cgroup_cpu_percent
.unwrap_or(defaults.cgroup_cpu_percent),
cgroup_io_bytes_per_sec: declared
.cgroup_io_bytes_per_sec
.unwrap_or(defaults.cgroup_io_bytes_per_sec),
workdir_max_bytes: declared
.workdir_max_bytes
.unwrap_or(defaults.workdir_max_bytes),
}
}
}
#[must_use]
pub(crate) fn resolve(
declared: Option<&murmur_artifact::ResourceCapabilities>,
) -> HostResourceLimits {
HostResourceLimits::resolve(declared)
}
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) fn limit_from_signal(signal: i32) -> Option<&'static str> {
match signal {
libc::SIGXCPU => Some("cpu_seconds"),
libc::SIGXFSZ => Some("max_file_size_bytes"),
_ => None,
}
}
pub(crate) fn resource_limit_line(limit: &str) -> String {
let explanation = match limit {
"cgroup_pids_max" => "the capsule reached its process limit while this command ran, so \
starting a new process failed (EAGAIN). Retry with fewer parallel processes"
.to_string(),
"cgroup_memory_bytes" => "the capsule reached its memory limit while this command ran, \
and the kernel killed a process in it to free memory. Retry using less memory at \
once (smaller inputs, fewer parallel jobs)"
.to_string(),
"cpu_seconds" => "this command used up its CPU-time limit and was stopped. Retry with \
less work per command"
.to_string(),
"max_file_size_bytes" => "this command tried to write a file larger than the capsule's \
file-size limit and was stopped. Retry writing smaller files"
.to_string(),
other => format!(
"this command was stopped by the capsule's capabilities.resources.{other} limit. \
Retry with less work"
),
};
format!(
"resource_limit: {limit} — {explanation}, or ask the operator to raise \
capabilities.resources.{limit}."
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NprocBound {
CgroupPidsMax,
Headroom { baseline: u64 },
}
#[cfg(unix)]
#[allow(unsafe_code)]
#[allow(clippy::unnecessary_cast)]
pub(crate) fn apply_hard_rlimits(
limits: &HostResourceLimits,
nproc: NprocBound,
) -> std::io::Result<()> {
if let NprocBound::Headroom { baseline } = nproc {
set_hard_rlimit(
libc::RLIMIT_NPROC as u32,
baseline.saturating_add(limits.max_processes),
)?;
}
set_hard_rlimit(libc::RLIMIT_NOFILE as u32, limits.max_open_files)?;
set_hard_rlimit(libc::RLIMIT_FSIZE as u32, limits.max_file_size_bytes)?;
set_hard_rlimit(libc::RLIMIT_CPU as u32, limits.cpu_seconds)?;
set_hard_rlimit(libc::RLIMIT_CORE as u32, 0)?;
#[cfg(target_os = "linux")]
set_hard_rlimit(libc::RLIMIT_AS as u32, limits.memory_bytes)?;
#[cfg(not(target_os = "linux"))]
let _ = set_hard_rlimit(libc::RLIMIT_DATA as u32, limits.memory_bytes);
Ok(())
}
#[cfg(unix)]
#[allow(unsafe_code)]
fn set_hard_rlimit(resource: u32, value: u64) -> std::io::Result<()> {
let mut current = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
if unsafe { libc::getrlimit(resource as _, &mut current) } != 0 {
return Err(std::io::Error::last_os_error());
}
let requested = libc::rlim_t::try_from(value).unwrap_or(libc::rlim_t::MAX);
let target = if current.rlim_max == libc::RLIM_INFINITY {
requested
} else {
requested.min(current.rlim_max)
};
let limit = libc::rlimit {
rlim_cur: target,
rlim_max: target,
};
if unsafe { libc::setrlimit(resource as _, &limit) } != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
#[cfg_attr(not(unix), allow(dead_code))]
#[allow(clippy::needless_return)]
pub(crate) fn uid_task_count() -> Option<u64> {
#[cfg(target_os = "linux")]
{
let uid = {
#[allow(unsafe_code)]
unsafe {
libc::geteuid()
}
};
return uid_task_count_in(Path::new("/proc"), uid);
}
#[cfg(target_os = "macos")]
{
const PROC_UID_ONLY: u32 = 4;
extern "C" {
fn proc_listpids(
r#type: u32,
typeinfo: u32,
buffer: *mut libc::c_void,
buffersize: libc::c_int,
) -> libc::c_int;
}
#[allow(unsafe_code)]
let bytes = unsafe {
let uid = libc::geteuid();
proc_listpids(PROC_UID_ONLY, uid, std::ptr::null_mut(), 0)
};
if bytes <= 0 {
return None;
}
return Some(bytes as u64 / std::mem::size_of::<u32>() as u64);
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
None
}
#[cfg(target_os = "linux")]
pub(crate) fn uid_task_count_in(proc_root: &Path, uid: u32) -> Option<u64> {
use std::os::unix::fs::MetadataExt;
let entries = std::fs::read_dir(proc_root).ok()?;
let count = entries
.flatten()
.filter(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| name.bytes().all(|b| b.is_ascii_digit()))
&& entry.metadata().is_ok_and(|metadata| metadata.uid() == uid)
})
.map(|entry| {
std::fs::read_dir(entry.path().join("task"))
.map(|threads| threads.flatten().count() as u64)
.unwrap_or(0)
})
.sum();
Some(count)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct WorkdirBreach {
pub(crate) max_bytes: u64,
pub(crate) observed_bytes: u64,
}
#[derive(Debug)]
pub(crate) struct WorkdirGuard {
max_bytes: u64,
breach: Mutex<Option<WorkdirBreach>>,
stop: AtomicBool,
}
impl WorkdirGuard {
pub(crate) fn spawn(workdir: &Path, max_bytes: u64) -> Arc<Self> {
let guard = Arc::new(Self {
max_bytes,
breach: Mutex::new(None),
stop: AtomicBool::new(false),
});
let weak = Arc::downgrade(&guard);
let workdir = workdir.to_path_buf();
thread::spawn(move || loop {
thread::sleep(WORKDIR_CHECK_INTERVAL);
let Some(guard) = weak.upgrade() else {
break;
};
if guard.stop.load(Ordering::Relaxed) || guard.breach().is_some() {
break;
}
let observed = directory_size_bytes(&workdir);
if observed > guard.max_bytes {
guard.record_breach(&workdir, observed);
break;
}
});
guard
}
pub(crate) fn breach(&self) -> Option<WorkdirBreach> {
self.breach.lock().ok().and_then(|breach| *breach)
}
fn record_breach(&self, workdir: &Path, observed_bytes: u64) {
let breach = WorkdirBreach {
max_bytes: self.max_bytes,
observed_bytes,
};
if let Ok(mut slot) = self.breach.lock() {
if slot.is_none() {
*slot = Some(breach);
}
}
let message = workdir_breach_message(breach);
crate::runtime_err!("[capsule-runtime] {message}");
crate::agent::append_bootstrap_log(workdir, &format!("[resources] {message}"));
}
}
impl Drop for WorkdirGuard {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
}
}
pub(crate) fn workdir_breach_message(breach: WorkdirBreach) -> String {
format!(
"workdir grew to {} bytes, past the {} byte ceiling \
(capabilities.resources.workdir_max_bytes)",
breach.observed_bytes, breach.max_bytes
)
}
pub(crate) fn directory_size_bytes(root: &Path) -> u64 {
let mut total: u64 = 0;
let mut stack: Vec<PathBuf> = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(metadata) = entry.path().symlink_metadata() else {
continue;
};
if metadata.is_dir() {
stack.push(entry.path());
} else {
total = total.saturating_add(metadata.len());
}
}
}
total
}
#[cfg(all(test, target_os = "linux"))]
pub(crate) mod test_support {
use std::fs::{File, OpenOptions};
use std::sync::{Arc, Barrier};
pub(crate) fn host_threads_lock() -> File {
let path = std::env::temp_dir().join("murmur-capsule-runtime-host-threads.lock");
let file = OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&path)
.unwrap_or_else(|error| panic!("opening {}: {error}", path.display()));
file.lock()
.unwrap_or_else(|error| panic!("locking {}: {error}", path.display()));
file
}
pub(crate) struct HeldThreads {
release: Arc<Barrier>,
handles: Vec<std::thread::JoinHandle<()>>,
}
impl HeldThreads {
pub(crate) fn start(count: usize) -> Self {
let started = Arc::new(Barrier::new(count + 1));
let release = Arc::new(Barrier::new(count + 1));
let handles = (0..count)
.map(|_| {
let started = Arc::clone(&started);
let release = Arc::clone(&release);
std::thread::Builder::new()
.stack_size(64 * 1024)
.spawn(move || {
started.wait();
release.wait();
})
.expect("a holder thread must start")
})
.collect();
started.wait();
Self { release, handles }
}
}
impl Drop for HeldThreads {
fn drop(&mut self) {
self.release.wait();
for handle in self.handles.drain(..) {
let _ = handle.join();
}
}
}
#[allow(unsafe_code)]
pub(crate) fn own_rlimit_nproc() -> (libc::rlim_t, libc::rlim_t) {
let mut current = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
assert_eq!(
unsafe { libc::getrlimit(libc::RLIMIT_NPROC, &mut current) },
0
);
(current.rlim_cur, current.rlim_max)
}
pub(crate) fn rendered_rlimit(value: libc::rlim_t) -> String {
if value == libc::RLIM_INFINITY {
"unlimited".to_string()
} else {
value.to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_uses_defaults_when_manifest_is_silent() {
assert_eq!(
HostResourceLimits::resolve(None),
HostResourceLimits::default()
);
}
#[test]
fn resolve_fills_each_omitted_field_independently() {
let declared = murmur_artifact::ResourceCapabilities {
max_open_files: Some(16),
cgroup_pids_max: Some(32),
..murmur_artifact::ResourceCapabilities::default()
};
let limits = HostResourceLimits::resolve(Some(&declared));
assert_eq!(limits.max_open_files, 16);
assert_eq!(limits.cgroup_pids_max, 32);
assert_eq!(limits.max_processes, DEFAULT_MAX_PROCESSES);
assert_eq!(limits.max_file_size_bytes, DEFAULT_MAX_FILE_SIZE_BYTES);
assert_eq!(limits.cpu_seconds, DEFAULT_CPU_SECONDS);
assert_eq!(limits.memory_bytes, DEFAULT_MEMORY_BYTES);
assert_eq!(limits.cgroup_memory_bytes, DEFAULT_CGROUP_MEMORY_BYTES);
assert_eq!(limits.cgroup_cpu_percent, DEFAULT_CGROUP_CPU_PERCENT);
assert_eq!(
limits.cgroup_io_bytes_per_sec,
DEFAULT_CGROUP_IO_BYTES_PER_SEC
);
assert_eq!(limits.workdir_max_bytes, DEFAULT_WORKDIR_MAX_BYTES);
}
#[test]
fn free_resolve_matches_the_inherent_one() {
let declared = murmur_artifact::ResourceCapabilities {
cpu_seconds: Some(5),
..murmur_artifact::ResourceCapabilities::default()
};
assert_eq!(
resolve(Some(&declared)),
HostResourceLimits::resolve(Some(&declared))
);
}
#[test]
fn only_the_two_unambiguous_signals_name_a_limit() {
assert_eq!(limit_from_signal(libc::SIGXCPU), Some("cpu_seconds"));
assert_eq!(
limit_from_signal(libc::SIGXFSZ),
Some("max_file_size_bytes")
);
for ambiguous in [libc::SIGKILL, libc::SIGSEGV, libc::SIGABRT, libc::SIGTERM] {
assert_eq!(
limit_from_signal(ambiguous),
None,
"signal {ambiguous} has more than one possible cause and must stay unattributed"
);
}
}
#[test]
fn every_attributable_limit_has_its_own_line() {
use crate::cgroup::CgroupEventCounters;
let cpu = limit_from_signal(libc::SIGXCPU).unwrap();
let fsize = limit_from_signal(libc::SIGXFSZ).unwrap();
let memory = CgroupEventCounters {
oom_kill: 1,
pids_max: 0,
}
.attribution_since(CgroupEventCounters::default())
.unwrap();
let pids = CgroupEventCounters {
oom_kill: 0,
pids_max: 1,
}
.attribution_since(CgroupEventCounters::default())
.unwrap();
let expected = [
(
pids,
"resource_limit: cgroup_pids_max — the capsule reached its process limit while \
this command ran, so starting a new process failed (EAGAIN). Retry with fewer \
parallel processes, or ask the operator to raise \
capabilities.resources.cgroup_pids_max.",
),
(
memory,
"resource_limit: cgroup_memory_bytes — the capsule reached its memory limit while \
this command ran, and the kernel killed a process in it to free memory. Retry \
using less memory at once (smaller inputs, fewer parallel jobs), or ask the \
operator to raise capabilities.resources.cgroup_memory_bytes.",
),
(
cpu,
"resource_limit: cpu_seconds — this command used up its CPU-time limit and was \
stopped. Retry with less work per command, or ask the operator to raise \
capabilities.resources.cpu_seconds.",
),
(
fsize,
"resource_limit: max_file_size_bytes — this command tried to write a file larger \
than the capsule's file-size limit and was stopped. Retry writing smaller files, \
or ask the operator to raise capabilities.resources.max_file_size_bytes.",
),
];
for (name, text) in expected {
let line = resource_limit_line(name);
assert_eq!(line, text);
assert!(line.starts_with(&format!("resource_limit: {name} — ")));
assert!(line.contains(&format!("capabilities.resources.{name}")));
assert!(line.contains("ask the operator to raise"));
assert!(!line.contains('\n'), "{line:?} must be a single line");
}
for cgroup in [pids, memory] {
let line = resource_limit_line(cgroup);
assert!(line.contains("the capsule"), "{line}");
assert!(line.contains("while this command ran"), "{line}");
}
for signal in [cpu, fsize] {
assert!(resource_limit_line(signal).contains("this command"));
}
assert_eq!(
resource_limit_line("some_future_limit"),
"resource_limit: some_future_limit — this command was stopped by the capsule's \
capabilities.resources.some_future_limit limit. Retry with less work, or ask the \
operator to raise capabilities.resources.some_future_limit."
);
}
#[test]
fn directory_size_sums_nested_files() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("a"), vec![0u8; 100]).unwrap();
std::fs::create_dir(temp.path().join("nested")).unwrap();
std::fs::write(temp.path().join("nested").join("b"), vec![0u8; 250]).unwrap();
assert_eq!(directory_size_bytes(temp.path()), 350);
}
#[test]
fn directory_size_of_a_missing_path_is_zero() {
assert_eq!(
directory_size_bytes(Path::new("/nonexistent-murmur-workdir-probe")),
0
);
}
#[test]
fn guard_reports_no_breach_for_a_workdir_under_the_ceiling() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("small"), vec![0u8; 64]).unwrap();
let guard = WorkdirGuard::spawn(temp.path(), DEFAULT_WORKDIR_MAX_BYTES);
assert_eq!(guard.breach(), None);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn uid_task_count_reports_a_plausible_baseline() {
let count = uid_task_count().expect("this platform can be asked for its task count");
assert!(
count > 0,
"the test process itself is owned by this uid, so the count cannot be zero"
);
}
#[cfg(target_os = "linux")]
#[test]
fn uid_task_count_sums_threads_not_processes() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
for (pid, threads) in [("100", 1), ("200", 4), ("300", 2), ("self", 5)] {
let task = root.join(pid).join("task");
std::fs::create_dir_all(&task).unwrap();
for thread in 0..threads {
std::fs::write(task.join(thread.to_string()), b"").unwrap();
}
}
std::fs::create_dir(root.join("400")).unwrap();
let own_uid = {
use std::os::unix::fs::MetadataExt;
std::fs::metadata(root.join("100")).unwrap().uid()
};
assert_eq!(uid_task_count_in(root, own_uid), Some(7));
assert_eq!(uid_task_count_in(root, own_uid.wrapping_add(1)), Some(0));
assert_eq!(uid_task_count_in(&root.join("missing"), own_uid), None);
}
#[cfg(unix)]
#[test]
#[allow(unsafe_code)]
#[allow(clippy::unnecessary_cast)]
fn set_hard_rlimit_writes_both_the_soft_and_hard_slot() {
set_hard_rlimit(libc::RLIMIT_CORE as u32, 0).unwrap();
let mut current = libc::rlimit {
rlim_cur: 1,
rlim_max: 1,
};
assert_eq!(
unsafe { libc::getrlimit(libc::RLIMIT_CORE as _, &mut current) },
0
);
assert_eq!(current.rlim_cur, 0);
assert_eq!(
current.rlim_max, 0,
"the hard slot must be set, not only the soft one"
);
}
}