fix(cargo-cache): scope the safety claim to what the code actually prevents

Follow-up to f57e2a6, addressing the reviewer's sharpest question: is the race
"closed by construction", or merely detected and retried? The honest answer is
"both, on different paths", and the README said only the first half.

* README now states three claims separately instead of collapsing them:
  a publisher rotating a snapshot cannot tear a clone of it (by construction —
  the marker ordering prevents the unlink, and the consumer's verification is
  a redundant second check on that path); every OTHER way the source can
  change mid-clone is detected, not prevented (the eviction pass's reader
  check is check-then-delete, and a seed-fallback-dir has no interlock at all
  — there, verification plus a bounded retry and a loud failure is the whole
  guard); and disk reclamation is bounded rather than immediate. Overclaiming
  this property once was the finding; overclaiming it twice would be worse.

* publish-snapshot-selftest.sh now covers the PUBLISHER's half of the race,
  where a reader of the swap belongs. Scenario 3 only ever covered a consumer
  that had already FINISHED cloning — safe for free, since its own hardlinks
  keep the inodes alive. New scenario 6 covers a reader still in flight past
  the grace period (the generation is left on disk, the deferral is warned
  about, and an earlier consumer is still unaffected); scenario 7 covers the
  sweep, so "we defer instead of forcing" cannot quietly become a disk leak.
  Red-proven against 248af306's scripts:

    ASSERTION FAILED: the previous generation was unlinked while a reader
    still held it

  The consumer's half stays in seed-target-dir-selftest.sh scenario 8, which
  still red-proves at 16693 of 48805 entries against the same scripts.

* seed-target-dir-selftest.sh now asserts what happens when the retries are
  EXHAUSTED, not just what hardlink_clone_into returns: an unreadable source
  makes the seed script exit non-zero, name the reason, leave no target dir,
  and — the one that matters — not fall through to its cold-start branch. A
  corrupt-cache bug degrading into an invisible 4x-slower CI job is the
  failure mode worth pinning down. Skipped when running as root, where mode
  bits deny nothing.

* usage_kb: a directory we cannot read measured as the empty string, which was
  then spliced into usage_gb's awk program and made it a syntax error at the
  exact moment something was already going wrong. Now measures 0.

Verification: `bash scripts/selftest.sh` — 5 suites, exit 0, 82 assertions
(was 75 after f57e2a6, 63 before). shellcheck over scripts/: no new findings.

Measured the cost the reviewer asked about, on ext4, warm cache, 78,554
entries: `cp -al` 3126 ms against 44 ms for one `find | wc -l`. Two counts per
attempt is ~2.8% on top of the clone. Not measured on the CI runner's volume.

Refs: daniel/gitdan#11, zemyna#911

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sqh2vscfzisk83VuPVQX9L
This commit is contained in:
2026-08-23 16:49:44 -05:00
co-authored by Claude Opus 5
parent f57e2a6013
commit 719475831b
4 changed files with 123 additions and 46 deletions
+51
View File
@@ -19,6 +19,21 @@
# lock is still held while this runs, and must not be baked into the
# snapshot: a lock timestamped at this run's start would look fresh to
# the prune pass on every branch later seeded from it.
# 6. DEFERRED RECLAMATION — the publisher's half of the seed-vs-rotation
# race. Scenario 3 above covers a consumer that has ALREADY FINISHED
# cloning; that one is safe for free, because its own hardlinks keep the
# inodes alive. A consumer still WALKING the old generation is the case
# that actually tears, and unlinking underneath it is what produced a
# silently truncated clone. So when a reader is still in flight past the
# grace period, the swap leaves the rotated-away generation on disk
# instead of unlinking it.
# 7. AND THE SWEEP — a deferred generation is not leaked: the next publish
# of that snapshot reclaims it once no reader holds it. Without this the
# "we defer instead of forcing" answer would just be a disk leak with
# better manners.
#
# The consumer's half of the same race — a seed catching a rotation mid-clone
# — is in seed-target-dir-selftest.sh scenario 8.
set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
. "$script_dir/cache-lib.sh"
@@ -109,5 +124,41 @@ ok "no scratch directories left behind"
|| fail "the consumer's clone changed under it when the snapshot was replaced"
ok "the live consumer still reads its own consistent generation-1 copy"
echo
echo "=== 6: a reader still in flight defers reclamation ==="
# A synthetic reader marker stands in for a consumer whose clone outlasts the
# grace period. Racing a real slow consumer would make the suite's runtime the
# thing under test; the marker IS the entire contract between the two sides,
# so holding one is being a reader.
SNAP_NAME=$(basename "$SNAP")
date +%s > "$root/.reading-${SNAP_NAME}-slowpoke"
replace_file "$TGT/debug/deps/libx.rlib" gen3
CACHE_READ_GRACE_SECONDS=1 bash "$script_dir/publish-snapshot.sh" "$KEY" "$root" jobDefer \
> "$scratch/log" 2>&1 || { cat "$scratch/log"; fail "publish-snapshot.sh exited non-zero"; }
[ "$(cat "$SNAP/debug/deps/libx.rlib")" = "gen3" ] || fail "the new generation was not published"
ok "the new generation is published even while a reader holds the old one"
deferred=$(find "$root" -maxdepth 1 -name ".publish-old-${KEY}-*" -print -quit)
[ -n "$deferred" ] || fail "the previous generation was unlinked while a reader still held it"
ok "the rotated-away generation is left on disk rather than unlinked under a reader"
grep -q 'deferring reclamation' "$scratch/log" || fail "the deferral was not reported"
ok "the deferral is surfaced as a warning, not silent"
[ "$(cat "$CONSUMER/debug/deps/libx.rlib")" = "gen1" ] \
|| fail "the earlier consumer's clone changed under it"
ok "the generation-1 consumer is still unaffected"
echo
echo "=== 7: a later publish sweeps the deferred generation ==="
rm -f "$root/.reading-${SNAP_NAME}-slowpoke"
replace_file "$TGT/debug/deps/libx.rlib" gen4
publish jobSweep
[ "$(cat "$SNAP/debug/deps/libx.rlib")" = "gen4" ] || fail "the fourth generation was not published"
ok "publishing continues normally after a deferral"
[ -z "$(find "$root" -maxdepth 1 -name '.publish-old-*' -print -quit)" ] \
|| fail "the deferred generation was never reclaimed — this is a disk leak"
ok "the deferred generation is reclaimed once no reader holds it"
[ -z "$(find "$root" -maxdepth 1 \( -name '.stage-*' -o -name '.reading-*' \) -print -quit)" ] \
|| fail "scratch left behind: $(find "$root" -maxdepth 1 \( -name '.stage-*' -o -name '.reading-*' \) -print)"
ok "no staging or reader-marker scratch left behind"
echo
echo "publish-snapshot-selftest: ${pass_count} assertions passed"