engineering-process 1.0.1__tar.gz → 1.1.1__tar.gz
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.
- {engineering_process-1.0.1/engineering_process.egg-info → engineering_process-1.1.1}/PKG-INFO +67 -4
- {engineering_process-1.0.1 → engineering_process-1.1.1}/README.md +66 -3
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/__init__.py +1 -1
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/_supervisor_posix.py +93 -93
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/cli.py +55 -111
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/project.py +58 -1
- {engineering_process-1.0.1 → engineering_process-1.1.1/engineering_process.egg-info}/PKG-INFO +67 -4
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process.egg-info/SOURCES.txt +2 -1
- {engineering_process-1.0.1 → engineering_process-1.1.1}/process_assets/skills/finish-change/SKILL.md +5 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/process_assets/skills/implement-change/SKILL.md +6 -0
- engineering_process-1.1.1/process_assets/skills/improve-process/SKILL.md +75 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/process_assets/skills/plan-change/SKILL.md +6 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/process_assets/skills/review-change/SKILL.md +7 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/process_assets/skills/run-change/SKILL.md +10 -2
- {engineering_process-1.0.1 → engineering_process-1.1.1}/process_assets/skills/start-change/SKILL.md +8 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/process_assets/skills/verify-change/SKILL.md +6 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/pyproject.toml +1 -1
- {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/project.schema.json +58 -1
- {engineering_process-1.0.1 → engineering_process-1.1.1}/tests/test_automation.py +63 -2
- {engineering_process-1.0.1 → engineering_process-1.1.1}/tests/test_cli.py +22 -0
- engineering_process-1.1.1/tests/test_contracts.py +232 -0
- engineering_process-1.1.1/tests/test_skills.py +88 -0
- engineering_process-1.1.1/tests/test_supervisor_posix.py +112 -0
- engineering_process-1.0.1/process_assets/skills/improve-process/SKILL.md +0 -22
- engineering_process-1.0.1/tests/test_contracts.py +0 -67
- engineering_process-1.0.1/tests/test_skills.py +0 -53
- {engineering_process-1.0.1 → engineering_process-1.1.1}/LICENSE +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/MANIFEST.in +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/__main__.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/_supervisor_windows.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/_windows_job.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/adoption.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/commands.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/contracts.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/distribution.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/helper_launch.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/lifecycle.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/publication_compat.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/release.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/repository.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/requirements-dev.txt +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/requirements-runtime.txt +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/skills.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/supervision.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process.egg-info/dependency_links.txt +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process.egg-info/entry_points.txt +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process.egg-info/requires.txt +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process.egg-info/top_level.txt +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/process-graph.json +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/change.schema.json +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/plan.schema.json +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/process-graph.schema.json +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/process-lock.schema.json +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/project-legacy.schema.json +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/receipt.schema.json +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/release-change.schema.json +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/release.schema.json +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/review.schema.json +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/schemas/run.schema.json +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/setup.cfg +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/templates/AGENTS.process.md +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/templates/PULL_REQUEST_TEMPLATE.md +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/templates/adopt-process-windows-job.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/templates/adopt-process.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/tests/test_adoption.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/tests/test_architecture.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/tests/test_commands.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/tests/test_lifecycle.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/tests/test_publication_compat.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/tests/test_release.py +0 -0
- {engineering_process-1.0.1 → engineering_process-1.1.1}/tests/test_repository.py +0 -0
{engineering_process-1.0.1/engineering_process.egg-info → engineering_process-1.1.1}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: engineering-process
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.1
|
|
4
4
|
Summary: A small agent-neutral engineering lifecycle with managed adoption
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/phuongnse/engineering-process
|
|
@@ -92,10 +92,72 @@ Commands are argument arrays, never shell strings. Each command has a finite tim
|
|
|
92
92
|
Output has a hard aggregate budget; evidence stores byte counts and hashes, never raw
|
|
93
93
|
stdout or stderr that could contain secrets.
|
|
94
94
|
|
|
95
|
+
### Production readiness
|
|
96
|
+
|
|
97
|
+
A consumer declares `.process/readiness.json` with production as its direction, its
|
|
98
|
+
current stage, immutable pack versions, and the state of every required capability.
|
|
99
|
+
An enforced capability maps to project-required profiles; a planned capability names
|
|
100
|
+
the concrete gap without pretending to have evidence:
|
|
101
|
+
|
|
102
|
+
{
|
|
103
|
+
"target": "production",
|
|
104
|
+
"stage": "production",
|
|
105
|
+
"packs": [{"id": "library-cli", "version": 1}],
|
|
106
|
+
"capabilities": [
|
|
107
|
+
{"id": "correctness", "state": "enforced", "evidenceProfiles": ["development"]},
|
|
108
|
+
{"id": "runtime-safety", "state": "enforced", "evidenceProfiles": ["development"]},
|
|
109
|
+
{"id": "compatibility", "state": "enforced", "evidenceProfiles": ["development"]},
|
|
110
|
+
{"id": "portability", "state": "enforced", "evidenceProfiles": ["development", "review"]},
|
|
111
|
+
{"id": "installability", "state": "enforced", "evidenceProfiles": ["review"]},
|
|
112
|
+
{"id": "distribution-integrity", "state": "enforced", "evidenceProfiles": ["review"]},
|
|
113
|
+
{"id": "adoption-integrity", "state": "enforced", "evidenceProfiles": ["development", "review"]}
|
|
114
|
+
]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
`project validate` and `doctor` resolve that declaration to the exact checks owned by
|
|
118
|
+
the consumer. The declaration does not make a weak command sufficient: normal CI and
|
|
119
|
+
independent review still judge whether those commands prove the named capability. A
|
|
120
|
+
building consumer may keep planned gaps while ordinary development continues. A
|
|
121
|
+
production-stage declaration fails closed if any capability remains planned.
|
|
122
|
+
|
|
123
|
+
The sidecar is a deliberate self-hosting boundary. Public authority N continues to
|
|
124
|
+
validate the unchanged strict `.process/project.json` while source N+1 validates and
|
|
125
|
+
self-applies the new readiness contract. Adoption leaves the consumer-owned sidecar
|
|
126
|
+
in place, so every later authority can repeat the same forward-compatible sequence.
|
|
127
|
+
Pack versions are also immutable: a process update must keep `operations@1` working
|
|
128
|
+
even after `operations@2` exists. Process adoption and pack upgrades are separate
|
|
129
|
+
consumer-owned changes, preventing a new standard from deadlocking authority adoption.
|
|
130
|
+
|
|
131
|
+
`library-cli@1` was derived from this repository as a real producer and self-consumer.
|
|
132
|
+
`operations@1` was then derived from renovate-ops and requires auditability, automation
|
|
133
|
+
correctness, bounded execution, least privilege, policy integrity, recovery, and
|
|
134
|
+
target-selection integrity. `desktop-media@1` is derived from LyricRail and keeps its
|
|
135
|
+
existing correctness, input, source-portability, audit, media, package, and recovery-
|
|
136
|
+
mechanism evidence enforced. Stable dependency/recovery claims, signing, key custody,
|
|
137
|
+
runtime/license delivery, Linux advisory resolution, real-host workspace security,
|
|
138
|
+
updater, incident recovery, and independent security review remain planned.
|
|
139
|
+
Consumers without readiness remain compatible during that evidence-backed rollout.
|
|
140
|
+
|
|
141
|
+
For each ordinary change, `run-change` first surfaces this readiness view. The accepted
|
|
142
|
+
request and consumer rules determine which capabilities are affected. Every change
|
|
143
|
+
retains the project's baseline `requiredProfiles`; start and plan add any conditional
|
|
144
|
+
evidence profiles needed by affected capabilities and include a planned gap only when
|
|
145
|
+
the accepted request explicitly selects it. Implement and review protect the enforced
|
|
146
|
+
floor. A planned-to-enforced promotion is a reviewed consumer source diff with fresh
|
|
147
|
+
evidence. Unrelated planned gaps remain visible but do not block development, and no
|
|
148
|
+
skill chooses product priorities or changes readiness automatically.
|
|
149
|
+
|
|
150
|
+
When a consumer incident exposes a reusable process gap, `improve-process` first keeps
|
|
151
|
+
the consumer safe, then prepares a sanitized GitHub issue draft from that checkout.
|
|
152
|
+
It deduplicates by consumer/process-version/invariant, requires owner authorization
|
|
153
|
+
before `gh issue create`, and uses an accepted issue as the later process change source
|
|
154
|
+
and `consumerEvidence`. No producer clone, consumer-CI write token, automatic process
|
|
155
|
+
mutation, or wait for a process release is required to continue consumer development.
|
|
156
|
+
|
|
95
157
|
Pin the process in requirements/process.in:
|
|
96
158
|
|
|
97
159
|
--only-binary :all:
|
|
98
|
-
engineering-process==0.
|
|
160
|
+
engineering-process==1.0.1
|
|
99
161
|
|
|
100
162
|
Generate requirements/process.txt with hashes, install that lock, then run:
|
|
101
163
|
|
|
@@ -232,7 +294,7 @@ At any point:
|
|
|
232
294
|
## Release to consumer PR
|
|
233
295
|
|
|
234
296
|
Every opted-in consumer uses Renovate's pip-compile manager. Its engineering-process
|
|
235
|
-
package rule
|
|
297
|
+
package rule keeps the adoption pull request in draft and runs exactly:
|
|
236
298
|
|
|
237
299
|
python .process/adopt-process.py --project-root . --requirements-lock requirements/process.txt
|
|
238
300
|
|
|
@@ -247,7 +309,8 @@ The release workflow publishes exact wheel and sdist bytes to PyPI, verifies the
|
|
|
247
309
|
registry hashes, creates the immutable GitHub release, and sends one authenticated
|
|
248
310
|
engineering-process-published event to renovate-ops. That control plane runs Renovate
|
|
249
311
|
for each repository whose protected config explicitly opts in. Each consumer's normal
|
|
250
|
-
CI and independent review decide whether its PR can merge
|
|
312
|
+
CI and independent review decide whether its draft PR can merge; the consumer owner
|
|
313
|
+
authorizes that merge.
|
|
251
314
|
|
|
252
315
|
This repository opts in through .github/renovate.json, so it receives the same
|
|
253
316
|
adoption PR as every other consumer. See SELF_HOSTING.md and RELEASING.md.
|
|
@@ -68,10 +68,72 @@ Commands are argument arrays, never shell strings. Each command has a finite tim
|
|
|
68
68
|
Output has a hard aggregate budget; evidence stores byte counts and hashes, never raw
|
|
69
69
|
stdout or stderr that could contain secrets.
|
|
70
70
|
|
|
71
|
+
### Production readiness
|
|
72
|
+
|
|
73
|
+
A consumer declares `.process/readiness.json` with production as its direction, its
|
|
74
|
+
current stage, immutable pack versions, and the state of every required capability.
|
|
75
|
+
An enforced capability maps to project-required profiles; a planned capability names
|
|
76
|
+
the concrete gap without pretending to have evidence:
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
"target": "production",
|
|
80
|
+
"stage": "production",
|
|
81
|
+
"packs": [{"id": "library-cli", "version": 1}],
|
|
82
|
+
"capabilities": [
|
|
83
|
+
{"id": "correctness", "state": "enforced", "evidenceProfiles": ["development"]},
|
|
84
|
+
{"id": "runtime-safety", "state": "enforced", "evidenceProfiles": ["development"]},
|
|
85
|
+
{"id": "compatibility", "state": "enforced", "evidenceProfiles": ["development"]},
|
|
86
|
+
{"id": "portability", "state": "enforced", "evidenceProfiles": ["development", "review"]},
|
|
87
|
+
{"id": "installability", "state": "enforced", "evidenceProfiles": ["review"]},
|
|
88
|
+
{"id": "distribution-integrity", "state": "enforced", "evidenceProfiles": ["review"]},
|
|
89
|
+
{"id": "adoption-integrity", "state": "enforced", "evidenceProfiles": ["development", "review"]}
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
`project validate` and `doctor` resolve that declaration to the exact checks owned by
|
|
94
|
+
the consumer. The declaration does not make a weak command sufficient: normal CI and
|
|
95
|
+
independent review still judge whether those commands prove the named capability. A
|
|
96
|
+
building consumer may keep planned gaps while ordinary development continues. A
|
|
97
|
+
production-stage declaration fails closed if any capability remains planned.
|
|
98
|
+
|
|
99
|
+
The sidecar is a deliberate self-hosting boundary. Public authority N continues to
|
|
100
|
+
validate the unchanged strict `.process/project.json` while source N+1 validates and
|
|
101
|
+
self-applies the new readiness contract. Adoption leaves the consumer-owned sidecar
|
|
102
|
+
in place, so every later authority can repeat the same forward-compatible sequence.
|
|
103
|
+
Pack versions are also immutable: a process update must keep `operations@1` working
|
|
104
|
+
even after `operations@2` exists. Process adoption and pack upgrades are separate
|
|
105
|
+
consumer-owned changes, preventing a new standard from deadlocking authority adoption.
|
|
106
|
+
|
|
107
|
+
`library-cli@1` was derived from this repository as a real producer and self-consumer.
|
|
108
|
+
`operations@1` was then derived from renovate-ops and requires auditability, automation
|
|
109
|
+
correctness, bounded execution, least privilege, policy integrity, recovery, and
|
|
110
|
+
target-selection integrity. `desktop-media@1` is derived from LyricRail and keeps its
|
|
111
|
+
existing correctness, input, source-portability, audit, media, package, and recovery-
|
|
112
|
+
mechanism evidence enforced. Stable dependency/recovery claims, signing, key custody,
|
|
113
|
+
runtime/license delivery, Linux advisory resolution, real-host workspace security,
|
|
114
|
+
updater, incident recovery, and independent security review remain planned.
|
|
115
|
+
Consumers without readiness remain compatible during that evidence-backed rollout.
|
|
116
|
+
|
|
117
|
+
For each ordinary change, `run-change` first surfaces this readiness view. The accepted
|
|
118
|
+
request and consumer rules determine which capabilities are affected. Every change
|
|
119
|
+
retains the project's baseline `requiredProfiles`; start and plan add any conditional
|
|
120
|
+
evidence profiles needed by affected capabilities and include a planned gap only when
|
|
121
|
+
the accepted request explicitly selects it. Implement and review protect the enforced
|
|
122
|
+
floor. A planned-to-enforced promotion is a reviewed consumer source diff with fresh
|
|
123
|
+
evidence. Unrelated planned gaps remain visible but do not block development, and no
|
|
124
|
+
skill chooses product priorities or changes readiness automatically.
|
|
125
|
+
|
|
126
|
+
When a consumer incident exposes a reusable process gap, `improve-process` first keeps
|
|
127
|
+
the consumer safe, then prepares a sanitized GitHub issue draft from that checkout.
|
|
128
|
+
It deduplicates by consumer/process-version/invariant, requires owner authorization
|
|
129
|
+
before `gh issue create`, and uses an accepted issue as the later process change source
|
|
130
|
+
and `consumerEvidence`. No producer clone, consumer-CI write token, automatic process
|
|
131
|
+
mutation, or wait for a process release is required to continue consumer development.
|
|
132
|
+
|
|
71
133
|
Pin the process in requirements/process.in:
|
|
72
134
|
|
|
73
135
|
--only-binary :all:
|
|
74
|
-
engineering-process==0.
|
|
136
|
+
engineering-process==1.0.1
|
|
75
137
|
|
|
76
138
|
Generate requirements/process.txt with hashes, install that lock, then run:
|
|
77
139
|
|
|
@@ -208,7 +270,7 @@ At any point:
|
|
|
208
270
|
## Release to consumer PR
|
|
209
271
|
|
|
210
272
|
Every opted-in consumer uses Renovate's pip-compile manager. Its engineering-process
|
|
211
|
-
package rule
|
|
273
|
+
package rule keeps the adoption pull request in draft and runs exactly:
|
|
212
274
|
|
|
213
275
|
python .process/adopt-process.py --project-root . --requirements-lock requirements/process.txt
|
|
214
276
|
|
|
@@ -223,7 +285,8 @@ The release workflow publishes exact wheel and sdist bytes to PyPI, verifies the
|
|
|
223
285
|
registry hashes, creates the immutable GitHub release, and sends one authenticated
|
|
224
286
|
engineering-process-published event to renovate-ops. That control plane runs Renovate
|
|
225
287
|
for each repository whose protected config explicitly opts in. Each consumer's normal
|
|
226
|
-
CI and independent review decide whether its PR can merge
|
|
288
|
+
CI and independent review decide whether its draft PR can merge; the consumer owner
|
|
289
|
+
authorizes that merge.
|
|
227
290
|
|
|
228
291
|
This repository opts in through .github/renovate.json, so it receives the same
|
|
229
292
|
adoption PR as every other consumer. See SELF_HOSTING.md and RELEASING.md.
|
{engineering_process-1.0.1 → engineering_process-1.1.1}/engineering_process/_supervisor_posix.py
RENAMED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from contextlib import suppress
|
|
5
6
|
import ctypes
|
|
6
7
|
import os
|
|
7
8
|
from pathlib import Path
|
|
@@ -17,6 +18,8 @@ from .supervision import CleanupOutcome, NATURAL_DRAIN_GRACE_MILLISECONDS
|
|
|
17
18
|
|
|
18
19
|
PR_SET_CHILD_SUBREAPER = 36
|
|
19
20
|
RUN_ID_ENVIRONMENT_VARIABLE = "ENGINEERING_PROCESS_RUN_ID"
|
|
21
|
+
PROCESS_TABLE_TIMEOUT_SECONDS = 10
|
|
22
|
+
PROCESS_TABLE_OBSERVATION_INTERVAL_SECONDS = 0.05
|
|
20
23
|
_SUBREAPER_ENABLED = False
|
|
21
24
|
|
|
22
25
|
|
|
@@ -31,39 +34,44 @@ def _enable_subreaper() -> None:
|
|
|
31
34
|
_SUBREAPER_ENABLED = True
|
|
32
35
|
|
|
33
36
|
|
|
34
|
-
def
|
|
37
|
+
def _ps_process_table() -> tuple[dict[int, int], str | None]:
|
|
38
|
+
try:
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
["ps", "-axo", "pid=,ppid="],
|
|
41
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
42
|
+
timeout=PROCESS_TABLE_TIMEOUT_SECONDS, check=False, text=True,
|
|
43
|
+
)
|
|
44
|
+
except subprocess.TimeoutExpired:
|
|
45
|
+
return {}, f"process table snapshot timed out after {PROCESS_TABLE_TIMEOUT_SECONDS} seconds"
|
|
46
|
+
except OSError:
|
|
47
|
+
return {}, "process table snapshot could not start"
|
|
48
|
+
if result.returncode != 0:
|
|
49
|
+
return {}, f"process table snapshot exited with status {result.returncode}"
|
|
50
|
+
try:
|
|
51
|
+
rows = (line.split() for line in result.stdout.splitlines())
|
|
52
|
+
table = {int(pid): int(parent) for pid, parent in rows}
|
|
53
|
+
except (TypeError, ValueError):
|
|
54
|
+
return {}, "process table snapshot was malformed"
|
|
55
|
+
if os.getpid() not in table:
|
|
56
|
+
return {}, "process table snapshot was incomplete"
|
|
57
|
+
return table, None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _process_table() -> tuple[dict[int, int], str | None]:
|
|
35
61
|
table: dict[int, int] = {}
|
|
36
62
|
if Path("/proc").is_dir():
|
|
37
63
|
for entry in Path("/proc").iterdir():
|
|
38
64
|
if not entry.name.isdecimal():
|
|
39
65
|
continue
|
|
40
66
|
try:
|
|
41
|
-
for line in (entry / "status").read_text(
|
|
42
|
-
encoding="utf-8", errors="replace"
|
|
43
|
-
).splitlines():
|
|
67
|
+
for line in (entry / "status").read_text(encoding="utf-8", errors="replace").splitlines():
|
|
44
68
|
if line.startswith("PPid:"):
|
|
45
69
|
table[int(entry.name)] = int(line.split()[1])
|
|
46
70
|
break
|
|
47
71
|
except (OSError, ValueError, IndexError):
|
|
48
72
|
continue
|
|
49
|
-
return table
|
|
50
|
-
|
|
51
|
-
["ps", "-axo", "pid=,ppid="],
|
|
52
|
-
stdin=subprocess.DEVNULL,
|
|
53
|
-
stdout=subprocess.PIPE,
|
|
54
|
-
stderr=subprocess.DEVNULL,
|
|
55
|
-
timeout=2,
|
|
56
|
-
check=False,
|
|
57
|
-
text=True,
|
|
58
|
-
)
|
|
59
|
-
if result.returncode == 0:
|
|
60
|
-
for line in result.stdout.splitlines():
|
|
61
|
-
try:
|
|
62
|
-
pid, parent = (int(value) for value in line.split())
|
|
63
|
-
except (ValueError, TypeError):
|
|
64
|
-
continue
|
|
65
|
-
table[pid] = parent
|
|
66
|
-
return table
|
|
73
|
+
return table, None
|
|
74
|
+
return _ps_process_table()
|
|
67
75
|
|
|
68
76
|
|
|
69
77
|
def _descendants(root: int, table: Mapping[int, int]) -> set[int]:
|
|
@@ -100,10 +108,8 @@ def _pid_alive(pid: int) -> bool:
|
|
|
100
108
|
try:
|
|
101
109
|
state = status.read_text(encoding="utf-8").rsplit(")", 1)[1].split()[0]
|
|
102
110
|
if state == "Z":
|
|
103
|
-
|
|
111
|
+
with suppress(OSError):
|
|
104
112
|
os.waitpid(pid, os.WNOHANG)
|
|
105
|
-
except OSError:
|
|
106
|
-
pass
|
|
107
113
|
return False
|
|
108
114
|
except (OSError, IndexError):
|
|
109
115
|
pass
|
|
@@ -111,11 +117,9 @@ def _pid_alive(pid: int) -> bool:
|
|
|
111
117
|
|
|
112
118
|
|
|
113
119
|
def _process_group_exists(process_group: int) -> bool:
|
|
114
|
-
|
|
120
|
+
with suppress(OSError):
|
|
115
121
|
while os.waitpid(-process_group, os.WNOHANG)[0]:
|
|
116
122
|
pass
|
|
117
|
-
except OSError:
|
|
118
|
-
pass
|
|
119
123
|
try:
|
|
120
124
|
os.killpg(process_group, 0)
|
|
121
125
|
except ProcessLookupError:
|
|
@@ -127,10 +131,8 @@ def _process_group_exists(process_group: int) -> bool:
|
|
|
127
131
|
|
|
128
132
|
def _signal_processes(processes: set[int], signal_number: int) -> None:
|
|
129
133
|
for pid in processes:
|
|
130
|
-
|
|
134
|
+
with suppress(OSError):
|
|
131
135
|
os.kill(pid, signal_number)
|
|
132
|
-
except OSError:
|
|
133
|
-
pass
|
|
134
136
|
|
|
135
137
|
|
|
136
138
|
def _wait_for_process_group(process_group: int, timeout: float) -> bool:
|
|
@@ -145,20 +147,12 @@ def _wait_for_process_group(process_group: int, timeout: float) -> bool:
|
|
|
145
147
|
def _terminate_group(process_group: int, grace_seconds: float) -> CleanupOutcome:
|
|
146
148
|
if not _process_group_exists(process_group):
|
|
147
149
|
return CleanupOutcome(bounded=True)
|
|
148
|
-
|
|
150
|
+
with suppress(OSError):
|
|
149
151
|
os.killpg(process_group, signal.SIGTERM)
|
|
150
|
-
except ProcessLookupError:
|
|
151
|
-
return CleanupOutcome(bounded=True, descendants_found=True)
|
|
152
|
-
except OSError:
|
|
153
|
-
pass
|
|
154
152
|
if _wait_for_process_group(process_group, grace_seconds):
|
|
155
153
|
return CleanupOutcome(bounded=True, descendants_found=True)
|
|
156
|
-
|
|
154
|
+
with suppress(OSError):
|
|
157
155
|
os.killpg(process_group, signal.SIGKILL)
|
|
158
|
-
except ProcessLookupError:
|
|
159
|
-
return CleanupOutcome(bounded=True, descendants_found=True)
|
|
160
|
-
except OSError:
|
|
161
|
-
pass
|
|
162
156
|
bounded = _wait_for_process_group(process_group, grace_seconds)
|
|
163
157
|
return CleanupOutcome(
|
|
164
158
|
bounded=bounded,
|
|
@@ -167,10 +161,24 @@ def _terminate_group(process_group: int, grace_seconds: float) -> CleanupOutcome
|
|
|
167
161
|
)
|
|
168
162
|
|
|
169
163
|
|
|
164
|
+
def _terminate_root(process: subprocess.Popen[bytes], grace_seconds: float) -> bool:
|
|
165
|
+
for signal_number in (signal.SIGTERM, signal.SIGKILL):
|
|
166
|
+
with suppress(OSError):
|
|
167
|
+
os.killpg(process.pid, signal_number)
|
|
168
|
+
try:
|
|
169
|
+
process.wait(timeout=grace_seconds)
|
|
170
|
+
return True
|
|
171
|
+
except subprocess.TimeoutExpired:
|
|
172
|
+
pass
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
170
176
|
class PosixProcessSupervisor:
|
|
171
177
|
def __init__(self) -> None:
|
|
172
178
|
self._known_descendants: dict[int, set[int]] = {}
|
|
173
179
|
self._run_ids: dict[int, str] = {}
|
|
180
|
+
self._last_observation: dict[int, float] = {}
|
|
181
|
+
self._observation_errors: dict[int, str] = {}
|
|
174
182
|
|
|
175
183
|
def resolve_application(
|
|
176
184
|
self,
|
|
@@ -234,37 +242,43 @@ class PosixProcessSupervisor:
|
|
|
234
242
|
)
|
|
235
243
|
self._known_descendants[process.pid] = set()
|
|
236
244
|
self._run_ids[process.pid] = run_id
|
|
237
|
-
self.
|
|
245
|
+
self._observation_errors.pop(process.pid, None)
|
|
246
|
+
self._observe(process, force=True)
|
|
238
247
|
return process
|
|
239
248
|
|
|
240
|
-
def
|
|
241
|
-
|
|
249
|
+
def _observe(self, process: subprocess.Popen[bytes], *, force: bool) -> None:
|
|
250
|
+
now = time.monotonic()
|
|
251
|
+
if not force and now - self._last_observation.get(process.pid, 0) < PROCESS_TABLE_OBSERVATION_INTERVAL_SECONDS:
|
|
252
|
+
return
|
|
253
|
+
table, error = _process_table()
|
|
254
|
+
self._last_observation[process.pid] = time.monotonic()
|
|
242
255
|
known = self._known_descendants.setdefault(process.pid, set())
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
256
|
+
if error is not None:
|
|
257
|
+
self._observation_errors.setdefault(process.pid, error)
|
|
258
|
+
else:
|
|
259
|
+
known.update(_descendants(process.pid, table))
|
|
260
|
+
run_id = self._run_ids.get(process.pid)
|
|
261
|
+
if sys.platform.startswith("linux") and run_id is not None:
|
|
262
|
+
known.update(
|
|
263
|
+
pid
|
|
264
|
+
for pid, parent in table.items()
|
|
265
|
+
if parent == os.getpid()
|
|
251
266
|
and pid != process.pid
|
|
252
267
|
and _has_run_id(pid, run_id)
|
|
253
268
|
)
|
|
254
|
-
}
|
|
255
|
-
known.update(adopted)
|
|
256
269
|
|
|
257
|
-
def
|
|
258
|
-
self,
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
self.observe(process)
|
|
270
|
+
def observe(self, process: subprocess.Popen[bytes]) -> None:
|
|
271
|
+
self._observe(process, force=False)
|
|
272
|
+
|
|
273
|
+
def _terminate_detached(self, process: subprocess.Popen[bytes], grace_seconds: float) -> CleanupOutcome:
|
|
274
|
+
self._observe(process, force=True)
|
|
263
275
|
candidates = self._known_descendants.pop(process.pid, set())
|
|
264
276
|
self._run_ids.pop(process.pid, None)
|
|
277
|
+
self._last_observation.pop(process.pid, None)
|
|
278
|
+
observation_error = self._observation_errors.pop(process.pid, None)
|
|
265
279
|
live = {pid for pid in candidates if _pid_alive(pid)}
|
|
266
280
|
if not live:
|
|
267
|
-
return CleanupOutcome(bounded=
|
|
281
|
+
return CleanupOutcome(bounded=observation_error is None, error=observation_error)
|
|
268
282
|
_signal_processes(live, signal.SIGTERM)
|
|
269
283
|
deadline = time.monotonic() + grace_seconds
|
|
270
284
|
while time.monotonic() < deadline and any(_pid_alive(pid) for pid in live):
|
|
@@ -281,11 +295,16 @@ class PosixProcessSupervisor:
|
|
|
281
295
|
os.waitpid(pid, os.WNOHANG)
|
|
282
296
|
except (ChildProcessError, OSError):
|
|
283
297
|
pass
|
|
284
|
-
|
|
298
|
+
descendants_bounded = not any(_pid_alive(pid) for pid in live)
|
|
299
|
+
error = observation_error or (
|
|
300
|
+
None
|
|
301
|
+
if descendants_bounded
|
|
302
|
+
else "detached descendants survived bounded termination"
|
|
303
|
+
)
|
|
285
304
|
return CleanupOutcome(
|
|
286
|
-
bounded=
|
|
305
|
+
bounded=descendants_bounded and error is None,
|
|
287
306
|
descendants_found=True,
|
|
288
|
-
error=
|
|
307
|
+
error=error,
|
|
289
308
|
)
|
|
290
309
|
|
|
291
310
|
def terminate(
|
|
@@ -294,34 +313,15 @@ class PosixProcessSupervisor:
|
|
|
294
313
|
*,
|
|
295
314
|
grace_seconds: float,
|
|
296
315
|
) -> CleanupOutcome:
|
|
297
|
-
self.
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
except subprocess.TimeoutExpired:
|
|
307
|
-
try:
|
|
308
|
-
os.killpg(process.pid, signal.SIGKILL)
|
|
309
|
-
except ProcessLookupError:
|
|
310
|
-
pass
|
|
311
|
-
except OSError:
|
|
312
|
-
pass
|
|
313
|
-
try:
|
|
314
|
-
process.wait(timeout=grace_seconds)
|
|
315
|
-
except subprocess.TimeoutExpired:
|
|
316
|
-
detached = self._terminate_detached(process, grace_seconds)
|
|
317
|
-
return CleanupOutcome(
|
|
318
|
-
bounded=False,
|
|
319
|
-
descendants_found=detached.descendants_found,
|
|
320
|
-
error=(
|
|
321
|
-
"command root process survived bounded process-group termination"
|
|
322
|
-
+ (f"; {detached.error}" if detached.error else "")
|
|
323
|
-
),
|
|
324
|
-
)
|
|
316
|
+
self._observe(process, force=True)
|
|
317
|
+
if not _terminate_root(process, grace_seconds):
|
|
318
|
+
detached = self._terminate_detached(process, grace_seconds)
|
|
319
|
+
error = "command root process survived bounded process-group termination"
|
|
320
|
+
return CleanupOutcome(
|
|
321
|
+
bounded=False,
|
|
322
|
+
descendants_found=detached.descendants_found,
|
|
323
|
+
error=error + (f"; {detached.error}" if detached.error else ""),
|
|
324
|
+
)
|
|
325
325
|
descendants = _terminate_group(process.pid, grace_seconds)
|
|
326
326
|
detached = self._terminate_detached(process, grace_seconds)
|
|
327
327
|
return CleanupOutcome(
|
|
@@ -342,7 +342,7 @@ class PosixProcessSupervisor:
|
|
|
342
342
|
# alive, even after the original process has exited. Give descendants that
|
|
343
343
|
# were synchronously asked to stop one short bounded interval to disappear
|
|
344
344
|
# naturally before classifying them as abandoned background work.
|
|
345
|
-
self.
|
|
345
|
+
self._observe(process, force=True)
|
|
346
346
|
if not _process_group_exists(process.pid):
|
|
347
347
|
return self._terminate_detached(process, grace_seconds)
|
|
348
348
|
natural_drain_seconds = min(
|