Skip to content

feat: bound runaway mutants with --max-memory and a process group sweep - #647

Open
ykhrustalev wants to merge 7 commits into
sourcefrog:mainfrom
ykhrustalev:ykhrustalev/max-memory-and-process-group-sweep
Open

ykhrustalev wants to merge 7 commits into
sourcefrog:mainfrom
ykhrustalev:ykhrustalev/max-memory-and-process-group-sweep

Conversation

@ykhrustalev

@ykhrustalev ykhrustalev commented Sep 16, 2026

Copy link
Copy Markdown

Split into a stack as requested. This PR is kept open as the reference for the whole change; the pieces are reviewed and merged individually. Each dependent PR's diff includes its parents until they land; its body names which commit is new.

Track 1 — process lifecycle

Track 2 — memory limit

Convergence

The two tracks are independent; #648 and #649 can be reviewed in either order.


Problem
A mutant can turn a bounded loop into an unbounded allocator, and a test process growing at hundreds of MB/s exhausts the machine long before a 5x-baseline test timeout fires — on CI the VM is torn down with no log and the shard's mutants are never recorded. Separately, tests that leave processes running survive a normal exit, because the child's process group is only signalled on timeout, so they keep allocating between scenarios.

Solution

  • Sweeps the cargo process group after every phase, not only on timeout: SIGTERM, a bounded grace period, then SIGKILL. The timeout path shares this sweep instead of doing its own kill.
  • Adds --max-memory SIZE and the max_memory config key to bound each scenario's cargo process tree.
  • Prefers a cgroup v2 memory.max per scenario where a writable cgroup is available, falling back to setrlimit(RLIMIT_AS), and logs which mechanism is in use.
  • Fails before testing any mutant when --max-memory is given and neither mechanism can be applied, rather than running with no limit. macOS accepts but ignores RLIMIT_AS, so the option warns and is a no-op there.
  • Reports the killing signal, the cgroup oom_kill count, and anything the sweep reaped on the outcome line, in the scenario log, and in outcomes.json, so an OOM-caught mutant is distinguishable from one caught by a failing assertion. Caught / missed / unviable / timeout classification is unchanged.

Testing

  • Two new testdata trees: spawns_background_child (test leaves sleep 300 running; the integration test probes the recorded pids afterwards) and unbounded_allocation (one mutant allocates a MiB at a time forever).
  • max_memory_catches_a_mutant_that_allocates_without_bound is gated to Linux and asserts the mutant is caught in under a second with a 60s timeout, and that the outcome line names the OOM kill.
  • Unit tests cover size parsing, CLI-over-config precedence, the outcome wording, and that --max-memory with no usable mechanism is a hard error.
  • Verified on Linux against all three paths: cgroup v2, the RLIMIT_AS fallback under a read-only cgroupfs, and the hard error with the RLIMIT_AS hard limit lowered below the request.

Processes left running by a scenario's tests survived a normal exit and
kept allocating while later mutants were tested. Sweep the group after
every phase, not only on timeout: SIGTERM, a bounded grace period, then
SIGKILL. The timeout path now shares this sweep instead of its own kill.
A mutant can turn a bounded loop into an unbounded allocator that
exhausts the machine well before the test timeout fires. Prefer a cgroup
v2 memory.max per scenario, falling back to setrlimit(RLIMIT_AS), log
which is in use, and fail before any mutant runs if neither can apply.
Surface the killing signal, the cgroup oom_kill count, and anything the
process group sweep reaped on the outcome line, in the scenario log, and
in outcomes.json, so an OOM-caught mutant is distinguishable from one
caught by a failing assertion. Classification rules are unchanged.
@sourcefrog

Copy link
Copy Markdown
Owner

Thanks, this sounds potentially good but please split it into smaller individual PRs.

@ykhrustalev

Copy link
Copy Markdown
Author

@sourcefrog thanks for the feedback

Creation was not atomic: a failure writing memory.max left the directory
behind, as did any early return between creating the cgroup and finishing
with it. Move removal into Drop so every path is covered, and stop treating
a missing memory.swap.max as fatal -- it only exists where the kernel
accounts for swap, and memory.max alone still bounds resident memory.

Also tolerate a name left over by a run that died with this pid, and say so
in the log when memory.events can't be read.
Carrying a MemoryMechanism beside an Option<CgroupTree> let the two disagree,
and the code paid for it with an expect(), two unreachable!()s and five clippy
allows. Give each variant the state its mechanism needs, and cfg out the
RlimitAs variants where nix has no RLIMIT_AS, so the impossible combinations
cannot be written rather than merely never happening.
terminate() sent one SIGTERM and then waited indefinitely, so a cargo process
that ignored or could not receive it -- stopped, say -- hung the whole run,
and the process group sweep that would have killed it never ran. Wait only
for the shared grace period, then kill the group.

Also probe the process group before listing it: nearly every phase leaves
nothing behind, and listing meant reading every /proc/<pid>/stat on the
machine, once per phase.
--max-memory=0 was accepted and meant 'stop every scenario immediately',
which is the opposite of what -t 0 means elsewhere in this tool; require at
least 1M. An OOM-killed mutant is also a caught mutant, so it was invisible
without -v: count those separately in the run summary.

The Linux test needed a writable cgroup and failed rather than skipped
without one, which would be red on most CI runners; it now detects the
mechanism and skips.
@ykhrustalev

Copy link
Copy Markdown
Author

@sourcefrog it is split now

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants