@giamme/forge 0.11.1 → 0.12.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/CHANGELOG.md +34 -0
- package/SKILL.md +4 -2
- package/VERSION +1 -1
- package/docs/artifacts.md +44 -11
- package/docs/commands.md +45 -7
- package/docs/decomposed-runs.md +43 -2
- package/docs/forge-ui.md +27 -6
- package/docs/reviews.md +31 -6
- package/package.json +1 -1
- package/references/dataset.md +3 -1
- package/references/decompose.md +159 -17
- package/references/harnesses.md +13 -1
- package/references/ui.md +14 -4
- package/scripts/forge-control.py +268 -25
- package/scripts/forge-dispatch.sh +80 -3
- package/scripts/forge-guard.py +156 -15
- package/scripts/forge-lib-plans.sh +9 -3
- package/scripts/forge-lib-review.sh +22 -3
- package/scripts/forge-lib-task.sh +51 -4
- package/scripts/forge-lib-verify.sh +137 -6
- package/scripts/forge-parallel-options.sh +59 -0
- package/scripts/forge-parallel.sh +212 -28
- package/scripts/forge-ripwire.py +1 -1
- package/scripts/forge-route.py +157 -5
- package/scripts/forge-schedule.py +687 -50
- package/scripts/forge-verify.py +878 -0
- package/scripts/forge_dataset/forecast.py +29 -4
- package/scripts/forge_dataset/harvest.py +10 -4
- package/scripts/forge_verify_reports.py +225 -0
- package/ui/README.md +18 -3
- package/ui/docs/api.md +126 -17
- package/ui/forge_ui/control.py +12 -2
- package/ui/forge_ui/export.py +20 -7
- package/ui/forge_ui/reader.py +307 -11
- package/ui/forge_ui/server.py +16 -0
- package/ui/forge_ui/static/app.js +2 -1
- package/ui/forge_ui/static/controls.js +217 -33
- package/ui/forge_ui/static/gantt.js +14 -9
- package/ui/forge_ui/static/index.html +1 -0
- package/ui/forge_ui/static/panel.js +2 -1
- package/ui/forge_ui/static/runmodel.js +92 -11
- package/ui/forge_ui/static/scene3d.js +9 -5
- package/ui/forge_ui/static/style.css +60 -0
- package/ui/forge_ui/static/verifyview.js +324 -0
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Verification lanes: run a project's verification as parallel, named lanes.
|
|
3
|
+
|
|
4
|
+
``--lanes-dir`` holds one file per lane. A lane is a shell command (run as
|
|
5
|
+
``/bin/bash -c <text>`` in the checkout, exactly like the single verify command) with
|
|
6
|
+
optional leading ``#forge: key[=value] ...`` lines:
|
|
7
|
+
|
|
8
|
+
* ``group=<name>`` lanes of one group never run at the same time
|
|
9
|
+
* ``first`` the lane runs before all others; if one fails nothing else starts
|
|
10
|
+
* ``retries=<n>`` reruns of the lane when it fails (else ``--retries``)
|
|
11
|
+
* ``reports=<dir>`` JUnit (``*.xml``) and TAP (``*.tap``) reports the lane writes, relative to
|
|
12
|
+
the checkout: only files written during a run count, re-read at most every 10 s while it
|
|
13
|
+
runs, and the result is the lane's ``tests`` (counts and failing tests)
|
|
14
|
+
* ``baseline=<file>`` known failing test names, one per line, relative to the checkout: a
|
|
15
|
+
lane whose every failing test is known ends ``known``, which passes; incomplete
|
|
16
|
+
evidence (a report that could not be read) never does
|
|
17
|
+
|
|
18
|
+
forge-verify.py lanes --lanes-dir DIR [--checkout DIR] [--json]
|
|
19
|
+
forge-verify.py run --checkout DIR --out DIR --lanes-dir DIR [--parallel N]
|
|
20
|
+
[--retries N] [--fingerprint-cmd CMD]
|
|
21
|
+
|
|
22
|
+
``run`` writes, into ``--out``: one log per lane (``verification.lane.<name>.log``, the run in
|
|
23
|
+
progress, and ``verification.lane.<name>.run-<k>.log`` for each finished run),
|
|
24
|
+
``verification.lanes.json`` (rewritten atomically on every state change and at most once
|
|
25
|
+
a second while lanes print) and ``verification.runs.tsv``
|
|
26
|
+
(``n kind exit tree_changed duration_s log lane started``, one row per lane run).
|
|
27
|
+
|
|
28
|
+
A failed lane is rerun alone, in rounds after the initial one, up to its budget
|
|
29
|
+
(``--retries``, default 1, or the lane's ``retries=``); a pass on a rerun makes it ``flaky``.
|
|
30
|
+
Nothing reruns once the checkout changed: a rerun on another tree proves nothing.
|
|
31
|
+
|
|
32
|
+
Exit codes: 0 every lane passed and nothing mutated, 1 otherwise, 130 interrupted,
|
|
33
|
+
2 configuration error (nothing ran).
|
|
34
|
+
|
|
35
|
+
Structure, for the tasks that extend this: a lane's result is plain data in
|
|
36
|
+
``Verification.doc["lanes"]`` (its ``runs`` list holds one entry per execution);
|
|
37
|
+
``Verification.run_round`` runs one round of lane runs over the names it is given and
|
|
38
|
+
``Verification.run`` is only the sequence of rounds around the mutation check.
|
|
39
|
+
"""
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import argparse
|
|
43
|
+
import codecs
|
|
44
|
+
import json
|
|
45
|
+
import os
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
import re
|
|
48
|
+
import shutil
|
|
49
|
+
import signal
|
|
50
|
+
import stat
|
|
51
|
+
import subprocess
|
|
52
|
+
import sys
|
|
53
|
+
import tempfile
|
|
54
|
+
import time
|
|
55
|
+
|
|
56
|
+
sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
|
|
57
|
+
import forge_verify_reports as reports # noqa: E402 next to this script
|
|
58
|
+
|
|
59
|
+
LANE_NAME = re.compile(r'[A-Za-z0-9][A-Za-z0-9_-]{0,63}')
|
|
60
|
+
HEADER = '#forge:'
|
|
61
|
+
KILL_AFTER = 5.0 # seconds between TERM and KILL on an interrupt
|
|
62
|
+
WRITE_EVERY = 1.0 # lanes.json refresh while lanes only print
|
|
63
|
+
TICK = 0.05
|
|
64
|
+
SCAN_EVERY = 10.0 # seconds between looks at a running lane's reports
|
|
65
|
+
PASSING = ('pass', 'flaky', 'known')
|
|
66
|
+
LAST_LINE_MAX = 300
|
|
67
|
+
PUMP_MAX = 4 << 20 # bytes read from one lane's log per look, so a flood cannot starve the loop
|
|
68
|
+
TAIL_KEEP = 64 << 10 # what is still read of a finished lane's backlog: its last line and markers
|
|
69
|
+
INTERRUPTED = 130
|
|
70
|
+
|
|
71
|
+
ANSI = re.compile(r'\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-Z\\-_]')
|
|
72
|
+
STEP = re.compile(r'::forge step name=(\S+) state=(start|end)(?: exit=(-?\d+))?')
|
|
73
|
+
OPTION_FORMS = {
|
|
74
|
+
'group': LANE_NAME,
|
|
75
|
+
'retries': re.compile(r'[0-9]'),
|
|
76
|
+
'reports': re.compile(r'\S+'),
|
|
77
|
+
'baseline': re.compile(r'\S+'),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# --- declarations ----------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def parse_lane(name: str, text: str) -> tuple[dict | None, list[str]]:
|
|
84
|
+
"""One lane file's text -> (lane, errors). The lane is None when there are errors."""
|
|
85
|
+
errors: list[str] = []
|
|
86
|
+
lines = text.split('\n')
|
|
87
|
+
options: dict = {}
|
|
88
|
+
group = None
|
|
89
|
+
first = False
|
|
90
|
+
seen: set[str] = set()
|
|
91
|
+
body_from = 0
|
|
92
|
+
for body_from, line in enumerate(lines):
|
|
93
|
+
if not line.startswith(HEADER):
|
|
94
|
+
break
|
|
95
|
+
tokens = line[len(HEADER):].split()
|
|
96
|
+
if not tokens:
|
|
97
|
+
errors.append(f'{name}: empty "{HEADER}" line')
|
|
98
|
+
for token in tokens:
|
|
99
|
+
key, eq, value = token.partition('=')
|
|
100
|
+
if key in seen:
|
|
101
|
+
errors.append(f'{name}: option "{key}" given twice')
|
|
102
|
+
continue
|
|
103
|
+
seen.add(key)
|
|
104
|
+
if key == 'first':
|
|
105
|
+
if eq:
|
|
106
|
+
errors.append(f'{name}: option "first" takes no value')
|
|
107
|
+
first = True
|
|
108
|
+
elif key in OPTION_FORMS:
|
|
109
|
+
if not eq or not OPTION_FORMS[key].fullmatch(value):
|
|
110
|
+
errors.append(f'{name}: option "{token}" is malformed')
|
|
111
|
+
elif key == 'group':
|
|
112
|
+
group = value
|
|
113
|
+
elif key == 'retries':
|
|
114
|
+
options['retries'] = int(value)
|
|
115
|
+
else:
|
|
116
|
+
options[key] = value
|
|
117
|
+
else:
|
|
118
|
+
errors.append(f'{name}: unknown option "{key}"')
|
|
119
|
+
else:
|
|
120
|
+
body_from = len(lines)
|
|
121
|
+
command = '\n'.join(lines[body_from:]).strip()
|
|
122
|
+
if '\0' in text:
|
|
123
|
+
errors.append(f'{name}: contains a NUL byte')
|
|
124
|
+
if not command:
|
|
125
|
+
errors.append(f'{name}: empty command')
|
|
126
|
+
if errors:
|
|
127
|
+
return None, errors
|
|
128
|
+
return {'name': name, 'command': command, 'group': group, 'first': first,
|
|
129
|
+
'options': options}, []
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def path_errors(lane: dict, checkout: str | None) -> list[str]:
|
|
133
|
+
"""reports= and baseline= stay inside the checkout; with one, the baseline must exist."""
|
|
134
|
+
errors = []
|
|
135
|
+
for key in ('reports', 'baseline'):
|
|
136
|
+
value = lane['options'].get(key)
|
|
137
|
+
if value is None:
|
|
138
|
+
continue
|
|
139
|
+
norm = os.path.normpath(value)
|
|
140
|
+
if os.path.isabs(value) or norm == '..' or norm.startswith('..' + os.sep):
|
|
141
|
+
errors.append(f'{lane["name"]}: {key}={value} must be a path inside the checkout')
|
|
142
|
+
continue
|
|
143
|
+
if not checkout:
|
|
144
|
+
continue
|
|
145
|
+
root = os.path.realpath(checkout)
|
|
146
|
+
real = os.path.realpath(os.path.join(checkout, value))
|
|
147
|
+
if real != root and not real.startswith(root + os.sep):
|
|
148
|
+
errors.append(f'{lane["name"]}: {key}={value} leads out of the checkout')
|
|
149
|
+
elif key == 'baseline':
|
|
150
|
+
try:
|
|
151
|
+
regular = stat.S_ISREG(os.lstat(os.path.join(checkout, value)).st_mode)
|
|
152
|
+
except OSError:
|
|
153
|
+
regular = False
|
|
154
|
+
if not regular:
|
|
155
|
+
errors.append(f'{lane["name"]}: baseline={value} is not a regular file in the checkout')
|
|
156
|
+
return errors
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def load_lanes(lanes_dir: str, checkout: str | None = None) -> tuple[list[dict], list[str]]:
|
|
160
|
+
"""Every lane in the directory, in declaration order (sorted names), and the errors."""
|
|
161
|
+
root = Path(lanes_dir)
|
|
162
|
+
if not root.is_dir():
|
|
163
|
+
return [], [f'{lanes_dir}: not a directory']
|
|
164
|
+
lanes: list[dict] = []
|
|
165
|
+
errors: list[str] = []
|
|
166
|
+
for entry in sorted(os.listdir(root)):
|
|
167
|
+
if entry.startswith('.') or entry.endswith('~'):
|
|
168
|
+
continue
|
|
169
|
+
path = root / entry
|
|
170
|
+
if not LANE_NAME.fullmatch(entry):
|
|
171
|
+
errors.append(f'{entry}: not a valid lane name (letters, digits, "_" and "-"; at most 64)')
|
|
172
|
+
continue
|
|
173
|
+
if path.is_symlink() or not path.is_file():
|
|
174
|
+
errors.append(f'{entry}: not a regular file')
|
|
175
|
+
continue
|
|
176
|
+
try:
|
|
177
|
+
text = path.read_bytes().decode('utf-8')
|
|
178
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
179
|
+
errors.append(f'{entry}: cannot read: {exc}')
|
|
180
|
+
continue
|
|
181
|
+
lane, problems = parse_lane(entry, text)
|
|
182
|
+
errors.extend(problems)
|
|
183
|
+
if lane:
|
|
184
|
+
problems = path_errors(lane, checkout)
|
|
185
|
+
errors.extend(problems)
|
|
186
|
+
if not problems:
|
|
187
|
+
lanes.append(lane)
|
|
188
|
+
if not lanes and not errors:
|
|
189
|
+
errors.append(f'{lanes_dir}: no lanes declared')
|
|
190
|
+
return lanes, errors
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# --- mutation check --------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def _text(data: bytes) -> str:
|
|
196
|
+
return data.decode('utf-8', 'replace').strip()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Stopped(Exception):
|
|
200
|
+
"""A stop arrived while a fingerprint command ran; the command was stopped."""
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def run_stoppable(argv: list[str], stopped=None, **kwargs) -> tuple[int, bytes, bytes]:
|
|
204
|
+
"""Run a command in its own process group and wait for it, stopping the group (TERM, then
|
|
205
|
+
KILL after KILL_AFTER seconds) and raising Stopped as soon as ``stopped()`` is true."""
|
|
206
|
+
proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
|
207
|
+
stderr=subprocess.PIPE, start_new_session=True, **kwargs)
|
|
208
|
+
while True:
|
|
209
|
+
try:
|
|
210
|
+
out, err = proc.communicate(timeout=TICK)
|
|
211
|
+
return proc.returncode, out, err
|
|
212
|
+
except subprocess.TimeoutExpired:
|
|
213
|
+
if stopped is None or not stopped():
|
|
214
|
+
continue
|
|
215
|
+
for sig, wait in ((signal.SIGTERM, KILL_AFTER), (signal.SIGKILL, KILL_AFTER)):
|
|
216
|
+
try:
|
|
217
|
+
os.killpg(proc.pid, sig)
|
|
218
|
+
except (ProcessLookupError, PermissionError):
|
|
219
|
+
pass
|
|
220
|
+
try:
|
|
221
|
+
proc.communicate(timeout=wait)
|
|
222
|
+
break
|
|
223
|
+
except subprocess.TimeoutExpired:
|
|
224
|
+
continue
|
|
225
|
+
raise Stopped()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _git(checkout: str, *args: str, env: dict | None = None, stopped=None) -> str:
|
|
229
|
+
rc, out, err = run_stoppable(['git', '-C', checkout, *args], stopped, env=env)
|
|
230
|
+
if rc != 0:
|
|
231
|
+
raise RuntimeError(f'git {" ".join(args)}: {_text(err)}')
|
|
232
|
+
return _text(out)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def default_fingerprint(checkout: str, stopped=None) -> str:
|
|
236
|
+
"""HEAD plus the tree id of the whole working tree (tracked and untracked, ignored files
|
|
237
|
+
left out), by content: the status labels do not change when an already modified file
|
|
238
|
+
is edited again. It is built in a temporary copy of the index, so the real one is
|
|
239
|
+
never touched. ``.forge`` is left out, as in forge_tree."""
|
|
240
|
+
try:
|
|
241
|
+
head = _git(checkout, 'rev-parse', 'HEAD', stopped=stopped)
|
|
242
|
+
except RuntimeError:
|
|
243
|
+
head = '-'
|
|
244
|
+
real = Path(checkout) / _git(checkout, 'rev-parse', '--git-path', 'index')
|
|
245
|
+
fd, tmp = tempfile.mkstemp(prefix='forge-index.')
|
|
246
|
+
os.close(fd)
|
|
247
|
+
try:
|
|
248
|
+
if real.is_file():
|
|
249
|
+
shutil.copy2(real, tmp) # keeps the mtime git uses to spot racy entries
|
|
250
|
+
else:
|
|
251
|
+
os.unlink(tmp)
|
|
252
|
+
env = dict(os.environ, GIT_INDEX_FILE=tmp)
|
|
253
|
+
_git(checkout, 'add', '-A', '--', '.', ':(exclude).forge', env=env, stopped=stopped)
|
|
254
|
+
tree = _git(checkout, 'write-tree', env=env, stopped=stopped)
|
|
255
|
+
finally:
|
|
256
|
+
try:
|
|
257
|
+
os.unlink(tmp)
|
|
258
|
+
except FileNotFoundError:
|
|
259
|
+
pass
|
|
260
|
+
return f'{head} {tree}'
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def fingerprint(checkout: str, command: str | None, stopped=None) -> str:
|
|
264
|
+
"""The checkout's fingerprint; Stopped when ``stopped()`` turns true meanwhile."""
|
|
265
|
+
if not command:
|
|
266
|
+
return default_fingerprint(checkout, stopped)
|
|
267
|
+
rc, out, err = run_stoppable(['/bin/bash', '-c', command], stopped, cwd=checkout)
|
|
268
|
+
if rc != 0:
|
|
269
|
+
raise RuntimeError(f'fingerprint command exited {rc}: {_text(err)}')
|
|
270
|
+
return _text(out)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# --- running ---------------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
def _file_mode() -> int:
|
|
276
|
+
mask = os.umask(0)
|
|
277
|
+
os.umask(mask)
|
|
278
|
+
return 0o666 & ~mask
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def write_atomic(path: Path, text: str) -> None:
|
|
282
|
+
"""Write through a temp file and rename. mkstemp makes the file 0600; the lane logs
|
|
283
|
+
follow the umask, so the state files get the same mode (a forge-ui server may read them
|
|
284
|
+
as another user)."""
|
|
285
|
+
fd, tmp = tempfile.mkstemp(prefix='.' + path.name + '.', dir=path.parent)
|
|
286
|
+
try:
|
|
287
|
+
os.fchmod(fd, _file_mode())
|
|
288
|
+
with os.fdopen(fd, 'w') as fh:
|
|
289
|
+
fh.write(text)
|
|
290
|
+
os.replace(tmp, path)
|
|
291
|
+
except BaseException:
|
|
292
|
+
try:
|
|
293
|
+
os.unlink(tmp)
|
|
294
|
+
except FileNotFoundError:
|
|
295
|
+
pass
|
|
296
|
+
raise
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def lane_log(name: str) -> str:
|
|
300
|
+
return f'verification.lane.{name}.log'
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def run_log(name: str, k: int) -> str:
|
|
304
|
+
"""Where run k of a lane is kept once it is over; no dots in names, so no collision."""
|
|
305
|
+
return f'verification.lane.{name}.run-{k}.log'
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class Live:
|
|
309
|
+
"""A started lane process and what is read from its log so far."""
|
|
310
|
+
|
|
311
|
+
def __init__(self, proc: subprocess.Popen, log: Path, offset: int):
|
|
312
|
+
self.proc = proc
|
|
313
|
+
self.reader = open(log, 'rb')
|
|
314
|
+
self.reader.seek(offset)
|
|
315
|
+
self.decoder = codecs.getincrementaldecoder('utf-8')('replace')
|
|
316
|
+
self.partial = ''
|
|
317
|
+
self.exited = False # the leader has exited and its group was sent the TERM
|
|
318
|
+
self.scanned = time.monotonic() # its reports are first looked at after SCAN_EVERY
|
|
319
|
+
|
|
320
|
+
def close(self) -> None:
|
|
321
|
+
self.reader.close()
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class Verification:
|
|
325
|
+
def __init__(self, checkout: str, out: str, lanes: list[dict], parallel: int,
|
|
326
|
+
fingerprint_cmd: str | None = None, retries: int = 1):
|
|
327
|
+
self.checkout = checkout
|
|
328
|
+
self.out = Path(out)
|
|
329
|
+
self.parallel = parallel
|
|
330
|
+
self.retries = retries
|
|
331
|
+
self.before: str | None = None
|
|
332
|
+
self.fingerprint_cmd = fingerprint_cmd
|
|
333
|
+
self.stop = False
|
|
334
|
+
self.running: dict[str, Live] = {}
|
|
335
|
+
# (pgid, KILL time, the lane's group, give-up time) of finished lanes whose leftovers still run
|
|
336
|
+
self.lingering: list[tuple[int, float, str | None, float]] = []
|
|
337
|
+
self.dirty = False
|
|
338
|
+
self.written = 0.0
|
|
339
|
+
self.doc: dict = {
|
|
340
|
+
'version': 1, 'state': 'running', 'started': time.time(), 'ended': None,
|
|
341
|
+
'parallel': parallel, 'mutated': False,
|
|
342
|
+
'lanes': [{'name': lane['name'], 'command': lane['command'], 'group': lane['group'],
|
|
343
|
+
'first': lane['first'], 'options': lane['options'], 'state': 'waiting',
|
|
344
|
+
'exit': None, 'started': None, 'ended': None, 'runs': [], 'step': None,
|
|
345
|
+
'last_line': None, 'last_output_at': None, 'log': lane_log(lane['name']),
|
|
346
|
+
'tests': None} for lane in lanes],
|
|
347
|
+
}
|
|
348
|
+
self.by_name = {lane['name']: lane for lane in self.doc['lanes']}
|
|
349
|
+
|
|
350
|
+
# state files ----------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
def save(self, force: bool = True) -> None:
|
|
353
|
+
"""Rewrite lanes.json. State changes force it; output only marks it due."""
|
|
354
|
+
now = time.time()
|
|
355
|
+
if not force:
|
|
356
|
+
self.dirty = True
|
|
357
|
+
if now - self.written < WRITE_EVERY:
|
|
358
|
+
return
|
|
359
|
+
self.dirty = False
|
|
360
|
+
self.written = now
|
|
361
|
+
write_atomic(self.out / 'verification.lanes.json', json.dumps(self.doc, indent=1) + '\n')
|
|
362
|
+
|
|
363
|
+
def flush(self) -> None:
|
|
364
|
+
if self.dirty:
|
|
365
|
+
self.save(force=False)
|
|
366
|
+
|
|
367
|
+
def write_runs(self) -> None:
|
|
368
|
+
"""verification.runs.tsv from the lanes' run entries: n kind exit tree_changed
|
|
369
|
+
duration_s log lane started. n counts runs in the order they started. Lanes run
|
|
370
|
+
in parallel, so a change to the tree is not pinned to a run; the one exception is
|
|
371
|
+
a verification that made a single run."""
|
|
372
|
+
runs = [(r, lane['name']) for lane in self.doc['lanes'] for r in lane['runs']
|
|
373
|
+
if r['exit'] is not None]
|
|
374
|
+
runs.sort(key=lambda item: (item[0]['started'], item[1]))
|
|
375
|
+
single = len([r for lane in self.doc['lanes'] for r in lane['runs']]) == 1
|
|
376
|
+
rows = []
|
|
377
|
+
for n, (run, name) in enumerate(runs, 1):
|
|
378
|
+
changed = 1 if self.doc['mutated'] and single else 0
|
|
379
|
+
rows.append('\t'.join([str(n), run['kind'], str(run['exit']), str(changed),
|
|
380
|
+
str(int(run['ended'] - run['started'])), run['log'], name,
|
|
381
|
+
f'{run["started"]:.3f}']))
|
|
382
|
+
write_atomic(self.out / 'verification.runs.tsv', '\n'.join(rows) + ('\n' if rows else ''))
|
|
383
|
+
|
|
384
|
+
# one round -----------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
def run_round(self, names: list[str], kind: str = 'initial') -> None:
|
|
387
|
+
"""Run the named lanes: up to ``parallel`` at once, never two of one group, started
|
|
388
|
+
in the order given. Returns when all have finished, or when stopped (the running
|
|
389
|
+
ones are then terminated and finished as interrupted, the others stay waiting)."""
|
|
390
|
+
pending = list(names)
|
|
391
|
+
running = self.running
|
|
392
|
+
while (pending or running) and not self.stop:
|
|
393
|
+
for name, live in running.items():
|
|
394
|
+
if not live.exited and live.proc.poll() is not None:
|
|
395
|
+
self._leader_exited(name, live)
|
|
396
|
+
drained = self._pump(running)
|
|
397
|
+
self._scan_running(running)
|
|
398
|
+
for name in [n for n, live in running.items() if live.exited and n in drained]:
|
|
399
|
+
self._finish(name, running.pop(name))
|
|
400
|
+
self._reap_lingering()
|
|
401
|
+
# a group is free once no process of its last lane is left (a server still shutting
|
|
402
|
+
# down can hold the port the next lane of the group needs)
|
|
403
|
+
busy = ({self.by_name[n]['group'] for n in running} | {g for _, _, g, _ in self.lingering}) - {None}
|
|
404
|
+
for name in list(pending):
|
|
405
|
+
if len(running) >= self.parallel:
|
|
406
|
+
break
|
|
407
|
+
group = self.by_name[name]['group']
|
|
408
|
+
if group in busy:
|
|
409
|
+
continue
|
|
410
|
+
pending.remove(name)
|
|
411
|
+
if self._start(name, kind) and group:
|
|
412
|
+
busy.add(group)
|
|
413
|
+
self.flush()
|
|
414
|
+
if pending or running:
|
|
415
|
+
time.sleep(TICK)
|
|
416
|
+
if self.stop and running:
|
|
417
|
+
self._terminate()
|
|
418
|
+
|
|
419
|
+
def _start(self, name: str, kind: str) -> Live | None:
|
|
420
|
+
"""Start one lane run; the process is in ``self.running`` from the moment it exists,
|
|
421
|
+
so no later failure can leave it unaccounted for."""
|
|
422
|
+
lane = self.by_name[name]
|
|
423
|
+
now = time.time()
|
|
424
|
+
log = self.out / lane['log']
|
|
425
|
+
k = len(lane['runs']) + 1
|
|
426
|
+
run = {'k': k, 'kind': kind, 'started': now, 'ended': None, 'exit': None,
|
|
427
|
+
'log': run_log(name, k)}
|
|
428
|
+
lane['runs'].append(run)
|
|
429
|
+
lane.update(state='running', started=now, ended=None, exit=None, step=None)
|
|
430
|
+
try:
|
|
431
|
+
if os.path.islink(log):
|
|
432
|
+
os.unlink(log) # the log name is ours: never written through a link left in --out
|
|
433
|
+
fd = os.open(log, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, 0o666)
|
|
434
|
+
with os.fdopen(fd, 'ab') as fh:
|
|
435
|
+
offset = fh.tell()
|
|
436
|
+
proc = subprocess.Popen(['/bin/bash', '-c', lane['command']], cwd=self.checkout,
|
|
437
|
+
stdin=subprocess.DEVNULL, stdout=fh,
|
|
438
|
+
stderr=subprocess.STDOUT, start_new_session=True)
|
|
439
|
+
except (OSError, ValueError) as exc: # ValueError: an embedded NUL in the command
|
|
440
|
+
with open(log, 'ab') as fh:
|
|
441
|
+
fh.write(f'forge-verify: cannot start lane: {exc}\n'.encode())
|
|
442
|
+
self._end(lane, run, 126)
|
|
443
|
+
return None
|
|
444
|
+
live = self.running[name] = Live(proc, log, offset)
|
|
445
|
+
self.save()
|
|
446
|
+
return live
|
|
447
|
+
|
|
448
|
+
def _leader_exited(self, name: str, live: Live) -> None:
|
|
449
|
+
"""The lane's leader has exited. Whatever it left running in its process group (a
|
|
450
|
+
background server, a watcher) is terminated now, so a leftover that keeps printing
|
|
451
|
+
stops: no lane process outlives the script. The lane stays running until its log is
|
|
452
|
+
read to the end, which the main loop does a capped step at a time."""
|
|
453
|
+
live.exited = True
|
|
454
|
+
pgid = live.proc.pid
|
|
455
|
+
if self._group_alive(pgid):
|
|
456
|
+
self._signal(pgid, signal.SIGTERM)
|
|
457
|
+
now = time.monotonic()
|
|
458
|
+
self.lingering.append((pgid, now + KILL_AFTER, self.by_name[name]['group'], now + 4 * KILL_AFTER))
|
|
459
|
+
|
|
460
|
+
def _finish(self, name: str, live: Live, interrupted: bool = False) -> None:
|
|
461
|
+
"""Record the end of a lane whose leader has exited (or was killed)."""
|
|
462
|
+
lane = self.by_name[name]
|
|
463
|
+
rc = live.proc.poll()
|
|
464
|
+
if rc is None: # still there after the KILL: a process stuck in the kernel
|
|
465
|
+
rc = -signal.SIGKILL
|
|
466
|
+
self._pump({name: live}) # what is left to read, capped; an interrupt still gets through
|
|
467
|
+
live.close()
|
|
468
|
+
self._end(lane, lane['runs'][-1], rc if rc >= 0 else 128 - rc, interrupted)
|
|
469
|
+
|
|
470
|
+
def _end(self, lane: dict, run: dict, code: int, interrupted: bool = False) -> None:
|
|
471
|
+
now = time.time()
|
|
472
|
+
run.update(ended=now, exit=code)
|
|
473
|
+
lane.update(state='interrupted' if interrupted else self._judge(lane, run, code),
|
|
474
|
+
exit=code, ended=now, step=None)
|
|
475
|
+
self.save()
|
|
476
|
+
self.write_runs()
|
|
477
|
+
|
|
478
|
+
def _pump(self, running: dict[str, Live], limit: int = PUMP_MAX) -> set[str]:
|
|
479
|
+
"""Read what the lanes printed since the last look: last_line and the step markers.
|
|
480
|
+
At most ``limit`` bytes per lane, so the caller gets back to its stop check, the
|
|
481
|
+
reaping and the refresh however fast a lane prints. Returns the names of the lanes whose
|
|
482
|
+
log was read to the end."""
|
|
483
|
+
drained: set[str] = set()
|
|
484
|
+
for name, live in running.items():
|
|
485
|
+
lane = self.by_name[name]
|
|
486
|
+
exited = live.proc.poll() is not None # asked first: all of its output is then readable
|
|
487
|
+
if exited and live.exited:
|
|
488
|
+
self._skip_backlog(live)
|
|
489
|
+
left = limit
|
|
490
|
+
eof = False
|
|
491
|
+
while left > 0:
|
|
492
|
+
chunk = live.reader.read(min(left, 1 << 20))
|
|
493
|
+
if not chunk:
|
|
494
|
+
eof = True
|
|
495
|
+
break
|
|
496
|
+
left -= len(chunk)
|
|
497
|
+
lane['last_output_at'] = time.time()
|
|
498
|
+
self.dirty = True
|
|
499
|
+
parts = re.split(r'[\r\n]', live.partial + live.decoder.decode(chunk))
|
|
500
|
+
live.partial = parts.pop()[-65536:]
|
|
501
|
+
for line in parts:
|
|
502
|
+
self._line(lane, line)
|
|
503
|
+
if eof:
|
|
504
|
+
drained.add(name)
|
|
505
|
+
if eof and exited:
|
|
506
|
+
tail = live.partial + live.decoder.decode(b'', final=True)
|
|
507
|
+
live.partial = ''
|
|
508
|
+
if tail:
|
|
509
|
+
self._line(lane, tail)
|
|
510
|
+
return drained
|
|
511
|
+
|
|
512
|
+
@staticmethod
|
|
513
|
+
def _skip_backlog(live: Live) -> None:
|
|
514
|
+
"""A finished lane's unread backlog is not read through: only its last line matters
|
|
515
|
+
now, and the log keeps every byte. Otherwise a leftover that ignores the TERM and
|
|
516
|
+
floods until its KILL would keep the lane draining long after it ended."""
|
|
517
|
+
try:
|
|
518
|
+
size = os.fstat(live.reader.fileno()).st_size
|
|
519
|
+
except OSError:
|
|
520
|
+
return
|
|
521
|
+
if size - live.reader.tell() > TAIL_KEEP + PUMP_MAX:
|
|
522
|
+
live.reader.seek(size - TAIL_KEEP)
|
|
523
|
+
live.decoder.reset()
|
|
524
|
+
live.partial = ''
|
|
525
|
+
|
|
526
|
+
def _line(self, lane: dict, raw: str) -> None:
|
|
527
|
+
line = ANSI.sub('', raw).strip()
|
|
528
|
+
if not line:
|
|
529
|
+
return
|
|
530
|
+
lane['last_line'] = line[:LAST_LINE_MAX]
|
|
531
|
+
marker = STEP.fullmatch(line)
|
|
532
|
+
if marker:
|
|
533
|
+
step, state = marker.group(1), marker.group(2)
|
|
534
|
+
if state == 'start':
|
|
535
|
+
lane['step'] = {'name': step, 'started': time.time()}
|
|
536
|
+
elif lane['step'] and lane['step']['name'] == step:
|
|
537
|
+
lane['step'] = None
|
|
538
|
+
|
|
539
|
+
# reports and the baseline -------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
def _scan(self, lane: dict, run: dict):
|
|
542
|
+
"""The run's reports (written since it started) as the lane's tests; None without reports=."""
|
|
543
|
+
rel = lane['options'].get('reports')
|
|
544
|
+
if not rel:
|
|
545
|
+
return None
|
|
546
|
+
tests, names, complete = reports.scan(os.path.join(self.checkout, rel), run['started'])
|
|
547
|
+
# Counts once a report exists: a run that wrote none has nothing to count.
|
|
548
|
+
lane['tests'] = tests if tests['reports'] or tests['notes'] else None
|
|
549
|
+
return tests, names, complete
|
|
550
|
+
|
|
551
|
+
def _scan_running(self, running: dict) -> None:
|
|
552
|
+
now = time.monotonic()
|
|
553
|
+
for name, live in running.items():
|
|
554
|
+
lane = self.by_name[name]
|
|
555
|
+
if live.exited or not lane['options'].get('reports') or now - live.scanned < SCAN_EVERY:
|
|
556
|
+
continue
|
|
557
|
+
live.scanned = now
|
|
558
|
+
previous = lane['tests']
|
|
559
|
+
self._scan(lane, lane['runs'][-1])
|
|
560
|
+
if lane['tests'] != previous:
|
|
561
|
+
self.save(force=False)
|
|
562
|
+
|
|
563
|
+
def _judge(self, lane: dict, run: dict, code: int) -> str:
|
|
564
|
+
"""pass, fail, or known: a non-zero exit whose every failing test, on complete
|
|
565
|
+
evidence, is in the lane's baseline."""
|
|
566
|
+
scanned = self._scan(lane, run)
|
|
567
|
+
if scanned is None:
|
|
568
|
+
return 'pass' if code == 0 else 'fail'
|
|
569
|
+
tests, names, complete = scanned
|
|
570
|
+
if code != 0 and lane['options'].get('baseline') and lane['tests'] is None:
|
|
571
|
+
lane['tests'] = tests # where the note below can be read
|
|
572
|
+
if tests['failing']:
|
|
573
|
+
at = reports.log_lines(str(self.out / lane['log']), [f['name'] for f in tests['failing']])
|
|
574
|
+
for entry in tests['failing']:
|
|
575
|
+
entry['log_line'] = at.get(entry['name'])
|
|
576
|
+
baseline = lane['options'].get('baseline')
|
|
577
|
+
if code == 0 or not baseline:
|
|
578
|
+
return 'pass' if code == 0 else 'fail'
|
|
579
|
+
try:
|
|
580
|
+
known = reports.load_baseline(os.path.join(self.checkout, baseline))
|
|
581
|
+
except OSError as exc:
|
|
582
|
+
tests['notes'].append(f'judged by its exit code: the baseline could not be read ({exc})')
|
|
583
|
+
return 'fail'
|
|
584
|
+
for entry in tests['failing']:
|
|
585
|
+
entry['known'] = entry['name'] in known
|
|
586
|
+
if not complete:
|
|
587
|
+
tests['notes'].append('judged by its exit code: a report written during this run could not be read')
|
|
588
|
+
return 'fail'
|
|
589
|
+
if not names:
|
|
590
|
+
tests['notes'].append('judged by its exit code: no failing test was found in its reports')
|
|
591
|
+
return 'fail'
|
|
592
|
+
# Over every failure, not only the 200 kept for display.
|
|
593
|
+
return 'known' if all(name.strip() in known for name in names) else 'fail'
|
|
594
|
+
|
|
595
|
+
# reruns ---------------------------------------------------------------------------------
|
|
596
|
+
|
|
597
|
+
def budget(self, name: str) -> int:
|
|
598
|
+
return self.by_name[name]['options'].get('retries', self.retries)
|
|
599
|
+
|
|
600
|
+
def changed(self) -> bool:
|
|
601
|
+
"""The mutation check, after a round: leftovers are stopped first, and once the tree
|
|
602
|
+
changed it stays changed."""
|
|
603
|
+
if not self.doc['mutated']:
|
|
604
|
+
self._kill_all()
|
|
605
|
+
try:
|
|
606
|
+
self.doc['mutated'] = fingerprint(self.checkout, self.fingerprint_cmd,
|
|
607
|
+
lambda: self.stop) != self.before
|
|
608
|
+
except Stopped:
|
|
609
|
+
return False # interrupted: nothing is judged, the caller sees self.stop
|
|
610
|
+
except (RuntimeError, OSError):
|
|
611
|
+
self.doc['mutated'] = True
|
|
612
|
+
if self.doc['mutated']:
|
|
613
|
+
self.save()
|
|
614
|
+
return self.doc['mutated']
|
|
615
|
+
|
|
616
|
+
def rerun(self, names: list[str]) -> None:
|
|
617
|
+
"""Rounds of reruns of the failed lanes among ``names`` with budget left, each round
|
|
618
|
+
under the same parallel and group rules; a pass on a rerun is flaky."""
|
|
619
|
+
while not self.stop and not self.changed():
|
|
620
|
+
if self.stop: # it came during that look at the tree
|
|
621
|
+
return
|
|
622
|
+
due = [n for n in names if self.by_name[n]['state'] == 'fail'
|
|
623
|
+
and len(self.by_name[n]['runs']) - 1 < self.budget(n)]
|
|
624
|
+
if not due:
|
|
625
|
+
return
|
|
626
|
+
for name in due:
|
|
627
|
+
self._archive(name, truncate=True)
|
|
628
|
+
self.run_round(due, kind='rerun')
|
|
629
|
+
for name in due:
|
|
630
|
+
lane = self.by_name[name]
|
|
631
|
+
if lane['state'] == 'pass':
|
|
632
|
+
lane['state'] = 'flaky'
|
|
633
|
+
self.save()
|
|
634
|
+
|
|
635
|
+
def _archive(self, name: str, truncate: bool = False) -> None:
|
|
636
|
+
"""Keep the lane's last run under its run-<k> name; a rerun then starts a fresh log."""
|
|
637
|
+
lane = self.by_name[name]
|
|
638
|
+
if not lane['runs']:
|
|
639
|
+
return
|
|
640
|
+
live, kept = self.out / lane['log'], self.out / lane['runs'][-1]['log']
|
|
641
|
+
try:
|
|
642
|
+
if os.path.islink(live) or os.path.islink(kept):
|
|
643
|
+
raise OSError('a symbolic link is not written through')
|
|
644
|
+
if live.exists() and not kept.exists():
|
|
645
|
+
shutil.copyfile(live, kept)
|
|
646
|
+
if truncate:
|
|
647
|
+
os.close(os.open(live, os.O_WRONLY | os.O_TRUNC | os.O_NOFOLLOW))
|
|
648
|
+
except OSError as exc:
|
|
649
|
+
print(f'forge-verify: cannot keep {kept.name}: {exc}', file=sys.stderr)
|
|
650
|
+
|
|
651
|
+
# process groups ---------------------------------------------------------------------
|
|
652
|
+
|
|
653
|
+
def _terminate(self) -> None:
|
|
654
|
+
"""Interrupt: stop every lane, then finish the running ones as interrupted."""
|
|
655
|
+
running = dict(self.running)
|
|
656
|
+
self._kill_all()
|
|
657
|
+
for name, live in running.items():
|
|
658
|
+
self._finish(name, live, interrupted=True)
|
|
659
|
+
self.running.clear()
|
|
660
|
+
|
|
661
|
+
def _kill_all(self) -> None:
|
|
662
|
+
"""TERM every lane process group (running lanes and what finished lanes left), KILL
|
|
663
|
+
what is left after KILL_AFTER seconds, and wait until no process of any group is
|
|
664
|
+
left. Safe to call again; used on every way out of ``run``."""
|
|
665
|
+
groups = {live.proc.pid for live in self.running.values()} | {entry[0] for entry in self.lingering}
|
|
666
|
+
for pgid in groups:
|
|
667
|
+
self._signal(pgid, signal.SIGTERM)
|
|
668
|
+
deadline = time.monotonic() + KILL_AFTER
|
|
669
|
+
while time.monotonic() < deadline and any(self._group_alive(g) for g in groups):
|
|
670
|
+
time.sleep(TICK)
|
|
671
|
+
for pgid in groups:
|
|
672
|
+
if self._group_alive(pgid):
|
|
673
|
+
self._signal(pgid, signal.SIGKILL)
|
|
674
|
+
deadline = time.monotonic() + 3 * KILL_AFTER # SIGKILL cannot be caught; do not hang on D state
|
|
675
|
+
while time.monotonic() < deadline and any(self._group_alive(g) for g in groups):
|
|
676
|
+
time.sleep(TICK)
|
|
677
|
+
self.lingering.clear()
|
|
678
|
+
|
|
679
|
+
def _reap_lingering(self) -> None:
|
|
680
|
+
"""KILL a finished lane's leftover processes that ignored the TERM sent at its end, and
|
|
681
|
+
forget them once they are gone (or, stuck in the kernel, after the give-up time)."""
|
|
682
|
+
now = time.monotonic()
|
|
683
|
+
keep = []
|
|
684
|
+
for pgid, deadline, group, give_up in self.lingering:
|
|
685
|
+
if not self._group_alive(pgid) or now >= give_up:
|
|
686
|
+
continue
|
|
687
|
+
if now >= deadline:
|
|
688
|
+
self._signal(pgid, signal.SIGKILL)
|
|
689
|
+
keep.append((pgid, deadline, group, give_up))
|
|
690
|
+
self.lingering = keep
|
|
691
|
+
|
|
692
|
+
@staticmethod
|
|
693
|
+
def _signal(pgid: int, sig: int) -> None:
|
|
694
|
+
try:
|
|
695
|
+
os.killpg(pgid, sig)
|
|
696
|
+
except (ProcessLookupError, PermissionError):
|
|
697
|
+
pass
|
|
698
|
+
|
|
699
|
+
def _group_alive(self, pgid: int) -> bool:
|
|
700
|
+
for live in self.running.values():
|
|
701
|
+
if live.proc.pid == pgid:
|
|
702
|
+
live.proc.poll() # reap a leader that exited, or it stays a zombie of the group
|
|
703
|
+
try:
|
|
704
|
+
os.killpg(pgid, 0)
|
|
705
|
+
except (ProcessLookupError, PermissionError):
|
|
706
|
+
return False
|
|
707
|
+
return True
|
|
708
|
+
|
|
709
|
+
# the whole verification ------------------------------------------------------------
|
|
710
|
+
|
|
711
|
+
def run(self) -> int:
|
|
712
|
+
"""Run everything. Whatever happens, no lane process is left behind; an unexpected
|
|
713
|
+
error finishes lanes.json as failed instead of leaving it saying ``running``."""
|
|
714
|
+
try:
|
|
715
|
+
return self._run()
|
|
716
|
+
except Exception as exc:
|
|
717
|
+
print(f'forge-verify: {type(exc).__name__}: {exc}', file=sys.stderr)
|
|
718
|
+
self._kill_all()
|
|
719
|
+
self._abort()
|
|
720
|
+
return 1
|
|
721
|
+
finally:
|
|
722
|
+
self._kill_all()
|
|
723
|
+
for live in self.running.values():
|
|
724
|
+
live.close()
|
|
725
|
+
|
|
726
|
+
def _abort(self) -> None:
|
|
727
|
+
now = time.time()
|
|
728
|
+
for lane in self.doc['lanes']:
|
|
729
|
+
if lane['state'] == 'running':
|
|
730
|
+
lane.update(state='interrupted', ended=now, step=None)
|
|
731
|
+
lane['runs'][-1]['ended'] = now
|
|
732
|
+
elif lane['state'] == 'waiting':
|
|
733
|
+
lane['state'] = 'skipped'
|
|
734
|
+
self.doc.update(state='fail', ended=now)
|
|
735
|
+
for write in (self.save, self.write_runs): # best effort: the disk may be what failed
|
|
736
|
+
try:
|
|
737
|
+
write()
|
|
738
|
+
except OSError:
|
|
739
|
+
pass
|
|
740
|
+
|
|
741
|
+
def _run(self) -> int:
|
|
742
|
+
self.out.mkdir(parents=True, exist_ok=True)
|
|
743
|
+
try:
|
|
744
|
+
self.before = fingerprint(self.checkout, self.fingerprint_cmd, lambda: self.stop)
|
|
745
|
+
except Stopped:
|
|
746
|
+
pass # self.stop is set: no lane starts, every lane ends interrupted
|
|
747
|
+
except (RuntimeError, OSError) as exc:
|
|
748
|
+
print(f'forge-verify: cannot fingerprint {self.checkout}: {exc}', file=sys.stderr)
|
|
749
|
+
self._abort()
|
|
750
|
+
return 2
|
|
751
|
+
self.save()
|
|
752
|
+
lanes = self.doc['lanes']
|
|
753
|
+
names = [lane['name'] for lane in lanes]
|
|
754
|
+
firsts = [n for n in names if self.by_name[n]['first']]
|
|
755
|
+
rest = [n for n in names if n not in firsts]
|
|
756
|
+
if firsts:
|
|
757
|
+
self.run_round(firsts)
|
|
758
|
+
self.rerun(firsts) # before the gate: a flaky first lane lets the rest run
|
|
759
|
+
if not self.stop:
|
|
760
|
+
if any(self.by_name[n]['state'] not in PASSING for n in firsts) or self.doc['mutated']:
|
|
761
|
+
for n in rest:
|
|
762
|
+
self.by_name[n]['state'] = 'skipped'
|
|
763
|
+
else:
|
|
764
|
+
self.run_round(rest)
|
|
765
|
+
self.rerun(rest)
|
|
766
|
+
if not self.stop:
|
|
767
|
+
self.changed() # stops the last lanes' leftovers, then looks at the tree again
|
|
768
|
+
if self.stop: # also when the stop came during that last look
|
|
769
|
+
for lane in lanes:
|
|
770
|
+
if lane['state'] in ('waiting', 'running'):
|
|
771
|
+
lane['state'] = 'interrupted'
|
|
772
|
+
self.doc['state'] = 'interrupted'
|
|
773
|
+
else:
|
|
774
|
+
ok = all(lane['state'] in PASSING for lane in lanes) and not self.doc['mutated']
|
|
775
|
+
self.doc['state'] = 'pass' if ok else 'fail'
|
|
776
|
+
for name in names:
|
|
777
|
+
self._archive(name) # every run has its own log, the last one included
|
|
778
|
+
self.doc['ended'] = time.time()
|
|
779
|
+
self.save()
|
|
780
|
+
self.write_runs()
|
|
781
|
+
self.summary()
|
|
782
|
+
return INTERRUPTED if self.stop else 0 if self.doc['state'] == 'pass' else 1
|
|
783
|
+
|
|
784
|
+
def summary(self) -> None:
|
|
785
|
+
for lane in self.doc['lanes']:
|
|
786
|
+
took = '' if lane['started'] is None or lane['ended'] is None \
|
|
787
|
+
else f'{int(lane["ended"] - lane["started"])}s'
|
|
788
|
+
runs = len(lane['runs'])
|
|
789
|
+
extra = [f'({runs} runs)'] if runs > 1 else []
|
|
790
|
+
tests = lane['tests']
|
|
791
|
+
if tests:
|
|
792
|
+
extra.append(f'tests {tests["pass"]} passed, {tests["fail"] + tests["error"]} failed, '
|
|
793
|
+
f'{tests["skip"]} skipped')
|
|
794
|
+
known = sum(1 for f in tests['failing'] if f['known'])
|
|
795
|
+
if known:
|
|
796
|
+
extra.append(f'known {known}')
|
|
797
|
+
print(f' {lane["name"]:<24} {lane["state"]:<12} {took:<6} {" ".join(extra)}'.rstrip())
|
|
798
|
+
flaky = [lane['name'] for lane in self.doc['lanes'] if lane['state'] == 'flaky']
|
|
799
|
+
if flaky:
|
|
800
|
+
print(' flaky (failed, then passed on a rerun): ' + ', '.join(flaky))
|
|
801
|
+
if self.doc['mutated']:
|
|
802
|
+
print(' the working tree changed while the lanes ran; with lanes in parallel the '
|
|
803
|
+
'change cannot be pinned to one lane (mutated: true)')
|
|
804
|
+
print(f'verification: {self.doc["state"]}')
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
# --- command line ----------------------------------------------------------------------
|
|
808
|
+
|
|
809
|
+
def positive(value: str) -> int:
|
|
810
|
+
if not re.fullmatch(r'[0-9]+', value) or int(value) < 1:
|
|
811
|
+
raise argparse.ArgumentTypeError('must be an integer of at least 1')
|
|
812
|
+
return int(value)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def retries_value(value: str) -> int:
|
|
816
|
+
if not re.fullmatch(r'[0-9]', value):
|
|
817
|
+
raise argparse.ArgumentTypeError('must be an integer from 0 to 9')
|
|
818
|
+
return int(value)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def cmd_lanes(args: argparse.Namespace) -> int:
|
|
822
|
+
lanes, errors = load_lanes(args.lanes_dir, args.checkout)
|
|
823
|
+
if errors:
|
|
824
|
+
print('\n'.join(errors), file=sys.stderr)
|
|
825
|
+
return 2
|
|
826
|
+
if args.json:
|
|
827
|
+
print(json.dumps({'lanes': lanes}, indent=1))
|
|
828
|
+
return 0
|
|
829
|
+
for lane in lanes:
|
|
830
|
+
flags = ' '.join(filter(None, ['first' if lane['first'] else '',
|
|
831
|
+
f'group={lane["group"]}' if lane['group'] else ''] +
|
|
832
|
+
[f'{k}={v}' for k, v in lane['options'].items()]))
|
|
833
|
+
print(f'{lane["name"]}\t{flags or "-"}\t{lane["command"].splitlines()[0]}')
|
|
834
|
+
return 0
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
838
|
+
if not Path(args.checkout).is_dir():
|
|
839
|
+
print(f'{args.checkout}: not a directory', file=sys.stderr)
|
|
840
|
+
return 2
|
|
841
|
+
lanes, errors = load_lanes(args.lanes_dir, args.checkout)
|
|
842
|
+
if errors:
|
|
843
|
+
print('\n'.join(errors), file=sys.stderr)
|
|
844
|
+
return 2
|
|
845
|
+
verification = Verification(args.checkout, args.out, lanes, args.parallel, args.fingerprint_cmd,
|
|
846
|
+
args.retries)
|
|
847
|
+
|
|
848
|
+
def interrupt(signum, frame):
|
|
849
|
+
verification.stop = True
|
|
850
|
+
|
|
851
|
+
signal.signal(signal.SIGTERM, interrupt)
|
|
852
|
+
signal.signal(signal.SIGINT, interrupt)
|
|
853
|
+
return verification.run()
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def main(argv: list[str] | None = None) -> int:
|
|
857
|
+
parser = argparse.ArgumentParser(prog='forge-verify.py', description=__doc__.split('\n\n')[0])
|
|
858
|
+
sub = parser.add_subparsers(dest='command', required=True)
|
|
859
|
+
lanes = sub.add_parser('lanes', help='validate and list the declared lanes')
|
|
860
|
+
lanes.add_argument('--lanes-dir', required=True)
|
|
861
|
+
lanes.add_argument('--checkout', help='also check reports= and baseline= against this checkout')
|
|
862
|
+
lanes.add_argument('--json', action='store_true')
|
|
863
|
+
lanes.set_defaults(func=cmd_lanes)
|
|
864
|
+
run = sub.add_parser('run', help='run the lanes')
|
|
865
|
+
run.add_argument('--checkout', required=True)
|
|
866
|
+
run.add_argument('--out', required=True)
|
|
867
|
+
run.add_argument('--lanes-dir', required=True)
|
|
868
|
+
run.add_argument('--parallel', type=positive, default=3)
|
|
869
|
+
run.add_argument('--retries', type=retries_value, default=1,
|
|
870
|
+
help='reruns of a failed lane (default 1; a lane\'s retries= wins)')
|
|
871
|
+
run.add_argument('--fingerprint-cmd')
|
|
872
|
+
run.set_defaults(func=cmd_run)
|
|
873
|
+
args = parser.parse_args(argv)
|
|
874
|
+
return args.func(args)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
if __name__ == '__main__':
|
|
878
|
+
sys.exit(main())
|