"""显式 ignored 验收与现有长测运行器的 Linux gate 接线。""" import json from pathlib import Path import re from urllib.parse import parse_qsl, unquote, urlsplit from linux_gate import (ROOT, MissingInput, absolute_file, run_command, target_command, workspace_targets, remove_executables, input_fingerprint) from settings import write_private_json from process import resource_trend from cms import validate_read_descriptor_recovery def execute(command, config, directory, result, *, single_harness=False): record, content = run_command(command, directory, len(result["commands"]), config.get("command_timeout_seconds", 14400), single_harness=single_harness) result["commands"].append(record) return record, content def classify_cases(names, manifest, target): by_name = {} for row in manifest: key = (row["package"], row["target"], row["name"]) if key in by_name: raise RuntimeError(f"duplicate ignored classification: {key}") by_name[key] = row classified = [] for name in names: leaf = name.rsplit("::", 1)[-1] key = (target["package"], target["target"], leaf) if key not in by_name: raise RuntimeError(f"unclassified ignored test: {name}") classified.append({**by_name[key], "exact": name}) if len({case["name"] for case in classified}) != len(classified): raise RuntimeError("same ignored leaf appears in multiple modules; classify by a distinct source name") return classified def audit_classification(): manifest = json.loads((ROOT / "test/ci/ignored.json").read_text()) stages = {"probe", "author", "native", "postgres", "sandbox", "bounded-perf"} if any(row["stage"] not in stages for row in manifest): raise RuntimeError("ignored classification contains an unknown execution stage") expected = {(row["source"], row["name"], row["reason"]) for row in manifest} actual = set() pattern = re.compile(r'#\[ignore(?:\s*=\s*"([^"]*)")?\]\s*(?:#\[[^\]]*\]\s*)*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)') for directory in ("test", "crates"): for path in (ROOT / directory).rglob("*.rs"): for match in pattern.finditer(path.read_text()): actual.add((str(path.relative_to(ROOT)), match[2], match[1] or "")) if actual != expected: raise RuntimeError(f"ignored classification drift: new={sorted(actual - expected)}, removed={sorted(expected - actual)}") return manifest def test_executable(content, target): artifacts = [] for line in content.splitlines(): if not line.startswith("{"): continue message = json.loads(line) if (message.get("reason") == "compiler-artifact" and message.get("profile", {}).get("test") and message.get("target", {}).get("name") == target["target"] and message.get("executable")): artifacts.append(message["executable"]) if len(set(artifacts)) != 1: raise RuntimeError(f"expected one libtest executable for {target}") return artifacts[0] def inventory(config, directory, result): manifest = audit_classification() targets, target_directory = workspace_targets(config, directory, result, True) cases = [] for target in targets: if target["selector"] == ["--doc"]: continue _, content = execute([*target_command(config, target), "--no-run", "--message-format=json"], config, directory, result) executable = test_executable(content, target) _, listing = execute([executable, "--ignored", "--list", "--format", "terse"], config, directory, result) names = [line.removesuffix(": test") for line in listing.splitlines() if line.endswith(": test")] cases.extend({**case, "target": target, "target_directory": str(target_directory)} for case in classify_cases(names, manifest, target)) result.setdefault("removed_test_executables", []).extend( remove_executables(content, target_directory, config.get("remove_test_executables", False))) write_private_json(directory / "inventory.json", cases, replace=(directory / "inventory.json").exists()) missing = {(row["package"], row["target"], row["name"]) for row in manifest} - { (case["target"]["package"], case["target"]["target"], case["name"]) for case in cases} if missing: raise RuntimeError(f"ignored source cases missing from Linux all-feature libtest inventory: {sorted(missing)}") result["classified_cases"] = len(cases) result["internal_probes"] = [{"target": case["target"], "exact": case["exact"], "status": "parent-only", "reason": case["reason"]} for case in cases if case["stage"] == "probe"] def current_inventory(document, config, result): paths = sorted((Path(config.get("output", ROOT / "target/linux-ci")) / "inventory").glob("*/result.json")) if paths: path = paths[-1] record = json.loads(path.read_text()) if (record.get("source"), record.get("inputs"), record.get("status")) == (result["source"], input_fingerprint(document, config, "inventory"), "passed"): return json.loads((path.parent / "inventory.json").read_text()) raise MissingInput("run inventory for the current source and input fingerprints first") def validate_postgres(document): setting = document.get("database", {}).get("postgres_test", {}) url = urlsplit(setting.get("url", "")) database = unquote(url.path.removeprefix("/")) if (setting.get("type") != "postgres" or url.scheme not in ("postgres", "postgresql") or url.hostname not in ("127.0.0.1", "::1") or url.port is None or database.count("{case}") != 1 or any(key.lower() in ("dbname", "host", "hostaddr", "port", "service") for key, _ in parse_qsl(url.query))): raise MissingInput("database.postgres_test must select the explicitly owned loopback cluster and one {case} database template") def require_inputs(stage, config): required = config.get("required_inputs", {}).get(stage) if not required: raise MissingInput(f"performance.linux_ci.required_inputs.{stage} must declare the prepared author/fixture input paths") declared = [Path(value).resolve() for value in config.get("inputs", [])] for value in required: path = Path(value) if not path.is_absolute() or not path.exists(): raise MissingInput(f"missing {stage} input: {value}") if not any(path.resolve() == root or path.resolve().is_relative_to(root) for root in declared): raise MissingInput(f"{stage} input is not fingerprinted by performance.linux_ci.inputs: {value}") def ignored_stage(stage, document, config, directory, result): require_inputs(stage, config) if stage == "postgres": validate_postgres(document) if not config.get("owned_postgres"): raise MissingInput("performance.linux_ci.owned_postgres must record the owned cluster data_directory and port") owned = config["owned_postgres"] if urlsplit(document["database"]["postgres_test"]["url"]).port != owned.get("port"): raise MissingInput("PostgreSQL URL does not match performance.linux_ci.owned_postgres.port") if not Path(owned.get("data_directory", "")).is_absolute(): raise MissingInput("owned_postgres.data_directory must be absolute") if stage == "author" and config.get("author_network") is not True: raise MissingInput("author stage fetches fixed upstream inputs; explicitly set performance.linux_ci.author_network=true") if stage == "author" and not config.get("author_outputs"): raise MissingInput("declare performance.linux_ci.author_outputs separately from immutable raw author inputs") cases = [case for case in current_inventory(document, config, result) if case["stage"] == stage] if not cases: raise RuntimeError(f"no classified cases for {stage}") # 固定作者先准备工具,再执行真实源码包构建;其他用例按 target 复用链接产物。 cases.sort(key=lambda case: (0 if case["name"].startswith("prepare_") else 1, case["target"]["package"], case["target"]["target"], case["exact"])) target_key = None build_content = None target_directory = ROOT / "target" for case in cases: target = case["target"] target_directory = Path(case["target_directory"]) result["target_directory"] = str(target_directory) key = json.dumps(target, sort_keys=True) if key != target_key: if build_content is not None: result.setdefault("removed_test_executables", []).extend( remove_executables(build_content, target_directory, config.get("remove_test_executables", False))) _, build_content = execute([*target_command(config, target), "--no-run", "--message-format=json"], config, directory, result) executable = test_executable(build_content, target) target_key = key record, _ = execute([executable, "--ignored", "--exact", case["exact"], "--test-threads=1", "--nocapture"], config, directory, result, single_harness=True) if record.get("rust_tests") != {"passed": 1, "failed": 0, "ignored": 0}: raise RuntimeError(f"exact ignored selection did not execute one case: {case['exact']}") result.setdefault("cases", []).append({"exact": case["exact"], "target": target, "status": "passed"}) result.setdefault("removed_test_executables", []).extend( remove_executables(build_content, target_directory, config.get("remove_test_executables", False))) def validate_budget(config): parent = Path(config.get("cgroup_parent", "")) if not parent.is_absolute() or not (parent / "cgroup.controllers").is_file(): raise MissingInput("performance.linux_ci.cgroup_parent must select the owned empty cgroup v2 parent") if (parent / "cgroup.procs").read_text().strip(): raise MissingInput("CI runner must be in a control child, not the cgroup parent") if not {"cpu", "memory"} <= set((parent / "cgroup.subtree_control").read_text().split()): raise MissingInput("owned parent requires enabled memory/cpu child controllers") quota, period = (parent / "cpu.max").read_text().split() memory = (parent / "memory.max").read_text().strip() if quota == "max" or int(quota) / int(period) > 2 or memory == "max" or int(memory) > 4 * 1024 ** 3: raise MissingInput("owned parent must bound total CPU <=2 and memory <=4GiB") if (parent / "memory.swap.max").read_text().strip() != "0": raise MissingInput("owned parent must disable swap") membership = Path("/proc/self/cgroup").read_text().strip().split("::", 1)[1] current = Path("/sys/fs/cgroup") / membership.lstrip("/") if current == parent or not current.is_relative_to(parent): raise MissingInput("CI runner is outside the configured owned parent control subgroup") return parent def validate_soak(report, suite, seconds, growth_limit, memory_mib): if report.get("status") not in ("passed", "complete"): raise RuntimeError("soak runner did not complete") cases = list(report["cases"].values()) if suite == "cms" else report["cases"] expected = 3 if suite == "live" else 2 if len(cases) != expected: raise RuntimeError(f"expected {expected} {suite} cases, received {len(cases)}") if suite == "cms": coverage = set(report["cases"]) required = {"dever", "md"} else: coverage = {case["protocol" if suite == "live" else "transport"] for case in cases} required = {"tcp", "ws", "sse"} if suite == "live" else {"http", "https"} if any(case["suite"] != suite or (suite != "live" and case["implementation"] != "dever") for case in cases): raise RuntimeError("soak report contains a different suite or implementation") if coverage != required: raise RuntimeError("soak report does not cover every required source or transport") for case in cases: budget = case.get("cgroup") if suite == "cms" else case.get("budget_after_stop") if budget is None or any(budget["events"][key] for key in ("oom", "oom_kill")): raise RuntimeError("missing memory-budget evidence or nonzero OOM") if budget["max"] != str(memory_mib * 1024 ** 2): raise RuntimeError("soak used a different memory limit from the selected stage") if suite == "cms": load = case["concurrent_reads"] if load["elapsed_seconds"] < seconds or load["errors"] or load["succeeded"] == 0: raise RuntimeError("CMS duration, business load or FD recovery failed") validate_read_descriptor_recovery(load) trend = load["trend"] elif suite == "live": if (case["result"]["elapsed_ms"] < seconds * 1000 or case["result"]["errors"] or any(case["fd_delta_after_cycles"])): raise RuntimeError("live duration, protocol or FD recovery failed") samples = [phase["last"] for phase in case["phases"] if phase["phase"] == "disconnected"] trend = resource_trend(samples) else: loads = [case["load"]] if suite == "http2" else [cycle["load"] for cycle in case["cycles"]] disconnected = [case["disconnected"]] if suite == "http2" else [cycle["disconnected"] for cycle in case["cycles"]] if any(sample["fd_delta"] != 0 for sample in disconnected): raise RuntimeError("HTTP/2 did not restore file descriptors after disconnect") if sum(load["elapsed_seconds"] for load in loads) < seconds: raise RuntimeError("HTTP/2 measured load is shorter than the required duration") for load in loads: if load["errors"] or load["succeeded"] <= 0 or load["succeeded"] < .99 * load["scheduled"]: raise RuntimeError("HTTP/2 did not achieve 99% scheduled successful replies with zero errors") trend = resource_trend(case["raw"]) if trend is None or trend["rss_late_growth_bytes"] > growth_limit: raise RuntimeError("late RSS growth exceeds the configured budget; inspect raw resource samples") case["gate_resource_trend"] = trend def soak_stage(stage, config, directory, result): suite, memory = stage.rsplit("-", 1) suite = {"recovery": "http2-recovery"}.get(suite, suite) require_inputs("soak", config) parent = validate_budget(config) settings = config.get("soak", {}) seconds = settings.get("seconds", 1800) if not 1800 <= seconds <= 2700: raise ValueError("performance.linux_ci.soak.seconds must be 1800..2700") python = str(absolute_file(config["tools"].get("python"), "performance.linux_ci.tools.python")) output = directory / "measurement" if suite == "cms": manifest = str(absolute_file(settings.get("cms_manifest"), "performance.linux_ci.soak.cms_manifest")) command = [python, "-B", "test/performance/cms.py", "run", "--manifest", manifest, "--concurrency", str(settings.get("cms_concurrency", 16)), "--read-seconds", str(seconds)] else: artifacts = Path(settings.get("artifacts", "")) if not artifacts.is_absolute() or not (artifacts / "manifest.json").is_file(): raise MissingInput("performance.linux_ci.soak.artifacts requires prepared protocol artifacts") command = [python, "-B", "test/performance/run.py", "run", "--artifacts", str(artifacts), "--suite", suite, "--implementations", "dever", "--repeats", "1", "--interval", "0.1"] if suite == "http2": command += ["--duration", str(seconds), "--warmup", "5", "--paths", "/json", "--rates", str(settings.get("http2_rate", 5000)), "--http2-connections", "4"] else: if seconds % 30: raise ValueError("cyclic soak seconds must be divisible by 30") command += ["--duration", "30", "--cycles", str(seconds // 30)] command += (["--connection-tiers", "32"] if suite == "live" else ["--recovery-rate", str(settings.get("http2_rate", 5000)), "--recovery-path", "/json", "--recovery-connections", "4"]) if "server_cpu" in settings: command += ["--server-cpu", str(settings["server_cpu"]), "--client-cpus", str(settings["client_cpu"])] command += ["--output", str(output), "--memory-mib", memory, "--cgroup-parent", str(parent)] execute(command, config, directory, result) report = json.loads((output / "report.json").read_text()) validate_soak(report, suite, seconds, settings.get("rss_late_growth_mib", 8) * 1024 ** 2, int(memory)) write_private_json(directory / "validated-report.json", report) result["measurement"] = str(output / "report.json") result["coverage"] = "CMS dever/md; HTTP/2 h2c/TLS JSON; recovery h2c/TLS; live TCP/WS/SSE, each selected stage only" def run_stage(stage, document, config, directory, result): if stage == "inventory": inventory(config, directory, result) elif stage in ("author", "native", "postgres", "sandbox", "bounded-perf"): ignored_stage(stage, document, config, directory, result) else: soak_stage(stage, config, directory, result)