diff options
Diffstat (limited to 'toolkit/components/aboutprocesses')
11 files changed, 2666 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..4cc1083857 --- /dev/null +++ b/toolkit/components/aboutprocesses/content/aboutProcesses.css @@ -0,0 +1,237 @@ +/* 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%; + min-width: 40em; +} + +/* 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); + min-width: 40em; + background-color: var(--in-content-box-background); +} +tr { + display: table; + table-layout: fixed; + width: 100%; +} + +/* At least one column needs to have a flexible width, + so no width specified for td:nth-child(1) aka column-name*/ + +/* column-memory-resident */ +td:nth-child(2) { + width: 15%; +} +#process-tbody td:nth-child(2) { + text-align: end; +} + +/* column-cpu-total */ +td:nth-child(3) { + width: 15%; +} +#process-tbody td:nth-child(3) { + text-align: end; +} + +/* column-action-icon */ +td:nth-child(4) { + width: 16px; + text-align: center; +} + +#process-thead > tr { + height: inherit; +} + +#process-thead > tr > td { + border: none; + background-color: var(--in-content-button-background); +} +#process-thead > tr > td: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; + border-bottom: 1px solid var(--in-content-border-color); +} +td { + padding: 5px 10px; + min-height: 16px; + max-height: 16px; + color: var(--in-content-text-color); + max-width: 70vw; + 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; +} +#process-tbody > tr > td:first-child { + text-overflow: ellipsis; +} +.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/twisty-collapsed.svg"); + position: absolute; + display: block; + line-height: 50%; + top: 4px; /* Half the image's height */ + inset-inline-start: -16px; + width: 100%; + -moz-context-properties: fill; + fill: currentColor; +} +.twisty:dir(rtl)::before { + content: url("chrome://global/skin/icons/twisty-collapsed-rtl.svg"); +} +.twisty.open::before { + content: url("chrome://global/skin/icons/twisty-expanded.svg"); +} +#process-tbody > tr > td.indent { + padding-inline: 48px 0; +} +#process-tbody > tr > td.double_indent { + padding-inline: 58px 0; +} + +#process-tbody > tr[selected] > td { + background-color: var(--in-content-item-selected); + color: var(--in-content-selected-text); +} +#process-tbody > tr:hover { + background-color: var(--in-content-item-hover); +} + +.clickable { + background-repeat: no-repeat; + background-position: right 4px center; +} +.clickable:dir(rtl) { + background-position-x: left 4px; +} +.asc, +.desc { + -moz-context-properties: fill; + fill: currentColor; +} +/* + Linux has conventions opposite to Windows, macOS on the direction of arrows + when sorting. +*/ +%ifdef XP_LINUX +.asc { + background-image: url(chrome://global/skin/icons/arrow-up-12.svg); +} +.desc { + background-image: url(chrome://global/skin/icons/arrow-dropdown-12.svg); +} +%else +.asc { + background-image: url(chrome://global/skin/icons/arrow-dropdown-12.svg); +} +.desc { + background-image: url(chrome://global/skin/icons/arrow-up-12.svg); +} +%endif + +#process-thead > tr > td.clickable:hover { + background-color: var(--in-content-button-background-hover); +} +#process-thead > tr > td.clickable:hover:active { + background-color: var(--in-content-button-background-active); +} + +#process-tbody > tr.process > td.type { + font-weight: bold; +} +#process-tbody > 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 */ + fill-opacity: 0; /* Make SVG background transparent */ + -moz-context-properties: fill, fill-opacity; + fill: currentColor; +} + +tr:is([selected], :hover):not(.killing) > .close-icon { + opacity: 1; +} + +.close-icon:hover { + background-color: var(--in-content-button-background-hover); +} + +.close-icon:hover:active { + background-color: var(--in-content-button-background-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. + */ + +#process-tbody > tr.separate-from-previous-process-group { + border-top: dotted 1px var(--in-content-box-border-color); + margin-top: -1px; +} diff --git a/toolkit/components/aboutprocesses/content/aboutProcesses.html b/toolkit/components/aboutprocesses/content/aboutProcesses.html new file mode 100644 index 0000000000..a95fa83393 --- /dev/null +++ b/toolkit/components/aboutprocesses/content/aboutProcesses.html @@ -0,0 +1,29 @@ +<!-- 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'"> + <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> + <td class="clickable" id="column-name" data-l10n-id="about-processes-column-name"></td> + <td class="clickable" id="column-memory-resident" data-l10n-id="about-processes-column-memory-resident"></td> <!-- Memory usage. --> + <td class="clickable" id="column-cpu-total" data-l10n-id="about-processes-column-cpu-total"></td><!--CPU (User and Kernel)--> + <td id="column-kill" data-l10n-id="about-processes-column-action">⚙</td><!-- 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..709e76db2b --- /dev/null +++ b/toolkit/components/aboutprocesses/content/aboutProcesses.js @@ -0,0 +1,1434 @@ +/* -*- 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 often we should add a sample to our buffer. +const BUFFER_SAMPLING_RATE_MS = 1000; + +// The age of the oldest sample to keep. +const BUFFER_DURATION_MS = 10000; + +// 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 { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { WebExtensionPolicy } = Cu.getGlobalForObject(Services); + +const SHOW_THREADS = Services.prefs.getBoolPref( + "toolkit.aboutProcesses.showThreads" +); +const SHOW_ALL_SUBFRAMES = Services.prefs.getBoolPref( + "toolkit.aboutProcesses.showAllSubframes" +); + +/** + * Returns a Promise that's resolved after the next turn of the event loop. + * + * Just returning a resolved Promise would mean that any `then` callbacks + * would be called right after the end of the current turn, so `setTimeout` + * is used to delay Promise resolution until the next turn. + * + * In mochi tests, it's possible for this to be called after the + * about:performance window has been torn down, which causes `setTimeout` to + * throw an NS_ERROR_NOT_INITIALIZED exception. In that case, returning + * `undefined` is fine. + */ +function wait(ms = 0) { + try { + let resolve; + let p = new Promise(resolve_ => { + resolve = resolve_; + }); + setTimeout(resolve, ms); + return p; + } catch (e) { + dump( + "WARNING: wait aborted because of an invalid Window state in aboutPerformance.js.\n" + ); + return undefined; + } +} + +/** + * 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. + * + * @type Promise<{ + * 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 gPromisePrefetchedUnits; + +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 = { + /** + * Indexed by the number of minutes since the snapshot was taken. + * + * @type {Array<ApplicationSnapshot>} + */ + _buffer: [], + /** + * The latest snapshot. + * + * @type ApplicationSnapshot + */ + _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 the buffer is empty, add one value for bootstraping purposes. + if (!this._buffer.length) { + this._latest = await this._promiseSnapshot(); + this._buffer.push(this._latest); + await wait(BUFFER_SAMPLING_RATE_MS * 1.1); + } + + let now = Cu.now(); + + // If we haven't sampled in a while, add a sample to the buffer. + let latestInBuffer = this._buffer[this._buffer.length - 1]; + let deltaT = now - latestInBuffer.date; + if (force || deltaT > BUFFER_SAMPLING_RATE_MS) { + this._latest = await this._promiseSnapshot(); + this._buffer.push(this._latest); + } + + // If we have too many samples, remove the oldest sample. + let oldestInBuffer = this._buffer[0]; + if (oldestInBuffer.date + BUFFER_DURATION_MS < this._latest.date) { + this._buffer.shift(); + } + }, + + _getThreadDelta(cur, prev, deltaT) { + let name = cur.name || "???"; + let result = { + tid: cur.tid, + name, + // Total amount of CPU used, in ns (user). + totalCpuUser: cur.cpuUser, + slopeCpuUser: null, + // Total amount of CPU used, in ns (kernel). + totalCpuKernel: cur.cpuKernel, + slopeCpuKernel: null, + // Total amount of CPU used, in ns (user + kernel). + totalCpu: cur.cpuUser + cur.cpuKernel, + slopeCpu: null, + }; + if (!prev) { + return result; + } + if (prev.tid != cur.tid) { + throw new Error("Assertion failed: A thread cannot change tid."); + } + result.slopeCpuUser = (cur.cpuUser - prev.cpuUser) / deltaT; + result.slopeCpuKernel = (cur.cpuKernel - prev.cpuKernel) / deltaT; + result.slopeCpu = result.slopeCpuKernel + result.slopeCpuUser; + 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); + // Resident set size is the total memory used by the process, including shared memory. + // Resident unique size is the memory used by the process, without shared memory. + // Since all processes share memory with the parent process, we count the shared memory + // as part of the parent process (`"browser"`) rather than as part of the individual + // processes. + let totalRamSize = + cur.type == "browser" ? cur.residentSetSize : cur.residentUniqueSize; + let result = { + pid: cur.pid, + childID: cur.childID, + filename: cur.filename, + totalRamSize, + deltaRamSize: null, + totalCpuUser: cur.cpuUser, + slopeCpuUser: null, + totalCpuKernel: cur.cpuKernel, + slopeCpuKernel: null, + totalCpu: cur.cpuUser + cur.cpuKernel, + slopeCpu: null, + type: cur.type, + origin: cur.origin || "", + threads: null, + displayRank: Control._getDisplayGroupRank(cur, windows), + windows, + // 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, null, null) + ); + } + 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 => { + let prevThread = prevThreads.get(curThread.tid); + if (!prevThread) { + return this._getThreadDelta(curThread); + } + return this._getThreadDelta(curThread, prevThread, deltaT); + }); + } + result.deltaRamSize = + cur.type == "browser" + ? cur.residentSetSize - prev.residentSetSize + : cur.residentUniqueSize - prev.residentUniqueSize; + result.slopeCpuUser = (cur.cpuUser - prev.cpuUser) / deltaT; + result.slopeCpuKernel = (cur.cpuKernel - prev.cpuKernel) / deltaT; + result.slopeCpu = result.slopeCpuUser + result.slopeCpuKernel; + result.threads = threads; + return result; + }, + + getCounters() { + tabFinder.update(); + + // We rebuild the maps during each iteration to make sure that + // we do not maintain references to processes that have been + // shutdown. + + let current = this._latest; + let counters = []; + + for (let cur of current.processes.values()) { + // Look for the oldest point of comparison + let oldest = null; + let delta; + for (let index = 0; index <= this._buffer.length - 2; ++index) { + oldest = this._buffer[index].processes.get(cur.pid); + if (oldest) { + // Found it! + break; + } + } + if (oldest) { + // Existing process. Let's display slopes info. + delta = this._getProcessDelta(cur, oldest); + } else { + // New process. Let's display basic info. + delta = this._getProcessDelta(cur, null); + } + counters.push(delta); + } + + return counters; + }, +}; + +var View = { + _fragment: document.createDocumentFragment(), + // Processes, tabs and subframes that we killed during the previous iteration. + // Array<{pid:Number} | {windowId:Number}> + _killedRecently: [], + async commit() { + this._killedRecently.length = 0; + let tbody = document.getElementById("process-tbody"); + + // Force translation to happen before we insert the new content in the DOM + // to avoid flicker when resizing. + await document.l10n.translateFragment(this._fragment); + + while (tbody.firstChild) { + tbody.firstChild.remove(); + } + tbody.appendChild(this._fragment); + this._fragment = document.createDocumentFragment(); + }, + insertAfterRow(row) { + row.parentNode.insertBefore(this._fragment, row.nextSibling); + this._fragment = document.createDocumentFragment(); + }, + + /** + * Append a row showing a single process (without its threads). + * + * @param {ProcessDelta} data The data to display. + * @return {DOMElement} The row displaying the process. + */ + appendProcessRow(data, units) { + let row = document.createElement("tr"); + row.classList.add("process"); + + if (data.isHung) { + row.classList.add("hung"); + } + + // Column: Name + { + let fluentName; + let classNames = []; + switch (data.type) { + case "web": + fluentName = "about-processes-web-process-name"; + break; + case "webIsolated": + fluentName = "about-processes-web-isolated-process-name"; + break; + case "webLargeAllocation": + fluentName = "about-processes-web-large-allocation-process-name"; + break; + case "file": + fluentName = "about-processes-file-process-name"; + break; + case "extension": + fluentName = "about-processes-extension-process-name"; + classNames = ["extensions"]; + break; + case "privilegedabout": + fluentName = "about-processes-privilegedabout-process-name"; + break; + case "withCoopCoep": + fluentName = "about-processes-with-coop-coep-process-name"; + break; + case "browser": + fluentName = "about-processes-browser-process-name"; + break; + case "plugin": + fluentName = "about-processes-plugin-process-name"; + break; + case "gmpPlugin": + fluentName = "about-processes-gmp-plugin-process-name"; + break; + case "gpu": + fluentName = "about-processes-gpu-process-name"; + break; + case "vr": + fluentName = "about-processes-vr-process-name"; + break; + case "rdd": + fluentName = "about-processes-rdd-process-name"; + break; + case "socket": + fluentName = "about-processes-socket-process-name"; + break; + case "remoteSandboxBroker": + fluentName = "about-processes-remote-sandbox-broker-process-name"; + break; + case "forkServer": + fluentName = "about-processes-fork-server-process-name"; + break; + case "preallocated": + fluentName = "about-processes-preallocated-process-name"; + 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-name"; + break; + } + let elt = this._addCell(row, { + fluentName, + fluentArgs: { + pid: "" + data.pid, // Make sure that this number is not localized + origin: data.origin, + type: data.type, + }, + classes: ["type", "favicon", ...classNames], + }); + + 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://browser/skin/link.svg"; + } + } + elt.style.backgroundImage = `url('${image}')`; + } + + // Column: Resident size + { + let formattedTotal = this._formatMemory(data.totalRamSize); + if (data.deltaRamSize) { + let formattedDelta = this._formatMemory(data.deltaRamSize); + this._addCell(row, { + fluentName: "about-processes-total-memory-size", + fluentArgs: { + total: formattedTotal.amount, + totalUnit: units.memory[formattedTotal.unit], + delta: Math.abs(formattedDelta.amount), + deltaUnit: units.memory[formattedDelta.unit], + deltaSign: data.deltaRamSize > 0 ? "+" : "-", + }, + classes: ["totalMemorySize"], + }); + } else { + this._addCell(row, { + fluentName: "about-processes-total-memory-size-no-change", + fluentArgs: { + total: formattedTotal.amount, + totalUnit: units.memory[formattedTotal.unit], + }, + classes: ["totalMemorySize"], + }); + } + } + + // Column: CPU: User and Kernel + if (data.slopeCpu == null) { + this._addCell(row, { + fluentName: "about-processes-cpu-user-and-kernel-not-ready", + classes: ["cpu"], + }); + } else { + let { duration, unit } = this._getDuration(data.totalCpu); + let localizedUnit = units.duration[unit]; + if (data.slopeCpu == 0) { + this._addCell(row, { + fluentName: "about-processes-cpu-user-and-kernel-idle", + fluentArgs: { + total: duration, + unit: localizedUnit, + }, + classes: ["cpu"], + }); + } else { + this._addCell(row, { + fluentName: "about-processes-cpu-user-and-kernel", + fluentArgs: { + percent: data.slopeCpu, + total: duration, + unit: localizedUnit, + }, + classes: ["cpu"], + }); + } + } + + // Column: Kill button – but not for all processes. + let killButton = this._addCell(row, { + content: "", + classes: ["action-icon"], + }); + + if (["web", "webIsolated", "webLargeAllocation"].includes(data.type)) { + // 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" + ); + } + } + + this._fragment.appendChild(row); + return row; + }, + + appendThreadSummaryRow(data, isOpen) { + let row = document.createElement("tr"); + row.classList.add("thread-summary"); + + // Column: Name + let elt = this._addCell(row, { + fluentName: "about-processes-thread-summary", + fluentArgs: { number: data.threads.length }, + classes: ["name", "indent"], + }); + if (data.threads.length) { + let img = document.createElement("span"); + img.classList.add("twisty"); + if (data.isOpen) { + img.classList.add("open"); + } + elt.insertBefore(img, elt.firstChild); + } + + // Column: Resident size + this._addCell(row, { + content: "", + classes: ["totalRamSize"], + }); + + // Column: CPU: User and Kernel + this._addCell(row, { + content: "", + classes: ["cpu"], + }); + + // Column: action + this._addCell(row, { + content: "", + classes: ["action-icon"], + }); + + this._fragment.appendChild(row); + return row; + }, + + appendDOMWindowRow(data, parent) { + let row = document.createElement("tr"); + row.classList.add("window"); + + // Column: filename + let tab = tabFinder.get(data.outerWindowId); + let fluentName; + let name; + let className; + if (parent.type == "extension") { + fluentName = "about-processes-extension-name"; + if (data.addon) { + name = data.addon.name; + } else if (data.documentURI.scheme == "about") { + // about: URLs don't have an host. + name = data.documentURI.spec; + } else { + name = data.documentURI.host; + } + } else if (tab && tab.tabbrowser) { + fluentName = "about-processes-tab-name"; + name = data.documentTitle; + className = "tab"; + } else if (tab) { + fluentName = "about-processes-preloaded-tab"; + name = null; + className = "preloaded-tab"; + } else if (data.count == 1) { + fluentName = "about-processes-frame-name-one"; + name = data.prePath; + className = "frame-one"; + } else { + fluentName = "about-processes-frame-name-many"; + name = data.prePath; + className = "frame-many"; + } + let elt = this._addCell(row, { + fluentName, + fluentArgs: { + name, + url: data.documentURI.spec, + number: data.count, + shortUrl: + data.documentURI.scheme == "about" + ? data.documentURI.spec + : data.documentURI.prePath, + }, + classes: ["name", "indent", "favicon", className], + }); + let image = tab?.tab.getAttribute("image"); + if (image) { + elt.style.backgroundImage = `url('${image}')`; + } + + // Column: Resident size (empty) + this._addCell(row, { + content: "", + classes: ["totalRamSize"], + }); + + // Column: CPU (empty) + this._addCell(row, { + content: "", + classes: ["cpu"], + }); + + // Column: action + let killButton = this._addCell(row, { + content: "", + classes: ["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"); + } + } + this._fragment.appendChild(row); + return row; + }, + + /** + * Append a row showing a single thread. + * + * @param {ThreadDelta} data The data to display. + * @return {DOMElement} The row displaying the thread. + */ + appendThreadRow(data, units) { + let row = document.createElement("tr"); + row.classList.add("thread"); + + // Column: filename + this._addCell(row, { + fluentName: "about-processes-thread-name", + fluentArgs: { + name: data.name, + tid: "" + data.tid /* Make sure that this number is not localized */, + }, + classes: ["name", "double_indent"], + }); + + // Column: Resident size (empty) + this._addCell(row, { + content: "", + classes: ["totalRamSize"], + }); + + // Column: CPU: User and Kernel + if (data.slopeCpu == null) { + this._addCell(row, { + fluentName: "about-processes-cpu-user-and-kernel-not-ready", + classes: ["cpu"], + }); + } else { + let { duration, unit } = this._getDuration(data.totalCpu); + let localizedUnit = units.duration[unit]; + if (data.slopeCpu == 0) { + this._addCell(row, { + fluentName: "about-processes-cpu-user-and-kernel-idle", + fluentArgs: { + total: duration, + unit: localizedUnit, + }, + classes: ["cpu"], + }); + } else { + this._addCell(row, { + fluentName: "about-processes-cpu-user-and-kernel", + fluentArgs: { + percent: data.slopeCpu, + total: duration, + unit: localizedUnit, + }, + classes: ["cpu"], + }); + } + } + + // Column: Buttons (empty) + this._addCell(row, { + content: "", + classes: [], + }); + + this._fragment.appendChild(row); + return row; + }, + + _addCell(row, { content, classes, fluentName, fluentArgs }) { + let elt = document.createElement("td"); + if (fluentName) { + let span = document.createElement("span"); + document.l10n.setAttributes(span, fluentName, fluentArgs); + elt.appendChild(span); + } else { + elt.textContent = content; + elt.setAttribute("title", content); + } + elt.classList.add(...classes); + row.appendChild(elt); + return elt; + }, + + _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 = { + _openItems: new Set(), + // 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")) { + sibling.remove(); + } + sibling = next; + } + }, + init() { + this._initHangReports(); + + // Start prefetching units. + gPromisePrefetchedUnits = (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; + // - change selection. + tbody.addEventListener("click", event => { + this._updateLastMouseEvent(); + + // Handle showing or hiding subitems of a row. + let target = event.target; + if (target.classList.contains("twisty")) { + this._handleTwisty(target); + return; + } + if (target.classList.contains("close-icon")) { + this._handleKill(target); + return; + } + + // Handle selection changes + let row = target.parentNode; + if (this.selectedRow) { + this.selectedRow.removeAttribute("selected"); + } + if (row.windowId) { + row.setAttribute("selected", "true"); + this.selectedRow = row; + } else if (this.selectedRow) { + this.selectedRow = null; + } + }); + + // 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; + } + + if (this._sortColumn) { + const td = document.getElementById(this._sortColumn); + td.classList.remove("asc"); + td.classList.remove("desc"); + } + + const columnId = event.target.id; + if (columnId == this._sortColumn) { + // Reverse sorting order. + this._sortAscendent = !this._sortAscendent; + } else { + this._sortColumn = columnId; + this._sortAscendent = true; + } + + if (this._sortAscendent) { + event.target.classList.remove("desc"); + event.target.classList.add("asc"); + } else { + event.target.classList.remove("asc"); + event.target.classList.add("desc"); + } + + 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 wait(0); + + await this._updateDisplay(force); + }, + + // The force parameter can force a full update even when the mouse has been + // moved recently. + async _updateDisplay(force = false) { + if ( + !force && + Date.now() - this._lastMouseEvent < TIME_BEFORE_SORTING_AGAIN + ) { + return; + } + + let counters = State.getCounters(); + let units = await gPromisePrefetchedUnits; + + // Reset the selectedRow field and the _openItems set each time we redraw + // to avoid keeping forever references to dead processes. + let openItems = this._openItems; + this._openItems = new Set(); + + // Similarly, 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); + let previousProcess = null; + for (let process of counters) { + this._sortDOMWindows(process.windows); + + let isOpen = openItems.has(process.pid); + process.isOpen = isOpen; + + let isHung = process.childID && hungItems.has(process.childID); + process.isHung = isHung; + + let processRow = View.appendProcessRow(process, units); + processRow.process = process; + + if (process.type != "extension") { + // We do not want to display extensions. + let winRow; + for (let win of process.windows) { + if (SHOW_ALL_SUBFRAMES || win.tab || win.isProcessRoot) { + winRow = View.appendDOMWindowRow(win, process); + winRow.win = win; + } + } + } + + if (SHOW_THREADS) { + let threadSummaryRow = View.appendThreadSummaryRow(process, isOpen); + threadSummaryRow.process = process; + + if (isOpen) { + this._openItems.add(process.pid); + this._showThreads(processRow, units); + } + } + 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; + } + + await View.commit(); + }, + _showThreads(row, units) { + let process = row.process; + this._sortThreads(process.threads); + let elt = row; + for (let thread of process.threads) { + // Enrich `elt` with a property `thread`, used for testing. + elt = View.appendThreadRow(thread, units); + elt.thread = thread; + } + return elt; + }, + _sortThreads(threads) { + return threads.sort((a, b) => { + let order; + switch (this._sortColumn) { + case "column-name": + order = a.name.localeCompare(b.name) || a.pid - b.pid; + break; + case "column-cpu-total": + order = b.slopeCpu - a.slopeCpu; + break; + + case "column-memory-resident": + case "column-pid": + case null: + order = b.tid - a.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-pid": + order = b.pid - a.pid; + break; + 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 = b.slopeCpu - a.slopeCpu; + 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 "webLargeAllocation": + 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; + } + }, + + // Open/close list of threads. + async _handleTwisty(target) { + // We await immediately, to ensure that all DOM changes are made in the same tick. + // Otherwise, it's both wasteful and harder to test. + let units = await gPromisePrefetchedUnits; + let row = target.parentNode.parentNode; + let id = row.process.pid; + if (target.classList.toggle("open")) { + this._openItems.add(id); + this._showThreads(row, units); + View.insertAfterRow(row); + } else { + this._openItems.delete(id); + 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"); + } + } + } + }, +}; + +window.onload = async function() { + Control.init(); + await Control.update(); + 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..58d1b5693e --- /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..d66c196532 --- /dev/null +++ b/toolkit/components/aboutprocesses/tests/browser/browser.ini @@ -0,0 +1,8 @@ +[default] +head = head.js +skip-if = asan || tsan # With sanitizers, we regularly hit internal timeouts. + +[browser_aboutprocesses_default_options.js] +[browser_aboutprocesses_show_all_frames.js] +[browser_aboutprocesses_show_threads.js] +[browser_aboutprocesses_show_frames_without_threads.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_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/head.js b/toolkit/components/aboutprocesses/tests/browser/head.js new file mode 100644 index 0000000000..dc677b4798 --- /dev/null +++ b/toolkit/components/aboutprocesses/tests/browser/head.js @@ -0,0 +1,912 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); + +// 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: 10, + 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; +const MEMORY_REGEXP = /([0-9.,]+)(TB|GB|MB|KB|B)( \(([-+]?)([0-9.,]+)(GB|MB|KB|B)\))?/; +//Example: "383.55MB (-12.5MB)" +const CPU_REGEXP = /(\~0%|idle|[0-9.,]+%|[?]) \(([0-9.,]+) ?(ns|µs|ms|s|m|h|d)\)/; +//Example: "13% (4,470ms)" + +// Wait for `about:processes` to be updated. +function promiseAboutProcessesUpdated({ + doc, + tbody, + force, + tabAboutProcesses, +}) { + let result = new Promise(resolve => { + let observer = new doc.ownerGlobal.MutationObserver(() => { + info("Observed about:processes refresh"); + observer.disconnect(); + resolve(); + }); + observer.observe(tbody, { childList: true }); + }); + if (force) { + SpecialPowers.spawn(tabAboutProcesses.linkedBrowser, [], async () => { + info("Forcing about:processes refresh"); + content.Control.update(/* force = */ true); + }); + } + return result; +} + +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); +} +function testCpu(string, total, slope, assumptions) { + info(`Testing CPU display ${string} vs total ${total}, slope ${slope}`); + if (string == "(measuring)") { + info("Still measuring"); + return; + } + let [, extractedPercentage, extractedTotal, extractedUnit] = CPU_REGEXP.exec( + string + ); + + 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; + } + } + + 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}]` + ); +} + +function testMemory(string, total, delta, assumptions) { + Assert.ok( + true, + `Testing memory display ${string} vs total ${total}, delta ${delta}` + ); + let extracted = MEMORY_REGEXP.exec(string); + Assert.notEqual( + extracted, + null, + `Can we parse ${string} with ${MEMORY_REGEXP}?` + ); + let [ + , + extractedTotal, + extractedUnit, + , + extractedDeltaSign, + extractedDeltaTotal, + extractedDeltaUnit, + ] = 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}]` + ); + } + + if (extractedDeltaSign == null) { + Assert.equal(delta || 0, 0); + return; + } + let deltaTotalNumber = Number.parseFloat( + // Remove the thousands separator that breaks parseFloat. + extractedDeltaTotal.replace(/,/g, "") + ); + 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 memoryResidentContent = children[1].textContent; + let cpuContent = children[2].textContent; + let fluentArgs = document.l10n.getAttributes(children[0].children[0]).args; + let process = { + memoryResidentContent, + cpuContent, + pidContent: fluentArgs.pid, + typeContent: fluentArgs.type, + threads: null, + }; + let threadDetailsRow = row.nextSibling; + while (threadDetailsRow) { + if (threadDetailsRow.classList.contains("thread-summary")) { + break; + } + threadDetailsRow = threadDetailsRow.nextSibling; + } + if (!threadDetailsRow) { + return process; + } + process.threads = { + row: threadDetailsRow, + numberContent: document.l10n.getAttributes( + threadDetailsRow.children[0].children[1] + ).args.number, + }; + return process; +} + +function findTabRowByName(doc, name) { + for (let row of doc.getElementsByClassName("name")) { + if (!row.parentNode.classList.contains("window")) { + continue; + } + let foundName = document.l10n.getAttributes(row.children[0]).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 testAboutProcessesWithConfig({ showAllFrames, showThreads }) { + const isFission = Services.prefs.getBoolPref("fission.autostart"); + Services.prefs.setBoolPref( + "toolkit.aboutProcesses.showAllSubframes", + showAllFrames + ); + Services.prefs.setBoolPref("toolkit.aboutProcesses.showThreads", showThreads); + + // Install a test extension to also cover processes and sub-frames related to the + // extension process. + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { 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); + 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); + }); + 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 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(doc); + Assert.ok(tbody); + + 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; + } + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 300)); + Services.obs.notifyObservers( + { + childID: hungChildID, + hangType: Ci.nsIHangReport.PLUGIN_HANG, + pluginName: "Fake plug-in", + QueryInterface: ChromeUtils.generateQI(["nsIHangReport"]), + }, + "process-hang-report" + ); + } + }; + fakeProcessHangMonitor(); + + // Give about:processes a little time to appear and be populated. + await TestUtils.waitForCondition( + () => tbody.childElementCount, + "The table should be populated", + /* interval = */ 300 + ); + await TestUtils.waitForCondition( + () => !!tbody.getElementsByClassName("hung").length, + "The hung process should appear", + /* interval = */ 300 + ); + + info("Looking at the contents of about:processes"); + let processesToBeFound = [ + // The browser process. + { + name: "browser", + type: ["browser"], + predicate: row => row.process.type == "browser", + }, + // The hung process. + { + name: "hung", + type: ["web", "webIsolated"], + predicate: row => + row.classList.contains("hung") && row.classList.contains("process"), + }, + // Any non-hung process + { + name: "non-hung", + type: ["web", "webIsolated"], + predicate: row => + !row.classList.contains("hung") && + row.classList.contains("process") && + row.process.type == "web", + }, + ]; + 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 { + memoryResidentContent, + cpuContent, + pidContent, + typeContent, + threads, + } = extractProcessDetails(row); + + info("Sanity checks: type"); + Assert.ok( + finder.type.includes(typeContent), + `Type ${typeContent} should be one of ${finder.type}` + ); + + 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"); + testMemory( + memoryResidentContent, + row.process.totalRamSize, + row.process.deltaRamSize, + HARDCODED_ASSUMPTIONS_PROCESS + ); + + info("Sanity checks: CPU (Total)"); + testCpu( + cpuContent, + 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 { + info("Sanity checks: number of threads"); + let numberOfThreads = Number.parseInt(threads.numberContent); + Assert.ok( + numberOfThreads >= HARDCODED_ASSUMPTIONS_PROCESS.minimalNumberOfThreads + ); + Assert.ok( + numberOfThreads <= HARDCODED_ASSUMPTIONS_PROCESS.maximalNumberOfThreads + ); + Assert.equal( + numberOfThreads, + row.process.threads.length, + "The number we display should be the number of threads" + ); + + info("Testing that we can open the list of threads"); + let twisty = threads.row.getElementsByClassName("twisty")[0]; + twisty.click(); + + // Since `twisty.click()` is partially async, we need to wait + // until all the threads are properly displayed. + await promiseAboutProcessesUpdated({ doc, tbody, tabAboutProcesses }); + let numberOfThreadsFound = 0; + await TestUtils.waitForCondition( + () => { + numberOfThreadsFound = 0; + for ( + let threadRow = threads.row.nextSibling; + threadRow && threadRow.classList.contains("thread"); + threadRow = threadRow.nextSibling + ) { + numberOfThreadsFound++; + } + return numberOfThreadsFound == numberOfThreads; + }, + `We should see ${numberOfThreads} threads, found ${numberOfThreadsFound}`, + /* interval = */ 300 + ); + for ( + let threadRow = threads.row.nextSibling; + threadRow && threadRow.classList.contains("thread"); + threadRow = threadRow.nextSibling + ) { + await TestUtils.waitForCondition( + () => + threadRow.children.length >= 3 && threadRow.children[2].textContent, + "The thread row should be populated" + ); + let children = threadRow.children; + let cpuContent = children[2].textContent; + let tidContent = document.l10n.getAttributes(children[0].children[0]) + .args.tid; + + info("Sanity checks: tid"); + let tid = Number.parseInt(tidContent); + Assert.notEqual(tid, 0, "The tid should be set"); + Assert.equal(tid, threadRow.thread.tid, "Displayed tid is correct"); + + info("Sanity checks: CPU (User and Kernel)"); + testCpu( + cpuContent, + threadRow.thread.totalCpu, + threadRow.thread.slopeCpu, + HARDCODED_ASSUMPTIONS_THREAD + ); + } + } + + // 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 = document.l10n.getAttributes(row.children[0].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" + ); + } + } + + await promiseAboutProcessesUpdated({ + doc, + tbody, + force: true, + tabAboutProcesses, + }); + + 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; + await TestUtils.waitForCondition( + () => + gBrowser.selectedTab.linkedBrowser.currentURI.spec == + tabHung.linkedBrowser.currentURI.spec, + "We should have focused the hung tab" + ); + gBrowser.selectedTab = tabAboutProcesses; + + info("Double-clicking on the extensions process"); + let whenTabSwitchedToAddons = 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 () => { + 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 tab switch"); + await whenTabSwitchedToAddons; + await TestUtils.waitForCondition( + () => 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, + tbody, + 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}` + ); + } + + // 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, + tbody, + 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 + await TestUtils.waitForCondition( + () => !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, + tbody, + force: true, + tabAboutProcesses, + }); + + for (let origin of [ + "http://example.net", // tabCloseProcess* + "https://example.org", // tabCloseTogether* + ]) { + await TestUtils.waitForCondition( + () => !findProcessRowByOrigin(doc, origin), + `Process ${origin} should disappear from about:processes` + ); + } + } + + info("Additional sanity check for all processes"); + for (let row of document.getElementsByClassName("process")) { + let { pidContent, typeContent } = extractProcessDetails(row); + Assert.equal(typeContent, row.process.type); + Assert.equal(Number.parseInt(pidContent), row.process.pid); + } + BrowserTestUtils.removeTab(tabAboutProcesses); + BrowserTestUtils.removeTab(tabHung); + 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); + + Services.prefs.clearUserPref("toolkit.aboutProcesses.showAllSubframes"); + Services.prefs.clearUserPref("toolkit.aboutProcesses.showThreads"); + + await extension.unload(); +} |