diff --git a/README.md b/README.md index 68786fe..132b226 100644 --- a/README.md +++ b/README.md @@ -361,17 +361,35 @@ bash scripts/selftest.sh --fast # fixture-only suites, no compiler | suite | covers | |---|---| | `hardlink-clone-selftest.sh` | that a build in a clone cannot mutate its source — with a control proving a raw `cp -al` does. Needs a real compiler. | -| `seed-target-dir-selftest.sh` | seed-source preference, lock-file stripping, two jobs racing on one cache key, **and a seed racing a publisher's rotation of the source it is reading** — the race that actually truncates a tree | +| `seed-target-dir-selftest.sh` | seed-source preference, lock-file stripping, two jobs racing on one cache key, **and a seed whose source is rotated — or silently loses a subtree — underneath its clone**: the two ways a hardlink clone tears | | `publish-snapshot-selftest.sh` | the atomic swap, that a live consumer survives a republish, and the publisher's side of the rotation race: deferred reclamation under a live reader, and its sweep once the reader is gone | | `prune-cache-selftest.sh` | liveness, protection, locking, eviction order, self-clear, **and that a cache a job claims *inside* the check-to-unlink window survives it** — against a real scratch `origin` | | `restore-mtimes-selftest.sh` | the merge hazard and the watermark that closes it, including the two-jobs-one-namespace case. Needs a real compiler. | Every suite runs the actual script, not a reimplementation of its logic, and every fix scenario is paired with a control that reproduces the bug — a -scenario that passes either way proves nothing. The concurrency scenarios race -real processes rather than mocking the interleaving, and gate the interfering -step on *observed* progress of the step it interferes with, so the window is -hit deterministically instead of on a fast machine's coin flip. +scenario that passes either way proves nothing. + +The concurrency scenarios run the real scripts as real concurrent processes, +but they never race for the interleaving. The interfering step is placed +inside the window by stubbing, on `PATH`, a command the code under test calls +at a known point: `prune-cache-selftest.sh` scenario 12 stubs `du`, so the +pass's own measurement publishes a reader marker strictly between its check +and its unlink; `seed-target-dir-selftest.sh` scenarios 8a and 8b stub `cp`, +so the consumer's own clone is what rotates the snapshot underneath it, or +what loses a subtree of its own source, strictly inside the identity window. +Placement is then a fact rather than a scheduling outcome — and each stub +asserts that it fired, because a scenario whose interference silently did not +happen passes for the wrong reason. + +Gating the interfering step on *observed progress* of the step it interferes +with was the earlier answer here, and it is not one: seeing that a walk has +started says nothing about where it will be when the interference lands, so +the assertion downstream held only some of the time (issue #3). + +Where more than one guard could catch a fault, the scenario asserts *which* +one did — otherwise deleting the guard under test leaves the suite green +because a sibling fires in its place. The action YAML holds no logic beyond wiring; everything testable lives in `scripts/`. A composite action needs `shell: bash` on every `run:` step, and diff --git a/scripts/publish-snapshot-selftest.sh b/scripts/publish-snapshot-selftest.sh index ef726e5..36d2860 100755 --- a/scripts/publish-snapshot-selftest.sh +++ b/scripts/publish-snapshot-selftest.sh @@ -41,7 +41,7 @@ # that marker must still defer. # # The consumer's half of the same race — a seed catching a rotation mid-clone -# — is in seed-target-dir-selftest.sh scenario 8. +# — is in seed-target-dir-selftest.sh scenarios 8a and 8b. set -euo pipefail script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) . "$script_dir/cache-lib.sh" diff --git a/scripts/seed-target-dir-selftest.sh b/scripts/seed-target-dir-selftest.sh index 5997aec..be3c313 100755 --- a/scripts/seed-target-dir-selftest.sh +++ b/scripts/seed-target-dir-selftest.sh @@ -85,9 +85,13 @@ make_tree() { seed() { bash "$script_dir/seed-target-dir.sh" "$@" > "$scratch/log" 2>&1 || { tail -40 "$scratch/log"; fail "seed-target-dir.sh exited non-zero"; }; } -# The same script, with the scenario-8 stub directory ahead of the real -# coreutils on PATH. Kept separate so no other scenario can pick a stub up by -# accident, and so the caller keeps the exit status instead of aborting on it. +# The same script, with the stub directory scenarios 8a and 8b write into +# ahead of the real coreutils on PATH. Kept separate so no other scenario can +# pick a stub up by accident, and so the caller keeps the exit status instead +# of aborting on it. Both halves live here rather than in the first scenario +# that needs them, so either scenario can be run, moved or mutated alone. +mkdir -p "$scratch/bin" +real_cp=$(command -v cp) seed_with_stub() { PATH="$scratch/bin:$PATH" bash "$script_dir/seed-target-dir.sh" "$@"; } # Always succeeds and always prints a number: a directory that does not exist @@ -256,8 +260,6 @@ gen1_inode=$(stat -c '%i' "$root/snapshot-$BASE_KEY") [ "$gen1_entries" -ne "$(tree_entries "$root/target-$BASE_KEY")" ] \ || fail "both generations have ${gen1_entries} entries — this fixture cannot tell them apart" -mkdir -p "$scratch/bin" -real_cp=$(command -v cp) cat > "$scratch/bin/cp" <