feat: publish Dever core with project scaffolding and versioned AI skills
This commit is contained in:
1 parent
70fa73cdc2
commit
ac458ffa7f
960 files changed
+221319
-1163
No files matched your search
@@ -0,0 +1,92 @@
|
||||
# Linux 质量门
|
||||
|
||||
`run.py` 仅串行编排既有 Cargo、Python、SDK、PostgreSQL fixture 和 performance 运行器;没有下载工具、全局安装或环境变量产品配置。根 `config/setting.json` 保留原有字段,在既有 `performance` 对象内增加 `linux_ci`,与 peer 路径同级。顶层 `linux_ci` 不受支持,会被 runtime 严格配置解析拒绝:
|
||||
|
||||
```json
|
||||
{
|
||||
"performance": {
|
||||
"network_peer": "/absolute/network_bench",
|
||||
"live_peer": "/absolute/live_bench",
|
||||
"linux_ci": {
|
||||
"tools": {
|
||||
"cargo": "/absolute/toolchain/bin/cargo",
|
||||
"cargo_fmt": "/absolute/rustfmt/bin/cargo-fmt",
|
||||
"cargo_clippy": "/absolute/clippy/bin/cargo-clippy",
|
||||
"python": "/absolute/python3",
|
||||
"node": "/absolute/node",
|
||||
"go": "/absolute/go"
|
||||
},
|
||||
"cargo_config": [
|
||||
"profile.dev.package.miniz_oxide.opt-level=3",
|
||||
"profile.dev.package.flate2.opt-level=3",
|
||||
"profile.dev.package.sha2.opt-level=3"
|
||||
],
|
||||
"output": "/absolute/repository/target/linux-ci",
|
||||
"command_timeout_seconds": 14400,
|
||||
"remove_test_executables": true,
|
||||
"cgroup_parent": "/sys/fs/cgroup/system.slice/dever-linux-ci.service",
|
||||
"inputs": [],
|
||||
"required_inputs": {},
|
||||
"author_outputs": [],
|
||||
"author_network": false,
|
||||
"soak": {
|
||||
"artifacts": "/absolute/prepared-protocols",
|
||||
"cms_manifest": "/absolute/prepared-cms/manifest.json",
|
||||
"seconds": 1800,
|
||||
"cms_concurrency": 16,
|
||||
"http2_rate": 5000,
|
||||
"rss_late_growth_mib": 8
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
替换路径,勿直接复制示例运行。下文 CI 字段均位于 `performance.linux_ci`。`inputs` 列出固定原始作者输入及准备好的资产文件/目录,逐文件计 SHA-256;工具、协议产物、CMS manifest/可执行文件/配置和 `performance.network_peer/live_peer` 也自动计摘要,`linux_ci` 对象不作为 peer 路径解析。输出目录不得包含在 inputs 中。报告只存摘要,不复制数据库密码。
|
||||
|
||||
`required_inputs` 为 `author`、`native`、`postgres`、`sandbox`、`bounded-perf`、`soak` 各提供非空绝对路径列表,必须落在 `inputs` 声明范围内。缺路径记录 blocked,不算 pass。作者 fixture 的具体固定路径/准备命令仍以 `sdk/native-release.md`、`test/ecosystem-release/prepare.py`、`test/ecosystem-release/build_inputs.py` 和对应 ignored 原因文本为准;gate 不把现存文件冒充由当前源码重建的 provenance。
|
||||
|
||||
显式 LLVM Worker 验收读取 `target/native-release-inputs/component-fixture`:先用当前源码构建 `dever-tests` 的 `component-fixture` bin,再将程序复制到该准备目录,记录源码/程序摘要,并把固定副本列入 `inputs` 和 `required_inputs.native`。不能将 `target/debug/component-fixture` 当作不可变输入:Cargo 在 default/all-feature 组合间会重新链接这个输出。普通 Worker 测试仍使用 Cargo 为各自组合生成的 `CARGO_BIN_EXE_component-fixture`,不改质量门的输入变更拒绝规则。
|
||||
|
||||
`author_outputs` 精确声明 author 会制作的 pack、下载结果和 receipt 路径。author 阶段只将其余原始输入当作不可变输入,另外记录完整输出摘要;后续验收按准备后完整输入摘要绑定。不要把整个作者输入根排除掉。显式 `author_network: true` 才能执行已分类的固定官方下载/源码构建。新 ignored 必须人工更新 `ignored.json`;inventory 核对源码声明及实际 all-feature libtest 名单,执行时一律使用完整 `--exact` 名称。内部 probe 记录 parent-only,只由所属父用例运行。
|
||||
|
||||
资源准备归外部托管者:自有空父启用 memory/cpu,限制总 CPU<=2、memory<=4GiB、swap=0;runner/Cargo/负载客户端放 `control` 子组,自有 PostgreSQL 可放兄弟子组,64/128MiB 服务子组由现有 performance owner 创建。gate 只读检查现有父限制与自身归属,不创建/修改主机策略。作者工具所需固定 PATH/动态库路径可由外部托管者显式准备,不写入产品配置。
|
||||
|
||||
```sh
|
||||
python3 -B test/ci/run.py plan
|
||||
python3 -B test/ci/run.py run --stage static
|
||||
python3 -B test/ci/run.py run --stage default
|
||||
python3 -B test/ci/run.py run --stage all-features
|
||||
python3 -B test/ci/run.py run --stage features
|
||||
python3 -B test/ci/run.py run --stage inventory
|
||||
python3 -B test/ci/run.py run --stage author
|
||||
python3 -B test/ci/run.py run --stage python
|
||||
python3 -B test/ci/run.py run --stage sdk
|
||||
python3 -B test/ci/run.py run --stage native
|
||||
python3 -B test/ci/run.py run --stage postgres
|
||||
python3 -B test/ci/run.py run --stage sandbox
|
||||
python3 -B test/ci/run.py run --stage bounded-perf
|
||||
```
|
||||
|
||||
以上命令由同一受限托管进程串行执行。若 author 改变输入,应在作者准备完成后再跑正式静态/default/all-feature门;较早输入版本的证据会显示过期。每阶段写入新的独占时间目录,开始即记录 running;缺输入记录 blocked,失败命令保留 exit/count/日志与原始异常。`summary` 与 inventory 消费者只接受最新一次尝试的当前源码/输入证据,不能越过较新的失败或中断回用旧 pass;所有必选阶段通过才返回0。源码摘要覆盖未提交文件,不只看 Git HEAD。
|
||||
|
||||
default/all-features 由实际 Cargo metadata 注册 target 和已解析 feature 形成串行矩阵,覆盖 lib/bin/integration/doctest;default 显式保留 workspace 解析的 package features,最小 feature 阶段另按单 package 验证。只有显式启用 `remove_test_executables` 时才删除 Cargo 本次返回、`profile.test=true` 且位于 target 下 `deps/` 的测试可执行文件,失败阶段同样清理;不删除作者工具、静态 init、依赖 archive 或整个 target。删除对象在日志 JSON 中可审计,重跑 Cargo 可恢复。inventory 按 package/target/name 三元组核对完整性,跨 target 同名用例不能代替缺失项。
|
||||
|
||||
Python 阶段包含 `test/ci`、`test/performance`、`test/ecosystem-release` 三个目录;后两者必须已配置 `performance.network_peer/live_peer`,真实自有 loopback检查由主代理执行。三 SDK 路径全部必需,三个明确成功标志全部出现才通过。
|
||||
|
||||
PostgreSQL 只接受显式 loopback端口和恰好一个 `{case}` 库名模板,拒绝 query 覆盖 host/dbname 等。增加 `performance.linux_ci.owned_postgres: {"data_directory":"/absolute/owned-cluster","port":51234}`,由托管者创建所需基库并在 finally 停止该自有实例;gate复用现有用例的 schema/tenant 清理,不替用户启动或连接既有业务数据库。
|
||||
|
||||
长测复用原 runner,逐阶段分别执行:
|
||||
|
||||
```sh
|
||||
for stage in cms-64 cms-128 http2-64 http2-128 recovery-64 recovery-128 live-64 live-128; do
|
||||
python3 -B test/ci/run.py run --stage "$stage" || exit "$?"
|
||||
done
|
||||
python3 -B test/ci/run.py summary
|
||||
```
|
||||
|
||||
每个 CMS 源码/h2c/TLS/恢复协议/TCP/WS/SSE 子场景至少30分钟,服务在整个子场景保持同一进程。HTTP/2选代表 JSON 路径;其他body路径仍由短合同测试覆盖。live/recovery 使用60×30秒循环,受现有1小时生命周期限制;总默认矩阵约9小时加准备。HTTP/2要求零错误、成功>=99%计划请求;CMS逐回复验证实际发布列表。全部要求OOM=0、断连FD恢复。RSS记录原始采样和四段中位数,最后两段中位数增量默认不得超过8MiB;这是本轮可配置回归预算,不把 allocator保留直接等同泄漏。失效报告和失败负载保留,校准降速后须完整重跑。
|
||||
|
||||
CMS读取时长支持0.1..3600秒;长测客户端用固定2048桶统计延迟,不随请求数保存无限样本。分位数为对数桶上界,包含请求与结果核对时间。报告记录实际时长及统计方式,不与此前精确短测分位数直接混算。
|
||||
|
||||
CMS先关闭控制客户端,再记录FD基线;断连后按FD目标及重复数核对所有非数据库资源。负载前已确定的SQLite主文件/WAL句柄各不得超过配置的`max_connections`,同进程SHM句柄不得超过1。`descriptors_before`、`descriptors_after`和`pooled_descriptor_limits`保存实际依据;总`fd_delta_after_disconnect`保留为观测值,不把正常池扩容当成连接泄漏。每个源码目录的`report.json`在失败时也保存已完成的发布/读取数据、资源采样、预算及错误,总报告收集该失败case。HTTP/2/live无数据库池,继续使用原来的完整FD回落门。
|
||||
@@ -0,0 +1,298 @@
|
||||
"""显式 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)
|
||||
File diff suppressed because it is too large.
Load diff
+372
@@ -0,0 +1,372 @@
|
||||
"""Linux 质量门:串行复用现有检查,按源码和输入摘要保存阶段证据。"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.append(str(ROOT / "test/performance"))
|
||||
sys.modules["linux_gate"] = sys.modules[__name__]
|
||||
from settings import configured_peer, write_private_json
|
||||
|
||||
STAGES = ("static", "default", "all-features", "features", "python", "sdk",
|
||||
"inventory", "author", "native", "postgres", "sandbox", "bounded-perf",
|
||||
"cms-64", "cms-128", "http2-64", "http2-128", "recovery-64", "recovery-128",
|
||||
"live-64", "live-128")
|
||||
SOURCE_ROOTS = ("crates", "test", "sdk", "library", "examples", ".cargo")
|
||||
|
||||
|
||||
class MissingInput(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def digest_file(path):
|
||||
with path.open("rb") as source:
|
||||
return hashlib.file_digest(source, "sha256").hexdigest()
|
||||
|
||||
|
||||
def source_fingerprint(root=ROOT):
|
||||
paths = [root / name for name in ("Cargo.toml", "Cargo.lock")]
|
||||
for name in SOURCE_ROOTS:
|
||||
directory = root / name
|
||||
if directory.exists():
|
||||
paths.extend(path for path in directory.rglob("*") if path.is_file()
|
||||
and not {"__pycache__", "target", ".git"}.intersection(path.parts))
|
||||
digest = hashlib.sha256()
|
||||
for path in sorted(paths):
|
||||
digest.update(str(path.relative_to(root)).encode() + b"\0")
|
||||
digest.update(digest_file(path).encode() + b"\0")
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def absolute_file(value, name):
|
||||
path = Path(value or "")
|
||||
if not path.is_absolute() or not path.is_file():
|
||||
raise MissingInput(f"{name} requires an existing absolute file path")
|
||||
return path
|
||||
|
||||
|
||||
def load_config():
|
||||
path = ROOT / "config/setting.json"
|
||||
if not path.is_file():
|
||||
raise MissingInput("create config/setting.json with performance.linux_ci (see test/ci/README.md)")
|
||||
document = json.loads(path.read_text())
|
||||
performance = document.get("performance") if isinstance(document, dict) else None
|
||||
config = performance.get("linux_ci") if isinstance(performance, dict) else None
|
||||
if not isinstance(config, dict):
|
||||
raise MissingInput("config/setting.json must define performance.linux_ci")
|
||||
return document, config
|
||||
|
||||
|
||||
def input_fingerprint(document, config, stage=None):
|
||||
inputs = {}
|
||||
for name, value in config.get("tools", {}).items():
|
||||
path = Path(value)
|
||||
if not path.is_absolute():
|
||||
raise ValueError("performance.linux_ci.tools must use absolute paths")
|
||||
inputs[f"tool:{name}:{value}"] = digest_file(path) if path.is_file() else "missing"
|
||||
paths = set(config.get("inputs", []))
|
||||
paths.update(str(configured_peer(name, ROOT / "config/setting.json"))
|
||||
for name in ("network_peer", "live_peer") if name in document.get("performance", {}))
|
||||
soak = config.get("soak", {})
|
||||
if soak.get("artifacts"):
|
||||
paths.add(soak["artifacts"])
|
||||
if soak.get("cms_manifest"):
|
||||
manifest = Path(soak["cms_manifest"])
|
||||
paths.add(str(manifest))
|
||||
if manifest.is_file():
|
||||
for case in json.loads(manifest.read_text()).get("cases", {}).values():
|
||||
paths.update(case[key] for key in ("executable", "config"))
|
||||
for value in sorted(paths):
|
||||
path = Path(value)
|
||||
if not path.is_absolute():
|
||||
raise ValueError("performance.linux_ci.inputs must use absolute paths")
|
||||
if stage == "inventory":
|
||||
continue # 清单依赖当前源码和工具,不消费作者资产。
|
||||
outputs = [Path(value) for value in config.get("author_outputs", [])] if stage == "author" else []
|
||||
if any(path == output or path.is_relative_to(output) for output in outputs):
|
||||
continue
|
||||
if path.is_dir():
|
||||
for child in sorted(path.rglob("*")):
|
||||
if child.is_file() and not any(child == output or child.is_relative_to(output) for output in outputs):
|
||||
inputs[str(child)] = digest_file(child)
|
||||
else:
|
||||
inputs[value] = digest_file(path) if path.is_file() else "missing"
|
||||
# 保存摘要而非数据库密码/令牌;配置任何变化都使旧证据失效。
|
||||
return hashlib.sha256(json.dumps([document, inputs], sort_keys=True).encode()).hexdigest()
|
||||
|
||||
|
||||
def command_plan(stage, config):
|
||||
tools = config.get("tools", {})
|
||||
cargo = str(absolute_file(tools.get("cargo"), "performance.linux_ci.tools.cargo"))
|
||||
common = ["--offline", "--locked", "-j1", *cargo_config(config)]
|
||||
test = [cargo, "test", *common, "--workspace"]
|
||||
if stage == "static":
|
||||
fmt = str(absolute_file(tools.get("cargo_fmt"), "performance.linux_ci.tools.cargo_fmt"))
|
||||
clippy = str(absolute_file(tools.get("cargo_clippy"), "performance.linux_ci.tools.cargo_clippy"))
|
||||
return [[fmt, "fmt", "--all", "--check"],
|
||||
*[[clippy, "clippy", *common, "--workspace", "--all-targets", *features,
|
||||
"--", "-D", "warnings"] for features in ([], ["--all-features"])]]
|
||||
if stage in ("default", "all-features"):
|
||||
return [[*test, *(["--all-features"] if stage == "all-features" else []),
|
||||
"--", "--test-threads=1"]]
|
||||
if stage == "features":
|
||||
profiles = {
|
||||
"dever-runtime": ["", "auth-crypto", "crypto", "wire", "external", "api", "database", "sqlite", "postgres", "sqlite,postgres"],
|
||||
"dever-backend-bridge": ["", "embedded", "runtime-abi", "runtime-database", "runtime-api", "runtime-external", "runtime-sqlite", "runtime-postgres", "runtime-sqlite,runtime-postgres"],
|
||||
}
|
||||
return [[cargo, "check", *common, "-p", package, "--lib", "--no-default-features",
|
||||
*(["--features", features] if features else [])]
|
||||
for package, combinations in profiles.items() for features in combinations] + [
|
||||
[cargo, "tree", "--offline", "--locked", "-p", "dever-cli", "--edges", "features"]]
|
||||
python = str(absolute_file(tools.get("python"), "performance.linux_ci.tools.python"))
|
||||
if stage == "python":
|
||||
return [[python, "-B", "-m", "unittest", "discover", "-s", directory, "-p", "test_*.py", "-v"]
|
||||
for directory in ("test/ci", "test/performance", "test/ecosystem-release")]
|
||||
if stage == "sdk":
|
||||
return [[python, "-B", "test/external-sdk/check.py", "--python", python,
|
||||
"--node", str(absolute_file(tools.get("node"), "performance.linux_ci.tools.node")),
|
||||
"--go", str(absolute_file(tools.get("go"), "performance.linux_ci.tools.go"))]]
|
||||
raise ValueError(f"stage has a dedicated runner: {stage}")
|
||||
|
||||
|
||||
def cargo_config(config):
|
||||
return [value for override in config.get("cargo_config", []) for value in ("--config", override)]
|
||||
|
||||
|
||||
def workspace_targets(config, directory, result, all_features):
|
||||
cargo = str(absolute_file(config["tools"].get("cargo"), "performance.linux_ci.tools.cargo"))
|
||||
command = [cargo, "metadata", "--offline", "--locked", "--format-version", "1", *cargo_config(config)]
|
||||
if all_features:
|
||||
command.append("--all-features")
|
||||
record, content = run_command(command, directory, len(result["commands"]), 120)
|
||||
result["commands"].append(record)
|
||||
metadata = next(json.loads(line) for line in content.splitlines() if line.startswith('{"packages":'))
|
||||
active = {node["id"]: set(node["features"]) for node in metadata["resolve"]["nodes"]}
|
||||
targets = []
|
||||
result["target_directory"] = metadata["target_directory"]
|
||||
for package in metadata["packages"]:
|
||||
if package["id"] not in metadata["workspace_members"]:
|
||||
continue
|
||||
for target in package["targets"]:
|
||||
required = set(target.get("required-features", []))
|
||||
if not required <= active[package["id"]]:
|
||||
continue
|
||||
kind = target["kind"][0]
|
||||
if target["test"] and kind in ("lib", "rlib", "staticlib", "bin", "test"):
|
||||
selector = ["--lib"] if kind in ("lib", "rlib", "staticlib") else ["--" + kind, target["name"]]
|
||||
targets.append({"package": package["name"], "target": target["name"], "selector": selector,
|
||||
"features": sorted(active[package["id"]])})
|
||||
if target["doctest"] and kind in ("lib", "rlib", "staticlib"):
|
||||
targets.append({"package": package["name"], "target": target["name"], "selector": ["--doc"],
|
||||
"features": sorted(active[package["id"]])})
|
||||
return targets, Path(metadata["target_directory"])
|
||||
|
||||
|
||||
def target_command(config, target, all_features=True):
|
||||
features = (["--all-features"] if all_features else
|
||||
["--features", ",".join(target["features"])] if target.get("features") else [])
|
||||
return [config["tools"]["cargo"], "test", "--offline", "--locked", "-j1", *cargo_config(config),
|
||||
"-p", target["package"], *target["selector"], *features]
|
||||
|
||||
|
||||
def remove_executables(content, target_directory, enabled):
|
||||
if not enabled:
|
||||
return []
|
||||
removed = []
|
||||
for line in content.splitlines():
|
||||
if not line.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if (message.get("reason") == "compiler-artifact" and message.get("profile", {}).get("test")
|
||||
and message.get("executable")):
|
||||
path = Path(message["executable"]).resolve()
|
||||
if not path.is_relative_to(target_directory.resolve()) or path.parent.name != "deps":
|
||||
raise RuntimeError(f"refusing to remove a non-test output: {path}")
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
removed.append(str(path))
|
||||
return removed
|
||||
|
||||
|
||||
def cleanup_test_executables(directory, config, result):
|
||||
if not config.get("remove_test_executables", False) or "target_directory" not in result:
|
||||
return
|
||||
for command_file in sorted(directory.glob("*.command.json")):
|
||||
command = json.loads(command_file.read_text())
|
||||
arguments = command["command"]
|
||||
if arguments[0] != config["tools"]["cargo"] or "--message-format=json" not in arguments:
|
||||
continue
|
||||
log = Path(command["log"])
|
||||
result.setdefault("removed_test_executables", []).extend(
|
||||
remove_executables(log.read_text(errors="replace"), Path(result["target_directory"]), True))
|
||||
|
||||
|
||||
def run_workspace(stage, config, directory, result):
|
||||
all_features = stage == "all-features"
|
||||
targets, target_directory = workspace_targets(config, directory, result, all_features)
|
||||
result["targets"] = targets
|
||||
passed = 0
|
||||
for target in targets:
|
||||
command = [*target_command(config, target, all_features), "--message-format=json", "--", "--test-threads=1"]
|
||||
print(f"{stage}: {target['package']} {' '.join(target['selector'])}", flush=True)
|
||||
record, content = run_command(command, directory, len(result["commands"]), config.get("command_timeout_seconds", 14400))
|
||||
result["commands"].append(record)
|
||||
passed += record.get("rust_tests", {}).get("passed", 0)
|
||||
result.setdefault("removed_test_executables", []).extend(
|
||||
remove_executables(content, target_directory, config.get("remove_test_executables", False)))
|
||||
if not targets or not passed:
|
||||
raise RuntimeError("workspace target matrix executed no Rust tests")
|
||||
result["passed_tests"] = passed
|
||||
|
||||
|
||||
def run_command(command, directory, number, timeout, *, single_harness=False):
|
||||
path = directory / f"{number:03d}.log"
|
||||
started = time.time()
|
||||
record = {"command": command, "log": str(path), "started": started}
|
||||
write_private_json(directory / f"{number:03d}.command.json", record)
|
||||
failure = None
|
||||
child = None
|
||||
try:
|
||||
with path.open("x") as output:
|
||||
child = subprocess.Popen(command, cwd=ROOT, stdout=output, stderr=subprocess.STDOUT,
|
||||
start_new_session=True)
|
||||
try:
|
||||
child.wait(timeout=timeout)
|
||||
except BaseException:
|
||||
try:
|
||||
# Python 测量器以 KeyboardInterrupt 执行其 finally,回收独立进程组和 cgroup。
|
||||
os.killpg(child.pid, signal.SIGINT)
|
||||
child.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
os.killpg(child.pid, signal.SIGKILL)
|
||||
child.wait(timeout=5)
|
||||
except ProcessLookupError:
|
||||
child.wait()
|
||||
raise
|
||||
except BaseException as error:
|
||||
failure = error
|
||||
record["error"] = str(error) or type(error).__name__
|
||||
record["exit_code"] = child.returncode if child is not None else None
|
||||
record["elapsed_seconds"] = time.time() - started
|
||||
content = path.read_text(errors="replace")
|
||||
counts = re.findall(r"test result: (?:ok|FAILED)\. (\d+) passed; (\d+) failed; (\d+) ignored", content)
|
||||
if single_harness:
|
||||
# 直接运行一个 libtest 时,最后一条是父 harness;前面的可能是其子进程探针。
|
||||
counts = counts[-1:]
|
||||
if counts:
|
||||
record["rust_tests"] = {name: sum(int(row[index]) for row in counts)
|
||||
for index, name in enumerate(("passed", "failed", "ignored"))}
|
||||
record["python_tests"] = sum(map(int, re.findall(r"Ran (\d+) tests? in", content)))
|
||||
write_private_json(directory / f"{number:03d}.command.json", record, replace=True)
|
||||
if failure is not None:
|
||||
raise failure
|
||||
if record["exit_code"]:
|
||||
raise RuntimeError(f"command exited {record['exit_code']}: {path}")
|
||||
if re.search(r"OK \(skipped=|: unavailable", content):
|
||||
raise MissingInput(f"command skipped required verification: {path}")
|
||||
return record, content
|
||||
|
||||
|
||||
def check_output(stage, record, content):
|
||||
if stage in ("default", "all-features") and record.get("rust_tests", {}).get("passed", 0) == 0:
|
||||
raise RuntimeError("workspace command did not execute any Rust tests")
|
||||
if stage == "python" and record["python_tests"] == 0:
|
||||
raise RuntimeError("Python discovery executed no tests")
|
||||
if stage == "sdk" and any(f"{language}: handshake, health, result, business error, cancel, shutdown and rejection passed"
|
||||
not in content for language in ("Python", "JavaScript", "Go")):
|
||||
raise RuntimeError("all three SDK acceptance results are required")
|
||||
if stage == "features" and "dever-core feature \"reference\"" in content:
|
||||
raise RuntimeError("production CLI depends on the reference feature")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("action", choices=("plan", "run", "summary"))
|
||||
parser.add_argument("--stage", choices=STAGES)
|
||||
args = parser.parse_args()
|
||||
document, config = load_config()
|
||||
output = Path(config.get("output", ROOT / "target/linux-ci"))
|
||||
if not output.is_absolute():
|
||||
raise ValueError("performance.linux_ci.output must be absolute")
|
||||
source = source_fingerprint()
|
||||
if args.action == "summary":
|
||||
results = {}
|
||||
for stage in STAGES:
|
||||
candidates = sorted((output / stage).glob("*/result.json"))
|
||||
record = json.loads(candidates[-1].read_text()) if candidates else {}
|
||||
stage_inputs = input_fingerprint(document, config, stage)
|
||||
current = (record.get("source") == source and record.get("inputs") == stage_inputs
|
||||
and (stage != "author" or record.get("outputs") == input_fingerprint(document, config)))
|
||||
results[stage] = record["status"] if current else "missing-current-evidence"
|
||||
print(json.dumps(results, indent=2))
|
||||
return 0 if all(status == "passed" for status in results.values()) else 1
|
||||
if args.action == "plan":
|
||||
inputs = input_fingerprint(document, config, args.stage)
|
||||
print(json.dumps({"stages": STAGES, "source": source, "inputs": inputs,
|
||||
"commands": command_plan(args.stage, config) if args.stage in STAGES[:6] else None}, indent=2))
|
||||
return 0
|
||||
if args.stage is None:
|
||||
parser.error("run requires --stage (all stages are serialized by the caller)")
|
||||
directory = output / args.stage / f"{time.time_ns()}"
|
||||
directory.mkdir(parents=True, exist_ok=False)
|
||||
result = {"stage": args.stage, "source": source, "inputs": None, "status": "running", "commands": []}
|
||||
write_private_json(directory / "result.json", result)
|
||||
try:
|
||||
try:
|
||||
inputs = input_fingerprint(document, config, args.stage)
|
||||
except (ValueError, OSError) as error:
|
||||
raise MissingInput(str(error)) from error
|
||||
result["inputs"] = inputs
|
||||
write_private_json(directory / "result.json", result, replace=True)
|
||||
from acceptance import validate_budget
|
||||
validate_budget(config)
|
||||
if args.stage in ("default", "all-features"):
|
||||
run_workspace(args.stage, config, directory, result)
|
||||
elif args.stage in STAGES[:6]:
|
||||
for number, command in enumerate(command_plan(args.stage, config)):
|
||||
print(f"{args.stage}: {' '.join(command)}", flush=True)
|
||||
record, content = run_command(command, directory, number, config.get("command_timeout_seconds", 14400))
|
||||
result["commands"].append(record)
|
||||
check_output(args.stage, record, content)
|
||||
else:
|
||||
from acceptance import run_stage
|
||||
run_stage(args.stage, document, config, directory, result)
|
||||
current_document, current_config = load_config()
|
||||
final_inputs = input_fingerprint(current_document, current_config, args.stage)
|
||||
if args.stage == "author":
|
||||
result["outputs"] = input_fingerprint(current_document, current_config)
|
||||
if source_fingerprint() != source or final_inputs != inputs:
|
||||
raise RuntimeError("source or declared inputs changed while the stage ran; evidence is stale")
|
||||
result["status"] = "passed"
|
||||
except MissingInput as error:
|
||||
result.update(status="blocked", error=str(error))
|
||||
except (Exception, KeyboardInterrupt) as error:
|
||||
result.update(status="failed", error=str(error) or type(error).__name__)
|
||||
finally:
|
||||
try:
|
||||
cleanup_test_executables(directory, config, result)
|
||||
except Exception as error:
|
||||
result.update(status="failed", cleanup_error=str(error))
|
||||
result["commands"] = [json.loads(path.read_text()) for path in sorted(directory.glob("*.command.json"))]
|
||||
write_private_json(directory / "result.json", result, replace=True)
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0 if result["status"] == "passed" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except (MissingInput, ValueError, OSError) as error:
|
||||
print(f"blocked: {error}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""验证共享分配账本:线程缓存正常析构,真实泄漏和错误结果必须拒绝。"""
|
||||
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DRIVER = ROOT / "test/native-runtime-abi/managed-driver.c"
|
||||
FIXTURE = ROOT / "test/native-runtime-abi/driver-fixture.c"
|
||||
WRAPPED = ("malloc", "calloc", "realloc", "free", "posix_memalign", "realpath")
|
||||
|
||||
|
||||
class DriverTests(unittest.TestCase):
|
||||
def run_fixture(self, scenario):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
executable = Path(temporary) / "ledger"
|
||||
subprocess.run([
|
||||
"/usr/bin/cc", "-std=c11", "-Wall", "-Wextra", "-Werror", "-O2",
|
||||
f"-DDEVER_LEDGER_SCENARIO={scenario}", str(DRIVER), str(FIXTURE),
|
||||
*[f"-Wl,--wrap={name}" for name in WRAPPED], "-pthread", "-B/usr/bin/",
|
||||
"-o", str(executable),
|
||||
], check=True, env={}, capture_output=True, text=True, timeout=20)
|
||||
return subprocess.run([str(executable)], env={}, capture_output=True,
|
||||
text=True, timeout=5)
|
||||
|
||||
def test_late_thread_cache_is_released_before_counting(self):
|
||||
result = self.run_fixture(0)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_owned_leaks_and_changed_results_fail(self):
|
||||
for scenario, message in ((1, "allocation imbalance: 1"),
|
||||
(2, "changed its result on repeated execution: 7")):
|
||||
with self.subTest(scenario=scenario):
|
||||
result = self.run_fixture(scenario)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn(message, result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,317 @@
|
||||
"""质量门防假通过合同;不运行 Cargo、数据库或真实压力测试。"""
|
||||
|
||||
import importlib.util
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
spec = importlib.util.spec_from_file_location("linux_gate", Path(__file__).with_name("run.py"))
|
||||
gate = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = gate
|
||||
spec.loader.exec_module(gate)
|
||||
import acceptance
|
||||
from acceptance import classify_cases, validate_postgres, validate_soak
|
||||
|
||||
|
||||
class GateTests(unittest.TestCase):
|
||||
def test_ci_config_uses_the_shared_performance_section(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
(root / "config").mkdir()
|
||||
setting = root / "config/setting.json"
|
||||
config = {"output": str(root / "results"), "inputs": []}
|
||||
document = {"performance": {"linux_ci": config}}
|
||||
with patch.object(gate, "ROOT", root):
|
||||
with self.assertRaisesRegex(gate.MissingInput, r"performance\.linux_ci"):
|
||||
gate.load_config()
|
||||
setting.write_text(json.dumps(document))
|
||||
self.assertEqual(gate.load_config(), (document, config))
|
||||
before = gate.input_fingerprint(document, config)
|
||||
config["command_timeout_seconds"] = 120
|
||||
self.assertNotEqual(before, gate.input_fingerprint(document, config))
|
||||
for invalid in ({"linux_ci": config}, {"performance": {}},
|
||||
{"performance": []}, {"performance": {"linux_ci": []}}):
|
||||
setting.write_text(json.dumps(invalid))
|
||||
with self.subTest(document=invalid), self.assertRaisesRegex(
|
||||
gate.MissingInput, r"performance\.linux_ci"):
|
||||
gate.load_config()
|
||||
|
||||
def test_performance_peer_fingerprints_resolve_project_relative_paths(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
(root / "config").mkdir()
|
||||
(root / "peers").mkdir()
|
||||
peer = root / "peers/network"
|
||||
peer.write_text("original peer")
|
||||
live_peer = root / "peers/live"
|
||||
live_peer.write_text("original live peer")
|
||||
document = {"performance": {"network_peer": "peers/network", "live_peer": str(live_peer),
|
||||
"linux_ci": {"inputs": []}}}
|
||||
(root / "config/setting.json").write_text(json.dumps(document))
|
||||
with patch.object(gate, "ROOT", root):
|
||||
before = gate.input_fingerprint(document, {})
|
||||
peer.write_text("changed peer")
|
||||
self.assertNotEqual(before, gate.input_fingerprint(document, {}))
|
||||
before = gate.input_fingerprint(document, {})
|
||||
live_peer.write_text("changed live peer")
|
||||
self.assertNotEqual(before, gate.input_fingerprint(document, {}))
|
||||
for config in ({"inputs": ["peers/network"]}, {"tools": {"cargo": "peers/network"}}):
|
||||
with self.subTest(config=config), self.assertRaisesRegex(ValueError, "absolute paths"):
|
||||
gate.input_fingerprint(document, config)
|
||||
|
||||
def test_default_targets_preserve_workspace_resolved_features(self):
|
||||
target = {"package": "dever-backend-bridge", "selector": ["--test", "backend_bridge"],
|
||||
"features": ["embedded", "runtime-api"]}
|
||||
command = gate.target_command({"tools": {"cargo": "/explicit/cargo"}}, target, False)
|
||||
self.assertEqual(command[-2:], ["--features", "embedded,runtime-api"])
|
||||
self.assertNotIn("--all-features", command)
|
||||
|
||||
def test_sdk_missing_and_partial_execution_fail(self):
|
||||
config = {"tools": {"cargo": sys.executable, "python": sys.executable}}
|
||||
with self.assertRaises(gate.MissingInput):
|
||||
gate.command_plan("sdk", config)
|
||||
with self.assertRaises(RuntimeError):
|
||||
gate.check_output("sdk", {}, "Python: handshake passed")
|
||||
with self.assertRaises(RuntimeError):
|
||||
gate.check_output("python", {"python_tests": 0}, "OK")
|
||||
|
||||
def test_classification_rejects_unknown_duplicate_and_wrong_target(self):
|
||||
row = {"package": "sandbox", "target": "isolation", "name": "restricted_probe", "stage": "probe"}
|
||||
target = {"package": "sandbox", "target": "isolation"}
|
||||
self.assertEqual(classify_cases(["restricted_probe"], [row], target)[0]["stage"], "probe")
|
||||
for names, rows, owner in ((["unknown"], [row], target), (["restricted_probe"], [row, row], target),
|
||||
(["restricted_probe"], [row], {**target, "target": "other"}),
|
||||
(["a::restricted_probe", "b::restricted_probe"], [row], target)):
|
||||
with self.assertRaises(RuntimeError):
|
||||
classify_cases(names, rows, owner)
|
||||
|
||||
def test_postgres_requires_owned_loopback_case_template_without_override(self):
|
||||
def document(url):
|
||||
return {"database": {"postgres_test": {"type": "postgres", "url": url}}}
|
||||
validate_postgres(document("postgresql://fixture@127.0.0.1:51234/dever_{case}"))
|
||||
for url in ("postgresql://fixture@db.example:5432/dever_{case}",
|
||||
"postgresql://fixture@127.0.0.1:5432/fixed", "postgresql://fixture@127.0.0.1/dever_{case}",
|
||||
"postgresql://fixture@127.0.0.1:5432/dever_{case}?dbname=production"):
|
||||
with self.assertRaises(gate.MissingInput):
|
||||
validate_postgres(document(url))
|
||||
|
||||
def test_failed_command_keeps_exit_and_counts(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
with self.assertRaises(RuntimeError):
|
||||
gate.run_command([sys.executable, "-c", "print('test result: FAILED. 2 passed; 1 failed; 3 ignored'); raise SystemExit(7)"], directory, 0, 5)
|
||||
record = json.loads((directory / "000.command.json").read_text())
|
||||
self.assertEqual(record["exit_code"], 7)
|
||||
self.assertEqual(record["rust_tests"], {"passed": 2, "failed": 1, "ignored": 3})
|
||||
|
||||
def test_single_harness_counts_parent_instead_of_nested_probe(self):
|
||||
child = "test result: ok. 1 passed; 0 failed; 0 ignored"
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
for number, (single, parent_passed, parent_ignored, exit_code) in enumerate(
|
||||
((False, 1, 0, 0), (True, 1, 0, 0), (True, 0, 0, 0),
|
||||
(True, 0, 1, 0), (True, 0, 0, 7))):
|
||||
parent_failed = int(exit_code != 0)
|
||||
parent = (f"test result: {'FAILED' if parent_failed else 'ok'}. "
|
||||
f"{parent_passed} passed; {parent_failed} failed; {parent_ignored} ignored")
|
||||
command = [sys.executable, "-c",
|
||||
f"print({child!r}); print({parent!r}); raise SystemExit({exit_code})"]
|
||||
with self.subTest(single=single, parent=parent):
|
||||
if exit_code:
|
||||
with self.assertRaises(RuntimeError):
|
||||
gate.run_command(command, directory, number, 5, single_harness=single)
|
||||
else:
|
||||
gate.run_command(command, directory, number, 5, single_harness=single)
|
||||
record = json.loads((directory / f"{number:03d}.command.json").read_text())
|
||||
self.assertEqual(record["exit_code"], exit_code)
|
||||
self.assertEqual(record["rust_tests"], {
|
||||
"passed": parent_passed + int(not single),
|
||||
"failed": parent_failed, "ignored": parent_ignored})
|
||||
|
||||
def test_exact_ignored_acceptance_requires_one_successful_parent(self):
|
||||
target = {"package": "sandbox", "target": "isolation"}
|
||||
case = {"name": "parent", "exact": "parent", "stage": "sandbox",
|
||||
"target": target, "target_directory": "/unused/target"}
|
||||
for counts in ({"passed": 1, "failed": 0, "ignored": 0},
|
||||
{"passed": 0, "failed": 0, "ignored": 0},
|
||||
{"passed": 0, "failed": 0, "ignored": 1},
|
||||
{"passed": 1, "failed": 1, "ignored": 0},
|
||||
{"passed": 2, "failed": 0, "ignored": 0}, None):
|
||||
with (self.subTest(counts=counts),
|
||||
patch.object(acceptance, "require_inputs"),
|
||||
patch.object(acceptance, "current_inventory", return_value=[case]),
|
||||
patch.object(acceptance, "target_command", return_value=["cargo"]),
|
||||
patch.object(acceptance, "test_executable", return_value="/unused/test"),
|
||||
patch.object(acceptance, "remove_executables", return_value=[]),
|
||||
patch.object(acceptance, "execute", side_effect=[({}, "build"),
|
||||
({"rust_tests": counts}, "test")]) as execute):
|
||||
result = {"commands": []}
|
||||
expected = counts == {"passed": 1, "failed": 0, "ignored": 0}
|
||||
with contextlib.nullcontext() if expected else self.assertRaisesRegex(RuntimeError, "one case"):
|
||||
acceptance.ignored_stage("sandbox", {}, {}, Path("/unused"), result)
|
||||
self.assertTrue(execute.call_args.kwargs["single_harness"])
|
||||
self.assertEqual(len(result.get("cases", [])), int(expected))
|
||||
|
||||
def test_source_and_author_input_fingerprints_detect_real_changes(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
for name in ("Cargo.toml", "Cargo.lock"):
|
||||
(root / name).write_text(name)
|
||||
(root / "test").mkdir()
|
||||
before = gate.source_fingerprint(root)
|
||||
(root / "test/new.py").write_text("changed")
|
||||
self.assertNotEqual(before, gate.source_fingerprint(root))
|
||||
output = root / "generated.pack"
|
||||
output.write_text("old")
|
||||
config = {"inputs": [str(root)], "author_outputs": [str(output)]}
|
||||
author_before = gate.input_fingerprint({}, config, "author")
|
||||
native_before = gate.input_fingerprint({}, config, "native")
|
||||
output.write_text("new")
|
||||
self.assertEqual(author_before, gate.input_fingerprint({}, config, "author"))
|
||||
self.assertNotEqual(native_before, gate.input_fingerprint({}, config, "native"))
|
||||
(root / "Cargo.lock").write_text("changed immutable input")
|
||||
self.assertNotEqual(author_before, gate.input_fingerprint({}, config, "author"))
|
||||
|
||||
def test_cleanup_cannot_delete_author_binary(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
executable = root / "native-acceptance-init"
|
||||
executable.write_text("owned author fixture")
|
||||
message = json.dumps({"reason": "compiler-artifact", "profile": {"test": True}, "executable": str(executable)})
|
||||
with self.assertRaises(RuntimeError):
|
||||
gate.remove_executables(message, root, True)
|
||||
self.assertTrue(executable.exists())
|
||||
|
||||
def test_failed_workspace_command_still_cleans_its_test_executable(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
executable = root / "target/debug/deps/test-owned"
|
||||
executable.parent.mkdir(parents=True)
|
||||
executable.write_text("rebuildable executable")
|
||||
config = {"output": str(root / "results"), "remove_test_executables": True,
|
||||
"tools": {"cargo": sys.executable}}
|
||||
|
||||
def fail_workspace(stage, config, directory, result):
|
||||
result["target_directory"] = str(root / "target")
|
||||
message = json.dumps({"reason": "compiler-artifact", "profile": {"test": True},
|
||||
"executable": str(executable)})
|
||||
gate.run_command([sys.executable, "-c", f"print({message!r}); raise SystemExit(7)",
|
||||
"--message-format=json"],
|
||||
directory, 0, 5)
|
||||
|
||||
with (patch.object(gate, "load_config", return_value=({}, config)),
|
||||
patch.object(gate, "source_fingerprint", return_value="source"),
|
||||
patch.object(gate, "run_workspace", side_effect=fail_workspace),
|
||||
patch.object(acceptance, "validate_budget"),
|
||||
patch.object(sys, "argv", ["run.py", "run", "--stage", "default"]),
|
||||
contextlib.redirect_stdout(io.StringIO())):
|
||||
self.assertEqual(gate.main(), 1)
|
||||
self.assertFalse(executable.exists())
|
||||
record = json.loads(next((root / "results/default").glob("*/result.json")).read_text())
|
||||
self.assertEqual(record["status"], "failed")
|
||||
self.assertIn("command exited 7", record["error"])
|
||||
self.assertEqual(record["removed_test_executables"], [str(executable)])
|
||||
|
||||
def test_missing_input_is_saved_as_blocked_stage_evidence(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
config = {"output": temporary}
|
||||
with (patch.object(gate, "load_config", return_value=({}, config)),
|
||||
patch.object(gate, "source_fingerprint", return_value="source"),
|
||||
patch.object(gate, "input_fingerprint", side_effect=FileNotFoundError("missing peer")),
|
||||
patch.object(sys, "argv", ["run.py", "run", "--stage", "python"]),
|
||||
contextlib.redirect_stdout(io.StringIO())):
|
||||
self.assertEqual(gate.main(), 1)
|
||||
record = json.loads(next((Path(temporary) / "python").glob("*/result.json")).read_text())
|
||||
self.assertEqual(record["status"], "blocked")
|
||||
self.assertIn("missing peer", record["error"])
|
||||
|
||||
def test_inventory_never_reuses_pass_after_a_newer_failure_or_interruption(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
config = {"output": temporary}
|
||||
fingerprint = gate.input_fingerprint({}, config, "inventory")
|
||||
for index, status in enumerate(("passed", "running")):
|
||||
directory = root / "inventory" / str(index)
|
||||
directory.mkdir(parents=True)
|
||||
(directory / "result.json").write_text(json.dumps({"source": "source", "inputs": fingerprint,
|
||||
"status": status}))
|
||||
(directory / "inventory.json").write_text("[]")
|
||||
with self.assertRaises(gate.MissingInput):
|
||||
acceptance.current_inventory({}, config, {"source": "source"})
|
||||
output = io.StringIO()
|
||||
with (patch.object(gate, "load_config", return_value=({}, config)),
|
||||
patch.object(gate, "source_fingerprint", return_value="source"),
|
||||
patch.object(gate, "STAGES", ("inventory",)),
|
||||
patch.object(sys, "argv", ["run.py", "summary"]),
|
||||
contextlib.redirect_stdout(output)):
|
||||
self.assertEqual(gate.main(), 1)
|
||||
self.assertEqual(json.loads(output.getvalue()), {"inventory": "running"})
|
||||
(root / "inventory/1/result.json").write_text(json.dumps({
|
||||
"source": "source", "inputs": fingerprint, "status": "failed"}))
|
||||
with self.assertRaises(gate.MissingInput):
|
||||
acceptance.current_inventory({}, config, {"source": "source"})
|
||||
|
||||
def test_inventory_detects_a_missing_owner_even_when_leaf_names_match(self):
|
||||
row = {"package": "package", "target": "first", "name": "same_name", "stage": "native"}
|
||||
target = {"package": "package", "target": "first", "selector": ["--lib"]}
|
||||
artifact = json.dumps({"reason": "compiler-artifact", "profile": {"test": True},
|
||||
"target": {"name": "first"}, "executable": "/owned/test"})
|
||||
with (tempfile.TemporaryDirectory() as temporary,
|
||||
patch.object(acceptance, "audit_classification", return_value=[row, {**row, "target": "second"}]),
|
||||
patch.object(acceptance, "workspace_targets", return_value=([target], Path(temporary))),
|
||||
patch.object(acceptance, "execute", side_effect=[({}, artifact), ({}, "same_name: test\n")])):
|
||||
with self.assertRaisesRegex(RuntimeError, "second"):
|
||||
acceptance.inventory({"tools": {"cargo": "/cargo"}}, Path(temporary), {"commands": []})
|
||||
|
||||
def test_soak_rejects_short_load_errors_drops_missing_budget_and_fd_leak(self):
|
||||
samples = [{"rss_bytes": 100, "fds": 3, "elapsed_seconds": index * 600} for index in range(4)]
|
||||
case = {"suite": "http2", "implementation": "dever", "transport": "http",
|
||||
"budget_after_stop": {"events": {"oom": 0, "oom_kill": 0}, "max": str(64 * 1024 ** 2)},
|
||||
"load": {"elapsed_seconds": 1800, "scheduled": 1000, "succeeded": 1000, "errors": 0},
|
||||
"disconnected": {"fd_delta": 0}, "raw": samples}
|
||||
report = {"status": "complete", "cases": [case, {**case, "transport": "https"}]}
|
||||
validate_soak(report, "http2", 1800, 1024, 64)
|
||||
for field, value in (("elapsed_seconds", 1799), ("errors", 1), ("succeeded", 989)):
|
||||
invalid = json.loads(json.dumps(report))
|
||||
invalid["cases"][0]["load"][field] = value
|
||||
with self.assertRaises(RuntimeError):
|
||||
validate_soak(invalid, "http2", 1800, 1024, 64)
|
||||
for field, value in (("transport", "https"), ("implementation", "hyper"),
|
||||
("disconnected", {"fd_delta": 1}),
|
||||
("budget_after_stop", {"events": {"oom": 0, "oom_kill": 0}, "max": "max"})):
|
||||
invalid = json.loads(json.dumps(report))
|
||||
invalid["cases"][0][field] = value
|
||||
with self.assertRaises(RuntimeError):
|
||||
validate_soak(invalid, "http2", 1800, 1024, 64)
|
||||
case["budget_after_stop"] = None
|
||||
with self.assertRaises(RuntimeError):
|
||||
validate_soak(report, "http2", 1800, 1024, 64)
|
||||
|
||||
def test_cms_soak_checks_descriptor_identity_and_pool_bound_not_total_count(self):
|
||||
load = {"elapsed_seconds": 1800, "succeeded": 1000, "errors": 0,
|
||||
"fd_delta_after_disconnect": 1,
|
||||
"descriptors_before": {"3": "socket:[listener]", "4": "/fixture/platform.db"},
|
||||
"descriptors_after": {"3": "socket:[listener]", "4": "/fixture/platform.db",
|
||||
"5": "/fixture/platform.db"},
|
||||
"pooled_descriptor_limits": {"/fixture/platform.db": 4},
|
||||
"trend": {"rss_late_growth_bytes": 0}}
|
||||
case = {"cgroup": {"events": {"oom": 0, "oom_kill": 0}, "max": str(64 * 1024 ** 2)},
|
||||
"concurrent_reads": load}
|
||||
report = {"status": "passed", "cases": {"dever": case, "md": case}}
|
||||
validate_soak(report, "cms", 1800, 1024, 64)
|
||||
for descriptors in ({**load["descriptors_after"], "3": "socket:[replacement]"},
|
||||
{**load["descriptors_after"], "9": "socket:[leaked]"},
|
||||
{**load["descriptors_after"], **{str(n): "/fixture/platform.db" for n in range(10, 15)}}):
|
||||
invalid = json.loads(json.dumps(report))
|
||||
invalid["cases"]["md"]["concurrent_reads"]["descriptors_after"] = descriptors
|
||||
with self.subTest(descriptors=descriptors), self.assertRaises(RuntimeError):
|
||||
validate_soak(invalid, "cms", 1800, 1024, 64)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user