Skip to content

Commit 88d4e32

Browse files
authored
Merge pull request #126 from bwrsandman/multiprocess
Use task queue to spawn multiple processes of tidy
2 parents 20c47ad + 02da243 commit 88d4e32

File tree

4 files changed

+160
-60
lines changed

4 files changed

+160
-60
lines changed

action.yml

+5
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ inputs:
6161
description: "Use annotations instead of comments. See README for limitations on annotations"
6262
required: false
6363
default: false
64+
parallel:
65+
description: "Number of tidy instances to be run in parallel. Zero will automatically determine the right number."
66+
required: false
67+
default: "0"
6468
pr:
6569
default: ${{ github.event.pull_request.number }}
6670
repo:
@@ -88,3 +92,4 @@ runs:
8892
- --lgtm-comment-body='${{ inputs.lgtm_comment_body }}'
8993
- --split_workflow=${{ inputs.split_workflow }}
9094
- --annotations=${{ inputs.annotations }}
95+
- --parallel=${{ inputs.parallel }}

post/clang_tidy_review/clang_tidy_review/__init__.py

+139-54
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,14 @@
99
import glob
1010
import itertools
1111
import json
12+
import multiprocessing
1213
import os
14+
import queue
15+
import shutil
16+
import sys
17+
import tempfile
18+
import threading
19+
import traceback
1320
from operator import itemgetter
1421
import pprint
1522
import pathlib
@@ -161,50 +168,48 @@ def get_auth_from_arguments(args: argparse.Namespace) -> Auth:
161168

162169

163170
def build_clang_tidy_warnings(
164-
line_filter,
165-
build_dir,
166-
clang_tidy_checks,
167-
clang_tidy_binary: pathlib.Path,
168-
config_file,
169-
files,
170-
username: str,
171+
base_invocation: List,
172+
env: dict,
173+
tmpdir: str,
174+
task_queue: queue.Queue,
175+
lock: threading.Lock,
176+
failed_files: List,
171177
) -> None:
172-
"""Run clang-tidy on the given files and save output into FIXES_FILE"""
178+
"""Run clang-tidy on the given files and save output into a temporary file"""
173179

174-
config = config_file_or_checks(clang_tidy_binary, clang_tidy_checks, config_file)
180+
while True:
181+
name = task_queue.get()
182+
invocation = base_invocation[:]
175183

176-
args = [
177-
clang_tidy_binary,
178-
f"-p={build_dir}",
179-
f"-line-filter={line_filter}",
180-
f"--export-fixes={FIXES_FILE}",
181-
"--enable-check-profile",
182-
f"-store-check-profile={PROFILE_DIR}",
183-
]
184+
# Get a temporary file. We immediately close the handle so clang-tidy can
185+
# overwrite it.
186+
(handle, fixes_file) = tempfile.mkstemp(suffix=".yaml", dir=tmpdir)
187+
os.close(handle)
188+
invocation.append(f"--export-fixes={fixes_file}")
184189

185-
if config:
186-
print(f"Using config: {config}")
187-
args.append(config)
188-
else:
189-
print("Using recursive directory config")
190+
invocation.append(name)
190191

