Files
gitdan-actions/scripts/seed-target-dir-selftest.sh
T
claude aee337a1cb docs(readme): catalogue scenario 11 and close its scratch self-check
The suite-coverage table and the concurrency-scenario prose enumerate
every scenario by number and by the property it pins, so adding one
without listing it leaves the catalogue claiming complete coverage it
no longer has. PR #9 updated the same table for the same reason when it
added 8b/8c/8d/10; this follows it.

Also adds the closing no-scratch-left-behind check that 8a and ALIAS
end with. Scenario 11 is last in the file, so nothing downstream would
have caught a leaked staging path or reader marker.
2026-08-24 17:57:27 -05:00

730 lines
40 KiB
Bash
Executable File

#!/usr/bin/env bash
# Regression test for seed-target-dir.sh: which source a run seeds from, and
# what happens when two jobs sharing one cache key seed at the same time.
#
# Runs the ACTUAL script against a real scratch cache directory with fake
# target trees standing in for cargo output — no compiler needed, so this is
# the fast half of the suite. hardlink-clone-selftest.sh covers the parts that
# need a real build.
#
# What each scenario demonstrates:
#
# 1. BASE SNAPSHOT PREFERRED — a PR whose base has published a snapshot
# seeds from it, and the seeded directory really is a hardlink clone
# (shared inodes), not a copy.
# 2. OWN DIR WINS — a second run of the same ref reuses what is already
# there and does not re-seed over its own work.
# 3. OWN SNAPSHOT AS SELF-RESTORE — a publisher whose live target dir was
# evicted restores from the snapshot it last published, instead of
# rebuilding cold.
# 4. FALLBACK DIR — with no snapshot at all, an explicitly configured
# fallback (a pre-existing flat cache, during a migration) is used.
# 5. COLD — with nothing available, the directory is created empty rather
# than the script failing.
# 6. LOCK FILES STRIPPED — Cargo's in-place-flock'd lock files never
# survive a clone, because a shared lock inode would make two branches
# contend on one mutex.
# 7. CONCURRENT SEED IS ATOMIC — two seeds racing on one cache key: the
# loser discards its staging copy and uses the winner's directory, and
# at no point is a partially-populated directory visible under the final
# name. This is the property that replaces "the runner only has one job
# slot" with an actual guarantee.
# 8a. SEED VS PUBLISH ROTATION — the race scenario 7 does NOT cover, and the
# one that actually mattered: a consumer hardlink-cloning a snapshot
# while the publisher of that snapshot rotates it. Two seeds racing on a
# DESTINATION is a different race from a seed racing a publisher on its
# SOURCE, and only the second one can truncate a tree. What it pins is
# that the consumer notices its source was REPLACED and re-clones,
# ending up with the whole generation now published — not that the
# rotation interfered with the copy, which is not guaranteed and is what
# made the racing version of this scenario flaky (issue #3).
# 8b. AND A SILENTLY TRUNCATED WALK — the same tear seen through the other
# check: a subtree unlinked out of the parent's listing before `cp -al`
# reads it is never visited, the copy exits 0 and the source's identity
# never changes. Against the unguarded version this is a partial tree
# renamed into place and reported as a success — 20,328 of 48,805
# entries, `seed: cloned in 1s`, exit 0, seeded-from=base-snapshot.
# 8c. AND A COPY THAT SAYS SO ITSELF — the third witness, and the only one
# the tool volunteers: `cp -al` exiting non-zero over a tree that both
# other checks read as whole. Pins that a copy's own failure report is
# never overruled by two inferences that saw nothing.
# 8d. AND AN IDENTITY THAT COULD NOT BE READ AT ALL — the fourth. A failed
# identity read is reported as the string `missing`, so two of them
# compare equal to each other; without the term that rejects the
# sentinel, a clone whose source could not be identified at either end
# is published on the strength of two errors. A regression guard on a
# defensive term rather than a reproduction of a reachable state — see
# the scenario's own comment.
# All four scenarios force their interleaving rather than racing for it,
# and each asserts WHICH check caught the tear, so none stays green if
# the check it exercises is removed.
# 9. AN UNREADABLE SOURCE FAILS LOUDLY — the clone reports a distinct status
# instead of renaming whatever it managed to produce into place, and the
# seed SCRIPT turns that status into a failed job rather than a silent
# cold build. Retries are what make a torn read survivable; exhausting
# them must not degrade into "start cold and rebuild everything", which
# would turn a corrupt-cache bug into an invisible 4x-slower CI job.
# (The publisher's half of the rotation race — deferring reclamation
# while a reader is still in flight — lives in
# publish-snapshot-selftest.sh, next to the swap it modifies.)
# 10. A TREE THAT CANNOT BE PRIVATELY OWNED IS DISCARDED — the other way a
# clone must refuse to publish, and the one that is not about tearing at
# all: if unsharing the mutable paths fails, the staging tree still
# aliases its source, so renaming it into place would wire two branches
# onto one set of fingerprints. That is the silent stale-reuse bug the
# whole scheme exists to prevent, so the failure has to abort the clone
# rather than be swallowed.
# 11. THE CONSUMER'S MARKER ACTUALLY STOPS THE PUBLISHER — reader_lock_acquire
# is exercised (not faked, unlike publish-snapshot-selftest.sh's scenario
# 6) by a real hardlink_clone_into racing a real, concurrent
# publish-snapshot.sh republish of the exact snapshot being cloned. Pins
# that the publisher OBSERVABLY WAITS on this consumer's marker — its own
# log reports entering the drain wait — rather than only that the run
# succeeds, which stayed green with the marker call deleted (issue #10).
# Kept as its own scenario, not folded into 8a, because 8a already pins
# exactly one property (the identity check) for exactly one mutant, and
# the suite's one-scenario-one-mutant diagonal across 8a to 8d and 10 is
# deliberate.
set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
. "$script_dir/cache-lib.sh"
scratch=$(mktemp -d)
trap 'rm -rf "$scratch"' EXIT
root="$scratch/cache"
mkdir -p "$root"
pass_count=0
fail() { echo "ASSERTION FAILED: $*" >&2; exit 1; }
ok() { pass_count=$((pass_count + 1)); echo "PASS: $*"; }
assert_file() { [ -e "$1" ] || fail "expected $1 to exist ($2)"; ok "$2"; }
assert_absent() { [ -e "$1" ] && fail "expected $1 to be gone ($2)"; ok "$2"; }
assert_content() { [ "$(cat "$1")" = "$2" ] || fail "expected '$2' in $1, got '$(cat "$1")' ($3)"; ok "$3"; }
# A plausible target tree: a big shared artifact, a mutable fingerprint, a
# build-script output, and a lock file.
make_tree() {
local d="$1" marker="$2"
mkdir -p "$d/debug/deps" "$d/debug/.fingerprint/x" "$d/debug/build/x/out"
echo "$marker" > "$d/debug/deps/libx.rlib"
echo "$marker" > "$d/debug/.fingerprint/x/dep-lib-x"
echo "$marker" > "$d/debug/build/x/out/gen.txt"
: > "$d/debug/.cargo-lock"
}
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 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)
real_stat=$(command -v stat)
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
# is 0 entries, not an error worth aborting the suite over.
tree_entries() {
local n
n=$(find "$1" -mindepth 1 2>/dev/null | wc -l) || n=0
printf '%s\n' "$n"
return 0
}
# Waits for a file a concurrently running script will create. The publisher in
# scenario 8a is started from inside the consumer's process tree rather than
# by this script, so its completion cannot be waited on as a job.
wait_for_file() {
local path="$1" what="$2" deadline
deadline=$(( $(date +%s) + 60 ))
until [ -e "$path" ]; do
[ "$(date +%s)" -lt "$deadline" ] || fail "$what"
sleep 0.1
done
}
# Reads the clone's own torn-read report back and asserts WHICH of its four
# checks fired: the copy's exit status, the entry count, the source's identity,
# or that identity being unreadable at all. Scenarios 8a to 8d each force
# exactly one of the four, and a scenario that only asserted "some tear was
# reported" would stay green if the check it exercises were deleted and one of
# the others happened to fire in its place.
#
# assert_tear <log> <cp-status> <same|short> <same|differs|unreadable> <msg>
assert_tear() {
local log="$1" want_rc="$2" want_count="$3" want_inode="$4" msg="$5" line
# The identity fields are matched as `<digits>|missing` rather than as
# "anything up to the space", so a report whose inode field is neither is a
# loud parse failure instead of a silently-compared string.
local re='cp rc=([0-9]+), ([0-9]+)/([0-9]+) entries, source inode ([0-9]+|missing) -> ([0-9]+|missing)'
# `|| line=""` rather than a bare assignment: no match makes grep exit 1,
# which under `set -e` would abort the suite with no message at all — the
# exact case this assertion exists to report.
line=$(grep -o 'was torn ([^)]*)' "$log" | head -1) || line=""
[ -n "$line" ] || { tail -20 "$log"; fail "no torn read was reported at all ($msg)"; }
[[ $line =~ $re ]] || fail "unrecognised torn-read report: ${line}"
[ "${BASH_REMATCH[1]}" = "$want_rc" ] || fail "expected cp to exit ${want_rc}: ${line}"
case "$want_count" in
same) [ "${BASH_REMATCH[2]}" -eq "${BASH_REMATCH[3]}" ] || fail "expected a whole staging tree: ${line}" ;;
short) [ "${BASH_REMATCH[2]}" -lt "${BASH_REMATCH[3]}" ] || fail "expected a short staging tree: ${line}" ;;
*) fail "assert_tear: bad entry-count expectation '${want_count}'" ;;
esac
case "$want_inode" in
# `same` demands a READ identity that held, not merely two equal strings:
# two failed reads are both `missing` and would otherwise satisfy it, which
# is the exact confusion scenario 8d exists to pin.
same) [ "${BASH_REMATCH[4]}" != missing ] || fail "expected the source's identity to be readable: ${line}"
[ "${BASH_REMATCH[4]}" = "${BASH_REMATCH[5]}" ] || fail "expected the source's identity to hold: ${line}" ;;
differs) [ "${BASH_REMATCH[4]}" != "${BASH_REMATCH[5]}" ] || fail "expected the source's identity to change: ${line}" ;;
unreadable) [ "${BASH_REMATCH[4]}" = missing ] && [ "${BASH_REMATCH[5]}" = missing ] \
|| fail "expected neither identity read to have resolved: ${line}" ;;
*) fail "assert_tear: bad identity expectation '${want_inode}'" ;;
esac
ok "$msg (${line})"
}
# A tree of many sibling subtrees, built by cloning one small template
# directory N times — N forks rather than N*M file creations. The shape is
# what scenarios 8a and 8b need: two generations of DIFFERENT entry count, so
# an assertion can tell which one a consumer ended up with, and enough
# sibling subtrees that removing one moves the count. Size no longer has to
# buy a race window — both scenarios force their interleaving — so it stays
# small enough to be free.
make_wide_tree() {
local d="$1" marker="$2" ndirs="$3" i
mkdir -p "$d/debug/deps/.tmpl" "$d/debug/.fingerprint/x"
for i in $(seq 0 59); do echo "$marker" > "$d/debug/deps/.tmpl/f$i"; done
for i in $(seq -w 1 "$ndirs"); do cp -al "$d/debug/deps/.tmpl" "$d/debug/deps/d$i"; done
rm -rf "$d/debug/deps/.tmpl"
echo "$marker" > "$d/debug/.fingerprint/x/dep-lib-x"
}
BASE_KEY=$(cache_key dev)
OWN_KEY=$(cache_key feat/thing)
echo "=== 1: base snapshot preferred, and cloned by hardlink ==="
make_tree "$root/snapshot-$BASE_KEY" base-content
seed "$OWN_KEY" "$BASE_KEY" "$root" job1
own="$root/target-$OWN_KEY"
assert_content "$own/debug/deps/libx.rlib" base-content "seeded from the base snapshot"
[ "$(stat -c '%i' "$own/debug/deps/libx.rlib")" = "$(stat -c '%i' "$root/snapshot-$BASE_KEY/debug/deps/libx.rlib")" ] \
|| fail "artifact was copied, not hardlinked"
ok "artifact shares an inode with the snapshot (hardlink clone, not a copy)"
[ "$(stat -c '%i' "$own/debug/.fingerprint/x/dep-lib-x")" != "$(stat -c '%i' "$root/snapshot-$BASE_KEY/debug/.fingerprint/x/dep-lib-x")" ] \
|| fail "fingerprint still shares an inode with the snapshot"
ok "fingerprint is privately owned (unshare_mutable_paths ran)"
echo
echo "=== 6: Cargo lock files never survive a clone ==="
assert_absent "$own/debug/.cargo-lock" "cloned .cargo-lock removed"
echo
echo "=== 2: an existing own dir is reused, never re-seeded over ==="
echo own-work > "$own/debug/deps/libx.rlib"
seed "$OWN_KEY" "$BASE_KEY" "$root" job1
assert_content "$own/debug/deps/libx.rlib" own-work "own directory reused as-is"
grep -q 'reusing this ref' "$scratch/log" || fail "expected the reuse path in the log"
ok "reuse is reported in the log"
echo
echo "=== 3: a publisher restores from its own snapshot after eviction ==="
make_tree "$root/snapshot-$BASE_KEY" published-dev
seed "$BASE_KEY" "" "$root" job1
assert_content "$root/target-$BASE_KEY/debug/deps/libx.rlib" published-dev "publisher self-restored from its own snapshot"
echo
echo "=== 4: explicit fallback dir when no snapshot exists ==="
OTHER=$(cache_key feat/other)
make_tree "$scratch/legacy-flat" legacy
seed "$OTHER" "$(cache_key nosuch)" "$root" job1 "$scratch/legacy-flat"
assert_content "$root/target-$OTHER/debug/deps/libx.rlib" legacy "seeded from the fallback dir"
echo
echo "=== 5: cold start when nothing is available ==="
COLD=$(cache_key feat/cold)
seed "$COLD" "$(cache_key nosuch)" "$root" job1
[ -d "$root/target-$COLD" ] || fail "cold start did not create the directory"
[ -z "$(ls -A "$root/target-$COLD")" ] || fail "cold start directory is not empty"
ok "cold start creates an empty directory rather than failing"
echo
echo "=== 7: two jobs racing on one cache key ==="
RACE=$(cache_key feat/race)
make_tree "$root/snapshot-$BASE_KEY" race-source
# Both jobs seed concurrently from the same snapshot into the same key. Each
# stages under its own tag, so the only interaction is the final rename.
( bash "$script_dir/seed-target-dir.sh" "$RACE" "$BASE_KEY" "$root" jobA > "$scratch/logA" 2>&1 ) &
( bash "$script_dir/seed-target-dir.sh" "$RACE" "$BASE_KEY" "$root" jobB > "$scratch/logB" 2>&1 ) &
wait
race_dir="$root/target-$RACE"
assert_content "$race_dir/debug/deps/libx.rlib" race-source "the surviving directory is complete"
[ -z "$(find "$root" -maxdepth 1 -name '.stage-*' -print -quit)" ] || fail "a staging directory was left behind"
ok "no staging directory survived the race"
# Exactly one job may claim it seeded; the other must report either the
# concurrent-peer path or a plain reuse (if it started after the winner
# finished). Neither may report a cold start.
if grep -q 'starts cold' "$scratch/logA" "$scratch/logB"; then
cat "$scratch/logA" "$scratch/logB"; fail "a racing job reported a cold start"
fi
ok "neither racing job fell through to a cold start"
echo
echo "=== 8a: a rotation landing inside the clone's identity window ==="
# The race scenario 7 does NOT cover: a seed racing a publisher on its SOURCE
# rather than two seeds racing on a DESTINATION. What this pins is that a
# consumer whose source is REPLACED WHOLESALE mid-clone notices the
# substitution and re-clones, ending up holding the whole generation now
# published — NOT that the rotation necessarily interfered with the copy,
# which is not guaranteed and is what made the racing version of this
# scenario flaky (issue #3).
#
# The interleaving is forced rather than hoped for, the way
# prune-cache-selftest.sh's scenario 12 forces a marker into the
# check-to-unlink window: the consumer's own `cp` performs the rotation, so
# it lands strictly after the entry count and inode that open the identity
# window and strictly before the inode that closes it. The publisher is the
# real publish-snapshot.sh running concurrently; only WHEN it runs is
# arranged.
ROT=$(cache_key feat/rotate)
rm -rf "$root/snapshot-$BASE_KEY" "$root/target-$BASE_KEY"
# The two generations must differ in entry count, or "the seeded tree matches
# the published snapshot" would hold for whichever one the consumer ended up
# with and the fixture could not distinguish the outcomes at all.
make_wide_tree "$root/snapshot-$BASE_KEY" gen1 12
make_wide_tree "$root/target-$BASE_KEY" gen2 3
gen1_entries=$(tree_entries "$root/snapshot-$BASE_KEY")
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"
cat > "$scratch/bin/cp" <<EOF
#!/usr/bin/env bash
# Fires once, and only on the consumer's own top-level hardlink clone —
# identified by its destination, which is that seed's private staging path.
# Every other cp in the process tree (the unshare copies, and the publisher's
# own staging clone) falls through to the real one.
if [ "\${@: -1}" = "$root/.stage-jobRot" ] && [ ! -e "$scratch/rotated" ]; then
: > "$scratch/rotated"
rc=0; "$real_cp" "\$@" || rc=\$?
# Concurrently: the publisher's post-swap drain wait is against THIS
# consumer's marker, which is held until the identity read that follows
# this cp returns, so running the publish inline would deadlock the two
# sides against each other.
( bash "$script_dir/publish-snapshot.sh" "$BASE_KEY" "$root" pubRot > "$scratch/logPub" 2>&1
echo \$? > "$scratch/rcPub" ) &
# Hand control back only once the swap is on disk, so the identity read
# immediately after this cp is guaranteed to resolve to the new generation.
deadline=\$(( \$(date +%s) + 60 ))
while [ "\$(stat -c '%i' "$root/snapshot-$BASE_KEY" 2>/dev/null)" = "$gen1_inode" ]; do
[ "\$(date +%s)" -lt "\$deadline" ] || { echo "stub cp: the publisher never swapped the snapshot" >&2; exit 90; }
sleep 0.05
done
exit \$rc
fi
exec "$real_cp" "\$@"
EOF
chmod +x "$scratch/bin/cp"
rcRot=0
seed_with_stub "$ROT" "$BASE_KEY" "$root" jobRot > "$scratch/logRot" 2>&1 || rcRot=$?
[ -e "$scratch/rotated" ] \
|| fail "the stubbed cp never fired: no rotation was placed in the window, so this scenario proves nothing"
ok "the rotation was placed inside the consumer's identity window"
wait_for_file "$scratch/rcPub" "the publisher never finished"
[ "$(cat "$scratch/rcPub")" = "0" ] || { tail -40 "$scratch/logPub"; fail "publish-snapshot.sh exited non-zero"; }
[ "$rcRot" = "0" ] || { tail -40 "$scratch/logRot"; fail "the seed exited non-zero"; }
ok "the seed completed"
# WHICH check caught the rotation is the point of this scenario, so it is
# asserted rather than assumed. The copy succeeded and the staging tree holds
# every entry the source had when the walk began, so neither cp's exit status
# nor the entry count is a witness here — the source's identity changing
# under the walk is the only one. Reading the report back is what keeps this
# scenario sensitive to losing that single comparison.
assert_tear "$scratch/logRot" 0 same differs "the substitution was caught by the source's identity alone"
rot_dir="$root/target-$ROT"
snap_entries=$(tree_entries "$root/snapshot-$BASE_KEY")
rot_entries=$(tree_entries "$rot_dir")
[ "$rot_entries" -eq "$snap_entries" ] \
|| fail "the seeded tree is not the generation now published: ${rot_entries} entries against the snapshot's ${snap_entries} (generation 1 had ${gen1_entries})"
ok "the seed re-cloned and holds the whole published generation (${rot_entries} entries)"
assert_content "$rot_dir/debug/.fingerprint/x/dep-lib-x" gen2 "the seeded tree holds one whole generation, not a splice of two"
leftovers=$(find "$root" -maxdepth 1 \( -name '.stage-*' -o -name '.reading-*' -o -name '.publish-*' \) -print)
[ -z "$leftovers" ] || fail "scratch left behind: ${leftovers}"
ok "no staging, reader-marker or deferred-generation scratch left behind"
rm -f "$scratch/bin/cp"
echo
echo "=== 8b: a subtree unlinked out from under the walk, silently ==="
# The other way a clone tears, and the one with nothing to report: a subtree
# that leaves the parent's listing before `cp -al` reads it is simply never
# visited. The copy exits 0 and the source's identity never changes, so the
# entry count taken before the walk is the only witness there is — this is
# the mode that used to publish a partial tree and call it a success.
#
# Modelled by renaming the subtree out and back around the consumer's own cp:
# out before the walk starts (the only way to be missed without an error),
# back before the retry, because the real thing that removes entries — a
# publish rotating a generation away — has a whole generation at the path by
# the time the retry looks.
TRUNC=$(cache_key feat/truncate)
TRUNC_BASE=$(cache_key release/1)
make_wide_tree "$root/snapshot-$TRUNC_BASE" trunkgen 6
victim="$root/snapshot-$TRUNC_BASE/debug/deps/d3"
trunc_entries=$(tree_entries "$root/snapshot-$TRUNC_BASE")
[ "$(tree_entries "$victim")" -gt 0 ] \
|| fail "the subtree this scenario removes is empty — its removal would not change the entry count"
cat > "$scratch/bin/cp" <<EOF
#!/usr/bin/env bash
if [ "\${@: -1}" = "$root/.stage-jobTrunc" ] && [ ! -e "$scratch/unlinked" ]; then
: > "$scratch/unlinked"
mv "$victim" "$scratch/held"
rc=0; "$real_cp" "\$@" || rc=\$?
mv "$scratch/held" "$victim"
exit \$rc
fi
exec "$real_cp" "\$@"
EOF
chmod +x "$scratch/bin/cp"
rcTrunc=0
seed_with_stub "$TRUNC" "$TRUNC_BASE" "$root" jobTrunc > "$scratch/logTrunc" 2>&1 || rcTrunc=$?
[ -e "$scratch/unlinked" ] \
|| fail "the stubbed cp never fired: nothing was unlinked mid-walk, so this scenario proves nothing"
ok "a subtree was taken out of the source's listing before the walk read it"
[ "$rcTrunc" = "0" ] || { tail -40 "$scratch/logTrunc"; fail "the seed exited non-zero"; }
ok "the seed completed"
assert_tear "$scratch/logTrunc" 0 short same "the silent truncation was caught by the entry count alone"
trunc_dir="$root/target-$TRUNC"
[ "$(tree_entries "$trunc_dir")" -eq "$trunc_entries" ] \
|| fail "the seeded tree is short: $(tree_entries "$trunc_dir") entries against the source's ${trunc_entries}"
ok "the seed re-cloned and holds every entry the source has (${trunc_entries})"
[ -e "$victim" ] || fail "the fixture did not put the subtree back"
[ -e "$trunc_dir/debug/deps/d3/f0" ] \
|| fail "the subtree missed by the first walk is absent from the seeded tree"
ok "the subtree the first walk never saw is present in the seeded tree"
leftovers=$(find "$root" -maxdepth 1 \( -name '.stage-*' -o -name '.reading-*' \) -print)
[ -z "$leftovers" ] || fail "scratch left behind: ${leftovers}"
ok "no staging or reader-marker scratch left behind"
rm -f "$scratch/bin/cp"
echo
echo "=== 8c: a copy that reports failure over a tree that looks whole ==="
# The third witness, and the only one the tool volunteers rather than leaving
# to be inferred: `cp -al` exiting non-zero. What this pins is that a copy's
# own failure report is never overruled by the other two checks agreeing that
# the tree looks intact.
#
# Both of those 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 an entry lost against one gained
# cancels out. The inode comparison sees only a source replaced wholesale. A
# `cp -a` that links every entry and still fails — it could not preserve a
# directory's ownership, say — is invisible to both, and that is the shape
# forced here: the report reads 0 entries short and one unchanged inode, so
# the exit status is the only witness there is.
#
# Forced with the PATH stub the suite already uses for 8a and 8b, on the
# consumer's own top-level clone: the whole tree really is copied, and then
# the failure is reported, once. The retry finds nothing wrong, so the
# scenario also pins that the seed RECOVERS rather than merely refusing —
# a status that is honoured but not survivable would fail every job whose
# runner hiccuped once.
CPSTAT=$(cache_key feat/cp-status)
CPSTAT_BASE=$(cache_key release/2)
make_wide_tree "$root/snapshot-$CPSTAT_BASE" cpstatgen 4
cpstat_entries=$(tree_entries "$root/snapshot-$CPSTAT_BASE")
cat > "$scratch/bin/cp" <<EOF
#!/usr/bin/env bash
# Fires once, and only on the consumer's own top-level hardlink clone. The
# real copy runs to completion first: the point is a status nothing else can
# see, so the staging tree it reports on must be whole.
if [ "\${@: -1}" = "$root/.stage-jobCpStat" ] && [ ! -e "$scratch/cp_reported_failure" ]; then
: > "$scratch/cp_reported_failure"
"$real_cp" "\$@"
echo "cp: failed to preserve ownership for '\${@: -1}': Operation not permitted" >&2
exit 1
fi
exec "$real_cp" "\$@"
EOF
chmod +x "$scratch/bin/cp"
rcCpStat=0
seed_with_stub "$CPSTAT" "$CPSTAT_BASE" "$root" jobCpStat > "$scratch/logCpStat" 2>&1 || rcCpStat=$?
[ -e "$scratch/cp_reported_failure" ] \
|| fail "the stubbed cp never fired: no copy reported failure, so this scenario proves nothing"
ok "a copy reported failure over a staging tree that was in fact complete"
[ "$rcCpStat" = "0" ] || { tail -40 "$scratch/logCpStat"; fail "the seed exited non-zero"; }
ok "the seed completed"
# `same` on both inferences is the whole point: neither of them saw anything,
# so deleting the exit-status check leaves nothing to report and this
# assertion is the one that goes red.
assert_tear "$scratch/logCpStat" 1 same same "the failure was caught by the copy's exit status alone"
cpstat_dir="$root/target-$CPSTAT"
[ "$(tree_entries "$cpstat_dir")" -eq "$cpstat_entries" ] \
|| fail "the seeded tree is short: $(tree_entries "$cpstat_dir") entries against the source's ${cpstat_entries}"
ok "the seed re-cloned and holds every entry the source has (${cpstat_entries})"
assert_content "$cpstat_dir/debug/.fingerprint/x/dep-lib-x" cpstatgen "the seeded tree is the source's content"
leftovers=$(find "$root" -maxdepth 1 \( -name '.stage-*' -o -name '.reading-*' \) -print)
[ -z "$leftovers" ] || fail "scratch left behind: ${leftovers}"
ok "no staging or reader-marker scratch left behind"
rm -f "$scratch/bin/cp"
echo
echo "=== 8d: an identity that could not be read at either end ==="
# The fourth check, `[ "$i_before" != missing ]`, and the one whose absence is
# hardest to see. `_dir_inode` folds every stat failure into the string
# `missing`, so two FAILED identity reads compare equal TO EACH OTHER: drop
# this check and a clone whose source could not be identified before or after
# the walk satisfies `i_before = i_after` on the strength of two errors, and
# is renamed into place having proven nothing about the tree it holds.
#
# It cannot be forced by taking the source away. A source that is really gone
# fails `cp -al` too, so the exit status would fire in this check's place and
# the scenario would pin 8c's property over again. What isolates it is the
# identity read failing while the copy SUCCEEDS — a transient stat error over
# a source that is otherwise perfectly readable — so the stub goes on `stat`
# rather than on the tree, and narrowly: only the `%i` reads of this clone's
# own source, which is the sole caller of `stat -c '%i'` on this path. The
# `%Y` reads the reader markers do are left alone.
#
# BOTH reads of the attempt have to fail. If only one did, the surviving one
# would differ from `missing` and the identity COMPARISON would become the
# witness instead — 8a's property, not this one. The assertion that the stub
# fired is therefore a count rather than a flag.
#
# So be clear about what this scenario is. It is NOT a reproduction of a state
# a CI job reaches: every route to it is closed off — a rotation hands the
# witness to 8a, and a source that is genuinely gone fails `cp -al` and hands
# it to 8c. It is a REGRESSION GUARD ON A DEFENSIVE TERM, and the thing it
# defends against is a sentinel comparing equal to itself, which is a property
# of the code rather than of the filesystem. That is worth pinning precisely
# because nothing else can reach it: a term no fixture exercises is the one a
# refactor drops without argument.
IDENT=$(cache_key feat/identity-unreadable)
IDENT_BASE=$(cache_key release/3)
make_wide_tree "$root/snapshot-$IDENT_BASE" identgen 4
ident_src="$root/snapshot-$IDENT_BASE"
ident_entries=$(tree_entries "$ident_src")
cat > "$scratch/bin/stat" <<EOF
#!/usr/bin/env bash
# Fails the first two identity reads of this clone's source — the pair that
# opens and closes one attempt's identity window — and passes everything else
# through, including the reader markers' own \`%Y\` reads.
if [ "\$1" = "-c" ] && [ "\$2" = "%i" ] && [ "\$3" = "$ident_src" ] \
&& [ "\$(wc -c < "$scratch/identity_reads_failed" 2>/dev/null || echo 0)" -lt 2 ]; then
printf 'x' >> "$scratch/identity_reads_failed"
echo "stat: cannot statx '\$3': Input/output error" >&2
exit 1
fi
exec "$real_stat" "\$@"
EOF
chmod +x "$scratch/bin/stat"
rcIdent=0
seed_with_stub "$IDENT" "$IDENT_BASE" "$root" jobIdent > "$scratch/logIdent" 2>&1 || rcIdent=$?
ident_failures=$(wc -c < "$scratch/identity_reads_failed" 2>/dev/null || echo 0)
[ "$ident_failures" -eq 2 ] \
|| fail "expected both identity reads of one attempt to fail, got ${ident_failures} — a single failure would make the identity comparison the witness instead, which is scenario 8a's property"
ok "neither identity read of the first attempt resolved"
[ "$rcIdent" = "0" ] || { tail -40 "$scratch/logIdent"; fail "the seed exited non-zero"; }
ok "the seed completed"
assert_tear "$scratch/logIdent" 0 same unreadable "the unreadable identity was caught by the sentinel check alone"
ident_dir="$root/target-$IDENT"
[ "$(tree_entries "$ident_dir")" -eq "$ident_entries" ] \
|| fail "the seeded tree is short: $(tree_entries "$ident_dir") entries against the source's ${ident_entries}"
ok "the seed re-cloned once the identity was readable again (${ident_entries} entries)"
assert_content "$ident_dir/debug/.fingerprint/x/dep-lib-x" identgen "the seeded tree is the source's content"
leftovers=$(find "$root" -maxdepth 1 \( -name '.stage-*' -o -name '.reading-*' \) -print)
[ -z "$leftovers" ] || fail "scratch left behind: ${leftovers}"
ok "no staging or reader-marker scratch left behind"
rm -f "$scratch/bin/stat"
echo
echo "=== 9: a source that cannot be read fails loudly ==="
rc=0
hardlink_clone_into "$root/nosuch-source" "$root/target-nosuch" nosuch-tag > "$scratch/logMissing" 2>&1 || rc=$?
[ "$rc" -eq 2 ] || fail "expected status 2 for an unreadable source, got ${rc}"
ok "an unreadable source returns the distinct hard-failure status"
assert_absent "$root/target-nosuch" "nothing was renamed into place"
# The status only matters if the script acts on it. A source that exists but
# cannot be read exercises the whole path: retries exhaust, the function
# returns 2, and seed-target-dir.sh must exit non-zero rather than falling
# through to its cold-start branch.
if [ "$(id -u)" = "0" ]; then
echo "SKIP: running as root — mode bits do not deny access"
else
UNREADABLE=$(cache_key feat/unreadable)
VICTIM=$(cache_key feat/victim)
make_tree "$root/snapshot-$UNREADABLE" locked-away
chmod 000 "$root/snapshot-$UNREADABLE"
rc=0
CACHE_CLONE_ATTEMPTS=2 bash "$script_dir/seed-target-dir.sh" \
"$VICTIM" "$UNREADABLE" "$root" jobUnread > "$scratch/logUnread" 2>&1 || rc=$?
chmod 755 "$root/snapshot-$UNREADABLE"
[ "$rc" -ne 0 ] || { tail -20 "$scratch/logUnread"; fail "the seed reported success against a source it could not read"; }
ok "the seed exits non-zero when its source cannot be cloned"
grep -q 'refusing to build against a partial cache' "$scratch/logUnread" \
|| { tail -20 "$scratch/logUnread"; fail "the failure was not reported as such"; }
ok "the failure names the reason rather than exiting silently"
if grep -q 'starts cold' "$scratch/logUnread"; then
tail -20 "$scratch/logUnread"; fail "an unreadable source degraded into a silent cold build"
fi
ok "it does not degrade into a silent cold build"
assert_absent "$root/target-$VICTIM" "no target dir was left behind by the failed seed"
fi
echo
echo "=== 10: a staging tree that cannot be privately owned is never published ==="
# unshare_mutable_paths turns the hardlinked copies of Cargo's mutable
# metadata back into private inodes, and its failure status is load-bearing in
# a way no other check covers: a staging tree whose dep-info files still point
# at the SOURCE's inodes is not torn — every entry is present and the source
# never moved — so all four torn-clone checks pass it. Publish it anyway and
# this branch's build rewrites the base branch's dep-info, which is the silent
# stale-artifact reuse documented at length in cache-lib.sh. The only thing
# standing between that tree and DST is the status being propagated.
#
# Forced with the same PATH stub shape as 8a to 8d, on the narrowest possible
# target: `_unshare_files` copies each shared file as `cp -p -- <f>
# <f>.unshare.<pid>`, so refusing exactly the `.d` copies leaves the directory
# unshares (`cp -a`) and the clone itself (`cp -al`) untouched, and the
# failure that reaches the clone is unambiguously this one. The dep-info file
# has to live outside .fingerprint/ and build/, or it would be inside a
# subtree already replaced wholesale and never reach the per-file pass.
ALIAS=$(cache_key feat/unshare-fails)
ALIAS_BASE=$(cache_key release/4)
make_tree "$root/snapshot-$ALIAS_BASE" aliasgen
alias_dep="$root/snapshot-$ALIAS_BASE/debug/deps/libx.d"
echo aliasgen > "$alias_dep"
cat > "$scratch/bin/cp" <<EOF
#!/usr/bin/env bash
# Refuses the dep-info unshares and nothing else. Modelled on the failure that
# actually happens here — the cache volume filling up mid-clone.
if [ "\$1" = "-p" ] && [ "\$2" = "--" ] && [ "\${3%.d}" != "\$3" ]; then
: > "$scratch/depinfo_unshare_refused"
echo "cp: cannot create regular file '\$4': No space left on device" >&2
exit 1
fi
exec "$real_cp" "\$@"
EOF
chmod +x "$scratch/bin/cp"
rcAlias=0
seed_with_stub "$ALIAS" "$ALIAS_BASE" "$root" jobAlias > "$scratch/logAlias" 2>&1 || rcAlias=$?
[ -e "$scratch/depinfo_unshare_refused" ] \
|| fail "the stubbed cp never fired: no unshare was refused, so this scenario proves nothing"
ok "a dep-info unshare was refused inside the clone"
[ "$rcAlias" != "0" ] || { tail -40 "$scratch/logAlias"; fail "the seed reported success over a staging tree that still aliased its source"; }
ok "the seed exits non-zero when the staging tree cannot be privately owned"
# Which failure aborted the clone is asserted, not assumed: without this the
# scenario would stay green if the clone had failed for any other reason —
# the same wrong-reason pass that let a mutation survive scenario 9 (issue #5).
grep -q 'failed to unshare dep-info files' "$scratch/logAlias" \
|| { tail -20 "$scratch/logAlias"; fail "the dep-info unshare failure was not the one reported"; }
ok "the refused unshare is what was reported"
grep -q 'could not privately own the mutable paths' "$scratch/logAlias" \
|| { tail -20 "$scratch/logAlias"; fail "the clone did not report why it refused to publish"; }
ok "the clone names aliasing as its reason for refusing"
assert_absent "$root/target-$ALIAS" "nothing was renamed into place"
[ "$(stat -c '%h' "$alias_dep")" = "1" ] \
|| fail "the source's dep-info file is still hardlinked from somewhere: $(stat -c '%h' "$alias_dep") links"
ok "the discarded staging tree took its hardlinks to the source with it"
leftovers=$(find "$root" -maxdepth 1 \( -name '.stage-*' -o -name '.reading-*' \) -print)
[ -z "$leftovers" ] || fail "scratch left behind: ${leftovers}"
ok "no staging or reader-marker scratch left behind"
rm -f "$scratch/bin/cp"
echo
echo "=== 11: the consumer's marker actually stops the publisher ==="
# hardlink_clone_into's reader_lock_acquire call is exercised here, not
# faked. publish-snapshot-selftest.sh's scenario 6 stands a hand-written
# marker file in for "a consumer whose clone outlasts the grace period" — a
# deliberate simplification that does not need this consumer's clone code at
# all, so it cannot tell reader_lock_acquire apart from no marker existing.
# This scenario forces the two real scripts to race on the same snapshot: a
# consumer hardlink-cloning it, and a publisher republishing it out from
# under that clone, exactly as 8a does to force the identity check — but what
# is pinned here is not the consumer's response to the rotation (8a's
# property), it is that the PUBLISHER, reading the SAME marker this
# consumer's clone wrote, is observed to have entered its drain wait. Deleting
# reader_lock_acquire (issue #10) leaves the marker never written: the
# publisher's live_reader_count sees zero readers on its first check and
# proceeds straight to reclaiming the rotated generation, silently — the run
# still succeeds, and nothing about its own outcome says so, only the absence
# of a line in the publisher's log.
INTLK=$(cache_key feat/interlock-consumer)
INTLK_BASE=$(cache_key release/5)
TAG_INTLK=jobIntlk
rm -rf "$root/snapshot-$INTLK_BASE" "$root/target-$INTLK_BASE"
make_tree "$root/snapshot-$INTLK_BASE" intlk-gen1
make_tree "$root/target-$INTLK_BASE" intlk-gen2
intlk_gen1_inode=$(stat -c '%i' "$root/snapshot-$INTLK_BASE")
cat > "$scratch/bin/cp" <<EOF
#!/usr/bin/env bash
# Fires once, only on the consumer's own top-level hardlink clone — identified
# by its destination, the seed's private staging path. Every other cp in the
# process tree (the unshare copies, and the publisher's own staging clone)
# falls through to the real one.
if [ "\${@: -1}" = "$root/.stage-$TAG_INTLK" ] && [ ! -e "$scratch/firedIntlk" ]; then
: > "$scratch/firedIntlk"
rc=0; "$real_cp" "\$@" || rc=\$?
# Concurrently: a real, second publish of the exact snapshot this consumer
# is cloning — the republish that, without the marker this consumer's clone
# holds, would reclaim the generation out from under it.
( CACHE_READ_GRACE_SECONDS=10 bash "$script_dir/publish-snapshot.sh" "$INTLK_BASE" "$root" pubIntlk \
> "$scratch/logPubIntlk" 2>&1
echo \$? > "$scratch/rcPubIntlk" ) &
# Hand control back only once the swap is on disk, so the identity read
# immediately after this cp is guaranteed to resolve to the new generation
# — the same technique 8a uses to force the interleaving rather than hope
# for it. reader_lock_release does not run until AFTER this script exits,
# so the marker stays live for the publisher's whole swap-and-scan.
deadline=\$(( \$(date +%s) + 60 ))
while [ "\$(stat -c '%i' "$root/snapshot-$INTLK_BASE" 2>/dev/null)" = "$intlk_gen1_inode" ]; do
[ "\$(date +%s)" -lt "\$deadline" ] || { echo "stub cp: the publisher never swapped the snapshot" >&2; exit 92; }
sleep 0.05
done
exit \$rc
fi
exec "$real_cp" "\$@"
EOF
chmod +x "$scratch/bin/cp"
rcIntlk=0
seed_with_stub "$INTLK" "$INTLK_BASE" "$root" "$TAG_INTLK" > "$scratch/logIntlk" 2>&1 || rcIntlk=$?
[ -e "$scratch/firedIntlk" ] \
|| fail "the stubbed cp never fired: the consumer never raced the publisher, so this scenario proves nothing"
ok "the consumer's clone raced a real, concurrent republish of its own source"
wait_for_file "$scratch/rcPubIntlk" "the publisher never finished"
[ "$(cat "$scratch/rcPubIntlk")" = "0" ] || { tail -40 "$scratch/logPubIntlk"; fail "publish-snapshot.sh exited non-zero"; }
[ "$rcIntlk" = "0" ] || { tail -40 "$scratch/logIntlk"; fail "the seed exited non-zero"; }
ok "both sides of the race completed"
# The property under test: not that the run succeeded, but that the publisher
# itself reports having found a live reader and waited on it. This is silent
# in the consumer's own log and in the run's exit status alike — only the
# publisher's log carries it.
grep -q "readers: waiting for 1 in-flight clone(s) of snapshot-${INTLK_BASE}" "$scratch/logPubIntlk" \
|| { tail -40 "$scratch/logPubIntlk"; fail "the publisher never reported waiting on the consumer's reader marker — the interlock did not observably engage"; }
ok "the publisher observably waited on the consumer's own reader marker before reclaiming the rotated snapshot generation"
leftovers=$(find "$root" -maxdepth 1 \( -name '.stage-*' -o -name '.reading-*' -o -name '.publish-*' \) -print)
[ -z "$leftovers" ] || fail "scratch left behind: ${leftovers}"
ok "no staging, reader-marker or deferred-generation scratch left behind"
rm -f "$scratch/bin/cp"
echo
echo "seed-target-dir-selftest: ${pass_count} assertions passed"