/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-*/ /* vim: set ts=8 sts=2 et sw=2 tw=80: */ /* 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/. */ // You can direct about:memory to immediately load memory reports from a file // by providing a file= query string. For example, // // about:memory?file=/home/username/reports.json.gz // // "file=" is not case-sensitive. We'll URI-unescape the contents of the // "file=" argument, and obviously the filename is case-sensitive iff you're on // a case-sensitive filesystem. If you specify more than one "file=" argument, // only the first one is used. "use strict"; // --------------------------------------------------------------------------- let CC = Components.Constructor; const KIND_NONHEAP = Ci.nsIMemoryReporter.KIND_NONHEAP; const KIND_HEAP = Ci.nsIMemoryReporter.KIND_HEAP; const KIND_OTHER = Ci.nsIMemoryReporter.KIND_OTHER; const UNITS_BYTES = Ci.nsIMemoryReporter.UNITS_BYTES; const UNITS_COUNT = Ci.nsIMemoryReporter.UNITS_COUNT; const UNITS_COUNT_CUMULATIVE = Ci.nsIMemoryReporter.UNITS_COUNT_CUMULATIVE; const UNITS_PERCENTAGE = Ci.nsIMemoryReporter.UNITS_PERCENTAGE; const { XPCOMUtils } = ChromeUtils.importESModule( "resource://gre/modules/XPCOMUtils.sys.mjs" ); const { NetUtil } = ChromeUtils.importESModule( "resource://gre/modules/NetUtil.sys.mjs" ); ChromeUtils.defineESModuleGetters(this, { Downloads: "resource://gre/modules/Downloads.sys.mjs", FileUtils: "resource://gre/modules/FileUtils.sys.mjs", }); ChromeUtils.defineLazyGetter(this, "nsBinaryStream", () => CC( "@mozilla.org/binaryinputstream;1", "nsIBinaryInputStream", "setInputStream" ) ); ChromeUtils.defineLazyGetter(this, "nsFile", () => CC("@mozilla.org/file/local;1", "nsIFile", "initWithPath") ); ChromeUtils.defineLazyGetter(this, "nsGzipConverter", () => CC( "@mozilla.org/streamconv;1?from=gzip&to=uncompressed", "nsIStreamConverter" ) ); let gMgr = Cc["@mozilla.org/memory-reporter-manager;1"].getService( Ci.nsIMemoryReporterManager ); const gPageName = "about:memory"; document.title = gPageName; const gMainProcessPrefix = "Main Process"; const gFilterUpdateDelayMS = 300; let gIsDiff = false; let gCurrentReports = []; let gCurrentHasMozMallocUsableSize = false; let gCurrentIsDiff = false; let gFilter = ""; // --------------------------------------------------------------------------- // Forward slashes in URLs in paths are represented with backslashes to avoid // being mistaken for path separators. Paths/names where this hasn't been // undone are prefixed with "unsafe"; the rest are prefixed with "safe". function flipBackslashes(aUnsafeStr) { // Save memory by only doing the replacement if it's necessary. return !aUnsafeStr.includes("\\") ? aUnsafeStr : aUnsafeStr.replace(/\\/g, "/"); } const gAssertionFailureMsgPrefix = "aboutMemory.js assertion failed: "; // This is used for things that should never fail, and indicate a defect in // this file if they do. function assert(aCond, aMsg) { if (!aCond) { reportAssertionFailure(aMsg); throw new Error(gAssertionFailureMsgPrefix + aMsg); } } // This is used for malformed input from memory reporters. function assertInput(aCond, aMsg) { if (!aCond) { throw new Error(`Invalid memory report(s): ${aMsg}`); } } function handleException(aEx) { let str = "" + aEx; if (str.startsWith(gAssertionFailureMsgPrefix)) { // Argh, assertion failure within this file! Give up. throw aEx; } else { // File or memory reporter problem. Print a message. updateMainAndFooter(str, NO_TIMESTAMP, HIDE_FOOTER, "badInputWarning"); } } function reportAssertionFailure(aMsg) { let debug = Cc["@mozilla.org/xpcom/debug;1"].getService(Ci.nsIDebug2); if (debug.isDebugBuild) { debug.assertion(aMsg, "false", "aboutMemory.js", 0); } } function debug(aVal) { let section = appendElement(document.body, "div", "section"); appendElementWithText(section, "div", "debug", JSON.stringify(aVal)); } function stringMatchesFilter(aString, aFilter) { assert( typeof aFilter == "string" || aFilter instanceof RegExp, "unexpected aFilter type" ); return typeof aFilter == "string" ? aString.includes(aFilter) : aFilter.test(aString); } // --------------------------------------------------------------------------- window.onunload = function () {}; // --------------------------------------------------------------------------- // The
holding everything but the header and footer (if they're present). // It's what is updated each time the page changes. let gMain; // The
holding the footer. let gFooter; // The "verbose" checkbox. let gVerbose; // The "anonymize" checkbox. let gAnonymize; // Values for the |aFooterAction| argument to updateTitleMainAndFooter. const HIDE_FOOTER = 0; const SHOW_FOOTER = 1; // Values for the |aShowTimestamp| argument to updateTitleMainAndFooter. const NO_TIMESTAMP = 0; const SHOW_TIMESTAMP = 1; function updateTitleMainAndFooter( aTitleNote, aMsg, aShowTimestamp, aFooterAction, aClassName ) { document.title = gPageName; if (aTitleNote) { document.title += ` (${aTitleNote})`; } // Clear gMain by replacing it with an empty node. let tmp = gMain.cloneNode(false); gMain.parentNode.replaceChild(tmp, gMain); gMain = tmp; gMain.classList.remove("hidden"); gMain.classList.remove("verbose"); gMain.classList.remove("non-verbose"); if (gVerbose) { gMain.classList.add(gVerbose.checked ? "verbose" : "non-verbose"); } let msgElement; if (aMsg) { let className = "section"; if (aClassName) { className = className + " " + aClassName; } if (aShowTimestamp == SHOW_TIMESTAMP) { // JS has many options for pretty-printing timestamps. We use // toISOString() because it has sub-second granularity, which is useful // if you quickly and repeatedly click one of the buttons. aMsg += ` (${new Date().toISOString()})`; } msgElement = appendElementWithText(gMain, "div", className, aMsg); } switch (aFooterAction) { case HIDE_FOOTER: gFooter.classList.add("hidden"); break; case SHOW_FOOTER: gFooter.classList.remove("hidden"); break; default: assert(false, "bad footer action in updateTitleMainAndFooter"); } return msgElement; } function updateMainAndFooter(aMsg, aShowTimestamp, aFooterAction, aClassName) { return updateTitleMainAndFooter( "", aMsg, aShowTimestamp, aFooterAction, aClassName ); } function appendTextNode(aP, aText) { let e = document.createTextNode(aText); aP.appendChild(e); return e; } function appendElement(aP, aTagName, aClassName) { let e = newElement(aTagName, aClassName); aP.appendChild(e); return e; } function appendElementWithText(aP, aTagName, aClassName, aText) { let e = appendElement(aP, aTagName, aClassName); // Setting textContent clobbers existing children, but there are none. More // importantly, it avoids creating a JS-land object for the node, saving // memory. e.textContent = aText; return e; } function newElement(aTagName, aClassName) { let e = document.createElement(aTagName); if (aClassName) { e.className = aClassName; } return e; } // --------------------------------------------------------------------------- const explicitTreeDescription = "This tree covers explicit memory allocations by the application. It includes \ \n\n\ * all allocations made at the heap allocation level (via functions such as malloc, \ calloc, realloc, memalign, operator new, and operator new[]) that have not been \ explicitly decommitted (i.e. evicted from memory and swap), and \ \n\n\ * some allocations (those covered by memory reporters) made at the operating \ system level (via calls to functions such as VirtualAlloc, vm_allocate, and \ mmap), \ \n\n\ * where possible, the overhead of the heap allocator itself.\ \n\n\ It excludes memory that is mapped implicitly such as code and data segments, \ and thread stacks. \ \n\n\ 'explicit' is not guaranteed to cover every explicit allocation, but it does cover \ most (including the entire heap), and therefore it is the single best number to \ focus on when trying to reduce memory usage."; // --------------------------------------------------------------------------- function appendButton(aP, aTitle, aOnClick, aText, aId) { let b = appendElementWithText(aP, "button", "", aText); b.title = aTitle; b.onclick = aOnClick; if (aId) { b.id = aId; } return b; } function appendHiddenFileInput(aP, aId, aChangeListener) { let input = appendElementWithText(aP, "input", "hidden", ""); input.type = "file"; input.id = aId; // used in testing input.addEventListener("change", aChangeListener); return input; } window.onload = function () { // Generate the header. let header = appendElement(document.body, "div", "ancillary"); // A hidden file input element that can be invoked when necessary. let fileInput1 = appendHiddenFileInput(header, "fileInput1", function () { let file = this.files[0]; let filename = file.mozFullPath; updateAboutMemoryFromFile(filename); }); // Ditto. let fileInput2 = appendHiddenFileInput( header, "fileInput2", function (aElem) { let file = this.files[0]; // First time around, we stash a copy of the filename and reinvoke. Second // time around we do the diff and display. if (!this.filename1) { this.filename1 = file.mozFullPath; // aElem.skipClick is only true when testing -- it allows fileInput2's // onchange handler to be re-called without having to go via the file // picker. if (!aElem.skipClick) { this.click(); } } else { let filename1 = this.filename1; delete this.filename1; updateAboutMemoryFromTwoFiles(filename1, file.mozFullPath); } } ); const CuDesc = "Measure current memory reports and show."; const LdDesc = "Load memory reports from file and show."; const DfDesc = "Load memory report data from two files and show the difference."; const SvDesc = "Save memory reports to file."; const GCDesc = "Do a global garbage collection."; const CCDesc = "Do a cycle collection."; const MMDesc = 'Send three "heap-minimize" notifications in a ' + "row. Each notification triggers a global garbage " + "collection followed by a cycle collection, and causes the " + "process to reduce memory usage in other ways, e.g. by " + "flushing various caches."; const GCAndCCLogDesc = "Save garbage collection log and concise cycle " + "collection log.\n" + "WARNING: These logs may be large (>1GB)."; const GCAndCCAllLogDesc = "Save garbage collection log and verbose cycle " + "collection log.\n" + "WARNING: These logs may be large (>1GB)."; const DMDEnabledDesc = "Analyze memory reports coverage and save the " + "output to the temp directory.\n"; const DMDDisabledDesc = "DMD is not running. Please re-start with $DMD and " + "the other relevant environment variables set " + "appropriately."; let ops = appendElement(header, "div", ""); let row1 = appendElement(ops, "div", "opsRow"); let labelDiv1 = appendElementWithText( row1, "div", "opsRowLabel", "Show memory reports" ); labelDiv1.setAttribute("role", "heading"); labelDiv1.setAttribute("aria-level", "1"); let label1 = appendElementWithText(labelDiv1, "label", ""); gVerbose = appendElement(label1, "input", ""); gVerbose.type = "checkbox"; gVerbose.id = "verbose"; // used for testing appendTextNode(label1, "verbose"); // The "measureButton" id is used for testing. appendButton(row1, CuDesc, doMeasure, "Measure", "measureButton"); appendButton(row1, LdDesc, () => fileInput1.click(), "Load…"); appendButton(row1, DfDesc, () => fileInput2.click(), "Load and diff…"); let row2 = appendElement(ops, "div", "opsRow"); let labelDiv2 = appendElementWithText( row2, "div", "opsRowLabel", "Save memory reports" ); labelDiv2.setAttribute("role", "heading"); labelDiv2.setAttribute("aria-level", "1"); appendButton(row2, SvDesc, saveReportsToFile, "Measure and save…"); // XXX: this isn't a great place for this checkbox, but I can't think of // anywhere better. let label2 = appendElementWithText(labelDiv2, "label", ""); gAnonymize = appendElement(label2, "input", ""); gAnonymize.type = "checkbox"; appendTextNode(label2, "anonymize"); let row3 = appendElement(ops, "div", "opsRow"); let labelDiv3 = appendElementWithText( row3, "div", "opsRowLabel", "Free memory" ); labelDiv3.setAttribute("role", "heading"); labelDiv3.setAttribute("aria-level", "1"); appendButton(row3, GCDesc, doGC, "GC"); appendButton(row3, CCDesc, doCC, "CC"); appendButton(row3, MMDesc, doMMU, "Minimize memory usage"); let row4 = appendElement(ops, "div", "opsRow"); let labelDiv4 = appendElementWithText( row4, "div", "opsRowLabel", "Save GC & CC logs" ); labelDiv4.setAttribute("role", "heading"); labelDiv4.setAttribute("aria-level", "1"); appendButton( row4, GCAndCCLogDesc, saveGCLogAndConciseCCLog, "Save concise", "saveLogsConcise" ); appendButton( row4, GCAndCCAllLogDesc, saveGCLogAndVerboseCCLog, "Save verbose", "saveLogsVerbose" ); // Three cases here: // - DMD is disabled (i.e. not built): don't show the button. // - DMD is enabled but is not running: show the button, but disable it. // - DMD is enabled and is running: show the button and enable it. if (gMgr.isDMDEnabled) { let row5 = appendElement(ops, "div", "opsRow"); let labelDiv5 = appendElementWithText( row5, "div", "opsRowLabel", "Save DMD output" ); labelDiv5.setAttribute("role", "heading"); labelDiv5.setAttribute("aria-level", "1"); let enableButtons = gMgr.isDMDRunning; let dmdButton = appendButton( row5, enableButtons ? DMDEnabledDesc : DMDDisabledDesc, doDMD, "Save" ); dmdButton.disabled = !enableButtons; } // Generate the main div, where content ("section" divs) will go. It's // hidden at first. gMain = appendElement(document.body, "div", ""); gMain.id = "mainDiv"; // Generate the footer. It's hidden at first. gFooter = appendElement(document.body, "div", "ancillary hidden"); gFooter.setAttribute("role", "contentinfo"); if (Services.policies.isAllowed("aboutSupport")) { let a = appendElementWithText( gFooter, "a", "option", "Troubleshooting information" ); a.href = "about:support"; } let legendText1 = "Click on a non-leaf node in a tree to expand ('++') " + "or collapse ('--') its children."; let legendText2 = "Hover the pointer over the name of a memory report " + "to see a description of what it measures."; appendElementWithText(gFooter, "div", "legend", legendText1); appendElementWithText(gFooter, "div", "legend hiddenOnMobile", legendText2); // See if we're loading from a file. (Because about:memory is a non-standard // URL, location.search is undefined, so we have to use location.href // instead.) let search = location.href.split("?")[1]; if (search) { let searchSplit = search.split("&"); for (let s of searchSplit) { if (s.toLowerCase().startsWith("file=")) { let filename = s.substring("file=".length); updateAboutMemoryFromFile(decodeURIComponent(filename)); return; } } } }; // --------------------------------------------------------------------------- function doGC() { Services.obs.notifyObservers(null, "child-gc-request"); Cu.forceGC(); updateMainAndFooter( "Garbage collection completed", SHOW_TIMESTAMP, HIDE_FOOTER ); } function doCC() { Services.obs.notifyObservers(null, "child-cc-request"); window.windowUtils.cycleCollect(); updateMainAndFooter( "Cycle collection completed", SHOW_TIMESTAMP, HIDE_FOOTER ); } function doMMU() { Services.obs.notifyObservers(null, "child-mmu-request"); gMgr.minimizeMemoryUsage(() => updateMainAndFooter( "Memory minimization completed", SHOW_TIMESTAMP, HIDE_FOOTER ) ); } function doMeasure() { updateAboutMemoryFromReporters(); } function saveGCLogAndConciseCCLog() { dumpGCLogAndCCLog(false); } function saveGCLogAndVerboseCCLog() { dumpGCLogAndCCLog(true); } function doDMD() { updateMainAndFooter( "Saving memory reports and DMD output...", NO_TIMESTAMP, HIDE_FOOTER ); try { let dumper = Cc["@mozilla.org/memory-info-dumper;1"].getService( Ci.nsIMemoryInfoDumper ); dumper.dumpMemoryInfoToTempDir( /* identifier = */ "", gAnonymize.checked, /* minimize = */ false ); updateMainAndFooter( "Saved memory reports and DMD reports analysis " + "to the temp directory", SHOW_TIMESTAMP, HIDE_FOOTER ); } catch (ex) { updateMainAndFooter(ex.toString(), NO_TIMESTAMP, HIDE_FOOTER); } } function dumpGCLogAndCCLog(aVerbose) { let dumper = Cc["@mozilla.org/memory-info-dumper;1"].getService( Ci.nsIMemoryInfoDumper ); let inProgress = updateMainAndFooter( "Saving logs...", NO_TIMESTAMP, HIDE_FOOTER ); let section = appendElement(gMain, "div", "section"); function displayInfo(aGCLog, aCCLog, aIsParent) { appendElementWithText(section, "div", "", "Saved GC log to " + aGCLog.path); let ccLogType = aVerbose ? "verbose" : "concise"; appendElementWithText( section, "div", "", "Saved " + ccLogType + " CC log to " + aCCLog.path ); } dumper.dumpGCAndCCLogsToFile("", aVerbose, /* dumpChildProcesses = */ true, { onDump: displayInfo, onFinish() { inProgress.remove(); }, }); } /** * Top-level function that does the work of generating the page from the memory * reporters. */ function updateAboutMemoryFromReporters() { updateMainAndFooter("Measuring...", NO_TIMESTAMP, HIDE_FOOTER); try { gCurrentReports = []; gCurrentHasMozMallocUsableSize = gMgr.hasMozMallocUsableSize; gCurrentIsDiff = false; gFilter = ""; // Record the reports from the live memory reporters then process them. let handleReport = function ( aProcess, aUnsafePath, aKind, aUnits, aAmount, aDescription ) { gCurrentReports.push({ process: aProcess, path: aUnsafePath, kind: aKind, units: aUnits, amount: aAmount, description: aDescription, }); }; let displayReports = function () { updateTitleMainAndFooter( "live measurement", "", NO_TIMESTAMP, SHOW_FOOTER ); updateAboutMemoryFromCurrentData(); }; gMgr.getReports( handleReport, null, displayReports, null, gAnonymize.checked ); } catch (ex) { handleException(ex); } } // Increment this if the JSON format changes. // let gCurrentFileFormatVersion = 1; /** * Parse a string as JSON and extract the |memory_report| property if it has * one, which indicates the string is from a crash dump. * * @param aStr * The string. * @return The extracted object. */ function parseAndUnwrapIfCrashDump(aStr) { let obj = JSON.parse(aStr); if (obj.memory_report !== undefined) { // It looks like a crash dump. The memory reports should be in the // |memory_report| property. obj = obj.memory_report; } return obj; } /** * Populate about:memory using the data stored in gCurrentReports and * gCurrentHasMozMallocUsableSize. */ function updateAboutMemoryFromCurrentData() { function processCurrentMemoryReports(aHandleReport, aDisplayReports) { for (let r of gCurrentReports) { aHandleReport( r.process, r.path, r.kind, r.units, r.amount, r.description, r._presence ); } aDisplayReports(); } gIsDiff = gCurrentIsDiff; appendAboutMemoryMain( processCurrentMemoryReports, gFilter, gCurrentHasMozMallocUsableSize ); gIsDiff = false; } /** * Populate about:memory using the data in the given JSON object. * * @param aObj * An object that (hopefully!) conforms to the JSON schema used by * nsIMemoryInfoDumper. */ function updateAboutMemoryFromJSONObject(aObj) { try { assertInput( aObj.version === gCurrentFileFormatVersion, "data version number missing or doesn't match" ); assertInput( aObj.hasMozMallocUsableSize !== undefined, "missing 'hasMozMallocUsableSize' property" ); assertInput( aObj.reports && aObj.reports instanceof Array, "missing or non-array 'reports' property" ); gCurrentReports = aObj.reports.concat(); gCurrentHasMozMallocUsableSize = aObj.hasMozMallocUsableSize; gCurrentIsDiff = gIsDiff; gFilter = ""; updateAboutMemoryFromCurrentData(); } catch (ex) { handleException(ex); } } /** * Populate about:memory using the data in the given JSON string. * * @param aStr * A string containing JSON data conforming to the schema used by * nsIMemoryReporterManager::dumpReports. */ function updateAboutMemoryFromJSONString(aStr) { try { let obj = parseAndUnwrapIfCrashDump(aStr); updateAboutMemoryFromJSONObject(obj); } catch (ex) { handleException(ex); } } /** * Loads the contents of a file into a string and passes that to a callback. * * @param aFilename * The name of the file being read from. * @param aTitleNote * A description to put in the page title upon completion. * @param aFn * The function to call and pass the read string to upon completion. */ function loadMemoryReportsFromFile(aFilename, aTitleNote, aFn) { updateMainAndFooter("Loading...", NO_TIMESTAMP, HIDE_FOOTER); try { let reader = new FileReader(); reader.onerror = () => { throw new Error("FileReader.onerror"); }; reader.onabort = () => { throw new Error("FileReader.onabort"); }; reader.onload = aEvent => { // Clear "Loading..." from above. updateTitleMainAndFooter(aTitleNote, "", NO_TIMESTAMP, SHOW_FOOTER); aFn(aEvent.target.result); }; // If it doesn't have a .gz suffix, read it as a (legacy) ungzipped file. if (!aFilename.endsWith(".gz")) { File.createFromFileName(aFilename).then(file => { reader.readAsText(file); }); return; } // Read compressed gzip file. let converter = new nsGzipConverter(); converter.asyncConvertData( "gzip", "uncompressed", { data: [], onStartRequest(aR, aC) {}, onDataAvailable(aR, aStream, aO, aCount) { let bi = new nsBinaryStream(aStream); this.data.push(bi.readBytes(aCount)); }, onStopRequest(aR, aC, aStatusCode) { try { if (!Components.isSuccessCode(aStatusCode)) { throw new Components.Exception( "Error while reading gzip file", aStatusCode ); } reader.readAsText(new Blob(this.data)); } catch (ex) { handleException(ex); } }, }, null ); let file = new nsFile(aFilename); let fileChan = NetUtil.newChannel({ uri: Services.io.newFileURI(file), loadUsingSystemPrincipal: true, }); fileChan.asyncOpen(converter); } catch (ex) { handleException(ex); } } /** * Like updateAboutMemoryFromReporters(), but gets its data from a file instead * of the memory reporters. * * @param aFilename * The name of the file being read from. The expected format of the * file's contents is described in a comment in nsIMemoryInfoDumper.idl. */ function updateAboutMemoryFromFile(aFilename) { loadMemoryReportsFromFile( aFilename, /* title note */ aFilename, updateAboutMemoryFromJSONString ); } /** * Like updateAboutMemoryFromFile(), but gets its data from a two files and * diffs them. * * @param aFilename1 * The name of the first file being read from. * @param aFilename2 * The name of the first file being read from. */ function updateAboutMemoryFromTwoFiles(aFilename1, aFilename2) { let titleNote = `diff of ${aFilename1} and ${aFilename2}`; loadMemoryReportsFromFile(aFilename1, titleNote, function (aStr1) { loadMemoryReportsFromFile(aFilename2, titleNote, function (aStr2) { try { let obj1 = parseAndUnwrapIfCrashDump(aStr1); let obj2 = parseAndUnwrapIfCrashDump(aStr2); gIsDiff = true; updateAboutMemoryFromJSONObject(diffJSONObjects(obj1, obj2)); gIsDiff = false; } catch (ex) { handleException(ex); } }); }); } // --------------------------------------------------------------------------- // Something unlikely to appear in a process name. let kProcessPathSep = "^:^:^"; // Short for "diff report". function DReport(aKind, aUnits, aAmount, aDescription, aNMerged, aPresence) { this._kind = aKind; this._units = aUnits; this._amount = aAmount; this._description = aDescription; this._nMerged = aNMerged; if (aPresence !== undefined) { this._presence = aPresence; } } DReport.prototype = { assertCompatible(aKind, aUnits) { assert(this._kind == aKind, "Mismatched kinds"); assert(this._units == aUnits, "Mismatched units"); // We don't check that the "description" properties match. This is because // on Linux we can get cases where the paths are the same but the // descriptions differ, like this: // // "path": "size/other-files/icon-theme.cache/[r--p]", // "description": "/usr/share/icons/gnome/icon-theme.cache (read-only, not executable, private)" // // "path": "size/other-files/icon-theme.cache/[r--p]" // "description": "/usr/share/icons/hicolor/icon-theme.cache (read-only, not executable, private)" // // In those cases, we just use the description from the first-encountered // one, which is what about:memory also does. // (Note: reports with those paths are no longer generated, but allowing // the descriptions to differ seems reasonable.) }, merge(aJr) { this.assertCompatible(aJr.kind, aJr.units); this._amount += aJr.amount; this._nMerged++; }, toJSON(aProcess, aPath, aAmount) { return { process: aProcess, path: aPath, kind: this._kind, units: this._units, amount: aAmount, description: this._description, _presence: this._presence, }; }, }; // Constants that indicate if a DReport was present only in one of the data // sets, or had to be added for balance. DReport.PRESENT_IN_FIRST_ONLY = 1; DReport.PRESENT_IN_SECOND_ONLY = 2; DReport.ADDED_FOR_BALANCE = 3; /** * Return true if the report contains a webIsolated process, * which is a good indication that Fission is enabled. */ function hasWebIsolatedProcess(aJSONReports) { for (let jr of aJSONReports) { assert(jr.process !== undefined, "Missing process"); if (jr.process.startsWith("webIsolated")) { return true; } } return false; } /** * Make a report map, which has combined path+process strings for keys, and * DReport objects for values. * * @param aJSONReports * The |reports| field of a JSON object. * @param aForgetIsolation If this is true, treat webIsolated processes like web processes. * @return The constructed report map. */ function makeDReportMap(aJSONReports, aForgetIsolation) { let dreportMap = {}; for (let jr of aJSONReports) { assert(jr.process !== undefined, "Missing process"); assert(jr.path !== undefined, "Missing path"); assert(jr.kind !== undefined, "Missing kind"); assert(jr.units !== undefined, "Missing units"); assert(jr.amount !== undefined, "Missing amount"); assert(jr.description !== undefined, "Missing description"); // Strip out some non-deterministic stuff that prevents clean diffs. // Ideally the memory reports themselves would contain information about // which parts of the the process and path need to be stripped -- saving us // from hardwiring knowledge of specific reporters here -- but we have no // mechanism for that. (Any future redesign of how memory reporters work // should include such a mechanism.) // Strip PIDs: // - pid 123 // - pid=123 // - pid: 123 let pidRegex = /pid([ =]|: )\d+/g; let pidSubst = "pid$1NNN"; let process = jr.process.replace(pidRegex, pidSubst); let path = jr.path.replace(pidRegex, pidSubst); if (aForgetIsolation && process.startsWith("webIsolated")) { process = "web (pid NNN)"; } // Strip TIDs and threadpool IDs. path = path.replace(/\(tid=(\d+)\)/, "(tid=NNN)"); path = path.replace(/#\d+ \(tid=NNN\)/, "#N (tid=NNN)"); // Strip addresses: // - .../js-zone(0x12345678)/... // - .../zone(0x12345678)/... // - .../worker(, 0x12345678)/... path = path.replace(/zone\(0x[0-9A-Fa-f]+\)\//, "zone(0xNNN)/"); path = path.replace( /\/worker\((.+), 0x[0-9A-Fa-f]+\)\//, "/worker($1, 0xNNN)/" ); // Strip top window IDs: // - explicit/window-objects/top(, id=123)/... // - event-counts/window-objects/top(, id=123)/... path = path.replace( /^((?:explicit|event-counts)\/window-objects\/top\(.*, id=)\d+\)/, "$1NNN)" ); // Strip null principal UUIDs (but not other UUIDs, because they may be // deterministic, such as those used by add-ons). path = path.replace( /moz-nullprincipal:{........-....-....-....-............}/g, "moz-nullprincipal:{NNNNNNNN-NNNN-NNNN-NNNN-NNNNNNNNNNNN}" ); // Strip segment counts from address-space. if (path.startsWith("address-space")) { path = path.replace(/\(segments=\d+\)/g, "(segments=NNNN)"); } // Normalize omni.ja! paths. path = path.replace( /jar:file:\\\\\\(.+)\\omni.ja!/, "jar:file:\\\\\\...\\omni.ja!" ); // Normalize script source counts. path = path.replace(/source\(scripts=(\d+), /, "source(scripts=NNN, "); let processPath = process + kProcessPathSep + path; let rOld = dreportMap[processPath]; if (rOld === undefined) { dreportMap[processPath] = new DReport( jr.kind, jr.units, jr.amount, jr.description, 1, undefined ); } else { rOld.merge(jr); } } return dreportMap; } // Return a new dreportMap which is the diff of two dreportMaps. Empties // aDReportMap2 along the way. function diffDReportMaps(aDReportMap1, aDReportMap2) { let result = {}; for (let processPath in aDReportMap1) { let r1 = aDReportMap1[processPath]; let r2 = aDReportMap2[processPath]; let r2_amount, r2_nMerged; let presence; if (r2 !== undefined) { r1.assertCompatible(r2._kind, r2._units); r2_amount = r2._amount; r2_nMerged = r2._nMerged; delete aDReportMap2[processPath]; presence = undefined; // represents that it's present in both } else { r2_amount = 0; r2_nMerged = 0; presence = DReport.PRESENT_IN_FIRST_ONLY; } result[processPath] = new DReport( r1._kind, r1._units, r2_amount - r1._amount, r1._description, Math.max(r1._nMerged, r2_nMerged), presence ); } for (let processPath in aDReportMap2) { let r2 = aDReportMap2[processPath]; result[processPath] = new DReport( r2._kind, r2._units, r2._amount, r2._description, r2._nMerged, DReport.PRESENT_IN_SECOND_ONLY ); } return result; } function makeJSONReports(aDReportMap) { let reports = []; for (let processPath in aDReportMap) { let r = aDReportMap[processPath]; if (r._amount !== 0) { // If _nMerged > 1, we give the full (aggregated) amount in the first // copy, and then use amount=0 in the remainder. When viewed in // about:memory, this shows up as an entry with a "[2]"-style suffix // and the correct amount. let split = processPath.split(kProcessPathSep); assert(split.length >= 2); let process = split.shift(); let path = split.join(); reports.push(r.toJSON(process, path, r._amount)); for (let i = 1; i < r._nMerged; i++) { reports.push(r.toJSON(process, path, 0)); } } } return reports; } // Diff two JSON objects holding memory reports. function diffJSONObjects(aJson1, aJson2) { function simpleProp(aProp) { assert( aJson1[aProp] !== undefined && aJson1[aProp] === aJson2[aProp], aProp + " properties don't match" ); return aJson1[aProp]; } // If one report we're diffing contains webIsolated processes, but the other // does not, then we're probably comparing a report with Fission enabled with // one where it is not enabled. In this case, we want to make all of the // webIsolated processes look like plain old web processes to get a better // diff. let hasIsolated1 = hasWebIsolatedProcess(aJson1.reports); let hasIsolated2 = hasWebIsolatedProcess(aJson2.reports); let eitherIsolated = hasIsolated1 || hasIsolated2; let forgetIsolation = hasIsolated1 != hasIsolated2 && eitherIsolated; return { version: simpleProp("version"), hasMozMallocUsableSize: simpleProp("hasMozMallocUsableSize"), reports: makeJSONReports( diffDReportMaps( makeDReportMap(aJson1.reports, forgetIsolation), makeDReportMap(aJson2.reports, forgetIsolation) ) ), }; } // --------------------------------------------------------------------------- // |PColl| is short for "process collection". function PColl() { this._trees = {}; this._degenerates = {}; this._heapTotal = 0; } /** * Processes reports (whether from reporters or from a file) and append the * main part of the page. * * @param aProcessReports * Function that extracts the memory reports from the reporters or from * file. * @param aFilter * String or RegExp used to filter reports by their path. * @param aHasMozMallocUsableSize * Boolean indicating if moz_malloc_usable_size works. */ function appendAboutMemoryMain( aProcessReports, aFilter, aHasMozMallocUsableSize ) { let pcollsByProcess = {}; let infoByProcess = {}; function handleReport( aProcess, aUnsafePath, aKind, aUnits, aAmount, aDescription, aPresence ) { if (aUnsafePath.startsWith("explicit/")) { assertInput( aKind === KIND_HEAP || aKind === KIND_NONHEAP, "bad explicit kind" ); assertInput(aUnits === UNITS_BYTES, "bad explicit units"); } assert( aPresence === undefined || aPresence == DReport.PRESENT_IN_FIRST_ONLY || aPresence == DReport.PRESENT_IN_SECOND_ONLY, "bad presence" ); // If the process is empty, that means this process -- which is the main // process, because this is chrome JS code -- is doing the dumping. // Generate the process identifier: `Main Process (pid $PID)`. // // Note that `HandleReportAndFinishReportingCallbacks::Callback()` handles // this when saving memory reports to file. So, if we are loading memory // reports from file then `aProcess` will already be non-empty. let process = aProcess ? aProcess : gMainProcessPrefix + " (pid " + Services.appinfo.processID + ")"; // Store the "resident" value for each process, so that if we filter it // out, we can still use it to correctly sort processes and generate the // process index. let info = infoByProcess[process]; if (!info) { info = infoByProcess[process] = {}; } if (aUnsafePath == "resident") { infoByProcess[process].resident = aAmount; } // Ignore reports that don't match the current filter. if (!stringMatchesFilter(aUnsafePath, aFilter)) { return; } let unsafeNames = aUnsafePath.split("/"); let unsafeName0 = unsafeNames[0]; let isDegenerate = unsafeNames.length === 1; // Get the PColl table for the process, creating it if necessary. let pcoll = pcollsByProcess[process]; if (!pcollsByProcess[process]) { pcoll = pcollsByProcess[process] = new PColl(); } // Get the root node, creating it if necessary. let psubcoll = isDegenerate ? pcoll._degenerates : pcoll._trees; let t = psubcoll[unsafeName0]; if (!t) { t = psubcoll[unsafeName0] = new TreeNode( unsafeName0, aUnits, isDegenerate ); } if (!isDegenerate) { // Add any missing nodes in the tree implied by aUnsafePath, and fill in // the properties that we can with a top-down traversal. for (let i = 1; i < unsafeNames.length; i++) { let unsafeName = unsafeNames[i]; let u = t.findKid(unsafeName); if (!u) { u = new TreeNode(unsafeName, aUnits, isDegenerate); if (!t._kids) { t._kids = []; } t._kids.push(u); } t = u; } // Update the heap total if necessary. if (unsafeName0 === "explicit" && aKind == KIND_HEAP) { pcollsByProcess[process]._heapTotal += aAmount; } } if (t._amount) { // Duplicate! Sum the values and mark it as a dup. t._amount += aAmount; t._nMerged = t._nMerged ? t._nMerged + 1 : 2; assert(t._presence === aPresence, "presence mismatch"); } else { // New leaf node. Fill in extra node details from the report. t._amount = aAmount; t._description = aDescription; if (aPresence !== undefined) { t._presence = aPresence; } } } function displayReports() { // Sort the processes. let processes = Object.keys(infoByProcess); processes.sort(function (aProcessA, aProcessB) { assert( aProcessA != aProcessB, `Elements of Object.keys() should be unique, but ` + `saw duplicate '${aProcessA}' elem.` ); // Always put the main process first. if (aProcessA.startsWith(gMainProcessPrefix)) { return -1; } if (aProcessB.startsWith(gMainProcessPrefix)) { return 1; } // Then sort by resident size. let residentA = infoByProcess[aProcessA].resident || -1; let residentB = infoByProcess[aProcessB].resident || -1; if (residentA > residentB) { return -1; } if (residentA < residentB) { return 1; } // Then sort by process name. if (aProcessA < aProcessB) { return -1; } if (aProcessA > aProcessB) { return 1; } return 0; }); // We set up this general layout inside gMain: // //
//
// //
// // If we detect that outputContainer already exists, then this is an update // (due to typing in a filter string) to an already-displayed memory report. // In this case we preserve the structure of the layout and only replace // div.sections and #indexItem. Preserving the filter sidebar item means we // preserve any editing state in its . // Generate the main process sections. let sections = newElement("div", "sections"); sections.setAttribute("role", "main"); for (let [i, process] of processes.entries()) { let pcolls = pcollsByProcess[process]; if (!pcolls) { continue; } let section = appendElement(sections, "div", "section"); appendProcessAboutMemoryElements( section, i, process, pcolls._trees, pcolls._degenerates, pcolls._heapTotal, aHasMozMallocUsableSize, aFilter != "" ); } if (!sections.firstChild) { appendElementWithText(sections, "div", "section", "No results found."); } // Generate the process index. let indexItem = newElement("div", "sidebarItem"); indexItem.classList.add("indexItem"); appendElementWithText(indexItem, "div", "sidebarLabel", "Process index"); let indexList = appendElement(indexItem, "ul", "index"); for (let [i, process] of processes.entries()) { let indexListItem = appendElement(indexList, "li"); let pcolls = pcollsByProcess[process]; if (pcolls) { let indexLink = appendElementWithText(indexListItem, "a", "", process); indexLink.href = "#start" + i; } else { // We've filtered out all reports from this process. Generate a non-link // entry in the process index, and skip creating a process report // section. indexListItem.textContent = process; } } // If we are updating, just swap in the new process output. let outputContainer = gMain.querySelector(".outputContainer"); if (outputContainer) { outputContainer.querySelector(".sections").replaceWith(sections); outputContainer.querySelector(".indexItem").replaceWith(indexItem); return; } // Otherwise, generate the rest of the layout. outputContainer = appendElement(gMain, "div", "outputContainer"); outputContainer.appendChild(sections); let sidebar = appendElement(outputContainer, "div", "sidebar"); sidebar.setAttribute("role", "navigation"); let sidebarContents = appendElement(sidebar, "div", "sidebarContents"); // Generate the filter input and checkbox. let filterItem = appendElement(sidebarContents, "div", "sidebarItem"); filterItem.classList.add("filterItem"); appendElementWithText(filterItem, "div", "sidebarLabel", "Filter"); let filterInput = appendElement(filterItem, "input", "filterInput"); filterInput.placeholder = "Memory report path filter"; let filterOptions = appendElement(filterItem, "div"); let filterRegExLabel = appendElement(filterOptions, "label"); let filterRegExCheckbox = appendElement(filterRegExLabel, "input"); filterRegExCheckbox.type = "checkbox"; filterRegExLabel.append(" Regular expression"); // Set up event handlers to update the display if the filter input or // checkbox changes. let filterUpdateTimeout; let filterUpdate = function () { if (filterUpdateTimeout) { window.clearTimeout(filterUpdateTimeout); } filterUpdateTimeout = window.setTimeout(function () { try { gFilter = filterRegExCheckbox.checked && filterInput.value != "" ? new RegExp(filterInput.value) : filterInput.value; } catch (ex) { // Match nothing if the regex was invalid. gFilter = new RegExp("^$"); } updateAboutMemoryFromCurrentData(); }, gFilterUpdateDelayMS); }; filterInput.oninput = filterUpdate; filterRegExCheckbox.onchange = filterUpdate; // Append the process list item after the filter item. sidebarContents.appendChild(indexItem); } aProcessReports(handleReport, displayReports); } // --------------------------------------------------------------------------- // There are two kinds of TreeNode. // - Leaf TreeNodes correspond to reports. // - Non-leaf TreeNodes are just scaffolding nodes for the tree; their values // are derived from their children. // Some trees are "degenerate", i.e. they contain a single node, i.e. they // correspond to a report whose path has no '/' separators. function TreeNode(aUnsafeName, aUnits, aIsDegenerate) { this._units = aUnits; this._unsafeName = aUnsafeName; if (aIsDegenerate) { this._isDegenerate = true; } // Leaf TreeNodes have these properties added immediately after construction: // - _amount // - _description // - _nMerged (only defined if > 1) // - _presence (only defined if value is PRESENT_IN_{FIRST,SECOND}_ONLY) // // Non-leaf TreeNodes have these properties added later: // - _kids // - _amount // - _description // - _hideKids (only defined if true) // - _maxAbsDescendant (on-demand, only when gIsDiff is set) } TreeNode.prototype = { findKid(aUnsafeName) { if (this._kids) { for (let kid of this._kids) { if (kid._unsafeName === aUnsafeName) { return kid; } } } return undefined; }, // When gIsDiff is false, tree operations -- sorting and determining if a // sub-tree is significant -- are straightforward. But when gIsDiff is true, // the combination of positive and negative values within a tree complicates // things. So for a non-leaf node, instead of just looking at _amount, we // instead look at the maximum absolute value of the node and all of its // descendants. maxAbsDescendant() { if (!this._kids) { // No kids? Just return the absolute value of the amount. return Math.abs(this._amount); } if ("_maxAbsDescendant" in this) { // We've computed this before? Return the saved value. return this._maxAbsDescendant; } // Compute the maximum absolute value of all descendants. let max = Math.abs(this._amount); for (let kid of this._kids) { max = Math.max(max, kid.maxAbsDescendant()); } this._maxAbsDescendant = max; return max; }, toString() { switch (this._units) { case UNITS_BYTES: return formatBytes(this._amount); case UNITS_COUNT: case UNITS_COUNT_CUMULATIVE: return formatNum(this._amount); case UNITS_PERCENTAGE: return formatPercentage(this._amount); default: throw new Error( "Invalid memory report(s): bad units in TreeNode.toString" ); } }, }; // Sort TreeNodes first by size, then by name. The latter is important for the // about:memory tests, which need a predictable ordering of reporters which // have the same amount. TreeNode.compareAmounts = function (aA, aB) { let a, b; if (gIsDiff) { a = aA.maxAbsDescendant(); b = aB.maxAbsDescendant(); } else { a = aA._amount; b = aB._amount; } if (a > b) { return -1; } if (a < b) { return 1; } return TreeNode.compareUnsafeNames(aA, aB); }; TreeNode.compareUnsafeNames = function (aA, aB) { return aA._unsafeName.localeCompare(aB._unsafeName); }; /** * Fill in the remaining properties for the specified tree in a bottom-up * fashion. * * @param aRoot * The tree root. */ function fillInTree(aRoot) { // Fill in the remaining properties bottom-up. function fillInNonLeafNodes(aT) { if (!aT._kids) { // Leaf node. Has already been filled in. } else if (aT._kids.length === 1 && aT != aRoot) { // Non-root, non-leaf node with one child. Merge the child with the node // to avoid redundant entries. let kid = aT._kids[0]; let kidBytes = fillInNonLeafNodes(kid); aT._unsafeName += "/" + kid._unsafeName; if (kid._kids) { aT._kids = kid._kids; } else { delete aT._kids; } aT._amount = kidBytes; aT._description = kid._description; if (kid._nMerged !== undefined) { aT._nMerged = kid._nMerged; } assert(!aT._hideKids && !kid._hideKids, "_hideKids set when merging"); } else { // Non-leaf node with multiple children. Derive its _amount and // _description entirely from its children... let kidsBytes = 0; for (let kid of aT._kids) { kidsBytes += fillInNonLeafNodes(kid); } // ... except in one special case. When diffing two memory report sets, // if one set has a node with children and the other has the same node // but without children -- e.g. the first has "a/b/c" and "a/b/d", but // the second only has "a/b" -- we need to add a fake node "a/b/(fake)" // to the second to make the trees comparable. It's ugly, but it works. if ( aT._amount !== undefined && (aT._presence === DReport.PRESENT_IN_FIRST_ONLY || aT._presence === DReport.PRESENT_IN_SECOND_ONLY) ) { aT._amount += kidsBytes; let fake = new TreeNode("(fake child)", aT._units); fake._presence = DReport.ADDED_FOR_BALANCE; fake._amount = aT._amount - kidsBytes; aT._kids.push(fake); delete aT._presence; } else { assert( aT._amount === undefined, "_amount already set for non-leaf node" ); aT._amount = kidsBytes; } aT._description = "The sum of all entries below this one."; } return aT._amount; } // cannotMerge is set because don't want to merge into a tree's root node. fillInNonLeafNodes(aRoot); } /** * Compute the "heap-unclassified" value and insert it into the "explicit" * tree. * * @param aT * The "explicit" tree. * @param aHeapAllocatedNode * The "heap-allocated" tree node. * @param aHeapTotal * The sum of all explicit HEAP reports for this process. * @return A boolean indicating if "heap-allocated" is known for the process. */ function addHeapUnclassifiedNode(aT, aHeapAllocatedNode, aHeapTotal) { if (aHeapAllocatedNode === undefined) { return false; } if (aT.findKid("heap-unclassified")) { // heap-unclassified was already calculated, there's nothing left to do. // This can happen when memory reports are exported from areweslimyet.com. return true; } assert(aHeapAllocatedNode._isDegenerate, "heap-allocated is not degenerate"); let heapAllocatedBytes = aHeapAllocatedNode._amount; let heapUnclassifiedT = new TreeNode("heap-unclassified", UNITS_BYTES); heapUnclassifiedT._amount = heapAllocatedBytes - aHeapTotal; heapUnclassifiedT._description = "Memory not classified by a more specific report. This includes " + "slop bytes due to internal fragmentation in the heap allocator " + "(caused when the allocator rounds up request sizes)."; aT._kids.push(heapUnclassifiedT); aT._amount += heapUnclassifiedT._amount; return true; } /** * Sort all kid nodes from largest to smallest, and insert aggregate nodes * where appropriate. * * @param aTotalBytes * The size of the tree's root node. * @param aT * The tree. */ function sortTreeAndInsertAggregateNodes(aTotalBytes, aT) { const kSignificanceThresholdPerc = 1; function isInsignificant(aT) { if (gVerbose.checked) { return false; } let perc = gIsDiff ? (100 * aT.maxAbsDescendant()) / Math.abs(aTotalBytes) : (100 * aT._amount) / aTotalBytes; return perc < kSignificanceThresholdPerc; } if (!aT._kids) { return; } aT._kids.sort(TreeNode.compareAmounts); // If the first child is insignificant, they all are, and there's no point // creating an aggregate node that lacks siblings. Just set the parent's // _hideKids property and process all children. if (isInsignificant(aT._kids[0])) { aT._hideKids = true; for (let kid of aT._kids) { sortTreeAndInsertAggregateNodes(aTotalBytes, kid); } return; } // Look at all children except the last one. let i; for (i = 0; i < aT._kids.length - 1; i++) { if (isInsignificant(aT._kids[i])) { // This child is below the significance threshold. If there are other // (smaller) children remaining, move them under an aggregate node. let i0 = i; let nAgg = aT._kids.length - i0; // Create an aggregate node. Inherit units from the parent; everything // in the tree should have the same units anyway (we test this later). let aggT = new TreeNode(`(${nAgg} tiny)`, aT._units); aggT._kids = []; let aggBytes = 0; for (; i < aT._kids.length; i++) { aggBytes += aT._kids[i]._amount; aggT._kids.push(aT._kids[i]); } aggT._hideKids = true; aggT._amount = aggBytes; aggT._description = nAgg + " sub-trees that are below the " + kSignificanceThresholdPerc + "% significance threshold."; aT._kids.splice(i0, nAgg, aggT); aT._kids.sort(TreeNode.compareAmounts); // Process the moved children. for (let kid of aggT._kids) { sortTreeAndInsertAggregateNodes(aTotalBytes, kid); } return; } sortTreeAndInsertAggregateNodes(aTotalBytes, aT._kids[i]); } // The first n-1 children were significant. Don't consider if the last child // is significant; there's no point creating an aggregate node that only has // one child. Just process it. sortTreeAndInsertAggregateNodes(aTotalBytes, aT._kids[i]); } // Global variable indicating if we've seen any invalid values for this // process; it holds the unsafePaths of any such reports. It is reset for // each new process. let gUnsafePathsWithInvalidValuesForThisProcess = []; function appendWarningElements( aP, aHasKnownHeapAllocated, aHasMozMallocUsableSize, aFiltered ) { // These warnings may not make sense if the reporters they reference have been // filtered out, so just skip them if we have a filter applied. if (!aFiltered && !aHasKnownHeapAllocated && !aHasMozMallocUsableSize) { appendElementWithText( aP, "p", "", "WARNING: the 'heap-allocated' memory reporter and the " + "moz_malloc_usable_size() function do not work for this platform " + "and/or configuration. This means that 'heap-unclassified' is not " + "shown and the 'explicit' tree shows much less memory than it should.\n\n" ); } else if (!aFiltered && !aHasKnownHeapAllocated) { appendElementWithText( aP, "p", "", "WARNING: the 'heap-allocated' memory reporter does not work for this " + "platform and/or configuration. This means that 'heap-unclassified' " + "is not shown and the 'explicit' tree shows less memory than it should.\n\n" ); } else if (!aFiltered && !aHasMozMallocUsableSize) { appendElementWithText( aP, "p", "", "WARNING: the moz_malloc_usable_size() function does not work for " + "this platform and/or configuration. This means that much of the " + "heap-allocated memory is not measured by individual memory reporters " + "and so will fall under 'heap-unclassified'.\n\n" ); } if (gUnsafePathsWithInvalidValuesForThisProcess.length) { let div = appendElement(aP, "div"); appendElementWithText( div, "p", "", "WARNING: the following values are negative or unreasonably large.\n" ); let ul = appendElement(div, "ul"); for ( let i = 0; i < gUnsafePathsWithInvalidValuesForThisProcess.length; i++ ) { appendTextNode(ul, " "); appendElementWithText( ul, "li", "", flipBackslashes(gUnsafePathsWithInvalidValuesForThisProcess[i]) + "\n" ); } appendElementWithText( div, "p", "", "This indicates a defect in one or more memory reporters. The " + "invalid values are highlighted.\n\n" ); gUnsafePathsWithInvalidValuesForThisProcess = []; // reset for the next process } } /** * Appends the about:memory elements for a single process. * * @param aP * The parent DOM node. * @param aN * The number of the process, starting at 0. * @param aProcess * The name of the process. * @param aTrees * The table of non-degenerate trees for this process. * @param aDegenerates * The table of degenerate trees for this process. * @param aHasMozMallocUsableSize * Boolean indicating if moz_malloc_usable_size works. * @param aFiltered * Boolean indicating whether the reports were filtered. * @return The generated text. */ function appendProcessAboutMemoryElements( aP, aN, aProcess, aTrees, aDegenerates, aHeapTotal, aHasMozMallocUsableSize, aFiltered ) { let appendLink = function (aHere, aThere, aArrow) { let link = appendElementWithText(aP, "a", "upDownArrow", aArrow); link.href = "#" + aThere + aN; link.id = aHere + aN; link.title = `Go to the ${aThere} of ${aProcess}`; link.style = "text-decoration: none"; // This gives nice spacing when we copy and paste. appendElementWithText(aP, "span", "", "\n"); }; appendElementWithText(aP, "h1", "", aProcess); appendLink("start", "end", "↓"); // We'll fill this in later. let warningsDiv = appendElement(aP, "div", "accuracyWarning"); // The explicit tree. let hasExplicitTree; let hasKnownHeapAllocated; { let treeName = "explicit"; let t = aTrees[treeName]; if (t) { let pre = appendSectionHeader(aP, "Explicit Allocations"); hasExplicitTree = true; fillInTree(t); // Using the "heap-allocated" reporter here instead of // nsMemoryReporterManager.heapAllocated goes against the usual pattern. // But the "heap-allocated" node will go in the tree like the others, so // we have to deal with it, and once we're dealing with it, it's easier // to keep doing so rather than switching to the distinguished amount. hasKnownHeapAllocated = aDegenerates && addHeapUnclassifiedNode(t, aDegenerates["heap-allocated"], aHeapTotal); sortTreeAndInsertAggregateNodes(t._amount, t); t._description = explicitTreeDescription; appendTreeElements(pre, t, aProcess, ""); delete aTrees[treeName]; } appendTextNode(aP, "\n"); // gives nice spacing when we copy and paste } // Fill in and sort all the non-degenerate other trees. let otherTrees = []; for (let unsafeName in aTrees) { let t = aTrees[unsafeName]; assert(!t._isDegenerate, "tree is degenerate"); fillInTree(t); sortTreeAndInsertAggregateNodes(t._amount, t); otherTrees.push(t); } otherTrees.sort(TreeNode.compareUnsafeNames); // Get the length of the longest root value among the degenerate other trees, // and sort them as well. let otherDegenerates = []; let maxStringLength = 0; for (let unsafeName in aDegenerates) { let t = aDegenerates[unsafeName]; assert(t._isDegenerate, "tree is not degenerate"); let length = t.toString().length; if (length > maxStringLength) { maxStringLength = length; } otherDegenerates.push(t); } otherDegenerates.sort(TreeNode.compareUnsafeNames); // Now generate the elements, putting non-degenerate trees first. if (otherTrees.length || otherDegenerates.length) { let pre = appendSectionHeader(aP, "Other Measurements"); for (let t of otherTrees) { appendTreeElements(pre, t, aProcess, ""); appendTextNode(pre, "\n"); // blank lines after non-degenerate trees } for (let t of otherDegenerates) { let padText = "".padStart(maxStringLength - t.toString().length, " "); appendTreeElements(pre, t, aProcess, padText); } appendTextNode(aP, "\n"); // gives nice spacing when we copy and paste } // Add any warnings about inaccuracies in the "explicit" tree due to platform // limitations. These must be computed after generating all the text. The // newlines give nice spacing if we copy+paste into a text buffer. if (hasExplicitTree) { appendWarningElements( warningsDiv, hasKnownHeapAllocated, aHasMozMallocUsableSize, aFiltered ); } appendElementWithText(aP, "h3", "", "End of " + aProcess); appendLink("end", "start", "↑"); } // The locale used when formatting a number as a human-readable string in any // format. const kStyleLocale = "en-US"; // Used for UNITS_BYTES values that are printed as MiB. const kMBFormat = new Intl.NumberFormat(kStyleLocale, { minimumFractionDigits: 2, maximumFractionDigits: 2, }); // Used for UNITS_PERCENTAGE values. const kPercFormatter = new Intl.NumberFormat(kStyleLocale, { style: "percent", minimumFractionDigits: 2, maximumFractionDigits: 2, }); // Used for fractions within the tree. const kFracFormatter = new Intl.NumberFormat(kStyleLocale, { style: "percent", minimumIntegerDigits: 2, minimumFractionDigits: 2, maximumFractionDigits: 2, }); // Used for special-casing 100% fractions within the tree. const kFrac1Formatter = new Intl.NumberFormat(kStyleLocale, { style: "percent", minimumIntegerDigits: 3, minimumFractionDigits: 1, maximumFractionDigits: 1, }); // Used when no custom formatting was requested. const kDefaultNumFormatter = new Intl.NumberFormat(kStyleLocale); /** * Formats an int as a human-readable string. * * @param aN * The integer to format. * @param aFormatter * Optional formatter object. * @return A human-readable string representing the int. */ function formatNum(aN, aFormatter) { return (aFormatter || kDefaultNumFormatter).format(aN); } /** * Converts a byte count to an appropriate string representation. * * @param aBytes * The byte count. * @return The string representation. */ function formatBytes(aBytes) { return gVerbose.checked ? `${formatNum(aBytes)} B` : `${formatNum(aBytes / (1024 * 1024), kMBFormat)} MB`; } /** * Converts a UNITS_PERCENTAGE value to an appropriate string representation. * * @param aPerc100x * The percentage, multiplied by 100 (see nsIMemoryReporter). * @return The string representation */ function formatPercentage(aPerc100x) { // A percentage like 12.34% will have an aPerc100x value of 1234, and we need // to divide that by 10,000 to get the 0.1234 that toLocaleString() wants. return formatNum(aPerc100x / 10000, kPercFormatter); } /* * Converts a tree fraction to an appropriate string representation. * * @param aNum * The numerator. * @param aDenom * The denominator. * @return The string representation */ function formatTreeFrac(aNum, aDenom) { // Two special behaviours here: // - We treat 0 / 0 as 100%. // - We want 4 digits, as much as possible, because it gives good vertical // alignment. For positive numbers, 00.00%--99.99% works straighforwardly, // but 100.0% needs special handling. let num = aDenom === 0 ? 1 : aNum / aDenom; return 0.99995 <= num && num <= 1 ? formatNum(1, kFrac1Formatter) : formatNum(num, kFracFormatter); } const kNoKidsSep = " ── ", kHideKidsSep = " ++ ", kShowKidsSep = " -- "; function appendMrNameSpan( aP, aDescription, aUnsafeName, aIsInvalid, aNMerged, aPresence ) { let safeName = flipBackslashes(aUnsafeName); if (!aIsInvalid && !aNMerged && !aPresence) { safeName += "\n"; } let nameSpan = appendElementWithText(aP, "span", "mrName", safeName); nameSpan.title = aDescription; if (aIsInvalid) { let noteText = " [?!]"; if (!aNMerged) { noteText += "\n"; } let noteSpan = appendElementWithText(aP, "span", "mrNote", noteText); noteSpan.title = "Warning: this value is invalid and indicates a bug in one or more " + "memory reporters. "; } if (aNMerged) { let noteText = ` [${aNMerged}]`; if (!aPresence) { noteText += "\n"; } let noteSpan = appendElementWithText(aP, "span", "mrNote", noteText); noteSpan.title = "This value is the sum of " + aNMerged + " memory reports that all have the same path."; } if (aPresence) { let c, title; switch (aPresence) { case DReport.PRESENT_IN_FIRST_ONLY: c = "-"; title = "This value was only present in the first set of memory reports."; break; case DReport.PRESENT_IN_SECOND_ONLY: c = "+"; title = "This value was only present in the second set of memory reports."; break; case DReport.ADDED_FOR_BALANCE: c = "!"; title = "One of the sets of memory reports lacked children for this " + "node's parent. This is a fake child node added to make the " + "two memory sets comparable."; break; default: assert(false, "bad presence"); break; } let noteSpan = appendElementWithText(aP, "span", "mrNote", ` [${c}]\n`); noteSpan.title = title; } } // This is used to record the (safe) IDs of which sub-trees have been manually // expanded (marked as true) and collapsed (marked as false). It's used to // replicate the collapsed/expanded state when the page is updated. It can end // up holding IDs of nodes that no longer exist, e.g. for compartments that // have been closed. This doesn't seem like a big deal, because the number is // limited by the number of entries the user has changed from their original // state. let gShowSubtreesBySafeTreeId = {}; function assertClassListContains(aElem, aClassName) { assert(aElem, "undefined " + aClassName); assert(aElem.classList.contains(aClassName), "classname isn't " + aClassName); } function toggle(aEvent) { // This relies on each line being a span that contains at least four spans: // mrValue, mrPerc, mrSep, mrName, and then zero or more mrNotes. All // whitespace must be within one of these spans for this function to find the // right nodes. And the span containing the children of this line must // immediately follow. Assertions check this. // We want the outer span. |aEvent.target| will normally be one of the inner // spans. However, if the click was dispatched via a11y, it might be the outer // span because some of the inner spans are pruned from the a11y tree. let outerSpan = aEvent.target.classList.contains("hasKids") ? aEvent.target : aEvent.target.parentNode; assertClassListContains(outerSpan, "hasKids"); // Toggle the '++'/'--' separator. let isExpansion; let sepSpan = outerSpan.childNodes[2]; assertClassListContains(sepSpan, "mrSep"); if (sepSpan.textContent === kHideKidsSep) { isExpansion = true; sepSpan.textContent = kShowKidsSep; outerSpan.setAttribute("aria-expanded", "true"); } else if (sepSpan.textContent === kShowKidsSep) { isExpansion = false; sepSpan.textContent = kHideKidsSep; outerSpan.setAttribute("aria-expanded", "false"); } else { assert(false, "bad sepSpan textContent"); } // Toggle visibility of the span containing this node's children. let subTreeSpan = outerSpan.nextSibling; assertClassListContains(subTreeSpan, "kids"); subTreeSpan.classList.toggle("hidden"); // Record/unrecord that this sub-tree was toggled. let safeTreeId = outerSpan.id; if (gShowSubtreesBySafeTreeId[safeTreeId] !== undefined) { delete gShowSubtreesBySafeTreeId[safeTreeId]; } else { gShowSubtreesBySafeTreeId[safeTreeId] = isExpansion; } } function expandPathToThisElement(aElement) { if (aElement.classList.contains("kids")) { // Unhide the kids. aElement.classList.remove("hidden"); expandPathToThisElement(aElement.previousSibling); // hasKids } else if (aElement.classList.contains("hasKids")) { // Change the separator to '--'. let sepSpan = aElement.childNodes[2]; assertClassListContains(sepSpan, "mrSep"); sepSpan.textContent = kShowKidsSep; aElement.setAttribute("aria-expanded", "true"); expandPathToThisElement(aElement.parentNode.parentNode); // kids or pre.entries } else { assertClassListContains(aElement, "entries"); } } /** * Appends the elements for the tree, including its heading. * * @param aP * The parent DOM node. * @param aRoot * The tree root. * @param aProcess * The process the tree corresponds to. * @param aPadText * A string to pad the start of each entry. */ function appendTreeElements(aP, aRoot, aProcess, aPadText) { /** * Appends the elements for a particular tree, without a heading. There's a * subset of the Unicode "light" box-drawing chars that is widely implemented * in terminals, and this code sticks to that subset to maximize the chance * that copying and pasting about:memory output to a terminal will work * correctly. * * @param aP * The parent DOM node. * @param aProcess * The process the tree corresponds to. * @param aUnsafeNames * An array of the names forming the path to aT. * @param aRoot * The root of the tree this sub-tree belongs to. * @param aT * The tree. * @param aTlThis * The treeline for this entry. * @param aTlKids * The treeline for this entry's children. * @param aParentStringLength * The length of the formatted byte count of the top node in the tree. */ function appendTreeElements2( aP, aProcess, aUnsafeNames, aRoot, aT, aTlThis, aTlKids, aParentStringLength ) { function appendN(aS, aC, aN) { for (let i = 0; i < aN; i++) { aS += aC; } return aS; } // The entire entry including children needs to be treated as a list item // for a11y purposes. let p = document.createElement("span"); p.setAttribute("role", "listitem"); aP.appendChild(p); // The tree line. Indent more if this entry is narrower than its parent. let valueText = aT.toString(); let extraTlLength = Math.max(aParentStringLength - valueText.length, 0); if (extraTlLength > 0) { aTlThis = appendN(aTlThis, "─", extraTlLength); aTlKids = appendN(aTlKids, " ", extraTlLength); } let treeLine = appendElementWithText(p, "span", "treeline", aTlThis); treeLine.setAttribute("aria-hidden", "true"); // Detect and record invalid values. But not if gIsDiff is true, because // we expect negative values in that case. assertInput( aRoot._units === aT._units, "units within a tree are inconsistent" ); let tIsInvalid = false; if (!gIsDiff && !(0 <= aT._amount && aT._amount <= aRoot._amount)) { tIsInvalid = true; let unsafePath = aUnsafeNames.join("/"); gUnsafePathsWithInvalidValuesForThisProcess.push(unsafePath); reportAssertionFailure( `Invalid value (${aT._amount} / ${aRoot._amount}) for ` + flipBackslashes(unsafePath) ); } // For non-leaf nodes, the entire sub-tree is put within a span so it can // be collapsed if the node is clicked on. let d; let sep; let showSubtrees; if (aT._kids) { // Determine if we should show the sub-tree below this entry; this // involves reinstating any previous toggling of the sub-tree. let unsafePath = aUnsafeNames.join("/"); let safeTreeId = `${aProcess}:${flipBackslashes(unsafePath)}`; showSubtrees = !aT._hideKids; if (gShowSubtreesBySafeTreeId[safeTreeId] !== undefined) { showSubtrees = gShowSubtreesBySafeTreeId[safeTreeId]; } d = appendElement(p, "span", "hasKids"); d.id = safeTreeId; d.onclick = toggle; d.setAttribute("role", "button"); sep = showSubtrees ? kShowKidsSep : kHideKidsSep; d.setAttribute("aria-expanded", showSubtrees ? "true" : "false"); } else { assert(!aT._hideKids, "leaf node with _hideKids set"); sep = kNoKidsSep; d = p; } // The value. appendElementWithText( d, "span", "mrValue" + (tIsInvalid ? " invalid" : ""), valueText ); // The percentage (omitted for single entries). if (!aT._isDegenerate) { let percText = formatTreeFrac(aT._amount, aRoot._amount); appendElementWithText(d, "span", "mrPerc", ` (${percText})`); } // The separator. appendElementWithText(d, "span", "mrSep", sep); // The entry's name. appendMrNameSpan( d, aT._description, aT._unsafeName, tIsInvalid, aT._nMerged, aT._presence ); // In non-verbose mode, invalid nodes can be hidden in collapsed sub-trees. // But it's good to always see them, so force this. if (!gVerbose.checked && tIsInvalid) { expandPathToThisElement(aT._kids ? d : aP); } // Recurse over children. if (aT._kids) { // The 'kids' class is just used for sanity checking in toggle(). d = appendElement(p, "span", showSubtrees ? "kids" : "kids hidden"); d.setAttribute("role", "list"); let tlThisForMost, tlKidsForMost; if (aT._kids.length > 1) { tlThisForMost = aTlKids + "├──"; tlKidsForMost = aTlKids + "│ "; } let tlThisForLast = aTlKids + "└──"; let tlKidsForLast = aTlKids + " "; for (let [i, kid] of aT._kids.entries()) { let isLast = i == aT._kids.length - 1; aUnsafeNames.push(kid._unsafeName); appendTreeElements2( d, aProcess, aUnsafeNames, aRoot, kid, !isLast ? tlThisForMost : tlThisForLast, !isLast ? tlKidsForMost : tlKidsForLast, valueText.length ); aUnsafeNames.pop(); } } } let rootStringLength = aRoot.toString().length; appendTreeElements2( aP, aProcess, [aRoot._unsafeName], aRoot, aRoot, aPadText, aPadText, rootStringLength ); } // --------------------------------------------------------------------------- function appendSectionHeader(aP, aText) { appendElementWithText(aP, "h2", "", aText + "\n"); let entries = appendElement(aP, "pre", "entries"); entries.setAttribute("role", "list"); return entries; } // --------------------------------------------------------------------------- function saveReportsToFile() { let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); fp.appendFilter("Zipped JSON files", "*.json.gz"); fp.appendFilters(Ci.nsIFilePicker.filterAll); fp.filterIndex = 0; fp.addToRecentDocs = true; fp.defaultString = "memory-report.json.gz"; let fpFinish = function (aFile) { let dumper = Cc["@mozilla.org/memory-info-dumper;1"].getService( Ci.nsIMemoryInfoDumper ); let finishDumping = () => { updateMainAndFooter( "Saved memory reports to " + aFile.path, SHOW_TIMESTAMP, HIDE_FOOTER ); }; dumper.dumpMemoryReportsToNamedFile( aFile.path, finishDumping, null, gAnonymize.checked, /* minimize memory usage = */ false ); }; let fpCallback = function (aResult) { if ( aResult == Ci.nsIFilePicker.returnOK || aResult == Ci.nsIFilePicker.returnReplace ) { fpFinish(fp.file); } }; try { fp.init( window.browsingContext, "Save Memory Reports", Ci.nsIFilePicker.modeSave ); } catch (ex) { // This will fail on Android, since there is no Save as file picker there. // Just save to the default downloads dir if it does. Downloads.getSystemDownloadsDirectory().then(function (aDirPath) { let file = FileUtils.File(aDirPath); file.append(fp.defaultString); fpFinish(file); }); return; } fp.open(fpCallback); }