alissa-tools-github-devloop 0.8.46__py3-none-any.whl → 0.8.48__py3-none-any.whl
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.
- alissa/tools/github/devloop/config.py +41 -0
- alissa/tools/github/devloop/hungtests.py +481 -0
- alissa/tools/github/devloop/integration_check.py +423 -0
- alissa/tools/github/devloop/loop.py +570 -14
- alissa/tools/github/devloop/version +1 -1
- {alissa_tools_github_devloop-0.8.46.dist-info → alissa_tools_github_devloop-0.8.48.dist-info}/METADATA +41 -2
- {alissa_tools_github_devloop-0.8.46.dist-info → alissa_tools_github_devloop-0.8.48.dist-info}/RECORD +12 -10
- {alissa_tools_github_devloop-0.8.46.dist-info → alissa_tools_github_devloop-0.8.48.dist-info}/WHEEL +1 -1
- {alissa_tools_github_devloop-0.8.46.dist-info → alissa_tools_github_devloop-0.8.48.dist-info}/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.46.dist-info → alissa_tools_github_devloop-0.8.48.dist-info}/licenses/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.46.dist-info → alissa_tools_github_devloop-0.8.48.dist-info}/licenses/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.46.dist-info → alissa_tools_github_devloop-0.8.48.dist-info}/top_level.txt +0 -0
|
@@ -132,6 +132,36 @@ ENV_REVERT_LABEL = "ALISSA_REVERT_LABEL"
|
|
|
132
132
|
# worker's worktree as part of the session; the daemon never executes it.
|
|
133
133
|
ENV_REGENERATE = "ALISSA_DEV_REGENERATE"
|
|
134
134
|
|
|
135
|
+
# The hung-test reaper's window (issue #174): minutes a worker's test-runner
|
|
136
|
+
# tree may sit at under `hungtests.MIN_PROGRESS_SECONDS` of CPU growth before
|
|
137
|
+
# the daemon kills it. Env-only, read straight from the environment like
|
|
138
|
+
# ENV_REGENERATE: not a config key, not a build ARG. Default 20; 0 turns the
|
|
139
|
+
# reaper off; blank falls through to the default; anything that is not a
|
|
140
|
+
# non-negative integer is a config error at boot, by name -- a typo silently
|
|
141
|
+
# read as "off" would leave a 55 h hung suite exactly where it was.
|
|
142
|
+
ENV_HUNG_TEST_MINUTES = "ALISSA_DEV_HUNG_TEST_MINUTES"
|
|
143
|
+
DEFAULT_HUNG_TEST_MINUTES = 20
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parse_hung_test_minutes(raw: str) -> int:
|
|
147
|
+
"""The ALISSA_DEV_HUNG_TEST_MINUTES value as minutes (0 = off)."""
|
|
148
|
+
text = (raw or "").strip()
|
|
149
|
+
if not text:
|
|
150
|
+
return DEFAULT_HUNG_TEST_MINUTES
|
|
151
|
+
try:
|
|
152
|
+
minutes = int(text)
|
|
153
|
+
except ValueError:
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"{ENV_HUNG_TEST_MINUTES} must be a whole number of minutes "
|
|
156
|
+
f"(0 = off), got {raw!r}"
|
|
157
|
+
) from None
|
|
158
|
+
if minutes < 0:
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"{ENV_HUNG_TEST_MINUTES} must be >= 0 (0 = off), got {minutes}"
|
|
161
|
+
)
|
|
162
|
+
return minutes
|
|
163
|
+
|
|
164
|
+
|
|
135
165
|
# `owner/repo` for a regenerate entry: exactly one `/`, no whitespace, and no
|
|
136
166
|
# `:` (the separator that ends it). Shape only, like the actor-id check.
|
|
137
167
|
_REGENERATE_REPO_RE = re.compile(r"^[^\s/:]+/[^\s/:]+\Z")
|
|
@@ -857,6 +887,12 @@ class Config:
|
|
|
857
887
|
# Read through `regenerate_for(owner, repo)`, never executed here.
|
|
858
888
|
regenerate: tuple[RegenerateRule, ...] = ()
|
|
859
889
|
|
|
890
|
+
# The hung-test reaper's window in minutes (issue #174), from
|
|
891
|
+
# ALISSA_DEV_HUNG_TEST_MINUTES only (no config key, no CLI flag). A
|
|
892
|
+
# worker's `npm test` / `tsx --test` tree whose CPU grows by under 5 s
|
|
893
|
+
# across this window is killed; 0 turns the reaper off.
|
|
894
|
+
hung_test_minutes: int = DEFAULT_HUNG_TEST_MINUTES
|
|
895
|
+
|
|
860
896
|
dry_run: bool = False
|
|
861
897
|
|
|
862
898
|
# Derived at build time: `repos` casefolded for matching. GitHub names are
|
|
@@ -1118,6 +1154,10 @@ class Config:
|
|
|
1118
1154
|
# The regenerable-paths map (issue #138): env-only, validated here so
|
|
1119
1155
|
# a malformed entry fails the boot by name instead of a directive.
|
|
1120
1156
|
regenerate = parse_regenerate(env_map.get(ENV_REGENERATE, ""))
|
|
1157
|
+
# The hung-test window (issue #174): env-only, validated here.
|
|
1158
|
+
hung_test_minutes = parse_hung_test_minutes(
|
|
1159
|
+
env_map.get(ENV_HUNG_TEST_MINUTES, "")
|
|
1160
|
+
)
|
|
1121
1161
|
|
|
1122
1162
|
bow_owners = normalize_bow_owners(raw.get("bow_owners", cls.bow_owners))
|
|
1123
1163
|
|
|
@@ -1430,6 +1470,7 @@ class Config:
|
|
|
1430
1470
|
),
|
|
1431
1471
|
disk_warn_percent=warn_percent,
|
|
1432
1472
|
regenerate=regenerate,
|
|
1473
|
+
hung_test_minutes=hung_test_minutes,
|
|
1433
1474
|
dry_run=bool(raw.get("dry_run", cls.dry_run)),
|
|
1434
1475
|
)
|
|
1435
1476
|
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""The hung-test reaper's mechanics (issue #174).
|
|
2
|
+
|
|
3
|
+
On 2026-10-18 the studio devloop container ran at load ~15 while four worker
|
|
4
|
+
`npm test` runs sat hung, each at about 10 CPU-seconds in total -- one of them
|
|
5
|
+
for 55 hours, its lane long merged. Their test workers had died (esbuild's
|
|
6
|
+
"The service was stopped", EAGAIN with the box out of processes), the
|
|
7
|
+
`tsx --test` runner then waited forever, and the sessions waited on it in an
|
|
8
|
+
`until grep -q '^EXIT=' suite.log` loop. Nothing in the fleet noticed: a
|
|
9
|
+
session waiting on a log reads ALIVE to every check the daemon has.
|
|
10
|
+
|
|
11
|
+
What lives here is the janitor's split again -- only the MECHANICS: which
|
|
12
|
+
process trees are worker test runs (`find_test_trees`), whether one has made
|
|
13
|
+
CPU progress across a window (`CpuWatch`), how to stop one (`kill_tree`), and
|
|
14
|
+
how to tell its session (`message_session`). The POLICY -- which hung or
|
|
15
|
+
orphaned tree to kill, and when -- lives in `loop.py` next to the janitor
|
|
16
|
+
whose predicates it shares.
|
|
17
|
+
|
|
18
|
+
The properties everything here is written around:
|
|
19
|
+
|
|
20
|
+
* Nothing outside a task workspace is ever a candidate. A tree is admitted
|
|
21
|
+
only when its cwd or one of its args sits inside a watched hub's task
|
|
22
|
+
workspace, judged by the janitor's own grammar (`parse_task_workspace`), so
|
|
23
|
+
`main/`, `.source/`, an operator's shell and the daemon itself are never
|
|
24
|
+
CONSIDERED -- the property the tests pin, rather than "checked and spared".
|
|
25
|
+
* CPU progress, not wall-clock, is the hang signal. A full suite that runs for
|
|
26
|
+
an hour is fine; a runner that burned under `MIN_PROGRESS_SECONDS` of CPU
|
|
27
|
+
over the whole window is waiting on something that is not coming back.
|
|
28
|
+
* Nothing raises past its own concern. A process that exits mid-read, an
|
|
29
|
+
unreadable `/proc` entry, a signal to a pid that is already gone -- all are
|
|
30
|
+
skipped, because this runs inside a poll pass.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import logging
|
|
37
|
+
import os
|
|
38
|
+
import signal
|
|
39
|
+
import time
|
|
40
|
+
from dataclasses import dataclass, field
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Callable, Iterable, Mapping, Sequence
|
|
43
|
+
|
|
44
|
+
from .janitor import TaskWorkspace, parse_task_workspace
|
|
45
|
+
from .proc import CommandError, run
|
|
46
|
+
|
|
47
|
+
log = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
# Where the process table is read. A module attribute (read at call time,
|
|
50
|
+
# never bound as a default) so the tests point it at a fake tree.
|
|
51
|
+
PROC_ROOT = Path("/proc")
|
|
52
|
+
|
|
53
|
+
# A tree whose summed CPU time grows by less than this across the window is
|
|
54
|
+
# hung. The incident's trees sat at ~10 CPU-seconds TOTAL over hours; a live
|
|
55
|
+
# runner burns this much in seconds.
|
|
56
|
+
MIN_PROGRESS_SECONDS = 5.0
|
|
57
|
+
|
|
58
|
+
# TERM, then KILL whatever is still alive this long after.
|
|
59
|
+
KILL_GRACE_SECONDS = 10.0
|
|
60
|
+
|
|
61
|
+
# How often the TERM→KILL wait re-reads the tree.
|
|
62
|
+
_KILL_POLL_SECONDS = 0.5
|
|
63
|
+
|
|
64
|
+
# The env a managed seat exports (alissa CLI): the tmux session it runs in and
|
|
65
|
+
# the Alissa session id the session-message path addresses. Inherited by every
|
|
66
|
+
# process the seat starts, detached ones included -- which is why they are
|
|
67
|
+
# read off the tree rather than its ancestry: a `nohup npm test &` whose shell
|
|
68
|
+
# exited is reparented to init and has no ancestor pane at all.
|
|
69
|
+
ENV_TMUX_SESSION = "ALISSA_TMUX_SESSION"
|
|
70
|
+
ENV_SESSION_ID = "ALISSA_SESSION_ID"
|
|
71
|
+
|
|
72
|
+
# The prefix the CLI puts on a managed session's real tmux name; the roster
|
|
73
|
+
# (`alissa tmux ls --json`'s `name`) carries it bare.
|
|
74
|
+
TMUX_PREFIX = "ali-"
|
|
75
|
+
|
|
76
|
+
# `npm test` and its aliases, plus `npm run test` / `npm run-script test`.
|
|
77
|
+
_NPM_BINARIES = frozenset({"npm", "npm-cli.js"})
|
|
78
|
+
_NPM_TEST = frozenset({"test", "t", "tst"})
|
|
79
|
+
_NPM_RUN = frozenset({"run", "run-script", "rum", "urn"})
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class Proc:
|
|
84
|
+
"""One process, as `/proc/<pid>` described it at read time.
|
|
85
|
+
|
|
86
|
+
`cpu` is utime+stime PLUS cutime+cstime, in seconds: the reaped children's
|
|
87
|
+
time is the runner's progress too. A runner that executes one test file
|
|
88
|
+
after another reaps each child as it finishes; without the children's
|
|
89
|
+
share the tree's sum would DROP between samples while it works."""
|
|
90
|
+
|
|
91
|
+
pid: int
|
|
92
|
+
ppid: int
|
|
93
|
+
state: str
|
|
94
|
+
start: int
|
|
95
|
+
cpu: float
|
|
96
|
+
argv: "tuple[str, ...]"
|
|
97
|
+
cwd: "str | None"
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def alive(self) -> bool:
|
|
101
|
+
return self.state not in ("Z", "X")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class TestTree:
|
|
106
|
+
"""One worker test run: the topmost runner process and every descendant,
|
|
107
|
+
with the task workspace it was admitted under."""
|
|
108
|
+
|
|
109
|
+
root: Proc
|
|
110
|
+
members: "tuple[Proc, ...]"
|
|
111
|
+
workspace: TaskWorkspace
|
|
112
|
+
tmux_session: "str | None" = None
|
|
113
|
+
session_id: "str | None" = None
|
|
114
|
+
age: float = 0.0
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def key(self) -> "tuple[int, int]":
|
|
118
|
+
"""(pid, start ticks): a pid alone is reused; the pair is not."""
|
|
119
|
+
return (self.root.pid, self.root.start)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def cpu(self) -> float:
|
|
123
|
+
return sum(member.cpu for member in self.members)
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def session(self) -> "str | None":
|
|
127
|
+
"""The owning session's roster name (no `ali-` prefix), or None."""
|
|
128
|
+
name = self.tmux_session
|
|
129
|
+
if not name:
|
|
130
|
+
return None
|
|
131
|
+
return name[len(TMUX_PREFIX):] if name.startswith(TMUX_PREFIX) else name
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def command(self) -> str:
|
|
135
|
+
return " ".join(self.root.argv)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _clock_ticks() -> int:
|
|
139
|
+
try:
|
|
140
|
+
return int(os.sysconf("SC_CLK_TCK")) or 100
|
|
141
|
+
except (ValueError, OSError, AttributeError): # pragma: no cover
|
|
142
|
+
return 100
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def read_proc(pid: int, root: "Path | None" = None) -> "Proc | None":
|
|
146
|
+
"""`/proc/<pid>` as a Proc, or None when it cannot be read (gone, a
|
|
147
|
+
kernel thread with no cmdline, permission)."""
|
|
148
|
+
base = (PROC_ROOT if root is None else root) / str(pid)
|
|
149
|
+
try:
|
|
150
|
+
stat = (base / "stat").read_text()
|
|
151
|
+
raw = (base / "cmdline").read_bytes()
|
|
152
|
+
except OSError:
|
|
153
|
+
return None
|
|
154
|
+
# The comm field is parenthesised and may itself hold spaces or `)`;
|
|
155
|
+
# everything after the LAST `)` is the fixed-position tail.
|
|
156
|
+
_, _, tail = stat.rpartition(")")
|
|
157
|
+
fields = tail.split()
|
|
158
|
+
try:
|
|
159
|
+
state, ppid = fields[0], int(fields[1])
|
|
160
|
+
ticks = sum(int(value) for value in fields[11:15])
|
|
161
|
+
start = int(fields[19])
|
|
162
|
+
except (IndexError, ValueError):
|
|
163
|
+
return None
|
|
164
|
+
argv = tuple(part.decode("utf-8", "replace") for part in raw.split(b"\0") if part)
|
|
165
|
+
if not argv:
|
|
166
|
+
return None
|
|
167
|
+
try:
|
|
168
|
+
cwd: "str | None" = os.readlink(base / "cwd")
|
|
169
|
+
except OSError:
|
|
170
|
+
cwd = None
|
|
171
|
+
return Proc(
|
|
172
|
+
pid=pid, ppid=ppid, state=state, start=start,
|
|
173
|
+
cpu=ticks / _clock_ticks(), argv=argv, cwd=cwd,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def snapshot(root: "Path | None" = None) -> "dict[int, Proc]":
|
|
178
|
+
"""Every readable process, by pid. An unreadable table yields nothing."""
|
|
179
|
+
base = PROC_ROOT if root is None else root
|
|
180
|
+
try:
|
|
181
|
+
names = [entry.name for entry in base.iterdir()]
|
|
182
|
+
except OSError as exc:
|
|
183
|
+
log.warning("hung tests: cannot list %s (%s) — skipped this pass", base, exc)
|
|
184
|
+
return {}
|
|
185
|
+
procs = {}
|
|
186
|
+
for name in names:
|
|
187
|
+
if not name.isdigit():
|
|
188
|
+
continue
|
|
189
|
+
proc = read_proc(int(name), base)
|
|
190
|
+
if proc is not None:
|
|
191
|
+
procs[proc.pid] = proc
|
|
192
|
+
return procs
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def uptime(root: "Path | None" = None) -> "float | None":
|
|
196
|
+
"""Seconds since boot (`/proc/uptime`), or None."""
|
|
197
|
+
base = PROC_ROOT if root is None else root
|
|
198
|
+
try:
|
|
199
|
+
return float((base / "uptime").read_text().split()[0])
|
|
200
|
+
except (OSError, IndexError, ValueError):
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def is_test_runner(argv: "Sequence[str]") -> bool:
|
|
205
|
+
"""Is this command line a test runner: `npm test` (or `npm run test`),
|
|
206
|
+
or anything carrying a bare `--test` argument -- `node --test`,
|
|
207
|
+
`tsx --test`, `npx tsx --test`, and a `timeout … --test` wrapper around
|
|
208
|
+
one. Exact tokens only: a `bash -c '… --test …'` is one argument holding
|
|
209
|
+
the words, and the shell is not the runner."""
|
|
210
|
+
if "--test" in argv:
|
|
211
|
+
return True
|
|
212
|
+
for index, token in enumerate(argv):
|
|
213
|
+
if os.path.basename(token) not in _NPM_BINARIES:
|
|
214
|
+
continue
|
|
215
|
+
rest = [arg for arg in argv[index + 1:] if not arg.startswith("-")]
|
|
216
|
+
if rest and rest[0] in _NPM_TEST:
|
|
217
|
+
return True
|
|
218
|
+
if len(rest) >= 2 and rest[0] in _NPM_RUN and rest[1] == "test":
|
|
219
|
+
return True
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _path_in(text: str) -> "str | None":
|
|
224
|
+
if text.startswith("file://"):
|
|
225
|
+
text = text[len("file://"):]
|
|
226
|
+
return text if text.startswith("/") else None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def workspace_of(
|
|
230
|
+
proc: Proc, hubs: "Sequence[Path]"
|
|
231
|
+
) -> "TaskWorkspace | None":
|
|
232
|
+
"""The task workspace `proc`'s cwd or one of its path args sits in, or
|
|
233
|
+
None. The hub's direct child that holds the path must parse as a task
|
|
234
|
+
workspace under the janitor's grammar -- `main/`, `.source/` and anything
|
|
235
|
+
else under a hub never qualify."""
|
|
236
|
+
candidates = [proc.cwd] if proc.cwd else []
|
|
237
|
+
candidates += [p for p in (_path_in(arg) for arg in proc.argv) if p]
|
|
238
|
+
for text in candidates:
|
|
239
|
+
path = Path(os.path.normpath(text))
|
|
240
|
+
for hub in hubs:
|
|
241
|
+
try:
|
|
242
|
+
relative = path.relative_to(hub)
|
|
243
|
+
except ValueError:
|
|
244
|
+
continue
|
|
245
|
+
if not relative.parts:
|
|
246
|
+
continue
|
|
247
|
+
workspace = parse_task_workspace(hub / relative.parts[0], hub)
|
|
248
|
+
if workspace is not None:
|
|
249
|
+
return workspace
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def session_env(pid: int, root: "Path | None" = None) -> "tuple[str | None, str | None]":
|
|
254
|
+
"""(ALISSA_TMUX_SESSION, ALISSA_SESSION_ID) from the process's env."""
|
|
255
|
+
base = (PROC_ROOT if root is None else root) / str(pid)
|
|
256
|
+
try:
|
|
257
|
+
raw = (base / "environ").read_bytes()
|
|
258
|
+
except OSError:
|
|
259
|
+
return None, None
|
|
260
|
+
found: "dict[str, str]" = {}
|
|
261
|
+
for part in raw.split(b"\0"):
|
|
262
|
+
name, sep, value = part.decode("utf-8", "replace").partition("=")
|
|
263
|
+
if sep and name in (ENV_TMUX_SESSION, ENV_SESSION_ID) and value:
|
|
264
|
+
found[name] = value
|
|
265
|
+
return found.get(ENV_TMUX_SESSION), found.get(ENV_SESSION_ID)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _children(procs: "Mapping[int, Proc]") -> "dict[int, list[int]]":
|
|
269
|
+
children: "dict[int, list[int]]" = {}
|
|
270
|
+
for proc in procs.values():
|
|
271
|
+
children.setdefault(proc.ppid, []).append(proc.pid)
|
|
272
|
+
return children
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def descendants(procs: "Mapping[int, Proc]", pid: int) -> "list[Proc]":
|
|
276
|
+
"""`pid` and every process below it, root first."""
|
|
277
|
+
children = _children(procs)
|
|
278
|
+
order: "list[Proc]" = []
|
|
279
|
+
stack = [pid]
|
|
280
|
+
seen: "set[int]" = set()
|
|
281
|
+
while stack:
|
|
282
|
+
current = stack.pop()
|
|
283
|
+
if current in seen or current not in procs:
|
|
284
|
+
continue
|
|
285
|
+
seen.add(current)
|
|
286
|
+
order.append(procs[current])
|
|
287
|
+
stack.extend(sorted(children.get(current, ()), reverse=True))
|
|
288
|
+
return order
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _has_runner_ancestor(procs: "Mapping[int, Proc]", proc: Proc) -> bool:
|
|
292
|
+
"""Is any live ancestor of `proc` itself a runner? A `sh -c 'tsx --test'`
|
|
293
|
+
between two runners is one argument and not a runner, so the parent alone
|
|
294
|
+
cannot answer -- the whole chain up to init is read."""
|
|
295
|
+
seen = {proc.pid}
|
|
296
|
+
current = procs.get(proc.ppid)
|
|
297
|
+
while current is not None and current.pid not in seen:
|
|
298
|
+
if current.alive and is_test_runner(current.argv):
|
|
299
|
+
return True
|
|
300
|
+
seen.add(current.pid)
|
|
301
|
+
current = procs.get(current.ppid)
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def find_test_trees(
|
|
306
|
+
procs: "Mapping[int, Proc]",
|
|
307
|
+
hubs: "Sequence[Path]",
|
|
308
|
+
root: "Path | None" = None,
|
|
309
|
+
) -> "list[TestTree]":
|
|
310
|
+
"""Every worker test run inside a task workspace of one of `hubs`.
|
|
311
|
+
|
|
312
|
+
A tree is rooted at its TOPMOST runner (a runner with no runner above it),
|
|
313
|
+
so `timeout 900 npx tsx --test` and the `npm exec` / `tsx` / `node --test`
|
|
314
|
+
chain under it are one tree, killed together. The workspace is read off
|
|
315
|
+
the first member, root first, whose cwd or args name one; a tree none of
|
|
316
|
+
whose members does is not ours and is never returned."""
|
|
317
|
+
resolved = []
|
|
318
|
+
for hub in hubs:
|
|
319
|
+
try:
|
|
320
|
+
resolved.append(Path(hub).resolve())
|
|
321
|
+
except OSError:
|
|
322
|
+
continue
|
|
323
|
+
boot = uptime(root)
|
|
324
|
+
ticks = _clock_ticks()
|
|
325
|
+
trees = []
|
|
326
|
+
for proc in sorted(procs.values(), key=lambda p: p.pid):
|
|
327
|
+
if not proc.alive or not is_test_runner(proc.argv):
|
|
328
|
+
continue
|
|
329
|
+
if _has_runner_ancestor(procs, proc):
|
|
330
|
+
continue
|
|
331
|
+
members = tuple(m for m in descendants(procs, proc.pid) if m.alive)
|
|
332
|
+
workspace = None
|
|
333
|
+
for member in members:
|
|
334
|
+
workspace = workspace_of(member, resolved)
|
|
335
|
+
if workspace is not None:
|
|
336
|
+
break
|
|
337
|
+
if workspace is None:
|
|
338
|
+
continue
|
|
339
|
+
tmux_session, session_id = session_env(proc.pid, root)
|
|
340
|
+
age = 0.0 if boot is None else max(0.0, boot - proc.start / ticks)
|
|
341
|
+
trees.append(TestTree(
|
|
342
|
+
root=proc, members=members, workspace=workspace,
|
|
343
|
+
tmux_session=tmux_session, session_id=session_id, age=age,
|
|
344
|
+
))
|
|
345
|
+
return trees
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@dataclass
|
|
349
|
+
class CpuWatch:
|
|
350
|
+
"""The last CPU sample per tree, and the hang verdict across a window.
|
|
351
|
+
|
|
352
|
+
A tree is sampled the first time it is seen; once `window` seconds have
|
|
353
|
+
passed since its sample it is judged: hung when it is at least `window`
|
|
354
|
+
old and its CPU grew by under `MIN_PROGRESS_SECONDS`, otherwise the
|
|
355
|
+
sample moves forward and a new window starts. A tree that burns CPU is
|
|
356
|
+
therefore re-judged every window, and a tree younger than one never is.
|
|
357
|
+
Samples of trees that are no longer seen are dropped."""
|
|
358
|
+
|
|
359
|
+
window: float
|
|
360
|
+
clock: "Callable[[], float] | None" = None
|
|
361
|
+
samples: "dict[tuple[int, int], tuple[float, float]]" = field(default_factory=dict)
|
|
362
|
+
|
|
363
|
+
def observe(self, trees: "Iterable[TestTree]") -> "list[TestTree]":
|
|
364
|
+
now = (self.clock or monotonic)()
|
|
365
|
+
hung = []
|
|
366
|
+
seen = set()
|
|
367
|
+
for tree in trees:
|
|
368
|
+
seen.add(tree.key)
|
|
369
|
+
sample = self.samples.get(tree.key)
|
|
370
|
+
if sample is None:
|
|
371
|
+
self.samples[tree.key] = (now, tree.cpu)
|
|
372
|
+
continue
|
|
373
|
+
at, cpu = sample
|
|
374
|
+
if now - at < self.window:
|
|
375
|
+
continue
|
|
376
|
+
if tree.age >= self.window and tree.cpu - cpu < MIN_PROGRESS_SECONDS:
|
|
377
|
+
hung.append(tree)
|
|
378
|
+
continue
|
|
379
|
+
self.samples[tree.key] = (now, tree.cpu)
|
|
380
|
+
for key in list(self.samples):
|
|
381
|
+
if key not in seen:
|
|
382
|
+
del self.samples[key]
|
|
383
|
+
return hung
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def send_signal(pid: int, signum: int) -> None:
|
|
387
|
+
"""`os.kill`, swallowing a pid that is already gone or not ours."""
|
|
388
|
+
try:
|
|
389
|
+
os.kill(pid, signum)
|
|
390
|
+
except (ProcessLookupError, PermissionError):
|
|
391
|
+
pass
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def sleep(seconds: float) -> None:
|
|
395
|
+
"""`time.sleep`, behind a module seam the tests replace."""
|
|
396
|
+
time.sleep(seconds)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def monotonic() -> float:
|
|
400
|
+
"""`time.monotonic`, behind a module seam the tests replace."""
|
|
401
|
+
return time.monotonic()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _still_alive(member: Proc, root: "Path | None") -> bool:
|
|
405
|
+
now = read_proc(member.pid, root)
|
|
406
|
+
return now is not None and now.start == member.start and now.alive
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def kill_tree(
|
|
410
|
+
tree: TestTree,
|
|
411
|
+
*,
|
|
412
|
+
root: "Path | None" = None,
|
|
413
|
+
grace: float = KILL_GRACE_SECONDS,
|
|
414
|
+
) -> "tuple[int, int]":
|
|
415
|
+
"""TERM every member of `tree`, wait up to `grace` seconds, then KILL
|
|
416
|
+
whatever is still alive. Returns (termed, killed).
|
|
417
|
+
|
|
418
|
+
The member list is re-read first, so a child the runner spawned since the
|
|
419
|
+
snapshot is signalled too; every pid is matched on its start time before
|
|
420
|
+
each signal, so a pid the kernel reused for something else is never hit.
|
|
421
|
+
"""
|
|
422
|
+
fresh = snapshot(root)
|
|
423
|
+
current = fresh.get(tree.root.pid)
|
|
424
|
+
if current is None or current.start != tree.root.start:
|
|
425
|
+
return 0, 0
|
|
426
|
+
members = [m for m in descendants(fresh, tree.root.pid) if m.alive]
|
|
427
|
+
for member in members:
|
|
428
|
+
send_signal(member.pid, signal.SIGTERM)
|
|
429
|
+
deadline = monotonic() + grace
|
|
430
|
+
survivors = [m for m in members if _still_alive(m, root)]
|
|
431
|
+
while survivors and monotonic() < deadline:
|
|
432
|
+
sleep(_KILL_POLL_SECONDS)
|
|
433
|
+
survivors = [m for m in survivors if _still_alive(m, root)]
|
|
434
|
+
for member in survivors:
|
|
435
|
+
send_signal(member.pid, signal.SIGKILL)
|
|
436
|
+
return len(members), len(survivors)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def session_id_for(tmux_session: str, runner: "Callable[..., str]" = run) -> "str | None":
|
|
440
|
+
"""The Alissa session id behind a managed tmux session, read off
|
|
441
|
+
`alissa session list --json`'s `tmuxSession`, or None."""
|
|
442
|
+
try:
|
|
443
|
+
out = runner(["alissa", "session", "list", "--json", "--limit", "50"], timeout=60)
|
|
444
|
+
data = json.loads(out)
|
|
445
|
+
except (CommandError, OSError, ValueError) as exc:
|
|
446
|
+
log.debug("hung tests: could not list sessions (%s)", exc)
|
|
447
|
+
return None
|
|
448
|
+
if isinstance(data, dict):
|
|
449
|
+
data = data.get("sessions", [])
|
|
450
|
+
wanted = {tmux_session, TMUX_PREFIX + tmux_session}
|
|
451
|
+
for entry in data if isinstance(data, list) else []:
|
|
452
|
+
if isinstance(entry, dict) and entry.get("tmuxSession") in wanted:
|
|
453
|
+
value = entry.get("sessionId")
|
|
454
|
+
if isinstance(value, str) and value:
|
|
455
|
+
return value
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def message_session(
|
|
460
|
+
session_id: str, text: str, runner: "Callable[..., str]" = run
|
|
461
|
+
) -> bool:
|
|
462
|
+
"""Post `text` to one session through the session-message path
|
|
463
|
+
(`alissa session msg send -s <id>`). True when the CLI accepted it."""
|
|
464
|
+
try:
|
|
465
|
+
runner(["alissa", "session", "msg", "send", "-s", session_id, text], timeout=60)
|
|
466
|
+
except (CommandError, OSError) as exc:
|
|
467
|
+
log.warning("hung tests: could not message session %s (%s)", session_id, exc)
|
|
468
|
+
return False
|
|
469
|
+
return True
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def hung_message(tree: TestTree, minutes: int, reason: str) -> str:
|
|
473
|
+
"""The one message a live session gets after its test run was stopped."""
|
|
474
|
+
return (
|
|
475
|
+
f"devloop: your test run in {tree.workspace.path.name} "
|
|
476
|
+
f"(`{tree.command}`, pid {tree.root.pid}) hung and was stopped "
|
|
477
|
+
f"({reason}). Do not rerun the full suite and never wait on one for "
|
|
478
|
+
f"more than {minutes} minutes: run only the test files your diff "
|
|
479
|
+
f"touches, each on its own, one file per command. CI on the PR runs "
|
|
480
|
+
f"the full suite."
|
|
481
|
+
)
|