Repository navigation
shape-doctor.py, after #357's crashed(), still reads a validator killed by a signal (a negative exit, -9 or -15) or one that left by os._exit(1) with nothing printed as one that "reports a finding", because crashed() needs a traceback line, so a root's own gate ended by the OOM killer or a timeout's SIGTERM is a FINDING about the project, and the standard's template killed the same way is not CANNOT ANSWER #447
Description
Lane: openreposhape-4 (openRepoShape-4)
Found by this lane's adversarial review of #278, #297 and #298 (PR #357, head 2bf402d, its N1): "A crash without a traceback is still 'reports a finding' (by ruling) ... A negative return code (killed by a signal) is never a verdict and could join crashed() cheaply. Follow-up." PR #357's body carries it as follow-up N1 at its head 8ad856f. #357 landed as 5678f68 and closed #278, #297 and #298. Before it, main 3ae0109 had no crashed() and read every non-zero exit as a finding, which was #278 itself. This issue is what stays after #357 landed. Verified on main at 5678f68. Local bare remotes, an empty global git config, GIT_ALLOW_PROTOCOL=file, and a gh that exits 97 (only the machine row calls it). Not claimed: Windows, where a process ended by another has the exit code its killer chose, not a negative one.
What is wrong. On main at 5678f68:
crashed()(shape-doctor.py:1078) tells a crash by three things together: a non-zero exit, noFINDINGorREFUSEDline, and aTraceback (most recent call last):line.- A child killed by a signal prints no traceback, and
subprocessreports it as a NEGATIVE code (-9for SIGKILL,-15for SIGTERM). validator_rowthen picks its verb by"refuses" if code >= 2 else "reports a finding"(:1417), and a negative code is less than 2:
U=<openRepoShape checkout> # a clean checkout of main at 5678f68, the squash #357 landed as
# Runs in bash on Linux (GNU coreutils). Nothing leaves the machine: every repository is a local bare one under $R, git
# reads an empty global config, GIT_ALLOW_PROTOCOL=file makes any accidental https/ssh clone fail, and a `gh` that exits
# 97 stands first on PATH.
R=$(mktemp -d); mkdir $R/bin; printf '#!/bin/sh\nexit 97\n' > $R/bin/gh; chmod +x $R/bin/gh; touch $R/gitconfig
export PATH=$R/bin:$PATH GIT_CONFIG_GLOBAL=$R/gitconfig GIT_CONFIG_NOSYSTEM=1 GIT_ALLOW_PROTOCOL=file \
GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t.invalid GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t.invalid
row() { python3 -c '
import json, sys
d = json.load(open(sys.argv[1]))
for r in d["rows"]:
if r["id"] in sys.argv[2].split(","):
det = r.get("detail", {})
print(" " + r["id"] + ":", r["status"], "|", r["reason"][:int(sys.argv[3]) if sys.argv[3:] else 120], "| crashed:", det.get("crashed"), "environment:", det.get("environment"))
print(" verdict:", d["verdict"] + ", exit", d["exit"])' "$@"; }
python3 $U/scaffold-project.py --org testorg --project Atlas --elected-by 'Test Human' --elected-on 2026-10-12 \
--local-remote-dir $R/remotes --work-dir $R/work >/dev/null
echo "== the root's own scripts/validate-pins.py ends without a traceback"
for HOW in 'os.kill(os.getpid(), signal.SIGKILL)' 'os.kill(os.getpid(), signal.SIGTERM)' 'os._exit(1)'; do
C=$R/c$((++n)); git clone -q --recurse-submodules $R/remotes/Atlas.git $C
printf 'import os, signal\n%s\n' "$HOW" > $C/scripts/validate-pins.py
python3 $U/shape-doctor.py --root $C --json > $C.json; echo " $HOW:"; row $C.json pins
done
echo "== the root's own scripts/validate-pins.py holds merge-conflict markers (a conflicted merge nobody resolved)"
C=$R/c$((++n)); git clone -q --recurse-submodules $R/remotes/Atlas.git $C
printf '<<<<<<< HEAD\nimport sys\n=======\nimport os\n>>>>>>> theirs\n' >> $C/scripts/validate-pins.py
python3 $U/shape-doctor.py --root $C --json > $C.json; row $C.json pins
(cd / && python3 $C/scripts/validate-pins.py --root $C > $C.out 2>&1; echo " run by hand: exit $?"; sed 's/^/ /' $C.out)
echo "== the root's own scripts/validate-pins.py is a file the doctor's user cannot read (as that user, not as root)"
C=$R/c$((++n)); git clone -q --recurse-submodules $R/remotes/Atlas.git $C
chmod 000 $C/scripts/validate-pins.py
python3 $U/shape-doctor.py --root $C --json > $C.json; row $C.json pins 400
(cd / && python3 $C/scripts/validate-pins.py --root $C > $C.out 2>&1; echo " run by hand: exit $?"; sed 's/^/ /' $C.out)
echo "== the standard's TEMPLATE validate-manifest.py is killed (the root has no copy of its own)"
git clone -q $U $R/std
printf 'import os, signal\nos.kill(os.getpid(), signal.SIGKILL)\n' > $R/std/templates/assembly-root/scripts/validate-manifest.py
C=$R/t; git clone -q --recurse-submodules $R/remotes/Atlas.git $C; git -C $C rm -q scripts/validate-manifest.py
python3 $R/std/shape-doctor.py --root $C --json > $C.json; row $C.json manifest
echo "== control: the same template raising an exception"
printf 'raise RuntimeError("boom")\n' > $R/std/templates/assembly-root/scripts/validate-manifest.py
python3 $R/std/shape-doctor.py --root $C --json > $C.json; row $C.json manifestOutput on main at 5678f68:
== the root's own scripts/validate-pins.py ends without a traceback
os.kill(os.getpid(), signal.SIGKILL):
pins: FINDING | the project's own scripts/validate-pins.py reports a finding (exit -9) | crashed: None environment: None
verdict: DRIFTED (1 locally-modified), exit 1
os.kill(os.getpid(), signal.SIGTERM):
pins: FINDING | the project's own scripts/validate-pins.py reports a finding (exit -15) | crashed: None environment: None
verdict: DRIFTED (1 locally-modified), exit 1
os._exit(1):
pins: FINDING | the project's own scripts/validate-pins.py reports a finding (exit 1) | crashed: None environment: None
verdict: DRIFTED (1 locally-modified), exit 1
== the root's own scripts/validate-pins.py holds merge-conflict markers (a conflicted merge nobody resolved)
pins: FINDING | the project's own scripts/validate-pins.py reports a finding (exit 1): SyntaxError: invalid syntax | crashed: None environment: None
verdict: DRIFTED (1 locally-modified), exit 1
run by hand: exit 1
File "$R/c4/scripts/validate-pins.py", line 835
<<<<<<< HEAD
^^
SyntaxError: invalid syntax
== the root's own scripts/validate-pins.py is a file the doctor's user cannot read (as that user, not as root)
pins: FINDING | the project's own scripts/validate-pins.py refuses (exit 2): /usr/bin/python3: can't open file '$R/c5/scripts/validate-pins.py': [Errno 13] P | crashed: None environment: None
verdict: INVALID (pins), exit 1
run by hand: exit 2
python3: can't open file '$R/c5/scripts/validate-pins.py': [Errno 13] Permission denied
== the standard's TEMPLATE validate-manifest.py is killed (the root has no copy of its own)
manifest: FINDING | the standard's templates/assembly-root/scripts/validate-manifest.py reports a finding (exit -9) | crashed: None environment: None
verdict: DRIFTED (1 copy-missing), exit 1
== control: the same template raising an exception
manifest: FINDING | the standard's templates/assembly-root/scripts/validate-manifest.py crashed before reaching a verdict (exit 1): RuntimeE | crashed: True environment: True
verdict: CANNOT ANSWER (manifest: the standard's templates/assembly-root/scripts/validate-manifest.py crashed before reaching a verdict (exit 1): RuntimeError: boom; that copy is this checkout's, not the project's, so nothing here is a statement about the project), exit 3
The root's own gate killed by the kernel's OOM killer, or by a SIGTERM from a runner's timeout, becomes a FINDING about the project, "reports a finding (exit -9)", with nothing quoted. The standard's TEMPLATE killed the same way is a FINDING too, DRIFTED, exit 1. The control shows that #357 gives the same template CANNOT ANSWER, exit 3, when it raises instead. Before #357, main 3ae0109 printed "reports a finding (exit 1): RuntimeError: boom" for the control, which was #278.
Two more ways a root's own validate-pins.py ends without a verdict, with no signal and no bare os._exit, found by this lane's plan for the next doctor change (its R07-1). A validator holding merge-conflict markers (a conflicted merge nobody finished) is a SyntaxError: CPython prints the line, a caret and SyntaxError: invalid syntax, exits 1, and prints no Traceback (most recent call last): line, so crashed() is None and the row says "reports a finding (exit 1)" and quotes the SyntaxError. A validator file the doctor's user cannot read (chmod 000; root reads it anyway) is python3: can't open file ... [Errno 13] Permission denied, exit 2, with no FINDING or REFUSED line and no traceback, and the row says "refuses (exit 2)", the verb for a validator's own refusal, about a file the interpreter never opened. Neither is a kill, so decision 1 below does not reach them. The first is decision 2's case (exit 1, no FINDING or REFUSED line, no traceback header), and the second is its exit-2 twin. The doctor quotes at most 200 characters of a validator's line (quoted[:200]), which the long $R of this run reached, so the hand run beneath it shows the whole message.
Why. The validators' protocol is 0 valid, 1 findings, 2 refusal. A Python program cannot exit with a negative code by its own choice: sys.exit and os._exit give 0 to 255. A negative code is always the environment ending the child before it reached a verdict. os._exit(1) with nothing printed is the review's other case. Every finding a validator reports is a FINDING line, so "exit 1 and no line at all" is not a finding either. It is less certain than a signal, because sys.exit("message") also exits 1 with no FINDING line.
What it should do. A validator that was killed is never "reports a finding". The decisions are the author's:
- A negative exit is a crash. It joins
crashed(), with the signal's name in the reason (signal.Signals(-code).name). It follows shape-doctor.py: a validator that crashed says it crashed instead of "reports a finding", and is CANNOT ANSWER when it is the standard's template, which a standard whose path holds thePYTHONPATHseparator no longer runs at all; a neutral product's digestvalidate-pins.pyskipped is anotenaming the product, from a child that can no longer reachghor a proxy; and a root whose manifest does not load and whosescripts/repo_shape.pyis gone is told to restore that file before its gate (#278, #297, #298) #357's rule: the root's own copy is a FINDING withdetail.crashed, and the standard's template isdetail.environment, CANNOT ANSWER, exit 3. - Exit 1 with no
FINDINGorREFUSEDline and no traceback. Whether that is a crash too (os._exit(1)), or stays a finding because a validator may end withsys.exit("<its words>"). The validators' ownmains can be read to settle it. - The naming rows (shape-doctor.py's two
namingrows read only the naming script's exit code, so a crash of the STANDARD's ownvalidate-repository-naming.pyis a FINDING about the project (INVALID (naming), exit 1, where #357 makes a crashed template CANNOT ANSWER), and a directory whose name is not UTF-8 getsnaming FINDING Atl?as: UnicodeEncodeError: ..., the script's traceback quoted as a fact about the name #410) take the same test.
A suggestion covering decisions 1 and 2, from this lane's plan for the next doctor change (its R07-1); the decisions stay the author's. Tell a verdict by what the validator printed, and not by what it failed to print. A validator reached one only when it exits 0, or exits 1 or 2 AND printed a line starting FINDING or REFUSED. Anything else is a crash: a negative exit, an exit of 3 or more, and exit 1 or 2 with neither line, which is os._exit(1), sys.exit("words"), the conflict-marker SyntaxError and can't open file. One rule then covers every case above, where a test for a traceback covers none of the last four. The plan read each validator's main (validate-pins.py, validate-manifest.py, validate-family.py and the naming script) and found a FINDING or REFUSED line before every exit 1 or 2 it chooses, so a validator that printed a finding and then died is still a finding, as #357's crashed() rules today.
How to test. Beside #357's crash tests in tests/test_doctor_validator_rows.py. An own copy and a template that os.kill(os.getpid(), signal.SIGKILL) (POSIX, WINDOWS_SKIP): detail.crashed, the reason names SIGKILL, and the template's run is CANNOT ANSWER, exit 3. Under decision 2, os._exit(1) the same way, and a copy with conflict markers appended and one with mode 000 (POSIX, not as root).
Verified on main at 5678f68, the squash #357 landed as (shape-doctor.py sha256 c206264b…).
Related: #278 / PR #357 (crashed()), #410 (the naming rows), #448 (the same PR's other follow-up).
Activity
brettheap commentedon Oct 14, 2026
CLAIMED — lane openRepoShape-4, session 11c6c0ef-495a-4a05-94cf-811b7b0a1012@Eagle, 2026-10-14T17:16:38Z, for #447
Logged in
lanes/log/openRepoShape-4.mdataaabc050d422506f4bc13c4fe3be2bea1f0089f9.The three reads (lane-collision-protocol Rule 1):