#!/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_measurements_after_pool(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[-2:], [["test_media_async", "start"], ["test_media_async", "end"]]) self.assertIn("test_media_async: running alone to check CPU/wall-time limits", result.stdout) def test_long_network_tests_share_pool(self): names = ["test_bbr_integration", "test_auto_socket_dynamic"] for name in names: self.fixture(name, """ Path(name + '.ready').touch() deadline = time.monotonic() + 5 while len(list(Path('.').glob('*.ready'))) < 2: if time.monotonic() > deadline: sys.exit(9) time.sleep(0.01) time.sleep(0.05) """) # У фиктивного autosocket нет требований root: не вызываем sudo в этой регрессии. result = self.run_runner(names, environment=dict(os.environ, TEST_JOBS="2", PATH=str(self.root))) self.assertEqual(result.returncode, 0, result.stdout) events = [json.loads(line) for line in (self.root / "events").read_text().splitlines()] self.assertEqual({name for name, _ in events[:2]}, set(names)) self.assertEqual([state for _, state in events], ["start", "start", "end", "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()