diff options
Diffstat (limited to 'toolkit/components/aboutprocesses/content')
3 files changed, 1700 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); +}; |