#!/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. # 8. 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 and unlinks the # generation being read. 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. Against the unguarded version this scenario # reproduces a silent partial clone reported as success — 20,328 of # 48,805 entries, `seed: cloned in 1s`, exit 0, seeded-from=base-snapshot. # 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.) 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"; }; } # Always succeeds and always prints a number: an absent directory is "0 # entries so far", which is the normal state at the top of the progress poll # below, 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 } # A tree wide enough that a hardlink clone of it takes long enough to be # caught mid-walk. The race under test is a real interleaving, not a mocked # one, so the fixture has to be big enough for the window to exist: a # four-file tree clones in microseconds and no scheduling could ever land # inside it. Built by cloning one small template directory N times, which is N # forks rather than N*M file creations. 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 "=== 8: seeding while the base republishes the snapshot underneath it ===" ROT=$(cache_key feat/rotate) rm -rf "$root/snapshot-$BASE_KEY" "$root/target-$BASE_KEY" # Generation 1 is wide (the consumer will still be walking it when the swap # happens); the generation replacing it is small, so the publisher's own # staging clone does not itself outlast the consumer's. make_wide_tree "$root/snapshot-$BASE_KEY" gen1 800 make_wide_tree "$root/target-$BASE_KEY" gen2 8 gen1_entries=$(tree_entries "$root/snapshot-$BASE_KEY") ( bash "$script_dir/seed-target-dir.sh" "$ROT" "$BASE_KEY" "$root" jobRot > "$scratch/logRot" 2>&1; echo $? > "$scratch/rcRot" ) & seed_pid=$! # Rotate only once the clone is demonstrably mid-walk. Gating on observed # progress rather than on a sleep is what makes the interleaving reproducible # instead of a coin flip that passes on a fast machine for the wrong reason. threshold=$(( gen1_entries / 5 )) progress=0 deadline=$(( $(date +%s) + 60 )) while :; do progress=$(tree_entries "$root/.stage-jobRot") if [ "$progress" -ge "$threshold" ]; then break; fi if ! kill -0 "$seed_pid" 2>/dev/null; then fail "the seed finished before its clone could be caught mid-walk (fixture too small for this machine?)" fi if [ "$(date +%s)" -ge "$deadline" ]; then fail "the staging clone never reached ${threshold} of ${gen1_entries} entries" fi done ok "caught the consumer's clone mid-walk at ${progress}/${gen1_entries} entries" bash "$script_dir/publish-snapshot.sh" "$BASE_KEY" "$root" pubRot > "$scratch/logPub" 2>&1 \ || { tail -40 "$scratch/logPub"; fail "publish-snapshot.sh exited non-zero"; } wait "$seed_pid" rot_dir="$root/target-$ROT" [ "$(cat "$scratch/rcRot")" = "0" ] || { tail -40 "$scratch/logRot"; fail "the seed exited non-zero"; } ok "the seed completed" # THE assertion. Before the guard, this is where it failed: the seed reported # `cloned in 1s` and exit 0 while target- held less than half the entries # of the snapshot it claimed to have cloned. Comparing against the snapshot as # it stands NOW is the right bar either way — a clone that raced the rotation # must end up holding one complete generation, and a consumer caught mid-walk # re-reads, so that generation is the new one. snap_entries=$(tree_entries "$root/snapshot-$BASE_KEY") rot_entries=$(tree_entries "$rot_dir") [ "$rot_entries" -eq "$snap_entries" ] \ || fail "the seeded tree is truncated: ${rot_entries} entries against the snapshot's ${snap_entries} (was ${gen1_entries} before the rotation)" ok "the seeded tree is complete (${rot_entries} entries, no silent truncation)" assert_content "$rot_dir/debug/.fingerprint/x/dep-lib-x" gen2 "the seeded tree holds one whole generation, not a splice of two" grep -q 'was torn' "$scratch/logRot" || fail "the rotation was not detected as a torn read" ok "the torn read was detected and reported, not swallowed" [ -z "$(find "$root" -maxdepth 1 \( -name '.stage-*' -o -name '.reading-*' -o -name '.publish-*' \) -print -quit)" ] \ || fail "scratch left behind: $(find "$root" -maxdepth 1 \( -name '.stage-*' -o -name '.reading-*' -o -name '.publish-*' \) -print)" ok "no staging, reader-marker or deferred-generation scratch left behind" 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 "seed-target-dir-selftest: ${pass_count} assertions passed"