Skip to content

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

Closed

Description

@brettheap

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, no FINDING or REFUSED line, and a Traceback (most recent call last): line.
  • A child killed by a signal prints no traceback, and subprocess reports it as a NEGATIVE code (-9 for SIGKILL, -15 for SIGTERM).
  • validator_row then 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 manifest

Output 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:

  1. 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 the PYTHONPATH separator no longer runs at all; a neutral product's digest validate-pins.py skipped is a note naming the product, from a child that can no longer reach gh or a proxy; and a root whose manifest does not load and whose scripts/repo_shape.py is gone is told to restore that file before its gate (#278, #297, #298) #357's rule: the root's own copy is a FINDING with detail.crashed, and the standard's template is detail.environment, CANNOT ANSWER, exit 3.
  2. Exit 1 with no FINDING or REFUSED line and no traceback. Whether that is a crash too (os._exit(1)), or stays a finding because a validator may end with sys.exit("<its words>"). The validators' own mains can be read to settle it.
  3. The naming rows (shape-doctor.py's two naming rows read only the naming script's exit code, so a crash of the STANDARD's own validate-repository-naming.py is 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 gets naming 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

  1. brettheap commented on Oct 14, 2026

    @brettheap
    ContributorAuthor

    CLAIMED — lane openRepoShape-4, session 11c6c0ef-495a-4a05-94cf-811b7b0a1012@Eagle, 2026-10-14T17:16:38Z, for #447

    Logged in lanes/log/openRepoShape-4.md at aaabc050d422506f4bc13c4fe3be2bea1f0089f9.

    The three reads (lane-collision-protocol Rule 1):

    1. existing `CLAIMED —` comments on opensoft/openRepoShape#447:
       none
    2. `gh pr list --repo opensoft/openRepoShape --state all --search "#447"`, kept where the row or its body names `#447`:
       none
    3. `git ls-remote --heads git@github.com:opensoft/openRepoShape` branches naming `447` as a whole token:
       none
    
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions