Skip to main content

rch_common/
stale_target_reap.rs

1//! Shared predicate + shell-snippet builder for reaping *stale* per-job remote
2//! `CARGO_TARGET_DIR` directories.
3//!
4//! rch gives every forwarded-`CARGO_TARGET_DIR` build a target dir named either
5//! `.rch-target-<worker>-job-<id>-<ts>-<seq>` (per-job, the legacy/opt-out name;
6//! also `…-pid-<pid>-…`) or `.rch-target-<worker>-pool-<key>` (the default
7//! REUSED-across-jobs pooled dir keyed by build dimensions). Such a dir can
8//! stay in active use far beyond a single command — a long-running build keeps
9//! writing into it (one was observed accumulating ~11.5h of artifacts). So a
10//! per-job dir must **never** be removed merely because some build finished; that
11//! could clip a build still in flight. The idle window excludes recently used
12//! directories; durable ownership must also permit deletion because completed
13//! builds can retain quiet outputs until their requesting wrapper recovers.
14//!
15//! This logic is shared by two callers so the predicate cannot drift:
16//!
17//! 1. The **orchestrator hook** reaper
18//!    (`rch::transfer::TransferPipeline::reap_stale_sibling_per_job_target_dirs`),
19//!    which runs as a side-effect of an offloaded build and scans only the single
20//!    project dir being built on the chosen worker.
21//! 2. The **daemon-side worker sweep** (`rchd::stale_target_reap`), a periodic
22//!    background task that scans *every* project dir under the worker's
23//!    `remote_base` so orphaned dirs in repos nobody is currently building still
24//!    get reclaimed.
25//!
26//! Both share [`is_safe_reap_path`] / [`is_safe_reap_token`] (the security
27//! boundary — inputs are embedded into the generated shell) and
28//! [`reap_loop_body`] (the per-dir staleness test + removal). The matched glob is
29//! always exactly `.rch-target-*-job-*` / `.rch-target-*-pid-*` / `.rch-target-*-pool-*`
30//! — never a bare `target`, never a source dir, never `.git`/`.beads`.
31//!
32//! Pooled dirs (`-pool-`) are SHARED by concurrent jobs with identical build
33//! dimensions. Beyond the idle window, pooled targets and the durable
34//! `rch-cargo-cache-*` caches must clear two LIVENESS GATES before anything
35//! removes them: no open file descriptor (or cwd) anywhere under the dir, and
36//! no live process whose command line names it. See [`gate_snapshot_fragment`]
37//! and [`evaluate_gc_candidate`]. A gate that cannot be evaluated counts as
38//! "in use": an error must never make a directory eligible for deletion.
39//!
40//! Worker-wide deletion also shares the durable source registry transaction
41//! with admission. A completed build may retain its outputs for recovery long
42//! after its processes and mtimes go quiet; its active claim still forbids GC.
43
44const SOURCE_CLAIM_REGISTRY: &str = "/tmp/rch-source-authority-locks/claims-v1";
45
46/// Retirement failure leaves ambiguous durable ownership. Stop the entire
47/// supervisor before another candidate or successful metrics can hide it.
48const SOURCE_GC_CHECKED_RETIREMENT: &str = "if ! __gc_source_end; then \
49    printf 'RCH_GC_RETIREMENT_FAILED %s\\n' \"$d\" >&2; exit 1; fi; ";
50
51/// Shared worker-side record protocol for source admission and GC. Callers
52/// hold the registry metadata flock. Only incomplete *pending* records may be
53/// quarantined: active or cancelling records may already authorize activity.
54/// Private writes stay outside the record globs until their bytes are durable.
55pub const SOURCE_CLAIM_RECORD_HELPERS: &str = r#"
56rch_claim_name() {
57    __rch_name=${1##*/}; __rch_token=${__rch_name%%.*};
58    __rch_digest=${__rch_name#*.}; __rch_digest=${__rch_digest%%.*};
59    __rch_extension=${__rch_name##*.};
60    case "$__rch_token" in ''|*[!a-f0-9-]*) return 1;; esac;
61    [ "${#__rch_token}" -le 128 ] || return 1;
62    case "$__rch_digest" in *[!a-f0-9]*) return 1;; esac;
63    [ "${#__rch_digest}" -eq 64 ] || return 1;
64    case "$__rch_extension" in claim|pending|cancelling) ;; *) return 1;; esac;
65    [ "$__rch_name" = "$__rch_token.$__rch_digest.$__rch_extension" ];
66};
67rch_claim_content_valid() {
68    [ ! -L "$1" ] && [ -f "$1" ] || return 1;
69    __rch_size=$(wc -c < "$1") || return 1;
70    [ "$__rch_size" -gt 0 ] && [ "$__rch_size" -le 33554432 ] || return 1;
71    __rch_actual=$(sha256sum -- "$1") || return 1;
72    [ "${__rch_actual%% *}" = "$2" ] || return 1;
73    while IFS= read -r __rch_root || [ -n "$__rch_root" ]; do
74        case "$__rch_root" in /*) ;; *) return 1;; esac;
75        case "$__rch_root" in *//*|*/./*|*/../*|*/.|*/..) return 1;; esac;
76        [ "$__rch_root" = / ] || [ "${__rch_root%/}" = "$__rch_root" ] || return 1;
77    done < "$1";
78};
79rch_claim_record_valid() {
80    rch_claim_name "$1" && rch_claim_content_valid "$1" "$__rch_digest";
81};
82rch_claim_quarantine_fence() {
83    # Only our hard-linked fence, with no active grant, is an unowned intent.
84    # Byte similarity alone cannot bless an independent corrupt cancellation.
85    rch_claim_name "$2" || return 1;
86    [ "$__rch_extension" = cancelling ] && [ ! -L "$2" ] && [ -f "$2" ] || return 1;
87    __rch_quarantined="$1/quarantine/$__rch_token.$__rch_digest.pending";
88    [ ! -L "$1/quarantine" ] && [ ! -L "$__rch_quarantined" ] \
89        && [ -f "$__rch_quarantined" ] && [ "$2" -ef "$__rch_quarantined" ] || return 1;
90    for __rch_active in "$1/$__rch_token."*.claim; do
91        [ ! -e "$__rch_active" ] && [ ! -L "$__rch_active" ] || return 1;
92    done;
93    cmp -s "$2" "$__rch_quarantined";
94};
95rch_claim_quarantine_pending() {
96    # A .pending file never grants source activity. Its original filename is
97    # retained permanently: quarantine is an identity fence, not fresh state.
98    rch_claim_name "$2" || return 1;
99    [ "$__rch_extension" = pending ] && [ ! -L "$2" ] && [ -f "$2" ] || return 1;
100    [ ! -L "$1/quarantine" ] || return 1;
101    (umask 077; mkdir -p -- "$1/quarantine") || return 1;
102    __rch_quarantined="$1/quarantine/$__rch_name";
103    [ ! -e "$__rch_quarantined" ] && [ ! -L "$__rch_quarantined" ] || return 1;
104    __rch_fence="$1/$__rch_token.$__rch_digest.cancelling";
105    for __rch_previous in "$1/$__rch_token."*.claim "$1/$__rch_token."*.cancelling \
106        "$1/released/$__rch_token."*.claim "$1/cancelled/$__rch_token."*.claim; do
107        [ -e "$__rch_previous" ] || [ -L "$__rch_previous" ] || continue;
108        # Resume only our own interrupted hard-link-before-rename frontier.
109        [ "$__rch_previous" = "$__rch_fence" ] && [ ! -L "$__rch_previous" ] \
110            && [ "$2" -ef "$__rch_previous" ] || return 1;
111    done;
112    if [ ! -e "$__rch_fence" ]; then
113        ln -- "$2" "$__rch_fence" || return 1;
114    fi;
115    # Old in-flight dispatcher scripts do not know quarantine/. They DO
116    # reject this incomplete cancelling record, so cannot revive the token.
117    sync -f "$1" || return 1;
118    mv -T -- "$2" "$__rch_quarantined" || return 1;
119    sync -f "$1/quarantine" && sync -f "$1" || return 1;
120    printf 'RCH_SOURCE_CLAIM_QUARANTINED %s\n' "$__rch_quarantined" >&2;
121};
122rch_claim_write_atomic() {
123    # The existing workers already require GNU sync -f/sha256sum/realpath.
124    # A failed write/fsync/rename leaves private evidence, never a partially
125    # visible .pending or .cancelling record. No failure path deletes bytes.
126    [ ! -e "$2" ] && [ ! -L "$2" ] || return 1;
127    __rch_temporary=$(mktemp "$1/.record-write.XXXXXXXXXX") || return 1;
128    if ! printf '%s\n' "$4" > "$__rch_temporary" \
129        || ! rch_claim_content_valid "$__rch_temporary" "$3" \
130        || ! sync -f "$__rch_temporary"; then
131        printf 'RCH_SOURCE_CLAIM_WRITE_FAILED %s\n' "$__rch_temporary" >&2;
132        return 1;
133    fi;
134    [ ! -e "$2" ] && [ ! -L "$2" ] || return 1;
135    mv -T -- "$__rch_temporary" "$2" && sync -f "$1";
136};
137"#;
138
139/// The glob patterns matched for reaping. Restricted to per-job / per-pid /
140/// pooled dirs so a bare `target` (or any non-rch dir) is never touched.
141pub const REAP_GLOBS: &[&str] = &[
142    ".rch-target-*-job-*",
143    ".rch-target-*-pid-*",
144    ".rch-target-*-pool-*",
145];
146
147/// Basename glob of the DURABLE per-worker Cargo cache dirs
148/// (`rch-cargo-cache-<worker>`, issue #42) that `rch gc` enumerates and — under
149/// the full gate set — may collect.
150///
151/// Deliberately NOT in [`REAP_GLOBS`]: that list drives the per-job reaper and
152/// the rsync exclude set, where a Cargo cache must never appear. Kept in sync
153/// with [`crate::gc_roots::RCH_CARGO_CACHE_PREFIX`] by
154/// `cargo_cache_glob_tracks_the_creating_prefix`.
155pub const CARGO_CACHE_GLOB: &str = "rch-cargo-cache-*";
156
157/// Minimum non-zero cargo-cache idle window in minutes (24h), mirroring
158/// [`MIN_POOLED_IDLE_MINUTES`]: a durable Cargo cache is a warm cache too, so a
159/// misconfigured short window is floored rather than honored.
160pub const MIN_CARGO_CACHE_IDLE_MINUTES: u64 = 24 * 60;
161
162/// Convert `[remediation.pooled_target] reaper_pooled_idle_hours` into the
163/// pooled idle window in minutes. `0` means "never collect pooled dirs";
164/// anything else is floored at [`MIN_POOLED_IDLE_MINUTES`].
165///
166/// One helper so `rch gc`, `rch cache status`, the daemon sweep and the
167/// transfer-start janitor cannot each round the same knob differently.
168#[must_use]
169pub fn pooled_idle_minutes_from_hours(idle_hours: u32) -> Option<u64> {
170    (idle_hours != 0).then(|| (u64::from(idle_hours) * 60).max(MIN_POOLED_IDLE_MINUTES))
171}
172
173/// Convert `[remediation.pooled_target] gc_cargo_cache_idle_days` into the
174/// cargo-cache idle window in minutes. `0` means "never collect cache dirs";
175/// anything else is floored at [`MIN_CARGO_CACHE_IDLE_MINUTES`].
176#[must_use]
177pub fn cargo_cache_idle_minutes_from_days(idle_days: u32) -> Option<u64> {
178    (idle_days != 0).then(|| (u64::from(idle_days) * 24 * 60).max(MIN_CARGO_CACHE_IDLE_MINUTES))
179}
180
181/// Whether `s` is safe to use as a `cd` target / `find` root of a reap script:
182/// absolute, at least two path segments deep (never `/` or a bare top-level dir),
183/// no `..`, and composed only of unambiguous path characters (no shell
184/// metacharacters, quotes, spaces, or globs).
185///
186/// This is the security boundary: reap inputs are embedded into a generated shell
187/// command (inside double quotes), so anything that could break out of that
188/// context, escape the intended scope, or traverse upward is rejected.
189pub fn is_safe_reap_path(s: &str) -> bool {
190    s.starts_with('/')
191        && s.matches('/').count() >= 2
192        && !s.contains("..")
193        && s.len() <= 4096
194        && s.chars().all(is_safe_reap_char)
195}
196
197/// Whether `s` is safe to use as a *base* directory of a reap script (the
198/// worker's `remote_base`, e.g. `/tmp/rch`). Looser than [`is_safe_reap_path`]
199/// only in that it permits a single path segment (e.g. `/srv`), but still rejects
200/// the filesystem root, `..`, and shell metacharacters.
201pub fn is_safe_reap_base(s: &str) -> bool {
202    s.starts_with('/')
203        && s.matches('/').count() >= 1
204        && s.trim_end_matches('/').len() > 1
205        && !s.contains("..")
206        && s.len() <= 4096
207        && s.chars().all(is_safe_reap_char)
208}
209
210/// Whether `s` is safe to embed as a directory basename token in a reap script
211/// (e.g. the current job's dir name, used to exclude it from reaping).
212pub fn is_safe_reap_token(s: &str) -> bool {
213    !s.is_empty()
214        && s != "."
215        && s != ".."
216        && !s.contains('/')
217        && s.len() <= 255
218        && s.chars().all(is_safe_reap_char)
219}
220
221/// The only characters permitted in reap-script path inputs. Excludes every shell
222/// metacharacter (quotes, `$`, backtick, `*`, spaces, `;`, `|`, `&`, …) so the
223/// inputs cannot break out of their double-quoted context.
224pub fn is_safe_reap_char(c: char) -> bool {
225    c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.')
226}
227
228/// Floor (in hours) below which the idle threshold is never allowed to drop,
229/// regardless of configuration — so a misconfiguration can never reap a live
230/// incremental cache. Mirrors the hook's `stale_target_reap_idle_hours` floor.
231pub const MIN_IDLE_HOURS: u32 = 1;
232
233/// Convert an `idle_hours` setting to the `find -mmin` window used by the reap
234/// predicate, applying the 1h floor.
235pub fn idle_minutes_from_hours(idle_hours: u32) -> u64 {
236    u64::from(idle_hours.max(MIN_IDLE_HOURS)) * 60
237}
238
239/// The shared per-dir reap predicate + removal, as a `sh` loop body operating on a
240/// loop variable `$d` (a candidate dir path or basename, already confirmed to be a
241/// directory by the caller's loop).
242///
243/// For each candidate it keeps the dir if the dir **or any descendant** (file or
244/// subdir) was modified within the idle window — an active or just-`mkdir`'d build
245/// — and otherwise `rm -rf`s it. `-mmin -N -print -quit` stops at the first recent
246/// entry, so live dirs are detected cheaply. Deliberately **no** `-type f`: an
247/// empty, just-created dir (a concurrent build's target before its first write)
248/// has zero files but a recent dir mtime and must be kept.
249///
250/// `idle_minutes` is the window; `exclude_token`, when `Some`, is a basename to
251/// skip (the orchestrator's own current job dir). `removed_counter` / `freed_kb`
252/// are shell variable names the body increments so callers can emit metrics
253/// (pass empty strings to skip accounting). The body assumes `$d` holds the
254/// candidate path and does **not** itself iterate.
255pub fn reap_loop_body(
256    idle_minutes: u64,
257    exclude_token: Option<&str>,
258    removed_counter: &str,
259    freed_kb: &str,
260) -> String {
261    reap_loop_body_with_event(idle_minutes, exclude_token, removed_counter, freed_kb, None)
262}
263
264/// [`reap_loop_body`] with an optional per-removal EVENT line: when `trigger`
265/// is `Some` (and counters are enabled), every successful removal prints
266/// `RCH_REAP_RM <kb> <trigger> <path>` so a reviewer can reconstruct any
267/// deletion — which dir, how big, which policy removed it — from the sweep
268/// output alone (bead 6dj11's observability requirement; also the
269/// diagnosability gap named in bd-kwvy8).
270pub fn reap_loop_body_with_event(
271    idle_minutes: u64,
272    exclude_token: Option<&str>,
273    removed_counter: &str,
274    freed_kb: &str,
275    trigger: Option<&str>,
276) -> String {
277    reap_loop_body_with_source_gate(
278        idle_minutes,
279        exclude_token,
280        removed_counter,
281        freed_kb,
282        trigger,
283        false,
284    )
285}
286
287fn reap_loop_body_with_source_gate(
288    idle_minutes: u64,
289    exclude_token: Option<&str>,
290    removed_counter: &str,
291    freed_kb: &str,
292    trigger: Option<&str>,
293    source_gate: bool,
294) -> String {
295    let exclude = match exclude_token {
296        Some(tok) => format!("[ \"$d\" = \"{tok}\" ] && continue; "),
297        None => String::new(),
298    };
299    let (claim, unlock, recent) = if source_gate {
300        (
301            checked_source_admission(&shell_escape::escape(trigger.unwrap_or("ttl").into())),
302            SOURCE_GC_CHECKED_RETIREMENT,
303            checked_source_recent_activity(
304                &idle_minutes.to_string(),
305                &shell_escape::escape(trigger.unwrap_or("ttl").into()),
306                true,
307            ),
308        )
309    } else {
310        (
311            String::new(),
312            "",
313            format!(
314                "if find \"$d\" -mmin -{idle_minutes} -print -quit 2>/dev/null | grep -q .; then continue; fi; "
315            ),
316        )
317    };
318    // Account for size only when both counter var names are provided.
319    let (size_capture, removal) = if removed_counter.is_empty() || freed_kb.is_empty() {
320        (String::new(), "rm -rf -- \"$d\" 2>/dev/null;".to_string())
321    } else {
322        // Tagged (sweep) contexts capture rm's stderr instead of silencing it
323        // (bd-kwvy8: a half-removed dir with removed=0 was undiagnosable
324        // because the failing rm was 2>/dev/null'd) — a failed removal emits
325        // `RCH_REAP_ERR <trigger> <path> :: <first stderr line>`.
326        let removal_body = match trigger {
327            Some(tag) => format!(
328                "if __rmerr=$(rm -rf -- \"$d\" 2>&1); then \
329                   {removed_counter}=$(({removed_counter} + 1)); {freed_kb}=$(({freed_kb} + sz)); \
330                   printf 'RCH_REAP_RM %s {tag} %s\\n' \"$sz\" \"$d\"; \
331                 else \
332                   printf 'RCH_REAP_ERR {tag} %s :: %s\\n' \"$d\" \"$(printf '%s' \"$__rmerr\" | head -1)\"; \
333                 fi;"
334            ),
335            None => format!(
336                "if rm -rf -- \"$d\" 2>/dev/null; then {removed_counter}=$(({removed_counter} + 1)); {freed_kb}=$(({freed_kb} + sz)); fi;"
337            ),
338        };
339        (
340            "sz=$(du -sk \"$d\" 2>/dev/null | awk '{print $1}'); [ -z \"$sz\" ] && sz=0; "
341                .to_string(),
342            removal_body,
343        )
344    };
345    format!(
346        "[ -d \"$d\" ] || continue; \
347         {exclude}\
348         {claim}\
349         {recent}\
350         {size_capture}{removal}{unlock}"
351    )
352}
353
354/// Known ownership/retention holds skip one candidate. Unknown retention must
355/// stop the supervisor before it can emit successful sweep metrics.
356fn checked_source_admission(trigger_argument: &str) -> String {
357    format!(
358        "if __gc_source_begin \"$d\"; then d=$__gc_candidate; else \
359           __gc_admission_status=$?; \
360           case \"$__gc_admission_status\" in \
361             1|3) printf 'RCH_GC_SKIP %s source-ownership %s\\n' {trigger_argument} \"$d\"; continue;; \
362             *) printf 'RCH_GC_ADMISSION_UNKNOWN %s\\n' \"$d\" >&2; exit 1;; \
363           esac; \
364         fi; "
365    )
366}
367
368/// Inspect the real find result, without letting a pipeline's matcher hide a
369/// traversal failure. A held claim must retire before either skip or refusal.
370fn checked_source_recent_activity(
371    minutes_argument: &str,
372    trigger_argument: &str,
373    source_held: bool,
374) -> String {
375    let end = if source_held {
376        SOURCE_GC_CHECKED_RETIREMENT
377    } else {
378        ""
379    };
380    format!(
381        "if __gc_source_recent_activity \"$d\" {minutes_argument}; then \
382           {end}printf 'RCH_GC_SKIP %s active %s\\n' {trigger_argument} \"$d\"; continue; \
383         else __gc_recent_status=$?; fi; \
384         if [ \"$__gc_recent_status\" -ne 1 ]; then {end}exit 1; fi; "
385    )
386}
387
388/// Keep the metadata lock in the supervising shell from the ownership check
389/// through the final filesystem mutation. No source hierarchy/activity lock is
390/// acquired here, so admission's hierarchy -> metadata order cannot invert.
391/// The HUP-resistant parent retains fd 8 even if a removal tool closes its own
392/// inherited descriptors after the SSH connection disappears. Its durable GC
393/// claim remains after SIGKILL/OOM too; only completed child activity permits
394/// retirement, and an interrupted removal stays unavailable for inspection.
395/// Defines `__gc_source_begin` and `__gc_source_end` for the production registry.
396/// A caller must check end after every successful begin, before leaving the
397/// candidate interval. Returning this fragment alone acquires no ownership.
398/// Begin returns 0 with ownership held, 1 for a verified ownership hold, 2 for
399/// unknown setup/publication/inspection, and 3 for a known retention hold.
400pub fn source_claim_gc_guard_fragment() -> String {
401    source_claim_gate_fragment(SOURCE_CLAIM_REGISTRY)
402}
403
404/// Retired source trees keep this marker after their active claim is released.
405/// Hold a candidate containing a marker or nested beneath a marked ancestor.
406/// Inspect only each ancestor's immediate marker, never its sibling trees.
407/// Marker presence alone is a hold; symlink targets are not followed.
408/// Return 0 for retained, 1 for known unmarked, and 2 for unknown inspection.
409pub const RETAINED_SOURCE_GC_GUARD: &str = r#"
410rch_retained_source_gc_hold() {
411    case "$1" in /*) ;; *) return 2;; esac
412    if rch_retained_marker=$(find "$1" -name '.rch-retention-v1' -print -quit); then
413        if [ -n "$rch_retained_marker" ]; then return 0; fi
414    else
415        printf 'RCH_CLEANUP_RETENTION_UNKNOWN dir=%s\n' "$1" >&2
416        return 2
417    fi
418    rch_retained_parent=${1%/*}
419    [ -n "$rch_retained_parent" ] || rch_retained_parent=/
420    while :; do
421        # Look the one name up directly instead of listing the ancestor: in a
422        # busy /data/tmp a sibling deleted between readdir and lstat made find
423        # fail, and every candidate failed closed. An unsearchable ancestor
424        # still cannot rule a marker out.
425        rch_retained_marker=${rch_retained_parent%/}/.rch-retention-v1
426        if [ -e "$rch_retained_marker" ] || [ -L "$rch_retained_marker" ]; then return 0; fi
427        if [ ! -d "$rch_retained_parent" ] || [ ! -x "$rch_retained_parent" ]; then
428            printf 'RCH_CLEANUP_RETENTION_UNKNOWN dir=%s ancestor=%s\n' \
429                "$1" "$rch_retained_parent" >&2
430            return 2
431        fi
432        [ "$rch_retained_parent" != / ] || break
433        rch_retained_parent=${rch_retained_parent%/*}
434        [ -n "$rch_retained_parent" ] || rch_retained_parent=/
435    done
436    return 1
437}
438"#;
439
440/// Generate the identical transaction for an isolated caller-owned registry.
441/// This permits local fixtures to exercise the real protocol without touching
442/// the worker's production registry. It does not acquire or waive ownership.
443pub fn source_claim_gc_guard_fragment_with_registry(registry: &str) -> String {
444    source_claim_gate_fragment(registry)
445}
446
447fn source_claim_gate_fragment(registry: &str) -> String {
448    const GATE: &str = r#"
449trap '' HUP;
450__gc_source_recent_activity() {
451    if __gc_recent_path=$(find "$1" -mmin -"$2" -print -quit); then
452        if [ -n "$__gc_recent_path" ]; then return 0; fi;
453        return 1;
454    fi;
455    printf 'RCH_GC_ACTIVITY_UNKNOWN %s\n' "$1" >&2;
456    return 2;
457};
458__gc_source_realpath() {
459    __gc_resolved=$(realpath -m -- "$1" && printf '.') || return 1;
460    __gc_resolved=${__gc_resolved%.}; __gc_resolved=${__gc_resolved%?};
461    __gc_lines=$(printf '%s\n' "$__gc_resolved" | wc -l);
462    __gc_cr=$(printf '\r');
463    [ "$__gc_lines" -eq 1 ] || return 1;
464    case "$__gc_resolved" in /*) ;; *) return 1;; esac;
465    case "$__gc_resolved" in *"$__gc_cr"*) return 1;; esac;
466};
467__gc_source_records_free() {
468    __gc_source_realpath "$1" || return 2; __gc_candidate=$__gc_resolved;
469    for __gc_record in "$__gc_source_registry"/*.claim "$__gc_source_registry"/*.pending; do
470        [ -e "$__gc_record" ] || [ -L "$__gc_record" ] || continue;
471        if ! rch_claim_record_valid "$__gc_record"; then
472            rch_claim_quarantine_pending "$__gc_source_registry" "$__gc_record" || return 2;
473            continue;
474        fi;
475        while IFS= read -r __gc_root || [ -n "$__gc_root" ]; do
476            case "$__gc_root" in /*) ;; *) return 2;; esac;
477            case "$__gc_root" in *//*|*/./*|*/../*|*/.|*/..) return 2;; esac;
478            [ "$__gc_root" = / ] || [ "${__gc_root%/}" = "$__gc_root" ] || return 2;
479            __gc_source_realpath "$__gc_root" || return 2; __gc_root=$__gc_resolved;
480            [ "$__gc_root" != / ] && [ "$__gc_candidate" != / ] || return 2;
481            case "$__gc_candidate" in "$__gc_root"|"$__gc_root"/*) return 3;; esac;
482            case "$__gc_root" in "$__gc_candidate"/*) return 3;; esac;
483        done < "$__gc_record";
484    done;
485};
486__gc_source_end() {
487    if [ -n "${__gc_reservation-}" ]; then
488        if [ -L "$__gc_reservation" ] || [ ! -f "$__gc_reservation" ]; then
489            exec 8>&-; return 1;
490        fi;
491        __gc_actual=$(sha256sum -- "$__gc_reservation") || { exec 8>&-; return 1; };
492        [ "${__gc_actual%% *}" = "$__gc_reservation_digest" ] || { exec 8>&-; return 1; };
493        [ ! -L "$__gc_source_registry/released" ] || { exec 8>&-; return 1; };
494        mkdir -p -- "$__gc_source_registry/released" || { exec 8>&-; return 1; };
495        __gc_receipt="$__gc_source_registry/released/${__gc_reservation##*/}";
496        [ ! -e "$__gc_receipt" ] && [ ! -L "$__gc_receipt" ] || { exec 8>&-; return 1; };
497        if ! mv -- "$__gc_reservation" "$__gc_receipt" || ! sync -f "$__gc_receipt" \
498            || ! sync -f "$__gc_source_registry/released" || ! sync -f "$__gc_source_registry"; then
499            exec 8>&-; return 1;
500        fi;
501        __gc_reservation="";
502    fi;
503    exec 8>&-;
504};
505__gc_source_begin() {
506    __gc_reservation="";
507    [ ! -L "$__gc_source_registry" ] || return 2;
508    [ ! -L "$__gc_source_registry/quarantine" ] || return 2;
509    (umask 077; mkdir -p -- "$__gc_source_registry") || return 2;
510    [ ! -L "$__gc_source_registry/metadata.lock" ] || return 2;
511    exec 8>"$__gc_source_registry/metadata.lock" || return 2;
512    if ! flock -x 8; then __gc_source_end; return 2; fi;
513    if [ ! -d "$1" ] || [ -L "$1" ]; then __gc_source_end; return 2; fi;
514    if __gc_source_records_free "$1"; then :; else
515        __gc_records_status=$?;
516        if ! __gc_source_end; then return 2; fi;
517        if [ "$__gc_records_status" -eq 3 ]; then return 1; fi;
518        return 2;
519    fi;
520    if rch_retained_source_gc_hold "$__gc_candidate"; then
521        printf 'RCH_GC_RETENTION_HOLD %s\n' "$__gc_candidate" >&2;
522        if ! __gc_source_end; then return 2; fi;
523        return 3;
524    else
525        __gc_retention_status=$?;
526    fi;
527    if [ "$__gc_retention_status" -ne 1 ]; then
528        __gc_source_end; return 2;
529    fi;
530    __gc_token=fc-$(cat /proc/sys/kernel/random/uuid) || { __gc_source_end; return 2; };
531    case "$__gc_token" in *[!a-f0-9-]*) __gc_source_end; return 2;; esac;
532    [ "${#__gc_token}" -eq 39 ] || { __gc_source_end; return 2; };
533    for __gc_previous in "$__gc_source_registry/$__gc_token."* \
534        "$__gc_source_registry/released/$__gc_token."* "$__gc_source_registry/cancelled/$__gc_token."* \
535        "$__gc_source_registry/quarantine/$__gc_token."*; do
536        if [ -e "$__gc_previous" ] || [ -L "$__gc_previous" ]; then __gc_source_end; return 2; fi;
537    done;
538    __gc_actual=$(printf '%s\n' "$__gc_candidate" | sha256sum) || { __gc_source_end; return 2; };
539    __gc_reservation_digest=${__gc_actual%% *};
540    __gc_pending="$__gc_source_registry/$__gc_token.$__gc_reservation_digest.pending";
541    if ! rch_claim_write_atomic "$__gc_source_registry" "$__gc_pending" \
542        "$__gc_reservation_digest" "$__gc_candidate"; then __gc_source_end; return 2; fi;
543    __gc_active="$__gc_source_registry/$__gc_token.$__gc_reservation_digest.claim";
544    if ! mv -- "$__gc_pending" "$__gc_active" || ! sync -f "$__gc_source_registry"; then
545        __gc_source_end; return 2;
546    fi;
547    __gc_reservation=$__gc_active;
548};
549"#;
550    format!(
551        "__gc_source_registry={}; {SOURCE_CLAIM_RECORD_HELPERS}\n{RETAINED_SOURCE_GC_GUARD}\n{GATE}",
552        shell_escape::escape(registry.into())
553    )
554}
555
556/// The shared candidate-discovery preamble for worker-wide sweeps: canonicalize
557/// `$base` (already validated by [`is_safe_reap_base`]), refuse shallow roots,
558/// and write every candidate dir path into `$__tmpf` — the per-job/per-pid
559/// `.rch-target-*` dirs at any depth under `$base`, plus the LEGACY
560/// `rch_target_*` trees directly under the worker's tmp base (bead 6dj11: the
561/// 2026-07-10 css incident class, which nothing else reaps). The tmp base is
562/// `$TMPDIR` when set, else `/data/tmp`, else `/tmp`, and the legacy pass is
563/// gated on it being at least two segments deep so a bare `/tmp` fallback is
564/// deliberately never scanned (mirrors the `$base` depth guard).
565///
566/// The tmp base is resolved into `$__tmpscan` — canonicalized with `pwd -P`
567/// exactly like `$__rt`, and empty when it must not be scanned. Canonicalizing
568/// BOTH roots is what makes the string dedup sound: if one root were canonical
569/// and the other not, the same physical directory could appear under two
570/// spellings and be counted (and byte-capped) twice. The depth guard is applied
571/// before AND after resolution, so a `/tmp` fallback that canonicalizes to a
572/// deeper path (macOS `/private/tmp`) still does not become sweepable.
573/// `$__tmpbase` itself remains the `mktemp` location.
574///
575/// `on_guard_exit` is the `sh` fragment run before `exit 0` on every guard
576/// bail-out (e.g. printing an empty metrics line so callers always parse a
577/// result). The `find … > file` + `while read … < file` shape (instead of a
578/// pipe) keeps the caller's loop in the parent shell so counters survive.
579/// Depth to scan under the worker's tmp base for dot-prefixed reap-class dirs.
580///
581/// rch stages pooled/per-job targets at
582/// `<tmpbase>/rch/<project>/<hash>/.rch-target-<worker>-{pool,job,pid}-<key>`,
583/// i.e. THREE levels below the tmp base — outside the sync-root (`$__rt`)
584/// walk that every other pass uses. Before this, only the depth-1
585/// `rch_target_*` legacy pass looked at the tmp base at all, so those trees
586/// were never reaped and grew to 110-129 GB per worker while `rch gc`
587/// truthfully reported "removed 0 dir(s), freed 0 MB". Six leaves headroom
588/// for a deeper stage layout without turning the walk into a full-tree scan.
589const TMPBASE_MAXDEPTH: u32 = 6;
590
591/// Emit an in-place dedup of the candidate list held in `$<var>` that can never
592/// truncate it.
593///
594/// The tmp-base pass can rediscover a dir the `$base` pass already listed (when
595/// the sync root sits under the tmp base), and a duplicate would be counted
596/// twice in the byte-cap pass's `total_kb`, over-evicting warm caches. The
597/// obvious `sort -u F -o F` is safe with GNU sort on a healthy filesystem — but
598/// this sweep runs precisely when a disk is FULL, and a sort that fails after
599/// opening its output would leave the candidate list empty, so gc would reap
600/// nothing exactly when it is needed most. Sorting into a fresh temp and moving
601/// it over only on success keeps the original list intact on any failure; the
602/// cost of a failed dedup is a duplicate entry, which the reap loop already
603/// tolerates via its leading `[ -d "$d" ] || continue`.
604fn dedup_candidate_file(var: &str) -> String {
605    format!(
606        "if __ded=$(mktemp 2>/dev/null || mktemp -p \"$__tmpbase\" 2>/dev/null); then \
607           if sort -u \"${var}\" -o \"$__ded\" 2>/dev/null; then mv -f \"$__ded\" \"${var}\"; \
608           else rm -f \"$__ded\"; fi; \
609         fi; "
610    )
611}
612
613/// Shell fragment that builds the two GATE SNAPSHOTS every collection of a
614/// pooled/cache dir is required to clear, plus the `__gc_gates_ok` helper that
615/// tests one dir against them.
616///
617/// Two snapshots, taken ONCE per script run (not per candidate — a per-dir
618/// `lsof +D` walks the whole tree, and a warm pool holds millions of inodes):
619///
620/// * `$__held` — every path currently open anywhere on the worker: each
621///   process's cwd plus every open file descriptor. Read straight from
622///   `/proc/<pid>/{cwd,fd}` with one GNU `find -printf '%l'` (no fork per
623///   pid); if `/proc` is unusable it falls back to a single system-wide
624///   `lsof -F n`. `$__held_ok` is 1 only when a snapshot was actually
625///   produced.
626/// * `$__proc` — every process's full command line (`ps -eo args=`), so a
627///   build rooted at a dir is caught even when it holds no descriptor open at
628///   the instant of the sweep (a `rustc` between writes, a `cargo` driver
629///   whose child does the I/O). `$__proc_ok` is 1 only when it is non-empty.
630///
631/// `__gc_gates_ok <dir>` returns 0 (safe to collect) ONLY when BOTH snapshots
632/// exist AND neither matches the dir. Every failure mode — no `/proc`, no
633/// `lsof`, no `ps`, an unwritable temp dir — leaves the corresponding `_ok`
634/// flag at 0 and the helper returns non-zero, i.e. an unavailable gate makes a
635/// dir INELIGIBLE. A false "busy" only wastes disk; a false "free" deletes a
636/// live build.
637///
638/// Matching is exact-prefix via `awk index()`, never a regex: candidate paths
639/// come from `find` output and must never be reinterpreted as a pattern.
640fn gate_snapshot_fragment() -> String {
641    concat!(
642        "__held=\"\"; __held_ok=0; __hsrc=none; __proc=\"\"; __proc_ok=0; ",
643        "if __held=$(mktemp 2>/dev/null || mktemp -p \"$__tmpbase\" 2>/dev/null); then ",
644        "  if [ -d /proc/self/fd ]; then ",
645        "    find /proc/[0-9]*/cwd /proc/[0-9]*/fd -maxdepth 1 -printf '%l\\n' 2>/dev/null > \"$__held\" || :; ",
646        "  fi; ",
647        "  if [ -s \"$__held\" ]; then __hsrc=proc; ",
648        "  elif command -v lsof >/dev/null 2>&1; then ",
649        "    lsof -w -n -P -F n 2>/dev/null | sed -n 's/^n//p' > \"$__held\" || :; ",
650        "    if [ -s \"$__held\" ]; then __hsrc=lsof; fi; ",
651        "  fi; ",
652        "  if [ -s \"$__held\" ]; then __held_ok=1; fi; ",
653        "fi; ",
654        // The sweep's OWN process chain is excluded from the command-line
655        // snapshot. `collect_paths_command` embeds the paths it is about to
656        // remove into the script, so the shell running it has every one of
657        // them in its argv — without this, the gate matches the sweep itself
658        // and nothing is ever collected (the `pgrep -f` self-match trap, one
659        // level up). Only ancestors are excluded: the snapshot is taken once,
660        // before any candidate is examined, so transient children (`awk`,
661        // `find`, `du`) that carry a candidate path are not in it.
662        "__self_pids=\" $$ \"; __pp=$$; __i=0; ",
663        "while [ \"$__i\" -lt 12 ]; do ",
664        "  __pp=$(ps -o ppid= -p \"$__pp\" 2>/dev/null | tr -d ' '); ",
665        "  case \"$__pp\" in ''|0|1) break;; *[!0-9]*) break;; esac; ",
666        "  __self_pids=\"$__self_pids$__pp \"; __i=$((__i + 1)); ",
667        "done; ",
668        "if __proc=$(mktemp 2>/dev/null || mktemp -p \"$__tmpbase\" 2>/dev/null); then ",
669        "  ps -ww -eo pid=,args= > \"$__proc\" 2>/dev/null ",
670        "    || ps -eo pid=,args= > \"$__proc\" 2>/dev/null || :; ",
671        "  if [ -s \"$__proc\" ]; then __proc_ok=1; fi; ",
672        "fi; ",
673        "__gc_handles() { ",
674        "  if [ \"$__held_ok\" -ne 1 ]; then printf unknown; return 0; fi; ",
675        "  if awk -v p=\"$1\" '$0==p || index($0, p \"/\")==1 {f=1; exit} END{exit(f?0:1)}' \"$__held\"; ",
676        "  then printf held; else printf free; fi; ",
677        "}; ",
678        "__gc_procs() { ",
679        "  if [ \"$__proc_ok\" -ne 1 ]; then printf unknown; return 0; fi; ",
680        "  if awk -v p=\"$1\" -v skip=\"$__self_pids\" ",
681        "    '{ if (index(skip, \" \" $1 \" \") > 0) next; if (index($0, p) > 0) { f=1; exit } } ",
682        "     END{exit(f?0:1)}' \"$__proc\"; ",
683        "  then printf held; else printf free; fi; ",
684        "}; ",
685        "__gc_gates_ok() { ",
686        "  [ \"$(__gc_handles \"$1\")\" = free ] || return 1; ",
687        "  [ \"$(__gc_procs \"$1\")\" = free ] || return 1; ",
688        "  return 0; ",
689        "}; ",
690        "printf 'RCH_GC_GATES handles=%s handles_source=%s procs=%s\\n' \"$__held_ok\" \"$__hsrc\" \"$__proc_ok\"; ",
691    )
692    .to_string()
693}
694
695/// Shell fragment removing the gate-snapshot temp files. Safe when a snapshot
696/// was never created (`rm -f ""` is not attempted).
697///
698/// Written as `if`, NOT as `[ -n "$x" ] && rm -f "$x"`: the `&&` form yields
699/// exit status 1 when the variable is empty, and this fragment is the LAST
700/// statement of the enumeration script — a worker whose `mktemp` failed would
701/// have made the whole read-only enumeration look like a failed command.
702fn gate_snapshot_cleanup() -> &'static str {
703    "if [ -n \"$__held\" ]; then rm -f \"$__held\"; fi; \
704     if [ -n \"$__proc\" ]; then rm -f \"$__proc\"; fi; "
705}
706
707/// Gate prefix for a POOLED-dir reap loop body, given that pass's idle window.
708///
709/// Order matters: the cheap whole-tree `-mmin` idle test runs FIRST and simply
710/// `continue`s, so a worker mid-build does not pay two `awk` scans per warm
711/// pool, and a worker whose gates are unavailable does not print one skip line
712/// per pool it was never going to touch anyway. Only a dir the TTL pass would
713/// actually remove is gate-checked, and only that dir's refusal is reported.
714fn pooled_collect_gate(idle_minutes: u64) -> String {
715    format!(
716        "[ -d \"$d\" ] || continue; \
717         if find \"$d\" -mmin -{idle_minutes} -print -quit 2>/dev/null | grep -q .; then continue; fi; \
718         if ! __gc_gates_ok \"$d\"; then printf 'RCH_GC_SKIP pooled-ttl gate %s\\n' \"$d\"; continue; fi; "
719    )
720}
721
722fn candidate_discovery_preamble(escaped_base: &str, on_guard_exit: &str) -> String {
723    // The tmp base is resolved by the SAME prelude that creates the durable
724    // per-worker Cargo caches and stages target dirs
725    // (`remote_cargo_home_base_prelude`), so the sweep cannot look somewhere
726    // other than where rch writes. It used to be a hand-copied `$TMPDIR` →
727    // `/data/tmp` → `/tmp` ladder here — the duplication that let ~700 GB of
728    // pooled/cache dirs sit unscanned while `rch gc` reported 0 MB.
729    let tmp_base_prelude = crate::gc_roots::remote_cargo_home_base_prelude();
730    let tmp_base_var = crate::gc_roots::RCH_CARGO_HOME_BASE_VAR;
731    format!(
732        "set -u; \
733         base=\"{escaped_base}\"; \
734         printf 'RCH_GC_ROOT base %s\\n' \"$base\"; \
735         if [ ! -d \"$base\" ]; then printf 'RCH_GC_ROOT_SKIPPED %s missing\\n' \"$base\"; {on_guard_exit}exit 0; fi; \
736         __rt=$(cd \"$base\" 2>/dev/null && pwd -P) || {{ printf 'RCH_GC_ROOT_SKIPPED %s unresolvable\\n' \"$base\"; {on_guard_exit}exit 0; }}; \
737         [ -n \"$__rt\" ] || {{ printf 'RCH_GC_ROOT_SKIPPED %s unresolvable\\n' \"$base\"; {on_guard_exit}exit 0; }}; \
738         case \"$__rt\" in */*/*) ;; *) printf 'RCH_GC_ROOT_SKIPPED %s too-shallow\\n' \"$__rt\"; {on_guard_exit}exit 0;; esac; \
739         printf 'RCH_GC_ROOT resolved %s\\n' \"$__rt\"; \
740         {tmp_base_prelude}; \
741         __tmpbase=\"${{{tmp_base_var}}}\"; \
742         __tmpscan=\"\"; \
743         case \"$__tmpbase\" in /*/*) __tmpscan=$(cd \"$__tmpbase\" 2>/dev/null && pwd -P) || __tmpscan=\"\";; esac; \
744         case \"$__tmpscan\" in /*/*) ;; *) __tmpscan=\"\";; esac; \
745         printf 'RCH_GC_ROOT tmp %s\\n' \"$__tmpscan\"; \
746         {gates}\
747         __tmpf=$(mktemp 2>/dev/null || mktemp -p \"$__tmpbase\" 2>/dev/null) || {{ {cleanup}{on_guard_exit}exit 0; }}; \
748         find \"$__rt\" -maxdepth 8 -type d \\( -name \".rch-target-*-job-*\" -o -name \".rch-target-*-pid-*\" \\) -prune 2>/dev/null > \"$__tmpf\"; \
749         if [ -n \"$__tmpscan\" ]; then find \"$__tmpscan\" -maxdepth 1 -type d -name \"rch_target_*\" -prune 2>/dev/null >> \"$__tmpf\"; \
750           find \"$__tmpscan\" -maxdepth {TMPBASE_MAXDEPTH} -type d \\( -name \".rch-target-*-job-*\" -o -name \".rch-target-*-pid-*\" \\) -prune 2>/dev/null >> \"$__tmpf\"; fi; \
751         {dedup}",
752        gates = gate_snapshot_fragment(),
753        cleanup = gate_snapshot_cleanup(),
754        dedup = dedup_candidate_file("__tmpf")
755    )
756}
757
758/// Minimum non-zero pooled idle window in minutes (24h). Pooled dirs are warm
759/// caches; the builder floors any smaller non-zero window to this as a last
760/// defense behind the config-level validation.
761pub const MIN_POOLED_IDLE_MINUTES: u64 = 24 * 60;
762
763/// Build the worker-wide sweep script shared by the daemon's periodic reaper
764/// (`rchd::stale_target_reap`) and the on-demand `rch gc` — one builder so the
765/// two can never drift (bead 6dj11). Applies [`reap_loop_body`] to every
766/// candidate from [`candidate_discovery_preamble`] and always prints a final
767/// `RCH_WORKER_REAP_METRICS removed=<n> freed_kb=<kb>` line.
768///
769/// `pooled_idle_minutes` adds a SECOND pass over the pooled
770/// `.rch-target-*-pool-*` dirs with its own (much longer) idle window —
771/// `None` skips pooled dirs entirely. Pooled dirs are reused warm caches, so
772/// they only reap after e.g. seven idle days (a pool key nobody has built for
773/// a week is a corpse, not a cache); non-zero windows are floored at
774/// [`MIN_POOLED_IDLE_MINUTES`]. Same counters, same predicate, one metrics
775/// line covering both passes.
776///
777/// `escaped_base` MUST already be validated with [`is_safe_reap_base`]; it is
778/// embedded inside double quotes.
779#[must_use]
780pub fn worker_sweep_command(
781    escaped_base: &str,
782    idle_minutes: u64,
783    pooled_idle_minutes: Option<u64>,
784    max_cache_kb: Option<u64>,
785) -> String {
786    worker_sweep_command_with_registry(
787        escaped_base,
788        idle_minutes,
789        pooled_idle_minutes,
790        max_cache_kb,
791        SOURCE_CLAIM_REGISTRY,
792    )
793}
794
795/// [`worker_sweep_command`] against an explicit source-ownership registry,
796/// for sweeps (and tests) that must not consult the worker's real one.
797#[must_use]
798pub fn worker_sweep_command_with_registry(
799    escaped_base: &str,
800    idle_minutes: u64,
801    pooled_idle_minutes: Option<u64>,
802    max_cache_kb: Option<u64>,
803    registry: &str,
804) -> String {
805    let loop_body = reap_loop_body_with_source_gate(
806        idle_minutes,
807        None,
808        "removed",
809        "freed_kb",
810        Some("ttl"),
811        true,
812    );
813    let guard = "printf 'RCH_WORKER_REAP_METRICS removed=0 freed_kb=0\\n'; ";
814    let preamble = candidate_discovery_preamble(escaped_base, guard);
815    let pooled_pass = match pooled_idle_minutes {
816        Some(window) => {
817            let window = window.max(MIN_POOLED_IDLE_MINUTES);
818            let pooled_body = reap_loop_body_with_source_gate(
819                window,
820                None,
821                "removed",
822                "freed_kb",
823                Some("pooled-ttl"),
824                true,
825            );
826            let dedup2 = dedup_candidate_file("__tmpf2");
827            format!(
828                "if __tmpf2=$(mktemp 2>/dev/null || mktemp -p \"$__tmpbase\" 2>/dev/null); then \
829                   find \"$__rt\" -maxdepth 8 -type d -name \".rch-target-*-pool-*\" -prune 2>/dev/null > \"$__tmpf2\"; \
830                   if [ -n \"$__tmpscan\" ]; then find \"$__tmpscan\" -maxdepth {TMPBASE_MAXDEPTH} -type d -name \".rch-target-*-pool-*\" -prune 2>/dev/null >> \"$__tmpf2\"; fi; \
831                   {dedup2}\
832                   while IFS= read -r d; do {pooled_gate}{pooled_body} done < \"$__tmpf2\"; \
833                   rm -f \"$__tmpf2\"; \
834                 fi; ",
835                pooled_gate = pooled_collect_gate(window),
836            )
837        }
838        None => String::new(),
839    };
840    // Byte-cap eviction (bead 6dj11): after the TTL passes, when the TOTAL of
841    // every remaining reap-class dir exceeds the budget, evict oldest first —
842    // but NEVER a dir with activity within the short idle window (the same
843    // whole-tree `find -mmin` active-build safety floor as the TTL pass), and
844    // never one that fails the liveness gates (a disk budget must not be able
845    // to delete a dir a build is holding open) — until back under. Oldest-first makes this a warm-LRU by construction:
846    // the newest warm pools survive, the coldest go first. Ordering uses the
847    // dir's own mtime via `date -r` (portable across GNU/BSD, and cargo
848    // touches the target root constantly during builds, so it is a good
849    // recency proxy — the `-mmin` tree walk remains the safety authority);
850    // sizes come from `du -sk`. The candidate list is re-discovered because
851    // the TTL passes just removed entries; `sort -n` orders oldest-mtime
852    // first; the eviction loop reads from a FILE so the counter mutations
853    // survive (no pipe subshell).
854    let dedup3 = dedup_candidate_file("__tmpf3");
855    let cap_pass = match max_cache_kb {
856        Some(cap_kb) => format!(
857            "if [ {cap_kb} -gt 0 ] \
858               && __lst=$(mktemp 2>/dev/null || mktemp -p \"$__tmpbase\" 2>/dev/null) \
859               && __tmpf3=$(mktemp 2>/dev/null || mktemp -p \"$__tmpbase\" 2>/dev/null); then \
860               find \"$__rt\" -maxdepth 8 -type d \\( -name \".rch-target-*-job-*\" -o -name \".rch-target-*-pid-*\" -o -name \".rch-target-*-pool-*\" \\) -prune 2>/dev/null > \"$__tmpf3\"; \
861               if [ -n \"$__tmpscan\" ]; then find \"$__tmpscan\" -maxdepth 1 -type d -name \"rch_target_*\" -prune 2>/dev/null >> \"$__tmpf3\"; \
862                 find \"$__tmpscan\" -maxdepth {TMPBASE_MAXDEPTH} -type d \\( -name \".rch-target-*-job-*\" -o -name \".rch-target-*-pid-*\" -o -name \".rch-target-*-pool-*\" \\) -prune 2>/dev/null >> \"$__tmpf3\"; fi; \
863               {dedup3}\
864               : > \"$__lst\"; total_kb=0; \
865               while IFS= read -r d; do \
866                 [ -d \"$d\" ] || continue; \
867                 __n=$(date -r \"$d\" +%s 2>/dev/null); [ -n \"$__n\" ] || __n=0; \
868                 __k=$(du -sk \"$d\" 2>/dev/null | awk '{{print $1}}'); [ -n \"$__k\" ] || __k=0; \
869                 total_kb=$((total_kb + __k)); \
870                 printf '%s %s %s\\n' \"$__n\" \"$__k\" \"$d\" >> \"$__lst\"; \
871               done < \"$__tmpf3\"; \
872               if [ \"$total_kb\" -gt {cap_kb} ]; then \
873                 printf 'RCH_REAP_CAP initial_kb=%s cap_kb={cap_kb}\\n' \"$total_kb\"; \
874                 sort -n \"$__lst\" > \"$__tmpf3\"; \
875                 while IFS=' ' read -r __n __k d; do \
876                   [ \"$total_kb\" -le {cap_kb} ] && break; \
877                   [ -d \"$d\" ] || continue; \
878                   {early_recent}\
879                   if ! __gc_gates_ok \"$d\"; then printf 'RCH_GC_SKIP cap gate %s\\n' \"$d\"; continue; fi; \
880                   {source_begin}\
881                   {held_recent}\
882                   if __rmerr=$(rm -rf -- \"$d\" 2>&1); then \
883                     removed=$((removed + 1)); freed_kb=$((freed_kb + __k)); total_kb=$((total_kb - __k)); \
884                     printf 'RCH_REAP_RM %s cap %s\\n' \"$__k\" \"$d\"; \
885                   else \
886                     printf 'RCH_REAP_ERR cap %s :: %s\\n' \"$d\" \"$(printf '%s' \"$__rmerr\" | head -1)\"; \
887                   fi; \
888                   {source_end}\
889                 done < \"$__tmpf3\"; \
890                 printf 'RCH_REAP_CAP final_kb=%s\\n' \"$total_kb\"; \
891               fi; \
892               rm -f \"$__lst\" \"$__tmpf3\"; \
893             fi; ",
894            source_begin = checked_source_admission("cap"),
895            source_end = SOURCE_GC_CHECKED_RETIREMENT,
896            early_recent = checked_source_recent_activity(&idle_minutes.to_string(), "cap", false),
897            held_recent = checked_source_recent_activity(&idle_minutes.to_string(), "cap", true),
898        ),
899        None => String::new(),
900    };
901    format!(
902        "{preamble}\
903         {source_gate}\
904         removed=0; freed_kb=0; \
905         while IFS= read -r d; do {loop_body} done < \"$__tmpf\"; \
906         rm -f \"$__tmpf\"; \
907         {pooled_pass}\
908         {cap_pass}\
909         {gate_cleanup}\
910         printf 'RCH_WORKER_REAP_METRICS removed=%s freed_kb=%s\\n' \"$removed\" \"$freed_kb\"",
911        gate_cleanup = gate_snapshot_cleanup(),
912        source_gate = source_claim_gate_fragment(registry),
913    )
914}
915
916/// Parse the `RCH_WORKER_REAP_METRICS removed=<n> freed_kb=<kb>` line a
917/// [`worker_sweep_command`] run prints. Returns `(removed, freed_kb)`.
918#[must_use]
919pub fn parse_worker_reap_metrics(stdout: &str) -> Option<(u64, u64)> {
920    let line = stdout
921        .lines()
922        .find(|l| l.contains("RCH_WORKER_REAP_METRICS"))?;
923    let mut removed = None;
924    let mut freed_kb = None;
925    for token in line.split_whitespace().skip(1) {
926        let Some((key, value)) = token.split_once('=') else {
927            continue;
928        };
929        match key {
930            "removed" => removed = value.parse::<u64>().ok(),
931            "freed_kb" => freed_kb = value.parse::<u64>().ok(),
932            _ => {}
933        }
934    }
935    Some((removed.unwrap_or(0), freed_kb.unwrap_or(0)))
936}
937
938/// One per-removal event from a [`worker_sweep_command`] run.
939#[derive(Debug, Clone, PartialEq, Eq)]
940pub struct ReapEvent {
941    /// Disk usage of the removed dir in KiB.
942    pub kb: u64,
943    /// Which policy removed it: `ttl`, `pooled-ttl`, or `cap`.
944    pub trigger: String,
945    /// Absolute path removed.
946    pub path: String,
947}
948
949/// Parse the `RCH_REAP_RM <kb> <trigger> <path>` event lines a
950/// [`worker_sweep_command`] run prints — one per successful removal, so any
951/// deletion can be reconstructed from the output alone. Unparseable lines are
952/// skipped.
953#[must_use]
954pub fn parse_reap_events(stdout: &str) -> Vec<ReapEvent> {
955    stdout
956        .lines()
957        .filter_map(|line| {
958            let rest = line.trim().strip_prefix("RCH_REAP_RM ")?;
959            let mut parts = rest.splitn(3, ' ');
960            let kb = parts.next()?.parse::<u64>().ok()?;
961            let trigger = parts.next()?.to_string();
962            let path = parts.next()?.trim();
963            if path.is_empty() {
964                return None;
965            }
966            Some(ReapEvent {
967                kb,
968                trigger,
969                path: path.to_string(),
970            })
971        })
972        .collect()
973}
974
975/// One failed-removal diagnostic from a [`worker_sweep_command`] run.
976#[derive(Debug, Clone, PartialEq, Eq)]
977pub struct ReapError {
978    /// Which pass hit the failure: `ttl`, `pooled-ttl`, or `cap`.
979    pub trigger: String,
980    /// Path whose removal failed.
981    pub path: String,
982    /// First line of `rm`'s stderr.
983    pub message: String,
984}
985
986/// Parse the `RCH_REAP_ERR <trigger> <path> :: <message>` lines a
987/// [`worker_sweep_command`] run prints — one per FAILED removal (bd-kwvy8:
988/// silenced rm failures made a half-removed dir undiagnosable). Unparseable
989/// lines are skipped.
990#[must_use]
991pub fn parse_reap_errors(stdout: &str) -> Vec<ReapError> {
992    stdout
993        .lines()
994        .filter_map(|line| {
995            let rest = line.trim().strip_prefix("RCH_REAP_ERR ")?;
996            let (trigger, rest) = rest.split_once(' ')?;
997            let (path, message) = rest.split_once(" :: ")?;
998            if path.is_empty() {
999                return None;
1000            }
1001            Some(ReapError {
1002                trigger: trigger.to_string(),
1003                path: path.to_string(),
1004                message: message.trim().to_string(),
1005            })
1006        })
1007        .collect()
1008}
1009
1010/// Build the READ-ONLY enumeration script behind `rch cache status` and
1011/// `rch gc`: identical candidate discovery to [`worker_sweep_command`] (so
1012/// what status shows is exactly what gc would consider), plus the POOLED
1013/// `.rch-target-*-pool-*` stores and the durable `rch-cargo-cache-*` caches —
1014/// the two classes that hold most of the bytes on a worker and that gc may
1015/// collect under the full gate set. Nothing is removed. One line per
1016/// candidate:
1017///
1018/// `RCH_TARGET_ENTRY <newest_mtime_unix> <kb> <handles> <procs> <path>`
1019///
1020/// `<handles>` and `<procs>` are the two liveness gates (`free`, `held`, or
1021/// `unknown`); see [`gate_snapshot_fragment`]. `unknown` is the fail-closed
1022/// answer and makes a warm-cache dir ineligible.
1023///
1024/// `newest_mtime_unix` is the newest mtime of the dir or any descendant (the
1025/// exact signal the idle predicate tests) and `<kb>` the apparent-size sum,
1026/// both from ONE `find -printf '%T@ %s'` + awk pass per dir (GNU find —
1027/// workers are Linux by contract). A du-based variant needed three stat walks
1028/// and exceeded 15 minutes live on a loaded worker whose pooled dirs held
1029/// millions of inodes; apparent size instead of block usage is an acceptable
1030/// trade for an observability surface. `escaped_base` MUST already be
1031/// validated with [`is_safe_reap_base`].
1032#[must_use]
1033pub fn enumerate_targets_command(escaped_base: &str) -> String {
1034    let preamble = candidate_discovery_preamble(escaped_base, "");
1035    // Pooled dirs are appended AFTER the preamble, so they need the tmp-base
1036    // pass and a second dedup of their own: what `rch gc --dry-run` lists must
1037    // be exactly what `worker_sweep_command` would act on, and the sweep's
1038    // pooled pass reaches the tmp base. Without this the dry run would omit
1039    // precisely the dirs the real run reaps.
1040    let dedup = dedup_candidate_file("__tmpf");
1041    format!(
1042        "{preamble}\
1043         find \"$__rt\" -maxdepth 8 -type d \\( -name \".rch-target-*-pool-*\" -o -name \"{cache_glob}\" \\) -prune 2>/dev/null >> \"$__tmpf\"; \
1044         if [ -n \"$__tmpscan\" ]; then \
1045           find \"$__tmpscan\" -maxdepth {TMPBASE_MAXDEPTH} -type d \\( -name \".rch-target-*-pool-*\" -o -name \"{cache_glob}\" \\) -prune 2>/dev/null >> \"$__tmpf\"; fi; \
1046         {dedup}\
1047         while IFS= read -r d; do \
1048           [ -d \"$d\" ] || continue; \
1049           set -- $(find \"$d\" -printf '%T@ %s\\n' 2>/dev/null | awk '{{ t=int($1); if (t>n) n=t; s+=$2 }} END {{ printf \"%d %d\", n, int(s/1024) }}'); \
1050           newest=${{1:-0}}; kb=${{2:-0}}; \
1051           printf 'RCH_TARGET_ENTRY %s %s %s %s %s\\n' \"$newest\" \"$kb\" \"$(__gc_handles \"$d\")\" \"$(__gc_procs \"$d\")\" \"$d\"; \
1052         done < \"$__tmpf\"; \
1053         rm -f \"$__tmpf\"; \
1054         {gate_cleanup}exit 0",
1055        cache_glob = CARGO_CACHE_GLOB,
1056        gate_cleanup = gate_snapshot_cleanup()
1057    )
1058}
1059
1060/// One enumerated remote target dir from [`enumerate_targets_command`] output.
1061#[derive(Debug, Clone, PartialEq, Eq)]
1062pub struct RemoteTargetEntry {
1063    /// Newest mtime (Unix seconds) of the dir or any descendant.
1064    pub newest_mtime_unix: u64,
1065    /// Disk usage in KiB.
1066    pub kb: u64,
1067    /// Absolute path on the worker.
1068    pub path: String,
1069    /// Whether any process holds a descriptor open under this dir, or has it
1070    /// as its cwd. [`GateEvidence::Unknown`] when the snapshot could not be
1071    /// taken — which makes the dir ineligible, never eligible.
1072    pub open_handles: GateEvidence,
1073    /// Whether a live process's command line names this dir.
1074    /// [`GateEvidence::Unknown`] when the snapshot could not be taken.
1075    pub live_process: GateEvidence,
1076}
1077
1078impl RemoteTargetEntry {
1079    /// Whether this is a pooled (`-pool-`) dir — a warm cache reused across
1080    /// jobs.
1081    #[must_use]
1082    pub fn is_pooled(&self) -> bool {
1083        self.class() == GcClass::Pooled
1084    }
1085
1086    /// What kind of rch runtime dir this is, from its basename alone.
1087    #[must_use]
1088    pub fn class(&self) -> GcClass {
1089        GcClass::from_path(&self.path)
1090    }
1091
1092    /// Idle age in seconds relative to `now_unix`, saturating at 0 for a dir
1093    /// whose newest mtime is in the future (clock skew between client and
1094    /// worker) — a future mtime reads as "just touched", i.e. too young.
1095    #[must_use]
1096    pub fn age_secs(&self, now_unix: u64) -> u64 {
1097        now_unix.saturating_sub(self.newest_mtime_unix)
1098    }
1099}
1100
1101/// What kind of rch runtime directory a path names. Derived in Rust from the
1102/// basename (never in the shell) so classification cannot drift between the
1103/// enumeration script and the decision that acts on it.
1104#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1105pub enum GcClass {
1106    /// `.rch-target-<worker>-job-<id>` / `-pid-<pid>-…`: one build's target dir.
1107    PerJob,
1108    /// `.rch-target-<worker>-pool-<key>`: a warm target store shared by jobs.
1109    Pooled,
1110    /// `rch-cargo-cache-<worker>`: the durable per-worker `CARGO_HOME`.
1111    CargoCache,
1112    /// `rch_target_*`: the pre-`.rch-target-` layout, still found on old hosts.
1113    LegacyTarget,
1114    /// Anything else. Never collectible — if the enumeration ever hands back a
1115    /// path rch does not recognize, gc leaves it alone.
1116    Unrecognized,
1117}
1118
1119impl GcClass {
1120    /// Classify by basename.
1121    #[must_use]
1122    pub fn from_path(path: &str) -> Self {
1123        let name = path.trim_end_matches('/').rsplit('/').next().unwrap_or("");
1124        if let Some(rest) = name.strip_prefix(".rch-target-") {
1125            if rest.contains("-pool-") {
1126                return Self::Pooled;
1127            }
1128            if rest.contains("-job-") || rest.contains("-pid-") {
1129                return Self::PerJob;
1130            }
1131            return Self::Unrecognized;
1132        }
1133        if name.starts_with(crate::gc_roots::RCH_CARGO_CACHE_PREFIX) {
1134            return Self::CargoCache;
1135        }
1136        if name.starts_with("rch_target_") {
1137            return Self::LegacyTarget;
1138        }
1139        Self::Unrecognized
1140    }
1141
1142    /// Stable machine-readable tag (JSON output, tests).
1143    #[must_use]
1144    pub const fn as_str(self) -> &'static str {
1145        match self {
1146            Self::PerJob => "per_job",
1147            Self::Pooled => "pooled",
1148            Self::CargoCache => "cargo_cache",
1149            Self::LegacyTarget => "legacy_target",
1150            Self::Unrecognized => "unrecognized",
1151        }
1152    }
1153
1154    /// The `RCH_REAP_RM` trigger tag recorded for a removal of this class.
1155    #[must_use]
1156    pub const fn trigger(self) -> &'static str {
1157        match self {
1158            Self::PerJob | Self::LegacyTarget => "ttl",
1159            Self::Pooled => "pooled-ttl",
1160            Self::CargoCache => "cache-ttl",
1161            Self::Unrecognized => "none",
1162        }
1163    }
1164
1165    /// Whether collecting this class demands that BOTH liveness gates be
1166    /// *known* and free. True for the two warm-cache classes that this change
1167    /// made collectible: they are shared and long-lived, so an unavailable
1168    /// gate must block them. Per-job and legacy dirs keep their long-standing
1169    /// idle-window authority (an unavailable gate does not newly freeze a
1170    /// sweep that has always been safe on the idle window alone), but a gate
1171    /// that is available and says "held" still blocks them.
1172    #[must_use]
1173    pub const fn requires_known_gates(self) -> bool {
1174        matches!(self, Self::Pooled | Self::CargoCache)
1175    }
1176}
1177
1178impl std::fmt::Display for GcClass {
1179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1180        f.write_str(self.as_str())
1181    }
1182}
1183
1184/// The answer one liveness gate gave for one directory.
1185#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
1186pub enum GateEvidence {
1187    /// The gate ran and found nothing holding the dir.
1188    Free,
1189    /// The gate ran and found the dir in use.
1190    Held,
1191    /// The gate could not run (no `/proc`, no `lsof`, no `ps`, unwritable
1192    /// temp, permission denied). The DEFAULT, so any parse gap or missing
1193    /// field reads as "unknown" and therefore ineligible.
1194    #[default]
1195    Unknown,
1196}
1197
1198impl GateEvidence {
1199    /// Parse the token the enumeration script prints.
1200    #[must_use]
1201    pub fn from_token(token: &str) -> Option<Self> {
1202        match token {
1203            "free" => Some(Self::Free),
1204            "held" => Some(Self::Held),
1205            "unknown" => Some(Self::Unknown),
1206            _ => None,
1207        }
1208    }
1209
1210    /// The token form.
1211    #[must_use]
1212    pub const fn as_token(self) -> &'static str {
1213        match self {
1214            Self::Free => "free",
1215            Self::Held => "held",
1216            Self::Unknown => "unknown",
1217        }
1218    }
1219}
1220
1221impl std::fmt::Display for GateEvidence {
1222    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1223        f.write_str(self.as_token())
1224    }
1225}
1226
1227/// Per-class idle windows, in seconds, that `rch gc` applies. `None` means the
1228/// class is never collected.
1229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1230pub struct GcPolicy {
1231    /// Window for [`GcClass::PerJob`] and [`GcClass::LegacyTarget`].
1232    pub idle_secs: u64,
1233    /// Window for [`GcClass::Pooled`]; `None` disables pooled collection.
1234    pub pooled_idle_secs: Option<u64>,
1235    /// Window for [`GcClass::CargoCache`]; `None` disables cache collection.
1236    pub cache_idle_secs: Option<u64>,
1237}
1238
1239impl GcPolicy {
1240    /// The idle window a class must clear, or `None` when the class is not
1241    /// collected at all.
1242    #[must_use]
1243    pub fn window_secs(&self, class: GcClass) -> Option<u64> {
1244        match class {
1245            GcClass::PerJob | GcClass::LegacyTarget => Some(self.idle_secs),
1246            GcClass::Pooled => self.pooled_idle_secs,
1247            GcClass::CargoCache => self.cache_idle_secs,
1248            GcClass::Unrecognized => None,
1249        }
1250    }
1251}
1252
1253/// Why a candidate was NOT collected.
1254#[derive(Debug, Clone, PartialEq, Eq)]
1255pub enum GcKeepReason {
1256    /// The basename is not an rch runtime dir rch is willing to remove.
1257    Unrecognized,
1258    /// The class is disabled by configuration (a `0` idle window).
1259    ClassDisabled,
1260    /// Not idle long enough yet.
1261    TooYoung {
1262        /// Observed idle age.
1263        age_secs: u64,
1264        /// Window it must clear.
1265        required_secs: u64,
1266    },
1267    /// A process holds a descriptor open under the dir, or is cwd'd into it.
1268    OpenHandles,
1269    /// A live process's command line names the dir.
1270    LiveProcess,
1271    /// A gate could not be evaluated. Named so the operator can fix the
1272    /// worker rather than wonder why nothing is collected.
1273    GateUnavailable(&'static str),
1274}
1275
1276impl std::fmt::Display for GcKeepReason {
1277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1278        match self {
1279            Self::Unrecognized => f.write_str("not an rch-managed runtime dir"),
1280            Self::ClassDisabled => f.write_str("collection disabled for this class by config"),
1281            Self::TooYoung {
1282                age_secs,
1283                required_secs,
1284            } => write!(
1285                f,
1286                "idle {}h < required {}h",
1287                age_secs / 3600,
1288                required_secs / 3600
1289            ),
1290            Self::OpenHandles => f.write_str("open file descriptors under the dir"),
1291            Self::LiveProcess => f.write_str("a live process is rooted at the dir"),
1292            Self::GateUnavailable(gate) => write!(f, "{gate} gate unavailable (treated as in use)"),
1293        }
1294    }
1295}
1296
1297impl GcKeepReason {
1298    /// Stable machine-readable tag for JSON output.
1299    #[must_use]
1300    pub const fn as_str(&self) -> &'static str {
1301        match self {
1302            Self::Unrecognized => "unrecognized",
1303            Self::ClassDisabled => "class_disabled",
1304            Self::TooYoung { .. } => "too_young",
1305            Self::OpenHandles => "open_handles",
1306            Self::LiveProcess => "live_process",
1307            Self::GateUnavailable(_) => "gate_unavailable",
1308        }
1309    }
1310}
1311
1312/// The decision for one enumerated dir.
1313#[derive(Debug, Clone, PartialEq, Eq)]
1314pub enum GcVerdict {
1315    /// Every gate passed; gc may remove it (`--apply`).
1316    Collect,
1317    /// Left in place, with the reason.
1318    Keep(GcKeepReason),
1319}
1320
1321impl GcVerdict {
1322    /// Whether this dir would be removed.
1323    #[must_use]
1324    pub fn is_collect(&self) -> bool {
1325        matches!(self, Self::Collect)
1326    }
1327}
1328
1329/// Decide whether one enumerated dir may be collected.
1330///
1331/// The gates, ALL of which must hold, in the order they are cheapest to
1332/// explain:
1333///
1334/// 1. the basename is a class rch created and is willing to remove;
1335/// 2. the class is enabled (a `0` window disables it);
1336/// 3. the dir has been idle at least the class's window — `newest_mtime_unix`
1337///    is the newest mtime of the dir *or any descendant*, so an active build
1338///    can never clear this;
1339/// 4. no open file descriptor and no cwd under the dir;
1340/// 5. no live process rooted at the dir.
1341///
1342/// Any gate that could not be evaluated ([`GateEvidence::Unknown`]) blocks
1343/// collection for the classes this change newly made collectible — the whole
1344/// point of the conservative rule is that an error must read as "in use", never
1345/// as "free".
1346#[must_use]
1347pub fn evaluate_gc_candidate(
1348    entry: &RemoteTargetEntry,
1349    now_unix: u64,
1350    policy: &GcPolicy,
1351) -> GcVerdict {
1352    let class = entry.class();
1353    if class == GcClass::Unrecognized {
1354        return GcVerdict::Keep(GcKeepReason::Unrecognized);
1355    }
1356    let Some(required_secs) = policy.window_secs(class) else {
1357        return GcVerdict::Keep(GcKeepReason::ClassDisabled);
1358    };
1359    let age_secs = entry.age_secs(now_unix);
1360    if age_secs < required_secs {
1361        return GcVerdict::Keep(GcKeepReason::TooYoung {
1362            age_secs,
1363            required_secs,
1364        });
1365    }
1366    let strict = class.requires_known_gates();
1367    match entry.open_handles {
1368        GateEvidence::Held => return GcVerdict::Keep(GcKeepReason::OpenHandles),
1369        GateEvidence::Unknown if strict => {
1370            return GcVerdict::Keep(GcKeepReason::GateUnavailable("open-descriptor"));
1371        }
1372        _ => {}
1373    }
1374    match entry.live_process {
1375        GateEvidence::Held => return GcVerdict::Keep(GcKeepReason::LiveProcess),
1376        GateEvidence::Unknown if strict => {
1377            return GcVerdict::Keep(GcKeepReason::GateUnavailable("live-process"));
1378        }
1379        _ => {}
1380    }
1381    GcVerdict::Collect
1382}
1383
1384/// Second-pass byte-cap eviction, in Rust so every eviction carries the same
1385/// per-dir reasoning as a TTL collection.
1386///
1387/// After the TTL verdicts, when the total size of everything still on disk
1388/// exceeds `cap_kb` (`[remediation.pooled_target] reaper_max_cache_gb`; `0`
1389/// disables), evict OLDEST-FIRST until back under budget. Returns the indices
1390/// of `entries` to additionally collect, in eviction order.
1391///
1392/// A cap eviction is held to *more* than the TTL pass required of it: the dir
1393/// must still clear the SHORT idle window (the active-build safety floor — a
1394/// disk budget must never clip a running build) and must still pass the same
1395/// liveness gates, including the rule that an unavailable gate blocks a warm
1396/// cache. The pre-existing shell cap pass checked only the idle window; this
1397/// is strictly the safer of the two.
1398#[must_use]
1399pub fn select_cap_evictions(
1400    entries: &[RemoteTargetEntry],
1401    verdicts: &[GcVerdict],
1402    now_unix: u64,
1403    policy: &GcPolicy,
1404    cap_kb: u64,
1405) -> Vec<usize> {
1406    if cap_kb == 0 || entries.len() != verdicts.len() {
1407        return Vec::new();
1408    }
1409    let mut remaining_kb: u64 = entries
1410        .iter()
1411        .zip(verdicts)
1412        .filter(|(_, v)| !v.is_collect())
1413        .map(|(e, _)| e.kb)
1414        .sum();
1415    if remaining_kb <= cap_kb {
1416        return Vec::new();
1417    }
1418
1419    let mut candidates: Vec<usize> = entries
1420        .iter()
1421        .zip(verdicts)
1422        .enumerate()
1423        .filter(|(_, (entry, verdict))| {
1424            if verdict.is_collect() {
1425                return false;
1426            }
1427            let class = entry.class();
1428            if class == GcClass::Unrecognized || policy.window_secs(class).is_none() {
1429                return false;
1430            }
1431            if entry.age_secs(now_unix) < policy.idle_secs {
1432                return false;
1433            }
1434            let strict = class.requires_known_gates();
1435            let gate_ok = |g: GateEvidence| match g {
1436                GateEvidence::Free => true,
1437                GateEvidence::Held => false,
1438                GateEvidence::Unknown => !strict,
1439            };
1440            gate_ok(entry.open_handles) && gate_ok(entry.live_process)
1441        })
1442        .map(|(idx, _)| idx)
1443        .collect();
1444    // Oldest first, so the warm pools that survive are the ones still in use.
1445    // Path is the tie-break so the order is deterministic across runs.
1446    candidates.sort_by(|a, b| {
1447        entries[*a]
1448            .newest_mtime_unix
1449            .cmp(&entries[*b].newest_mtime_unix)
1450            .then_with(|| entries[*a].path.cmp(&entries[*b].path))
1451    });
1452
1453    let mut evict = Vec::new();
1454    for idx in candidates {
1455        if remaining_kb <= cap_kb {
1456            break;
1457        }
1458        remaining_kb = remaining_kb.saturating_sub(entries[idx].kb);
1459        evict.push(idx);
1460    }
1461    evict
1462}
1463
1464/// One dir `rch gc --apply` intends to remove, with the window and tag the
1465/// worker re-checks it against.
1466#[derive(Debug, Clone, PartialEq, Eq)]
1467pub struct GcCollectTarget {
1468    /// Absolute path on the worker.
1469    pub path: String,
1470    /// Idle window (minutes) the worker re-verifies before removing.
1471    pub idle_minutes: u64,
1472    /// Trigger tag recorded on the removal.
1473    pub trigger: &'static str,
1474}
1475
1476/// Build the removal script for an explicit, already-decided list of dirs.
1477///
1478/// This is the ONLY code path by which `rch gc --apply` deletes anything, and
1479/// it re-runs every gate on the worker immediately before each `rm`:
1480/// still-a-directory, still idle past its window, no open descriptors, no live
1481/// process. The client's verdict is therefore a *proposal*; the worker's
1482/// re-check is the authority, which closes the window between enumeration and
1483/// removal (a build can start in between — enumeration of a loaded worker takes
1484/// minutes).
1485///
1486/// Every path is re-validated with [`is_safe_reap_path`] and must carry a
1487/// recognized rch basename, so neither a corrupted enumeration nor a hostile
1488/// path can widen what gets removed. Returns `Err` naming the offending path
1489/// rather than silently dropping it.
1490///
1491/// Emits the same `RCH_REAP_RM` / `RCH_REAP_ERR` / `RCH_WORKER_REAP_METRICS`
1492/// lines as [`worker_sweep_command`], plus `RCH_GC_SKIP <trigger> <reason>
1493/// <path>` for a dir the worker's re-check declined.
1494pub fn collect_paths_command(targets: &[GcCollectTarget]) -> Result<String, String> {
1495    collect_paths_command_with_registry(targets, SOURCE_CLAIM_REGISTRY)
1496}
1497
1498/// Why `target` may not be embedded in a removal command, or `None` if it may.
1499///
1500/// [`collect_paths_command`] refuses a batch containing any such target. Callers
1501/// screen targets with this first and report each rejected one as a skip, so a
1502/// single unembeddable path (e.g. `…/worker volume/…`, which contains a space)
1503/// cannot abort a whole worker's collection (bd-kr4qb).
1504#[must_use]
1505pub fn collect_target_rejection(target: &GcCollectTarget) -> Option<String> {
1506    if !is_safe_reap_path(&target.path) {
1507        return Some(format!(
1508            "refusing to collect {:?}: not an absolute, `..`-free, metacharacter-free path at \
1509             least two levels deep",
1510            target.path
1511        ));
1512    }
1513    if GcClass::from_path(&target.path) == GcClass::Unrecognized {
1514        return Some(format!(
1515            "refusing to collect {:?}: basename is not an rch-managed runtime dir",
1516            target.path
1517        ));
1518    }
1519    if target.idle_minutes == 0 {
1520        return Some(format!(
1521            "refusing to collect {:?}: a zero idle window would remove a live dir",
1522            target.path
1523        ));
1524    }
1525    if !target
1526        .trigger
1527        .chars()
1528        .all(|c| c.is_ascii_alphanumeric() || c == '-')
1529    {
1530        return Some(format!("invalid trigger tag {:?}", target.trigger));
1531    }
1532    None
1533}
1534
1535fn collect_paths_command_with_registry(
1536    targets: &[GcCollectTarget],
1537    registry: &str,
1538) -> Result<String, String> {
1539    if targets.is_empty() {
1540        return Err("no targets to collect".to_string());
1541    }
1542    let mut list = String::new();
1543    for target in targets {
1544        if let Some(rejection) = collect_target_rejection(target) {
1545            return Err(rejection);
1546        }
1547        list.push_str(&format!(
1548            " \"{}:{}:{}\"",
1549            target.idle_minutes, target.trigger, target.path
1550        ));
1551    }
1552    // `$__tmpbase` is the mktemp location for the gate snapshots and is
1553    // resolved by the very prelude that creates the dirs being collected.
1554    let tmp_base_prelude = crate::gc_roots::remote_cargo_home_base_prelude();
1555    let tmp_base_var = crate::gc_roots::RCH_CARGO_HOME_BASE_VAR;
1556    Ok(format!(
1557        "set -u; \
1558         {tmp_base_prelude}; \
1559         __tmpbase=\"${{{tmp_base_var}}}\"; \
1560         {gates}\
1561         {source_gate}\
1562         removed=0; freed_kb=0; \
1563         for __e in{list}; do \
1564           __mins=${{__e%%:*}}; __r=${{__e#*:}}; __tag=${{__r%%:*}}; d=${{__r#*:}}; \
1565           if [ ! -d \"$d\" ]; then printf 'RCH_GC_SKIP %s missing %s\\n' \"$__tag\" \"$d\"; continue; fi; \
1566           {early_recent}\
1567           if ! __gc_gates_ok \"$d\"; then printf 'RCH_GC_SKIP %s gate %s\\n' \"$__tag\" \"$d\"; continue; fi; \
1568           {source_begin}\
1569           {held_recent}\
1570           sz=$(du -sk \"$d\" 2>/dev/null | awk '{{print $1}}'); [ -z \"$sz\" ] && sz=0; \
1571           if __rmerr=$(rm -rf -- \"$d\" 2>&1); then \
1572             removed=$((removed + 1)); freed_kb=$((freed_kb + sz)); \
1573             printf 'RCH_REAP_RM %s %s %s\\n' \"$sz\" \"$__tag\" \"$d\"; \
1574           else \
1575             printf 'RCH_REAP_ERR %s %s :: %s\\n' \"$__tag\" \"$d\" \"$(printf '%s' \"$__rmerr\" | head -1)\"; \
1576           fi; \
1577           {source_end}\
1578         done; \
1579         {gate_cleanup}\
1580         printf 'RCH_WORKER_REAP_METRICS removed=%s freed_kb=%s\\n' \"$removed\" \"$freed_kb\"",
1581        gates = gate_snapshot_fragment(),
1582        gate_cleanup = gate_snapshot_cleanup(),
1583        source_gate = source_claim_gate_fragment(registry),
1584        source_begin = checked_source_admission("\"$__tag\""),
1585        source_end = SOURCE_GC_CHECKED_RETIREMENT,
1586        early_recent = checked_source_recent_activity("\"$__mins\"", "\"$__tag\"", false),
1587        held_recent = checked_source_recent_activity("\"$__mins\"", "\"$__tag\"", true),
1588    ))
1589}
1590
1591/// One dir a `--apply` run declined at removal time.
1592#[derive(Debug, Clone, PartialEq, Eq)]
1593pub struct GcSkip {
1594    /// Trigger tag of the pass that declined it.
1595    pub trigger: String,
1596    /// Why: `missing`, `active`, `gate`, or `source-ownership`.
1597    pub reason: String,
1598    /// Absolute path.
1599    pub path: String,
1600}
1601
1602/// Parse the `RCH_GC_SKIP <trigger> <reason> <path>` lines.
1603#[must_use]
1604pub fn parse_gc_skips(stdout: &str) -> Vec<GcSkip> {
1605    stdout
1606        .lines()
1607        .filter_map(|line| {
1608            let rest = line.trim().strip_prefix("RCH_GC_SKIP ")?;
1609            let mut parts = rest.splitn(3, ' ');
1610            let trigger = parts.next()?.to_string();
1611            let reason = parts.next()?.to_string();
1612            let path = parts.next()?.trim();
1613            if path.is_empty() {
1614                return None;
1615            }
1616            Some(GcSkip {
1617                trigger,
1618                reason,
1619                path: path.to_string(),
1620            })
1621        })
1622        .collect()
1623}
1624
1625/// A stale-target GC reservation (`fc-*.claim`) younger than this is never
1626/// judged abandoned (bd-gyehj).
1627pub const ABANDONED_GC_CLAIM_MIN_MINUTES: u64 = 6 * 60;
1628
1629/// Report, and with `apply` release, abandoned GC reservations on a worker.
1630///
1631/// A GC supervisor that dies between `__gc_source_begin` and
1632/// `__gc_source_end` (ssh timeout, kill, OOM, reboot) leaves its `fc-*.claim`
1633/// behind, and it fences every build of that tree from then on. The claim is
1634/// kept on purpose after a KILL, because the `rm` child may still be deleting
1635/// (see [`source_claim_gate_fragment`]), so a free metadata lock does not
1636/// prove the deletion is over. A claim counts as abandoned only when, under
1637/// the registry's metadata lock, all of these hold:
1638/// - it is at least `min_age_minutes` old;
1639/// - its record is intact (regular file, digest matches);
1640/// - no process has its cwd, root or an open descriptor at or under the path;
1641/// - no process's argv names the path.
1642///
1643/// Release is the move to `released/` that `__gc_source_end` performs.
1644/// Emits `RCH_GC_CLAIMS <status>` once, then one
1645/// `RCH_GC_CLAIM <verdict> <reason> <age_minutes> <name> <path>` per claim.
1646#[must_use]
1647pub fn abandoned_gc_claims_command(apply: bool) -> String {
1648    abandoned_gc_claims_command_with_registry(
1649        apply,
1650        SOURCE_CLAIM_REGISTRY,
1651        ABANDONED_GC_CLAIM_MIN_MINUTES,
1652    )
1653}
1654
1655/// [`abandoned_gc_claims_command`] against an explicit registry and age.
1656#[must_use]
1657pub fn abandoned_gc_claims_command_with_registry(
1658    apply: bool,
1659    registry: &str,
1660    min_age_minutes: u64,
1661) -> String {
1662    let registry = shell_escape::escape(registry.into());
1663    let apply = u8::from(apply);
1664    format!(
1665        r#"r={registry}; min={min_age_minutes}; apply={apply};
1666if [ ! -d "$r" ] || [ -L "$r" ]; then printf 'RCH_GC_CLAIMS none\n'; exit 0; fi;
1667if [ -L "$r/metadata.lock" ]; then printf 'RCH_GC_CLAIMS unsafe-lock\n'; exit 0; fi;
1668if ! command -v flock >/dev/null 2>&1; then printf 'RCH_GC_CLAIMS no-flock\n'; exit 0; fi;
1669exec 8>"$r/metadata.lock" || exit 1;
1670if ! flock -x -w 30 8; then printf 'RCH_GC_CLAIMS busy\n'; exit 0; fi;
1671if [ ! -d /proc/self/fd ]; then printf 'RCH_GC_CLAIMS no-proc\n'; exit 0; fi;
1672refs=$(mktemp) && args=$(mktemp) || exit 1;
1673trap 'rm -f -- "$refs" "$args"' EXIT;
1674find /proc/[0-9]*/cwd /proc/[0-9]*/root /proc/[0-9]*/fd -maxdepth 1 -printf '%l\n' 2>/dev/null | grep '^/' | sort -u > "$refs";
1675for p in /proc/[0-9]*; do tr '\0' ' ' 2>/dev/null < "$p/cmdline"; echo; done > "$args";
1676printf 'RCH_GC_CLAIMS ok\n';
1677now=$(date +%s);
1678for c in "$r"/fc-*.claim; do
1679  [ -e "$c" ] || [ -L "$c" ] || continue;
1680  n=${{c##*/}}; p=-; age=0; v=keep;
1681  if [ -L "$c" ] || [ ! -f "$c" ]; then why=unsafe-record;
1682  else
1683    m=$(stat -c %Y -- "$c" 2>/dev/null) || m=$now; age=$(( (now - m) / 60 ));
1684    p=$(head -n 1 -- "$c"); d=${{n#*.}}; d=${{d%%.*}}; a=$(sha256sum -- "$c");
1685    if [ "${{a%% *}}" != "$d" ] || [ -z "$p" ]; then why=unsafe-record;
1686    elif [ "$age" -lt "$min" ]; then why=young;
1687    elif awk -v p="$p/" -v q="$p" 'index($0,p)==1 || $0==q {{f=1; exit}} END{{exit !f}}' "$refs"; then why=open-handle;
1688    elif grep -qF -- "$p" "$args"; then why=live-process;
1689    elif [ "$apply" = 1 ]; then
1690      dest="$r/released/$n";
1691      if [ ! -L "$r/released" ] && mkdir -p -- "$r/released" && [ ! -e "$dest" ] && [ ! -L "$dest" ] \
1692        && mv -- "$c" "$dest" && sync -f "$dest" && sync -f "$r/released"; then v=released; why=abandoned;
1693      else why=release-failed; fi;
1694    else v=would-release; why=abandoned; fi;
1695  fi;
1696  printf 'RCH_GC_CLAIM %s %s %s %s %s\n' "$v" "$why" "$age" "$n" "$p";
1697done;
1698sync -f "$r" 2>/dev/null || :;
1699"#
1700    )
1701}
1702
1703/// One `RCH_GC_CLAIM` line from [`abandoned_gc_claims_command`].
1704#[derive(Debug, Clone, PartialEq, Eq)]
1705pub struct GcClaimReport {
1706    /// `keep`, `would-release` or `released`.
1707    pub verdict: String,
1708    pub reason: String,
1709    pub age_minutes: u64,
1710    pub name: String,
1711    /// The claimed tree, `-` when the record could not be read.
1712    pub path: String,
1713}
1714
1715/// Parse the claims scan: its `RCH_GC_CLAIMS` status (absent when the script
1716/// never reached it) and every claim line.
1717#[must_use]
1718pub fn parse_gc_claim_reports(stdout: &str) -> (Option<String>, Vec<GcClaimReport>) {
1719    let mut status = None;
1720    let mut claims = Vec::new();
1721    for line in stdout.lines() {
1722        let line = line.trim();
1723        if let Some(rest) = line.strip_prefix("RCH_GC_CLAIMS ") {
1724            status = Some(rest.to_string());
1725        } else if let Some(rest) = line.strip_prefix("RCH_GC_CLAIM ") {
1726            let mut parts = rest.splitn(5, ' ');
1727            let (Some(verdict), Some(reason), Some(age), Some(name), Some(path)) = (
1728                parts.next(),
1729                parts.next(),
1730                parts.next(),
1731                parts.next(),
1732                parts.next(),
1733            ) else {
1734                continue;
1735            };
1736            let Ok(age_minutes) = age.parse() else {
1737                continue;
1738            };
1739            claims.push(GcClaimReport {
1740                verdict: verdict.to_string(),
1741                reason: reason.to_string(),
1742                age_minutes,
1743                name: name.to_string(),
1744                path: path.to_string(),
1745            });
1746        }
1747    }
1748    (status, claims)
1749}
1750
1751/// The roots one enumeration/sweep run actually looked at, as reported by the
1752/// script itself (`RCH_GC_ROOT <kind> <path>`).
1753#[derive(Debug, Clone, Default, PartialEq, Eq)]
1754pub struct ScanRootReport {
1755    /// The `$base` handed to the script, verbatim.
1756    pub requested: Option<String>,
1757    /// The canonicalized base actually walked, absent when the guard bailed.
1758    pub resolved: Option<String>,
1759    /// The canonicalized worker temp base walked, empty/absent when it was
1760    /// skipped by the shallow-root guard.
1761    pub temp_base: Option<String>,
1762    /// `<path> <reason>` for a root the guard refused to walk.
1763    pub skipped: Option<String>,
1764}
1765
1766/// Parse the `RCH_GC_ROOT` / `RCH_GC_ROOT_SKIPPED` lines. Lets `rch gc` report
1767/// which roots were really scanned instead of the ones it hoped to scan — a
1768/// root that silently bails is exactly how this bug hid.
1769#[must_use]
1770pub fn parse_scan_roots(stdout: &str) -> ScanRootReport {
1771    let mut report = ScanRootReport::default();
1772    for line in stdout.lines() {
1773        let line = line.trim();
1774        if let Some(rest) = line.strip_prefix("RCH_GC_ROOT_SKIPPED ") {
1775            if !rest.is_empty() {
1776                report.skipped = Some(rest.to_string());
1777            }
1778            continue;
1779        }
1780        let Some(rest) = line.strip_prefix("RCH_GC_ROOT ") else {
1781            continue;
1782        };
1783        let Some((kind, value)) = rest.split_once(' ') else {
1784            continue;
1785        };
1786        let value = value.trim();
1787        let slot = match kind {
1788            "base" => &mut report.requested,
1789            "resolved" => &mut report.resolved,
1790            "tmp" => &mut report.temp_base,
1791            _ => continue,
1792        };
1793        *slot = (!value.is_empty()).then(|| value.to_string());
1794    }
1795    report
1796}
1797
1798/// Whether both liveness-gate snapshots were available on this run, from the
1799/// `RCH_GC_GATES handles=<0|1> handles_source=<...> procs=<0|1>` line.
1800///
1801/// A missing line reads as "unavailable" — the fail-closed direction.
1802#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1803pub struct GateAvailability {
1804    /// Whether the open-descriptor snapshot was taken.
1805    pub handles: bool,
1806    /// Whether the process-command-line snapshot was taken.
1807    pub processes: bool,
1808}
1809
1810impl GateAvailability {
1811    /// Whether both gates could be evaluated.
1812    #[must_use]
1813    pub fn both(self) -> bool {
1814        self.handles && self.processes
1815    }
1816}
1817
1818/// Parse the `RCH_GC_GATES` line.
1819#[must_use]
1820pub fn parse_gate_availability(stdout: &str) -> GateAvailability {
1821    let mut out = GateAvailability::default();
1822    let Some(line) = stdout.lines().find(|l| l.contains("RCH_GC_GATES")) else {
1823        return out;
1824    };
1825    for token in line.split_whitespace() {
1826        match token.split_once('=') {
1827            Some(("handles", v)) => out.handles = v == "1",
1828            Some(("procs", v)) => out.processes = v == "1",
1829            _ => {}
1830        }
1831    }
1832    out
1833}
1834
1835/// Parse the `RCH_TARGET_ENTRY` lines an [`enumerate_targets_command`] run
1836/// prints. Unparseable lines are skipped (fail-open observability).
1837///
1838/// Two shapes are accepted: the current
1839/// `<newest> <kb> <handles> <procs> <path>` and the pre-gate
1840/// `<newest> <kb> <path>`, which parses with both gates
1841/// [`GateEvidence::Unknown`] — i.e. an old worker script makes every warm-cache
1842/// dir ineligible rather than eligible-by-omission.
1843#[must_use]
1844pub fn parse_target_entries(stdout: &str) -> Vec<RemoteTargetEntry> {
1845    stdout
1846        .lines()
1847        .filter_map(|line| {
1848            let rest = line.trim().strip_prefix("RCH_TARGET_ENTRY ")?;
1849            let mut parts = rest.splitn(5, ' ');
1850            let newest_mtime_unix = parts.next()?.parse::<u64>().ok()?;
1851            let kb = parts.next()?.parse::<u64>().ok()?;
1852            let third = parts.next()?;
1853            let (open_handles, live_process, path) = match GateEvidence::from_token(third) {
1854                Some(handles) => {
1855                    let live = GateEvidence::from_token(parts.next()?)?;
1856                    (handles, live, parts.next()?.trim().to_string())
1857                }
1858                // Legacy 3-field line: everything after `<kb> ` is the path.
1859                None => (
1860                    GateEvidence::Unknown,
1861                    GateEvidence::Unknown,
1862                    rest.splitn(3, ' ').nth(2)?.trim().to_string(),
1863                ),
1864            };
1865            if path.is_empty() {
1866                return None;
1867            }
1868            Some(RemoteTargetEntry {
1869                newest_mtime_unix,
1870                kb,
1871                path,
1872                open_handles,
1873                live_process,
1874            })
1875        })
1876        .collect()
1877}
1878
1879#[cfg(test)]
1880mod tests {
1881    use super::*;
1882
1883    // ── gc eligibility model (issue: `rch gc` reported 0 MB fleet-wide) ─────
1884
1885    fn entry(path: &str, age_secs: u64, gates: (GateEvidence, GateEvidence)) -> RemoteTargetEntry {
1886        RemoteTargetEntry {
1887            newest_mtime_unix: NOW - age_secs,
1888            kb: 4_096,
1889            path: path.to_string(),
1890            open_handles: gates.0,
1891            live_process: gates.1,
1892        }
1893    }
1894
1895    const NOW: u64 = 1_800_000_000;
1896    const FREE: (GateEvidence, GateEvidence) = (GateEvidence::Free, GateEvidence::Free);
1897    const DAY: u64 = 24 * 3600;
1898
1899    fn policy() -> GcPolicy {
1900        GcPolicy {
1901            idle_secs: 12 * 3600,
1902            pooled_idle_secs: Some(7 * DAY),
1903            cache_idle_secs: Some(14 * DAY),
1904        }
1905    }
1906
1907    const POOL: &str = "/data/projects/repo/.rch-target-hz2-pool-deadbeef";
1908    const CACHE: &str = "/data/tmp/rch-cargo-cache-hz2";
1909
1910    /// The glob gc enumerates with must stay welded to the prefix the creator
1911    /// uses; a rename on one side is exactly how these dirs went unscanned.
1912    #[test]
1913    fn cargo_cache_glob_tracks_the_creating_prefix() {
1914        assert_eq!(
1915            CARGO_CACHE_GLOB,
1916            format!("{}*", crate::gc_roots::RCH_CARGO_CACHE_PREFIX)
1917        );
1918        // ...and the durable-cache expression really produces a matching name.
1919        let expr = crate::gc_roots::remote_cargo_cache_expr("hz2");
1920        assert!(expr.ends_with("/rch-cargo-cache-hz2"), "{expr}");
1921        assert_eq!(
1922            GcClass::from_path(&expr.replace("${RCH_CH_BASE}", "/data/tmp")),
1923            GcClass::CargoCache
1924        );
1925    }
1926
1927    #[test]
1928    fn gc_class_is_derived_from_the_basename() {
1929        assert_eq!(GcClass::from_path(POOL), GcClass::Pooled);
1930        assert_eq!(GcClass::from_path(CACHE), GcClass::CargoCache);
1931        assert_eq!(
1932            GcClass::from_path("/data/projects/r/.rch-target-hz2-job-1-2-0"),
1933            GcClass::PerJob
1934        );
1935        assert_eq!(
1936            GcClass::from_path("/data/projects/r/.rch-target-hz2-pid-42-1-0"),
1937            GcClass::PerJob
1938        );
1939        assert_eq!(
1940            GcClass::from_path("/data/tmp/rch_target_old"),
1941            GcClass::LegacyTarget
1942        );
1943        // Never anything else: a bare target dir, a source tree, a git dir.
1944        for other in [
1945            "/data/projects/repo/target",
1946            "/data/projects/repo",
1947            "/data/projects/repo/.git",
1948            "/data/projects/repo/.rch-target-hz2",
1949            "/data/tmp/rch-cargo-home-hz2-run-7",
1950        ] {
1951            assert_eq!(
1952                GcClass::from_path(other),
1953                GcClass::Unrecognized,
1954                "{other} must never be collectible"
1955            );
1956        }
1957    }
1958
1959    /// The behaviour the fix exists for: an old, quiet pooled dir IS eligible.
1960    #[test]
1961    fn pool_and_cache_dirs_are_eligible_when_every_gate_passes() {
1962        for (path, age) in [(POOL, 8 * DAY), (CACHE, 15 * DAY)] {
1963            assert_eq!(
1964                evaluate_gc_candidate(&entry(path, age, FREE), NOW, &policy()),
1965                GcVerdict::Collect,
1966                "{path} should be collectible"
1967            );
1968        }
1969    }
1970
1971    /// Each gate ALONE must be able to veto a collection.
1972    #[test]
1973    fn every_gate_independently_blocks_collection() {
1974        let p = policy();
1975
1976        // 1. age
1977        assert_eq!(
1978            evaluate_gc_candidate(&entry(POOL, 6 * DAY, FREE), NOW, &p),
1979            GcVerdict::Keep(GcKeepReason::TooYoung {
1980                age_secs: 6 * DAY,
1981                required_secs: 7 * DAY
1982            })
1983        );
1984        assert_eq!(
1985            evaluate_gc_candidate(&entry(CACHE, 13 * DAY, FREE), NOW, &p),
1986            GcVerdict::Keep(GcKeepReason::TooYoung {
1987                age_secs: 13 * DAY,
1988                required_secs: 14 * DAY
1989            })
1990        );
1991
1992        // 2. open file descriptors
1993        assert_eq!(
1994            evaluate_gc_candidate(
1995                &entry(POOL, 99 * DAY, (GateEvidence::Held, GateEvidence::Free)),
1996                NOW,
1997                &p
1998            ),
1999            GcVerdict::Keep(GcKeepReason::OpenHandles)
2000        );
2001
2002        // 3. a live build process rooted at the dir
2003        assert_eq!(
2004            evaluate_gc_candidate(
2005                &entry(POOL, 99 * DAY, (GateEvidence::Free, GateEvidence::Held)),
2006                NOW,
2007                &p
2008            ),
2009            GcVerdict::Keep(GcKeepReason::LiveProcess)
2010        );
2011
2012        // 4. class disabled by configuration
2013        let disabled = GcPolicy {
2014            pooled_idle_secs: None,
2015            cache_idle_secs: None,
2016            ..p
2017        };
2018        for path in [POOL, CACHE] {
2019            assert_eq!(
2020                evaluate_gc_candidate(&entry(path, 99 * DAY, FREE), NOW, &disabled),
2021                GcVerdict::Keep(GcKeepReason::ClassDisabled)
2022            );
2023        }
2024
2025        // 5. an unrecognized dir is never collected, at any age.
2026        assert_eq!(
2027            evaluate_gc_candidate(
2028                &entry("/data/projects/repo/target", 99 * DAY, FREE),
2029                NOW,
2030                &p
2031            ),
2032            GcVerdict::Keep(GcKeepReason::Unrecognized)
2033        );
2034    }
2035
2036    /// The conservative rule: a gate that ERRORED reads as "in use". A dir that
2037    /// fails to prove itself free is never collected.
2038    #[test]
2039    fn an_unavailable_gate_makes_a_warm_cache_dir_ineligible() {
2040        let p = policy();
2041        for gates in [
2042            (GateEvidence::Unknown, GateEvidence::Free),
2043            (GateEvidence::Free, GateEvidence::Unknown),
2044            (GateEvidence::Unknown, GateEvidence::Unknown),
2045        ] {
2046            for path in [POOL, CACHE] {
2047                let verdict = evaluate_gc_candidate(&entry(path, 99 * DAY, gates), NOW, &p);
2048                assert!(
2049                    matches!(verdict, GcVerdict::Keep(GcKeepReason::GateUnavailable(_))),
2050                    "{path} with gates {gates:?} must be ineligible, got {verdict:?}"
2051                );
2052            }
2053        }
2054    }
2055
2056    /// Per-job dirs keep the idle window as their authority (an unavailable
2057    /// gate must not newly freeze a sweep that has always been safe on age
2058    /// alone) — but a gate that DID run and says "held" still vetoes them.
2059    #[test]
2060    fn per_job_dirs_keep_idle_window_authority_but_still_honour_a_live_gate() {
2061        let p = policy();
2062        let job = "/data/projects/repo/.rch-target-hz2-job-1-2-0";
2063        assert_eq!(
2064            evaluate_gc_candidate(
2065                &entry(job, 2 * DAY, (GateEvidence::Unknown, GateEvidence::Unknown)),
2066                NOW,
2067                &p
2068            ),
2069            GcVerdict::Collect
2070        );
2071        assert_eq!(
2072            evaluate_gc_candidate(
2073                &entry(job, 2 * DAY, (GateEvidence::Held, GateEvidence::Unknown)),
2074                NOW,
2075                &p
2076            ),
2077            GcVerdict::Keep(GcKeepReason::OpenHandles)
2078        );
2079    }
2080
2081    /// A worker mtime AHEAD of the client's clock must read as "just touched",
2082    /// never as a huge age that would collect a live dir.
2083    #[test]
2084    fn a_future_mtime_reads_as_zero_age() {
2085        let mut e = entry(POOL, 0, FREE);
2086        e.newest_mtime_unix = NOW + 10_000;
2087        assert_eq!(e.age_secs(NOW), 0);
2088        assert!(matches!(
2089            evaluate_gc_candidate(&e, NOW, &policy()),
2090            GcVerdict::Keep(GcKeepReason::TooYoung { .. })
2091        ));
2092    }
2093
2094    #[test]
2095    fn idle_window_helpers_floor_and_disable() {
2096        assert_eq!(pooled_idle_minutes_from_hours(0), None);
2097        assert_eq!(
2098            pooled_idle_minutes_from_hours(1),
2099            Some(MIN_POOLED_IDLE_MINUTES)
2100        );
2101        assert_eq!(pooled_idle_minutes_from_hours(168), Some(168 * 60));
2102        assert_eq!(cargo_cache_idle_minutes_from_days(0), None);
2103        assert_eq!(
2104            cargo_cache_idle_minutes_from_days(1),
2105            Some(MIN_CARGO_CACHE_IDLE_MINUTES)
2106        );
2107        assert_eq!(cargo_cache_idle_minutes_from_days(14), Some(14 * 24 * 60));
2108    }
2109
2110    /// An old worker script (or a truncated line) yields UNKNOWN gates, which
2111    /// is the ineligible direction — never eligible-by-omission.
2112    #[test]
2113    fn legacy_entry_lines_parse_with_unknown_gates_and_are_ineligible() {
2114        let legacy = format!("RCH_TARGET_ENTRY {} 4096 {POOL}\n", NOW - 99 * DAY);
2115        let entries = parse_target_entries(&legacy);
2116        assert_eq!(entries.len(), 1);
2117        assert_eq!(entries[0].path, POOL);
2118        assert_eq!(entries[0].open_handles, GateEvidence::Unknown);
2119        assert_eq!(entries[0].live_process, GateEvidence::Unknown);
2120        assert!(matches!(
2121            evaluate_gc_candidate(&entries[0], NOW, &policy()),
2122            GcVerdict::Keep(GcKeepReason::GateUnavailable(_))
2123        ));
2124
2125        let current = format!(
2126            "RCH_TARGET_ENTRY {} 4096 free free {POOL}\n",
2127            NOW - 99 * DAY
2128        );
2129        let entries = parse_target_entries(&current);
2130        assert_eq!(entries[0].path, POOL);
2131        assert_eq!(entries[0].open_handles, GateEvidence::Free);
2132        assert_eq!(
2133            evaluate_gc_candidate(&entries[0], NOW, &policy()),
2134            GcVerdict::Collect
2135        );
2136    }
2137
2138    #[test]
2139    fn gate_availability_defaults_to_unavailable() {
2140        assert_eq!(
2141            parse_gate_availability("nothing here"),
2142            GateAvailability::default()
2143        );
2144        assert!(!parse_gate_availability("").both());
2145        let ok = parse_gate_availability("RCH_GC_GATES handles=1 handles_source=proc procs=1\n");
2146        assert!(ok.both());
2147        let partial =
2148            parse_gate_availability("RCH_GC_GATES handles=1 handles_source=proc procs=0\n");
2149        assert!(!partial.both());
2150        assert!(partial.handles);
2151    }
2152
2153    #[test]
2154    fn scan_roots_and_skips_are_reported_back() {
2155        let out = "RCH_GC_ROOT base /srv\nRCH_GC_ROOT_SKIPPED /srv too-shallow\n";
2156        let roots = parse_scan_roots(out);
2157        assert_eq!(roots.requested.as_deref(), Some("/srv"));
2158        assert_eq!(roots.resolved, None);
2159        assert_eq!(roots.skipped.as_deref(), Some("/srv too-shallow"));
2160
2161        let out = "RCH_GC_ROOT base /data/projects\nRCH_GC_ROOT resolved /data/projects\nRCH_GC_ROOT tmp \n";
2162        let roots = parse_scan_roots(out);
2163        assert_eq!(roots.resolved.as_deref(), Some("/data/projects"));
2164        assert_eq!(
2165            roots.temp_base, None,
2166            "an unscanned tmp base is absent, not empty-string"
2167        );
2168        assert_eq!(roots.skipped, None);
2169    }
2170
2171    #[test]
2172    fn collect_paths_command_refuses_anything_it_did_not_recognize() {
2173        let good = GcCollectTarget {
2174            path: POOL.to_string(),
2175            idle_minutes: 10_080,
2176            trigger: "pooled-ttl",
2177        };
2178        assert!(collect_paths_command(std::slice::from_ref(&good)).is_ok());
2179        assert!(collect_paths_command(&[]).is_err());
2180
2181        for bad_path in [
2182            "/",
2183            "/tmp",
2184            "relative/.rch-target-w-pool-a",
2185            "/tmp/../etc/.rch-target-w-pool-a",
2186            "/tmp/x/.rch-target-w-pool-a; rm -rf /",
2187            "/data/projects/repo/target",
2188            "/data/projects/repo",
2189        ] {
2190            let t = GcCollectTarget {
2191                path: bad_path.to_string(),
2192                ..good.clone()
2193            };
2194            assert!(
2195                collect_paths_command(&[t]).is_err(),
2196                "{bad_path} must be refused"
2197            );
2198        }
2199
2200        // A zero window would remove a dir that is being written to right now.
2201        let zero = GcCollectTarget {
2202            idle_minutes: 0,
2203            ..good.clone()
2204        };
2205        assert!(collect_paths_command(&[zero]).is_err());
2206    }
2207
2208    #[test]
2209    fn collect_target_rejection_screens_unembeddable_paths_individually() {
2210        // bd-kr4qb: `…/worker volume/…` (a space) must be rejected on its own,
2211        // so callers can skip it and still collect every other target.
2212        let good = GcCollectTarget {
2213            path: POOL.to_string(),
2214            idle_minutes: 10_080,
2215            trigger: "pooled-ttl",
2216        };
2217        let spaced = GcCollectTarget {
2218            path: "/data/tmp/rch/repo/.rch-tmp/.tmp8xieom/worker volume/.rch-target-w-pool-a"
2219                .to_string(),
2220            ..good.clone()
2221        };
2222        assert_eq!(collect_target_rejection(&good), None);
2223        let reason = collect_target_rejection(&spaced).expect("space must be rejected");
2224        assert!(reason.contains("metacharacter-free"), "{reason}");
2225        // The rejection is exactly what fails the batch...
2226        assert_eq!(
2227            collect_paths_command(&[good.clone(), spaced.clone()]),
2228            Err(reason)
2229        );
2230        // ...so screening it out leaves a collectable batch.
2231        let screened: Vec<GcCollectTarget> = [good, spaced]
2232            .into_iter()
2233            .filter(|t| collect_target_rejection(t).is_none())
2234            .collect();
2235        assert_eq!(screened.len(), 1);
2236        assert!(collect_paths_command(&screened).is_ok());
2237    }
2238
2239    #[test]
2240    fn collect_paths_command_rechecks_every_gate_on_the_worker() {
2241        let cmd = collect_paths_command(&[GcCollectTarget {
2242            path: POOL.to_string(),
2243            idle_minutes: 10_080,
2244            trigger: "pooled-ttl",
2245        }])
2246        .expect("valid target");
2247
2248        // Still a directory, still idle, still unheld — checked in that order,
2249        // immediately before the rm, so the enumerate→apply window is closed.
2250        assert!(cmd.contains("if [ ! -d \"$d\" ]; then printf 'RCH_GC_SKIP %s missing"));
2251        // 13da097f: the idle check is the shared, checked activity helper.
2252        assert!(cmd.contains("if __gc_source_recent_activity \"$d\" \"$__mins\"; then"));
2253        assert!(cmd.contains("if ! __gc_gates_ok \"$d\"; then printf 'RCH_GC_SKIP %s gate"));
2254        assert!(cmd.contains("rm -rf -- \"$d\""));
2255        assert!(cmd.contains("RCH_WORKER_REAP_METRICS"));
2256        // The gate snapshots exist, and an unavailable one fails closed.
2257        assert!(cmd.contains("__gc_gates_ok() {"));
2258        assert!(cmd.contains("[ \"$(__gc_handles \"$1\")\" = free ] || return 1"));
2259        assert!(cmd.contains("[ \"$(__gc_procs \"$1\")\" = free ] || return 1"));
2260        assert!(cmd.contains("__held_ok\" -ne 1 ]; then printf unknown"));
2261        assert!(cmd.contains("__proc_ok\" -ne 1 ]; then printf unknown"));
2262    }
2263
2264    /// The gates must ride on the SAME temp-base resolution that creates the
2265    /// dirs, not a second copy of the ladder.
2266    #[test]
2267    fn scan_roots_reuse_the_creating_prelude() {
2268        let prelude = crate::gc_roots::remote_cargo_home_base_prelude();
2269        for cmd in [
2270            enumerate_targets_command("/data/projects"),
2271            worker_sweep_command("/data/projects", 720, Some(10_080), None),
2272            collect_paths_command(&[GcCollectTarget {
2273                path: POOL.to_string(),
2274                idle_minutes: 10_080,
2275                trigger: "pooled-ttl",
2276            }])
2277            .unwrap(),
2278        ] {
2279            assert!(
2280                cmd.contains(&prelude),
2281                "the temp base must come from remote_cargo_home_base_prelude, not a copy"
2282            );
2283        }
2284        // ...and the old hand-copied ladder is gone for good.
2285        let cmd = enumerate_targets_command("/data/projects");
2286        assert!(!cmd.contains("__tmpbase=\"${TMPDIR:-}\""));
2287    }
2288
2289    /// The enumeration is read-only and its exit status must mean "the SSH
2290    /// command ran", nothing else. A `[ -n "$x" ] && rm -f "$x"` tail would
2291    /// exit 1 on a worker whose `mktemp` failed and turn a healthy read-only
2292    /// scan into a reported worker failure.
2293    #[test]
2294    fn enumeration_cannot_exit_nonzero_because_a_temp_file_was_never_made() {
2295        let cmd = enumerate_targets_command("/data/projects");
2296        assert!(
2297            cmd.trim_end().ends_with("exit 0"),
2298            "enumerate must end with an explicit status"
2299        );
2300        for var in ["__held", "__proc"] {
2301            assert!(
2302                !cmd.contains(&format!("[ -n \"${var}\" ] && rm -f")),
2303                "${var} cleanup must be an `if`, not an `&&` whose failure becomes the exit status"
2304            );
2305            assert!(cmd.contains(&format!("if [ -n \"${var}\" ]; then rm -f \"${var}\"; fi")));
2306        }
2307    }
2308
2309    /// The gates are consulted only for a dir the TTL pass would actually
2310    /// remove: the cheap idle test runs first, so a busy worker does not pay
2311    /// two `awk` scans per warm pool nor emit a skip line for every one.
2312    #[test]
2313    fn pooled_gate_runs_after_the_cheap_idle_test() {
2314        let cmd = worker_sweep_command("/data/projects", 720, Some(10_080), None);
2315        let gate = cmd
2316            .find("__gc_gates_ok \"$d\"; then printf 'RCH_GC_SKIP pooled-ttl")
2317            .expect("pooled gate present");
2318        let idle = cmd
2319            .find("find \"$d\" -mmin -10080 -print -quit")
2320            .expect("pooled idle test present");
2321        assert!(idle < gate, "the idle test must precede the gate check");
2322    }
2323
2324    #[test]
2325    fn byte_cap_eviction_is_gated_on_liveness_too() {
2326        let cmd = worker_sweep_command("/data/projects", 720, Some(10_080), Some(1024));
2327        assert!(
2328            cmd.contains("if ! __gc_gates_ok \"$d\"; then printf 'RCH_GC_SKIP cap gate"),
2329            "a disk budget must not evict a dir a build is holding open"
2330        );
2331        assert!(cmd.contains("printf 'RCH_GC_SKIP %s active %s\\n' cap"));
2332    }
2333
2334    #[test]
2335    fn pooled_sweep_pass_is_gated_on_liveness() {
2336        let cmd = worker_sweep_command("/data/projects", 720, Some(10_080), None);
2337        assert!(
2338            cmd.contains("if ! __gc_gates_ok \"$d\"; then printf 'RCH_GC_SKIP pooled-ttl gate"),
2339            "the daemon sweep must apply the same liveness gates to pooled dirs"
2340        );
2341        // The daemon sweep must NOT delete durable Cargo caches: that class is
2342        // collected only by an explicit, dry-run-by-default `rch gc --apply`.
2343        assert!(!cmd.contains("rch-cargo-cache-*"));
2344    }
2345
2346    #[test]
2347    fn byte_cap_evicts_oldest_first_and_only_past_the_active_floor() {
2348        let p = policy();
2349        // Three pools, all within their 7-day pooled TTL so none is collected
2350        // by the TTL pass; two are past the SHORT (12h) active-build floor.
2351        let mut entries = vec![
2352            entry(POOL, 5 * DAY, FREE), // oldest
2353            entry("/data/projects/r/.rch-target-hz2-pool-b", 2 * DAY, FREE),
2354            entry("/data/projects/r/.rch-target-hz2-pool-c", 60, FREE), // fresh
2355        ];
2356        for e in &mut entries {
2357            e.kb = 10_000;
2358        }
2359        let verdicts: Vec<GcVerdict> = entries
2360            .iter()
2361            .map(|e| evaluate_gc_candidate(e, NOW, &p))
2362            .collect();
2363        assert!(verdicts.iter().all(|v| !v.is_collect()));
2364
2365        // No cap configured: nothing extra.
2366        assert!(select_cap_evictions(&entries, &verdicts, NOW, &p, 0).is_empty());
2367        // Under budget: nothing extra.
2368        assert!(select_cap_evictions(&entries, &verdicts, NOW, &p, 30_000).is_empty());
2369        // 30_000 KB on disk, 15_000 budget: evict the oldest until under.
2370        let evicted = select_cap_evictions(&entries, &verdicts, NOW, &p, 15_000);
2371        assert_eq!(evicted, vec![0, 1]);
2372        // The fresh dir is never evicted, however tight the budget.
2373        let evicted = select_cap_evictions(&entries, &verdicts, NOW, &p, 1);
2374        assert_eq!(
2375            evicted,
2376            vec![0, 1],
2377            "the active-build floor outranks the cap"
2378        );
2379    }
2380
2381    #[test]
2382    fn byte_cap_still_honours_the_liveness_gates() {
2383        let p = policy();
2384        let mut held = entry(POOL, 5 * DAY, (GateEvidence::Held, GateEvidence::Free));
2385        held.kb = 10_000;
2386        let mut unknown = entry(
2387            "/data/projects/r/.rch-target-hz2-pool-b",
2388            5 * DAY,
2389            (GateEvidence::Unknown, GateEvidence::Unknown),
2390        );
2391        unknown.kb = 10_000;
2392        let entries = vec![held, unknown];
2393        let verdicts: Vec<GcVerdict> = entries
2394            .iter()
2395            .map(|e| evaluate_gc_candidate(e, NOW, &p))
2396            .collect();
2397        assert!(
2398            select_cap_evictions(&entries, &verdicts, NOW, &p, 1).is_empty(),
2399            "a held dir and an unprovable dir must both survive the cap"
2400        );
2401    }
2402
2403    #[test]
2404    fn gc_skip_lines_parse_back() {
2405        let out = "RCH_GC_SKIP pooled-ttl gate /a/b/.rch-target-w-pool-x\n\
2406                   RCH_GC_SKIP cache-ttl active /a/b/rch-cargo-cache-w\n\
2407                   RCH_GC_SKIP bogus\n";
2408        let skips = parse_gc_skips(out);
2409        assert_eq!(skips.len(), 2);
2410        assert_eq!(skips[0].trigger, "pooled-ttl");
2411        assert_eq!(skips[0].reason, "gate");
2412        assert_eq!(skips[1].path, "/a/b/rch-cargo-cache-w");
2413    }
2414
2415    /// End-to-end on a real fixture: an aged pooled dir and an aged Cargo cache
2416    /// are BOTH enumerated, both report free gates, and a dir this very test
2417    /// process holds a descriptor into reports `held` and survives `--apply`.
2418    ///
2419    /// Linux-only: the descriptor snapshot reads `/proc/<pid>/fd`, and the
2420    /// `lsof` fallback would scan every open file on the host — fine on a
2421    /// worker, not something to make a unit test wait for on a dev Mac.
2422    #[cfg(target_os = "linux")]
2423    #[test]
2424    fn open_descriptors_keep_a_stale_pool_alive_end_to_end() {
2425        use std::process::Command;
2426
2427        let tmp = tempfile::tempdir().unwrap();
2428        let base = tmp.path().join("projects");
2429        let tmpbase = tmp.path().join("scratch");
2430        let free_pool = base.join("repo").join(".rch-target-w1-pool-free");
2431        let held_pool = base.join("repo").join(".rch-target-w1-pool-held");
2432        let cache = tmpbase.join("rch-cargo-cache-w1");
2433        for d in [&free_pool, &held_pool, &cache] {
2434            std::fs::create_dir_all(d).unwrap();
2435            std::fs::write(d.join("artifact.o"), b"xxxx").unwrap();
2436        }
2437        for d in [&free_pool, &held_pool, &cache] {
2438            let ok = Command::new("find")
2439                .arg(d)
2440                .args(["-exec", "touch", "-t", "202001010000", "{}", "+"])
2441                .status()
2442                .map(|s| s.success())
2443                .unwrap_or(false);
2444            if !ok {
2445                return; // no usable find/touch here
2446            }
2447        }
2448
2449        // Hold a descriptor open under one pool for the rest of the test.
2450        let _guard = std::fs::File::open(held_pool.join("artifact.o")).unwrap();
2451
2452        let enumerate = enumerate_targets_command(base.to_str().unwrap());
2453        let out = Command::new("sh")
2454            .arg("-c")
2455            .arg(&enumerate)
2456            .env("TMPDIR", &tmpbase)
2457            .output()
2458            .expect("enumerate should execute");
2459        let stdout = String::from_utf8_lossy(&out.stdout);
2460        assert!(
2461            parse_gate_availability(&stdout).both(),
2462            "both gate snapshots must be available on Linux: {stdout}"
2463        );
2464        let entries = parse_target_entries(&stdout);
2465        let find = |needle: &str| {
2466            entries
2467                .iter()
2468                .find(|e| e.path.contains(needle))
2469                .unwrap_or_else(|| panic!("{needle} not enumerated in {entries:?}"))
2470        };
2471        assert_eq!(find("rch-cargo-cache-w1").class(), GcClass::CargoCache);
2472        assert_eq!(find("pool-held").open_handles, GateEvidence::Held);
2473        assert_eq!(find("pool-free").open_handles, GateEvidence::Free);
2474
2475        // `--apply` must remove the free pool and the cache, and refuse the
2476        // held one even though it is just as old.
2477        let targets: Vec<GcCollectTarget> = [&free_pool, &held_pool, &cache]
2478            .iter()
2479            .map(|d| GcCollectTarget {
2480                path: std::fs::canonicalize(d)
2481                    .unwrap()
2482                    .to_string_lossy()
2483                    .into_owned(),
2484                idle_minutes: MIN_POOLED_IDLE_MINUTES,
2485                trigger: "pooled-ttl",
2486            })
2487            .collect();
2488        // A private registry: offloaded, this fixture sits inside a source
2489        // root the test build itself owns in the worker's real one.
2490        let cmd = collect_paths_command_with_registry(
2491            &targets,
2492            tmp.path().join("claims-v1").to_str().unwrap(),
2493        )
2494        .expect("targets are valid");
2495        let out = Command::new("sh")
2496            .arg("-c")
2497            .arg(&cmd)
2498            .env("TMPDIR", &tmpbase)
2499            .output()
2500            .expect("collect should execute");
2501        let stdout = String::from_utf8_lossy(&out.stdout);
2502        let stderr = String::from_utf8_lossy(&out.stderr);
2503        assert!(out.status.success(), "collect must be valid sh: {stderr}");
2504        assert!(
2505            !stderr.contains("syntax error") && !stderr.contains("unexpected"),
2506            "collect emitted shell errors: {stderr}"
2507        );
2508        assert!(
2509            !free_pool.exists(),
2510            "an idle, unheld pool must be collected"
2511        );
2512        assert!(
2513            !cache.exists(),
2514            "an idle, unheld cargo cache must be collected"
2515        );
2516        assert!(
2517            held_pool.exists(),
2518            "a pool with an open descriptor must survive: {stdout}"
2519        );
2520        let skipped = parse_gc_skips(&stdout);
2521        assert!(
2522            skipped
2523                .iter()
2524                .any(|s| s.path.contains("pool-held") && s.reason == "gate"),
2525            "the held pool must be reported as gate-skipped: {stdout}"
2526        );
2527        assert_eq!(parse_worker_reap_metrics(&stdout).map(|m| m.0), Some(2));
2528    }
2529
2530    #[test]
2531    fn safe_reap_path_accepts_deep_abs() {
2532        assert!(is_safe_reap_path("/tmp/rch/myproject/abc123"));
2533        assert!(is_safe_reap_path("/tmp/rch"));
2534    }
2535
2536    #[test]
2537    fn safe_reap_path_rejects_dangerous() {
2538        assert!(!is_safe_reap_path("/"));
2539        assert!(!is_safe_reap_path("/tmp")); // only one segment
2540        assert!(!is_safe_reap_path("/tmp/../etc"));
2541        assert!(!is_safe_reap_path("/tmp/rch; rm -rf x"));
2542        assert!(!is_safe_reap_path("relative/path"));
2543        assert!(!is_safe_reap_path("/tmp/$(whoami)"));
2544    }
2545
2546    #[test]
2547    fn safe_reap_base_allows_single_segment_but_not_root() {
2548        assert!(is_safe_reap_base("/srv"));
2549        assert!(is_safe_reap_base("/tmp/rch"));
2550        assert!(!is_safe_reap_base("/"));
2551        assert!(!is_safe_reap_base("//"));
2552        assert!(!is_safe_reap_base("/../x"));
2553    }
2554
2555    #[test]
2556    fn safe_reap_token_rules() {
2557        assert!(is_safe_reap_token(".rch-target-ts2-job-1-2-0"));
2558        assert!(!is_safe_reap_token(""));
2559        assert!(!is_safe_reap_token("."));
2560        assert!(!is_safe_reap_token(".."));
2561        assert!(!is_safe_reap_token("a/b"));
2562        assert!(!is_safe_reap_token("a b"));
2563    }
2564
2565    #[test]
2566    fn idle_minutes_floor() {
2567        assert_eq!(idle_minutes_from_hours(0), 60);
2568        assert_eq!(idle_minutes_from_hours(12), 720);
2569    }
2570
2571    /// Pooled/per-job targets staged under the tmp base
2572    /// (`/data/tmp/rch/<project>/<hash>/.rch-target-<worker>-pool-<key>`) sit
2573    /// three levels below it and outside the sync-root walk, so before the
2574    /// tmp-base pass they were invisible to every discovery pass and grew to
2575    /// 110-129 GB per worker while gc reported "removed 0 dir(s)".
2576    #[test]
2577    fn tmp_base_dot_prefixed_targets_are_discovered() {
2578        let cmd = worker_sweep_command("/data/projects", 720, Some(10_080), Some(1024));
2579
2580        // Job/pid pass reaches the tmp base, not just depth-1 `rch_target_*`.
2581        assert!(cmd.contains(&format!(
2582            "find \"$__tmpscan\" -maxdepth {TMPBASE_MAXDEPTH} -type d \\( -name \".rch-target-*-job-*\" -o -name \".rch-target-*-pid-*\" \\)"
2583        )));
2584        // Pooled pass reaches the tmp base too.
2585        assert!(cmd.contains(&format!(
2586            "find \"$__tmpscan\" -maxdepth {TMPBASE_MAXDEPTH} -type d -name \".rch-target-*-pool-*\""
2587        )));
2588        // The legacy depth-1 pass is retained, not replaced.
2589        assert!(cmd.contains("-maxdepth 1 -type d -name \"rch_target_*\""));
2590        // A base that is also under the tmp base can list a dir twice; dedup
2591        // keeps the byte-cap pass's total_kb honest.
2592        for var in ["__tmpf", "__tmpf2", "__tmpf3"] {
2593            assert!(
2594                cmd.contains(&format!("sort -u \"${var}\" -o \"$__ded\"")),
2595                "missing dedup for ${var}"
2596            );
2597            assert!(
2598                cmd.contains(&format!("mv -f \"$__ded\" \"${var}\"")),
2599                "dedup for ${var} must move the sorted temp back into place"
2600            );
2601        }
2602        // Dedup must NEVER sort a candidate list onto itself: gc runs on full
2603        // disks, and a sort that fails after opening its output would empty the
2604        // list exactly when gc is needed most.
2605        for var in ["__tmpf", "__tmpf2", "__tmpf3"] {
2606            assert!(
2607                !cmd.contains(&format!("sort -u \"${var}\" -o \"${var}\"")),
2608                "in-place sort of ${var} can truncate the candidate list"
2609            );
2610        }
2611    }
2612
2613    /// The tmp-base walk stays behind the same two-segment guard as the sync
2614    /// root, so a bare `/tmp` fallback is never swept wholesale.
2615    #[test]
2616    fn tmp_base_passes_keep_shallow_root_guard() {
2617        let cmd = worker_sweep_command("/data/projects", 720, Some(10_080), Some(1024));
2618
2619        // The depth guard is applied to the raw tmp base before resolution AND
2620        // to the resolved path, so neither a shallow `/tmp` nor a `/tmp` that
2621        // canonicalizes deeper (macOS `/private/tmp`) becomes sweepable.
2622        assert!(cmd.contains("case \"$__tmpbase\" in /*/*) __tmpscan="));
2623        assert!(cmd.contains("case \"$__tmpscan\" in /*/*) ;; *) __tmpscan=\"\";; esac"));
2624        // Both roots are canonicalized, which is what makes string dedup sound.
2625        assert!(cmd.contains("__rt=$(cd \"$base\" 2>/dev/null && pwd -P)"));
2626        assert!(cmd.contains("__tmpscan=$(cd \"$__tmpbase\" 2>/dev/null && pwd -P)"));
2627
2628        // Every tmp-base scan must sit behind the resolved, guarded variable —
2629        // never scan `$__tmpbase` directly, which is only the mktemp location.
2630        assert!(
2631            !cmd.contains("find \"$__tmpbase\""),
2632            "tmp-base scans must use the resolved $__tmpscan, not $__tmpbase"
2633        );
2634        // ...and each such scan is gated on it being non-empty.
2635        let scans = cmd.matches("find \"$__tmpscan\"").count();
2636        let gates = cmd.matches("if [ -n \"$__tmpscan\" ]; then").count();
2637        assert!(
2638            scans >= 4,
2639            "expected the job/pid, legacy, pooled and cap scans"
2640        );
2641        assert!(
2642            gates >= 3,
2643            "every tmp-base scan group must be gated on $__tmpscan being set"
2644        );
2645    }
2646
2647    #[test]
2648    fn loop_body_keeps_recent_and_excludes_current() {
2649        let body = reap_loop_body(720, Some(".rch-target-self"), "", "");
2650        // Excludes the current job dir.
2651        assert!(body.contains("[ \"$d\" = \".rch-target-self\" ] && continue"));
2652        // Keeps dirs with recent activity (no -type f).
2653        assert!(body.contains("find \"$d\" -mmin -720 -print -quit"));
2654        assert!(!body.contains("-type f"));
2655        // Removes otherwise.
2656        assert!(body.contains("rm -rf -- \"$d\""));
2657    }
2658
2659    #[test]
2660    fn loop_body_with_metrics_accounts_size() {
2661        let body = reap_loop_body(720, None, "removed", "freed_kb");
2662        assert!(body.contains("du -sk \"$d\""));
2663        assert!(body.contains("removed=$((removed + 1))"));
2664        assert!(body.contains("freed_kb=$((freed_kb + sz))"));
2665    }
2666
2667    /// Minimal shell-style glob match (`*` = any run of chars) for asserting a
2668    /// dir name is covered by one of the `REAP_GLOBS`.
2669    fn glob_matches(glob: &str, name: &str) -> bool {
2670        // Split on `*` and require each literal segment to appear in order, with
2671        // the first/last anchored when the glob has no leading/trailing `*`.
2672        let parts: Vec<&str> = glob.split('*').collect();
2673        let mut pos = 0usize;
2674        for (i, part) in parts.iter().enumerate() {
2675            if part.is_empty() {
2676                continue;
2677            }
2678            match name[pos..].find(part) {
2679                Some(idx) => {
2680                    if i == 0 && !glob.starts_with('*') && idx != 0 {
2681                        return false;
2682                    }
2683                    pos += idx + part.len();
2684                }
2685                None => return false,
2686            }
2687        }
2688        // Trailing literal must end the name when glob doesn't end in `*`.
2689        if !glob.ends_with('*') {
2690            return name.ends_with(parts.last().copied().unwrap_or(""));
2691        }
2692        true
2693    }
2694
2695    #[test]
2696    fn pooled_target_dir_name_is_reapable() {
2697        // The pooled remote target dir (`.rch-target-<worker>-pool-<key>`) minted
2698        // by the hook for target-dir REUSE must still be matched by a reap glob so
2699        // the existing idle-based reaper reclaims abandoned pools.
2700        let name = ".rch-target-ts2-pool-deadbeefcafef00ddeadbeefcafef00d";
2701        assert!(
2702            REAP_GLOBS.iter().any(|g| glob_matches(g, name)),
2703            "pooled target dir {name} must match a reap glob: {REAP_GLOBS:?}"
2704        );
2705        assert!(
2706            is_safe_reap_token(name),
2707            "pooled name must be reap-token-safe"
2708        );
2709        // The legacy per-job/per-pid names stay reapable too.
2710        assert!(
2711            REAP_GLOBS
2712                .iter()
2713                .any(|g| glob_matches(g, ".rch-target-ts2-job-7-123-0"))
2714        );
2715        assert!(
2716            REAP_GLOBS
2717                .iter()
2718                .any(|g| glob_matches(g, ".rch-target-ts2-pid-99-123-0"))
2719        );
2720        // A bare `target` (or non-rch dir) is NEVER matched.
2721        assert!(!REAP_GLOBS.iter().any(|g| glob_matches(g, "target")));
2722        assert!(!REAP_GLOBS.iter().any(|g| glob_matches(g, ".rch-target")));
2723    }
2724
2725    #[test]
2726    fn worker_sweep_pooled_pass_is_optional_and_floored() {
2727        // Without a pooled window, pool dirs are never touched.
2728        let cmd = worker_sweep_command("/data/projects", 720, None, None);
2729        assert!(!cmd.contains("-pool-"));
2730
2731        // With one, a second pass targets exactly the pool glob under its own
2732        // (floored) window, feeding the same counters.
2733        let cmd = worker_sweep_command("/data/projects", 720, Some(168 * 60), None);
2734        assert!(cmd.contains("-name \".rch-target-*-pool-*\" -prune"));
2735        assert!(
2736            cmd.contains("-mmin -10080"),
2737            "pooled window must reach the predicate"
2738        );
2739        assert!(cmd.contains("done < \"$__tmpf2\""));
2740        assert!(!cmd.contains("| while"));
2741
2742        // A dangerously small non-zero window is floored to 24h.
2743        let cmd = worker_sweep_command("/data/projects", 720, Some(60), None);
2744        assert!(cmd.contains("-mmin -1440"));
2745    }
2746
2747    #[test]
2748    fn worker_sweep_cap_pass_is_optional_and_shaped() {
2749        // Without a cap there is no accounting pass at all.
2750        let cmd = worker_sweep_command("/data/projects", 720, None, None);
2751        assert!(!cmd.contains("total_kb"));
2752
2753        let cmd = worker_sweep_command("/data/projects", 720, None, Some(100 * 1024 * 1024));
2754        // The budget (KiB) reaches the comparison verbatim.
2755        assert!(cmd.contains("104857600"));
2756        // Oldest-first eviction order via numeric sort on newest-mtime.
2757        assert!(cmd.contains("sort -n"));
2758        // The active-build safety floor uses the SHORT idle window: once for
2759        // the TTL pass, once inside the eviction loop.
2760        assert!(
2761            cmd.matches("__gc_source_recent_activity \"$d\" 720")
2762                .count()
2763                >= 2
2764        );
2765        // Counter mutations survive: eviction loop reads from a file.
2766        assert!(cmd.contains("done < \"$__tmpf3\""));
2767        assert!(!cmd.contains("| while"));
2768    }
2769
2770    #[test]
2771    fn worker_sweep_command_keeps_the_load_bearing_shape() {
2772        let cmd = worker_sweep_command("/data/projects", 720, None, None);
2773        // Per-job discovery is depth-bounded, pruned, and job/pid-only (pooled
2774        // dirs are reused and never swept).
2775        assert!(cmd.contains(
2776            "find \"$__rt\" -maxdepth 8 -type d \\( -name \".rch-target-*-job-*\" -o -name \".rch-target-*-pid-*\" \\) -prune"
2777        ));
2778        assert!(!cmd.contains("sweep-pool"));
2779        // 6dj11: the legacy tmp-base pass, depth-guarded so /tmp is never swept.
2780        assert!(cmd.contains(
2781            "if [ -n \"$__tmpscan\" ]; then find \"$__tmpscan\" -maxdepth 1 -type d -name \"rch_target_*\" -prune"
2782        ));
2783        // Metrics survive the loop: no `find | while` pipe subshell.
2784        assert!(cmd.contains("done < \"$__tmpf\""));
2785        assert!(!cmd.contains("| while"));
2786        assert!(cmd.contains("RCH_WORKER_REAP_METRICS removed=%s freed_kb=%s"));
2787        // The idle window (already minutes) reaches the predicate verbatim.
2788        assert!(cmd.contains("__gc_source_recent_activity \"$d\" 720"));
2789    }
2790
2791    #[test]
2792    fn enumerate_command_is_read_only_and_includes_pooled_dirs() {
2793        let cmd = enumerate_targets_command("/data/projects");
2794        // Pooled dirs AND the durable per-worker Cargo caches, in one pass.
2795        assert!(cmd.contains(
2796            "\\( -name \".rch-target-*-pool-*\" -o -name \"rch-cargo-cache-*\" \\) -prune"
2797        ));
2798        assert!(cmd.contains("RCH_TARGET_ENTRY"));
2799        assert!(cmd.contains("done < \"$__tmpf\""));
2800        // Read-only: never a recursive removal, and every `rm` targets a shell
2801        // temp-file variable this script created — never a discovered path.
2802        // (Stated as an invariant rather than an occurrence count so adding
2803        // temp bookkeeping cannot silently weaken it.)
2804        assert!(!cmd.contains("rm -rf"));
2805        let removals: Vec<&str> = cmd
2806            .match_indices("rm -f ")
2807            .map(|(i, _)| &cmd[i + "rm -f ".len()..])
2808            .collect();
2809        assert!(
2810            !removals.is_empty(),
2811            "enumerate should still clean up its temp files"
2812        );
2813        for tail in removals {
2814            assert!(
2815                tail.starts_with("\"$__"),
2816                "enumerate must only rm its own temp files, found: rm -f {}",
2817                &tail[..tail.len().min(40)]
2818            );
2819        }
2820        assert!(cmd.contains("rm -f \"$__tmpf\""));
2821    }
2822
2823    /// The string-shape tests above cannot catch a malformed generated script.
2824    /// Actually RUN the sweep against a fixture and assert behaviour: a stale
2825    /// pool under the TMP BASE (the case that was invisible before) is reaped,
2826    /// a stale job dir under `$base` is reaped, and an ACTIVE dir in either
2827    /// location survives.
2828    #[cfg(unix)]
2829    #[test]
2830    fn worker_sweep_executes_and_reaps_only_idle_dirs() {
2831        use std::process::Command;
2832
2833        let tmp = tempfile::tempdir().unwrap();
2834        let base = tmp.path().join("projects");
2835        let tmpbase = tmp.path().join("scratch");
2836        // Pools live 3 levels below the tmp base, mirroring the real layout:
2837        // <tmpbase>/rch/<project>/<hash>/.rch-target-<worker>-pool-<key>
2838        let stale_pool = tmpbase
2839            .join("rch")
2840            .join("proj")
2841            .join("hash")
2842            .join(".rch-target-w1-pool-deadbeef");
2843        let active_pool = tmpbase
2844            .join("rch")
2845            .join("proj2")
2846            .join("hash")
2847            .join(".rch-target-w1-pool-cafe");
2848        let stale_job = base.join("repo").join(".rch-target-w1-job-1-2-0");
2849        let active_job = base.join("repo").join(".rch-target-w1-job-9-9-9");
2850        for d in [&stale_pool, &active_pool, &stale_job, &active_job] {
2851            std::fs::create_dir_all(d).unwrap();
2852            std::fs::write(d.join("artifact.o"), b"xxxx").unwrap();
2853        }
2854
2855        // Age the stale ones well past the idle window. Whole-tree, because the
2856        // predicate is `find <dir> -mmin -N` over everything inside.
2857        for d in [&stale_pool, &stale_job] {
2858            let ok = Command::new("find")
2859                .arg(d)
2860                .args(["-exec", "touch", "-t", "202001010000", "{}", "+"])
2861                .status()
2862                .map(|s| s.success())
2863                .unwrap_or(false);
2864            if !ok {
2865                return; // no usable find/touch here; nothing to assert
2866            }
2867        }
2868
2869        // A private registry: offloaded, this fixture sits inside a source
2870        // root the test build itself owns in the worker's real one.
2871        let cmd = worker_sweep_command_with_registry(
2872            base.to_str().unwrap(),
2873            60,
2874            Some(MIN_POOLED_IDLE_MINUTES),
2875            None,
2876            tmp.path().join("claims-v1").to_str().unwrap(),
2877        );
2878        let out = Command::new("sh")
2879            .arg("-c")
2880            .arg(&cmd)
2881            .env("TMPDIR", &tmpbase)
2882            .output()
2883            .expect("sweep should execute");
2884
2885        let stderr = String::from_utf8_lossy(&out.stderr);
2886        assert!(
2887            out.status.success(),
2888            "generated sweep must be a valid script; stderr: {stderr}"
2889        );
2890        // A malformed script can still exit 0 while emitting syntax errors.
2891        assert!(
2892            !stderr.contains("syntax error") && !stderr.contains("unexpected"),
2893            "generated sweep emitted shell errors: {stderr}"
2894        );
2895
2896        assert!(
2897            !stale_pool.exists(),
2898            "stale pool under the TMP BASE must be reaped (the regression this fixes)"
2899        );
2900        assert!(
2901            !stale_job.exists(),
2902            "stale job dir under $base must be reaped"
2903        );
2904        assert!(active_pool.exists(), "recently-touched pool must survive");
2905        assert!(active_job.exists(), "recently-touched job dir must survive");
2906
2907        let stdout = String::from_utf8_lossy(&out.stdout);
2908        let (removed, _freed) =
2909            parse_worker_reap_metrics(&stdout).expect("sweep must print its metrics line");
2910        assert_eq!(removed, 2, "exactly the two idle dirs; stdout: {stdout}");
2911    }
2912
2913    #[cfg(target_os = "linux")]
2914    fn source_gc_fixture_dir(path: &std::path::Path) {
2915        std::fs::create_dir_all(path).unwrap();
2916        std::fs::write(path.join("artifact.o"), vec![b'x'; 16 * 1024]).unwrap();
2917        assert!(
2918            std::process::Command::new("find")
2919                .arg(path)
2920                .args(["-exec", "touch", "-t", "202001010000", "{}", "+"])
2921                .status()
2922                .unwrap()
2923                .success()
2924        );
2925    }
2926
2927    #[cfg(target_os = "linux")]
2928    fn source_gc_fixture_claim(
2929        registry: &std::path::Path,
2930        root: &std::path::Path,
2931        token: &str,
2932        extension: &str,
2933    ) -> std::path::PathBuf {
2934        std::fs::create_dir_all(registry).unwrap();
2935        let pending = registry.join(format!("input-{token}"));
2936        std::fs::write(&pending, format!("{}\n", root.display())).unwrap();
2937        let output = std::process::Command::new("sha256sum")
2938            .arg(&pending)
2939            .output()
2940            .unwrap();
2941        assert!(output.status.success());
2942        let digest = std::str::from_utf8(&output.stdout)
2943            .unwrap()
2944            .split_whitespace()
2945            .next()
2946            .unwrap();
2947        let record = registry.join(format!("{token}.{digest}.{extension}"));
2948        std::fs::rename(pending, &record).unwrap();
2949        record
2950    }
2951
2952    #[cfg(target_os = "linux")]
2953    #[test]
2954    fn worker_gc_preserves_active_pending_and_aliased_source_claims() {
2955        use std::os::unix::fs::symlink;
2956        use std::process::Command;
2957
2958        let tmp = tempfile::tempdir().unwrap();
2959        let base = tmp.path().join("projects");
2960        let registry = tmp.path().join("claims");
2961        let scratch = tmp.path().join("scratch");
2962        std::fs::create_dir_all(&scratch).unwrap();
2963        let exact = base.join("exact/.rch-target-w-job-exact");
2964        let ancestor = base.join("ancestor/.rch-target-w-job-ancestor");
2965        let descendant = base.join("descendant/.rch-target-w-job-descendant");
2966        let aliased = base.join("aliased/.rch-target-w-job-aliased");
2967        let disjoint = base.join("free/.rch-target-w-job-free");
2968        std::fs::create_dir_all(descendant.join("nested")).unwrap();
2969        for path in [&exact, &ancestor, &descendant, &aliased, &disjoint] {
2970            source_gc_fixture_dir(path);
2971        }
2972        let alias = tmp.path().join("source-alias");
2973        symlink(aliased.parent().unwrap(), &alias).unwrap();
2974        let claims = [
2975            source_gc_fixture_claim(&registry, &exact, "aa", "claim"),
2976            source_gc_fixture_claim(&registry, ancestor.parent().unwrap(), "ab", "claim"),
2977            source_gc_fixture_claim(&registry, &descendant.join("nested"), "ac", "pending"),
2978            source_gc_fixture_claim(&registry, &alias, "ad", "claim"),
2979        ];
2980        let command = worker_sweep_command_with_registry(
2981            base.to_str().unwrap(),
2982            60,
2983            None,
2984            None,
2985            registry.to_str().unwrap(),
2986        );
2987        let run = || {
2988            Command::new("sh")
2989                .arg("-c")
2990                .arg(&command)
2991                .env("TMPDIR", &scratch)
2992                .output()
2993                .unwrap()
2994        };
2995        let output = run();
2996        assert!(
2997            output.status.success(),
2998            "{}",
2999            String::from_utf8_lossy(&output.stderr)
3000        );
3001        for path in [&exact, &ancestor, &descendant, &aliased] {
3002            assert!(
3003                path.join("artifact.o").exists(),
3004                "claimed output removed: {path:?}"
3005            );
3006        }
3007        assert!(
3008            !disjoint.exists(),
3009            "a disjoint unowned candidate should be collected"
3010        );
3011        let stdout = String::from_utf8_lossy(&output.stdout);
3012        assert_eq!(parse_worker_reap_metrics(&stdout).unwrap().0, 1, "{stdout}");
3013        assert_eq!(
3014            parse_gc_skips(&stdout)
3015                .iter()
3016                .filter(|skip| skip.reason == "source-ownership")
3017                .count(),
3018            4,
3019            "{stdout}"
3020        );
3021
3022        // Terminal receipts retain history without pinning the paths forever.
3023        std::fs::create_dir_all(registry.join("released")).unwrap();
3024        std::fs::create_dir_all(registry.join("cancelled")).unwrap();
3025        for claim in claims {
3026            let destination =
3027                if claim.extension().and_then(|extension| extension.to_str()) == Some("pending") {
3028                    registry
3029                        .join("cancelled")
3030                        .join(claim.with_extension("claim").file_name().unwrap())
3031                } else {
3032                    registry.join("released").join(claim.file_name().unwrap())
3033                };
3034            std::fs::rename(&claim, destination).unwrap();
3035        }
3036        let output = run();
3037        assert!(output.status.success());
3038        for path in [&exact, &ancestor, &descendant, &aliased] {
3039            assert!(!path.exists(), "released path remained pinned: {path:?}");
3040        }
3041        assert_eq!(
3042            parse_worker_reap_metrics(&String::from_utf8_lossy(&output.stdout))
3043                .unwrap()
3044                .0,
3045            4
3046        );
3047    }
3048
3049    #[cfg(target_os = "linux")]
3050    #[test]
3051    fn worker_gc_corrupt_source_record_fails_closed() {
3052        let tmp = tempfile::tempdir().unwrap();
3053        let base = tmp.path().join("projects");
3054        let scratch = tmp.path().join("scratch");
3055        let registry = tmp.path().join("claims");
3056        std::fs::create_dir_all(&scratch).unwrap();
3057        let candidate = base.join("repo/.rch-target-w-job-unclaimed");
3058        source_gc_fixture_dir(&candidate);
3059        // Active corruption may hide an acknowledged source owner. It must
3060        // keep blocking GC even though incomplete pending writes can retire.
3061        let record = source_gc_fixture_claim(&registry, &tmp.path().join("other"), "aa", "claim");
3062        std::fs::write(record, b"/truncated").unwrap();
3063        let output = std::process::Command::new("sh")
3064            .arg("-c")
3065            .arg(worker_sweep_command_with_registry(
3066                base.to_str().unwrap(),
3067                60,
3068                None,
3069                None,
3070                registry.to_str().unwrap(),
3071            ))
3072            .env("TMPDIR", scratch)
3073            .output()
3074            .unwrap();
3075        assert!(!output.status.success(), "{output:?}");
3076        assert!(candidate.join("artifact.o").exists());
3077        let stdout = String::from_utf8_lossy(&output.stdout);
3078        assert!(parse_worker_reap_metrics(&stdout).is_none());
3079        assert!(String::from_utf8_lossy(&output.stderr).contains("RCH_GC_ADMISSION_UNKNOWN"));
3080    }
3081
3082    #[cfg(target_os = "linux")]
3083    #[test]
3084    fn worker_gc_quarantines_incomplete_pending_and_preserves_real_owners() {
3085        let tmp = tempfile::tempdir().unwrap();
3086        let base = tmp.path().join("projects");
3087        let registry = tmp.path().join("claims");
3088        let scratch = tmp.path().join("scratch");
3089        std::fs::create_dir_all(&scratch).unwrap();
3090        let active = base.join("active/.rch-target-w-job-active");
3091        let pending = base.join("pending/.rch-target-w-job-pending");
3092        let free = base.join("free/.rch-target-w-job-free");
3093        for candidate in [&active, &pending, &free] {
3094            source_gc_fixture_dir(candidate);
3095        }
3096        let held = source_gc_fixture_claim(&registry, &active, "ca01", "claim");
3097        let ready = source_gc_fixture_claim(&registry, &pending, "ca02", "pending");
3098        let mut corrupt = Vec::new();
3099        for (token, bytes) in [("ca03", b"".as_slice()), ("ca04", b"/truncated".as_slice())] {
3100            let path = source_gc_fixture_claim(&registry, &free, token, "pending");
3101            std::fs::write(&path, bytes).unwrap();
3102            corrupt.push((path, bytes));
3103        }
3104        let output = std::process::Command::new("sh")
3105            .args([
3106                "-c",
3107                &worker_sweep_command_with_registry(
3108                    base.to_str().unwrap(),
3109                    60,
3110                    None,
3111                    None,
3112                    registry.to_str().unwrap(),
3113                ),
3114            ])
3115            .env("TMPDIR", &scratch)
3116            .output()
3117            .unwrap();
3118        assert!(output.status.success(), "{output:?}");
3119        assert!(active.join("artifact.o").is_file());
3120        assert!(pending.join("artifact.o").is_file());
3121        assert!(held.is_file());
3122        assert!(ready.is_file());
3123        assert!(
3124            !free.exists(),
3125            "a failed ungranted write must not poison all GC"
3126        );
3127        assert_eq!(
3128            parse_worker_reap_metrics(&String::from_utf8_lossy(&output.stdout))
3129                .unwrap()
3130                .0,
3131            1
3132        );
3133        assert_eq!(
3134            String::from_utf8_lossy(&output.stderr)
3135                .matches("RCH_SOURCE_CLAIM_QUARANTINED")
3136                .count(),
3137            2
3138        );
3139        for (path, bytes) in corrupt {
3140            assert!(!path.exists());
3141            assert_eq!(
3142                std::fs::read(registry.join("quarantine").join(path.file_name().unwrap())).unwrap(),
3143                bytes
3144            );
3145        }
3146    }
3147
3148    #[cfg(target_os = "linux")]
3149    #[test]
3150    fn worker_gc_failed_claim_write_keeps_candidate_and_partial_bytes() {
3151        let tmp = tempfile::tempdir().unwrap();
3152        let registry = tmp.path().join("claims");
3153        let candidate = tmp.path().join("projects/repo/.rch-target-w-job-free");
3154        source_gc_fixture_dir(&candidate);
3155        let gate = source_claim_gate_fragment(registry.to_str().unwrap());
3156        let script = format!(
3157            "{gate}\nif __gc_source_begin {}; then __gc_source_end; printf admitted; else printf refused; fi",
3158            shell_escape::escape(candidate.to_string_lossy()),
3159        );
3160        let output = std::process::Command::new("sh")
3161            .args(["-c", &format!("trap '' XFSZ; ulimit -f 0; {script}")])
3162            .output()
3163            .unwrap();
3164        assert!(output.status.success(), "{output:?}");
3165        assert_eq!(output.stdout, b"refused");
3166        assert!(candidate.join("artifact.o").is_file());
3167        assert!(String::from_utf8_lossy(&output.stderr).contains("RCH_SOURCE_CLAIM_WRITE_FAILED"));
3168        let names: Vec<_> = std::fs::read_dir(&registry)
3169            .unwrap()
3170            .map(Result::unwrap)
3171            .map(|entry| entry.file_name().to_string_lossy().into_owned())
3172            .collect();
3173        assert!(names.iter().any(|name| name.starts_with(".record-write.")));
3174        assert!(
3175            !names
3176                .iter()
3177                .any(|name| name.ends_with(".pending") || name.ends_with(".claim"))
3178        );
3179        let output = std::process::Command::new("sh")
3180            .args(["-c", &script])
3181            .output()
3182            .unwrap();
3183        assert!(output.status.success(), "{output:?}");
3184        assert_eq!(output.stdout, b"admitted");
3185        assert_eq!(
3186            std::fs::read_dir(registry.join("released"))
3187                .unwrap()
3188                .count(),
3189            1
3190        );
3191    }
3192
3193    #[cfg(target_os = "linux")]
3194    #[test]
3195    fn source_gc_registry_setup_failure_stops_before_success_metrics() {
3196        use std::io::Write;
3197
3198        let directory = tempfile::Builder::new()
3199            .prefix("source-gc-registry-failure-")
3200            .disable_cleanup(true)
3201            .tempdir()
3202            .unwrap()
3203            .keep();
3204        let directory = directory.canonicalize().unwrap();
3205        let candidate = directory.join("candidate");
3206        std::fs::create_dir(&candidate).unwrap();
3207        let original = candidate.join("original-input");
3208        let registry = directory.join("occupied-registry");
3209        for (path, bytes) in [
3210            (&original, b"candidate bytes".as_slice()),
3211            (&registry, b"registry obstruction".as_slice()),
3212        ] {
3213            std::fs::OpenOptions::new()
3214                .write(true)
3215                .create_new(true)
3216                .open(path)
3217                .unwrap()
3218                .write_all(bytes)
3219                .unwrap();
3220        }
3221        let script = format!(
3222            "{}\nset -eu; while IFS= read -r d; do {}\n\
3223             __gc_source_end; done; printf 'RCH_WORKER_REAP_METRICS removed=0 freed_kb=0\\n'",
3224            source_claim_gc_guard_fragment_with_registry(registry.to_str().unwrap()),
3225            checked_source_admission("ttl"),
3226        );
3227        let mut child = std::process::Command::new("sh")
3228            .args(["-c", &script, "registry-failure"])
3229            .stdin(std::process::Stdio::piped())
3230            .stdout(std::process::Stdio::piped())
3231            .stderr(std::process::Stdio::piped())
3232            .spawn()
3233            .unwrap();
3234        writeln!(child.stdin.take().unwrap(), "{}", candidate.display()).unwrap();
3235        let output = child.wait_with_output().unwrap();
3236        assert_eq!(output.status.code(), Some(1), "{output:?}");
3237        assert!(output.stdout.is_empty());
3238        assert!(String::from_utf8_lossy(&output.stderr).contains("RCH_GC_ADMISSION_UNKNOWN"));
3239        assert_eq!(std::fs::read(original).unwrap(), b"candidate bytes");
3240        assert_eq!(std::fs::read(registry).unwrap(), b"registry obstruction");
3241    }
3242
3243    #[test]
3244    fn source_gc_recent_activity_distinguishes_live_idle_and_missing_inputs() {
3245        use std::io::Write;
3246
3247        let directory = tempfile::Builder::new()
3248            .prefix("source-gc-recent-")
3249            .disable_cleanup(true)
3250            .tempdir()
3251            .unwrap()
3252            .keep();
3253        let directory = directory.canonicalize().unwrap();
3254        let original = directory.join("original-input");
3255        std::fs::OpenOptions::new()
3256            .write(true)
3257            .create_new(true)
3258            .open(&original)
3259            .unwrap()
3260            .write_all(b"recent input bytes")
3261            .unwrap();
3262        let script = format!(
3263            "{}\n__gc_source_recent_activity \"$1\" 5",
3264            source_claim_gc_guard_fragment_with_registry(
3265                directory.join("unused-registry").to_str().unwrap()
3266            ),
3267        );
3268        for expected in [0, 1] {
3269            if expected == 1 {
3270                let old = std::time::SystemTime::now() - std::time::Duration::from_secs(10 * 60);
3271                for path in [&original, &directory] {
3272                    std::fs::File::open(path)
3273                        .unwrap()
3274                        .set_modified(old)
3275                        .unwrap();
3276                }
3277            }
3278            let output = std::process::Command::new("sh")
3279                .args(["-c", &script, "recent-activity"])
3280                .arg(&directory)
3281                .output()
3282                .unwrap();
3283            assert_eq!(output.status.code(), Some(expected), "{output:?}");
3284            assert!(output.stdout.is_empty() && output.stderr.is_empty());
3285            assert_eq!(std::fs::read(&original).unwrap(), b"recent input bytes");
3286        }
3287        let unknown = std::process::Command::new("sh")
3288            .args(["-c", &script, "missing-activity"])
3289            .arg(directory.join("missing-root"))
3290            .output()
3291            .unwrap();
3292        assert_eq!(unknown.status.code(), Some(2), "{unknown:?}");
3293        assert!(String::from_utf8_lossy(&unknown.stderr).contains("RCH_GC_ACTIVITY_UNKNOWN"));
3294        assert_eq!(std::fs::read(original).unwrap(), b"recent input bytes");
3295        assert!(!directory.join("unused-registry").exists());
3296    }
3297
3298    #[cfg(target_os = "linux")]
3299    #[test]
3300    fn source_gc_retention_blocks_without_a_claim_and_keeps_unmarked_siblings_admissible() {
3301        use std::io::Write;
3302        use std::os::unix::fs::symlink;
3303
3304        for marker_location in ["candidate", "descendant", "ancestor", "sibling"] {
3305            let directory = tempfile::Builder::new()
3306                .prefix("source-gc-retention-")
3307                .disable_cleanup(true)
3308                .tempdir()
3309                .unwrap()
3310                .keep();
3311            let directory = directory.canonicalize().unwrap();
3312            let candidate = directory.join("project/candidate with ' quote");
3313            let registry = directory.join("claims");
3314            std::fs::create_dir_all(&candidate).unwrap();
3315            let original = candidate.join("original-input");
3316            std::fs::OpenOptions::new()
3317                .write(true)
3318                .create_new(true)
3319                .open(&original)
3320                .unwrap()
3321                .write_all(b"source retention input")
3322                .unwrap();
3323            let marker_root = match marker_location {
3324                "candidate" => candidate.clone(),
3325                "descendant" => candidate.join("retired-source"),
3326                "ancestor" => directory.join("project"),
3327                "sibling" => directory.join("project/marked-sibling"),
3328                _ => unreachable!(),
3329            };
3330            std::fs::create_dir_all(&marker_root).unwrap();
3331            symlink(
3332                "retention-metadata-not-a-path",
3333                marker_root.join(".rch-retention-v1"),
3334            )
3335            .unwrap();
3336            let held = marker_location != "sibling";
3337            let script = format!(
3338                "{}\nset -eu; candidate=$1;\n{}",
3339                source_claim_gc_guard_fragment_with_registry(registry.to_str().unwrap()),
3340                if held {
3341                    r#"
3342if __gc_source_begin "$candidate"; then exit 10; else [ "$?" -eq 3 ]; fi;
3343[ -z "$__gc_reservation" ];
3344[ ! -e "$__gc_source_registry/released" ];
3345for record in "$__gc_source_registry"/*.claim "$__gc_source_registry"/*.pending; do
3346    [ ! -e "$record" ] && [ ! -L "$record" ];
3347done;
3348flock -n "$__gc_source_registry/metadata.lock" true;
3349"#
3350                } else {
3351                    r#"
3352__gc_source_begin "$candidate";
3353claim=$__gc_reservation;
3354rch_claim_record_valid "$claim";
3355[ "$(cat "$claim")" = "$candidate" ];
3356if flock -n -E 73 "$__gc_source_registry/metadata.lock" true; then exit 11;
3357else [ "$?" -eq 73 ]; fi;
3358__gc_source_end;
3359[ ! -e "$claim" ];
3360rch_claim_record_valid "$__gc_source_registry/released/${claim##*/}";
3361[ "$(cat "$__gc_source_registry/released/${claim##*/}")" = "$candidate" ];
3362flock -n "$__gc_source_registry/metadata.lock" true;
3363"#
3364                },
3365            );
3366            // Exercise the real private registry and kernel lock; never run
3367            // candidate deletion or the shared worker registry in this fixture.
3368            let output = std::process::Command::new("sh")
3369                .args(["-c", &script, "source-gc-retention"])
3370                .arg(&candidate)
3371                .output()
3372                .unwrap();
3373            assert!(output.status.success(), "{marker_location}: {output:?}");
3374            assert_eq!(std::fs::read(original).unwrap(), b"source retention input");
3375            assert!(
3376                std::fs::symlink_metadata(marker_root.join(".rch-retention-v1"))
3377                    .unwrap()
3378                    .file_type()
3379                    .is_symlink()
3380            );
3381            if held {
3382                assert!(String::from_utf8_lossy(&output.stderr).contains("RCH_GC_RETENTION_HOLD"));
3383                assert!(!registry.join("released").exists());
3384            } else {
3385                assert_eq!(
3386                    std::fs::read_dir(registry.join("released"))
3387                        .unwrap()
3388                        .count(),
3389                    1
3390                );
3391            }
3392        }
3393    }
3394
3395    #[cfg(target_os = "linux")]
3396    #[test]
3397    fn checked_source_admission_uses_the_same_physical_candidate_as_its_claim() {
3398        use std::io::Write;
3399        use std::os::unix::fs::symlink;
3400
3401        let directory = tempfile::Builder::new()
3402            .prefix("source-gc-physical-")
3403            .disable_cleanup(true)
3404            .tempdir()
3405            .unwrap()
3406            .keep();
3407        let directory = directory.canonicalize().unwrap();
3408        let candidate = directory.join("physical-parent/candidate");
3409        std::fs::create_dir_all(&candidate).unwrap();
3410        let original = candidate.join("original-input");
3411        std::fs::OpenOptions::new()
3412            .write(true)
3413            .create_new(true)
3414            .open(&original)
3415            .unwrap()
3416            .write_all(b"physical candidate bytes")
3417            .unwrap();
3418        let alias = directory.join("alias-parent");
3419        symlink(candidate.parent().unwrap(), &alias).unwrap();
3420        let registry = directory.join("claims");
3421        let script = format!(
3422            "{}\nset -eu; for d in \"$1\"; do {}\n\
3423             [ \"$(cat \"$__gc_reservation\")\" = \"$d\" ]; \
3424             __gc_source_end; printf '%s\\n' \"$d\"; done",
3425            source_claim_gc_guard_fragment_with_registry(registry.to_str().unwrap()),
3426            checked_source_admission("ttl"),
3427        );
3428        let output = std::process::Command::new("sh")
3429            .args(["-c", &script, "source-gc-physical"])
3430            .arg(alias.join("candidate"))
3431            .output()
3432            .unwrap();
3433        assert!(output.status.success(), "{output:?}");
3434        assert_eq!(
3435            String::from_utf8(output.stdout).unwrap(),
3436            format!("{}\n", candidate.display())
3437        );
3438        assert_eq!(
3439            std::fs::read(original).unwrap(),
3440            b"physical candidate bytes"
3441        );
3442        assert_eq!(
3443            std::fs::read_link(alias).unwrap(),
3444            candidate.parent().unwrap()
3445        );
3446        assert_eq!(
3447            std::fs::read_dir(registry.join("released"))
3448                .unwrap()
3449                .count(),
3450            1
3451        );
3452    }
3453
3454    #[cfg(target_os = "linux")]
3455    #[test]
3456    fn worker_gc_recent_skip_checks_real_source_retirement_before_success() {
3457        use std::io::Write;
3458
3459        for failed_retirement in [false, true] {
3460            let directory = tempfile::Builder::new()
3461                .prefix("gc-retirement-skip-")
3462                .disable_cleanup(true)
3463                .tempdir()
3464                .unwrap()
3465                .keep();
3466            let directory = directory.canonicalize().unwrap();
3467            let registry = directory.join("claims");
3468            let candidate = directory.join("candidate");
3469            std::fs::create_dir(&registry).unwrap();
3470            std::fs::create_dir(&candidate).unwrap();
3471            let original = candidate.join("original-input");
3472            std::fs::OpenOptions::new()
3473                .write(true)
3474                .create_new(true)
3475                .open(&original)
3476                .unwrap()
3477                .write_all(b"recent source bytes")
3478                .unwrap();
3479            if failed_retirement {
3480                std::os::unix::fs::symlink(&directory, registry.join("released")).unwrap();
3481            }
3482            // The actual production loop's recent-input branch retires its
3483            // real claim before continuing. No removal branch is reached.
3484            let body = reap_loop_body_with_source_gate(60, None, "", "", Some("ttl"), true);
3485            let script = format!(
3486                "{}\nset -u; for d in \"$1\"; do {body} done; printf completed",
3487                source_claim_gate_fragment(registry.to_str().unwrap())
3488            );
3489            let output = std::process::Command::new("sh")
3490                .args(["-c", &script, "retirement-skip"])
3491                .arg(&candidate)
3492                .output()
3493                .unwrap();
3494            assert_eq!(std::fs::read(&original).unwrap(), b"recent source bytes");
3495            assert_eq!(
3496                output.status.code(),
3497                Some(if failed_retirement { 1 } else { 0 }),
3498                "{output:?}"
3499            );
3500            if failed_retirement {
3501                assert!(!String::from_utf8_lossy(&output.stdout).contains("completed"));
3502                assert!(
3503                    String::from_utf8_lossy(&output.stderr).contains("RCH_GC_RETIREMENT_FAILED")
3504                );
3505                let claims: Vec<_> = std::fs::read_dir(&registry)
3506                    .unwrap()
3507                    .map(Result::unwrap)
3508                    .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "claim"))
3509                    .collect();
3510                assert_eq!(claims.len(), 1);
3511                assert_eq!(
3512                    std::fs::read(claims[0].path()).unwrap(),
3513                    format!("{}\n", candidate.display()).as_bytes()
3514                );
3515            } else {
3516                assert_eq!(
3517                    output.stdout,
3518                    format!("RCH_GC_SKIP ttl active {}\ncompleted", candidate.display()).as_bytes()
3519                );
3520                assert_eq!(
3521                    std::fs::read_dir(registry.join("released"))
3522                        .unwrap()
3523                        .count(),
3524                    1
3525                );
3526            }
3527        }
3528    }
3529
3530    #[cfg(target_os = "linux")]
3531    #[test]
3532    fn source_claim_atomic_rename_failure_preserves_private_record() {
3533        let tmp = tempfile::tempdir().unwrap();
3534        let registry = tmp.path().join("claims");
3535        std::fs::create_dir(&registry).unwrap();
3536        // The destination's missing parent makes the real rename fail after
3537        // the real file write and fsync have completed.
3538        let destination = registry.join("missing/record.pending");
3539        let script = format!(
3540            "{SOURCE_CLAIM_RECORD_HELPERS}\nset -eu; registry=$1; destination=$2; \
3541             digest=$(printf '/source\\n' | sha256sum); digest=${{digest%% *}}; \
3542             rch_claim_write_atomic \"$registry\" \"$destination\" \"$digest\" /source",
3543        );
3544        let output = std::process::Command::new("sh")
3545            .args(["-c", &script, "claim-rename-fixture"])
3546            .arg(&registry)
3547            .arg(&destination)
3548            .output()
3549            .unwrap();
3550        assert!(!output.status.success());
3551        assert!(!destination.exists());
3552        let retained: Vec<_> = std::fs::read_dir(&registry)
3553            .unwrap()
3554            .map(Result::unwrap)
3555            .collect();
3556        assert_eq!(retained.len(), 1);
3557        assert!(
3558            retained[0]
3559                .file_name()
3560                .to_string_lossy()
3561                .starts_with(".record-write.")
3562        );
3563        assert_eq!(std::fs::read(retained[0].path()).unwrap(), b"/source\n");
3564    }
3565
3566    #[cfg(target_os = "linux")]
3567    #[test]
3568    fn byte_cap_and_explicit_gc_cannot_override_source_ownership() {
3569        let tmp = tempfile::tempdir().unwrap();
3570        let base = tmp.path().join("projects");
3571        let scratch = tmp.path().join("scratch");
3572        let registry = tmp.path().join("claims");
3573        std::fs::create_dir_all(&scratch).unwrap();
3574        let owned = base.join("owned/.rch-target-w-pool-owned");
3575        let free = base.join("free/.rch-target-w-pool-free");
3576        source_gc_fixture_dir(&owned);
3577        source_gc_fixture_dir(&free);
3578        source_gc_fixture_claim(&registry, &owned, "aa", "claim");
3579        let run = |command: String| {
3580            std::process::Command::new("sh")
3581                .arg("-c")
3582                .arg(command)
3583                .env("TMPDIR", &scratch)
3584                .output()
3585                .unwrap()
3586        };
3587        let output = run(worker_sweep_command_with_registry(
3588            base.to_str().unwrap(),
3589            60,
3590            None,
3591            Some(1),
3592            registry.to_str().unwrap(),
3593        ));
3594        assert!(
3595            output.status.success(),
3596            "{}",
3597            String::from_utf8_lossy(&output.stderr)
3598        );
3599        assert!(owned.join("artifact.o").exists());
3600        assert!(!free.exists());
3601        assert_eq!(
3602            parse_worker_reap_metrics(&String::from_utf8_lossy(&output.stdout))
3603                .unwrap()
3604                .0,
3605            1
3606        );
3607        source_gc_fixture_dir(&free);
3608        let targets = [&owned, &free].map(|path| GcCollectTarget {
3609            path: path.to_string_lossy().into_owned(),
3610            idle_minutes: 60,
3611            trigger: "manual",
3612        });
3613        let output =
3614            run(collect_paths_command_with_registry(&targets, registry.to_str().unwrap()).unwrap());
3615        assert!(
3616            output.status.success(),
3617            "{}",
3618            String::from_utf8_lossy(&output.stderr)
3619        );
3620        assert!(owned.join("artifact.o").exists());
3621        assert!(!free.exists());
3622        assert_eq!(
3623            parse_worker_reap_metrics(&String::from_utf8_lossy(&output.stdout))
3624                .unwrap()
3625                .0,
3626            1
3627        );
3628    }
3629
3630    #[cfg(target_os = "linux")]
3631    #[test]
3632    // Both signal cases reap the supervisor (`-KILL` right after the kill,
3633    // `-HUP` after resume); clippy cannot follow the branch on `signal`.
3634    #[allow(clippy::zombie_processes)]
3635    fn source_gc_preserves_ownership_through_hup_and_killed_supervisor() {
3636        use std::os::unix::fs::PermissionsExt;
3637        use std::process::{Command, Stdio};
3638        use std::time::{Duration, Instant};
3639
3640        for signal in ["-HUP", "-KILL"] {
3641            let tmp = tempfile::tempdir().unwrap();
3642            let candidate = tmp.path().join(".rch-target-w-job-interrupted");
3643            let registry = tmp.path().join("claims");
3644            let ready = tmp.path().join("ready");
3645            let resume = tmp.path().join("resume");
3646            let bin = tmp.path().join("bin");
3647            std::fs::create_dir(&bin).unwrap();
3648            source_gc_fixture_dir(&candidate);
3649            let remover = bin.join("rm");
3650            std::fs::write(&remover, b"#!/bin/sh\nexec 8>&-\n: > \"$RCH_GC_TEST_READY\"\ni=0\nwhile [ ! -e \"$RCH_GC_TEST_RESUME\" ]; do i=$((i+1)); [ \"$i\" -lt 1000 ] || exit 9; sleep 0.01; done\nexec /bin/rm \"$@\"\n").unwrap();
3651            std::fs::set_permissions(&remover, std::fs::Permissions::from_mode(0o755)).unwrap();
3652            let script = format!(
3653                "{} __gc_source_begin \"$1\" || exit 1; rm -rf -- \"$1\"; __gc_source_end;",
3654                source_claim_gate_fragment(registry.to_str().unwrap())
3655            );
3656            let mut child = Command::new("sh")
3657                .args(["-c", &script, "gc-lifetime"])
3658                .arg(&candidate)
3659                .env(
3660                    "PATH",
3661                    format!("{}:{}", bin.display(), std::env::var("PATH").unwrap()),
3662                )
3663                .env("RCH_GC_TEST_READY", &ready)
3664                .env("RCH_GC_TEST_RESUME", &resume)
3665                .stdin(Stdio::null())
3666                .stdout(Stdio::null())
3667                .stderr(Stdio::piped())
3668                .spawn()
3669                .unwrap();
3670            let deadline = Instant::now() + Duration::from_secs(10);
3671            while !ready.exists() && Instant::now() < deadline {
3672                assert!(
3673                    child.try_wait().unwrap().is_none(),
3674                    "source guard exited before deletion"
3675                );
3676                std::thread::sleep(Duration::from_millis(10));
3677            }
3678            assert!(ready.exists(), "removal child failed to reach barrier");
3679            assert!(
3680                Command::new("kill")
3681                    .args([signal, &child.id().to_string()])
3682                    .status()
3683                    .unwrap()
3684                    .success()
3685            );
3686            let contender = || {
3687                Command::new("flock")
3688                    .arg("-n")
3689                    .arg(registry.join("metadata.lock"))
3690                    .arg("true")
3691                    .status()
3692                    .unwrap()
3693            };
3694            if signal == "-KILL" {
3695                assert!(!child.wait().unwrap().success());
3696                assert!(
3697                    contender().success(),
3698                    "the killed supervisor should release its kernel lock"
3699                );
3700                let retry = format!(
3701                    "{} __gc_source_begin \"$1\"",
3702                    source_claim_gate_fragment(registry.to_str().unwrap())
3703                );
3704                assert!(
3705                    !Command::new("sh")
3706                        .args(["-c", &retry, "gc-retry"])
3707                        .arg(&candidate)
3708                        .status()
3709                        .unwrap()
3710                        .success(),
3711                    "surviving deletion lost its durable overlap blocker"
3712                );
3713            } else {
3714                assert!(
3715                    !contender().success(),
3716                    "admission crossed a still-running deletion"
3717                );
3718            }
3719            assert!(candidate.exists());
3720            let release_claims = || {
3721                let script =
3722                    abandoned_gc_claims_command_with_registry(true, registry.to_str().unwrap(), 0);
3723                let output = Command::new("sh").args(["-c", &script]).output().unwrap();
3724                assert!(output.status.success(), "{output:?}");
3725                let (status, claims) =
3726                    parse_gc_claim_reports(&String::from_utf8_lossy(&output.stdout));
3727                assert_eq!(status.as_deref(), Some("ok"), "{output:?}");
3728                claims
3729            };
3730            if signal == "-KILL" {
3731                // bd-gyehj: the orphaned rm still names the tree, so the
3732                // operator release must keep the claim while it runs.
3733                let claims = release_claims();
3734                assert_eq!(claims.len(), 1, "{claims:?}");
3735                assert_eq!(claims[0].verdict, "keep", "{claims:?}");
3736                assert!(
3737                    ["open-handle", "live-process"].contains(&claims[0].reason.as_str()),
3738                    "{claims:?}"
3739                );
3740            }
3741            std::fs::write(resume, b"continue").unwrap();
3742            if signal == "-HUP" {
3743                assert!(child.wait().unwrap().success());
3744            } else {
3745                let deadline = Instant::now() + Duration::from_secs(10);
3746                while candidate.exists() && Instant::now() < deadline {
3747                    std::thread::sleep(Duration::from_millis(10));
3748                }
3749                assert!(
3750                    std::fs::read_dir(&registry).unwrap().any(|entry| entry
3751                        .unwrap()
3752                        .path()
3753                        .extension()
3754                        .is_some_and(|extension| extension == "claim")),
3755                    "an interrupted GC claim must remain after its unobserved child completes"
3756                );
3757                // Once nothing references the tree, the release retires it
3758                // exactly as __gc_source_end would.
3759                let mut claims = release_claims();
3760                let deadline = Instant::now() + Duration::from_secs(10);
3761                while claims.first().is_some_and(|c| c.verdict == "keep")
3762                    && Instant::now() < deadline
3763                {
3764                    std::thread::sleep(Duration::from_millis(50));
3765                    claims = release_claims();
3766                }
3767                assert_eq!(claims.len(), 1, "{claims:?}");
3768                assert_eq!(claims[0].verdict, "released", "{claims:?}");
3769                assert!(registry.join("released").join(&claims[0].name).is_file());
3770                assert!(release_claims().is_empty());
3771            }
3772            assert!(!candidate.exists());
3773            assert!(
3774                contender().success(),
3775                "metadata lock was not released after deletion"
3776            );
3777        }
3778    }
3779
3780    #[cfg(target_os = "linux")]
3781    #[test]
3782    fn abandoned_gc_claims_respect_age_integrity_and_live_references() {
3783        use std::process::{Command, Stdio};
3784
3785        let tmp = tempfile::tempdir().unwrap();
3786        let registry = tmp.path().join("claims");
3787        let tree = tmp.path().join(".rch-target-w-pool-stranded");
3788        std::fs::create_dir_all(&tree).unwrap();
3789        std::fs::create_dir_all(&registry).unwrap();
3790        let record = format!("{}\n", tree.display());
3791        let staged = tmp.path().join("record");
3792        std::fs::write(&staged, &record).unwrap();
3793        let digest = String::from_utf8(
3794            Command::new("sha256sum")
3795                .arg(&staged)
3796                .output()
3797                .unwrap()
3798                .stdout,
3799        )
3800        .unwrap()
3801        .split_whitespace()
3802        .next()
3803        .unwrap()
3804        .to_string();
3805        let name = format!("fc-00000000-0000-4000-8000-000000000001.{digest}.claim");
3806        let claim = registry.join(&name);
3807        std::fs::write(&claim, &record).unwrap();
3808        let scan = |apply: bool, min_age: u64| {
3809            let script = abandoned_gc_claims_command_with_registry(
3810                apply,
3811                registry.to_str().unwrap(),
3812                min_age,
3813            );
3814            let output = Command::new("sh").args(["-c", &script]).output().unwrap();
3815            assert!(output.status.success(), "{output:?}");
3816            let (status, claims) = parse_gc_claim_reports(&String::from_utf8_lossy(&output.stdout));
3817            assert_eq!(status.as_deref(), Some("ok"), "{output:?}");
3818            claims
3819        };
3820        let only = |claims: Vec<GcClaimReport>| {
3821            assert_eq!(claims.len(), 1, "{claims:?}");
3822            (claims[0].verdict.clone(), claims[0].reason.clone())
3823        };
3824
3825        assert_eq!(only(scan(true, 60)), ("keep".into(), "young".into()));
3826        assert_eq!(
3827            only(scan(false, 0)),
3828            ("would-release".into(), "abandoned".into())
3829        );
3830        assert!(claim.is_file(), "a preview must not release");
3831
3832        let mut holder = Command::new("sleep")
3833            .arg("30")
3834            .current_dir(&tree)
3835            .stdin(Stdio::null())
3836            .spawn()
3837            .unwrap();
3838        let held = only(scan(true, 0));
3839        holder.kill().unwrap();
3840        holder.wait().unwrap();
3841        assert_eq!(held, ("keep".into(), "open-handle".into()));
3842
3843        std::fs::write(&claim, b"/tampered\n").unwrap();
3844        assert_eq!(only(scan(true, 0)), ("keep".into(), "unsafe-record".into()));
3845        std::fs::write(&claim, &record).unwrap();
3846
3847        let released = scan(true, 0);
3848        assert_eq!(released[0].path, tree.display().to_string());
3849        assert_eq!(only(released), ("released".into(), "abandoned".into()));
3850        assert!(!claim.exists());
3851        assert!(registry.join("released").join(&name).is_file());
3852    }
3853
3854    #[test]
3855    fn gc_claim_report_lines_parse_with_spaces_in_the_path() {
3856        let (status, claims) = parse_gc_claim_reports(
3857            "noise\nRCH_GC_CLAIMS ok\nRCH_GC_CLAIM keep young 12 fc-a.b.claim /data/my dir\n\
3858             RCH_GC_CLAIM keep x notanumber fc-c.d.claim /p\nRCH_GC_CLAIM short\n",
3859        );
3860        assert_eq!(status.as_deref(), Some("ok"));
3861        assert_eq!(
3862            claims,
3863            vec![GcClaimReport {
3864                verdict: "keep".into(),
3865                reason: "young".into(),
3866                age_minutes: 12,
3867                name: "fc-a.b.claim".into(),
3868                path: "/data/my dir".into(),
3869            }]
3870        );
3871        assert_eq!(parse_gc_claim_reports("").0, None);
3872    }
3873
3874    /// `rch gc --dry-run` enumerates; the real run sweeps. If enumerate cannot
3875    /// SEE a dir the sweep would reap, the dry run lies about what will happen.
3876    /// Both must cover the tmp base, for pooled dirs as well as job/pid dirs.
3877    #[cfg(unix)]
3878    #[test]
3879    fn enumerate_and_sweep_agree_on_tmp_base_discovery() {
3880        use std::process::Command;
3881
3882        let tmp = tempfile::tempdir().unwrap();
3883        let base = tmp.path().join("projects");
3884        let tmpbase = tmp.path().join("scratch");
3885        let tmp_pool = tmpbase
3886            .join("rch")
3887            .join("proj")
3888            .join("hash")
3889            .join(".rch-target-w1-pool-abc");
3890        let base_pool = base.join("repo").join(".rch-target-w1-pool-def");
3891        let tmp_job = tmpbase
3892            .join("rch")
3893            .join("proj")
3894            .join("hash")
3895            .join(".rch-target-w1-job-1-2-0");
3896        for d in [&tmp_pool, &base_pool, &tmp_job] {
3897            std::fs::create_dir_all(d).unwrap();
3898            std::fs::write(d.join("artifact.o"), b"xxxx").unwrap();
3899        }
3900
3901        let cmd = enumerate_targets_command(base.to_str().unwrap());
3902        let out = Command::new("sh")
3903            .arg("-c")
3904            .arg(&cmd)
3905            .env("TMPDIR", &tmpbase)
3906            .output()
3907            .expect("enumerate should execute");
3908        let stderr = String::from_utf8_lossy(&out.stderr);
3909        assert!(out.status.success(), "enumerate must be valid sh: {stderr}");
3910        assert!(
3911            !stderr.contains("syntax error") && !stderr.contains("unexpected"),
3912            "enumerate emitted shell errors: {stderr}"
3913        );
3914
3915        let stdout = String::from_utf8_lossy(&out.stdout);
3916        let entries = parse_target_entries(&stdout);
3917        let listed: Vec<&str> = entries.iter().map(|e| e.path.as_str()).collect();
3918
3919        // Both roots are canonicalized by the script (`pwd -P`), so compare
3920        // canonical paths — on macOS a tempdir under /var resolves to
3921        // /private/var and a literal comparison would spuriously fail.
3922        for expected in [&tmp_pool, &base_pool, &tmp_job] {
3923            let want = std::fs::canonicalize(expected).unwrap_or_else(|_| expected.clone());
3924            let matched = listed.iter().any(|p| {
3925                std::fs::canonicalize(p).map(|c| c == want).unwrap_or(false)
3926                    || std::path::Path::new(p) == want
3927            });
3928            assert!(
3929                matched,
3930                "enumerate must list {}; listed: {listed:?}",
3931                want.display()
3932            );
3933        }
3934        // Dedup must leave each dir exactly once, so the dry run cannot
3935        // double-report (and double-count KB) for a single directory.
3936        for want in &listed {
3937            assert_eq!(
3938                listed.iter().filter(|p| *p == want).count(),
3939                1,
3940                "duplicate entry for {want} in {listed:?}"
3941            );
3942        }
3943    }
3944
3945    /// When the sync root sits UNDER the tmp base, both discovery passes match
3946    /// the same dirs. Without dedup the byte-cap pass would count them twice.
3947    #[cfg(unix)]
3948    #[test]
3949    fn overlapping_roots_are_deduped_not_double_counted() {
3950        use std::process::Command;
3951
3952        let tmp = tempfile::tempdir().unwrap();
3953        // base is INSIDE tmpbase: every candidate matches both finds.
3954        let tmpbase = tmp.path().join("scratch");
3955        let base = tmpbase.join("projects");
3956        let stale = base.join("repo").join(".rch-target-w1-job-1-2-0");
3957        std::fs::create_dir_all(&stale).unwrap();
3958        std::fs::write(stale.join("artifact.o"), b"xxxx").unwrap();
3959        let aged = Command::new("find")
3960            .arg(&stale)
3961            .args(["-exec", "touch", "-t", "202001010000", "{}", "+"])
3962            .status()
3963            .map(|s| s.success())
3964            .unwrap_or(false);
3965        if !aged {
3966            return;
3967        }
3968
3969        // A private registry: offloaded, this fixture sits inside a source
3970        // root the test build itself owns in the worker's real one.
3971        let cmd = worker_sweep_command_with_registry(
3972            base.to_str().unwrap(),
3973            60,
3974            Some(MIN_POOLED_IDLE_MINUTES),
3975            None,
3976            tmp.path().join("claims-v1").to_str().unwrap(),
3977        );
3978        let out = Command::new("sh")
3979            .arg("-c")
3980            .arg(&cmd)
3981            .env("TMPDIR", &tmpbase)
3982            .output()
3983            .expect("sweep should execute");
3984        assert!(out.status.success());
3985
3986        let stdout = String::from_utf8_lossy(&out.stdout);
3987        let (removed, _) =
3988            parse_worker_reap_metrics(&stdout).expect("sweep must print its metrics line");
3989        assert!(!stale.exists(), "the stale dir should be reaped");
3990        assert_eq!(
3991            removed, 1,
3992            "a dir matched by both passes must be counted once; stdout: {stdout}"
3993        );
3994    }
3995
3996    #[test]
3997    fn parse_worker_reap_metrics_roundtrips() {
3998        let out = "noise\nRCH_WORKER_REAP_METRICS removed=3 freed_kb=204800\n";
3999        assert_eq!(parse_worker_reap_metrics(out), Some((3, 204_800)));
4000        assert!(parse_worker_reap_metrics("no metrics here").is_none());
4001    }
4002
4003    #[test]
4004    fn parse_target_entries_parses_and_skips_garbage() {
4005        let out = "RCH_TARGET_ENTRY 1754700000 1024 /data/tmp/rch_target_old\n\
4006                   garbage line\n\
4007                   RCH_TARGET_ENTRY notanum 5 /x\n\
4008                   RCH_TARGET_ENTRY 1754700001 2048 /data/projects/repo/.rch-target-css-pool-abc\n";
4009        let entries = parse_target_entries(out);
4010        assert_eq!(entries.len(), 2);
4011        assert_eq!(entries[0].path, "/data/tmp/rch_target_old");
4012        assert_eq!(entries[0].kb, 1024);
4013        assert!(!entries[0].is_pooled());
4014        assert!(entries[1].is_pooled());
4015    }
4016
4017    #[test]
4018    fn reap_events_are_emitted_per_pass_and_parse_back() {
4019        // Each pass tags its removals so any deletion is attributable.
4020        let cmd = worker_sweep_command("/data/projects", 720, Some(168 * 60), Some(1024));
4021        assert!(cmd.contains("RCH_REAP_RM %s ttl %s"));
4022        assert!(cmd.contains("RCH_REAP_RM %s pooled-ttl %s"));
4023        assert!(cmd.contains("RCH_REAP_RM %s cap %s"));
4024
4025        let out = "RCH_REAP_RM 2048 ttl /data/projects/r/.rch-target-w-job-1\n\
4026                   RCH_REAP_RM 512 cap /data/projects/r/.rch-target-w-pool-x\n\
4027                   RCH_WORKER_REAP_METRICS removed=2 freed_kb=2560\n";
4028        let events = parse_reap_events(out);
4029        assert_eq!(events.len(), 2);
4030        assert_eq!(events[0].trigger, "ttl");
4031        assert_eq!(events[1].kb, 512);
4032        assert_eq!(events[1].trigger, "cap");
4033        // Untagged bodies (the orchestrator hook path) emit no events.
4034        let hook_body = reap_loop_body(720, None, "", "");
4035        assert!(!hook_body.contains("RCH_REAP_RM"));
4036    }
4037
4038    #[test]
4039    fn rm_failures_are_captured_in_tagged_contexts_only() {
4040        // Tagged sweep bodies capture rm stderr into an ERR event…
4041        let tagged = reap_loop_body_with_event(720, None, "removed", "freed_kb", Some("ttl"));
4042        assert!(tagged.contains("RCH_REAP_ERR ttl"));
4043        assert!(!tagged.contains("rm -rf -- \"$d\" 2>/dev/null"));
4044        // …while the untagged hook body keeps its historical silent shape.
4045        let untagged = reap_loop_body(720, None, "removed", "freed_kb");
4046        assert!(!untagged.contains("RCH_REAP_ERR"));
4047        assert!(untagged.contains("rm -rf -- \"$d\" 2>/dev/null"));
4048
4049        // The cap pass captures failures and accounts totals.
4050        let cmd = worker_sweep_command("/data/projects", 720, None, Some(1024));
4051        assert!(cmd.contains("RCH_REAP_ERR cap"));
4052        assert!(cmd.contains("RCH_REAP_CAP initial_kb=%s cap_kb=1024"));
4053        assert!(cmd.contains("RCH_REAP_CAP final_kb=%s"));
4054        // Cap skips now speak the parseable `RCH_GC_SKIP <trigger> <reason>
4055        // <path>` shape the gc surfaces read back, and carry the gate reason.
4056        assert!(cmd.contains("printf 'RCH_GC_SKIP %s active %s\\n' cap \"$d\""));
4057        assert!(cmd.contains("RCH_GC_SKIP cap gate %s"));
4058    }
4059
4060    #[test]
4061    fn parse_reap_errors_reads_trigger_path_and_message() {
4062        let out = "RCH_REAP_RM 5 ttl /data/projects/r/.rch-target-w-job-1\n\
4063                   RCH_REAP_ERR cap /data/projects/r/.rch-target-w-pool-x :: rm: cannot remove 'x': Permission denied\n\
4064                   garbage\n";
4065        let errors = parse_reap_errors(out);
4066        assert_eq!(errors.len(), 1);
4067        assert_eq!(errors[0].trigger, "cap");
4068        assert_eq!(errors[0].path, "/data/projects/r/.rch-target-w-pool-x");
4069        assert!(errors[0].message.contains("Permission denied"));
4070    }
4071}