Skip to content

Commit df3ef76

Browse files
[UTest] Fix #223: Fix unittest-parallel script.
This commit fixes several issues with the script at once: - Fix #224. - Fix #225. - Fix #226.
1 parent 6fee5fb commit df3ef76

File tree

1 file changed

+93
-47
lines changed

1 file changed

+93
-47
lines changed

utils/unittest-parallel.py

Lines changed: 93 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,27 @@
66
import subprocess
77
import sys
88
import time
9-
import time
109
import xml.etree.ElementTree as ET
10+
import itertools
1111
from enum import Enum
1212
from tqdm import tqdm
1313

14-
class ErrorType(Enum):
15-
NO_ERROR = 1
14+
class ReturnCode(Enum):
15+
SUCCESS = 0
16+
FAILURE = 1
1617
TIMEOUT = 2
17-
UNEXPECTED_RETURN = 3
18+
UNKNOWN = 3
19+
20+
def classify_returncode(returncode: int) -> ReturnCode:
21+
match returncode:
22+
case 0:
23+
return ReturnCode.SUCCESS
24+
case 1:
25+
return ReturnCode.FAILURE
26+
case 124 | 137:
27+
return ReturnCode.TIMEOUT
28+
case _:
29+
return ReturnCode.UNKNOWN
1830

1931
# data needed for junit output
2032
class JunitData:
@@ -24,11 +36,14 @@ class JunitData:
2436
execution_time = 0.0
2537
test_cases = []
2638

27-
def append_data(self, stdout: str, stderr: str, error_type: ErrorType, test_name: str):
39+
def append_data(self, stdout: str, stderr: str, returncode: int, test_name: str):
40+
return_class = classify_returncode(returncode)
41+
2842
# If a process times out we do not want to parse the result. It is just a failure.
29-
if error_type != ErrorType.NO_ERROR:
30-
self.failures += 1
43+
if return_class not in [ReturnCode.SUCCESS, ReturnCode.FAILURE]:
44+
self.errors += 1
3145
return
46+
3247
assert stdout is not None
3348
tree = ET.fromstring(stdout)
3449
test_suite = tree.find('testsuite')
@@ -62,25 +77,37 @@ class TestData:
6277
total_assertions = 0
6378
passed_assertions = 0
6479
failed_assertions = 0
80+
6581
total_test_cases = 0
6682
passed_test_cases = 0
6783
failed_test_cases = 0
68-
timeouts = 0
84+
6985
execution_time = 0.0
86+
7087
error_msgs = []
7188
timeouted_tests = []
72-
is_error = False
89+
unknown_test_failures = []
90+
has_test_failure = False
91+
92+
def append_data(self, stdout: str, stderr: str, returncode: int, test_name: str):
93+
return_class = classify_returncode(returncode)
7394

74-
def append_data(self, stdout: str, stderr: str, error_type: ErrorType, test_name: str):
7595
# If a process times out we do not want to parse the result. It is just a failure.
76-
if error_type == ErrorType.TIMEOUT:
77-
self.timeouts += 1
96+
if return_class == ReturnCode.TIMEOUT:
7897
self.total_test_cases += 1
7998
self.failed_test_cases += 1
80-
self.is_error = True
99+
self.has_test_failure = True
81100
self.timeouted_tests.append(test_name)
82101
return
83102

