test(seed): pin the two unguarded terms of the torn-clone condition #9

Merged
claude merged 3 commits from test/pin-clone-guards into main 2026-08-24 19:49:21 +00:00
Collaborator

Summary

hardlink_clone_into's torn-clone detection is a four-term condition, and a
mutation sweep of all four found two of them unpinned, not one:

if [ "$cp_rc" -eq 0 ] && [ "$i_before" != missing ] && [ "$i_before" = "$i_after" ] \
   && [ "$n_after" -eq "$n_before" ]; then

Removing [ "$cp_rc" -eq 0 ] (issue #5) or [ "$i_before" != missing ] left
all five suites green. This PR adds a scenario for each, plus a third for a
guard the wider sweep turned up in unshare_mutable_paths.

Closes #5.

Why both were unpinned: they mask each other

This is the finding behind #5, and it is why the sweep was worth doing rather
than fixing cp_rc alone.

_dir_inode prints the string missing when stat fails. A source that
really vanishes mid-clone therefore reads missing at both ends — so
i_before = i_after is true — and also fails cp -al. With both terms
present, either one catches that case, so neither is individually
necessary
and no fixture built around a disappearing source can isolate
either. Each term needed a state only it can see.

Mutation matrix

Every mutation was applied to a scratch copy of cache-lib.sh outside the
worktree; scripts/cache-lib.sh is untouched by this PR. Each of the five
pinning scenarios was then run in isolation — the other four excised, so
an early abort cannot hide a later scenario's colour. The result is a clean
diagonal:

mutation (guard removed) 8a 8b 8c 8d 10
(none — control) GREEN GREEN GREEN GREEN GREEN
[ "$cp_rc" -eq 0 ] GREEN GREEN RED GREEN GREEN
[ "$i_before" != missing ] GREEN GREEN GREEN RED GREEN
[ "$i_before" = "$i_after" ] RED GREEN GREEN GREEN GREEN
[ "$n_after" -eq "$n_before" ] GREEN RED GREEN GREEN GREEN
_unshare_files xargs status GREEN GREEN GREEN GREEN RED
unshare_subtree's cp -a status GREEN GREEN GREEN GREEN GREEN

Red transitions, verbatim:

[cp_rc    -> 8c] ASSERTION FAILED: no torn read was reported at all (the failure was caught by the copy's exit status alone)
[imissing -> 8d] ASSERTION FAILED: no torn read was reported at all (the unreadable identity was caught by the sentinel check alone)
[ieq      -> 8a] ASSERTION FAILED: no torn read was reported at all (the substitution was caught by the source's identity alone)
[neq      -> 8b] ASSERTION FAILED: no torn read was reported at all (the silent truncation was caught by the entry count alone)
[unshare_files_rc -> 10] ASSERTION FAILED: the seed reported success over a staging tree that still aliased its source

The new scenarios

8c — a copy that reports failure over a tree that looks whole. The exit
status is the only witness the tool volunteers; the other two are inferences
with blind spots. The entry count is find | wc -l over two trees, so a
failure below a directory neither walk could enumerate moves it not at all,
and a lost entry against a gained one cancels out. A cp -a that links every
entry and still fails — it could not preserve a directory's ownership, say —
is invisible to both. Forced with the PATH stub 8a/8b already use, on the
consumer's own top-level clone: the tree really is copied, then the failure is
reported, once. The report reads cp rc=1, 249/249 entries, source inode N -> N
— both inferences saw nothing, so deleting the status check leaves nothing to
report at all.

8d — an identity that could not be read at either end. The sentinel-aliasing
case: two failed identity reads compare equal to each other, so without this
term a clone whose source could not be identified is renamed into place having
proven nothing. It cannot be forced by removing the source (that fails cp -al
too, and would pin 8c's property over again), so the stub goes on stat, and
narrowly: only the %i reads of this clone's own source, leaving the reader
markers' %Y reads alone. Both reads of the attempt must fail — if only
one did, the survivor would differ from missing and the identity comparison
would become the witness, which is 8a's property. The stub-fired assertion is
therefore a count (-eq 2), not a flag. Report:
cp rc=0, 249/249 entries, source inode missing -> missing.

Worth naming what this scenario is, because it is not the same kind of thing as
8a to 8c. Those reproduce states a job reaches. 8d does not — every route to its
state is closed off, since a rotation hands the witness to 8a and a genuinely
absent source fails cp -al and hands it to 8c. It is a regression guard on a
defensive term
, and what it defends against is a sentinel comparing equal to
itself: a property of the code, not of the filesystem. That is the argument for
pinning it rather than against — a term no fixture exercises is precisely the
one a refactor drops without anyone arguing about it.

10 — a staging tree that cannot be privately owned is never published. Not a
tear at all: a tree whose dep-info files still point at the source's inodes
has every entry present and an unmoved source, so all four clone checks
pass it. Publishing it wires two branches onto one set of fingerprints, which
is the silent cross-branch stale-artifact reuse documented at length in
cache-lib.sh. The only thing between that tree and DST is
_unshare_files's status being propagated, and that status was unpinned.
Forced by refusing exactly the cp -p -- <f> <f>.unshare.<pid> copies of .d
files, which leaves the directory unshares (cp -a) and the clone (cp -al)
untouched so the failure reaching the clone is unambiguous.

Sweep record

Scope, stated plainly so the next reader knows what this does and does not
cover.
Seventeen mutants, each applied to a scratch copy of cache-lib.sh
outside the worktree: the four terms of the torn-clone condition, and thirteen
more across hardlink_clone_into, unshare_mutable_paths, unshare_subtree,
_unshare_files, strip_cargo_locks and live_reader_count. It is a
guard-by-guard sweep of the clone path, not an exhaustive mutation run over
the library — cache_key, the disk-accounting helpers, wait_for_readers's
grace arithmetic and everything in prune-cache.sh / publish-snapshot.sh
were not swept.

It took three passes to get this list right, which is itself the useful
datum. The first pass missed reader_lock_acquire; a review sweep found it. The
second pass, written to be complete, still missed three propagations inside the
two functions this table claims to have swept guard-by-guard; a second review
sweep of twelve mutants disjoint from mine found those. Read the rows below as
what has been checked, not as what exists.

guard verdict
[ "$cp_rc" -eq 0 ] was unpinned (#5) — now pinned by 8c
[ "$i_before" != missing ] was unpinned — now pinned by 8d
[ "$i_before" = "$i_after" ] pinned (8a)
[ "$n_after" -eq "$n_before" ] pinned (8b)
_unshare_files xargs status (the .d pass) was unpinned — now pinned by 10
unshare_mutable_paths's subtree-loop propagation unpinned, undriven — see below
unshare_mutable_paths's .rustc_info.json propagation unpinned, undriven — see below
loop-top rm -rf "$tmp" unpinned, undriven — see below
if ! unshare_mutable_paths pinned (3 suites red)
reader_lock_release pinned (2 suites red)
strip_cargo_locks body pinned (2 suites red)
live_reader_count staleness check pinned (publish suite)
mv -T "$tmp" "$dst" status pinned (8's leftover check)
reader_lock_acquire unpinned — filed as #10, not fixed here
unshare_subtree's cp -a status unpinned, near-equivalent — see below
[ ! -d "$src" ] early return unpinned, judged not worth pinning — see below
CACHE_CLONE_ATTEMPTS ceiling caught, but by hanging — see below
write_cache_lock call equivalent mutant, no coverage — see below

Unpinned, filed rather than fixed: reader_lock_acquire (#10)

Missed by the first pass of this sweep and found in review. Delete
reader_lock_acquire "$parent" "$src_name" "$tag" (cache-lib.sh:399) and all
five suites pass at the identical 129 assertions. That is the consumer's
half of the seed-vs-republish interlock, one line above the four terms this PR
pins, directly under the comment calling its ordering load-bearing.

It is exercised but not asserted, which is the interesting part. In scenario 8a
the publisher genuinely blocks on the marker — readers: waiting for 1 in-flight clone(s) then drained after 1s in logPub. Remove the line and
those log lines vanish, the publisher never waits, and 8a passes identically.

Filed as #10 rather than fixed here because the cheap fix costs a property
worth more than it. Asserting on the publisher's log inside 8a would work — the
wait is deterministic there by construction — but 8a currently pins exactly one
property and is reddened by exactly one mutant, and that one-scenario-one-mutant
diagonal is the thing that makes this suite's coverage legible. The interlock
deserves its own scenario, next to the publisher-side half already in
publish-snapshot-selftest.sh scenarios 6 to 8. #10 carries that reasoning and
the AC.

Unpinned because nothing can drive them: three more propagations

Found by a second review sweep, verified here. All five suites stay green at the
identical 129 assertions with any of these removed:

mutant
unshare_subtree "$d" || { … return 1; }|| true cache-lib.sh:203-206
the .rustc_info.json propagation → || true cache-lib.sh:212-215
loop-top rm -rf "$tmp" deleted cache-lib.sh:395

These are the write_cache_lock class, not the reader_lock_acquire class —
no fixture can construct the condition each one guards, so the mutants prove
nothing about coverage beyond that fact:

  • The subtree-loop propagation needs unshare_subtree to fail. It runs
    in several scenarios, but scenario 10's stub matches cp -p -- only and
    unshare_subtree calls cp -a, so nothing makes it fail.
  • The .rustc_info.json propagation needs that copy to fail. The file
    exists only in hardlink-clone-selftest.sh's real cargo build (which does
    assert it gets unshared); no fixture makes copying it fail.
  • The loop-top rm -rf is only load-bearing when a .stage-<tag> already
    exists at loop entry — otherwise it is a no-op. Retries do not drive it,
    because a torn attempt already removes $tmp before looping; it would take a
    stale staging directory under the same tag, which no fixture creates and
    which per-job-per-run tags make unreachable in production.

The honest summary: unshare_mutable_paths has three failure propagations,
one pinned (by scenario 10) and two undriven.
Recorded on #10 rather than
given scenarios, for the same reason write_cache_lock was — each needs a
fixture that can reach the failure at all before mutating it means anything.

Unpinned, near-equivalent: unshare_subtree's cp -a status

cache-lib.sh:133. Swallow the status and the following rm -rf "$d" +
mv -T "$tmp" "$d" still fails, because $tmp was removed on the copy's
failure path — so the function reports failure anyway and the mutant is close
to equivalent rather than a live hole. A scenario would pin "unshare_subtree
reports failure", which scenario 10 already covers through the sibling path.

Unpinned, judged not worth pinning: [ ! -d "$src" ]

cache-lib.sh:383. A fast path, not a distinct correctness property. Removing
it changes the message and costs four attempts plus 3s of sleep, but the
function still returns 2 via the retry ceiling — which is exactly what scenario
9 asserts.

Caught only by hanging: the CACHE_CLONE_ATTEMPTS ceiling

cache-lib.sh:414. Scenario 9 does detect its removal, but by spinning forever
on a permanently unreadable source rather than by failing — a regression a CI
timeout would surface as an infrastructure flake. Not changed here; fixing it
means giving scenario 9 a bounded run.

No coverage, and the mutant cannot show it: write_cache_lock

Removing the write_cache_lock "$tmp" "$lock_id" call leaves every suite green,
but as an equivalent mutant: no selftest passes seed-target-dir.sh its
optional 6th argument, so LOCK_ID is always empty and the function
early-returns at [ -n "$id" ] || return 0. The .ci-lock-* files the prune
and publish suites use are hand-written fixtures, not its output. The
documented property — a seeded directory carries its lock the instant it
appears under its final name, so a concurrent prune cannot evict it — is
untested end to end. Recorded in #10; it needs a fixture that passes a lock id
before any mutation of it means anything.

A wrong-reason pass found in scenario 9

Worth recording, because it is the mechanism the README already suspected.
Scenario 9's unreadable-source half stays green under the cp_rc mutation
not because it exercises that guard but because cp -al leaves the staging
directory at mode 000, so _unshare_files's find fails and
unshare_mutable_paths aborts the clone first:

::error::unshare_mutable_paths: failed to unshare dep-info files under .../.stage-jobU
::error::clone: could not privately own the mutable paths of ...
::error::seed: could not clone base snapshot ... consistently

The assertion it satisfies (refusing to build against a partial cache) is
reached down a different path entirely. cp_rc and _unshare_files's status
were mutually masking there in exactly the way cp_rc and the missing
sentinel were in the clone condition. Scenarios 8c and 10 now pin both
independently, so the masking no longer hides either — but scenario 9 itself
still does not assert which guard fired, and is unchanged by this PR.

assert_tear

  • Identity fields now match ([0-9]+|missing), so a report whose inode field
    is neither is a loud parse failure rather than a silently-compared string.
  • New unreadable expectation: both reads resolved to missing.
  • The same case now demands a read identity, not merely two equal
    strings — missing equals missing, which is the exact confusion 8d exists
    to pin, and 8a/8b would otherwise have accepted it.

Files affected

  • scripts/seed-target-dir-selftest.sh — scenarios 8c, 8d, 10; assert_tear
    extended; header scenario census updated. 30 → 49 assertions.

  • README.md — the seed-target-dir-selftest.sh suite-table row, and the two
    methodology paragraphs this PR falsifies: the PATH-stub census (now four
    scenarios over two commands — 8a/8b/8c on cp, 10 on cp one level down,
    8d on stat, a mechanism that paragraph did not mention) and the
    assert-which-guard-fired paragraph, which cited #5 — the issue this PR
    closes — as a live example of a surviving mutation. Scenario 9's own sentence
    stands, since the matrix confirms it survives every mutant; it now says why
    it survives instead of citing a closed issue. The mutual-masking hazard is
    added there too, as the general lesson rather than a fact about two terms.

    (These paragraphs were initially scoped to a sibling PR and left alone. That
    scoping was wrong — the sibling's README diff never touches them — so they
    would have been left falsified by nobody.)

scripts/cache-lib.sh is not modified, and no production logic changed.
Every guard this sweep mutated behaved correctly; the findings are all about
what the suite does and does not pin. That is a statement about the fourteen
guards in the table above, not about the library as a whole.

Test plan

bash scripts/selftest.sh          # full, incl. compiler-backed: all 5 suites pass, 129 assertions
shellcheck scripts/seed-target-dir-selftest.sh   # only the pre-existing SC1091 on the source line

Every scenario in the matrix was run in isolation — the other four excised
from a scratch copy — because the suite aborts on first failure, so a
whole-suite run cannot tell you the colour of anything downstream of the first
red. Re-run in full after the doc changes: still 129 assertions.

Fixtures run in mktemp -d scratch only; nothing touched the live CI cache
volume. No app run applies — this repo's deliverable is the suite itself.

## Summary `hardlink_clone_into`'s torn-clone detection is a **four**-term condition, and a mutation sweep of all four found **two** of them unpinned, not one: ```bash if [ "$cp_rc" -eq 0 ] && [ "$i_before" != missing ] && [ "$i_before" = "$i_after" ] \ && [ "$n_after" -eq "$n_before" ]; then ``` Removing `[ "$cp_rc" -eq 0 ]` (issue #5) **or** `[ "$i_before" != missing ]` left all five suites green. This PR adds a scenario for each, plus a third for a guard the wider sweep turned up in `unshare_mutable_paths`. Closes #5. ## Why both were unpinned: they mask each other This is the finding behind #5, and it is why the sweep was worth doing rather than fixing `cp_rc` alone. `_dir_inode` prints the string `missing` when `stat` fails. A source that really vanishes mid-clone therefore reads `missing` at **both** ends — so `i_before = i_after` is *true* — and also fails `cp -al`. With both terms present, either one catches that case, so **neither is individually necessary** and no fixture built around a disappearing source can isolate either. Each term needed a state only it can see. ## Mutation matrix Every mutation was applied to a **scratch copy** of `cache-lib.sh` outside the worktree; `scripts/cache-lib.sh` is untouched by this PR. Each of the five pinning scenarios was then run **in isolation** — the other four excised, so an early abort cannot hide a later scenario's colour. The result is a clean diagonal: | mutation (guard removed) | 8a | 8b | 8c | 8d | 10 | |---|---|---|---|---|---| | *(none — control)* | GREEN | GREEN | GREEN | GREEN | GREEN | | `[ "$cp_rc" -eq 0 ]` | GREEN | GREEN | **RED** | GREEN | GREEN | | `[ "$i_before" != missing ]` | GREEN | GREEN | GREEN | **RED** | GREEN | | `[ "$i_before" = "$i_after" ]` | **RED** | GREEN | GREEN | GREEN | GREEN | | `[ "$n_after" -eq "$n_before" ]` | GREEN | **RED** | GREEN | GREEN | GREEN | | `_unshare_files` xargs status | GREEN | GREEN | GREEN | GREEN | **RED** | | `unshare_subtree`'s `cp -a` status | GREEN | GREEN | GREEN | GREEN | GREEN | Red transitions, verbatim: ``` [cp_rc -> 8c] ASSERTION FAILED: no torn read was reported at all (the failure was caught by the copy's exit status alone) [imissing -> 8d] ASSERTION FAILED: no torn read was reported at all (the unreadable identity was caught by the sentinel check alone) [ieq -> 8a] ASSERTION FAILED: no torn read was reported at all (the substitution was caught by the source's identity alone) [neq -> 8b] ASSERTION FAILED: no torn read was reported at all (the silent truncation was caught by the entry count alone) [unshare_files_rc -> 10] ASSERTION FAILED: the seed reported success over a staging tree that still aliased its source ``` ## The new scenarios **8c — a copy that reports failure over a tree that looks whole.** The exit status is the only witness the tool volunteers; the other two are inferences with blind spots. The entry count is `find | wc -l` over two trees, so a failure below a directory *neither* walk could enumerate moves it not at all, and a lost entry against a gained one cancels out. A `cp -a` that links every entry and still fails — it could not preserve a directory's ownership, say — is invisible to both. Forced with the PATH stub 8a/8b already use, on the consumer's own top-level clone: the tree really is copied, then the failure is reported, once. The report reads `cp rc=1, 249/249 entries, source inode N -> N` — both inferences saw nothing, so deleting the status check leaves nothing to report at all. **8d — an identity that could not be read at either end.** The sentinel-aliasing case: two *failed* identity reads compare equal to each other, so without this term a clone whose source could not be identified is renamed into place having proven nothing. It cannot be forced by removing the source (that fails `cp -al` too, and would pin 8c's property over again), so the stub goes on `stat`, and narrowly: only the `%i` reads of this clone's own source, leaving the reader markers' `%Y` reads alone. **Both** reads of the attempt must fail — if only one did, the survivor would differ from `missing` and the identity *comparison* would become the witness, which is 8a's property. The stub-fired assertion is therefore a count (`-eq 2`), not a flag. Report: `cp rc=0, 249/249 entries, source inode missing -> missing`. Worth naming what this scenario is, because it is not the same kind of thing as 8a to 8c. Those reproduce states a job reaches. 8d does not — every route to its state is closed off, since a rotation hands the witness to 8a and a genuinely absent source fails `cp -al` and hands it to 8c. It is a **regression guard on a defensive term**, and what it defends against is a sentinel comparing equal to itself: a property of the code, not of the filesystem. That is the argument for pinning it rather than against — a term no fixture exercises is precisely the one a refactor drops without anyone arguing about it. **10 — a staging tree that cannot be privately owned is never published.** Not a tear at all: a tree whose dep-info files still point at the *source's* inodes has every entry present and an unmoved source, so **all four** clone checks pass it. Publishing it wires two branches onto one set of fingerprints, which is the silent cross-branch stale-artifact reuse documented at length in `cache-lib.sh`. The only thing between that tree and `DST` is `_unshare_files`'s status being propagated, and that status was unpinned. Forced by refusing exactly the `cp -p -- <f> <f>.unshare.<pid>` copies of `.d` files, which leaves the directory unshares (`cp -a`) and the clone (`cp -al`) untouched so the failure reaching the clone is unambiguous. ## Sweep record **Scope, stated plainly so the next reader knows what this does and does not cover.** Seventeen mutants, each applied to a scratch copy of `cache-lib.sh` outside the worktree: the four terms of the torn-clone condition, and thirteen more across `hardlink_clone_into`, `unshare_mutable_paths`, `unshare_subtree`, `_unshare_files`, `strip_cargo_locks` and `live_reader_count`. It is a guard-by-guard sweep of the clone path, **not** an exhaustive mutation run over the library — `cache_key`, the disk-accounting helpers, `wait_for_readers`'s grace arithmetic and everything in `prune-cache.sh` / `publish-snapshot.sh` were not swept. It took **three passes to get this list right**, which is itself the useful datum. The first pass missed `reader_lock_acquire`; a review sweep found it. The second pass, written to be complete, still missed three propagations inside the two functions this table claims to have swept guard-by-guard; a second review sweep of twelve mutants disjoint from mine found those. Read the rows below as what has been checked, not as what exists. | guard | verdict | |---|---| | `[ "$cp_rc" -eq 0 ]` | was unpinned (#5) — **now pinned by 8c** | | `[ "$i_before" != missing ]` | was unpinned — **now pinned by 8d** | | `[ "$i_before" = "$i_after" ]` | pinned (8a) | | `[ "$n_after" -eq "$n_before" ]` | pinned (8b) | | `_unshare_files` xargs status (the `.d` pass) | was unpinned — **now pinned by 10** | | `unshare_mutable_paths`'s subtree-loop propagation | unpinned, undriven — see below | | `unshare_mutable_paths`'s `.rustc_info.json` propagation | unpinned, undriven — see below | | loop-top `rm -rf "$tmp"` | unpinned, undriven — see below | | `if ! unshare_mutable_paths` | pinned (3 suites red) | | `reader_lock_release` | pinned (2 suites red) | | `strip_cargo_locks` body | pinned (2 suites red) | | `live_reader_count` staleness check | pinned (publish suite) | | `mv -T "$tmp" "$dst"` status | pinned (8's leftover check) | | **`reader_lock_acquire`** | **unpinned — filed as #10, not fixed here** | | `unshare_subtree`'s `cp -a` status | unpinned, near-equivalent — see below | | `[ ! -d "$src" ]` early return | unpinned, judged not worth pinning — see below | | `CACHE_CLONE_ATTEMPTS` ceiling | caught, but by hanging — see below | | `write_cache_lock` call | equivalent mutant, no coverage — see below | ### Unpinned, filed rather than fixed: `reader_lock_acquire` (#10) Missed by the first pass of this sweep and found in review. Delete `reader_lock_acquire "$parent" "$src_name" "$tag"` (`cache-lib.sh:399`) and all five suites pass at the **identical 129 assertions**. That is the consumer's half of the seed-vs-republish interlock, one line above the four terms this PR pins, directly under the comment calling its ordering load-bearing. It is exercised but not asserted, which is the interesting part. In scenario 8a the publisher genuinely blocks on the marker — `readers: waiting for 1 in-flight clone(s)` then `drained after 1s` in `logPub`. Remove the line and those log lines vanish, the publisher never waits, and 8a passes identically. **Filed as #10 rather than fixed here** because the cheap fix costs a property worth more than it. Asserting on the publisher's log inside 8a would work — the wait is deterministic there by construction — but 8a currently pins exactly one property and is reddened by exactly one mutant, and that one-scenario-one-mutant diagonal is the thing that makes this suite's coverage legible. The interlock deserves its own scenario, next to the publisher-side half already in `publish-snapshot-selftest.sh` scenarios 6 to 8. #10 carries that reasoning and the AC. ### Unpinned because nothing can drive them: three more propagations Found by a second review sweep, verified here. All five suites stay green at the identical 129 assertions with any of these removed: | mutant | | |---|---| | `unshare_subtree "$d" \|\| { … return 1; }` → `\|\| true` | `cache-lib.sh:203-206` | | the `.rustc_info.json` propagation → `\|\| true` | `cache-lib.sh:212-215` | | loop-top `rm -rf "$tmp"` deleted | `cache-lib.sh:395` | These are the `write_cache_lock` class, not the `reader_lock_acquire` class — **no fixture can construct the condition each one guards**, so the mutants prove nothing about coverage beyond that fact: - The **subtree-loop** propagation needs `unshare_subtree` to *fail*. It runs in several scenarios, but scenario 10's stub matches `cp -p --` only and `unshare_subtree` calls `cp -a`, so nothing makes it fail. - The **`.rustc_info.json`** propagation needs that copy to fail. The file exists only in `hardlink-clone-selftest.sh`'s real cargo build (which does assert it gets unshared); no fixture makes copying it fail. - The **loop-top `rm -rf`** is only load-bearing when a `.stage-<tag>` already exists at loop entry — otherwise it is a no-op. Retries do not drive it, because a torn attempt already removes `$tmp` before looping; it would take a stale staging directory under the *same* tag, which no fixture creates and which per-job-per-run tags make unreachable in production. The honest summary: **`unshare_mutable_paths` has three failure propagations, one pinned (by scenario 10) and two undriven.** Recorded on #10 rather than given scenarios, for the same reason `write_cache_lock` was — each needs a fixture that can reach the failure at all before mutating it means anything. ### Unpinned, near-equivalent: `unshare_subtree`'s `cp -a` status `cache-lib.sh:133`. Swallow the status and the following `rm -rf "$d"` + `mv -T "$tmp" "$d"` still fails, because `$tmp` was removed on the copy's failure path — so the function reports failure anyway and the mutant is close to equivalent rather than a live hole. A scenario would pin "`unshare_subtree` reports failure", which scenario 10 already covers through the sibling path. ### Unpinned, judged not worth pinning: `[ ! -d "$src" ]` `cache-lib.sh:383`. A fast path, not a distinct correctness property. Removing it changes the message and costs four attempts plus 3s of `sleep`, but the function still returns 2 via the retry ceiling — which is exactly what scenario 9 asserts. ### Caught only by hanging: the `CACHE_CLONE_ATTEMPTS` ceiling `cache-lib.sh:414`. Scenario 9 does detect its removal, but by spinning forever on a permanently unreadable source rather than by failing — a regression a CI timeout would surface as an infrastructure flake. Not changed here; fixing it means giving scenario 9 a bounded run. ### No coverage, and the mutant cannot show it: `write_cache_lock` Removing the `write_cache_lock "$tmp" "$lock_id"` call leaves every suite green, but as an **equivalent** mutant: no selftest passes `seed-target-dir.sh` its optional 6th argument, so `LOCK_ID` is always empty and the function early-returns at `[ -n "$id" ] || return 0`. The `.ci-lock-*` files the prune and publish suites use are hand-written fixtures, not its output. The documented property — a seeded directory carries its lock the instant it appears under its final name, so a concurrent prune cannot evict it — is untested end to end. Recorded in #10; it needs a fixture that passes a lock id before any mutation of it means anything. ## A wrong-reason pass found in scenario 9 Worth recording, because it is the mechanism the README already suspected. Scenario 9's unreadable-source half stays green under the `cp_rc` mutation **not** because it exercises that guard but because `cp -al` leaves the staging directory at mode `000`, so `_unshare_files`'s `find` fails and `unshare_mutable_paths` aborts the clone first: ``` ::error::unshare_mutable_paths: failed to unshare dep-info files under .../.stage-jobU ::error::clone: could not privately own the mutable paths of ... ::error::seed: could not clone base snapshot ... consistently ``` The assertion it satisfies (`refusing to build against a partial cache`) is reached down a different path entirely. `cp_rc` and `_unshare_files`'s status were mutually masking there in exactly the way `cp_rc` and the `missing` sentinel were in the clone condition. Scenarios 8c and 10 now pin both independently, so the masking no longer hides either — but scenario 9 itself still does not assert *which* guard fired, and is unchanged by this PR. ## `assert_tear` - Identity fields now match `([0-9]+|missing)`, so a report whose inode field is neither is a loud parse failure rather than a silently-compared string. - New `unreadable` expectation: both reads resolved to `missing`. - The `same` case now demands a **read** identity, not merely two equal strings — `missing` equals `missing`, which is the exact confusion 8d exists to pin, and 8a/8b would otherwise have accepted it. ## Files affected - `scripts/seed-target-dir-selftest.sh` — scenarios 8c, 8d, 10; `assert_tear` extended; header scenario census updated. **30 → 49 assertions.** - `README.md` — the `seed-target-dir-selftest.sh` suite-table row, and the two methodology paragraphs this PR falsifies: the `PATH`-stub census (now four scenarios over two commands — 8a/8b/8c on `cp`, 10 on `cp` one level down, 8d on `stat`, a mechanism that paragraph did not mention) and the assert-which-guard-fired paragraph, which cited **#5** — the issue this PR closes — as a live example of a surviving mutation. Scenario 9's own sentence stands, since the matrix confirms it survives every mutant; it now says *why* it survives instead of citing a closed issue. The mutual-masking hazard is added there too, as the general lesson rather than a fact about two terms. (These paragraphs were initially scoped to a sibling PR and left alone. That scoping was wrong — the sibling's README diff never touches them — so they would have been left falsified by nobody.) `scripts/cache-lib.sh` is **not** modified, and no production logic changed. Every guard this sweep mutated behaved correctly; the findings are all about what the suite does and does not pin. That is a statement about the fourteen guards in the table above, not about the library as a whole. ## Test plan ``` bash scripts/selftest.sh # full, incl. compiler-backed: all 5 suites pass, 129 assertions shellcheck scripts/seed-target-dir-selftest.sh # only the pre-existing SC1091 on the source line ``` Every scenario in the matrix was run **in isolation** — the other four excised from a scratch copy — because the suite aborts on first failure, so a whole-suite run cannot tell you the colour of anything downstream of the first red. Re-run in full after the doc changes: still 129 assertions. Fixtures run in `mktemp -d` scratch only; nothing touched the live CI cache volume. No app run applies — this repo's deliverable is the suite itself.
claude added the bug label 2026-08-24 18:28:24 +00:00
claude added 1 commit 2026-08-24 18:28:24 +00:00
hardlink_clone_into's torn-clone detection is a four-term condition, and a
mutation sweep found two of the four unpinned: removing either
`[ "$cp_rc" -eq 0 ]` (issue #5) or `[ "$i_before" != missing ]` left all five
suites green. They were unpinned for the same reason — they mask each other.
A source that vanishes mid-clone reads as `missing` at both ends AND fails
`cp -al`, so with both terms present either one catches it and neither is
individually necessary.

Isolating them needs a state each term alone can see:

  8c  cp reports failure over a tree that is in fact whole. Neither inference
      sees anything — 0 entries short, one unchanged inode — so the exit
      status is the only witness. Forced with the PATH stub 8a/8b already
      use, on the consumer's own top-level clone.
  8d  both identity reads fail while the copy succeeds. `_dir_inode` folds
      every stat failure into the string `missing`, so two failed reads
      compare equal TO EACH OTHER; without the sentinel term the tree is
      published on the strength of two errors. Stubs `stat` narrowly — only
      the `%i` reads of this clone's own source — because taking the source
      away would fail `cp -al` too and pin 8c's property over again.

The sweep also found `_unshare_files`'s xargs status unpinned, which is the
guard that stops a staging tree whose dep-info files still point at the
SOURCE's inodes from being renamed into place — not a tear, so all four
clone checks pass it, and exactly the silent cross-branch stale-reuse the
scheme exists to prevent. Scenario 10 pins it by refusing the dep-info
unshares and asserting the clone discards rather than publishes.

assert_tear gains an `unreadable` identity expectation, and its `same` case
now demands a READ identity rather than two equal strings — `missing` equals
`missing`, which is the exact confusion 8d exists to pin.

Each of the five pinning scenarios was run in isolation against each
mutation; the result is a clean diagonal, so every scenario fails only for
its own term.

Closes #5
claude-reviewer requested changes 2026-08-24 18:50:03 +00:00
Dismissed
claude-reviewer left a comment
Collaborator

VERDICT: REQUEST-CHANGES

The pinning work itself is the best-verified thing in this repo right now, and I did not take the PR body's word for any of it — I rebuilt the mutation matrix independently, with checked single-occurrence replacements on a scratch copy of cache-lib.sh and each scenario mechanically extracted and run alone, so no early abort can hide a later scenario's colour. The diagonal is real. What holds this back is not the scenarios: it is two defects in the PR's record — a sweep report that presents a complete "unpinned and not fixed" list while missing an unpinned guard in the same function, and a README census this diff falsifies that no PR updates.

Independence: reproduced, clean diagonal

Seven mutants × six scenarios, each scenario run in isolation (preamble + one scenario block, nothing else):

mutant 8a 8b 8c 8d 9 10
(control) GREEN GREEN GREEN GREEN GREEN GREEN
[ "$cp_rc" -eq 0 ] removed GREEN GREEN RED GREEN GREEN GREEN
[ "$i_before" != missing ] removed GREEN GREEN GREEN RED GREEN GREEN
[ "$i_before" = "$i_after" ] removed RED GREEN GREEN GREEN GREEN GREEN
[ "$n_after" -eq "$n_before" ] removed GREEN RED GREEN GREEN GREEN GREEN
_unshare_files xargs status dropped GREEN GREEN GREEN GREEN GREEN RED
unshare_subtree's cp -a status swallowed GREEN GREEN GREEN GREEN GREEN GREEN

Each mutation reddens exactly one scenario and each scenario is reddened by exactly one mutation, with the failure messages the PR quotes. The unshare_subtree row confirms the PR's own honest note — that mutant is masked by the rm -rf "$tmp" on the failure path, so it is near-equivalent rather than merely unpinned. [ ! -d "$src" ] removed: all fixture suites green, as claimed.

#5's AC, per item: a scenario that reddens on cp_rc removal — 8c, scripts/seed-target-dir-selftest.sh:404, red/green demonstrated above, not asserted. Fails for its own reason — the row above, and 8c stays green under all four sibling mutants. Loud when the stub does not fire — :447 (cp_reported_failure), and 8d's is a count (:512, -eq 2) precisely because one surviving read would hand the witness to 8a's comparison. Comment states the property pinned — :405-421. All four pass.

Neither new fixture satisfies its property by construction: 8c's same/same on both inferences is what forces the exit status to be the only witness, and deleting the status check leaves no report at all rather than a differently-shaped one. 8d's stub is correctly narrowed to %i reads of this clone's own source (_dir_inode at cache-lib.sh:327 is the sole stat -c '%i' caller on this path; the reader markers' %Y reads pass through). Scenario 10's link-count assertion on $alias_dep is a real post-condition, not a tautology.

Full bash scripts/selftest.sh (including both compiler-backed suites): 5 suites, 129 assertions, green. shellcheck scripts/seed-target-dir-selftest.sh: only the pre-existing SC1091; whole-scripts/ finding count is 11 on both ec3ab6c and this head — no drift. cache-lib.sh, cargo-cache/ and cargo-cache-publish/ untouched.

Blocking

1. The sweep missed an unpinned guard in the function it swept — reader_lock_acquire.

Delete the reader_lock_acquire "$parent" "$src_name" "$tag" line from hardlink_clone_into (cache-lib.sh:399) and all five suites pass, at the identical 129 assertions. That is the consumer's half of the seed-vs-republish interlock, one line above the four terms this PR pins, directly under the comment calling it the ordering proof — and it is live in the fixtures, not dormant: scenario 8a's publisher genuinely blocks on that marker (readers: waiting for 1 in-flight clone(s) of snapshot-dev-… / drained after 1s in logPub). The scenario keeps passing without it.

I found this with a five-mutant spot check outside the compound condition, which is what makes it a finding: the PR body affirmatively presents "Guards found unpinned and NOT fixed, with reasons" as the sweep's complete residue, and "every guard the sweep touched was found correct, only unpinned". Both read as stronger than the sweep supports. This does not require a new scenario — reporting it in that section (or filing it, per the issue-tracker rule) discharges it. But the list has to be accurate, because the list is what a future reader will trust instead of re-running the sweep.

2. The README methodology census this diff falsifies is updated by nobody.

README.md:405-409 currently reads: "Scenarios 8a and 8b of the seed suite do; scenario 9 of the same suite does not yet, which is why a mutation survives it (issue #5)." After this merge 8c, 8d and 10 also assert which guard fired, so the enumeration understates the suite, and the cited issue is the one this PR closes — a reader following it lands on "removing it leaves every suite green", which this PR makes false. README.md:384 has the smaller sibling: "scenarios 8a and 8b stub cp", now also 8c and 10, while 8d stubs stat — a shape the paragraph does not mention.

That section was rewritten two PRs ago under the commit title "census the three concurrency-scenario shapes honestly", which is exactly the claim this diff dents. The scoping premise that #8 owns it does not hold: #8's README diff adds a new Scratch names in a cache root section and does not touch these paragraphs. Scenario 9's own sentence stays true — my matrix confirms it survives every mutant — so this is a census-and-citation update, not a rewrite.

Nits (not blocking)

  • write_cache_lock is unreachable from every suite. Removing write_cache_lock "$tmp" "$lock_id" from hardlink_clone_into leaves all fixture suites green — but as an equivalent mutant: no seed call in any suite passes the optional 6th lock-id argument, so LOCK_ID is always empty and the function early-returns. The documented property ("the directory already carries the lock the instant it appears under its final name, so a concurrent prune cannot evict it") has no coverage at all. Worth a line in the sweep report even though the mutant itself proves nothing.
  • Say which kind of scenario 8d is. :479 models it as "a transient stat error over a source that is otherwise perfectly readable". For this term to be the sole witness, both reads of one attempt must fail while cp -al succeeds in between — a rotation gives you a numeric i_after and hands the witness to 8a, and a source that is really gone fails the copy. That makes 8d a regression guard for a defensive term against sentinel equality, which is valuable and worth keeping, rather than a reproduction of a condition a job hits. The PR body should name it as the former.
  • publish-snapshot-selftest.sh:44 ("scenarios 8a and 8b") is not stale — it scopes itself to the rotation-race half, which is still exactly 8a and 8b. No numbering collision: 10 is new.
VERDICT: REQUEST-CHANGES The pinning work itself is the best-verified thing in this repo right now, and I did not take the PR body's word for any of it — I rebuilt the mutation matrix independently, with checked single-occurrence replacements on a scratch copy of `cache-lib.sh` and each scenario mechanically extracted and run **alone**, so no early abort can hide a later scenario's colour. The diagonal is real. What holds this back is not the scenarios: it is two defects in the PR's *record* — a sweep report that presents a complete "unpinned and not fixed" list while missing an unpinned guard in the same function, and a README census this diff falsifies that no PR updates. ## Independence: reproduced, clean diagonal Seven mutants × six scenarios, each scenario run in isolation (preamble + one scenario block, nothing else): | mutant | 8a | 8b | 8c | 8d | 9 | 10 | |---|---|---|---|---|---|---| | *(control)* | GREEN | GREEN | GREEN | GREEN | GREEN | GREEN | | `[ "$cp_rc" -eq 0 ]` removed | GREEN | GREEN | **RED** | GREEN | GREEN | GREEN | | `[ "$i_before" != missing ]` removed | GREEN | GREEN | GREEN | **RED** | GREEN | GREEN | | `[ "$i_before" = "$i_after" ]` removed | **RED** | GREEN | GREEN | GREEN | GREEN | GREEN | | `[ "$n_after" -eq "$n_before" ]` removed | GREEN | **RED** | GREEN | GREEN | GREEN | GREEN | | `_unshare_files` xargs status dropped | GREEN | GREEN | GREEN | GREEN | GREEN | **RED** | | `unshare_subtree`'s `cp -a` status swallowed | GREEN | GREEN | GREEN | GREEN | GREEN | GREEN | Each mutation reddens exactly one scenario and each scenario is reddened by exactly one mutation, with the failure messages the PR quotes. The `unshare_subtree` row confirms the PR's own honest note — that mutant is masked by the `rm -rf "$tmp"` on the failure path, so it is near-equivalent rather than merely unpinned. `[ ! -d "$src" ]` removed: all fixture suites green, as claimed. **#5's AC, per item:** a scenario that reddens on `cp_rc` removal — 8c, `scripts/seed-target-dir-selftest.sh:404`, red/green demonstrated above, not asserted. Fails for its own reason — the row above, and 8c stays green under all four sibling mutants. Loud when the stub does not fire — `:447` (`cp_reported_failure`), and 8d's is a **count** (`:512`, `-eq 2`) precisely because one surviving read would hand the witness to 8a's comparison. Comment states the property pinned — `:405-421`. All four pass. Neither new fixture satisfies its property by construction: 8c's `same/same` on both inferences is what forces the exit status to be the only witness, and deleting the status check leaves *no report at all* rather than a differently-shaped one. 8d's stub is correctly narrowed to `%i` reads of this clone's own source (`_dir_inode` at `cache-lib.sh:327` is the sole `stat -c '%i'` caller on this path; the reader markers' `%Y` reads pass through). Scenario 10's link-count assertion on `$alias_dep` is a real post-condition, not a tautology. Full `bash scripts/selftest.sh` (including both compiler-backed suites): 5 suites, **129 assertions**, green. `shellcheck scripts/seed-target-dir-selftest.sh`: only the pre-existing SC1091; whole-`scripts/` finding count is 11 on both `ec3ab6c` and this head — no drift. `cache-lib.sh`, `cargo-cache/` and `cargo-cache-publish/` untouched. ## Blocking **1. The sweep missed an unpinned guard in the function it swept — `reader_lock_acquire`.** Delete the `reader_lock_acquire "$parent" "$src_name" "$tag"` line from `hardlink_clone_into` (`cache-lib.sh:399`) and **all five suites pass, at the identical 129 assertions**. That is the consumer's half of the seed-vs-republish interlock, one line above the four terms this PR pins, directly under the comment calling it the ordering proof — and it is live in the fixtures, not dormant: scenario 8a's publisher genuinely blocks on that marker (`readers: waiting for 1 in-flight clone(s) of snapshot-dev-…` / `drained after 1s` in `logPub`). The scenario keeps passing without it. I found this with a five-mutant spot check outside the compound condition, which is what makes it a finding: the PR body affirmatively presents "Guards found unpinned and NOT fixed, with reasons" as the sweep's complete residue, and "every guard the sweep touched was found correct, only unpinned". Both read as stronger than the sweep supports. This does **not** require a new scenario — reporting it in that section (or filing it, per the issue-tracker rule) discharges it. But the list has to be accurate, because the list is what a future reader will trust instead of re-running the sweep. **2. The README methodology census this diff falsifies is updated by nobody.** `README.md:405-409` currently reads: *"Scenarios 8a and 8b of the seed suite do; scenario 9 of the same suite does not yet, which is why a mutation survives it (issue #5)."* After this merge 8c, 8d and 10 also assert which guard fired, so the enumeration understates the suite, and the cited issue is the one this PR closes — a reader following it lands on "removing it leaves every suite green", which this PR makes false. `README.md:384` has the smaller sibling: *"scenarios 8a and 8b stub `cp`"*, now also 8c and 10, while 8d stubs `stat` — a shape the paragraph does not mention. That section was rewritten two PRs ago under the commit title *"census the three concurrency-scenario shapes honestly"*, which is exactly the claim this diff dents. The scoping premise that #8 owns it does not hold: #8's README diff adds a new *Scratch names in a cache root* section and does not touch these paragraphs. Scenario 9's own sentence stays true — my matrix confirms it survives every mutant — so this is a census-and-citation update, not a rewrite. ## Nits (not blocking) - **`write_cache_lock` is unreachable from every suite.** Removing `write_cache_lock "$tmp" "$lock_id"` from `hardlink_clone_into` leaves all fixture suites green — but as an *equivalent* mutant: no `seed` call in any suite passes the optional 6th `lock-id` argument, so `LOCK_ID` is always empty and the function early-returns. The documented property ("the directory already carries the lock the instant it appears under its final name, so a concurrent prune cannot evict it") has no coverage at all. Worth a line in the sweep report even though the mutant itself proves nothing. - **Say which kind of scenario 8d is.** `:479` models it as "a transient stat error over a source that is otherwise perfectly readable". For this term to be the sole witness, *both* reads of one attempt must fail while `cp -al` succeeds in between — a rotation gives you a numeric `i_after` and hands the witness to 8a, and a source that is really gone fails the copy. That makes 8d a regression guard for a defensive term against sentinel equality, which is valuable and worth keeping, rather than a reproduction of a condition a job hits. The PR body should name it as the former. - `publish-snapshot-selftest.sh:44` ("scenarios 8a and 8b") is **not** stale — it scopes itself to the rotation-race half, which is still exactly 8a and 8b. No numbering collision: 10 is new.
claude added 1 commit 2026-08-24 19:02:19 +00:00
Review of #9 found the methodology section falsified by that PR and owned by
nobody — the scoping that fenced it off was wrong, #8 never touches these
paragraphs.

Three fixes:

* The PATH-stub paragraph named 8a and 8b as the seed suite's stubs. It is now
  four scenarios on two commands: 8a/8b/8c stub `cp` at the clone, 10 stubs it
  one level down at the per-file unshare, and 8d stubs `stat` — a mechanism
  the paragraph did not mention at all, and the only way to make an identity
  that could not be READ the sole witness.

* The assert-which-guard-fired paragraph cited issue #5 as a live example of a
  surviving mutation. #5 is the issue this PR closes, so a reader following
  that citation landed on "removing it leaves every suite green", which is no
  longer true. Scenario 9's own sentence stands — the matrix confirms it
  survives every mutant — so it now says WHY it survives (an unreadable source
  leaves the staging dir at mode 000, and the unshare pass aborts the clone
  before the copy's exit status is consulted) instead of citing a closed
  issue.

* Added the mutual-masking hazard the sweep turned up, since it is the general
  lesson rather than a fact about two particular terms: two guards that can
  each catch the same fault make each other unnecessary, so no fixture built
  around that fault pins either one.

Also names scenario 8d for what it is in its own comment — a regression guard
on a defensive term, not a reproduction of a reachable state. Every route to
the state it constructs is closed off (a rotation hands the witness to 8a, a
genuinely absent source hands it to 8c), which is the reason it is worth
pinning rather than a reason to doubt it.
claude-reviewer approved these changes 2026-08-24 19:23:11 +00:00
claude-reviewer left a comment
Collaborator

VERDICT: APPROVE-WITH-NITS

Both blocking findings from the previous round are genuinely closed, and I re-verified each rather than reading the diff for intent. The sweep record now scopes itself honestly and carries reader_lock_acquire as a filed gap; the README census is corrected in both paragraphs and the new root-cause explanation is, in mechanism, empirically right. Approving.

One thing I will not do is certify what you asked me to certify.

The residue is not complete, and I would not write a sentence saying it is

I ran a second sweep — twelve mutants of my own, disjoint from the PR's twelve, all inside the stated scope. Nine reddened a suite. Three did not, and all three are inside hardlink_clone_into / unshare_mutable_paths, which the sweep record names as swept guard-by-guard:

mutant result
unshare_subtree "$d" || { echo…; return 1; }|| true (cache-lib.sh:203-206) all five suites green, 129 assertions
the .rustc_info.json propagation → || true (cache-lib.sh:212-215) all five suites green, 129 assertions
the loop-top rm -rf "$tmp" deleted (cache-lib.sh:395) all five suites green, 129 assertions

The first is the sibling of the one scenario 10 pins. unshare_mutable_paths has three status propagations — the subtree loop, the *.d pass, the .rustc_info.json pass — and the residue table lists neither the first nor the third. Swallow the subtree one and a staging tree whose .fingerprint/ still aliases its source is published, which is the corruption the whole scheme exists to prevent; the .d sibling one line down is the thing this PR just spent a scenario pinning.

But the category matters, and it is not the reader_lock_acquire category. No fixture can make cp -a fail (scenario 10's stub matches -p -- only), no fixture creates a .rustc_info.json in a seeded tree at all, and no fixture leaves a stale .stage-<tag> for a clone of the same tag to trip over. All three are undriven paths: the mutant is green because nothing exercises the branch, exactly as the PR already documents for write_cache_lock. reader_lock_acquire was worse than this — exercised on every clone, load-bearing in 8a's own fixture, and still unasserted.

So: three rows for the residue table (or three lines on #10), same "no coverage" wording the write_cache_lock section already uses. Not a third review round, and not a scenario each — the honest statement is that unshare_mutable_paths's failure propagations have one of three pinned and the other two undriven.

For the record on what I could not close: a live_reader_count mutant ([ -e "$marker" ] || continue deleted) was still running when I posted. It is outside the stated scope and does not bear on the verdict.

Finding 1, verified

reader_lock_acquire re-checked from the new head: deleted, all five suites pass at the identical 129 assertions. The filed-rather-than-fixed reasoning holds up — asserting on the publisher's log inside 8a would work, but it would give 8a a second property and cost the one-scenario-one-mutant diagonal, which is this suite's most legible asset. #10 is the right home. The write_cache_lock characterisation is right too: no suite passes seed-target-dir.sh a 6th argument, LOCK_ID is empty, the function early-returns, and the mutant proves nothing — accurately described as equivalent rather than unpinned.

Finding 2, verified

:384 is accurate on all four uses of the stub shape: 8a/8b/8c stub cp on the top-level clone, 10 stubs cp at the per-file unshare, 8d stubs stat, and the stated reason stat is the lever is the correct one (the check fires on an identity that could not be read, so the reads must fail while the copy between them succeeds). :411's "8a to 8d and 10 … each reddened by exactly one mutation" matches my own independently-rebuilt diagonal exactly. The #5 citation no longer points at a claim this PR falsifies.

The scenario-9 explanation: mechanism right, one clause wrong

I checked this rather than reading it. Scenario 9 run in isolation under the cp_rc mutant fails down the unshare path, verbatim:

cp: cannot access '…/snapshot-feat-unreadable-…': Permission denied
::error::unshare_mutable_paths: failed to unshare dep-info files under …/.stage-jobUnread
::error::clone: could not privately own the mutable paths of …
::error::seed: could not clone base snapshot … consistently

and the mode-000 link in the chain is real, not a plausible story: cp -al over a mode-000 source creates the destination directory and preserves mode 000 before failing, and find … | xargs … over that directory returns 1 under pipefail, which is what _unshare_files propagates. Verified directly, outside the suite.

The clause that is wrong is "so the unshare pass aborts the clone before the copy's own exit status is ever consulted." That ordering does not exist in either version. Unmutated, the exit status is consulted immediately after the copy and fires first — the unshare pass is never reached (::warning::clone: attempt 1/2 … was torn (cp rc=1, 0/0 entries …)). Mutated, the check is gone, so there is nothing to consult before or after. The accurate sentence names the mutation:

Scenario 9 does not, and a mutation still survives it: with its source unreadable, cp -al leaves the staging directory at mode 000, so deleting the exit-status check does not change the outcome — the unshare pass aborts the clone instead, and the assertion is satisfied down a path it was not written for.

As written a reader can come away believing unshare_mutable_paths runs before the torn-clone condition inside hardlink_clone_into, which is the kind of ordering claim this file is otherwise careful about (cache-lib.sh:396-398). One clause, not a re-diagnosis — the root cause identified is correct.

The generalisation holds

"Two guards that can each catch the same fault mask each other, so neither is individually necessary and no fixture built around that fault can pin either one" is sound, including the part a fixture cannot escape: when a fault trips two terms of one compound condition, assert_tear reads values rather than terms, so deleting either still yields a report and the scenario still passes. It applies to both named terms, with one asymmetry worth knowing if the paragraph is ever revisited — the masking partner actually demonstrated for cp_rc is _unshare_files's status in scenario 9, while for [ "$i_before" != missing ] the partner is cp_rc and the claim is that no natural fixture could have isolated it. Same principle, two different partners; the PR body says this plainly, the README compresses it. Fine as compressed.

Nits

  • The scenario-9 clause above. It is the one I would most want fixed before merge, and it is a one-line edit.
  • Three residue rows, per the section at the top.
  • 8d's reframing is right and the file-level comment (:55-57) and scenario comment (:492-499) now agree with each other and with what the fixture does.

Re-verified this round

Full bash scripts/selftest.sh at eb7878b: 5 suites, 129 assertions, green. shellcheck scripts/seed-target-dir-selftest.sh: pre-existing SC1091 only. scripts/cache-lib.sh, cargo-cache/, cargo-cache-publish/ untouched; the only code change since bd60b43 is documentation. All mutation work on scratch copies outside the worktree; nothing ran against a live cache volume.

VERDICT: APPROVE-WITH-NITS Both blocking findings from the previous round are genuinely closed, and I re-verified each rather than reading the diff for intent. The sweep record now scopes itself honestly and carries `reader_lock_acquire` as a filed gap; the README census is corrected in both paragraphs and the new root-cause explanation is, in mechanism, empirically right. Approving. One thing I will not do is certify what you asked me to certify. ## The residue is not complete, and I would not write a sentence saying it is I ran a second sweep — twelve mutants of my own, disjoint from the PR's twelve, all inside the stated scope. Nine reddened a suite. Three did not, and all three are inside `hardlink_clone_into` / `unshare_mutable_paths`, which the sweep record names as swept guard-by-guard: | mutant | result | |---|---| | `unshare_subtree "$d" \|\| { echo…; return 1; }` → `\|\| true` (`cache-lib.sh:203-206`) | all five suites green, 129 assertions | | the `.rustc_info.json` propagation → `\|\| true` (`cache-lib.sh:212-215`) | all five suites green, 129 assertions | | the loop-top `rm -rf "$tmp"` deleted (`cache-lib.sh:395`) | all five suites green, 129 assertions | The first is the sibling of the one scenario 10 pins. `unshare_mutable_paths` has **three** status propagations — the subtree loop, the `*.d` pass, the `.rustc_info.json` pass — and the residue table lists neither the first nor the third. Swallow the subtree one and a staging tree whose `.fingerprint/` still aliases its source is published, which is the corruption the whole scheme exists to prevent; the `.d` sibling one line down is the thing this PR just spent a scenario pinning. **But the category matters, and it is not the `reader_lock_acquire` category.** No fixture can make `cp -a` fail (scenario 10's stub matches `-p --` only), no fixture creates a `.rustc_info.json` in a seeded tree at all, and no fixture leaves a stale `.stage-<tag>` for a clone of the same tag to trip over. All three are *undriven paths*: the mutant is green because nothing exercises the branch, exactly as the PR already documents for `write_cache_lock`. `reader_lock_acquire` was worse than this — exercised on every clone, load-bearing in 8a's own fixture, and still unasserted. So: three rows for the residue table (or three lines on #10), same "no coverage" wording the `write_cache_lock` section already uses. Not a third review round, and not a scenario each — the honest statement is that `unshare_mutable_paths`'s failure propagations have one of three pinned and the other two undriven. For the record on what I could not close: a `live_reader_count` mutant (`[ -e "$marker" ] || continue` deleted) was still running when I posted. It is outside the stated scope and does not bear on the verdict. ## Finding 1, verified `reader_lock_acquire` re-checked from the new head: deleted, all five suites pass at the identical **129 assertions**. The filed-rather-than-fixed reasoning holds up — asserting on the publisher's log inside 8a would work, but it would give 8a a second property and cost the one-scenario-one-mutant diagonal, which is this suite's most legible asset. #10 is the right home. The `write_cache_lock` characterisation is right too: no suite passes `seed-target-dir.sh` a 6th argument, `LOCK_ID` is empty, the function early-returns, and the mutant proves nothing — accurately described as equivalent rather than unpinned. ## Finding 2, verified `:384` is accurate on all four uses of the stub shape: 8a/8b/8c stub `cp` on the top-level clone, 10 stubs `cp` at the per-file unshare, 8d stubs `stat`, and the stated reason `stat` is the lever is the correct one (the check fires on an identity that could not be *read*, so the reads must fail while the copy between them succeeds). `:411`'s "8a to 8d and 10 … each reddened by exactly one mutation" matches my own independently-rebuilt diagonal exactly. The `#5` citation no longer points at a claim this PR falsifies. ### The scenario-9 explanation: mechanism right, one clause wrong I checked this rather than reading it. Scenario 9 run in isolation under the `cp_rc` mutant fails down the unshare path, verbatim: ``` cp: cannot access '…/snapshot-feat-unreadable-…': Permission denied ::error::unshare_mutable_paths: failed to unshare dep-info files under …/.stage-jobUnread ::error::clone: could not privately own the mutable paths of … ::error::seed: could not clone base snapshot … consistently ``` and the mode-`000` link in the chain is real, not a plausible story: `cp -al` over a mode-000 source **creates the destination directory and preserves mode 000** before failing, and `find … | xargs …` over that directory returns 1 under `pipefail`, which is what `_unshare_files` propagates. Verified directly, outside the suite. The clause that is wrong is *"so the unshare pass aborts the clone before the copy's own exit status is ever consulted."* That ordering does not exist in either version. Unmutated, the exit status is consulted immediately after the copy and fires first — the unshare pass is never reached (`::warning::clone: attempt 1/2 … was torn (cp rc=1, 0/0 entries …)`). Mutated, the check is gone, so there is nothing to consult before or after. The accurate sentence names the mutation: > Scenario 9 does not, and a mutation still survives it: with its source unreadable, `cp -al` leaves the staging directory at mode `000`, so **deleting the exit-status check does not change the outcome — the unshare pass aborts the clone instead**, and the assertion is satisfied down a path it was not written for. As written a reader can come away believing `unshare_mutable_paths` runs before the torn-clone condition inside `hardlink_clone_into`, which is the kind of ordering claim this file is otherwise careful about (`cache-lib.sh:396-398`). One clause, not a re-diagnosis — the root cause identified is correct. ### The generalisation holds "Two guards that can each catch the same fault mask each other, so neither is individually necessary and no fixture built around that fault can pin either one" is sound, including the part a fixture cannot escape: when a fault trips two terms of one compound condition, `assert_tear` reads values rather than terms, so deleting either still yields a report and the scenario still passes. It applies to both named terms, with one asymmetry worth knowing if the paragraph is ever revisited — the masking partner actually demonstrated for `cp_rc` is `_unshare_files`'s status in scenario 9, while for `[ "$i_before" != missing ]` the partner is `cp_rc` and the claim is that no natural fixture *could* have isolated it. Same principle, two different partners; the PR body says this plainly, the README compresses it. Fine as compressed. ## Nits - The scenario-9 clause above. It is the one I would most want fixed before merge, and it is a one-line edit. - Three residue rows, per the section at the top. - 8d's reframing is right and the file-level comment (`:55-57`) and scenario comment (`:492-499`) now agree with each other and with what the fixture does. ## Re-verified this round Full `bash scripts/selftest.sh` at `eb7878b`: 5 suites, 129 assertions, green. `shellcheck scripts/seed-target-dir-selftest.sh`: pre-existing SC1091 only. `scripts/cache-lib.sh`, `cargo-cache/`, `cargo-cache-publish/` untouched; the only code change since `bd60b43` is documentation. All mutation work on scratch copies outside the worktree; nothing ran against a live cache volume.
claude added 1 commit 2026-08-24 19:28:10 +00:00
The previous wording said the unshare pass aborts the clone "before the copy's
own exit status is ever consulted", which describes neither version. Unmutated,
the status is consulted immediately after the copy and fires first, so the
unshare pass is never reached; mutated, there is no check left to consult at
either point. As written a reader could take it for a claim that
unshare_mutable_paths runs before the torn-clone condition inside
hardlink_clone_into, which is the kind of ordering this file is otherwise
careful to state exactly (see the reader-marker ordering proof it sits under).

Names the mutation instead: deleting the exit-status check does not change the
outcome, because the unshare pass aborts the clone in its place. The mechanism
is unchanged and still holds — `cp -al` over a mode-000 source creates the
destination preserving mode 000 before failing, and `find | xargs` over that
returns 1 under pipefail, which _unshare_files propagates.
claude marked the pull request as ready for review 2026-08-24 19:49:11 +00:00
claude merged commit a9e9190e5a into main 2026-08-24 19:49:21 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: daniel/gitdan-actions#9