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

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:
2026-08-27 14:39:29 -05:00
parent e5b26a9368
commit 0553a6b956
3 changed files with 187 additions and 73 deletions
+76 -34
View File
@@ -317,6 +317,73 @@ _unshare_files() {
xargs -0 -r -n 64 bash -c 'rc=0; for f; do cp -p -- "$f" "$f.unshare.$$" && mv -f -- "$f.unshare.$$" "$f" || rc=1; done; exit $rc' _
}
# True when a directory holds a compiled library artifact of its own.
#
# The glob is left unquoted and unmatched-glob-safe on purpose: with nullglob
# off an unmatched pattern stays literal and the `-e` test fails, which is the
# answer wanted.
_holds_compiled_artifact() {
local f
for f in "$1"/*.rlib "$1"/*.rmeta; do
[ -e "$f" ] && return 0
done
return 1
}
# The directories `unshare_mutable_paths` replaces, under either layout.
#
# All four names are pruned, so nothing selected here can contain anything else
# selected here and the caller never unshares a subtree twice.
#
# `out` is the one that needs deciding rather than naming, and it is the whole
# difficulty of layout v2: a compile unit's rlib and a build script's OUT_DIR
# are both a directory called `out`, one directory apart, and they need
# opposite treatment.
#
# AMBIGUITY RESOLVES TOWARD UNSHARING, and that direction is the rule rather
# than a default: over-unsharing costs bytes, under-unsharing costs corruption.
# So an `out` directory is left shared only when TWO independent signals agree
# it is a compile unit's artifact directory, and either one missing is enough
# to real-copy it:
#
# 1. it holds an `.rlib`/`.rmeta` of its own — the artifact whose sharing is
# the entire point of the clone; and
# 2. its unit directory has no record of a build-script execution beside it
# (`run/` under layout v2, a loose `root-output` under v1).
#
# Signal 2 alone was the first cut of this and it is NOT sufficient, because
# Cargo writes `root-output` only AFTER the script exits successfully. A build
# script that populates `OUT_DIR` and then FAILS leaves a unit with no record
# at all, which reads as "compile unit" — and the old `-name build` selection
# covered that state by real-copying `build/` wholesale, so trusting signal 2
# alone was a regression against it. Reproduced on cargo 1.93.1 stable: the
# clone's build wrote through the shared inode into the source's OUT_DIR.
# Signal 1 closes it, because a failed build script's OUT_DIR holds no rlib.
#
# The residual is a build script that writes a file NAMED `*.rlib`/`*.rmeta`
# into `OUT_DIR` and has never once succeeded. Nothing bounds that away; it is
# simply far narrower than what it replaces.
#
# Requiring signal 1 also means a bin, test or build-script COMPILE unit's
# `out` is real-copied rather than shared — at no cost in bytes, since
# everything in one is an executable or a `*.d`, and both are privately owned
# by the file rules below either way.
_mutable_dirs() {
local root="$1" d unit
while IFS= read -r d; do
if [ "${d##*/}" = out ]; then
unit="${d%/out}"
if ! [ -d "$unit/run" ] && ! [ -e "$unit/root-output" ] \
&& _holds_compiled_artifact "$d"; then
continue
fi
fi
printf '%s\n' "$d"
done < <(find "$root" -type d \
\( -name .fingerprint -o -name fingerprint -o -name run -o -name out \) \
-prune -print 2>/dev/null)
}
# THE load-bearing function of this whole design.
#
# A hardlink clone is only safe if every write the clone's build performs
@@ -403,7 +470,7 @@ _unshare_files() {
# included. Bracketed locally: cargo 1.97.1 and 1.98.0-nightly write v1,
# 1.100.0-nightly writes v2.
#
# Until 2026-08-27 the selection below was `-name .fingerprint -o -name build`,
# Until 2026-08-27 the selection was `-name .fingerprint -o -name build`,
# which under a v2 Cargo matched nothing on its first clause and the entire
# tree on its second, because the artifacts moved under `build/` too. The guard
# held by accident and the saving did not: on one scratch crate (serde +
@@ -423,33 +490,6 @@ _unshare_files() {
# and garbage-collects old sessions by unlinking directory entries — neither
# of which mutates a shared inode. CI should still set CARGO_INCREMENTAL=0,
# for size rather than correctness.
# The directories `unshare_mutable_paths` replaces, under either layout.
#
# All four names are pruned, so nothing selected here can contain anything else
# selected here and the caller never unshares a subtree twice.
#
# `out` is the one that needs deciding rather than naming, and it is the whole
# difficulty of layout v2: a compile unit's rlib and a build script's OUT_DIR
# are both a directory called `out`, one directory apart, and they need
# opposite treatment. The discriminator is structural — Cargo records a build
# script's execution beside its OUT_DIR and nowhere else, as `run/root-output`
# under v2 and as a loose `root-output` under v1 — so a unit directory holding
# one of those is a RUN unit and its `out` is the OUT_DIR. A unit directory
# without one is a compile unit, and its `out` holds the artifact whose sharing
# is the entire point of the clone.
_mutable_dirs() {
local root="$1" d unit
while IFS= read -r d; do
if [ "${d##*/}" = out ]; then
unit="${d%/out}"
[ -d "$unit/run" ] || [ -e "$unit/root-output" ] || continue
fi
printf '%s\n' "$d"
done < <(find "$root" -type d \
\( -name .fingerprint -o -name fingerprint -o -name run -o -name out \) \
-prune -print 2>/dev/null)
}
unshare_mutable_paths() {
local root="$1" d
[ -d "$root" ] || return 0
@@ -479,12 +519,14 @@ unshare_mutable_paths() {
}
# Linked outputs. Unlike an rlib or an rmeta — which rustc writes to a
# temporary and renames into place — an executable or shared object is
# written by the LINKER, and the linker writes THROUGH an existing inode
# whenever the path it is given has no other hard link. Measured 2026-08-27
# on cargo 1.98.0-nightly (a335d47ff, layout v1) and 1.100.0-nightly
# (e8cb624d5, layout v2), mold and the default linker alike: a `cargo test`
# binary in a `cp -al` clone rewrote the SOURCE's copy of itself in place,
# under both layouts.
# written by the LINKER, and the linker writes THROUGH an existing inode.
# Measured 2026-08-27 on cargo 1.93.1 stable, 1.96.0-nightly, 1.98.0-nightly
# (layout v1) and 1.100.0-nightly (e8cb624d5, layout v2), mold and the
# default linker alike: a `cargo test --no-run` binary in a `cp -al` clone
# rewrote the SOURCE's copy of itself in place, under both layouts. Hardlink
# count at link time is NOT what decides it — measured 2 in both a rewritten
# and an intact case, and two test binaries of one crate at `nlink == 1`
# behaved differently from each other. See gitdan-actions#17.
#
# The executable bit is the discriminator because it is the linker's own
# output that is at risk, not the directory it happens to land in — `.rlib`,