#!/usr/bin/env bash # Regression test for publish-snapshot.sh: the atomic swap, and the two # properties the swap exists to guarantee. # # 1. FIRST PUBLISH — with no prior snapshot, the target dir is published and # is a hardlink clone of it (cheap), with the mutable metadata privately # owned (sound: the publisher's NEXT build must not be able to mutate the # snapshot it just published). # 2. REPUBLISH REPLACES — a second publish replaces the snapshot's content # rather than merging into it, and leaves no scratch directories behind. # 3. A LIVE CONSUMER SURVIVES A REPUBLISH — a branch that already cloned the # previous generation keeps reading its own consistent copy. Removing the # old snapshot unlinks directory entries; the inodes stay alive through # the consumer's own links. This is why a republish can never pull data # out from under a running job. # 4. NO TARGET DIR — publishing when there is nothing to publish is a no-op, # not a failure. # 5. LOCKS AND MARKERS DO NOT RIDE ALONG — the publishing job's own cache # 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" 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: $*"; } # Cargo and rustc REPLACE an artifact (write elsewhere, rename over the path) # rather than truncating it in place, which is exactly why a snapshot may # share artifact inodes with the live target dir it was cloned from. The # fixtures here have to model that faithfully — a plain `>` redirect truncates # in place and would write straight through the shared inode into the # snapshot and every consumer, which is a property of the test fixture, not of # a real build. hardlink-clone-selftest.sh is what verifies the real thing # against a real compiler. replace_file() { printf '%s\n' "$2" > "$1.new" mv -f "$1.new" "$1" } make_tree() { local d="$1" marker="$2" mkdir -p "$d/debug/deps" "$d/debug/.fingerprint/x" echo "$marker" > "$d/debug/deps/libx.rlib" echo "$marker" > "$d/debug/.fingerprint/x/dep-lib-x" : > "$d/debug/.cargo-lock" } KEY=$(cache_key dev) TGT="$root/target-$KEY" SNAP="$root/snapshot-$KEY" publish() { bash "$script_dir/publish-snapshot.sh" "$KEY" "$root" "$1" > "$scratch/log" 2>&1 || { cat "$scratch/log"; fail "publish-snapshot.sh exited non-zero"; }; } echo "=== 4: nothing to publish is a no-op ===" publish job1 [ -d "$SNAP" ] && fail "published a snapshot with no target dir present" grep -q 'nothing to snapshot' "$scratch/log" || fail "expected a 'nothing to snapshot' line" ok "no target dir: no-op, reported plainly" echo echo "=== 1: first publish ===" make_tree "$TGT" gen1 date +%s > "$TGT/.ci-lock-ci-42" touch "$TGT/.cache-last-used" publish job1 [ "$(cat "$SNAP/debug/deps/libx.rlib")" = "gen1" ] || fail "snapshot content wrong" ok "snapshot published" [ "$(stat -c '%i' "$SNAP/debug/deps/libx.rlib")" = "$(stat -c '%i' "$TGT/debug/deps/libx.rlib")" ] \ || fail "snapshot artifact was copied, not hardlinked" ok "snapshot shares artifact inodes with the target dir (cheap)" [ "$(stat -c '%i' "$SNAP/debug/.fingerprint/x/dep-lib-x")" != "$(stat -c '%i' "$TGT/debug/.fingerprint/x/dep-lib-x")" ] \ || fail "snapshot fingerprint still aliases the live target dir" ok "snapshot owns its mutable metadata (publisher's next build cannot corrupt it)" echo echo "=== 5: locks and LRU markers do not ride along ===" [ -e "$SNAP/.ci-lock-ci-42" ] && fail "the publishing job's lock was baked into the snapshot" ok "cache lock not published" [ -e "$SNAP/.cache-last-used" ] && fail "the LRU marker was baked into the snapshot" ok "LRU marker not published" [ -e "$SNAP/debug/.cargo-lock" ] && fail "a Cargo lock file was published" ok "Cargo lock file not published" echo echo "=== 3: a consumer that cloned generation 1 ===" CONSUMER="$root/target-$(cache_key feat/consumer)" hardlink_clone_into "$SNAP" "$CONSUMER" consumer-tag || fail "consumer clone failed" ok "consumer cloned generation 1" echo echo "=== 2: republish replaces, leaves no scratch behind ===" replace_file "$TGT/debug/deps/libx.rlib" gen2 # The fingerprint IS written in place by Cargo — and the snapshot owns its own # copy precisely so that write cannot reach it. Truncating in place here is # the faithful model. echo gen2 > "$TGT/debug/.fingerprint/x/dep-lib-x" publish job1 [ "$(cat "$SNAP/debug/deps/libx.rlib")" = "gen2" ] || fail "republish did not replace the snapshot" ok "republished snapshot carries generation 2" leftovers=$(find "$root" -maxdepth 1 \( -name '.stage-*' -o -name '.publish-*' \) -print) [ -z "$leftovers" ] || fail "scratch directories left behind: $leftovers" ok "no scratch directories left behind" [ "$(cat "$CONSUMER/debug/deps/libx.rlib")" = "gen1" ] \ || 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"