tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

leaks.py (15571B)


      1 # This Source Code Form is subject to the terms of the Mozilla Public
      2 # License, v. 2.0. If a copy of the MPL was not distributed with this
      3 # file, You can obtain one at http://mozilla.org/MPL/2.0/
      4 
      5 # The content of this file comes orginally from automationutils.py
      6 # and *should* be revised.
      7 
      8 import re
      9 from operator import itemgetter
     10 
     11 RE_DOCSHELL = re.compile(r"I\/DocShellAndDOMWindowLeak ([+\-]{2})DOCSHELL")
     12 RE_DOMWINDOW = re.compile(r"I\/DocShellAndDOMWindowLeak ([+\-]{2})DOMWINDOW")
     13 
     14 
     15 class ShutdownLeaks:
     16    """
     17    Parses the mochitest run log when running a debug build, assigns all leaked
     18    DOM windows (that are still around after test suite shutdown, despite running
     19    the GC) to the tests that created them and prints leak statistics.
     20    """
     21 
     22    def __init__(self, logger):
     23        self.logger = logger
     24        self.tests = []
     25        self.leakedWindows = {}
     26        self.hiddenWindowsCount = 0
     27        self.leakedDocShells = set()
     28        self.hiddenDocShellsCount = 0
     29        self.numDocShellCreatedLogsSeen = 0
     30        self.numDocShellDestroyedLogsSeen = 0
     31        self.numDomWindowCreatedLogsSeen = 0
     32        self.numDomWindowDestroyedLogsSeen = 0
     33        self.currentTest = None
     34        self.seenShutdown = set()
     35 
     36    def log(self, message):
     37        action = message["action"]
     38 
     39        # Remove 'log' when clipboard is gone and/or structured.
     40        if action in ("log", "process_output"):
     41            line = message["message"] if action == "log" else message["data"]
     42 
     43            m = RE_DOMWINDOW.search(line)
     44            if m:
     45                self._logWindow(line, m.group(1) == "++")
     46                return
     47 
     48            m = RE_DOCSHELL.search(line)
     49            if m:
     50                self._logDocShell(line, m.group(1) == "++")
     51                return
     52 
     53            if line.startswith("Completed ShutdownLeaks collections in process"):
     54                pid = int(line.split()[-1])
     55                self.seenShutdown.add(pid)
     56        elif action == "test_start":
     57            fileName = message["test"].replace(
     58                "chrome://mochitests/content/browser/", ""
     59            )
     60            self.currentTest = {
     61                "fileName": fileName,
     62                "windows": set(),
     63                "docShells": set(),
     64            }
     65        elif action == "test_end":
     66            # don't track a test if no windows or docShells leaked
     67            if self.currentTest and (
     68                self.currentTest["windows"] or self.currentTest["docShells"]
     69            ):
     70                self.tests.append(self.currentTest)
     71            self.currentTest = None
     72 
     73    def process(self):
     74        failures = 0
     75 
     76        if not self.seenShutdown:
     77            self.logger.error(
     78                "TEST-UNEXPECTED-FAIL | ShutdownLeaks | process() called before end of test suite"
     79            )
     80            failures += 1
     81 
     82        if (
     83            self.numDocShellCreatedLogsSeen == 0
     84            or self.numDocShellDestroyedLogsSeen == 0
     85        ):
     86            self.logger.error(
     87                "TEST-UNEXPECTED-FAIL | did not see DOCSHELL log strings."
     88                " this occurs if the DOCSHELL logging gets disabled by"
     89                " something. %d created seen %d destroyed seen"
     90                % (self.numDocShellCreatedLogsSeen, self.numDocShellDestroyedLogsSeen)
     91            )
     92            failures += 1
     93        else:
     94            self.logger.info(
     95                "TEST-INFO | Confirming we saw %d DOCSHELL created and %d destroyed log"
     96                " strings."
     97                % (self.numDocShellCreatedLogsSeen, self.numDocShellDestroyedLogsSeen)
     98            )
     99 
    100        if (
    101            self.numDomWindowCreatedLogsSeen == 0
    102            or self.numDomWindowDestroyedLogsSeen == 0
    103        ):
    104            self.logger.error(
    105                "TEST-UNEXPECTED-FAIL | did not see DOMWINDOW log strings."
    106                " this occurs if the DOMWINDOW logging gets disabled by"
    107                " something%d created seen %d destroyed seen"
    108                % (self.numDomWindowCreatedLogsSeen, self.numDomWindowDestroyedLogsSeen)
    109            )
    110            failures += 1
    111        else:
    112            self.logger.info(
    113                "TEST-INFO | Confirming we saw %d DOMWINDOW created and %d destroyed log"
    114                " strings."
    115                % (self.numDomWindowCreatedLogsSeen, self.numDomWindowDestroyedLogsSeen)
    116            )
    117 
    118        errors = []
    119        for test in self._parseLeakingTests():
    120            for url, count in self._zipLeakedWindows(test["leakedWindows"]):
    121                errors.append({
    122                    "test": test["fileName"],
    123                    "msg": "leaked %d window(s) until shutdown [url = %s]"
    124                    % (count, url),
    125                })
    126                failures += 1
    127 
    128            if test["leakedWindowsString"]:
    129                self.logger.info(
    130                    "TEST-INFO | %s | windows(s) leaked: %s"
    131                    % (test["fileName"], test["leakedWindowsString"])
    132                )
    133 
    134            if test["leakedDocShells"]:
    135                errors.append({
    136                    "test": test["fileName"],
    137                    "msg": "leaked %d docShell(s) until shutdown"
    138                    % (len(test["leakedDocShells"])),
    139                })
    140                failures += 1
    141                self.logger.info(
    142                    "TEST-INFO | %s | docShell(s) leaked: %s"
    143                    % (
    144                        test["fileName"],
    145                        ", ".join([
    146                            "[pid = %s] [id = %s]" % x for x in test["leakedDocShells"]
    147                        ]),
    148                    )
    149                )
    150 
    151            if test["hiddenWindowsCount"] > 0:
    152                # Note: to figure out how many hidden windows were created, we divide
    153                # this number by 2, because 1 hidden window creation implies in
    154                # 1 outer window + 1 inner window.
    155                # pylint --py3k W1619
    156                self.logger.info(
    157                    "TEST-INFO | %s | This test created %d hidden window(s)"
    158                    % (test["fileName"], test["hiddenWindowsCount"] / 2)
    159                )
    160 
    161            if test["hiddenDocShellsCount"] > 0:
    162                self.logger.info(
    163                    "TEST-INFO | %s | This test created %d hidden docshell(s)"
    164                    % (test["fileName"], test["hiddenDocShellsCount"])
    165                )
    166 
    167        return failures, errors
    168 
    169    def _logWindow(self, line, created):
    170        pid = self._parseValue(line, "pid")
    171        serial = self._parseValue(line, "serial")
    172        self.numDomWindowCreatedLogsSeen += 1 if created else 0
    173        self.numDomWindowDestroyedLogsSeen += 0 if created else 1
    174 
    175        # log line has invalid format
    176        if not pid or not serial:
    177            self.logger.error(
    178                "TEST-UNEXPECTED-FAIL | ShutdownLeaks | failed to parse line"
    179            )
    180            self.logger.error("TEST-INFO | ShutdownLeaks | Unparsable line <%s>" % line)
    181            return
    182 
    183        key = (pid, serial)
    184 
    185        if self.currentTest:
    186            windows = self.currentTest["windows"]
    187            if created:
    188                windows.add(key)
    189            else:
    190                windows.discard(key)
    191        elif int(pid) in self.seenShutdown and not created:
    192            url = self._parseValue(line, "url")
    193            if not self._isHiddenWindowURL(url):
    194                self.leakedWindows[key] = url
    195            else:
    196                self.hiddenWindowsCount += 1
    197 
    198    def _logDocShell(self, line, created):
    199        pid = self._parseValue(line, "pid")
    200        id = self._parseValue(line, "id")
    201        self.numDocShellCreatedLogsSeen += 1 if created else 0
    202        self.numDocShellDestroyedLogsSeen += 0 if created else 1
    203 
    204        # log line has invalid format
    205        if not pid or not id:
    206            self.logger.error(
    207                "TEST-UNEXPECTED-FAIL | ShutdownLeaks | failed to parse line"
    208            )
    209            self.logger.error("TEST-INFO | ShutdownLeaks | Unparsable line <%s>" % line)
    210            return
    211 
    212        key = (pid, id)
    213 
    214        if self.currentTest:
    215            docShells = self.currentTest["docShells"]
    216            if created:
    217                docShells.add(key)
    218            else:
    219                docShells.discard(key)
    220        elif int(pid) in self.seenShutdown and not created:
    221            url = self._parseValue(line, "url")
    222            if not self._isHiddenWindowURL(url):
    223                self.leakedDocShells.add(key)
    224            else:
    225                self.hiddenDocShellsCount += 1
    226 
    227    def _parseValue(self, line, name):
    228        match = re.search(r"\[%s = (.+?)\]" % name, line)
    229        if match:
    230            return match.group(1)
    231        return None
    232 
    233    def _parseLeakingTests(self):
    234        leakingTests = []
    235 
    236        for test in self.tests:
    237            leakedWindows = [id for id in test["windows"] if id in self.leakedWindows]
    238            test["leakedWindows"] = [self.leakedWindows[id] for id in leakedWindows]
    239            test["hiddenWindowsCount"] = self.hiddenWindowsCount
    240            test["leakedWindowsString"] = ", ".join([
    241                "[pid = %s] [serial = %s]" % x for x in leakedWindows
    242            ])
    243            test["leakedDocShells"] = [
    244                id for id in test["docShells"] if id in self.leakedDocShells
    245            ]
    246            test["hiddenDocShellsCount"] = self.hiddenDocShellsCount
    247            test["leakCount"] = len(test["leakedWindows"]) + len(
    248                test["leakedDocShells"]
    249            )
    250 
    251            if (
    252                test["leakCount"]
    253                or test["hiddenWindowsCount"]
    254                or test["hiddenDocShellsCount"]
    255            ):
    256                leakingTests.append(test)
    257 
    258        return sorted(leakingTests, key=itemgetter("leakCount"), reverse=True)
    259 
    260    def _zipLeakedWindows(self, leakedWindows):
    261        counts = []
    262        counted = set()
    263 
    264        for url in leakedWindows:
    265            if url not in counted:
    266                counts.append((url, leakedWindows.count(url)))
    267                counted.add(url)
    268 
    269        return sorted(counts, key=itemgetter(1), reverse=True)
    270 
    271    def _isHiddenWindowURL(self, url):
    272        return url == "resource://gre-resources/hiddenWindowMac.xhtml"
    273 
    274 
    275 class LSANLeaks:
    276    """
    277    Parses the log when running an LSAN build, looking for interesting stack frames
    278    in allocation stacks, and prints out reports.
    279    """
    280 
    281    def __init__(self, logger):
    282        self.logger = logger
    283        self.inReport = False
    284        self.fatalError = False
    285        self.symbolizerError = False
    286        self.foundFrames = set([])
    287        self.recordMoreFrames = None
    288        self.currStack = None
    289        self.maxNumRecordedFrames = 4
    290 
    291        # Don't various allocation-related stack frames, as they do not help much to
    292        # distinguish different leaks.
    293        unescapedSkipList = [
    294            "malloc",
    295            "js_malloc",
    296            "js_arena_malloc",
    297            "malloc_",
    298            "__interceptor_malloc",
    299            "moz_xmalloc",
    300            "calloc",
    301            "js_calloc",
    302            "js_arena_calloc",
    303            "calloc_",
    304            "__interceptor_calloc",
    305            "moz_xcalloc",
    306            "realloc",
    307            "js_realloc",
    308            "js_arena_realloc",
    309            "realloc_",
    310            "__interceptor_realloc",
    311            "moz_xrealloc",
    312            "new",
    313            "js::MallocProvider",
    314        ]
    315        self.skipListRegExp = re.compile(
    316            "^" + "|".join([re.escape(f) for f in unescapedSkipList]) + "$"
    317        )
    318 
    319        self.startRegExp = re.compile(
    320            r"==\d+==ERROR: LeakSanitizer: detected memory leaks"
    321        )
    322        self.fatalErrorRegExp = re.compile(
    323            r"==\d+==LeakSanitizer has encountered a fatal error."
    324        )
    325        self.symbolizerOomRegExp = re.compile(
    326            "LLVMSymbolizer: error reading file: Cannot allocate memory"
    327        )
    328        self.stackFrameRegExp = re.compile(r"    #\d+ 0x[0-9a-f]+ in ([^(</]+)")
    329        self.sysLibStackFrameRegExp = re.compile(
    330            r"    #\d+ 0x[0-9a-f]+ \(([^+]+)\+0x[0-9a-f]+\)"
    331        )
    332 
    333    def log(self, line, path=""):
    334        if re.match(self.startRegExp, line):
    335            self.inReport = True
    336            return
    337 
    338        if re.match(self.fatalErrorRegExp, line):
    339            self.fatalError = True
    340            return
    341 
    342        if re.match(self.symbolizerOomRegExp, line):
    343            self.symbolizerError = True
    344            return
    345 
    346        if not self.inReport:
    347            return
    348 
    349        if line.startswith("Direct leak") or line.startswith("Indirect leak"):
    350            self._finishStack(path)
    351            self.recordMoreFrames = True
    352            self.currStack = []
    353            return
    354 
    355        if line.startswith("SUMMARY: AddressSanitizer"):
    356            self._finishStack(path)
    357            self.inReport = False
    358            return
    359 
    360        if not self.recordMoreFrames:
    361            return
    362 
    363        stackFrame = re.match(self.stackFrameRegExp, line)
    364        if stackFrame:
    365            # Split the frame to remove any return types.
    366            frame = stackFrame.group(1).split()[-1]
    367            if not re.match(self.skipListRegExp, frame):
    368                self._recordFrame(frame)
    369            return
    370 
    371        sysLibStackFrame = re.match(self.sysLibStackFrameRegExp, line)
    372        if sysLibStackFrame:
    373            # System library stack frames will never match the skip list,
    374            # so don't bother checking if they do.
    375            self._recordFrame(sysLibStackFrame.group(1))
    376 
    377        # If we don't match either of these, just ignore the frame.
    378        # We'll end up with "unknown stack" if everything is ignored.
    379 
    380    def process(self):
    381        failures = 0
    382 
    383        if self.fatalError:
    384            self.logger.error(
    385                "TEST-UNEXPECTED-FAIL | LeakSanitizer | LeakSanitizer "
    386                "has encountered a fatal error."
    387            )
    388            failures += 1
    389 
    390        if self.symbolizerError:
    391            self.logger.error(
    392                "TEST-UNEXPECTED-FAIL | LeakSanitizer | LLVMSymbolizer "
    393                "was unable to allocate memory."
    394            )
    395            failures += 1
    396            self.logger.info(
    397                "TEST-INFO | LeakSanitizer | This will cause leaks that "
    398                "should be ignored to instead be reported as an error"
    399            )
    400 
    401        if self.foundFrames:
    402            self.logger.info(
    403                "TEST-INFO | LeakSanitizer | To show the "
    404                "addresses of leaked objects add report_objects=1 to LSAN_OPTIONS"
    405            )
    406            self.logger.info(
    407                "TEST-INFO | LeakSanitizer | This can be done "
    408                "in testing/mozbase/mozrunner/mozrunner/utils.py"
    409            )
    410 
    411        frames = list(self.foundFrames)
    412        frames.sort()
    413        for f in frames:
    414            if self.scope:
    415                f = "%s | %s" % (f, self.scope)
    416            self.logger.error("TEST-UNEXPECTED-FAIL | LeakSanitizer leak at " + f)
    417            failures += 1
    418 
    419        return failures
    420 
    421    def _finishStack(self, path=""):
    422        if self.recordMoreFrames and len(self.currStack) == 0:
    423            self.currStack = ["unknown stack"]
    424        if self.currStack:
    425            self.foundFrames.add(", ".join(self.currStack))
    426            self.currStack = None
    427            self.scope = path
    428        self.recordMoreFrames = False
    429        self.numRecordedFrames = 0
    430 
    431    def _recordFrame(self, frame):
    432        self.currStack.append(frame)
    433        self.numRecordedFrames += 1
    434        if self.numRecordedFrames >= self.maxNumRecordedFrames:
    435            self.recordMoreFrames = False