CI / shellcheck + selftests (pull_request) Failing after 1m36s
The new gate's first run went red on both suites that drive a real Cargo.
Neither failure was a defect in what they test; both were assumptions about
the machine, which had only ever been a dev box with a nightly installed and
no colour forcing. Fixed here rather than waived — turning a gate on is what
obliges fixing what it finds.
COLOUR. Every assertion in restore-mtimes-selftest.sh, and one in
hardlink-clone-selftest.sh, reads cargo's own words out of a build log
(`Compiling libdep`, `Fresh probe`). gitdan-ci's runner image forces colour, so
cargo wrote `Compiling\e[0m libdep` into the log and `grep -q "Compiling
libdep"` stopped matching. The suite then reported the opposite of what
happened — the failure printed the log, and the log plainly said `Compiling
libdep`. Both suites now pin CARGO_TERM_COLOR=never, which is the format their
assertions are written against.
Red-proven: with the export removed and CARGO_TERM_COLOR=always,
restore-mtimes-selftest reproduces the CI failure verbatim ("expected to find:
Compiling libdep"); with it, 14/14 pass under the same forced colour.
THE NIGHTLY PROBE ASKED THE WRONG QUESTION. `cargo +nightly -V` answers "did a
cargo proxy called with +nightly exit 0", which is not "will this build have
checksum freshness": `-V` short-circuits before `-Z` is validated at all. So
the probe said "on" on a runner where the flag was not in effect, and the
suite asserted the checksum-freshness mutation family against a build that
never had it — failing in the CONTROL, where a failure reads as "the hazard is
gone" rather than "the toolchain is wrong". It now probes the capability:
`cargo +nightly -Z checksum-freshness locate-project`, the narrowest command
that actually parses the flag. It rejects the stable channel and an unknown
flag name alike, needs no network and builds nothing.
Red-proven: the old channel probe, pointed at a cargo without the flag,
reproduces the CI failure exactly.
AND THE OFF PATH DID NOT WORK EITHER. The suite's header claimed that without
checksum freshness "the test still covers the build/ and *.d families". It did
not: the final scenario backdates the source to 2001 and asserts the rebuild
is not Fresh, which is checksum-freshness-only reasoning. Under Cargo's
ordinary mtime freshness a 2001 source IS older than the artifact and Fresh is
the correct answer, so the scenario asserted a bug. It is now gated on the
mode and skipped loudly, like the control's dep-* assertion already was: 5
assertions with a nightly, 3 without.
CI installs a nightly as well as stable (nightly first, so stable stays the
default) — that hazard is the one this whole scheme exists to close, and a CI
that skips it is checking the cheap half.
215 lines
9.3 KiB
Bash
Executable File
215 lines
9.3 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.
|
|
CHECKSUM_MODE="off"
|
|
# Probe the CAPABILITY, not the channel. `cargo +nightly -V` answers "did a
|
|
# cargo proxy called with +nightly exit 0", which is a different question from
|
|
# "will this build have checksum freshness" — `-V` short-circuits before `-Z`
|
|
# is validated at all, so that probe says yes on any cargo that resolves the
|
|
# name, including one whose nightly has since moved the flag. The scenario
|
|
# below then asserts the checksum-freshness mutation family against a build
|
|
# that never had it, and fails in the CONTROL, where a failure reads as "the
|
|
# hazard is gone" rather than "the toolchain is wrong". Observed the first
|
|
# time this suite ran on gitdan-ci.
|
|
#
|
|
# `-Z <flag> locate-project` is the narrowest command that actually parses the
|
|
# flag: it rejects the stable channel and an unknown flag name alike, needs no
|
|
# network, and builds nothing.
|
|
if cargo +nightly -Z checksum-freshness locate-project >/dev/null 2>&1; then
|
|
export CARGO_UNSTABLE_CHECKSUM_FRESHNESS=true
|
|
CARGO_BIN=(cargo +nightly)
|
|
CHECKSUM_MODE="on"
|
|
else
|
|
CARGO_BIN=(cargo)
|
|
fi
|
|
echo "=== checksum-freshness mode: ${CHECKSUM_MODE} ==="
|
|
|
|
CONTENT_A='pub fn f() -> u32 { 1 }'
|
|
CONTENT_B='pub fn f() -> u32 { 22222 } pub fn g() -> u32 { 7 }'
|
|
|
|
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/^/ /'
|
|
|
|
if [ "$CHECKSUM_MODE" = "on" ]; then
|
|
if printf '%s' "$ctl_mutated" | grep -q '\.fingerprint/.*/dep-'; then
|
|
ok "control confirms the checksum-freshness dep-info file is among the mutated set"
|
|
else
|
|
fail "expected .fingerprint/*/dep-* in the control's mutated set under checksum freshness"
|
|
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"
|