Merged
test: add strict serial gtest stress runner #1327
test: add strict serial gtest stress runner #1327
Merged
liuxingsenAdd on 25 days ago
Total: 3 files changed+725-0
@@ -0,0 +1,317 @@
1+#!/usr/bin/env python3
2+# Copyright (c) 2026 Huawei Technologies Co., Ltd.
3+# openUBMC is licensed under Mulan PSL v2.
4+# You can use this software according to the terms and conditions of the Mulan PSL v2.
5+# You may obtain a copy of Mulan PSL v2 at:
6+# http://license.coscl.org.cn/MulanPSL2
7+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
8+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
9+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
10+# See the Mulan PSL v2 for more details.
11+"""Run every configured Meson/GoogleTest executable serially for many rounds.
12+ 
13+The build is intentionally external: build first, then run this script against
14+that exact build directory. Each invocation creates a new results directory.
15+"""
16+ 
17+import argparse
18+from collections import Counter
19+from datetime import datetime, timezone
20+import json
21+import os
22+from pathlib import Path
23+import re
24+import shlex
25+import signal
26+import subprocess
27+import sys
28+ 
29+ 
30+ROOT = Path(__file__).resolve().parents[1]
31+ANSI = re.compile(r"\x1b\[[0-9;]*m")
32+GTEST_CASE = re.compile(r"\[\s*(RUN|OK|FAILED|SKIPPED)\s*\]\s+([^\s\[]+\.[^\s\[]+)")
33+ 
34+ 
35+class SignalInterrupt(KeyboardInterrupt):
36+ def __init__(self, signum):
37+ super().__init__(signum)
38+ self.signum = signum
39+ 
40+ 
41+def wrapper_exec(command):
42+ """Put the matching libmcpp runtime ahead of Meson's per-test LD path."""
43+ if not command:
44+ raise ValueError("Meson wrapper received no test command")
45+ prebuilt = [
46+ ROOT / ".libmcpp_prebuilt" / relative
47+ for relative in (
48+ "src", "libraries/mcbase/src", "libraries/mcengine/src",
49+ "libraries/mcexpr/src", "libraries/mcdbus/src", "libraries/mcapp/src",
50+ )
51+ ]
52+ lua = ROOT / "temp/usr/lib64"
53+ lua_support = ROOT / ".lua_runtime_libs"
54+ test_name = Path(command[0]).name
55+ lua_test = test_name.startswith(("test_lua_plugin", "test_lua_seri", "test_chip_base"))
56+ front = ([lua] if lua_test else []) + prebuilt + [lua_support]
57+ back = ([] if lua_test else [lua]) + [Path(p) for p in os.environ.get("LD_LIBRARY_PATH", "").split(":") if p]
58+ paths = []
59+ for path in front + back:
60+ value = str(path)
61+ if path.is_dir() and value not in paths:
62+ paths.append(value)
63+ os.environ["LD_LIBRARY_PATH"] = ":".join(paths)
64+ os.execvpe(command[0], command, os.environ)
65+ 
66+ 
67+def pretty_name(test):
68+ suites = test.get("suite", [])
69+ project = suites[0].split(":", 1)[0] if suites else "component_drivers"
70+ labels = [s.split(":", 1)[1] for s in suites if ":" in s and s.split(":", 1)[1]]
71+ name = f"{project}:{test['name']}"
72+ return f"{'+'.join(labels)} - {name}" if labels else name
73+ 
74+ 
75+def parse_cases(output):
76+ started = Counter()
77+ ended = Counter()
78+ counts = Counter()
79+ for raw in output.splitlines():
80+ # Runtime workers and GoogleTest share the captured output stream. A
81+ # worker log may be concatenated before a marker without a newline, or
82+ # multiple markers may land on the same physical line.
83+ for match in GTEST_CASE.finditer(ANSI.sub("", raw)):
84+ outcome, parsed_name = match.groups()
85+ # Google's final failure list repeats case names without '(N ms)'.
86+ # Count only results for cases that have a corresponding RUN line.
87+ if outcome == "RUN":
88+ started[parsed_name] += 1
89+ continue
90+ 
91+ name = parsed_name
92+ if started[name] <= ended[name]:
O
Oopenubmc-ci-bot25 days ago

💡 建议:前缀恢复可能把结果错配给同名前缀的待完成用例

恢复逻辑用 startswith + max(pending, key=len)(第 90-97 行)匹配待完成用例。当两个用例名存在前缀包含关系(如 Suite.A 与 Suite.AB)时,Suite.AB 的结束标记可能被记到仍待完成的 Suite.A 上,导致计数错配并掩盖真实的未完成用例。

修复建议: 先对待完成用例做精确匹配,仅在所有精确匹配都失败时才回退到前缀匹配,降低错配概率。

likedislike
liuxingsen
25 days ago Comments:
93+ # Runtime log messages can race with GoogleTest's stdout and be
94+ # appended directly after the case name. Recover the outstanding
95+ # case by prefix instead of treating a successful case as missing.
96+ candidates = [candidate for candidate in started if parsed_name.startswith(candidate)]
97+ if not candidates:
98+ continue
99+ name = max(candidates, key=len)
100+ # A repeated result for an already completed longer name must
101+ # not finish an outstanding case whose name is its prefix.
102+ if started[name] <= ended[name]:
103+ continue
104+ ended[name] += 1
105+ counts[outcome.lower()] += 1
106+ counts["run"] = sum(started.values())
107+ counts["incomplete"] = sum((started - ended).values())
108+ return dict(counts)
109+ 
110+ 
111+def read_meson_log(path):
112+ entries = []
113+ with path.open(encoding="utf-8", errors="replace") as stream:
114+ for line_number, line in enumerate(stream, 1):
115+ if line.strip():
116+ try:
117+ entries.append(json.loads(line))
118+ except json.JSONDecodeError as error:
119+ raise ValueError(f"{path}:{line_number}: {error}") from error
120+ return entries
121+ 
122+ 
123+def gtest_count_error(actual, expected):
124+ if expected is None or actual == expected:
125+ return None
126+ return f"GoogleTest case count changed from {expected} to {actual}"
127+ 
128+ 
129+def run_round(build_dir, result_dir, index, expected, timeout_seconds):
130+ base = f"memory_audit_{result_dir.name}_round_{index:03d}"
131+ console = result_dir / f"round_{index:03d}.console.log"
132+ wrapper = " ".join(shlex.quote(part) for part in (sys.executable, str(Path(__file__).resolve()), "--wrapper-exec"))
133+ command = [
134+ "meson", "test", "-C", str(build_dir), "--no-rebuild",
135+ "--num-processes", "1", "--logbase", base, "--wrapper", wrapper,
136+ ]
137+ timed_out = False
138+ interrupted = False
139+ errors = []
140+ with console.open("wb") as stream:
141+ try:
142+ process = subprocess.Popen(command, stdout=stream, stderr=subprocess.STDOUT, start_new_session=True)
143+ except OSError as error:
144+ errors.append(f"cannot start meson test: {error}")
145+ stream.write((errors[-1] + "\n").encode("utf-8", errors="replace"))
146+ returncode = None # No child process started, so there is no exit code.
147+ else:
148+ try:
O
Oopenubmc-ci-bot25 days ago

