michael@0: # michael@0: # This Source Code Form is subject to the terms of the Mozilla Public michael@0: # License, v. 2.0. If a copy of the MPL was not distributed with this michael@0: # file, You can obtain one at http://mozilla.org/MPL/2.0/. michael@0: michael@0: from __future__ import with_statement michael@0: import glob, logging, os, platform, shutil, subprocess, sys, tempfile, urllib2, zipfile michael@0: import base64 michael@0: import re michael@0: import os michael@0: from urlparse import urlparse michael@0: from operator import itemgetter michael@0: import signal michael@0: michael@0: try: michael@0: import mozinfo michael@0: except ImportError: michael@0: # Stub out fake mozinfo since this is not importable on Android 4.0 Opt. michael@0: # This should be fixed; see michael@0: # https://bugzilla.mozilla.org/show_bug.cgi?id=650881 michael@0: mozinfo = type('mozinfo', (), dict(info={}))() michael@0: mozinfo.isWin = mozinfo.isLinux = mozinfo.isUnix = mozinfo.isMac = False michael@0: michael@0: # TODO! FILE: localautomation :/ michael@0: # mapping from would-be mozinfo attr <-> sys.platform michael@0: mapping = {'isMac': ['mac', 'darwin'], michael@0: 'isLinux': ['linux', 'linux2'], michael@0: 'isWin': ['win32', 'win64'], michael@0: } michael@0: mapping = dict(sum([[(value, key) for value in values] for key, values in mapping.items()], [])) michael@0: attr = mapping.get(sys.platform) michael@0: if attr: michael@0: setattr(mozinfo, attr, True) michael@0: if mozinfo.isLinux: michael@0: mozinfo.isUnix = True michael@0: michael@0: __all__ = [ michael@0: "ZipFileReader", michael@0: "addCommonOptions", michael@0: "dumpLeakLog", michael@0: "isURL", michael@0: "processLeakLog", michael@0: "getDebuggerInfo", michael@0: "DEBUGGER_INFO", michael@0: "replaceBackSlashes", michael@0: 'KeyValueParseError', michael@0: 'parseKeyValue', michael@0: 'systemMemory', michael@0: 'environment', michael@0: 'dumpScreen', michael@0: "ShutdownLeaks" michael@0: ] michael@0: michael@0: # Map of debugging programs to information about them, like default arguments michael@0: # and whether or not they are interactive. michael@0: DEBUGGER_INFO = { michael@0: # gdb requires that you supply the '--args' flag in order to pass arguments michael@0: # after the executable name to the executable. michael@0: "gdb": { michael@0: "interactive": True, michael@0: "args": "-q --args" michael@0: }, michael@0: michael@0: "cgdb": { michael@0: "interactive": True, michael@0: "args": "-q --args" michael@0: }, michael@0: michael@0: "lldb": { michael@0: "interactive": True, michael@0: "args": "--", michael@0: "requiresEscapedArgs": True michael@0: }, michael@0: michael@0: # valgrind doesn't explain much about leaks unless you set the michael@0: # '--leak-check=full' flag. But there are a lot of objects that are michael@0: # semi-deliberately leaked, so we set '--show-possibly-lost=no' to avoid michael@0: # uninteresting output from those objects. We set '--smc-check==all-non-file' michael@0: # and '--vex-iropt-register-updates=allregs-at-mem-access' so that valgrind michael@0: # deals properly with JIT'd JavaScript code. michael@0: "valgrind": { michael@0: "interactive": False, michael@0: "args": " ".join(["--leak-check=full", michael@0: "--show-possibly-lost=no", michael@0: "--smc-check=all-non-file", michael@0: "--vex-iropt-register-updates=allregs-at-mem-access"]) michael@0: } michael@0: } michael@0: michael@0: class ZipFileReader(object): michael@0: """ michael@0: Class to read zip files in Python 2.5 and later. Limited to only what we michael@0: actually use. michael@0: """ michael@0: michael@0: def __init__(self, filename): michael@0: self._zipfile = zipfile.ZipFile(filename, "r") michael@0: michael@0: def __del__(self): michael@0: self._zipfile.close() michael@0: michael@0: def _getnormalizedpath(self, path): michael@0: """ michael@0: Gets a normalized path from 'path' (or the current working directory if michael@0: 'path' is None). Also asserts that the path exists. michael@0: """ michael@0: if path is None: michael@0: path = os.curdir michael@0: path = os.path.normpath(os.path.expanduser(path)) michael@0: assert os.path.isdir(path) michael@0: return path michael@0: michael@0: def _extractname(self, name, path): michael@0: """ michael@0: Extracts a file with the given name from the zip file to the given path. michael@0: Also creates any directories needed along the way. michael@0: """ michael@0: filename = os.path.normpath(os.path.join(path, name)) michael@0: if name.endswith("/"): michael@0: os.makedirs(filename) michael@0: else: michael@0: path = os.path.split(filename)[0] michael@0: if not os.path.isdir(path): michael@0: os.makedirs(path) michael@0: with open(filename, "wb") as dest: michael@0: dest.write(self._zipfile.read(name)) michael@0: michael@0: def namelist(self): michael@0: return self._zipfile.namelist() michael@0: michael@0: def read(self, name): michael@0: return self._zipfile.read(name) michael@0: michael@0: def extract(self, name, path = None): michael@0: if hasattr(self._zipfile, "extract"): michael@0: return self._zipfile.extract(name, path) michael@0: michael@0: # This will throw if name is not part of the zip file. michael@0: self._zipfile.getinfo(name) michael@0: michael@0: self._extractname(name, self._getnormalizedpath(path)) michael@0: michael@0: def extractall(self, path = None): michael@0: if hasattr(self._zipfile, "extractall"): michael@0: return self._zipfile.extractall(path) michael@0: michael@0: path = self._getnormalizedpath(path) michael@0: michael@0: for name in self._zipfile.namelist(): michael@0: self._extractname(name, path) michael@0: michael@0: log = logging.getLogger() michael@0: michael@0: def isURL(thing): michael@0: """Return True if |thing| looks like a URL.""" michael@0: # We want to download URLs like http://... but not Windows paths like c:\... michael@0: return len(urlparse(thing).scheme) >= 2 michael@0: michael@0: # Python does not provide strsignal() even in the very latest 3.x. michael@0: # This is a reasonable fake. michael@0: def strsig(n): michael@0: # Signal numbers run 0 through NSIG-1; an array with NSIG members michael@0: # has exactly that many slots michael@0: _sigtbl = [None]*signal.NSIG michael@0: for k in dir(signal): michael@0: if k.startswith("SIG") and not k.startswith("SIG_") and k != "SIGCLD" and k != "SIGPOLL": michael@0: _sigtbl[getattr(signal, k)] = k michael@0: # Realtime signals mostly have no names michael@0: if hasattr(signal, "SIGRTMIN") and hasattr(signal, "SIGRTMAX"): michael@0: for r in range(signal.SIGRTMIN+1, signal.SIGRTMAX+1): michael@0: _sigtbl[r] = "SIGRTMIN+" + str(r - signal.SIGRTMIN) michael@0: # Fill in any remaining gaps michael@0: for i in range(signal.NSIG): michael@0: if _sigtbl[i] is None: michael@0: _sigtbl[i] = "unrecognized signal, number " + str(i) michael@0: if n < 0 or n >= signal.NSIG: michael@0: return "out-of-range signal, number "+str(n) michael@0: return _sigtbl[n] michael@0: michael@0: def printstatus(status, name = ""): michael@0: # 'status' is the exit status michael@0: if os.name != 'posix': michael@0: # Windows error codes are easier to look up if printed in hexadecimal michael@0: if status < 0: michael@0: status += 2**32 michael@0: print "TEST-INFO | %s: exit status %x\n" % (name, status) michael@0: elif os.WIFEXITED(status): michael@0: print "TEST-INFO | %s: exit %d\n" % (name, os.WEXITSTATUS(status)) michael@0: elif os.WIFSIGNALED(status): michael@0: # The python stdlib doesn't appear to have strsignal(), alas michael@0: print "TEST-INFO | {}: killed by {}".format(name,strsig(os.WTERMSIG(status))) michael@0: else: michael@0: # This is probably a can't-happen condition on Unix, but let's be defensive michael@0: print "TEST-INFO | %s: undecodable exit status %04x\n" % (name, status) michael@0: michael@0: def addCommonOptions(parser, defaults={}): michael@0: parser.add_option("--xre-path", michael@0: action = "store", type = "string", dest = "xrePath", michael@0: # individual scripts will set a sane default michael@0: default = None, michael@0: help = "absolute path to directory containing XRE (probably xulrunner)") michael@0: if 'SYMBOLS_PATH' not in defaults: michael@0: defaults['SYMBOLS_PATH'] = None michael@0: parser.add_option("--symbols-path", michael@0: action = "store", type = "string", dest = "symbolsPath", michael@0: default = defaults['SYMBOLS_PATH'], michael@0: help = "absolute path to directory containing breakpad symbols, or the URL of a zip file containing symbols") michael@0: parser.add_option("--debugger", michael@0: action = "store", dest = "debugger", michael@0: help = "use the given debugger to launch the application") michael@0: parser.add_option("--debugger-args", michael@0: action = "store", dest = "debuggerArgs", michael@0: help = "pass the given args to the debugger _before_ " michael@0: "the application on the command line") michael@0: parser.add_option("--debugger-interactive", michael@0: action = "store_true", dest = "debuggerInteractive", michael@0: help = "prevents the test harness from redirecting " michael@0: "stdout and stderr for interactive debuggers") michael@0: michael@0: def getFullPath(directory, path): michael@0: "Get an absolute path relative to 'directory'." michael@0: return os.path.normpath(os.path.join(directory, os.path.expanduser(path))) michael@0: michael@0: def searchPath(directory, path): michael@0: "Go one step beyond getFullPath and try the various folders in PATH" michael@0: # Try looking in the current working directory first. michael@0: newpath = getFullPath(directory, path) michael@0: if os.path.isfile(newpath): michael@0: return newpath michael@0: michael@0: # At this point we have to fail if a directory was given (to prevent cases michael@0: # like './gdb' from matching '/usr/bin/./gdb'). michael@0: if not os.path.dirname(path): michael@0: for dir in os.environ['PATH'].split(os.pathsep): michael@0: newpath = os.path.join(dir, path) michael@0: if os.path.isfile(newpath): michael@0: return newpath michael@0: return None michael@0: michael@0: def getDebuggerInfo(directory, debugger, debuggerArgs, debuggerInteractive = False): michael@0: michael@0: debuggerInfo = None michael@0: michael@0: if debugger: michael@0: debuggerPath = searchPath(directory, debugger) michael@0: if not debuggerPath: michael@0: print "Error: Path %s doesn't exist." % debugger michael@0: sys.exit(1) michael@0: michael@0: debuggerName = os.path.basename(debuggerPath).lower() michael@0: michael@0: def getDebuggerInfo(type, default): michael@0: if debuggerName in DEBUGGER_INFO and type in DEBUGGER_INFO[debuggerName]: michael@0: return DEBUGGER_INFO[debuggerName][type] michael@0: return default michael@0: michael@0: debuggerInfo = { michael@0: "path": debuggerPath, michael@0: "interactive" : getDebuggerInfo("interactive", False), michael@0: "args": getDebuggerInfo("args", "").split(), michael@0: "requiresEscapedArgs": getDebuggerInfo("requiresEscapedArgs", False) michael@0: } michael@0: michael@0: if debuggerArgs: michael@0: debuggerInfo["args"] = debuggerArgs.split() michael@0: if debuggerInteractive: michael@0: debuggerInfo["interactive"] = debuggerInteractive michael@0: michael@0: return debuggerInfo michael@0: michael@0: michael@0: def dumpLeakLog(leakLogFile, filter = False): michael@0: """Process the leak log, without parsing it. michael@0: michael@0: Use this function if you want the raw log only. michael@0: Use it preferably with the |XPCOM_MEM_LEAK_LOG| environment variable. michael@0: """ michael@0: michael@0: # Don't warn (nor "info") if the log file is not there. michael@0: if not os.path.exists(leakLogFile): michael@0: return michael@0: michael@0: with open(leakLogFile, "r") as leaks: michael@0: leakReport = leaks.read() michael@0: michael@0: # Only |XPCOM_MEM_LEAK_LOG| reports can be actually filtered out. michael@0: # Only check whether an actual leak was reported. michael@0: if filter and not "0 TOTAL " in leakReport: michael@0: return michael@0: michael@0: # Simply copy the log. michael@0: log.info(leakReport.rstrip("\n")) michael@0: michael@0: def processSingleLeakFile(leakLogFileName, processType, leakThreshold): michael@0: """Process a single leak log. michael@0: """ michael@0: michael@0: # Per-Inst Leaked Total Rem ... michael@0: # 0 TOTAL 17 192 419115886 2 ... michael@0: # 833 nsTimerImpl 60 120 24726 2 ... michael@0: lineRe = re.compile(r"^\s*\d+\s+(?P\S+)\s+" michael@0: r"(?P-?\d+)\s+(?P-?\d+)\s+" michael@0: r"-?\d+\s+(?P-?\d+)") michael@0: michael@0: processString = "" michael@0: if processType: michael@0: # eg 'plugin' michael@0: processString = " %s process:" % processType michael@0: michael@0: crashedOnPurpose = False michael@0: totalBytesLeaked = None michael@0: leakAnalysis = [] michael@0: leakedObjectNames = [] michael@0: with open(leakLogFileName, "r") as leaks: michael@0: for line in leaks: michael@0: if line.find("purposefully crash") > -1: michael@0: crashedOnPurpose = True michael@0: matches = lineRe.match(line) michael@0: if not matches: michael@0: # eg: the leak table header row michael@0: log.info(line.rstrip()) michael@0: continue michael@0: name = matches.group("name") michael@0: size = int(matches.group("size")) michael@0: bytesLeaked = int(matches.group("bytesLeaked")) michael@0: numLeaked = int(matches.group("numLeaked")) michael@0: # Output the raw line from the leak log table if it is the TOTAL row, michael@0: # or is for an object row that has been leaked. michael@0: if numLeaked != 0 or name == "TOTAL": michael@0: log.info(line.rstrip()) michael@0: # Analyse the leak log, but output later or it will interrupt the leak table michael@0: if name == "TOTAL": michael@0: totalBytesLeaked = bytesLeaked michael@0: if size < 0 or bytesLeaked < 0 or numLeaked < 0: michael@0: leakAnalysis.append("TEST-UNEXPECTED-FAIL | leakcheck |%s negative leaks caught!" michael@0: % processString) michael@0: continue michael@0: if name != "TOTAL" and numLeaked != 0: michael@0: leakedObjectNames.append(name) michael@0: leakAnalysis.append("TEST-INFO | leakcheck |%s leaked %d %s (%s bytes)" michael@0: % (processString, numLeaked, name, bytesLeaked)) michael@0: log.info('\n'.join(leakAnalysis)) michael@0: michael@0: if totalBytesLeaked is None: michael@0: # We didn't see a line with name 'TOTAL' michael@0: if crashedOnPurpose: michael@0: log.info("TEST-INFO | leakcheck |%s deliberate crash and thus no leak log" michael@0: % processString) michael@0: else: michael@0: # TODO: This should be a TEST-UNEXPECTED-FAIL, but was changed to a warning michael@0: # due to too many intermittent failures (see bug 831223). michael@0: log.info("WARNING | leakcheck |%s missing output line for total leaks!" michael@0: % processString) michael@0: return michael@0: michael@0: if totalBytesLeaked == 0: michael@0: log.info("TEST-PASS | leakcheck |%s no leaks detected!" % processString) michael@0: return michael@0: michael@0: # totalBytesLeaked was seen and is non-zero. michael@0: if totalBytesLeaked > leakThreshold: michael@0: # Fail the run if we're over the threshold (which defaults to 0) michael@0: prefix = "TEST-UNEXPECTED-FAIL" michael@0: else: michael@0: prefix = "WARNING" michael@0: # Create a comma delimited string of the first N leaked objects found, michael@0: # to aid with bug summary matching in TBPL. Note: The order of the objects michael@0: # had no significance (they're sorted alphabetically). michael@0: maxSummaryObjects = 5 michael@0: leakedObjectSummary = ', '.join(leakedObjectNames[:maxSummaryObjects]) michael@0: if len(leakedObjectNames) > maxSummaryObjects: michael@0: leakedObjectSummary += ', ...' michael@0: log.info("%s | leakcheck |%s %d bytes leaked (%s)" michael@0: % (prefix, processString, totalBytesLeaked, leakedObjectSummary)) michael@0: michael@0: def processLeakLog(leakLogFile, leakThreshold = 0): michael@0: """Process the leak log, including separate leak logs created michael@0: by child processes. michael@0: michael@0: Use this function if you want an additional PASS/FAIL summary. michael@0: It must be used with the |XPCOM_MEM_BLOAT_LOG| environment variable. michael@0: """ michael@0: michael@0: if not os.path.exists(leakLogFile): michael@0: log.info("WARNING | leakcheck | refcount logging is off, so leaks can't be detected!") michael@0: return michael@0: michael@0: if leakThreshold != 0: michael@0: log.info("TEST-INFO | leakcheck | threshold set at %d bytes" % leakThreshold) michael@0: michael@0: (leakLogFileDir, leakFileBase) = os.path.split(leakLogFile) michael@0: fileNameRegExp = re.compile(r".*?_([a-z]*)_pid\d*$") michael@0: if leakFileBase[-4:] == ".log": michael@0: leakFileBase = leakFileBase[:-4] michael@0: fileNameRegExp = re.compile(r".*?_([a-z]*)_pid\d*.log$") michael@0: michael@0: for fileName in os.listdir(leakLogFileDir): michael@0: if fileName.find(leakFileBase) != -1: michael@0: thisFile = os.path.join(leakLogFileDir, fileName) michael@0: processType = None michael@0: m = fileNameRegExp.search(fileName) michael@0: if m: michael@0: processType = m.group(1) michael@0: processSingleLeakFile(thisFile, processType, leakThreshold) michael@0: michael@0: def replaceBackSlashes(input): michael@0: return input.replace('\\', '/') michael@0: michael@0: class KeyValueParseError(Exception): michael@0: """error when parsing strings of serialized key-values""" michael@0: def __init__(self, msg, errors=()): michael@0: self.errors = errors michael@0: Exception.__init__(self, msg) michael@0: michael@0: def parseKeyValue(strings, separator='=', context='key, value: '): michael@0: """ michael@0: parse string-serialized key-value pairs in the form of michael@0: `key = value`. Returns a list of 2-tuples. michael@0: Note that whitespace is not stripped. michael@0: """ michael@0: michael@0: # syntax check michael@0: missing = [string for string in strings if separator not in string] michael@0: if missing: michael@0: raise KeyValueParseError("Error: syntax error in %s" % (context, michael@0: ','.join(missing)), michael@0: errors=missing) michael@0: return [string.split(separator, 1) for string in strings] michael@0: michael@0: def systemMemory(): michael@0: """ michael@0: Returns total system memory in kilobytes. michael@0: Works only on unix-like platforms where `free` is in the path. michael@0: """ michael@0: return int(os.popen("free").readlines()[1].split()[1]) michael@0: michael@0: def environment(xrePath, env=None, crashreporter=True, debugger=False, dmdPath=None): michael@0: """populate OS environment variables for mochitest""" michael@0: michael@0: env = os.environ.copy() if env is None else env michael@0: michael@0: assert os.path.isabs(xrePath) michael@0: michael@0: ldLibraryPath = xrePath michael@0: michael@0: envVar = None michael@0: dmdLibrary = None michael@0: preloadEnvVar = None michael@0: if mozinfo.isUnix: michael@0: envVar = "LD_LIBRARY_PATH" michael@0: env['MOZILLA_FIVE_HOME'] = xrePath michael@0: dmdLibrary = "libdmd.so" michael@0: preloadEnvVar = "LD_PRELOAD" michael@0: elif mozinfo.isMac: michael@0: envVar = "DYLD_LIBRARY_PATH" michael@0: dmdLibrary = "libdmd.dylib" michael@0: preloadEnvVar = "DYLD_INSERT_LIBRARIES" michael@0: elif mozinfo.isWin: michael@0: envVar = "PATH" michael@0: dmdLibrary = "dmd.dll" michael@0: preloadEnvVar = "MOZ_REPLACE_MALLOC_LIB" michael@0: if envVar: michael@0: envValue = ((env.get(envVar), str(ldLibraryPath)) michael@0: if mozinfo.isWin michael@0: else (ldLibraryPath, dmdPath, env.get(envVar))) michael@0: env[envVar] = os.path.pathsep.join([path for path in envValue if path]) michael@0: michael@0: if dmdPath and dmdLibrary and preloadEnvVar: michael@0: env['DMD'] = '1' michael@0: env[preloadEnvVar] = os.path.join(dmdPath, dmdLibrary) michael@0: michael@0: # crashreporter michael@0: env['GNOME_DISABLE_CRASH_DIALOG'] = '1' michael@0: env['XRE_NO_WINDOWS_CRASH_DIALOG'] = '1' michael@0: env['NS_TRACE_MALLOC_DISABLE_STACKS'] = '1' michael@0: michael@0: if crashreporter and not debugger: michael@0: env['MOZ_CRASHREPORTER_NO_REPORT'] = '1' michael@0: env['MOZ_CRASHREPORTER'] = '1' michael@0: else: michael@0: env['MOZ_CRASHREPORTER_DISABLE'] = '1' michael@0: michael@0: # Crash on non-local network connections. michael@0: env['MOZ_DISABLE_NONLOCAL_CONNECTIONS'] = '1' michael@0: michael@0: # Set WebRTC logging in case it is not set yet michael@0: env.setdefault('NSPR_LOG_MODULES', 'signaling:5,mtransport:5,datachannel:5') michael@0: env.setdefault('R_LOG_LEVEL', '6') michael@0: env.setdefault('R_LOG_DESTINATION', 'stderr') michael@0: env.setdefault('R_LOG_VERBOSE', '1') michael@0: michael@0: # ASan specific environment stuff michael@0: asan = bool(mozinfo.info.get("asan")) michael@0: if asan and (mozinfo.isLinux or mozinfo.isMac): michael@0: try: michael@0: # Symbolizer support michael@0: llvmsym = os.path.join(xrePath, "llvm-symbolizer") michael@0: if os.path.isfile(llvmsym): michael@0: env["ASAN_SYMBOLIZER_PATH"] = llvmsym michael@0: log.info("ASan using symbolizer at %s", llvmsym) michael@0: michael@0: totalMemory = systemMemory() michael@0: michael@0: # Only 4 GB RAM or less available? Use custom ASan options to reduce michael@0: # the amount of resources required to do the tests. Standard options michael@0: # will otherwise lead to OOM conditions on the current test slaves. michael@0: message = "INFO | runtests.py | ASan running in %s configuration" michael@0: if totalMemory <= 1024 * 1024 * 4: michael@0: message = message % 'low-memory' michael@0: env["ASAN_OPTIONS"] = "quarantine_size=50331648:malloc_context_size=5" michael@0: else: michael@0: message = message % 'default memory' michael@0: except OSError,err: michael@0: log.info("Failed determine available memory, disabling ASan low-memory configuration: %s", err.strerror) michael@0: except: michael@0: log.info("Failed determine available memory, disabling ASan low-memory configuration") michael@0: else: michael@0: log.info(message) michael@0: michael@0: return env michael@0: michael@0: def dumpScreen(utilityPath): michael@0: """dumps a screenshot of the entire screen to a directory specified by michael@0: the MOZ_UPLOAD_DIR environment variable""" michael@0: import mozfile michael@0: michael@0: # Need to figure out which OS-dependent tool to use michael@0: if mozinfo.isUnix: michael@0: utility = [os.path.join(utilityPath, "screentopng")] michael@0: utilityname = "screentopng" michael@0: elif mozinfo.isMac: michael@0: utility = ['/usr/sbin/screencapture', '-C', '-x', '-t', 'png'] michael@0: utilityname = "screencapture" michael@0: elif mozinfo.isWin: michael@0: utility = [os.path.join(utilityPath, "screenshot.exe")] michael@0: utilityname = "screenshot" michael@0: michael@0: # Get dir where to write the screenshot file michael@0: parent_dir = os.environ.get('MOZ_UPLOAD_DIR', None) michael@0: if not parent_dir: michael@0: log.info('Failed to retrieve MOZ_UPLOAD_DIR env var') michael@0: return michael@0: michael@0: # Run the capture michael@0: try: michael@0: tmpfd, imgfilename = tempfile.mkstemp(prefix='mozilla-test-fail-screenshot_', suffix='.png', dir=parent_dir) michael@0: os.close(tmpfd) michael@0: returncode = subprocess.call(utility + [imgfilename]) michael@0: printstatus(returncode, utilityname) michael@0: except OSError, err: michael@0: log.info("Failed to start %s for screenshot: %s", michael@0: utility[0], err.strerror) michael@0: return michael@0: michael@0: class ShutdownLeaks(object): michael@0: """ michael@0: Parses the mochitest run log when running a debug build, assigns all leaked michael@0: DOM windows (that are still around after test suite shutdown, despite running michael@0: the GC) to the tests that created them and prints leak statistics. michael@0: """ michael@0: michael@0: def __init__(self, logger): michael@0: self.logger = logger michael@0: self.tests = [] michael@0: self.leakedWindows = {} michael@0: self.leakedDocShells = set() michael@0: self.currentTest = None michael@0: self.seenShutdown = False michael@0: michael@0: def log(self, line): michael@0: if line[2:11] == "DOMWINDOW": michael@0: self._logWindow(line) michael@0: elif line[2:10] == "DOCSHELL": michael@0: self._logDocShell(line) michael@0: elif line.startswith("TEST-START"): michael@0: fileName = line.split(" ")[-1].strip().replace("chrome://mochitests/content/browser/", "") michael@0: self.currentTest = {"fileName": fileName, "windows": set(), "docShells": set()} michael@0: elif line.startswith("INFO TEST-END"): michael@0: # don't track a test if no windows or docShells leaked michael@0: if self.currentTest and (self.currentTest["windows"] or self.currentTest["docShells"]): michael@0: self.tests.append(self.currentTest) michael@0: self.currentTest = None michael@0: elif line.startswith("INFO TEST-START | Shutdown"): michael@0: self.seenShutdown = True michael@0: michael@0: def process(self): michael@0: for test in self._parseLeakingTests(): michael@0: for url, count in self._zipLeakedWindows(test["leakedWindows"]): michael@0: self.logger("TEST-UNEXPECTED-FAIL | %s | leaked %d window(s) until shutdown [url = %s]", test["fileName"], count, url) michael@0: michael@0: if test["leakedDocShells"]: michael@0: self.logger("TEST-UNEXPECTED-FAIL | %s | leaked %d docShell(s) until shutdown", test["fileName"], len(test["leakedDocShells"])) michael@0: michael@0: def _logWindow(self, line): michael@0: created = line[:2] == "++" michael@0: pid = self._parseValue(line, "pid") michael@0: serial = self._parseValue(line, "serial") michael@0: michael@0: # log line has invalid format michael@0: if not pid or not serial: michael@0: self.logger("TEST-UNEXPECTED-FAIL | ShutdownLeaks | failed to parse line <%s>", line) michael@0: return michael@0: michael@0: key = pid + "." + serial michael@0: michael@0: if self.currentTest: michael@0: windows = self.currentTest["windows"] michael@0: if created: michael@0: windows.add(key) michael@0: else: michael@0: windows.discard(key) michael@0: elif self.seenShutdown and not created: michael@0: self.leakedWindows[key] = self._parseValue(line, "url") michael@0: michael@0: def _logDocShell(self, line): michael@0: created = line[:2] == "++" michael@0: pid = self._parseValue(line, "pid") michael@0: id = self._parseValue(line, "id") michael@0: michael@0: # log line has invalid format michael@0: if not pid or not id: michael@0: self.logger("TEST-UNEXPECTED-FAIL | ShutdownLeaks | failed to parse line <%s>", line) michael@0: return michael@0: michael@0: key = pid + "." + id michael@0: michael@0: if self.currentTest: michael@0: docShells = self.currentTest["docShells"] michael@0: if created: michael@0: docShells.add(key) michael@0: else: michael@0: docShells.discard(key) michael@0: elif self.seenShutdown and not created: michael@0: self.leakedDocShells.add(key) michael@0: michael@0: def _parseValue(self, line, name): michael@0: match = re.search("\[%s = (.+?)\]" % name, line) michael@0: if match: michael@0: return match.group(1) michael@0: return None michael@0: michael@0: def _parseLeakingTests(self): michael@0: leakingTests = [] michael@0: michael@0: for test in self.tests: michael@0: test["leakedWindows"] = [self.leakedWindows[id] for id in test["windows"] if id in self.leakedWindows] michael@0: test["leakedDocShells"] = [id for id in test["docShells"] if id in self.leakedDocShells] michael@0: test["leakCount"] = len(test["leakedWindows"]) + len(test["leakedDocShells"]) michael@0: michael@0: if test["leakCount"]: michael@0: leakingTests.append(test) michael@0: michael@0: return sorted(leakingTests, key=itemgetter("leakCount"), reverse=True) michael@0: michael@0: def _zipLeakedWindows(self, leakedWindows): michael@0: counts = [] michael@0: counted = set() michael@0: michael@0: for url in leakedWindows: michael@0: if not url in counted: michael@0: counts.append((url, leakedWindows.count(url))) michael@0: counted.add(url) michael@0: michael@0: return sorted(counts, key=itemgetter(1), reverse=True)