Skip to content

Commit 9c10127

Browse files
fix(mutmut): switch container to :ro repo mount + tmpfs scratch dir
v1.1 item B from the 2026-05-18 pre-release sweep — closes the host-tree-corruption hazard that nearly shipped a live mutation to git on 2026-05-18 (caught at stage time). ## Problem Prior layout: -v "$REPO_ROOT:/work" (read-write bind mount). Every mutation mutmut applied was a real write to the HOST filesystem under /c/Users/PC/wl_manager/. When mutmut died between mutate-and-restore (SIGKILL, container stop, host reboot), the source file stayed in mutated state and `git diff` showed it as a normal edit. The 2026-05-18 session caught a live `_csv_file_hash → None` mutation about to be staged in Phase 2 — would have silently broken CSV-hash bootstrapping if missed. ## Fix Two-mount layout: /repo — host repo, READ-ONLY (`:ro` bind mount). mutmut cannot write here at all. /scratch — tmpfs (in-RAM, 512 MiB cap). Populated from /repo at container creation via 'cp -a /repo/. /scratch/'. mutmut runs with WORKDIR=/scratch and mutates the tmpfs copy. Host /repo is untouchable. Helper functions added to scripts/mutmut.sh: - populate_scratch() — copies /repo into /scratch as root - scratch_is_empty() — detects tmpfs wipe after container stop/start cycles, triggers re-populate Source-refresh semantics: 'scripts/mutmut.sh kill' then re-run. The fresh container repopulates /scratch from current /repo. The tmpfs is intentionally not refreshed on every 'run' invocation — that would invalidate mutmut's incremental cache (keyed on source file timestamps) and lose the cross-run speedup. ## Verification Verified end-to-end: 1. Mount inspect: /c/Users/PC/wl_manager -> /repo, rw=false 2. Tmpfs config: {"/scratch":"size=536870912"} (512 MiB cap) 3. /scratch/bin/ contains the wl_*.py module copies 4. Deliberate write attempt to /repo/bin/wl_validation.py: 'Read-only file system' (exit non-zero) 5. Host file sha256 unchanged after fresh container creation (pre and post both c8553865...) 6. pytest tests/unit/test_validation.py inside /scratch: 33 passed (+1 vs host because Linux symlink test runs; Windows host skips it) ## Doc update docs/MUTATION_TESTING.md "Recommended improvements" marks item 2 (volume mount switch) CLOSED with the verification evidence above. Open list renumbers 2->1, 3->2, 4->3 (markdownlint ordered-list rule). doc-drift: OK (33 docs against build 660).
1 parent d40304d commit 9c10127

2 files changed

Lines changed: 90 additions & 13 deletions

File tree

docs/MUTATION_TESTING.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -209,25 +209,28 @@ queued as v1.1 maintenance work.
209209
doc. Escape hatch: explicit `TEST_RUNNER_FILES` env var still
210210
overrides auto-derivation.
211211

212-
### Open (queued for v1.1 release prep)
212+
2. ~~**Switch the volume mount to read-only.**~~ — DONE. The host
213+
repo is now mounted at `/repo` as `:ro`; a 512 MiB tmpfs is
214+
mounted at `/scratch` and populated from `/repo` at container
215+
creation. mutmut runs with `WORKDIR=/scratch` and mutates the
216+
tmpfs copy only — the host tree is unreachable from inside the
217+
container. Verified: a deliberate write attempt to
218+
`/repo/bin/wl_validation.py` fails with "Read-only file system";
219+
host file sha256 unchanged after a fresh container creation.
220+
Source-refresh signal: `scripts/mutmut.sh kill` then re-run (the
221+
tmpfs is repopulated from `/repo` only on container creation).
213222

214-
1. **Switch the volume mount to read-only.** Replace
215-
`-v "$REPO_ROOT:/work"` with `-v "$REPO_ROOT:/work:ro"` and have
216-
mutmut use a tmpfs (or container-local) scratch dir for its
217-
mutations. This removes the host-tree-corruption hazard entirely.
218-
Mutmut's internal cache lives in `mutants/.mutmut-cache` and can
219-
stay on a separate writable volume. Estimated effort: 1 hour
220-
(test the cache-survives-restart property).
223+
### Open (queued for v1.1 release prep)
221224

222-
2. **Re-run wl_validation with the correct selector** to get a
225+
1. **Re-run wl_validation with the correct selector** to get a
223226
fresh survivor count after the 2 new tests above. Expected
224227
result: ≤8 survivors (the equivalent mutants), down from 12.
225228

226-
3. **Run wl_csv.py with the correct selector**. Expected: real
229+
2. **Run wl_csv.py with the correct selector**. Expected: real
227230
survivor count, plausibly 5-20 genuine gaps in CSV diff /
228231
hash-registry logic.
229232

230-
4. **Add `bin/wl_audit.py` mutation pass.** The 37 reported survivors
233+
3. **Add `bin/wl_audit.py` mutation pass.** The 37 reported survivors
231234
on `wl_audit.py` from a prior session need the same re-validation
232235
under the correct selector before deciding what to do with them.
233236

