fix(scripts): row 211 R4 busy check ancestor reads, denied lookups, sourced tests (#1709)

From the GPT review of R3 (row211-r3-gpt.md, R1 to R3 and Check 5):

- slot-busy.sh: every ancestor status read must give a numeric PPid. A
  failed ancestor or cmdline read counts as gone only when a listing of
  the proc root succeeds and lacks the pid; anything else is exit 2 with
  no CLEAR. The [ -d ] and [ -e ] tests, which can't tell a denied lookup
  from a missing one, are gone.
- slot-busy.sh: a shell-snapshot wrapper whose text sources (source or .)
  a gate, test or mutant script is BUSY; one that only runs children is
  still skipped.
- install-git-hooks.sh: the hook is staged in a mktemp file in the hooks
  directory, not a pid-named path.
- mutants.sh: exit 1 on a survivor, NO MATCH, COPY FAILED or a timeout;
  nine new mutants, 30 of 30 killed.

test-git-guard.sh: RED 90/14, GREEN 105/0. BUILD.md R4 has the details,
including a correction to the R3 bind-mount note.
This commit is contained in:
jason.woltje committed 2026-11-02 14:11:47 -06:00
1 parent 7d03f212db
commit fd1aa8cc81
8 files changed
+517 -35

No files matched your search

+21
View File
@@ -5835,3 +5835,24 @@ failed (19:28:58Z to 19:29:04Z). The run before GREEN gave 80/5 because the new
CLEAR"; it was reworded. Mutants 21 of 21 killed (19:30:11Z to 19:33:22Z, base green). No 64-hex test
pin names a changed path. Not pushed. Gaps: the installer's resolved-path refusal has no test of its own
(it needs a bind mount); details in agents/darkwing/work/queue-211/BUILD.md, section "R3".
## 2026-11-02T20:11:25Z: row 211 (#1709, #426) R4, busy check ancestor reads, denied lookups and sourced tests
Before: row 211 R3 as picked onto 09a847eda (bcd74ec33, 3a121885c, 7d03f212d on
`t3/row211-push-guard-r4-fix`). The GPT review of R3 (row211-r3-gpt.md) found three ways slot-busy.sh could
print CLEAR in doubt: a failed ancestor status read ended the walk without an error; `[ -e ]` read a denied
lookup as an exited process; and a shell-snapshot wrapper that sourced a test into its own shell, with no
child, was skipped. It also noted that mutants.sh exited 0 after a survivor, that the installer's temporary
file had a pid name, and that the R3 note on bind mounts was wrong.
After: one R4 commit by the lead's implementation sub-agent. Every ancestor read must give a numeric PPid;
a failed read, or a failed cmdline read, counts as gone only when a listing of the proc root succeeds and
lacks the pid, and anything else is exit 2 with no CLEAR. A wrapper whose text sources (`source` or `.`) a
gate, test or mutant script is BUSY; a wrapper that only runs children is still skipped. mutants.sh exits 1
on a survivor, NO MATCH, COPY FAILED or timeout (shown by hand in TMPDIR: survived exit 1, NO MATCH exit 1).
The installer stages the hook in a mktemp file. test-git-guard.sh: RED 90 passed, 14 failed (19:57:25Z to
19:57:37Z); the first GREEN gave 101/3 (20:01:11Z to 20:01:25Z), from a BASH_REMATCH reset in the new
sourced() that a GREEN-time case now pins; GREEN 105 passed, 0 failed (20:02:40Z to 20:02:49Z). Mutants 30
of 30 killed, runner exit 0 (20:02:56Z to 20:06:58Z, base green). No 64-hex test pin names a changed path.
Not pushed. Gaps: lexical false BUSY and missed runtime-built sourced paths, and an undetected bind-mounted
hooks directory; details in agents/darkwing/work/queue-211/BUILD.md, section "R4".
+249
View File
@@ -322,3 +322,252 @@ output to files there.
- The installer's temporary file is named by pid (`.pre-push.install-git-hooks.$$`). R3 leaves it alone.
- The "Runs" section above was written before any run. The R3 runs here are the ones on record for this
round.
## R4, the lead's review of R3 (2026-11-02)
The lead's implementation sub-agent (Claude Opus 5.5, a child of Sage's T3 c5c1e8ba) wrote this round from
the brief `/mnt/storage/scratch/mosaic-lead/briefs/row211-r4-fix.md` and the review
`/mnt/storage/scratch/mosaic-lead/reviews/row211-r3-gpt.md` (R1 to R3, Check 2 and Check 5). Base 09a847eda,
branch `t3/row211-push-guard-r4-fix`: 5734c1a80, c8f2cb70c and 4f36e3c2e picked clean as bcd74ec33,
3a121885c and 7d03f212d, then one R4 commit. The rule for the round: an error, a denied read or a doubt ends
in exit 2 with no CLEAR line. The suite goes from 85 cases to 105 and the mutants from 21 to 30. The
sections above stay as written; where R4 changes them, it says so here.
### The gone check (R1 and R2)
Both findings come down to one question: has this pid gone, or can the scan just not see it? `gone ROOT PID`
in `slot-busy.sh` runs `ls -f -- ROOT` and is true only when that listing succeeds and PID isn't one of its
names. It can't read a denied lookup as gone:
- When the lookup failed because ROOT lost read or search permission, `ls` can't open ROOT, exits 2, and
`gone` is false.
- When ROOT still lists but the pid's entry can't be read (a pid directory or cmdline at mode 000, or ROOT
readable but not searchable), the name is still in the listing, so `gone` is false. `ls -f` lists in
directory order without a stat of each name, so a name whose lookup is denied still appears.
- `gone` is true only when the kernel's own listing of ROOT lacks the name. The name is compared with bash
string matching on the captured listing, not through a pipe, so a pipe failure can't become "absent".
- `/proc` lists only thread group leaders. The scan's pids come from a listing of the same root, and an
ancestor's PPid is a thread group id, so the second listing compares like with like.
### R1: every ancestor read counts
Was: the walk read each ancestor's PPid with awk and ended on any failure (`|| p=""`). Only the scan's own
read was checked, so a failed read further up dropped the rest of the ancestors without an error.
Fix: every ancestor read must succeed and give a numeric PPid. On a failed read or a bad PPid the walk ends
only when `gone /proc PID` confirms the ancestor exited. Anything else prints "cannot read the status of
ancestor PID" and exits 2. The loop runs to PPid 0; `[ -n "$p" ]` went, since p is always numeric now. The
scan's own read is unchanged.
Tests: `awkn`, an awk shim that is the real awk on call 1 (the scan's own status) and then fails ("an
ancestor's status read that fails after its own read passed: exit 2, no CLEAR"), prints nothing ("an
ancestor's status with no PPid: exit 2, no CLEAR"), or on call 2 prints 4194999, above pid_max, which `/proc`
never lists ("an ancestor that has exited (its pid gone from /proc) ends the walk: exit 0 and CLEAR", the
control for the allowed path). Each checks the shim ran at least twice, three times for the exited case, so
the failing read is an ancestor's and not the scan's own.
Mutants: `ancestor-read-unchecked` (the error becomes `break`) and `exited-ancestor-error` (no gone check, so
an exited ancestor is an error). Both killed.
### R2: a denied lookup isn't a vanished process
Was: after a failed cmdline read, `[ -e "$d" ] || continue` counted the process as exited. `[ -e ]` is false
for a missing path and for a denied lookup alike. The loop also opened with `[ -d "$d" ] || continue`, the
same flaw before the read.
Fix: after a failed read, `gone "$PROC" "$pid" && continue`; anything else is an error and exit 2. The
`[ -d ]` line is gone. The glob's literal when nothing matches fails the numeric pid test, and every listed
pid now goes through the read and the gone check.
The read is now `{ tr '\0' ' ' < "$d/cmdline"; } 2>/dev/null`. bash prints its own "No such file or
directory" for a failed `<` before the `2>/dev/null` on the same command applies. Without the `[ -d ]` line
that message showed on the real `/proc` for processes that exited mid-scan. The braces send it to
`/dev/null`. The error count and the exit code never depended on it.
Tests: `denytr`, a tr shim that sets the fake proc root to mode 000 at the read of pid 4194450 (a
`node --test`) and fails: "a lookup denied after the listing (proc root mode 000): exit 2, no CLEAR", with
the control "the denied pid directory and its node --test cmdline were still there". `locktr` sets the root to
000 while 4194451 is read and lets that read pass; 4194452, a `node --test`, then can't be opened: "a proc
root that turns unreadable after one read hides no later process: exit 2, no CLEAR". Each test gives the mode
back right after its run, and the suite's cleanup runs `chmod 0755` on every directory in `LOCKED` before
`rm -rf`.
Mutants: `denied-lookup-vanished` (back to `[ -e "$d" ] || continue`), `denied-listing-gone` (a failed
listing counts as gone) and `dir-check-skips` (the `[ -d ]` line back before the read). All killed.
Two R3 mutants had the removed `[ -e "$d" ] || continue` line as their site. They now name the gone line,
with the same intent: `unreadable-read-clear` skips every failed read, and `vanished-is-error` makes a
confirmed exit an error. `snapshot-matched` now deletes the new snapshot line, which removes the whole wrapper
exclusion as before. `snapshot-substring`'s site is unchanged.
### R3: a wrapper that sources a test script
Was: every command line in the snapshot-wrapper shape was skipped. A wrapper that sourced a test into its own
shell, with no child, gave CLEAR while the test ran (the review's live probe).
Fix: `match()` still recognizes the wrapper by `RE_SNAPSHOT`, then passes the rest of its text, after the
snapshot source, to `sourced()`. That prints "sourced gate", "sourced mutant" or "sourced test script" when
the text sources one, and fails otherwise. Children's command lines play no part. A wrapper that only runs
other commands, an external node test included, is still skipped, and its child is still matched (F2, F5).
What counts as sourced (`RE_SOURCED`): the word `source` or `.`, at the start of the text or after a blank,
one of `; & | ( ) { }`, a backtick or a quote; then blanks; an optional `--` and blanks; an optional opening
quote; then the path, up to the next blank, shell operator, `<`, `>` or quote. The path, padded with spaces,
goes through the gate rule, the `*mutant*` rule and the test-script rule, in that order. Every such word in
the text is checked, not only the first. That covers `source scripts/test-x.sh`, `. ./gate.sh 253`,
`source "scripts/test-x.sh" arg`, `eval '. ./gate.sh 253'`, `eval 'source .../mutants.sh M3'`,
`builtin source ...` and a test sourced after other files.
False BUSY it can add: the text is read as words, not parsed. Inside a wrapper, `grep x . scripts/test-x.sh`,
`git add . scripts/test-x.sh`, `ls . gate.sh`, `echo source scripts/test-x.sh` and `cat . notes-mutant.txt`
all read as sourcing, and so does the same text in a heredoc or a comment. A false BUSY only delays a gate.
Missed: a path from a variable or a substitution (`source "$f"`, `. "$(pwd)/scripts/test-x.sh"`), a quoted
path with a space in it, a file that sources a test from inside it, and a function or alias that runs one.
Those run in the wrapper's shell too, so they remain a gap (see the limits below).
Tests: rule cases on the wrapper shape: "sourced test script" four times (`source`, `.`, a quoted path with an
argument, and a test after a file that isn't one), "sourced gate" (`eval '. ./gate.sh 253'`) and "sourced
mutant" (`eval 'source agents/x/work/queue-1/mutants.sh M3'`). Fake pid 4194406, a wrapper that sources
`.venv/bin/activate` and `. ./env.sh` and then runs `./gate.sh 253` as a child: "a wrapper that sources other
files and runs a gate as a child isn't a match". The new section "slot-busy, real snapshot wrappers" starts a
live `bash -c "source $SANDBOX/shell-snapshots/snapshot-x.sh && source $SANDBOX/scripts/test-x.sh"` whose
test-x.sh blocks in `read -t 60 -u 3` on a fifo, and confirms its children file reads and is empty: "a live
wrapper that sourced a test script, blocked in a builtin with no child: exit 1", "its BUSY line names the
wrapper's pid, the sourced rule and its cwd" and "the real /proc has a BUSY line for that wrapper too". Its
control is a live wrapper that runs a stand-in `node --test` as a child: "a live wrapper that runs node --test
as a child: exit 1 on the child's BUSY line" and "the wrapper itself has no BUSY line". The suite kills and
reaps both wrappers and the child, and clears `KIDS` only after a confirmed reap.
The first GREEN run found a defect the RED-time cases missed. In `sourced()`, the `RE_GATE` and `RE_TEST`
tests reset `BASH_REMATCH`. After a sourced file that wasn't a rule, the next step read an unset
`BASH_REMATCH[0]`, and the `$(match)` subshell died on `set -u`. 4194406 printed an error line, and a wrapper
that sourced a test after another file would have been skipped. The match is now saved before the rule tests.
The fourth "sourced test script" case was added after RED to pin this, with the `sourced-first-only` mutant.
Mutants: `sourced-test-hidden` (the snapshot branch returns 1 again), `sourced-first-only` (only the first
sourced word is checked) and `dot-source-missed` (`.` dropped from `RE_SOURCED`). All killed.
### Item 4: the mutant runner's exit status
Was: exit 0 after printing "survived" or "NO MATCH".
Fix: `run()` sets `bad=1` on COPY FAILED, NO MATCH, survived, and a suite exit of 124 or 137 (timeout's codes
after TERM and after KILL). The runner ends with `exit "$bad"`. A red base still stops it with exit 3. Every
output line reads as before; the killed line takes its exit code from a saved `rc`.
Hand check, in TMPDIR only: two copies of the runner with every `run` line taken out and one line put in
before `exit "$bad"`. A comment-only edit to `slot-busy.sh` printed "comment-only: survived" and the copy
exited 1 (20:07:55Z). A site that isn't in the file printed "no-such-site: NO MATCH" and the copy exited 1
(20:08:02Z). A first try cut the copies short and lost the `exit "$bad"` line, so they exited 0. That was a
fault in the copies, not the runner, and the faithful copies above replaced them. Nothing of the check is
left in the runner.
### Item 5: the installer's temporary file
Was: `tmp="$HOOKS/.pre-push.install-git-hooks.$$"`, a name known in advance. `cp` writes through a symlink
planted there.
Fix: `stage()`, called just before each copy (the `--force-install` replacement and the new install), makes the
file with `mktemp "$HOOKS/.pre-push.install-git-hooks.XXXXXX"` (O_EXCL, a random name), then copies the hook
into it and sets 0755. It runs after both `own_hooks` checks and the hook-symlink refusal. A failed `mktemp`
is exit 1, "cannot create a temporary file in HOOKS". The refusals and their order are as before, and a
refusal now creates no file at all.
Test: a fresh clone with a symlink at `.git/hooks/.pre-push.install-git-hooks.<pid>` pointing to a victim
file, and the installer started by `exec` from a subshell whose `$BASHPID` is that pid: "the install writes
nothing through a link planted at a pid-named temporary path" and "the installed hook is a regular file with
the repository's bytes".
Mutant: `installer-fixed-tmp` (the pid name back). Killed.
### Item 6: a correction to known limit 2 after R3
That line says the resolved-path refusal in `own_hooks` would need a bind mount to test. That is wrong.
`cd -P` and `pwd -P` resolve symlinks only. A bind mount shows the same path, so the comparison passes a
bind-mounted hooks directory; it can't be tested with a bind mount because it doesn't catch one. Once
`[ ! -L "$HOOKS" ]` has passed, `cd -P "$HOOKS"` resolves the same parents as `cd -P "$common"` plus
`/hooks`, so the comparison fires only if the path changes between the two checks. The R3 text stays as it
was written; this entry supersedes it.
### What didn't change
`scripts/git-hooks/pre-push` is untouched. Empty stdin passes, a new branch and a fast-forward pass, deletes
and non-fast-forwards are refused, an existing tag can't change, and a remote tip missing locally is refused
with fetch advice and no fetch. Those cases pass unchanged in GREEN. `docs/TOOLS.md` now gives 105 cases,
the sourced-wrapper rule, the gone condition on exit 2 and the installer's `mktemp` file.
### R4 runs
`TMPDIR` was a fresh `mktemp -d /mnt/storage/scratch/tmp/XXXXXX`. Every run was in the foreground under
`timeout -k 10 1800`, with output to files there.
- RED, all new tests in place and no fix, 19:57:25Z to 19:57:37Z: 90 passed, 14 failed. The 14: the two
installer temporary-file cases, five sourced rule cases, two ancestor cases (fail, empty), two denied-lookup
cases and the three live sourced-wrapper cases. The five new cases that pass there are controls that must
hold before and after: 4194406, the exited ancestor, the denied pid directory still there, and the external
node wrapper's two cases.
- GREEN, first run, 20:01:11Z to 20:01:25Z: 101 passed, 3 failed ("no matching process: exit 0 and CLEAR",
"a process that vanishes during its read isn't an error: exit 0 and CLEAR" and "an ancestor that has exited
(its pid gone from /proc) ends the walk: exit 0 and CLEAR"). All three share the fake root that holds
4194406, whose error line from the `BASH_REMATCH` defect above broke the exact CLEAR output. Fixed, and one
rule case added.
- GREEN, 20:02:40Z to 20:02:49Z: 105 passed, 0 failed.
- Mutants, 20:02:56Z to 20:06:58Z: base green (105/0), runner exit 0, 30 of 30 killed, none survived, no NO
MATCH, no timeout. Every kill exited 1.
| Mutant | FAIL | First failing case |
|---|---|---|
| ancestor-dropped | 8 | push --force of a rewritten branch is refused |
| unknown-object-passes | 2 | a push over a remote tip missing locally is refused, saying fetch first |
| delete-passes | 13 | the install into a clean clone exits 0 |
| install-over-hookspath | 2 | it says core.hooksPath is set |
| busy-exits-0 | 23 | a node --test process: exit 1 |
| differing-hook-replaced | 3 | the install refuses a different installed pre-push (exit 2) |
| replace-in-place | 1 | the replacement was a rename: the old file keeps its bytes |
| symlink-followed | 1 | the install refuses a symlinked pre-push, even with --force-install |
| ancestor-error-passes | 1 | a failed ancestor check (an unknown local object) is refused |
| snapshot-matched | 14 | no matching process: exit 0 and CLEAR |
| ancestors-matched | 1 | this suite, an ancestor, has no BUSY line |
| no-node-rule | 10 | a node --test process: exit 1 |
| no-compose-rule | 2 | busy on: docker compose -p x up -d (docker compose) |
| unreadable-read-clear | 8 | a pid directory with no cmdline: exit 2 |
| vanished-is-error | 3 | a process that vanishes during its read isn't an error: exit 0 and CLEAR |
| read-errors-ignored | 8 | a pid directory with no cmdline: exit 2 |
| self-read-unchecked | 1 | its own process unreadable (awk exits 1): exit 2, no CLEAR |
| snapshot-substring | 2 | busy on: node --test /seat/tests/shell-snapshots/snapshot-guard.test.mjs (node --test) |
| tag-replace-passes | 4 | push --force of a new tag object on the same commit is refused |
| hooks-dir-symlink-followed | 3 | the install refuses a symlinked hooks directory (exit 2) |
| short-sha-accepted | 2 | a remote object name of 0 is refused, not read as a new ref |
| ancestor-read-unchecked | 2 | an ancestor's status read that fails after its own read passed: exit 2, no CLEAR |
| exited-ancestor-error | 1 | an ancestor that has exited (its pid gone from /proc) ends the walk: exit 0 and CLEAR |
| denied-lookup-vanished | 2 | a lookup denied after the listing (proc root mode 000): exit 2, no CLEAR |
| denied-listing-gone | 2 | a lookup denied after the listing (proc root mode 000): exit 2, no CLEAR |
| dir-check-skips | 1 | a proc root that turns unreadable after one read hides no later process: exit 2, no CLEAR |
| sourced-test-hidden | 9 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source /seat |
| sourced-first-only | 1 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source .venv |
| dot-source-missed | 3 | busy on: /bin/bash -c source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && . /seat/scri |
| installer-fixed-tmp | 2 | the install writes nothing through a link planted at a pid-named temporary path |
The runner cuts each first failing case at 100 characters, which is why three of them end mid-word.
- Real host: `bash scripts/slot-busy.sh` on the real `/proc` exited 1 on other seats' runs, which the brief
expects. Before the brace fix it also printed bash's "No such file or directory" for processes that exited
mid-scan; after it, none.
- Pins: `git grep -nE '[0-9a-f]{64}' -- 'packages/*/tests/*' 'scripts/test-*.sh'` gives 33 lines (the
positive control). None names a path this round changes, and none holds the old or new sha256 or blob id of
the four changed scripts. No test reads `docs/TOOLS.md` or this file.
### Known limits after R4
- `git push --no-verify` skips the hook. Gitea branch protection is the guard that holds (unchanged).
- The busy rules read command lines as text. They can call a process BUSY that isn't a run, the sourced rule
adds the false BUSY cases above, and a test sourced by a path built at run time, by a nested file or by a
function is still missed.
- A bind-mounted hooks directory isn't detected (item 6). A mount-point test, such as the hooks directory's
st_dev against its parent's, would catch one. Setting one up needs root. Not this round.
- The installer has a check-then-use window after its path checks, as the review's Check 5 says. An actor who
can replace repository directories in it already controls the hooks.
- A BUSY line holds the command line as it is, so a wrapper whose `-c` text has newlines prints over several
output lines. Chain on the exit code, never on the lines (unchanged advice).
- The scan sees only what `/proc` lists to it. Under `hidepid=2`, another user's processes aren't listed and
can't make it BUSY. This host's `/proc` has no hidepid option.
+25 -7
View File
@@ -6,6 +6,9 @@
# scripts/test-git-guard.sh under a 180 s timeout. The worktree's files are
# never edited. "killed" means the suite failed; "survived" means it passed.
# The unmutated suite runs first, and a red base stops the run (exit 3).
# Exit 0 only when every mutant is killed; 1 when any survives, has NO MATCH
# or COPY FAILED, or times out (suite exit 124 or 137, still printed as
# killed with that exit code).
set -u
LOG=${LOG:-.}
FILES="scripts/git-hooks/pre-push scripts/install-git-hooks.sh scripts/slot-busy.sh scripts/test-git-guard.sh"
@@ -41,16 +44,20 @@ else
exit 3
fi
bad=0
run() { # $1: name, $2: file, $3: from, $4: to
local name=$1 file=$2 t log
t=$(tree "$name") || { echo "$name: COPY FAILED"; return; }
if ! edit "$t/$file" "$3" "$4"; then echo "$name: NO MATCH"; return; fi
local name=$1 file=$2 t log rc
t=$(tree "$name") || { echo "$name: COPY FAILED"; bad=1; return; }
if ! edit "$t/$file" "$3" "$4"; then echo "$name: NO MATCH"; bad=1; return; fi
if [ -n "${DRY:-}" ]; then echo "$name: match"; return; fi
log="$LOG/mut-$name.log"
if suite "$t" "$log"; then
echo "$name: survived"
bad=1
else
echo "$name: killed (exit $?, $(grep -c '^FAIL' "$log") FAIL; first: $(grep -m1 '^FAIL' "$log" | cut -c6-100))"
rc=$?
echo "$name: killed (exit $rc, $(grep -c '^FAIL' "$log") FAIL; first: $(grep -m1 '^FAIL' "$log" | cut -c6-100))"
case $rc in 124|137) bad=1 ;; esac
fi
}
H=scripts/git-hooks/pre-push
@@ -68,16 +75,27 @@ 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 return 1; fi\n' ''
run snapshot-matched $S $' if [[ "$c" =~ $RE_SNAPSHOT ]]; then sourced "${c:${#BASH_REMATCH[0]}}"; 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' ':'
# R3, the GPT review (F1 to F5 and the object-name length check).
run unreadable-read-clear $S ' [ -e "$d" ] || continue' ' continue'
run vanished-is-error $S $' [ -e "$d" ] || continue\n' ''
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 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'
# R4 (the lead's review of R3: R1 to R3 and the installer's temporary file).
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 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 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.$$"'
exit "$bad"
+1
View File
@@ -1881,3 +1881,4 @@ are never rewritten or removed; corrections are new entries.
2026-10-26 | Sage | Mos1909/1915/1917 real postlanding reviews | Boyd/Dewey/Filbert/Gladstone real authored candidate52ba verdictfiles independently byte/hash-verified, allAPPROVE scoped; Dewey656broaderPARTIAL retained. Formal330R1/331R4 requests failedpre-send/no comments or approvals; no borrowed/copied/mintedkeys. LegacyhelperGETname jason.woltje is NOT ownJarvis, ownslotabsent; rows tracking-open/#436, files cited via normalqueue. NewFilbertNEW-330-01 filedtask665 exactreadback, notlandingreopen/gate. Repair646-648/652-656 remainOPEN untilactualcomments;1917 conditionalbacklog routing retained. #662scope1338posted;660PR405REVISE/notready. Product1968inputs unchanged; no source/helper/test/gate edits/runs; peerjournal prefixes preserved.
2026-11-02 | Sage implementation sub-agent (Claude Opus 5.5, child of T3 c5c1e8ba) | row 211 (#1709/#426) R3 from the GPT review, on t3/row211-push-guard-r3-fix over 09a847eda (picks 5734c1a80, c8f2cb70c) | F1-F5 and the object-name length check fixed with tests; suite RED 67/18, GREEN 85/0; 21 of 21 mutants killed; one local commit, not pushed
2026-11-02 | Sage implementation sub-agent (Claude Opus 5.5, child of T3 c5c1e8ba) | row 211 (#1709/#426) R4 from the GPT review of R3, on t3/row211-push-guard-r4-fix over 09a847eda (picks bcd74ec33, 3a121885c, 7d03f212d) | R1-R3, runner exit status and installer mktemp fixed with tests; suite RED 90/14, GREEN 105/0; 30 of 30 mutants killed, runner exit 0; one local commit, not pushed
+10 -6
View File
@@ -166,9 +166,9 @@ resolved through the CLI.
| `scripts/test-conductor.sh` | Auto-apply selftests (sandboxed) | 17 cases |
| `scripts/test-auth.sh` | Auth checkpoint selftests (no Docker) | 13 cases |
| `scripts/test-land-work.sh` | Land-work selftests (sandboxed, fake Docker) | 84 cases |
| `scripts/test-git-guard.sh` | Push guard, its installer and `slot-busy.sh` (scratch bare remote, fake `/proc`) | 85 cases |
| `scripts/test-git-guard.sh` | Push guard, its installer and `slot-busy.sh` (scratch bare remote, fake `/proc`) | 105 cases |
| `scripts/install-git-hooks.sh [--repo DIR] [--force-install]` | Install the push guard as `pre-push` in the common `.git/hooks` | Refuses a set `core.hooksPath`, a symlinked hooks directory or a different installed hook; exit 0 ok · 1 failed · 2 refused · 4 usage |
| `scripts/slot-busy.sh` | Busy check: one `BUSY` line per gate or test process, or `CLEAR` | Exit 0 CLEAR · 1 BUSY · 2 error, including a process it can't read; chain on the exit code |
| `scripts/slot-busy.sh` | Busy check: one `BUSY` line per gate or test process, or `CLEAR` | Exit 0 CLEAR · 1 BUSY · 2 error, including a process or an ancestor it can't read that isn't confirmed gone; chain on the exit code |
| `scripts/gitea-api.sh <METHOD> <path> [body]` | Gitea API helper; reads the JSON credential file or a raw per-seat token file | Token never on argv/stdout |
### Push guard and busy check (row 211, #426)
@@ -183,7 +183,8 @@ and says to fetch first.
`git push --no-verify` skips it, so Gitea's branch protection (ops-01) is the
guard that holds. `scripts/install-git-hooks.sh` copies it into the common
`.git/hooks`, which every worktree of the checkout shares, refusing a hooks
directory that is a symlink, then runs a canary
directory that is a symlink. It stages the bytes in a new file that `mktemp`
makes in that directory, then runs a canary
through `git hook run`. Installing it in the canonical checkout is a step for
the lead after the row lands.
@@ -198,9 +199,12 @@ Never `bash scripts/slot-busy.sh | tail -1 && gate`: `tail` exits 0, so the
gate runs while BUSY. It matches `node ... --test`, a `*gate*.sh`, `mutant`, a
`test-*.sh` and `docker compose` command lines, skips itself, its ancestors
and agent shell-snapshot wrappers (a shell whose `-c` text starts by sourcing
`.../shell-snapshots/snapshot-*.sh`), and reads only `/proc/<pid>/cmdline`, the
cwd link and its own and its ancestors' `status`. A process it can't read that
hasn't exited makes it exit 2, never CLEAR.
`.../shell-snapshots/snapshot-*.sh`). A wrapper whose text also sources
(`source` or `.`) a gate, test or mutant script is BUSY, because that script
runs in the wrapper's own shell. It reads only `/proc/<pid>/cmdline`, the cwd
link, its own and its ancestors' `status` and, after a failed read, the proc
root's listing. A process or an ancestor it can't read makes it exit 2, never
CLEAR, unless a listing of the proc root succeeds and no longer has that pid.
## Seat launch with registration (`scripts/mosaic`)
+12 -5
View File
@@ -11,8 +11,10 @@
# resolves anywhere but the common directory's hooks, and when a different
# pre-push is installed, unless --force-install is given. It never edits an
# installed hook in place:
# a new hook is linked in, a replacement is renamed over the old one. A canary
# then runs the installed hook through git and needs a delete refused.
# a new hook is linked in, a replacement is renamed over the old one. The
# bytes go first into a new file that mktemp makes in the hooks directory, so
# nothing planted there in advance is written through. A canary then runs the
# installed hook through git and needs a delete refused.
#
# Exit codes: 0 installed (or already the same bytes); 1 failed; 2 refused;
# 4 usage. Installing in the canonical checkout is its own step with the lead.
@@ -71,7 +73,12 @@ own_hooks
if [ -L "$HOOK" ]; then die 2 "refused: $HOOK is a symlink; remove it by hand if it is not wanted"; fi
mkdir -p "$HOOKS" || die 1 "cannot create $HOOKS"
own_hooks
tmp="$HOOKS/.pre-push.install-git-hooks.$$"
# The hook's bytes in a new file that mktemp creates (O_EXCL, a random name)
# in the checked hooks directory, made only once a copy is due.
stage() {
tmp=$(mktemp "$HOOKS/.pre-push.install-git-hooks.XXXXXX") || die 1 "cannot create a temporary file in $HOOKS"
cp -- "$SRC" "$tmp" && chmod 0755 "$tmp" || { rm -f -- "$tmp"; die 1 "cannot write $tmp"; }
}
if [ -e "$HOOK" ]; then
[ -f "$HOOK" ] || die 2 "refused: $HOOK is not a regular file"
if cmp -s "$SRC" "$HOOK"; then
@@ -79,13 +86,13 @@ if [ -e "$HOOK" ]; then
note="the same bytes were already there"
else
[ "$FORCE" = 1 ] || die 2 "refused: a different pre-push hook is installed at $HOOK; compare it with $SRC, and pass --force-install to replace it"
cp -- "$SRC" "$tmp" && chmod 0755 "$tmp" || { rm -f -- "$tmp"; die 1 "cannot write $tmp"; }
stage
# rename() swaps the whole file; the old hook's bytes are never edited.
mv -f -- "$tmp" "$HOOK" || { rm -f -- "$tmp"; die 1 "cannot replace $HOOK"; }
note="replaced a different hook (--force-install)"
fi
else
cp -- "$SRC" "$tmp" && chmod 0755 "$tmp" || { rm -f -- "$tmp"; die 1 "cannot write $tmp"; }
stage
# link() never replaces, so a hook that appeared meanwhile is kept.
if ! ln -- "$tmp" "$HOOK" 2>/dev/null; then rm -f -- "$tmp"; die 2 "refused: a pre-push hook appeared at $HOOK while installing"; fi
rm -f -- "$tmp"
+52 -17
View File
@@ -6,9 +6,10 @@
# scripts/slot-busy.sh [--proc-root DIR]
#
# 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 it can't read
# that hasn't exited (it still prints any BUSY lines, never CLEAR). Chain it
# on the exit code, never on a filter of its output:
# 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:
# 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.
#
@@ -18,9 +19,12 @@
# 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. It reads only /proc/<pid>/cmdline,
# the cwd link and its own and its ancestors' /proc/<pid>/status, never a
# file under a seat's directory.
# 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.
# --proc-root names a stand-in for /proc in the selftests.
set -uo pipefail
@@ -34,16 +38,29 @@ while [ $# -gt 0 ]; do
done
[ -d "$PROC" ] || { echo "slot-busy: error: $PROC is not a directory" >&2; exit 2; }
# This process and its ancestors, from the real /proc. Its own status must
# read and name a parent; a failed read further up only ends the walk, which
# can give a false BUSY but never a false CLEAR.
# 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.
gone() {
local names
names=$(ls -f -- "$1" 2>/dev/null) || return 1
[[ $'\n'"$names"$'\n' != *$'\n'"$2"$'\n'* ]]
}
# This process and its ancestors, from the real /proc. Every status read must
# give a numeric PPid. An ancestor that exited (confirmed gone) ends the walk;
# any other failed read is an error, never a shorter walk.
declare -A SKIP=()
p=$(awk '/^PPid:/{print $2; exit}' "/proc/$$/status" 2>/dev/null) && [[ "$p" =~ ^[0-9]+$ ]] \
|| { echo "slot-busy: error: cannot read its own process" >&2; exit 2; }
SKIP[$$]=1
while [ -n "$p" ] && [ "$p" != 0 ]; do
while [ "$p" != 0 ]; do
SKIP[$p]=1
p=$(awk '/^PPid:/{print $2; exit}' "/proc/$p/status" 2>/dev/null) || p=""
if ! q=$(awk '/^PPid:/{print $2; exit}' "/proc/$p/status" 2>/dev/null) || ! [[ "$q" =~ ^[0-9]+$ ]]; then
gone /proc "$p" && break
echo "slot-busy: error: cannot read the status of ancestor $p" >&2; exit 2
fi
p=$q
done
RE_NODE='[ /]node[^ ]* (.* )?--test '
@@ -53,9 +70,25 @@ 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.
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
return 1
}
match() { # $1: the command line, NULs as spaces. Prints the rule that matched.
local c=" $1 "
if [[ "$c" =~ $RE_SNAPSHOT ]]; then return 1; fi
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
@@ -67,17 +100,19 @@ match() { # $1: the command line, NULs as spaces. Prints the rule that matched.
seen=0
busy=0
errors=0
# No directory test before the read: `[ -d ]` is false for a denied lookup
# too, so every listed pid goes through the read and the gone check.
for d in "$PROC"/[0-9]*; do
[ -d "$d" ] || continue
pid=${d##*/}
[[ "$pid" =~ ^[0-9]+$ ]] || continue
seen=$((seen + 1))
[ -z "${SKIP[$pid]:-}" ] || continue
# A process can exit between the listing and the read; that's not an error.
# A failed read while its directory is still there is: the scan can't say
# what that process runs.
if ! cmd=$(tr '\0' ' ' < "$d/cmdline" 2>/dev/null); then
[ -e "$d" ] || continue
# A failed read is, unless a fresh listing confirms the pid is gone: the
# scan can't say what that process runs.
# The braces send the shell's own message for a failed `<` to /dev/null too.
if ! cmd=$( { tr '\0' ' ' < "$d/cmdline"; } 2>/dev/null); then
gone "$PROC" "$pid" && continue
echo "slot-busy: error: cannot read $d/cmdline" >&2
errors=$((errors + 1))
continue
+147
View File
@@ -14,8 +14,12 @@ unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY G
SANDBOX="$(cd "$(mktemp -d)" && pwd -P)"
STANDIN=""
KIDS="" # wrapper shells and their children that the suite starts
LOCKED="" # directories a test makes unreadable; cleanup gives modes back
cleanup() {
if [ -n "$STANDIN" ]; then kill "$STANDIN" 2>/dev/null; wait "$STANDIN" 2>/dev/null; fi
for k in $KIDS; do kill "$k" 2>/dev/null; wait "$k" 2>/dev/null; done
for l in $LOCKED; do chmod 0755 "$l" 2>/dev/null; done
rm -rf "$SANDBOX"
}
trap cleanup EXIT
@@ -208,6 +212,17 @@ out=$(bash "$INSTALL" --repo "$SANDBOX/sl" --force-install 2>&1); rc=$?
check "it refuses it with --force-install too" "$([ "$rc" = 2 ] && has "$out" "/.git/hooks is a symlink"; echo $?)"
check "nothing was written in the directory the symlink names" "$([ -z "$(ls -A "$SANDBOX/shared-hooks")" ]; echo $?)"
# The temporary copy's name isn't predictable from the installer's pid. The
# subshell plants a link at the pid-named path, then execs the installer, so
# the installer's $$ is that pid.
fresh "$SANDBOX/pt"
printf 'victim\n' > "$SANDBOX/pt-victim"
out=$(pid=$BASHPID; ln -s "$SANDBOX/pt-victim" "$SANDBOX/pt/.git/hooks/.pre-push.install-git-hooks.$pid" && exec bash "$INSTALL" --repo "$SANDBOX/pt" 2>&1); rc=$?
check "the install writes nothing through a link planted at a pid-named temporary path" \
"$([ "$rc" = 0 ] && [ "$(cat "$SANDBOX/pt-victim")" = victim ]; echo $?)"
check "the installed hook is a regular file with the repository's bytes" \
"$([ -f "$SANDBOX/pt/.git/hooks/pre-push" ] && [ ! -L "$SANDBOX/pt/.git/hooks/pre-push" ] && cmp -s "$HOOK_SRC" "$SANDBOX/pt/.git/hooks/pre-push"; echo $?)"
fresh "$SANDBOX/wd"
git -C "$SANDBOX/wd" worktree add -q -b seat/x "$SANDBOX/wd-wt" origin/main
out=$(bash "$INSTALL" --repo "$SANDBOX/wd-wt" 2>&1); rc=$?
@@ -236,10 +251,14 @@ fakeproc 4194402 /srv grep -r gate .
# Both wrappers' text would match a rule without the snapshot exclusion.
fakeproc 4194403 /srv /bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && node --test x.test.mjs"
fakeproc 4194405 /srv /usr/bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-2-ab.sh 2>/dev/null || true && eval 'bash scripts/test-x.sh > x.log' && pwd -P >| /home/u/cwd"
# A wrapper that sources files that aren't gates, tests or mutants, then
# runs a gate as a child: the child is matched, the wrapper isn't.
fakeproc 4194406 /srv /bin/bash -c "source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh && source .venv/bin/activate && . ./env.sh && ./gate.sh 253"
mkdir -p "$P/4194404"; : > "$P/4194404/cmdline"
out=$(busyrun); rc=$?
check "no matching process: exit 0 and CLEAR" "$([ "$rc" = 0 ] && [ "$out" = CLEAR ]; echo $?)"
check "an agent's shell-snapshot wrapper isn't a match" "$(! has "$out" "4194403" && ! has "$out" "4194405"; echo $?)"
check "a wrapper that sources other files and runs a gate as a child isn't a match" "$(! has "$out" "4194406"; echo $?)"
fakeproc 4194410 /seat/a node --test packages/x/tests/a.test.mjs
out=$(busyrun); rc=$?
@@ -268,6 +287,16 @@ rule "docker compose" /usr/bin/docker-compose up
rule "node --test" /bin/bash -c "node --test x.test.mjs"
rule "node --test" node --test /seat/tests/shell-snapshots/snapshot-guard.test.mjs
rule "gate" bash /seat/shell-snapshots/snapshot-gate.sh
# A wrapper that sources a gate, test or mutant script runs it in its own
# shell, with no child to match, so the wrapper itself is BUSY.
SNAP="source /home/u/.claude/shell-snapshots/snapshot-bash-1.sh"
rule "sourced test script" /bin/bash -c "$SNAP && source /seat/scripts/test-x.sh"
rule "sourced test script" /bin/bash -c "$SNAP && . /seat/scripts/test-x.sh"
rule "sourced test script" /bin/bash -c "$SNAP && source \"scripts/test-x.sh\" arg"
# A sourced file that isn't one comes first; the one after it still counts.
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'"
rm -rf "${P:?}/4194420"
echo "== slot-busy, read errors =="
@@ -315,6 +344,77 @@ out=$(PATH="$SANDBOX/failawk:$PATH" bash "$BUSY" --proc-root "$P" 2>&1); rc=$?
check "its own process unreadable (awk exits 1): exit 2, no CLEAR" \
"$([ "$rc" = 2 ] && has "$out" "cannot read its own process" && ! has "$out" CLEAR; echo $?)"
# Every ancestor's status read counts, not only the first. The awk below is
# the real awk for call 1 (the scan's own status) and then fails or prints
# nothing; a third one prints a pid that isn't in /proc on call 2, so the
# walk meets an ancestor that has exited.
mkdir -p "$SANDBOX/awkn"
cat > "$SANDBOX/awkn/awk" <<EOF
#!/bin/sh
n=\$(cat "$SANDBOX/awkn/n" 2>/dev/null || echo 0); n=\$((n + 1)); echo "\$n" > "$SANDBOX/awkn/n"
if [ "\$n" -ge 2 ]; then
case \$(cat "$SANDBOX/awkn/mode") in
fail) exit 1 ;;
empty) exit 0 ;;
exited) if [ "\$n" = 2 ]; then echo 4194999; exit 0; fi ;;
esac
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=$?
calls=$(cat "$SANDBOX/awkn/n" 2>/dev/null || echo 0)
}
awkn fail
check "an ancestor's status read that fails after its own read passed: exit 2, no CLEAR" \
"$([ "$calls" -ge 2 ] && [ "$rc" = 2 ] && has "$out" "cannot read the status of ancestor" && ! has "$out" CLEAR; echo $?)"
awkn empty
check "an ancestor's status with no PPid: exit 2, no CLEAR" \
"$([ "$calls" -ge 2 ] && [ "$rc" = 2 ] && has "$out" "cannot read the status of ancestor" && ! has "$out" CLEAR; echo $?)"
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 $?)"
# 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.
DP="$SANDBOX/dproc"
mkdir -p "$SANDBOX/denytr" "$DP/4194450"
printf '%s\0' node --test d.test.mjs > "$DP/4194450/cmdline"
cat > "$SANDBOX/denytr/tr" <<EOF
#!/bin/sh
f=\$(readlink "/proc/\$\$/fd/0")
case "\$f" in */4194450/cmdline) chmod 000 "$DP"; exit 1 ;; esac
exec $(command -v tr) "\$@"
EOF
chmod +x "$SANDBOX/denytr/tr"
LOCKED="$LOCKED $DP"
out=$(PATH="$SANDBOX/denytr:$PATH" bash "$BUSY" --proc-root "$DP" 2>&1); rc=$?
denied=$([ ! -r "$DP" ] && echo yes); chmod 0755 "$DP"
check "a lookup denied after the listing (proc root mode 000): exit 2, no CLEAR" \
"$([ "$denied" = yes ] && [ "$rc" = 2 ] && has "$out" "cannot read $DP/4194450/cmdline" && ! has "$out" CLEAR; echo $?)"
check "the denied pid directory and its node --test cmdline were still there" \
"$([ "$(tr '\0' ' ' < "$DP/4194450/cmdline")" = "node --test d.test.mjs " ]; echo $?)"
# The root turns unreadable after the first pid's read went through: the next
# pid, a node --test, is a doubt, not a skip.
rm -rf "${DP:?}/4194450"
mkdir -p "$SANDBOX/locktr" "$DP/4194451" "$DP/4194452"
printf '%s\0' sleep 60 > "$DP/4194451/cmdline"
printf '%s\0' node --test l.test.mjs > "$DP/4194452/cmdline"
cat > "$SANDBOX/locktr/tr" <<EOF
#!/bin/sh
f=\$(readlink "/proc/\$\$/fd/0")
case "\$f" in */4194451/cmdline) chmod 000 "$DP" ;; esac
exec $(command -v tr) "\$@"
EOF
chmod +x "$SANDBOX/locktr/tr"
out=$(PATH="$SANDBOX/locktr:$PATH" bash "$BUSY" --proc-root "$DP" 2>&1); rc=$?
denied=$([ ! -r "$DP" ] && echo yes); chmod 0755 "$DP"
check "a proc root that turns unreadable after one read hides no later process: exit 2, no CLEAR" \
"$([ "$denied" = yes ] && [ "$rc" = 2 ] && has "$out" "cannot read $DP/4194452/cmdline" && ! has "$out" CLEAR; echo $?)"
out=$(bash "$BUSY" --proc-root "$SANDBOX/no-such-proc" 2>&1); rc=$?
check "a missing proc root: exit 2" "$([ "$rc" = 2 ]; echo $?)"
mkdir -p "$SANDBOX/empty-proc"
@@ -350,6 +450,53 @@ out=$(bash "$BUSY" 2>&1); rc=$?
check "after the stand-in exits, it has no BUSY line" "$(! printf '%s\n' "$out" | grep -q "^BUSY $gone "; echo $?)"
check "the real-/proc run exits 0 or 1, never 2" "$([ "$rc" = 0 ] || [ "$rc" = 1 ]; echo $?)"
echo "== slot-busy, real snapshot wrappers =="
# Live wrappers, each shown to the check through a proc root that links only
# its own real /proc directories, so other jobs on the host don't count.
mkdir -p "$SANDBOX/shell-snapshots" "$SANDBOX/scripts" "$SANDBOX/wrap"
: > "$SANDBOX/shell-snapshots/snapshot-x.sh"
mkfifo "$SANDBOX/wrap/fifo"
# Sourced: it says it's ready, then blocks in the read builtin, no child.
cat > "$SANDBOX/scripts/test-x.sh" <<EOF
exec 3<>"$SANDBOX/wrap/fifo"
: > "$SANDBOX/wrap/ready"
read -t 60 -u 3 _
EOF
(cd "$SANDBOX/wrap" && exec bash -c "source $SANDBOX/shell-snapshots/snapshot-x.sh && source $SANDBOX/scripts/test-x.sh") &
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/lproc1"; mkdir -p "$LP"; ln -s "/proc/$WRAP" "$LP/$WRAP"
out=$(bash "$BUSY" --proc-root "$LP" 2>&1); rc=$?
check "a live wrapper that sourced a test script, blocked in a builtin with no child: exit 1" \
"$([ -e "$SANDBOX/wrap/ready" ] && [ "$kids" = none ] && [ "$rc" = 1 ] && ! has "$out" CLEAR; echo $?)"
check "its BUSY line names the wrapper's pid, the sourced rule and its cwd" \
"$(printf '%s\n' "$out" | grep -q "^BUSY $WRAP sourced test script cwd=$SANDBOX/wrap "; echo $?)"
out=$(bash "$BUSY" 2>&1); rc=$?
check "the real /proc has a BUSY line for that wrapper too" \
"$([ "$rc" != 0 ] && printf '%s\n' "$out" | grep -q "^BUSY $WRAP sourced test script "; echo $?)"
kill "$WRAP" 2>/dev/null; wait "$WRAP" 2>/dev/null; KIDS=""
# External: the wrapper runs node --test as a child (`; :` keeps bash from
# exec'ing it). The wrapper is skipped and its child is BUSY.
(cd "$SANDBOX/wrap" && exec bash -c "source $SANDBOX/shell-snapshots/snapshot-x.sh && $SANDBOX/bin/node --test active.test.mjs; :") &
WRAP=$!; KIDS="$KIDS $WRAP"; kid=""
for _ in $(seq 50); do
kid=$(cat "/proc/$WRAP/task/$WRAP/children" 2>/dev/null); kid=${kid%% *}
[ -n "$kid" ] && has "$(tr '\0' ' ' < "/proc/$kid/cmdline" 2>/dev/null)" "--test active" && break
sleep 0.1
done
KIDS="$KIDS $kid"
LP="$SANDBOX/lproc2"; mkdir -p "$LP"; ln -s "/proc/$WRAP" "$LP/$WRAP"; [ -n "$kid" ] && ln -s "/proc/$kid" "$LP/$kid"
out=$(bash "$BUSY" --proc-root "$LP" 2>&1); rc=$?
check "a live wrapper that runs node --test as a child: exit 1 on the child's BUSY line" \
"$([ -n "$kid" ] && [ "$rc" = 1 ] && printf '%s\n' "$out" | grep -q "^BUSY $kid node --test "; echo $?)"
check "the wrapper itself has no BUSY line" "$(! printf '%s\n' "$out" | grep -q "^BUSY $WRAP "; echo $?)"
# The wrapper exits by itself once its child has; only then are both gone.
[ -n "$kid" ] && kill "$kid" 2>/dev/null
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
echo "---"
echo "git-guard selftests: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]