use std::ffi::{CStr, CString};
use std::io;
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
use std::os::unix::ffi::{OsStrExt, OsStringExt};
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tokio::process::{Child, Command};
use crate::Mechanism;
#[cfg(feature = "process-control")]
use crate::Signal;
#[cfg(feature = "limits")]
use crate::limits::{CappedAxes, LimitEvidence, LimitKind, LimitVerdict, ResourceLimits};
#[cfg(feature = "process-control")]
use crate::member::MemberInfo;
#[cfg(feature = "stats")]
use crate::stats::ProcessGroupStats;
use crate::sys::pgroup::ProcessGroup;
#[cfg(feature = "stats")]
use crate::sys::{ProcIdentity, ProcMetrics};
static NEXT_ID: AtomicU64 = AtomicU64::new(0);
fn cgroup_name_salt() -> u64 {
static SALT: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
*SALT.get_or_init(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0)
})
}
pub(crate) struct Job {
backend: Backend,
skip_drop_kill: super::SkipDropKill,
}
enum Backend {
Cgroup(Cgroup),
ProcessGroup(ProcessGroup),
}
fn warn_containment_degraded_once() {
#[cfg(feature = "tracing")]
{
use std::sync::Once;
static WARNED: Once = Once::new();
WARNED.call_once(|| {
tracing::warn!(
target: "processkit",
"cgroup v2 unavailable — containment degraded to the POSIX \
process-group fallback; a child that calls setsid can escape \
teardown. Fires once per process (per-spawn detail is at debug)."
);
});
}
}
impl Job {
pub(crate) fn new(#[cfg(feature = "limits")] limits: &ResourceLimits) -> io::Result<Self> {
let backend = match Cgroup::create(
#[cfg(feature = "limits")]
limits,
) {
Ok(cg) => Backend::Cgroup(cg),
Err(_e) => {
#[cfg(feature = "limits")]
if limits.any() {
return Err(_e);
}
warn_containment_degraded_once();
Backend::ProcessGroup(ProcessGroup::new())
}
};
Ok(Job {
backend,
skip_drop_kill: super::SkipDropKill::new(),
})
}
pub(crate) fn spawn(
&self,
cmd: &mut Command,
opts: &crate::sys::SpawnOptions,
) -> io::Result<Child> {
self.spawn_displacing_spare(cmd, opts)
.map(|(child, _displaced)| child)
}
pub(crate) fn spawn_displacing_spare(
&self,
cmd: &mut Command,
opts: &crate::sys::SpawnOptions,
) -> io::Result<(Child, super::DisplacedSpare)> {
let arm = |cmd: &mut Command| {
if opts.kill_on_parent_death {
let spawner_pid = std::process::id();
unsafe {
cmd.as_std_mut()
.pre_exec(move || arm_pdeathsig(spawner_pid));
}
}
};
match &self.backend {
Backend::Cgroup(cg) => {
let leaf = cg.open_leaf();
let procs =
CString::new(leaf.procs_path().into_os_string().into_vec()).map_err(|_| {
io::Error::new(io::ErrorKind::InvalidInput, "cgroup path contains NUL")
})?;
unsafe {
cmd.as_std_mut()
.pre_exec(move || write_self_pid(procs.as_c_str()));
}
arm(cmd);
let child = cmd.spawn()?;
leaf.commit(child.id().map(|pid| pid as i32));
let displaced = self.skip_drop_kill.clear();
Ok((child, displaced))
}
Backend::ProcessGroup(pg) => {
arm(cmd);
pg.spawn_displacing_spare(cmd, opts)
}
}
}
#[cfg(feature = "pty")]
pub(crate) fn spawn_pty(
&self,
cmd: &mut Command,
opts: &crate::sys::SpawnOptions,
_env: Option<Vec<(std::ffi::OsString, std::ffi::OsString)>>,
) -> io::Result<crate::sys::pty::PtySpawn> {
let displaced = std::cell::Cell::new(super::DisplacedSpare::default());
crate::sys::pty::spawn_pty(
cmd,
opts,
|c, o| {
let (child, spare) = self.spawn_displacing_spare(c, o)?;
displaced.set(spare);
Ok(child)
},
|pid| self.rollback_pty_spawn(pid, displaced.take()),
)
}
#[cfg(feature = "pty")]
pub(crate) fn rollback_pty_spawn(&self, pid: u32, displaced: super::DisplacedSpare) {
match &self.backend {
Backend::Cgroup(cg) => {
if !cg.kill_leaf_of(pid as i32) {
crate::sys::pgroup::hard_kill_fresh_spawn(pid as i32);
}
self.skip_drop_kill.restore(displaced);
}
Backend::ProcessGroup(pg) => pg.rollback_pty_spawn(pid, displaced),
}
}
#[cfg(feature = "process-control")]
pub(crate) fn adopt(&self, child: &Child) -> io::Result<()> {
let pid = child
.id()
.ok_or_else(|| io::Error::other("child has no pid (already exited?)"))?
as i32;
match &self.backend {
Backend::Cgroup(cg) => {
match cgroup_write(&cg.path.join("cgroup.procs"), pid.to_string().as_bytes()) {
Ok(()) => {
self.skip_drop_kill.clear();
Ok(())
}
Err(e) if e.raw_os_error() == Some(libc::ESRCH) => Ok(()),
Err(e) => Err(e),
}
}
Backend::ProcessGroup(pg) => pg.adopt(child),
}
}
#[cfg(feature = "process-control")]
pub(crate) fn adopt_external(&self, pid: u32) -> io::Result<()> {
match &self.backend {
Backend::Cgroup(cg) => {
let anchor = capture_adoption_anchor(pid)?;
match cgroup_write(&cg.path.join("cgroup.procs"), pid.to_string().as_bytes()) {
Ok(()) => {
if crate::sys::pgroup::is_recycled(
Some(anchor),
crate::sys::procfs::read_starttime(pid),
) {
return Err(recycled_during_cgroup_adoption(
pid,
cg.evict_recycled(pid),
));
}
self.skip_drop_kill.clear();
Ok(())
}
Err(e) if e.raw_os_error() == Some(libc::ESRCH) => Ok(()),
Err(e) => Err(e),
}
}
Backend::ProcessGroup(pg) => pg.adopt_external(pid),
}
}
pub(crate) fn kill_all(&self) -> io::Result<()> {
match &self.backend {
Backend::Cgroup(cg) => cg.kill(),
Backend::ProcessGroup(pg) => pg.kill_all(),
}
}
#[cfg(feature = "limits")]
pub(crate) fn update_limits(&self, limits: &ResourceLimits) -> io::Result<()> {
match &self.backend {
Backend::Cgroup(cg) => cg.update_limits(limits),
Backend::ProcessGroup(_) => {
if limits.any() {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"resource limits require a cgroup or Job Object; this group fell back to \
a POSIX process group, which has no whole-tree resource accounting",
))
} else {
Ok(())
}
}
}
}
#[cfg(feature = "limits")]
pub(crate) fn limit_evidence(&self, capped: CappedAxes) -> LimitEvidence {
match &self.backend {
Backend::Cgroup(cg) => cg.limit_evidence(capped),
Backend::ProcessGroup(_) => LimitEvidence::unknown(),
}
}
#[cfg(feature = "process-control")]
pub(crate) fn signal(&self, sig: Signal) -> io::Result<()> {
match &self.backend {
Backend::Cgroup(cg) if sig.raw() == libc::SIGKILL => cg.kill(),
Backend::Cgroup(cg) => cg.signal(sig.raw()),
Backend::ProcessGroup(pg) => pg.signal(sig.raw()),
}
}
#[cfg(feature = "process-control")]
pub(crate) fn soft_stop_scope(&self) -> crate::SoftStopScope {
crate::SoftStopScope::WholeTree
}
#[cfg(feature = "process-control")]
pub(crate) fn suspend(&self) -> io::Result<()> {
match &self.backend {
Backend::Cgroup(cg) => cg.freeze(true),
Backend::ProcessGroup(pg) => pg.suspend(),
}
}
#[cfg(feature = "process-control")]
pub(crate) fn resume(&self) -> io::Result<()> {
match &self.backend {
Backend::Cgroup(cg) => cg.freeze(false),
Backend::ProcessGroup(pg) => pg.resume(),
}
}
#[cfg(feature = "process-control")]
pub(crate) fn members(&self) -> io::Result<Vec<u32>> {
let pids = match &self.backend {
Backend::Cgroup(cg) => cg.members()?,
Backend::ProcessGroup(pg) => pg.members(),
};
Ok(pids.into_iter().map(|pid| pid as u32).collect())
}
#[cfg(feature = "process-control")]
pub(crate) fn members_info(&self) -> io::Result<Vec<MemberInfo>> {
match &self.backend {
Backend::Cgroup(cg) => cg.members_info(),
Backend::ProcessGroup(pg) => Ok(pg.members_info()),
}
}
pub(crate) async fn graceful_shutdown(
&self,
signal: i32,
timeout: Duration,
escalate: bool,
) -> io::Result<super::graceful::GracefulOutcome> {
match &self.backend {
Backend::Cgroup(cg) => {
super::graceful::run(cg, &self.skip_drop_kill, signal, timeout, escalate).await
}
Backend::ProcessGroup(pg) => pg.graceful_shutdown(signal, timeout, escalate).await,
}
}
#[cfg(feature = "stats")]
pub(crate) fn stats(&self) -> io::Result<ProcessGroupStats> {
match &self.backend {
Backend::Cgroup(cg) => cg.stats(),
Backend::ProcessGroup(pg) => pg.stats(),
}
}
pub(crate) fn mechanism(&self) -> Mechanism {
match &self.backend {
Backend::Cgroup(_) => Mechanism::CgroupV2,
Backend::ProcessGroup(_) => Mechanism::ProcessGroup,
}
}
}
#[cfg(feature = "process-control")]
pub(crate) fn process_info(pid: u32) -> io::Result<Option<MemberInfo>> {
Ok(crate::sys::procfs::read_stat_meta_checked(pid)?
.map(|m| MemberInfo::new(pid, m.ppid, m.comm, m.starttime)))
}
#[cfg(feature = "process-control")]
fn capture_adoption_anchor(pid: u32) -> io::Result<u64> {
match crate::sys::procfs::read_stat_meta_checked(pid)? {
None => Err(io::Error::new(
io::ErrorKind::NotFound,
format!("no process with pid {pid} to adopt"),
)),
Some(meta) => meta.starttime.ok_or_else(|| {
io::Error::other(format!(
"cannot adopt pid {pid}: /proc/{pid}/stat yielded no start-time identity, and \
this group will not track an external process by number alone"
))
}),
}
}
#[cfg(feature = "process-control")]
enum RecycleUndo {
NotAMember,
Evicted,
Stuck(io::Error),
}
#[cfg(feature = "process-control")]
fn recycled_during_cgroup_adoption(pid: u32, undo: RecycleUndo) -> io::Error {
let aftermath = match undo {
RecycleUndo::NotAMember => "the number is not a member of this group's cgroup, so this \
group's teardown will not reach whoever holds it now"
.to_string(),
RecycleUndo::Evicted => "the migration was undone — the number was moved back out of this \
group's cgroup, into the cgroup this group's own directory lives \
in — so this group's teardown will not reach it; the cgroup it \
was in before this call is NOT restored, because cgroup v2 \
membership is exclusive and the kernel does not report what a \
task left behind"
.to_string(),
RecycleUndo::Stuck(e) => format!(
"the number could NOT be moved back out of this group's cgroup ({e}), so whoever \
holds it is a member of this group and this group's teardown — kill_all, shutdown, \
Drop — will kill it"
),
};
io::Error::other(format!(
"pid {pid} was recycled while it was being adopted: its start-time identity differs from \
the one captured at the start of the call, so the process the caller named is not the \
one this call acted on — {aftermath}"
))
}
#[cfg(feature = "stats")]
fn read_proc_starttime(pid: u32) -> Option<u64> {
crate::sys::procfs::read_starttime(pid)
}
#[cfg(feature = "stats")]
pub(crate) fn process_identity(pid: u32) -> Option<ProcIdentity> {
read_proc_starttime(pid).map(ProcIdentity::from_raw)
}
#[cfg(feature = "stats")]
pub(crate) fn process_metrics(pid: u32, expected: Option<ProcIdentity>) -> ProcMetrics {
process_metrics_with_seams(
pid,
expected,
|pid| std::fs::read_to_string(format!("/proc/{pid}/stat")).ok(),
|pid| std::fs::read_to_string(format!("/proc/{pid}/status")).ok(),
)
}
#[cfg(feature = "stats")]
fn process_metrics_with_seams(
pid: u32,
expected: Option<ProcIdentity>,
mut read_stat: impl FnMut(u32) -> Option<String>,
read_status: impl FnOnce(u32) -> Option<String>,
) -> ProcMetrics {
let mut metrics = ProcMetrics::default();
let stat = read_stat(pid);
if let Some(expected) = expected {
let current = stat
.as_deref()
.and_then(crate::sys::procfs::starttime_from_stat);
if current != Some(expected.raw()) {
return ProcMetrics::default();
}
}
let fields: Option<Vec<&str>> = stat
.as_deref()
.and_then(crate::sys::procfs::after_comm)
.map(|after| after.split_whitespace().collect());
if let Some(fields) = &fields {
if fields.len() > 12
&& let (Ok(utime), Ok(stime)) = (fields[11].parse::<u64>(), fields[12].parse::<u64>())
{
let hz = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
if hz > 0 {
let ticks = utime.saturating_add(stime);
let nanos = ticks as u128 * 1_000_000_000u128 / hz as u128;
metrics.cpu_time = Some(Duration::from_nanos(nanos.min(u64::MAX as u128) as u64));
}
}
}
if let Some(status) = read_status(pid) {
if let Some(expected) = expected
&& read_stat(pid)
.as_deref()
.and_then(crate::sys::procfs::starttime_from_stat)
!= Some(expected.raw())
{
return ProcMetrics::default();
}
for line in status.lines() {
if let Some(rest) = line.strip_prefix("VmHWM:") {
if let Some(kb) = rest
.split_whitespace()
.next()
.and_then(|s| s.parse::<u64>().ok())
{
metrics.peak_memory_bytes = Some(kb.saturating_mul(1024));
}
break;
}
}
}
metrics
}
impl Drop for Job {
fn drop(&mut self) {
match &self.backend {
Backend::Cgroup(cg) => {
if !self.skip_drop_kill.is_set() {
let _ = cg.kill();
for _ in 0..50 {
if let Ok(true) = cg.is_empty() {
break;
}
std::thread::sleep(Duration::from_millis(2));
}
}
cg.reclaim_leaves();
let _ = std::fs::remove_dir(&cg.path);
}
Backend::ProcessGroup(_) => {}
}
}
}
fn cgroup2_root() -> Option<PathBuf> {
for candidate in ["/sys/fs/cgroup", "/sys/fs/cgroup/unified"] {
let root = Path::new(candidate);
if root.join("cgroup.controllers").exists() {
return Some(root.to_path_buf());
}
}
None
}
fn cgroup2_self_dir(root: &Path) -> io::Result<PathBuf> {
let self_cgroup = std::fs::read_to_string("/proc/self/cgroup")?;
let rel = self_cgroup
.lines()
.find_map(|line| line.strip_prefix("0::"))
.unwrap_or("/")
.trim();
Ok(root.join(rel.trim_start_matches('/')))
}
fn dir_allows_subdir_creation(dir: &Path) -> bool {
access_ok(dir, libc::W_OK | libc::X_OK)
}
fn access_ok(path: &Path, mode: libc::c_int) -> bool {
let Ok(c_path) = CString::new(path.as_os_str().as_bytes()) else {
return false;
};
let rc = unsafe { libc::faccessat(libc::AT_FDCWD, c_path.as_ptr(), mode, libc::AT_EACCESS) };
rc == 0
}
pub(crate) fn detect_mechanism() -> Mechanism {
let Some(root) = cgroup2_root() else {
return Mechanism::ProcessGroup;
};
match cgroup2_self_dir(&root) {
Ok(parent) if dir_allows_subdir_creation(&parent) => Mechanism::CgroupV2,
_ => Mechanism::ProcessGroup,
}
}
fn cgroup_write(path: &Path, contents: impl AsRef<[u8]>) -> io::Result<()> {
#[cfg(test)]
if let Some(injected) = crate::sys::fault_injection::check(
crate::sys::fault_injection::Site::CgroupWrite,
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default(),
) {
return Err(injected);
}
std::fs::write(path, contents)
}
fn read_member_pids(
dir: &Path,
read: impl Fn(&Path) -> io::Result<String>,
) -> io::Result<Vec<i32>> {
match read(&dir.join("cgroup.procs")) {
Ok(procs) => Ok(procs
.lines()
.filter_map(|l| l.trim().parse::<i32>().ok())
.filter(|&pid| pid > 0)
.collect()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e),
}
}
struct Cgroup {
path: PathBuf,
leaves: Mutex<Leaves>,
}
struct Leaves {
live: Vec<Leaf>,
reclaim_at: usize,
}
struct Leaf {
pid: Option<i32>,
dir: PathBuf,
}
const LEAF_RECLAIM_FLOOR: usize = 16;
impl Leaves {
const fn new() -> Self {
Leaves {
live: Vec::new(),
reclaim_at: LEAF_RECLAIM_FLOOR,
}
}
fn reclaim(&mut self) {
self.live
.retain(|leaf| match std::fs::remove_dir(&leaf.dir) {
Ok(()) => false,
Err(e) => e.kind() != io::ErrorKind::NotFound,
});
self.reclaim_at = self.live.len().saturating_mul(2).max(LEAF_RECLAIM_FLOOR);
}
}
struct LeafSlot<'a> {
cg: &'a Cgroup,
dir: Option<PathBuf>,
}
impl LeafSlot<'_> {
fn procs_path(&self) -> PathBuf {
self.dir
.as_deref()
.unwrap_or(&self.cg.path)
.join("cgroup.procs")
}
fn commit(mut self, pid: Option<i32>) {
if let Some(dir) = self.dir.take() {
self.cg.register_leaf(pid, dir);
}
}
}
impl Drop for LeafSlot<'_> {
fn drop(&mut self) {
if let Some(dir) = self.dir.take() {
match std::fs::remove_dir(&dir) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(_) => self.cg.register_leaf(None, dir),
}
}
}
}
impl Cgroup {
fn at(path: PathBuf) -> Self {
Cgroup {
path,
leaves: Mutex::new(Leaves::new()),
}
}
fn leaves(&self) -> std::sync::MutexGuard<'_, Leaves> {
self.leaves.lock().unwrap_or_else(|e| e.into_inner())
}
fn leaf_dirs(&self) -> Vec<PathBuf> {
self.leaves()
.live
.iter()
.map(|leaf| leaf.dir.clone())
.collect()
}
fn open_leaf(&self) -> LeafSlot<'_> {
let dir = self
.path
.join(format!("spawn-{}", NEXT_ID.fetch_add(1, Ordering::Relaxed)));
if std::fs::create_dir(&dir).is_err() {
return LeafSlot {
cg: self,
dir: None,
};
}
if !access_ok(&dir.join("cgroup.procs"), libc::W_OK) {
let _ = std::fs::remove_dir(&dir);
return LeafSlot {
cg: self,
dir: None,
};
}
LeafSlot {
cg: self,
dir: Some(dir),
}
}
fn register_leaf(&self, pid: Option<i32>, dir: PathBuf) {
let mut leaves = self.leaves();
if let Some(pid) = pid {
for leaf in &mut leaves.live {
if leaf.pid == Some(pid) {
leaf.pid = None;
}
}
}
leaves.live.push(Leaf { pid, dir });
if leaves.live.len() >= leaves.reclaim_at {
leaves.reclaim();
}
}
#[cfg(feature = "pty")]
fn kill_leaf_of(&self, pid: i32) -> bool {
let dir = {
let mut leaves = self.leaves();
let Some(leaf) = leaves.live.iter_mut().find(|leaf| leaf.pid == Some(pid)) else {
return false;
};
leaf.pid = None;
leaf.dir.clone()
};
if cgroup_write(&dir.join("cgroup.kill"), b"1").is_err() {
return false;
}
self.reclaim_leaves();
true
}
fn reclaim_leaves(&self) {
self.leaves().reclaim();
}
#[cfg(feature = "process-control")]
fn evict_recycled(&self, pid: u32) -> RecycleUndo {
let members = match read_member_pids(&self.path, |path| std::fs::read_to_string(path)) {
Ok(members) => members,
Err(e) => return RecycleUndo::Stuck(e),
};
if !members
.iter()
.any(|&member| u32::try_from(member).is_ok_and(|member| member == pid))
{
return RecycleUndo::NotAMember;
}
let Some(parent) = self.path.parent() else {
return RecycleUndo::Stuck(io::Error::other(
"this group's cgroup has no parent directory to move the number back out into",
));
};
match cgroup_write(&parent.join("cgroup.procs"), pid.to_string().as_bytes()) {
Ok(()) => RecycleUndo::Evicted,
Err(e) if e.raw_os_error() == Some(libc::ESRCH) => RecycleUndo::NotAMember,
Err(e) => RecycleUndo::Stuck(e),
}
}
fn create(#[cfg(feature = "limits")] limits: &ResourceLimits) -> io::Result<Self> {
let root = cgroup2_root()
.ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, "cgroup v2 not mounted"))?;
let root = root.as_path();
let parent = cgroup2_self_dir(root)?;
let salt = cgroup_name_salt();
let mut created = None;
for _ in 0..32 {
let name = format!(
"processkit-{}-{:x}-{}",
std::process::id(),
salt,
NEXT_ID.fetch_add(1, Ordering::Relaxed)
);
let path = parent.join(name);
match std::fs::create_dir(&path) {
Ok(()) => {
created = Some(path);
break;
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
let path = created.ok_or_else(|| {
io::Error::new(
io::ErrorKind::AlreadyExists,
"could not create a unique cgroup directory after retries",
)
})?;
let cg = Cgroup::at(path);
#[cfg(feature = "limits")]
if limits.any()
&& let Err(e) = cg.apply_limits(&parent, limits)
{
let _ = std::fs::remove_dir(&cg.path);
return Err(e);
}
Ok(cg)
}
#[cfg(feature = "limits")]
fn apply_limits(&self, parent: &Path, limits: &ResourceLimits) -> io::Result<()> {
self.enable_controllers(parent, &needed_controllers(limits))?;
if let Some(bytes) = limits.max_memory {
cgroup_write(&self.path.join("memory.max"), bytes.to_string())?;
}
if let Some(n) = limits.max_processes {
cgroup_write(&self.path.join("pids.max"), n.to_string())?;
}
if let Some(cores) = limits.cpu_quota {
cgroup_write(&self.path.join("cpu.max"), cpu_max_value(cores))?;
}
Ok(())
}
#[cfg(feature = "limits")]
fn update_limits(&self, limits: &ResourceLimits) -> io::Result<()> {
let parent = self.path.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::NotFound,
"cgroup directory has no parent — cannot resolve subtree_control",
)
})?;
self.enable_controllers(parent, &needed_controllers(limits))?;
write_limit_reset(
&self.path.join("memory.max"),
limits.max_memory.map(|b| b.to_string()),
)?;
write_limit_reset(
&self.path.join("pids.max"),
limits.max_processes.map(|n| n.to_string()),
)?;
write_limit_reset(
&self.path.join("cpu.max"),
limits.cpu_quota.map(cpu_max_value),
)?;
Ok(())
}
#[cfg(feature = "limits")]
fn limit_evidence(&self, capped: CappedAxes) -> LimitEvidence {
self.limit_evidence_with(capped, |path| std::fs::read_to_string(path))
}
#[cfg(feature = "limits")]
fn limit_evidence_with(
&self,
capped: CappedAxes,
read: impl Fn(&Path) -> io::Result<String>,
) -> LimitEvidence {
let axis = |kind: LimitKind, files: &[&str], key: &str| -> LimitVerdict {
if !capped.has(kind) {
return LimitVerdict::NotTripped;
}
for file in files {
if let Ok(text) = read(&self.path.join(file)) {
return match flat_keyed_value(&text, key) {
Some(0) => LimitVerdict::NotTripped,
Some(_) => LimitVerdict::Tripped,
None => LimitVerdict::Unknown,
};
}
}
LimitVerdict::Unknown
};
LimitEvidence::new(
axis(
LimitKind::Memory,
&["memory.events.local", "memory.events"],
"oom",
),
axis(
LimitKind::Processes,
&["pids.events.local", "pids.events"],
"max",
),
axis(LimitKind::Cpu, &["cpu.stat"], "nr_throttled"),
)
}
#[cfg(feature = "limits")]
fn enable_controllers(&self, parent: &Path, needed: &[&str]) -> io::Result<()> {
let enabled =
std::fs::read_to_string(parent.join("cgroup.subtree_control")).unwrap_or_default();
let to_enable = controllers_to_enable(needed, &enabled);
if !to_enable.is_empty() {
let spec = to_enable
.iter()
.map(|c| format!("+{c}"))
.collect::<Vec<_>>()
.join(" ");
let file = parent.join("cgroup.subtree_control");
cgroup_write(&file, &spec).map_err(|e| {
io::Error::new(
e.kind(),
format!(
"enabling cgroup controllers ({spec}) in {} failed: {e}. cgroup v2's \
'no internal processes' rule forbids enabling controllers in a cgroup \
that holds member processes (except the real hierarchy root), and this \
process is a member of that cgroup — so processkit's resource limits \
apply only when this process runs at the real cgroup-v2 root, not under \
a systemd session/scope/service nor an ordinary (private-cgroupns) \
container, both of which place it in a non-root cgroup. (A cgroup \
namespace root does not count — it only virtualizes the view.) processkit \
does not migrate your process into a sub-cgroup to satisfy the rule; \
arrange that externally (the create-leaf/migrate-self/enable dance) if \
you need limits there.",
file.display()
),
)
})?;
}
Ok(())
}
fn members(&self) -> io::Result<Vec<i32>> {
self.members_with(|path| std::fs::read_to_string(path))
}
fn members_with(&self, read: impl Fn(&Path) -> io::Result<String>) -> io::Result<Vec<i32>> {
let mut pids = read_member_pids(&self.path, &read)?;
for dir in self.leaf_dirs() {
pids.extend(read_member_pids(&dir, &read)?);
}
pids.sort_unstable();
pids.dedup();
Ok(pids)
}
#[cfg(feature = "process-control")]
fn members_info(&self) -> io::Result<Vec<MemberInfo>> {
let pids = self.members()?;
Ok(pids
.into_iter()
.filter_map(|pid| {
crate::sys::procfs::read_stat_meta(pid as u32)
.map(|m| MemberInfo::new(pid as u32, m.ppid, m.comm, m.starttime))
})
.collect())
}
fn is_empty(&self) -> io::Result<bool> {
Ok(self.members()?.is_empty())
}
#[cfg(feature = "stats")]
fn stats(&self) -> io::Result<ProcessGroupStats> {
self.stats_with(|path| std::fs::read_to_string(path))
}
#[cfg(feature = "stats")]
fn stats_with(
&self,
read: impl Fn(&Path) -> io::Result<String>,
) -> io::Result<ProcessGroupStats> {
let mut stats = self.stats_with_seams(
&read,
|p| process_identity(p as u32),
|p, id| process_metrics(p as u32, Some(id)),
)?;
let counters = self.container_counters_with(&read);
stats.io_read_bytes = counters.io_read_bytes;
stats.io_write_bytes = counters.io_write_bytes;
stats.peak_process_count = counters.peak_process_count;
Ok(stats)
}
#[cfg(feature = "stats")]
fn container_counters_with(
&self,
read: impl Fn(&Path) -> io::Result<String>,
) -> ContainerCounters {
let io = read(&self.path.join("io.stat")).ok();
let (io_read_bytes, io_write_bytes) = io.as_deref().map_or((None, None), |text| {
(
nested_keyed_sum(text, "rbytes"),
nested_keyed_sum(text, "wbytes"),
)
});
let peak_process_count = read(&self.path.join("pids.peak"))
.ok()
.and_then(|text| text.trim().parse::<u64>().ok())
.and_then(|peak| usize::try_from(peak).ok());
ContainerCounters {
io_read_bytes,
io_write_bytes,
peak_process_count,
}
}
#[cfg(feature = "stats")]
fn stats_with_seams(
&self,
read: impl Fn(&Path) -> io::Result<String>,
capture_identity: impl Fn(i32) -> Option<ProcIdentity>,
read_metrics: impl Fn(i32, ProcIdentity) -> ProcMetrics,
) -> io::Result<ProcessGroupStats> {
let pids = self.members_with(&read)?;
let active = pids.len();
let mut pinned: Vec<(i32, ProcIdentity)> = Vec::new();
for pid in pids {
if let Some(id) = capture_identity(pid) {
pinned.push((pid, id));
}
}
let mut cpu = Duration::ZERO;
let mut have_cpu = false;
let mut mem = 0u64;
let mut have_mem = false;
let mut last_err = None;
if !pinned.is_empty() {
match self.members_with(&read) {
Ok(snapshot) => {
let snapshot: std::collections::HashSet<i32> = snapshot.into_iter().collect();
for (pid, id) in pinned {
match sample_pinned(pid, id, |p| Ok(snapshot.contains(&p)), &read_metrics) {
MemberSample::Folded(m) => {
if let Some(c) = m.cpu_time {
cpu = cpu.saturating_add(c);
have_cpu = true;
}
if let Some(p) = m.peak_memory_bytes {
mem = mem.saturating_add(p);
have_mem = true;
}
}
MemberSample::Skipped => {}
MemberSample::Failed(e) => last_err = Some(e),
}
}
}
Err(e) => last_err = Some(e),
}
}
if let Some(e) = last_err {
return Err(e);
}
Ok(ProcessGroupStats {
active_process_count: active,
total_cpu_time: have_cpu.then_some(cpu),
peak_memory_bytes: have_mem.then_some(mem),
io_read_bytes: None,
io_write_bytes: None,
peak_process_count: None,
})
}
fn signal(&self, sig: i32) -> io::Result<()> {
self.signal_with(sig, |path| std::fs::read_to_string(path))
}
fn signal_with(&self, sig: i32, read: impl Fn(&Path) -> io::Result<String>) -> io::Result<()> {
self.signal_with_seams(sig, read, pidfd_open, pidfd_send_signal)
}
fn signal_with_seams<H>(
&self,
sig: i32,
read: impl Fn(&Path) -> io::Result<String>,
open: impl Fn(i32) -> io::Result<H>,
send: impl Fn(&H, i32) -> io::Result<()>,
) -> io::Result<()> {
let mut last_err = None;
let mut pinned: Vec<(i32, H)> = Vec::new();
for pid in self.members_with(&read)? {
match pin_member(pid, &open) {
Pinned::Handle(handle) => pinned.push((pid, handle)),
Pinned::Gone => {}
Pinned::Failed(err) => last_err = Some(err),
}
}
if !pinned.is_empty() {
match self.members_with(&read) {
Ok(snapshot) => {
let snapshot: std::collections::HashSet<i32> = snapshot.into_iter().collect();
for (pid, handle) in pinned {
match deliver_pinned(
pid,
sig,
&handle,
|p| Ok(snapshot.contains(&p)),
&send,
) {
Delivery::Delivered | Delivery::Skipped => {}
Delivery::Failed(err) => last_err = Some(err),
}
}
}
Err(err) => last_err = Some(err),
}
}
match last_err {
Some(err) => Err(err),
None => Ok(()),
}
}
#[cfg(feature = "process-control")]
fn freeze(&self, frozen: bool) -> io::Result<()> {
let val: &[u8] = if frozen { b"1" } else { b"0" };
match cgroup_write(&self.path.join("cgroup.freeze"), val) {
Ok(()) => return Ok(()),
Err(e) if e.kind() != io::ErrorKind::NotFound => return Err(e),
Err(_) => {} }
let sig = if frozen { libc::SIGSTOP } else { libc::SIGCONT };
self.signal(sig)
}
fn kill(&self) -> io::Result<()> {
self.kill_with(|path| std::fs::read_to_string(path))
}
fn kill_with(&self, read: impl Fn(&Path) -> io::Result<String>) -> io::Result<()> {
self.kill_with_seams(read, pidfd_open, pidfd_send_signal)
}
fn kill_with_seams<H>(
&self,
read: impl Fn(&Path) -> io::Result<String>,
open: impl Fn(i32) -> io::Result<H>,
send: impl Fn(&H, i32) -> io::Result<()>,
) -> io::Result<()> {
if cgroup_write(&self.path.join("cgroup.kill"), b"1").is_ok() {
return Ok(());
}
let froze = cgroup_write(&self.path.join("cgroup.freeze"), b"1").is_ok();
let mut last_delivery_error = None;
for _ in 0..50 {
if let Err(err) = self.signal_with_seams(libc::SIGKILL, &read, &open, &send) {
last_delivery_error = Some(err);
}
if let Ok(members) = self.members_with(&read)
&& members.is_empty()
{
break;
}
std::thread::sleep(Duration::from_millis(2));
}
let left_frozen = self.thaw_after_kill_sweep(froze);
match self.members_with(&read) {
Ok(members) if members.is_empty() => match left_frozen {
Some(err) => Err(err),
None => Ok(()),
},
Ok(_) => Err(last_delivery_error.unwrap_or_else(|| {
io::Error::other(
"cgroup did not drain after the bounded SIGKILL sweep (kernel < 5.14 fallback)",
)
})),
Err(e) => Err(e),
}
}
fn thaw_after_kill_sweep(&self, froze: bool) -> Option<io::Error> {
let path = self.path.join("cgroup.freeze");
let unrecovered = |written: io::Result<()>| match written {
Ok(()) => None,
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => frozen_now(std::fs::read_to_string(&path), froze).then_some(e),
};
unrecovered(cgroup_write(&path, b"0"))?;
std::thread::sleep(Duration::from_millis(2));
let err = unrecovered(cgroup_write(&path, b"0"))?;
Some(io::Error::new(
err.kind(),
format!(
"the process tree was killed and the cgroup drained, but the freezer — set to keep \
the sweep ahead of new forks — could not be cleared ({err}); the cgroup at {} is \
left FROZEN and is not usable for further spawns — cgroup v2 \
freezes a task that joins a frozen cgroup, and this backend joins one before \
`exec`, so the next child would stop instead of running. Clear `cgroup.freeze` \
(the write `ProcessGroup::resume` makes, where that feature is enabled) before \
spawning into this group again",
self.path.display()
),
))
}
}
fn frozen_now(state: io::Result<String>, froze: bool) -> bool {
match state.as_deref().map(str::trim) {
Ok("1") => true,
Ok("0") => false,
_ => froze,
}
}
impl super::graceful::GracefulTarget for Cgroup {
fn signal_all(&self, signal: i32) -> super::graceful::SoftDelivery {
match self.signal(signal) {
Ok(()) => super::graceful::SoftDelivery::Sent,
Err(_) => super::graceful::SoftDelivery::Failed,
}
}
fn is_drained(&self) -> bool {
self.is_empty().unwrap_or(false)
}
fn alive_count(&self) -> Option<usize> {
self.members().ok().map(|members| members.len())
}
fn hard_kill(&self) -> io::Result<()> {
self.kill()
}
}
enum Delivery {
Delivered,
Skipped,
Failed(io::Error),
}
enum Pinned<H> {
Handle(H),
Gone,
Failed(io::Error),
}
fn pin_member<H>(pid: i32, open: impl Fn(i32) -> io::Result<H>) -> Pinned<H> {
match open(pid) {
Ok(handle) => Pinned::Handle(handle),
Err(e) if e.raw_os_error() == Some(libc::ESRCH) => Pinned::Gone,
Err(e) if e.raw_os_error() == Some(libc::ENOSYS) => Pinned::Failed(pidfd_unsupported()),
Err(e) => Pinned::Failed(e),
}
}
fn deliver_pinned<H>(
pid: i32,
sig: i32,
handle: &H,
still_member: impl Fn(i32) -> io::Result<bool>,
send: impl Fn(&H, i32) -> io::Result<()>,
) -> Delivery {
match still_member(pid) {
Ok(true) => {}
Ok(false) => return Delivery::Skipped,
Err(e) => return Delivery::Failed(e),
}
match send(handle, sig) {
Ok(()) => Delivery::Delivered,
Err(e) if e.raw_os_error() == Some(libc::ESRCH) => Delivery::Delivered,
Err(e) if e.raw_os_error() == Some(libc::ENOSYS) => Delivery::Failed(pidfd_unsupported()),
Err(e) => Delivery::Failed(e),
}
}
#[cfg_attr(not(test), allow(dead_code))]
fn deliver_identity_safe<H>(
pid: i32,
sig: i32,
open: impl Fn(i32) -> io::Result<H>,
still_member: impl Fn(i32) -> io::Result<bool>,
send: impl Fn(&H, i32) -> io::Result<()>,
) -> Delivery {
let handle = match pin_member(pid, open) {
Pinned::Handle(handle) => handle,
Pinned::Gone => return Delivery::Delivered,
Pinned::Failed(e) => return Delivery::Failed(e),
};
deliver_pinned(pid, sig, &handle, still_member, send)
}
#[cfg(feature = "stats")]
enum MemberSample {
Folded(ProcMetrics),
Skipped,
Failed(io::Error),
}
#[cfg(feature = "stats")]
fn sample_pinned(
pid: i32,
id: ProcIdentity,
still_member: impl Fn(i32) -> io::Result<bool>,
read_metrics: impl Fn(i32, ProcIdentity) -> ProcMetrics,
) -> MemberSample {
match still_member(pid) {
Ok(true) => {}
Ok(false) => return MemberSample::Skipped,
Err(e) => return MemberSample::Failed(e),
}
MemberSample::Folded(read_metrics(pid, id))
}
#[cfg(feature = "stats")]
#[cfg_attr(not(test), allow(dead_code))]
fn sample_member_identity_safe(
pid: i32,
capture_identity: impl Fn(i32) -> Option<ProcIdentity>,
still_member: impl Fn(i32) -> io::Result<bool>,
read_metrics: impl Fn(i32, ProcIdentity) -> ProcMetrics,
) -> MemberSample {
let Some(id) = capture_identity(pid) else {
return MemberSample::Skipped;
};
sample_pinned(pid, id, still_member, read_metrics)
}
fn pidfd_unsupported() -> io::Error {
io::Error::new(
io::ErrorKind::Unsupported,
"identity-safe per-member signalling needs pidfd (pidfd_open/pidfd_send_signal, \
Linux >= 5.3); this kernel lacks it, so processkit refuses to fall back to a racy \
kill(pid, ...) that could hit a pid recycled by a process outside the cgroup — use \
SIGKILL teardown (atomic cgroup.kill) or run on a >= 5.3 kernel",
)
}
fn pidfd_open(pid: i32) -> io::Result<OwnedFd> {
let rc = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) };
if rc < 0 {
return Err(io::Error::last_os_error());
}
Ok(unsafe { OwnedFd::from_raw_fd(rc as RawFd) })
}
fn pidfd_send_signal(fd: &OwnedFd, sig: i32) -> io::Result<()> {
let rc = unsafe {
libc::syscall(
libc::SYS_pidfd_send_signal,
fd.as_raw_fd(),
sig,
std::ptr::null::<libc::siginfo_t>(),
0,
)
};
if rc < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
#[cfg(feature = "limits")]
fn controllers_to_enable<'a>(needed: &[&'a str], subtree_control: &str) -> Vec<&'a str> {
let already: std::collections::HashSet<&str> = subtree_control.split_whitespace().collect();
needed
.iter()
.copied()
.filter(|c| !already.contains(c))
.collect()
}
#[cfg(feature = "limits")]
fn cpu_max_value(cores: f64) -> String {
const PERIOD: u64 = 100_000;
let quota = (cores * PERIOD as f64).round().max(1.0) as u64;
format!("{quota} {PERIOD}")
}
#[cfg(feature = "stats")]
struct ContainerCounters {
io_read_bytes: Option<u64>,
io_write_bytes: Option<u64>,
peak_process_count: Option<usize>,
}
#[cfg(feature = "stats")]
fn nested_keyed_sum(text: &str, key: &str) -> Option<u64> {
let mut saw_line = false;
let mut saw_key = false;
let mut sum = 0u64;
for line in text.lines() {
let mut fields = line.split_whitespace();
if fields.next().is_none() {
continue;
}
saw_line = true;
for field in fields {
let Some((name, value)) = field.split_once('=') else {
continue;
};
if name == key
&& let Ok(value) = value.parse::<u64>()
{
saw_key = true;
sum = sum.saturating_add(value);
}
}
}
match (saw_line, saw_key) {
(false, _) => Some(0),
(true, true) => Some(sum),
(true, false) => None,
}
}
#[cfg(feature = "limits")]
fn flat_keyed_value(text: &str, key: &str) -> Option<u64> {
text.lines().find_map(|line| {
let mut fields = line.split_whitespace();
(fields.next()? == key).then(|| fields.next()?.parse::<u64>().ok())?
})
}
#[cfg(feature = "limits")]
fn needed_controllers(limits: &ResourceLimits) -> Vec<&'static str> {
let mut needed: Vec<&'static str> = Vec::new();
if limits.max_memory.is_some() {
needed.push("memory");
}
if limits.max_processes.is_some() {
needed.push("pids");
}
if limits.cpu_quota.is_some() {
needed.push("cpu");
}
needed
}
#[cfg(feature = "limits")]
fn write_limit_reset(path: &Path, value: Option<String>) -> io::Result<()> {
match value {
Some(v) => cgroup_write(path, v),
None if path.exists() => cgroup_write(path, "max"),
None => Ok(()),
}
}
fn arm_pdeathsig(spawner_pid: u32) -> io::Result<()> {
unsafe {
if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL, 0, 0, 0) != 0 {
return Err(io::Error::last_os_error());
}
if libc::getppid() as u32 != spawner_pid {
libc::_exit(0);
}
}
Ok(())
}
fn write_self_pid(path: &CStr) -> io::Result<()> {
unsafe {
let fd = libc::open(path.as_ptr(), libc::O_WRONLY | libc::O_CLOEXEC);
if fd < 0 {
return Err(io::Error::last_os_error());
}
let mut buf = [0u8; 12];
let mut i = buf.len();
let mut v = libc::getpid() as u32;
loop {
i -= 1;
buf[i] = b'0' + (v % 10) as u8;
v /= 10;
if v == 0 {
break;
}
}
let bytes = &buf[i..];
let written = libc::write(fd, bytes.as_ptr().cast(), bytes.len());
let werr = io::Error::last_os_error();
libc::close(fd);
if written < 0 {
return Err(werr);
}
if (written as usize) != bytes.len() {
return Err(io::Error::from(io::ErrorKind::WriteZero));
}
Ok(())
}
}
#[cfg(test)]
mod cgroup_read_seam_tests {
use std::cell::Cell;
use std::io;
use std::path::{Path, PathBuf};
use super::{Cgroup, Delivery, deliver_identity_safe, frozen_now};
fn cgroup() -> Cgroup {
Cgroup::at(PathBuf::from("/mock/processkit"))
}
#[test]
fn members_parses_readable_procs() {
let members = cgroup()
.members_with(|path| {
assert_eq!(path, Path::new("/mock/processkit/cgroup.procs"));
Ok("12\n0\ninvalid\n-3\n42\n".to_owned())
})
.expect("readable member list");
assert_eq!(members, [12, 42]);
}
#[test]
fn missing_procs_means_empty_cgroup() {
let members = cgroup()
.members_with(|_| Err(io::Error::from(io::ErrorKind::NotFound)))
.expect("a removed cgroup has no members");
assert!(members.is_empty());
}
#[test]
fn permission_denied_procs_is_unknown() {
let err = cgroup()
.members_with(|_| Err(io::Error::from(io::ErrorKind::PermissionDenied)))
.expect_err("an unreadable cgroup must not look empty");
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
}
#[test]
fn io_error_procs_is_unknown() {
let err = cgroup()
.members_with(|_| Err(io::Error::from_raw_os_error(libc::EIO)))
.expect_err("an I/O failure must not look empty");
assert_eq!(err.raw_os_error(), Some(libc::EIO));
}
#[cfg(feature = "limits")]
mod limit_evidence {
use std::cell::RefCell;
use std::io;
use std::path::{Path, PathBuf};
use crate::limits::{CappedAxes, LimitKind, LimitVerdict, ResourceLimits};
use super::cgroup;
fn capped(limits: ResourceLimits) -> CappedAxes {
let mut axes = CappedAxes::default();
axes.record(&limits);
axes
}
const ALL_CAPPED: fn() -> CappedAxes = || {
capped(ResourceLimits {
max_memory: Some(1),
max_processes: Some(1),
cpu_quota: Some(1.0),
})
};
#[test]
fn non_zero_counters_trip_each_axis() {
let ev = cgroup().limit_evidence_with(ALL_CAPPED(), |path| {
Ok(match path.file_name().unwrap().to_str().unwrap() {
"memory.events.local" => "low 0\nhigh 0\nmax 50022\noom 1\noom_kill 1\n",
"pids.events.local" => "max 3\n",
"cpu.stat" => {
"usage_usec 105292\nnr_periods 21\nnr_throttled 21\nthrottled_usec 1977211\n"
}
other => panic!("unexpected evidence read: {other}"),
}
.to_owned())
});
assert_eq!(ev.memory(), LimitVerdict::Tripped);
assert_eq!(ev.processes(), LimitVerdict::Tripped);
assert_eq!(ev.cpu(), LimitVerdict::Tripped);
}
#[test]
fn zero_counters_are_a_decisive_not_tripped() {
let ev = cgroup().limit_evidence_with(ALL_CAPPED(), |path| {
Ok(match path.file_name().unwrap().to_str().unwrap() {
"memory.events.local" => "low 0\nhigh 0\nmax 0\noom 0\noom_kill 0\n",
"pids.events.local" => "max 0\n",
"cpu.stat" => "usage_usec 1\nnr_periods 0\nnr_throttled 0\n",
other => panic!("unexpected evidence read: {other}"),
}
.to_owned())
});
assert_eq!(ev.memory(), LimitVerdict::NotTripped);
assert_eq!(ev.processes(), LimitVerdict::NotTripped);
assert_eq!(ev.cpu(), LimitVerdict::NotTripped);
}
#[test]
fn an_oom_kill_without_a_local_oom_event_does_not_trip_memory() {
let ev = cgroup().limit_evidence_with(
capped(ResourceLimits {
max_memory: Some(1),
..ResourceLimits::default()
}),
|_| Ok("low 0\nhigh 0\nmax 0\noom 0\noom_kill 4\noom_group_kill 1\n".to_owned()),
);
assert_eq!(
ev.memory(),
LimitVerdict::NotTripped,
"a kill by the GLOBAL oom killer is not evidence that this cgroup's own cap fired"
);
}
#[test]
fn an_uncapped_axis_is_not_tripped_without_any_read() {
let reads: RefCell<Vec<PathBuf>> = RefCell::new(Vec::new());
let ev = cgroup().limit_evidence_with(
capped(ResourceLimits {
max_processes: Some(4),
..ResourceLimits::default()
}),
|path| {
reads.borrow_mut().push(path.to_path_buf());
Ok("max 7\n".to_owned())
},
);
assert_eq!(ev.processes(), LimitVerdict::Tripped);
assert_eq!(ev.memory(), LimitVerdict::NotTripped);
assert_eq!(ev.cpu(), LimitVerdict::NotTripped);
assert_eq!(
reads.borrow().as_slice(),
[PathBuf::from("/mock/processkit/pids.events.local")],
"only the capped axis may be read"
);
}
#[test]
fn an_uncapped_group_performs_no_evidence_io() {
let reads = std::cell::Cell::new(0usize);
let ev = cgroup().limit_evidence_with(CappedAxes::default(), |_| {
reads.set(reads.get() + 1);
Ok(String::new())
});
assert_eq!(reads.get(), 0, "an uncapped group must not read anything");
for kind in [LimitKind::Memory, LimitKind::Processes, LimitKind::Cpu] {
assert_eq!(ev.verdict(kind), LimitVerdict::NotTripped);
}
}
#[test]
fn a_missing_local_file_falls_back_to_the_hierarchical_counter() {
let ev = cgroup().limit_evidence_with(ALL_CAPPED(), |path| {
match path.file_name().unwrap().to_str().unwrap() {
"memory.events.local" | "pids.events.local" => {
Err(io::Error::from(io::ErrorKind::NotFound))
}
"memory.events" => Ok("max 1\noom 2\noom_kill 2\n".to_owned()),
"pids.events" => Ok("max 0\n".to_owned()),
"cpu.stat" => Ok("nr_throttled 5\n".to_owned()),
other => panic!("unexpected evidence read: {other}"),
}
});
assert_eq!(ev.memory(), LimitVerdict::Tripped);
assert_eq!(ev.processes(), LimitVerdict::NotTripped);
assert_eq!(ev.cpu(), LimitVerdict::Tripped);
}
#[test]
fn unreadable_counters_are_unknown_not_a_no() {
let ev = cgroup().limit_evidence_with(ALL_CAPPED(), |_| {
Err(io::Error::from(io::ErrorKind::PermissionDenied))
});
for kind in [LimitKind::Memory, LimitKind::Processes, LimitKind::Cpu] {
assert_eq!(ev.verdict(kind), LimitVerdict::Unknown, "axis {kind:?}");
}
}
#[test]
fn a_readable_file_without_the_key_is_unknown() {
let ev = cgroup().limit_evidence_with(ALL_CAPPED(), |path| {
Ok(match path.file_name().unwrap().to_str().unwrap() {
"memory.events.local" => "low 0\nhigh 0\nmax 3\n",
"pids.events.local" => "not_max 9\n",
"cpu.stat" => "usage_usec 42\nuser_usec 40\nsystem_usec 2\n",
other => panic!("unexpected evidence read: {other}"),
}
.to_owned())
});
for kind in [LimitKind::Memory, LimitKind::Processes, LimitKind::Cpu] {
assert_eq!(ev.verdict(kind), LimitVerdict::Unknown, "axis {kind:?}");
}
}
#[test]
fn counters_are_read_from_this_cgroups_directory() {
let ev = cgroup().limit_evidence_with(
capped(ResourceLimits {
cpu_quota: Some(0.5),
..ResourceLimits::default()
}),
|path| {
assert_eq!(path, Path::new("/mock/processkit/cpu.stat"));
Ok("nr_throttled 1\n".to_owned())
},
);
assert_eq!(ev.cpu(), LimitVerdict::Tripped);
}
}
#[test]
fn signal_with_propagates_read_error_without_reaching_the_per_pid_loop() {
let err = cgroup()
.signal_with(libc::SIGTERM, |_| {
Err(io::Error::from(io::ErrorKind::PermissionDenied))
})
.expect_err("an unreadable member list must not look like a successful no-op signal");
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
}
#[test]
fn signal_with_empty_member_list_is_a_no_op_success() {
cgroup()
.signal_with(libc::SIGTERM, |_| Ok(String::new()))
.expect("no members to signal is trivially successful");
}
#[test]
fn kill_with_persistent_read_error_reports_a_real_drain_failure() {
let err = cgroup()
.kill_with(|_| Err(io::Error::from_raw_os_error(libc::EIO)))
.expect_err("a cgroup.procs that never becomes readable must not report as drained");
assert_eq!(err.raw_os_error(), Some(libc::EIO));
}
#[test]
fn kill_with_empty_member_list_drains_immediately() {
cgroup()
.kill_with(|_| Ok(String::new()))
.expect("an already-empty cgroup is reported as drained by the fallback sweep");
}
#[test]
fn frozen_now_takes_the_files_answer_over_the_callers_own_freeze_write() {
assert!(
frozen_now(Ok("1\n".to_owned()), false),
"a group the kernel calls frozen is frozen whoever froze it"
);
assert!(
!frozen_now(Ok("0\n".to_owned()), true),
"a landed freeze that is no longer in force is nothing to report"
);
}
#[test]
fn frozen_now_falls_back_when_the_file_gives_no_usable_answer() {
let no_answer = || {
[
Err(io::Error::from(io::ErrorKind::PermissionDenied)),
Err(io::Error::from(io::ErrorKind::NotFound)),
Ok("frozen\n".to_owned()),
Ok(String::new()),
]
};
for (n, state) in no_answer().into_iter().enumerate() {
assert!(
frozen_now(state, true),
"case {n}: a freeze this call landed and could not clear stands until \
the file says otherwise"
);
}
for (n, state) in no_answer().into_iter().enumerate() {
assert!(
!frozen_now(state, false),
"case {n}: with no freeze of this call's own and no answer from the \
file, there is nothing to report"
);
}
}
#[test]
fn kill_with_skips_a_pid_recycled_between_snapshot_and_kill() {
struct Handle(i32);
let reads = Cell::new(0usize);
let opened = std::cell::RefCell::new(Vec::new());
let signalled = std::cell::RefCell::new(Vec::new());
cgroup()
.kill_with_seams(
|_: &Path| {
let read = reads.get() + 1;
reads.set(read);
Ok(match read {
1 => "1001\n1002\n",
2 => "1001\n",
3 | 4 => "",
other => panic!("unexpected cgroup.procs read {other}"),
}
.to_owned())
},
|pid| {
opened.borrow_mut().push(pid);
Ok(Handle(pid))
},
|handle: &Handle, signal| {
assert_eq!(signal, libc::SIGKILL);
signalled.borrow_mut().push(handle.0);
Ok(())
},
)
.expect("a recycled member is a benign skip when the cgroup drains");
assert_eq!(*opened.borrow(), vec![1001, 1002]);
assert_eq!(
*signalled.borrow(),
vec![1001],
"the pid absent from the post-pin membership snapshot must not be signalled"
);
}
#[test]
fn kill_with_delivers_sigkill_to_a_confirmed_member() {
struct Handle(i32);
let reads = Cell::new(0usize);
let signalled = std::cell::RefCell::new(Vec::new());
cgroup()
.kill_with_seams(
|_: &Path| {
let read = reads.get() + 1;
reads.set(read);
Ok(match read {
1 | 2 => "1001\n",
3 | 4 => "",
other => panic!("unexpected cgroup.procs read {other}"),
}
.to_owned())
},
|pid| Ok(Handle(pid)),
|handle: &Handle, signal| {
assert_eq!(signal, libc::SIGKILL);
signalled.borrow_mut().push(handle.0);
Ok(())
},
)
.expect("a confirmed member must receive SIGKILL through its pinned handle");
assert_eq!(*signalled.borrow(), vec![1001]);
}
#[cfg(feature = "stats")]
#[test]
fn stats_with_read_error_is_not_reported_as_zero_active_processes() {
let err = cgroup()
.stats_with(|_| Err(io::Error::from(io::ErrorKind::PermissionDenied)))
.expect_err("an unreadable member list must not look like an empty (0-process) group");
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
}
#[cfg(feature = "stats")]
#[test]
fn stats_with_empty_member_list_reports_zero_active_processes() {
let stats = cgroup()
.stats_with(|_| Ok(String::new()))
.expect("an empty member list is a legitimate zero-active-process stats snapshot");
assert_eq!(stats.active_process_count, 0);
}
#[cfg(feature = "stats")]
mod container_counters {
use std::cell::Cell;
use std::io;
use std::path::Path;
use super::super::nested_keyed_sum;
use super::cgroup;
fn files(entries: &[(&'static str, &'static str)]) -> impl Fn(&Path) -> io::Result<String> {
let entries: Vec<(String, String)> = entries
.iter()
.map(|(name, body)| ((*name).to_owned(), (*body).to_owned()))
.collect();
move |path: &Path| {
let name = path
.file_name()
.and_then(|n| n.to_str())
.expect("a cgroup interface file has a name");
entries
.iter()
.find(|(entry, _)| entry == name)
.map(|(_, body)| body.clone())
.ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))
}
}
#[test]
fn io_bytes_are_summed_over_every_device() {
let counters = cgroup().container_counters_with(files(&[(
"io.stat",
"259:0 rbytes=1024 wbytes=2048 rios=4 wios=8 dbytes=0 dios=0\n\
8:16 rbytes=512 wbytes=64 rios=1 wios=1 dbytes=0 dios=0\n",
)]));
assert_eq!(counters.io_read_bytes, Some(1536));
assert_eq!(counters.io_write_bytes, Some(2112));
}
#[test]
fn peak_process_count_reads_pids_peak_not_pids_current() {
let read = Cell::new(Vec::new());
let counters = cgroup().container_counters_with(|path: &Path| {
let mut seen = read.take();
seen.push(path.to_owned());
read.set(seen);
match path.file_name().and_then(|n| n.to_str()) {
Some("pids.peak") => Ok("7\n".to_owned()),
Some("pids.current") => panic!("the peak must not be read from pids.current"),
_ => Err(io::Error::from(io::ErrorKind::NotFound)),
}
});
assert_eq!(counters.peak_process_count, Some(7));
assert!(
read.take()
.iter()
.all(|p| p.starts_with("/mock/processkit")),
"the counters are the job's own cgroup's, not a leaf's or an ancestor's"
);
}
#[test]
fn absent_controller_files_are_none_never_zero() {
let counters = cgroup().container_counters_with(files(&[]));
assert_eq!(counters.io_read_bytes, None);
assert_eq!(counters.io_write_bytes, None);
assert_eq!(counters.peak_process_count, None);
}
#[test]
fn unreadable_counter_files_are_none() {
let counters = cgroup().container_counters_with(|_: &Path| {
Err(io::Error::from(io::ErrorKind::PermissionDenied))
});
assert_eq!(counters.io_read_bytes, None);
assert_eq!(counters.io_write_bytes, None);
assert_eq!(counters.peak_process_count, None);
}
#[test]
fn an_accounting_cgroup_that_moved_nothing_reports_zero_not_none() {
let counters = cgroup().container_counters_with(files(&[("io.stat", "")]));
assert_eq!(counters.io_read_bytes, Some(0));
assert_eq!(counters.io_write_bytes, Some(0));
}
#[test]
fn unparsable_pids_peak_is_none() {
let counters = cgroup().container_counters_with(files(&[("pids.peak", "max\n")]));
assert_eq!(counters.peak_process_count, None);
}
#[test]
fn a_malformed_device_line_does_not_erase_the_others() {
assert_eq!(
nested_keyed_sum(
"259:0 rbytes=oops wbytes=1\n8:16 rbytes=10 wbytes=2\n",
"rbytes"
),
Some(10)
);
}
#[test]
fn a_key_this_kernel_does_not_account_is_none() {
assert_eq!(nested_keyed_sum("259:0 rios=4 wios=8\n", "rbytes"), None);
assert_eq!(nested_keyed_sum("", "rbytes"), Some(0));
}
#[test]
fn stats_with_layers_the_container_counters_onto_the_member_fold() {
let procs_reads = Cell::new(0usize);
let stats = cgroup()
.stats_with(
|path: &Path| match path.file_name().and_then(|n| n.to_str()) {
Some("cgroup.procs") => {
procs_reads.set(procs_reads.get() + 1);
Ok(if procs_reads.get() == 1 {
"1001\n1002\n".to_owned()
} else {
"1001\n".to_owned()
})
}
Some("io.stat") => Ok("259:0 rbytes=4096 wbytes=8192\n".to_owned()),
Some("pids.peak") => Ok("5\n".to_owned()),
other => panic!("unexpected read of {other:?}"),
},
)
.expect("a snapshot with both sources present");
assert_eq!(
stats.active_process_count, 2,
"the count still reflects the initial member list"
);
assert_eq!(stats.io_read_bytes, Some(4096));
assert_eq!(stats.io_write_bytes, Some(8192));
assert_eq!(
stats.peak_process_count,
Some(5),
"the terminal peak is the cgroup's own, higher than the count now"
);
}
#[test]
fn a_host_without_the_controllers_still_reports_the_member_fold() {
let stats = cgroup()
.stats_with(
|path: &Path| match path.file_name().and_then(|n| n.to_str()) {
Some("cgroup.procs") => Ok("1001\n".to_owned()),
_ => Err(io::Error::from(io::ErrorKind::NotFound)),
},
)
.expect("missing controller files are not a snapshot failure");
assert_eq!(stats.active_process_count, 1);
assert_eq!(stats.io_read_bytes, None);
assert_eq!(stats.io_write_bytes, None);
assert_eq!(stats.peak_process_count, None);
}
}
struct FakeHandle;
#[test]
fn reused_pid_outside_cgroup_is_never_signalled() {
let sent = Cell::new(false);
let outcome = deliver_identity_safe(
1234,
libc::SIGTERM,
|_| Ok(FakeHandle),
|_| Ok(false),
|_: &FakeHandle, _| {
sent.set(true);
Ok(())
},
);
assert!(matches!(outcome, Delivery::Skipped));
assert!(
!sent.get(),
"a pid recycled outside the cgroup must never be signalled"
);
}
#[test]
fn confirmed_member_is_signalled_with_the_requested_signal() {
let sent = Cell::new(None);
let outcome = deliver_identity_safe(
42,
libc::SIGTERM,
|_| Ok(FakeHandle),
|_| Ok(true),
|_: &FakeHandle, sig| {
sent.set(Some(sig));
Ok(())
},
);
assert!(matches!(outcome, Delivery::Delivered));
assert_eq!(
sent.get(),
Some(libc::SIGTERM),
"the requested signal reaches a confirmed member"
);
}
#[test]
fn member_gone_before_pin_is_a_benign_no_op() {
let sent = Cell::new(false);
let outcome = deliver_identity_safe(
7,
libc::SIGTERM,
|_| Err::<FakeHandle, _>(io::Error::from_raw_os_error(libc::ESRCH)),
|_| -> io::Result<bool> {
panic!("membership must not be checked once the pin fails ESRCH")
},
|_: &FakeHandle, _| {
sent.set(true);
Ok(())
},
);
assert!(matches!(outcome, Delivery::Delivered));
assert!(!sent.get());
}
#[test]
fn no_pidfd_support_fails_safe_instead_of_raw_kill() {
let sent = Cell::new(false);
let outcome = deliver_identity_safe(
7,
libc::SIGTERM,
|_| Err::<FakeHandle, _>(io::Error::from_raw_os_error(libc::ENOSYS)),
|_| Ok(true),
|_: &FakeHandle, _| {
sent.set(true);
Ok(())
},
);
match outcome {
Delivery::Failed(e) => assert_eq!(e.kind(), io::ErrorKind::Unsupported),
_ => panic!("a kernel without pidfd must fail safe, not signal"),
}
assert!(!sent.get(), "fail-safe must not send any signal");
}
#[test]
fn unreadable_membership_after_pin_fails_safe_without_sending() {
let sent = Cell::new(false);
let outcome = deliver_identity_safe(
7,
libc::SIGTERM,
|_| Ok(FakeHandle),
|_| Err(io::Error::from(io::ErrorKind::PermissionDenied)),
|_: &FakeHandle, _| {
sent.set(true);
Ok(())
},
);
match outcome {
Delivery::Failed(e) => assert_eq!(e.kind(), io::ErrorKind::PermissionDenied),
_ => panic!("an unreadable membership must fail safe"),
}
assert!(!sent.get());
}
#[test]
fn pinned_target_exiting_before_send_is_a_benign_esrch() {
let outcome = deliver_identity_safe(
7,
libc::SIGTERM,
|_| Ok(FakeHandle),
|_| Ok(true),
|_: &FakeHandle, _| Err(io::Error::from_raw_os_error(libc::ESRCH)),
);
assert!(matches!(outcome, Delivery::Delivered));
}
#[test]
fn eperm_on_send_is_a_real_failure_that_surfaces() {
let outcome = deliver_identity_safe(
7,
libc::SIGTERM,
|_| Ok(FakeHandle),
|_| Ok(true),
|_: &FakeHandle, _| Err(io::Error::from_raw_os_error(libc::EPERM)),
);
match outcome {
Delivery::Failed(e) => assert_eq!(e.raw_os_error(), Some(libc::EPERM)),
_ => panic!("EPERM is a real delivery failure and must surface"),
}
}
#[test]
fn signal_with_reads_cgroup_procs_a_constant_number_of_times_for_a_whole_tree() {
let members = (1000..1100)
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join("\n");
let reads = Cell::new(0usize);
let sends = Cell::new(0usize);
cgroup()
.signal_with_seams(
libc::SIGTERM,
|_| {
reads.set(reads.get() + 1);
Ok(members.clone())
},
|_| Ok(FakeHandle),
|_: &FakeHandle, _| {
sends.set(sends.get() + 1);
Ok(())
},
)
.expect("every confirmed member is signalled");
assert_eq!(
reads.get(),
2,
"one read for the initial member list + one shared reconfirm read, \
independent of the 100 members (was 1 + n before this task)"
);
assert_eq!(
sends.get(),
100,
"each confirmed member is still signalled exactly once"
);
}
#[test]
fn signal_with_skips_a_pid_recycled_outside_the_cgroup_via_the_single_snapshot() {
struct Handle(i32);
let reads = Cell::new(0usize);
let signalled = std::cell::RefCell::new(Vec::new());
cgroup()
.signal_with_seams(
libc::SIGTERM,
|_| {
reads.set(reads.get() + 1);
Ok(if reads.get() == 1 {
"1001\n1002\n1003\n".to_owned()
} else {
"1001\n1003\n".to_owned()
})
},
|pid| Ok(Handle(pid)),
|h: &Handle, _| {
signalled.borrow_mut().push(h.0);
Ok(())
},
)
.expect("a benign recycle race is not a broadcast failure");
assert_eq!(
*signalled.borrow(),
vec![1001, 1003],
"the pid missing from the single reconfirm snapshot is skipped; the rest are signalled"
);
assert_eq!(
reads.get(),
2,
"still exactly two reads for the whole batch"
);
}
}
#[cfg(test)]
mod cgroup_write_seam_tests {
use std::io;
use super::Cgroup;
use crate::sys::fault_injection::{Faults, Site};
const SITE: Site = Site::CgroupWrite;
fn temp_cgroup() -> (tempfile::TempDir, Cgroup) {
let dir = tempfile::tempdir().expect("temp dir");
let parent = dir.path().join("parent");
let leaf = parent.join("leaf");
std::fs::create_dir_all(&leaf).expect("create the cgroup dirs");
std::fs::write(parent.join("cgroup.subtree_control"), "cpu memory pids\n")
.expect("seed the parent's delegated controllers");
for file in ["memory.max", "pids.max", "cpu.max"] {
std::fs::write(leaf.join(file), "max\n").expect("seed a limit interface file");
}
std::fs::write(leaf.join("cgroup.procs"), "").expect("seed an empty member list");
(dir, Cgroup::at(leaf))
}
#[cfg(feature = "limits")]
fn read(path: &std::path::Path) -> String {
std::fs::read_to_string(path).expect("read back a control file")
}
#[cfg(feature = "process-control")]
#[test]
fn a_recycled_adoption_hands_the_number_back_to_the_parent_cgroup() {
let (_dir, cgroup) = temp_cgroup();
let parent = cgroup
.path
.parent()
.expect("the stand-in cgroup has a parent")
.to_path_buf();
std::fs::write(cgroup.path.join("cgroup.procs"), "4321\n").expect("seed the member list");
assert!(
matches!(cgroup.evict_recycled(4321), super::RecycleUndo::Evicted),
"a number this cgroup holds must be moved back out"
);
assert_eq!(
std::fs::read_to_string(parent.join("cgroup.procs"))
.expect("the destination member list"),
"4321",
"the number must be handed to the cgroup this job's directory lives in"
);
}
#[cfg(feature = "process-control")]
#[test]
fn a_recycle_after_a_correct_migration_moves_nobody() {
let (_dir, cgroup) = temp_cgroup();
let parent = cgroup
.path
.parent()
.expect("the stand-in cgroup has a parent")
.to_path_buf();
assert!(
matches!(cgroup.evict_recycled(4321), super::RecycleUndo::NotAMember),
"a number this cgroup does not hold is nothing to undo"
);
assert!(
!parent.join("cgroup.procs").exists(),
"a number this call never migrated must not be written anywhere"
);
}
#[cfg(feature = "process-control")]
#[test]
fn an_undo_the_host_refuses_is_reported_as_still_this_groups_to_kill() {
let (_dir, cgroup) = temp_cgroup();
std::fs::write(cgroup.path.join("cgroup.procs"), "4321\n").expect("seed the member list");
let faults = Faults::new()
.fail_every(SITE, Some("cgroup.procs"), libc::EPERM)
.arm();
let undo = cgroup.evict_recycled(4321);
assert_eq!(
faults.fired(SITE),
1,
"exactly the move-out write was failed"
);
assert!(matches!(undo, super::RecycleUndo::Stuck(_)));
let text = super::recycled_during_cgroup_adoption(4321, undo).to_string();
assert!(
text.contains("could NOT be moved back out") && text.contains("will kill it"),
"the caller must be told the number is still this group's to kill: {text}"
);
assert!(
text.contains(&format!("os error {}", libc::EPERM)),
"the refusal's own errno must reach the caller: {text}"
);
}
#[cfg(feature = "limits")]
#[test]
fn a_rejected_limit_write_surfaces_and_leaves_the_later_axes_untouched() {
use crate::limits::{CappedAxes, ResourceLimits};
use crate::{ErrorKind, ErrorReason, LimitKind, LimitReason};
let (_dir, cgroup) = temp_cgroup();
let limits = ResourceLimits {
max_memory: Some(64 << 20),
max_processes: Some(16),
cpu_quota: Some(0.5),
..ResourceLimits::default()
};
let faults = Faults::new()
.fail_every(SITE, Some("pids.max"), libc::EIO)
.arm();
let mut capped = CappedAxes::default();
let mut reflected = ResourceLimits::default();
let err = crate::group::update_limits_with(&mut capped, &mut reflected, limits, |limits| {
cgroup.update_limits(limits)
})
.expect_err("an EIO half-way through must not report the caps as applied");
assert_eq!(faults.fired(SITE), 1, "exactly one write was failed");
assert_eq!(err.kind(), ErrorKind::ResourceLimit);
match err.reason() {
ErrorReason::ResourceLimit {
kind,
reason,
detail,
} => {
assert_eq!(*kind, LimitKind::Memory, "the first requested axis");
assert_eq!(
*reason,
LimitReason::Unenforceable,
"a cgroup exists and refused the write — not `Unsupported`"
);
assert!(
detail.contains(&format!("os error {}", libc::EIO)),
"the OS errno must reach the caller: {detail}"
);
}
other => panic!("expected a ResourceLimit failure, got {other:?}"),
}
assert_eq!(
read(&cgroup.path.join("memory.max")),
(64u64 << 20).to_string(),
"the write before the failure really reached the kernel"
);
assert_eq!(
read(&cgroup.path.join("cpu.max")),
"max\n",
"the write after the failure was never attempted"
);
}
#[cfg(feature = "process-control")]
#[test]
fn a_refused_cgroup_freeze_write_surfaces_instead_of_degrading() {
let (_dir, cgroup) = temp_cgroup();
let faults = Faults::new()
.fail_every(SITE, Some("cgroup.freeze"), libc::EACCES)
.arm();
let err = cgroup
.freeze(true)
.expect_err("a refused freeze on a modern kernel must not look like a suspend");
assert_eq!(faults.fired(SITE), 1);
assert_eq!(
err.raw_os_error(),
Some(libc::EACCES),
"the refusal reaches the caller as itself, not as some fallback's error"
);
let public = crate::group::map_unsupported(err, "suspend");
assert_eq!(
public.kind(),
crate::ErrorKind::PermissionDenied,
"an EACCES from the freeze write is a permission problem, never a \
silent success and never `Unsupported`"
);
}
#[cfg(feature = "process-control")]
#[test]
fn an_absent_cgroup_freeze_file_falls_back_to_the_per_pid_sweep() {
let (_dir, cgroup) = temp_cgroup();
let faults = Faults::new()
.fail_every(SITE, Some("cgroup.freeze"), libc::ENOENT)
.arm();
cgroup
.freeze(true)
.expect("a missing cgroup.freeze falls back to the per-pid signal path");
assert_eq!(
faults.fired(SITE),
1,
"the freeze write was attempted first"
);
}
#[test]
fn a_refused_thaw_after_the_sweep_is_not_reported_as_a_clean_kill() {
let (_dir, cgroup) = temp_cgroup();
let faults = Faults::new()
.fail_every(SITE, Some("cgroup.kill"), libc::EACCES)
.fail_from_nth(SITE, Some("cgroup.freeze"), 2, libc::EACCES)
.arm();
let err = cgroup
.kill()
.expect_err("a cgroup left frozen is not a kill the caller can build on");
assert_eq!(
std::fs::read_to_string(cgroup.path.join("cgroup.freeze"))
.expect("the freeze the sweep wrote"),
"1",
"the sweep's freeze landed and no thaw cleared it"
);
assert_eq!(faults.fired(SITE), 3, "the thaw was retried exactly once");
assert_eq!(
err.kind(),
io::ErrorKind::PermissionDenied,
"the refusal keeps its own kind, as a refused suspend does"
);
let text = err.to_string();
assert!(
text.contains("FROZEN") && text.contains(&format!("os error {}", libc::EACCES)),
"the caller must be told the group is frozen, and why: {text}"
);
assert_eq!(
crate::Error::io(err).kind(),
crate::ErrorKind::PermissionDenied,
"a refused thaw reaches the caller as a permission problem, not a silent success"
);
}
#[test]
fn a_second_kill_over_a_group_left_frozen_reports_it_frozen_again() {
let (_dir, cgroup) = temp_cgroup();
let first = Faults::new()
.fail_every(SITE, Some("cgroup.kill"), libc::EACCES)
.fail_from_nth(SITE, Some("cgroup.freeze"), 2, libc::EACCES)
.arm();
cgroup
.kill()
.expect_err("the first call leaves the group frozen and says so");
drop(first);
let faults = Faults::new().fail_every(SITE, None, libc::EACCES).arm();
let err = cgroup
.kill()
.expect_err("a group that is still frozen is still not one the caller can spawn into");
assert_eq!(
std::fs::read_to_string(cgroup.path.join("cgroup.freeze"))
.expect("the freeze the first call left behind"),
"1",
"the group really is still frozen — nothing thawed it between the two calls"
);
assert_eq!(
faults.fired(SITE),
4,
"the repeat's own freeze was refused, and its thaw still retried exactly once"
);
assert_eq!(
err.kind(),
io::ErrorKind::PermissionDenied,
"the repeat reports the refusal in its own right, not as a copy of the first"
);
assert!(
err.to_string().contains("FROZEN"),
"the second answer must name the same state as the first: {err}"
);
}
#[test]
fn a_refused_thaw_that_never_froze_anything_still_reports_a_clean_kill() {
let (_dir, cgroup) = temp_cgroup();
let faults = Faults::new().fail_every(SITE, None, libc::EACCES).arm();
cgroup
.kill()
.expect("a sweep that drained the tree without ever freezing it is a kill");
assert_eq!(faults.fired(SITE), 3, "the refused thaw was not retried");
assert!(
!cgroup.path.join("cgroup.freeze").exists(),
"no freeze was ever put in force"
);
}
#[test]
fn a_thaw_onto_a_cgroup_that_vanished_still_reports_a_clean_kill() {
let (_dir, cgroup) = temp_cgroup();
let faults = Faults::new()
.fail_every(SITE, Some("cgroup.kill"), libc::EACCES)
.fail_from_nth(SITE, Some("cgroup.freeze"), 2, libc::ENOENT)
.arm();
cgroup
.kill()
.expect("a cgroup that no longer exists is holding nothing frozen");
assert_eq!(
faults.fired(SITE),
2,
"`cgroup.kill` and the thaw; an absent file is not retried either"
);
}
}
#[cfg(test)]
mod fail_safe_tests {
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use super::{Backend, Cgroup, Job};
use crate::sys::SkipDropKill;
use crate::sys::graceful::GracefulTarget;
fn unreadable_procs_cgroup() -> Option<(Cgroup, PathBuf)> {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!(
"processkit-failsafe-test-{}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("create temp cgroup dir");
let procs = dir.join("cgroup.procs");
std::fs::write(&procs, b"").expect("create cgroup.procs");
std::fs::set_permissions(&procs, std::fs::Permissions::from_mode(0o000))
.expect("revoke read permission on cgroup.procs");
let cg = Cgroup::at(dir.clone());
if cg.is_empty().is_ok() {
let _ = std::fs::remove_dir_all(&dir);
eprintln!(
"skipping: this environment can read past chmod 000 (likely running as root) \
— the fail-safe path under test is not reachable here"
);
return None;
}
Some((cg, dir))
}
#[test]
fn is_drained_treats_unreadable_procs_as_not_drained() {
let Some((cg, dir)) = unreadable_procs_cgroup() else {
return;
};
assert!(
!cg.is_drained(),
"an unreadable member list is unknown, not drained — GracefulTarget::is_drained \
must not treat it as an empty cgroup (doing so would cancel the escalation)"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn drop_keeps_waiting_out_the_bounded_drain_when_procs_is_unreadable() {
let Some((cg, dir)) = unreadable_procs_cgroup() else {
return;
};
let job = Job {
backend: Backend::Cgroup(cg),
skip_drop_kill: SkipDropKill::new(),
};
let start = Instant::now();
drop(job);
let elapsed = start.elapsed();
assert!(
elapsed >= Duration::from_millis(90),
"Job::drop exited its drain wait early ({elapsed:?}) — an unreadable member \
list must not be treated as an empty (drained) cgroup"
);
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(all(test, feature = "limits"))]
mod tests {
use super::{controllers_to_enable, cpu_max_value, flat_keyed_value};
#[test]
fn flat_keyed_value_reads_a_counter_by_whole_key() {
let events = "low 0\nhigh 0\nmax 50022\noom 1\noom_kill 3\noom_group_kill 0\n";
assert_eq!(flat_keyed_value(events, "oom"), Some(1));
assert_eq!(flat_keyed_value(events, "oom_kill"), Some(3));
assert_eq!(flat_keyed_value(events, "max"), Some(50022));
assert_eq!(flat_keyed_value("oom_kill 3\n", "oom"), None);
assert_eq!(flat_keyed_value("oom 1\n", "oom_kill"), None);
}
#[test]
fn flat_keyed_value_separates_absent_from_zero() {
assert_eq!(flat_keyed_value("max 0\n", "max"), Some(0));
assert_eq!(flat_keyed_value("", "max"), None);
assert_eq!(flat_keyed_value("usage_usec 42\n", "nr_throttled"), None);
assert_eq!(flat_keyed_value("max\n", "max"), None);
assert_eq!(flat_keyed_value("max nan\n", "max"), None);
assert_eq!(flat_keyed_value("max -1\n", "max"), None);
assert_eq!(
flat_keyed_value("a 1\nnr_throttled 21 \n", "nr_throttled"),
Some(21)
);
}
#[test]
fn cpu_max_formats_quota_and_period() {
assert_eq!(cpu_max_value(0.5), "50000 100000");
assert_eq!(cpu_max_value(2.0), "200000 100000");
assert_eq!(cpu_max_value(0.000_001), "1 100000");
}
#[test]
fn controllers_to_enable_skips_already_enabled_ones() {
assert!(controllers_to_enable(&["memory", "pids"], "cpu memory pids").is_empty());
assert_eq!(
controllers_to_enable(&["memory", "pids", "cpu"], "memory"),
["pids", "cpu"]
);
assert_eq!(controllers_to_enable(&["memory"], ""), ["memory"]);
assert!(controllers_to_enable(&["pids"], "pids io hugetlb").is_empty());
}
}
#[cfg(test)]
mod rearm_race_tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
struct RacingRearm<'a> {
latch: &'a crate::sys::SkipDropKill,
polls: AtomicUsize,
}
impl crate::sys::graceful::GracefulTarget for RacingRearm<'_> {
fn signal_all(&self, _signal: i32) -> crate::sys::graceful::SoftDelivery {
crate::sys::graceful::SoftDelivery::Sent
}
fn is_drained(&self) -> bool {
if self.polls.fetch_add(1, Ordering::Relaxed) == 1 {
self.latch.clear();
}
false
}
fn alive_count(&self) -> Option<usize> {
None
}
fn hard_kill(&self) -> std::io::Result<()> {
Ok(())
}
}
#[tokio::test(start_paused = true)]
async fn shutdown_request_does_not_override_a_concurrent_rearm() {
let skip = crate::sys::SkipDropKill::new();
skip.clear(); let target = RacingRearm {
latch: &skip,
polls: AtomicUsize::new(0),
};
crate::sys::graceful::run(
&target,
&skip,
libc::SIGTERM,
Duration::from_millis(100),
false,
)
.await
.expect("graceful run");
assert!(
!skip.is_set(),
"a child that joined the cgroup mid-shutdown must keep its Drop-kill \
backstop — the stale request must not re-spare it (Job::drop then \
cgroup.kill's the tree)"
);
}
}
#[cfg(test)]
mod pidfd_integration_tests {
use super::{Delivery, deliver_identity_safe, pidfd_open, pidfd_send_signal};
fn pidfd_available() -> bool {
pidfd_open(std::process::id() as i32).is_ok()
}
fn spawn_sleeper() -> std::process::Child {
std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn `sleep 30`")
}
#[test]
fn pidfd_pins_identity_and_reports_exit_via_esrch() {
if !pidfd_available() {
eprintln!("skipping: pidfd_open unavailable on this kernel/sandbox");
return;
}
let mut child = spawn_sleeper();
let pid = child.id() as i32;
let fd = pidfd_open(pid).expect("pin the live child");
pidfd_send_signal(&fd, 0).expect("null-signal a live pinned child");
child.kill().expect("kill child");
child.wait().expect("reap child");
let err =
pidfd_send_signal(&fd, 0).expect_err("a reaped, pinned task must not be signallable");
assert_eq!(
err.raw_os_error(),
Some(libc::ESRCH),
"a pinned task that exited must report ESRCH, never signal a recycled pid"
);
}
#[test]
fn a_live_non_member_is_skipped_by_the_real_primitive() {
if !pidfd_available() {
eprintln!("skipping: pidfd_open unavailable on this kernel/sandbox");
return;
}
let mut child = spawn_sleeper();
let pid = child.id() as i32;
let outcome = deliver_identity_safe(
pid,
libc::SIGKILL,
pidfd_open,
|_| Ok(false),
pidfd_send_signal,
);
assert!(matches!(outcome, Delivery::Skipped));
assert!(
child.try_wait().expect("try_wait").is_none(),
"a non-member must receive no signal — the live child is untouched"
);
let _ = child.kill();
let _ = child.wait();
}
#[test]
fn a_confirmed_live_member_is_delivered_to() {
use std::os::unix::process::ExitStatusExt;
if !pidfd_available() {
eprintln!("skipping: pidfd_open unavailable on this kernel/sandbox");
return;
}
let mut child = spawn_sleeper();
let pid = child.id() as i32;
let outcome = deliver_identity_safe(
pid,
libc::SIGTERM,
pidfd_open,
|_| Ok(true),
pidfd_send_signal,
);
assert!(matches!(outcome, Delivery::Delivered));
let status = child.wait().expect("reap the signalled child");
assert_eq!(
status.signal(),
Some(libc::SIGTERM),
"the child exited on the SIGTERM we delivered through the pidfd"
);
}
}
#[cfg(all(test, feature = "stats"))]
mod member_sample_tests {
use std::cell::Cell;
use std::io;
use std::time::Duration;
use super::{
Cgroup, MemberSample, ProcIdentity, process_identity, process_metrics,
process_metrics_with_seams, read_proc_starttime, sample_member_identity_safe,
};
use crate::sys::ProcMetrics;
fn cgroup() -> Cgroup {
Cgroup::at(std::path::PathBuf::from("/mock/processkit"))
}
fn some_metrics() -> ProcMetrics {
ProcMetrics {
cpu_time: Some(Duration::from_millis(10)),
peak_memory_bytes: Some(2048),
}
}
#[test]
fn reused_pid_outside_cgroup_is_never_folded() {
let read = Cell::new(false);
let outcome = sample_member_identity_safe(
1234,
|_| Some(ProcIdentity::from_raw(42)),
|_| Ok(false),
|_, _| {
read.set(true);
some_metrics()
},
);
assert!(matches!(outcome, MemberSample::Skipped));
assert!(
!read.get(),
"a pid recycled outside the cgroup must never have its counters folded"
);
}
#[test]
fn confirmed_member_is_folded_with_its_counters() {
let outcome = sample_member_identity_safe(
42,
|_| Some(ProcIdentity::from_raw(7)),
|_| Ok(true),
|_, _| some_metrics(),
);
match outcome {
MemberSample::Folded(m) => {
assert_eq!(m.cpu_time, Some(Duration::from_millis(10)));
assert_eq!(m.peak_memory_bytes, Some(2048));
}
_ => panic!("a confirmed member must be folded"),
}
}
#[test]
fn member_gone_before_pin_is_a_benign_skip() {
let read = Cell::new(false);
let outcome = sample_member_identity_safe(
7,
|_| None,
|_| -> io::Result<bool> { panic!("membership must not be checked once the pin fails") },
|_, _| {
read.set(true);
some_metrics()
},
);
assert!(matches!(outcome, MemberSample::Skipped));
assert!(!read.get(), "a gone member's counters must not be read");
}
#[test]
fn unreadable_membership_fails_safe_without_reading_counters() {
let read = Cell::new(false);
let outcome = sample_member_identity_safe(
7,
|_| Some(ProcIdentity::from_raw(1)),
|_| Err(io::Error::from(io::ErrorKind::PermissionDenied)),
|_, _| {
read.set(true);
some_metrics()
},
);
match outcome {
MemberSample::Failed(e) => assert_eq!(e.kind(), io::ErrorKind::PermissionDenied),
_ => panic!("an unreadable membership must fail safe"),
}
assert!(!read.get(), "fail-safe must not read any counters");
}
#[test]
fn recycle_after_reconfirm_folds_nothing() {
let outcome = sample_member_identity_safe(
7,
|_| Some(ProcIdentity::from_raw(1)),
|_| Ok(true),
|_, _| ProcMetrics::default(),
);
match outcome {
MemberSample::Folded(m) => {
assert!(
m.cpu_time.is_none() && m.peak_memory_bytes.is_none(),
"a recycle caught by the identity-gated read contributes nothing"
);
}
_ => panic!("a confirmed member is folded (with an all-None reading here)"),
}
}
#[test]
fn stats_reads_cgroup_procs_a_constant_number_of_times_for_a_whole_tree() {
let members = (1000..1100)
.map(|p| p.to_string())
.collect::<Vec<_>>()
.join("\n");
let reads = Cell::new(0usize);
let stats = cgroup()
.stats_with_seams(
|_| {
reads.set(reads.get() + 1);
Ok(members.clone())
},
|_| Some(ProcIdentity::from_raw(1)),
|_, _| some_metrics(),
)
.expect("a fully-confirmed tree folds cleanly");
assert_eq!(
reads.get(),
2,
"one read for the initial member list + one shared reconfirm read, \
independent of the 100 members (was 1 + n before this task)"
);
assert_eq!(stats.active_process_count, 100);
assert_eq!(
stats.total_cpu_time,
Some(Duration::from_millis(1000)),
"100 members × 10ms folded once each"
);
assert_eq!(
stats.peak_memory_bytes,
Some(204_800),
"100 members × 2048 bytes folded once each"
);
}
#[test]
fn stats_skips_a_pid_recycled_outside_the_cgroup_via_the_single_snapshot() {
let reads = Cell::new(0usize);
let folded = std::cell::RefCell::new(Vec::new());
let stats = cgroup()
.stats_with_seams(
|_| {
reads.set(reads.get() + 1);
Ok(if reads.get() == 1 {
"1001\n1002\n1003\n".to_owned()
} else {
"1001\n1003\n".to_owned()
})
},
|_| Some(ProcIdentity::from_raw(1)),
|pid, _| {
folded.borrow_mut().push(pid);
some_metrics()
},
)
.expect("a benign recycle race is not a fold failure");
assert_eq!(
*folded.borrow(),
vec![1001, 1003],
"only members present in the single reconfirm snapshot have their counters read"
);
assert_eq!(
stats.active_process_count, 3,
"active count reflects the initial member list, before the recycle"
);
assert_eq!(reads.get(), 2, "still exactly two reads for the whole fold");
assert_eq!(
stats.total_cpu_time,
Some(Duration::from_millis(20)),
"only the two confirmed members (1001, 1003) are folded"
);
assert_eq!(stats.peak_memory_bytes, Some(4096));
}
#[test]
fn process_identity_matches_a_same_process_metrics_read() {
let me = std::process::id();
assert!(
read_proc_starttime(me).is_some(),
"our own /proc/<pid>/stat starttime must be readable"
);
let id = process_identity(me).expect("our own live process has a start identity");
let gated = process_metrics(me, Some(id));
assert!(
gated.cpu_time.is_some(),
"an identity-matched read of our own process reports CPU time"
);
}
#[test]
fn identity_change_after_status_read_discards_both_process_metrics() {
fn stat_with_starttime(starttime: u64) -> String {
format!("1 (mock) S 0 0 0 0 0 0 0 0 0 0 5 7 0 0 0 0 0 0 {starttime}")
}
let original = ProcIdentity::from_raw(100);
let stat_reads = Cell::new(0);
let metrics = process_metrics_with_seams(
42,
Some(original),
|_| {
let read = stat_reads.get();
stat_reads.set(read + 1);
Some(stat_with_starttime(if read == 0 { 100 } else { 200 }))
},
|_| Some("Name:\trecycled\nVmHWM:\t123 kB\n".to_owned()),
);
assert_eq!(
stat_reads.get(),
2,
"identity is checked on both sides of status"
);
assert!(
metrics.cpu_time.is_none() && metrics.peak_memory_bytes.is_none(),
"a post-status identity mismatch must discard CPU and the replacement process's memory"
);
}
#[test]
fn a_mismatched_identity_yields_defaults_not_the_live_process_counters() {
let me = std::process::id();
let real = process_identity(me).expect("our own live process has a start identity");
let bogus = ProcIdentity::from_raw(real.raw().wrapping_add(1));
let gated = process_metrics(me, Some(bogus));
assert!(
gated.cpu_time.is_none() && gated.peak_memory_bytes.is_none(),
"a mismatched identity must yield defaults, never the live process's \
CPU/memory — the recycled-pid fail-safe"
);
assert!(
process_metrics(me, None).cpu_time.is_some(),
"an unchecked read (identity None) still reports metrics"
);
}
}
#[cfg(test)]
mod detect_mechanism_tests {
use std::path::Path;
use super::{
Job, cgroup2_root, cgroup2_self_dir, detect_mechanism, dir_allows_subdir_creation,
};
use crate::Mechanism;
fn new_job() -> Job {
#[cfg(feature = "limits")]
{
Job::new(&crate::limits::ResourceLimits::default()).expect("create a job")
}
#[cfg(not(feature = "limits"))]
{
Job::new().expect("create a job")
}
}
#[test]
fn detection_reports_a_valid_linux_mechanism() {
assert!(
matches!(
detect_mechanism(),
Mechanism::CgroupV2 | Mechanism::ProcessGroup
),
"linux detection is cgroup v2 or its pgroup fallback"
);
}
#[test]
fn the_writability_probe_creates_no_filesystem_entry() {
let tmp =
std::env::temp_dir().join(format!("processkit-detect-probe-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).expect("scratch dir");
let _ = dir_allows_subdir_creation(&tmp);
let stayed_empty = std::fs::read_dir(&tmp)
.expect("read scratch dir")
.next()
.is_none();
let _ = std::fs::remove_dir_all(&tmp);
assert!(
stayed_empty,
"the writability probe must create no filesystem entry"
);
}
#[test]
fn query_creates_no_cgroup_dir_and_matches_a_real_group() {
let parent = cgroup2_root().and_then(|root| cgroup2_self_dir(&root).ok());
let count_pk_dirs = |dir: &Path| -> usize {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
entries
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().starts_with("processkit-"))
.count()
};
let before = parent.as_deref().map(count_pk_dirs);
for _ in 0..32 {
let _ = detect_mechanism();
}
let after = parent.as_deref().map(count_pk_dirs);
assert_eq!(
before, after,
"the read-only host query must create no cgroup directory"
);
let job = new_job();
assert_eq!(
detect_mechanism(),
job.mechanism(),
"the read-only mechanism query must match a really-created group's mechanism"
);
}
}
#[cfg(all(test, feature = "pty"))]
mod pty_rollback_spare_tests {
use std::path::PathBuf;
use std::time::Duration;
use tokio::process::{Child, Command};
use super::{Backend, Cgroup, Job};
use crate::sys::fault_injection::{Faults, Site};
use crate::sys::{SkipDropKill, SpawnOptions};
fn cgroup_job(tag: &str) -> (Job, PathBuf) {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!(
"processkit-pty-spare-{tag}-{}-{nanos}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("create the stand-in cgroup dir");
std::fs::write(dir.join("cgroup.procs"), b"").expect("create cgroup.procs");
let job = Job {
backend: Backend::Cgroup(Cgroup::at(dir.clone())),
skip_drop_kill: SkipDropKill::new(),
};
(job, dir)
}
fn survivor_command() -> Command {
let mut command = Command::new("sh");
command
.args(["-c", "trap '' TERM; while :; do sleep 60; done"])
.kill_on_drop(true);
command
}
fn idle_command() -> Command {
let mut command = Command::new("sh");
command.args(["-c", "while :; do sleep 60; done"]);
command
}
async fn reap(mut child: Child) {
let _ = child.start_kill();
let _ = child.wait().await;
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "spawns real subprocesses"]
async fn a_failed_pty_launch_restores_the_cgroup_arms_spare() {
let (job, dir) = cgroup_job("restore");
let survivor = job
.spawn(&mut survivor_command(), &SpawnOptions::default())
.expect("spawn a cgroup member");
job.graceful_shutdown(libc::SIGTERM, Duration::from_millis(100), false)
.await
.expect("graceful shutdown");
assert!(
job.skip_drop_kill.is_set(),
"precondition: a non-escalating shutdown spares the survivors"
);
{
let _fault = Faults::new()
.fail_every(Site::PtyMasterClone, Some("writer"), libc::EIO)
.arm();
let result = job.spawn_pty(&mut idle_command(), &pty_options(), None);
assert!(
result.is_err(),
"the injected master-clone fault must surface as an error"
);
}
assert!(
job.skip_drop_kill.is_set(),
"the rollback must restore the spare its own spawn displaced — otherwise \
Job::drop hard-kills survivors the caller chose not to escalate against"
);
drop(job);
reap(survivor).await;
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "spawns real subprocesses"]
async fn a_spawn_between_the_pty_spawn_and_its_rollback_keeps_the_backstop_armed() {
let (job, dir) = cgroup_job("newcomer");
let survivor = job
.spawn(&mut survivor_command(), &SpawnOptions::default())
.expect("spawn a cgroup member");
job.graceful_shutdown(libc::SIGTERM, Duration::from_millis(100), false)
.await
.expect("graceful shutdown");
let (pty_child, displaced) = job
.spawn_displacing_spare(&mut idle_command(), &SpawnOptions::default())
.expect("spawn the pty child");
let pty_pid = pty_child.id().expect("the pty child reports a pid");
let newcomer = job
.spawn(&mut survivor_command(), &SpawnOptions::default())
.expect("spawn the newcomer");
job.rollback_pty_spawn(pty_pid, displaced);
assert!(
!job.skip_drop_kill.is_set(),
"a member that joined after the rolled-back spawn must keep its \
kill-on-drop backstop — restoring the older spare would strip it"
);
drop(job);
reap(pty_child).await;
reap(newcomer).await;
reap(survivor).await;
let _ = std::fs::remove_dir_all(&dir);
}
fn pty_options() -> SpawnOptions {
SpawnOptions {
use_pty: true,
..SpawnOptions::default()
}
}
}
#[cfg(test)]
mod leaf_cgroup_tests {
use std::cell::Cell;
use std::io;
use std::path::{Path, PathBuf};
use super::{Backend, Cgroup, Job, LEAF_RECLAIM_FLOOR, LeafSlot};
use crate::sys::SkipDropKill;
fn job_cgroup() -> (tempfile::TempDir, Cgroup) {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("processkit-job");
std::fs::create_dir(&path).expect("create the stand-in job cgroup dir");
write_procs(&path, &[]);
std::fs::write(path.join("cgroup.kill"), "").expect("seed the whole-job kill file");
(dir, Cgroup::at(path))
}
fn seed_leaf(cg: &Cgroup, name: &str, pid: Option<i32>, members: &[i32]) -> PathBuf {
let dir = cg.path.join(name);
std::fs::create_dir(&dir).expect("create the stand-in leaf dir");
write_procs(&dir, members);
std::fs::write(dir.join("cgroup.kill"), "").expect("seed the leaf kill file");
cg.register_leaf(pid, dir.clone());
dir
}
fn write_procs(dir: &Path, pids: &[i32]) {
let text: String = pids.iter().map(|pid| format!("{pid}\n")).collect();
std::fs::write(dir.join("cgroup.procs"), text).expect("seed a member list");
}
#[cfg(feature = "pty")]
fn read_back(path: &Path) -> String {
std::fs::read_to_string(path).expect("read back a control file")
}
#[test]
fn membership_is_the_union_of_the_job_cgroup_and_every_leaf() {
let (_tmp, cg) = job_cgroup();
write_procs(&cg.path, &[11]);
seed_leaf(&cg, "spawn-a", Some(101), &[101, 1011]);
seed_leaf(&cg, "spawn-b", Some(102), &[102]);
assert_eq!(
cg.members().expect("read the job's membership"),
[11, 101, 102, 1011],
"a whole-job membership read must see every leaf's members, not just the \
job cgroup's own"
);
}
#[test]
fn a_gone_leaf_is_empty_and_an_unreadable_one_is_unknown() {
let (_tmp, cg) = job_cgroup();
let gone = seed_leaf(&cg, "spawn-gone", Some(1), &[]);
std::fs::remove_dir_all(&gone).expect("remove the leaf directory");
seed_leaf(&cg, "spawn-live", Some(2), &[42]);
assert_eq!(cg.members().expect("a removed leaf is not a failure"), [42]);
let err = cg
.members_with(|path| {
if path.starts_with(&gone) {
Err(io::Error::from(io::ErrorKind::PermissionDenied))
} else {
std::fs::read_to_string(path)
}
})
.expect_err("an unreadable leaf must not look like a job without those members");
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
}
#[test]
fn membership_costs_one_read_per_cgroup_per_pass_whatever_the_pid_count() {
struct Handle;
let (_tmp, cg) = job_cgroup();
seed_leaf(&cg, "spawn-a", Some(101), &[]);
seed_leaf(&cg, "spawn-b", Some(102), &[]);
let reads_for = |pids: &[i32]| -> usize {
let listing: String = pids.iter().map(|pid| format!("{pid}\n")).collect();
let reads = Cell::new(0usize);
cg.signal_with_seams(
libc::SIGTERM,
|_: &Path| {
reads.set(reads.get() + 1);
Ok(listing.clone())
},
|_pid| Ok(Handle),
|_: &Handle, _| Ok(()),
)
.expect("a broadcast over confirmed members");
reads.get()
};
assert_eq!(reads_for(&[1001, 1002, 1003, 1004]), 6);
assert_eq!(
reads_for(&[1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008]),
6,
"twice the members must not cost a single extra read"
);
}
#[cfg(feature = "pty")]
#[test]
fn a_selective_kill_reaches_only_the_leaf_of_the_spawn_it_names() {
let (_tmp, cg) = job_cgroup();
let a = seed_leaf(&cg, "spawn-a", Some(101), &[101]);
let b = seed_leaf(&cg, "spawn-b", Some(102), &[102]);
assert!(
cg.kill_leaf_of(101),
"the spawn's leaf is there to be killed"
);
assert_eq!(read_back(&a.join("cgroup.kill")), "1");
assert_eq!(
read_back(&b.join("cgroup.kill")),
"",
"another spawn's leaf must not be touched by this spawn's rollback"
);
assert_eq!(
read_back(&cg.path.join("cgroup.kill")),
"",
"the whole-job kill file must not be written by a per-spawn rollback"
);
assert!(
!cg.kill_leaf_of(101),
"the pid is consumed: a number the kernel may recycle must not aim a \
second kill at a leaf that is no longer its spawn's"
);
}
#[cfg(feature = "pty")]
#[test]
fn a_refused_leaf_kill_reports_no_kill_and_keeps_the_leaf() {
use crate::sys::fault_injection::{Faults, Site};
let (_tmp, cg) = job_cgroup();
let leaf = seed_leaf(&cg, "spawn-a", Some(101), &[101]);
let faults = Faults::new()
.fail_every(Site::CgroupWrite, Some("cgroup.kill"), libc::EACCES)
.arm();
let killed = cg.kill_leaf_of(101);
assert_eq!(faults.fired(Site::CgroupWrite), 1);
drop(faults);
assert!(
!killed,
"a refused write is not a kill — the caller must fall back rather than \
believe this spawn's tree is gone"
);
assert!(leaf.exists(), "the leaf must not be reclaimed unkilled");
assert_eq!(
cg.members().expect("read the job's membership"),
[101],
"what the selective kill could not reach is still a member of the job"
);
}
#[test]
fn a_reclaim_releases_only_the_directories_the_kernel_lets_go_of() {
let (_tmp, cg) = job_cgroup();
let drained = cg.path.join("spawn-drained");
std::fs::create_dir(&drained).expect("create the drained leaf dir");
cg.register_leaf(Some(1), drained.clone());
let busy = seed_leaf(&cg, "spawn-busy", Some(2), &[7]);
cg.reclaim_leaves();
assert!(!drained.exists(), "a removable leaf directory is reclaimed");
assert!(busy.exists(), "a leaf that still holds something is kept");
assert_eq!(cg.leaf_dirs(), [busy], "and only that one stays registered");
assert_eq!(
cg.members().expect("read the job's membership"),
[7],
"the kept leaf's members are still enumerated"
);
}
#[test]
fn finished_leaves_do_not_pile_up_between_teardowns() {
let (_tmp, cg) = job_cgroup();
let busy = seed_leaf(&cg, "spawn-busy", Some(0), &[7]);
let register_drained_leaves = |count: usize, tag: &str| {
for n in 0..count {
let dir = cg.path.join(format!("spawn-{tag}-{n}"));
std::fs::create_dir(&dir).expect("create a drained leaf dir");
cg.register_leaf(Some(n as i32 + 1), dir);
}
let registered = cg.leaf_dirs();
let on_disk = std::fs::read_dir(&cg.path)
.expect("read the job cgroup dir")
.filter_map(Result::ok)
.filter(|entry| entry.path().is_dir())
.count();
(registered, on_disk)
};
let (registered, on_disk) = register_drained_leaves(2 * LEAF_RECLAIM_FLOOR, "a");
assert!(
registered.len() <= LEAF_RECLAIM_FLOOR && on_disk == registered.len(),
"after {} launches: {registered:?} registered, {on_disk} directories",
2 * LEAF_RECLAIM_FLOOR
);
assert!(
registered.contains(&busy),
"the live leaf is never reclaimed"
);
let (later, later_on_disk) = register_drained_leaves(4 * LEAF_RECLAIM_FLOOR, "b");
assert!(
later.len() <= LEAF_RECLAIM_FLOOR && later_on_disk == later.len(),
"after {} more launches: {later:?} registered, {later_on_disk} directories — \
the leftovers must not grow with the number of launches",
4 * LEAF_RECLAIM_FLOOR
);
assert!(later.contains(&busy), "and it still is not");
assert!(busy.exists());
}
#[test]
fn a_directory_that_cannot_host_a_leaf_falls_back_to_the_job_cgroup() {
let (_tmp, cg) = job_cgroup();
let slot = cg.open_leaf();
assert_eq!(
slot.procs_path(),
cg.path.join("cgroup.procs"),
"the child must still join the job's own cgroup"
);
drop(slot);
let leftovers: Vec<_> = std::fs::read_dir(&cg.path)
.expect("read the job cgroup dir")
.filter_map(Result::ok)
.filter(|entry| entry.path().is_dir())
.collect();
assert!(
leftovers.is_empty(),
"the directory reserved for a leaf that could not be used must be removed"
);
assert!(
cg.leaf_dirs().is_empty(),
"and nothing must be registered for it"
);
}
#[test]
fn a_leaf_the_kernel_refuses_to_remove_stays_the_jobs_to_enumerate() {
let (_tmp, cg) = job_cgroup();
let dir = cg.path.join("spawn-failed-after-fork");
std::fs::create_dir(&dir).expect("create the reserved leaf dir");
write_procs(&dir, &[4242]);
let slot = LeafSlot {
cg: &cg,
dir: Some(dir.clone()),
};
assert_eq!(
slot.procs_path(),
dir.join("cgroup.procs"),
"precondition: this launch's child joined the leaf, not the job cgroup"
);
drop(slot);
assert!(
dir.exists(),
"the kernel refused the rmdir, so the leaf is still standing"
);
assert_eq!(
cg.leaf_dirs(),
std::slice::from_ref(&dir),
"and the job must still know about it"
);
assert_eq!(
cg.members().expect("read the job's membership"),
[4242],
"a member the job cannot enumerate is one no per-pid kill can reach — and \
one that makes an unkilled job look drained"
);
#[cfg(feature = "pty")]
assert!(
!cg.kill_leaf_of(4242),
"no pid may steer a selective kill at a leaf this job never got one for"
);
std::fs::remove_file(dir.join("cgroup.procs")).expect("drain the leaf");
drop(Job {
backend: Backend::Cgroup(cg),
skip_drop_kill: SkipDropKill::new(),
});
assert!(
!dir.exists(),
"a drained leaf must not outlive the job that took it back"
);
}
#[test]
fn dropping_a_job_reclaims_its_leaf_directories_first() {
let (tmp, cg) = job_cgroup();
let path = cg.path.clone();
let drained = path.join("spawn-drained");
std::fs::create_dir(&drained).expect("create the drained leaf dir");
cg.register_leaf(Some(1), drained.clone());
let nested = path.join("spawn-nested");
std::fs::create_dir(&nested).expect("create the nested leaf dir");
std::fs::create_dir(nested.join("child-of-a-child")).expect("nest a cgroup inside it");
cg.register_leaf(Some(2), nested.clone());
drop(Job {
backend: Backend::Cgroup(cg),
skip_drop_kill: SkipDropKill::new(),
});
assert!(!drained.exists(), "an empty leaf must not outlive the job");
assert!(
nested.exists(),
"a leaf the kernel refuses to remove is left standing, like the job dir itself"
);
drop(tmp);
}
}
#[cfg(test)]
mod real_cgroup_leaf_tests {
use std::os::unix::process::ExitStatusExt;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::process::{Child, Command};
use super::{Backend, Job};
use crate::sys::SpawnOptions;
fn cgroup_job() -> Option<(Job, PathBuf)> {
#[cfg(feature = "limits")]
let job = Job::new(&crate::limits::ResourceLimits::default()).expect("create a job");
#[cfg(not(feature = "limits"))]
let job = Job::new().expect("create a job");
let path = match &job.backend {
Backend::Cgroup(cg) => cg.path.clone(),
Backend::ProcessGroup(_) => {
eprintln!(
"skipping: this host has no writable cgroup v2 (Job::new fell back to the \
process-group backend) — the per-spawn leaf contract is not reachable here"
);
return None;
}
};
Some((job, path))
}
fn sleeper() -> Command {
let mut command = Command::new("sh");
command.args(["-c", "while :; do sleep 60; done"]);
command
}
fn leaf_dirs_on_disk(job_dir: &Path) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = std::fs::read_dir(job_dir)
.expect("read the job cgroup dir")
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.is_dir()
&& path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("spawn-"))
})
.collect();
dirs.sort();
dirs
}
fn procs_of(dir: &Path) -> Vec<u32> {
std::fs::read_to_string(dir.join("cgroup.procs"))
.expect("read a cgroup.procs")
.lines()
.filter_map(|line| line.trim().parse().ok())
.collect()
}
#[cfg(feature = "pty")]
fn is_alive(pid: u32) -> bool {
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
}
#[cfg(feature = "pty")]
async fn wait_until_gone(pid: u32, what: &str) {
for _ in 0..600 {
if !is_alive(pid) {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
panic!("{what}: pid {pid} was still alive after the bounded wait");
}
#[cfg(feature = "pty")]
async fn published_pid(path: &Path) -> u32 {
for _ in 0..600 {
if let Ok(text) = std::fs::read_to_string(path) {
let text = text.trim().to_owned();
if !text.is_empty() {
return text.parse().expect("the helper publishes a pid");
}
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("the helper never published its pid");
}
#[cfg(feature = "pty")]
fn pidfile(tag: &str) -> PathBuf {
let path =
std::env::temp_dir().join(format!("processkit_leaf_{tag}_{}.pid", std::process::id()));
let _ = std::fs::remove_file(&path);
path
}
async fn reap(mut child: Child) {
let _ = child.start_kill();
let _ = child.wait().await;
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "creates real cgroups and spawns real subprocesses"]
async fn every_spawn_lands_in_its_own_leaf_and_the_job_lists_them_all() {
let Some((job, dir)) = cgroup_job() else {
return;
};
let first = job
.spawn(&mut sleeper(), &SpawnOptions::default())
.expect("spawn the first child");
let second = job
.spawn(&mut sleeper(), &SpawnOptions::default())
.expect("spawn the second child");
let (first_pid, second_pid) = (first.id().expect("a pid"), second.id().expect("a pid"));
let leaves = leaf_dirs_on_disk(&dir);
assert_eq!(leaves.len(), 2, "one leaf per spawn, in {dir:?}");
let leaf_of = |pid: u32| -> PathBuf {
let mut holding = leaves
.iter()
.filter(|leaf| procs_of(leaf).contains(&pid))
.cloned();
let leaf = holding
.next()
.unwrap_or_else(|| panic!("pid {pid} is in none of the job's leaves: {leaves:?}"));
assert!(holding.next().is_none(), "a pid is in exactly one cgroup");
leaf
};
assert_ne!(
leaf_of(first_pid),
leaf_of(second_pid),
"two spawns sharing a leaf would make a per-spawn kill hit them both"
);
assert!(
procs_of(&dir).is_empty(),
"the job's own cgroup holds no spawned member once every spawn has a leaf"
);
#[cfg(feature = "process-control")]
{
let members = job.members().expect("read the job's members");
assert!(
members.contains(&first_pid) && members.contains(&second_pid),
"a membership read must aggregate the leaves: {members:?}"
);
}
drop(job);
reap(first).await;
reap(second).await;
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "creates real cgroups and spawns real subprocesses"]
async fn dropping_a_job_leaves_no_leaf_directory_behind() {
let Some((job, dir)) = cgroup_job() else {
return;
};
let first = job
.spawn(&mut sleeper(), &SpawnOptions::default())
.expect("spawn the first child");
let second = job
.spawn(&mut sleeper(), &SpawnOptions::default())
.expect("spawn the second child");
assert_eq!(leaf_dirs_on_disk(&dir).len(), 2);
drop(job);
assert!(
!dir.exists(),
"the job directory must be gone — a leaf left behind would keep it \
(`rmdir` answers ENOTEMPTY) and leak both"
);
reap(first).await;
reap(second).await;
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "creates real cgroups"]
async fn a_launch_that_never_started_leaves_no_leaf_behind() {
let Some((job, dir)) = cgroup_job() else {
return;
};
let mut command = Command::new("/nonexistent/processkit-no-such-program");
job.spawn(&mut command, &SpawnOptions::default())
.expect_err("the launch itself must fail");
assert!(
leaf_dirs_on_disk(&dir).is_empty(),
"the leaf reserved for a launch that never happened must be removed"
);
drop(job);
}
#[tokio::test(flavor = "current_thread")]
#[ignore = "creates real cgroups and spawns real subprocesses"]
async fn a_launch_that_failed_after_forking_keeps_its_child_enumerable_and_killable() {
use std::ffi::CString;
use std::os::unix::ffi::OsStringExt;
use std::os::unix::process::CommandExt;
use crate::sys::fault_injection::{Faults, Site};
let Some((job, dir)) = cgroup_job() else {
return;
};
let Backend::Cgroup(cg) = &job.backend else {
unreachable!("cgroup_job hands back the cgroup backend or nothing");
};
let slot = cg.open_leaf();
let leaf = slot
.procs_path()
.parent()
.expect("a cgroup.procs has a directory")
.to_path_buf();
assert_ne!(
leaf, dir,
"precondition: this host let the launch reserve a leaf of its own"
);
let procs = CString::new(slot.procs_path().into_os_string().into_vec())
.expect("a NUL-free cgroup path");
let mut command = sleeper();
unsafe {
command
.as_std_mut()
.pre_exec(move || super::write_self_pid(procs.as_c_str()));
}
let mut child = command.spawn().expect("the fork itself succeeds");
let pid = child.id().expect("a pid") as i32;
drop(slot);
assert!(
procs_of(&leaf).contains(&(pid as u32)),
"precondition: the child of the failed launch is in the leaf, not the job \
cgroup — the whole point of the leaf being the one thing that could lose it"
);
assert_eq!(
cg.leaf_dirs(),
std::slice::from_ref(&leaf),
"a leaf the kernel refused to remove must stay the job's"
);
assert!(
cg.members()
.expect("read the job's membership")
.contains(&pid),
"and its member must still be one of the job's"
);
#[cfg(feature = "process-control")]
assert!(
job.members()
.expect("read the job's members")
.contains(&(pid as u32)),
"as seen through the whole-job verb the caller actually has"
);
let faults = Faults::new()
.fail_every(Site::CgroupWrite, Some("cgroup.kill"), libc::EACCES)
.arm();
let swept = job.kill_all();
assert_eq!(faults.fired(Site::CgroupWrite), 1);
drop(faults);
swept.expect("the per-pid sweep must drain the job");
let status = tokio::time::timeout(Duration::from_secs(10), child.wait())
.await
.expect("the child of the failed launch must be killed by that sweep")
.expect("wait for the child of the failed launch");
assert_eq!(
status.signal(),
Some(libc::SIGKILL),
"killed by the sweep, not left to exit on its own: {status:?}"
);
drop(job);
assert!(
!dir.exists(),
"the job directory must be gone, leaf and all"
);
}
#[cfg(feature = "pty")]
#[tokio::test(flavor = "current_thread")]
#[ignore = "creates real cgroups and spawns real subprocesses incl. a setsid escapee"]
async fn a_rollback_kills_this_spawns_setsid_escapee_and_spares_the_other_spawns() {
let Some((job, dir)) = cgroup_job() else {
return;
};
if !has_setsid() {
eprintln!("skipping: no setsid(1) on this host to build a session escapee with");
return;
}
let file = pidfile("escapee");
let survivor = job
.spawn(&mut sleeper(), &SpawnOptions::default())
.expect("spawn the survivor");
let survivor_pid = survivor.id().expect("a pid");
let mut command = Command::new("sh");
command
.args([
"-c",
"setsid sh -c 'echo $$ > \"$PK_PIDFILE\"; exec sleep 300' </dev/null \
>/dev/null 2>&1 & while :; do sleep 60; done",
])
.env("PK_PIDFILE", &file);
let mut victim = job
.spawn(&mut command, &SpawnOptions::default())
.expect("spawn the launch that will be rolled back");
let victim_pid = victim.id().expect("a pid");
let escapee = published_pid(&file).await;
assert_eq!(
unsafe { libc::getsid(escapee as libc::pid_t) },
escapee as libc::pid_t,
"escapee {escapee} never became a session leader — the test would prove nothing"
);
assert_eq!(
leaf_dirs_on_disk(&dir).len(),
2,
"precondition: two spawns, two leaves"
);
job.rollback_pty_spawn(victim_pid, crate::sys::DisplacedSpare::default());
wait_until_gone(
escapee,
"the rollback's leaf-scoped cgroup.kill must reach a setsid escapee of \
the spawn it undoes",
)
.await;
let status = tokio::time::timeout(Duration::from_secs(10), victim.wait())
.await
.expect("the rolled-back spawn's own child must be killed by the leaf kill")
.expect("wait for the rolled-back child");
assert_eq!(
status.signal(),
Some(libc::SIGKILL),
"and killed by the leaf's SIGKILL, not left to exit on its own: {status:?}"
);
assert!(
is_alive(survivor_pid),
"another spawn of the same job must not be touched by this spawn's rollback"
);
#[cfg(feature = "process-control")]
assert!(
job.members()
.expect("read the job's members")
.contains(&survivor_pid),
"and it must still be a member of the job"
);
if let Backend::Cgroup(cg) = &job.backend {
cg.reclaim_leaves();
}
assert_eq!(
leaf_dirs_on_disk(&dir).len(),
1,
"the killed spawn's leaf is reclaimed; the survivor's stays"
);
drop(job);
reap(survivor).await;
let _ = std::fs::remove_file(&file);
unsafe { libc::kill(escapee as libc::pid_t, libc::SIGKILL) };
}
#[cfg(feature = "pty")]
fn has_setsid() -> bool {
std::process::Command::new("sh")
.args(["-c", "command -v setsid"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
}
#[cfg(all(test, feature = "process-control"))]
mod real_cgroup_adopt_tests {
use std::path::{Path, PathBuf};
use std::time::Duration;
use super::{Backend, Job};
use crate::sys::fault_injection::{Faults, Site};
fn cgroup_job() -> Option<(Job, PathBuf)> {
#[cfg(feature = "limits")]
let job = Job::new(&crate::limits::ResourceLimits::default()).expect("create a job");
#[cfg(not(feature = "limits"))]
let job = Job::new().expect("create a job");
match &job.backend {
Backend::Cgroup(cg) => {
let path = cg.path.clone();
Some((job, path))
}
Backend::ProcessGroup(_) => {
eprintln!(
"skipping: this host has no writable cgroup v2 (Job::new fell back to the \
process-group backend) — the cgroup adoption path is not reachable here"
);
None
}
}
}
fn procs_of(dir: &Path) -> Vec<u32> {
std::fs::read_to_string(dir.join("cgroup.procs"))
.expect("read a cgroup.procs")
.lines()
.filter_map(|line| line.trim().parse().ok())
.collect()
}
fn spawn_orphan() -> u32 {
let out = std::process::Command::new("sh")
.args(["-c", "sleep 60 >/dev/null 2>&1 </dev/null & echo $!"])
.output()
.expect("launch the orphan's launcher");
assert!(out.status.success(), "the orphan's launcher failed");
String::from_utf8_lossy(&out.stdout)
.trim()
.parse()
.expect("the launcher prints the orphan's pid")
}
fn is_alive(pid: u32) -> bool {
unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
}
async fn wait_until_gone(pid: u32, what: &str) {
for _ in 0..600 {
if !is_alive(pid) {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
panic!("{what}: pid {pid} was still alive after the bounded wait");
}
#[tokio::test]
#[ignore = "needs a writable cgroup v2 and spawns a real subprocess"]
async fn adopt_external_makes_a_foreign_process_a_cgroup_member() {
let Some((job, dir)) = cgroup_job() else {
return;
};
let pid = spawn_orphan();
job.adopt_external(pid).expect("adopt a foreign pid");
assert!(
procs_of(&dir).contains(&pid),
"the adopted process must be a member of the job's own cgroup"
);
job.kill_all().expect("kill the job");
wait_until_gone(pid, "the adopted foreign process dies with the job").await;
}
#[tokio::test]
#[ignore = "needs a writable cgroup v2 and spawns a real subprocess"]
async fn a_refused_cgroup_procs_write_is_not_reported_as_containment() {
let Some((job, dir)) = cgroup_job() else {
return;
};
let pid = spawn_orphan();
let err = {
let _faults = Faults::new()
.fail_every(Site::CgroupWrite, Some("cgroup.procs"), libc::EACCES)
.arm();
job.adopt_external(pid)
.expect_err("a refused cgroup.procs write must fail the adoption")
};
assert_eq!(err.raw_os_error(), Some(libc::EACCES), "{err:?}");
assert!(
!procs_of(&dir).contains(&pid),
"a refused adoption must not leave the pid a member"
);
assert!(
is_alive(pid),
"a refused adoption must leave the process alone"
);
unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
drop(job);
}
#[tokio::test]
#[ignore = "needs a writable cgroup v2 and spawns a real subprocess"]
async fn a_recycled_adoption_is_taken_back_out_of_the_job_cgroup() {
let Some((job, dir)) = cgroup_job() else {
return;
};
let pid = spawn_orphan();
job.adopt_external(pid).expect("adopt a foreign pid");
assert!(
procs_of(&dir).contains(&pid),
"the adopted process must be a member before the undo has anything to do"
);
let Backend::Cgroup(cg) = &job.backend else {
unreachable!("cgroup_job only returns the cgroup backend");
};
match cg.evict_recycled(pid) {
super::RecycleUndo::Evicted => {}
super::RecycleUndo::NotAMember => panic!("the pid was a member a moment ago"),
super::RecycleUndo::Stuck(e) => {
unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
panic!(
"this host refuses to move an adopted pid back out of the job's cgroup \
({e}) — the undo degrades to the documented 'still a member' outcome here"
);
}
}
assert!(
!procs_of(&dir).contains(&pid),
"an evicted number must no longer be a member of the job's cgroup"
);
job.kill_all().expect("kill the job");
tokio::time::sleep(Duration::from_millis(200)).await;
let survived = is_alive(pid);
unsafe { libc::kill(pid as libc::pid_t, libc::SIGKILL) };
assert!(
survived,
"a process taken back out of the job's cgroup must not die with the job"
);
drop(job);
}
#[tokio::test]
#[ignore = "needs a writable cgroup v2"]
async fn adopt_external_of_a_pid_that_names_nothing_is_not_found() {
let Some((job, _dir)) = cgroup_job() else {
return;
};
let err = job
.adopt_external(2_000_000_000)
.expect_err("a pid that names nothing is not adoptable");
assert_eq!(err.kind(), std::io::ErrorKind::NotFound, "{err:?}");
}
}