103+
# If a process fails enexpectedly, we do not want to parse the result
104+
if return_class == ReturnCode.UNKNOWN:
105+
self.total_test_cases += 1
106+
self.failed_test_cases += 1
107+
self.has_test_failure = True
108+
self.unknown_test_failures.append((test_name, returncode))
109+
return
110+
84111
current_failed_tests = 0
85112
assert stdout is not None
86113
assert stderr is not None
@@ -117,35 +144,37 @@ def append_data(self, stdout: str, stderr: str, error_type: ErrorType, test_name
117144
if current_failed_tests > 0:
118145
self.error_msgs.append(stdout)
119146
self.error_msgs.append(stderr)
120-
self.is_error = True
147+
self.has_test_failure = True
121148
elif stderr != '':
122149
self.error_msgs.append(stderr)
123150

124151
def dump(self, _):
125152
for msg in self.error_msgs:
126153
print(msg)
127154

128-
if (self.is_error):
155+
if self.has_test_failure:
129156
digits_total = max(len(str(self.total_assertions)), len(str(self.total_test_cases)))
130157
digits_passed = max(len(str(self.passed_assertions)), len(str(self.passed_test_cases)))
131158
digits_failed = max(len(str(self.failed_assertions)), len(str(self.failed_test_cases)))
132159
print(f'\u001b[31;1m===============================================================================\u001b[39;0m')
133160
print(f'test cases: {self.total_test_cases:>{digits_total}} | \u001b[32;1m{self.passed_test_cases:>{digits_passed}} passed\u001b[39;0m | \u001b[31;1m{self.failed_test_cases:>{digits_failed}} failed\u001b[39;0m')
134-
print(f'assertions: {self.total_assertions:>{digits_total}} | \u001b[32;1m{self.passed_assertions:>{digits_passed}} passed\u001b[39;0m | \u001b[31;1m{self.failed_assertions:>{digits_failed}} failed\u001b[39;0m\n')
135-
if self.timeouts > 0:
136-
print(f'\u001b[31;1mTimeouts: {self.timeouts}\u001b[39;0m\n')
137-
print('Tests that timed out:')
138-
for timeout_test in self.timeouted_tests:
139-
print(timeout_test)
140-
141-
print(f'Execution time: {self.execution_time}s\n')
161+
print(f'assertions: {self.total_assertions:>{digits_total}} | \u001b[32;1m{self.passed_assertions:>{digits_passed}} passed\u001b[39;0m | \u001b[31;1m{self.failed_assertions:>{digits_failed}} failed\u001b[39;0m')
162+
163+
if len(self.timeouted_tests) > 0:
164+
print(f'\n\u001b[31;1mTimeouts: {len(self.timeouted_tests)}\u001b[39;0m')
165+
print('\n'.join(map(lambda test: f' {test}', self.timeouted_tests)))
166+
167+
if len(self.unknown_test_failures) > 0:
168+
print(f'\n\u001b[31;1mUnknown test failures: {len(self.unknown_test_failures)}\u001b[39;0m')
169+
print('\n'.join(itertools.starmap(lambda test, ret: f' {test} (return code {ret})', self.unknown_test_failures)))
142170
else:
143171
print(f'\u001b[32;1m===============================================================================\u001b[39;0m')
144172
print(f'\u001b[32;1mAll tests passed\u001b[39;0m ({self.passed_assertions} assertions in {self.passed_test_cases} test cases)\n')
145-
print(f'Execution time: {self.execution_time}s\n')
173+
174+
print(f'\nExecution time: {self.execution_time}s')
146175

147176
def is_failure(self):
148-
if (self.failed_assertions > 0 or self.failed_test_cases > 0):
177+
if self.failed_assertions > 0 or self.failed_test_cases > 0:
149178
return True
150179
else:
151180
return False
@@ -174,17 +203,37 @@ def handle_returncode(stdout: str, stderr: str, returncode: int, test_name: str)
174203
match returncode:
175204
# no error
176205
case 0 | 1:
177-
data.append_data(stdout, stderr, ErrorType.NO_ERROR, test_name)
206+
data.append_data(stdout, stderr, ReturnCode.NONE, test_name)
178207

179208
# timout with SIGTERM or SIGKILL
180209
case 124 | 137:
181-
data.append_data(stdout, stderr, ErrorType.TIMEOUT, test_name)
210+
data.append_data(stdout, stderr, ReturnCode.TIMEOUT, test_name)
182211

183212
# unexpected return code
184213
case _:
185-
data.append_data(stdout, stderr, ErrorType.UNEXPECTED_RETURN, test_name)
214+
data.append_data(stdout, stderr, ReturnCode.UNEXPECTED_RETURN, test_name)
215+
216+
running_processes: map(int, tuple(subprocess.Popen, str)) = {}
217+
218+
def wait_for_child() -> int:
219+
pid, status = os.wait() # wait for *any* child to exit
220+
221+
assert pid in running_processes, 'os.wait() returned unexpected child PID'
222+
223+
process, p_test_name = running_processes[pid]
224+
assert process.pid == pid, 'PID mismatch'
225+
226+
returncode = os.waitstatus_to_exitcode(status) # get return status from process; don't use Popen.returncode
227+
assert returncode is not None
228+
229+
progress_bar.update(1)
230+
del running_processes[pid]
231+
stdout, stderr = process.communicate()
232+
233+
data.append_data(stdout, stderr, returncode, test_name)
234+
235+
return returncode
186236

187-
running_processes = {}
188237
# execute tests in parallel until we have max_processes running
189238
for test_name in test_names:
190239
test_name = test_name.replace(',', '\\,')
@@ -195,29 +244,26 @@ def handle_returncode(stdout: str, stderr: str, returncode: int, test_name: str)
195244
# if we have max_processes number of processes running, wait for a process to finish and remove the process from
196245
# the running processes dictionary
197246
if len(running_processes) >= max_processes:
198-
pid, status = os.wait() # wait for *any* child to exit
199-
assert pid in running_processes, 'os.wait() returned unexpected child PID'
200-
process, p_test_name = running_processes[pid]
201-
assert process.pid == pid, 'PID mismatch'
202-
returncode = os.waitstatus_to_exitcode(status) # get return status from process; don't use Popen.returncode
203-
assert returncode is not None
204-
progress_bar.update(1)
205-
del running_processes[pid]
206-
stdout, stderr = process.communicate()
207-
handle_returncode(stdout, stderr, returncode, p_test_name)
247+
returncode = wait_for_child()
208248
if args.stop_fail and returncode != 0: # stop on first failure
209249
failed = True
210250
break
211251

212252
# wait for the remaining running processes to finish
213-
for pid, (process, p_test_name) in running_processes.items():
214-
stdout, stderr = process.communicate() # wait for process to terminate; consume stdout/stderr to avoid deadlock
215-
progress_bar.update(1)
216-
handle_returncode(stdout, stderr, process.returncode, p_test_name)
217-
if process.returncode != 0 and args.stop_fail:
253+
while len(running_processes) != 0:
254+
returncode = wait_for_child()
255+
if args.stop_fail and returncode != 0: # stop on first failure
218256
failed = True
219257
break
220258

259+
# for pid, (process, p_test_name) in running_processes.items():
260+
# stdout, stderr = process.communicate() # wait for process to terminate; consume stdout/stderr to avoid deadlock
261+
# progress_bar.update(1)
262+
# handle_returncode(stdout, stderr, process.returncode, p_test_name)
263+
# if process.returncode != 0 and args.stop_fail:
264+
# failed = True
265+
# break
266+
221267
if failed:
222268
# send SIGTERM to all processes
223269
for process, test_name in running_processes.values():
@@ -236,7 +282,7 @@ def handle_returncode(stdout: str, stderr: str, returncode: int, test_name: str)
236282
execution_time = time_end - time_start
237283
data.execution_time = execution_time
238284

239-
return data
285+
return data, failed
240286

241287

242288
if __name__ == '__main__':
@@ -268,10 +314,10 @@ def handle_returncode(stdout: str, stderr: str, returncode: int, test_name: str)
268314
output = subprocess.run(list_tests_command, stdout=subprocess.PIPE)
269315
test_names = output.stdout.decode().strip().split('\n')
270316

271-
data = run_tests(args, test_names, args.binary_path, is_interactive)
317+
data, failed = run_tests(args, test_names, args.binary_path, is_interactive)
272318

273319
data.dump(args.out)
274-
if data.is_failure():
320+
if failed or data.is_failure():
275321
exit(1)
276322
else:
277323
exit(0)

0 commit comments

Comments
 (0)