fix(scripts): row 211 R5 busy check literal listing, flat form, sourced word walk (#1709)

From the GPT review of R4 (row211-r4-gpt.md, N1 and N2), for #1709 and #426:

- slot-busy.sh (N1): gone() lists the proc root with
  ls -f -1 --quoting-style=literal, so QUOTING_STYLE can't quote a pid
  out of the listing, and the script exports LC_ALL=C before any tool
  runs. A failed or denied listing is still never gone.
- slot-busy.sh (N2): a command line holding quotes, backslashes, shell
  operators or uneven whitespace gets a flat form, made in awk. The
  snapshot rule reads the flat form. The general rules read the flat
  form, then the raw text. A wrapper's text goes through an awk word
  walk: every word of a source or . span counts, quoted text is read
  again, and the wrapper's own snapshot is skipped. More than 32 walk
  levels, an awk failure or bad output is exit 2, "cannot classify".
- test-git-guard.sh: 48 new cases, 153 in all. The new cases cover
  QUOTING_STYLE, the locale, the rule shapes, depth, awk failure, and
  live wrappers for the review's 13 shapes plus a CLEAR control.
- mutants.sh: sites R5 moved are updated, and 27 new mutants bring the
  total to 57, all killed.

RED 119/34 on fd1aa8cc8's slot-busy.sh, GREEN 153/0. The hook and the
installer are unchanged.
This commit is contained in:
jason.woltje committed 2026-11-02 17:53:26 -06:00
1 parent fd1aa8cc81
commit afc176de02
3 files changed
+325 -54

No files matched your search

+36 -8
View File
@@ -75,16 +75,16 @@ run differing-hook-replaced $I '[ "$FORCE" = 1 ] || die 2 "refused: a different'
run replace-in-place $I 'mv -f -- "$tmp" "$HOOK" ||' 'cat -- "$tmp" > "$HOOK" && rm -f -- "$tmp" ||'
run symlink-followed $I 'if [ -L "$HOOK" ]; then die 2' 'if false; then die 2'
run ancestor-error-passes $H ' *) refuse "$rref: the ancestor check failed' ' *) : "$rref: the ancestor check failed'
run snapshot-matched $S $' if [[ "$c" =~ $RE_SNAPSHOT ]]; then sourced "${c:${#BASH_REMATCH[0]}}"; return; fi\n' ''
run snapshot-matched $S $' if [[ " $n " =~ $RE_SNAPSHOT ]]; then sourced "$1"; return; fi\n' ''
run ancestors-matched $S ' [ -z "${SKIP[$pid]:-}" ] || continue' ' :'
run no-node-rule $S 'if [[ "$c" =~ $RE_NODE ]]; then echo "node --test"; return 0; fi' ':'
run no-compose-rule $S 'if [[ "$c" =~ $RE_COMPOSE ]]; then echo "docker compose"; return 0; fi' ':'
run no-node-rule $S 'if [[ "$1" =~ $RE_NODE ]]; then echo "node --test"; return 0; fi' ':'
run no-compose-rule $S 'if [[ "$1" =~ $RE_COMPOSE ]]; then echo "docker compose"; return 0; fi' ':'
# R3, the GPT review (F1 to F5 and the object-name length check).
run unreadable-read-clear $S ' gone "$PROC" "$pid" && continue' ' continue'
run vanished-is-error $S $' gone "$PROC" "$pid" && continue\n' ''
run read-errors-ignored $S '[ "$errors" = 0 ] || {' 'true || {'
run self-read-unchecked $S ' || { echo "slot-busy: error: cannot read its own process" >&2; exit 2; }' ' || p=""'
run snapshot-substring $S '[[ "$c" =~ $RE_SNAPSHOT ]]' '[[ "$c" == *"/shell-snapshots/snapshot-"* ]]'
run snapshot-substring $S '[[ " $n " =~ $RE_SNAPSHOT ]]' '[[ " $n " == *"/shell-snapshots/snapshot-"* ]]'
run tag-replace-passes $H 'if [ "${rref#refs/tags/}" != "$rref" ] && [ "$lsha" != "$rsha" ]; then' 'if false; then'
run hooks-dir-symlink-followed $I 'own_hooks() {' 'own_hooks() { return 0;'
run short-sha-accepted $H ' if ! idlen "$lsha" || ! idlen "$rsha"; then' ' if false; then'
@@ -92,10 +92,38 @@ run short-sha-accepted $H ' if ! idlen "$lsha" || ! idlen "$rsha"; then' '
run ancestor-read-unchecked $S $' gone /proc "$p" && break\n echo "slot-busy: error: cannot read the status of ancestor $p" >&2; exit 2' ' break'
run exited-ancestor-error $S ' gone /proc "$p" && break' ' :'
run denied-lookup-vanished $S ' gone "$PROC" "$pid" && continue' ' [ -e "$d" ] || continue'
run denied-listing-gone $S 'names=$(ls -f -- "$1" 2>/dev/null) || return 1' 'names=$(ls -f -- "$1" 2>/dev/null) || return 0'
run denied-listing-gone $S 'names=$(ls -f -1 --quoting-style=literal -- "$1" 2>/dev/null) || return 1' 'names=$(ls -f -1 --quoting-style=literal -- "$1" 2>/dev/null) || return 0'
run dir-check-skips $S $' pid=${d##*/}\n' $' [ -d "$d" ] || continue\n pid=${d##*/}\n'
run sourced-test-hidden $S 'then sourced "${c:${#BASH_REMATCH[0]}}"; return; fi' 'then return 1; fi'
run sourced-first-only $S ' rest=${rest#*"$m"}' ' break'
run dot-source-missed $S '(source|\\.)[[:space:]]+' '(source)[[:space:]]+'
run sourced-test-hidden $S 'then sourced "$1"; return; fi' 'then return 1; fi'
run sourced-first-only $S '{ if (!depth && !bq) span = 0 }' '{ if (!depth && !bq) { if (span) return 1 } }'
run dot-source-missed $S 'w == "source" || w == "."' 'w == "source"'
run installer-fixed-tmp $I ' tmp=$(mktemp "$HOOKS/.pre-push.install-git-hooks.XXXXXX") || die 1 "cannot create a temporary file in $HOOKS"' ' tmp="$HOOKS/.pre-push.install-git-hooks.$$"'
# R5 (the GPT review of R4: N1 and N2). Sites R5 moved are edited above.
run ls-env-quoting $S '-1 --quoting-style=literal --' '-1 --'
run locale-kept $S $'export LC_ALL=C\n' ''
run quotes-kept $S 'gsub(/["\047\\]/, "", t); ' ''
run operators-kept $S 'gsub(/[[:space:];&|(){}<>`]+/, " ", t)' 'gsub(/[[:space:]]+/, " ", t)'
run snapshot-single-space $S '<>`]+/, " ", t)' '<>`]/, " ", t)'
run flat-skipped $S ' if [[ "$n" == *$SPECIAL* || "$n" == *" "* ]]; then' ' if false; then'
run flat-double-blank-missed $S ' || "$n" == *" "* ]]; then' ' ]]; then'
run special-tab-missed $S '<>`\t\n\r\v\f]'"'" '<>`\n\r\v\f]'"'"
run flat-failure-passes $S '[[ "$n" == F* ]] || return 2' '[[ "$n" == F* ]] || true'
run flat-sentinel-unchecked $S 'n=$(flat "$1") && [[ "$n" == F* ]] || return 2' 'n=$(flat "$1") || return 2'
run raw-rules-dropped $S 'rules " $n " || rules " $1 "' 'rules " $n "'
run flat-rules-dropped $S 'rules " $n " || rules " $1 "' 'rules " $1 "'
run source-arg-first-word $S 'span = span || w == "source" || w == "."' 'span = w == "source" || w == "."'
run no-requote $S 'if (q && w ~ AGAIN) {' 'if (0) {'
run quoted-span-reset $S 'if (open) span = 1' 'span = open'
run span-ends-in-parens $S '{ if (!depth && !bq) span = 0 }' '{ span = 0 }'
run backtick-ignored $S 'bq = !bq' 'bq = bq'
run sq-kept $S 'if (c == SQ) {' 'if (0) {'
run dq-kept $S '} else if (c == DQ) {' '} else if (0) {'
run dollar-quote-glued $S '} else if (c == "$" && index(SQ DQ, substr(s, i + 1, 1))) {' '} else if (0) {'
run backslash-kept $S '} else if (c == BS) {' '} else if (0) {'
run snapshot-path-checked $S 'if (snap == "" && w ~ SNAP) snap = w' 'if (0) snap = w'
run snapshot-repeat-checked $S 'else if (w != snap) {' 'else if (1) {'
run depth-unbounded $S 'if (lvl > maxlvl) return 2' 'if (0) return 2'
run awk-failure-none $S "') || return 2" "') || return 1"
run lexer-garbage-none $S '*) return 2 ;; esac' '*) return 1 ;; esac'
run classify-error-passes $S '[ "$rc" != 1 ] || continue' '[ "$rc" = 0 ] || continue'
exit "$bad"
+142 -42
View File
@@ -7,26 +7,33 @@
#
# It prints one BUSY line per matching process, or one CLEAR line. Exit codes:
# 0 CLEAR, 1 BUSY, 2 its own error, which includes a process or an ancestor
# it can't read that isn't confirmed gone (it still prints any BUSY lines,
# never CLEAR). Gone means a listing of the proc root that succeeds and lacks
# the pid. Chain it on the exit code, never on a filter of its output:
# it can't read that isn't confirmed gone, and a wrapper's text it can't
# classify (it still prints any BUSY lines, never CLEAR). Gone means a
# literal listing of the proc root that succeeds and lacks the pid. Chain it
# on the exit code, never on a filter of its output:
# bash scripts/slot-busy.sh > LOG && gate (set -o pipefail if piped)
# never `bash scripts/slot-busy.sh | tail -1 && gate`, which runs while BUSY.
#
# A process matches when its command line holds `node ... --test`, a
# `*gate*.sh`, `mutant`, a `test-*.sh` or `docker compose`/`docker-compose`.
# It skips itself and its ancestors (a gate script may run the check), and an
# agent harness's shell-snapshot wrapper (`bash -c 'source .../shell-snapshots/
# snapshot-*.sh ...'`), whose command line is the agent's command text; that
# shell's real children are still matched, and so is any other command line
# that only names a shell-snapshots path. A wrapper whose text after the
# snapshot source also sources (`source` or `.`) a gate, test or mutant
# script runs it in its own shell, so that wrapper is BUSY. It reads only
# /proc/<pid>/cmdline, the cwd link, its own and its ancestors'
# /proc/<pid>/status and, after a failed read, the proc root's listing, never
# a file under a seat's directory.
# The rules read the command line as it is and in a flat form: quotes and
# backslashes removed, and each run of blanks and the characters ; & | ( ) { }
# < > ` made one space. It skips itself and its ancestors (a gate script may
# run the check), and an agent harness's shell-snapshot wrapper (`bash -c
# 'source .../shell-snapshots/snapshot-*.sh ...'`, matched in the flat form),
# whose command line is the agent's command text; that shell's real children
# are still matched, and so is any other command line that only names a
# shell-snapshots path. A wrapper that sources (`source` or `.`) a gate, test
# or mutant script runs it in its own shell, so that wrapper is BUSY. Every
# word from the `source` or `.` to the next ; & | or newline counts, and
# quoted text is read again as shell text. It reads only /proc/<pid>/cmdline,
# the cwd link, its own and its ancestors' /proc/<pid>/status and, after a
# failed read, the proc root's listing, never a file under a seat's directory.
# --proc-root names a stand-in for /proc in the selftests.
set -uo pipefail
# Every tool reads bytes the same way whatever the caller's locale: under a
# UTF-8 locale a byte that isn't UTF-8 would end a [^ /]* run early.
export LC_ALL=C
PROC=/proc
while [ $# -gt 0 ]; do
@@ -40,10 +47,12 @@ done
# gone ROOT PID: true only when ROOT lists and PID isn't in the listing. A
# failed or denied listing is false: a lookup that fails can't tell an exited
# process from one it may not see. `ls -f` reads the names only, no stat.
# process from one it may not see. `ls -f` reads the names only, no stat. The
# flags fix the format: one name per line, never quoted, whatever
# QUOTING_STYLE holds (a quoted name would read as a missing pid).
gone() {
local names
names=$(ls -f -- "$1" 2>/dev/null) || return 1
names=$(ls -f -1 --quoting-style=literal -- "$1" 2>/dev/null) || return 1
[[ $'\n'"$names"$'\n' != *$'\n'"$2"$'\n'* ]]
}
@@ -67,34 +76,119 @@ RE_NODE='[ /]node[^ ]* (.* )?--test '
RE_GATE='[ /_-]gate[^ /]*\.sh '
RE_TEST='[ /]test-[^ /]*\.sh '
RE_COMPOSE='[ /]docker( compose|-compose) '
# The wrapper's shape: a shell, its options with -c, then `source` (or `.`)
# of a shell-snapshots/snapshot-*.sh file as the first command.
# The wrapper's shape, in the flat form: a shell, its options with -c, then
# `source` (or `.`) of a shell-snapshots/snapshot-*.sh file as the first
# command.
RE_SNAPSHOT='^ (/[^ ]*/)?(ba|z)?sh( -[a-zA-Z]+)* -[a-zA-Z]*c[a-zA-Z]*( -[a-zA-Z]+)* (source|\.) [^ ]*/shell-snapshots/snapshot-[^ /]*\.sh '
# A sourced file in the wrapper's text: `source` or `.` after the start, a
# blank, a shell operator or a quote, then blanks, an optional `--`, an
# optional opening quote and the path, up to a blank, operator or quote.
RE_SOURCED="(^|[[:space:];&|(){}\`'\"])(source|\\.)[[:space:]]+(--[[:space:]]+)?['\"]?([^[:space:];&|<>(){}\`'\"]+)"
sourced() { # $1: the wrapper's text after the snapshot source. Prints the rule.
local rest=$1 m f
while [[ "$rest" =~ $RE_SOURCED ]]; do
# Saved first: the rule tests below reset BASH_REMATCH.
m=${BASH_REMATCH[0]} f=" ${BASH_REMATCH[4]} "
if [[ "$f" =~ $RE_GATE ]]; then echo "sourced gate"; return 0; fi
if [[ "$f" == *mutant* ]]; then echo "sourced mutant"; return 0; fi
if [[ "$f" =~ $RE_TEST ]]; then echo "sourced test script"; return 0; fi
rest=${rest#*"$m"}
done
# sourced TEXT: a wrapper's whole command line. Prints the rule when a
# `source` or `.` in it names a gate, test or mutant script; returns 1 when
# none does, 2 when the text can't be classified.
#
# The awk walk reads the text in words the way the shell splits them: a
# blank, newline or one of ; & | ( ) < > ` ends a word; '...' and "..." are
# parts of a word with the quotes removed (a backslash inside "..." keeps the
# next character in the word); a backslash keeps the next character; $'...'
# and $"..." are read as quotes. A quote that never closes runs to the end of
# the text. Quoted text that holds a blank, an operator, a quote or a
# backslash is walked again as shell text (eval, bash -c and the harness's
# '\'' forms), and a span left open at its end stays open after the word.
# A span starts after a `source` or `.` word and ends at ; & | or a newline
# outside parentheses and backticks; every word in it is checked, so
# redirections, $(...) and `...` parts and later arguments count. The first
# shell-snapshots/snapshot-*.sh word in a span is the harness's own snapshot,
# skipped whatever its name; a later word equal to it is skipped too. Text
# quoted more than 32 levels deep, a failed awk or output that is neither a
# rule nor "none" returns 2.
sourced() {
local out
out=$(printf '%s' "$1" | awk -v maxlvl=32 '
BEGIN {
RS = "\001"; SQ = "\047"; DQ = "\""; BS = "\\"
SEP = " \t\n;&|()<>`"; STOP = SEP SQ DQ BS "$"
AGAIN = "[[:space:];&|()<>`" SQ DQ "\\\\]"
GATE = "(^|[/_-])gate[^/]*\\.sh$"; TEST = "(^|/)test-[^/]*\\.sh$"
SNAP = "/shell-snapshots/snapshot-[^/]*\\.sh$"
}
{ text = (NR > 1) ? text RS $0 : $0 }
function walk(s, span, lvl, n, i, j, c, w, q, had, r, depth, bq) {
if (lvl > maxlvl) return 2
n = length(s); i = 1
for (;;) {
w = ""; q = had = 0
while ((c = substr(s, i, 1)) != "" && !index(SEP, c)) {
had = 1
if (c == SQ) {
for (j = i + 1; j <= n && substr(s, j, 1) != SQ; j++) ;
w = w substr(s, i + 1, j - i - 1); i = j + 1; q = 1
} else if (c == DQ) {
for (j = i + 1; j <= n && (c = substr(s, j, 1)) != DQ; j++) if (c == BS) j++
w = w substr(s, i + 1, j - i - 1); i = j + 1; q = 1
} else if (c == "$" && index(SQ DQ, substr(s, i + 1, 1))) {
i++
} else if (c == BS) {
c = substr(s, i + 1, 1); if (c != "\n") w = w c; i += 2; q = 1
} else {
for (j = i + 1; j <= n && !index(STOP, substr(s, j, 1)); j++) ;
w = w substr(s, i, j - i); i = j
}
}
if (had) {
if (q && w ~ AGAIN) {
r = walk(w, span, lvl + 1)
if (r != 1) return r
if (open) span = 1
}
if (span) {
if (snap == "" && w ~ SNAP) snap = w
else if (w != snap) {
if (w ~ GATE) { rule = "sourced gate"; return 0 }
if (index(w, "mutant")) { rule = "sourced mutant"; return 0 }
if (w ~ TEST) { rule = "sourced test script"; return 0 }
}
}
span = span || w == "source" || w == "."
}
if (c == "") { open = span; return 1 }
i++
if (c == ";" || c == "&" || c == "|" || c == "\n") { if (!depth && !bq) span = 0 }
else if (c == "(") depth++
else if (c == ")") { if (depth) depth-- }
else if (c == "`") bq = !bq
}
}
END { r = walk(text, 0, 1); print (r == 0 ? rule : r == 1 ? "none" : "deep") }
') || return 2
case $out in none) return 1 ;; "sourced "*) echo "$out"; return 0 ;; *) return 2 ;; esac
}
rules() { # $1: a command line with a blank at each end. Prints the rule.
if [[ "$1" =~ $RE_NODE ]]; then echo "node --test"; return 0; fi
if [[ "$1" =~ $RE_GATE ]]; then echo "gate"; return 0; fi
if [[ "$1" == *mutant* ]]; then echo "mutant"; return 0; fi
if [[ "$1" =~ $RE_TEST ]]; then echo "test script"; return 0; fi
if [[ "$1" =~ $RE_COMPOSE ]]; then echo "docker compose"; return 0; fi
return 1
}
match() { # $1: the command line, NULs as spaces. Prints the rule that matched.
local c=" $1 "
if [[ "$c" =~ $RE_SNAPSHOT ]]; then sourced "${c:${#BASH_REMATCH[0]}}"; return; fi
if [[ "$c" =~ $RE_NODE ]]; then echo "node --test"; return 0; fi
if [[ "$c" =~ $RE_GATE ]]; then echo "gate"; return 0; fi
if [[ "$c" == *mutant* ]]; then echo "mutant"; return 0; fi
if [[ "$c" =~ $RE_TEST ]]; then echo "test script"; return 0; fi
if [[ "$c" =~ $RE_COMPOSE ]]; then echo "docker compose"; return 0; fi
return 1
# The characters the flat form changes, other than a single space. A command
# line with none of them and no two blanks in a row is its own flat form.
SPECIAL=$'[\"\'\\\\;&|(){}<>`\t\n\r\v\f]'
# flat TEXT: prints F, then TEXT in the flat form. awk, not bash: a bash
# ${s//x/y} costs time per match, and a 2 MB line full of quotes took minutes.
flat() {
printf '%s' "$1" | awk '
BEGIN { RS = "\001" }
{ t = (NR > 1) ? t RS $0 : $0 }
END { gsub(/["\047\\]/, "", t); gsub(/[[:space:];&|(){}<>`]+/, " ", t); printf "F%s", t }
'
}
match() { # $1: the command line, NULs as spaces. Prints the rule; 1 none, 2 unclassified.
local n=$1
if [[ "$n" == *$SPECIAL* || "$n" == *" "* ]]; then
n=$(flat "$1") && [[ "$n" == F* ]] || return 2
n=${n#F}
fi
if [[ " $n " =~ $RE_SNAPSHOT ]]; then sourced "$1"; return; fi
rules " $n " || rules " $1 "
}
seen=0
@@ -119,13 +213,19 @@ for d in "$PROC"/[0-9]*; do
fi
cmd=${cmd% }
[ -n "$cmd" ] || continue
rule=$(match "$cmd") || continue
rule=$(match "$cmd"); rc=$?
[ "$rc" != 1 ] || continue
if [ "$rc" != 0 ]; then
echo "slot-busy: error: cannot classify the command line of $d" >&2
errors=$((errors + 1))
continue
fi
cwd=$(readlink "$d/cwd" 2>/dev/null) || cwd="?"
printf 'BUSY %s %s cwd=%s %s\n' "$pid" "$rule" "$cwd" "${cmd:0:200}"
busy=1
done
[ "$seen" -ge 1 ] || { echo "slot-busy: error: no processes under $PROC" >&2; exit 2; }
[ "$errors" = 0 ] || { echo "slot-busy: error: $errors process(es) could not be read, so the host isn't known to be idle" >&2; exit 2; }
[ "$errors" = 0 ] || { echo "slot-busy: error: $errors process(es) could not be read or classified, so the host isn't known to be idle" >&2; exit 2; }
if [ "$busy" = 1 ]; then exit 1; fi
echo "CLEAR"
exit 0
+147 -4
View File
@@ -273,7 +273,7 @@ rule() { # $1: expected rule, rest: argv
local want=$1; shift
rm -rf "${P:?}/4194420"; fakeproc 4194420 /seat/b "$@"
out=$(busyrun); rc=$?
check "busy on: $* ($want)" "$([ "$rc" = 1 ] && has "$out" "BUSY 4194420 $want "; echo $?)"
check "busy on: ${*//$'\n'/\\n} ($want)" "$([ "$rc" = 1 ] && has "$out" "BUSY 4194420 $want "; echo $?)"
}
rule "node --test" /usr/bin/node --experimental-strip-types --test a.test.ts
rule "gate" bash /mnt/scratch/sage-gate.sh 253
@@ -297,6 +297,58 @@ rule "sourced test script" /bin/bash -c "$SNAP && source \"scripts/test-x.sh\" a
rule "sourced test script" /bin/bash -c "$SNAP && source .venv/bin/activate && . scripts/test-x.sh"
rule "sourced gate" /usr/bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-2-ab.sh 2>/dev/null || true && eval '. ./gate.sh 253' && pwd -P >| /home/u/cwd"
rule "sourced mutant" /bin/bash -c "$SNAP && cd /seat && eval 'source agents/x/work/queue-1/mutants.sh M3'"
# R5 (N2): every word up to the next operator counts, not only the first; and
# quoted, escaped and nested forms read the way the shell reads them.
rule "sourced test script" /bin/bash -c "$SNAP && source 2>/dev/null /seat/scripts/test-x.sh"
rule "sourced test script" /bin/bash -c "$SNAP && source \$(cd scripts && pwd)/test-x.sh"
rule "sourced test script" /bin/bash -c "$SNAP && source \`cd scripts; pwd\`/test-x.sh"
rule "sourced test script" /bin/bash -c "$SNAP && source \"/seat/a;b (c)/scripts/test-x.sh\""
rule "sourced test script" /bin/bash -c "$SNAP && eval 'source \"/seat/a;b/scripts/test-x.sh\"'"
rule "sourced test script" /bin/bash -c "$SNAP && eval \"source \\\"/seat/dir with spaces/scripts/test-x.sh\\\"\""
rule "sourced test script" /usr/bin/bash -c "$SNAP 2>/dev/null || true && eval 'source '\\''scripts/test-x.sh'\\''' && pwd -P >| /home/u/cwd"
rule "sourced test script" /bin/bash -c "$SNAP && eval \$'source scripts/test-x.sh'"
rule "sourced test script" /bin/bash -c "$SNAP && cat <<EOF"$'\n'"don't"$'\n'"EOF"$'\n'"source \"it's dir/scripts/test-x.sh\""
# An unclosed quote runs to the end of the text and is read again there.
rule "sourced test script" /bin/bash -c "$SNAP && echo \"it's && source /seat/scripts/test-x.sh"
# The general rules read the flat form too, and still read the raw one.
rule "test script" /bin/bash -c "bash scripts/test-x.sh;echo done"
rule "gate" bash "/seat/gate;1.sh"
# Two blanks in a row make one in the flat form, with no other character to
# flatten.
rule "docker compose" /bin/sh -c "docker compose -p x up"
rule "docker compose" /bin/sh -c $'docker\tcompose -p x up'
rm -rf "${P:?}/4194420"
idle() { # argv: a process that must leave the check CLEAR
rm -rf "${P:?}/4194421"; fakeproc 4194421 /seat/c "$@"
out=$(busyrun); rc=$?
check "not busy: ${*//$'\n'/\\n}" "$([ "$rc" = 0 ] && [ "$out" = CLEAR ]; echo $?)"
rm -rf "${P:?}/4194421"
}
# The harness's own snapshot path is skipped, quoted or not, whatever its name.
idle /bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-3-gate.sh && echo hi"
idle /bin/bash -c "source \"/home/u/.claude/shell-snapshots/snapshot-bash-3-gate.sh\" && echo hi"
idle /bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-3-gate.sh && . /home/u/.claude/shell-snapshots/snapshot-bash-3-gate.sh && echo hi"
# Locale: a byte that isn't UTF-8 must not end a name under a UTF-8 locale.
rm -rf "${P:?}/4194420"; fakeproc 4194420 /seat/b bash $'scripts/test-\xffx.sh'
out=$(LC_ALL=C.UTF-8 bash "$BUSY" --proc-root "$P" 2>&1); rc=$?
check "under LC_ALL=C.UTF-8, a test script name holding the byte 0xff: exit 1" \
"$([ "$rc" = 1 ] && has "$out" "BUSY 4194420 test script "; echo $?)"
# Quoting that nests deeper than the walk reads is an error, never CLEAR. Each
# level escapes backslashes and double quotes and wraps the text in quotes.
nest() { # $1: levels. Sets w.
w='source scripts/test-x.sh'
for _ in $(seq "$1"); do w=$(printf '%s' "$w" | sed 's/[\\"]/\\&/g'); w="\"$w\""; done
}
nest 16
rm -rf "${P:?}/4194420"; fakeproc 4194420 /seat/b /bin/bash -c "$SNAP && eval $w"
out=$(busyrun); rc=$?
check "a sourced test under 16 escaped quoting levels (${#w} bytes): exit 1, sourced test script" \
"$([ "$rc" = 1 ] && has "$out" "BUSY 4194420 sourced test script "; echo $?)"
nest 17
rm -rf "${P:?}/4194420"; fakeproc 4194420 /seat/b /bin/bash -c "$SNAP && eval $w"
out=$(busyrun); rc=$?
check "quoting 17 escaped levels deep (${#w} bytes): exit 2, cannot classify, no CLEAR" \
"$([ "$rc" = 2 ] && has "$out" "cannot classify the command line of $P/4194420" && ! has "$out" CLEAR; echo $?)"
rm -rf "${P:?}/4194420"
echo "== slot-busy, read errors =="
@@ -362,9 +414,9 @@ fi
exec $(command -v awk) "\$@"
EOF
chmod +x "$SANDBOX/awkn/awk"
awkn() { # $1: fail, empty or exited. Runs the check on the idle fake /proc.
echo "$1" > "$SANDBOX/awkn/mode"; rm -f "$SANDBOX/awkn/n"
out=$(PATH="$SANDBOX/awkn:$PATH" bash "$BUSY" --proc-root "$P" 2>&1); rc=$?
awkn() { # $1: fail, empty or exited; rest: NAME=VALUE for the check's environment.
echo "$1" > "$SANDBOX/awkn/mode"; rm -f "$SANDBOX/awkn/n"; shift
out=$(env "$@" PATH="$SANDBOX/awkn:$PATH" bash "$BUSY" --proc-root "$P" 2>&1); rc=$?
calls=$(cat "$SANDBOX/awkn/n" 2>/dev/null || echo 0)
}
awkn fail
@@ -377,6 +429,48 @@ awkn exited
check "an ancestor that has exited (its pid gone from /proc) ends the walk: exit 0 and CLEAR" \
"$([ "$calls" -ge 3 ] && [ "$rc" = 0 ] && [ "$out" = CLEAR ]; echo $?)"
# R5 (N1): the gone check reads the proc root's listing whatever the caller's
# QUOTING_STYLE is. A quoted name would read as a vanished pid, and both
# failed reads above would turn into CLEAR.
for qs in shell-always c escape shell-escape-always locale; do
out=$(QUOTING_STYLE=$qs PATH="$SANDBOX/failtr:$PATH" bash "$BUSY" --proc-root "$P" 2>&1); rc=$?
check "QUOTING_STYLE=$qs: a failing cmdline read with the pid still listed: exit 2, no CLEAR" \
"$([ "$rc" = 2 ] && has "$out" "cannot read $P/" && ! has "$out" CLEAR; echo $?)"
awkn fail QUOTING_STYLE=$qs
check "QUOTING_STYLE=$qs: a failing ancestor status read with the pid still listed: exit 2, no CLEAR" \
"$([ "$calls" -ge 2 ] && [ "$rc" = 2 ] && has "$out" "cannot read the status of ancestor" && ! has "$out" CLEAR; echo $?)"
done
# The flat form and the walk over a wrapper's text are awk programs. If
# either awk fails, or prints nothing, the command line can't be classified:
# exit 2, never CLEAR. This awk fails or goes silent for one program only
# (the walk has `function walk`, the flat form `gsub`).
mkdir -p "$SANDBOX/lexawk"
cat > "$SANDBOX/lexawk/awk" <<EOF
#!/bin/sh
m=\$(cat "$SANDBOX/lexawk/mode")
case "\$*" in
*"function walk"*) case \$m in walk-fail) cat > /dev/null; exit 1 ;; walk-empty) cat > /dev/null; exit 0 ;; esac ;;
*gsub*) case \$m in flat-fail) cat > /dev/null; exit 1 ;; flat-empty) cat > /dev/null; exit 0 ;; esac ;;
esac
exec $(command -v awk) "\$@"
EOF
chmod +x "$SANDBOX/lexawk/awk"
lexawk() { # $1: mode, $2: label, rest: argv of a process that is BUSY
local mode=$1 label=$2; shift 2
fakeproc 4194460 /seat/w "$@"
echo "$mode" > "$SANDBOX/lexawk/mode"
out=$(PATH="$SANDBOX/lexawk:$PATH" bash "$BUSY" --proc-root "$P" 2>&1); rc=$?
check "$label: exit 2, cannot classify, no CLEAR" \
"$([ "$rc" = 2 ] && has "$out" "cannot classify the command line of $P/4194460" && ! has "$out" CLEAR; echo $?)"
rm -rf "${P:?}/4194460"
}
lexawk walk-fail "the wrapper walk's awk exits 1" /bin/bash -c "$SNAP && source /seat/scripts/test-x.sh"
lexawk walk-empty "the wrapper walk's awk prints nothing" /bin/bash -c "$SNAP && source /seat/scripts/test-x.sh"
# Only the flat form names this one (`.sh;` ends no rule's match as it is).
lexawk flat-fail "the flat form's awk exits 1" /bin/bash -c "bash scripts/test-x.sh;echo done"
lexawk flat-empty "the flat form's awk prints nothing" /bin/bash -c "bash scripts/test-x.sh;echo done"
# A lookup that is denied isn't a process that vanished. This tr makes the
# proc root unreadable once the pid directory is listed, then fails the read;
# the pid directory and its node --test cmdline stay where they are.
@@ -497,6 +591,55 @@ check "the wrapper itself has no BUSY line" "$(! printf '%s\n' "$out" | grep -q
for _ in $(seq 50); do kill -0 "$WRAP" 2>/dev/null || break; sleep 0.1; done
if ! kill -0 "$WRAP" 2>/dev/null; then wait "$WRAP" 2>/dev/null; KIDS=""; fi
# R5 (N2): the 13 shapes from the R4 review's Check 2, each a live wrapper
# that runs the sourced file in its own shell, with no child. S is the
# snapshot, SF a snapshot that holds `false` (so `||` runs the test), T the
# test. The sourced file blocks as test-x.sh does above.
S="$SANDBOX/shell-snapshots/snapshot-x.sh"
SF="$SANDBOX/shell-snapshots/snapshot-f.sh"
T="$SANDBOX/scripts/test-x.sh"
echo false > "$SF"
mkdir -p "$SANDBOX/wrap/scripts" "$SANDBOX/dir with spaces/scripts"
for f in wrap/scripts/test-x.sh wrap/gate.sh wrap/mutants.sh "dir with spaces/scripts/test-x.sh"; do
cp "$T" "$SANDBOX/$f"
done
live() { # $1: shape number, $2: expected rule or CLEAR, $3: label, $4: the wrapper's text
local n=$1 want=$2 label=$3 lp
rm -f "$SANDBOX/wrap/ready"
(cd "$SANDBOX/wrap" && exec bash -c "$4") &
WRAP=$!; KIDS="$KIDS $WRAP"
for _ in $(seq 50); do [ -e "$SANDBOX/wrap/ready" ] && break; sleep 0.1; done
kids=$(cat "/proc/$WRAP/task/$WRAP/children") && kids="none$kids"
lp="$SANDBOX/lproc-s$n"; mkdir -p "$lp"; ln -s "/proc/$WRAP" "$lp/$WRAP"
out=$(bash "$BUSY" --proc-root "$lp" 2>&1); rc=$?
if [ "$want" = CLEAR ]; then
check "live shape $n, $label, no child: exit 0 and CLEAR" \
"$([ -e "$SANDBOX/wrap/ready" ] && [ "$kids" = none ] && [ "$rc" = 0 ] && [ "$out" = CLEAR ]; echo $?)"
else
check "live shape $n, $label, no child: exit 1, $want" \
"$([ -e "$SANDBOX/wrap/ready" ] && [ "$kids" = none ] && [ "$rc" = 1 ] && ! has "$out" CLEAR \
&& printf '%s\n' "$out" | grep -q "^BUSY $WRAP $want cwd=$SANDBOX/wrap "; echo $?)"
fi
kill "$WRAP" 2>/dev/null; wait "$WRAP" 2>/dev/null; KIDS=""
}
live 1 "sourced test script" 'source S && source T' "source $S && source $T"
live 2 "sourced test script" "source S && .<TAB><TAB>'scripts/test-x.sh'" "source $S && ."$'\t\t'"'scripts/test-x.sh'"
live 3 "sourced test script" 'source S ; source T ; :' "source $S ; source $T ; :"
live 4 "sourced test script" 'source SF || source T' "source $SF || source $T"
live 5 "sourced gate" 'source S && . "./gate.sh"' "source $S && . \"./gate.sh\""
live 6 "sourced mutant" 'source S && source ./mutants.sh' "source $S && source ./mutants.sh"
live 7 "sourced test script" 'source S && source ".../dir with spaces/scripts/test-x.sh"' \
"source $S && source \"$SANDBOX/dir with spaces/scripts/test-x.sh\""
live 8 "sourced test script" 'source S && "source" T' "source $S && \"source\" $T"
live 9 "sourced test script" 'source S;source T;:' "source $S;source $T;:"
live 10 "sourced test script" 'source S;. T;:' "source $S;. $T;:"
live 11 "sourced test script" 'source "S" && source "T"' "source \"$S\" && source \"$T\""
live 12 "sourced test script" 'source<TAB>S && source "T"' "source"$'\t'"$S && source \"$T\""
live 13 "sourced test script" 'source<SPACE><SPACE>S && source "T"' "source $S && source \"$T\""
# A plain wrapper that blocks the same way but sources no test stays CLEAR.
live 14 CLEAR 'a plain wrapper, the same blocking read inline' \
"source $S && exec 3<>\"$SANDBOX/wrap/fifo\" && : > \"$SANDBOX/wrap/ready\" && read -t 60 -u 3 _"
echo "---"
echo "git-guard selftests: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]