diff --git a/utils/ci/parse_tsan_reports.py b/utils/ci/parse_tsan_reports.py new file mode 100755 index 00000000000..95fa43674b3 --- /dev/null +++ b/utils/ci/parse_tsan_reports.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +""" + Copyright 2025-2026 Hewlett Packard Enterprise Development LP + All rights reserved. + + SPDX-License-Identifier: BSD-2-Clause-Patent + + Parse ThreadSanitizer log files produced during CI unit test runs. + + TSan report format: + WARNING: ThreadSanitizer: data race (pid=12345) + Write of size 4 at 0xADDR by thread T1: + #0 func_name /path/file.c:42 (binary+0xaddr) + Previous read of size 4 at 0xADDR by thread T2: + #0 other_func /path/file.c:7 (binary+0xaddr) + SUMMARY: ThreadSanitizer: data race ... + + Outputs: + * SARIF 2.1.0 file -> uploaded to the Security / Code scanning tab + * Markdown summary -> appended to $GITHUB_STEP_SUMMARY + + Invocation (from .github/workflows/unit-test-template.yml, called by unit-testing.yml): + + python3 utils/ci/parse_tsan_reports.py \ + --report-dir sanitizer-logs \ + --source-root "$(pwd)" \ + --sarif-out test-results/tsan.sarif \ + --summary-out test-results/tsan_summary.md + + TSan must be started with: + TSAN_OPTIONS="log_path=/tsan:exitcode=42:second_deadlock_stack=1" + so that each process writes its own tsan. file into the shared + sanitizer-logs/ directory. +""" + +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +from sanitizer_report_base import FILE_LINE_COL, StackFrame, build_sarif_doc, build_summary_md +from sanitizer_report_base import collect_reports as _collect_reports +from sanitizer_report_base import (get_args, main_runner, report_heading, resolve_paths_threaded, + sarif_location) + +# ── Data structures ─────────────────────────────────────────────────────────── + + +@dataclass +class TsanThread: + """One stack (write/read/mutex thread) within a TSan report. + + Only the frames are needed: the descriptive role line ("Write of size 4 + at 0xADDR by thread T1:", etc.) is already preserved verbatim in the + report's own `raw` text, which is what gets rendered. + """ + frames: list = field(default_factory=list) # list[StackFrame] + + +@dataclass +class TsanReport: + """One TSan error report (race, deadlock, etc.) parsed from a tsan. log.""" + + pid: str + error_type: str # e.g. "data-race", "lock-order-inversion", "use-of-invalid-mutex" + error_summary: str # the full WARNING: ... line + threads: list = field(default_factory=list) # list[TsanThread] + raw: str = "" + test_name: str = "" # suite name, filled in by collect_reports() + + +# ── Parsing ─────────────────────────────────────────────────────────────────── + +# Matches the header line of a TSan report: +# WARNING: ThreadSanitizer: data race (pid=12345) +_RE_HEADER = re.compile( + r"WARNING: ThreadSanitizer:\s+(?P.+?)\s+\(pid=(?P\d+)\)" +) + +# Matches a thread role descriptor line: +# Write of size 4 at 0xADDR by thread T1: +# Previous read of size 4 at 0xADDR by main thread: +# Mutex M1 ... created by main thread: +_RE_THREAD_ROLE = re.compile( + r"^ (?:(?:Previous|Subsequent)\s+)?(?:Write|Read|Mutex|Lock)\s+", + re.IGNORECASE, +) + +# Matches TSan stack frames: +# #0 func_name /path/file.c:42 (binary+0xaddr) +# #0 func_name /path/file.c:42:7 (binary+0xaddr) +# Lacks the "0xADDR" address ASan/UBSan have and has a trailing "(binary+off)", +# so it composes its own regex from the shared FILE_LINE_COL fragment rather +# than reusing ADDR_FRAME_RE. +_RE_FRAME = re.compile( + rf"^\s+#(?P\d+)\s+(?P\S+)" + rf"(?:\s+{FILE_LINE_COL})?" + rf"(?:\s+\([^)]+\))?$", + re.IGNORECASE, +) + + +def _normalize_error_type(raw: str) -> str: + """Convert 'data race' → 'data-race', etc.""" + return re.sub(r"[^a-z0-9]+", "-", raw.lower().strip()).strip("-") or "race" + + +def parse_report_file(path: Path) -> list: + """Parse one TSan log file. Returns a list of TsanReport objects.""" + text = path.read_text(errors="replace") + if "ThreadSanitizer" not in text: + return [] + + reports = [] + current: Optional[TsanReport] = None + current_thread: Optional[TsanThread] = None + + for line in text.splitlines(): + m = _RE_HEADER.match(line) + if m: + # Commit any in-progress report + if current_thread is not None and current is not None: + current.threads.append(current_thread) + current_thread = None + if current is not None: + reports.append(current) + current = TsanReport( + pid=m.group("pid"), + error_type=_normalize_error_type(m.group("type")), + error_summary=line.strip(), + threads=[], + raw=line + "\n", + ) + continue + + if current is None: + continue + current.raw += line + "\n" + + # Detect thread role lines + if _RE_THREAD_ROLE.match(line): + if current_thread is not None: + current.threads.append(current_thread) + current_thread = TsanThread(frames=[]) + continue + + # Stack frames + m = _RE_FRAME.match(line) + if m and current_thread is not None: + current_thread.frames.append(StackFrame( + index=int(m.group("idx")), + function=m.group("func"), + file=m.group("file"), + line=int(m.group("line")) if m.group("line") else None, + column=int(m.group("col")) if m.group("col") else None, + rel_file=None, + )) + + # Commit any in-progress report + if current_thread is not None and current is not None: + current.threads.append(current_thread) + if current is not None: + reports.append(current) + + return reports + + +def collect_reports(report_dir: Path) -> list: + """Return parsed TsanReport objects for every tsan. file found.""" + return _collect_reports(report_dir, "tsan", parse_report_file) + + +def _primary_frame(report: TsanReport) -> Optional[StackFrame]: + """Return the innermost in-project frame from the first thread stack.""" + for thread in report.threads: + for frame in thread.frames: + if frame.rel_file and frame.line: + return frame + return None + + +# ── SARIF 2.1.0 ─────────────────────────────────────────────────────────────── + +_TOOL_NAME = "ThreadSanitizer" +_TOOL_URI = "https://clang.llvm.org/docs/ThreadSanitizer.html" + + +def build_sarif(reports: list) -> dict: + """Build a SARIF 2.1.0 document from the list of TsanReport objects.""" + rules: dict = {} + results = [] + + for report in reports: + rule_id = f"tsan/{report.error_type}" + if rule_id not in rules: + rules[rule_id] = { + "id": rule_id, + "name": "".join( + w.capitalize() for w in report.error_type.split("-") + ), + "shortDescription": {"text": report.error_type.replace("-", " ")}, + "helpUri": _TOOL_URI, + "properties": {"tags": ["tsan", "thread-safety"]}, + } + + primary = _primary_frame(report) + locations = [] + if primary: + loc = sarif_location(primary.rel_file, primary.line, primary.column) + if loc: + locations.append(loc) + + results.append({ + "ruleId": rule_id, + "level": "error", + "message": {"text": report.error_summary}, + "locations": locations, + }) + + return build_sarif_doc(_TOOL_NAME, _TOOL_URI, list(rules.values()), results) + + +# ── Markdown summary ────────────────────────────────────────────────────────── + +_TSAN_NOTE = ( + "> **Note:** Some findings may be false positives due to Argobots ULT " + "context switches. Add suppressions to `utils/test_tsan.supp` as needed." +) + +_TSAN_NO_ISSUES = "#### ✅ ThreadSanitizer — No thread-safety issues detected" + + +def _tsan_row(i: int, report) -> list: + """Return summary table cells for one TSan report.""" + primary = _primary_frame(report) + loc = f"`{primary.rel_file}:{primary.line}`" if primary else "_(no source)_" + test_name = report.test_name or "_(unknown)_" + return [str(i), test_name, f"`{report.error_type}`", loc, report.error_summary[:80]] + + +def build_summary(reports: list) -> str: + """Return a Markdown summary block for the GitHub job summary.""" + return build_summary_md( + tool_name="ThreadSanitizer", + emoji="❌", + items=reports, + headers=["#", "Test", "Error type", "Primary location", "Description"], + row_fn=_tsan_row, + details_fn=report_heading, + note=_TSAN_NOTE, + no_items_msg=_TSAN_NO_ISSUES, + ) + + +# ── Main ────────────────────────────────────────────────────────────────────── + +def main() -> int: + """Entry point.""" + return main_runner( + get_args("Parse TSan log files and emit SARIF and a summary."), + collect_fn=collect_reports, + resolve_fn=resolve_paths_threaded, + build_sarif_fn=build_sarif, + build_summary_fn=build_summary, + tool_label="TSan", + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/utils/cq/words.dict b/utils/cq/words.dict index 643f134100e..7506ab6ab34 100644 --- a/utils/cq/words.dict +++ b/utils/cq/words.dict @@ -37,6 +37,8 @@ MPI Makefile Mellanox MiB +Mutex +mutex NLT NodeSet OC @@ -65,6 +67,7 @@ Skylake StackFrame SubProcess Subdirectories +TsanThread TestFail Testcase tsan