Logo AND Algorithmique Numérique Distribuée

Public GIT Repository
Add new entry in Release_Notes.
[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     # Input Hazard
71     'IHCallMatching':'InputHazard',
72     
73     'OK':'FOK'}
74
75 error_scope = {
76     'AInvalidParam':'single call',
77     'BResLeak':'single process',
78 #    'BInitFini':'single process',
79     'BReqLifecycle':'single process',
80     'BEpochLifecycle':'single process',
81     'BLocalConcurrency':'single process',
82     'CMatch':'multi-processes',
83     'DRace':'multi-processes',
84     'DMatch':'multi-processes',
85     'DGlobalConcurrency':'multi-processes',
86     'EBufferingHazard':'system',
87     'FOK':'correct executions'
88 }
89
90 displayed_name = {
91     'AInvalidParam':'Invalid parameter',
92     'BResLeak':'Resource leak',
93 #    'BInitFini':'MPI call before initialization/after finalization',
94     'BReqLifecycle':'Request lifecycle',
95     'BLocalConcurrency':'Local concurrency',
96     'CMatch':'Parameter matching',
97     'DMatch':"Call ordering",
98     'DRace':'Message race',
99     'DGlobalConcurrency':'Global concurrency',
100     'EBufferingHazard':'Buffering hazard',
101     'FOK':"Correct execution",
102
103     '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'
104 }
105
106 def parse_one_code(filename):
107     """
108     Reads the header of the provided filename, and extract a list of todo item, each of them being a (cmd, expect, test_num) tupple.
109     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.
110     """
111     res = []
112     test_num = 0
113     with open(filename, "r") as input_file:
114         state = 0  # 0: before header; 1: in header; 2; after header
115         line_num = 1
116         for line in input_file:
117             if re.match(".*BEGIN_MBI_TESTS.*", line):
118                 if state == 0:
119                     state = 1
120                 else:
121                     raise ValueError(f"MBI_TESTS header appears a second time at line {line_num}: \n{line}")
122             elif re.match(".*END_MBI_TESTS.*", line):
123                 if state == 1:
124                     state = 2
125                 else:
126                     raise ValueError(f"Unexpected end of MBI_TESTS header at line {line_num}: \n{line}")
127             if state == 1 and re.match(r'\s+\$ ?.*', line):
128                 m = re.match(r'\s+\$ ?(.*)', line)
129                 cmd = m.group(1)
130                 nextline = next(input_file)
131                 detail = 'OK'
132                 if re.match('[ |]*OK *', nextline):
133                     expect = 'OK'
134                 else:
135                     m = re.match('[ |]*ERROR: *(.*)', nextline)
136                     if not m:
137                         raise ValueError(
138                             f"\n{filename}:{line_num}: MBI parse error: Test not followed by a proper 'ERROR' line:\n{line}{nextline}")
139                     expect = 'ERROR'
140                     detail = m.group(1)
141                     if detail not in possible_details:
142                         raise ValueError(
143                             f"\n{filename}:{line_num}: MBI parse error: Detailled outcome {detail} is not one of the allowed ones.")
144
145                 nextline = next(input_file)
146                 m = re.match('[ |]*(.*)', nextline)
147                 if not m:
148                     raise ValueError(f"\n{filename}:{line_num}: MBI parse error: Expected diagnostic of the test not found.\n")
149                 diagnostic = m.group(1)
150
151                 test = {'filename': filename, 'id': test_num, 'cmd': cmd, 'expect': expect, 'detail': detail, 'diagnostic': diagnostic}
152                 res.append(test.copy())
153                 test_num += 1
154                 line_num += 1
155
156     if state == 0:
157         raise ValueError(f"MBI_TESTS header not found in file '{filename}'.")
158     if state == 1:
159         raise ValueError(f"MBI_TESTS header not properly ended in file '{filename}'.")
160
161     if len(res) == 0:
162         raise ValueError(f"No test found in {filename}. Please fix it.")
163     return res
164
165 def categorize(tool, toolname, test_id, expected):
166     outcome = tool.parse(test_id)
167
168     if not os.path.exists(f'{test_id}.elapsed') and not os.path.exists(f'logs/{toolname}/{test_id}.elapsed'):
169         if outcome == 'failure':
170             elapsed = 0
171         else:
172             raise ValueError(f"Invalid test result: {test_id}.txt exists but not {test_id}.elapsed")
173     else:
174         with open(f'{test_id}.elapsed' if os.path.exists(f'{test_id}.elapsed') else f'logs/{toolname}/{test_id}.elapsed', 'r') as infile:
175             elapsed = infile.read()
176
177     # Properly categorize this run
178     if outcome == 'timeout':
179         res_category = 'timeout'
180         if elapsed is None:
181             diagnostic = 'hard timeout'
182         else:
183             diagnostic = f'timeout after {elapsed} sec'
184     elif outcome == 'failure' or outcome == 'segfault':
185         res_category = 'failure'
186         diagnostic = 'tool error, or test not run'
187     elif outcome == 'UNIMPLEMENTED':
188         res_category = 'unimplemented'
189         diagnostic = 'coverage issue'
190     elif outcome == 'other':
191         res_category = 'other'
192         diagnostic = 'inconclusive run'
193     elif expected == 'OK':
194         if outcome == 'OK':
195             res_category = 'TRUE_NEG'
196             diagnostic = 'correctly reported no error'
197         else:
198             res_category = 'FALSE_POS'
199             diagnostic = 'reported an error in a correct code'
200     elif expected == 'ERROR':
201         if outcome == 'OK':
202             res_category = 'FALSE_NEG'
203             diagnostic = 'failed to detect an error'
204         else:
205             res_category = 'TRUE_POS'
206             diagnostic = 'correctly detected an error'
207     else:
208         raise ValueError(f"Unexpected expectation: {expected} (must be OK or ERROR)")
209
210     return (res_category, elapsed, diagnostic, outcome)
211
212
213 def run_cmd(buildcmd, execcmd, cachefile, filename, binary, timeout, batchinfo, read_line_lambda=None):
214     """
215     Runs the test on need. Returns True if the test was ran, and False if it was cached.
216
217     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.
218
219     Parameters:
220      - 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.
221      - cachefile is the name of the test
222      - filename is the source file containing the code
223      - binary the file name in which to compile the code
224      - batchinfo: something like "1/1" to say that this run is the only batch (see -b parameter of MBI.py)
225      - 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.
226     """
227     if os.path.exists(f'{cachefile}.txt') and os.path.exists(f'{cachefile}.elapsed') and os.path.exists(f'{cachefile}.md5sum'):
228         hash_md5 = hashlib.md5()
229         with open(filename, 'rb') as sourcefile:
230             for chunk in iter(lambda: sourcefile.read(4096), b""):
231                 hash_md5.update(chunk)
232         newdigest = hash_md5.hexdigest()
233         with open(f'{cachefile}.md5sum', 'r') as md5file:
234             olddigest = md5file.read()
235         #print(f'Old digest: {olddigest}; New digest: {newdigest}')
236         if olddigest == newdigest:
237             print(f" (result cached -- digest: {olddigest})")
238             return False
239         os.remove(f'{cachefile}.txt')
240
241     print(f"Wait up to {timeout} seconds")
242
243     start_time = time.time()
244     if buildcmd is None:
245         output = f"No need to compile {binary}.c (batchinfo:{batchinfo})\n\n"
246     else:
247         output = f"Compiling {binary}.c (batchinfo:{batchinfo})\n\n"
248         output += f"$ {buildcmd}\n"
249
250         compil = subprocess.run(buildcmd, shell=True, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
251         if compil.stdout is not None:
252             output += str(compil.stdout, errors='replace')
253         if compil.returncode != 0:
254             output += f"Compilation of {binary}.c raised an error (retcode: {compil.returncode})"
255             for line in (output.split('\n')):
256                 print(f"| {line}", file=sys.stderr)
257             with open(f'{cachefile}.elapsed', 'w') as outfile:
258                 outfile.write(str(time.time() - start_time))
259             with open(f'{cachefile}.txt', 'w') as outfile:
260                 outfile.write(output)
261             return True
262
263     output += f"\n\nExecuting the command\n $ {execcmd}\n"
264     for line in (output.split('\n')):
265         print(f"| {line}", file=sys.stderr)
266
267     # We run the subprocess and parse its output line by line, so that we can kill it as soon as it detects a timeout
268     process = subprocess.Popen(shlex.split(execcmd), stdout=subprocess.PIPE,
269                                stderr=subprocess.STDOUT, preexec_fn=os.setsid)
270     poll_obj = select.poll()
271     poll_obj.register(process.stdout, select.POLLIN)
272
273     pid = process.pid
274     pgid = os.getpgid(pid)  # We need that to forcefully kill subprocesses when leaving
275     while True:
276         if poll_obj.poll(5):  # Something to read? Do check the timeout status every 5 sec if not
277             line = process.stdout.readline()
278             # From byte array to string, replacing non-representable strings with question marks
279             line = str(line, errors='replace')
280             output = output + line
281             print(f"| {line}", end='', file=sys.stderr)
282             if read_line_lambda != None:
283                 read_line_lambda(line, process)
284         if time.time() - start_time > timeout:
285             with open(f'{cachefile}.timeout', 'w') as outfile:
286                 outfile.write(f'{time.time() - start_time} seconds')
287             break
288         if process.poll() is not None:  # The subprocess ended. Grab all existing output, and return
289             line = 'more'
290             while line != None and line != '':
291                 line = process.stdout.readline()
292                 if line is not None:
293                     # From byte array to string, replacing non-representable strings with question marks
294                     line = str(line, errors='replace')
295                     output = output + line
296                     print(f"| {line}", end='', file=sys.stderr)
297
298             break
299
300     # 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 :(
301     try:
302         os.killpg(pgid, signal.SIGTERM)  # Terminate all forked processes, to make sure it's clean whatever the tool does
303         process.terminate()  # No op if it's already stopped but useful on timeouts
304         time.sleep(0.2)  # allow some time for the tool to finish its childs
305         os.killpg(pgid, signal.SIGKILL)  # Finish 'em all, manually
306         os.kill(pid, signal.SIGKILL)  # die! die! die!
307     except ProcessLookupError:
308         pass  # OK, it's gone now
309
310     elapsed = time.time() - start_time
311
312     rc = process.poll()
313     if rc < 0:
314         status = f"Command killed by signal {-rc}, elapsed time: {elapsed}\n"
315     else:
316         status = f"Command return code: {rc}, elapsed time: {elapsed}\n"
317     print(status)
318     output += status
319
320     with open(f'{cachefile}.elapsed', 'w') as outfile:
321         outfile.write(str(elapsed))
322
323     with open(f'{cachefile}.txt', 'w') as outfile:
324         outfile.write(output)
325     with open(f'{cachefile}.md5sum', 'w') as outfile:
326         hashed = hashlib.md5()
327         with open(filename, 'rb') as sourcefile:
328             for chunk in iter(lambda: sourcefile.read(4096), b""):
329                 hashed.update(chunk)
330         outfile.write(hashed.hexdigest())
331
332     return True