diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /devtools/shared/test-helpers/allocation-tracker.js | |
parent | Initial commit. (diff) | |
download | firefox-esr-upstream.tar.xz firefox-esr-upstream.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/shared/test-helpers/allocation-tracker.js')
-rw-r--r-- | devtools/shared/test-helpers/allocation-tracker.js | 637 |
1 files changed, 637 insertions, 0 deletions
diff --git a/devtools/shared/test-helpers/allocation-tracker.js b/devtools/shared/test-helpers/allocation-tracker.js new file mode 100644 index 0000000000..17dcfafdf0 --- /dev/null +++ b/devtools/shared/test-helpers/allocation-tracker.js @@ -0,0 +1,637 @@ +/* this source code form is subject to the terms of the mozilla public + * license, v. 2.0. if a copy of the mpl was not distributed with this + * file, you can obtain one at http://mozilla.org/mpl/2.0/. */ + +/* + * This file helps tracking Javascript object allocations. + * It is only included in local builds as a debugging helper. + * + * It is typicaly used when running DevTools tests (either mochitests or DAMP). + * To use it, you need to set the following environment variable: + * DEBUG_DEVTOOLS_ALLOCATIONS="normal" + * This will only print the number of JS objects created during your test. + * DEBUG_DEVTOOLS_ALLOCATIONS="verbose" + * This will print the allocation sites of all the JS objects created during your + * test. i.e. from which files and lines the objects have been created. + * In both cases, look for "DEVTOOLS ALLOCATION" in your terminal to see tracker's + * output. + * + * But you can also import it from your test script if you want to focus on one + * particular piece of code: + * const { allocationTracker } = + * require("devtools/shared/test-helpers/allocation-tracker"); + * // Calling `allocationTracker` will immediately start recording allocations + * let tracker = allocationTracker(); + * + * // Do something + * + * // If you want to log all the allocation sites, call this method: + * tracker.logAllocationLog(); + * // Or, if you want to only print the number of objects being allocated, call this: + * tracker.logCount(); + * // Once you are done, stop the tracker as it slow down execution a lot. + * tracker.stop(); + */ + +"use strict"; + +const MemoryReporter = Cc["@mozilla.org/memory-reporter-manager;1"].getService( + Ci.nsIMemoryReporterManager +); + +const global = Cu.getGlobalForObject(this); +const { addDebuggerToGlobal } = ChromeUtils.importESModule( + "resource://gre/modules/jsdebugger.sys.mjs" +); +addDebuggerToGlobal(global); + +/** + * Start recording JS object allocations. + * + * @param Object watchGlobal + * One global object to observe. Only allocation made from this global + * will be recorded. + * @param Boolean watchAllGlobals + * If true, allocations from everywhere are going to be recorded. + * @param Boolean watchAllGlobals + * If true, only allocations made from DevTools contexts are going to be recorded. + */ +exports.allocationTracker = function ({ + watchGlobal, + watchAllGlobals, + watchDevToolsGlobals, +} = {}) { + dump("DEVTOOLS ALLOCATION: Start logging allocations\n"); + let dbg = new global.Debugger(); + + // Enable allocation site tracking, to have the stack for each allocation + dbg.memory.trackingAllocationSites = true; + // Force saving *all* the allocation sites + dbg.memory.allocationSamplingProbability = 1.0; + // Bumps the default buffer size, which may prevent recording all the test allocations + dbg.memory.maxAllocationsLogLength = 5000000; + + let acceptGlobal; + if (watchGlobal) { + acceptGlobal = () => false; + dbg.addDebuggee(watchGlobal); + } else if (watchAllGlobals) { + acceptGlobal = () => true; + } else if (watchDevToolsGlobals) { + // Only accept globals related to DevTools + const builtinGlobal = require("resource://devtools/shared/loader/builtin-modules.js"); + acceptGlobal = g => { + // self-hosting-global crashes when trying to call unsafeDereference + if (g.class == "self-hosting-global") { + dump("TRACKER NEW GLOBAL: - : " + g.class + "\n"); + return false; + } + let ref = g.unsafeDereference(); + // If we are on a toolbox's iframe, typically each panel's iframe + // retrieve the toolbox iframe via window.top + if (g.class == "Window" && ref.top) { + ref = ref.top; + } + const location = Cu.getRealmLocation(ref); + let accept = !!location.match(/devtools/i); + + // Also ignore the dedicated Sandbox used to spawn builtin-modules, + // as well as its internal ChromeDebugger Sandbox. + // We ignore the global used by the dedicated loader used to load + // the allocation-tracker module. + if ( + ref == Cu.getGlobalForObject(builtinGlobal) || + ref == Cu.getGlobalForObject(builtinGlobal.modules.ChromeDebugger) + ) { + accept = false; + } + + dump( + "TRACKER NEW GLOBAL: " + (accept ? "+" : "-") + " : " + location + "\n" + ); + return accept; + }; + } + + // Watch all globals + if (watchAllGlobals || watchDevToolsGlobals) { + dbg.addAllGlobalsAsDebuggees(); + + for (const g of dbg.getDebuggees()) { + if (!acceptGlobal(g)) { + dbg.removeDebuggee(g); + } + } + } + + // Remove this global to ignore all its object/JS + dbg.removeDebuggee(global); + + // addAllGlobalsAsDebuggees won't automatically track new ones, + // so ensure tracking all new globals + dbg.onNewGlobalObject = function (g) { + if (acceptGlobal(g)) { + dbg.addDebuggee(g); + } + }; + + return { + get overflowed() { + return dbg.memory.allocationsLogOverflowed; + }, + + async startRecordingAllocations(debug_allocations) { + // Do a first pass of GC, to ensure all to-be-freed objects from the first run + // are really freed. + // We have to temporarily disable allocation-site recording in order to ensure + // freeing everything and especially avoid retaining objects in the allocation-log + // related to `drainAllocationLog` feature. + dbg.memory.allocationSamplingProbability = 0.0; + // Also force clearing the allocation log in order to prevent holding alive globals + // which have been destroyed before we start recording + this.flushAllocations(); + await this.doGC(); + dbg.memory.allocationSamplingProbability = 1.0; + + // Measure the current process memory usage + const memory = this.getAllocatedMemory(); + + // Then, record how many objects were already allocated, which should not be declared + // as potential leaks. For ex, there is all the modules already loaded + // in the main DevTools loader. + const objects = this.stillAllocatedObjects(); + + // Flush the allocations so that the next call to logAllocationLog + // ignore allocations which happened before this call. + if (debug_allocations == "allocations") { + this.flushAllocations(); + } + + // Retrieve all allocation sites of all the objects already allocated. + // So that we can ignore them when we stop the record. + const allocations = + debug_allocations == "leaks" ? this.getAllAllocations() : null; + + this.data = { memory, objects, allocations }; + return this.data; + }, + + async stopRecordingAllocations(debug_allocations) { + // We have to flush the allocation log in order to prevent leaking some objects + // being hold in memory solely by their allocation-site (i.e. `SavedFrame` in `Debugger::allocationsLog`) + if (debug_allocations != "allocations") { + this.flushAllocations(); + } + + // In the content process we watch for all globals. + // Disable allocation record immediately, as we get some allocation reported by the allocation-tracker itself. + if (watchAllGlobals) { + dbg.memory.allocationSamplingProbability = 0.0; + } + + // Before computing allocations, re-do some GCs in order to free all what is to-be-freed. + await this.doGC(); + + // If we are in the parent process, we watch only for devtools globals. + // So we can more safely assert that no allocation occured while doing the GCs. + // If means that the test we are recording is having pending operation which aren't properly recorded. + if (!watchAllGlobals) { + const allocations = dbg.memory.drainAllocationsLog(); + if (allocations.length) { + this.logAllocationLog( + allocations, + "Allocation that happened during the GC" + ); + console.error( + "Allocation happened during the GC. Are you waiting correctly before calling stopRecordingAllocations?" + ); + } + } + + const memory = this.getAllocatedMemory(); + const objects = this.stillAllocatedObjects(); + + let leaks; + if (debug_allocations == "allocations") { + this.logAllocationLog(); + } else if (debug_allocations == "leaks") { + leaks = this.logAllocationSitesDiff(this.data.allocations); + } + + return { + objectsWithoutStack: + objects.objectsWithoutStack - this.data.objects.objectsWithoutStack, + objectsWithStack: + objects.objectsWithStack - this.data.objects.objectsWithStack, + memory: memory - this.data.memory, + leaks, + }; + }, + + /** + * Return the collection of currently allocated JS Objects. + * + * This returns an object whose structure is documented in logAllocationSites. + */ + getAllAllocations() { + const sensus = dbg.memory.takeCensus({ + breakdown: { by: "allocationStack" }, + }); + const sources = {}; + for (const [k, v] of sensus.entries()) { + const src = k.source || "UNKNOWN"; + const line = k.line || "?"; + const count = v.count; + + let item = sources[src]; + if (!item) { + item = sources[src] = { count: 0, lines: {} }; + } + item.count += count; + if (line != -1) { + if (!item.lines[line]) { + item.lines[line] = 0; + } + item.lines[line] += count; + } + } + return sources; + }, + + /** + * Substract count of `previousSources` from `newSources`. + * This help know which allocations where done between `previousSources` and `newSources` records, + * and, are still allocated. + * + * The structure of source objects is documented in logAllocationSites. + */ + sourcesDiff(previousSources, newSources) { + for (const src in previousSources) { + const previousItem = previousSources[src]; + const item = newSources[src]; + if (!item) { + continue; + } + item.count -= previousItem.count; + + for (const line in previousItem.lines) { + const count = previousItem.lines[line]; + if (line != -1) { + if (!item.lines[line]) { + continue; + } + item.lines[line] -= count; + } + } + } + }, + + /** + * Print to stdout data about all recorded allocations + * + * It prints an array of allocations per file, sorted by files allocating the most + * objects. And get detail of allocation per line. + * + * [{ src: "chrome://devtools/content/framework/toolbox.js", + * count: 210, // Total # of allocs for toolbox.js + * lines: [ + * "10: 200", // toolbox.js allocation 200 objects on line 10 + * "124: 10 + * ] + * }, + * { src: "chrome://devtools/content/inspector/inspector.js", + * count: 12, + * lines: [ + * "20: 12", + * ] + * }] + * + * @param first Number + * Retrieve only the top $first script allocation the most + * objects + */ + logAllocationSites(message, sources, { first = 1000 } = {}) { + const allocationList = Object.entries(sources) + // Sort by number of total object + .sort(([srcA, itemA], [srcB, itemB]) => itemB.count - itemA.count) + // Keep only the first n-th sources, with the most allocations + .filter((_, i) => i < first) + .map(([src, item]) => { + const lines = []; + Object.entries(item.lines) + // Filter out lines where we only freed objects + .filter(([line, count]) => count > 0) + .sort(([lineA, countA], [lineB, countB]) => { + if (countA != countB) { + return countB - countA; + } + return lineB - lineA; + }) + .forEach(([line, count]) => { + // Compress the data to make it readable on stdout + lines.push(line + ": " + count); + }); + return { src, count: item.count, lines }; + }) + // Filter out modules where we only freed objects + .filter(({ count }) => count > 0); + dump( + "DEVTOOLS ALLOCATION: " + + message + + ":\n" + + JSON.stringify(allocationList, null, 2) + + "\n" + ); + return allocationList; + }, + + /** + * This method requires a previous call to getAllAllocations + * and will print only the allocation sites which are still allocated. + * Usage: + * const previousSources = this.getAllAllocations(); + * ... exercice something, which may leak ... + * this.logAllocationSitesDiff(previousSources); + */ + logAllocationSitesDiff(previousSources) { + const newSources = this.getAllAllocations(); + this.sourcesDiff(previousSources, newSources); + return this.logAllocationSites("allocations which leaked", newSources); + }, + + /** + * Convert allocation structure coming out from Memory API's `drainAllocationsLog()` + * to source structure documented in logAllocationSites. + */ + allocationsToSources(allocations) { + const sources = {}; + for (const alloc of allocations) { + const { frame } = alloc; + let src = "UNKNOWN"; + let line = -1; + try { + if (frame) { + src = frame.source || "UNKNOWN"; + line = frame.line || -1; + } + } catch (e) { + // For some frames accessing source throws + } + let item = sources[src]; + if (!item) { + item = sources[src] = { count: 0, lines: {} }; + } + item.count++; + if (line != -1) { + if (!item.lines[line]) { + item.lines[line] = 0; + } + item.lines[line]++; + } + } + return sources; + }, + + /** + * This method will log all the allocations that happened since the last call + * to this method -or- to `flushAllocations`. + * Reported allocations may have been freed. + * Use `logAllocationSitesDiff` to know what hasn't been freed. + */ + logAllocationLog(allocations, msg = "") { + if (!allocations) { + allocations = dbg.memory.drainAllocationsLog(); + } + const sources = this.allocationsToSources(allocations); + return this.logAllocationSites( + msg + ? msg + : "all allocations (which may be freed or are still allocated)", + sources + ); + }, + + logCount() { + dump( + "DEVTOOLS ALLOCATION: Javascript object allocations: " + + this.countAllocations() + + "\n" + ); + }, + + countAllocations() { + // Fetch all allocation sites from Debugger API + const allocations = dbg.memory.drainAllocationsLog(); + return allocations.length; + }, + + /** + * Reset the allocation log, so that the next call to logAllocationLog/drainAllocationsLog + * will report all allocations which happened after this call to flushAllocations. + */ + flushAllocations() { + dbg.memory.drainAllocationsLog(); + }, + + /** + * Compute the live count of object currently allocated. + * + * `objects` attribute will count all the objects, + * while `objectsWithNoStack` will report how many are missing allocation site/stack. + */ + stillAllocatedObjects() { + const sensus = dbg.memory.takeCensus({ + breakdown: { by: "allocationStack" }, + }); + let objectsWithStack = 0; + let objectsWithoutStack = 0; + for (const [k, v] of sensus.entries()) { + // Objects with missing stack will all be keyed under "noStack" string, + // while all others will have a stack object as key. + if (k === "noStack") { + objectsWithoutStack += v.count; + } else { + objectsWithStack += v.count; + } + } + return { objectsWithStack, objectsWithoutStack }; + }, + + /** + * Reports the amount of OS memory used by the current process. + */ + getAllocatedMemory() { + return MemoryReporter.residentUnique; + }, + + async doGC() { + // In order to get stable results, we really have to do 3 GC attempts + // *and* do wait for 1s between each GC. + const numCycles = 3; + for (let i = 0; i < numCycles; i++) { + Cu.forceGC(); + Cu.forceCC(); + await new Promise(resolve => Cu.schedulePreciseShrinkingGC(resolve)); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 1000)); + } + + // Also call minimizeMemoryUsage as that's the only way to purge JIT cache. + // CachedIR objects (JIT related objects) are ultimately leading to keep + // all transient globals in memory. For some reason, when enabling trackingAllocationSites=true + // we compute stack traces (SavedFrame) for each object being allocated. + // This either create new CachedIR -or- force holding alive existing CachedIR + // and CachedIR itself hold strong references to the transient globals. + // See bug 1733480. + await new Promise(resolve => MemoryReporter.minimizeMemoryUsage(resolve)); + }, + + /** + * Return the absolute file path to a memory snapshot. + * This is used to compute dominator trees in `traceObjects`. + */ + getSnapshotFile() { + return ChromeUtils.saveHeapSnapshot({ debugger: dbg }); + }, + + /** + * Print information about why a list of objects are being held in memory. + * + * @param Array<NodeId> objects + * List of NodeId's of objects to debug. NodeIds can be retrieved + * via ChromeUtils.getObjectNodeId. + * @param String snapshotFile + * Absolute path to a Heap snapshot file retrieved via this.getSnapshotFile. + * This is used to trace content process objects. We have to record the snapshot + * from the content process, but can only read it from the parent process because + * of I/O restrictions in content processes. + */ + traceObjects(objects, snapshotFile) { + // There is no API to get the heap snapshot at runtime, + // the only way is to save it to disk and then load it from disk + if (!snapshotFile) { + snapshotFile = this.getSnapshotFile(); + } + const snapshot = ChromeUtils.readHeapSnapshot(snapshotFile); + + function getObjectClass(id) { + if (!id) { + return "<null>"; + } + try { + let stack = [...snapshot.describeNode({ by: "allocationStack" }, id)]; + let line; + if (stack) { + stack = stack.find(([src]) => src != "noStack"); + if (stack) { + line = stack[0].line; + stack = stack[0].source; + if (stack) { + const pstack = stack; + stack = stack.match(/\/([^\/]+)$/); + if (stack) { + stack = stack[1]; + } else { + stack = pstack; + } + } else { + stack = "no-source"; + } + } else { + stack = "no-stack"; + } + } else { + stack = "no-desc"; + } + return ( + Object.entries( + snapshot.describeNode({ by: "objectClass" }, id) + )[0][0] + (stack ? "@" + stack + ":" + line : "") + ); + } catch (e) { + if (e.name == "NS_ERROR_ILLEGAL_VALUE") { + return "<not-in-memory-snapshot:is-from-untracked-global?>"; + } + return "<invalid:" + id + ":" + e + ">"; + } + } + function printPath(src, dst) { + let paths; + try { + paths = snapshot.computeShortestPaths(src, [dst], 10); + } catch (e) {} + if (paths && paths.has(dst)) { + let pathLength = Infinity; + for (const path of paths.get(dst)) { + // Only print the smaller paths. + // The longer ones will only repeat the smaller ones, with some extra edges. + if (path.length > pathLength) { + continue; + } + pathLength = path.length; + dump( + "- " + + path + .map( + ({ predecessor, edge }) => + getObjectClass(predecessor) + "." + edge + ) + .join("\n \\--> ") + + "\n \\--> " + + getObjectClass(dst) + + "\n" + ); + } + } else { + dump("NO-PATH\n"); + } + } + + const tree = snapshot.computeDominatorTree(); + for (const objectNodeId of objects) { + dump(" # Tracing: " + getObjectClass(objectNodeId) + "\n"); + + // Print the path from the global object down to leaked object. + // This print the allocation site of each object which has a reference + // to another object, ultimately leading to our leaked object. + dump("### Path(s) from root:\n"); + printPath(tree.root, objectNodeId); + + /** + * This happens to be redundant with printPath, but printed the other way around. + * + // Print the dominators. + // i.e. from the leaked object, print all parent objects whichs + // keeps a reference to the previous object, up to a global object. + dump("### Dominators:\n"); + let node = objectNodeId, + dump(" " + getObjectClass(node) + "\n"); + while ((node = tree.getImmediateDominator(node))) { + dump(" ^-- " + getObjectClass(node) + "\n"); + } + */ + + /** + * In case you are not able to figure out what the object is. + * This will print all what it keeps allocated, + * kinds of list of attributes + * + dump("### Dominateds:\n"); + node = objectNodeId, + dump(" " + getObjectClass(node) + "\n"); + for (const n of tree.getImmediatelyDominated(objectNodeId)) { + dump(" --> " + getObjectClass(n) + "\n"); + } + */ + } + }, + + stop() { + dump("DEVTOOLS ALLOCATION: Stop logging allocations\n"); + dbg.onNewGlobalObject = undefined; + dbg.removeAllDebuggees(); + dbg = null; + }, + }; +}; |