Files
aego/tools/ci/integration_race.py
sanspie 4a7696922a
GitHub / darwin arm64 native GitHub-hosted native CI: success
ci-platforms / darwin amd64 smoke (push) Skipped
github-native / darwin amd64 native (push) Skipped
github-native / gitea-pending (push) Skipped
github-native / linux amd64 native (push) Skipped
github-native / windows amd64 native (push) Skipped
github-native / darwin arm64 native (push) Skipped
github-native / gitea-result (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
GitHub / windows amd64 native GitHub-hosted native CI: success
GitHub / darwin amd64 native GitHub-hosted native CI: success
GitHub / linux amd64 native GitHub-hosted native CI: success
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 9m28s
ci / checks (push) Failing after 56m1s
ci / release (push) Skipped
ci: report failing integration race tests
2026-11-10 09:38:04 +03:00

244 lines
12 KiB
Python

#!/usr/bin/env python3
"""Run all integration Test/Example/Fuzz seeds under -race in bounded groups.
The normal 30-minute Go timeout is retained for each group. The binary is
compiled once into a new evidence directory, never into the checkout root.
Skips retain Go's ordinary exit semantics but are reported as UNVERIFIED.
"""
import argparse
from collections import Counter
import hashlib
import json
import os
from pathlib import Path
import platform
import re
import subprocess
import sys
import tempfile
import time
import traceback
from worktree_manifest import inventory
PACKAGE = 'aego/tests/integration'
TEST_NAME = re.compile(r'^(?:Test|Example|Fuzz)\w*$')
GROUP_COUNT = 8
GO_TIMEOUT = '30m'
def names_from(text):
names = [line.strip() for line in text.splitlines() if TEST_NAME.fullmatch(line.strip())]
if not names or len(names) != len(set(names)):
raise ValueError('empty or duplicate top-level test enumeration')
return sorted(names)
def groups_for(names):
if not names or names != sorted(set(names)):
raise ValueError('groups require the complete sorted unique test enumeration')
return [names[i::GROUP_COUNT] for i in range(min(len(names), GROUP_COUNT))]
def selector(names):
if not names:
raise ValueError('an empty selector must not accidentally run the entire package')
return '^(' + '|'.join(re.escape(name) for name in names) + ')$'
def check_events(events, expected):
selected = set(expected)
starts, ends = Counter(), Counter()
results, outputs, problems = {}, {}, []
for event in events:
if not isinstance(event, dict) or event.get('Package') != PACKAGE:
problems.append('unexpected event package/schema')
continue
name, action = event.get('Test', ''), event.get('Action', '')
if name and name.split('/', 1)[0] not in selected:
problems.append('unselected test executed: ' + name)
if name and event.get('Output'):
outputs.setdefault(name, []).append(event['Output'])
if name and '/' not in name and action == 'run':
starts[name] += 1
if name and action in ('pass', 'fail', 'skip'):
if name in results:
problems.append('duplicate terminal result: ' + name)
results[name] = action.upper()
if '/' not in name:
ends[name] += 1
for name in expected:
if starts[name] != 1 or ends[name] != 1:
problems.append(f'{name}: expected one start and terminal result, got {starts[name]}/{ends[name]}')
skipped = [{'name': name, 'result': 'SKIP', 'acceptance': 'UNVERIFIED', 'reasonOutput': ''.join(outputs.get(name, []))}
for name, result in results.items() if result == 'SKIP']
return {'coverageComplete': not problems, 'problems': problems,
'topLevel': {name: results.get(name, 'NOT_RUN') for name in expected},
'failures': [name for name, result in results.items() if result == 'FAIL'], 'skipped': skipped}
def write_json(path, data):
with path.open('x', encoding='utf-8') as stream:
json.dump(data, stream, indent=2, ensure_ascii=True)
stream.write('\n')
def digest(path):
h = hashlib.sha256()
with path.open('rb') as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
h.update(chunk)
return h.hexdigest()
def command(argv, cwd, log, timeout=None, stdin=None, check=True):
write_json(log.with_suffix('.command.json'), {'argv': argv, 'cwd': str(cwd)})
with log.open('xb') as output:
proc = subprocess.Popen(argv, cwd=cwd, stdin=stdin, stdout=output, stderr=subprocess.STDOUT)
try:
code = proc.wait(timeout=timeout)
except BaseException as error:
# This is the Popen handle spawned above, never an arbitrary PID
# lookup. Incomplete execution is never a successful group.
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=10)
if isinstance(error, subprocess.TimeoutExpired):
log.with_suffix('.exit').write_text('TIMEOUT\n')
raise RuntimeError(f'owned process exceeded deadline: {argv[0]}') from error
raise
log.with_suffix('.exit').write_text(str(code) + '\n')
if code != 0 and check:
raise RuntimeError(f'command exited {code}: {argv[0]}; see {log}')
return code
def run_group(binary, names, cwd, directory):
directory.mkdir()
argv = [str(binary), '-test.v=test2json', '-test.paniconexit0=true', '-test.count=1',
'-test.timeout=' + GO_TIMEOUT, '-test.run=' + selector(names)]
result = {'selected': names, 'status': 'FAIL', 'timeout': GO_TIMEOUT}
start = time.monotonic()
try:
# Convert the output even on failure: otherwise the artifact contains
# the raw log but the report loses the identity of the failed test.
test_exit = command(argv, cwd, directory / 'test.log', timeout=30 * 60 + 45, check=False)
with (directory / 'test.log').open('rb') as raw, (directory / 'events.jsonl').open('xb') as output, (directory / 'converter.log').open('xb') as errors:
converted = subprocess.run(['go', 'tool', 'test2json', '-t', '-p', PACKAGE], cwd=cwd,
stdin=raw, stdout=output, stderr=errors, timeout=60)
result['converterExitCode'] = converted.returncode
if converted.returncode != 0:
raise RuntimeError('go tool test2json failed')
with (directory / 'events.jsonl').open(encoding='utf-8') as stream:
result.update(check_events((json.loads(line) for line in stream), names))
if test_exit == 0 and result['coverageComplete'] and not result['failures']:
result['status'] = 'COMPLETED_WITH_SKIPS' if result['skipped'] else 'PASS'
elif test_exit != 0:
result['error'] = f'test binary exited {test_exit}; see {directory / "test.log"}'
except Exception as error:
result['error'] = f'{type(error).__name__}: {error}'
result['traceback'] = traceback.format_exc()
result['elapsedSeconds'] = round(time.monotonic() - start, 6)
exit_file = directory / 'test.exit'
if exit_file.is_file():
exit_text = exit_file.read_text().strip()
result['testExitCode'] = int(exit_text) if re.fullmatch(r'-?\d+', exit_text) else exit_text
else:
result['testExitCode'] = 'UNVERIFIED'
for name in ('test.log', 'test.exit', 'events.jsonl', 'converter.log'):
if (directory / name).is_file():
result[name + 'SHA256'] = digest(directory / name)
write_json(directory / 'result.json', result)
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--root', type=Path, default=Path(__file__).resolve().parents[2])
parser.add_argument('--evidence', type=Path)
parser.add_argument('--allow-unborn', action='store_true')
parser.add_argument('--require-no-skip', action='store_true')
args = parser.parse_args()
root = args.root.resolve(strict=True)
if args.evidence:
requested = args.evidence.absolute()
parent = requested.parent.resolve(strict=True)
evidence = parent / requested.name
if evidence == root or root in evidence.parents:
rel = evidence.relative_to(root).as_posix()
if subprocess.run(['git', '-C', str(root), 'check-ignore', '-q', '--', rel]).returncode != 0:
raise SystemExit('evidence inside checkout must be ignored')
evidence.mkdir()
else:
parent = root / '.cache/integration-race'
parent.mkdir(parents=True, exist_ok=True)
evidence = Path(tempfile.mkdtemp(prefix='run-', dir=parent))
temporary = evidence / 'tmp'
temporary.mkdir(mode=0o700)
os.environ['TMPDIR'] = str(temporary)
# Running scenes may contain music. Always use the dummy audio device.
os.environ['SDL_AUDIO_DRIVER'] = 'dummy'
os.environ['SDL_AUDIODRIVER'] = 'dummy'
report = {'schema': 1, 'package': PACKAGE, 'status': 'FAIL', 'race': True,
'count': 1, 'groupCount': GROUP_COUNT, 'timeoutPerGroup': GO_TIMEOUT, 'groups': [],
'runner': platform.platform(),
'limits': ['Every ordinary Test/Example/Fuzz seed runs once; skips are UNVERIFIED.',
'Benchmark bodies are not part of go test and are not run here.']}
(evidence / 'exit').write_text('RUNNING\n')
code = 1
try:
before = inventory(root, allow_unborn=args.allow_unborn)
write_json(evidence / 'source-before.json', before)
report.update(sourceFingerprint=before['worktreeSHA256'], head=before['head'], audio='dummy', tempDirectory=str(temporary))
command(['go', 'version'], root, evidence / 'go-version.log')
report['goVersion'] = (evidence / 'go-version.log').read_text().strip()
command(['go', 'env', 'GOOS', 'GOARCH', 'CGO_ENABLED', 'GOFLAGS'], root, evidence / 'go-env.log')
command(['go', 'test', '-race', '-count=1', '-list', '^(Test|Example|Fuzz)', './tests/integration'], root, evidence / 'go-list.log')
names = names_from((evidence / 'go-list.log').read_text(errors='replace'))
binary = evidence / 'integration.test'
command(['go', 'test', '-race', '-c', '-o', str(binary), './tests/integration'], root, evidence / 'build.log')
report['testBinarySHA256'] = digest(binary)
command([str(binary), '-test.list=^(Test|Example|Fuzz)'], root / 'tests/integration', evidence / 'binary-list.log')
if names != names_from((evidence / 'binary-list.log').read_text(errors='replace')):
raise RuntimeError('Go and compiled binary enumerate different tests')
groups = groups_for(names)
write_json(evidence / 'groups.json', groups)
for i, group in enumerate(groups, 1):
result = run_group(binary, group, root / 'tests/integration', evidence / f'group-{i:02d}')
report['groups'].append(result)
detail = ''
if result.get('failures'):
detail = '; failed=' + ', '.join(result['failures'])
elif result['status'] == 'FAIL':
detail = '; ' + result.get('error', 'incomplete test coverage')
print(f'group {i}/{len(groups)}: {result["status"]}; tests={len(group)}{detail}', flush=True)
after = inventory(root, allow_unborn=args.allow_unborn)
write_json(evidence / 'source-after.json', after)
report['sourceUnchanged'] = before == after
report['coverageComplete'] = all(g.get('coverageComplete', False) for g in report['groups'])
report['skipped'] = [skip for g in report['groups'] for skip in g.get('skipped', [])]
passed = all(g['status'] in ('PASS', 'COMPLETED_WITH_SKIPS') for g in report['groups'])
if report['sourceUnchanged'] and report['coverageComplete'] and passed:
report['status'] = 'COMPLETED_WITH_SKIPS' if report['skipped'] else 'PASS'
code = 1 if args.require_no_skip and report['skipped'] else 0
except Exception as error:
report['error'] = f'{type(error).__name__}: {error}'
report['traceback'] = traceback.format_exc()
print(report['error'], file=sys.stderr)
except KeyboardInterrupt:
code = 130
report['status'] = 'INTERRUPTED'
report['exitCode'] = code
write_json(evidence / 'summary.json', report)
(evidence / 'exit').write_text(str(code) + '\n')
print(f'{report["status"]}: {evidence}', flush=True)
return code
if __name__ == '__main__':
raise SystemExit(main())