Logo AND Algorithmique Numérique Distribuée

Public GIT Repository
Merge branch 'master' of framagit.org:simgrid/simgrid
[simgrid.git] / teshsuite / smpi / MBI / MBIutils.py
1 # Copyright 2021-2022. The MBI project. All rights reserved.
2 # This program is free software; you can redistribute it and/or modify it under the terms of the license (GNU GPL).
3
4 import os
5 import time
6 import subprocess
7 import sys
8 import re
9 import shlex
10 import select
11 import signal
12 import hashlib
13
14 class AbstractTool:
15     def ensure_image(self, params="", dockerparams=""):
16         """Verify that this is executed from the right docker image, and complain if not."""
17         if os.path.exists("/MBI") or os.path.exists("trust_the_installation"):
18             print("This seems to be a MBI docker image. Good.")
19         else:
20             print("Please run this script in a MBI docker image. Run these commands:")
21             print("  docker build -f Dockerfile -t mpi-bugs-initiative:latest . # Only the first time")
22             print(f"  docker run -it --rm --name MIB --volume $(pwd):/MBI {dockerparams}mpi-bugs-initiative /MBI/MBI.py {params}")
23             sys.exit(1)
24
25     def build(self, rootdir, cached=True):
26         """Rebuilds the tool binaries. By default, we try to reuse the existing build."""
27         print("Nothing to do to rebuild the tool binaries.")
28
29     def setup(self, rootdir):
30         """
31         Ensure that this tool (previously built) is usable in this environment: setup the PATH, etc.
32         This is called only once for all tests, from the logs directory.
33         """
34         # pass
35
36     def run(self, execcmd, filename, binary, num_id, timeout, batchinfo):
37         """Compile that test code and anaylse it with the Tool if needed (a cache system should be used)"""
38         # pass
39
40     def teardown(self):
41         """
42         Clean the results of all test runs: remove temp files and binaries.
43         This is called only once for all tests, from the logs directory.
44         """
45         # pass
46
47     def parse(self, cachefile):
48         """Read the result of a previous run from the cache, and compute the test outcome"""
49         return 'failure'
50
51 # Associate all possible detailed outcome to a given error scope. Scopes must be sorted alphabetically.
52 possible_details = {
53     # scope limited to one call
54     'InvalidBuffer':'AInvalidParam', 'InvalidCommunicator':'AInvalidParam', 'InvalidDatatype':'AInvalidParam', 'InvalidRoot':'AInvalidParam', 'InvalidTag':'AInvalidParam', 'InvalidWindow':'AInvalidParam', 'InvalidOperator':'AInvalidParam', 'InvalidOtherArg':'AInvalidParam', 'ActualDatatype':'AInvalidParam',
55     'InvalidSrcDest':'AInvalidParam',
56     # scope: Process-wide
57 #    'OutOfInitFini':'BInitFini',
58     'CommunicatorLeak':'BResLeak', 'DatatypeLeak':'BResLeak', 'GroupLeak':'BResLeak', 'OperatorLeak':'BResLeak', 'TypeLeak':'BResLeak', 'RequestLeak':'BResLeak',
59     'MissingStart':'BReqLifecycle', 'MissingWait':'BReqLifecycle',
60     'MissingEpoch':'BEpochLifecycle','DoubleEpoch':'BEpochLifecycle',
61     'LocalConcurrency':'BLocalConcurrency',
62     # scope: communicator
63     'CallMatching':'DMatch',
64     'CommunicatorMatching':'CMatch', 'DatatypeMatching':'CMatch', 'OperatorMatching':'CMatch', 'RootMatching':'CMatch', 'TagMatching':'CMatch',
65     'MessageRace':'DRace',
66
67     'GlobalConcurrency':'DGlobalConcurrency',
68     # larger scope
69     'BufferingHazard':'EBufferingHazard',
70     'OK':'FOK'}
71
72 error_scope = {
73     'AInvalidParam':'single call',
74     'BResLeak':'single process',
75 #    'BInitFini':'single process',
76     'BReqLifecycle':'single process',
77     'BEpochLifecycle':'single process',
78     'BLocalConcurrency':'single process',
79     'CMatch':'multi-processes',
80     'DRace':'multi-processes',
81     'DMatch':'multi-processes',
82     'DGlobalConcurrency':'multi-processes',
83     'EBufferingHazard':'system',
84     'FOK':'correct executions'
85 }
86
87 displayed_name = {
88     'AInvalidParam':'Invalid parameter',
89     'BResLeak':'Resource leak',
90 #    'BInitFini':'MPI call before initialization/after finalization',
91     'BReqLifecycle':'Request lifecycle',
92     'BLocalConcurrency':'Local concurrency',
93     'CMatch':'Parameter matching',
94     'DMatch':"Call ordering",
95     'DRace':'Message race',
96     'DGlobalConcurrency':'Global concurrency',
97     'EBufferingHazard':'Buffering hazard',
98     'FOK':"Correct execution",
99
100     'aislinn':'Aislinn', 'civl':'CIVL', 'hermes':'Hermes', 'isp':'ISP', 'itac':'ITAC', 'simgrid':'Mc SimGrid', 'smpi':'SMPI', 'smpivg':'SMPI+VG', 'mpisv':'MPI-SV', 'must':'MUST', 'parcoach':'PARCOACH'
101 }
102
103 def parse_one_code(filename):
104     """
105     Reads the header of the provided filename, and extract a list of todo item, each of them being a (cmd, expect, test_num) tupple.
106     The test_num is useful to build a log file containing both the binary and the test_num, when there is more than one test in the same binary.
107     """
108     res = []
109     test_num = 0
110     with open(filename, "r") as input_file:
111         state = 0  # 0: before header; 1: in header; 2; after header
112         line_num = 1
113         for line in input_file:
114             if re.match(".*BEGIN_MBI_TESTS.*", line):
115                 if state == 0:
116                     state = 1
117                 else:
118                     raise ValueError(f"MBI_TESTS header appears a second time at line {line_num}: \n{line}")
119             elif re.match(".*END_MBI_TESTS.*", line):
120                 if state == 1:
121                     state = 2
122                 else:
123                     raise ValueError(f"Unexpected end of MBI_TESTS header at line {line_num}: \n{line}")
124             if state == 1 and re.match(r'\s+\$ ?.*', line):
125                 m = re.match(r'\s+\$ ?(.*)', line)
126                 cmd = m.group(1)
127                 nextline = next(input_file)
128                 detail = 'OK'
129                 if re.match('[ |]*OK *', nextline):
130                     expect = 'OK'
131                 else:
132                     m = re.match('[ |]*ERROR: *(.*)', nextline)
133                     if not m:
134                         raise ValueError(
135                             f"\n{filename}:{line_num}: MBI parse error: Test not followed by a proper 'ERROR' line:\n{line}{nextline}")
136                     expect = 'ERROR'
137                     detail = m.group(1)
138                     if detail not in possible_details:
139                         raise ValueError(
140                             f"\n{filename}:{line_num}: MBI parse error: Detailled outcome {detail} is not one of the allowed ones.")
141
142                 nextline = next(input_file)
143                 m = re.match('[ |]*(.*)', nextline)
144                 if not m:
145                     raise ValueError(f"\n{filename}:{line_num}: MBI parse error: Expected diagnostic of the test not found.\n")
146                 diagnostic = m.group(1)
147
148                 test = {'filename': filename, 'id': test_num, 'cmd': cmd, 'expect': expect, 'detail': detail, 'diagnostic': diagnostic}
149                 res.append(test.copy())
150                 test_num += 1
151                 line_num += 1
152
153     if state == 0:
154         raise ValueError(f"MBI_TESTS header not found in file '{filename}'.")
155     if state == 1:
156         raise ValueError(f"MBI_TESTS header not properly ended in file '{filename}'.")
157
158     if len(res) == 0:
159         raise ValueError(f"No test found in {filename}. Please fix it.")
160     return res
161
162 def categorize(tool, toolname, test_id, expected):
163     outcome = tool.parse(test_id)
164
165     if not os.path.exists(f'{test_id}.elapsed') and not os.path.exists(f'logs/{toolname}/{test_id}.elapsed'):
166         if outcome == 'failure':
167             elapsed = 0
168         else:
169             raise ValueError(f"Invalid test result: {test_id}.txt exists but not {test_id}.elapsed")
170     else:
171         with open(f'{test_id}.elapsed' if os.path.exists(f'{test_id}.elapsed') else f'logs/{toolname}/{test_id}.elapsed', 'r') as infile:
172             elapsed = infile.read()
173
174     # Properly categorize this run
175     if outcome == 'timeout':
176         res_category = 'timeout'
177         if elapsed is None:
178             diagnostic = 'hard timeout'
179         else:
180             diagnostic = f'timeout after {elapsed} sec'
181     elif outcome == 'failure' or outcome == 'segfault':
182         res_category = 'failure'
183         diagnostic = 'tool error, or test not run'
184     elif outcome == 'UNIMPLEMENTED':
185         res_category = 'unimplemented'
186         diagnostic = 'coverage issue'
187     elif outcome == 'other':
188         res_category = 'other'
189         diagnostic = 'inconclusive run'
190     elif expected == 'OK':
191         if outcome == 'OK':
192             res_category = 'TRUE_NEG'
193             diagnostic = 'correctly reported no error'
194         else:
195             res_category = 'FALSE_POS'
196             diagnostic = 'reported an error in a correct code'
197     elif expected == 'ERROR':
198         if outcome == 'OK':
199             res_category = 'FALSE_NEG'
200             diagnostic = 'failed to detect an error'
201         else:
202             res_category = 'TRUE_POS'
203             diagnostic = 'correctly detected an error'
204     else:
205         raise ValueError(f"Unexpected expectation: {expected} (must be OK or ERROR)")
206
207     return (res_category, elapsed, diagnostic, outcome)
208
209
210 def run_cmd(buildcmd, execcmd, cachefile, filename, binary, timeout, batchinfo, read_line_lambda=None):
211     """
212     Runs the test on need. Returns True if the test was ran, and False if it was cached.
213
214     The result is cached if possible, and the test is rerun only if the `test.txt` (containing the tool output) or the `test.elapsed` (containing the timing info) do not exist, or if `test.md5sum` (containing the md5sum of the code to compile) does not match.
215
216     Parameters:
217      - buildcmd and execcmd are shell commands to run. buildcmd can be any shell line (incuding && groups), but execcmd must be a single binary to run.
218      - cachefile is the name of the test
219      - filename is the source file containing the code
220      - binary the file name in which to compile the code
221      - batchinfo: something like "1/1" to say that this run is the only batch (see -b parameter of MBI.py)
222      - read_line_lambda: a lambda to which each line of the tool output is feed ASAP. It allows MUST to interrupt the execution when a deadlock is reported.
223     """
224     if os.path.exists(f'{cachefile}.txt') and os.path.exists(f'{cachefile}.elapsed') and os.path.exists(f'{cachefile}.md5sum'):
225         hash_md5 = hashlib.md5()
226         with open(filename, 'rb') as sourcefile:
227             for chunk in iter(lambda: sourcefile.read(4096), b""):
228                 hash_md5.update(chunk)
229         newdigest = hash_md5.hexdigest()
230         with open(f'{cachefile}.md5sum', 'r') as md5file:
231             olddigest = md5file.read()
232         #print(f'Old digest: {olddigest}; New digest: {newdigest}')
233         if olddigest == newdigest:
234             print(f" (result cached -- digest: {olddigest})")
235             return False
236         os.remove(f'{cachefile}.txt')
237
238     print(f"Wait up to {timeout} seconds")
239
240     start_time = time.time()
241     if buildcmd is None:
242         output = f"No need to compile {binary}.c (batchinfo:{batchinfo})\n\n"
243     else:
244         output = f"Compiling {binary}.c (batchinfo:{batchinfo})\n\n"
245         output += f"$ {buildcmd}\n"
246
247         compil = subprocess.run(buildcmd, shell=True, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
248         if compil.stdout is not None:
249             output += str(compil.stdout, errors='replace')
250         if compil.returncode != 0:
251             output += f"Compilation of {binary}.c raised an error (retcode: {compil.returncode})"
252             for line in (output.split('\n')):
253                 print(f"| {line}", file=sys.stderr)
254             with open(f'{cachefile}.elapsed', 'w') as outfile:
255                 outfile.write(str(time.time() - start_time))
256             with open(f'{cachefile}.txt', 'w') as outfile:
257                 outfile.write(output)
258             return True
259
260     output += f"\n\nExecuting the command\n $ {execcmd}\n"
261     for line in (output.split('\n')):
262         print(f"| {line}", file=sys.stderr)
263
264     # We run the subprocess and parse its output line by line, so that we can kill it as soon as it detects a timeout
265     process = subprocess.Popen(shlex.split(execcmd), stdout=subprocess.PIPE,
266                                stderr=subprocess.STDOUT, preexec_fn=os.setsid)
267     poll_obj = select.poll()
268     poll_obj.register(process.stdout, select.POLLIN)
269
270     pid = process.pid
271     pgid = os.getpgid(pid)  # We need that to forcefully kill subprocesses when leaving
272     while True:
273         if poll_obj.poll(5):  # Something to read? Do check the timeout status every 5 sec if not
274             line = process.stdout.readline()
275             # From byte array to string, replacing non-representable strings with question marks
276             line = str(line, errors='replace')
277             output = output + line
278             print(f"| {line}", end='', file=sys.stderr)
279             if read_line_lambda != None:
280                 read_line_lambda(line, process)
281         if time.time() - start_time > timeout:
282             with open(f'{cachefile}.timeout', 'w') as outfile:
283                 outfile.write(f'{time.time() - start_time} seconds')
284             break
285         if process.poll() is not None:  # The subprocess ended. Grab all existing output, and return
286             line = 'more'
287             while line != None and line != '':
288                 line = process.stdout.readline()
289                 if line is not None:
290                     # From byte array to string, replacing non-representable strings with question marks
291                     line = str(line, errors='replace')
292                     output = output + line
293                     print(f"| {line}", end='', file=sys.stderr)
294
295             break
296
297     # We want to clean all forked processes in all cases, no matter whether they are still running (timeout) or supposed to be off. The runners easily get clogged with zombies :(
298     try:
299         os.killpg(pgid, signal.SIGTERM)  # Terminate all forked processes, to make sure it's clean whatever the tool does
300         process.terminate()  # No op if it's already stopped but useful on timeouts
301         time.sleep(0.2)  # allow some time for the tool to finish its childs
302         os.killpg(pgid, signal.SIGKILL)  # Finish 'em all, manually
303         os.kill(pid, signal.SIGKILL)  # die! die! die!
304     except ProcessLookupError:
305         pass  # OK, it's gone now
306
307     elapsed = time.time() - start_time
308
309     rc = process.poll()
310     if rc < 0:
311         status = f"Command killed by signal {-rc}, elapsed time: {elapsed}\n"
312     else:
313         status = f"Command return code: {rc}, elapsed time: {elapsed}\n"
314     print(status)
315     output += status
316
317     with open(f'{cachefile}.elapsed', 'w') as outfile:
318         outfile.write(str(elapsed))
319
320     with open(f'{cachefile}.txt', 'w') as outfile:
321         outfile.write(output)
322     with open(f'{cachefile}.md5sum', 'w') as outfile:
323         hashed = hashlib.md5()
324         with open(filename, 'rb') as sourcefile:
325             for chunk in iter(lambda: sourcefile.read(4096), b""):
326                 hashed.update(chunk)
327         outfile.write(hashed.hexdigest())
328
329     return True