💡 建议:Popen 启动失败(meson 不在 PATH 等)会未捕获地中断整个压测

run_round 的 subprocess.Popen(第 146 行)没有捕获 FileNotFoundError:若 meson 不在 PATH,Popen 会直接抛出异常并沿 main 的循环向上传播,导致剩余轮次全部中断,且 summary.json 也不会落盘(第 297 行的 write_summary 尚未执行)。这与脚本把每轮结果记录为失败并继续/汇总的整体容错设计不一致——缺程序、超时、中断都已被优雅处理,唯独启动失败会成为硬崩溃。

修复建议: 在 main 循环外层或 run_round 内对 subprocess.Popen 增加 FileNotFoundError 处理,将其记录为本轮失败(errors 中说明 meson 不存在)并照常写 summary,便于在 CI 上报出可读的根因;或在脚本启动处用 shutil.which("meson") 做前置校验(与已有 missing_executables 预检一致)。

likedislike
liuxingsen
25 days ago Comments:
149+ returncode = process.wait(timeout=timeout_seconds)
150+ except (subprocess.TimeoutExpired, KeyboardInterrupt) as error:
151+ timed_out = isinstance(error, subprocess.TimeoutExpired)
152+ interrupted = isinstance(error, KeyboardInterrupt)
153+ try:
154+ os.killpg(process.pid, signal.SIGTERM)
155+ except ProcessLookupError:
156+ pass
157+ try:
158+ process.wait(timeout=5)
159+ except subprocess.TimeoutExpired:
160+ try:
161+ os.killpg(process.pid, signal.SIGKILL)
162+ except ProcessLookupError:
163+ pass
164+ process.wait()
165+ returncode = 124 if timed_out else 128 + getattr(error, "signum", signal.SIGINT)
166+ 
167+ generated = {}
168+ for extension in ("json", "txt", "xml"):
169+ matches = list((build_dir / "meson-logs").glob(f"{base}*.{extension}"))
170+ if len(matches) == 1:
171+ destination = result_dir / f"round_{index:03d}.meson.{extension}"
172+ destination.write_bytes(matches[0].read_bytes())
173+ generated[extension] = destination
174+ 
175+ if "json" not in generated:
176+ errors.append("Meson JSON test log missing or ambiguous")
177+ entries = []
178+ else:
179+ try:
180+ entries = read_meson_log(generated["json"])
181+ except ValueError as error:
182+ errors.append(str(error))
183+ entries = []
184+ 
185+ actual = Counter(entry.get("name", "") for entry in entries)
186+ missing = expected - actual
187+ unexpected = actual - expected
188+ if missing:
189+ errors.append(f"{sum(missing.values())} configured test executables did not report a result")
190+ if unexpected:
191+ errors.append(f"{sum(unexpected.values())} unexpected or duplicate test results")
192+ if timed_out:
193+ errors.append(f"whole round exceeded {timeout_seconds} seconds")
194+ if interrupted:
195+ errors.append("run interrupted")
196+ if returncode:
197+ errors.append(f"meson test exited with {returncode}")
198+ 
199+ meson_results = Counter(entry.get("result", "MISSING") for entry in entries)
200+ gtest = Counter()
201+ suite_errors = []
202+ no_cases = []
203+ incomplete = []
204+ for entry in entries:
205+ name = entry.get("name", "<unnamed>")
206+ result = entry.get("result", "MISSING")
207+ cases = parse_cases(entry.get("stdout", "") + "\n" + entry.get("stderr", ""))
208+ gtest.update(cases)
209+ if result != "OK":
210+ suite_errors.append({"name": name, "result": result, "returncode": entry.get("returncode")})
211+ if result == "OK" and cases.get("run", 0) == 0:
212+ no_cases.append(name)
213+ if cases.get("incomplete", 0):
214+ incomplete.append(name)
215+ if suite_errors:
216+ errors.append(f"{len(suite_errors)} Meson suites failed, skipped, or timed out")
217+ if no_cases:
218+ errors.append(f"{len(no_cases)} successful Meson suites ran no GoogleTest cases")
219+ if incomplete:
220+ errors.append(f"{len(incomplete)} suites left GoogleTest cases without a result")
221+ if gtest["failed"]:
222+ errors.append(f"{gtest['failed']} GoogleTest cases failed")
223+ if gtest["skipped"]:
224+ errors.append(f"{gtest['skipped']} GoogleTest cases skipped")
225+ 
226+ return {
227+ "round": index, "passed": not errors, "interrupted": interrupted,
228+ "meson_exit_code": returncode,
229+ "executables_expected": sum(expected.values()),
230+ "executables_reported": len(entries),
231+ "meson_results": dict(meson_results),
232+ "gtest_cases": {key: gtest[key] for key in ("run", "ok", "failed", "skipped", "incomplete")},
233+ "suite_errors": suite_errors, "suites_without_cases": no_cases,
234+ "suites_with_incomplete_cases": incomplete,
235+ "missing_suites": list(missing.elements()),
236+ "unexpected_suites": list(unexpected.elements()),
237+ "errors": errors,
238+ "logs": {"console": str(console), **{key: str(path) for key, path in generated.items()}},
239+ }
240+ 
241+ 
242+def write_summary(path, summary):
243+ path.write_text(json.dumps(summary, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
244+ 
245+ 
246+def interrupt_on_term(signum, _frame):
247+ raise SignalInterrupt(signum)
248+ 
249+ 
250+def main():
251+ parser = argparse.ArgumentParser(description=__doc__)
252+ parser.add_argument("--build-dir", type=Path, default=ROOT / "builddir_memory_audit")
253+ parser.add_argument("--rounds", type=int, default=20)
254+ parser.add_argument("--round-timeout", type=int, default=7200, metavar="SECONDS")
255+ parser.add_argument("--log-root", type=Path, default=ROOT / "test_failure_logs/memory-audit-20260927")
256+ args = parser.parse_args()
257+ signal.signal(signal.SIGTERM, interrupt_on_term)
258+ if args.rounds < 1 or args.round_timeout < 1:
259+ parser.error("--rounds and --round-timeout must be positive")
260+ build_dir = args.build_dir.resolve()
261+ intro = build_dir / "meson-info/intro-tests.json"
262+ if not intro.is_file():
263+ parser.error(f"configured Meson tests not found: {intro}")
264+ tests = json.loads(intro.read_text(encoding="utf-8"))
265+ if not tests:
266+ parser.error(f"no tests registered in {build_dir}")
267+ # Only cmd[0] is the executable; later absolute arguments may be output
268+ # paths or other values that do not need to exist before running the test.
269+ missing_executables = [str(command) for test in tests for command in test["cmd"][:1]
O
Oopenubmc-ci-bot25 days ago

💡 建议:missing_executables 只检查 cmd[:1],预检范围偏窄

这里只对每条测试 cmd 的第一个参数做 is_file() 存在性检查。若主程序存在但后续某个绝对路径参数(如辅助解释器/脚本)缺失,或二进制以相对路径引用(此处 is_absolute() 直接跳过),预检不会发现,问题会推迟到轮次执行中才暴露,反而更难定位。

修复建议: 遍历 cmd 中所有绝对路径参数做存在性校验(或至少在注释中说明仅检查首个可执行文件)。

likedislike
liuxingsen
25 days ago Comments:
270+ if Path(command).is_absolute() and not Path(command).is_file()]
271+ if missing_executables:
272+ parser.error(f"build is incomplete: {len(missing_executables)} test executables missing; first: {missing_executables[0]}")
273+ 
274+ result_dir = args.log_root.resolve() / datetime.now(timezone.utc).strftime("run-%Y%m%dT%H%M%SZ")
275+ result_dir = result_dir.with_name(f"{result_dir.name}-{os.getpid()}")
276+ result_dir.mkdir(parents=True, exist_ok=False)
277+ expected = Counter(pretty_name(test) for test in tests)
278+ summary = {
279+ "build_dir": str(build_dir), "result_dir": str(result_dir),
280+ "requested_rounds": args.rounds, "configured_executables": len(tests),
281+ "command": ["meson", "test", "-C", str(build_dir), "--no-rebuild", "--num-processes", "1"],
282+ "rounds": [],
283+ }
284+ print(f"Running {len(tests)} Meson test executables serially for {args.rounds} rounds; logs: {result_dir}", flush=True)
285+ expected_gtest_cases = None
286+ for index in range(1, args.rounds + 1):
287+ outcome = run_round(build_dir, result_dir, index, expected, args.round_timeout)
288+ cases = outcome["gtest_cases"]
289+ count_error = gtest_count_error(cases["run"], expected_gtest_cases)
290+ if count_error:
291+ outcome["errors"].append(count_error)
292+ outcome["passed"] = False
293+ elif outcome["passed"] and expected_gtest_cases is None:
294+ expected_gtest_cases = cases["run"]
295+ summary["gtest_cases_per_round"] = expected_gtest_cases
296+ summary["rounds"].append(outcome)
297+ write_summary(result_dir / "summary.json", summary)
298+ print(f"Round {index}/{args.rounds}: {'PASS' if outcome['passed'] else 'FAIL'}; "
299+ f"suites {outcome['executables_reported']}/{len(tests)}, "
300+ f"gtest passed={cases['ok']} failed={cases['failed']} skipped={cases['skipped']} "
301+ f"incomplete={cases['incomplete']}; "
302+ f"Meson results={outcome['meson_results']}", flush=True)
303+ if not outcome["passed"]:
304+ break
305+ failures = sum(not item["passed"] for item in summary["rounds"])
306+ summary["passed"] = failures == 0 and len(summary["rounds"]) == args.rounds
307+ summary["failed_rounds"] = failures
308+ write_summary(result_dir / "summary.json", summary)
309+ print(f"Completed {len(summary['rounds'])}/{args.rounds} rounds; failures={failures}; {result_dir / 'summary.json'}", flush=True)
310+ return 0 if summary["passed"] else 1
311+ 
312+ 
313+if __name__ == "__main__":
314+ if len(sys.argv) > 1 and sys.argv[1] == "--wrapper-exec":
315+ wrapper_exec(sys.argv[2:])
316+ else:
317+ sys.exit(main())
@@ -0,0 +1,263 @@
1+#!/usr/bin/env python3
2+# Copyright (c) 2026 Huawei Technologies Co., Ltd.
3+# openUBMC is licensed under Mulan PSL v2.
4+# You can use this software according to the terms and conditions of the Mulan PSL v2.
5+# You may obtain a copy of Mulan PSL v2 at:
6+# http://license.coscl.org.cn/MulanPSL2
7+# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
8+# EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT,
9+# MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
10+# See the Mulan PSL v2 for more details.
11+ 
12+from collections import Counter
13+from pathlib import Path
14+import json
15+import os
16+import shutil
17+import signal
18+import subprocess
19+import sys
20+import tempfile
21+import unittest
22+from unittest import mock
23+ 
24+import run_memory_audit_stress as stress
25+ 
26+ 
27+class ParseCasesTest(unittest.TestCase):
28+ def test_accepts_run_marker_interleaved_after_runtime_log(self):
29+ output = """\
30+DEBUG thread_pool.cpp:494 operator() | io thread 1 started[ RUN ] MucseInterfaceTest.CoolingUpdatesSignedTemperatureBySmbus
31+[ OK ] MucseInterfaceTest.CoolingUpdatesSignedTemperatureBySmbus (0 ms)
32+"""
33+ 
34+ self.assertEqual(
35+ stress.parse_cases(output),
36+ {"run": 1, "ok": 1, "incomplete": 0},
37+ )
38+ 
39+ def test_accepts_log_text_interleaved_after_result_name(self):
40+ output = """\
41+[ RUN ] MucseInterfaceTest.CoolingUpdatesTemperatureByNcsi
42+[ OK ] MucseInterfaceTest.CoolingUpdatesTemperatureByNcsiDEBUG runtime started (1 ms)
43+"""
44+ 
45+ self.assertEqual(
46+ stress.parse_cases(output),
47+ {"run": 1, "ok": 1, "incomplete": 0},
48+ )
49+ 
50+ def test_ignores_repeated_failure_summary_without_run(self):
51+ output = """\
52+[ FAILED ] Suite.FailedCase
53+"""
54+ 
55+ self.assertEqual(stress.parse_cases(output), {"run": 0, "incomplete": 0})
56+ 
57+ def test_accepts_multiple_markers_on_one_line(self):
58+ output = """\
59+[ RUN ] Suite.First[ OK ] Suite.First (0 ms)[ RUN ] Suite.Second
60+[ OK ] Suite.Second (0 ms)
61+"""
62+ 
63+ self.assertEqual(
64+ stress.parse_cases(output),
65+ {"run": 2, "ok": 2, "incomplete": 0},
66+ )
67+ 
68+ 
69+ def test_completed_long_name_does_not_finish_pending_short_name(self):
70+ output = """\
71+[ RUN ] Suite.A
72+[ RUN ] Suite.AB
73+[ OK ] Suite.AB (0 ms)
74+[ OK ] Suite.AB (0 ms)
75+"""
76+ for result_name in ("Suite.AB", "Suite.ABDEBUG"):
77+ with self.subTest(result_name=result_name):
78+ observed = output.rsplit("[ OK ]", 1)[0] + f"[ OK ] {result_name} (0 ms)\n"
79+ self.assertEqual(stress.parse_cases(observed), {"run": 2, "ok": 1, "incomplete": 1})
80+ 
81+ def test_exact_pending_name_wins_over_its_shorter_prefix(self):
82+ output = """\
83+[ RUN ] Suite.A
84+[ RUN ] Suite.AB
85+[ OK ] Suite.AB (0 ms)
86+"""
87+ self.assertEqual(stress.parse_cases(output), {"run": 2, "ok": 1, "incomplete": 1})
88+ 
89+ 
90+class ProcessBoundaryTest(unittest.TestCase):
91+ def run_interrupted_round(self, error, kill_required=False):
92+ with tempfile.TemporaryDirectory(prefix="stress-process-boundary-") as tmp:
93+ root = Path(tmp)
94+ build = root / "build"
95+ results = root / "results"
96+ build.mkdir()
97+ results.mkdir()
98+ process = mock.Mock(pid=123456)
99+ if kill_required:
100+ process.wait.side_effect = [error, subprocess.TimeoutExpired("meson", 5), -9]
101+ else:
102+ process.wait.side_effect = [error, -15]
103+ with mock.patch.object(stress.subprocess, "Popen", return_value=process), \
104+ mock.patch.object(stress.os, "killpg") as killpg:
105+ outcome = stress.run_round(build, results, 1, Counter(), 2)
106+ self.assertFalse(outcome["passed"])
107+ self.assertEqual(killpg.call_args_list[0], mock.call(process.pid, signal.SIGTERM))
108+ if kill_required:
109+ self.assertEqual(killpg.call_args_list[1], mock.call(process.pid, signal.SIGKILL))
110+ return outcome
111+ 
112+ def test_whole_round_timeout_is_distinct_from_interrupt(self):
113+ outcome = self.run_interrupted_round(subprocess.TimeoutExpired("meson", 2), kill_required=True)
114+ self.assertEqual(outcome["meson_exit_code"], 124)
115+ self.assertFalse(outcome["interrupted"])
116+ self.assertIn("whole round exceeded 2 seconds", outcome["errors"])
117+ 
118+ def test_keyboard_interrupt_reports_sigint_status(self):
119+ outcome = self.run_interrupted_round(KeyboardInterrupt())
120+ self.assertEqual(outcome["meson_exit_code"], 128 + signal.SIGINT)
121+ self.assertTrue(outcome["interrupted"])
122+ self.assertIn("run interrupted", outcome["errors"])
123+ 
124+ def test_sigterm_handler_preserves_its_signal_status(self):
125+ try:
126+ stress.interrupt_on_term(signal.SIGTERM, None)
127+ except KeyboardInterrupt as error:
128+ outcome = self.run_interrupted_round(error)
129+ self.assertEqual(outcome["meson_exit_code"], 128 + signal.SIGTERM)
130+ self.assertTrue(outcome["interrupted"])
131+ self.assertNotIn("whole round exceeded 2 seconds", outcome["errors"])
132+ 
133+ 
134+class LaunchFailureTest(unittest.TestCase):
135+ def check_launch_failure(self, nonexecutable_meson):
136+ with tempfile.TemporaryDirectory(prefix="stress-launch-failure-") as tmp:
137+ root = Path(tmp)
138+ build = root / "build"
139+ intro = build / "meson-info/intro-tests.json"
140+ intro.parent.mkdir(parents=True)
141+ intro.write_text(json.dumps([{
142+ "name": "fixture", "suite": ["stress-fixture"],
143+ "cmd": [sys.executable],
144+ }]))
145+ search_path = root / "bin"
146+ search_path.mkdir()
147+ if nonexecutable_meson:
148+ meson = search_path / "meson"
149+ meson.write_text("#!/bin/sh\nexit 0\n")
150+ meson.chmod(0o600)
151+ logs = root / "logs"
152+ result = subprocess.run(
153+ [sys.executable, "-B", str(Path(stress.__file__).resolve()),
154+ "--build-dir", str(build), "--rounds", "2", "--log-root", str(logs)],
155+ env={**os.environ, "PATH": str(search_path)},
156+ text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
157+ timeout=10,
158+ )
159+ self.assertEqual(result.returncode, 1, result.stdout)
160+ summaries = list(logs.glob("*/summary.json"))
161+ self.assertEqual(len(summaries), 1, result.stdout)
162+ summary = json.loads(summaries[0].read_text())
163+ self.assertFalse(summary["passed"])
164+ self.assertEqual(summary["requested_rounds"], 2)
165+ self.assertEqual(summary["failed_rounds"], 1)
166+ self.assertEqual(len(summary["rounds"]), 1)
167+ outcome = summary["rounds"][0]
168+ self.assertIsNone(outcome["meson_exit_code"])
169+ self.assertEqual(outcome["executables_reported"], 0)
170+ self.assertEqual(outcome["missing_suites"], ["stress-fixture:fixture"])
171+ self.assertTrue(any("cannot start meson test" in error for error in outcome["errors"]), outcome)
172+ self.assertIn("cannot start meson test", Path(outcome["logs"]["console"]).read_text())
173+ self.assertNotIn("Traceback", result.stdout)
174+ 
175+ def test_missing_meson_writes_failed_round_summary(self):
176+ self.check_launch_failure(nonexecutable_meson=False)
177+ 
178+ def test_nonexecutable_meson_writes_failed_round_summary(self):
179+ self.check_launch_failure(nonexecutable_meson=True)
180+ 
181+ 
182+class LogAndWrapperTest(unittest.TestCase):
183+ def test_invalid_json_identifies_file_and_line(self):
184+ with tempfile.TemporaryDirectory(prefix="stress-log-") as tmp:
185+ path = Path(tmp) / "testlog.json"
186+ path.write_text('{"result": "OK"}\ninvalid JSON\n')
187+ with self.assertRaises(ValueError) as caught:
188+ stress.read_meson_log(path)
189+ self.assertIn(f"{path}:2:", str(caught.exception))
190+ 
191+ def test_wrapper_orders_existing_runtime_paths_and_removes_duplicates(self):
192+ with tempfile.TemporaryDirectory(prefix="stress-wrapper-") as tmp:
193+ root = Path(tmp)
194+ prebuilt = root / ".libmcpp_prebuilt/src"
195+ lua = root / "temp/usr/lib64"
196+ support = root / ".lua_runtime_libs"
197+ existing = root / "test-libs"
198+ for path in (prebuilt, lua, support, existing):
199+ path.mkdir(parents=True)
200+ for name, expected in (
201+ ("test_driver", [prebuilt, support, lua, existing]),
202+ ("test_lua_plugin_fixture", [lua, prebuilt, support, existing]),
203+ ):
204+ with self.subTest(executable=name), mock.patch.object(stress, "ROOT", root), \
205+ mock.patch.dict(os.environ, {"LD_LIBRARY_PATH": f"{existing}:{prebuilt}:{root / 'missing'}"}, clear=True), \
206+ mock.patch.object(stress.os, "execvpe") as execute:
207+ stress.wrapper_exec([name, "arg"])
208+ self.assertEqual(os.environ["LD_LIBRARY_PATH"], ":".join(map(str, expected)))
209+ self.assertEqual(execute.call_args.args[:2], (name, [name, "arg"]))
210+ 
211+ 
212+class GtestCountErrorTest(unittest.TestCase):
213+ def test_accepts_first_and_matching_round_counts(self):
214+ self.assertIsNone(stress.gtest_count_error(11574, None))
215+ self.assertIsNone(stress.gtest_count_error(11574, 11574))
216+ 
217+ def test_rejects_changed_round_count(self):
218+ self.assertEqual(
219+ stress.gtest_count_error(11573, 11574),
220+ "GoogleTest case count changed from 11574 to 11573",
221+ )
222+ 
223+ 
224+@unittest.skipUnless(shutil.which("meson"), "requires Meson")
225+class RoundResultTest(unittest.TestCase):
226+ def run_fixture(self, skipped):
227+ with tempfile.TemporaryDirectory(prefix="stress-round-result-") as tmp:
228+ root = Path(tmp)
229+ (root / "meson.build").write_text(
230+ "project('stress-fixture')\n"
231+ "python = import('python').find_installation('python3')\n"
232+ "test('fixture', python, args: files('fixture.py'))\n"
233+ )
234+ output = "[ RUN ] Suite.First\n[ OK ] Suite.First (0 ms)\n"
235+ if skipped:
236+ output += "[ RUN ] Suite.Second\n[ SKIPPED ] Suite.Second (0 ms)\n"
237+ (root / "fixture.py").write_text(f"print({output!r})\n")
238+ build = root / "build"
239+ setup = subprocess.run(
240+ ["meson", "setup", str(build), str(root)],
241+ text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
242+ )
243+ self.assertEqual(setup.returncode, 0, setup.stdout)
244+ results = root / "results"
245+ results.mkdir()
246+ return stress.run_round(
247+ build, results, 1, Counter({"stress-fixture:fixture": 1}), 30,
248+ )
249+ 
250+ def test_accepts_complete_successful_round(self):
251+ outcome = self.run_fixture(skipped=False)
252+ self.assertTrue(outcome["passed"], outcome)
253+ self.assertEqual(outcome["gtest_cases"]["ok"], 1)
254+ 
255+ def test_rejects_skipped_cases_even_when_meson_reports_success(self):
256+ outcome = self.run_fixture(skipped=True)
257+ self.assertEqual(outcome["meson_exit_code"], 0, outcome)
258+ self.assertEqual(outcome["gtest_cases"]["skipped"], 1, outcome)
259+ self.assertFalse(outcome["passed"], outcome)
260+ 
261+ 
O
Oopenubmc-ci-bot25 days ago

💡 建议:补充超时/中断与 wrapper_exec/read_meson_log 的回归用例

现有 8 个用例覆盖了 parse_cases、gtest_count_error 与真实 Meson 轮次(含跳过判定),但未覆盖最易出错的几处:run_round 的整轮超时与 SIGTERM 中断分支(第 137-152 行)、wrapper_exec 的 LD_LIBRARY_PATH 排序/预构建目录回退、以及 read_meson_log 对损坏 JSON 行的报错路径。

修复建议: 为以上分支补充用例,防止后续改动破坏这些关键路径。

likedislike
liuxingsen
25 days ago Comments:
262+if __name__ == "__main__":
263+ unittest.main()
@@ -84,6 +84,151 @@ ASAN 检查的分配边界,因此不能把此次行为回归写成已复现分
84 84 
85---85---
86 86 
87+## 2026-10-14:内存边界与异步生命周期修复拆分
88+ 
89+原 [!1307](https://gitcode.com/openUBMC/component_drivers/merge_requests/1307) 汇总了协议边界、对象寿命、测试时序和本地构建工具等不同问题。历史压力日志出现过间歇性崩溃,源码检查又发现若干可独立修复的越界及异步收尾路径。不能据此把每条风险都认定为某次历史崩溃的已证实根因,因此本轮按问题类型拆成十个独立 PR,让代码、回归和证据范围可以分别核对。
90+ 
91+下表是本轮修复范围索引;PR 中的源码与提交对应的验证报告才是逐项验收依据。各分支最初独立建立在同一主线基线上,后续主线变化需要正常整合。
92+ 
93+| 主题 | 问题触发与关键修复 | 对应 PR |
94+| --- | --- | --- |
95+| 1. NCSI 回复与字段复制 | 短回复、设备声明的过长 MAC/SN/PN、分帧日志长度与真实 payload 不一致,可能使读取或复制越界。先校验最小头、固定字段容量、累计偏移和实际接收量,再取字段;检查复制结果,保留设备不在位短回复及旧日志命令回退。异常输入会返回失败,属于运行时代码修复。 | [!1326](https://gitcode.com/openUBMC/component_drivers/merge_requests/1326) |
96+| 2. 总线、芯片与 SMC 缓冲区 | CAN/I2C 写入长度超过输入向量或缓冲区、芯片短回复、非法 offset 宽度以及 SMC 帧长不一致,可能越界或触发超宽移位。补齐发送/接收边界,小端寄存器按字节解码;保留主线 LocalBus 已提取位域的语义,避免二次掩码。非法请求和短回复的错误返回是实际行为变化。 | [!1335](https://gitcode.com/openUBMC/component_drivers/merge_requests/1335) |
97+| 3. CSR1 调度任务与宿主引用 | 空闲注册也持有宿主,会阻止宿主进入析构注销;回调内注销又可能等待自身。空闲状态保存弱引用,运行期间才持有宿主;重注册复用仍运行的状态,防止同一任务重入。Aggregate 仅为托管宿主保存弱引用,避开最终释放及未托管对象的失效计数访问。 | [!1329](https://gitcode.com/openUBMC/component_drivers/merge_requests/1329) |
98+| 4. CSR1 内嵌接口定时器 | 总线、芯片和 Scanner 的接口存储结束后,晚释放的 timer 仍可能回访原父接口;活动回调也可能继续使用接口成员。timer 改为无父对象、成员持有并显式绑定执行器;析构/替换先停止任务,保留单次 timer 句柄用于后续收尾。 | [!1330](https://gitcode.com/openUBMC/component_drivers/merge_requests/1330) |
99+| 5. 网卡定时器启停 | Broadcom 重复启动时可能先覆盖协议或包编号,而旧回调仍在使用接口;Hisi 等路径也需明确停止与成员销毁顺序。启动前停止旧任务,再更新依赖并建立新 timer;配套夹具等待真实完成事件。它包含正式网卡启停行为修复,不只是延长测试等待时间。 | [!1331](https://gitcode.com/openUBMC/component_drivers/merge_requests/1331) |
100+| 6. NVMe 遥测与升级收尾 | Huawei/Samsung 遥测回调及升级线程使用宿主或兄弟接口;先拆成员再收尾可能访问失效存储。设备析构入口先停任务,Huawei 升级线程在依赖成员销毁前 join;timer 显式使用宿主或接口配置的执行器。 | [!1332](https://gitcode.com/openUBMC/component_drivers/merge_requests/1332) |
101+| 7. PRBS 打包字段与 SMART 数值 | 直接把打包回复地址当 `uint32_t*` 读取可能未对齐;128 位 PowerOnHours 高半部移入 `uint64_t` 会发生移位数不小于64的未定义行为。PRBS 经 `memcpy` 取对齐局部值再转小端;SMART 分别解码高低64位,以 `ldexp(high, 64) + low` 形成既有 `double` 属性。浮点舍入仍存在,不能宣称精确保存全部128位整数。 | [!1333](https://gitcode.com/openUBMC/component_drivers/merge_requests/1333) |
102+| 8. 测试夹具与异步等待 | Scanner、网卡、DERA、RAID 等夹具依赖固定 sleep 或未清理回调,可能在异步工作完成前释放对象。使用完成通知和明确收尾,修正测试释放顺序、恢复相关覆盖并补回 SMBus 帧事务测试。本主题只改测试。 | [!1334](https://gitcode.com/openUBMC/component_drivers/merge_requests/1334) |
103+| 9. 串行压力统计与说明 | 单看 Meson 退出成功不能证明所有配置程序和 gtest 用例完整执行。保存逐轮日志与 summary,严格拒绝跳过、缺失和未完成;区分超时与中断,避免重复长用例名结束标记误补短用例名。工具不安装 sanitizer 抑制,使用方法见下一节。 | [!1327](https://gitcode.com/openUBMC/component_drivers/merge_requests/1327) |
104+| 10. 本地 Lua 工具链选择 | 本地 `smart_build.sh` 只看到 lua.h 就可能采用不完整 temp 工具链,切换来源还可能残留可选头或错误复用缓存。统一检查四个核心头,成对选择头/库,并将兼容头纳入软链接清理和缓存指纹。正式 Bingo/Conan recipe 与 CI 构建入口不调用该本地脚本。 | [!1328](https://gitcode.com/openUBMC/component_drivers/merge_requests/1328) |
105+ 
106+### 本轮版本与 CHANGELOG 约定
107+ 
108+本轮由主题3(!1329)和主题6(!1332)承担服务版本及 CHANGELOG 更新,保留各自既有的 `1.2.530 → 1.2.531` 条目;其余八项恢复为各自主线基线的版本与 CHANGELOG,不再逐项升版。每个 PR 的后续整改也不重复加版本条目,合入时按届时主线正常整合。这是本轮拆分约定,不是“所有运行时代码修复都无需版本记录”的通用规则。
109+ 
110+主题1、2、4、5、7仍会改变异常输入处理、数值解码或启停/销毁时序;没有单独升版不等于只有测试变化。主题8、9为测试和辅助统计工具,主题10为独立本地构建入口,其变化不修改正式服务接口或正式构建 recipe。
111+ 
112+### 从 C 的资源清理顺序理解生命周期修改
113+ 
114+以下片段用于解释上述 PR,不能理解为本文所在分支已经包含其他九项源码。
115+ 
116+以 `drivers/csr1/bus/common/interface/i_bus.cpp` 为例,修改前后创建 timer 的关键代码是:
117+ 
118+```cpp
119+// 修改前:接口地址同时作为 timer 的父对象
120+m_access_timer = mc::make_shared<mc::timer>(this);
121+ 
122+// 修改后:timer 由成员持有,执行器另行绑定
123+m_access_timer = mc::make_shared<mc::timer>();
124+auto* owner = get_owner();
125+m_access_timer->bind_executor(owner != nullptr ? owner->executor() : executor());
126+ 
127+// 修改后:先执行析构函数体,再由 C++ 自动销毁成员
128+Interface_Bus::~Interface_Bus()
129+{
130+ stop_access_timer();
131+}
132+```
133+ 
134+`this` 是当前接口地址,可类比 C 回调的 `void *ctx`。接口可能是 `struct card` 中按值内嵌的一个字段,timer 的父指针不能在这块存储结束后再使用。`mc::make_shared<mc::timer>()` 的尖括号指定创建的类型,返回由项目 `mc::shared_ptr` 管理的 timer 句柄;省略 `this` 切断的是父对象关系。`auto* owner` 仍是普通借用指针,只由编译器推导类型;条件表达式选择回调被投递到哪个执行器,不会自动保护所有成员并发访问。
135+ 
136+回调中的 `[this]` 是 lambda 捕获列表,只保存接口地址,类似 C 的函数指针加 ctx;保存地址不会延长接口寿命。因此还必须在 ctx 失效前停止活动工作。`~Interface_Bus()` 是析构函数,会在对象被销毁时执行;C++ 在其函数体执行完以后才自动拆除该类的成员,故这里有机会先做相当于 C `stop_and_wait(ctx)` 的收尾。
137+ 
138+主题5在更新协议对象和包编号前停止旧 timer,主题6在宿主其他成员销毁前停止遥测,都是对这一顺序的具体应用:
139+ 
140+```text
141+风险路径 A:旧回调仍用 ctx → ctx 的依赖被覆盖/销毁 → 回调继续读取失效状态
142+修复顺序 A:停止旧任务 → 等待已进入的业务回调退出 → 再更新或销毁依赖
143+风险路径 B:接口存储结束 → 外部仍持有的 timer 晚释放 → 沿旧父关系回访接口
144+修复路径 B:timer 创建时不建立该父关系 → 晚释放不再沿这条关系访问接口
145+```
146+ 
147+图示是需要切断的路径,不表示本轮已重现所有历史崩溃。依据本轮核对的 libmcpp `10829fb9`,`stop()` 取消旧代际并等待其他执行线程上已进入的业务回调;从同一 timer 的回调内停止会避开等待自身。它不保证所有排队闭包已执行,也不保证 timer 已销毁。成员 `reset()` 仅释放当前那份引用,执行器或其他持有者仍可能让 timer 继续存在。无父 timer、智能指针、绑定执行器都不能单独代替收尾,更不能保证任意兄弟成员销毁顺序安全。实际依赖包仍须核对,源码阅读不等于缓存二进制来源已经证明。
148+ 
149+主题3在 `drivers/csr1/chip/common/interface/i_aggregate.cpp::set_owner_object()` 中进一步处理未托管对象:
150+ 
151+```cpp
152+// 原汇总实现
153+if (owner != nullptr) {
154+ m_owner_weak = owner->weak_from_this();
155+}
156+// 拆分修订后
157+if (owner != nullptr && owner->is_managed()) {
158+ m_owner_weak = owner->weak_from_this();
159+}
160+```
161+ 
162+这里的 `mc::weak_ptr` 是项目封装,其弱计数位于宿主内部,不能套用标准库 `std::weak_ptr` 的内部实现。可把它理解成“借用 ctx,同时保存一项将来仍会访问 ctx 内计数的清理动作”:即使尝试取得强引用的 `lock()` 返回空,稍后释放这个弱引用仍可能需要访问计数。未托管栈对象离开作用域后,这种清理就不安全,因此先用 `is_managed()` 限制适用对象。运行时 `lock()` 成功获得的强引用只延长宿主存活时间,不提供互斥保护。
163+ 
164+调度器的弱引用让空闲注册不再阻止宿主销毁;运行期间持有宿主则保护尚未完成的工作。相当于 C 中在提交异步操作前增加 ctx 引用计数,完成后再减回去。若操作永不完成,不能仅因超时就释放仍可能被访问的 ctx。`wait_for_tasks()` 还会跳过当前同步执行状态,避免等待自身;从当前回调内注销返回,并不代表当前回调已经结束。 允许异步注销后保留运行状态的前提是任务受托管且其寿命完全绑定宿主(`lifetime_bound_to_owner()`),不能推广为任意异步回调都可安全注销;可替换子任务和未托管任务从任务执行上下文之外注销时仍需等待。
165+ 
166+主题6的 `drivers/pcie_nvme/huawei/interface/upgrade.cpp` 将等待升级线程提取为:
167+ 
168+```cpp
169+void NVMe_Upgrade::wait_for_completion()
170+{
171+ if (m_upgrade_thread && m_upgrade_thread->joinable()) {
172+ m_upgrade_thread->join();
173+ }
174+}
175+```
176+ 
177+这与 C 中在释放线程使用的 ctx 之前调用 `pthread_join()` 的目的相同。`joinable()` 表示该线程句柄仍可连接,不等于线程已经结束;`join()` 可能等待。设备析构入口先调用这条收尾,再进入成员销毁;只依赖升级接口自身晚些时候析构,无法保护那些可能更早被销毁的兄弟成员。此处也不承诺任意执行上下文中的等待都不会阻塞。
178+ 
179+### 本轮证据如何核对
180+ 
181+- 每项 PR 的测试结果必须标明实际执行的提交 SHA、构建配置及依赖;改动后若复用旧结果,写明原 SHA 和源码/文件等同性依据。README 不固定一组会随检视整改变化的“当前十项总通过数”。
182+- 本轮 C++ 定向回归使用 Linux 宿主构建、启用断言,部分依赖复用既有 libmcpp 缓存。源码 checkout `10829fb9` 不足以证明缓存二进制由该源码构建;未补齐来源时,不宣称重编译了最新依赖。
183+- 定向回归通过、机器人 CI 的成功标签和原50轮记录是不同证据。未执行的目标板、完整产品、完整多轮压力或 sanitizer 插桩验证必须明确列出;历史 Samsung 第44轮间歇性 SIGSEGV 不能凭一次定向通过就宣称闭环。
184+- `DISABLED_` 未执行用例不计入通过数,跳过、禁用及过滤条件要分别披露。压力工具不对白名单跳过放行,也不以统计容差掩盖缺失用例。
185+- 缓冲区/数值修复应核对正常值、边界值、短帧、错误长度和异常返回;生命周期修复应核对重复启停、在途销毁、外部保留 timer、最终释放和实际执行器配置。使用 mock 的范围与真实硬件/传输层异步路径要分开说明。
186+ 
187+---
188+ 
189+## 串行多轮 gtest 压力测试
190+ 
191+`scripts/run_memory_audit_stress.py` 对一个已经构建完成的 Meson 测试目录逐轮串行执行,并保存每轮控制台输出、Meson JSON/TXT/XML 日志及 `summary.json`。脚本不负责构建,也不会因发现源码变化自动重编译。
192+ 
193+在仓库根目录执行,下例的 `builddir` 应替换为本次已经配置并编译完成的测试目录:
194+ 
195+```bash
196+ninja -C builddir
197+python3 scripts/test_run_memory_audit_stress.py
198+python3 scripts/run_memory_audit_stress.py \
199+ --build-dir builddir \
200+ --rounds 50 \
201+ --round-timeout 7200 \
202+ --log-root test_failure_logs/memory-audit
203+```
204+ 
205+需要 Python 3、Meson,以及该构建所需的运行时依赖。`--round-timeout` 是整轮超时秒数,每个测试程序仍受 Meson 自身配置的超时限制。日志目录带 UTC 时间和进程号;首个失败轮次会停止后续轮次,脚本以非零状态退出。超时或中断时,脚本向本轮 Meson 进程组发送终止信号,必要时再强制终止。
206+ 
207+### 计数与通过条件
208+ 
209+- 从 `meson-info/intro-tests.json` 读取当前配置的程序清单,拒绝缺失、重复或意外的程序结果。
210+- Meson 结果必须全部为 `OK`;成功程序也必须至少执行一个 gtest 用例。
211+- gtest 的失败、跳过和仅开始未结束的用例均导致整轮失败。后台日志与 gtest 标记交错时,统计器按待完成用例名称匹配结果,避免将日志交错误记为用例未完成。
212+- 首个通过轮次确定实际执行用例数,后续轮次必须一致;全部请求轮次通过后,总结中的 `passed` 才为 `true`。
213+ 
214+该计数器不主动启用 `DISABLED_` 用例,也不会枚举全部 gtest 用例来校验环境中的过滤条件。多轮数量一致不能证明所有源码用例均已执行。提交测试报告时,应记录构建选项、过滤条件、实际执行数量、跳过和禁用范围。
215+ 
216+### 构建与运行证据
217+ 
218+运行前记录源码提交、构建选项和实际加载依赖。包装器会优先加入仓库中的 `.libmcpp_prebuilt` 库目录,并按测试名称调整 `temp/usr/lib64`、`.lua_runtime_libs` 与已有 `LD_LIBRARY_PATH` 的顺序;这些目录不存在时跳过。需先确认其库与当前构建匹配,不能只凭源码提交认定预编译库来自同一版本。
219+ 
220+本脚本不创建私有 `/tmp`、共享内存、D-Bus 或 Linux 命名空间;有隔离需求时,应通过自己的 CI 或测试环境提供隔离。不要停止其他任务的 D-Bus 或清理其他构建目录。脚本启动的进程组之外的后台服务应由创建它的测试环境负责回收。
221+ 
222+多轮通过只说明该构建和依赖下的测试结果,不等同于 ASan、UBSan 或其他内存检测通过。需在编译、链接阶段启用相应插桩,并保存完整诊断;仅设置 sanitizer 环境变量不会为现有二进制补上插桩。本工具不配置默认 sanitizer 抑制,也不随附覆盖整个外部依赖头文件的 `vptr` 抑制。外部依赖报错应保留原始证据并单独定位。
223+ 
224+### 历史记录与回归范围
225+ 
226+2026-09-27 的历史记录(报告提交 `8bdd569f`)记录了旧构建 50 轮通过,每轮 207 个测试程序、11,566 个 gtest 用例,总计 578,300 次用例运行。该构建未启用 sanitizer 插桩,且之后仍有源码和依赖变化;这些数字不作为当前分支或拆分 PR 的验证结果。历史机器上的私有隔离脚本和日志目录不属于可直接复用的仓库命令。
227+ 
228+历史审计涉及协议回复和写入缓冲区边界、CSR1 调度及宿主引用、嵌入接口定时器的停止和销毁顺序、NVMe 升级线程收尾,以及 SMART 128 位计数转换。对应修复与回归由各自 PR 独立说明;提交报告应区分已运行的定向回归、新的多轮结果和仍未完成的验证。
229+ 
230+---
231+ 
87## 构建与 tests 编译优化纪要232## 构建与 tests 编译优化纪要
88 233 
89> **更新日期:2026-04-02**234> **更新日期:2026-04-02**