claude-dev-env 2.7.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_shared/CLAUDE.md +1 -0
- package/_shared/advisor/advisor-protocol.md +19 -9
- package/_shared/pr-loop/audit-contract.md +4 -4
- package/_shared/pr-loop/precatch-rubric.md +2 -2
- package/_shared/pr-loop/worker-spawn.md +3 -1
- package/_shared/process-tree/CLAUDE.md +41 -0
- package/_shared/process-tree/scripts/config/process_tree_scripts_constants/__init__.py +1 -0
- package/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +27 -0
- package/_shared/process-tree/scripts/process_tree_kill.py +141 -0
- package/_shared/process-tree/scripts/pyproject.toml +16 -0
- package/_shared/process-tree/scripts/test_process_tree_kill.py +278 -0
- package/agents/code-quality-agent.md +6 -5
- package/agents/deep-research.md +7 -24
- package/agents/docs-agent.md +1 -27
- package/agents/issue-tracker.md +1 -7
- package/agents/skill-writer-agent.md +1 -2
- package/agents/test_agent_frontmatter.py +309 -12
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/fable_spawn_gate.py +187 -0
- package/hooks/blocking/piped_pytest_blocker.py +1223 -0
- package/hooks/blocking/plain_language_blocker.py +287 -15
- package/hooks/blocking/test_fable_spawn_gate.py +374 -0
- package/hooks/blocking/test_piped_pytest_blocker.py +587 -0
- package/hooks/blocking/test_plain_language_blocker.py +277 -2
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +34 -2
- package/hooks/git-hooks/CLAUDE.md +2 -2
- package/hooks/git-hooks/git_hooks_constants/__init__.py +28 -0
- package/hooks/git-hooks/pre_push.py +343 -54
- package/hooks/git-hooks/test_pre_push.py +852 -6
- package/hooks/hooks.json +9 -19
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/fable_spawn_gate_constants.py +62 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +360 -0
- package/hooks/hooks_constants/plain_language_blocker_constants.py +64 -1
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +10 -0
- package/hooks/hooks_constants/shell_command_segments.py +1 -1
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +27 -0
- package/hooks/hooks_constants/test_prose_metrics_parity.py +124 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/ask-user-question-required.md +26 -0
- package/rules/claims-as-quotes.md +65 -0
- package/scripts/CLAUDE.md +4 -4
- package/scripts/_code_review_test_support.py +6 -0
- package/scripts/check.ps1 +18 -5
- package/scripts/claude_chain_runner.py +203 -31
- package/scripts/codec_forwarding_test_support.py +2 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -4
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +38 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +403 -2
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +83 -13
- package/scripts/grok_headless_runner.py +148 -18
- package/scripts/resolve_worker_spawn.py +56 -10
- package/scripts/spawn_grok_batch.py +81 -23
- package/scripts/test_claude_chain_runner.py +358 -0
- package/scripts/test_grok_headless_runner.py +547 -10
- package/scripts/test_invoke_code_review.py +298 -0
- package/scripts/test_resolve_worker_spawn.py +185 -15
- package/scripts/test_spawn_grok_batch.py +326 -22
- package/scripts/tests/CLAUDE.md +1 -0
- package/scripts/tests/test_grok_worker_constants.py +59 -0
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +46 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +133 -8
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +107 -1
- package/skills/autoconverge/workflow/converge.mjs +113 -31
- package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -4
- package/skills/codex-review/scripts/codex_usage_probe.py +20 -33
- package/skills/codex-review/scripts/run_codex_review.py +16 -64
- package/skills/codex-review/scripts/test_codex_usage_probe.py +46 -41
- package/skills/codex-review/scripts/test_run_codex_review.py +1 -33
- package/skills/e-code-review/SKILL.md +9 -8
- package/skills/e-code-review/reference/fix.md +29 -7
- package/skills/e-code-review/reference/loop.md +230 -14
- package/skills/e-code-review/reference/low.md +33 -15
- package/skills/e-code-review/reference/medium.md +55 -21
- package/skills/e-code-review/reference/xhigh.md +30 -12
- package/skills/fresh-branch/CLAUDE.md +5 -5
- package/skills/fresh-branch/SKILL.md +14 -6
- package/skills/fresh-branch/scripts/create_fresh_branch.py +122 -39
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +10 -3
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +251 -0
- package/skills/grok-spawn/SKILL.md +10 -3
- package/skills/grok-spawn/reference/flag-profiles.md +3 -1
- package/skills/orchestrator/SKILL.md +4 -1
- package/skills/orchestrator-refresh/SKILL.md +5 -1
- package/skills/team-advisor/SKILL.md +4 -1
package/_shared/CLAUDE.md
CHANGED
|
@@ -8,6 +8,7 @@ Cross-cutting runtime assets shared by multiple skills. The PR-loop skills (`bug
|
|
|
8
8
|
|---|---|
|
|
9
9
|
| `pr-loop/` | Docs, scripts, and constants for the PR-loop workflow suite |
|
|
10
10
|
| `advisor/` | Warm-advisor spawn-and-consult protocol for `team-advisor` and `orchestrator` |
|
|
11
|
+
| `process-tree/` | One process-tree kill helper for every script that captures a spawned CLI's output |
|
|
11
12
|
|
|
12
13
|
## Install path
|
|
13
14
|
|
|
@@ -20,19 +20,19 @@ Use the **Model floor** ladder below (Fable → Opus → Sonnet → Haiku). Warm
|
|
|
20
20
|
|
|
21
21
|
### Third-party host
|
|
22
22
|
|
|
23
|
-
A third-party (non-Claude) harness cannot spawn a Claude `session-advisor` through the Agent tool. Bind a **max-tier Claude advisor** through the shared CLI Claude-chain
|
|
23
|
+
A third-party (non-Claude) harness cannot spawn a Claude `session-advisor` through the Agent tool. Bind a **max-tier Claude advisor** through the shared CLI Claude-chain. Do **not** treat this third-party session as the advisor.
|
|
24
24
|
|
|
25
25
|
1. Detect host profile first (this section).
|
|
26
26
|
2. Set the advisor floor to **Opus** so the walk is `candidate_tiers = ["Fable", "Opus"]` with `own_tier = Opus`. The walk never drops to Sonnet or Haiku on a third-party host.
|
|
27
27
|
3. **CLI bind (primary path):** for each candidate top-down, pipe a charter file into:
|
|
28
28
|
|
|
29
29
|
```
|
|
30
|
-
python "$HOME/.claude/scripts/claude_chain_runner.py" -- -p --model <alias> --effort <effort> --output-format json
|
|
30
|
+
python "$HOME/.claude/scripts/claude_chain_runner.py" --routing-mode ordered_account -- -p --model <alias> --effort <effort> --output-format json
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Use `--model fable --effort high` on Fable; use `--model opus --effort max` on Opus.
|
|
34
|
-
4. Stop at the first successful bind. Record `{tier, result: "cli"}` and set `selected_tier` to that tier. Persist `session_id` from the JSON events (any event carries it; reply text is the `type == "result"` event's `.result` field). Run every bind and every later consult with cwd set to the repo root the work is for — Claude sessions are project-scoped by working directory.
|
|
35
|
-
5. **Fail closed:** when every candidate fails (chain exhausted or model unavailable), set `selected_tier = null` and a `fallback_reason`, report that the advisor is unreachable, and **stop**. Do **not** answer ENDORSE / CORRECTION / PLAN / STOP as this third-party session. Do **not** self-endorse.
|
|
33
|
+
Use `--model fable --effort high` on Fable; use `--model opus --effort max` on Opus. **Root advisor bind** uses `--routing-mode ordered_account`: the runner walks `~/.claude/claude-chain.json` in **config order** (primary launcher first, secondary next), and fails over to the next entry **only** on a usage-limit signature. Authentication, timeout, configuration, and other non-usage process errors stop immediately with `terminal_status=advisor_blocked` (exit code 4 on the CLI) — they do **not** fall through to the next launcher. General (non-root) chain calls keep the default `--routing-mode usage_ranked`, which probes weekly remaining via `claude_chain_usage` / the usage-pause OAuth probe and ranks highest remaining first.
|
|
34
|
+
4. Stop at the first successful bind. Record `{tier, result: "cli"}` and set `selected_tier` to that tier. Persist `session_id` from the JSON events (any event carries it; the runner also surfaces it on `ChainInvocationOutcome.session_id`; reply text is the `type == "result"` event's `.result` field). Run every bind and every later consult with cwd set to the repo root the work is for — Claude sessions are project-scoped by working directory.
|
|
35
|
+
5. **Fail closed:** when every candidate fails (chain exhausted, `advisor_blocked`, or model unavailable), set `selected_tier = null` and a `fallback_reason`, report that the advisor is unreachable, and **stop**. Do **not** answer ENDORSE / CORRECTION / PLAN / STOP as this third-party session. Do **not** self-endorse.
|
|
36
36
|
6. Paste the **Third-party host** Advisor block into every executor spawn prompt — never the Claude SendMessage block. Executors report to the orchestrating session; that session consults the bound Claude CLI advisor and relays the four-signal reply.
|
|
37
37
|
|
|
38
38
|
Resolve a third-party session's own model field with `resolve_cli_model_id("ThirdParty")` → `third-party` when a host model alias is required. The **advisor** bind uses Fable/Opus aliases only.
|
|
@@ -66,10 +66,11 @@ On a **Claude host**, the consuming skill's session walks the candidate tiers to
|
|
|
66
66
|
- `model`: the short alias for that attempt's candidate tier via `resolve_cli_model_id` (or the alias table under CLI chain) — for example `opus`, not Title Case `Opus`. The floor is only the lower bound of the walk; the walk still tries stronger tiers first.
|
|
67
67
|
- `name`: a name the session and every consumer will use to reach it (e.g. `team-advisor-agent`).
|
|
68
68
|
- `run_in_background: true`.
|
|
69
|
+
- `prompt`: the charter below. A **Fable**-tier attempt carries the exact token `FABLE-SPAWN-AUTHORIZED` in that prompt — `hooks/blocking/fable_spawn_gate.py` denies every `Agent` or `Task` spawn at `model: fable` whose prompt lacks that token. An attempt at any other tier needs no token.
|
|
69
70
|
|
|
70
71
|
Stop at the first successful spawn. That attempt's tier is `selected_tier`; the warm agent lives at that tier for the rest of the session. If every candidate down to the floor fails, take the CLI fallback below.
|
|
71
72
|
|
|
72
|
-
Charter (the spawn prompt): the agent's role — standing reviewer, never edits files or runs commands, only answers via SendMessage — the repo path, and the session's current goal in two or three sentences. State plainly:
|
|
73
|
+
Charter (the spawn prompt): the agent's role — standing reviewer, never edits files or runs commands, only answers via SendMessage — the repo path, and the session's current goal in two or three sentences. On a Fable-tier attempt, include the exact token `FABLE-SPAWN-AUTHORIZED` as plain text in this prompt (substring match; the gate does not verify who placed it). State plainly:
|
|
73
74
|
- Every consult carries: who is asking (name and assignment), what changed since their last consult, the live decision or question, and any load-bearing paths or excerpts.
|
|
74
75
|
- Reply via SendMessage to whoever sent the consult, by name — never route a reply through the spawning session or "main." Many different consumers may reach this one agent; each reply goes back to its own sender.
|
|
75
76
|
- Treat each consult on its own terms, keyed to the sender's stated assignment. Different consumers' consults will interleave in this one transcript — don't blend context across consumers unless a consult explicitly asks for that.
|
|
@@ -112,7 +113,7 @@ Each paragraph is self-contained — the executor receives only this text, not t
|
|
|
112
113
|
|
|
113
114
|
The session that spawns the shared advisor owns its whole lifecycle — spawn, drift-respawn, and shutdown. Every other consumer (executors, or any other consulting session) only ever sends it messages; none of them spawn, respawn, or shut it down themselves. One shared advisor exists per orchestrated session, owned by the session that spawned it.
|
|
114
115
|
|
|
115
|
-
**Re-spawn on drift.** If a reply shows the agent working from a stale picture, or the session pivots to an unrelated task, the owning session ends that agent and spawns a fresh one with a new charter, rather than forcing the old context to stretch across two different jobs.
|
|
116
|
+
**Re-spawn on drift.** If a reply shows the agent working from a stale picture, or the session pivots to an unrelated task, the owning session ends that agent and spawns a fresh one with a new charter, rather than forcing the old context to stretch across two different jobs. A **Fable**-tier re-spawn carries the exact token `FABLE-SPAWN-AUTHORIZED` in that fresh prompt, as a Fable-tier warm-up attempt does.
|
|
116
117
|
|
|
117
118
|
### Third-party host
|
|
118
119
|
|
|
@@ -122,9 +123,18 @@ The orchestrating session owns the Claude CLI advisor bind for the whole run —
|
|
|
122
123
|
|
|
123
124
|
## CLI chain
|
|
124
125
|
|
|
125
|
-
The shared runner is `python "$HOME/.claude/scripts/claude_chain_runner.py" -- <claude args...>`.
|
|
126
|
+
The shared runner is `python "$HOME/.claude/scripts/claude_chain_runner.py" [--routing-mode usage_ranked|ordered_account] -- <claude args...>`.
|
|
126
127
|
|
|
127
|
-
|
|
128
|
+
| Mode | Flag | Walk order | Failover |
|
|
129
|
+
|---|---|---|---|
|
|
130
|
+
| Usage-ranked (default) | `--routing-mode usage_ranked` or omit the flag | Highest weekly remaining first (`claude_chain_usage` / usage-pause OAuth probe) | Usage-limit signature only |
|
|
131
|
+
| Ordered-account | `--routing-mode ordered_account` | Config list order in `~/.claude/claude-chain.json` | Usage-limit signature only; auth / timeout / config / other process errors → `advisor_blocked` |
|
|
132
|
+
|
|
133
|
+
**Third-party host root advisor bind and consult:** use **ordered-account** mode (see **Host profiles → Third-party host**). Primary launcher from the chain config is tried first; a usage-limit result advances to the next config entry; any non-usage failure terminates as `advisor_blocked`. Persist `session_id` from a successful bind and pass it to `-p --resume <session_id> --output-format json` on later consults.
|
|
134
|
+
|
|
135
|
+
**General chain calls** (non-root automation): keep the default usage-ranked mode so spare capacity on other accounts is preferred.
|
|
136
|
+
|
|
137
|
+
**Third-party host:** this runner is the **primary** advisor bind and consult path. Map each walk attempt to `--model <alias>` and the effort flags there. When the walk exhausts or returns `advisor_blocked`, fail closed.
|
|
128
138
|
|
|
129
139
|
**Claude host:** fall back to this runner when any of these holds, rather than on judgment call:
|
|
130
140
|
- The Agent-tool spawn errors at every candidate tier down to the floor — the tool itself, not just the top tier, is unavailable.
|
|
@@ -22,7 +22,7 @@ Each finding an audit produces MUST be one of exactly two shapes.
|
|
|
22
22
|
"id": "loop<L>-<K>",
|
|
23
23
|
"file": "path/relative/to/repo/root.py",
|
|
24
24
|
"line": 123,
|
|
25
|
-
"category": "A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P",
|
|
25
|
+
"category": "A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P | Q",
|
|
26
26
|
"severity": "P0 | P1 | P2",
|
|
27
27
|
"excerpt": "verbatim code snippet from the offending line(s)",
|
|
28
28
|
"failure_mode": "one sentence describing what goes wrong and when",
|
|
@@ -38,7 +38,7 @@ Used when an audit investigates a category and does NOT find a bug. Bare "verifi
|
|
|
38
38
|
|
|
39
39
|
```json
|
|
40
40
|
{
|
|
41
|
-
"category": "A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P",
|
|
41
|
+
"category": "A | B | C | D | E | F | G | H | I | J | K | L | M | N | O | P | Q",
|
|
42
42
|
"files_opened": ["file1.py", "file2.py"],
|
|
43
43
|
"lines_quoted": [
|
|
44
44
|
{"file": "file1.py", "line": 88, "text": "verbatim line content"}
|
|
@@ -108,7 +108,7 @@ Merge rules — applied whenever the LEAD combines findings from multiple source
|
|
|
108
108
|
- **Zero secondary findings**: the primary set is trusted and the audit moves on.
|
|
109
109
|
- **Malformed or non-parseable secondary output**: lead trusts the primary set and logs the event in `loop-<L>-diagnostics.json` under `haiku_findings` as `[{"parse_error": "<message>"}]`.
|
|
110
110
|
|
|
111
|
-
For `/bugteam`, the single audit agent provides per-category coverage by walking all A–
|
|
111
|
+
For `/bugteam`, the single audit agent provides per-category coverage by walking all A–Q rubrics in one invocation.
|
|
112
112
|
|
|
113
113
|
## Post-fix self-audit
|
|
114
114
|
|
|
@@ -121,7 +121,7 @@ Sequence:
|
|
|
121
121
|
3. Run `py_compile` (or language-equivalent) on each modified file.
|
|
122
122
|
4. Compute `fix_diff` against pre-fix contents for the modified set.
|
|
123
123
|
5. Run `_shared/pr-loop/scripts/code_rules_gate.py` with explicit paths for every modified file.
|
|
124
|
-
6. Spawn a scoped audit of `fix_diff` with full A–
|
|
124
|
+
6. Spawn a scoped audit of `fix_diff` with full A–Q rigor, Shape A/B contract, adversarial pass, AND Haiku secondary in parallel (paranoid mode on post-fix).
|
|
125
125
|
7. Read the previous loop's outcome XML (`<worktree_path>/.bugteam-pr<N>-loop<L-1>.outcomes.xml`) and obtain its total finding count. If this is the first loop (L <= 1) or the file does not exist, skip this comparison. Compute the post-fix total: previous total minus bugs fixed in this round plus new violations found in the post-fix audit (step 6). If the post-fix total exceeds the previous total, flag all new findings as same-loop fix-targets and revise. An increase in total findings across loop transitions is a regression.
|
|
126
126
|
8. Any new findings become same-loop fix-targets. Internal iteration count increments by one.
|
|
127
127
|
9. After 3 internal iterations with fresh findings each time, exit `stuck: post-fix audit not converging`.
|
|
@@ -54,7 +54,7 @@ The PR body and the diff describe the same change, both directions.
|
|
|
54
54
|
## Lane 5 — Adversarial audit
|
|
55
55
|
|
|
56
56
|
The bug-audit lens runs the adversarial second pass the audit contract specifies
|
|
57
|
-
(`~/.claude/_shared/pr-loop/audit-contract.md`): assume the first A-
|
|
57
|
+
(`~/.claude/_shared/pr-loop/audit-contract.md`): assume the first A-Q pass missed
|
|
58
58
|
at least three P1 bugs, then find them.
|
|
59
59
|
|
|
60
60
|
- Return new file:line findings for the bugs the first pass missed.
|
|
@@ -62,4 +62,4 @@ at least three P1 bugs, then find them.
|
|
|
62
62
|
and why it holds.
|
|
63
63
|
- A bare "nothing new" is not an acceptable result for this pass.
|
|
64
64
|
- **Proof of absence:** a per-category adversarial-probe entry for each re-examined
|
|
65
|
-
A-
|
|
65
|
+
A-Q category with no new finding.
|
|
@@ -73,10 +73,12 @@ plus the process exit code.
|
|
|
73
73
|
|
|
74
74
|
`resolve_worker_spawn(...)` takes keyword-only arguments:
|
|
75
75
|
`role`, `prompt_file`, `working_directory`, `timeout_seconds`,
|
|
76
|
-
`is_claude_tier_enabled`, `run_state_directory
|
|
76
|
+
`is_claude_tier_enabled`, `run_state_directory`.
|
|
77
77
|
`encode_spawn_outcome` turns a `SpawnOutcome` into the same JSON shape the CLI
|
|
78
78
|
prints.
|
|
79
79
|
|
|
80
|
+
The grok tier runs with no turn cap; `timeout_seconds` is its only bound.
|
|
81
|
+
|
|
80
82
|
## JSON result shape
|
|
81
83
|
|
|
82
84
|
Stdout is one JSON object:
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# _shared/process-tree
|
|
2
|
+
|
|
3
|
+
One home for ending a spawned process together with every descendant it
|
|
4
|
+
started. Callers that capture a CLI's output import it so a grandchild can
|
|
5
|
+
never outlive the run and hold the capture pipe open.
|
|
6
|
+
|
|
7
|
+
## Consumers
|
|
8
|
+
|
|
9
|
+
| Caller | Use |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `scripts/grok_headless_runner.py` | Kills a timed-out worker's tree between drain attempts |
|
|
12
|
+
| `skills/codex-review/scripts/run_codex_review.py` | Kills the review tree on timeout, then drains |
|
|
13
|
+
| `skills/codex-review/scripts/codex_usage_probe.py` | Tears the app-server tree down after the rate-limits exchange |
|
|
14
|
+
|
|
15
|
+
A skill script reaches this home the way the bugteam scripts reach
|
|
16
|
+
`_shared/pr-loop/scripts`: it puts the directory on `sys.path` and imports by
|
|
17
|
+
module name. `bin/install.mjs` copies `_shared` and `scripts` together, and
|
|
18
|
+
every install group that carries a consumer carries `_shared`, so the import
|
|
19
|
+
resolves in the repository and under `~/.claude` alike.
|
|
20
|
+
|
|
21
|
+
## Key files
|
|
22
|
+
|
|
23
|
+
| File | Purpose |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `scripts/process_tree_kill.py` | `terminate_process_tree` (poll, platform tree kill, re-poll, `Popen.kill()` fallback), `kill_process_tree_by_identifier`, and `should_start_new_session` for the matching `Popen` flag |
|
|
26
|
+
| `scripts/test_process_tree_kill.py` | Behavioral tests for every public entry point here, each platform branch, each failure the helper swallows, and the `Popen.kill()` fallback |
|
|
27
|
+
| `scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py` | The taskkill command, its `/T`, `/F`, and `/PID` flags, and the bound on the kill command |
|
|
28
|
+
| `scripts/pyproject.toml` | mypy configuration; `check.ps1` runs it under the `mypy-process-tree` label |
|
|
29
|
+
|
|
30
|
+
## Platform guard
|
|
31
|
+
|
|
32
|
+
Both the branch selector and the POSIX helper compare `sys.platform` against
|
|
33
|
+
the literal `"win32"`. That literal is what mypy narrows on: behind a named
|
|
34
|
+
constant, `os.getpgid`, `os.killpg`, and `signal.SIGKILL` fail type checking on
|
|
35
|
+
Windows.
|
|
36
|
+
|
|
37
|
+
## Pairing rule
|
|
38
|
+
|
|
39
|
+
`os.killpg` signals a whole process group, so a child sharing the caller's
|
|
40
|
+
group takes the caller down with it. Every `Popen` whose tree this module ends
|
|
41
|
+
passes `start_new_session=should_start_new_session()`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Named constants for the shared process-tree scripts."""
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Named constants for ``process_tree_kill.py``.
|
|
2
|
+
|
|
3
|
+
Every caller that ends a child process and its descendants reads the taskkill
|
|
4
|
+
command, its flags, and the bound on the kill command from here rather than
|
|
5
|
+
embedding the values in its own module.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
WINDOWS_TASKKILL_COMMAND: str = "taskkill"
|
|
11
|
+
"""Windows command that ends a process by id."""
|
|
12
|
+
|
|
13
|
+
WINDOWS_TASKKILL_TREE_FLAG: str = "/T"
|
|
14
|
+
"""``taskkill`` flag that extends the kill to every descendant process."""
|
|
15
|
+
|
|
16
|
+
WINDOWS_TASKKILL_FORCE_FLAG: str = "/F"
|
|
17
|
+
"""``taskkill`` flag that forces termination rather than requesting it."""
|
|
18
|
+
|
|
19
|
+
WINDOWS_TASKKILL_PID_FLAG: str = "/PID"
|
|
20
|
+
"""``taskkill`` flag that names the target process id."""
|
|
21
|
+
|
|
22
|
+
PROCESS_TREE_KILL_TIMEOUT_SECONDS: int = 10
|
|
23
|
+
"""Seconds allowed for the tree-kill command itself before it is abandoned.
|
|
24
|
+
|
|
25
|
+
Gates the kill command alone. Each caller sets its own bound on the drain that
|
|
26
|
+
follows the kill.
|
|
27
|
+
"""
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""End a spawned process and every descendant it started.
|
|
3
|
+
|
|
4
|
+
A CLI launched from a script often spawns its own children, and those
|
|
5
|
+
grandchildren hold the capture pipe open. Killing the direct child alone leaves
|
|
6
|
+
them running and the reading caller waiting on a pipe that never reaches
|
|
7
|
+
end-of-file. This module ends the whole tree instead::
|
|
8
|
+
|
|
9
|
+
terminate_process_tree(process) ok: grandchildren die, pipes close
|
|
10
|
+
process.kill() flag: grandchildren outlive the parent
|
|
11
|
+
|
|
12
|
+
Windows tears the tree down with ``taskkill /T``. POSIX signals the child's
|
|
13
|
+
process group. The group signal reaches only the intended tree when the child
|
|
14
|
+
leads its own session, so every caller pairs the two calls::
|
|
15
|
+
|
|
16
|
+
process = subprocess.Popen(
|
|
17
|
+
argv, start_new_session=should_start_new_session()
|
|
18
|
+
)
|
|
19
|
+
terminate_process_tree(process)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import signal
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
_config_directory = str(Path(__file__).resolve().parent / "config")
|
|
31
|
+
if _config_directory not in sys.path:
|
|
32
|
+
sys.path.insert(0, _config_directory)
|
|
33
|
+
|
|
34
|
+
from process_tree_scripts_constants.process_tree_kill_constants import ( # noqa: E402
|
|
35
|
+
PROCESS_TREE_KILL_TIMEOUT_SECONDS,
|
|
36
|
+
WINDOWS_TASKKILL_COMMAND,
|
|
37
|
+
WINDOWS_TASKKILL_FORCE_FLAG,
|
|
38
|
+
WINDOWS_TASKKILL_PID_FLAG,
|
|
39
|
+
WINDOWS_TASKKILL_TREE_FLAG,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
process_tree_subprocess_run = subprocess.run
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def should_start_new_session() -> bool:
|
|
46
|
+
"""Report whether a child should be launched as its own session leader.
|
|
47
|
+
|
|
48
|
+
POSIX ends a tree through ``os.killpg``, which signals every process in the
|
|
49
|
+
group. A child sharing the caller's group would take the caller down with
|
|
50
|
+
it, so the child leads a session of its own. Windows has no session flag
|
|
51
|
+
and ends the tree by process id, so the answer there is False.
|
|
52
|
+
"""
|
|
53
|
+
return sys.platform != "win32"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _kill_windows_process_tree(process_identifier: int) -> None:
|
|
57
|
+
"""End a Windows process and every descendant it started, by process id.
|
|
58
|
+
|
|
59
|
+
Swallows taskkill failures so the caller still falls back to
|
|
60
|
+
``Popen.kill()`` and its own drain. A raised ``TimeoutExpired`` here would
|
|
61
|
+
replace the caller's original timeout exception and skip that fallback.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
process_tree_subprocess_run(
|
|
65
|
+
[
|
|
66
|
+
WINDOWS_TASKKILL_COMMAND,
|
|
67
|
+
WINDOWS_TASKKILL_TREE_FLAG,
|
|
68
|
+
WINDOWS_TASKKILL_FORCE_FLAG,
|
|
69
|
+
WINDOWS_TASKKILL_PID_FLAG,
|
|
70
|
+
str(process_identifier),
|
|
71
|
+
],
|
|
72
|
+
stdout=subprocess.DEVNULL,
|
|
73
|
+
stderr=subprocess.DEVNULL,
|
|
74
|
+
check=False,
|
|
75
|
+
timeout=PROCESS_TREE_KILL_TIMEOUT_SECONDS,
|
|
76
|
+
)
|
|
77
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _kill_posix_process_group(process_identifier: int) -> None:
|
|
82
|
+
"""End a POSIX process group so no grandchild keeps the capture pipe open.
|
|
83
|
+
|
|
84
|
+
A process id that is already reaped, or one the caller may not signal,
|
|
85
|
+
raises ``OSError``; this swallows it and returns, so the caller falls back
|
|
86
|
+
to ``Popen.kill()``.
|
|
87
|
+
|
|
88
|
+
The leading literal ``sys.platform`` comparison is what lets mypy read the
|
|
89
|
+
rest of the body as POSIX-only. Under a named constant, ``os.getpgid``,
|
|
90
|
+
``os.killpg``, and ``signal.SIGKILL`` fail type checking on Windows.
|
|
91
|
+
"""
|
|
92
|
+
if sys.platform == "win32":
|
|
93
|
+
return
|
|
94
|
+
try:
|
|
95
|
+
process_group_identifier = os.getpgid(process_identifier)
|
|
96
|
+
os.killpg(process_group_identifier, signal.SIGKILL)
|
|
97
|
+
except OSError:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def kill_process_tree_by_identifier(process_identifier: int) -> None:
|
|
102
|
+
"""Issue the platform's tree kill for one process id, with no liveness check.
|
|
103
|
+
|
|
104
|
+
The platform test is written as a literal ``sys.platform`` comparison
|
|
105
|
+
because that is the form mypy narrows: under a named constant the
|
|
106
|
+
process-group calls in the POSIX branch fail type checking on Windows.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
process_identifier: The process id whose tree is ended.
|
|
110
|
+
"""
|
|
111
|
+
if sys.platform == "win32":
|
|
112
|
+
_kill_windows_process_tree(process_identifier)
|
|
113
|
+
return
|
|
114
|
+
_kill_posix_process_group(process_identifier)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def terminate_process_tree(process: subprocess.Popen[str]) -> None:
|
|
118
|
+
"""End a running process and every descendant it spawned.
|
|
119
|
+
|
|
120
|
+
::
|
|
121
|
+
|
|
122
|
+
already exited ok: returns, nothing killed
|
|
123
|
+
tree kill takes ok: grandchildren die, pipes close
|
|
124
|
+
tree kill misses the child ok: Popen.kill() ends the direct child
|
|
125
|
+
child exits mid-kill ok: the raised lookup error is swallowed
|
|
126
|
+
|
|
127
|
+
Falls back to ``Popen.kill()`` when the direct child survives the tree kill,
|
|
128
|
+
so the caller never waits on a live process.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
process: The process whose tree is ended.
|
|
132
|
+
"""
|
|
133
|
+
if process.poll() is not None:
|
|
134
|
+
return
|
|
135
|
+
kill_process_tree_by_identifier(process.pid)
|
|
136
|
+
if process.poll() is not None:
|
|
137
|
+
return
|
|
138
|
+
try:
|
|
139
|
+
process.kill()
|
|
140
|
+
except ProcessLookupError:
|
|
141
|
+
return
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# mypy configuration for the shared process-tree scripts.
|
|
2
|
+
#
|
|
3
|
+
# Scoped separately from hooks/pyproject.toml so this shared home and the hooks
|
|
4
|
+
# stay independent. check.ps1 invokes this via the mypy-process-tree tool label.
|
|
5
|
+
|
|
6
|
+
[tool.mypy]
|
|
7
|
+
mypy_path = ".:config"
|
|
8
|
+
python_version = "3.13"
|
|
9
|
+
ignore_missing_imports = true
|
|
10
|
+
warn_unused_ignores = true
|
|
11
|
+
warn_redundant_casts = true
|
|
12
|
+
no_implicit_optional = true
|
|
13
|
+
strict_equality = true
|
|
14
|
+
exclude = [
|
|
15
|
+
"test_",
|
|
16
|
+
]
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Behavioral tests for the shared process-tree kill helper.
|
|
2
|
+
|
|
3
|
+
Every test drives ``process_tree_kill`` itself: the platform branch it picks,
|
|
4
|
+
the taskkill argv it builds, the failures it swallows, and the
|
|
5
|
+
``Popen.kill()`` fallback it reaches when a tree kill leaves the child alive.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
|
|
17
|
+
if str(SCRIPTS_DIRECTORY) not in sys.path:
|
|
18
|
+
sys.path.insert(0, str(SCRIPTS_DIRECTORY))
|
|
19
|
+
|
|
20
|
+
import process_tree_kill # noqa: E402
|
|
21
|
+
from process_tree_kill import ( # noqa: E402
|
|
22
|
+
kill_process_tree_by_identifier,
|
|
23
|
+
terminate_process_tree,
|
|
24
|
+
)
|
|
25
|
+
from process_tree_scripts_constants.process_tree_kill_constants import ( # noqa: E402
|
|
26
|
+
PROCESS_TREE_KILL_TIMEOUT_SECONDS,
|
|
27
|
+
WINDOWS_TASKKILL_COMMAND,
|
|
28
|
+
WINDOWS_TASKKILL_FORCE_FLAG,
|
|
29
|
+
WINDOWS_TASKKILL_PID_FLAG,
|
|
30
|
+
WINDOWS_TASKKILL_TREE_FLAG,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
FAKE_PROCESS_IDENTIFIER = 4242
|
|
34
|
+
FAKE_PROCESS_GROUP_IDENTIFIER = 909
|
|
35
|
+
WINDOWS_PLATFORM = "win32"
|
|
36
|
+
POSIX_PLATFORM = "linux"
|
|
37
|
+
FAKE_KILL_SIGNAL = 9
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _FakeProcess:
|
|
41
|
+
"""Stands in for ``subprocess.Popen`` with a scripted liveness sequence.
|
|
42
|
+
|
|
43
|
+
``poll()`` returns each queued answer in turn, so a test names exactly what
|
|
44
|
+
the helper sees before the tree kill and after it.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
all_poll_results: list[int | None],
|
|
50
|
+
*,
|
|
51
|
+
kill_error: BaseException | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.pid = FAKE_PROCESS_IDENTIFIER
|
|
54
|
+
self._all_poll_results = list(all_poll_results)
|
|
55
|
+
self._kill_error = kill_error
|
|
56
|
+
self.kill_calls = 0
|
|
57
|
+
|
|
58
|
+
def poll(self) -> int | None:
|
|
59
|
+
if not self._all_poll_results:
|
|
60
|
+
return None
|
|
61
|
+
return self._all_poll_results.pop(0)
|
|
62
|
+
|
|
63
|
+
def kill(self) -> None:
|
|
64
|
+
self.kill_calls += 1
|
|
65
|
+
if self._kill_error is not None:
|
|
66
|
+
raise self._kill_error
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class _TreeKillRecorder:
|
|
70
|
+
"""Stands in for ``subprocess.run`` so no real taskkill leaves the test."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, raised_error: BaseException | None = None) -> None:
|
|
73
|
+
self.all_invocations: list[list[str]] = []
|
|
74
|
+
self.all_keyword_arguments: list[dict[str, object]] = []
|
|
75
|
+
self._raised_error = raised_error
|
|
76
|
+
|
|
77
|
+
def __call__(
|
|
78
|
+
self, invocation: list[str], **keyword_arguments: object
|
|
79
|
+
) -> subprocess.CompletedProcess[str]:
|
|
80
|
+
self.all_invocations.append(list(invocation))
|
|
81
|
+
self.all_keyword_arguments.append(dict(keyword_arguments))
|
|
82
|
+
if self._raised_error is not None:
|
|
83
|
+
raise self._raised_error
|
|
84
|
+
return subprocess.CompletedProcess(args=invocation, returncode=0)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _install_windows_platform(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
88
|
+
monkeypatch.setattr(process_tree_kill.sys, "platform", WINDOWS_PLATFORM)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _install_posix_platform(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
92
|
+
monkeypatch.setattr(process_tree_kill.sys, "platform", POSIX_PLATFORM)
|
|
93
|
+
monkeypatch.setattr(
|
|
94
|
+
process_tree_kill.signal, "SIGKILL", FAKE_KILL_SIGNAL, raising=False
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_windows_tree_kill_issues_the_taskkill_tree_argv(
|
|
99
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""The Windows branch runs ``taskkill /T /F /PID <pid>`` under its timeout."""
|
|
102
|
+
tree_kill_recorder = _TreeKillRecorder()
|
|
103
|
+
_install_windows_platform(monkeypatch)
|
|
104
|
+
monkeypatch.setattr(
|
|
105
|
+
process_tree_kill, "process_tree_subprocess_run", tree_kill_recorder
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
kill_process_tree_by_identifier(FAKE_PROCESS_IDENTIFIER)
|
|
109
|
+
|
|
110
|
+
assert tree_kill_recorder.all_invocations == [
|
|
111
|
+
[
|
|
112
|
+
WINDOWS_TASKKILL_COMMAND,
|
|
113
|
+
WINDOWS_TASKKILL_TREE_FLAG,
|
|
114
|
+
WINDOWS_TASKKILL_FORCE_FLAG,
|
|
115
|
+
WINDOWS_TASKKILL_PID_FLAG,
|
|
116
|
+
str(FAKE_PROCESS_IDENTIFIER),
|
|
117
|
+
]
|
|
118
|
+
]
|
|
119
|
+
assert (
|
|
120
|
+
tree_kill_recorder.all_keyword_arguments[0].get("timeout")
|
|
121
|
+
== PROCESS_TREE_KILL_TIMEOUT_SECONDS
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.mark.parametrize(
|
|
126
|
+
"raised_error",
|
|
127
|
+
[
|
|
128
|
+
subprocess.TimeoutExpired(
|
|
129
|
+
cmd=[WINDOWS_TASKKILL_COMMAND], timeout=PROCESS_TREE_KILL_TIMEOUT_SECONDS
|
|
130
|
+
),
|
|
131
|
+
OSError("taskkill is not on PATH"),
|
|
132
|
+
],
|
|
133
|
+
ids=["taskkill_times_out", "taskkill_cannot_launch"],
|
|
134
|
+
)
|
|
135
|
+
def test_windows_tree_kill_swallows_a_failing_taskkill(
|
|
136
|
+
monkeypatch: pytest.MonkeyPatch, raised_error: BaseException
|
|
137
|
+
) -> None:
|
|
138
|
+
"""A taskkill that hangs or cannot launch leaves the caller free to fall back."""
|
|
139
|
+
_install_windows_platform(monkeypatch)
|
|
140
|
+
monkeypatch.setattr(
|
|
141
|
+
process_tree_kill,
|
|
142
|
+
"process_tree_subprocess_run",
|
|
143
|
+
_TreeKillRecorder(raised_error),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
kill_process_tree_by_identifier(FAKE_PROCESS_IDENTIFIER)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_posix_tree_kill_signals_the_whole_process_group(
|
|
150
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""The POSIX branch resolves the child's group and signals the group, not the pid."""
|
|
153
|
+
all_group_signals: list[tuple[int, int]] = []
|
|
154
|
+
_install_posix_platform(monkeypatch)
|
|
155
|
+
monkeypatch.setattr(
|
|
156
|
+
process_tree_kill.os,
|
|
157
|
+
"getpgid",
|
|
158
|
+
lambda process_identifier: FAKE_PROCESS_GROUP_IDENTIFIER,
|
|
159
|
+
raising=False,
|
|
160
|
+
)
|
|
161
|
+
monkeypatch.setattr(
|
|
162
|
+
process_tree_kill.os,
|
|
163
|
+
"killpg",
|
|
164
|
+
lambda group_identifier, signal_number: all_group_signals.append(
|
|
165
|
+
(group_identifier, signal_number)
|
|
166
|
+
),
|
|
167
|
+
raising=False,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
kill_process_tree_by_identifier(FAKE_PROCESS_IDENTIFIER)
|
|
171
|
+
|
|
172
|
+
assert all_group_signals == [(FAKE_PROCESS_GROUP_IDENTIFIER, FAKE_KILL_SIGNAL)]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_posix_tree_kill_swallows_an_already_reaped_process(
|
|
176
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""A pid already reaped raises OSError; the caller still gets its fallback."""
|
|
179
|
+
|
|
180
|
+
def raise_no_such_process(process_identifier: int) -> int:
|
|
181
|
+
del process_identifier
|
|
182
|
+
raise ProcessLookupError("no such process")
|
|
183
|
+
|
|
184
|
+
_install_posix_platform(monkeypatch)
|
|
185
|
+
monkeypatch.setattr(
|
|
186
|
+
process_tree_kill.os, "getpgid", raise_no_such_process, raising=False
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
kill_process_tree_by_identifier(FAKE_PROCESS_IDENTIFIER)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_terminate_skips_a_process_that_already_exited(
|
|
193
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
194
|
+
) -> None:
|
|
195
|
+
"""An exited process needs no kill, so neither the tree kill nor kill() runs."""
|
|
196
|
+
tree_kill_recorder = _TreeKillRecorder()
|
|
197
|
+
_install_windows_platform(monkeypatch)
|
|
198
|
+
monkeypatch.setattr(
|
|
199
|
+
process_tree_kill, "process_tree_subprocess_run", tree_kill_recorder
|
|
200
|
+
)
|
|
201
|
+
exited_process = _FakeProcess([0])
|
|
202
|
+
|
|
203
|
+
terminate_process_tree(exited_process) # type: ignore[arg-type]
|
|
204
|
+
|
|
205
|
+
assert tree_kill_recorder.all_invocations == []
|
|
206
|
+
assert exited_process.kill_calls == 0
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_terminate_stops_after_a_tree_kill_that_took(
|
|
210
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
211
|
+
) -> None:
|
|
212
|
+
"""A tree kill that ends the child leaves no work for the kill() fallback."""
|
|
213
|
+
_install_windows_platform(monkeypatch)
|
|
214
|
+
monkeypatch.setattr(
|
|
215
|
+
process_tree_kill, "process_tree_subprocess_run", _TreeKillRecorder()
|
|
216
|
+
)
|
|
217
|
+
killed_process = _FakeProcess([None, 0])
|
|
218
|
+
|
|
219
|
+
terminate_process_tree(killed_process) # type: ignore[arg-type]
|
|
220
|
+
|
|
221
|
+
assert killed_process.kill_calls == 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_terminate_falls_back_to_direct_kill_when_the_tree_kill_misses(
|
|
225
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
226
|
+
) -> None:
|
|
227
|
+
"""A child alive after the tree kill is killed directly, so no caller waits on it.
|
|
228
|
+
|
|
229
|
+
::
|
|
230
|
+
|
|
231
|
+
taskkill raises, child still polls None ok: Popen.kill() runs
|
|
232
|
+
no fallback flag: Popen.__exit__ waits forever
|
|
233
|
+
"""
|
|
234
|
+
_install_windows_platform(monkeypatch)
|
|
235
|
+
monkeypatch.setattr(
|
|
236
|
+
process_tree_kill,
|
|
237
|
+
"process_tree_subprocess_run",
|
|
238
|
+
_TreeKillRecorder(OSError("taskkill is not on PATH")),
|
|
239
|
+
)
|
|
240
|
+
surviving_process = _FakeProcess([None, None])
|
|
241
|
+
|
|
242
|
+
terminate_process_tree(surviving_process) # type: ignore[arg-type]
|
|
243
|
+
|
|
244
|
+
assert surviving_process.kill_calls == 1
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def test_terminate_swallows_a_kill_on_a_process_that_just_exited(
|
|
248
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
249
|
+
) -> None:
|
|
250
|
+
"""A child that exits between the re-poll and the kill raises; the helper returns."""
|
|
251
|
+
_install_windows_platform(monkeypatch)
|
|
252
|
+
monkeypatch.setattr(
|
|
253
|
+
process_tree_kill, "process_tree_subprocess_run", _TreeKillRecorder()
|
|
254
|
+
)
|
|
255
|
+
racing_process = _FakeProcess(
|
|
256
|
+
[None, None], kill_error=ProcessLookupError("no such process")
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
terminate_process_tree(racing_process) # type: ignore[arg-type]
|
|
260
|
+
|
|
261
|
+
assert racing_process.kill_calls == 1
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def test_new_session_is_requested_off_windows_only(
|
|
265
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
266
|
+
) -> None:
|
|
267
|
+
"""A POSIX child gets its own session so ``killpg`` never reaches the parent.
|
|
268
|
+
|
|
269
|
+
::
|
|
270
|
+
|
|
271
|
+
posix ok: start_new_session True, child leads its own group
|
|
272
|
+
windows flag: start_new_session unsupported, so False
|
|
273
|
+
"""
|
|
274
|
+
_install_posix_platform(monkeypatch)
|
|
275
|
+
assert process_tree_kill.should_start_new_session() is True
|
|
276
|
+
|
|
277
|
+
_install_windows_platform(monkeypatch)
|
|
278
|
+
assert process_tree_kill.should_start_new_session() is False
|