Files
gitdan-actions/scripts/hardlink-clone-selftest.sh
T
claude 6a80423bd0
CI / shellcheck + selftests (pull_request) Successful in 1m17s
test(hardlink): settle checksum freshness by experiment, not by asking
Third toolchain probe in this branch, and the first one that asks the
question the suite actually depends on. The two before it each let the suite
assert a property the toolchain did not have:

  cargo +nightly -V                    answers "did a proxy called with
                                       +nightly exit 0". `-V` short-circuits
                                       before `-Z` is parsed at all.
  cargo +nightly -Z checksum-freshness answers "is the flag still accepted".
   locate-project                      1.100.0-nightly (2026-08-25) accepts it
                                       and resolves freshness by mtime anyway,
                                       which is how CI reached the final
                                       scenario and failed there.

The final scenario depends on exactly one property: that changed content with
an OLDER mtime rebuilds. Under mtime freshness the correct answer is Fresh, so
under mtime freshness that scenario asserts a bug — which is what CI reported.

So the probe performs that experiment, on its own crate and its own target
dir, with no clone anywhere near it. That separation is also what keeps it a
control rather than a restatement: the probe establishes that the toolchain
rebuilds on content, the scenario establishes that a hardlink clone did not
take that away.

Verified both ways locally: with a real content-freshness nightly, mode on and
4 assertions; with the env var stripped inside the probe — the runner's
condition, faithfully — the probe says so by name, mode goes off, the scenario
is skipped loudly and the remaining 3 assertions pass.
2026-08-26 12:54:24 -05:00

257 lines
12 KiB
Bash
Executable File

