4 changed files with 383 additions and 59 deletions
@ -0,0 +1,205 @@ |
|||||||
|
#!/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; autosocket проверяем отдельно с sudo. |
||||||
|
EXCLUSIVE = frozenset({ |
||||||
|
"bench_timeout_heap", "bench_uasync_timeouts", "test_u_async_performance", |
||||||
|
"test_intensive_memory_pool", "test_media_async", "test_bbr_integration", |
||||||
|
"test_uasync_load", "test_auto_socket_dynamic", |
||||||
|
}) |
||||||
|
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 |
||||||
|
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(args.tests) or running: |
||||||
|
if requested_signals: |
||||||
|
raise InterruptedRun(requested_signals[0]) |
||||||
|
while index < len(args.tests) and len(running) < args.jobs: |
||||||
|
if requested_signals: |
||||||
|
break |
||||||
|
name = args.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 == "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()) |
||||||
@ -0,0 +1,168 @@ |
|||||||
|
#!/usr/bin/env python3 |
||||||
|
"""Регрессии запускателя: лимит jobs, CPU-барьеры, ошибки и освобождение дочерних процессов.""" |
||||||
|
|
||||||
|
import json |
||||||
|
import os |
||||||
|
from pathlib import Path |
||||||
|
import signal |
||||||
|
import subprocess |
||||||
|
import sys |
||||||
|
import tempfile |
||||||
|
import time |
||||||
|
import unittest |
||||||
|
|
||||||
|
from run_tests import available_cpus |
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.name == "posix", "fixtures use POSIX executables and fork") |
||||||
|
class RunnerTests(unittest.TestCase): |
||||||
|
def setUp(self): |
||||||
|
self.directory = tempfile.TemporaryDirectory(prefix="utun_runner_") |
||||||
|
self.addCleanup(self.directory.cleanup) |
||||||
|
self.root = Path(self.directory.name) |
||||||
|
self.runner = Path(__file__).with_name("run_tests.py").resolve() |
||||||
|
|
||||||
|
def fixture(self, name, body=""): |
||||||
|
path = self.root / name |
||||||
|
path.write_text(f"#!{sys.executable}\n" + """ |
||||||
|
import json, os, signal, sys, time |
||||||
|
from pathlib import Path |
||||||
|
name = Path(__file__).name |
||||||
|
def event(state): |
||||||
|
fd = os.open('events', os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600) |
||||||
|
os.write(fd, (json.dumps([name, state]) + '\\n').encode()); os.close(fd) |
||||||
|
event('start') |
||||||
|
""" + body + "\nevent('end')\nprint('fixture output: ' + name)\n") |
||||||
|
path.chmod(0o755) |
||||||
|
|
||||||
|
def command(self, tests, options=()): |
||||||
|
return [sys.executable, str(self.runner), "--log-dir", "logs", *options, *tests] |
||||||
|
|
||||||
|
def run_runner(self, tests, options=(), environment=None): |
||||||
|
env = dict(os.environ, TEST_JOBS="2") if environment is None else environment |
||||||
|
return subprocess.run(self.command(tests, options), cwd=self.root, env=env, |
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=15) |
||||||
|
|
||||||
|
def test_default_cpu_count_and_override(self): |
||||||
|
self.fixture("fast") |
||||||
|
env = os.environ.copy(); env.pop("TEST_JOBS", None) |
||||||
|
result = self.run_runner(["fast"], environment=env) |
||||||
|
self.assertEqual(result.returncode, 0, result.stdout) |
||||||
|
self.assertIn(f"jobs={available_cpus()} available_cpus=", result.stdout) |
||||||
|
if hasattr(os, "sched_setaffinity"): |
||||||
|
affinity = {min(os.sched_getaffinity(0))} |
||||||
|
result = subprocess.run(self.command(["fast"]), cwd=self.root, env=env, text=True, |
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=10, |
||||||
|
preexec_fn=lambda: os.sched_setaffinity(0, affinity)) |
||||||
|
self.assertEqual(result.returncode, 0, result.stdout) |
||||||
|
self.assertIn("jobs=1 available_cpus=1", result.stdout) |
||||||
|
result = self.run_runner(["fast"], environment=dict(env, TEST_JOBS="1")) |
||||||
|
self.assertEqual(result.returncode, 0, result.stdout) |
||||||
|
self.assertIn("jobs=1 available_cpus=", result.stdout) |
||||||
|
result = self.run_runner(["fast"], environment=dict(env, TEST_JOBS="0")) |
||||||
|
self.assertEqual(result.returncode, 2, result.stdout) |
||||||
|
self.assertIn("expected a positive integer", result.stdout) |
||||||
|
|
||||||
|
def test_parallel_limit_and_exclusive_barrier(self): |
||||||
|
for name, pair in (("first_a", "first"), ("first_b", "first"), ("last_a", "last"), ("last_b", "last")): |
||||||
|
self.fixture(name, f""" |
||||||
|
Path(name + '.ready').touch() |
||||||
|
deadline = time.monotonic() + 5 |
||||||
|
while len(list(Path('.').glob('{pair}_*.ready'))) < 2: |
||||||
|
if time.monotonic() > deadline: sys.exit(9) |
||||||
|
time.sleep(0.01) |
||||||
|
time.sleep(0.05) |
||||||
|
""") |
||||||
|
self.fixture("test_media_async", "time.sleep(0.05)\n") |
||||||
|
result = self.run_runner(["first_a", "first_b", "test_media_async", "last_a", "last_b"]) |
||||||
|
self.assertEqual(result.returncode, 0, result.stdout) |
||||||
|
active, maximum = set(), 0 |
||||||
|
events = [json.loads(line) for line in (self.root / "events").read_text().splitlines()] |
||||||
|
for name, state in events: |
||||||
|
if state == "start": |
||||||
|
if name == "test_media_async": self.assertFalse(active) |
||||||
|
self.assertNotIn("test_media_async", active) |
||||||
|
active.add(name); maximum = max(maximum, len(active)) |
||||||
|
else: |
||||||
|
active.remove(name) |
||||||
|
self.assertFalse(active) |
||||||
|
self.assertEqual(maximum, 2) |
||||||
|
self.assertEqual({name for name, _ in events[:2]}, {"first_a", "first_b"}) |
||||||
|
self.assertEqual(events[4:6], [["test_media_async", "start"], ["test_media_async", "end"]]) |
||||||
|
|
||||||
|
def test_exit_codes_and_separate_logs(self): |
||||||
|
self.fixture("ok") |
||||||
|
self.fixture("bad", "print('failure detail', flush=True)\nsys.exit(42)\n") |
||||||
|
self.fixture("skip", "sys.exit(77)\n") |
||||||
|
result = self.run_runner(["ok", "bad", "skip", "missing"]) |
||||||
|
self.assertEqual(result.returncode, 1, result.stdout) |
||||||
|
self.assertIn("1 passed, 1 failed, 2 skipped (4 total)", result.stdout) |
||||||
|
self.assertIn("Failed tests: bad", result.stdout) |
||||||
|
self.assertIn("failure detail", (self.root / "logs/bad.log").read_text()) |
||||||
|
self.assertNotIn("failure detail", (self.root / "logs/ok.log").read_text()) |
||||||
|
|
||||||
|
def test_serial_override(self): |
||||||
|
self.fixture("serial_a", "time.sleep(0.05)\n") |
||||||
|
self.fixture("serial_b", "time.sleep(0.05)\n") |
||||||
|
result = self.run_runner(["serial_a", "serial_b"], environment=dict(os.environ, TEST_JOBS="1")) |
||||||
|
self.assertEqual(result.returncode, 0, result.stdout) |
||||||
|
events = [json.loads(line) for line in (self.root / "events").read_text().splitlines()] |
||||||
|
self.assertEqual(events, [["serial_a", "start"], ["serial_a", "end"], ["serial_b", "start"], ["serial_b", "end"]]) |
||||||
|
|
||||||
|
def stubborn_fixture(self): |
||||||
|
self.fixture("stubborn", """ |
||||||
|
signal.signal(signal.SIGTERM, signal.SIG_IGN) |
||||||
|
pid = os.fork() |
||||||
|
Path('child.pid' if pid == 0 else 'parent.pid').write_text(str(os.getpid())) |
||||||
|
while True: time.sleep(1) |
||||||
|
""") |
||||||
|
|
||||||
|
def assert_descendant_stopped(self): |
||||||
|
pid = int((self.root / "child.pid").read_text()) |
||||||
|
deadline = time.monotonic() + 2 |
||||||
|
while time.monotonic() < deadline: |
||||||
|
try: |
||||||
|
os.kill(pid, 0) |
||||||
|
stat = Path(f"/proc/{pid}/stat") |
||||||
|
if stat.exists() and stat.read_text().split(")", 1)[1].split()[0] == "Z": return |
||||||
|
except ProcessLookupError: |
||||||
|
return |
||||||
|
time.sleep(0.01) |
||||||
|
self.fail(f"descendant pid={pid} survived runner cleanup") |
||||||
|
|
||||||
|
def test_timeout_stops_descendant(self): |
||||||
|
self.stubborn_fixture() |
||||||
|
result = self.run_runner(["stubborn"], ["--timeout", "1"]) |
||||||
|
self.assertEqual(result.returncode, 1, result.stdout) |
||||||
|
self.assertIn("timeout=1s", result.stdout) |
||||||
|
self.assertIn("0 passed, 1 failed, 0 skipped", result.stdout) |
||||||
|
self.assert_descendant_stopped() |
||||||
|
|
||||||
|
def test_interruption_and_log_directory_lock(self): |
||||||
|
self.stubborn_fixture() |
||||||
|
process = subprocess.Popen(self.command(["stubborn"]), cwd=self.root, |
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) |
||||||
|
try: |
||||||
|
deadline = time.monotonic() + 5 |
||||||
|
while not (self.root / "child.pid").exists() and time.monotonic() < deadline: |
||||||
|
time.sleep(0.01) |
||||||
|
self.assertTrue((self.root / "child.pid").exists()) |
||||||
|
result = self.run_runner(["stubborn"]) |
||||||
|
self.assertEqual(result.returncode, 1, result.stdout) |
||||||
|
self.assertIn("concurrent runs", result.stdout) |
||||||
|
self.assertIsNone(process.poll()) |
||||||
|
process.send_signal(signal.SIGTERM) |
||||||
|
output, _ = process.communicate(timeout=5) |
||||||
|
self.assertEqual(process.returncode, 128 + signal.SIGTERM, output) |
||||||
|
self.assertIn("test processes stopped", output) |
||||||
|
self.assert_descendant_stopped() |
||||||
|
self.fixture("ok") |
||||||
|
result = self.run_runner(["ok"]) |
||||||
|
self.assertEqual(result.returncode, 0, result.stdout) |
||||||
|
finally: |
||||||
|
if process.poll() is None: |
||||||
|
process.send_signal(signal.SIGTERM); process.communicate(timeout=5) |
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": |
||||||
|
unittest.main() |
||||||
Loading…
Reference in new issue