From fc1ede60ca315c89f1f4523bbb2562a8b9dc640c Mon Sep 17 00:00:00 2001 From: Yuri Khrustalev Date: Tue, 15 Sep 2026 22:03:38 -0400 Subject: [PATCH 1/2] feat: add --max-memory to bound each scenario's memory use A mutant can turn a bounded loop into an unbounded allocator, and a test process growing at hundreds of MB/s exhausts the machine well before a 5x-baseline test timeout arrives. On a CI runner the VM is then torn down with no log, and the shard's mutants are never recorded. --max-memory SIZE, and the max_memory config key, put a ceiling on each scenario's cargo process tree instead. It is enforced with setrlimit (RLIMIT_AS), which limits address space rather than resident memory and so has to be set generously; which mechanism is in use is logged at startup. macOS accepts RLIMIT_AS and ignores it, so there the option warns and does nothing, as documented. Where it cannot be applied at all, giving the option is an error raised before any mutant runs, rather than a long run that silently had no limit. Zero is rejected too: unlike -t 0 it would mean 'stop everything', not 'no limit'. --- Cargo.toml | 2 +- NEWS.md | 2 + book/src/timeouts.md | 47 +++++++++ examples/custom_config.toml | 5 + src/cargo.rs | 3 + src/config.rs | 2 + src/lab.rs | 12 ++- src/main.rs | 7 ++ src/options.rs | 123 +++++++++++++++++++++- src/process.rs | 20 +++- src/process/memory.rs | 201 ++++++++++++++++++++++++++++++++++++ tests/main.rs | 27 +++++ 12 files changed, 446 insertions(+), 5 deletions(-) create mode 100644 src/process/memory.rs diff --git a/Cargo.toml b/Cargo.toml index e520cffa..5464616b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,7 +78,7 @@ version = "2.0.104" features = ["full", "extra-traits", "visit"] [target.'cfg(unix)'.dependencies] -nix = { version = "0.31", features = ["process", "signal"] } +nix = { version = "0.31", features = ["process", "resource", "signal"] } # reflink is disabled on musl for the time being due to # and diff --git a/NEWS.md b/NEWS.md index ed5bd2bb..27d46fdb 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,8 @@ ## Unreleased +- New: `--max-memory SIZE` (and the `max_memory` config key) bounds how much memory each scenario's cargo process tree may use, so that a mutant that turns a loop into an unbounded allocator is stopped by the kernel rather than taking the machine down with it. It is enforced with `setrlimit(RLIMIT_AS)`, which limits address space rather than resident memory, so set it generously. macOS does not enforce `RLIMIT_AS`, so the option is a no-op there. If the option is given and it cannot be applied, cargo-mutants fails before testing any mutant rather than running with no limit. + - New: `#[mutants::exclude_re("pattern")]` attribute to exclude specific mutations by regex, without disabling all mutations on the function. The attribute can be placed on functions, `impl` blocks, `trait` blocks, modules, files, and on expressions that can carry an attribute (such as `match`, struct literals, call expressions, method calls, and unary expressions). Multiple patterns can be applied. Also supported within `cfg_attr`. Requires the [mutants](https://crates.io/crates/mutants) crate version `0.0.5` or later. - Fixed: `#[mutants::skip]` (and `#[cfg_attr(..., mutants::skip)]`) is now honoured when placed on `const` and `static` items, including associated constants in `impl` and `trait` blocks. Previously the attribute was silently ignored on these items and operator mutants inside the initializer expression were still generated ([#508](https://github.com/sourcefrog/cargo-mutants/issues/508)). diff --git a/book/src/timeouts.md b/book/src/timeouts.md index bff23255..c775b85d 100644 --- a/book/src/timeouts.md +++ b/book/src/timeouts.md @@ -45,6 +45,53 @@ In this case you can use the `--build-timeout` or `--build-timeout-multiplier` o You might also choose to skip mutants that can cause long-running const evaluation. +## Memory limits + +A timeout is not always enough. A mutant can turn a bounded loop into an unbounded +allocator — flipping `+=` to `-=` on a parser's cursor, say — and a test that grows at +hundreds of megabytes per second can exhaust the machine long before the test timeout +arrives. On a CI runner the usual result is that the whole VM is torn down, with no log +and no record of which mutants had been tested. + +`--max-memory SIZE`, or the `max_memory` key in the configuration file, puts a ceiling on +each scenario's cargo process tree instead, so that the kernel stops the scenario rather +than the machine. Sizes may be plain byte counts, or carry a `K`, `M`, `G`, or `T` +suffix, which are binary multiples: `1K` is 1024 bytes. The smallest accepted value is +1M: unlike `--timeout=0`, `--max-memory=0` is not a way to turn the limit off, so it is +rejected rather than silently stopping every scenario. + +```shell +cargo mutants --max-memory 8G +``` + +```toml +# .cargo/mutants.toml +max_memory = "8G" +``` + +The limit is off by default, and applies to every phase of every scenario, builds +included, so leave room for the compiler as well as for the tests. + +It is enforced with `setrlimit(RLIMIT_AS)` on the cargo process, inherited by everything +it spawns. That limits *address space*, which is a much cruder proxy than resident +memory: allocators and rustc reserve far more address space than they ever make +resident, so a limit that would be comfortable as a resident-memory ceiling can fail +builds outright when applied this way. **Set it generously.** + +Which mechanism is in use is reported at startup: + +``` +INFO Limiting each scenario to 8589934592 bytes of memory using setrlimit(RLIMIT_AS) +``` + +On macOS, `RLIMIT_AS` is accepted by the kernel and then ignored, so `--max-memory` has +no effect there; cargo-mutants warns and carries on. On any platform where it cannot be +applied at all, giving `--max-memory` is an error, reported before any mutant is tested, +rather than a run that quietly had no limit. + +This option does not change how mutants are classified. A mutant whose tests are stopped +by the limit fails its tests and so is caught, in just the same way as one that panics. + ## Exceptions The multiplier timeout options cannot be used when the baseline is skipped diff --git a/examples/custom_config.toml b/examples/custom_config.toml index 7ae4322b..96aa73f1 100644 --- a/examples/custom_config.toml +++ b/examples/custom_config.toml @@ -30,6 +30,11 @@ timeout_multiplier = 2.0 # Minimum test timeout in seconds minimum_test_timeout = 60.0 +# Maximum memory for each scenario, so that a mutant that allocates without bound is +# stopped by the kernel rather than by the machine running out. Suffixes are binary +# multiples: "1K" is 1024 bytes. +max_memory = "8G" + # Copy VCS directories (.git, etc.) to build directories copy_vcs = true diff --git a/src/cargo.rs b/src/cargo.rs index 909dd881..b367577e 100644 --- a/src/cargo.rs +++ b/src/cargo.rs @@ -20,6 +20,7 @@ use crate::options::{Options, TestTool}; use crate::outcome::{Phase, PhaseResult}; use crate::output::ScenarioOutput; use crate::package::PackageSelection; +use crate::process::memory::MemoryLimit; use crate::process::{Exit, Process}; // Allowed nextest codes (those will be considered a mutation caught / ignored without a warning) @@ -37,6 +38,7 @@ pub fn run_cargo( packages: &PackageSelection, phase: Phase, timeout: Option, + memory_limit: Option<&MemoryLimit>, scenario_output: &mut ScenarioOutput, options: &Options, console: &Console, @@ -61,6 +63,7 @@ pub fn run_cargo( build_dir.path(), timeout, jobserver, + memory_limit, scenario_output, console, )?; diff --git a/src/config.rs b/src/config.rs index 7271192b..0bcee76b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -64,6 +64,8 @@ pub struct Config { /// Space or comma separated list of features to activate. pub features: Vec, + /// Maximum memory for each scenario, e.g. "4G"; suffixes are binary multiples. + pub max_memory: Option, /// Minimum test timeout, in seconds, as a floor on the autoset value. pub minimum_test_timeout: Option, /// Do not activate the `default` feature. diff --git a/src/lab.rs b/src/lab.rs index 784f7d1a..8e6d830f 100644 --- a/src/lab.rs +++ b/src/lab.rs @@ -18,8 +18,8 @@ use tracing::{debug, debug_span, error, trace, warn}; use crate::{ BaselineStrategy, BuildDir, Console, Context, Mutant, Options, Phase, Result, Scenario, ScenarioOutcome, cargo::run_cargo, options::TestPackages, outcome::LabOutcome, - output::OutputDir, package::Package, package::PackageSelection, timeouts::Timeouts, - workspace::Workspace, + output::OutputDir, package::Package, package::PackageSelection, process::memory::MemoryLimit, + timeouts::Timeouts, workspace::Workspace, }; /// Run all possible mutation experiments. @@ -38,6 +38,9 @@ pub fn test_mutants( ) -> Result { let start_time = Instant::now(); console.set_debug_log(output_dir.open_debug_log()?); + // Fail here, before the tree is copied, rather than after a long run that silently + // had no limit. + let memory_limit = options.max_memory.map(MemoryLimit::new).transpose()?; if options.shuffle { fastrand::shuffle(&mut mutants); } @@ -62,6 +65,7 @@ pub fn test_mutants( let lab = Lab { output_mutex, jobserver, + memory_limit, tests_for_mutant, options, console, @@ -164,6 +168,7 @@ fn join_threads(threads: Vec>>) -> Resul struct Lab<'a> { output_mutex: Mutex, jobserver: Option, + memory_limit: Option, tests_for_mutant: TestsForMutant, options: &'a Options, console: &'a Console, @@ -207,6 +212,7 @@ impl Lab<'_> { build_dir, output_mutex: &self.output_mutex, jobserver: self.jobserver.as_ref(), + memory_limit: self.memory_limit.as_ref(), tests_for_mutant: &self.tests_for_mutant, options: self.options, console: self.console, @@ -222,6 +228,7 @@ struct Worker<'a> { build_dir: &'a BuildDir, output_mutex: &'a Mutex, jobserver: Option<&'a jobserver::Client>, + memory_limit: Option<&'a MemoryLimit>, tests_for_mutant: &'a TestsForMutant, options: &'a Options, console: &'a Console, @@ -289,6 +296,7 @@ impl Worker<'_> { test_packages, phase, timeout, + self.memory_limit, &mut scenario_output, self.options, self.console, diff --git a/src/main.rs b/src/main.rs index 3e771564..6cfdb4b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -291,6 +291,13 @@ pub struct Args { #[arg(long, help_heading = "Execution")] list: bool, + /// Maximum memory for each scenario, e.g. 4G: a mutant that exceeds it is stopped + /// + /// Sizes may be given in bytes, or with a `K`, `M`, `G`, or `T` suffix, which are + /// binary multiples: `1K` is 1024 bytes. Enforced on Linux only; see the manual. + #[arg(long, help_heading = "Execution", value_name = "SIZE")] + max_memory: Option, + /// List source files, don't run anything. #[arg(long, help_heading = "Execution")] list_files: bool, diff --git a/src/options.rs b/src/options.rs index b983fb98..58e3a75b 100644 --- a/src/options.rs +++ b/src/options.rs @@ -13,7 +13,7 @@ use std::env; use std::ffi::OsString; use std::time::Duration; -use anyhow::Context; +use anyhow::{Context, bail}; use camino::{Utf8Path, Utf8PathBuf}; use clap::ArgAction; use globset::GlobSet; @@ -96,6 +96,9 @@ pub struct Options { /// The minimum test timeout, as a floor on the autoset value. pub minimum_test_timeout: Duration, + /// The maximum memory for each scenario's process tree, in bytes, if set. + pub max_memory: Option, + pub print_caught: bool, pub print_unviable: bool, @@ -237,6 +240,47 @@ fn join_slices(a: &[String], b: &[String]) -> Vec { a.iter().chain(b).cloned().collect() } +/// The smallest `--max-memory` worth accepting. +/// +/// Anything near zero stops every scenario before it can do anything, which is never +/// what someone means. In particular `--max-memory=0` is not "no limit", unlike `-t 0`. +const MIN_MAX_MEMORY: u64 = 1 << 20; + +/// Parse a memory size like `256M`, `2GiB`, or a plain count of bytes. +/// +/// Suffixes are binary multiples, as they conventionally are for memory: `1K` is 1024 +/// bytes, not 1000. +fn parse_size(s: &str) -> Result { + let s = s.trim(); + let digits_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()); + let (digits, suffix) = s.split_at(digits_end); + let number: u64 = digits + .parse() + .with_context(|| format!("{s:?} does not start with a number of bytes"))?; + let multiple: u64 = match suffix.trim().to_ascii_lowercase().as_str() { + "" | "b" => 1, + "k" | "kb" | "kib" => 1 << 10, + "m" | "mb" | "mib" => 1 << 20, + "g" | "gb" | "gib" => 1 << 30, + "t" | "tb" | "tib" => 1 << 40, + _ => bail!("unrecognized size suffix {suffix:?} in {s:?}"), + }; + number + .checked_mul(multiple) + .with_context(|| format!("size {s:?} is too large")) +} + +/// Parse and sanity-check a `--max-memory` value. +fn parse_max_memory(s: &str) -> Result { + let bytes = parse_size(s)?; + if bytes < MIN_MAX_MEMORY { + bail!( + "{s:?} is too small to run anything in: --max-memory must be at least {MIN_MAX_MEMORY} bytes" + ); + } + Ok(bytes) +} + /// Should ANSI colors be drawn? #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Display, Deserialize, ValueEnum)] #[strum(serialize_all = "snake_case")] @@ -366,6 +410,13 @@ impl Options { jobserver: args.jobserver, jobserver_tasks: args.jobserver_tasks, leak_dirs: args.leak_dirs, + max_memory: args + .max_memory + .as_deref() + .or(config.max_memory.as_deref()) + .map(parse_max_memory) + .transpose() + .context("Failed to parse --max-memory")?, minimum_test_timeout, no_default_features: args.no_default_features || config.no_default_features.unwrap_or(false), @@ -570,6 +621,76 @@ mod test { assert_eq!(options.build_timeout_multiplier, Some(3.5)); } + #[test] + fn parse_size_understands_binary_suffixes_and_rejects_nonsense() { + // Input -> bytes, where None means it should not parse at all. + let cases = [ + ("0", Some(0)), + ("1024", Some(1024)), + ("1024B", Some(1024)), + ("256M", Some(256 * 1024 * 1024)), + ("256MiB", Some(256 * 1024 * 1024)), + (" 4g ", Some(4 * 1024 * 1024 * 1024)), + ("2T", Some(2 * (1u64 << 40))), + ("", None), + ("M", None), + ("-1", None), + ("1.5G", None), + ("1 zettabyte", None), + ("18446744073709551615K", None), + ]; + for (input, expected) in cases { + assert_eq!(parse_size(input).ok(), expected, "input: {input:?}"); + } + } + + /// Zero is a footgun rather than a way to turn the limit off, unlike `-t 0`. + #[test] + fn parse_max_memory_rejects_uselessly_small_limits() { + for tiny in ["0", "1", "1K", "1023K"] { + assert!( + parse_max_memory(tiny).is_err(), + "{tiny:?} should be rejected as too small" + ); + } + assert_eq!(parse_max_memory("1M").ok(), Some(1 << 20)); + } + + #[test] + fn options_from_max_memory_arg() -> Result<(), Box> { + let args = Args::parse_from(["mutants", "--max-memory=256M"]); + let options = Options::new(&args, &Config::default())?; + assert_eq!(options.max_memory, Some(256 * 1024 * 1024)); + + let args = Args::parse_from(["mutants"]); + let options = Options::new(&args, &Config::default())?; + assert_eq!(options.max_memory, None); + Ok(()) + } + + #[test] + fn cli_max_memory_overrides_config() -> Result<(), Box> { + let config: Config = "max_memory = \"8G\"".parse()?; + let options = Options::new(&Args::parse_from(["mutants"]), &config)?; + assert_eq!(options.max_memory, Some(8 * 1024 * 1024 * 1024)); + + let args = Args::parse_from(["mutants", "--max-memory=1G"]); + let options = Options::new(&args, &config)?; + assert_eq!(options.max_memory, Some(1024 * 1024 * 1024)); + Ok(()) + } + + #[test] + fn unparseable_max_memory_is_an_error() { + let args = Args::parse_from(["mutants", "--max-memory=lots"]); + let err = Options::new(&args, &Config::default()) + .expect_err("--max-memory=lots should not be accepted"); + assert!( + format!("{err:#}").contains("--max-memory"), + "unhelpful error message: {err:#}" + ); + } + #[test] fn cli_timeout_multiplier_overrides_config() { let config = indoc! { r" diff --git a/src/process.rs b/src/process.rs index 0763e617..656406ca 100644 --- a/src/process.rs +++ b/src/process.rs @@ -36,6 +36,9 @@ mod unix; #[cfg(unix)] use unix::{configure_command, terminate_child}; +pub mod memory; +use memory::MemoryLimit; + pub struct Process { child: Child, start: Instant, @@ -45,16 +48,26 @@ pub struct Process { impl Process { /// Run a subprocess to completion, watching for interrupts, with a timeout, while /// ticking the progress bar. + #[allow(clippy::too_many_arguments)] // parallel to run_cargo pub fn run( argv: &[String], env: &[(String, String)], cwd: &Utf8Path, timeout: Option, jobserver: Option<&jobserver::Client>, + memory_limit: Option<&MemoryLimit>, scenario_output: &mut ScenarioOutput, console: &Console, ) -> Result { - let mut child = Process::start(argv, env, cwd, timeout, jobserver, scenario_output)?; + let mut child = Process::start( + argv, + env, + cwd, + timeout, + jobserver, + memory_limit, + scenario_output, + )?; let process_status = loop { if let Some(exit_status) = child.poll()? { break exit_status; @@ -67,12 +80,14 @@ impl Process { } /// Launch a process, and return an object representing the child. + #[allow(clippy::too_many_arguments)] // parallel to run_cargo pub fn start( argv: &[String], env: &[(String, String)], cwd: &Utf8Path, timeout: Option, jobserver: Option<&jobserver::Client>, + memory_limit: Option<&MemoryLimit>, scenario_output: &mut ScenarioOutput, ) -> Result { let start = Instant::now(); @@ -92,6 +107,9 @@ impl Process { js.configure(&mut command); } configure_command(&mut command); + if let Some(memory_limit) = memory_limit { + memory_limit.configure_command(&mut command); + } let child = command .spawn() .with_context(|| format!("failed to spawn {}", argv.join(" ")))?; diff --git a/src/process/memory.rs b/src/process/memory.rs new file mode 100644 index 00000000..1ca05dd2 --- /dev/null +++ b/src/process/memory.rs @@ -0,0 +1,201 @@ +// Copyright 2026 Martin Pool + +//! Bound how much memory one scenario's process tree can use. +//! +//! A mutant can turn a bounded loop into an unbounded allocator, and a test process that +//! grows at hundreds of MB/s can exhaust the machine before the test timeout arrives. +//! `--max-memory` puts a ceiling on each scenario instead, so the kernel stops the +//! scenario rather than the machine. +//! +//! The mechanism here is `setrlimit(RLIMIT_AS)`, which limits the *address space* of each +//! process in the tree. That is a crude proxy for memory use -- allocators reserve far +//! more address space than they ever make resident -- so the limit has to be set +//! generously, and it is only enforced on Linux. +//! +//! `any(target_os = "linux", target_os = "android", target_os = "macos")` recurs below: +//! it is where `nix` exposes `RLIMIT_AS` and cargo-mutants is supported. Everywhere else +//! the `RlimitAs` variant does not exist at all, which is what makes it unconstructible +//! rather than merely unreachable. + +use std::process::Command; + +use anyhow::bail; +use tracing::{info, warn}; + +use crate::Result; + +/// How a `--max-memory` limit is applied to a scenario's process tree. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MemoryMechanism { + /// `setrlimit(RLIMIT_AS)` on the cargo process, inherited by everything it spawns. + RlimitAs, + /// Nothing on this platform enforces a memory limit, so the option does nothing. + Unenforced, +} + +impl MemoryMechanism { + fn describe(self) -> &'static str { + match self { + MemoryMechanism::RlimitAs => "setrlimit(RLIMIT_AS)", + MemoryMechanism::Unenforced => "no enforced mechanism", + } + } +} + +/// Choose how to apply `--max-memory`, from what this platform and process can offer. +/// +/// `settable` says whether `RLIMIT_AS` can be set to the requested limit at all; +/// `enforced` says whether the kernel would then act on it, which macOS does not. +/// +/// Returns an error, rather than quietly running unlimited, when the user asked for a +/// limit and nothing can enforce it. +pub fn choose_mechanism(settable: bool, enforced: bool) -> Result { + if settable && enforced { + Ok(MemoryMechanism::RlimitAs) + } else if settable { + Ok(MemoryMechanism::Unenforced) + } else { + bail!( + "--max-memory was requested but no mechanism on this platform can enforce it: \ + cargo-mutants can use setrlimit(RLIMIT_AS), and it is not available" + ) + } +} + +/// A per-scenario memory limit, set up once and used for every phase of every scenario. +/// +/// Each variant owns exactly the state its mechanism needs. +#[derive(Debug)] +pub enum MemoryLimit { + #[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] + RlimitAs { + bytes: u64, + }, + Unenforced, +} + +impl MemoryLimit { + /// Set up a limit of `bytes` per scenario, or fail if nothing here can enforce one. + /// + /// This is done once, before any mutant is tested, so that an unenforceable limit is + /// an error the user sees immediately rather than a run that silently had no limit. + pub fn new(bytes: u64) -> Result { + let limit = match choose_mechanism(rlimit::settable(bytes), rlimit::ENFORCED)? { + #[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] + MemoryMechanism::RlimitAs => MemoryLimit::RlimitAs { bytes }, + _ => MemoryLimit::Unenforced, + }; + if limit.mechanism() == MemoryMechanism::Unenforced { + warn!( + "--max-memory has no effect on this platform: RLIMIT_AS is accepted but not enforced here" + ); + } else { + info!( + "Limiting each scenario to {bytes} bytes of memory using {}", + limit.mechanism().describe() + ); + } + Ok(limit) + } + + fn mechanism(&self) -> MemoryMechanism { + match self { + #[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] + MemoryLimit::RlimitAs { .. } => MemoryMechanism::RlimitAs, + MemoryLimit::Unenforced => MemoryMechanism::Unenforced, + } + } + + /// Arrange for the command, once forked, to be subject to the limit, so that it + /// applies from the very first allocation the child makes. + pub fn configure_command(&self, command: &mut Command) { + match self { + #[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] + MemoryLimit::RlimitAs { bytes } => rlimit::apply_to_child(command, *bytes), + MemoryLimit::Unenforced => {} + } + } +} + +#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] +mod rlimit { + use std::io; + use std::os::unix::process::CommandExt; + use std::process::Command; + + use nix::sys::resource::{Resource, getrlimit, setrlimit}; + use tracing::debug; + + /// Whether the kernel acts on `RLIMIT_AS`, as opposed to merely accepting it. + /// + /// macOS accepts the call and ignores it, so a limit set there would be a lie. + pub const ENFORCED: bool = cfg!(any(target_os = "linux", target_os = "android")); + + /// Whether `RLIMIT_AS` can be set to `bytes`: the inherited hard limit is the ceiling + /// on what an unprivileged process may ask for. + pub fn settable(bytes: u64) -> bool { + match getrlimit(Resource::RLIMIT_AS) { + Ok((_soft, hard)) => hard >= bytes, + Err(errno) => { + debug!(?errno, "failed to read RLIMIT_AS"); + false + } + } + } + + /// Limit the address space of the child, and so of everything it goes on to spawn. + pub fn apply_to_child(command: &mut Command, bytes: u64) { + // SAFETY: setrlimit is a bare syscall that does not allocate or take locks, so it + // is safe to call between fork and exec. + unsafe { + command.pre_exec(move || { + setrlimit(Resource::RLIMIT_AS, bytes, bytes).map_err(io::Error::from) + }); + } + } +} + +#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "macos")))] +mod rlimit { + pub const ENFORCED: bool = false; + + pub fn settable(_bytes: u64) -> bool { + false + } +} + +#[cfg(test)] +mod test { + use super::{MemoryMechanism, choose_mechanism}; + + /// The mechanism is picked from what the platform can do. + #[test] + fn choose_mechanism_picks_by_availability() { + // (RLIMIT_AS settable, RLIMIT_AS enforced) -> mechanism, where None means + // --max-memory should be rejected outright. + let cases = [ + ((true, true), Some(MemoryMechanism::RlimitAs)), + // macOS: the call is accepted and then ignored. + ((true, false), Some(MemoryMechanism::Unenforced)), + ((false, false), None), + ((false, true), None), + ]; + for ((settable, enforced), expected) in cases { + assert_eq!( + choose_mechanism(settable, enforced).ok(), + expected, + "settable={settable} enforced={enforced}" + ); + } + } + + #[test] + fn choose_mechanism_with_no_usable_mechanism_names_the_option() { + let err = choose_mechanism(false, false) + .expect_err("--max-memory with no mechanism should be an error"); + assert!( + err.to_string().contains("--max-memory"), + "unhelpful error message: {err}" + ); + } +} diff --git a/tests/main.rs b/tests/main.rs index 0fb8973d..906b6f43 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -3846,3 +3846,30 @@ fn in_diff_with_nonexistent_file_returns_exit_code_6() { .code(6) .stderr(contains("Failed to read diff file").or(contains("Failed to open diff file"))); } + +/// `--max-memory=0` reads like "no limit" by analogy with `--timeout=0`, but would mean +/// "stop every scenario immediately", so it's rejected rather than obeyed. +#[test] +fn max_memory_too_small_is_rejected() { + let tmp_src_dir = copy_of_testdata("small_well_tested"); + run() + .args(["mutants", "--max-memory=0", "-d"]) + .arg(tmp_src_dir.path()) + .assert() + .failure() + .stderr(contains("--max-memory must be at least")); +} + +/// A limit generous enough for the compiler doesn't disturb an ordinary run. +#[cfg(unix)] +#[test] +fn max_memory_does_not_disturb_a_normal_run() { + let tmp_src_dir = copy_of_testdata("small_well_tested"); + run() + .args(["mutants", "--max-memory=8G", "-d"]) + .arg(tmp_src_dir.path()) + .timeout(OUTER_TIMEOUT) + .assert() + .success() + .stdout(contains("4 mutants tested")); +} From d3f54810f942d45b9a91b838146ef933580c364b Mon Sep 17 00:00:00 2001 From: Yuri Khrustalev Date: Tue, 15 Sep 2026 22:05:26 -0400 Subject: [PATCH 2/2] feat: prefer cgroup v2 memory.max for --max-memory RLIMIT_AS limits address space, not resident memory, which is a poor proxy: rustc reserves far more than it makes resident, so a limit tight enough to stop a runaway test can fail the build instead. A cgroup v2 memory.max per scenario limits what we actually care about. Finding somewhere to create those cgroups is the fiddly part, because the kernel won't let a cgroup that holds processes delegate the memory controller to its children. We try our own cgroup, then our parent -- which already delegates memory whenever something has fenced us in with a memory.max, the case this is meant for -- and only as a last resort move ourselves into a leaf. Using the parent puts scenario cgroups outside that outer fence, so that path warns. The cgroup is removed when the scenario's limit is dropped, so no failure part-way through a phase can leak it. --- NEWS.md | 2 +- book/src/timeouts.md | 43 ++- src/process.rs | 12 +- src/process/memory.rs | 150 ++++++++-- src/process/memory/cgroup.rs | 265 ++++++++++++++++++ testdata/unbounded_allocation/Cargo_test.toml | 11 + testdata/unbounded_allocation/src/lib.rs | 29 ++ tests/main.rs | 71 +++++ 8 files changed, 541 insertions(+), 42 deletions(-) create mode 100644 src/process/memory/cgroup.rs create mode 100644 testdata/unbounded_allocation/Cargo_test.toml create mode 100644 testdata/unbounded_allocation/src/lib.rs diff --git a/NEWS.md b/NEWS.md index 27d46fdb..79aa1c0e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,7 +2,7 @@ ## Unreleased -- New: `--max-memory SIZE` (and the `max_memory` config key) bounds how much memory each scenario's cargo process tree may use, so that a mutant that turns a loop into an unbounded allocator is stopped by the kernel rather than taking the machine down with it. It is enforced with `setrlimit(RLIMIT_AS)`, which limits address space rather than resident memory, so set it generously. macOS does not enforce `RLIMIT_AS`, so the option is a no-op there. If the option is given and it cannot be applied, cargo-mutants fails before testing any mutant rather than running with no limit. +- New: `--max-memory SIZE` (and the `max_memory` config key) bounds how much memory each scenario's cargo process tree may use, so that a mutant that turns a loop into an unbounded allocator is stopped by the kernel rather than taking the machine down with it. On Linux it uses a cgroup v2 `memory.max` where a writable cgroup is available, and otherwise `setrlimit(RLIMIT_AS)`; the mechanism in use is logged. macOS does not enforce `RLIMIT_AS`, so the option is a no-op there. If the option is given and it cannot be applied, cargo-mutants fails before testing any mutant rather than running with no limit. - New: `#[mutants::exclude_re("pattern")]` attribute to exclude specific mutations by regex, without disabling all mutations on the function. The attribute can be placed on functions, `impl` blocks, `trait` blocks, modules, files, and on expressions that can carry an attribute (such as `match`, struct literals, call expressions, method calls, and unary expressions). Multiple patterns can be applied. Also supported within `cfg_attr`. Requires the [mutants](https://crates.io/crates/mutants) crate version `0.0.5` or later. diff --git a/book/src/timeouts.md b/book/src/timeouts.md index c775b85d..cf5afd04 100644 --- a/book/src/timeouts.md +++ b/book/src/timeouts.md @@ -72,22 +72,43 @@ max_memory = "8G" The limit is off by default, and applies to every phase of every scenario, builds included, so leave room for the compiler as well as for the tests. -It is enforced with `setrlimit(RLIMIT_AS)` on the cargo process, inherited by everything -it spawns. That limits *address space*, which is a much cruder proxy than resident -memory: allocators and rustc reserve far more address space than they ever make -resident, so a limit that would be comfortable as a resident-memory ceiling can fail -builds outright when applied this way. **Set it generously.** +Two mechanisms can enforce it, and they are not equivalent: -Which mechanism is in use is reported at startup: +* **cgroup v2** `memory.max`, on a cgroup created for each scenario. This limits + *resident* memory for the whole process tree, which is what you actually care about. + It is preferred whenever a writable cgroup is available. Swap is also capped, where the + kernel accounts for it; on kernels that do not, the limit covers resident memory only. + +* **`setrlimit(RLIMIT_AS)`** on the cargo process, inherited by everything it spawns. + This limits *address space*, a much cruder proxy: allocators and rustc reserve far more + address space than they ever make resident, so a limit that is comfortable as a + resident-memory ceiling can fail builds outright when applied this way. If cargo-mutants + falls back to this mechanism, set the limit generously. + +Which one is in use is reported at startup, for example: ``` -INFO Limiting each scenario to 8589934592 bytes of memory using setrlimit(RLIMIT_AS) +INFO Limiting each scenario to 8589934592 bytes of memory using cgroup v2 memory.max ``` -On macOS, `RLIMIT_AS` is accepted by the kernel and then ignored, so `--max-memory` has -no effect there; cargo-mutants warns and carries on. On any platform where it cannot be -applied at all, giving `--max-memory` is an error, reported before any mutant is tested, -rather than a run that quietly had no limit. +For the cgroup mechanism, cargo-mutants needs somewhere it may create child cgroups with +`memory.max`. It looks at its own cgroup first, writing `+memory` to that cgroup's +`cgroup.subtree_control` if it isn't set already. Failing that it looks at the parent, +which works when something has already put a `memory.max` fence around cargo-mutants — a +CI shard running under a memory-limited systemd scope or container, for instance. As a +last resort it moves itself into a `cargo-mutants-supervisor` cgroup of its own so that +its original cgroup can delegate the memory controller. + +> **The parent fallback escapes an enclosing limit.** Scenario cgroups made under the +> parent are *siblings* of cargo-mutants' own cgroup, so a `memory.max` set on +> cargo-mutants does not contain them: with `--jobs N` the run can use up to N × +> `--max-memory` in total, whatever that outer fence says. cargo-mutants warns when it +> takes this path. + +On macOS, `RLIMIT_AS` is accepted by the kernel and then ignored, and cgroups do not +exist, so `--max-memory` has no effect there; cargo-mutants warns and carries on. On any +platform where *neither* mechanism can be applied, giving `--max-memory` is an error, +reported before any mutant is tested, rather than a run that quietly had no limit. This option does not change how mutants are classified. A mutant whose tests are stopped by the limit fails its tests and so is caught, in just the same way as one that panics. diff --git a/src/process.rs b/src/process.rs index 656406ca..2d3b2678 100644 --- a/src/process.rs +++ b/src/process.rs @@ -37,12 +37,16 @@ mod unix; use unix::{configure_command, terminate_child}; pub mod memory; -use memory::MemoryLimit; +use memory::{MemoryLimit, ScenarioMemoryLimit}; pub struct Process { child: Child, start: Instant, timeout: Option, + /// The memory limit in force for this process tree, if any. Held so that any cgroup + /// outlives the child and is removed once it has exited. + #[allow(dead_code)] // its Drop is the point + memory: Option, } impl Process { @@ -107,8 +111,9 @@ impl Process { js.configure(&mut command); } configure_command(&mut command); - if let Some(memory_limit) = memory_limit { - memory_limit.configure_command(&mut command); + let memory = memory_limit.map(MemoryLimit::start).transpose()?; + if let Some(memory) = &memory { + memory.configure_command(&mut command)?; } let child = command .spawn() @@ -117,6 +122,7 @@ impl Process { child, start, timeout, + memory, }) } diff --git a/src/process/memory.rs b/src/process/memory.rs index 1ca05dd2..5b0ef63e 100644 --- a/src/process/memory.rs +++ b/src/process/memory.rs @@ -7,19 +7,29 @@ //! `--max-memory` puts a ceiling on each scenario instead, so the kernel stops the //! scenario rather than the machine. //! -//! The mechanism here is `setrlimit(RLIMIT_AS)`, which limits the *address space* of each -//! process in the tree. That is a crude proxy for memory use -- allocators reserve far -//! more address space than they ever make resident -- so the limit has to be set -//! generously, and it is only enforced on Linux. +//! Two mechanisms can do this, and they are not equivalent: +//! +//! * cgroup v2 `memory.max`, which limits *resident* memory for the whole process tree. +//! This is what we want, when we can get it. +//! * `setrlimit(RLIMIT_AS)`, which limits the *address space* of each process. It is a +//! cruder proxy -- allocators reserve far more address space than they use -- so the +//! limit has to be set generously, and it is only enforced on Linux. //! //! `any(target_os = "linux", target_os = "android", target_os = "macos")` recurs below: //! it is where `nix` exposes `RLIMIT_AS` and cargo-mutants is supported. Everywhere else //! the `RlimitAs` variant does not exist at all, which is what makes it unconstructible //! rather than merely unreachable. +#[cfg(target_os = "linux")] +mod cgroup; + use std::process::Command; +#[cfg(target_os = "linux")] +use anyhow::Context; use anyhow::bail; +#[cfg(target_os = "linux")] +use tracing::debug; use tracing::{info, warn}; use crate::Result; @@ -27,6 +37,8 @@ use crate::Result; /// How a `--max-memory` limit is applied to a scenario's process tree. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MemoryMechanism { + /// A cgroup v2 `memory.max` on a cgroup created for each scenario. + CgroupV2, /// `setrlimit(RLIMIT_AS)` on the cargo process, inherited by everything it spawns. RlimitAs, /// Nothing on this platform enforces a memory limit, so the option does nothing. @@ -36,6 +48,7 @@ pub enum MemoryMechanism { impl MemoryMechanism { fn describe(self) -> &'static str { match self { + MemoryMechanism::CgroupV2 => "cgroup v2 memory.max", MemoryMechanism::RlimitAs => "setrlimit(RLIMIT_AS)", MemoryMechanism::Unenforced => "no enforced mechanism", } @@ -49,15 +62,21 @@ impl MemoryMechanism { /// /// Returns an error, rather than quietly running unlimited, when the user asked for a /// limit and nothing can enforce it. -pub fn choose_mechanism(settable: bool, enforced: bool) -> Result { - if settable && enforced { +pub fn choose_mechanism( + cgroup_available: bool, + settable: bool, + enforced: bool, +) -> Result { + if cgroup_available { + Ok(MemoryMechanism::CgroupV2) + } else if settable && enforced { Ok(MemoryMechanism::RlimitAs) } else if settable { Ok(MemoryMechanism::Unenforced) } else { bail!( "--max-memory was requested but no mechanism on this platform can enforce it: \ - cargo-mutants can use setrlimit(RLIMIT_AS), and it is not available" + cargo-mutants can use cgroup v2 or setrlimit(RLIMIT_AS), and neither is available" ) } } @@ -67,6 +86,11 @@ pub fn choose_mechanism(settable: bool, enforced: bool) -> Result Result { - let limit = match choose_mechanism(rlimit::settable(bytes), rlimit::ENFORCED)? { - #[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] - MemoryMechanism::RlimitAs => MemoryLimit::RlimitAs { bytes }, - _ => MemoryLimit::Unenforced, - }; + let limit = MemoryLimit::detect(bytes)?; if limit.mechanism() == MemoryMechanism::Unenforced { warn!( - "--max-memory has no effect on this platform: RLIMIT_AS is accepted but not enforced here" + "--max-memory has no effect on this platform: RLIMIT_AS is accepted but not enforced here, and cgroups are not available" ); } else { info!( @@ -98,22 +118,96 @@ impl MemoryLimit { Ok(limit) } + #[cfg(target_os = "linux")] + fn detect(bytes: u64) -> Result { + match cgroup::CgroupTree::probe(bytes) { + Ok(tree) => Ok(MemoryLimit::CgroupV2 { bytes, tree }), + Err(err) => { + debug!(?err, "cgroup v2 memory limits are not available"); + MemoryLimit::without_cgroups(bytes) + } + } + } + + #[cfg(not(target_os = "linux"))] + fn detect(bytes: u64) -> Result { + MemoryLimit::without_cgroups(bytes) + } + + /// The limit to fall back to when no cgroup can be used. + fn without_cgroups(bytes: u64) -> Result { + match choose_mechanism(false, rlimit::settable(bytes), rlimit::ENFORCED)? { + #[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] + MemoryMechanism::RlimitAs => Ok(MemoryLimit::RlimitAs { bytes }), + // We passed `false` for cgroups, so `CgroupV2` cannot come back; folding it in + // here keeps that a dead branch rather than a panic. + _ => Ok(MemoryLimit::Unenforced), + } + } + fn mechanism(&self) -> MemoryMechanism { match self { + #[cfg(target_os = "linux")] + MemoryLimit::CgroupV2 { .. } => MemoryMechanism::CgroupV2, #[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] MemoryLimit::RlimitAs { .. } => MemoryMechanism::RlimitAs, MemoryLimit::Unenforced => MemoryMechanism::Unenforced, } } + /// Set up the limit for one scenario phase, before its command is spawned. + #[cfg_attr(not(target_os = "linux"), allow(clippy::unnecessary_wraps))] + pub fn start(&self) -> Result { + Ok(match self { + #[cfg(target_os = "linux")] + MemoryLimit::CgroupV2 { bytes, tree } => ScenarioMemoryLimit::CgroupV2( + tree.create_scenario(*bytes) + .context("create a cgroup for this scenario")?, + ), + #[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] + MemoryLimit::RlimitAs { bytes } => ScenarioMemoryLimit::RlimitAs(*bytes), + MemoryLimit::Unenforced => ScenarioMemoryLimit::Unenforced, + }) + } +} + +/// The memory limit in force for one scenario phase. +/// +/// A cgroup is per-scenario state that has to outlive the spawn, which is why this is +/// separate from [`MemoryLimit`]; the cgroup is removed when this is dropped. +#[derive(Debug)] +pub enum ScenarioMemoryLimit { + #[cfg(target_os = "linux")] + CgroupV2(cgroup::ScenarioCgroup), + #[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] + RlimitAs(u64), + Unenforced, +} + +impl ScenarioMemoryLimit { /// Arrange for the command, once forked, to be subject to the limit, so that it /// applies from the very first allocation the child makes. - pub fn configure_command(&self, command: &mut Command) { + #[cfg_attr(not(target_os = "linux"), allow(clippy::unnecessary_wraps))] + pub fn configure_command(&self, command: &mut Command) -> Result<()> { match self { + #[cfg(target_os = "linux")] + ScenarioMemoryLimit::CgroupV2(cgroup) => { + use std::io::Write; + use std::os::unix::process::CommandExt; + + let procs = cgroup.open_procs()?; + // SAFETY: opened before the fork, so the closure only writes to an + // already-open descriptor -- no allocation, no locks -- which is safe + // between fork and exec. "0" means "the process doing the writing". + unsafe { + command.pre_exec(move || (&procs).write_all(b"0\n")); + } + } #[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] - MemoryLimit::RlimitAs { bytes } => rlimit::apply_to_child(command, *bytes), - MemoryLimit::Unenforced => {} + ScenarioMemoryLimit::RlimitAs(bytes) => rlimit::apply_to_child(command, *bytes), + ScenarioMemoryLimit::Unenforced => {} } + Ok(()) } } @@ -168,30 +262,32 @@ mod rlimit { mod test { use super::{MemoryMechanism, choose_mechanism}; - /// The mechanism is picked from what the platform can do. + /// The mechanism is picked from what the platform can do, in order of preference. #[test] fn choose_mechanism_picks_by_availability() { - // (RLIMIT_AS settable, RLIMIT_AS enforced) -> mechanism, where None means - // --max-memory should be rejected outright. + // (cgroups available, RLIMIT_AS settable, RLIMIT_AS enforced) -> mechanism, where + // None means --max-memory should be rejected outright. let cases = [ - ((true, true), Some(MemoryMechanism::RlimitAs)), + ((true, true, true), Some(MemoryMechanism::CgroupV2)), + ((true, false, false), Some(MemoryMechanism::CgroupV2)), + ((false, true, true), Some(MemoryMechanism::RlimitAs)), // macOS: the call is accepted and then ignored. - ((true, false), Some(MemoryMechanism::Unenforced)), - ((false, false), None), - ((false, true), None), + ((false, true, false), Some(MemoryMechanism::Unenforced)), + ((false, false, false), None), + ((false, false, true), None), ]; - for ((settable, enforced), expected) in cases { + for ((cgroup, settable, enforced), expected) in cases { assert_eq!( - choose_mechanism(settable, enforced).ok(), + choose_mechanism(cgroup, settable, enforced).ok(), expected, - "settable={settable} enforced={enforced}" + "cgroup={cgroup} settable={settable} enforced={enforced}" ); } } #[test] fn choose_mechanism_with_no_usable_mechanism_names_the_option() { - let err = choose_mechanism(false, false) + let err = choose_mechanism(false, false, false) .expect_err("--max-memory with no mechanism should be an error"); assert!( err.to_string().contains("--max-memory"), diff --git a/src/process/memory/cgroup.rs b/src/process/memory/cgroup.rs new file mode 100644 index 00000000..aa9d2366 --- /dev/null +++ b/src/process/memory/cgroup.rs @@ -0,0 +1,265 @@ +// Copyright 2026 Martin Pool + +//! Per-scenario memory limits using cgroup v2. +//! +//! Each scenario's cargo process tree gets a cgroup of its own with `memory.max` set, so +//! the kernel stops it -- and tells us that it did, through `memory.events` -- rather +//! than letting it eat the machine. +//! +//! The awkward part is finding somewhere to put those cgroups. A cgroup's children only +//! have `memory.max` if the cgroup itself lists `memory` in `cgroup.subtree_control`, and +//! the kernel refuses to set that on a cgroup that contains processes. Since cargo-mutants +//! is itself a process in its own cgroup, we look at, in order of preference: +//! +//! 1. Our own cgroup, if the memory controller is or can be delegated from it: a scenario +//! cgroup there stays inside whatever limit the operator already put on us. Making it +//! delegable writes `+memory` to our own `cgroup.subtree_control`, which is a lasting +//! change to a cgroup we do not own, though an additive one. +//! 2. Our parent, if it already delegates the memory controller -- which is exactly the +//! case when something has already put a `memory.max` fence around us. Scenario +//! cgroups are then siblings of ours and so sit *outside* that fence; see the warning +//! in [`CgroupTree::probe`]. +//! 3. Our own cgroup again, after moving ourselves down into a leaf so that it no longer +//! holds any processes. This is a visible side effect, so it's the last resort. + +use std::fs::{File, OpenOptions, create_dir, read_to_string, remove_dir, write}; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use std::process; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::thread::sleep; +use std::time::Duration; + +use anyhow::{Context, anyhow, bail}; +use tracing::{debug, warn}; + +use crate::Result; + +/// Where the unified cgroup v2 hierarchy is mounted. +const CGROUP_ROOT: &str = "/sys/fs/cgroup"; + +/// How many times, and how far apart, to retry removing a scenario cgroup that the +/// kernel still considers occupied because a killed process has not been reaped yet. +const REMOVE_ATTEMPTS: u32 = 10; +const REMOVE_RETRY_INTERVAL: Duration = Duration::from_millis(20); + +/// How many names to try before giving up on finding a free one, in case a previous run +/// died without cleaning up and this process reused its pid. +const CREATE_ATTEMPTS: u32 = 100; + +/// A cgroup under which one memory-limited cgroup can be made per scenario. +#[derive(Debug)] +pub struct CgroupTree { + /// A cgroup with the memory controller delegated to its children. + parent: PathBuf, +} + +impl CgroupTree { + /// Find somewhere to make memory-limited cgroups, or explain why we can't. + /// + /// `bytes` is the limit we'll want later: it's applied to a throwaway cgroup here, so + /// that an unusable hierarchy is a complaint now rather than a failure mid-run. + pub fn probe(bytes: u64) -> Result { + let own = own_cgroup()?; + let mut problems: Vec = Vec::new(); + let try_using = + |dir: &Path, problems: &mut Vec| match CgroupTree::check(dir.to_owned(), bytes) + { + Ok(tree) => Some(tree), + Err(err) => { + problems.push(format!("{}: {err:#}", dir.display())); + None + } + }; + + if delegates_memory(&own).unwrap_or(false) || enable_memory_delegation(&own).is_ok() { + if let Some(tree) = try_using(&own, &mut problems) { + return Ok(tree); + } + } else { + problems.push(format!( + "{}: can't delegate the memory controller", + own.display() + )); + } + + if own != Path::new(CGROUP_ROOT) + && let Some(parent) = own.parent() + && delegates_memory(parent).unwrap_or(false) + && let Some(tree) = try_using(parent, &mut problems) + { + warn!( + "Scenario cgroups will be siblings of cargo-mutants' own cgroup, not inside \ + it, because its own cgroup cannot delegate the memory controller. \ + --max-memory still bounds each scenario, but their total is bounded only by \ + {}, not by any memory.max set on cargo-mutants itself", + parent.display() + ); + return Ok(tree); + } + + // Nothing else worked, so get out of our own cgroup and try it once more. + match move_self_into_leaf(&own).and_then(|()| enable_memory_delegation(&own)) { + Ok(()) => { + if let Some(tree) = try_using(&own, &mut problems) { + return Ok(tree); + } + } + Err(err) => problems.push(format!("{}: {err:#}", own.display())), + } + bail!("no usable cgroup v2 hierarchy: {}", problems.join("; ")) + } + + /// Prove that a scenario cgroup can really be made here before promising the user one. + fn check(parent: PathBuf, bytes: u64) -> Result { + let tree = CgroupTree { parent }; + // Dropped immediately, which removes it again. + tree.create_scenario(bytes) + .context("test-create a scenario cgroup")?; + debug!(?tree.parent, "using cgroup v2 for per-scenario memory limits"); + Ok(tree) + } + + /// Make a cgroup to hold one scenario's process tree, limited to `bytes` of memory. + pub fn create_scenario(&self, bytes: u64) -> Result { + let cgroup = ScenarioCgroup { + dir: self.claim_dir()?, + }; + cgroup.write("memory.max", &bytes.to_string())?; + // Swapping a runaway scenario out instead of stopping it would be slower than the + // failure we're trying to cause, but this knob only exists where the kernel + // accounts for swap, and memory.max alone still bounds resident memory. + if let Err(err) = cgroup.write("memory.swap.max", "0") { + debug!(?err, "no memory.swap.max: the limit will not cover swap"); + } + Ok(cgroup) + } + + /// Create a scenario cgroup directory with a name nothing else is using. + fn claim_dir(&self) -> Result { + /// Distinguishes concurrent scenarios from each other. + static SERIAL: AtomicU64 = AtomicU64::new(0); + (0..CREATE_ATTEMPTS) + .find_map(|_| { + let dir = self.parent.join(format!( + "cargo-mutants-{pid}-{serial}", + pid = process::id(), + serial = SERIAL.fetch_add(1, Ordering::Relaxed) + )); + match create_dir(&dir) { + Ok(()) => Some(Ok(dir)), + // A run that died without cleaning up leaves directories behind, and + // this process may have been given its pid; just take the next name. + Err(err) if err.kind() == ErrorKind::AlreadyExists => None, + Err(err) => { + Some(Err(err).with_context(|| format!("create cgroup {}", dir.display()))) + } + } + }) + .unwrap_or_else(|| { + Err(anyhow!( + "no free scenario cgroup name under {}", + self.parent.display() + )) + }) + } +} + +/// The cgroup holding one scenario phase's process tree. +#[derive(Debug)] +pub struct ScenarioCgroup { + dir: PathBuf, +} + +impl ScenarioCgroup { + /// Open this cgroup's `cgroup.procs`, so that a forked child can move itself in by + /// writing to an already-open file, without allocating. + pub fn open_procs(&self) -> Result { + let path = self.dir.join("cgroup.procs"); + OpenOptions::new() + .write(true) + .open(&path) + .with_context(|| format!("open {}", path.display())) + } + + fn write(&self, name: &str, value: &str) -> Result<()> { + let path = self.dir.join(name); + write(&path, value).with_context(|| format!("write {value:?} to {}", path.display())) + } +} + +/// Removing the cgroup on drop is what keeps a failure part-way through a scenario -- +/// or part-way through creating the cgroup itself -- from leaking it. +impl Drop for ScenarioCgroup { + fn drop(&mut self) { + // The kernel only removes a cgroup with no members left. The process group sweep + // has already killed everything in here, but a killed process still counts as a + // member until it is reaped, so give the kernel a moment to catch up. + let last_failure = (0..REMOVE_ATTEMPTS).find_map(|attempt| { + if attempt > 0 { + sleep(REMOVE_RETRY_INTERVAL); + } + match remove_dir(&self.dir) { + Ok(()) => Some(Ok(())), + Err(err) if attempt + 1 == REMOVE_ATTEMPTS => Some(Err(err)), + Err(_) => None, + } + }); + if let Some(Err(err)) = last_failure { + // Not worth failing a scenario over: an abandoned empty cgroup costs an inode. + debug!(?self.dir, ?err, "gave up removing scenario cgroup"); + } + } +} + +/// The directory of the cgroup v2 cgroup that this process is in. +fn own_cgroup() -> Result { + let root = Path::new(CGROUP_ROOT); + if !root.join("cgroup.controllers").exists() { + bail!("{CGROUP_ROOT} is not a cgroup v2 unified hierarchy"); + } + // The v2 entry in /proc/self/cgroup is the one with hierarchy id 0 and no named + // controllers; any others are v1 and of no use to us. + let own = read_to_string("/proc/self/cgroup").context("read /proc/self/cgroup")?; + let relative = own + .lines() + .find_map(|line| line.strip_prefix("0::")) + .context("no cgroup v2 entry in /proc/self/cgroup")?; + Ok(root.join(relative.trim().trim_start_matches('/'))) +} + +/// Whether children of this cgroup get `memory.max`. +fn delegates_memory(dir: &Path) -> Result { + let path = dir.join("cgroup.subtree_control"); + Ok(read_to_string(&path) + .with_context(|| format!("read {}", path.display()))? + .split_whitespace() + .any(|controller| controller == "memory")) +} + +/// Ask the kernel to give this cgroup's children `memory.max`. +/// +/// This fails while the cgroup contains processes, which is the usual case for our own +/// cgroup. +fn enable_memory_delegation(dir: &Path) -> Result<()> { + let path = dir.join("cgroup.subtree_control"); + write(&path, "+memory").with_context(|| { + format!( + "delegate the memory controller by writing to {}", + path.display() + ) + }) +} + +/// Move this process into a child of `dir`, so that `dir` itself holds no processes and +/// can therefore delegate controllers. +fn move_self_into_leaf(dir: &Path) -> Result<()> { + let leaf = dir.join("cargo-mutants-supervisor"); + if !leaf.exists() { + create_dir(&leaf).with_context(|| format!("create cgroup {}", leaf.display()))?; + } + let procs = leaf.join("cgroup.procs"); + write(&procs, "0\n").with_context(|| format!("move this process into {}", procs.display()))?; + debug!(?leaf, "moved cargo-mutants into a cgroup of its own"); + Ok(()) +} diff --git a/testdata/unbounded_allocation/Cargo_test.toml b/testdata/unbounded_allocation/Cargo_test.toml new file mode 100644 index 00000000..6760dbc7 --- /dev/null +++ b/testdata/unbounded_allocation/Cargo_test.toml @@ -0,0 +1,11 @@ +# A tree with a function that allocates without bound when one of its mutants is +# applied, so that `--max-memory` can be seen to stop it. + +[package] +name = "cargo-mutants-testdata-unbounded-allocation" +version = "0.1.0" +edition = "2018" +publish = false + +[lib] +doctest = false diff --git a/testdata/unbounded_allocation/src/lib.rs b/testdata/unbounded_allocation/src/lib.rs new file mode 100644 index 00000000..b14e6880 --- /dev/null +++ b/testdata/unbounded_allocation/src/lib.rs @@ -0,0 +1,29 @@ +//! A tree whose tests allocate without bound when a particular mutant is applied. +//! +//! `replace big_enough -> bool with false` turns the loop in `grow_buffer` into an +//! unbounded allocator, which grows fast enough to hit a memory limit long before any +//! reasonable test timeout. + +/// Is the buffer big enough to stop growing it? +fn big_enough(chunks: usize) -> bool { + chunks >= 8 +} + +/// Grow a buffer a mebibyte at a time until it's big enough, and return its size in +/// mebibytes. +pub fn grow_buffer() -> usize { + let mut buffer: Vec> = Vec::new(); + while !big_enough(buffer.len()) { + // Written, not just reserved, so that the memory is really resident. + buffer.push(vec![0xab; 1 << 20]); + } + buffer.len() +} + +#[cfg(test)] +mod test { + #[test] + fn grow_buffer_stops_when_big_enough() { + assert_eq!(super::grow_buffer(), 8); + } +} diff --git a/tests/main.rs b/tests/main.rs index 906b6f43..449a1645 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -30,6 +30,8 @@ use tempfile::{NamedTempFile, TempDir, tempdir}; mod integration_util; mod util; use integration_util::run; +#[cfg(target_os = "linux")] +use util::outcome_json; use util::{ CommandInstaExt, OUTER_TIMEOUT, assert_bytes_eq_json, copy_of_testdata, copy_testdata_to, outcome_json_counts, @@ -3873,3 +3875,72 @@ fn max_memory_does_not_disturb_a_normal_run() { .success() .stdout(contains("4 mutants tested")); } + +/// A mutant can turn a bounded loop into an unbounded allocator. `--max-memory` puts a +/// ceiling on each scenario, so the kernel stops the runaway mutant in a fraction of a +/// second, rather than the machine filling up until the test timeout arrives. +/// +/// Only the cgroup mechanism limits resident memory, so this is gated to Linux and skips +/// where no writable cgroup is available. +#[cfg(target_os = "linux")] +#[test] +fn max_memory_catches_a_mutant_that_allocates_without_bound() +-> Result<(), Box> { + let tmp_src_dir = copy_of_testdata("unbounded_allocation"); + let assert = run() + .arg("mutants") + .args([ + "--max-memory=256M", + "--regex=replace big_enough -> bool with false", + "--baseline=skip", + // Generous, so that "not a timeout" really means the memory limit stopped it. + "--timeout=60", + "--build-timeout=120", + "-L", + "debug", + "-v", + ]) + .current_dir(tmp_src_dir.path()) + .timeout(OUTER_TIMEOUT) + .assert(); + let stdout = String::from_utf8_lossy(&assert.get_output().stdout).into_owned(); + let stderr = String::from_utf8_lossy(&assert.get_output().stderr).into_owned(); + println!("stdout:\n{stdout}\nstderr:\n{stderr}"); + + // Under the RLIMIT_AS fallback a 256M address-space cap is too tight even to build, + // so there is nothing to assert. + if !stderr.contains("cgroup v2 memory.max") { + eprintln!("skipped: no writable cgroup v2 here, so --max-memory fell back to RLIMIT_AS"); + return Ok(()); + } + assert.success(); + + assert_eq!( + outcome_json_counts(&tmp_src_dir), + json!({ + "total_mutants": 1, + "caught": 1, + "missed": 0, + "timeout": 0, + "unviable": 0, + "success": 0, + }), + "the runaway mutant should be caught by the memory limit, not by the timeout" + ); + + // It should die on the memory limit long before the 60s test timeout. + let outcomes = outcome_json(&tmp_src_dir); + let test_secs = outcomes["outcomes"][0]["phase_results"] + .as_array() + .ok_or("no phase_results in outcomes.json")? + .iter() + .find(|pr| pr["phase"] == "Test") + .ok_or("the mutant never reached the test phase")?["duration"] + .as_f64() + .ok_or("no duration on the test phase")?; + assert!( + test_secs < 20.0, + "the memory-limited test took {test_secs}s, which is not well under the timeout" + ); + Ok(()) +}