Skip to content

Commit 0c9d02e

Browse files
committed
Use task queue to spawn multiple processes of tidy
1 parent 2ef8ea7 commit 0c9d02e

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
@@ -160,50 +167,48 @@ def get_auth_from_arguments(args: argparse.Namespace) -> Auth:
160167

161168

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

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

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

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

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

208213

209214
def clang_tidy_version(clang_tidy_binary: pathlib.Path):
@@ -249,11 +254,33 @@ def config_file_or_checks(
249254
return "--config"
250255

251256

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

@@ -820,7 +847,9 @@ def create_review_file(
820847
return review
821848

822849

823-
def make_timing_summary(clang_tidy_profiling: Dict, sha: Optional[str] = None) -> str:
850+
def make_timing_summary(
851+
clang_tidy_profiling: Dict, real_time: datetime.timedelta, sha: Optional[str] = None
852+
) -> str:
824853
if not clang_tidy_profiling:
825854
return ""
826855
top_amount = 10
@@ -893,7 +922,9 @@ def make_timing_summary(clang_tidy_profiling: Dict, sha: Optional[str] = None) -
893922
c = decorate_check_names(f"[{c}]").replace("[[", "[").rstrip("]")
894923
check_summary += f"|{c}|{u:.2f}|{s:.2f}|{w:.2f}|\n"
895924

896-
return f"## Timing\n{file_summary}{check_summary}"
925+
return (
926+
f"## Timing\nReal time: {real_time.seconds:.2f}\n{file_summary}{check_summary}"
927+
)
897928

898929

899930
def filter_files(diff, include: List[str], exclude: List[str]) -> List:
@@ -915,6 +946,7 @@ def create_review(
915946
clang_tidy_checks: str,
916947
clang_tidy_binary: pathlib.Path,
917948
config_file: str,
949+
max_task: int,
918950
include: List[str],
919951
exclude: List[str],
920952
) -> Optional[PRReview]:
@@ -923,6 +955,9 @@ def create_review(
923955
924956
"""
925957

958+
if max_task == 0:
959+
max_task = multiprocessing.cpu_count()
960+
926961
diff = pull_request.get_pr_diff()
927962
print(f"\nDiff from GitHub PR:\n{diff}\n")
928963

@@ -962,18 +997,68 @@ def create_review(
962997
username = pull_request.get_pr_author() or "your name here"
963998

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

9751057
# Read and parse the CLANG_TIDY_FIXES file
976-
clang_tidy_warnings = load_clang_tidy_warnings()
1058+
print("Writing fixes to " + FIXES_FILE + " ...")
1059+
merge_replacement_files(export_fixes_dir, FIXES_FILE)
1060+
shutil.rmtree(export_fixes_dir)
1061+
clang_tidy_warnings = load_clang_tidy_warnings(FIXES_FILE)
9771062

9781063
# Read and parse the timing data
9791064
clang_tidy_profiling = load_and_merge_profiling()
@@ -985,7 +1070,7 @@ def create_review(
9851070

9861071
# Post to the action job summary
9871072
step_summary = ""
988-
step_summary += make_timing_summary(clang_tidy_profiling, sha)
1073+
step_summary += make_timing_summary(clang_tidy_profiling, real_duration, sha)
9891074
set_summary(step_summary)
9901075

9911076
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)