tor-browser

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

allocation-tracker.js (17617B)


      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 /*
      6 * This file helps tracking Javascript object allocations.
      7 * It is only included in local builds as a debugging helper.
      8 *
      9 * It is typicaly used when running DevTools tests (either mochitests or DAMP).
     10 * To use it, you need to set the following environment variable:
     11 *   DEBUG_DEVTOOLS_ALLOCATIONS="normal"
     12 *     This will only print the number of JS objects created during your test.
     13 *   DEBUG_DEVTOOLS_ALLOCATIONS="verbose"
     14 *     This will print the allocation sites of all the JS objects created during your
     15 *     test. i.e. from which files and lines the objects have been created.
     16 * In both cases, look for "DEVTOOLS ALLOCATION" in your terminal to see tracker's
     17 * output.
     18 *
     19 * But you can also import it from your test script if you want to focus on one
     20 * particular piece of code:
     21 *   const { allocationTracker } =
     22 *     require("devtools/shared/test-helpers/allocation-tracker");
     23 *   // Calling `allocationTracker` will immediately start recording allocations
     24 *   let tracker = allocationTracker();
     25 *
     26 *   // Do something
     27 *
     28 *   // If you want to log all the allocation sites, call this method:
     29 *   tracker.logAllocationLog();
     30 *   // Or, if you want to only print the number of objects being allocated, call this:
     31 *   tracker.logCount();
     32 *   // Once you are done, stop the tracker as it slow down execution a lot.
     33 *   tracker.stop();
     34 */
     35 
     36 "use strict";
     37 
     38 const MemoryReporter = Cc["@mozilla.org/memory-reporter-manager;1"].getService(
     39  Ci.nsIMemoryReporterManager
     40 );
     41 
     42 const global = Cu.getGlobalForObject(this);
     43 const { addDebuggerToGlobal } = ChromeUtils.importESModule(
     44  "resource://gre/modules/jsdebugger.sys.mjs",
     45  { global: "contextual" }
     46 );
     47 addDebuggerToGlobal(global);
     48 
     49 /**
     50 * Start recording JS object allocations.
     51 *
     52 * @param Object watchGlobal
     53 *        One global object to observe. Only allocation made from this global
     54 *        will be recorded.
     55 * @param Boolean watchAllGlobals
     56 *        If true, allocations from everywhere are going to be recorded.
     57 * @param Boolean watchAllGlobals
     58 *        If true, only allocations made from DevTools contexts are going to be recorded.
     59 */
     60 exports.allocationTracker = function ({
     61  watchGlobal,
     62  watchAllGlobals,
     63  watchDevToolsGlobals,
     64 } = {}) {
     65  logTracker("Start logging allocations");
     66  let dbg = new global.Debugger();
     67 
     68  // Enable allocation site tracking, to have the stack for each allocation
     69  dbg.memory.trackingAllocationSites = true;
     70  // Force saving *all* the allocation sites
     71  dbg.memory.allocationSamplingProbability = 1.0;
     72  // Bumps the default buffer size, which may prevent recording all the test allocations
     73  dbg.memory.maxAllocationsLogLength = 5000000;
     74 
     75  let acceptGlobal;
     76  if (watchGlobal) {
     77    acceptGlobal = () => false;
     78    dbg.addDebuggee(watchGlobal);
     79  } else if (watchAllGlobals) {
     80    acceptGlobal = () => true;
     81  } else if (watchDevToolsGlobals) {
     82    // Only accept globals related to DevTools
     83    const builtinGlobal = require("resource://devtools/shared/loader/builtin-modules.js");
     84    acceptGlobal = g => {
     85      // self-hosting-global crashes when trying to call unsafeDereference
     86      if (g.class == "self-hosting-global") {
     87        logTracker("NEW GLOBAL: - : " + g.class);
     88        return false;
     89      }
     90      let ref = g.unsafeDereference();
     91      // If we are on a toolbox's iframe, typically each panel's iframe
     92      // retrieve the toolbox iframe via window.top
     93      if (g.class == "Window" && ref.top) {
     94        ref = ref.top;
     95      }
     96      const location = Cu.getRealmLocation(ref);
     97      let accept = !!location.match(/devtools/i);
     98 
     99      // Also ignore the dedicated Sandbox used to spawn builtin-modules,
    100      // as well as its internal ChromeDebugger Sandbox.
    101      // We ignore the global used by the dedicated loader used to load
    102      // the allocation-tracker module.
    103      if (
    104        ref == Cu.getGlobalForObject(builtinGlobal) ||
    105        ref == Cu.getGlobalForObject(builtinGlobal.modules.ChromeDebugger)
    106      ) {
    107        accept = false;
    108      }
    109 
    110      logTracker("NEW GLOBAL: " + (accept ? "+" : "-") + " : " + location);
    111      return accept;
    112    };
    113  }
    114 
    115  // Watch all globals
    116  if (watchAllGlobals || watchDevToolsGlobals) {
    117    dbg.addAllGlobalsAsDebuggees();
    118 
    119    for (const g of dbg.getDebuggees()) {
    120      if (!acceptGlobal(g)) {
    121        dbg.removeDebuggee(g);
    122      }
    123    }
    124  }
    125 
    126  // Remove this global to ignore all its object/JS
    127  dbg.removeDebuggee(global);
    128 
    129  // addAllGlobalsAsDebuggees won't automatically track new ones,
    130  // so ensure tracking all new globals
    131  dbg.onNewGlobalObject = function (g) {
    132    if (acceptGlobal(g)) {
    133      dbg.addDebuggee(g);
    134    }
    135  };
    136 
    137  return {
    138    get overflowed() {
    139      return dbg.memory.allocationsLogOverflowed;
    140    },
    141 
    142    async startRecordingAllocations(debug_allocations) {
    143      // Do a first pass of GC, to ensure all to-be-freed objects from the first run
    144      // are really freed.
    145      // We have to temporarily disable allocation-site recording in order to ensure
    146      // freeing everything and especially avoid retaining objects in the allocation-log
    147      // related to `drainAllocationLog` feature.
    148      dbg.memory.allocationSamplingProbability = 0.0;
    149      // Also force clearing the allocation log in order to prevent holding alive globals
    150      // which have been destroyed before we start recording
    151      this.flushAllocations();
    152      await this.doGC();
    153      dbg.memory.allocationSamplingProbability = 1.0;
    154 
    155      // Measure the current process memory usage
    156      const memory = this.getAllocatedMemory();
    157 
    158      // Then, record how many objects were already allocated, which should not be declared
    159      // as potential leaks. For ex, there is all the modules already loaded
    160      // in the main DevTools loader.
    161      const objects = this.stillAllocatedObjects();
    162 
    163      // Flush the allocations so that the next call to logAllocationLog
    164      // ignore allocations which happened before this call.
    165      if (debug_allocations == "allocations") {
    166        this.flushAllocations();
    167      }
    168 
    169      // Retrieve all allocation sites of all the objects already allocated.
    170      // So that we can ignore them when we stop the record.
    171      const allocations =
    172        debug_allocations == "leaks" ? this.getAllAllocations() : null;
    173 
    174      this.data = { memory, objects, allocations };
    175      return this.data;
    176    },
    177 
    178    async stopRecordingAllocations(debug_allocations) {
    179      // We have to flush the allocation log in order to prevent leaking some objects
    180      // being hold in memory solely by their allocation-site (i.e. `SavedFrame` in `Debugger::allocationsLog`)
    181      if (debug_allocations != "allocations") {
    182        this.flushAllocations();
    183      }
    184 
    185      // In the content process we watch for all globals.
    186      // Disable allocation record immediately, as we get some allocation reported by the allocation-tracker itself.
    187      if (watchAllGlobals) {
    188        dbg.memory.allocationSamplingProbability = 0.0;
    189      }
    190 
    191      // Before computing allocations, re-do some GCs in order to free all what is to-be-freed.
    192      await this.doGC();
    193 
    194      // If we are in the parent process, we watch only for devtools globals.
    195      // So we can more safely assert that no allocation occured while doing the GCs.
    196      // If means that the test we are recording is having pending operation which aren't properly recorded.
    197      if (!watchAllGlobals) {
    198        const allocations = dbg.memory.drainAllocationsLog();
    199        if (allocations.length) {
    200          this.logAllocationLog(
    201            allocations,
    202            "Allocation that happened during the GC"
    203          );
    204          console.error(
    205            "Allocation happened during the GC. Are you waiting correctly before calling stopRecordingAllocations?"
    206          );
    207        }
    208      }
    209 
    210      const memory = this.getAllocatedMemory();
    211      const objects = this.stillAllocatedObjects();
    212 
    213      let leaks;
    214      if (debug_allocations == "allocations") {
    215        this.logAllocationLog();
    216      } else if (debug_allocations == "leaks") {
    217        leaks = this.logAllocationSitesDiff(this.data.allocations);
    218      }
    219 
    220      return {
    221        objectsWithoutStack:
    222          objects.objectsWithoutStack - this.data.objects.objectsWithoutStack,
    223        objectsWithStack:
    224          objects.objectsWithStack - this.data.objects.objectsWithStack,
    225        memory: memory - this.data.memory,
    226        leaks,
    227      };
    228    },
    229 
    230    /**
    231     * Return the collection of currently allocated JS Objects.
    232     *
    233     * This returns an object whose structure is documented in logAllocationSites.
    234     */
    235    getAllAllocations() {
    236      const sensus = dbg.memory.takeCensus({
    237        breakdown: { by: "allocationStack" },
    238      });
    239      const sources = {};
    240      for (const [k, v] of sensus.entries()) {
    241        const src = k.source || "UNKNOWN";
    242        const line = k.line || "?";
    243        const count = v.count;
    244 
    245        let item = sources[src];
    246        if (!item) {
    247          item = sources[src] = { count: 0, lines: {} };
    248        }
    249        item.count += count;
    250        if (line != -1) {
    251          if (!item.lines[line]) {
    252            item.lines[line] = 0;
    253          }
    254          item.lines[line] += count;
    255        }
    256      }
    257      return sources;
    258    },
    259 
    260    /**
    261     * Substract count of `previousSources` from `newSources`.
    262     * This help know which allocations where done between `previousSources` and `newSources` records,
    263     * and, are still allocated.
    264     *
    265     * The structure of source objects is documented in logAllocationSites.
    266     */
    267    sourcesDiff(previousSources, newSources) {
    268      for (const src in previousSources) {
    269        const previousItem = previousSources[src];
    270        const item = newSources[src];
    271        if (!item) {
    272          continue;
    273        }
    274        item.count -= previousItem.count;
    275 
    276        for (const line in previousItem.lines) {
    277          const count = previousItem.lines[line];
    278          if (line != -1) {
    279            if (!item.lines[line]) {
    280              continue;
    281            }
    282            item.lines[line] -= count;
    283          }
    284        }
    285      }
    286    },
    287 
    288    /**
    289     * Print to stdout data about all recorded allocations
    290     *
    291     * It prints an array of allocations per file, sorted by files allocating the most
    292     * objects. And get detail of allocation per line.
    293     *
    294     *   [{ src: "chrome://devtools/content/framework/toolbox.js",
    295     *      count: 210, // Total # of allocs for toolbox.js
    296     *      lines: [
    297     *       "10: 200", // toolbox.js allocation 200 objects on line 10
    298     *       "124: 10
    299     *      ]
    300     *    },
    301     *    { src: "chrome://devtools/content/inspector/inspector.js",
    302     *      count: 12,
    303     *      lines: [
    304     *       "20: 12",
    305     *      ]
    306     *    }]
    307     *
    308     * @param first Number
    309     *        Retrieve only the top $first script allocation the most
    310     *        objects
    311     */
    312    logAllocationSites(message, sources, { first = 1000 } = {}) {
    313      const allocationList = Object.entries(sources)
    314        // Sort by number of total object
    315        .sort(([, itemA], [, itemB]) => itemB.count - itemA.count)
    316        // Keep only the first n-th sources, with the most allocations
    317        .filter((_, i) => i < first)
    318        .map(([src, item]) => {
    319          const lines = [];
    320          Object.entries(item.lines)
    321            // Filter out lines where we only freed objects
    322            .filter(([, count]) => count > 0)
    323            .sort(([lineA, countA], [lineB, countB]) => {
    324              if (countA != countB) {
    325                return countB - countA;
    326              }
    327              return lineB - lineA;
    328            })
    329            .forEach(([line, count]) => {
    330              // Compress the data to make it readable on stdout
    331              lines.push(line + ": " + count);
    332            });
    333          return { src, count: item.count, lines };
    334        })
    335        // Filter out modules where we only freed objects
    336        .filter(({ count }) => count > 0);
    337      logTracker(
    338        "DEVTOOLS ALLOCATION: " +
    339          message +
    340          ":\n" +
    341          JSON.stringify(allocationList, null, 2)
    342      );
    343      return allocationList;
    344    },
    345 
    346    /**
    347     * This method requires a previous call to getAllAllocations
    348     * and will print only the allocation sites which are still allocated.
    349     * Usage:
    350     *   const previousSources = this.getAllAllocations();
    351     *     ... exercice something, which may leak ...
    352     *   this.logAllocationSitesDiff(previousSources);
    353     */
    354    logAllocationSitesDiff(previousSources) {
    355      const newSources = this.getAllAllocations();
    356      this.sourcesDiff(previousSources, newSources);
    357      return this.logAllocationSites("allocations which leaked", newSources);
    358    },
    359 
    360    /**
    361     * Convert allocation structure coming out from Memory API's `drainAllocationsLog()`
    362     * to source structure documented in logAllocationSites.
    363     */
    364    allocationsToSources(allocations) {
    365      const sources = {};
    366      for (const alloc of allocations) {
    367        const { frame } = alloc;
    368        let src = "UNKNOWN";
    369        let line = -1;
    370        try {
    371          if (frame) {
    372            src = frame.source || "UNKNOWN";
    373            line = frame.line || -1;
    374          }
    375        } catch (e) {
    376          // For some frames accessing source throws
    377        }
    378        let item = sources[src];
    379        if (!item) {
    380          item = sources[src] = { count: 0, lines: {} };
    381        }
    382        item.count++;
    383        if (line != -1) {
    384          if (!item.lines[line]) {
    385            item.lines[line] = 0;
    386          }
    387          item.lines[line]++;
    388        }
    389      }
    390      return sources;
    391    },
    392 
    393    /**
    394     * This method will log all the allocations that happened since the last call
    395     * to this method -or- to `flushAllocations`.
    396     * Reported allocations may have been freed.
    397     * Use `logAllocationSitesDiff` to know what hasn't been freed.
    398     */
    399    logAllocationLog(allocations, msg = "") {
    400      if (!allocations) {
    401        allocations = dbg.memory.drainAllocationsLog();
    402      }
    403      const sources = this.allocationsToSources(allocations);
    404      return this.logAllocationSites(
    405        msg
    406          ? msg
    407          : "all allocations (which may be freed or are still allocated)",
    408        sources
    409      );
    410    },
    411 
    412    logCount() {
    413      logTracker(
    414        "DEVTOOLS ALLOCATION: Javascript object allocations: " +
    415          this.countAllocations()
    416      );
    417    },
    418 
    419    countAllocations() {
    420      // Fetch all allocation sites from Debugger API
    421      const allocations = dbg.memory.drainAllocationsLog();
    422      return allocations.length;
    423    },
    424 
    425    /**
    426     * Reset the allocation log, so that the next call to logAllocationLog/drainAllocationsLog
    427     * will report all allocations which happened after this call to flushAllocations.
    428     */
    429    flushAllocations() {
    430      dbg.memory.drainAllocationsLog();
    431    },
    432 
    433    /**
    434     * Compute the live count of object currently allocated.
    435     *
    436     * `objects` attribute will count all the objects,
    437     * while `objectsWithNoStack` will report how many are missing allocation site/stack.
    438     */
    439    stillAllocatedObjects() {
    440      const sensus = dbg.memory.takeCensus({
    441        breakdown: { by: "allocationStack" },
    442      });
    443      let objectsWithStack = 0;
    444      let objectsWithoutStack = 0;
    445      for (const [k, v] of sensus.entries()) {
    446        // Objects with missing stack will all be keyed under "noStack" string,
    447        // while all others will have a stack object as key.
    448        if (k === "noStack") {
    449          objectsWithoutStack += v.count;
    450        } else {
    451          objectsWithStack += v.count;
    452        }
    453      }
    454      return { objectsWithStack, objectsWithoutStack };
    455    },
    456 
    457    /**
    458     * Reports the amount of OS memory used by the current process.
    459     */
    460    getAllocatedMemory() {
    461      return MemoryReporter.residentUnique;
    462    },
    463 
    464    async doGC() {
    465      // In order to get stable results, we really have to do 3 GC attempts
    466      // *and* do wait for 1s between each GC.
    467      const numCycles = 3;
    468      for (let i = 0; i < numCycles; i++) {
    469        Cu.forceGC();
    470        Cu.forceCC();
    471        await new Promise(resolve => Cu.schedulePreciseShrinkingGC(resolve));
    472 
    473        // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
    474        await new Promise(resolve => setTimeout(resolve, 1000));
    475      }
    476 
    477      // Also call minimizeMemoryUsage as that's the only way to purge JIT cache.
    478      // CachedIR objects (JIT related objects) are ultimately leading to keep
    479      // all transient globals in memory. For some reason, when enabling trackingAllocationSites=true
    480      // we compute stack traces (SavedFrame) for each object being allocated.
    481      // This either create new CachedIR -or- force holding alive existing CachedIR
    482      // and CachedIR itself hold strong references to the transient globals.
    483      // See bug 1733480.
    484      await new Promise(resolve => MemoryReporter.minimizeMemoryUsage(resolve));
    485    },
    486 
    487    /**
    488     * Return the absolute file path to a memory snapshot.
    489     * This is used to compute dominator trees in `traceObjects`.
    490     */
    491    getSnapshotFile() {
    492      return ChromeUtils.saveHeapSnapshot({ debugger: dbg });
    493    },
    494 
    495    stop() {
    496      logTracker("Stop logging allocations");
    497      dbg.onNewGlobalObject = undefined;
    498      dbg.removeAllDebuggees();
    499      dbg = null;
    500    },
    501  };
    502 };
    503 
    504 function logTracker(message) {
    505  dump(` \x1b[2m[TRACKER]\x1b[0m ${message}\n`);
    506 }