1const SOURCE_CLAIM_REGISTRY: &str = "/tmp/rch-source-authority-locks/claims-v1";
45
46const SOURCE_GC_CHECKED_RETIREMENT: &str = "if ! __gc_source_end; then \
49 printf 'RCH_GC_RETIREMENT_FAILED %s\\n' \"$d\" >&2; exit 1; fi; ";
50
51pub 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
139pub const REAP_GLOBS: &[&str] = &[
142 ".rch-target-*-job-*",
143 ".rch-target-*-pid-*",
144 ".rch-target-*-pool-*",
145];
146
147pub const CARGO_CACHE_GLOB: &str = "rch-cargo-cache-*";
156
157pub const MIN_CARGO_CACHE_IDLE_MINUTES: u64 = 24 * 60;
161
162#[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#[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
181pub 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
197pub 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
210pub 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
221pub fn is_safe_reap_char(c: char) -> bool {
225 c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.')
226}
227
228pub const MIN_IDLE_HOURS: u32 = 1;
232
233pub fn idle_minutes_from_hours(idle_hours: u32) -> u64 {
236 u64::from(idle_hours.max(MIN_IDLE_HOURS)) * 60
237}
238
239pub 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
264pub 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 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 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
354fn 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
368fn 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
388pub fn source_claim_gc_guard_fragment() -> String {
401 source_claim_gate_fragment(SOURCE_CLAIM_REGISTRY)
402}
403
404pub 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
440pub 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
556const TMPBASE_MAXDEPTH: u32 = 6;
590
591fn 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
613fn 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 "__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
695fn 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
707fn 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 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
758pub const MIN_POOLED_IDLE_MINUTES: u64 = 24 * 60;
762
763#[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#[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 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
940pub struct ReapEvent {
941 pub kb: u64,
943 pub trigger: String,
945 pub path: String,
947}
948
949#[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#[derive(Debug, Clone, PartialEq, Eq)]
977pub struct ReapError {
978 pub trigger: String,
980 pub path: String,
982 pub message: String,
984}
985
986#[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#[must_use]
1033pub fn enumerate_targets_command(escaped_base: &str) -> String {
1034 let preamble = candidate_discovery_preamble(escaped_base, "");
1035 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#[derive(Debug, Clone, PartialEq, Eq)]
1062pub struct RemoteTargetEntry {
1063 pub newest_mtime_unix: u64,
1065 pub kb: u64,
1067 pub path: String,
1069 pub open_handles: GateEvidence,
1073 pub live_process: GateEvidence,
1076}
1077
1078impl RemoteTargetEntry {
1079 #[must_use]
1082 pub fn is_pooled(&self) -> bool {
1083 self.class() == GcClass::Pooled
1084 }
1085
1086 #[must_use]
1088 pub fn class(&self) -> GcClass {
1089 GcClass::from_path(&self.path)
1090 }
1091
1092 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1105pub enum GcClass {
1106 PerJob,
1108 Pooled,
1110 CargoCache,
1112 LegacyTarget,
1114 Unrecognized,
1117}
1118
1119impl GcClass {
1120 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
1186pub enum GateEvidence {
1187 Free,
1189 Held,
1191 #[default]
1195 Unknown,
1196}
1197
1198impl GateEvidence {
1199 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1230pub struct GcPolicy {
1231 pub idle_secs: u64,
1233 pub pooled_idle_secs: Option<u64>,
1235 pub cache_idle_secs: Option<u64>,
1237}
1238
1239impl GcPolicy {
1240 #[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#[derive(Debug, Clone, PartialEq, Eq)]
1255pub enum GcKeepReason {
1256 Unrecognized,
1258 ClassDisabled,
1260 TooYoung {
1262 age_secs: u64,
1264 required_secs: u64,
1266 },
1267 OpenHandles,
1269 LiveProcess,
1271 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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
1314pub enum GcVerdict {
1315 Collect,
1317 Keep(GcKeepReason),
1319}
1320
1321impl GcVerdict {
1322 #[must_use]
1324 pub fn is_collect(&self) -> bool {
1325 matches!(self, Self::Collect)
1326 }
1327}
1328
1329#[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#[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 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#[derive(Debug, Clone, PartialEq, Eq)]
1467pub struct GcCollectTarget {
1468 pub path: String,
1470 pub idle_minutes: u64,
1472 pub trigger: &'static str,
1474}
1475
1476pub fn collect_paths_command(targets: &[GcCollectTarget]) -> Result<String, String> {
1495 collect_paths_command_with_registry(targets, SOURCE_CLAIM_REGISTRY)
1496}
1497
1498#[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 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#[derive(Debug, Clone, PartialEq, Eq)]
1593pub struct GcSkip {
1594 pub trigger: String,
1596 pub reason: String,
1598 pub path: String,
1600}
1601
1602#[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
1625pub const ABANDONED_GC_CLAIM_MIN_MINUTES: u64 = 6 * 60;
1628
1629#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
1705pub struct GcClaimReport {
1706 pub verdict: String,
1708 pub reason: String,
1709 pub age_minutes: u64,
1710 pub name: String,
1711 pub path: String,
1713}
1714
1715#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
1754pub struct ScanRootReport {
1755 pub requested: Option<String>,
1757 pub resolved: Option<String>,
1759 pub temp_base: Option<String>,
1762 pub skipped: Option<String>,
1764}
1765
1766#[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1803pub struct GateAvailability {
1804 pub handles: bool,
1806 pub processes: bool,
1808}
1809
1810impl GateAvailability {
1811 #[must_use]
1813 pub fn both(self) -> bool {
1814 self.handles && self.processes
1815 }
1816}
1817
1818#[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#[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 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 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 #[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 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 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 #[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 #[test]
1973 fn every_gate_independently_blocks_collection() {
1974 let p = policy();
1975
1976 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 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 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 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 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 #[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 #[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 #[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 #[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(¤t);
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 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 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 assert_eq!(
2227 collect_paths_command(&[good.clone(), spaced.clone()]),
2228 Err(reason)
2229 );
2230 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 assert!(cmd.contains("if [ ! -d \"$d\" ]; then printf 'RCH_GC_SKIP %s missing"));
2251 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 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 #[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 let cmd = enumerate_targets_command("/data/projects");
2286 assert!(!cmd.contains("__tmpbase=\"${TMPDIR:-}\""));
2287 }
2288
2289 #[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 #[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 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 let mut entries = vec![
2352 entry(POOL, 5 * DAY, FREE), entry("/data/projects/r/.rch-target-hz2-pool-b", 2 * DAY, FREE),
2354 entry("/data/projects/r/.rch-target-hz2-pool-c", 60, FREE), ];
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 assert!(select_cap_evictions(&entries, &verdicts, NOW, &p, 0).is_empty());
2367 assert!(select_cap_evictions(&entries, &verdicts, NOW, &p, 30_000).is_empty());
2369 let evicted = select_cap_evictions(&entries, &verdicts, NOW, &p, 15_000);
2371 assert_eq!(evicted, vec![0, 1]);
2372 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 #[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; }
2447 }
2448
2449 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 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 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")); 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 #[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 assert!(cmd.contains(&format!(
2582 "find \"$__tmpscan\" -maxdepth {TMPBASE_MAXDEPTH} -type d \\( -name \".rch-target-*-job-*\" -o -name \".rch-target-*-pid-*\" \\)"
2583 )));
2584 assert!(cmd.contains(&format!(
2586 "find \"$__tmpscan\" -maxdepth {TMPBASE_MAXDEPTH} -type d -name \".rch-target-*-pool-*\""
2587 )));
2588 assert!(cmd.contains("-maxdepth 1 -type d -name \"rch_target_*\""));
2590 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 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 #[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 assert!(cmd.contains("case \"$__tmpbase\" in /*/*) __tmpscan="));
2623 assert!(cmd.contains("case \"$__tmpscan\" in /*/*) ;; *) __tmpscan=\"\";; esac"));
2624 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 assert!(
2631 !cmd.contains("find \"$__tmpbase\""),
2632 "tmp-base scans must use the resolved $__tmpscan, not $__tmpbase"
2633 );
2634 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 assert!(body.contains("[ \"$d\" = \".rch-target-self\" ] && continue"));
2652 assert!(body.contains("find \"$d\" -mmin -720 -print -quit"));
2654 assert!(!body.contains("-type f"));
2655 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 fn glob_matches(glob: &str, name: &str) -> bool {
2670 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 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 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 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 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 let cmd = worker_sweep_command("/data/projects", 720, None, None);
2729 assert!(!cmd.contains("-pool-"));
2730
2731 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 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 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 assert!(cmd.contains("104857600"));
2756 assert!(cmd.contains("sort -n"));
2758 assert!(
2761 cmd.matches("__gc_source_recent_activity \"$d\" 720")
2762 .count()
2763 >= 2
2764 );
2765 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 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 assert!(cmd.contains(
2781 "if [ -n \"$__tmpscan\" ]; then find \"$__tmpscan\" -maxdepth 1 -type d -name \"rch_target_*\" -prune"
2782 ));
2783 assert!(cmd.contains("done < \"$__tmpf\""));
2785 assert!(!cmd.contains("| while"));
2786 assert!(cmd.contains("RCH_WORKER_REAP_METRICS removed=%s freed_kb=%s"));
2787 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 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 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 #[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 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 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; }
2867 }
2868
2869 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 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(®istry, &exact, "aa", "claim"),
2976 source_gc_fixture_claim(®istry, ancestor.parent().unwrap(), "ab", "claim"),
2977 source_gc_fixture_claim(®istry, &descendant.join("nested"), "ac", "pending"),
2978 source_gc_fixture_claim(®istry, &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 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 let record = source_gc_fixture_claim(®istry, &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(®istry, &active, "ca01", "claim");
3097 let ready = source_gc_fixture_claim(®istry, &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(®istry, &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(®istry)
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 (®istry, 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 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(®istry).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 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(®istry)
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(®istry).unwrap();
3536 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(®istry)
3547 .arg(&destination)
3548 .output()
3549 .unwrap();
3550 assert!(!output.status.success());
3551 assert!(!destination.exists());
3552 let retained: Vec<_> = std::fs::read_dir(®istry)
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(®istry, &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 #[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 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(®istry).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 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(®istry).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 #[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 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 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 #[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 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 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 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 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 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 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 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 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}