server/sandbox/src/pids.rs
//! **The scan sidecar's process bound** (RA5-27): what stops a fork bomb in a job.
//!
//! `RLIMIT_NPROC` is no answer: it counts every process of the sidecar's uid in its user
//! namespace, which in a pod without user namespaces is node-wide (other containers that
//! run as 65532 count too), so a bound set that way starves the worker for reasons the
//! sidecar cannot see. The bound is a cgroup's `pids.max`, decided once when the sidecar
//! starts serving and named in every ping:
//!
//! | Case | When | What bounds a job |
//! |---|---|---|
//! | [`PidsBound::PerJob`] | the sidecar's cgroup v2 is delegated to it: `pids` in its `cgroup.controllers`, and the directory, `cgroup.procs` and `cgroup.subtree_control` writable; and a probe job proved it cannot leave its cgroup ([`prove_sealed`]) | the sidecar moves itself into `<own>/serve`, turns `+pids` on for its children, and runs each job in its own `<own>/job-<n>` with `pids.max` = the job's `pids`, sealed in its own cgroup namespace; after the job, `cgroup.kill` and `rmdir` |
//! | [`PidsBound::Container`] | the cgroup is read-only (a default podman or Kubernetes container), and its `pids.max` (or an ancestor's) is a number | the container's own limit, shared by every job: at most `max_jobs` times a worker's processes |
//! | [`PidsBound::Pod`] | neither, and the operator names the pod's limit (`serve --pod-pids-limit <n>`, the chart's `scanWorker.podPidsLimit`) | the kubelet's `podPidsLimit`: it sits on the POD's cgroup, above the container's cgroup namespace, so the sidecar cannot read it and takes it as stated |
//! | [`PidsBound::Unbounded`] | none of these | nothing: a RELEASE build refuses every ping and job by name (`holger-sandbox-13`); a dev build warns and runs |
//!
//! A job's processes join their cgroup between fork and exec: the parent opens the job's
//! `cgroup.procs` before the fork, and the child writes `0` (itself) to it, a plain write.
//!
//! **The seal** (RA5-27): the worker runs as the sidecar's own user, so every file of the
//! delegated cgroup is its to write. Joined, it could move itself back into `<own>/serve`
//! or write `max` to its own job's `pids.max`. So right after joining, the child unshares a
//! cgroup namespace ([`seal_in_child`]): its job's cgroup is the namespace's root, and with
//! the hierarchy mounted `nsdelegate` (systemd's default, and what a container inherits) the
//! kernel refuses a move to any cgroup outside that root and a write to the root's own
//! interface files. Without `CAP_SYS_ADMIN` the namespace is made together with a user
//! namespace that maps the worker's own uid and gid to themselves, as `unshare -r -C` does.
//! The sidecar proves the seal once, with a probe job, before it takes the per-job case
//! ([`prove_sealed`]); a seal that does not hold (no user namespaces, no `nsdelegate`) is no
//! per-job bound, and the container's limit holds instead.
use std::ffi::CStr;
use std::os::fd::{AsRawFd as _, OwnedFd, RawFd};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
/// Where the cgroup hierarchy is mounted.
pub const CGROUP_FS: &str = "/sys/fs/cgroup";
/// The words a refusal for [`PidsBound::Unbounded`] gives: what to set.
pub const UNBOUNDED_FIX: &str = "the scan sidecar's container has no pids limit and no delegated cgroup; \
set the container's pids limit (podman --pids-limit), or set the kubelet's podPidsLimit and name it \
to the sidecar (serve --pod-pids-limit <n>, the chart's scanWorker.podPidsLimit), \
or delegate the sidecar a cgroup v2 with the pids controller";
/// How the sidecar bounds its jobs' processes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PidsBound {
/// A delegated cgroup v2 (`own`): each job runs in its own `own/job-<n>`.
PerJob { own: PathBuf },
/// The container's own `pids.max`, shared by every job.
Container { max: u64 },
/// The pod's limit as the operator states it (`--pod-pids-limit`): out of the sidecar's sight.
Pod { max: u64 },
/// No bound at all.
Unbounded,
}
impl PidsBound {
/// The words a ping carries.
pub fn words(&self) -> String {
match self {
PidsBound::PerJob { own } => format!(
"pids: a cgroup per job under {}, each sealed in its own cgroup namespace",
own.display()
),
PidsBound::Container { max } => format!("pids: the container's limit, {max}"),
PidsBound::Pod { max } => {
format!("pids: the pod's limit as the operator states it, {max}")
}
PidsBound::Unbounded => {
"pids: NO LIMIT (a dev build runs; a release build refuses)".into()
}
}
}
}
/// What the cgroup files say: the decision's input.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Seen {
/// The sidecar's own cgroup v2 directory, when `/proc/self/cgroup` names one.
pub own: Option<PathBuf>,
/// That directory is the sidecar's to write, with the pids controller.
pub delegated: bool,
/// The lowest numeric `pids.max` from the sidecar's cgroup up to the mount's root
/// (cgroup v2, or v1's `pids` hierarchy).
pub container_max: Option<u64>,
}
/// **The decision**, pure: a delegated cgroup first, then the container's limit, else none.
pub fn decide(seen: &Seen) -> PidsBound {
match (&seen.own, seen.delegated, seen.container_max) {
(Some(own), true, _) => PidsBound::PerJob { own: own.clone() },
(_, _, Some(max)) => PidsBound::Container { max },
_ => PidsBound::Unbounded,
}
}
fn writable(p: &Path) -> bool {
rustix::fs::access(p, rustix::fs::Access::WRITE_OK).is_ok()
}
/// The cgroup directory a `/proc/self/cgroup` line names under `fs`, if it stays under it.
fn under(fs: &Path, rel: &str) -> Option<PathBuf> {
let rel = rel.trim().trim_start_matches('/');
if rel.split('/').any(|s| s == "..") {
return None;
}
Some(if rel.is_empty() {
fs.to_path_buf()
} else {
fs.join(rel)
})
}
/// The lowest numeric `pids.max` from `dir` up to `root`.
fn lowest_max(dir: &Path, root: &Path) -> Option<u64> {
let mut lowest: Option<u64> = None;
let mut at = Some(dir);
while let Some(d) = at {
if !d.starts_with(root) {
break;
}
if let Some(n) = std::fs::read_to_string(d.join("pids.max"))
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
{
lowest = Some(lowest.map_or(n, |l| l.min(n)));
}
if d == root {
break;
}
at = d.parent();
}
lowest
}
/// **Read what the cgroup files say**: `fs` is the cgroup mount (`/sys/fs/cgroup`),
/// `proc_cgroup` the text of `/proc/self/cgroup`.
pub fn look(fs: &Path, proc_cgroup: &str) -> Seen {
let v2 = proc_cgroup
.lines()
.find_map(|l| l.strip_prefix("0::"))
.and_then(|rel| under(fs, rel));
if let Some(own) = v2 {
let controllers =
std::fs::read_to_string(own.join("cgroup.controllers")).unwrap_or_default();
let delegated = own.is_dir()
&& controllers.split_whitespace().any(|c| c == "pids")
&& writable(&own)
&& writable(&own.join("cgroup.procs"))
&& writable(&own.join("cgroup.subtree_control"));
let container_max = lowest_max(&own, fs);
return Seen {
own: Some(own),
delegated,
container_max,
};
}
// cgroup v1: the `pids` hierarchy's limit only; v1 is never delegated here.
let v1 = proc_cgroup.lines().find_map(|l| {
let mut parts = l.splitn(3, ':');
let (_, controllers, rel) = (parts.next()?, parts.next()?, parts.next()?);
controllers
.split(',')
.any(|c| c == "pids")
.then(|| under(&fs.join("pids"), rel))
.flatten()
});
Seen {
own: None,
delegated: false,
container_max: v1.and_then(|d| lowest_max(&d, &fs.join("pids"))),
}
}
fn write(p: &Path, what: &str) -> std::io::Result<()> {
std::fs::write(p, what)
}
/// **Decide, and set the delegated case up**: move every process of the sidecar's cgroup
/// into `<own>/serve` (a cgroup v2 with children holds no processes itself), turn the
/// pids controller on for the children, and prove with a probe job that a job cannot
/// leave its cgroup ([`prove_sealed`]). Should a step fail, the case falls back to the
/// container's limit (or none), as if the cgroup were not delegated.
pub fn establish(fs: &Path, proc_cgroup: &str) -> PidsBound {
establish_proving(fs, proc_cgroup, prove_sealed)
}
/// [`establish`] with the seal's proof given (a test's).
pub fn establish_proving(
fs: &Path,
proc_cgroup: &str,
prove: impl FnOnce(&Path) -> Result<(), String>,
) -> PidsBound {
let seen = look(fs, proc_cgroup);
let bound = decide(&seen);
let PidsBound::PerJob { own } = &bound else {
return bound;
};
let set_up = (|| -> std::io::Result<()> {
let serve = own.join("serve");
match std::fs::create_dir(&serve) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e),
}
write(&serve.join("cgroup.procs"), &std::process::id().to_string())?;
// Every other process of the cgroup follows. One that exits meanwhile is gone
// (ESRCH); one forked meanwhile keeps the controller busy (EBUSY): move again.
let mut last = None;
for _ in 0..20 {
let procs = std::fs::read_to_string(own.join("cgroup.procs")).unwrap_or_default();
for pid in procs.split_whitespace() {
match write(&serve.join("cgroup.procs"), pid) {
Err(e) if e.raw_os_error() != Some(rustix::io::Errno::SRCH.raw_os_error()) => {
return Err(e)
}
_ => {}
}
}
match write(&own.join("cgroup.subtree_control"), "+pids") {
Err(e) if e.raw_os_error() == Some(rustix::io::Errno::BUSY.raw_os_error()) => {
last = Some(e)
}
done => return done,
}
std::thread::sleep(Duration::from_millis(5));
}
Err(last.unwrap_or_else(|| std::io::Error::other("the cgroup stayed busy")))
})();
let fallback = |why: String| {
eprintln!("holger-scan-worker: {why}; the container's limit holds instead");
decide(&Seen {
delegated: false,
..seen.clone()
})
};
match set_up {
Err(e) => fallback(format!(
"the delegated cgroup {} could not be set up ({e})",
own.display()
)),
Ok(()) => match prove(own) {
Ok(()) => bound,
Err(why) => fallback(format!(
"a job in the delegated cgroup {} could leave it ({why})",
own.display()
)),
},
}
}
static NEXT_JOB: AtomicU64 = AtomicU64::new(1);
/// **One job's cgroup** (`<own>/job-<n>`, `pids.max` the job's bound). Its `cgroup.procs`
/// is open before the fork; the child writes `0` to it. Dropped: `cgroup.kill`, then removed.
#[derive(Debug)]
pub struct JobCgroup {
dir: PathBuf,
procs: OwnedFd,
}
impl JobCgroup {
pub fn create(own: &Path, pids: u64) -> std::io::Result<Self> {
let dir = own.join(format!("job-{}", NEXT_JOB.fetch_add(1, Ordering::Relaxed)));
std::fs::create_dir(&dir)?;
let made = (|| {
write(&dir.join("pids.max"), &pids.to_string())?;
let procs = rustix::fs::open(
dir.join("cgroup.procs"),
// CREATE only matters off a cgroupfs (a test's tempdir): there the file is the kernel's.
rustix::fs::OFlags::WRONLY
| rustix::fs::OFlags::CLOEXEC
| rustix::fs::OFlags::CREATE,
rustix::fs::Mode::from_raw_mode(0o644),
)?;
Ok::<_, std::io::Error>(procs)
})();
match made {
Ok(procs) => Ok(Self { dir, procs }),
Err(e) => {
let _ = std::fs::remove_dir(&dir);
Err(e)
}
}
}
pub fn dir(&self) -> &Path {
&self.dir
}
/// The open `cgroup.procs`, for the child to write `0` to between fork and exec.
pub fn procs_fd(&self) -> RawFd {
self.procs.as_raw_fd()
}
}
impl Drop for JobCgroup {
fn drop(&mut self) {
// Every process the job left behind dies with its cgroup.
let _ = write(&self.dir.join("cgroup.kill"), "1");
for _ in 0..100 {
match std::fs::remove_dir(&self.dir) {
// A cgroup with live processes is EBUSY until they are gone.
Err(e) if e.raw_os_error() == Some(rustix::io::Errno::BUSY.raw_os_error()) => {
std::thread::sleep(Duration::from_millis(10))
}
_ => break,
}
}
}
}
/// **What a job's seal writes** (RA5-27), made before the fork: the maps of a user namespace
/// that maps the worker's own uid and gid to themselves.
#[derive(Debug, Clone)]
pub struct Seal {
uid_map: Vec<u8>,
gid_map: Vec<u8>,
}
impl Seal {
/// The maps for this process's effective uid and gid.
pub fn new() -> Self {
let (uid, gid) = (
rustix::process::geteuid().as_raw(),
rustix::process::getegid().as_raw(),
);
Self {
uid_map: format!("{uid} {uid} 1\n").into_bytes(),
gid_map: format!("{gid} {gid} 1\n").into_bytes(),
}
}
}
impl Default for Seal {
fn default() -> Self {
Self::new()
}
}
/// Open, write, close: async-signal-safe, no allocation (a `CStr` path).
fn write_in_child(path: &CStr, what: &[u8]) -> std::io::Result<()> {
let fd = rustix::fs::open(
path,
rustix::fs::OFlags::WRONLY | rustix::fs::OFlags::CLOEXEC,
rustix::fs::Mode::empty(),
)?;
rustix::io::write(&fd, what)?;
Ok(())
}
/// Between fork and exec, right after [`join_in_child`]: **seal the job in its cgroup**. The
/// child unshares a cgroup namespace, whose root is the cgroup it is in, its job's. Without
/// `CAP_SYS_ADMIN` that takes a user namespace along, which maps its own uid and gid to
/// themselves (`setgroups` denied first, as an unprivileged gid map needs).
///
/// # Safety
/// Only async-signal-safe system calls are made (unshare, open, write, close), with no
/// allocation: `seal`'s maps were made before the fork.
pub unsafe fn seal_in_child(seal: &Seal) -> std::io::Result<()> {
use rustix::thread::{unshare_unsafe, UnshareFlags};
// SAFETY: the child is single-threaded between fork and exec; nothing it shares with
// another thread is unshared.
match unsafe { unshare_unsafe(UnshareFlags::NEWCGROUP) } {
Ok(()) => return Ok(()),
Err(e) if e == rustix::io::Errno::PERM => {}
Err(e) => return Err(e.into()),
}
unsafe { unshare_unsafe(UnshareFlags::NEWUSER | UnshareFlags::NEWCGROUP) }?;
write_in_child(c"/proc/self/uid_map", &seal.uid_map)?;
write_in_child(c"/proc/self/setgroups", b"deny")?;
write_in_child(c"/proc/self/gid_map", &seal.gid_map)?;
Ok(())
}
/// The probe's answers through `spawn`'s error: the escape was refused, or it was not.
const PROBE_HELD: rustix::io::Errno = rustix::io::Errno::CANCELED;
const PROBE_ESCAPED: rustix::io::Errno = rustix::io::Errno::NOTRECOVERABLE;
/// **Prove the seal** (RA5-27): a probe job joins its own cgroup under `own`, seals itself
/// ([`seal_in_child`]) and tries what a hostile worker would: write `max` to its cgroup's
/// `pids.max`, and move itself into `own/serve`. Both must be refused. The probe opens both
/// files only after the seal, as the worker would: the kernel judges a cgroup file write by
/// the namespace its opener was in, so a file opened before the seal would let it out (and
/// none such is ever left open to a job: the job's `cgroup.procs` is close-on-exec). The
/// probe never execs: it answers through the error `spawn` hands back. `Err` says why the
/// seal does not hold (no namespace could be made, or the hierarchy is not mounted
/// `nsdelegate`).
pub fn prove_sealed(own: &Path) -> Result<(), String> {
use std::os::unix::ffi::OsStrExt as _;
let job = JobCgroup::create(own, 8).map_err(|e| format!("the probe's cgroup: {e}"))?;
let c_path = |p: PathBuf| {
std::ffi::CString::new(p.as_os_str().as_bytes())
.map_err(|e| format!("{}: {e}", p.display()))
};
let serve = c_path(own.join("serve").join("cgroup.procs"))?;
let max = c_path(job.dir().join("pids.max"))?;
let join = job.procs_fd();
let seal = Seal::new();
// A32-14, a process by design: the probe is a forked child that never execs.
let mut cmd = std::process::Command::new("/proc/self/exe");
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
{
use std::os::unix::process::CommandExt as _;
// SAFETY: only async-signal-safe system calls between fork and exec (write, unshare,
// open, close), with no allocation: the paths and the maps were made before the fork.
unsafe {
cmd.pre_exec(move || {
join_in_child(join)?;
seal_in_child(&seal)?;
let escaped =
write_in_child(&max, b"max").is_ok() | write_in_child(&serve, b"0").is_ok();
Err((if escaped { PROBE_ESCAPED } else { PROBE_HELD }).into())
});
}
}
// A32-14, a process by design: the probe's fork (see its command above); it answers through the error.
let answer = match cmd.spawn() {
Ok(mut child) => {
let _ = child.kill();
let _ = child.wait();
Err("the probe ran past its seal".to_string())
}
Err(e) if e.raw_os_error() == Some(PROBE_HELD.raw_os_error()) => Ok(()),
Err(e) if e.raw_os_error() == Some(PROBE_ESCAPED.raw_os_error()) => {
Err("a sealed job could still move out of its cgroup or raise its pids.max: the cgroup hierarchy is not mounted nsdelegate".to_string())
}
Err(e) => Err(format!("a job's cgroup namespace could not be made: {e}")),
};
drop(job);
answer
}
/// Between fork and exec: join the job's cgroup through its open `cgroup.procs`.
///
/// # Safety
/// `fd` must be open in the child; only a `write` is made (async-signal-safe, no allocation).
pub unsafe fn join_in_child(fd: RawFd) -> std::io::Result<()> {
let fd = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
rustix::io::write(fd, b"0")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// A cgroup v2 directory in a tempdir: its controllers, its pids.max, its procs.
fn cgroup(dir: &Path, controllers: &str, max: &str) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("cgroup.controllers"), controllers).unwrap();
std::fs::write(dir.join("cgroup.subtree_control"), "").unwrap();
std::fs::write(
dir.join("cgroup.procs"),
format!("{}\n", std::process::id()),
)
.unwrap();
std::fs::write(dir.join("pids.max"), max).unwrap();
}
#[test]
fn the_decision_takes_a_delegated_cgroup_then_the_containers_limit_then_none() {
let own = PathBuf::from("/sys/fs/cgroup/pod/sidecar");
assert_eq!(
decide(&Seen {
own: Some(own.clone()),
delegated: true,
container_max: Some(2048)
}),
PidsBound::PerJob { own: own.clone() }
);
assert_eq!(
decide(&Seen {
own: Some(own.clone()),
delegated: false,
container_max: Some(2048)
}),
PidsBound::Container { max: 2048 }
);
assert_eq!(
decide(&Seen {
own: None,
delegated: false,
container_max: Some(512)
}),
PidsBound::Container { max: 512 }
);
assert_eq!(
decide(&Seen {
own: Some(own),
delegated: false,
container_max: None
}),
PidsBound::Unbounded
);
assert_eq!(decide(&Seen::default()), PidsBound::Unbounded);
}
#[test]
fn a_read_only_cgroup_with_a_number_is_the_containers_limit_and_max_is_none() {
let fs = tempfile::tempdir().unwrap();
cgroup(fs.path(), "cpu memory pids", "2048\n");
let seen = look(fs.path(), "0::/\n");
assert_eq!(seen.container_max, Some(2048));
// A tempdir is writable: the controllers decide here; without `pids` nothing is delegated.
cgroup(fs.path(), "cpu memory", "2048\n");
assert_eq!(
decide(&look(fs.path(), "0::/\n")),
PidsBound::Container { max: 2048 }
);
cgroup(fs.path(), "cpu memory", "max\n");
assert_eq!(decide(&look(fs.path(), "0::/\n")), PidsBound::Unbounded);
// An ancestor's number bounds a child that says `max`.
cgroup(&fs.path().join("pod/c"), "cpu", "max\n");
assert_eq!(
look(fs.path(), "0::/pod/c\n").container_max,
None,
"the root here says max too"
);
std::fs::write(fs.path().join("pod/pids.max"), "300\n").unwrap();
assert_eq!(look(fs.path(), "0::/pod/c\n").container_max, Some(300));
// A path that climbs out of the mount names nothing.
assert_eq!(look(fs.path(), "0::/../etc\n").own, None);
}
#[test]
fn cgroup_v1_reads_the_pids_hierarchys_limit_and_is_never_delegated() {
let fs = tempfile::tempdir().unwrap();
cgroup(&fs.path().join("pids/docker/abc"), "", "100\n");
let seen = look(fs.path(), "12:pids:/docker/abc\n11:memory:/docker/abc\n");
assert_eq!(
seen,
Seen {
own: None,
delegated: false,
container_max: Some(100)
}
);
assert_eq!(decide(&seen), PidsBound::Container { max: 100 });
}
#[test]
fn a_delegated_cgroup_moves_the_sidecar_into_serve_and_turns_pids_on() {
let fs = tempfile::tempdir().unwrap();
let own = fs.path().join("pod/sidecar");
cgroup(&own, "cpu memory pids", "max\n");
let bound = establish_proving(fs.path(), "0::/pod/sidecar\n", |_| Ok(()));
assert_eq!(bound, PidsBound::PerJob { own: own.clone() });
assert_eq!(
std::fs::read_to_string(own.join("serve/cgroup.procs")).unwrap(),
std::process::id().to_string()
);
assert_eq!(
std::fs::read_to_string(own.join("cgroup.subtree_control")).unwrap(),
"+pids"
);
assert!(
bound.words().contains("a cgroup per job"),
"{}",
bound.words()
);
}
/// ★ RA5-27: a delegated cgroup whose jobs could leave it (the seal's proof fails) is no
/// per-job bound: the container's limit holds, or none.
#[test]
fn a_delegated_cgroup_a_job_could_leave_falls_back_to_the_containers_limit() {
let fs = tempfile::tempdir().unwrap();
let own = fs.path().join("pod/sidecar");
cgroup(&own, "cpu memory pids", "512\n");
let bound = establish_proving(fs.path(), "0::/pod/sidecar\n", |_| Err("not sealed".into()));
assert_eq!(bound, PidsBound::Container { max: 512 });
std::fs::write(own.join("pids.max"), "max\n").unwrap();
assert_eq!(
establish_proving(fs.path(), "0::/pod/sidecar\n", |_| Err("not sealed".into())),
PidsBound::Unbounded
);
// A tempdir is no cgroup: the real proof sees the probe's writes land, or no namespace made.
assert!(
prove_sealed(&own).is_err(),
"a plain directory holds no job"
);
}
#[test]
fn the_seal_maps_the_workers_own_ids() {
let s = Seal::new();
let uid = rustix::process::geteuid().as_raw();
assert_eq!(
String::from_utf8(s.uid_map).unwrap(),
format!("{uid} {uid} 1\n")
);
}
#[test]
fn a_job_cgroup_carries_the_jobs_bound_and_is_killed_after() {
let fs = tempfile::tempdir().unwrap();
let job = JobCgroup::create(fs.path(), 64).expect("the job's cgroup");
let dir = job.dir().to_path_buf();
assert!(
dir.file_name()
.unwrap()
.to_string_lossy()
.starts_with("job-"),
"{}",
dir.display()
);
assert_eq!(std::fs::read_to_string(dir.join("pids.max")).unwrap(), "64");
unsafe { join_in_child(job.procs_fd()) }.expect("the write");
assert_eq!(
std::fs::read_to_string(dir.join("cgroup.procs")).unwrap(),
"0"
);
drop(job);
assert_eq!(
std::fs::read_to_string(dir.join("cgroup.kill")).unwrap(),
"1",
"the job's leftovers are killed"
);
}
#[test]
fn the_ping_words_name_the_bound() {
assert_eq!(
PidsBound::Container { max: 2048 }.words(),
"pids: the container's limit, 2048"
);
assert!(PidsBound::Unbounded.words().contains("NO LIMIT"));
}
}