fix(hardlink): resolve an ambiguous out/ toward unsharing, and pin the linked-output scenario on a shape that exhibits it
CI / shellcheck + selftests (pull_request) Skipped
CI / shellcheck + selftests (pull_request) Skipped
Two review findings on #16. The `out/` discriminator keyed on Cargo's record of a build-script execution, which Cargo writes only AFTER the script exits successfully. A build script that populates OUT_DIR and then fails leaves a unit with no record at all, so its OUT_DIR read as a compile unit's artifact directory and stayed shared — a regression against the old `-name build` selection, which real-copied that state by construction. Reproduced on cargo 1.93.1 stable. An `out` directory now stays shared only when two independent signals agree: it holds an `.rlib`/`.rmeta` of its own, and its unit carries no execution record. Either one missing real-copies it. The cost is unchanged to the byte — the newly-unshared directories hold only executables and `*.d`, both already privately owned by the file rules. The live linked-test-binary scenario was built on the lib+bin probe crate, whose test binaries relink to a fresh inode — a shape gitdan-actions#17 records as measured safe. Both halves passed green against the unfixed selection on dep-info mutations the previous scenario already covers. It now builds a bin-only crate with a unit test, reads only executables, and skips loudly with a warning rather than passing quietly if the toolchain does not exhibit the rewrite at all. Also: drop a clause asserting the linker writes in place "whenever the path has no other hard link", which this change's own evidence denies; move the load-bearing comment block back above `unshare_mutable_paths`; correct a superseded 99.998% figure; and record both cost rows in the README rather than only the flattering whole-tree one.
This commit is contained in:
@@ -124,7 +124,8 @@ assert_partition() {
|
||||
|
||||
# The cost model, asserted rather than assumed. Selecting too much is not a
|
||||
# correctness bug, which is exactly why nothing caught layout v2 taking the
|
||||
# selection from a fifth of the tree to 99.998% of it (gitdan-actions#14):
|
||||
# selection from 39.3% of one scratch crate's tree to 99.996% of it
|
||||
# (gitdan-actions#14):
|
||||
# a hardlink clone that real-copies everything is a `cp -a` with extra steps.
|
||||
# The bound is loose on purpose. It is not a budget — the honest figure moves
|
||||
# with how much of a tree is linker output, and these fixtures are mostly
|
||||
@@ -186,6 +187,12 @@ mkfile "$v2/debug/build/probe/da96cf45111f80dd/out/gen.txt" 'generated from 24 b
|
||||
for f in invoked.timestamp root-output stdout stderr; do
|
||||
mkfile "$v2/debug/build/probe/da96cf45111f80dd/run/$f" "$f"
|
||||
done
|
||||
# A build script that wrote into OUT_DIR and then FAILED: Cargo records the
|
||||
# run only on success, so this unit has `out/` populated and no `run/` at all.
|
||||
# Reading "no execution record" as "compile unit" left this shared, which is
|
||||
# the one state the old `-name build` selection covered and the first cut of
|
||||
# this one did not.
|
||||
mkfile "$v2/debug/build/probe/f00ded00f00ded00/out/gen.txt" 'half-written'
|
||||
mkfile "$v2/debug/incremental/probe-abc/s-xyz/dep-graph.bin" "$artifact_bytes"
|
||||
|
||||
assert_partition "layout v2" "$v2" "$(cat <<'SPEC'
|
||||
@@ -205,6 +212,7 @@ private|debug/build/probe/da96cf45111f80dd/run/root-output
|
||||
private|debug/build/probe/da96cf45111f80dd/run/stdout
|
||||
private|debug/build/probe/da96cf45111f80dd/run/stderr
|
||||
private|debug/build/probe/da96cf45111f80dd/run/invoked.timestamp
|
||||
private|debug/build/probe/f00ded00f00ded00/out/gen.txt
|
||||
shared|debug/libprobe.rlib
|
||||
shared|debug/build/probe/1bf5493368dce3cd/out/libprobe-1bf5493368dce3cd.rlib
|
||||
shared|debug/build/probe/1bf5493368dce3cd/out/libprobe-1bf5493368dce3cd.rmeta
|
||||
@@ -243,6 +251,8 @@ for f in invoked.timestamp output root-output stderr; do
|
||||
mkfile "$v1/debug/build/probe-da96cf45111f80dd/$f" "$f"
|
||||
done
|
||||
mkfile "$v1/debug/build/probe-da96cf45111f80dd/out/gen.txt" 'generated from 24 bytes'
|
||||
# The same never-succeeded build script under layout v1.
|
||||
mkfile "$v1/debug/build/probe-f00ded00f00ded00/out/gen.txt" 'half-written'
|
||||
mkfile "$v1/debug/incremental/probe-abc/s-xyz/dep-graph.bin" "$artifact_bytes"
|
||||
|
||||
assert_partition "layout v1" "$v1" "$(cat <<'SPEC'
|
||||
@@ -261,6 +271,7 @@ private|debug/build/probe-da96cf45111f80dd/output
|
||||
private|debug/build/probe-da96cf45111f80dd/root-output
|
||||
private|debug/build/probe-da96cf45111f80dd/stderr
|
||||
private|debug/build/probe-da96cf45111f80dd/out/gen.txt
|
||||
private|debug/build/probe-f00ded00f00ded00/out/gen.txt
|
||||
shared|debug/libprobe.rlib
|
||||
shared|debug/deps/libprobe-1bf5493368dce3cd.rlib
|
||||
shared|debug/deps/libprobe-1bf5493368dce3cd.rmeta
|
||||
@@ -567,44 +578,84 @@ echo "=== a linked TEST binary, which nothing uplifts and nothing replaces ==="
|
||||
# The one artifact family that is NOT safe to share, and the reason
|
||||
# `unshare_mutable_paths` privately owns every executable. rustc writes an
|
||||
# rlib to a temporary and renames it in; the LINKER writes an executable
|
||||
# through whatever inode is already at the path. Cargo re-creates the path
|
||||
# first when it also has to uplift the result — a bin target's
|
||||
# `deps/<bin>-<hash>` has a hardlink twin at `<profile>/<bin>` — which is why
|
||||
# bin targets look safe. A test binary has no twin, nothing re-creates it, and
|
||||
# the write lands on the source's inode.
|
||||
# through whatever inode is already at the path.
|
||||
#
|
||||
# Measured 2026-08-27 on cargo 1.98.0-nightly (a335d47ff, layout v1) and
|
||||
# 1.100.0-nightly (e8cb624d5, layout v2): under BOTH layouts a `cargo test
|
||||
# --no-run` inside a raw `cp -al` clone rewrote the SOURCE's own test binary.
|
||||
# THE CRATE SHAPE IS LOAD-BEARING AND WAS WRONG ONCE. An earlier cut of this
|
||||
# scenario reused the lib+bin probe crate above, whose test binaries relink to
|
||||
# a FRESH inode — a shape gitdan-actions#17 records as measured safe. Both
|
||||
# halves then passed green against the unfixed selection, on the strength of
|
||||
# dep-info mutations the previous scenario already covers, and the scenario
|
||||
# pinned nothing. A bin-only crate with a unit test does exhibit the rewrite,
|
||||
# on cargo 1.93.1 stable and on 1.98.0-nightly and 1.100.0-nightly, so that is
|
||||
# what this builds. `nlink` does not predict which is which — two test binaries
|
||||
# of one crate, both at 1, behaved differently — so the shape is chosen by
|
||||
# measurement rather than derived.
|
||||
mkbincrate() {
|
||||
local dir="$1" marker="$2"
|
||||
mkdir -p "$dir/src"
|
||||
cat > "$dir/Cargo.toml" <<'TOML'
|
||||
[package]
|
||||
name = "binprobe"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
[workspace]
|
||||
TOML
|
||||
cat > "$dir/src/main.rs" <<RS
|
||||
fn main() { println!("${marker}"); }
|
||||
#[cfg(test)]
|
||||
mod t { #[test] fn a() { assert_eq!("${marker}".len() > 0, true); } }
|
||||
RS
|
||||
}
|
||||
|
||||
bin_dir="$scratch/binprobe"
|
||||
mkbincrate "$bin_dir" MARKER_AAAA
|
||||
cd "$bin_dir"
|
||||
base_exe="$scratch/base-exe"; clone_exe_ctl="$scratch/clone-exe-ctl"; clone_exe="$scratch/clone-exe"
|
||||
printf '%s\n' "$CONTENT_A" > src/lib.rs
|
||||
CARGO_TARGET_DIR="$base_exe" "${CARGO_BIN[@]}" test --no-run -q > /dev/null 2>&1
|
||||
before=$(snapshot_tree "$base_exe")
|
||||
|
||||
# Only the executables are read here. The families the other scenarios cover
|
||||
# would satisfy a "something changed" assertion on their own, which is exactly
|
||||
# how the earlier cut of this passed while pinning nothing.
|
||||
source_exe_digest() {
|
||||
(cd "$1" && find . -type f -executable -print0 | sort -z | xargs -0 -r sha1sum) 2>/dev/null
|
||||
}
|
||||
|
||||
CARGO_TARGET_DIR="$base_exe" "${CARGO_BIN[@]}" test --no-run -q > /dev/null 2>&1 \
|
||||
|| fail "the bin-only probe crate failed to build"
|
||||
before=$(source_exe_digest "$base_exe")
|
||||
cp -al "$base_exe" "$clone_exe_ctl"
|
||||
strip_cargo_locks "$clone_exe_ctl"
|
||||
printf '%s\n' "$CONTENT_B" > src/lib.rs
|
||||
mkbincrate "$bin_dir" MARKER_BBBB
|
||||
CARGO_TARGET_DIR="$clone_exe_ctl" "${CARGO_BIN[@]}" test --no-run -q > /dev/null 2>&1
|
||||
exe_ctl_mutated=$(mutated_paths "$before" "$(snapshot_tree "$base_exe")")
|
||||
[ -n "$exe_ctl_mutated" ] \
|
||||
|| fail "control: a raw cp -al clone of a test build no longer mutates the source — the test can no longer tell fixed from broken"
|
||||
ok "raw cp -al clone of a test build mutates the source ($(printf '%s\n' "$exe_ctl_mutated" | wc -l) paths)"
|
||||
printf '%s\n' "$exe_ctl_mutated" | sed 's/^/ /'
|
||||
exe_ctl_mutated=$(mutated_paths "$before" "$(source_exe_digest "$base_exe")")
|
||||
|
||||
# Rebuild the base from CONTENT_A so it is warm and consistent again, then do
|
||||
# the same thing through the real clone.
|
||||
printf '%s\n' "$CONTENT_A" > src/lib.rs
|
||||
CARGO_TARGET_DIR="$base_exe" "${CARGO_BIN[@]}" test --no-run -q > /dev/null 2>&1
|
||||
before=$(snapshot_tree "$base_exe")
|
||||
hardlink_clone_into "$base_exe" "$clone_exe" "selftest-exe" \
|
||||
|| fail "hardlink_clone_into refused the destination"
|
||||
printf '%s\n' "$CONTENT_B" > src/lib.rs
|
||||
CARGO_TARGET_DIR="$clone_exe" "${CARGO_BIN[@]}" test --no-run -q > /dev/null 2>&1
|
||||
exe_mutated=$(mutated_paths "$before" "$(snapshot_tree "$base_exe")")
|
||||
if [ -n "$exe_mutated" ]; then
|
||||
printf '%s\n' "$exe_mutated" | sed 's/^/ /' >&2
|
||||
fail "a test build in the clone mutated the source through a shared inode"
|
||||
# THREE OUTCOMES, as the freshness probe above has, and for the same reason: a
|
||||
# scenario that cannot tell "the fix works" from "the hazard never fired" is
|
||||
# not a gate. If this Cargo does not rewrite the source's test binary, the
|
||||
# assertion below would pass for a toolchain reason rather than a code one, so
|
||||
# it is skipped LOUDLY instead of passing quietly.
|
||||
if [ -z "$exe_ctl_mutated" ]; then
|
||||
echo "::warning::hardlink-clone-selftest: this toolchain did not rewrite the source's test binary through a raw cp -al clone, so the linked-output scenario proves nothing here and was SKIPPED. That is a statement about this Cargo, not about unshare_mutable_paths."
|
||||
else
|
||||
ok "control: a raw cp -al clone rewrites the source's own linked test binary"
|
||||
printf '%s\n' "$exe_ctl_mutated" | sed 's/^/ /'
|
||||
|
||||
# Rebuild the base from the original marker so it is warm and consistent
|
||||
# again, then do the same thing through the real clone.
|
||||
mkbincrate "$bin_dir" MARKER_AAAA
|
||||
CARGO_TARGET_DIR="$base_exe" "${CARGO_BIN[@]}" test --no-run -q > /dev/null 2>&1
|
||||
before=$(snapshot_tree "$base_exe")
|
||||
hardlink_clone_into "$base_exe" "$clone_exe" "selftest-exe" \
|
||||
|| fail "hardlink_clone_into refused the destination"
|
||||
mkbincrate "$bin_dir" MARKER_BBBB
|
||||
CARGO_TARGET_DIR="$clone_exe" "${CARGO_BIN[@]}" test --no-run -q > /dev/null 2>&1
|
||||
exe_mutated=$(mutated_paths "$before" "$(snapshot_tree "$base_exe")")
|
||||
if [ -n "$exe_mutated" ]; then
|
||||
printf '%s\n' "$exe_mutated" | sed 's/^/ /' >&2
|
||||
fail "a test build in the clone mutated the source through a shared inode"
|
||||
fi
|
||||
ok "no file in the source changed after a full test build in the clone"
|
||||
fi
|
||||
ok "no file in the source changed after a full test build in the clone"
|
||||
cd "$crate_dir"
|
||||
|
||||
echo
|
||||
if [ "$CHECKSUM_MODE" = "on" ]; then
|
||||
|
||||
Reference in New Issue
Block a user