Wed, 31 Dec 2014 07:16:47 +0100
Revert simplistic fix pending revisit of Mozilla integration attempt.
michael@0 | 1 | # |
michael@0 | 2 | # This Source Code Form is subject to the terms of the Mozilla Public |
michael@0 | 3 | # License, v. 2.0. If a copy of the MPL was not distributed with this |
michael@0 | 4 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. |
michael@0 | 5 | |
michael@0 | 6 | from __future__ import with_statement |
michael@0 | 7 | import glob, logging, os, platform, shutil, subprocess, sys, tempfile, urllib2, zipfile |
michael@0 | 8 | import base64 |
michael@0 | 9 | import re |
michael@0 | 10 | import os |
michael@0 | 11 | from urlparse import urlparse |
michael@0 | 12 | from operator import itemgetter |
michael@0 | 13 | import signal |
michael@0 | 14 | |
michael@0 | 15 | try: |
michael@0 | 16 | import mozinfo |
michael@0 | 17 | except ImportError: |
michael@0 | 18 | # Stub out fake mozinfo since this is not importable on Android 4.0 Opt. |
michael@0 | 19 | # This should be fixed; see |
michael@0 | 20 | # https://bugzilla.mozilla.org/show_bug.cgi?id=650881 |
michael@0 | 21 | mozinfo = type('mozinfo', (), dict(info={}))() |
michael@0 | 22 | mozinfo.isWin = mozinfo.isLinux = mozinfo.isUnix = mozinfo.isMac = False |
michael@0 | 23 | |
michael@0 | 24 | # TODO! FILE: localautomation :/ |
michael@0 | 25 | # mapping from would-be mozinfo attr <-> sys.platform |
michael@0 | 26 | mapping = {'isMac': ['mac', 'darwin'], |
michael@0 | 27 | 'isLinux': ['linux', 'linux2'], |
michael@0 | 28 | 'isWin': ['win32', 'win64'], |
michael@0 | 29 | } |
michael@0 | 30 | mapping = dict(sum([[(value, key) for value in values] for key, values in mapping.items()], [])) |
michael@0 | 31 | attr = mapping.get(sys.platform) |
michael@0 | 32 | if attr: |
michael@0 | 33 | setattr(mozinfo, attr, True) |
michael@0 | 34 | if mozinfo.isLinux: |
michael@0 | 35 | mozinfo.isUnix = True |
michael@0 | 36 | |
michael@0 | 37 | __all__ = [ |
michael@0 | 38 | "ZipFileReader", |
michael@0 | 39 | "addCommonOptions", |
michael@0 | 40 | "dumpLeakLog", |
michael@0 | 41 | "isURL", |
michael@0 | 42 | "processLeakLog", |
michael@0 | 43 | "getDebuggerInfo", |
michael@0 | 44 | "DEBUGGER_INFO", |
michael@0 | 45 | "replaceBackSlashes", |
michael@0 | 46 | 'KeyValueParseError', |
michael@0 | 47 | 'parseKeyValue', |
michael@0 | 48 | 'systemMemory', |
michael@0 | 49 | 'environment', |
michael@0 | 50 | 'dumpScreen', |
michael@0 | 51 | "ShutdownLeaks" |
michael@0 | 52 | ] |
michael@0 | 53 | |
michael@0 | 54 | # Map of debugging programs to information about them, like default arguments |
michael@0 | 55 | # and whether or not they are interactive. |
michael@0 | 56 | DEBUGGER_INFO = { |
michael@0 | 57 | # gdb requires that you supply the '--args' flag in order to pass arguments |
michael@0 | 58 | # after the executable name to the executable. |
michael@0 | 59 | "gdb": { |
michael@0 | 60 | "interactive": True, |
michael@0 | 61 | "args": "-q --args" |
michael@0 | 62 | }, |
michael@0 | 63 | |
michael@0 | 64 | "cgdb": { |
michael@0 | 65 | "interactive": True, |
michael@0 | 66 | "args": "-q --args" |
michael@0 | 67 | }, |
michael@0 | 68 | |
michael@0 | 69 | "lldb": { |
michael@0 | 70 | "interactive": True, |
michael@0 | 71 | "args": "--", |
michael@0 | 72 | "requiresEscapedArgs": True |
michael@0 | 73 | }, |
michael@0 | 74 | |
michael@0 | 75 | # valgrind doesn't explain much about leaks unless you set the |
michael@0 | 76 | # '--leak-check=full' flag. But there are a lot of objects that are |
michael@0 | 77 | # semi-deliberately leaked, so we set '--show-possibly-lost=no' to avoid |
michael@0 | 78 | # uninteresting output from those objects. We set '--smc-check==all-non-file' |
michael@0 | 79 | # and '--vex-iropt-register-updates=allregs-at-mem-access' so that valgrind |
michael@0 | 80 | # deals properly with JIT'd JavaScript code. |
michael@0 | 81 | "valgrind": { |
michael@0 | 82 | "interactive": False, |
michael@0 | 83 | "args": " ".join(["--leak-check=full", |
michael@0 | 84 | "--show-possibly-lost=no", |
michael@0 | 85 | "--smc-check=all-non-file", |
michael@0 | 86 | "--vex-iropt-register-updates=allregs-at-mem-access"]) |
michael@0 | 87 | } |
michael@0 | 88 | } |
michael@0 | 89 | |
michael@0 | 90 | class ZipFileReader(object): |
michael@0 | 91 | """ |
michael@0 | 92 | Class to read zip files in Python 2.5 and later. Limited to only what we |
michael@0 | 93 | actually use. |
michael@0 | 94 | """ |
michael@0 | 95 | |
michael@0 | 96 | def __init__(self, filename): |
michael@0 | 97 | self._zipfile = zipfile.ZipFile(filename, "r") |
michael@0 | 98 | |
michael@0 | 99 | def __del__(self): |
michael@0 | 100 | self._zipfile.close() |
michael@0 | 101 | |
michael@0 | 102 | def _getnormalizedpath(self, path): |
michael@0 | 103 | """ |
michael@0 | 104 | Gets a normalized path from 'path' (or the current working directory if |
michael@0 | 105 | 'path' is None). Also asserts that the path exists. |
michael@0 | 106 | """ |
michael@0 | 107 | if path is None: |
michael@0 | 108 | path = os.curdir |
michael@0 | 109 | path = os.path.normpath(os.path.expanduser(path)) |
michael@0 | 110 | assert os.path.isdir(path) |
michael@0 | 111 | return path |
michael@0 | 112 | |
michael@0 | 113 | def _extractname(self, name, path): |
michael@0 | 114 | """ |
michael@0 | 115 | Extracts a file with the given name from the zip file to the given path. |
michael@0 | 116 | Also creates any directories needed along the way. |
michael@0 | 117 | """ |
michael@0 | 118 | filename = os.path.normpath(os.path.join(path, name)) |
michael@0 | 119 | if name.endswith("/"): |
michael@0 | 120 | os.makedirs(filename) |
michael@0 | 121 | else: |
michael@0 | 122 | path = os.path.split(filename)[0] |
michael@0 | 123 | if not os.path.isdir(path): |
michael@0 | 124 | os.makedirs(path) |
michael@0 | 125 | with open(filename, "wb") as dest: |
michael@0 | 126 | dest.write(self._zipfile.read(name)) |
michael@0 | 127 | |
michael@0 | 128 | def namelist(self): |
michael@0 | 129 | return self._zipfile.namelist() |
michael@0 | 130 | |
michael@0 | 131 | def read(self, name): |
michael@0 | 132 | return self._zipfile.read(name) |
michael@0 | 133 | |
michael@0 | 134 | def extract(self, name, path = None): |
michael@0 | 135 | if hasattr(self._zipfile, "extract"): |
michael@0 | 136 | return self._zipfile.extract(name, path) |
michael@0 | 137 | |
michael@0 | 138 | # This will throw if name is not part of the zip file. |
michael@0 | 139 | self._zipfile.getinfo(name) |
michael@0 | 140 | |
michael@0 | 141 | self._extractname(name, self._getnormalizedpath(path)) |
michael@0 | 142 | |
michael@0 | 143 | def extractall(self, path = None): |
michael@0 | 144 | if hasattr(self._zipfile, "extractall"): |
michael@0 | 145 | return self._zipfile.extractall(path) |
michael@0 | 146 | |
michael@0 | 147 | path = self._getnormalizedpath(path) |
michael@0 | 148 | |
michael@0 | 149 | for name in self._zipfile.namelist(): |
michael@0 | 150 | self._extractname(name, path) |
michael@0 | 151 | |
michael@0 | 152 | log = logging.getLogger() |
michael@0 | 153 | |
michael@0 | 154 | def isURL(thing): |
michael@0 | 155 | """Return True if |thing| looks like a URL.""" |
michael@0 | 156 | # We want to download URLs like http://... but not Windows paths like c:\... |
michael@0 | 157 | return len(urlparse(thing).scheme) >= 2 |
michael@0 | 158 | |
michael@0 | 159 | # Python does not provide strsignal() even in the very latest 3.x. |
michael@0 | 160 | # This is a reasonable fake. |
michael@0 | 161 | def strsig(n): |
michael@0 | 162 | # Signal numbers run 0 through NSIG-1; an array with NSIG members |
michael@0 | 163 | # has exactly that many slots |
michael@0 | 164 | _sigtbl = [None]*signal.NSIG |
michael@0 | 165 | for k in dir(signal): |
michael@0 | 166 | if k.startswith("SIG") and not k.startswith("SIG_") and k != "SIGCLD" and k != "SIGPOLL": |
michael@0 | 167 | _sigtbl[getattr(signal, k)] = k |
michael@0 | 168 | # Realtime signals mostly have no names |
michael@0 | 169 | if hasattr(signal, "SIGRTMIN") and hasattr(signal, "SIGRTMAX"): |
michael@0 | 170 | for r in range(signal.SIGRTMIN+1, signal.SIGRTMAX+1): |
michael@0 | 171 | _sigtbl[r] = "SIGRTMIN+" + str(r - signal.SIGRTMIN) |
michael@0 | 172 | # Fill in any remaining gaps |
michael@0 | 173 | for i in range(signal.NSIG): |
michael@0 | 174 | if _sigtbl[i] is None: |
michael@0 | 175 | _sigtbl[i] = "unrecognized signal, number " + str(i) |
michael@0 | 176 | if n < 0 or n >= signal.NSIG: |
michael@0 | 177 | return "out-of-range signal, number "+str(n) |
michael@0 | 178 | return _sigtbl[n] |
michael@0 | 179 | |
michael@0 | 180 | def printstatus(status, name = ""): |
michael@0 | 181 | # 'status' is the exit status |
michael@0 | 182 | if os.name != 'posix': |
michael@0 | 183 | # Windows error codes are easier to look up if printed in hexadecimal |
michael@0 | 184 | if status < 0: |
michael@0 | 185 | status += 2**32 |
michael@0 | 186 | print "TEST-INFO | %s: exit status %x\n" % (name, status) |
michael@0 | 187 | elif os.WIFEXITED(status): |
michael@0 | 188 | print "TEST-INFO | %s: exit %d\n" % (name, os.WEXITSTATUS(status)) |
michael@0 | 189 | elif os.WIFSIGNALED(status): |
michael@0 | 190 | # The python stdlib doesn't appear to have strsignal(), alas |
michael@0 | 191 | print "TEST-INFO | {}: killed by {}".format(name,strsig(os.WTERMSIG(status))) |
michael@0 | 192 | else: |
michael@0 | 193 | # This is probably a can't-happen condition on Unix, but let's be defensive |
michael@0 | 194 | print "TEST-INFO | %s: undecodable exit status %04x\n" % (name, status) |
michael@0 | 195 | |
michael@0 | 196 | def addCommonOptions(parser, defaults={}): |
michael@0 | 197 | parser.add_option("--xre-path", |
michael@0 | 198 | action = "store", type = "string", dest = "xrePath", |
michael@0 | 199 | # individual scripts will set a sane default |
michael@0 | 200 | default = None, |
michael@0 | 201 | help = "absolute path to directory containing XRE (probably xulrunner)") |
michael@0 | 202 | if 'SYMBOLS_PATH' not in defaults: |
michael@0 | 203 | defaults['SYMBOLS_PATH'] = None |
michael@0 | 204 | parser.add_option("--symbols-path", |
michael@0 | 205 | action = "store", type = "string", dest = "symbolsPath", |
michael@0 | 206 | default = defaults['SYMBOLS_PATH'], |
michael@0 | 207 | help = "absolute path to directory containing breakpad symbols, or the URL of a zip file containing symbols") |
michael@0 | 208 | parser.add_option("--debugger", |
michael@0 | 209 | action = "store", dest = "debugger", |
michael@0 | 210 | help = "use the given debugger to launch the application") |
michael@0 | 211 | parser.add_option("--debugger-args", |
michael@0 | 212 | action = "store", dest = "debuggerArgs", |
michael@0 | 213 | help = "pass the given args to the debugger _before_ " |
michael@0 | 214 | "the application on the command line") |
michael@0 | 215 | parser.add_option("--debugger-interactive", |
michael@0 | 216 | action = "store_true", dest = "debuggerInteractive", |
michael@0 | 217 | help = "prevents the test harness from redirecting " |
michael@0 | 218 | "stdout and stderr for interactive debuggers") |
michael@0 | 219 | |
michael@0 | 220 | def getFullPath(directory, path): |
michael@0 | 221 | "Get an absolute path relative to 'directory'." |
michael@0 | 222 | return os.path.normpath(os.path.join(directory, os.path.expanduser(path))) |
michael@0 | 223 | |
michael@0 | 224 | def searchPath(directory, path): |
michael@0 | 225 | "Go one step beyond getFullPath and try the various folders in PATH" |
michael@0 | 226 | # Try looking in the current working directory first. |
michael@0 | 227 | newpath = getFullPath(directory, path) |
michael@0 | 228 | if os.path.isfile(newpath): |
michael@0 | 229 | return newpath |
michael@0 | 230 | |
michael@0 | 231 | # At this point we have to fail if a directory was given (to prevent cases |
michael@0 | 232 | # like './gdb' from matching '/usr/bin/./gdb'). |
michael@0 | 233 | if not os.path.dirname(path): |
michael@0 | 234 | for dir in os.environ['PATH'].split(os.pathsep): |
michael@0 | 235 | newpath = os.path.join(dir, path) |
michael@0 | 236 | if os.path.isfile(newpath): |
michael@0 | 237 | return newpath |
michael@0 | 238 | return None |
michael@0 | 239 | |
michael@0 | 240 | def getDebuggerInfo(directory, debugger, debuggerArgs, debuggerInteractive = False): |
michael@0 | 241 | |
michael@0 | 242 | debuggerInfo = None |
michael@0 | 243 | |
michael@0 | 244 | if debugger: |
michael@0 | 245 | debuggerPath = searchPath(directory, debugger) |
michael@0 | 246 | if not debuggerPath: |
michael@0 | 247 | print "Error: Path %s doesn't exist." % debugger |
michael@0 | 248 | sys.exit(1) |
michael@0 | 249 | |
michael@0 | 250 | debuggerName = os.path.basename(debuggerPath).lower() |
michael@0 | 251 | |
michael@0 | 252 | def getDebuggerInfo(type, default): |
michael@0 | 253 | if debuggerName in DEBUGGER_INFO and type in DEBUGGER_INFO[debuggerName]: |
michael@0 | 254 | return DEBUGGER_INFO[debuggerName][type] |
michael@0 | 255 | return default |
michael@0 | 256 | |
michael@0 | 257 | debuggerInfo = { |
michael@0 | 258 | "path": debuggerPath, |
michael@0 | 259 | "interactive" : getDebuggerInfo("interactive", False), |
michael@0 | 260 | "args": getDebuggerInfo("args", "").split(), |
michael@0 | 261 | "requiresEscapedArgs": getDebuggerInfo("requiresEscapedArgs", False) |
michael@0 | 262 | } |
michael@0 | 263 | |
michael@0 | 264 | if debuggerArgs: |
michael@0 | 265 | debuggerInfo["args"] = debuggerArgs.split() |
michael@0 | 266 | if debuggerInteractive: |
michael@0 | 267 | debuggerInfo["interactive"] = debuggerInteractive |
michael@0 | 268 | |
michael@0 | 269 | return debuggerInfo |
michael@0 | 270 | |
michael@0 | 271 | |
michael@0 | 272 | def dumpLeakLog(leakLogFile, filter = False): |
michael@0 | 273 | """Process the leak log, without parsing it. |
michael@0 | 274 | |
michael@0 | 275 | Use this function if you want the raw log only. |
michael@0 | 276 | Use it preferably with the |XPCOM_MEM_LEAK_LOG| environment variable. |
michael@0 | 277 | """ |
michael@0 | 278 | |
michael@0 | 279 | # Don't warn (nor "info") if the log file is not there. |
michael@0 | 280 | if not os.path.exists(leakLogFile): |
michael@0 | 281 | return |
michael@0 | 282 | |
michael@0 | 283 | with open(leakLogFile, "r") as leaks: |
michael@0 | 284 | leakReport = leaks.read() |
michael@0 | 285 | |
michael@0 | 286 | # Only |XPCOM_MEM_LEAK_LOG| reports can be actually filtered out. |
michael@0 | 287 | # Only check whether an actual leak was reported. |
michael@0 | 288 | if filter and not "0 TOTAL " in leakReport: |
michael@0 | 289 | return |
michael@0 | 290 | |
michael@0 | 291 | # Simply copy the log. |
michael@0 | 292 | log.info(leakReport.rstrip("\n")) |
michael@0 | 293 | |
michael@0 | 294 | def processSingleLeakFile(leakLogFileName, processType, leakThreshold): |
michael@0 | 295 | """Process a single leak log. |
michael@0 | 296 | """ |
michael@0 | 297 | |
michael@0 | 298 | # Per-Inst Leaked Total Rem ... |
michael@0 | 299 | # 0 TOTAL 17 192 419115886 2 ... |
michael@0 | 300 | # 833 nsTimerImpl 60 120 24726 2 ... |
michael@0 | 301 | lineRe = re.compile(r"^\s*\d+\s+(?P<name>\S+)\s+" |
michael@0 | 302 | r"(?P<size>-?\d+)\s+(?P<bytesLeaked>-?\d+)\s+" |
michael@0 | 303 | r"-?\d+\s+(?P<numLeaked>-?\d+)") |
michael@0 | 304 | |
michael@0 | 305 | processString = "" |
michael@0 | 306 | if processType: |
michael@0 | 307 | # eg 'plugin' |
michael@0 | 308 | processString = " %s process:" % processType |
michael@0 | 309 | |
michael@0 | 310 | crashedOnPurpose = False |
michael@0 | 311 | totalBytesLeaked = None |
michael@0 | 312 | leakAnalysis = [] |
michael@0 | 313 | leakedObjectNames = [] |
michael@0 | 314 | with open(leakLogFileName, "r") as leaks: |
michael@0 | 315 | for line in leaks: |
michael@0 | 316 | if line.find("purposefully crash") > -1: |
michael@0 | 317 | crashedOnPurpose = True |
michael@0 | 318 | matches = lineRe.match(line) |
michael@0 | 319 | if not matches: |
michael@0 | 320 | # eg: the leak table header row |
michael@0 | 321 | log.info(line.rstrip()) |
michael@0 | 322 | continue |
michael@0 | 323 | name = matches.group("name") |
michael@0 | 324 | size = int(matches.group("size")) |
michael@0 | 325 | bytesLeaked = int(matches.group("bytesLeaked")) |
michael@0 | 326 | numLeaked = int(matches.group("numLeaked")) |
michael@0 | 327 | # Output the raw line from the leak log table if it is the TOTAL row, |
michael@0 | 328 | # or is for an object row that has been leaked. |
michael@0 | 329 | if numLeaked != 0 or name == "TOTAL": |
michael@0 | 330 | log.info(line.rstrip()) |
michael@0 | 331 | # Analyse the leak log, but output later or it will interrupt the leak table |
michael@0 | 332 | if name == "TOTAL": |
michael@0 | 333 | totalBytesLeaked = bytesLeaked |
michael@0 | 334 | if size < 0 or bytesLeaked < 0 or numLeaked < 0: |
michael@0 | 335 | leakAnalysis.append("TEST-UNEXPECTED-FAIL | leakcheck |%s negative leaks caught!" |
michael@0 | 336 | % processString) |
michael@0 | 337 | continue |
michael@0 | 338 | if name != "TOTAL" and numLeaked != 0: |
michael@0 | 339 | leakedObjectNames.append(name) |
michael@0 | 340 | leakAnalysis.append("TEST-INFO | leakcheck |%s leaked %d %s (%s bytes)" |
michael@0 | 341 | % (processString, numLeaked, name, bytesLeaked)) |
michael@0 | 342 | log.info('\n'.join(leakAnalysis)) |
michael@0 | 343 | |
michael@0 | 344 | if totalBytesLeaked is None: |
michael@0 | 345 | # We didn't see a line with name 'TOTAL' |
michael@0 | 346 | if crashedOnPurpose: |
michael@0 | 347 | log.info("TEST-INFO | leakcheck |%s deliberate crash and thus no leak log" |
michael@0 | 348 | % processString) |
michael@0 | 349 | else: |
michael@0 | 350 | # TODO: This should be a TEST-UNEXPECTED-FAIL, but was changed to a warning |
michael@0 | 351 | # due to too many intermittent failures (see bug 831223). |
michael@0 | 352 | log.info("WARNING | leakcheck |%s missing output line for total leaks!" |
michael@0 | 353 | % processString) |
michael@0 | 354 | return |
michael@0 | 355 | |
michael@0 | 356 | if totalBytesLeaked == 0: |
michael@0 | 357 | log.info("TEST-PASS | leakcheck |%s no leaks detected!" % processString) |
michael@0 | 358 | return |
michael@0 | 359 | |
michael@0 | 360 | # totalBytesLeaked was seen and is non-zero. |
michael@0 | 361 | if totalBytesLeaked > leakThreshold: |
michael@0 | 362 | # Fail the run if we're over the threshold (which defaults to 0) |
michael@0 | 363 | prefix = "TEST-UNEXPECTED-FAIL" |
michael@0 | 364 | else: |
michael@0 | 365 | prefix = "WARNING" |
michael@0 | 366 | # Create a comma delimited string of the first N leaked objects found, |
michael@0 | 367 | # to aid with bug summary matching in TBPL. Note: The order of the objects |
michael@0 | 368 | # had no significance (they're sorted alphabetically). |
michael@0 | 369 | maxSummaryObjects = 5 |
michael@0 | 370 | leakedObjectSummary = ', '.join(leakedObjectNames[:maxSummaryObjects]) |
michael@0 | 371 | if len(leakedObjectNames) > maxSummaryObjects: |
michael@0 | 372 | leakedObjectSummary += ', ...' |
michael@0 | 373 | log.info("%s | leakcheck |%s %d bytes leaked (%s)" |
michael@0 | 374 | % (prefix, processString, totalBytesLeaked, leakedObjectSummary)) |
michael@0 | 375 | |
michael@0 | 376 | def processLeakLog(leakLogFile, leakThreshold = 0): |
michael@0 | 377 | """Process the leak log, including separate leak logs created |
michael@0 | 378 | by child processes. |
michael@0 | 379 | |
michael@0 | 380 | Use this function if you want an additional PASS/FAIL summary. |
michael@0 | 381 | It must be used with the |XPCOM_MEM_BLOAT_LOG| environment variable. |
michael@0 | 382 | """ |
michael@0 | 383 | |
michael@0 | 384 | if not os.path.exists(leakLogFile): |
michael@0 | 385 | log.info("WARNING | leakcheck | refcount logging is off, so leaks can't be detected!") |
michael@0 | 386 | return |
michael@0 | 387 | |
michael@0 | 388 | if leakThreshold != 0: |
michael@0 | 389 | log.info("TEST-INFO | leakcheck | threshold set at %d bytes" % leakThreshold) |
michael@0 | 390 | |
michael@0 | 391 | (leakLogFileDir, leakFileBase) = os.path.split(leakLogFile) |
michael@0 | 392 | fileNameRegExp = re.compile(r".*?_([a-z]*)_pid\d*$") |
michael@0 | 393 | if leakFileBase[-4:] == ".log": |
michael@0 | 394 | leakFileBase = leakFileBase[:-4] |
michael@0 | 395 | fileNameRegExp = re.compile(r".*?_([a-z]*)_pid\d*.log$") |
michael@0 | 396 | |
michael@0 | 397 | for fileName in os.listdir(leakLogFileDir): |
michael@0 | 398 | if fileName.find(leakFileBase) != -1: |
michael@0 | 399 | thisFile = os.path.join(leakLogFileDir, fileName) |
michael@0 | 400 | processType = None |
michael@0 | 401 | m = fileNameRegExp.search(fileName) |
michael@0 | 402 | if m: |
michael@0 | 403 | processType = m.group(1) |
michael@0 | 404 | processSingleLeakFile(thisFile, processType, leakThreshold) |
michael@0 | 405 | |
michael@0 | 406 | def replaceBackSlashes(input): |
michael@0 | 407 | return input.replace('\\', '/') |
michael@0 | 408 | |
michael@0 | 409 | class KeyValueParseError(Exception): |
michael@0 | 410 | """error when parsing strings of serialized key-values""" |
michael@0 | 411 | def __init__(self, msg, errors=()): |
michael@0 | 412 | self.errors = errors |
michael@0 | 413 | Exception.__init__(self, msg) |
michael@0 | 414 | |
michael@0 | 415 | def parseKeyValue(strings, separator='=', context='key, value: '): |
michael@0 | 416 | """ |
michael@0 | 417 | parse string-serialized key-value pairs in the form of |
michael@0 | 418 | `key = value`. Returns a list of 2-tuples. |
michael@0 | 419 | Note that whitespace is not stripped. |
michael@0 | 420 | """ |
michael@0 | 421 | |
michael@0 | 422 | # syntax check |
michael@0 | 423 | missing = [string for string in strings if separator not in string] |
michael@0 | 424 | if missing: |
michael@0 | 425 | raise KeyValueParseError("Error: syntax error in %s" % (context, |
michael@0 | 426 | ','.join(missing)), |
michael@0 | 427 | errors=missing) |
michael@0 | 428 | return [string.split(separator, 1) for string in strings] |
michael@0 | 429 | |
michael@0 | 430 | def systemMemory(): |
michael@0 | 431 | """ |
michael@0 | 432 | Returns total system memory in kilobytes. |
michael@0 | 433 | Works only on unix-like platforms where `free` is in the path. |
michael@0 | 434 | """ |
michael@0 | 435 | return int(os.popen("free").readlines()[1].split()[1]) |
michael@0 | 436 | |
michael@0 | 437 | def environment(xrePath, env=None, crashreporter=True, debugger=False, dmdPath=None): |
michael@0 | 438 | """populate OS environment variables for mochitest""" |
michael@0 | 439 | |
michael@0 | 440 | env = os.environ.copy() if env is None else env |
michael@0 | 441 | |
michael@0 | 442 | assert os.path.isabs(xrePath) |
michael@0 | 443 | |
michael@0 | 444 | ldLibraryPath = xrePath |
michael@0 | 445 | |
michael@0 | 446 | envVar = None |
michael@0 | 447 | dmdLibrary = None |
michael@0 | 448 | preloadEnvVar = None |
michael@0 | 449 | if mozinfo.isUnix: |
michael@0 | 450 | envVar = "LD_LIBRARY_PATH" |
michael@0 | 451 | env['MOZILLA_FIVE_HOME'] = xrePath |
michael@0 | 452 | dmdLibrary = "libdmd.so" |
michael@0 | 453 | preloadEnvVar = "LD_PRELOAD" |
michael@0 | 454 | elif mozinfo.isMac: |
michael@0 | 455 | envVar = "DYLD_LIBRARY_PATH" |
michael@0 | 456 | dmdLibrary = "libdmd.dylib" |
michael@0 | 457 | preloadEnvVar = "DYLD_INSERT_LIBRARIES" |
michael@0 | 458 | elif mozinfo.isWin: |
michael@0 | 459 | envVar = "PATH" |
michael@0 | 460 | dmdLibrary = "dmd.dll" |
michael@0 | 461 | preloadEnvVar = "MOZ_REPLACE_MALLOC_LIB" |
michael@0 | 462 | if envVar: |
michael@0 | 463 | envValue = ((env.get(envVar), str(ldLibraryPath)) |
michael@0 | 464 | if mozinfo.isWin |
michael@0 | 465 | else (ldLibraryPath, dmdPath, env.get(envVar))) |
michael@0 | 466 | env[envVar] = os.path.pathsep.join([path for path in envValue if path]) |
michael@0 | 467 | |
michael@0 | 468 | if dmdPath and dmdLibrary and preloadEnvVar: |
michael@0 | 469 | env['DMD'] = '1' |
michael@0 | 470 | env[preloadEnvVar] = os.path.join(dmdPath, dmdLibrary) |
michael@0 | 471 | |
michael@0 | 472 | # crashreporter |
michael@0 | 473 | env['GNOME_DISABLE_CRASH_DIALOG'] = '1' |
michael@0 | 474 | env['XRE_NO_WINDOWS_CRASH_DIALOG'] = '1' |
michael@0 | 475 | env['NS_TRACE_MALLOC_DISABLE_STACKS'] = '1' |
michael@0 | 476 | |
michael@0 | 477 | if crashreporter and not debugger: |
michael@0 | 478 | env['MOZ_CRASHREPORTER_NO_REPORT'] = '1' |
michael@0 | 479 | env['MOZ_CRASHREPORTER'] = '1' |
michael@0 | 480 | else: |
michael@0 | 481 | env['MOZ_CRASHREPORTER_DISABLE'] = '1' |
michael@0 | 482 | |
michael@0 | 483 | # Crash on non-local network connections. |
michael@0 | 484 | env['MOZ_DISABLE_NONLOCAL_CONNECTIONS'] = '1' |
michael@0 | 485 | |
michael@0 | 486 | # Set WebRTC logging in case it is not set yet |
michael@0 | 487 | env.setdefault('NSPR_LOG_MODULES', 'signaling:5,mtransport:5,datachannel:5') |
michael@0 | 488 | env.setdefault('R_LOG_LEVEL', '6') |
michael@0 | 489 | env.setdefault('R_LOG_DESTINATION', 'stderr') |
michael@0 | 490 | env.setdefault('R_LOG_VERBOSE', '1') |
michael@0 | 491 | |
michael@0 | 492 | # ASan specific environment stuff |
michael@0 | 493 | asan = bool(mozinfo.info.get("asan")) |
michael@0 | 494 | if asan and (mozinfo.isLinux or mozinfo.isMac): |
michael@0 | 495 | try: |
michael@0 | 496 | # Symbolizer support |
michael@0 | 497 | llvmsym = os.path.join(xrePath, "llvm-symbolizer") |
michael@0 | 498 | if os.path.isfile(llvmsym): |
michael@0 | 499 | env["ASAN_SYMBOLIZER_PATH"] = llvmsym |
michael@0 | 500 | log.info("ASan using symbolizer at %s", llvmsym) |
michael@0 | 501 | |
michael@0 | 502 | totalMemory = systemMemory() |
michael@0 | 503 | |
michael@0 | 504 | # Only 4 GB RAM or less available? Use custom ASan options to reduce |
michael@0 | 505 | # the amount of resources required to do the tests. Standard options |
michael@0 | 506 | # will otherwise lead to OOM conditions on the current test slaves. |
michael@0 | 507 | message = "INFO | runtests.py | ASan running in %s configuration" |
michael@0 | 508 | if totalMemory <= 1024 * 1024 * 4: |
michael@0 | 509 | message = message % 'low-memory' |
michael@0 | 510 | env["ASAN_OPTIONS"] = "quarantine_size=50331648:malloc_context_size=5" |
michael@0 | 511 | else: |
michael@0 | 512 | message = message % 'default memory' |
michael@0 | 513 | except OSError,err: |
michael@0 | 514 | log.info("Failed determine available memory, disabling ASan low-memory configuration: %s", err.strerror) |
michael@0 | 515 | except: |
michael@0 | 516 | log.info("Failed determine available memory, disabling ASan low-memory configuration") |
michael@0 | 517 | else: |
michael@0 | 518 | log.info(message) |
michael@0 | 519 | |
michael@0 | 520 | return env |
michael@0 | 521 | |
michael@0 | 522 | def dumpScreen(utilityPath): |
michael@0 | 523 | """dumps a screenshot of the entire screen to a directory specified by |
michael@0 | 524 | the MOZ_UPLOAD_DIR environment variable""" |
michael@0 | 525 | import mozfile |
michael@0 | 526 | |
michael@0 | 527 | # Need to figure out which OS-dependent tool to use |
michael@0 | 528 | if mozinfo.isUnix: |
michael@0 | 529 | utility = [os.path.join(utilityPath, "screentopng")] |
michael@0 | 530 | utilityname = "screentopng" |
michael@0 | 531 | elif mozinfo.isMac: |
michael@0 | 532 | utility = ['/usr/sbin/screencapture', '-C', '-x', '-t', 'png'] |
michael@0 | 533 | utilityname = "screencapture" |
michael@0 | 534 | elif mozinfo.isWin: |
michael@0 | 535 | utility = [os.path.join(utilityPath, "screenshot.exe")] |
michael@0 | 536 | utilityname = "screenshot" |
michael@0 | 537 | |
michael@0 | 538 | # Get dir where to write the screenshot file |
michael@0 | 539 | parent_dir = os.environ.get('MOZ_UPLOAD_DIR', None) |
michael@0 | 540 | if not parent_dir: |
michael@0 | 541 | log.info('Failed to retrieve MOZ_UPLOAD_DIR env var') |
michael@0 | 542 | return |
michael@0 | 543 | |
michael@0 | 544 | # Run the capture |
michael@0 | 545 | try: |
michael@0 | 546 | tmpfd, imgfilename = tempfile.mkstemp(prefix='mozilla-test-fail-screenshot_', suffix='.png', dir=parent_dir) |
michael@0 | 547 | os.close(tmpfd) |
michael@0 | 548 | returncode = subprocess.call(utility + [imgfilename]) |
michael@0 | 549 | printstatus(returncode, utilityname) |
michael@0 | 550 | except OSError, err: |
michael@0 | 551 | log.info("Failed to start %s for screenshot: %s", |
michael@0 | 552 | utility[0], err.strerror) |
michael@0 | 553 | return |
michael@0 | 554 | |
michael@0 | 555 | class ShutdownLeaks(object): |
michael@0 | 556 | """ |
michael@0 | 557 | Parses the mochitest run log when running a debug build, assigns all leaked |
michael@0 | 558 | DOM windows (that are still around after test suite shutdown, despite running |
michael@0 | 559 | the GC) to the tests that created them and prints leak statistics. |
michael@0 | 560 | """ |
michael@0 | 561 | |
michael@0 | 562 | def __init__(self, logger): |
michael@0 | 563 | self.logger = logger |
michael@0 | 564 | self.tests = [] |
michael@0 | 565 | self.leakedWindows = {} |
michael@0 | 566 | self.leakedDocShells = set() |
michael@0 | 567 | self.currentTest = None |
michael@0 | 568 | self.seenShutdown = False |
michael@0 | 569 | |
michael@0 | 570 | def log(self, line): |
michael@0 | 571 | if line[2:11] == "DOMWINDOW": |
michael@0 | 572 | self._logWindow(line) |
michael@0 | 573 | elif line[2:10] == "DOCSHELL": |
michael@0 | 574 | self._logDocShell(line) |
michael@0 | 575 | elif line.startswith("TEST-START"): |
michael@0 | 576 | fileName = line.split(" ")[-1].strip().replace("chrome://mochitests/content/browser/", "") |
michael@0 | 577 | self.currentTest = {"fileName": fileName, "windows": set(), "docShells": set()} |
michael@0 | 578 | elif line.startswith("INFO TEST-END"): |
michael@0 | 579 | # don't track a test if no windows or docShells leaked |
michael@0 | 580 | if self.currentTest and (self.currentTest["windows"] or self.currentTest["docShells"]): |
michael@0 | 581 | self.tests.append(self.currentTest) |
michael@0 | 582 | self.currentTest = None |
michael@0 | 583 | elif line.startswith("INFO TEST-START | Shutdown"): |
michael@0 | 584 | self.seenShutdown = True |
michael@0 | 585 | |
michael@0 | 586 | def process(self): |
michael@0 | 587 | for test in self._parseLeakingTests(): |
michael@0 | 588 | for url, count in self._zipLeakedWindows(test["leakedWindows"]): |
michael@0 | 589 | self.logger("TEST-UNEXPECTED-FAIL | %s | leaked %d window(s) until shutdown [url = %s]", test["fileName"], count, url) |
michael@0 | 590 | |
michael@0 | 591 | if test["leakedDocShells"]: |
michael@0 | 592 | self.logger("TEST-UNEXPECTED-FAIL | %s | leaked %d docShell(s) until shutdown", test["fileName"], len(test["leakedDocShells"])) |
michael@0 | 593 | |
michael@0 | 594 | def _logWindow(self, line): |
michael@0 | 595 | created = line[:2] == "++" |
michael@0 | 596 | pid = self._parseValue(line, "pid") |
michael@0 | 597 | serial = self._parseValue(line, "serial") |
michael@0 | 598 | |
michael@0 | 599 | # log line has invalid format |
michael@0 | 600 | if not pid or not serial: |
michael@0 | 601 | self.logger("TEST-UNEXPECTED-FAIL | ShutdownLeaks | failed to parse line <%s>", line) |
michael@0 | 602 | return |
michael@0 | 603 | |
michael@0 | 604 | key = pid + "." + serial |
michael@0 | 605 | |
michael@0 | 606 | if self.currentTest: |
michael@0 | 607 | windows = self.currentTest["windows"] |
michael@0 | 608 | if created: |
michael@0 | 609 | windows.add(key) |
michael@0 | 610 | else: |
michael@0 | 611 | windows.discard(key) |
michael@0 | 612 | elif self.seenShutdown and not created: |
michael@0 | 613 | self.leakedWindows[key] = self._parseValue(line, "url") |
michael@0 | 614 | |
michael@0 | 615 | def _logDocShell(self, line): |
michael@0 | 616 | created = line[:2] == "++" |
michael@0 | 617 | pid = self._parseValue(line, "pid") |
michael@0 | 618 | id = self._parseValue(line, "id") |
michael@0 | 619 | |
michael@0 | 620 | # log line has invalid format |
michael@0 | 621 | if not pid or not id: |
michael@0 | 622 | self.logger("TEST-UNEXPECTED-FAIL | ShutdownLeaks | failed to parse line <%s>", line) |
michael@0 | 623 | return |
michael@0 | 624 | |
michael@0 | 625 | key = pid + "." + id |
michael@0 | 626 | |
michael@0 | 627 | if self.currentTest: |
michael@0 | 628 | docShells = self.currentTest["docShells"] |
michael@0 | 629 | if created: |
michael@0 | 630 | docShells.add(key) |
michael@0 | 631 | else: |
michael@0 | 632 | docShells.discard(key) |
michael@0 | 633 | elif self.seenShutdown and not created: |
michael@0 | 634 | self.leakedDocShells.add(key) |
michael@0 | 635 | |
michael@0 | 636 | def _parseValue(self, line, name): |
michael@0 | 637 | match = re.search("\[%s = (.+?)\]" % name, line) |
michael@0 | 638 | if match: |
michael@0 | 639 | return match.group(1) |
michael@0 | 640 | return None |
michael@0 | 641 | |
michael@0 | 642 | def _parseLeakingTests(self): |
michael@0 | 643 | leakingTests = [] |
michael@0 | 644 | |
michael@0 | 645 | for test in self.tests: |
michael@0 | 646 | test["leakedWindows"] = [self.leakedWindows[id] for id in test["windows"] if id in self.leakedWindows] |
michael@0 | 647 | test["leakedDocShells"] = [id for id in test["docShells"] if id in self.leakedDocShells] |
michael@0 | 648 | test["leakCount"] = len(test["leakedWindows"]) + len(test["leakedDocShells"]) |
michael@0 | 649 | |
michael@0 | 650 | if test["leakCount"]: |
michael@0 | 651 | leakingTests.append(test) |
michael@0 | 652 | |
michael@0 | 653 | return sorted(leakingTests, key=itemgetter("leakCount"), reverse=True) |
michael@0 | 654 | |
michael@0 | 655 | def _zipLeakedWindows(self, leakedWindows): |
michael@0 | 656 | counts = [] |
michael@0 | 657 | counted = set() |
michael@0 | 658 | |
michael@0 | 659 | for url in leakedWindows: |
michael@0 | 660 | if not url in counted: |
michael@0 | 661 | counts.append((url, leakedWindows.count(url))) |
michael@0 | 662 | counted.add(url) |
michael@0 | 663 | |
michael@0 | 664 | return sorted(counts, key=itemgetter(1), reverse=True) |