diff options
Diffstat (limited to 'devtools/shared/test-helpers')
-rw-r--r-- | devtools/shared/test-helpers/allocation-tracker.js | 637 | ||||
-rw-r--r-- | devtools/shared/test-helpers/browser.ini | 8 | ||||
-rw-r--r-- | devtools/shared/test-helpers/browser_allocation_tracker.js | 250 | ||||
-rw-r--r-- | devtools/shared/test-helpers/moz.build | 13 | ||||
-rw-r--r-- | devtools/shared/test-helpers/test_javascript_tracer.js | 71 | ||||
-rw-r--r-- | devtools/shared/test-helpers/thread-helpers.sys.mjs | 143 | ||||
-rw-r--r-- | devtools/shared/test-helpers/tracked-objects.sys.mjs | 47 | ||||
-rw-r--r-- | devtools/shared/test-helpers/xpcshell.ini | 6 |
8 files changed, 1175 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; + }, + }; +}; diff --git a/devtools/shared/test-helpers/browser.ini b/devtools/shared/test-helpers/browser.ini new file mode 100644 index 0000000000..0b00c5c44a --- /dev/null +++ b/devtools/shared/test-helpers/browser.ini @@ -0,0 +1,8 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +support-files = + allocation-tracker.js + +[browser_allocation_tracker.js] +skip-if = debug || verify || ccov # Bug 1730507 - objects without stacks get allocated during the GC of the first test when running multiple times. Also avoid running in debug as we don't try to track memory from debug builds. And ccov as this doesn't aim to cover any production code, we are only testing test helpers here. diff --git a/devtools/shared/test-helpers/browser_allocation_tracker.js b/devtools/shared/test-helpers/browser_allocation_tracker.js new file mode 100644 index 0000000000..aedefcb2ba --- /dev/null +++ b/devtools/shared/test-helpers/browser_allocation_tracker.js @@ -0,0 +1,250 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Load the tracker in a dedicated loader using invisibleToDebugger and freshCompartment +// so that it can inspect any other module/compartment, even DevTools, chrome, +// and this script! +const { DevToolsLoader } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); +const loader = new DevToolsLoader({ + invisibleToDebugger: true, + freshCompartment: true, +}); +const { allocationTracker } = loader.require( + "chrome://mochitests/content/browser/devtools/shared/test-helpers/allocation-tracker" +); +const TrackedObjects = loader.require( + "resource://devtools/shared/test-helpers/tracked-objects.sys.mjs" +); + +// This test record multiple times complete heap snapshot, +// so that it can take a little bit to complete. +requestLongerTimeout(2); + +add_task(async function () { + // Use a sandbox to allocate test javascript object in order to avoid any + // external noise + const global = Cu.Sandbox("http://example.com"); + + const tracker = allocationTracker({ watchGlobal: global }); + const before = tracker.stillAllocatedObjects(); + + /* eslint-disable no-undef */ + // This will allocation 1001 objects. The array and 1000 elements in it. + Cu.evalInSandbox( + "let list; new " + + function () { + list = []; + for (let i = 0; i < 1000; i++) { + list.push({}); + } + }, + global, + undefined, + "test-file.js", + 1, + /* enforceFilenameRestrictions */ false + ); + /* eslint-enable no-undef */ + + const allocations = tracker.countAllocations(); + ok( + allocations >= 1001, + `At least 1001 objects are reported as created (${allocations})` + ); + + // Uncomment this and comment the call to `countAllocations` to debug the allocations. + // The call to `countAllocations` will reset the allocation record. + // tracker.logAllocationSites(); + + const afterCreation = tracker.stillAllocatedObjects(); + is( + afterCreation.objectsWithStack - before.objectsWithStack, + 1001, + "We got exactly the expected number of objects recorded with an allocation site" + ); + ok( + afterCreation.objectsWithStack > before.objectsWithStack, + "We got some random number of objects without an allocation site" + ); + + Cu.evalInSandbox( + "list = null;", + global, + undefined, + "test-file.js", + 7, + /* enforceFilenameRestrictions */ false + ); + + Cu.forceGC(); + Cu.forceCC(); + + const afterGC = tracker.stillAllocatedObjects(); + is( + afterCreation.objectsWithStack - afterGC.objectsWithStack, + 1001, + "All the expected objects were reported freed in the count with allocation sites" + ); + ok( + afterGC.objectsWithoutStack < afterCreation.objectsWithoutStack, + "And we released some random number of objects without an allocation site" + ); + + tracker.stop(); +}); + +add_task(async function () { + const leaked = {}; + TrackedObjects.track(leaked); + let transient = {}; + TrackedObjects.track(transient); + + is(TrackedObjects.getAllNodeIds().length, 2, "The two objects are reported"); + + info("Free the transient object"); + transient = null; + Cu.forceGC(); + + is( + TrackedObjects.getAllNodeIds().length, + 1, + "We now only have the leaked object" + ); + TrackedObjects.clear(); +}); + +add_task(async function () { + info("Test start and stop recording without any debug mode"); + const tracker = allocationTracker({ watchDevToolsGlobals: true }); + await tracker.startRecordingAllocations(); + await tracker.stopRecordingAllocations(); + tracker.stop(); +}); + +add_task(async function () { + info("Test start and stop recording with 'allocations' debug mode"); + const tracker = allocationTracker({ watchDevToolsGlobals: true }); + await tracker.startRecordingAllocations("allocations"); + await tracker.stopRecordingAllocations("allocations"); + tracker.stop(); +}); + +add_task(async function () { + info("Test start and stop recording with 'leaks' debug mode"); + const tracker = allocationTracker({ watchDevToolsGlobals: true }); + await tracker.startRecordingAllocations("leaks"); + await tracker.stopRecordingAllocations("leaks"); + tracker.stop(); +}); + +add_task(async function () { + info("Test start and stop recording with tracked objects"); + + const leaked = {}; + TrackedObjects.track(leaked); + + const tracker = allocationTracker({ watchAllGlobals: true }); + await tracker.startRecordingAllocations(); + await tracker.stopRecordingAllocations(); + tracker.stop(); + + TrackedObjects.clear(); +}); + +add_task(async function () { + info("Test start and stop recording with tracked objects"); + + const sandbox = Cu.Sandbox(window); + const tracker = allocationTracker({ watchGlobal: sandbox }); + await tracker.startRecordingAllocations("leaks"); + + Cu.evalInSandbox("this.foo = {};", sandbox, null, "sandbox.js", 1); + + const record = await tracker.stopRecordingAllocations("leaks"); + is( + record.objectsWithStack, + 1, + "We get only one leaked objects, the foo object of the sandbox." + ); + ok( + record.objectsWithoutStack > 10, + "We get an handful of objects without stacks. Most likely created by Memory API itself." + ); + + is( + record.leaks.length, + 2, + "We get the one leak and the objects with missing stacks" + ); + is( + record.leaks[0].src, + "UNKNOWN", + "First item is the objects with missing stacks" + ); + // In theory the two following values should be equal, + // but they aren't always because of some dark matter around objects with missing stacks. + // `count` is computed out of `takeCensus`, while `objectsWithoutStack` uses `drainAllocationsLog` + // While the first go through the current GC graph, the second is a record of allocations over time, + // this probably explain why there is some subtle difference + ok( + record.leaks[0].count <= record.objectsWithoutStack, + "For now, the leak report intermittently assume there is less leaked objects than the summary" + ); + is(record.leaks[1].src, "sandbox.js", "Second item if about our 'foo' leak"); + is(record.leaks[1].count, 1, "We leak one object on this file"); + is(record.leaks[1].lines.length, 1, "We leak from only one line"); + is(record.leaks[1].lines[0], "1: 1", "On first line, we leak one object"); + tracker.stop(); + + TrackedObjects.clear(); +}); + +add_task(async function () { + info("Test that transient globals are not leaked"); + + const tracker = allocationTracker({ watchAllGlobals: true }); + + let sandboxBefore = Cu.Sandbox(window); + // We need to allocate at least one object from the global to reproduce the leak + Cu.evalInSandbox( + "this.foo = {};", + sandboxBefore, + null, + "sandbox-before.js", + 1 + ); + const weakBefore = Cu.getWeakReference(sandboxBefore); + sandboxBefore = null; + + await tracker.startRecordingAllocations(); + + ok( + !weakBefore.get(), + "Sandbox created before the record should have been freed by GCs done by startRecordingAllocations" + ); + + let sandboxDuring = Cu.Sandbox(window); + // We need to allocate at least one object from the global to reproduce the leak + Cu.evalInSandbox( + "this.bar = {};", + sandboxDuring, + null, + "sandbox-during.js", + 1 + ); + const weakDuring = Cu.getWeakReference(sandboxDuring); + sandboxDuring = null; + + await tracker.stopRecordingAllocations(); + + ok( + !weakDuring.get(), + "Sandbox should have been freed by GCs done by stopRecordingAllocations" + ); + + tracker.stop(); +}); diff --git a/devtools/shared/test-helpers/moz.build b/devtools/shared/test-helpers/moz.build new file mode 100644 index 0000000000..92b1d5212d --- /dev/null +++ b/devtools/shared/test-helpers/moz.build @@ -0,0 +1,13 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +DevToolsModules( + "thread-helpers.sys.mjs", + "tracked-objects.sys.mjs", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "General") diff --git a/devtools/shared/test-helpers/test_javascript_tracer.js b/devtools/shared/test-helpers/test_javascript_tracer.js new file mode 100644 index 0000000000..718e613632 --- /dev/null +++ b/devtools/shared/test-helpers/test_javascript_tracer.js @@ -0,0 +1,71 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test for the thread helpers utility module. + * + * This uses a xpcshell test in order to avoid recording the noise + * of all Firefox components when using a mochitest. + */ + +const { traceAllJSCalls } = ChromeUtils.importESModule( + "resource://devtools/shared/test-helpers/thread-helpers.sys.mjs" +); +// ESLint thinks this is a browser test, but it's actually an xpcshell +// test and so `setTimeout` isn't available out of the box. +// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +add_task(async function sanityCheck() { + let ranTheOtherEventLoop = false; + setTimeout(function otherEventLoop() { + ranTheOtherEventLoop = true; + }, 0); + const jsTracer = traceAllJSCalls(); + function foo() {} + for (let i = 0; i < 10; i++) { + foo(); + } + jsTracer.stop(); + ok( + !ranTheOtherEventLoop, + "When we don't pause frame execution, the other event do not execute" + ); +}); + +add_task(async function withPrefix() { + const jsTracer = traceAllJSCalls({ prefix: "my-prefix" }); + function foo() {} + for (let i = 0; i < 10; i++) { + foo(); + } + jsTracer.stop(); + ok(true, "Were able to run with a prefix argument"); +}); + +add_task(async function pause() { + const start = Cu.now(); + let ranTheOtherEventLoop = false; + setTimeout(function otherEventLoop() { + ranTheOtherEventLoop = true; + }, 0); + const jsTracer = traceAllJSCalls({ pause: 100 }); + function foo() {} + for (let i = 0; i < 10; i++) { + foo(); + } + jsTracer.stop(); + const duration = Cu.now() - start; + ok( + duration > 10 * 100, + "The execution of the for loop was slow down by at least the pause duration in each loop" + ); + ok( + ranTheOtherEventLoop, + "When we pause frame execution, the other event can execute" + ); +}); diff --git a/devtools/shared/test-helpers/thread-helpers.sys.mjs b/devtools/shared/test-helpers/thread-helpers.sys.mjs new file mode 100644 index 0000000000..b99fc16f00 --- /dev/null +++ b/devtools/shared/test-helpers/thread-helpers.sys.mjs @@ -0,0 +1,143 @@ +/* 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/. */ + +/** + * Helper code to play with the javascript thread + **/ + +function getSandboxWithDebuggerSymbol() { + // Bug 1835268 - Changing this to an ES module import currently throws an + // assertion in test_javascript_tracer.js in debug builds. + const { addDebuggerToGlobal } = ChromeUtils.import( + "resource://gre/modules/jsdebugger.jsm" + ); + const systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); + + const debuggerSandbox = Cu.Sandbox(systemPrincipal, { + // This sandbox is also reused for ChromeDebugger implementation. + // As we want to load the `Debugger` API for debugging chrome contexts, + // we have to ensure loading it in a distinct compartment from its debuggee. + freshCompartment: true, + invisibleToDebugger: true, + }); + addDebuggerToGlobal(debuggerSandbox); + + return debuggerSandbox; +} + +/** + * Implementation of a Javascript tracer logging traces to stdout. + * + * To be used like this: + + const { traceAllJSCalls } = ChromeUtils.importESModule( + "resource://devtools/shared/test-helpers/thread-helpers.sys.mjs" + ); + const jsTracer = traceAllJSCalls(); + [... execute some code to tracer ...] + jsTracer.stop(); + + * @param prefix String + * Optional, if passed, this will be displayed in front of each + * line reporting a new frame execution. + * @param pause Number + * Optional, if passed, hold off each frame for `pause` ms, + * by letting the other event loops run in between. + * Be careful that it can introduce unexpected race conditions + * that can't necessarily be reproduced without this. + */ +export function traceAllJSCalls({ prefix = "", pause } = {}) { + const debuggerSandbox = getSandboxWithDebuggerSymbol(); + + debuggerSandbox.Services = Services; + const f = Cu.evalInSandbox( + "(" + + function (pauseInMs, prefixString) { + const dbg = new Debugger(); + // Add absolutely all the globals... + dbg.addAllGlobalsAsDebuggees(); + // ...but avoid tracing this sandbox code + const global = Cu.getGlobalForObject(this); + dbg.removeDebuggee(global); + + // Add all globals created later on + dbg.onNewGlobalObject = g => dbg.addDebuggee(g); + + function formatDisplayName(frame) { + if (frame.type === "call") { + const callee = frame.callee; + return callee.name || callee.userDisplayName || callee.displayName; + } + + return `(${frame.type})`; + } + + function stop() { + dbg.onEnterFrame = undefined; + dbg.removeAllDebuggees(); + } + global.stop = stop; + + let depth = 0; + dbg.onEnterFrame = frame => { + if (depth == 100) { + dump( + "Looks like an infinite loop? We stop the js tracer, but code may still be running!\n" + ); + stop(); + return; + } + + const { script } = frame; + const { lineNumber, columnNumber } = script.getOffsetMetadata( + frame.offset + ); + const padding = new Array(depth).join(" "); + dump( + `${prefixString}${padding}--[${frame.implementation}]--> ${ + script.source.url + } @ ${lineNumber}:${columnNumber} - ${formatDisplayName(frame)}\n` + ); + + depth++; + frame.onPop = () => { + depth--; + }; + + // Optionaly pause the frame execute by letting the other event loop to run in between. + if (typeof pauseInMs == "number") { + let freeze = true; + const timer = Cc["@mozilla.org/timer;1"].createInstance( + Ci.nsITimer + ); + timer.initWithCallback( + () => { + freeze = false; + }, + pauseInMs, + Ci.nsITimer.TYPE_ONE_SHOT + ); + Services.tm.spinEventLoopUntil("debugger-slow-motion", function () { + return !freeze; + }); + } + }; + + return { stop }; + } + + ")", + debuggerSandbox, + undefined, + "debugger-javascript-tracer", + 1, + /* enforceFilenameRestrictions */ false + ); + f(pause, prefix); + + return { + stop() { + debuggerSandbox.stop(); + }, + }; +} diff --git a/devtools/shared/test-helpers/tracked-objects.sys.mjs b/devtools/shared/test-helpers/tracked-objects.sys.mjs new file mode 100644 index 0000000000..54f53fb5fa --- /dev/null +++ b/devtools/shared/test-helpers/tracked-objects.sys.mjs @@ -0,0 +1,47 @@ +/* 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/. */ + +// Test-only module in order to register objects later inspected by +// the allocation tracker (in the same folder). +// +// We are going to store a weak reference to the passed objects, +// in order to prevent holding them in memory. +// Allocation tracker will then print detailed information +// about why these objects are still allocated. + +const objects = []; + +/** + * Request to track why the given object is kept in memory, + * later on, when retrieving all the watched object via getAllNodeIds. + */ +export function track(obj) { + // We store a weak reference, so that we do force keeping the object in memory!! + objects.push(Cu.getWeakReference(obj)); +} + +/** + * Return the NodeId's of all the objects passed via `track()` method. + * + * NodeId's are used by spidermonkey memory API to designates JS objects in head snapshots. + */ +export function getAllNodeIds() { + // Filter out objects which have been freed already + return ( + objects + .map(weak => weak.get()) + .filter(obj => !!obj) + // Convert objects from here instead of from allocation tracker in order + // to be from the shared system compartment and avoid trying to compute the NodeId + // of a wrapper! + .map(ChromeUtils.getObjectNodeId) + ); +} + +/** + * Used by tests to clear all tracked objects + */ +export function clear() { + objects.length = 0; +} diff --git a/devtools/shared/test-helpers/xpcshell.ini b/devtools/shared/test-helpers/xpcshell.ini new file mode 100644 index 0000000000..7231cc993c --- /dev/null +++ b/devtools/shared/test-helpers/xpcshell.ini @@ -0,0 +1,6 @@ +[DEFAULT] +tags = devtools +firefox-appdir = browser +skip-if = toolkit == 'android' + +[test_javascript_tracer.js] |