From fc1ede60ca315c89f1f4523bbb2562a8b9dc640c Mon Sep 17 00:00:00 2001 From: Yuri Khrustalev Date: Tue, 15 Sep 2026 22:03:38 -0400 Subject: [PATCH] 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")); +}