diff --git a/scripts/check_test_coverage.py b/scripts/check_test_coverage.py index 1b575b88..3a379bc3 100644 --- a/scripts/check_test_coverage.py +++ b/scripts/check_test_coverage.py @@ -46,6 +46,12 @@ r""" 2 = 环境错误(找不到 cl.exe / 找不到 mscoverage / 编译失败 / XML 缺失或解析失败) 3 = 用法错误 +中间产物目录(2026-10-25 起与主机单测同家): + %TEMP%\dtu_host\<工作树哈希>\coverage\<模块>\ —— exe / PDB / .coverage / + cobertura XML 都在这里,**不进仓库**。按工作树分家,是因为旧的固定共享名 + %TEMP%\dtu_test_cov 在多棵工作树并行跑覆盖率时会互相覆盖(判据与来龙去脉见 + test/host/common/unit_test_harness.py 顶部"产物目录归属"一节)。改根:--work-dir + 或环境变量 DTU_HOST_BUILD。 用法(仓库根目录): & '.venv\Scripts\python.exe' scripts/check_test_coverage.py # 全量跑 + 比基线 & '.venv\Scripts\python.exe' scripts/check_test_coverage.py --module sha256 # 只跑一套(调试) @@ -79,7 +85,6 @@ import re import shutil import subprocess import sys -import tempfile import xml.etree.ElementTree as ET from pathlib import Path @@ -682,7 +687,17 @@ def run(args): prod = production_sources() canon = {rel.lower(): rel for rel in prod} - work_root = args.work_dir or os.path.join(tempfile.gettempdir(), "dtu_test_cov") + # 中间产物根:**跟着工作树走**,与主机单测同一个家(task-10)。旧写法是 + # tempfile.gettempdir()/dtu_test_cov —— 全机一个名字,多棵工作树并行跑覆盖率就 + # 互相覆盖 exe/PDB/cov 文件(docs/测试说明/主机单测覆盖率口径.md 里"并行期每人 + # 只跑 --module 自己模块"那条规避,就是为了绕开它;分家之后不再需要)。 + # 复用 harness 的同一套判据(含 DTU_HOST_BUILD 覆盖),避免两处口径漂移。 + _host_build_root = getattr(harness, "host_build_root", None) + if _host_build_root is None: + print("[ENV] unit_test_harness 缺 host_build_root()——覆盖率产物无法与主机" + "单测同家(harness 版本过旧?),拒绝跑。", file=sys.stderr) + return 2 + work_root = args.work_dir or os.path.join(_host_build_root(), "coverage") if args.clean: shutil.rmtree(work_root, ignore_errors=True) os.makedirs(work_root, exist_ok=True) @@ -820,7 +835,7 @@ def main(): ap.add_argument("--md", metavar="PATH", help="额外输出 Markdown 报告") ap.add_argument("--json", metavar="PATH", help="额外输出 JSON 明细") ap.add_argument("--work-dir", metavar="PATH", - help="中间产物目录(默认临时目录下 dtu_test_cov)") + help="中间产物目录(默认与主机单测同家:TEMP 下 dtu_host/<工作树哈希>/coverage)") ap.add_argument("--clean", action="store_true", help="跑前清空中间目录") ap.add_argument("--timeout", type=int, default=600, metavar="SEC", help="单模块插桩运行超时(默认 600 秒)") diff --git a/scripts/ci_local.py b/scripts/ci_local.py index 870adeb0..71623d91 100644 --- a/scripts/ci_local.py +++ b/scripts/ci_local.py @@ -441,17 +441,24 @@ def main(argv=None): return 2 rc = 0 + # 实际跑过哪些段——收尾那行只报这些。理由与 A-5 同源且是同一类错误: + # "--l05-only 却宣布 L0/L0b/L0c 全部通过"就是把没跑的说成跑过了(SKIP 冒充 PASS + # 的镜像版)。2026-10-25 做 task-10 时从 --l05-only 的输出里发现,当场改掉。 + ran = [] try: if args.size_only: return run_size_gate() if not args.l05_only: rc = run_l0() + ran.append("L0") # 提交信息门禁独立于 --all-files(stage 不同),故单独跑一段; # 即使 L0 已失败也照跑,一次看全所有违规项。 rc_msg = run_gitlint(args.commits) + ran.append("L0b") # L0c 体积门禁同样**无条件照跑**:它回答的是"这份代码测过体积没有", # 与 L0/L0b 的结论正交,且只要 0.3 s——少跑一次就是 A-5 那个盲区。 rc_size = run_size_gate() + ran.append("L0c") if rc != 0 or rc_msg != 0 or rc_size != 0: if not args.keep_going: print("") @@ -461,9 +468,11 @@ def main(argv=None): rc = rc or rc_msg or rc_size if not args.l0_only: rc05 = run_l05(args) + ran.append("L0.5") rc = rc or rc05 if args.coverage: rc = rc or run_coverage() + ran.append("L0.5c") except FileNotFoundError as e: print("[ERROR] 环境错误:{}".format(e)) return 2 @@ -471,7 +480,7 @@ def main(argv=None): print("") print("=" * 64) if rc == 0: - print(" [OK] 本地 CI 全部通过(L0 + L0b + L0c + L0.5)") + print(" [OK] 本地 CI 全部通过({})".format(" + ".join(ran))) else: print(" [FAIL] 本地 CI 有未通过项(见上方输出)") print("=" * 64) diff --git a/test/host/common/README.md b/test/host/common/README.md index f68e9551..9d06edac 100644 --- a/test/host/common/README.md +++ b/test/host/common/README.md @@ -68,7 +68,7 @@ raise SystemExit(run_host_unit_test(TestBuild(name="...", src="...", ...))) | `include_dirs` | | `/I` 目录列表;**桩目录需排在前面**才能覆盖真实头 | | `summary_marker` | | 运行输出中必须出现的汇总行前缀;空 = 不校验 | | `extra_flags` | | 追加的 `cl.exe` 开关 | -| `exe_name` / `obj_tag` | | 覆盖派生的产物名 / 中间目录名(默认由 `name` 派生,落在系统 temp) | +| `exe_name` / `obj_tag` | | 覆盖派生的产物名 / 中间目录名(默认由 `name` 派生;落点见下节"产物目录归属") | ## 环境要求 @@ -76,6 +76,43 @@ raise SystemExit(run_host_unit_test(TestBuild(name="...", src="...", ...))) MSVC `cl.exe`(装了 VS 即有)+ Windows SDK + 纯标准库 Python;**不调 `vcvars64.bat`** (内部 `reg.exe` 受限),直接拼环境变量调 `cl.exe`。 +## 产物目录归属(跨工作树并行,2026-10-25) + +**obj / exe / pdb 每次运行独占一个子目录**,按工作树分家: + +``` +%TEMP%\dtu_host\<仓库根路径 sha1 前 10 位>\runs\-<纳秒时间戳>\ + _obj\*.obj ← 中间目标文件 + _tests.exe ← 本次编译产物(跑完随目录一起删) + _tests.exe.owner.json ← 归属凭据:suite / repo_root / pid / started +``` + +为什么这么放(两条都是实测出来的,不是洁癖): + +- **旧写法把 obj/exe 摊在全机共用的 `%TEMP%`,名字由套件名派生**( + `%TEMP%\net_socket_tests.exe`)。多棵工作树并行跑同名套件就会互相覆盖,而最坏情况 + 不是编不过:A 树编译完,B 树把同名 exe 又盖一遍,A 树随后执行的是 **B 树编出来的 + 二进制**——两棵树的 `summary_marker` 一模一样,harness 分辨不出来,等于拿别人的 + 运行结果给自己下结论。2026-10-25 三条道并行时全量 `ci_local` 里 `test-cppcheck` / + `test-coverage` 偶发 "files were modified by this hook"、单跑即绿,就是这一类。 +- **按工作树分家 + 每次跑独占**同时解决"跨树互踩"和"同树并发"(覆盖率钩子与手工 L0.5 + 同时跑同一棵树时也会撞)。不牺牲任何东西:每个测试单元就一个 `.c`,`cl` 每次整体 + 重编,"共享 obj 省编译"本来就是错觉。**没有加锁**——这里没有需要互斥的独占资源, + 只是名字撞了;加锁等于把 L0.5 变成和 build/bench 一样的全局串行。 + +两个开关(都写在 harness 顶部注释里,判据以 `unit_test_harness.host_build_root()` 为准): + +- `DTU_HOST_BUILD=<目录>`:改产物根(CI/容器要固定路径时用)。覆盖率侧同源—— + `scripts/check_test_coverage.py` 复用同一个函数,不再用自己那套固定名 + (旧 `%TEMP%\dtu_test_cov` 的共享名,正是 docs/测试说明/主机单测覆盖率口径.md 里 + "并行期每人只跑 `--module` 自己模块"那条规避的由来;分家之后该规避不再必要)。 +- `DTU_HOST_BUILD_KEEP=1`:保留本次独占目录不删(要单步调试那个 exe 时用,会打印路径)。 + +想知道当前树算到哪个目录: + +```powershell +& '.venv\Scripts\python.exe' -c "import sys;sys.path.insert(0,'test/host/common');import unit_test_harness as U;print(U.host_build_root())" +``` ## ⚠️ 注意 - 本目录在 pre-commit 的 cppcheck / 代码规范**排除范围**(`test/` 豁免) diff --git a/test/host/common/unit_test_harness.py b/test/host/common/unit_test_harness.py index 6519baac..c05827d5 100644 --- a/test/host/common/unit_test_harness.py +++ b/test/host/common/unit_test_harness.py @@ -41,10 +41,14 @@ """ import glob +import hashlib +import json import os +import shutil import subprocess import sys import tempfile +import time from dataclasses import dataclass, field # ----------------------------- 工具链探测位置 ----------------------------- @@ -129,6 +133,61 @@ class TestBuild: return tag + "_obj" +# ----------------------------- 产物目录归属(跨工作树并行) ----------------------------- +# 现象(2026-10-25 三条道并行开发实测):全量 ci_local 的 L0 里 test-cppcheck / +# test-coverage 偶发 "files were modified by this hook",单独重跑即绿、工作树零改动。 +# 根因是旧写法把 obj/exe 直接落在**全机共用**的 %TEMP%,名字由套件名派生 +# (%TEMP%\net_socket_tests.exe 与 %TEMP%\net_socket_obj)。多棵工作树并行跑**同名 +# 套件**就会互相覆盖,而最坏的不是编不过:A 树编译完,B 树把同名 exe 又盖一遍, +# A 树随后执行的是 **B 树编出来的二进制**——两棵树的 summary_marker 一模一样, +# harness 分辨不出来,于是拿着别人的运行结果给自己下结论(比红更难查)。 +# 覆盖率侧同源:%TEMP%\dtu_test_cov 也是固定共享名(docs/测试说明/主机单测覆盖率 +# 口径.md 里"并行期每人只跑 --module 自己模块"就是为绕开它写的规避;本处修好后 +# 那条规避不再必要)。 +# +# 口径:**按工作树分家**。产物根 = %TEMP%\dtu_host\<仓库根路径哈希>\,跨树天然不撞, +# 同一棵树内仍复用同一份目录(33 套的编译缓存没必要白白重编)。**不用锁**——锁会把 +# L0.5 变成和 build/bench 一样的全局串行,而这里并没有需要互斥的独占资源,只是名字 +# 撞了。显式覆盖:DTU_HOST_BUILD=<目录>(CI/容器要固定路径时用)。 + + +def repo_root(): + """本 harness 所在工作树的根(test/host/common/ 往上三级)。 + + 每棵工作树都有自己的 test/host/(工作树之间只共享 .venv 与模拟器 SDK 两个目录 + 联接),所以"harness 文件自身的位置"就是可靠的树身份,不依赖 cwd 或环境变量。 + """ + here = os.path.dirname(os.path.abspath(__file__)) # /test/host/common + root = here + for _ in range(3): # common -> host -> test -> root + root = os.path.dirname(root) + return os.path.normpath(root) + + +def host_build_root(): + """主机单测产物根目录(按工作树分家;判据见上方"产物目录归属")。""" + env = os.environ.get("DTU_HOST_BUILD") + if env: + return os.path.abspath(os.path.expanduser(env)) + key = hashlib.sha1(os.path.normcase(repo_root()).encode("utf-8")).hexdigest()[:10] + return os.path.join(tempfile.gettempdir(), "dtu_host", key) + + +def _write_owner(root, build): + """在产物根里落一份归属凭据(.owner.json):并发/偶发问题时一眼看出这份 + 产物属于哪棵树、哪个进程、什么时候跑的。写失败不阻塞测试(只是诊断信息)。""" + info = {"suite": build.name, "repo_root": repo_root(), "pid": os.getpid(), + "started": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + "build_root": root} + try: + with open(os.path.join(root, build.resolved_exe() + ".owner.json"), "w", + encoding="utf-8") as f: + json.dump(info, f, ensure_ascii=True, sort_keys=True) + f.write("\n") + except OSError: + pass + + def _first(pattern): """glob 命中里取最大者(版本号目录的字符串序即数值序,前提是位数一致)。""" hits = sorted(glob.glob(pattern)) @@ -219,11 +278,22 @@ def build_and_run(build, out=None): print("[ENV] 找不到 MSVC cl.exe(设置 DTU_MSVC_CL,或安装 VS2019/2022/2026 的 C++ 工具链)", file=err) return 2 - tmp = tempfile.gettempdir() - objdir = os.path.join(tmp, build.resolved_obj_tag()) - os.makedirs(objdir, exist_ok=True) - exe = os.path.join(tmp, build.resolved_exe()) - + # 产物落**按工作树分家**的根目录(判据见上方"产物目录归属");旧写法用 + # tempfile.gettempdir() 把 obj/exe 摊在全机共用的 %TEMP%,跨树同名即互踩。 + # 而且**每次跑独占一个子目录**(obj 与 exe 都在里面)——这一步才真正消灭竞态: + # 两个进程哪怕同树并发、哪怕有人把 DTU_HOST_BUILD 指到同一处,也拿不到同一个 + # exe 路径(旧写法会出现"A 编完 B 覆盖、A 跑的是 B 的二进制"),也拿不到同一个 + # .obj(并发 cl.exe 写同名 obj 会直接编不过)。这里不牺牲什么:每个测试单元就一个 + # .c,cl 每次都是整体重编,"共享 obj 省编译"本来就是错觉。跑完默认删掉该子目录; + # 调试要保留就用 DTU_HOST_BUILD_KEEP=1(会打印路径)。 + root = host_build_root() + rundir = os.path.join(root, "runs", "{}-{}".format(os.getpid(), time.time_ns())) + objdir = os.path.join(rundir, build.resolved_obj_tag()) + os.makedirs(objdir, exist_ok=True) # 顺带把 rundir 建出来 + exe = os.path.join(rundir, build.resolved_exe()) + _write_owner(rundir, build) + keep = (os.environ.get("DTU_HOST_BUILD_KEEP") or "").strip().lower() not in ( + "", "0", "false", "no") env = dict(os.environ) env["INCLUDE"] = os.pathsep.join(inc) env["LIB"] = os.pathsep.join(lib) @@ -235,32 +305,48 @@ def build_and_run(build, out=None): cmd += list(build.extra_flags) cmd += [build.src, "/Fe:" + exe, "/Fo:" + objdir + os.sep] - print("=== 编译并运行 {} ===".format(build.name), file=out) - cp = subprocess.run(cmd, env=env, capture_output=True) - cout, cerr = decode(cp.stdout), decode(cp.stderr) - if cout: - out.write(cout) - if cerr: - err.write(cerr) - if cp.returncode != 0: - print("[ENV] 编译失败", file=err) - return 2 + print("=== 编译并运行 {} ===(产物 {})".format(build.name, rundir), file=out) + try: + cp = subprocess.run(cmd, env=env, capture_output=True) + cout, cerr = decode(cp.stdout), decode(cp.stderr) + if cout: + out.write(cout) + if cerr: + err.write(cerr) + if cp.returncode != 0: + print("[ENV] 编译失败", file=err) + return 2 - run = subprocess.run([exe], env=env, capture_output=True) - rout = decode(run.stdout) - out.write(rout) - rerr = decode(run.stderr) - if rerr: - err.write(rerr) + # 只跑"本次编译确实产出的"exe:路径是本次独占的,所以只要存在就必须是本次 + # 写的;不存在 = 编译虽返回 0 但没落地(磁盘/杀软/并发清理),一律按环境错误 + # 处理,绝不拿着来路不明的二进制出结论。 + if not os.path.isfile(exe): + print("[ENV] 编译返回 0 但没有产物 exe: {}".format(exe), file=err) + return 2 - if build.summary_marker and build.summary_marker not in rout: - print("[ENV] 未产生测试汇总行(找不到 {!r})".format(build.summary_marker), file=err) - return 2 - if run.returncode == 0: - print("[OK] 单元测试全部通过", file=out) - return 0 - print("[FAIL] 有测试用例未通过", file=err) - return 1 + run = subprocess.run([exe], env=env, capture_output=True) + rout = decode(run.stdout) + out.write(rout) + rerr = decode(run.stderr) + if rerr: + err.write(rerr) + + if build.summary_marker and build.summary_marker not in rout: + print("[ENV] 未产生测试汇总行(找不到 {!r})".format(build.summary_marker), + file=err) + return 2 + if run.returncode == 0: + print("[OK] 单元测试全部通过", file=out) + return 0 + print("[FAIL] 有测试用例未通过", file=err) + return 1 + finally: + # 跑完(含异常/Ctrl-C)就把本次独占目录收掉:残留的半截 exe 永远不会被下一 + # 次运行当作自己的产物。调试场景用 DTU_HOST_BUILD_KEEP=1 保留(会打印路径)。 + if keep: + print("[KEEP] 本次产物保留在 {}".format(rundir), file=out) + else: + shutil.rmtree(rundir, ignore_errors=True) def run_host_unit_test(build):