Co-authored-by: Claude <claude@gitdan.com> Co-committed-by: Claude <claude@gitdan.com>
730 lines
40 KiB
Bash
Executable File
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"
|