191-
args += files
192-
193-
try:
194-
with message_group(f"Running:\n\t{args}"):
195-
env = dict(os.environ)
196-
env["USER"] = username
197-
subprocess.run(
198-
args,
199-
capture_output=True,
200-
check=True,
201-
encoding="utf-8",
202-
env=env,
203-
)
204-
except subprocess.CalledProcessError as e:
205-
print(
206-
f"\n\nclang-tidy failed with return code {e.returncode} and error:\n{e.stderr}\nOutput was:\n{e.stdout}"
192+
proc = subprocess.Popen(
193+
invocation, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env
207194
)
195+
output, err = proc.communicate()
196+
end = datetime.datetime.now()
197+
198+
if proc.returncode != 0:
199+
if proc.returncode < 0:
200+
msg = f"{name}: terminated by signal {-proc.returncode}\n"
201+
err += msg.encode("utf-8")
202+
failed_files.append(name)
203+
with lock:
204+
subprocess.list2cmdline(invocation)
205+
sys.stdout.write(
206+
f'{name}: {subprocess.list2cmdline(invocation)}\n{output.decode("utf-8")}'
207+
)
208+
if len(err) > 0:
209+
sys.stdout.flush()
210+
sys.stderr.write(err.decode("utf-8"))
211+
212+
task_queue.task_done()
208213

209214

210215
def clang_tidy_version(clang_tidy_binary: pathlib.Path):
@@ -250,11 +255,33 @@ def config_file_or_checks(
250255
return "--config"
251256

252257

253-
def load_clang_tidy_warnings():
254-
"""Read clang-tidy warnings from FIXES_FILE. Can be produced by build_clang_tidy_warnings"""
258+
def merge_replacement_files(tmpdir: str, mergefile: str):
259+
"""Merge all replacement files in a directory into a single file"""
260+
# The fixes suggested by clang-tidy >= 4.0.0 are given under
261+
# the top level key 'Diagnostics' in the output yaml files
262+
mergekey = "Diagnostics"
263+
merged = []
264+
for replacefile in glob.iglob(os.path.join(tmpdir, "*.yaml")):
265+
content = yaml.safe_load(open(replacefile, "r"))
266+
if not content:
267+
continue # Skip empty files.
268+
merged.extend(content.get(mergekey, []))
269+
270+
if merged:
271+
# MainSourceFile: The key is required by the definition inside
272+
# include/clang/Tooling/ReplacementsYaml.h, but the value
273+
# is actually never used inside clang-apply-replacements,
274+
# so we set it to '' here.
275+
output = {"MainSourceFile": "", mergekey: merged}
276+
with open(mergefile, "w") as out:
277+
yaml.safe_dump(output, out)
278+
279+
280+
def load_clang_tidy_warnings(fixes_file) -> Dict:
281+
"""Read clang-tidy warnings from fixes_file. Can be produced by build_clang_tidy_warnings"""
255282
try:
256-
with open(FIXES_FILE, "r") as fixes_file:
257-
return yaml.safe_load(fixes_file)
283+
with open(fixes_file, "r") as file:
284+
return yaml.safe_load(file)
258285
except FileNotFoundError:
259286
return {}
260287

@@ -824,7 +851,9 @@ def create_review_file(
824851
return review
825852

826853

827-
def make_timing_summary(clang_tidy_profiling: Dict, sha: Optional[str] = None) -> str:
854+
def make_timing_summary(
855+
clang_tidy_profiling: Dict, real_time: datetime.timedelta, sha: Optional[str] = None
856+
) -> str:
828857
if not clang_tidy_profiling:
829858
return ""
830859
top_amount = 10
@@ -901,7 +930,9 @@ def make_timing_summary(clang_tidy_profiling: Dict, sha: Optional[str] = None) -
901930
c = decorate_check_names(f"[{c}]").replace("[[", "[").rstrip("]")
902931
check_summary += f"|{c}|{u:.2f}|{s:.2f}|{w:.2f}|\n"
903932

904-
return f"## Timing\n{file_summary}{check_summary}"
933+
return (
934+
f"## Timing\nReal time: {real_time.seconds:.2f}\n{file_summary}{check_summary}"
935+
)
905936

906937

907938
def filter_files(diff, include: List[str], exclude: List[str]) -> List:
@@ -923,6 +954,7 @@ def create_review(
923954
clang_tidy_checks: str,
924955
clang_tidy_binary: pathlib.Path,
925956
config_file: str,
957+
max_task: int,
926958
include: List[str],
927959
exclude: List[str],
928960
) -> Optional[PRReview]:
@@ -931,6 +963,9 @@ def create_review(
931963
932964
"""
933965

966+
if max_task == 0:
967+
max_task = multiprocessing.cpu_count()
968+
934969
diff = pull_request.get_pr_diff()
935970
print(f"\nDiff from GitHub PR:\n{diff}\n")
936971

@@ -970,18 +1005,68 @@ def create_review(
9701005
username = pull_request.get_pr_author() or "your name here"
9711006

9721007
# Run clang-tidy with the configured parameters and produce the CLANG_TIDY_FIXES file
973-
build_clang_tidy_warnings(
974-
line_ranges,
975-
build_dir,
976-
clang_tidy_checks,
1008+
return_code = 0
1009+
export_fixes_dir = tempfile.mkdtemp()
1010+
env = dict(os.environ, USER=username)
1011+
config = config_file_or_checks(clang_tidy_binary, clang_tidy_checks, config_file)
1012+
base_invocation = [
9771013
clang_tidy_binary,
978-
config_file,
979-
files,
980-
username,
981-
)
1014+
f"-p={build_dir}",
1015+
f"-line-filter={line_ranges}",
1016+
"--enable-check-profile",
1017+
f"-store-check-profile={PROFILE_DIR}",
1018+
]
1019+
if config:
1020+
print(f"Using config: {config}")
1021+
base_invocation.append(config)
1022+
else:
1023+
print("Using recursive directory config")
1024+
1025+
print(f"Spawning a task queue with {max_task} processes")
1026+
start = datetime.datetime.now()
1027+
try:
1028+
# Spin up a bunch of tidy-launching threads.
1029+
task_queue = queue.Queue(max_task)
1030+
# List of files with a non-zero return code.
1031+
failed_files = []
1032+
lock = threading.Lock()
1033+
for _ in range(max_task):
1034+
t = threading.Thread(
1035+
target=build_clang_tidy_warnings,
1036+
args=(
1037+
base_invocation,
1038+
env,
1039+
export_fixes_dir,
1040+
task_queue,
1041+
lock,
1042+
failed_files,
1043+
),
1044+
)
1045+
t.daemon = True
1046+
t.start()
1047+
1048+
# Fill the queue with files.
1049+
for name in files:
1050+
task_queue.put(name)
1051+
1052+
# Wait for all threads to be done.
1053+
task_queue.join()
1054+
if len(failed_files):
1055+
return_code = 1
1056+
1057+
except KeyboardInterrupt:
1058+
# This is a sad hack. Unfortunately subprocess goes
1059+
# bonkers with ctrl-c and we start forking merrily.
1060+
print("\nCtrl-C detected, goodbye.")
1061+
os.kill(0, 9)
1062+
raise
1063+
real_duration = datetime.datetime.now() - start
9821064

9831065
# Read and parse the CLANG_TIDY_FIXES file
984-
clang_tidy_warnings = load_clang_tidy_warnings()
1066+
print("Writing fixes to " + FIXES_FILE + " ...")
1067+
merge_replacement_files(export_fixes_dir, FIXES_FILE)
1068+
shutil.rmtree(export_fixes_dir)
1069+
clang_tidy_warnings = load_clang_tidy_warnings(FIXES_FILE)
9851070

9861071
# Read and parse the timing data
9871072
clang_tidy_profiling = load_and_merge_profiling()
@@ -992,7 +1077,7 @@ def create_review(
9921077
sha = os.environ.get("GITHUB_SHA")
9931078

9941079
# Post to the action job summary
995-
step_summary = make_timing_summary(clang_tidy_profiling, sha)
1080+
step_summary = make_timing_summary(clang_tidy_profiling, real_duration, sha)
9961081
set_summary(step_summary)
9971082

9981083
print("clang-tidy had the following warnings:\n", clang_tidy_warnings, flush=True)

post/clang_tidy_review/clang_tidy_review/review.py

+8
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,13 @@ def main():
112112
type=bool_argument,
113113
default=False,
114114
)
115+
parser.add_argument(
116+
"-j",
117+
"--parallel",
118+
help="Number of tidy instances to be run in parallel.",
119+
type=int,
120+
default=0,
121+
)
115122
parser.add_argument(
116123
"--dry-run", help="Run and generate review, but don't post", action="store_true"
117124
)
@@ -157,6 +164,7 @@ def main():
157164
args.clang_tidy_checks,
158165
args.clang_tidy_binary,
159166
args.config_file,
167+
args.parallel,
160168
include,
161169
exclude,
162170
)

tests/test_review.py

+8-6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import datetime
2+
13
import clang_tidy_review as ctr
24

35
import difflib
@@ -235,10 +237,10 @@ def test_line_ranges():
235237
assert line_ranges == expected_line_ranges
236238

237239

238-
def test_load_clang_tidy_warnings(monkeypatch):
239-
monkeypatch.setattr(ctr, "FIXES_FILE", str(TEST_DIR / f"src/test_{ctr.FIXES_FILE}"))
240-
241-
warnings = ctr.load_clang_tidy_warnings()
240+
def test_load_clang_tidy_warnings():
241+
warnings = ctr.load_clang_tidy_warnings(
242+
str(TEST_DIR / f"src/test_{ctr.FIXES_FILE}")
243+
)
242244

243245
assert sorted(list(warnings.keys())) == ["Diagnostics", "MainSourceFile"]
244246
assert warnings["MainSourceFile"] == "/clang_tidy_review/src/hello.cxx"
@@ -470,5 +472,5 @@ def test_timing_summary(monkeypatch):
470472
assert "time.clang-tidy.total.wall" in profiling["hello.cxx"].keys()
471473
assert "time.clang-tidy.total.user" in profiling["hello.cxx"].keys()
472474
assert "time.clang-tidy.total.sys" in profiling["hello.cxx"].keys()
473-
summary = ctr.make_timing_summary(profiling)
474-
assert len(summary.split("\n")) == 21
475+
summary = ctr.make_timing_summary(profiling, datetime.timedelta(seconds=42))
476+
assert len(summary.split("\n")) == 22

0 commit comments

Comments
 (0)