diff options
Diffstat (limited to 'toolkit/components/aboutprocesses')
16 files changed, 3339 insertions, 0 deletions
diff --git a/toolkit/components/aboutprocesses/content/aboutProcesses.css b/toolkit/components/aboutprocesses/content/aboutProcesses.css new file mode 100644 index 0000000000..39848813b6 --- /dev/null +++ b/toolkit/components/aboutprocesses/content/aboutProcesses.css @@ -0,0 +1,270 @@ +/* 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/. */ + +@import url("chrome://global/skin/in-content/common.css"); + +html { + background-color: var(--in-content-page-background); +} +body { + overflow-x: hidden; +} + +#process-table { + user-select: none; + font-size: 1em; + border-spacing: 0; + background-color: var(--in-content-box-background); + margin: 0; + position: absolute; + top: 0; + inset-inline-start: 0; + width: 100%; + height: 100%; +} + +/* Avoid scrolling the header */ +#process-tbody { + display: block; + margin-top: 2em; +} +#process-thead { + position: fixed; + z-index: 1; + height: 2em; + border-bottom: 1px solid var(--in-content-border-color); + width: 100%; + background-color: var(--in-content-box-background); +} +tr { + display: grid; + /* Flexible width for the name column, 15% width for the memory and CPU column, + * then fixed width (16px icon + 2*10px margin) for the actions column. */ + grid-template-columns: 1fr 15% 15% 36px; + grid-auto-rows: 2em; + width: 100%; +} + +.cpu, .memory { + text-align: end; +} + +#process-thead > tr { + height: inherit; +} + +th { + font-weight: normal; + text-align: start; + background-color: var(--in-content-button-background); +} +th:not(:first-child) { + border-inline-start: 1px solid; + border-image: linear-gradient(transparent 0%, transparent 20%, var(--in-content-box-border-color) 20%, var(--in-content-box-border-color) 80%, transparent 80%, transparent 100%) 1 1; +} +th, td { + padding: 5px 10px; + min-height: 16px; + max-height: 2em; + overflow: hidden; + white-space: nowrap; +} +td.type, td.favicon { + background-repeat: no-repeat; + background-origin: border-box; + background-size: 16px 16px; + background-position: 11px center; + padding-inline-start: 38px; + -moz-context-properties: fill; + fill: currentColor; +} +td.type:dir(rtl), td.favicon:dir(rtl) { + background-position-x: right 11px; +} +td:first-child { + text-overflow: ellipsis; +} + +.profiler-icon { + background: url("chrome://devtools/skin/images/tool-profiler.svg") no-repeat center; + display: inline-block; + height: 100%; + width: 16px; + padding: 5px 7px; + /* The -10px is -5 to undo the td padding and -5 to undo the padding here. */ + margin: -10px 3px; + -moz-context-properties: fill, fill-opacity; + fill-opacity: 0.9; + fill: currentColor; +} +.profiler-icon:hover { + background-color: var(--in-content-button-background-hover); + color: var(--in-content-button-text-color-hover); +} + +.profiler-icon:hover:active { + background-color: var(--in-content-button-background-active); + color: var(--in-content-button-text-color-active); + fill-opacity: 1; +} + +.profiler-icon:not(.profiler-active) { + transform: scaleX(-1); +} + +.profiler-active { + fill: var(--in-content-accent-color); +} +td:not(:hover) > .profiler-icon:not(.profiler-active) { + display: none; +} + +.twisty { + position: relative; +} +/* Putting the background image in a positioned pseudo element lets us +* use CSS transforms on the background image, which we need for rtl. */ +.twisty::before { + content: url("chrome://global/skin/icons/arrow-right-12.svg"); + position: absolute; + display: block; + line-height: 50%; + top: 4px; /* Half the image's height */ + inset-inline-start: -16px; + width: 12px; + -moz-context-properties: fill; + fill: currentColor; +} +.twisty:dir(rtl)::before { + content: url("chrome://global/skin/icons/arrow-left-12.svg"); +} +.twisty.open::before { + content: url("chrome://global/skin/icons/arrow-down-12.svg"); +} +.twisty:-moz-focusring { + outline: none; +} +.twisty:-moz-focusring::before { + outline: var(--in-content-focus-outline); +} +.indent { + padding-inline: 48px 0; +} +.double_indent { + padding-inline: 58px 0; +} + +tr[selected] > td { + background-color: var(--in-content-item-selected); + color: var(--in-content-item-selected-text); +} +#process-tbody > tr:hover { + background-color: var(--in-content-item-hover); + color: var(--in-content-item-hover-text); +} + +/* Tab names and thread summary text can extend into memory and CPU columns. */ +.window > :first-child, +.thread-summary > :first-child { + grid-column: 1 / 4; +} + +/* Thread names can extend into the memory column. */ +.thread > :first-child { + grid-column: 1 / 3; +} + +.clickable { + background-repeat: no-repeat; + background-position: right 4px center; +} +.clickable:dir(rtl) { + background-position-x: left 4px; +} + +.arrow-up, +.arrow-down { + -moz-context-properties: fill; + fill: currentColor; +} + +.arrow-up { + background-image: url("chrome://global/skin/icons/arrow-up-12.svg"); +} +.arrow-down { + background-image: url("chrome://global/skin/icons/arrow-down-12.svg"); +} + +th.clickable:hover { + background-color: var(--in-content-button-background-hover); + color: var(--in-content-button-text-color-hover); +} +th.clickable:hover:active { + background-color: var(--in-content-button-background-active); + color: var(--in-content-button-text-color-active); +} + +tr.process > td.type { + font-weight: bold; +} +tr.thread { + font-size-adjust: 0.5; +} + +.killing { + opacity: 0.3; + transition-property: opacity; + transition-duration: 1s; +} + +.killed { + opacity: 0.3; +} + +/* icons */ +.close-icon { + background: url("chrome://global/skin/icons/close.svg") no-repeat center; + opacity: 0; /* Start out as transparent */ + -moz-context-properties: fill; + fill: currentColor; +} + +tr:is([selected], :hover):not(.killing) > .close-icon { + opacity: 1; +} + +.close-icon:hover { + background-color: var(--in-content-button-background-hover); + color: var(--in-content-button-text-color-hover); +} + +.close-icon:hover:active { + background-color: var(--in-content-button-background-active); + color: var(--in-content-button-text-color-active); +} + +/* column-name */ + +/* When the process is reported as frozen, we display an hourglass before its name. */ +.process.hung > :first-child > :not(.twisty)::before { + content: "⌛️"; +} + +/* + Show a separation between process groups. + */ + +tr.separate-from-previous-process-group { + border-top: dotted 1px var(--in-content-box-border-color); + margin-top: -1px; +} + +/* Graphical view of CPU use. */ +.cpu { + --bar-width: 0; + background: linear-gradient(to left, var(--blue-40) calc(var(--bar-width) * 1%), transparent calc(var(--bar-width) * 1%)); +} +.cpu:dir(rtl) { + background: linear-gradient(to right, var(--blue-40) calc(var(--bar-width) * 1%), transparent calc(var(--bar-width) * 1%)); +} diff --git a/toolkit/components/aboutprocesses/content/aboutProcesses.html b/toolkit/components/aboutprocesses/content/aboutProcesses.html new file mode 100644 index 0000000000..76a672cdc2 --- /dev/null +++ b/toolkit/components/aboutprocesses/content/aboutProcesses.html @@ -0,0 +1,54 @@ +<!-- 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/. --> +<!DOCTYPE html> +<html> + <head> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:;img-src data:; object-src 'none'" + /> + <meta name="color-scheme" content="light dark" /> + <title data-l10n-id="about-processes-title"></title> + <link + rel="icon" + id="favicon" + href="chrome://global/skin/icons/performance.svg" + /> + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + <link rel="localization" href="toolkit/about/aboutProcesses.ftl" /> + <link rel="localization" href="branding/brand.ftl" /> + <script src="chrome://global/content/aboutProcesses.js"></script> + <link rel="stylesheet" href="chrome://global/content/aboutProcesses.css" /> + </head> + <body> + <table id="process-table"> + <thead id="process-thead"> + <tr> + <th + class="clickable" + id="column-name" + data-l10n-id="about-processes-column-name" + ></th> + <th + class="clickable" + id="column-memory-resident" + data-l10n-id="about-processes-column-memory-resident" + ></th> + <!-- Memory usage. --> + <th + class="clickable" + id="column-cpu-total" + data-l10n-id="about-processes-column-cpu-total" + ></th> + <!--CPU time--> + <th id="column-kill" data-l10n-id="about-processes-column-action"> + ⚙ + </th> + <!-- Kill button. --> + </tr> + </thead> + <tbody id="process-tbody"></tbody> + </table> + </body> +</html> diff --git a/toolkit/components/aboutprocesses/content/aboutProcesses.js b/toolkit/components/aboutprocesses/content/aboutProcesses.js new file mode 100644 index 0000000000..1d94c52c1f --- /dev/null +++ b/toolkit/components/aboutprocesses/content/aboutProcesses.js @@ -0,0 +1,1566 @@ +/* -*- 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/. */ + +"use strict"; + +// Time in ms before we start changing the sort order again after receiving a +// mousemove event. +const TIME_BEFORE_SORTING_AGAIN = 5000; + +// How long we should wait between samples. +const MINIMUM_INTERVAL_BETWEEN_SAMPLES_MS = 1000; + +// How often we should update +const UPDATE_INTERVAL_MS = 2000; + +const NS_PER_US = 1000; +const NS_PER_MS = 1000 * 1000; +const NS_PER_S = 1000 * 1000 * 1000; +const NS_PER_MIN = NS_PER_S * 60; +const NS_PER_HOUR = NS_PER_MIN * 60; +const NS_PER_DAY = NS_PER_HOUR * 24; + +const ONE_GIGA = 1024 * 1024 * 1024; +const ONE_MEGA = 1024 * 1024; +const ONE_KILO = 1024; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(this, "ProfilerPopupBackground", function () { + return ChromeUtils.import( + "resource://devtools/client/performance-new/shared/background.jsm.js" + ); +}); + +const { WebExtensionPolicy } = Cu.getGlobalForObject(Services); + +const SHOW_THREADS = Services.prefs.getBoolPref( + "toolkit.aboutProcesses.showThreads" +); +const SHOW_ALL_SUBFRAMES = Services.prefs.getBoolPref( + "toolkit.aboutProcesses.showAllSubframes" +); +const SHOW_PROFILER_ICONS = Services.prefs.getBoolPref( + "toolkit.aboutProcesses.showProfilerIcons" +); +const PROFILE_DURATION = Math.max( + 1, + Services.prefs.getIntPref("toolkit.aboutProcesses.profileDuration") +); + +/** + * For the time being, Fluent doesn't support duration or memory formats, so we need + * to fetch units from Fluent. To avoid re-fetching at each update, we prefetch these + * units during initialization, asynchronously, and keep them. + * + * @type { + * duration: { ns: String, us: String, ms: String, s: String, m: String, h: String, d: String }, + * memory: { B: String, KB: String, MB: String, GB: String, TB: String, PB: String, EB: String } + * }. + */ +let gLocalizedUnits; + +let tabFinder = { + update() { + this._map = new Map(); + for (let win of Services.wm.getEnumerator("navigator:browser")) { + let tabbrowser = win.gBrowser; + for (let browser of tabbrowser.browsers) { + let id = browser.outerWindowID; // May be `null` if the browser isn't loaded yet + if (id != null) { + this._map.set(id, browser); + } + } + if (tabbrowser.preloadedBrowser) { + let browser = tabbrowser.preloadedBrowser; + if (browser.outerWindowID) { + this._map.set(browser.outerWindowID, browser); + } + } + } + }, + + /** + * Find the <xul:tab> for a window id. + * + * This is useful e.g. for reloading or closing tabs. + * + * @return null If the xul:tab could not be found, e.g. if the + * windowId is that of a chrome window. + * @return {{tabbrowser: <xul:tabbrowser>, tab: <xul.tab>}} The + * tabbrowser and tab if the latter could be found. + */ + get(id) { + let browser = this._map.get(id); + if (!browser) { + return null; + } + let tabbrowser = browser.getTabBrowser(); + if (!tabbrowser) { + return { + tabbrowser: null, + tab: { + getAttribute() { + return ""; + }, + linkedBrowser: browser, + }, + }; + } + return { tabbrowser, tab: tabbrowser.getTabForBrowser(browser) }; + }, +}; + +/** + * Utilities for dealing with state + */ +var State = { + // Store the previous and current samples so they can be compared. + _previous: null, + _latest: null, + + async _promiseSnapshot() { + let date = Cu.now(); + let main = await ChromeUtils.requestProcInfo(); + main.date = date; + + let processes = new Map(); + processes.set(main.pid, main); + for (let child of main.children) { + child.date = date; + processes.set(child.pid, child); + } + + return { processes, date }; + }, + + /** + * Update the internal state. + * + * @return {Promise} + */ + async update(force = false) { + if ( + force || + !this._latest || + Cu.now() - this._latest.date > MINIMUM_INTERVAL_BETWEEN_SAMPLES_MS + ) { + // Replacing this._previous before we are done awaiting + // this._promiseSnapshot can cause this._previous and this._latest to be + // equal for a short amount of time, which can cause test failures when + // a forced update of the display is triggered in the meantime. + let newSnapshot = await this._promiseSnapshot(); + this._previous = this._latest; + this._latest = newSnapshot; + } + }, + + _getThreadDelta(cur, prev, deltaT) { + let result = { + tid: cur.tid, + name: cur.name || `(${cur.tid})`, + // Total amount of CPU used, in ns. + totalCpu: cur.cpuTime, + slopeCpu: null, + active: null, + }; + if (!deltaT) { + return result; + } + result.slopeCpu = (result.totalCpu - (prev ? prev.cpuTime : 0)) / deltaT; + result.active = + !!result.slopeCpu || cur.cpuCycleCount > (prev ? prev.cpuCycleCount : 0); + return result; + }, + + _getDOMWindows(process) { + if (!process.windows) { + return []; + } + if (!process.type == "extensions") { + return []; + } + let windows = process.windows.map(win => { + let tab = tabFinder.get(win.outerWindowId); + let addon = + process.type == "extension" + ? WebExtensionPolicy.getByURI(win.documentURI) + : null; + let displayRank; + if (tab) { + displayRank = 1; + } else if (win.isProcessRoot) { + displayRank = 2; + } else if (win.documentTitle) { + displayRank = 3; + } else { + displayRank = 4; + } + return { + outerWindowId: win.outerWindowId, + documentURI: win.documentURI, + documentTitle: win.documentTitle, + isProcessRoot: win.isProcessRoot, + isInProcess: win.isInProcess, + tab, + addon, + // The number of instances we have collapsed. + count: 1, + // A rank used to quickly sort windows. + displayRank, + }; + }); + + // We keep all tabs and addons but we collapse subframes that have the same host. + + // A map from host -> subframe. + let collapsible = new Map(); + let result = []; + for (let win of windows) { + if (win.tab || win.addon) { + result.push(win); + continue; + } + let prev = collapsible.get(win.documentURI.prePath); + if (prev) { + prev.count += 1; + } else { + collapsible.set(win.documentURI.prePath, win); + result.push(win); + } + } + return result; + }, + + /** + * Compute the delta between two process snapshots. + * + * @param {ProcessSnapshot} cur + * @param {ProcessSnapshot?} prev + */ + _getProcessDelta(cur, prev) { + let windows = this._getDOMWindows(cur); + let result = { + pid: cur.pid, + childID: cur.childID, + totalRamSize: cur.memory, + deltaRamSize: null, + totalCpu: cur.cpuTime, + slopeCpu: null, + active: null, + type: cur.type, + origin: cur.origin || "", + threads: null, + displayRank: Control._getDisplayGroupRank(cur, windows), + windows, + utilityActors: cur.utilityActors, + // If this process has an unambiguous title, store it here. + title: null, + }; + // Attempt to determine a title for this process. + let titles = [ + ...new Set( + result.windows + .filter(win => win.documentTitle) + .map(win => win.documentTitle) + ), + ]; + if (titles.length == 1) { + result.title = titles[0]; + } + if (!prev) { + if (SHOW_THREADS) { + result.threads = cur.threads.map(data => this._getThreadDelta(data)); + } + return result; + } + if (prev.pid != cur.pid) { + throw new Error("Assertion failed: A process cannot change pid."); + } + let deltaT = (cur.date - prev.date) * NS_PER_MS; + let threads = null; + if (SHOW_THREADS) { + let prevThreads = new Map(); + for (let thread of prev.threads) { + prevThreads.set(thread.tid, thread); + } + threads = cur.threads.map(curThread => + this._getThreadDelta(curThread, prevThreads.get(curThread.tid), deltaT) + ); + } + result.deltaRamSize = cur.memory - prev.memory; + result.slopeCpu = (cur.cpuTime - prev.cpuTime) / deltaT; + result.active = !!result.slopeCpu || cur.cpuCycleCount > prev.cpuCycleCount; + result.threads = threads; + return result; + }, + + getCounters() { + tabFinder.update(); + + let counters = []; + + for (let cur of this._latest.processes.values()) { + let prev = this._previous?.processes.get(cur.pid); + counters.push(this._getProcessDelta(cur, prev)); + } + + return counters; + }, +}; + +var View = { + // Processes, tabs and subframes that we killed during the previous iteration. + // Array<{pid:Number} | {windowId:Number}> + _killedRecently: [], + commit() { + this._killedRecently.length = 0; + let tbody = document.getElementById("process-tbody"); + + let insertPoint = tbody.firstChild; + let nextRow; + while ((nextRow = this._orderedRows.shift())) { + if (insertPoint && insertPoint === nextRow) { + insertPoint = insertPoint.nextSibling; + } else { + tbody.insertBefore(nextRow, insertPoint); + } + } + + if (insertPoint) { + while ((nextRow = insertPoint.nextSibling)) { + this._removeRow(nextRow); + } + this._removeRow(insertPoint); + } + }, + // If we are not going to display the updated list of rows, drop references + // to rows that haven't been inserted in the DOM tree. + discardUpdate() { + for (let row of this._orderedRows) { + if (!row.parentNode) { + this._rowsById.delete(row.rowId); + } + } + this._orderedRows = []; + }, + insertAfterRow(row) { + let tbody = row.parentNode; + let nextRow; + while ((nextRow = this._orderedRows.pop())) { + tbody.insertBefore(nextRow, row.nextSibling); + } + }, + + _rowsById: new Map(), + _removeRow(row) { + this._rowsById.delete(row.rowId); + + row.remove(); + }, + _getOrCreateRow(rowId, cellCount) { + let row = this._rowsById.get(rowId); + if (!row) { + row = document.createElement("tr"); + while (cellCount--) { + row.appendChild(document.createElement("td")); + } + row.rowId = rowId; + this._rowsById.set(rowId, row); + } + this._orderedRows.push(row); + return row; + }, + + displayCpu(data, cpuCell, maxSlopeCpu) { + // Put a value < 0% when we really don't want to see a bar as + // otherwise it sometimes appears due to rounding errors when we + // don't have an integer number of pixels. + let barWidth = -0.5; + if (data.slopeCpu == null) { + this._fillCell(cpuCell, { + fluentName: "about-processes-cpu-user-and-kernel-not-ready", + classes: ["cpu"], + }); + } else { + let { duration, unit } = this._getDuration(data.totalCpu); + if (data.totalCpu == 0) { + // A thread having used exactly 0ns of CPU time is not possible. + // When we get 0 it means the thread used less than the precision of + // the measurement, and it makes more sense to show '0ms' than '0ns'. + // This is useful on Linux where the minimum non-zero CPU time value + // for threads of child processes is 10ms, and on Windows ARM64 where + // the minimum non-zero value is 16ms. + unit = "ms"; + } + let localizedUnit = gLocalizedUnits.duration[unit]; + if (data.slopeCpu == 0) { + let fluentName = data.active + ? "about-processes-cpu-almost-idle" + : "about-processes-cpu-fully-idle"; + this._fillCell(cpuCell, { + fluentName, + fluentArgs: { + total: duration, + unit: localizedUnit, + }, + classes: ["cpu"], + }); + } else { + this._fillCell(cpuCell, { + fluentName: "about-processes-cpu", + fluentArgs: { + percent: data.slopeCpu, + total: duration, + unit: localizedUnit, + }, + classes: ["cpu"], + }); + + let cpuPercent = data.slopeCpu * 100; + if (maxSlopeCpu > 1) { + cpuPercent /= maxSlopeCpu; + } + // Ensure we always have a visible bar for non-0 values. + barWidth = Math.max(0.5, cpuPercent); + } + } + cpuCell.style.setProperty("--bar-width", barWidth); + }, + + /** + * Display a row showing a single process (without its threads). + * + * @param {ProcessDelta} data The data to display. + * @param {Number} maxSlopeCpu The largest slopeCpu value. + * @return {DOMElement} The row displaying the process. + */ + displayProcessRow(data, maxSlopeCpu) { + const cellCount = 4; + let rowId = "p:" + data.pid; + let row = this._getOrCreateRow(rowId, cellCount); + row.process = data; + { + let classNames = "process"; + if (data.isHung) { + classNames += " hung"; + } + row.className = classNames; + } + + // Column: Name + let nameCell = row.firstChild; + { + let classNames = []; + let fluentName; + let fluentArgs = { + pid: "" + data.pid, // Make sure that this number is not localized + }; + switch (data.type) { + case "web": + fluentName = "about-processes-web-process"; + break; + case "webIsolated": + fluentName = "about-processes-web-isolated-process"; + fluentArgs.origin = data.origin; + break; + case "webServiceWorker": + fluentName = "about-processes-web-serviceworker"; + fluentArgs.origin = data.origin; + break; + case "file": + fluentName = "about-processes-file-process"; + break; + case "extension": + fluentName = "about-processes-extension-process"; + classNames = ["extensions"]; + break; + case "privilegedabout": + fluentName = "about-processes-privilegedabout-process"; + break; + case "privilegedmozilla": + fluentName = "about-processes-privilegedmozilla-process"; + break; + case "withCoopCoep": + fluentName = "about-processes-with-coop-coep-process"; + fluentArgs.origin = data.origin; + break; + case "browser": + fluentName = "about-processes-browser-process"; + break; + case "plugin": + fluentName = "about-processes-plugin-process"; + break; + case "gmpPlugin": + fluentName = "about-processes-gmp-plugin-process"; + break; + case "gpu": + fluentName = "about-processes-gpu-process"; + break; + case "vr": + fluentName = "about-processes-vr-process"; + break; + case "rdd": + fluentName = "about-processes-rdd-process"; + break; + case "socket": + fluentName = "about-processes-socket-process"; + break; + case "remoteSandboxBroker": + fluentName = "about-processes-remote-sandbox-broker-process"; + break; + case "forkServer": + fluentName = "about-processes-fork-server-process"; + break; + case "preallocated": + fluentName = "about-processes-preallocated-process"; + break; + case "utility": + fluentName = "about-processes-utility-process"; + break; + // The following are probably not going to show up for users + // but let's handle the case anyway to avoid heisenoranges + // during tests in case of a leftover process from a previous + // test. + default: + fluentName = "about-processes-unknown-process"; + fluentArgs.type = data.type; + break; + } + + // Show container names instead of raw origin attribute suffixes. + if (fluentArgs.origin?.includes("^")) { + let origin = fluentArgs.origin; + let privateBrowsingId, userContextId; + try { + ({ privateBrowsingId, userContextId } = + ChromeUtils.createOriginAttributesFromOrigin(origin)); + fluentArgs.origin = origin.slice(0, origin.indexOf("^")); + } catch (e) { + // createOriginAttributesFromOrigin can throw NS_ERROR_FAILURE for incorrect origin strings. + } + if (userContextId) { + let identityLabel = + ContextualIdentityService.getUserContextLabel(userContextId); + if (identityLabel) { + fluentArgs.origin += ` — ${identityLabel}`; + } + } + if (privateBrowsingId) { + fluentName += "-private"; + } + } + + let processNameElement = nameCell; + if (SHOW_PROFILER_ICONS) { + if (!nameCell.firstChild) { + processNameElement = document.createElement("span"); + nameCell.appendChild(processNameElement); + + let profilerIcon = document.createElement("span"); + profilerIcon.className = "profiler-icon"; + document.l10n.setAttributes( + profilerIcon, + "about-processes-profile-process", + { duration: PROFILE_DURATION } + ); + nameCell.appendChild(profilerIcon); + } else { + processNameElement = nameCell.firstChild; + } + } + document.l10n.setAttributes(processNameElement, fluentName, fluentArgs); + nameCell.className = ["type", "favicon", ...classNames].join(" "); + nameCell.setAttribute("id", data.pid + "-label"); + + let image; + switch (data.type) { + case "browser": + case "privilegedabout": + image = "chrome://branding/content/icon32.png"; + break; + case "extension": + image = "chrome://mozapps/skin/extensions/extension.svg"; + break; + default: + // If all favicons match, pick the shared favicon. + // Otherwise, pick a default icon. + // If some tabs have no favicon, we ignore them. + for (let win of data.windows || []) { + if (!win.tab) { + continue; + } + let favicon = win.tab.tab.getAttribute("image"); + if (!favicon) { + // No favicon here, let's ignore the tab. + } else if (!image) { + // Let's pick a first favicon. + // We'll remove it later if we find conflicting favicons. + image = favicon; + } else if (image == favicon) { + // So far, no conflict, keep the favicon. + } else { + // Conflicting favicons, fallback to default. + image = null; + break; + } + } + if (!image) { + image = "chrome://global/skin/icons/link.svg"; + } + } + nameCell.style.backgroundImage = `url('${image}')`; + } + + // Column: Memory + let memoryCell = nameCell.nextSibling; + { + let formattedTotal = this._formatMemory(data.totalRamSize); + if (data.deltaRamSize) { + let formattedDelta = this._formatMemory(data.deltaRamSize); + this._fillCell(memoryCell, { + fluentName: "about-processes-total-memory-size-changed", + fluentArgs: { + total: formattedTotal.amount, + totalUnit: gLocalizedUnits.memory[formattedTotal.unit], + delta: Math.abs(formattedDelta.amount), + deltaUnit: gLocalizedUnits.memory[formattedDelta.unit], + deltaSign: data.deltaRamSize > 0 ? "+" : "-", + }, + classes: ["memory"], + }); + } else { + this._fillCell(memoryCell, { + fluentName: "about-processes-total-memory-size-no-change", + fluentArgs: { + total: formattedTotal.amount, + totalUnit: gLocalizedUnits.memory[formattedTotal.unit], + }, + classes: ["memory"], + }); + } + } + + // Column: CPU + let cpuCell = memoryCell.nextSibling; + this.displayCpu(data, cpuCell, maxSlopeCpu); + + // Column: Kill button – but not for all processes. + let killButton = cpuCell.nextSibling; + killButton.className = "action-icon"; + + if (data.type.startsWith("web")) { + // This type of process can be killed. + if (this._killedRecently.some(kill => kill.pid && kill.pid == data.pid)) { + // We're racing between the "kill" action and the visual refresh. + // In a few cases, we could end up with the visual refresh showing + // a process as un-killed while we actually just killed it. + // + // We still want to display the process in case something actually + // went bad and the user needs the information to realize this. + // But we also want to make it visible that the process is being + // killed. + row.classList.add("killed"); + } else { + // Otherwise, let's display the kill button. + killButton.classList.add("close-icon"); + document.l10n.setAttributes( + killButton, + "about-processes-shutdown-process" + ); + } + } + + return row; + }, + + /** + * Display a thread summary row with the thread count and a twisty to + * open/close the list. + * + * @param {ProcessDelta} data The data to display. + * @return {boolean} Whether the full thread list should be displayed. + */ + displayThreadSummaryRow(data) { + const cellCount = 2; + let rowId = "ts:" + data.pid; + let row = this._getOrCreateRow(rowId, cellCount); + row.process = data; + row.className = "thread-summary"; + let isOpen = false; + + // Column: Name + let nameCell = row.firstChild; + let threads = data.threads; + let activeThreads = new Map(); + let activeThreadCount = 0; + for (let t of data.threads) { + if (!t.active) { + continue; + } + ++activeThreadCount; + let name = t.name.replace(/ ?#[0-9]+$/, ""); + if (!activeThreads.has(name)) { + activeThreads.set(name, { name, slopeCpu: t.slopeCpu, count: 1 }); + } else { + let thread = activeThreads.get(name); + thread.count++; + thread.slopeCpu += t.slopeCpu; + } + } + let fluentName, fluentArgs; + if (activeThreadCount) { + let percentFormatter = new Intl.NumberFormat(undefined, { + style: "percent", + minimumSignificantDigits: 1, + }); + + let threadList = Array.from(activeThreads.values()); + threadList.sort((t1, t2) => t2.slopeCpu - t1.slopeCpu); + + fluentName = "about-processes-active-threads"; + fluentArgs = { + number: threads.length, + active: activeThreadCount, + list: new Intl.ListFormat(undefined, { style: "narrow" }).format( + threadList.map(t => { + let name = t.count > 1 ? `${t.count} × ${t.name}` : t.name; + let percent = Math.round(t.slopeCpu * 1000) / 1000; + if (percent) { + return `${name} ${percentFormatter.format(percent)}`; + } + return name; + }) + ), + }; + } else { + fluentName = "about-processes-inactive-threads"; + fluentArgs = { + number: threads.length, + }; + } + + let span; + if (!nameCell.firstChild) { + nameCell.className = "name indent"; + // Create the nodes: + let imgBtn = document.createElement("span"); + // Provide markup for an accessible disclosure button: + imgBtn.className = "twisty"; + imgBtn.setAttribute("role", "button"); + imgBtn.setAttribute("tabindex", "0"); + // Label to include both summary and details texts + imgBtn.setAttribute("aria-labelledby", `${data.pid}-label ${rowId}`); + if (!imgBtn.hasAttribute("aria-expanded")) { + imgBtn.setAttribute("aria-expanded", "false"); + } + nameCell.appendChild(imgBtn); + + span = document.createElement("span"); + span.setAttribute("id", rowId); + nameCell.appendChild(span); + } else { + // The only thing that can change is the thread count. + let imgBtn = nameCell.firstChild; + isOpen = imgBtn.classList.contains("open"); + span = imgBtn.nextSibling; + } + document.l10n.setAttributes(span, fluentName, fluentArgs); + + // Column: action + let actionCell = nameCell.nextSibling; + actionCell.className = "action-icon"; + + return isOpen; + }, + + displayDOMWindowRow(data, parent) { + const cellCount = 2; + let rowId = "w:" + data.outerWindowId; + let row = this._getOrCreateRow(rowId, cellCount); + row.win = data; + row.className = "window"; + + // Column: name + let nameCell = row.firstChild; + let tab = tabFinder.get(data.outerWindowId); + let fluentName; + let fluentArgs = {}; + let className; + if (tab && tab.tabbrowser) { + fluentName = "about-processes-tab-name"; + fluentArgs.name = tab.tab.label; + className = "tab"; + } else if (tab) { + fluentName = "about-processes-preloaded-tab"; + className = "preloaded-tab"; + } else if (data.count == 1) { + fluentName = "about-processes-frame-name-one"; + fluentArgs.url = data.documentURI.spec; + className = "frame-one"; + } else { + fluentName = "about-processes-frame-name-many"; + fluentArgs.number = data.count; + fluentArgs.shortUrl = + data.documentURI.scheme == "about" + ? data.documentURI.spec + : data.documentURI.prePath; + className = "frame-many"; + } + this._fillCell(nameCell, { + fluentName, + fluentArgs, + classes: ["name", "indent", "favicon", className], + }); + let image = tab?.tab.getAttribute("image"); + if (image) { + nameCell.style.backgroundImage = `url('${image}')`; + } + + // Column: action + let killButton = nameCell.nextSibling; + killButton.className = "action-icon"; + + if (data.tab && data.tab.tabbrowser) { + // A tab. We want to be able to close it. + if ( + this._killedRecently.some( + kill => kill.windowId && kill.windowId == data.outerWindowId + ) + ) { + // We're racing between the "kill" action and the visual refresh. + // In a few cases, we could end up with the visual refresh showing + // a window as un-killed while we actually just killed it. + // + // We still want to display the window in case something actually + // went bad and the user needs the information to realize this. + // But we also want to make it visible that the window is being + // killed. + row.classList.add("killed"); + } else { + // Otherwise, let's display the kill button. + killButton.classList.add("close-icon"); + document.l10n.setAttributes(killButton, "about-processes-shutdown-tab"); + } + } + }, + + utilityActorNameToFluentName(actorName) { + let fluentName; + switch (actorName) { + case "audioDecoder_Generic": + fluentName = "about-processes-utility-actor-audio-decoder-generic"; + break; + + case "audioDecoder_AppleMedia": + fluentName = "about-processes-utility-actor-audio-decoder-applemedia"; + break; + + case "audioDecoder_WMF": + fluentName = "about-processes-utility-actor-audio-decoder-wmf"; + break; + + case "mfMediaEngineCDM": + fluentName = "about-processes-utility-actor-mf-media-engine"; + break; + + case "jSOracle": + fluentName = "about-processes-utility-actor-js-oracle"; + break; + + case "windowsUtils": + fluentName = "about-processes-utility-actor-windows-utils"; + break; + + default: + fluentName = "about-processes-utility-actor-unknown"; + break; + } + return fluentName; + }, + + displayUtilityActorRow(data, parent) { + const cellCount = 2; + // The actor name is expected to be unique within a given utility process. + let rowId = "u:" + parent.pid + data.actorName; + let row = this._getOrCreateRow(rowId, cellCount); + row.actor = data; + row.className = "actor"; + + // Column: name + let nameCell = row.firstChild; + let fluentName = this.utilityActorNameToFluentName(data.actorName); + let fluentArgs = {}; + this._fillCell(nameCell, { + fluentName, + fluentArgs, + classes: ["name", "indent", "favicon"], + }); + }, + + /** + * Display a row showing a single thread. + * + * @param {ThreadDelta} data The data to display. + * @param {Number} maxSlopeCpu The largest slopeCpu value. + */ + displayThreadRow(data, maxSlopeCpu) { + const cellCount = 3; + let rowId = "t:" + data.tid; + let row = this._getOrCreateRow(rowId, cellCount); + row.thread = data; + row.className = "thread"; + + // Column: name + let nameCell = row.firstChild; + this._fillCell(nameCell, { + fluentName: "about-processes-thread-name-and-id", + fluentArgs: { + name: data.name, + tid: "" + data.tid /* Make sure that this number is not localized */, + }, + classes: ["name", "double_indent"], + }); + + // Column: CPU + this.displayCpu(data, nameCell.nextSibling, maxSlopeCpu); + + // Third column (Buttons) is empty, nothing to do. + }, + + _orderedRows: [], + _fillCell(elt, { classes, fluentName, fluentArgs }) { + document.l10n.setAttributes(elt, fluentName, fluentArgs); + elt.className = classes.join(" "); + }, + + _getDuration(rawDurationNS) { + if (rawDurationNS <= NS_PER_US) { + return { duration: rawDurationNS, unit: "ns" }; + } + if (rawDurationNS <= NS_PER_MS) { + return { duration: rawDurationNS / NS_PER_US, unit: "us" }; + } + if (rawDurationNS <= NS_PER_S) { + return { duration: rawDurationNS / NS_PER_MS, unit: "ms" }; + } + if (rawDurationNS <= NS_PER_MIN) { + return { duration: rawDurationNS / NS_PER_S, unit: "s" }; + } + if (rawDurationNS <= NS_PER_HOUR) { + return { duration: rawDurationNS / NS_PER_MIN, unit: "m" }; + } + if (rawDurationNS <= NS_PER_DAY) { + return { duration: rawDurationNS / NS_PER_HOUR, unit: "h" }; + } + return { duration: rawDurationNS / NS_PER_DAY, unit: "d" }; + }, + + /** + * Format a value representing an amount of memory. + * + * As a special case, we also handle `null`, which represents the case in which we do + * not have sufficient information to compute an amount of memory. + * + * @param {Number?} value The value to format. Must be either `null` or a non-negative number. + * @return { {unit: "GB" | "MB" | "KB" | B" | "?"}, amount: Number } The formated amount and its + * unit, which may be used for e.g. additional CSS formating. + */ + _formatMemory(value) { + if (value == null) { + return { unit: "?", amount: 0 }; + } + if (typeof value != "number") { + throw new Error(`Invalid memory value ${value}`); + } + let abs = Math.abs(value); + if (abs >= ONE_GIGA) { + return { + unit: "GB", + amount: value / ONE_GIGA, + }; + } + if (abs >= ONE_MEGA) { + return { + unit: "MB", + amount: value / ONE_MEGA, + }; + } + if (abs >= ONE_KILO) { + return { + unit: "KB", + amount: value / ONE_KILO, + }; + } + return { + unit: "B", + amount: value, + }; + }, +}; + +var Control = { + // The set of all processes reported as "hung" by the process hang monitor. + // + // type: Set<ChildID> + _hungItems: new Set(), + _sortColumn: null, + _sortAscendent: true, + _removeSubtree(row) { + let sibling = row.nextSibling; + while (sibling && !sibling.classList.contains("process")) { + let next = sibling.nextSibling; + if (sibling.classList.contains("thread")) { + View._removeRow(sibling); + } + sibling = next; + } + }, + init() { + this._initHangReports(); + + // Start prefetching units. + this._promisePrefetchedUnits = (async function () { + let [ns, us, ms, s, m, h, d, B, KB, MB, GB, TB, PB, EB] = + await document.l10n.formatValues([ + { id: "duration-unit-ns" }, + { id: "duration-unit-us" }, + { id: "duration-unit-ms" }, + { id: "duration-unit-s" }, + { id: "duration-unit-m" }, + { id: "duration-unit-h" }, + { id: "duration-unit-d" }, + { id: "memory-unit-B" }, + { id: "memory-unit-KB" }, + { id: "memory-unit-MB" }, + { id: "memory-unit-GB" }, + { id: "memory-unit-TB" }, + { id: "memory-unit-PB" }, + { id: "memory-unit-EB" }, + ]); + return { + duration: { ns, us, ms, s, m, h, d }, + memory: { B, KB, MB, GB, TB, PB, EB }, + }; + })(); + + let tbody = document.getElementById("process-tbody"); + + // Single click: + // - show or hide the contents of a twisty; + // - close a process; + // - profile a process; + // - change selection. + tbody.addEventListener("click", event => { + this._updateLastMouseEvent(); + + this._handleActivate(event.target); + }); + + // Enter or Space keypress: + // - show or hide the contents of a twisty; + // - close a process; + // - profile a process; + // - change selection. + tbody.addEventListener("keypress", event => { + // Handle showing or hiding subitems of a row, when keyboard is used. + if (event.key === "Enter" || event.key === " ") { + this._handleActivate(event.target); + } + }); + + // Double click: + // - navigate to tab; + // - navigate to about:addons. + tbody.addEventListener("dblclick", event => { + this._updateLastMouseEvent(); + event.stopPropagation(); + + // Bubble up the doubleclick manually. + for ( + let target = event.target; + target && target.getAttribute("id") != "process-tbody"; + target = target.parentNode + ) { + if (target.classList.contains("tab")) { + // We've clicked on a tab, navigate. + let { tab, tabbrowser } = target.parentNode.win.tab; + tabbrowser.selectedTab = tab; + tabbrowser.ownerGlobal.focus(); + return; + } + if (target.classList.contains("extensions")) { + // We've clicked on the extensions process, open or reuse window. + let parentWin = + window.docShell.browsingContext.embedderElement.ownerGlobal; + parentWin.BrowserOpenAddonsMgr(); + return; + } + // Otherwise, proceed. + } + }); + + tbody.addEventListener("mousemove", () => { + this._updateLastMouseEvent(); + }); + + // Visibility change: + // - stop updating while the user isn't looking; + // - resume updating when the user returns. + window.addEventListener("visibilitychange", event => { + if (!document.hidden) { + this._updateDisplay(true); + } + }); + + document + .getElementById("process-thead") + .addEventListener("click", async event => { + if (!event.target.classList.contains("clickable")) { + return; + } + // Linux has conventions opposite to Windows and macOS on the direction of arrows + // when sorting. + const platformIsLinux = AppConstants.platform == "linux"; + const ascArrow = platformIsLinux ? "arrow-up" : "arrow-down"; + const descArrow = platformIsLinux ? "arrow-down" : "arrow-up"; + + if (this._sortColumn) { + const td = document.getElementById(this._sortColumn); + td.classList.remove(ascArrow, descArrow); + } + + const columnId = event.target.id; + if (columnId == this._sortColumn) { + // Reverse sorting order. + this._sortAscendent = !this._sortAscendent; + } else { + this._sortColumn = columnId; + this._sortAscendent = true; + } + + event.target.classList.toggle(ascArrow, this._sortAscendent); + event.target.classList.toggle(descArrow, !this._sortAscendent); + + await this._updateDisplay(true); + }); + }, + _lastMouseEvent: 0, + _updateLastMouseEvent() { + this._lastMouseEvent = Date.now(); + }, + _initHangReports() { + const PROCESS_HANG_REPORT_NOTIFICATION = "process-hang-report"; + + // Receiving report of a hung child. + // Let's store if for our next update. + let hangReporter = report => { + report.QueryInterface(Ci.nsIHangReport); + this._hungItems.add(report.childID); + }; + Services.obs.addObserver(hangReporter, PROCESS_HANG_REPORT_NOTIFICATION); + + // Don't forget to unregister the reporter. + window.addEventListener( + "unload", + () => { + Services.obs.removeObserver( + hangReporter, + PROCESS_HANG_REPORT_NOTIFICATION + ); + }, + { once: true } + ); + }, + async update(force = false) { + await State.update(force); + + if (document.hidden) { + return; + } + + await this._updateDisplay(force); + }, + + // The force parameter can force a full update even when the mouse has been + // moved recently. + async _updateDisplay(force = false) { + let counters = State.getCounters(); + if (this._promisePrefetchedUnits) { + gLocalizedUnits = await this._promisePrefetchedUnits; + this._promisePrefetchedUnits = null; + } + + // We reset `_hungItems`, based on the assumption that the process hang + // monitor will inform us again before the next update. Since the process hang monitor + // pings its clients about once per second and we update about once per 2 seconds + // (or more if the mouse moves), we should be ok. + let hungItems = this._hungItems; + this._hungItems = new Set(); + + counters = this._sortProcesses(counters); + + // Stored because it is used when opening the list of threads. + this._maxSlopeCpu = Math.max(...counters.map(process => process.slopeCpu)); + + let previousProcess = null; + for (let process of counters) { + this._sortDOMWindows(process.windows); + + process.isHung = process.childID && hungItems.has(process.childID); + + let processRow = View.displayProcessRow(process, this._maxSlopeCpu); + + if (process.type != "extension") { + // We do not want to display extensions. + for (let win of process.windows) { + if (SHOW_ALL_SUBFRAMES || win.tab || win.isProcessRoot) { + View.displayDOMWindowRow(win, process); + } + } + } + + if (process.type === "utility") { + for (let actor of process.utilityActors) { + View.displayUtilityActorRow(actor, process); + } + } + + if (SHOW_THREADS) { + if (View.displayThreadSummaryRow(process)) { + this._showThreads(processRow, this._maxSlopeCpu); + } + } + if ( + this._sortColumn == null && + previousProcess && + previousProcess.displayRank != process.displayRank + ) { + // Add a separation between successive categories of processes. + processRow.classList.add("separate-from-previous-process-group"); + } + previousProcess = process; + } + + if ( + !force && + Date.now() - this._lastMouseEvent < TIME_BEFORE_SORTING_AGAIN + ) { + // If there has been a recent mouse event, we don't want to reorder, + // add or remove rows so that the table content under the mouse pointer + // doesn't change when the user might be about to click to close a tab + // or kill a process. + // We didn't return earlier because updating CPU and memory values is + // still valuable. + View.discardUpdate(); + return; + } + + View.commit(); + + // Reset the selectedRow field if that row is no longer in the DOM + // to avoid keeping forever references to dead processes. + if (this.selectedRow && !this.selectedRow.parentNode) { + this.selectedRow = null; + } + + // Used by tests to differentiate full updates from l10n updates. + document.dispatchEvent(new CustomEvent("AboutProcessesUpdated")); + }, + _compareCpu(a, b) { + return ( + b.slopeCpu - a.slopeCpu || b.active - a.active || b.totalCpu - a.totalCpu + ); + }, + _showThreads(row, maxSlopeCpu) { + let process = row.process; + this._sortThreads(process.threads); + for (let thread of process.threads) { + View.displayThreadRow(thread, maxSlopeCpu); + } + }, + _sortThreads(threads) { + return threads.sort((a, b) => { + let order; + switch (this._sortColumn) { + case "column-name": + order = a.name.localeCompare(b.name) || a.tid - b.tid; + break; + case "column-cpu-total": + order = this._compareCpu(a, b); + break; + case "column-memory-resident": + case null: + order = a.tid - b.tid; + break; + default: + throw new Error("Unsupported order: " + this._sortColumn); + } + if (!this._sortAscendent) { + order = -order; + } + return order; + }); + }, + _sortProcesses(counters) { + return counters.sort((a, b) => { + let order; + switch (this._sortColumn) { + case "column-name": + order = + String(a.origin).localeCompare(b.origin) || + String(a.type).localeCompare(b.type) || + a.pid - b.pid; + break; + case "column-cpu-total": + order = this._compareCpu(a, b); + break; + case "column-memory-resident": + order = b.totalRamSize - a.totalRamSize; + break; + case null: + // Default order: classify processes by group. + order = + a.displayRank - b.displayRank || + // Other processes are ordered by origin. + String(a.origin).localeCompare(b.origin); + break; + default: + throw new Error("Unsupported order: " + this._sortColumn); + } + if (!this._sortAscendent) { + order = -order; + } + return order; + }); + }, + _sortDOMWindows(windows) { + return windows.sort((a, b) => { + let order = + a.displayRank - b.displayRank || + a.documentTitle.localeCompare(b.documentTitle) || + a.documentURI.spec.localeCompare(b.documentURI.spec); + if (!this._sortAscendent) { + order = -order; + } + return order; + }); + }, + + // Assign a display rank to a process. + // + // The `browser` process comes first (rank 0). + // Then come web tabs (rank 1). + // Then come web frames (rank 2). + // Then come special processes (minus preallocated) (rank 3). + // Then come preallocated processes (rank 4). + _getDisplayGroupRank(data, windows) { + const RANK_BROWSER = 0; + const RANK_WEB_TABS = 1; + const RANK_WEB_FRAMES = 2; + const RANK_UTILITY = 3; + const RANK_PREALLOCATED = 4; + let type = data.type; + switch (type) { + // Browser comes first. + case "browser": + return RANK_BROWSER; + // Web content comes next. + case "webIsolated": + case "webServiceWorker": + case "withCoopCoep": { + if (windows.some(w => w.tab)) { + return RANK_WEB_TABS; + } + return RANK_WEB_FRAMES; + } + // Preallocated processes come last. + case "preallocated": + return RANK_PREALLOCATED; + // "web" is special, as it could be one of: + // - web content currently loading/unloading/... + // - a preallocated process. + case "web": + if (windows.some(w => w.tab)) { + return RANK_WEB_TABS; + } + if (windows.length >= 1) { + return RANK_WEB_FRAMES; + } + // For the time being, we do not display DOM workers + // (and there's no API to get information on them). + // Once the blockers for bug 1663737 have landed, we'll be able + // to find out whether this process has DOM workers. If so, we'll + // count this process as a content process. + return RANK_PREALLOCATED; + // Other special processes before preallocated. + default: + return RANK_UTILITY; + } + }, + + // Handle events on image controls. + _handleActivate(target) { + if (target.classList.contains("twisty")) { + this._handleTwisty(target); + return; + } + if (target.classList.contains("close-icon")) { + this._handleKill(target); + return; + } + + if (target.classList.contains("profiler-icon")) { + this._handleProfiling(target); + return; + } + + this._handleSelection(target); + }, + + // Open/close list of threads. + _handleTwisty(target) { + let row = target.parentNode.parentNode; + if (target.classList.toggle("open")) { + target.setAttribute("aria-expanded", "true"); + this._showThreads(row, this._maxSlopeCpu); + View.insertAfterRow(row); + } else { + target.setAttribute("aria-expanded", "false"); + this._removeSubtree(row); + } + }, + + // Kill process/close tab/close subframe. + _handleKill(target) { + let row = target.parentNode; + if (row.process) { + // Kill process immediately. + let pid = row.process.pid; + + // Make sure that the user can't click twice on the kill button. + // Otherwise, chaos might ensue. Plus we risk crashing under Windows. + View._killedRecently.push({ pid }); + + // Discard tab contents and show that the process and all its contents are getting killed. + row.classList.add("killing"); + for ( + let childRow = row.nextSibling; + childRow && !childRow.classList.contains("process"); + childRow = childRow.nextSibling + ) { + childRow.classList.add("killing"); + let win = childRow.win; + if (win) { + View._killedRecently.push({ pid: win.outerWindowId }); + if (win.tab && win.tab.tabbrowser) { + win.tab.tabbrowser.discardBrowser( + win.tab.tab, + /* aForceDiscard = */ true + ); + } + } + } + + // Finally, kill the process. + const ProcessTools = Cc["@mozilla.org/processtools-service;1"].getService( + Ci.nsIProcessToolsService + ); + ProcessTools.kill(pid); + } else if (row.win && row.win.tab && row.win.tab.tabbrowser) { + // This is a tab, close it. + row.win.tab.tabbrowser.removeTab(row.win.tab.tab, { + skipPermitUnload: true, + animate: true, + }); + View._killedRecently.push({ outerWindowId: row.win.outerWindowId }); + row.classList.add("killing"); + + // If this was the only root window of the process, show that the process is also getting killed. + if (row.previousSibling.classList.contains("process")) { + let parentRow = row.previousSibling; + let roots = 0; + for (let win of parentRow.process.windows) { + if (win.isProcessRoot) { + roots += 1; + } + } + if (roots <= 1) { + // Yes, we're the only process root, so the process is dying. + // + // It might actually become a preloaded process rather than + // dying. That's an acceptable error. Even if we display incorrectly + // that the process is dying, this error will last only one refresh. + View._killedRecently.push({ pid: parentRow.process.pid }); + parentRow.classList.add("killing"); + } + } + } + }, + + // Handle profiling of a process. + _handleProfiling(target) { + if (Services.profiler.IsActive()) { + return; + } + Services.profiler.StartProfiler( + 10000000, + 1, + ["default", "ipcmessages", "power"], + ["pid:" + target.parentNode.parentNode.process.pid] + ); + target.classList.add("profiler-active"); + setTimeout(() => { + ProfilerPopupBackground.captureProfile("aboutprofiling"); + target.classList.remove("profiler-active"); + }, PROFILE_DURATION * 1000); + }, + + // Handle selection changes. + _handleSelection(target) { + let row = target.closest("tr"); + if (!row) { + return; + } + if (this.selectedRow) { + this.selectedRow.removeAttribute("selected"); + if (this.selectedRow.rowId == row.rowId) { + // Clicking the same row again clears the selection. + this.selectedRow = null; + return; + } + } + row.setAttribute("selected", "true"); + this.selectedRow = row; + }, +}; + +window.onload = async function () { + Control.init(); + + // Display immediately the list of processes. CPU values will be missing. + await Control.update(); + + // After the minimum interval between samples, force an update to show + // valid CPU values asap. + await new Promise(resolve => + setTimeout(resolve, MINIMUM_INTERVAL_BETWEEN_SAMPLES_MS) + ); + await Control.update(true); + + // Then update at the normal frequency. + window.setInterval(() => Control.update(), UPDATE_INTERVAL_MS); +}; diff --git a/toolkit/components/aboutprocesses/jar.mn b/toolkit/components/aboutprocesses/jar.mn new file mode 100644 index 0000000000..724a747306 --- /dev/null +++ b/toolkit/components/aboutprocesses/jar.mn @@ -0,0 +1,8 @@ +# 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/. + +toolkit.jar: + content/global/aboutProcesses.html (content/aboutProcesses.html) + content/global/aboutProcesses.js (content/aboutProcesses.js) + content/global/aboutProcesses.css (content/aboutProcesses.css) diff --git a/toolkit/components/aboutprocesses/moz.build b/toolkit/components/aboutprocesses/moz.build new file mode 100644 index 0000000000..f397ead22d --- /dev/null +++ b/toolkit/components/aboutprocesses/moz.build @@ -0,0 +1,12 @@ +# -*- 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/. + +with Files("**"): + BUG_COMPONENT = ("Toolkit", "Performance Monitoring") + +JAR_MANIFESTS += ["jar.mn"] + +BROWSER_CHROME_MANIFESTS += ["tests/browser/browser.ini"] diff --git a/toolkit/components/aboutprocesses/tests/browser/browser.ini b/toolkit/components/aboutprocesses/tests/browser/browser.ini new file mode 100644 index 0000000000..83f50ef208 --- /dev/null +++ b/toolkit/components/aboutprocesses/tests/browser/browser.ini @@ -0,0 +1,19 @@ +[default] +head = head.js +skip-if = + asan || tsan # With sanitizers, we regularly hit internal timeouts. +support-files = + small-shot.mp3 + +[browser_aboutprocesses_default_options.js] +https_first_disabled = true +[browser_aboutprocesses_show_all_frames.js] +https_first_disabled = true +[browser_aboutprocesses_show_threads.js] +https_first_disabled = true +[browser_aboutprocesses_show_frames_without_threads.js] +https_first_disabled = true +[browser_aboutprocesses_selection.js] +[browser_aboutprocesses_twisty.js] +[browser_aboutprocesses_shortcut.js] +[browser_aboutprocesses_utility_actors.js] diff --git a/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_default_options.js b/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_default_options.js new file mode 100644 index 0000000000..15ed1c4501 --- /dev/null +++ b/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_default_options.js @@ -0,0 +1,7 @@ +// Test about:processes with default options. +add_task(async function testDefaultOptions() { + return testAboutProcessesWithConfig({ + showAllFrames: false, + showThreads: false, + }); +}); diff --git a/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_selection.js b/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_selection.js new file mode 100644 index 0000000000..f208fb095d --- /dev/null +++ b/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_selection.js @@ -0,0 +1,124 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var doc, tbody, tabAboutProcesses; + +const rowTypes = ["process", "window", "thread-summary", "thread"]; + +function promiseUpdate() { + return promiseAboutProcessesUpdated({ + doc, + tbody, + force: true, + tabAboutProcesses, + }); +} + +add_setup(async function () { + Services.prefs.setBoolPref("toolkit.aboutProcesses.showThreads", true); + + info("Setting up about:processes"); + tabAboutProcesses = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: "about:processes", + waitForLoad: true, + }); + + doc = tabAboutProcesses.linkedBrowser.contentDocument; + tbody = doc.getElementById("process-tbody"); + await promiseUpdate(); + + info("Open a list of threads to have thread rows displayed"); + let twisty = doc.querySelector("tr.thread-summary .twisty"); + twisty.click(); + await promiseUpdate(); +}); + +add_task(async function testSelectionPersistedAfterUpdate() { + for (let rowType of rowTypes) { + let row = doc.querySelector(`tr.${rowType}`); + Assert.ok(!!row, `Found ${rowType} row`); + Assert.ok(!row.hasAttribute("selected"), "The row should not be selected"); + + info("Click in the row to select it."); + row.click(); + Assert.equal( + row.getAttribute("selected"), + "true", + "The row should be selected" + ); + Assert.equal( + doc.querySelectorAll("[selected]").length, + 1, + "There should be only one selected row" + ); + + info("Wait for an update and ensure the selected row is still the same"); + let rowId = row.rowId; + let findRowsWithId = rowId => + [...doc.querySelectorAll("tr")].filter(r => r.rowId == rowId); + Assert.equal( + findRowsWithId(rowId).length, + 1, + "There should be only one row with id " + rowId + ); + await promiseUpdate(); + let selectedRow = doc.querySelector("[selected]"); + if (rowType == "thread" && !selectedRow) { + info("The thread row might have disappeared if the thread has ended"); + Assert.equal( + findRowsWithId(rowId).length, + 0, + "There should no longer be a row with id " + rowId + ); + continue; + } + Assert.ok( + !!selectedRow, + "There should still be a selected row after an update" + ); + Assert.equal( + selectedRow.rowId, + rowId, + "The selected row should have the same id as the row we clicked" + ); + } +}); + +add_task(function testClickAgainToRemoveSelection() { + for (let rowType of rowTypes) { + let row = doc.querySelector(`tr.${rowType}`); + Assert.ok(!!row, `Found ${rowType} row`); + Assert.ok(!row.hasAttribute("selected"), "The row should not be selected"); + info("Click in the row to select it."); + row.click(); + Assert.equal( + row.getAttribute("selected"), + "true", + "The row should now be selected" + ); + Assert.equal( + doc.querySelectorAll("[selected]").length, + 1, + "There should be only one selected row" + ); + + info("Click the row again to remove the selection."); + row.click(); + Assert.ok( + !row.hasAttribute("selected"), + "The row should no longer be selected" + ); + Assert.ok( + !doc.querySelector("[selected]"), + "There should be no selected row" + ); + } +}); + +add_task(function cleanup() { + BrowserTestUtils.removeTab(tabAboutProcesses); + Services.prefs.clearUserPref("toolkit.aboutProcesses.showThreads"); +}); diff --git a/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_shortcut.js b/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_shortcut.js new file mode 100644 index 0000000000..61b8be1fcd --- /dev/null +++ b/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_shortcut.js @@ -0,0 +1,16 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + // Wait for the browser to be ready to receive keyboard events. + if (!gBrowser.selectedBrowser.hasLayers) { + await BrowserTestUtils.waitForEvent(window, "MozLayerTreeReady"); + } + + EventUtils.synthesizeKey("KEY_Escape", { shiftKey: true }); + + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + Assert.equal(gBrowser.selectedBrowser.currentURI.spec, "about:processes"); +}); diff --git a/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_show_all_frames.js b/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_show_all_frames.js new file mode 100644 index 0000000000..2c437f9dc4 --- /dev/null +++ b/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_show_all_frames.js @@ -0,0 +1,6 @@ +add_task(async function testShowFramesAndThreads() { + await testAboutProcessesWithConfig({ + showAllFrames: true, + showThreads: true, + }); +}); diff --git a/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_show_frames_without_threads.js b/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_show_frames_without_threads.js new file mode 100644 index 0000000000..178ceec9ed --- /dev/null +++ b/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_show_frames_without_threads.js @@ -0,0 +1,6 @@ +add_task(async function testShowFramesWithoutThreads() { + await testAboutProcessesWithConfig({ + showAllFrames: true, + showThreads: false, + }); +}); diff --git a/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_show_threads.js b/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_show_threads.js new file mode 100644 index 0000000000..10e630ab31 --- /dev/null +++ b/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_show_threads.js @@ -0,0 +1,7 @@ +// Test about:processes with showThreads: true, showAllFrames: false. +add_task(async function testShowThreads() { + return testAboutProcessesWithConfig({ + showAllFrames: false, + showThreads: true, + }); +}); diff --git a/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_twisty.js b/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_twisty.js new file mode 100644 index 0000000000..77fb0fbfbb --- /dev/null +++ b/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_twisty.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let doc, tbody, tabAboutProcesses; + +const rowTypes = ["process", "window", "thread-summary", "thread"]; + +function promiseUpdate() { + return promiseAboutProcessesUpdated({ + doc, + tbody, + force: true, + tabAboutProcesses, + }); +} + +add_setup(async function () { + Services.prefs.setBoolPref("toolkit.aboutProcesses.showThreads", true); + + info("Setting up about:processes"); + tabAboutProcesses = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: "about:processes", + waitForLoad: true, + }); + + doc = tabAboutProcesses.linkedBrowser.contentDocument; + tbody = doc.getElementById("process-tbody"); + await promiseUpdate(); +}); + +add_task(function testTwistyImageButtonSetup() { + let twistyBtn = doc.querySelector("tr.thread-summary .twisty"); + let groupRow = twistyBtn.parentNode.parentNode; + let groupRowId = groupRow.firstChild.children[1].getAttribute("id"); + let groupRowLabelId = groupRowId.split(":")[1]; + + info("Verify twisty button is properly set up."); + Assert.ok( + twistyBtn.hasAttribute("aria-labelledby"), + "the Twisty image button has an aria-labelledby" + ); + Assert.equal( + twistyBtn.getAttribute("aria-labelledby"), + `${groupRowLabelId}-label ${groupRowId}`, + "the Twisty image button's aria-labelledby refers to a valid 'id' that is the Name of its row" + ); + Assert.equal( + twistyBtn.getAttribute("role"), + "button", + "the Twisty image is programmatically a button" + ); + Assert.equal( + twistyBtn.getAttribute("tabindex"), + "0", + "the Twisty image button is included in the focus order" + ); + Assert.equal( + twistyBtn.getAttribute("aria-expanded"), + "false", + "the Twisty image button is collapsed by default" + ); +}); + +add_task(function testTwistyImageButtonClicking() { + let twistyBtn = doc.querySelector("tr.thread-summary .twisty"); + let groupRow = twistyBtn.parentNode.parentNode; + + info( + "Verify we can toggle/open a list of threads by clicking the twisty button." + ); + twistyBtn.click(); + + Assert.ok( + groupRow.nextSibling.classList.contains("thread") && + !groupRow.nextSibling.classList.contains("thread-summary"), + "clicking a collapsed Twisty adds subitems after the row" + ); + Assert.equal( + twistyBtn.getAttribute("aria-expanded"), + "true", + "the Twisty image button is expanded after a click" + ); +}); + +add_task(function testTwistyImageButtonKeypressing() { + let twistyBtn = doc.querySelector("tr.thread-summary .twisty"); + let groupRow = twistyBtn.parentNode.parentNode; + + info( + `Verify we can toggle/close a list of threads by pressing Enter or + Space on the twisty button.` + ); + // Verify the twisty button can be focused with a keyboard. + twistyBtn.focus(); + Assert.equal( + twistyBtn, + doc.activeElement, + "the Twisty image button can be focused" + ); + + // Verify we can toggle subitems with a keyboard. + // Twisty is expanded + EventUtils.synthesizeKey("KEY_Enter"); + Assert.ok( + !groupRow.nextSibling.classList.contains("thread") || + groupRow.nextSibling.classList.contains("thread-summary"), + "pressing Enter on expanded Twisty hides a list of threads after the row" + ); + Assert.equal( + twistyBtn.getAttribute("aria-expanded"), + "false", + "the Twisty image button is collapsed after an Enter keypress" + ); + // Twisty is collapsed + EventUtils.synthesizeKey(" "); + Assert.ok( + groupRow.nextSibling.classList.contains("thread") && + !groupRow.nextSibling.classList.contains("thread-summary"), + "pressing Space on collapsed Twisty shows a list of threads after the row" + ); + Assert.equal( + twistyBtn.getAttribute("aria-expanded"), + "true", + "the Twisty image button is expanded after a Space keypress" + ); +}); + +add_task(function cleanup() { + BrowserTestUtils.removeTab(tabAboutProcesses); + Services.prefs.clearUserPref("toolkit.aboutProcesses.showThreads"); +}); diff --git a/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_utility_actors.js b/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_utility_actors.js new file mode 100644 index 0000000000..8ab7dcb747 --- /dev/null +++ b/toolkit/components/aboutprocesses/tests/browser/browser_aboutprocesses_utility_actors.js @@ -0,0 +1,44 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test about:processes preparation of utility actor names. +add_task(async function testUtilityActorNames() { + await BrowserTestUtils.withNewTab( + { + gBrowser, + opening: "about:processes", + waitForLoad: true, + }, + browser => { + const View = browser.contentWindow.View; + const unknownActorName = "unknown"; + const kDontExistFluentName = + View.utilityActorNameToFluentName("i-dont-exist"); + const unknownFluentName = + View.utilityActorNameToFluentName(unknownActorName); + + Assert.equal( + unknownFluentName, + kDontExistFluentName, + "Anything is unknown" + ); + + for (let actorName of ChromeUtils.getAllPossibleUtilityActorNames()) { + const fluentName = View.utilityActorNameToFluentName(actorName); + if (actorName === unknownActorName) { + Assert.ok( + fluentName === unknownFluentName, + `Actor name ${actorName} is expected unknown ${fluentName}` + ); + } else { + Assert.ok( + fluentName !== unknownFluentName, + `Actor name ${actorName} is known ${fluentName}` + ); + } + } + } + ); +}); diff --git a/toolkit/components/aboutprocesses/tests/browser/head.js b/toolkit/components/aboutprocesses/tests/browser/head.js new file mode 100644 index 0000000000..7f22825fa2 --- /dev/null +++ b/toolkit/components/aboutprocesses/tests/browser/head.js @@ -0,0 +1,1066 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// A bunch of assumptions we make about the behavior of the parent process, +// and which we use as sanity checks. If Firefox evolves, we will need to +// update these values. +// Note that Test Verify can really stress the cpu durations. +const HARDCODED_ASSUMPTIONS_PROCESS = { + minimalNumberOfThreads: 6, + maximalNumberOfThreads: 1000, + minimalCPUPercentage: 0, + maximalCPUPercentage: 1000, + minimalCPUTotalDurationMS: 10, + maximalCPUTotalDurationMS: 10000000, + minimalRAMBytesUsage: 1024 * 1024 /* 1 Megabyte */, + maximalRAMBytesUsage: 1024 * 1024 * 1024 * 1024 * 1 /* 1 Tb */, +}; + +const HARDCODED_ASSUMPTIONS_THREAD = { + minimalCPUPercentage: 0, + maximalCPUPercentage: 100, + minimalCPUTotalDurationMS: 0, + maximalCPUTotalDurationMS: 10000000, +}; + +// How close we accept our rounding up/down. +const APPROX_FACTOR = 1.51; +const MS_PER_NS = 1000000; + +// Wait for `about:processes` to be updated. +async function promiseAboutProcessesUpdated({ doc, force, tabAboutProcesses }) { + let startTime = performance.now(); + + let updatePromise = new Promise(resolve => { + doc.addEventListener("AboutProcessesUpdated", resolve, { once: true }); + }); + + if (force) { + await SpecialPowers.spawn(tabAboutProcesses.linkedBrowser, [], async () => { + info("Forcing about:processes refresh"); + await content.Control.update(/* force = */ true); + }); + } + + await updatePromise; + + // Fluent will update the visible table content during the next + // refresh driver tick, wait for it. + // requestAnimationFrame calls us at the begining of the tick, we use + // dispatchToMainThread to execute our code after the end of it. + //XXX: Replace with proper wait for l10n completion once bug 1520659 is fixed. + await new Promise(doc.defaultView.requestAnimationFrame); + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + + ChromeUtils.addProfilerMarker( + "promiseAboutProcessesUpdated", + { startTime, category: "Test" }, + force ? "force" : undefined + ); +} + +function promiseProcessDied({ childID }) { + return new Promise(resolve => { + let observer = properties => { + properties.QueryInterface(Ci.nsIPropertyBag2); + let subjectChildID = properties.get("childID"); + if (subjectChildID == childID) { + Services.obs.removeObserver(observer, "ipc:content-shutdown"); + resolve(); + } + }; + Services.obs.addObserver(observer, "ipc:content-shutdown"); + }); +} + +function isCloseEnough(value, expected) { + if (value < 0 || expected < 0) { + throw new Error(`Invalid isCloseEnough(${value}, ${expected})`); + } + if (Math.round(value) == Math.round(expected)) { + return true; + } + if (expected == 0) { + return false; + } + let ratio = value / expected; + if (ratio <= APPROX_FACTOR && ratio >= 1 / APPROX_FACTOR) { + return true; + } + return false; +} + +function getMemoryMultiplier(unit, sign = "+") { + let multiplier; + switch (sign) { + case "+": + multiplier = 1; + break; + case "-": + multiplier = -1; + break; + default: + throw new Error("Invalid sign: " + sign); + } + switch (unit) { + case "B": + break; + case "KB": + multiplier *= 1024; + break; + case "MB": + multiplier *= 1024 * 1024; + break; + case "GB": + multiplier *= 1024 * 1024 * 1024; + break; + case "TB": + multiplier *= 1024 * 1024 * 1024 * 1024; + break; + default: + throw new Error("Invalid memory unit: " + unit); + } + return multiplier; +} + +function getTimeMultiplier(unit) { + switch (unit) { + case "ns": + return 1 / (1000 * 1000); + case "µs": + return 1 / 1000; + case "ms": + return 1; + case "s": + return 1000; + case "m": + return 60000; + } + throw new Error("Invalid time unit: " + unit); +} +async function testCpu(element, total, slope, assumptions) { + info( + `Testing CPU display ${element.textContent} - ${element.title} vs total ${total}, slope ${slope}` + ); + let barWidth = getComputedStyle(element).getPropertyValue("--bar-width"); + if (slope) { + Assert.greater( + Number.parseFloat(barWidth), + 0, + "The bar width should be > 0 when there is some CPU use" + ); + } else { + Assert.equal(barWidth, "-0.5", "There should be no CPU bar displayed"); + } + + if (element.textContent == "(measuring)") { + info("Still measuring"); + return; + } + + const CPU_TEXT_CONTENT_REGEXP = /\~0%|idle|[0-9.,]+%|[?]/; + let extractedPercentage = CPU_TEXT_CONTENT_REGEXP.exec( + element.textContent + )[0]; + switch (extractedPercentage) { + case "idle": + Assert.equal(slope, 0, "Idle means exactly 0%"); + // Nothing else to do here. + return; + case "~0%": + Assert.ok(slope > 0 && slope < 0.0001); + break; + case "?": + Assert.ok(slope == null); + // Nothing else to do here. + return; + default: { + // `Number.parseFloat("99%")` returns `99`. + let computedPercentage = Number.parseFloat(extractedPercentage); + Assert.ok( + isCloseEnough(computedPercentage, slope * 100), + `The displayed approximation of the slope is reasonable: ${computedPercentage} vs ${ + slope * 100 + }` + ); + // Also, sanity checks. + Assert.ok( + computedPercentage / 100 >= assumptions.minimalCPUPercentage, + `Not too little: ${computedPercentage / 100} >=? ${ + assumptions.minimalCPUPercentage + } ` + ); + Assert.ok( + computedPercentage / 100 <= assumptions.maximalCPUPercentage, + `Not too much: ${computedPercentage / 100} <=? ${ + assumptions.maximalCPUPercentage + } ` + ); + break; + } + } + + const CPU_TOOLTIP_REGEXP = /(?:.*: ([0-9.,]+) ?(ns|µs|ms|s|m|h|d))/; + // Example: "Total CPU time: 4,470ms" + + let [, extractedTotal, extractedUnit] = CPU_TOOLTIP_REGEXP.exec( + element.title + ); + + let totalMS = total / MS_PER_NS; + let computedTotal = + // We produce localized numbers, with "," as a thousands separator in en-US builds, + // but `parseFloat` doesn't understand the ",", so we need to remove it + // before parsing. + Number.parseFloat(extractedTotal.replace(/,/g, "")) * + getTimeMultiplier(extractedUnit); + Assert.ok( + isCloseEnough(computedTotal, totalMS), + `The displayed approximation of the total duration is reasonable: ${computedTotal} vs ${totalMS}` + ); + Assert.ok( + totalMS <= assumptions.maximalCPUTotalDurationMS && + totalMS >= assumptions.minimalCPUTotalDurationMS, + `The total number of MS is reasonable ${totalMS}: [${assumptions.minimalCPUTotalDurationMS}, ${assumptions.maximalCPUTotalDurationMS}]` + ); +} + +async function testMemory(element, total, delta, assumptions) { + info( + `Testing memory display ${element.textContent} - ${element.title} vs total ${total}, delta ${delta}` + ); + const MEMORY_TEXT_CONTENT_REGEXP = /([0-9.,]+)(TB|GB|MB|KB|B)/; + // Example: "383.55MB" + let extracted = MEMORY_TEXT_CONTENT_REGEXP.exec(element.textContent); + Assert.notEqual( + extracted, + null, + `Can we parse ${element.textContent} with ${MEMORY_TEXT_CONTENT_REGEXP}?` + ); + let [, extractedTotal, extractedUnit] = extracted; + + let extractedTotalNumber = Number.parseFloat(extractedTotal); + Assert.ok( + extractedTotalNumber > 0, + `Unitless total memory use is greater than 0: ${extractedTotal}` + ); + if (extractedUnit != "GB") { + Assert.ok( + extractedTotalNumber <= 1024, + `Unitless total memory use is less than 1024: ${extractedTotal}` + ); + } + + // Now check that the conversion was meaningful. + let computedTotal = getMemoryMultiplier(extractedUnit) * extractedTotalNumber; + Assert.ok( + isCloseEnough(computedTotal, total), + `The displayed approximation of the total amount of memory is reasonable: ${computedTotal} vs ${total}` + ); + if (!AppConstants.ASAN) { + // ASAN plays tricks with RAM (e.g. allocates the entirety of virtual memory), + // which makes this test unrealistic. + Assert.ok( + assumptions.minimalRAMBytesUsage <= computedTotal && + computedTotal <= assumptions.maximalRAMBytesUsage, + `The total amount amount of memory is reasonable: ${computedTotal} in [${assumptions.minimalRAMBytesUsage}, ${assumptions.maximalRAMBytesUsage}]` + ); + } + + const MEMORY_TOOLTIP_REGEXP = /(?:.*: ([-+]?)([0-9.,]+)(GB|MB|KB|B))?/; + // Example: "Evolution: -12.5MB" + extracted = MEMORY_TOOLTIP_REGEXP.exec(element.title); + Assert.notEqual( + extracted, + null, + `Can we parse ${element.title} with ${MEMORY_TOOLTIP_REGEXP}?` + ); + let [, extractedDeltaSign, extractedDeltaTotal, extractedDeltaUnit] = + extracted; + if (extractedDeltaSign == null) { + Assert.equal(delta || 0, 0); + return; + } + let deltaTotalNumber = Number.parseFloat( + // Remove the thousands separator that breaks parseFloat. + extractedDeltaTotal.replace(/,/g, "") + ); + // Note: displaying 1024KB can happen if the value is slightly less than + // 1024*1024B but rounded to 1024KB. + Assert.ok( + deltaTotalNumber > 0 && deltaTotalNumber <= 1024, + `Unitless delta memory use is in (0, 1024): ${extractedDeltaTotal}` + ); + Assert.ok( + ["B", "KB", "MB"].includes(extractedDeltaUnit), + `Delta unit is reasonable: ${extractedDeltaUnit}` + ); + + // Now check that the conversion was meaningful. + // Let's just check that the number displayed is within 10% of `delta`. + let computedDelta = + getMemoryMultiplier(extractedDeltaUnit, extractedDeltaSign) * + deltaTotalNumber; + Assert.equal( + computedDelta >= 0, + delta >= 0, + `Delta has the right sign: ${computedDelta} vs ${delta}` + ); +} + +function extractProcessDetails(row) { + let children = row.children; + let name = children[0]; + let memory = children[1]; + let cpu = children[2]; + if (Services.prefs.getBoolPref("toolkit.aboutProcesses.showProfilerIcons")) { + name = name.firstChild; + Assert.ok( + name.nextSibling.classList.contains("profiler-icon"), + "The profiler icon should be shown" + ); + } + let fluentArgs = row.ownerDocument.l10n.getAttributes(name).args; + let threadDetailsRow = row.nextSibling; + while (threadDetailsRow) { + if (threadDetailsRow.classList.contains("process")) { + threadDetailsRow = null; + break; + } + if (threadDetailsRow.classList.contains("thread-summary")) { + break; + } + threadDetailsRow = threadDetailsRow.nextSibling; + } + + return { + memory, + cpu, + pidContent: fluentArgs.pid, + threads: threadDetailsRow, + }; +} + +function findTabRowByName(doc, name) { + for (let row of doc.getElementsByClassName("name")) { + if (!row.parentNode.classList.contains("window")) { + continue; + } + let foundName = document.l10n.getAttributes(row).args.name; + if (foundName != name) { + continue; + } + return row.parentNode; + } + return null; +} + +function findProcessRowByOrigin(doc, origin) { + for (let row of doc.getElementsByClassName("process")) { + if (row.process.origin == origin) { + return row; + } + } + return null; +} + +async function setupTabWithOriginAndTitle(origin, title) { + let tab = BrowserTestUtils.addTab(gBrowser, origin, { skipAnimation: true }); + tab.testTitle = title; + tab.testOrigin = origin; + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await SpecialPowers.spawn(tab.linkedBrowser, [title], async title => { + content.document.title = title; + }); + return tab; +} + +async function setupAudioTab() { + let origin = "about:blank"; + let title = "utility audio"; + let tab = BrowserTestUtils.addTab(gBrowser, origin, { skipAnimation: true }); + tab.testTitle = title; + tab.testOrigin = origin; + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await SpecialPowers.spawn(tab.linkedBrowser, [title], async title => { + content.document.title = title; + const ROOT = + "https://example.com/browser/toolkit/components/aboutprocesses/tests/browser"; + let audio = content.document.createElement("audio"); + audio.setAttribute("controls", "true"); + audio.setAttribute("loop", true); + audio.src = `${ROOT}/small-shot.mp3`; + content.document.body.appendChild(audio); + await audio.play(); + }); + return tab; +} + +async function testAboutProcessesWithConfig({ showAllFrames, showThreads }) { + const isFission = gFissionBrowser; + await SpecialPowers.pushPrefEnv({ + set: [ + ["toolkit.aboutProcesses.showAllSubframes", showAllFrames], + ["toolkit.aboutProcesses.showThreads", showThreads], + // Force same-origin tabs to share a single process, to properly test + // functionality involving multiple tabs within a single process with Fission. + ["dom.ipc.processCount.webIsolated", 1], + // Ensure utility audio decoder is enabled + ["media.utility-process.enabled", true], + ], + }); + + // Install a test extension to also cover processes and sub-frames related to the + // extension process. + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: "test-aboutprocesses@mochi.test" }, + }, + }, + background() { + // Creates an about:blank iframe in the extension process to make sure that + // Bug 1665099 doesn't regress. + document.body.appendChild(document.createElement("iframe")); + + this.browser.test.sendMessage("bg-page-loaded"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg-page-loaded"); + + // Setup tabs asynchronously. + + // The about:processes tab. + info("Setting up about:processes"); + let promiseTabAboutProcesses = BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: "about:processes", + waitForLoad: true, + }); + + info("Setting up example.com"); + // Another tab that we'll pretend is hung. + let promiseTabHung = (async function () { + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com", { + skipAnimation: true, + }); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + let p = BrowserTestUtils.browserLoaded( + tab.linkedBrowser, + true /* includeSubFrames */ + ); + await SpecialPowers.spawn(tab.linkedBrowser, [], async () => { + // Open an in-process iframe to test toolkit.aboutProcesses.showAllSubframes + let frame = content.document.createElement("iframe"); + content.document.body.appendChild(frame); + }); + await p; + return tab; + })(); + + let promiseAudioPlayback = setupAudioTab(); + + let promiseUserContextTab = (async function () { + let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com", { + userContextId: 1, + skipAnimation: true, + }); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.document.title = "Tab with User Context"; + }); + return tab; + })(); + + info("Setting up tabs we intend to close"); + + // The two following tabs share the same domain. + // We use them to check that closing one doesn't close the other. + let promiseTabCloseSeparately1 = setupTabWithOriginAndTitle( + "http://example.org", + "Close me 1 (separately)" + ); + let promiseTabCloseSeparately2 = setupTabWithOriginAndTitle( + "http://example.org", + "Close me 2 (separately)" + ); + + // The two following tabs share the same domain. + // We use them to check that closing the process kills them both. + let promiseTabCloseProcess1 = setupTabWithOriginAndTitle( + "http://example.net", + "Close me 1 (process)" + ); + + let promiseTabCloseProcess2 = setupTabWithOriginAndTitle( + "http://example.net", + "Close me 2 (process)" + ); + + // The two following tabs share the same domain. + // We use them to check that closing the process kills them both. + let promiseTabCloseTogether1 = setupTabWithOriginAndTitle( + "https://example.org", + "Close me 1 (together)" + ); + + let promiseTabCloseTogether2 = setupTabWithOriginAndTitle( + "https://example.org", + "Close me 2 (together)" + ); + + // Wait for initialization to finish. + let tabAboutProcesses = await promiseTabAboutProcesses; + let tabHung = await promiseTabHung; + let audioPlayback = await promiseAudioPlayback; + let tabUserContext = await promiseUserContextTab; + let tabCloseSeparately1 = await promiseTabCloseSeparately1; + let tabCloseSeparately2 = await promiseTabCloseSeparately2; + let tabCloseProcess1 = await promiseTabCloseProcess1; + let tabCloseProcess2 = await promiseTabCloseProcess2; + let tabCloseTogether1 = await promiseTabCloseTogether1; + let tabCloseTogether2 = await promiseTabCloseTogether2; + + let doc = tabAboutProcesses.linkedBrowser.contentDocument; + let tbody = doc.getElementById("process-tbody"); + Assert.ok(!!tbody, "Found the #process-tbody element"); + + if (isFission) { + // We're going to kill this process later, so tell it to add an + // annotation so the leak checker knows it is okay there is no + // leak log. + await SpecialPowers.spawn(tabCloseProcess1.linkedBrowser, [], () => { + ChromeUtils.privateNoteIntentionalCrash(); + }); + } + + info("Setting up fake process hang detector"); + let hungChildID = tabHung.linkedBrowser.frameLoader.childID; + + // Keep informing about:processes that `tabHung` is hung. + // Note: this is a background task, do not `await` it. + let fakeProcessHangMonitor = async function () { + for (let i = 0; i < 100; ++i) { + if (!tabHung.linkedBrowser) { + // Let's stop spamming as soon as we can. + return; + } + + Services.obs.notifyObservers( + { + childID: hungChildID, + scriptBrowser: tabHung.linkedBrowser, + scriptFileName: "chrome://browser/content/browser.js", + QueryInterface: ChromeUtils.generateQI(["nsIHangReport"]), + }, + "process-hang-report" + ); + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 300)); + } + }; + fakeProcessHangMonitor(); + + // about:processes will take a little time to appear and be populated. + await promiseAboutProcessesUpdated({ doc, tabAboutProcesses }); + Assert.ok(tbody.childElementCount, "The table should be populated"); + Assert.ok( + !!tbody.getElementsByClassName("hung").length, + "The hung process should appear" + ); + + info("Looking at the contents of about:processes"); + let processesToBeFound = [ + // The browser process. + { + name: "browser", + predicate: row => row.process.type == "browser", + }, + // The hung process. + { + name: "hung", + predicate: row => + row.classList.contains("hung") && + row.classList.contains("process") && + ["web", "webIsolated"].includes(row.process.type), + }, + // Any non-hung process + { + name: "non-hung", + predicate: row => + !row.classList.contains("hung") && + row.classList.contains("process") && + ["web", "webIsolated"].includes(row.process.type), + }, + // A utility process with at least one actor. + { + name: "utility", + predicate: row => + row.process && + row.process.type == "utility" && + row.classList.contains("process") && + row.nextSibling && + row.nextSibling.classList.contains("actor"), + }, + ]; + for (let finder of processesToBeFound) { + info(`Running sanity tests on ${finder.name}`); + let row = tbody.firstChild; + while (row) { + if (finder.predicate(row)) { + break; + } + row = row.nextSibling; + } + Assert.ok(!!row, `found a table row for ${finder.name}`); + let { memory, cpu, pidContent, threads } = extractProcessDetails(row); + + info("Sanity checks: pid"); + let pid = Number.parseInt(pidContent); + Assert.ok(pid > 0, `Checking pid ${pidContent}`); + Assert.equal(pid, row.process.pid); + + info("Sanity checks: memory resident"); + await testMemory( + memory, + row.process.totalRamSize, + row.process.deltaRamSize, + HARDCODED_ASSUMPTIONS_PROCESS + ); + + info("Sanity checks: CPU (Total)"); + await testCpu( + cpu, + row.process.totalCpu, + row.process.slopeCpu, + HARDCODED_ASSUMPTIONS_PROCESS + ); + + // Testing threads. + if (!showThreads) { + info("In this mode, we shouldn't display any threads"); + Assert.equal( + threads, + null, + "In hidden threads mode, we shouldn't have any thread summary" + ); + } else { + Assert.ok(threads, "We have a thread summary row"); + + let { + number, + active = 0, + list, + } = doc.l10n.getAttributes(threads.children[0].children[1]).args; + + info("Sanity checks: number of threads"); + Assert.greaterOrEqual( + number, + HARDCODED_ASSUMPTIONS_PROCESS.minimalNumberOfThreads + ); + Assert.lessOrEqual( + number, + HARDCODED_ASSUMPTIONS_PROCESS.maximalNumberOfThreads + ); + Assert.equal( + number, + row.process.threads.length, + "The number we display should be the number of threads" + ); + + info("Sanity checks: number of active threads"); + Assert.greaterOrEqual( + active, + 0, + "The number of active threads should never be negative" + ); + Assert.lessOrEqual( + active, + number, + "The number of active threads should not exceed the total number of threads" + ); + let activeThreads = row.process.threads.filter(t => t.active); + Assert.equal( + active, + activeThreads.length, + "The displayed number of active threads should be correct" + ); + + let activeSet = new Set(); + for (let t of activeThreads) { + activeSet.add(t.name.replace(/ ?#[0-9]+$/, "")); + } + info("Sanity checks: thread list"); + Assert.equal( + list ? list.split(", ").length : 0, + activeSet.size, + "The thread summary list of active threads should have the expected length" + ); + + info("Testing that we can open the list of threads"); + let twisty = threads.getElementsByClassName("twisty")[0]; + twisty.click(); + + // Fluent will update the text content of new rows during the + // next refresh driver tick, wait for it. + // requestAnimationFrame calls us at the begining of the tick, we use + // dispatchToMainThread to execute our code after the end of it. + //XXX: Replace with proper wait for l10n completion once bug 1520659 is fixed. + await new Promise(doc.defaultView.requestAnimationFrame); + await new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); + + let numberOfThreadsFound = 0; + for ( + let threadRow = threads.nextSibling; + threadRow && threadRow.classList.contains("thread"); + threadRow = threadRow.nextSibling + ) { + numberOfThreadsFound++; + } + Assert.equal( + numberOfThreadsFound, + number, + `We should see ${number} threads, found ${numberOfThreadsFound}` + ); + let threadIds = []; + for ( + let threadRow = threads.nextSibling; + threadRow && threadRow.classList.contains("thread"); + threadRow = threadRow.nextSibling + ) { + Assert.ok( + threadRow.children.length >= 3 && threadRow.children[1].textContent, + "The thread row should be populated" + ); + let children = threadRow.children; + let cpu = children[1]; + let l10nArgs = doc.l10n.getAttributes(children[0]).args; + + // Sanity checks: name + Assert.ok(threadRow.thread.name, "Thread name is not empty"); + Assert.equal( + l10nArgs.name, + threadRow.thread.name, + "Displayed thread name is correct" + ); + + // Sanity checks: tid + let tidContent = l10nArgs.tid; + let tid = Number.parseInt(tidContent); + threadIds.push(tid); + Assert.notEqual(tid, 0, "The tid should be set"); + Assert.equal(tid, threadRow.thread.tid, "Displayed tid is correct"); + + // Sanity checks: CPU (per thread) + await testCpu( + cpu, + threadRow.thread.totalCpu, + threadRow.thread.slopeCpu, + HARDCODED_ASSUMPTIONS_THREAD + ); + } + // By default, threads are sorted by tid. + let threadList = threadIds.join(","); + Assert.equal( + threadList, + threadIds.sort((a, b) => a - b).join(","), + "The thread rows are in the default sort order." + ); + } + } + + await promiseAboutProcessesUpdated({ + doc, + force: true, + tabAboutProcesses, + }); + + // Testing subframes. + info("Testing subframes"); + let foundAtLeastOneInProcessSubframe = false; + for (let row of doc.getElementsByClassName("window")) { + let subframe = row.win; + if (subframe.tab) { + continue; + } + let url = doc.l10n.getAttributes(row.children[0]).args.url; + Assert.equal(url, subframe.documentURI.spec); + if (!subframe.isProcessRoot) { + foundAtLeastOneInProcessSubframe = true; + } + } + if (showAllFrames) { + Assert.ok( + foundAtLeastOneInProcessSubframe, + "Found at least one about:blank in-process subframe" + ); + } else { + Assert.ok( + !foundAtLeastOneInProcessSubframe, + "We shouldn't have any about:blank in-process subframe" + ); + } + + info("Double-clicking on a tab"); + let whenTabSwitchedToWeb = BrowserTestUtils.switchTab(gBrowser, () => { + // We pass a function to use `BrowserTestUtils.switchTab` not in its + // role as a tab switcher but rather in its role as a function that + // waits until something else has switched the tab. + // We'll actually cause tab switching below, by doucle-clicking + // in `about:processes`. + }); + await SpecialPowers.spawn(tabAboutProcesses.linkedBrowser, [], async () => { + // Locate and double-click on the representation of `tabHung`. + let tbody = content.document.getElementById("process-tbody"); + for (let row of tbody.getElementsByClassName("tab")) { + if (row.parentNode.win.documentURI.spec != "http://example.com/") { + continue; + } + // Simulate double-click. + let evt = new content.window.MouseEvent("dblclick", { + bubbles: true, + cancelable: true, + view: content.window, + }); + row.dispatchEvent(evt); + return; + } + Assert.ok(false, "We should have found the hung tab"); + }); + + info("Waiting for tab switch"); + await whenTabSwitchedToWeb; + Assert.equal( + gBrowser.selectedTab.linkedBrowser.currentURI.spec, + tabHung.linkedBrowser.currentURI.spec, + "We should have focused the hung tab" + ); + + await BrowserTestUtils.switchTab(gBrowser, tabAboutProcesses); + + info("Double-clicking on the extensions process"); + let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, "about:addons"); + await SpecialPowers.spawn(tabAboutProcesses.linkedBrowser, [], async () => { + let extensionsRow = + content.document.getElementsByClassName("extensions")[0]; + Assert.ok(!!extensionsRow, "We should have found the extensions process"); + let evt = new content.window.MouseEvent("dblclick", { + bubbles: true, + cancelable: true, + view: content.window, + }); + extensionsRow.dispatchEvent(evt); + }); + info("Waiting for about:addons to open"); + await tabPromise; + Assert.equal( + gBrowser.selectedTab.linkedBrowser.currentURI.spec, + "about:addons", + "We should now see the addon tab" + ); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + + info("Testing tab closing"); + + // A list of processes we have killed and for which we're waiting + // death confirmation. Only used in Fission. + let waitForProcessesToDisappear = []; + await promiseAboutProcessesUpdated({ + doc, + force: true, + tabAboutProcesses, + }); + if (isFission) { + // Before closing, all our origins should be present + for (let origin of [ + "http://example.com", // tabHung + "http://example.net", // tabCloseProcess* + "http://example.org", // tabCloseSeparately* + "https://example.org", // tabCloseTogether* + ]) { + Assert.ok( + findProcessRowByOrigin(doc, origin), + `There is a process for origin ${origin}` + ); + } + + // Verify that the user context id has been correctly displayed. + let userContextProcessRow = findProcessRowByOrigin( + doc, + "http://example.com^userContextId=1" + ); + Assert.ok( + userContextProcessRow, + "There is a separate process for the tab with a different user context" + ); + let name = userContextProcessRow.firstChild; + if ( + Services.prefs.getBoolPref("toolkit.aboutProcesses.showProfilerIcons") + ) { + name = name.firstChild; + Assert.ok( + name.nextSibling.classList.contains("profiler-icon"), + "The profiler icon should be shown" + ); + } + Assert.equal( + doc.l10n.getAttributes(name).args.origin, + "http://example.com — " + + ContextualIdentityService.getUserContextLabel(1), + "The user context ID should be replaced with the localized container name" + ); + + // These origins will disappear. + for (let origin of [ + "http://example.net", // tabCloseProcess* + "https://example.org", // tabCloseTogether* + ]) { + let row = findProcessRowByOrigin(doc, origin); + let childID = row.process.childID; + waitForProcessesToDisappear.push(promiseProcessDied({ childID })); + } + } + + // Close a few tabs. + for (let tab of [tabCloseSeparately1, tabCloseTogether1, tabCloseTogether2]) { + info("Closing a tab through about:processes"); + let found = findTabRowByName(doc, tab.linkedBrowser.contentTitle); + Assert.ok( + found, + `We should have found tab ${tab.linkedBrowser.contentTitle} to close it` + ); + let closeIcons = found.getElementsByClassName("close-icon"); + Assert.equal( + closeIcons.length, + 1, + "This tab should have exactly one close icon" + ); + closeIcons[0].click(); + Assert.ok( + found.classList.contains("killing"), + "We should have marked the row as dying" + ); + } + + //...and a process, if we're in Fission. + if (isFission) { + info("Closing an entire process through about:processes"); + let found = findProcessRowByOrigin(doc, "http://example.net"); + let closeIcons = found.getElementsByClassName("close-icon"); + Assert.equal( + closeIcons.length, + 1, + "This process should have exactly one close icon" + ); + closeIcons[0].click(); + Assert.ok( + found.classList.contains("killing"), + "We should have marked the row as dying" + ); + } + + // Give Firefox a little time to close the tabs and update about:processes. + // This might take two updates as we're racing between collecting data and + // processes actually being killed. + await promiseAboutProcessesUpdated({ + doc, + force: true, + tabAboutProcesses, + }); + + // The tabs we have closed directly or indirectly should now be (closed or crashed) and invisible in about:processes. + for (let { origin, tab } of [ + { origin: "http://example.org", tab: tabCloseSeparately1 }, + { origin: "https://example.org", tab: tabCloseTogether1 }, + { origin: "https://example.org", tab: tabCloseTogether2 }, + ...(isFission + ? [ + { origin: "http://example.net", tab: tabCloseProcess1 }, + { origin: "http://example.net", tab: tabCloseProcess2 }, + ] + : []), + ]) { + // Tab shouldn't show up anymore in about:processes + Assert.ok( + !findTabRowByName(doc, origin), + `Tab for ${origin} shouldn't show up anymore in about:processes` + ); + // ...and should be unloaded. + Assert.ok( + !tab.getAttribute("linkedPanel"), + `The tab should now be unloaded (${tab.testOrigin} - ${tab.testTitle})` + ); + } + + // On the other hand, tabs we haven't closed should still be open and visible in about:processes. + Assert.ok( + tabCloseSeparately2.linkedBrowser, + "Killing one tab in the domain should not have closed the other tab" + ); + let foundtabCloseSeparately2 = findTabRowByName( + doc, + tabCloseSeparately2.linkedBrowser.contentTitle + ); + Assert.ok( + foundtabCloseSeparately2, + "The second tab is still visible in about:processes" + ); + + if (isFission) { + // After closing, we must have closed some of our origins. + for (let origin of [ + "http://example.com", // tabHung + "http://example.org", // tabCloseSeparately* + ]) { + Assert.ok( + findProcessRowByOrigin(doc, origin), + `There should still be a process row for origin ${origin}` + ); + } + + info("Waiting for processes to die"); + await Promise.all(waitForProcessesToDisappear); + + info("Waiting for about:processes to be updated"); + await promiseAboutProcessesUpdated({ + doc, + force: true, + tabAboutProcesses, + }); + + for (let origin of [ + "http://example.net", // tabCloseProcess* + "https://example.org", // tabCloseTogether* + ]) { + Assert.ok( + !findProcessRowByOrigin(doc, origin), + `Process ${origin} should disappear from about:processes` + ); + } + } + + info("Additional sanity check for all processes"); + for (let row of doc.getElementsByClassName("process")) { + let { pidContent } = extractProcessDetails(row); + Assert.equal(Number.parseInt(pidContent), row.process.pid); + } + BrowserTestUtils.removeTab(tabAboutProcesses); + BrowserTestUtils.removeTab(tabHung); + BrowserTestUtils.removeTab(tabUserContext); + BrowserTestUtils.removeTab(tabCloseSeparately2); + + // We still need to remove these tabs. + // We killed the process, but we don't want to leave zombie tabs lying around. + BrowserTestUtils.removeTab(tabCloseProcess1); + BrowserTestUtils.removeTab(tabCloseProcess2); + BrowserTestUtils.removeTab(audioPlayback); + + await SpecialPowers.popPrefEnv(); + + await extension.unload(); +} diff --git a/toolkit/components/aboutprocesses/tests/browser/small-shot.mp3 b/toolkit/components/aboutprocesses/tests/browser/small-shot.mp3 Binary files differnew file mode 100644 index 0000000000..f9397a5106 --- /dev/null +++ b/toolkit/components/aboutprocesses/tests/browser/small-shot.mp3 |