#!/usr/bin/env bash
# Regression test for the single assumption this whole caching scheme rests
# on: that a build running inside a hardlink clone cannot mutate the directory
# it was cloned from.
#
# That assumption is FALSE for a plain `cp -al`. Measured, and asserted below
# as an explicit control: build in a raw `cp -al` clone and the source's
# `.fingerprint/<unit>/dep-*` (under CARGO_UNSTABLE_CHECKSUM_FRESHNESS),
# `build/<pkg>/output`, `build/<pkg>/out/**` and `deps/*.d` all change,
# because Cargo and build scripts write those with a plain truncating write
# rather than the write-then-rename Cargo uses for real artifacts.
#
# The consequence is not a slow build, it is a wrong one: a PR clone rewrites
# the base's dep-info to describe the PR's sources while the base's cache
# still holds the artifact built from the base's sources; once the PR merges,
# the base's next run finds the checksums match its (now merged) sources,
# reports `Fresh`, and links a binary built from the pre-merge code.
#
# cache-lib.sh's unshare_mutable_paths() is what closes that, and this test is
# what proves it stays closed. The control matters as much as the fix: a
# scenario that passes for both would prove nothing.
#
# Needs a working cargo on PATH. Everything happens under a mktemp -d scratch
# tree. Run by hand: bash scripts/hardlink-clone-selftest.sh
set -euo pipefail
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
. "$script_dir/cache-lib.sh"
command -v cargo >/dev/null || { echo "SKIP: no cargo on PATH"; exit 0; }
scratch=$(mktemp -d)
trap 'rm -rf "$scratch"' EXIT
pass_count=0
fail() { echo "ASSERTION FAILED: $*" >&2; exit 1; }
ok() { pass_count=$((pass_count + 1)); echo "PASS: $*"; }
# Content hash of every file in a tree, keyed by relative path.
snapshot_tree() { (cd "$1" && find . -type f -print0 | sort -z | xargs -0 -r sha1sum) 2>/dev/null; }
# `diff` exits 1 when the trees differ, which is the expected case here and
# must not trip `pipefail` — the difference IS the result.
mutated_paths() {
{ diff <(printf '%s' "$1") <(printf '%s' "$2") || true; } 2>/dev/null \
| awk '/^[<>]/ { print $3 }' | sort -u
}
# A crate with a build script, because build-script OUT_DIR writes are one of
# the two mutation families and are invisible without one.
mkcrate() {
local dir="$1"
mkdir -p "$dir/src"
cat > "$dir/Cargo.toml" <<'TOML'
[package]
name = "probe"
version = "0.1.0"
edition = "2021"
[workspace]
TOML
cat > "$dir/build.rs" <<'RS'
use std::{env, fs, path::PathBuf};
fn main() {
println!("cargo::rerun-if-changed=src/lib.rs");
let out = PathBuf::from(env::var("OUT_DIR").unwrap());
let src = fs::read_to_string("src/lib.rs").unwrap();
fs::write(out.join("gen.txt"), format!("generated from {} bytes", src.len())).unwrap();
}
RS
}
crate_dir="$scratch/probe"
mkcrate "$crate_dir"
cd "$crate_dir"
export CARGO_INCREMENTAL=0
# Every assertion below reads cargo's own words out of a build log
# (`Compiling libdep`, `Fresh probe`). A CI image that forces colour splices an
# ANSI reset between the status word and the crate name, at which point every
# one of those greps silently stops matching and the suite reports the
# opposite of what happened — observed on gitdan-ci's runner image, where
# scenario 2 failed while the log it printed plainly showed `Compiling libdep`.
# Pin the format the assertions are written against.
export CARGO_TERM_COLOR=never
# Checksum freshness is where the worst failure lives (the dep-* file carries
# per-source checksums and is rewritten in place). Only available on nightly;
# without it the test still covers the build/ and *.d families.
CONTENT_A='pub fn f() -> u32 { 1 }'
CONTENT_B='pub fn f() -> u32 { 22222 } pub fn g() -> u32 { 7 }'
# Probe the BEHAVIOUR, not the channel and not the flag. Two weaker probes
# were tried against gitdan-ci's runner and each let the suite assert a
# property the toolchain did not have:
#
# `cargo +nightly -V` — answers "did a proxy called with
# +nightly exit 0". `-V`
# short-circuits before `-Z` is
# even parsed.
# `cargo +nightly -Z checksum-freshness — answers "is this flag still
# locate-project` accepted". 1.100.0-nightly
# (2026-08-25) accepts it and does
# not resolve freshness by content
# anyway.
#
# The scenario at the end of this file depends on one thing and it is neither
# of those: that changed content with an OLDER mtime rebuilds. Under mtime
# freshness the correct answer is Fresh, so under mtime freshness that
# scenario asserts a bug. So the probe simply performs that experiment, on its
# own crate and its own target dir, with no clone anywhere near it — which is
# also what makes it a control rather than a restatement of the scenario: the
# probe establishes that the toolchain rebuilds on content, the scenario
# establishes that a hardlink clone did not take that away.
CHECKSUM_MODE="off"
CARGO_BIN=(cargo)
checksum_freshness_active() {
local d="$scratch/freshness-probe" t="$scratch/freshness-probe-target"
mkcrate "$d"
(
cd "$d" || exit 1
printf '%s\n' "$CONTENT_A" > src/lib.rs
CARGO_TARGET_DIR="$t" cargo +nightly build -q > /dev/null 2>&1 || exit 1
printf '%s\n' "$CONTENT_B" > src/lib.rs
touch -d '@1000000000' src/lib.rs
CARGO_TARGET_DIR="$t" cargo +nightly build -v > "$scratch/freshness-probe.log" 2>&1 || exit 1
! grep -qE '^\s+Fresh probe' "$scratch/freshness-probe.log"
)
}
if cargo +nightly -Z checksum-freshness locate-project > /dev/null 2>&1; then
export CARGO_UNSTABLE_CHECKSUM_FRESHNESS=true
if checksum_freshness_active; then
CARGO_BIN=(cargo +nightly)
CHECKSUM_MODE="on"
else
unset CARGO_UNSTABLE_CHECKSUM_FRESHNESS
echo "note: this nightly accepts -Z checksum-freshness but still resolves freshness by mtime"
fi
fi
cd "$crate_dir"
echo "=== checksum-freshness mode: ${CHECKSUM_MODE} ==="
build_base() {
local dir="$1"
printf '%s\n' "$CONTENT_A" > src/lib.rs
CARGO_TARGET_DIR="$dir" "${CARGO_BIN[@]}" build -q
}
echo
echo "=== control: a raw \`cp -al\` clone DOES mutate its source ==="
base_ctl="$scratch/base-ctl"; clone_ctl="$scratch/clone-ctl"
build_base "$base_ctl"
before=$(snapshot_tree "$base_ctl")
cp -al "$base_ctl" "$clone_ctl"
strip_cargo_locks "$clone_ctl" # locks alone are not the hazard under test
printf '%s\n' "$CONTENT_B" > src/lib.rs
CARGO_TARGET_DIR="$clone_ctl" "${CARGO_BIN[@]}" build -q
after=$(snapshot_tree "$base_ctl")
ctl_mutated=$(mutated_paths "$before" "$after")
if [ -z "$ctl_mutated" ]; then
fail "control produced no mutation — the test can no longer distinguish fixed from broken"
fi
ok "raw cp -al clone mutates the source ($(printf '%s\n' "$ctl_mutated" | wc -l) paths)"
printf '%s\n' "$ctl_mutated" | sed 's/^/ /'
# Reported, not asserted, and the distinction is the point. The control's job
# is to prove the hazard exists at all, which the non-empty set above already
# does; this line records WHICH families a given Cargo exhibits.
#
# `.fingerprint/*/dep-*` is the worst of them — it carries the per-source
# checksums, so mutating it through a shared inode turns a hardlink clone into
# silent stale-artifact reuse rather than a slow build. It was measured on
# cargo 1.9x nightly (see unshare_mutable_paths in cache-lib.sh) and is NOT
# reproduced on 1.100.0-nightly (2026-08-25), where the control mutates only
# the build/ and *.d families. Failing on its absence would mean this suite
# goes red whenever upstream stops doing something we never wanted it to do —
# and it would go red in the CONTROL, where a failure reads as "the hazard is
# gone" rather than "upstream changed". Nothing is lost by reporting it: the
# fix scenario below asserts the source is byte-identical after a full rebuild
# in the clone, which covers every family this Cargo has, named or not.
if [ "$CHECKSUM_MODE" = "on" ]; then
if printf '%s' "$ctl_mutated" | grep -q '\.fingerprint/.*/dep-'; then
echo " note: this cargo DOES rewrite .fingerprint/*/dep-* in place under checksum freshness"
else
echo " note: this cargo does NOT rewrite .fingerprint/*/dep-* in place; only the build/ and *.d families appear above"
fi
fi
echo
echo "=== fix: hardlink_clone_into() leaves the source byte-identical ==="
base_fix="$scratch/base-fix"; clone_fix="$scratch/clone-fix"
build_base "$base_fix"
before=$(snapshot_tree "$base_fix")
hardlink_clone_into "$base_fix" "$clone_fix" "selftest" || fail "hardlink_clone_into reported the destination already existed"
# The clone's contract, asserted before anything builds in it: artifacts
# share inodes (that is what makes the clone near-free), and every file Cargo
# rewrites in place does not (that is what makes it sound). Checking after a
# rebuild would prove nothing — the rebuild replaces those files anyway.
shared=0; unshared=0
while IFS= read -r f; do
rel="${f#"$base_fix"/}"
[ -e "$clone_fix/$rel" ] || continue
if [ "$(stat -c '%i' "$f")" = "$(stat -c '%i' "$clone_fix/$rel")" ]; then
case "$rel" in
*/.fingerprint/*|*/build/*|*.d|.rustc_info.json)
fail "mutable path still shares an inode with the source: $rel" ;;
esac
shared=$((shared + 1))
else
unshared=$((unshared + 1))
fi
done < <(find "$base_fix" -type f)
[ "$shared" -gt 0 ] || fail "nothing is shared — the clone degenerated into a full copy"
[ "$unshared" -gt 0 ] || fail "nothing was unshared — unshare_mutable_paths did not run"
ok "fresh clone shares ${shared} artifact files and privately owns ${unshared} mutable ones"
printf '%s\n' "$CONTENT_B" > src/lib.rs
CARGO_TARGET_DIR="$clone_fix" "${CARGO_BIN[@]}" build -q
after=$(snapshot_tree "$base_fix")
fix_mutated=$(mutated_paths "$before" "$after")
if [ -n "$fix_mutated" ]; then
echo " still mutated:" >&2
printf '%s\n' "$fix_mutated" | sed 's/^/ /' >&2
fail "a build in the clone mutated the source through a shared inode"
fi
ok "no file in the source changed after a full rebuild in the clone"
echo
if [ "$CHECKSUM_MODE" = "on" ]; then
echo "=== the whole point: the source's next build is still correct ==="
# The source's cache holds artifacts built from CONTENT_A. Advance the
# source to CONTENT_B (as a merge would) and rebuild in it. If the clone had
# corrupted its dep-info, Cargo would report Fresh and keep the stale rlib.
#
# CHECKSUM-FRESHNESS ONLY, and the backdated mtime is why. Under checksum
# freshness the dep-info file's per-source checksums decide, so a 2001
# timestamp on changed content must still rebuild — the assertion below.
# Under Cargo's ordinary MTIME freshness the same timestamp means the source
# is older than the artifact, and reporting Fresh is the correct answer;
# asserting otherwise asserts a bug. This scenario was written against a
# machine with a nightly installed and, run without one, failed on that
# correct answer.
printf '%s\n' "$CONTENT_B" > src/lib.rs
touch -d '@1000000000' src/lib.rs
log="$scratch/rebuild.log"
CARGO_TARGET_DIR="$base_fix" "${CARGO_BIN[@]}" build -v > "$log" 2>&1 || { cat "$log"; fail "rebuild in the source failed"; }
if grep -qE '^\s+Fresh probe' "$log"; then
fail "source declared its own crate Fresh against sources it has never built — stale-artifact reuse"
fi
ok "source correctly rebuilt its crate after advancing to the clone's content"
else
echo "=== skipped: the source's-next-build scenario needs checksum freshness ==="
echo " (a nightly cargo accepting -Z checksum-freshness; see the probe above)"
fi
echo
echo "hardlink-clone-selftest: ${pass_count} assertions passed"