Repository navigation
openRepoShape --doctor: a signal sent to the command's shell alone waits for the doctor, so its clone of the standard is never removed while shape-doctor.py runs from it, and a doctor ended by a signal leaves nothing (#680) - #740
Conversation
…its for the doctor, so its clone of the standard is never removed while `shape-doctor.py` runs from it, and a doctor ended by a signal leaves nothing (#680) Installed, `--doctor` clones the standard into $TMPDIR and arms an EXIT trap that removes it, disarmed once the doctor has run (#500). A TERM or HUP sent to the shell alone ended it at once: the EXIT trap removed the clone while the doctor, which nobody had signalled, still ran from it, and the "kept at" line was never printed. `run_doctor` now traps INT, TERM and HUP around the doctor, which stays in the foreground (design (a) of the issue), and each trap only records the signal. bash runs a trap for a foreground command once that command has ended, so the EXIT trap cannot run while the doctor is alive. Then: - a doctor that ran to its end keeps and names its clone, as before; - a doctor ended by a signal (a status over 128) leaves nothing, whether or not the shell was sent the signal too; - a shell sent TERM or HUP, or an INT the doctor was ended by too, ends by that signal once the above is done, so a calling shell reads 128 + its number in `$?` and a shell loop running the command stops at a Ctrl-C. An INT the doctor outlived is the doctor's business, as bash's own rule for SIGINT has it, and the shell exits with the doctor's status. tests/test_shape_doctor.py: a held stub doctor, started in a session of its own and signalled by recorded PID. INT, TERM and HUP sent to the shell and the doctor leave nothing, and so does TERM sent to the doctor alone; TERM, HUP and INT sent to the shell alone wait for the doctor, whose clone is there while it runs and is kept and named at its end. A plain repository's NEXT lines are run from the installed form after it has exited too, with helpers the drifted project's test now shares. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reviewer's GuideThe PR fixes installed File-Level Changes
Assessment against linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
|
(lane openRepoShape-3, 2026-10-16T23:08:46Z) Lane 3's independent review of #740 at Measured: the launcher alone in a directory over a held stub doctor, main against the head. Every signal went to a PID RV-d started.
Re-raising instead of NITs (optional):
The harness: it leaks nothing, and nothing was left running. The stub ends itself after 60 s, and teardown waits at most 120 s. RV-d found no load-sensitive assertion and had no flake at load averages of 25-85. Mutants: 10 of 12 launcher mutants are killed:
The two survivors change nothing a run can see. Tests on the committed merge with main Merges:
Docs and pinned copies: README:1151, AGENTS.md:220 and the handbook's line 1568 stay true, and the launcher is not a pinned copy. Lane: openreposhape-3 (openRepoShape-3) |
There was a problem hiding this comment.
Hey - I've found 2 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="tests/test_shape_doctor.py" line_range="2915" />
<code_context>
+@pytest.mark.parametrize(("name", "ends_by_it"), [
+ pytest.param("SIGTERM", True, id="TERM"),
+ pytest.param("SIGHUP", True, id="HUP"),
+ pytest.param("SIGINT", False, id="INT"),
+])
+def test_a_signal_to_the_installed_doctors_shell_alone_waits_for_it(
</code_context>
<issue_to_address>
**Normal exit 130 masks shell SIGINT**
When the doctor exits normally with status 130 after only the shell receives SIGINT, `run_doctor` treats `STATUS == 130` as proof that the doctor was ended by SIGINT and clears `caught`; if the doctor instead exits normally with 130 after only the shell received SIGINT, the launcher returns 130 without re-raising the signal, so a caller’s loop continues and the completed clone is not kept or named.
Do not infer that SIGINT reached the doctor solely from its exit status; preserve the shell’s pending SIGINT when the child may have exited normally with 130.
</issue_to_address>
### Comment 2
<location path="tests/test_shape_doctor.py" line_range="2768" />
<code_context>
+ tools = {path.name for path in named_in_the_clone(line, shape)}
+ named |= tools
+ if placeholder not in line:
+ run_once_it_has_exited(line, tmp_path)
+ ran |= tools
+ assert PLAIN_NEXTS_NAMED <= named, named
</code_context>
<issue_to_address>
**Failed commands count as successful**
When a named NEXT command exits nonzero without printing “can't open file”, `run_once_it_has_exited` checks only for that missing-file text, and the caller ignores its returned process status before adding the tool to `ran`, so the test passes despite the command failing.
Assert the expected return status and result from `run_once_it_has_exited` before adding the tool names to `ran`.
Also at `tests/test_shape_doctor.py:2690-2691`.
</issue_to_address>Sourcery assessment
Needs a human reviewer. 2 findings to address first, and if the signal handling is wrong, the doctor could lose its cloned standard while still using it, or a temporary clone could be left behind after an interrupted run. That clone is bounded and can be removed manually; reverting restores the previous signal behavior but does not clean up a clone already left behind.
Blocking findings: tests/test_shape_doctor.py:2915, tests/test_shape_doctor.py:2768
| @pytest.mark.parametrize(("name", "ends_by_it"), [ | ||
| pytest.param("SIGTERM", True, id="TERM"), | ||
| pytest.param("SIGHUP", True, id="HUP"), | ||
| pytest.param("SIGINT", False, id="INT"), |
There was a problem hiding this comment.
🟡 Medium · Normal exit 130 masks shell SIGINT
When the doctor exits normally with status 130 after only the shell receives SIGINT, run_doctor treats STATUS == 130 as proof that the doctor was ended by SIGINT and clears caught; if the doctor instead exits normally with 130 after only the shell received SIGINT, the launcher returns 130 without re-raising the signal, so a caller’s loop continues and the completed clone is not kept or named.
Do not infer that SIGINT reached the doctor solely from its exit status; preserve the shell’s pending SIGINT when the child may have exited normally with 130.
Prompt for AI agents
In `tests/test_shape_doctor.py` at line 2915:
**Normal exit 130 masks shell SIGINT**
When the doctor exits normally with status 130 after only the shell receives SIGINT, `run_doctor` treats `STATUS == 130` as proof that the doctor was ended by SIGINT and clears `caught`; if the doctor instead exits normally with 130 after only the shell received SIGINT, the launcher returns 130 without re-raising the signal, so a caller’s loop continues and the completed clone is not kept or named.
Do not infer that SIGINT reached the doctor solely from its exit status; preserve the shell’s pending SIGINT when the child may have exited normally with 130.| tools = {path.name for path in named_in_the_clone(line, shape)} | ||
| named |= tools | ||
| if placeholder not in line: | ||
| run_once_it_has_exited(line, tmp_path) |
There was a problem hiding this comment.
🟡 Medium · Failed commands count as successful
When a named NEXT command exits nonzero without printing “can't open file”, run_once_it_has_exited checks only for that missing-file text, and the caller ignores its returned process status before adding the tool to ran, so the test passes despite the command failing.
Assert the expected return status and result from run_once_it_has_exited before adding the tool names to ran.
Also at tests/test_shape_doctor.py:2690-2691.
Prompt for AI agents
In `tests/test_shape_doctor.py` at line 2768:
**Failed commands count as successful**
When a named NEXT command exits nonzero without printing “can't open file”, `run_once_it_has_exited` checks only for that missing-file text, and the caller ignores its returned process status before adding the tool to `ran`, so the test passes despite the command failing.
Assert the expected return status and result from `run_once_it_has_exited` before adding the tool names to `ran`.
Also at `tests/test_shape_doctor.py:2690-2691`.main gained 25 pull requests since de92614: #634, #699, #671, #719, #701, #717, #700, #702, #722, #720, #725, #721, #728, #705, #727, #718, #744, #743, #749, #740, #737, #734, #742, #751 and #736. No conflict: AGENTS.md, adopt-project.py and docs/cli.md merged as text, and README.md and docs/handbook.html are unchanged by the merge. README.md is 1473 lines, its cap; AGENTS.md 494, its cap; every handbook marker is current; `render-cli-reference.py --check` is ok. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…es every reason THIS checkout cannot answer, and the notes are current to main bf1f099 with the 25 pull requests since de92614 (#563, #683) - W683 and RV-f's N4 on #742: README's exit-3 list (Keeping a project's shape current, :1208-1220) and its handbook twin (`doctor`) named only a pin whose commit this checkout does not carry. They now say, as shape-doctor.py's EXIT CODES does: a pin's commit it lacks, a partial clone missing what is compared (#683), a read that fails (`update-upstream-unreadable`), a crashed validator of its own (#278, #410), or its own naming policy not loading (#678). +1 line, with tests/readme-cap.d/683.txt; README.md is 1474, its cap (1422 + 2 + 5 + 12 + 22 + 10 + 1). The two `Keeping a project's shape current` markers move: `doctor` text and marker, `current` marker only. - The notes, "What changed since v1.0.1", one entry per pull request from `git log --first-parent de92614..bf1f099`, each from its squash commit and the issues GitHub says it closes: - adopt-project.py: #699, #719, #749, #727, #717, #728 (with #621's hint), #743 + #720 + #718, #722, #721; - update-shape.py, bump-leg.py and a project's own scripts: #702 (with the pinned validators), #634; - scaffold, setup, family and the shared readers: #725, #671, #744; - shape-doctor.py: #742, #701, #737, #751, #736, #734, #700 + #705, #740; - install, CI and the docs: the exit-3 sentence above. - Counts (99 pull requests since bfed143; 109 of 135 milestone issues closed at 10:28Z), the "current to main" sha and the open list (26) are at main bf1f099, still placeholders for tag time. Left for round B: lane 5's eight lines (#630 issuecomment-6227129879), whose pull requests #726, #747, #682, #724, #746, #745 and #738 are open drafts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… verification row's colon, `run_validator`'s "costs no child an import" and the decoy test's `sitecustomize` claim say what main does (#666) (#730) Four comments and docstrings said something main no longer does. They now say what the code beneath them does. A fix round after review added two more of the same kind (see "Fix round"). Comments and docstrings only: no behaviour, no `--help` text, no pinned copy, so no README, handbook or `docs/cli.md` change. - **`adopt-project.py`, the module docstring.** It said the verification table reads `code: 0 of N source paths (seeded from template)`. The printed row has no colon (` {leg:<6} {count:>5} of {N} source paths{note}`). It now says the table's `code` row reads `0 of N source paths (seeded from template)`, the words `AGENTS.md` and `README.md` use since #505 and the docstring of the verification function uses too. `git grep 'code: 0 of'` finds nothing now. - **`shape-doctor.py`, `submodule_config`.** The last sentence of the docstring gave a reason that is true of the string and not decisive: "It is also what the row PRINTS, and a lone surrogate is a string no UTF-8 stream can write." The table goes through `ascii_text` and `--json` through `json.dumps`, so neither would raise on a lone surrogate. I dropped the sentence, the issue's first direction. The comparison with `origin_url`'s reading of a clone's remote is the reason, and the paragraph already gives it first. **This hunk carries the bytes of lane 3's #700**, which landed as `55c6ba7d`: the two removed lines and the one added line (` working clone.`) are the same. So the hunk is on main already and drops out of the squash (see "Evidence"). - **`shape-doctor.py`, `run_validator`.** "It costs no child an import" holds for an unedited copy only. A new paragraph says a root whose own copy of a validator gained an `import yaml` read `passes` while `site` was on, and now reads `crashed before reaching a verdict (exit 1): ModuleNotFoundError: No module named 'yaml'`, the crash its own CI gets, because `validate.yml` installs nothing. It calls that row a fair answer and says `-S` does not ask whether a copy was edited. No code change. - **`tests/test_shape_doctor.py`, the decoy test's two comments** (the block above `DECOY_REPO_SHAPE` and the inline comment before `reached`). `run_validator` starts every child as `[sys.executable, "-S", script, ...]`, so no child imports a `sitecustomize`. The comments now say that, say that replace-not-prepend and the #306 review S1 case are held by `test_run_validator_gives_the_child_exactly_the_pythonpath_it_is_given`, and say that the decoy test holds the template row's real verdict under an exported `PYTHONPATH`. The review of the first two commits approved with nits. Two new commits take S1 and N1: `7f2bb36b` makes both edits, and `426877bf` rewrites the last sentence of the N1 comment, which did not parse ("...a future removal of `-S`, which `test_run_validator_starts_every_child_with_no_site` ... is the test that holds"). It now ends "...a future removal of `-S`; `test_run_validator_starts_every_child_with_no_site` (in `tests/test_doctor_validator_site.py`) is the test that holds `-S`." Both are comments and docstrings only. - **S1, a third stale sentence.** The docstring of `test_run_validator_gives_the_child_exactly_the_pythonpath_it_is_given` said that for the callers which pass no `pythonpath`, "only a probe like this one, or (b)'s decoy, can see that they have none". That is false under `-S`: a `run_validator` that keeps the variable for those callers passes the decoy test in all three cases and fails the probe test twice (the second row of the table below). It now says only a probe like this one can see that they have none, and that the decoy of (b) could before `-S` (#342). It is the same defect as the issue's, in a place the issue did not name. "Could" holds for the `manifest` and `pins` cases: with `-S` removed, 2 of the 3 decoy cases fail, and the `family` case never caught it. - **N1, the inline comment overstated the loop.** The comment above `reached` said "What this loop still holds is that no row quotes the decoy or says it exited 86." Under `-S` the loop cannot fire. It now says so, says the loop is kept as a tripwire for a future removal of `-S`, and names `test_run_validator_starts_every_child_with_no_site` in `tests/test_doctor_validator_site.py` as the test that holds `-S`. That test is on main. - **N2, this body.** The merge checks and the notes on #630 were stale (they cited `de926142` and a conflict that is gone). They are rerun and rewritten below. **Only comments and docstrings changed.** `git diff 8062810 426877b` has 74 changed lines (44 added, 30 removed) in three files. A script checked each changed line against the file's comment tokens and docstring spans (both sides of the diff): 0 outside. A second check parsed each file at the base and at the head, blanked every docstring, and compared the ASTs: identical in all three files. For the two fix-round commits together, the AST of `tests/test_shape_doctor.py` with docstrings blanked is identical to the one at `a2113cff`, `git diff --check` is clean, and the longest added line is 75 columns (73 in the second commit). **What the squash holds.** `git merge-tree --write-tree` of this head with origin/main `20c8873b` gives the tree `6ead2ffc`. `git diff 20c8873 6ead2ffc` is 71 changed lines in three files, and `shape-doctor.py` has 8 of them: the new `run_validator` paragraph and nothing else. `git diff` of the two trees has no hunk that mentions `submodule_config`, `surrogate`, `PRINTS` or `working clone`. On main, `submodule_config`'s docstring has no "PRINTS" sentence and ends in `working clone.`. The same two checks on that tree: 0 lines outside comments and docstrings, ASTs identical. On main `2d988200` with the head `426877bf` the squash is the same: 71 changed lines, 8 of them in `shape-doctor.py`, 0 outside comments and docstrings, ASTs identical, and no hunk that mentions `submodule_config`. **`python3 scripts/render-cli-reference.py --check`**, at `426877bf`: `[ok] docs/cli.md is what the generator produces`. Only the first line of a module docstring is help text, and none of the first lines changed. **The decoy comments, measured.** I did not take the issue's reading on trust. In a throwaway clone at the base I put each #306 regression into `run_validator` and ran the two tests (`test_an_exported_pythonpath_does_not_reach_a_template_validator`, three cases, and `test_run_validator_gives_the_child_exactly_the_pythonpath_it_is_given`, six): | mutant | with `-S` (main) | with `-S` removed | |---|---|---| | the standard's `scripts/` put in FRONT of the operator's value | decoy test: 3 of 3 pass. Probe test: 1 of 6 fails | decoy test: 3 of 3 fail | | the variable kept for the callers that pass none (S1) | decoy test: 3 of 3 pass. Probe test: 2 of 6 fail | decoy test: 2 of 3 fail | | the operator's directory AHEAD of the standard's | decoy test: 3 of 3 fail. Probe test: 1 of 6 fails | not run | So on main the decoy test catches neither #306 regression and the probe test catches both, as lane 4's R342 measured on #636's head. The third row is why the comment says the raising `repo_shape.py` "still bites": it kills an operator-first order. The review re-ran these mutants on `a2113cff` and got the same counts. **The `run_validator` paragraph, measured.** A root whose own `validate-manifest.py` is `import yaml` plus a print, run through `run_validator` from this tree: exit 1 and `ModuleNotFoundError: No module named 'yaml'`. The same call with `-S` taken out of the child's argv: exit 0, `manifest ok`. `yaml` is importable from the machine's site-packages and not without `site`. With the real `gh` unreachable (a stub first on PATH, a bogus token, an empty `GH_CONFIG_DIR`). On `a2113cff`, before the fix round: - `tests/test_cli_reference.py`, `tests/test_repo_hygiene.py`, `tests/test_agents_md_codes.py`, `tests/test_scaffold_leg_path_collisions.py`: 250 passed (57.5 s). - `tests/test_doctor_gitmodules_not_utf8.py`, `tests/test_doctor_validator_rows.py`, `tests/test_doctor_validator_site.py`, `tests/test_doctor_validator_verdicts.py`, `tests/test_adopt_spec_only.py`, `tests/test_adopt_entry_paths.py`, `tests/test_adopt_submodules.py` (the files that drive `run_validator`, `submodule_config` or the seeded-leg row): 334 passed (28 min 18 s). - `tests/test_shape_doctor.py`, in two halves run side by side (the first 204 test ids, then the last 204): 204 passed in 11 min 31 s, and 201 passed, 3 skipped in 4 min 58 s. That is all 408 tests. The 3 skips are the PowerShell-line tests, which need `powershell.exe` on a Windows host. - The review ran all of `tests/test_shape_doctor.py` there: 405 passed, 3 skipped (512 s). On the predicted merge with main `2fac9790` it ran `test_shape_doctor.py`, `test_doctor_validator_site.py`, `test_doctor_validator_rows.py`, `test_doctor_validator_verdicts.py`, `test_doctor_gitmodules_not_utf8.py`, `test_repo_hygiene.py`, `test_cli_reference.py` and `test_agents_md_codes.py`: 632 passed, 3 skipped (760 s). On `426877bf`, after the fix round, which changes comments and docstrings only, so the runs above stand: - The two tests the fix round touches, `test_an_exported_pythonpath_does_not_reach_a_template_validator` (3 cases) and `test_run_validator_gives_the_child_exactly_the_pythonpath_it_is_given` (6 cases), run by node id: 9 passed (11.7 s). The same 9 also passed (16.1 s) at `7f2bb36b`. - `tests/test_repo_hygiene.py`: 77 passed (1.9 s). It also passed (2.0 s) at `7f2bb36b`. No test fails on the base, because nothing here changes behaviour. The AST comparison above is the proof in their place. `git merge-tree --write-tree` of this head against origin/main and against every ref on origin that could meet it. The full run was on 2026-10-16, with the head `7f2bb36b` (the one before the last comment commit) and main `20c8873b`: the 41 branches (`fix/*`, `l3-*`, `wip/*`, `docs/*` and `release/v1.0`) and the 55 PR heads from #630 up. That is 96 refs. I ran #731's head `ab55c29f` through the same list. After `426877bf` I re-ran main, now `2d988200`, and the six heads that edit `tests/test_shape_doctor.py`, the one file that commit touches (#705, #737, #740, `l3-669-members-row`, `l3-680-doctor-signals` and `l3-697-crash-cure-next`), and #630: all clean. - **origin/main: clean.** At `20c8873b`, with the head `7f2bb36b`, the predicted tree is `6ead2ffc`. At `2d988200` (three `adopt-project.py` commits later, with their tests and a change in `scripts/shape_materialize.py`), with the head `426877bf`, it is `7ee5caf4`, and the squash is the same three files (43 added, 28 removed). #731's head `ab55c29f` is clean with `2d988200` too. This head with #731's head: clean. - **#630's head `6fd33a93`: clean.** It conflicted with main in `docs/handbook.html` when this body was first drafted, and no longer does, with or without this change. - **#700 and #702 have landed** (`55c6ba7d` and `20c8873b`), and so has #701, and #699, #717 and #719 for `adopt-project.py`. Their heads still merge clean with this head. - **Every ref that edits `shape-doctor.py`, `tests/test_shape_doctor.py` or `adopt-project.py` merges clean with this head**, except the two old heads named in the bullet after next. They include the heads of PRs not on main yet (#705, #721, #737 and #742, and lane 3's `l3-680`, `l3-683`, `l3-687`, `l3-691` and `l3-697`, which were not on origin when this body was first drafted). #673 has no branch or PR head on origin. - **Four refs conflict with origin/main on their own**, and so with this head: `docs/release-notes-v1.0.0` and `release/v1.0`, and the pre-squash heads of the landed #637 and #641. Their conflicts do not come from this change. - **Two landed pre-squash heads conflict with this head only because they are old.** #632 conflicts with main on its own. #636 merges clean with main, but its old head conflicts with this head in `run_validator`'s docstring, next to the text #636 added; main has that text, and this head merges clean with main. - **Refs that conflict with main on their own are not made worse.** #682, #745, #747, `fix/648`, `fix/681` and `fix/704` conflict with main in `templates/family-root/scripts/validate-family.py`; #742 and `l3-683` in `shape-doctor.py`. Each merges clean with this head. - **Every other open head and branch: clean.** Nothing from the issue. Its four lines are done. The decoy test's `sitecustomize` machinery is dead code under `-S` and I left it, because the issue says comment only. The comment above `reached` now says it is kept as a tripwire. - **The decoy test's `sitecustomize.py` and exit-86 trap never run.** `DECOY_SITECUSTOMIZE`, `os._exit(86)` and the `(exit 86)` and `DECOY` clauses in `reached` can only fire for a child started without `-S`. They are kept as a tripwire and the comment says so. The other choice is to remove them; `test_run_validator_starts_every_child_with_no_site` already holds `-S`. Small, tests only. Review: rv666, lane 5's independent review, approved a2113cf with nits; 7f2bb36 and 426877b take its S1 and N1 (above), and codex found no major issue at a2113cf. CI (Brett Heap's ruling 27 and his fast-refresh ruling of 2026-10-15T22:48:35Z): H0 426877b passed tests, tests-git-bash and SonarCloud; the head 32722b3 is a clean update-branch merge of main bf1f099, with tests-git-bash and SonarCloud green; and the local gate passed on its predicted squash tree 55d5d3b over main dcf62ed: the shape_doctor, scaffold_leg_path_collisions, repo_hygiene, agents_md_codes, cli_reference sets, 686 passed, 3 skipped, 1 xfailed. Landed on Brett Heap's word of 2026-10-17T10:59:39Z, relayed by lane 3 on #473: "merge lane 5's approved v1.1.0 drafts". Closes #666 Lane: openreposhape-5 (openRepoShape-5) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>



Lane: openreposhape-3 (openRepoShape-3)
Claim: #680 (comment)
Closes #680.
What changed and why
The bash launcher
openRepoShape,run_doctor. Installed,--doctorclones the standard into$TMPDIR/openRepoShape-doctor.XXXXXXXX, andworkdirarms an EXIT trap that removes it (openRepoShape:201). The trap is disarmed once the doctor has run (#500). A TERM or HUP sent to the shell alone ended the shell at once. The EXIT trap then removed the clone while the doctor, which nobody had signalled, was still running from it.openRepoShape:697-716). INT, TERM and HUP are trapped around the doctor, and each trap only records the signal (caught, a local ofrun_doctor,:667). bash runs a trap for a foreground command only once that command has ended, so the EXIT trap cannot run while the doctor is alive.:766,:792-795). This holds whether or not the shell was sent the signal too.:783-799,kill -"$caught" "$$"). A calling shell reads 128 + the signal's number in$?.:776-782). This is bash's own rule for SIGINT, and what this shell already did before INT was trapped: an INT sent to the shell alone lets the doctor finish, and the shell exits with the doctor's status.:729-738) is rewritten, and theRAN TO ITS ENDwording carries into the next paragraph (:749).Design (a), and why
The doctor stays in the foreground, and the trap only records the signal. I did not use design (b), which runs the doctor in the background, forwards the signal and calls
wait:gitandghthe doctor runs would inherit that ignored SIGINT. A Ctrl-C reaches the whole foreground process group, and they would ignore it. Forwarding TERM reaches only the doctor, never what it started.The exit: a death by the signal, which a shell reads as 128 + N
The issue says "Exit 128 + the signal number". Every calling shell reads exactly that in
$?, measured below. I did this by re-raising the signal (kill -N $$aftertrap -), not withexit $((128 + N)). The reason is measured, not argued:exit 130goes on to its next turn at a Ctrl-C:after 1 status 130 | after 2 status 0 | after 3 status 0. With the re-raise, the loop stops: outer rc -2.exit 130would have been a regression for everyfor … do openRepoShape --doctor …; done.The tests pin the death by the signal (
returncode == -N). The mutant that usesexit 128+Nfails five of them.Measured: the issue's table, before (main de92614) and after (af75e37)
These measurements use the installed launcher, one file with nothing beside it, over a stub standard whose
shape-doctor.pyrecords its pid and blocks for 10 s. Signals go by recorded PID. The first five rows are the issue's; the last four are added.Before, main de92614:
$TMPDIRafterAfter, af75e37:
$TMPDIRafterWhat a calling
sh -c 'bash openRepoShape --doctor …; echo $?'reads after this change: TERM to the shell and the doctor gives 143, INT gives 130, HUP gives 129; TERM to the shell alone gives 143, HUP gives 129.Tests
All new tests are in
tests/test_shape_doctor.py's installed-doctor section. They are POSIX only (WINDOWS_SKIP), like the tests around them.test_a_doctor_ended_by_a_signal_leaves_nothing(:2884) covers INT, TERM and HUP sent to the shell and the doctor, plus TERM sent to the doctor alone. In each case there is no clone in$TMPDIR, no "kept at" line, and the doctor never reached its end. The exit is -N when the shell was signalled, and 128 + N when only the doctor was.test_a_signal_to_the_installed_doctors_shell_alone_waits_for_it(:2917) covers TERM, HUP and INT sent to the shell alone:shape-doctor.pyis there;HELD_DOCTOR(:2789) records its pid and its parent's. The test asserts that the parent is the shell it started, and signals only those two PIDs.releasefile, at most 60 s. The fixtureheld_run(:2848) lets it go at teardown, so a failed test leaves nothing running and needs no kill.DEFAULT_SIGNALS(:2809) resets INT, TERM and HUP beforeexec bash, so a pytest run undernohupor as a background job cannot hand the shell an ignored signal it could neither trap nor reset.test_a_plain_repositorys_next_lines_outlive_the_installed_doctor(:2743, L8) runs a git repository with anoriginthroughrun_installed:machinerow'ssetup-project.py --preflightrun as pasted, with nocan't open file;setup.sh --org <your-org>line is checked for its path only.:2697) now usesnext_lines,named_in_the_cloneandrun_once_it_has_exitedalong with the new test, rather than a second copy of them.run_installedtakes its environment frominstalled_env, which the new tests use too.Mutation checks. Each mutant of the launcher fails at least one new test:
exit 128+Nin place of the re-raisermbefore the re-raiseRuns. All runs used a stub
ghexiting 97 first on PATH,GH_TOKEN=bogusand an emptyGH_CONFIG_DIR, and every pytest command took/usr/bin/python3 -m pytest -q -p no:cacheprovider --basetemp=<scratch> -o tmp_path_retention_policy=failed:tests/test_shape_doctor.pysha256sum -cOK for both files after the run413 passed, 3 skipped in 783.57s (0:13:03)PLAIN_NEXTS_RUN <= ran), with its section via-k "installed or shim or ended_by_a_signal or plain_repositorys"26 passed, 390 deselected in 39.94stests/test_openreposhape_command.py tests/test_install_sh.py tests/test_repo_hygiene.py tests/test_cli_reference.py243 passed in 104.86s (0:01:44)tests/test_repo_hygiene.py tests/test_cli_reference.py99 passed in 47.90sGit Bash.
tests-git-bash'stest_the_doctor_checks_its_clone_out_at_the_ref_in_forcereachesrun_doctorwith a stub doctor and no signal. On that path the traps are set and reset andcaughtstays empty, so the run is unchanged. It passed here on Linux bash 5.2. It was not run under Git Bash.Docs
None changed. README.md:1151, AGENTS.md:220 and docs/handbook.html:1568 say that the installed doctor keeps its clone for the NEXT lines. That still holds for every doctor that ran to its end, and none of them states the interrupted case.
--helpanddocs/cli.mdare unchanged.Pinned copies
None touched. The launcher is not a pinned shape copy: it is not in
FAMILY_COPIED_VERBATIMand is not undertemplates/.Lands after
#656, which is already on main as 35133f2. The branch is cut from de92614. Main has since gained 30676c9 (#634), which touches neither file, and
git merge-treewith it is clean.🤖 Generated with Claude Code
Summary by Sourcery
Correct installed doctor signal handling so the shell waits safely, interrupted runs clean up, and completed runs retain usable clones.
Bug Fixes:
Enhancements:
Tests: