@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/SKILL.md +4 -2
  3. package/VERSION +1 -1
  4. package/docs/artifacts.md +44 -11
  5. package/docs/commands.md +45 -7
  6. package/docs/decomposed-runs.md +43 -2
  7. package/docs/forge-ui.md +27 -6
  8. package/docs/reviews.md +31 -6
  9. package/package.json +1 -1
  10. package/references/dataset.md +3 -1
  11. package/references/decompose.md +159 -17
  12. package/references/harnesses.md +13 -1
  13. package/references/ui.md +14 -4
  14. package/scripts/forge-control.py +268 -25
  15. package/scripts/forge-dispatch.sh +80 -3
  16. package/scripts/forge-guard.py +156 -15
  17. package/scripts/forge-lib-plans.sh +9 -3
  18. package/scripts/forge-lib-review.sh +22 -3
  19. package/scripts/forge-lib-task.sh +51 -4
  20. package/scripts/forge-lib-verify.sh +137 -6
  21. package/scripts/forge-parallel-options.sh +59 -0
  22. package/scripts/forge-parallel.sh +212 -28
  23. package/scripts/forge-ripwire.py +1 -1
  24. package/scripts/forge-route.py +157 -5
  25. package/scripts/forge-schedule.py +687 -50
  26. package/scripts/forge-verify.py +878 -0
  27. package/scripts/forge_dataset/forecast.py +29 -4
  28. package/scripts/forge_dataset/harvest.py +10 -4
  29. package/scripts/forge_verify_reports.py +225 -0
  30. package/ui/README.md +18 -3
  31. package/ui/docs/api.md +126 -17
  32. package/ui/forge_ui/control.py +12 -2
  33. package/ui/forge_ui/export.py +20 -7
  34. package/ui/forge_ui/reader.py +307 -11
  35. package/ui/forge_ui/server.py +16 -0
  36. package/ui/forge_ui/static/app.js +2 -1
  37. package/ui/forge_ui/static/controls.js +217 -33
  38. package/ui/forge_ui/static/gantt.js +14 -9
  39. package/ui/forge_ui/static/index.html +1 -0
  40. package/ui/forge_ui/static/panel.js +2 -1
  41. package/ui/forge_ui/static/runmodel.js +92 -11
  42. package/ui/forge_ui/static/scene3d.js +9 -5
  43. package/ui/forge_ui/static/style.css +60 -0
  44. 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())