Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
# <https://github.com/nicokoch/reflink/issues/11> and
Expand Down
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down
47 changes: 47 additions & 0 deletions book/src/timeouts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions examples/custom_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions src/cargo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -37,6 +38,7 @@ pub fn run_cargo(
packages: &PackageSelection,
phase: Phase,
timeout: Option<Duration>,
memory_limit: Option<&MemoryLimit>,
scenario_output: &mut ScenarioOutput,
options: &Options,
console: &Console,
Expand All @@ -61,6 +63,7 @@ pub fn run_cargo(
build_dir.path(),
timeout,
jobserver,
memory_limit,
scenario_output,
console,
)?;
Expand Down
2 changes: 2 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ pub struct Config {

/// Space or comma separated list of features to activate.
pub features: Vec<String>,
/// Maximum memory for each scenario, e.g. "4G"; suffixes are binary multiples.
pub max_memory: Option<String>,
/// Minimum test timeout, in seconds, as a floor on the autoset value.
pub minimum_test_timeout: Option<f64>,
/// Do not activate the `default` feature.
Expand Down
12 changes: 10 additions & 2 deletions src/lab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -38,6 +38,9 @@ pub fn test_mutants(
) -> Result<LabOutcome> {
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);
}
Expand All @@ -62,6 +65,7 @@ pub fn test_mutants(
let lab = Lab {
output_mutex,
jobserver,
memory_limit,
tests_for_mutant,
options,
console,
Expand Down Expand Up @@ -164,6 +168,7 @@ fn join_threads(threads: Vec<thread::ScopedJoinHandle<'_, Result<()>>>) -> Resul
struct Lab<'a> {
output_mutex: Mutex<OutputDir>,
jobserver: Option<jobserver::Client>,
memory_limit: Option<MemoryLimit>,
tests_for_mutant: TestsForMutant,
options: &'a Options,
console: &'a Console,
Expand Down Expand Up @@ -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,
Expand All @@ -222,6 +228,7 @@ struct Worker<'a> {
build_dir: &'a BuildDir,
output_mutex: &'a Mutex<OutputDir>,
jobserver: Option<&'a jobserver::Client>,
memory_limit: Option<&'a MemoryLimit>,
tests_for_mutant: &'a TestsForMutant,
options: &'a Options,
console: &'a Console,
Expand Down Expand Up @@ -289,6 +296,7 @@ impl Worker<'_> {
test_packages,
phase,
timeout,
self.memory_limit,
&mut scenario_output,
self.options,
self.console,
Expand Down
7 changes: 7 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// List source files, don't run anything.
#[arg(long, help_heading = "Execution")]
list_files: bool,
Expand Down
123 changes: 122 additions & 1 deletion src/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<u64>,

pub print_caught: bool,
pub print_unviable: bool,

Expand Down Expand Up @@ -237,6 +240,47 @@ fn join_slices(a: &[String], b: &[String]) -> Vec<String> {
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<u64> {
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<u64> {
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")]
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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"
Expand Down
Loading