scripts/mutmut.sh

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,35 @@
5252
# is named `wl_manager_mutmut` and stays up between commands;
5353
# `scripts/mutmut.sh kill` tears it down when you're done.
5454
#
55+
# Host-tree safety: :ro mount + tmpfs scratch
56+
# -------------------------------------------
57+
#
58+
# Prior versions of this script mounted `$REPO_ROOT` at `/work` as
59+
# read-write. Every mutation mutmut applied was a real write to
60+
# the host filesystem. If mutmut was killed between mutate-and-
61+
# restore (SIGKILL, container stop, host reboot), the source file
62+
# stayed in mutated state and `git diff` showed it as a normal
63+
# edit. The 2026-05-18 session caught a live `_csv_file_hash → None`
64+
# mutation about to be staged — host-mount + interrupted mutmut
65+
# is a real foot-gun.
66+
#
67+
# Current layout: host repo is mounted READ-ONLY at /repo. A
68+
# tmpfs (in-RAM, ephemeral) is mounted at /scratch. At container
69+
# creation, /repo is copied into /scratch. mutmut runs WITH
70+
# /scratch as CWD and mutates /scratch/bin/*.py — the host /repo
71+
# is never written to.
72+
#
73+
# Implications:
74+
# - To pick up source changes from the host, `scripts/mutmut.sh kill`
75+
# and then re-run. The fresh container will copy current /repo
76+
# into a fresh tmpfs. Editing host source between runs without
77+
# `kill` is intentionally invisible to the mutator.
78+
# - The mutmut cache (mutants/.mutmut-cache + .pytest_cache) lives
79+
# in /scratch and persists across `start/stop` of the same
80+
# container, but disappears on `kill` (container removal).
81+
# - tmpfs default cap is 512 MiB (--tmpfs-size). Plenty for the
82+
# ~10 MB working repo plus mutmut's tracking dirs.
83+
#
5584
# What gets mutated
5685
# -----------------
5786
#
@@ -199,25 +228,68 @@ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
199228
# Posix path conversion for Windows Git Bash
200229
export MSYS_NO_PATHCONV=1
201230

231+
populate_scratch() {
232+
# Copy /repo into /scratch. /scratch is the tmpfs mutation surface;
233+
# /repo is the read-only host mount. mutmut mutates /scratch only,
234+
# so the host tree is never written to.
235+
#
236+
# We use `cp -a /repo/. /scratch/` (trailing dot+slash) to copy
237+
# the CONTENTS of /repo into /scratch, not the /repo directory
238+
# itself. The -a preserves mode/ownership/timestamps — important
239+
# because mutmut keys its cache on source file timestamps.
240+
#
241+
# Running as -u 0 (root) inside the container because /scratch is
242+
# owned by root after tmpfs mount; the default container user
243+
# cannot write to a fresh root-owned mount.
244+
echo "→ populating /scratch from /repo (tmpfs is fresh)..."
245+
if ! docker exec -u 0 "$CONTAINER" sh -c 'cp -a /repo/. /scratch/'; then
246+
echo "✖ scratch population failed — removing container." >&2
247+
docker rm -f "$CONTAINER" >/dev/null
248+
exit 1
249+
fi
250+
}
251+
252+
scratch_is_empty() {
253+
# Quick check whether /scratch needs (re-)populating. We check for
254+
# the presence of bin/ — a fresh tmpfs has no bin/ until populate
255+
# runs. This lets us repopulate after `docker start` of a stopped
256+
# container (tmpfs is wiped on container stop/start cycles).
257+
! docker exec "$CONTAINER" sh -c 'test -d /scratch/bin'
258+
}
259+
202260
ensure_container() {
203261
if docker inspect "$CONTAINER" >/dev/null 2>&1; then
204262
# Container exists. Make sure it's running.
205263
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER")" != "true" ]; then
206264
docker start "$CONTAINER" >/dev/null
207265
fi
266+
# tmpfs is wiped on container stop/start, so we may need to
267+
# repopulate even on an existing container.
268+
if scratch_is_empty; then
269+
populate_scratch
270+
fi
208271
return
209272
fi
210273
echo "→ creating mutmut container ($IMAGE)..."
274+
# Mount layout:
275+
# /repo — host repo, READ-ONLY. mutmut never writes here.
276+
# /scratch — tmpfs (in-RAM), 512 MiB cap. Copied from /repo
277+
# at startup; mutmut mutates files here.
278+
# -w /scratch — CWD so relative paths (bin/wl_X.py, tests/...)
279+
# resolve into the writable copy.
211280
docker run -d --name "$CONTAINER" \
212-
-v "$REPO_ROOT:/work" \
213-
-w /work \
281+
-v "$REPO_ROOT:/repo:ro" \
282+
--tmpfs /scratch:size=536870912 \
283+
-w /scratch \
214284
--entrypoint sleep \
215285
"$IMAGE" infinity >/dev/null
216286

217287
echo "→ installing deps..."
218288
# We DON'T use --quiet here so failures are visible. mutmut
219289
# 2.4.4 is the last 2.x release before 3.x rewrote the API
220290
# incompatibly; we pin it to keep the harness stable.
291+
# pip installs into the container's writable layer (NOT /scratch),
292+
# so deps survive tmpfs wipes across container start/stop.
221293
if ! docker exec "$CONTAINER" pip install \
222294
mutmut==2.4.4 \
223295
pytest==9.0.3 \
@@ -228,6 +300,8 @@ ensure_container() {
228300
docker rm -f "$CONTAINER" >/dev/null
229301
exit 1
230302
fi
303+
304+
populate_scratch
231305
}
232306

233307
cmd_run() {

0 commit comments

Comments
 (0)