You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
206 lines
8.5 KiB
206 lines
8.5 KiB
#!/usr/bin/env python3 |
|
"""Запуск make check: ограниченный пул, отдельные логи и барьеры для замеров CPU.""" |
|
|
|
import argparse |
|
import os |
|
from pathlib import Path |
|
import shutil |
|
import signal |
|
import subprocess |
|
import sys |
|
import time |
|
|
|
|
|
# Проверки точных порогов CPU/wall-time выполняем после общего пула. |
|
EXCLUSIVE = frozenset({ |
|
"test_media_async", "test_uasync_load", |
|
}) |
|
EXCLUSIVE |= {name + ".exe" for name in EXCLUSIVE} |
|
|
|
|
|
def available_cpus(): |
|
if hasattr(os, "sched_getaffinity"): |
|
try: |
|
return max(1, len(os.sched_getaffinity(0))) |
|
except OSError as error: |
|
print(f"[WARN] CPU affinity unavailable: {error}; using system CPU count", file=sys.stderr) |
|
return os.cpu_count() or 1 |
|
|
|
|
|
def positive_int(value): |
|
try: |
|
number = int(value) |
|
except ValueError: |
|
raise argparse.ArgumentTypeError("expected a positive integer") from None |
|
if number < 1: |
|
raise argparse.ArgumentTypeError("expected a positive integer") |
|
return number |
|
|
|
|
|
class InterruptedRun(Exception): |
|
def __init__(self, signum): |
|
self.signum = signum |
|
|
|
|
|
# Advisory lock снимается ОС даже при SIGKILL; соседний запуск не перезапишет логи. |
|
def lock_logs(path): |
|
lock = (path / ".runner.lock").open("a+b") |
|
try: |
|
if os.name == "posix": |
|
import fcntl |
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) |
|
else: |
|
import msvcrt |
|
if not lock.tell(): |
|
lock.write(b"\0"); lock.flush() |
|
lock.seek(0) |
|
msvcrt.locking(lock.fileno(), msvcrt.LK_NBLCK, 1) |
|
except OSError: |
|
lock.close() |
|
raise |
|
return lock |
|
|
|
|
|
# Останавливаем всю группу теста, включая fork/exec-помощников, с общей паузой 1с. |
|
def stop_processes(processes): |
|
if os.name != "posix": |
|
for process in processes: |
|
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"], |
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) |
|
process.wait() |
|
return |
|
for process in processes: |
|
try: |
|
os.killpg(process.pid, signal.SIGTERM) |
|
except ProcessLookupError: |
|
pass |
|
deadline = time.monotonic() + 1 |
|
for process in processes: |
|
try: |
|
process.wait(timeout=max(0, deadline - time.monotonic())) |
|
except subprocess.TimeoutExpired: |
|
pass |
|
for process in processes: |
|
try: |
|
os.killpg(process.pid, signal.SIGKILL) |
|
except ProcessLookupError: |
|
pass |
|
process.wait() |
|
|
|
|
|
def run_tests(args, requested_signals): |
|
counts = dict(PASS=0, FAIL=0, SKIP=0) |
|
failed = [] |
|
running = [] |
|
index = 0 |
|
tests = [name for name in args.tests if name not in EXCLUSIVE] + [name for name in args.tests if name in EXCLUSIVE] |
|
use_sudo = ("test_auto_socket_dynamic" in args.tests and hasattr(os, "geteuid") and os.geteuid() != 0 |
|
and shutil.which("sudo") and subprocess.run(["sudo", "-n", "true"], |
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0) |
|
|
|
def report(name, status, elapsed, reason=""): |
|
counts[status] += 1 |
|
if status == "FAIL": |
|
failed.append(name) |
|
detail = f" ({reason}; see {args.log_dir / (name + '.log')})" if reason else "" |
|
print(f"[{status}] {name} ({elapsed * 1000:.0f}ms){detail}", flush=True) |
|
|
|
print(f"[INFO] test runner: jobs={args.jobs} available_cpus={available_cpus()} timeout={args.timeout}s", flush=True) |
|
try: |
|
while index < len(tests) or running: |
|
if requested_signals: |
|
raise InterruptedRun(requested_signals[0]) |
|
while index < len(tests) and len(running) < args.jobs: |
|
if requested_signals: |
|
break |
|
name = tests[index] |
|
if running and (name in EXCLUSIVE or running[0][0] in EXCLUSIVE): |
|
break |
|
index += 1 |
|
if not Path(name).is_file(): |
|
report(name, "SKIP", 0, "not built") |
|
continue |
|
command = [str(Path(name).resolve())] |
|
if name in EXCLUSIVE: |
|
print(f"[INFO] {name}: running alone to check CPU/wall-time limits", flush=True) |
|
if name == "test_auto_socket_dynamic" and use_sudo: |
|
command = ["sudo", "-n", "--"] + command |
|
print(f"[INFO] {name}: running as root via sudo -n", flush=True) |
|
log = (args.log_dir / (name + ".log")).open("wb") |
|
log.write(f"[INFO] runner: test={name} exclusive={name in EXCLUSIVE} command={command}\n".encode()) |
|
log.flush() |
|
started = time.monotonic() |
|
try: |
|
process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, start_new_session=True) |
|
except OSError as error: |
|
log.write(f"[ERROR] cannot start test: {error}\n".encode()); log.close() |
|
report(name, "FAIL", time.monotonic() - started, f"cannot start: {error}") |
|
continue |
|
running.append((name, process, log, started)) |
|
log.write(f"[INFO] runner: pid={process.pid}\n".encode()); log.flush() |
|
if name in EXCLUSIVE: |
|
break |
|
for entry in running[:]: |
|
name, process, log, started = entry |
|
elapsed = time.monotonic() - started |
|
rc = process.poll() |
|
reason = "" |
|
if rc is None and elapsed >= args.timeout: |
|
stop_processes([process]) |
|
rc = 124 |
|
reason = f"timeout={args.timeout}s" |
|
log.write(f"[ERROR] runner: {reason}; process group stopped\n".encode()) |
|
if rc is not None: |
|
if rc and rc != 77 and not reason: |
|
stop_processes([process]) |
|
log.close(); running.remove(entry) |
|
status = "PASS" if rc == 0 else "SKIP" if rc == 77 else "FAIL" |
|
report(name, status, time.monotonic() - started, reason or (f"exit={rc}" if rc else "")) |
|
if running: |
|
time.sleep(0.01) |
|
finally: |
|
if running: |
|
stop_processes([entry[1] for entry in running]) |
|
for name, process, log, started in running: |
|
log.write(b"[ERROR] runner: interrupted; process group stopped\n"); log.close() |
|
print("\n==========================================") |
|
print(f"Test Results: {counts['PASS']} passed, {counts['FAIL']} failed, {counts['SKIP']} skipped ({len(args.tests)} total)") |
|
print(f"Log directory: {args.log_dir}") |
|
print("==========================================") |
|
if failed: |
|
print("Failed tests: " + " ".join(failed)) |
|
return int(bool(failed)) |
|
|
|
|
|
def main(): |
|
parser = argparse.ArgumentParser(description=__doc__) |
|
parser.add_argument("--jobs", type=positive_int, default=os.environ.get("TEST_JOBS", str(available_cpus()))) |
|
parser.add_argument("--timeout", type=positive_int, default=90, help="per-test timeout in seconds") |
|
parser.add_argument("--log-dir", type=Path, default=Path("logs")) |
|
parser.add_argument("tests", nargs="+") |
|
args = parser.parse_args() |
|
if len(set(args.tests)) != len(args.tests) or any(Path(name).name != name for name in args.tests): |
|
parser.error("test names must be unique basenames") |
|
requested_signals = [] |
|
|
|
# Не выбрасываем исключение внутри Popen: сначала учитываем новый процесс и его fd. |
|
def interrupted(signum, frame): |
|
if not requested_signals: |
|
requested_signals.append(signum) |
|
|
|
signal.signal(signal.SIGINT, interrupted) |
|
signal.signal(signal.SIGTERM, interrupted) |
|
try: |
|
args.log_dir.mkdir(parents=True, exist_ok=True) |
|
with lock_logs(args.log_dir): |
|
return run_tests(args, requested_signals) |
|
except InterruptedRun as error: |
|
print(f"[ERROR] runner interrupted by signal {error.signum}; test processes stopped", file=sys.stderr, flush=True) |
|
return 128 + error.signum |
|
except OSError as error: |
|
print(f"[ERROR] runner: {error}; log directory={args.log_dir} (check permissions or concurrent runs)", file=sys.stderr) |
|
return 1 |
|
|
|
|
|
if __name__ == "__main__": |
|
sys.exit(main())
|
|
|