diff --git a/scripts/prune-cache-selftest.sh b/scripts/prune-cache-selftest.sh index 8c6feac..06585c9 100755 --- a/scripts/prune-cache-selftest.sh +++ b/scripts/prune-cache-selftest.sh @@ -43,7 +43,9 @@ # 14. AND NOT WHILE ANOTHER PASS MAY STILL BE EVICTING IT. An aside with no # readers is indistinguishable from one a concurrent pass has just # renamed and not yet decided about; reclaiming that one lets `rm -rf` -# empty a tree its owner may still restore under a live cache name. +# empty a tree its owner may still restore under a live cache name. Its +# fixture is an OLD directory renamed a moment ago — production's shape, +# and the only shape that can tell the two timestamps apart. set -euo pipefail script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) . "$script_dir/cache-lib.sh" @@ -264,13 +266,21 @@ echo echo "=== 14: an aside another pass may still be evicting is left alone ===" reset_cache aside="$root/.evicting-target-$DEAD-9999" -mkdir -p "$aside"; head -c 4096 /dev/zero > "$aside/blob" +# Built the way the pass builds one — an old cache directory renamed a moment +# ago — because the two ages that describe it disagree, and which of them the +# window reads is the whole mechanism. `mkdir`-ing the aside directly would +# give it a fresh mtime as well as a fresh ctime, and a fixture whose two +# clocks agree cannot tell %Z from %Y: the settle window would read the wrong +# one, never fire for any real aside, and this scenario would not notice. +victim="$root/target-$DEAD-victim" +mkdir -p "$victim"; head -c 4096 /dev/zero > "$victim/blob" +touch -d '2020-01-01' "$victim" # an old cache, which is every cache +mv -T "$victim" "$aside" # set aside a moment ago # Deliberately no reader marker: the reader gate would pass this straight # through, which is the whole point. An aside with no readers is exactly what # a pass that has just renamed one aside and not yet decided about it looks # like, and `rm -rf` traverses fd-relative — so reclaiming it out from under # that pass lets it republish a half-emptied tree under a live cache name. -# Nothing here fakes an age: the directory really was set aside a moment ago. run_prune "1000000 900000" assert_kept "$aside" "an aside younger than the settle window is not reclaimed" assert_kept "$aside/blob" "and is left intact, not part-way emptied" diff --git a/scripts/prune-cache.sh b/scripts/prune-cache.sh index 49419fa..432b452 100755 --- a/scripts/prune-cache.sh +++ b/scripts/prune-cache.sh @@ -89,9 +89,10 @@ STALE_LOCK_SECONDS="${STALE_LOCK_SECONDS:-7200}" # An aside directory is in flight for one rename plus one marker glob — # milliseconds. Anything older belongs to a pass that died between the two, so # an age is what separates "another pass is mid-eviction" from "a leftover", -# and it separates them without having to identify the pass that created it: a -# PID is meaningless across the job containers these passes run in, and -# recycles. Three orders of magnitude of headroom over the operation it covers, +# and it separates them without having to identify the pass that created it. +# The `$$` in an aside's name was that pass's PID inside its own job +# container, so testing it with `kill -0` from a different one is not +# unreliable, it is meaningless — and PIDs recycle besides. Three orders of magnitude of headroom over the operation it covers, # and short enough that a genuine leftover is reclaimed by the next run rather # than lingering while the volume is under pressure. EVICTION_ASIDE_SETTLE_SECONDS="${EVICTION_ASIDE_SETTLE_SECONDS:-60}"