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:
+76
-34
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user