diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /toolkit/components/aboutprocesses/content/aboutProcesses.js | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/aboutprocesses/content/aboutProcesses.js')
-rw-r--r-- | toolkit/components/aboutprocesses/content/aboutProcesses.js | 1566 |
1 files changed, 1566 insertions, 0 deletions
diff --git a/toolkit/components/aboutprocesses/content/aboutProcesses.js b/toolkit/components/aboutprocesses/content/aboutProcesses.js new file mode 100644 index 0000000000..1d94c52c1f --- /dev/null +++ b/toolkit/components/aboutprocesses/content/aboutProcesses.js @@ -0,0 +1,1566 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-*/ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Time in ms before we start changing the sort order again after receiving a +// mousemove event. +const TIME_BEFORE_SORTING_AGAIN = 5000; + +// How long we should wait between samples. +const MINIMUM_INTERVAL_BETWEEN_SAMPLES_MS = 1000; + +// How often we should update +const UPDATE_INTERVAL_MS = 2000; + +const NS_PER_US = 1000; +const NS_PER_MS = 1000 * 1000; +const NS_PER_S = 1000 * 1000 * 1000; +const NS_PER_MIN = NS_PER_S * 60; +const NS_PER_HOUR = NS_PER_MIN * 60; +const NS_PER_DAY = NS_PER_HOUR * 24; + +const ONE_GIGA = 1024 * 1024 * 1024; +const ONE_MEGA = 1024 * 1024; +const ONE_KILO = 1024; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(this, "ProfilerPopupBackground", function () { + return ChromeUtils.import( + "resource://devtools/client/performance-new/shared/background.jsm.js" + ); +}); + +const { WebExtensionPolicy } = Cu.getGlobalForObject(Services); + +const SHOW_THREADS = Services.prefs.getBoolPref( + "toolkit.aboutProcesses.showThreads" +); +const SHOW_ALL_SUBFRAMES = Services.prefs.getBoolPref( + "toolkit.aboutProcesses.showAllSubframes" +); +const SHOW_PROFILER_ICONS = Services.prefs.getBoolPref( + "toolkit.aboutProcesses.showProfilerIcons" +); +const PROFILE_DURATION = Math.max( + 1, + Services.prefs.getIntPref("toolkit.aboutProcesses.profileDuration") +); + +/** + * For the time being, Fluent doesn't support duration or memory formats, so we need + * to fetch units from Fluent. To avoid re-fetching at each update, we prefetch these + * units during initialization, asynchronously, and keep them. + * + * @type { + * duration: { ns: String, us: String, ms: String, s: String, m: String, h: String, d: String }, + * memory: { B: String, KB: String, MB: String, GB: String, TB: String, PB: String, EB: String } + * }. + */ +let gLocalizedUnits; + +let tabFinder = { + update() { + this._map = new Map(); + for (let win of Services.wm.getEnumerator("navigator:browser")) { + let tabbrowser = win.gBrowser; + for (let browser of tabbrowser.browsers) { + let id = browser.outerWindowID; // May be `null` if the browser isn't loaded yet + if (id != null) { + this._map.set(id, browser); + } + } + if (tabbrowser.preloadedBrowser) { + let browser = tabbrowser.preloadedBrowser; + if (browser.outerWindowID) { + this._map.set(browser.outerWindowID, browser); + } + } + } + }, + + /** + * Find the <xul:tab> for a window id. + * + * This is useful e.g. for reloading or closing tabs. + * + * @return null If the xul:tab could not be found, e.g. if the + * windowId is that of a chrome window. + * @return {{tabbrowser: <xul:tabbrowser>, tab: <xul.tab>}} The + * tabbrowser and tab if the latter could be found. + */ + get(id) { + let browser = this._map.get(id); + if (!browser) { + return null; + } + let tabbrowser = browser.getTabBrowser(); + if (!tabbrowser) { + return { + tabbrowser: null, + tab: { + getAttribute() { + return ""; + }, + linkedBrowser: browser, + }, + }; + } + return { tabbrowser, tab: tabbrowser.getTabForBrowser(browser) }; + }, +}; + +/** + * Utilities for dealing with state + */ +var State = { + // Store the previous and current samples so they can be compared. + _previous: null, + _latest: null, + + async _promiseSnapshot() { + let date = Cu.now(); + let main = await ChromeUtils.requestProcInfo(); + main.date = date; + + let processes = new Map(); + processes.set(main.pid, main); + for (let child of main.children) { + child.date = date; + processes.set(child.pid, child); + } + + return { processes, date }; + }, + + /** + * Update the internal state. + * + * @return {Promise} + */ + async update(force = false) { + if ( + force || + !this._latest || + Cu.now() - this._latest.date > MINIMUM_INTERVAL_BETWEEN_SAMPLES_MS + ) { + // Replacing this._previous before we are done awaiting + // this._promiseSnapshot can cause this._previous and this._latest to be + // equal for a short amount of time, which can cause test failures when + // a forced update of the display is triggered in the meantime. + let newSnapshot = await this._promiseSnapshot(); + this._previous = this._latest; + this._latest = newSnapshot; + } + }, + + _getThreadDelta(cur, prev, deltaT) { + let result = { + tid: cur.tid, + name: cur.name || `(${cur.tid})`, + // Total amount of CPU used, in ns. + totalCpu: cur.cpuTime, + slopeCpu: null, + active: null, + }; + if (!deltaT) { + return result; + } + result.slopeCpu = (result.totalCpu - (prev ? prev.cpuTime : 0)) / deltaT; + result.active = + !!result.slopeCpu || cur.cpuCycleCount > (prev ? prev.cpuCycleCount : 0); + return result; + }, + + _getDOMWindows(process) { + if (!process.windows) { + return []; + } + if (!process.type == "extensions") { + return []; + } + let windows = process.windows.map(win => { + let tab = tabFinder.get(win.outerWindowId); + let addon = + process.type == "extension" + ? WebExtensionPolicy.getByURI(win.documentURI) + : null; + let displayRank; + if (tab) { + displayRank = 1; + } else if (win.isProcessRoot) { + displayRank = 2; + } else if (win.documentTitle) { + displayRank = 3; + } else { + displayRank = 4; + } + return { + outerWindowId: win.outerWindowId, + documentURI: win.documentURI, + documentTitle: win.documentTitle, + isProcessRoot: win.isProcessRoot, + isInProcess: win.isInProcess, + tab, + addon, + // The number of instances we have collapsed. + count: 1, + // A rank used to quickly sort windows. + displayRank, + }; + }); + + // We keep all tabs and addons but we collapse subframes that have the same host. + + // A map from host -> subframe. + let collapsible = new Map(); + let result = []; + for (let win of windows) { + if (win.tab || win.addon) { + result.push(win); + continue; + } + let prev = collapsible.get(win.documentURI.prePath); + if (prev) { + prev.count += 1; + } else { + collapsible.set(win.documentURI.prePath, win); + result.push(win); + } + } + return result; + }, + + /** + * Compute the delta between two process snapshots. + * + * @param {ProcessSnapshot} cur + * @param {ProcessSnapshot?} prev + */ + _getProcessDelta(cur, prev) { + let windows = this._getDOMWindows(cur); + let result = { + pid: cur.pid, + childID: cur.childID, + totalRamSize: cur.memory, + deltaRamSize: null, + totalCpu: cur.cpuTime, + slopeCpu: null, + active: null, + type: cur.type, + origin: cur.origin || "", + threads: null, + displayRank: Control._getDisplayGroupRank(cur, windows), + windows, + utilityActors: cur.utilityActors, + // If this process has an unambiguous title, store it here. + title: null, + }; + // Attempt to determine a title for this process. + let titles = [ + ...new Set( + result.windows + .filter(win => win.documentTitle) + .map(win => win.documentTitle) + ), + ]; + if (titles.length == 1) { + result.title = titles[0]; + } + if (!prev) { + if (SHOW_THREADS) { + result.threads = cur.threads.map(data => this._getThreadDelta(data)); + } + return result; + } + if (prev.pid != cur.pid) { + throw new Error("Assertion failed: A process cannot change pid."); + } + let deltaT = (cur.date - prev.date) * NS_PER_MS; + let threads = null; + if (SHOW_THREADS) { + let prevThreads = new Map(); + for (let thread of prev.threads) { + prevThreads.set(thread.tid, thread); + } + threads = cur.threads.map(curThread => + this._getThreadDelta(curThread, prevThreads.get(curThread.tid), deltaT) + ); + } + result.deltaRamSize = cur.memory - prev.memory; + result.slopeCpu = (cur.cpuTime - prev.cpuTime) / deltaT; + result.active = !!result.slopeCpu || cur.cpuCycleCount > prev.cpuCycleCount; + result.threads = threads; + return result; + }, + + getCounters() { + tabFinder.update(); + + let counters = []; + + for (let cur of this._latest.processes.values()) { + let prev = this._previous?.processes.get(cur.pid); + counters.push(this._getProcessDelta(cur, prev)); + } + + return counters; + }, +}; + +var View = { + // Processes, tabs and subframes that we killed during the previous iteration. + // Array<{pid:Number} | {windowId:Number}> + _killedRecently: [], + commit() { + this._killedRecently.length = 0; + let tbody = document.getElementById("process-tbody"); + + let insertPoint = tbody.firstChild; + let nextRow; + while ((nextRow = this._orderedRows.shift())) { + if (insertPoint && insertPoint === nextRow) { + insertPoint = insertPoint.nextSibling; + } else { + tbody.insertBefore(nextRow, insertPoint); + } + } + + if (insertPoint) { + while ((nextRow = insertPoint.nextSibling)) { + this._removeRow(nextRow); + } + this._removeRow(insertPoint); + } + }, + // If we are not going to display the updated list of rows, drop references + // to rows that haven't been inserted in the DOM tree. + discardUpdate() { + for (let row of this._orderedRows) { + if (!row.parentNode) { + this._rowsById.delete(row.rowId); + } + } + this._orderedRows = []; + }, + insertAfterRow(row) { + let tbody = row.parentNode; + let nextRow; + while ((nextRow = this._orderedRows.pop())) { + tbody.insertBefore(nextRow, row.nextSibling); + } + }, + + _rowsById: new Map(), + _removeRow(row) { + this._rowsById.delete(row.rowId); + + row.remove(); + }, + _getOrCreateRow(rowId, cellCount) { + let row = this._rowsById.get(rowId); + if (!row) { + row = document.createElement("tr"); + while (cellCount--) { + row.appendChild(document.createElement("td")); + } + row.rowId = rowId; + this._rowsById.set(rowId, row); + } + this._orderedRows.push(row); + return row; + }, + + displayCpu(data, cpuCell, maxSlopeCpu) { + // Put a value < 0% when we really don't want to see a bar as + // otherwise it sometimes appears due to rounding errors when we + // don't have an integer number of pixels. + let barWidth = -0.5; + if (data.slopeCpu == null) { + this._fillCell(cpuCell, { + fluentName: "about-processes-cpu-user-and-kernel-not-ready", + classes: ["cpu"], + }); + } else { + let { duration, unit } = this._getDuration(data.totalCpu); + if (data.totalCpu == 0) { + // A thread having used exactly 0ns of CPU time is not possible. + // When we get 0 it means the thread used less than the precision of + // the measurement, and it makes more sense to show '0ms' than '0ns'. + // This is useful on Linux where the minimum non-zero CPU time value + // for threads of child processes is 10ms, and on Windows ARM64 where + // the minimum non-zero value is 16ms. + unit = "ms"; + } + let localizedUnit = gLocalizedUnits.duration[unit]; + if (data.slopeCpu == 0) { + let fluentName = data.active + ? "about-processes-cpu-almost-idle" + : "about-processes-cpu-fully-idle"; + this._fillCell(cpuCell, { + fluentName, + fluentArgs: { + total: duration, + unit: localizedUnit, + }, + classes: ["cpu"], + }); + } else { + this._fillCell(cpuCell, { + fluentName: "about-processes-cpu", + fluentArgs: { + percent: data.slopeCpu, + total: duration, + unit: localizedUnit, + }, + classes: ["cpu"], + }); + + let cpuPercent = data.slopeCpu * 100; + if (maxSlopeCpu > 1) { + cpuPercent /= maxSlopeCpu; + } + // Ensure we always have a visible bar for non-0 values. + barWidth = Math.max(0.5, cpuPercent); + } + } + cpuCell.style.setProperty("--bar-width", barWidth); + }, + + /** + * Display a row showing a single process (without its threads). + * + * @param {ProcessDelta} data The data to display. + * @param {Number} maxSlopeCpu The largest slopeCpu value. + * @return {DOMElement} The row displaying the process. + */ + displayProcessRow(data, maxSlopeCpu) { + const cellCount = 4; + let rowId = "p:" + data.pid; + let row = this._getOrCreateRow(rowId, cellCount); + row.process = data; + { + let classNames = "process"; + if (data.isHung) { + classNames += " hung"; + } + row.className = classNames; + } + + // Column: Name + let nameCell = row.firstChild; + { + let classNames = []; + let fluentName; + let fluentArgs = { + pid: "" + data.pid, // Make sure that this number is not localized + }; + switch (data.type) { + case "web": + fluentName = "about-processes-web-process"; + break; + case "webIsolated": + fluentName = "about-processes-web-isolated-process"; + fluentArgs.origin = data.origin; + break; + case "webServiceWorker": + fluentName = "about-processes-web-serviceworker"; + fluentArgs.origin = data.origin; + break; + case "file": + fluentName = "about-processes-file-process"; + break; + case "extension": + fluentName = "about-processes-extension-process"; + classNames = ["extensions"]; + break; + case "privilegedabout": + fluentName = "about-processes-privilegedabout-process"; + break; + case "privilegedmozilla": + fluentName = "about-processes-privilegedmozilla-process"; + break; + case "withCoopCoep": + fluentName = "about-processes-with-coop-coep-process"; + fluentArgs.origin = data.origin; + break; + case "browser": + fluentName = "about-processes-browser-process"; + break; + case "plugin": + fluentName = "about-processes-plugin-process"; + break; + case "gmpPlugin": + fluentName = "about-processes-gmp-plugin-process"; + break; + case "gpu": + fluentName = "about-processes-gpu-process"; + break; + case "vr": + fluentName = "about-processes-vr-process"; + break; + case "rdd": + fluentName = "about-processes-rdd-process"; + break; + case "socket": + fluentName = "about-processes-socket-process"; + break; + case "remoteSandboxBroker": + fluentName = "about-processes-remote-sandbox-broker-process"; + break; + case "forkServer": + fluentName = "about-processes-fork-server-process"; + break; + case "preallocated": + fluentName = "about-processes-preallocated-process"; + break; + case "utility": + fluentName = "about-processes-utility-process"; + break; + // The following are probably not going to show up for users + // but let's handle the case anyway to avoid heisenoranges + // during tests in case of a leftover process from a previous + // test. + default: + fluentName = "about-processes-unknown-process"; + fluentArgs.type = data.type; + break; + } + + // Show container names instead of raw origin attribute suffixes. + if (fluentArgs.origin?.includes("^")) { + let origin = fluentArgs.origin; + let privateBrowsingId, userContextId; + try { + ({ privateBrowsingId, userContextId } = + ChromeUtils.createOriginAttributesFromOrigin(origin)); + fluentArgs.origin = origin.slice(0, origin.indexOf("^")); + } catch (e) { + // createOriginAttributesFromOrigin can throw NS_ERROR_FAILURE for incorrect origin strings. + } + if (userContextId) { + let identityLabel = + ContextualIdentityService.getUserContextLabel(userContextId); + if (identityLabel) { + fluentArgs.origin += ` — ${identityLabel}`; + } + } + if (privateBrowsingId) { + fluentName += "-private"; + } + } + + let processNameElement = nameCell; + if (SHOW_PROFILER_ICONS) { + if (!nameCell.firstChild) { + processNameElement = document.createElement("span"); + nameCell.appendChild(processNameElement); + + let profilerIcon = document.createElement("span"); + profilerIcon.className = "profiler-icon"; + document.l10n.setAttributes( + profilerIcon, + "about-processes-profile-process", + { duration: PROFILE_DURATION } + ); + nameCell.appendChild(profilerIcon); + } else { + processNameElement = nameCell.firstChild; + } + } + document.l10n.setAttributes(processNameElement, fluentName, fluentArgs); + nameCell.className = ["type", "favicon", ...classNames].join(" "); + nameCell.setAttribute("id", data.pid + "-label"); + + let image; + switch (data.type) { + case "browser": + case "privilegedabout": + image = "chrome://branding/content/icon32.png"; + break; + case "extension": + image = "chrome://mozapps/skin/extensions/extension.svg"; + break; + default: + // If all favicons match, pick the shared favicon. + // Otherwise, pick a default icon. + // If some tabs have no favicon, we ignore them. + for (let win of data.windows || []) { + if (!win.tab) { + continue; + } + let favicon = win.tab.tab.getAttribute("image"); + if (!favicon) { + // No favicon here, let's ignore the tab. + } else if (!image) { + // Let's pick a first favicon. + // We'll remove it later if we find conflicting favicons. + image = favicon; + } else if (image == favicon) { + // So far, no conflict, keep the favicon. + } else { + // Conflicting favicons, fallback to default. + image = null; + break; + } + } + if (!image) { + image = "chrome://global/skin/icons/link.svg"; + } + } + nameCell.style.backgroundImage = `url('${image}')`; + } + + // Column: Memory + let memoryCell = nameCell.nextSibling; + { + let formattedTotal = this._formatMemory(data.totalRamSize); + if (data.deltaRamSize) { + let formattedDelta = this._formatMemory(data.deltaRamSize); + this._fillCell(memoryCell, { + fluentName: "about-processes-total-memory-size-changed", + fluentArgs: { + total: formattedTotal.amount, + totalUnit: gLocalizedUnits.memory[formattedTotal.unit], + delta: Math.abs(formattedDelta.amount), + deltaUnit: gLocalizedUnits.memory[formattedDelta.unit], + deltaSign: data.deltaRamSize > 0 ? "+" : "-", + }, + classes: ["memory"], + }); + } else { + this._fillCell(memoryCell, { + fluentName: "about-processes-total-memory-size-no-change", + fluentArgs: { + total: formattedTotal.amount, + totalUnit: gLocalizedUnits.memory[formattedTotal.unit], + }, + classes: ["memory"], + }); + } + } + + // Column: CPU + let cpuCell = memoryCell.nextSibling; + this.displayCpu(data, cpuCell, maxSlopeCpu); + + // Column: Kill button – but not for all processes. + let killButton = cpuCell.nextSibling; + killButton.className = "action-icon"; + + if (data.type.startsWith("web")) { + // This type of process can be killed. + if (this._killedRecently.some(kill => kill.pid && kill.pid == data.pid)) { + // We're racing between the "kill" action and the visual refresh. + // In a few cases, we could end up with the visual refresh showing + // a process as un-killed while we actually just killed it. + // + // We still want to display the process in case something actually + // went bad and the user needs the information to realize this. + // But we also want to make it visible that the process is being + // killed. + row.classList.add("killed"); + } else { + // Otherwise, let's display the kill button. + killButton.classList.add("close-icon"); + document.l10n.setAttributes( + killButton, + "about-processes-shutdown-process" + ); + } + } + + return row; + }, + + /** + * Display a thread summary row with the thread count and a twisty to + * open/close the list. + * + * @param {ProcessDelta} data The data to display. + * @return {boolean} Whether the full thread list should be displayed. + */ + displayThreadSummaryRow(data) { + const cellCount = 2; + let rowId = "ts:" + data.pid; + let row = this._getOrCreateRow(rowId, cellCount); + row.process = data; + row.className = "thread-summary"; + let isOpen = false; + + // Column: Name + let nameCell = row.firstChild; + let threads = data.threads; + let activeThreads = new Map(); + let activeThreadCount = 0; + for (let t of data.threads) { + if (!t.active) { + continue; + } + ++activeThreadCount; + let name = t.name.replace(/ ?#[0-9]+$/, ""); + if (!activeThreads.has(name)) { + activeThreads.set(name, { name, slopeCpu: t.slopeCpu, count: 1 }); + } else { + let thread = activeThreads.get(name); + thread.count++; + thread.slopeCpu += t.slopeCpu; + } + } + let fluentName, fluentArgs; + if (activeThreadCount) { + let percentFormatter = new Intl.NumberFormat(undefined, { + style: "percent", + minimumSignificantDigits: 1, + }); + + let threadList = Array.from(activeThreads.values()); + threadList.sort((t1, t2) => t2.slopeCpu - t1.slopeCpu); + + fluentName = "about-processes-active-threads"; + fluentArgs = { + number: threads.length, + active: activeThreadCount, + list: new Intl.ListFormat(undefined, { style: "narrow" }).format( + threadList.map(t => { + let name = t.count > 1 ? `${t.count} × ${t.name}` : t.name; + let percent = Math.round(t.slopeCpu * 1000) / 1000; + if (percent) { + return `${name} ${percentFormatter.format(percent)}`; + } + return name; + }) + ), + }; + } else { + fluentName = "about-processes-inactive-threads"; + fluentArgs = { + number: threads.length, + }; + } + + let span; + if (!nameCell.firstChild) { + nameCell.className = "name indent"; + // Create the nodes: + let imgBtn = document.createElement("span"); + // Provide markup for an accessible disclosure button: + imgBtn.className = "twisty"; + imgBtn.setAttribute("role", "button"); + imgBtn.setAttribute("tabindex", "0"); + // Label to include both summary and details texts + imgBtn.setAttribute("aria-labelledby", `${data.pid}-label ${rowId}`); + if (!imgBtn.hasAttribute("aria-expanded")) { + imgBtn.setAttribute("aria-expanded", "false"); + } + nameCell.appendChild(imgBtn); + + span = document.createElement("span"); + span.setAttribute("id", rowId); + nameCell.appendChild(span); + } else { + // The only thing that can change is the thread count. + let imgBtn = nameCell.firstChild; + isOpen = imgBtn.classList.contains("open"); + span = imgBtn.nextSibling; + } + document.l10n.setAttributes(span, fluentName, fluentArgs); + + // Column: action + let actionCell = nameCell.nextSibling; + actionCell.className = "action-icon"; + + return isOpen; + }, + + displayDOMWindowRow(data, parent) { + const cellCount = 2; + let rowId = "w:" + data.outerWindowId; + let row = this._getOrCreateRow(rowId, cellCount); + row.win = data; + row.className = "window"; + + // Column: name + let nameCell = row.firstChild; + let tab = tabFinder.get(data.outerWindowId); + let fluentName; + let fluentArgs = {}; + let className; + if (tab && tab.tabbrowser) { + fluentName = "about-processes-tab-name"; + fluentArgs.name = tab.tab.label; + className = "tab"; + } else if (tab) { + fluentName = "about-processes-preloaded-tab"; + className = "preloaded-tab"; + } else if (data.count == 1) { + fluentName = "about-processes-frame-name-one"; + fluentArgs.url = data.documentURI.spec; + className = "frame-one"; + } else { + fluentName = "about-processes-frame-name-many"; + fluentArgs.number = data.count; + fluentArgs.shortUrl = + data.documentURI.scheme == "about" + ? data.documentURI.spec + : data.documentURI.prePath; + className = "frame-many"; + } + this._fillCell(nameCell, { + fluentName, + fluentArgs, + classes: ["name", "indent", "favicon", className], + }); + let image = tab?.tab.getAttribute("image"); + if (image) { + nameCell.style.backgroundImage = `url('${image}')`; + } + + // Column: action + let killButton = nameCell.nextSibling; + killButton.className = "action-icon"; + + if (data.tab && data.tab.tabbrowser) { + // A tab. We want to be able to close it. + if ( + this._killedRecently.some( + kill => kill.windowId && kill.windowId == data.outerWindowId + ) + ) { + // We're racing between the "kill" action and the visual refresh. + // In a few cases, we could end up with the visual refresh showing + // a window as un-killed while we actually just killed it. + // + // We still want to display the window in case something actually + // went bad and the user needs the information to realize this. + // But we also want to make it visible that the window is being + // killed. + row.classList.add("killed"); + } else { + // Otherwise, let's display the kill button. + killButton.classList.add("close-icon"); + document.l10n.setAttributes(killButton, "about-processes-shutdown-tab"); + } + } + }, + + utilityActorNameToFluentName(actorName) { + let fluentName; + switch (actorName) { + case "audioDecoder_Generic": + fluentName = "about-processes-utility-actor-audio-decoder-generic"; + break; + + case "audioDecoder_AppleMedia": + fluentName = "about-processes-utility-actor-audio-decoder-applemedia"; + break; + + case "audioDecoder_WMF": + fluentName = "about-processes-utility-actor-audio-decoder-wmf"; + break; + + case "mfMediaEngineCDM": + fluentName = "about-processes-utility-actor-mf-media-engine"; + break; + + case "jSOracle": + fluentName = "about-processes-utility-actor-js-oracle"; + break; + + case "windowsUtils": + fluentName = "about-processes-utility-actor-windows-utils"; + break; + + default: + fluentName = "about-processes-utility-actor-unknown"; + break; + } + return fluentName; + }, + + displayUtilityActorRow(data, parent) { + const cellCount = 2; + // The actor name is expected to be unique within a given utility process. + let rowId = "u:" + parent.pid + data.actorName; + let row = this._getOrCreateRow(rowId, cellCount); + row.actor = data; + row.className = "actor"; + + // Column: name + let nameCell = row.firstChild; + let fluentName = this.utilityActorNameToFluentName(data.actorName); + let fluentArgs = {}; + this._fillCell(nameCell, { + fluentName, + fluentArgs, + classes: ["name", "indent", "favicon"], + }); + }, + + /** + * Display a row showing a single thread. + * + * @param {ThreadDelta} data The data to display. + * @param {Number} maxSlopeCpu The largest slopeCpu value. + */ + displayThreadRow(data, maxSlopeCpu) { + const cellCount = 3; + let rowId = "t:" + data.tid; + let row = this._getOrCreateRow(rowId, cellCount); + row.thread = data; + row.className = "thread"; + + // Column: name + let nameCell = row.firstChild; + this._fillCell(nameCell, { + fluentName: "about-processes-thread-name-and-id", + fluentArgs: { + name: data.name, + tid: "" + data.tid /* Make sure that this number is not localized */, + }, + classes: ["name", "double_indent"], + }); + + // Column: CPU + this.displayCpu(data, nameCell.nextSibling, maxSlopeCpu); + + // Third column (Buttons) is empty, nothing to do. + }, + + _orderedRows: [], + _fillCell(elt, { classes, fluentName, fluentArgs }) { + document.l10n.setAttributes(elt, fluentName, fluentArgs); + elt.className = classes.join(" "); + }, + + _getDuration(rawDurationNS) { + if (rawDurationNS <= NS_PER_US) { + return { duration: rawDurationNS, unit: "ns" }; + } + if (rawDurationNS <= NS_PER_MS) { + return { duration: rawDurationNS / NS_PER_US, unit: "us" }; + } + if (rawDurationNS <= NS_PER_S) { + return { duration: rawDurationNS / NS_PER_MS, unit: "ms" }; + } + if (rawDurationNS <= NS_PER_MIN) { + return { duration: rawDurationNS / NS_PER_S, unit: "s" }; + } + if (rawDurationNS <= NS_PER_HOUR) { + return { duration: rawDurationNS / NS_PER_MIN, unit: "m" }; + } + if (rawDurationNS <= NS_PER_DAY) { + return { duration: rawDurationNS / NS_PER_HOUR, unit: "h" }; + } + return { duration: rawDurationNS / NS_PER_DAY, unit: "d" }; + }, + + /** + * Format a value representing an amount of memory. + * + * As a special case, we also handle `null`, which represents the case in which we do + * not have sufficient information to compute an amount of memory. + * + * @param {Number?} value The value to format. Must be either `null` or a non-negative number. + * @return { {unit: "GB" | "MB" | "KB" | B" | "?"}, amount: Number } The formated amount and its + * unit, which may be used for e.g. additional CSS formating. + */ + _formatMemory(value) { + if (value == null) { + return { unit: "?", amount: 0 }; + } + if (typeof value != "number") { + throw new Error(`Invalid memory value ${value}`); + } + let abs = Math.abs(value); + if (abs >= ONE_GIGA) { + return { + unit: "GB", + amount: value / ONE_GIGA, + }; + } + if (abs >= ONE_MEGA) { + return { + unit: "MB", + amount: value / ONE_MEGA, + }; + } + if (abs >= ONE_KILO) { + return { + unit: "KB", + amount: value / ONE_KILO, + }; + } + return { + unit: "B", + amount: value, + }; + }, +}; + +var Control = { + // The set of all processes reported as "hung" by the process hang monitor. + // + // type: Set<ChildID> + _hungItems: new Set(), + _sortColumn: null, + _sortAscendent: true, + _removeSubtree(row) { + let sibling = row.nextSibling; + while (sibling && !sibling.classList.contains("process")) { + let next = sibling.nextSibling; + if (sibling.classList.contains("thread")) { + View._removeRow(sibling); + } + sibling = next; + } + }, + init() { + this._initHangReports(); + + // Start prefetching units. + this._promisePrefetchedUnits = (async function () { + let [ns, us, ms, s, m, h, d, B, KB, MB, GB, TB, PB, EB] = + await document.l10n.formatValues([ + { id: "duration-unit-ns" }, + { id: "duration-unit-us" }, + { id: "duration-unit-ms" }, + { id: "duration-unit-s" }, + { id: "duration-unit-m" }, + { id: "duration-unit-h" }, + { id: "duration-unit-d" }, + { id: "memory-unit-B" }, + { id: "memory-unit-KB" }, + { id: "memory-unit-MB" }, + { id: "memory-unit-GB" }, + { id: "memory-unit-TB" }, + { id: "memory-unit-PB" }, + { id: "memory-unit-EB" }, + ]); + return { + duration: { ns, us, ms, s, m, h, d }, + memory: { B, KB, MB, GB, TB, PB, EB }, + }; + })(); + + let tbody = document.getElementById("process-tbody"); + + // Single click: + // - show or hide the contents of a twisty; + // - close a process; + // - profile a process; + // - change selection. + tbody.addEventListener("click", event => { + this._updateLastMouseEvent(); + + this._handleActivate(event.target); + }); + + // Enter or Space keypress: + // - show or hide the contents of a twisty; + // - close a process; + // - profile a process; + // - change selection. + tbody.addEventListener("keypress", event => { + // Handle showing or hiding subitems of a row, when keyboard is used. + if (event.key === "Enter" || event.key === " ") { + this._handleActivate(event.target); + } + }); + + // Double click: + // - navigate to tab; + // - navigate to about:addons. + tbody.addEventListener("dblclick", event => { + this._updateLastMouseEvent(); + event.stopPropagation(); + + // Bubble up the doubleclick manually. + for ( + let target = event.target; + target && target.getAttribute("id") != "process-tbody"; + target = target.parentNode + ) { + if (target.classList.contains("tab")) { + // We've clicked on a tab, navigate. + let { tab, tabbrowser } = target.parentNode.win.tab; + tabbrowser.selectedTab = tab; + tabbrowser.ownerGlobal.focus(); + return; + } + if (target.classList.contains("extensions")) { + // We've clicked on the extensions process, open or reuse window. + let parentWin = + window.docShell.browsingContext.embedderElement.ownerGlobal; + parentWin.BrowserOpenAddonsMgr(); + return; + } + // Otherwise, proceed. + } + }); + + tbody.addEventListener("mousemove", () => { + this._updateLastMouseEvent(); + }); + + // Visibility change: + // - stop updating while the user isn't looking; + // - resume updating when the user returns. + window.addEventListener("visibilitychange", event => { + if (!document.hidden) { + this._updateDisplay(true); + } + }); + + document + .getElementById("process-thead") + .addEventListener("click", async event => { + if (!event.target.classList.contains("clickable")) { + return; + } + // Linux has conventions opposite to Windows and macOS on the direction of arrows + // when sorting. + const platformIsLinux = AppConstants.platform == "linux"; + const ascArrow = platformIsLinux ? "arrow-up" : "arrow-down"; + const descArrow = platformIsLinux ? "arrow-down" : "arrow-up"; + + if (this._sortColumn) { + const td = document.getElementById(this._sortColumn); + td.classList.remove(ascArrow, descArrow); + } + + const columnId = event.target.id; + if (columnId == this._sortColumn) { + // Reverse sorting order. + this._sortAscendent = !this._sortAscendent; + } else { + this._sortColumn = columnId; + this._sortAscendent = true; + } + + event.target.classList.toggle(ascArrow, this._sortAscendent); + event.target.classList.toggle(descArrow, !this._sortAscendent); + + await this._updateDisplay(true); + }); + }, + _lastMouseEvent: 0, + _updateLastMouseEvent() { + this._lastMouseEvent = Date.now(); + }, + _initHangReports() { + const PROCESS_HANG_REPORT_NOTIFICATION = "process-hang-report"; + + // Receiving report of a hung child. + // Let's store if for our next update. + let hangReporter = report => { + report.QueryInterface(Ci.nsIHangReport); + this._hungItems.add(report.childID); + }; + Services.obs.addObserver(hangReporter, PROCESS_HANG_REPORT_NOTIFICATION); + + // Don't forget to unregister the reporter. + window.addEventListener( + "unload", + () => { + Services.obs.removeObserver( + hangReporter, + PROCESS_HANG_REPORT_NOTIFICATION + ); + }, + { once: true } + ); + }, + async update(force = false) { + await State.update(force); + + if (document.hidden) { + return; + } + + await this._updateDisplay(force); + }, + + // The force parameter can force a full update even when the mouse has been + // moved recently. + async _updateDisplay(force = false) { + let counters = State.getCounters(); + if (this._promisePrefetchedUnits) { + gLocalizedUnits = await this._promisePrefetchedUnits; + this._promisePrefetchedUnits = null; + } + + // We reset `_hungItems`, based on the assumption that the process hang + // monitor will inform us again before the next update. Since the process hang monitor + // pings its clients about once per second and we update about once per 2 seconds + // (or more if the mouse moves), we should be ok. + let hungItems = this._hungItems; + this._hungItems = new Set(); + + counters = this._sortProcesses(counters); + + // Stored because it is used when opening the list of threads. + this._maxSlopeCpu = Math.max(...counters.map(process => process.slopeCpu)); + + let previousProcess = null; + for (let process of counters) { + this._sortDOMWindows(process.windows); + + process.isHung = process.childID && hungItems.has(process.childID); + + let processRow = View.displayProcessRow(process, this._maxSlopeCpu); + + if (process.type != "extension") { + // We do not want to display extensions. + for (let win of process.windows) { + if (SHOW_ALL_SUBFRAMES || win.tab || win.isProcessRoot) { + View.displayDOMWindowRow(win, process); + } + } + } + + if (process.type === "utility") { + for (let actor of process.utilityActors) { + View.displayUtilityActorRow(actor, process); + } + } + + if (SHOW_THREADS) { + if (View.displayThreadSummaryRow(process)) { + this._showThreads(processRow, this._maxSlopeCpu); + } + } + if ( + this._sortColumn == null && + previousProcess && + previousProcess.displayRank != process.displayRank + ) { + // Add a separation between successive categories of processes. + processRow.classList.add("separate-from-previous-process-group"); + } + previousProcess = process; + } + + if ( + !force && + Date.now() - this._lastMouseEvent < TIME_BEFORE_SORTING_AGAIN + ) { + // If there has been a recent mouse event, we don't want to reorder, + // add or remove rows so that the table content under the mouse pointer + // doesn't change when the user might be about to click to close a tab + // or kill a process. + // We didn't return earlier because updating CPU and memory values is + // still valuable. + View.discardUpdate(); + return; + } + + View.commit(); + + // Reset the selectedRow field if that row is no longer in the DOM + // to avoid keeping forever references to dead processes. + if (this.selectedRow && !this.selectedRow.parentNode) { + this.selectedRow = null; + } + + // Used by tests to differentiate full updates from l10n updates. + document.dispatchEvent(new CustomEvent("AboutProcessesUpdated")); + }, + _compareCpu(a, b) { + return ( + b.slopeCpu - a.slopeCpu || b.active - a.active || b.totalCpu - a.totalCpu + ); + }, + _showThreads(row, maxSlopeCpu) { + let process = row.process; + this._sortThreads(process.threads); + for (let thread of process.threads) { + View.displayThreadRow(thread, maxSlopeCpu); + } + }, + _sortThreads(threads) { + return threads.sort((a, b) => { + let order; + switch (this._sortColumn) { + case "column-name": + order = a.name.localeCompare(b.name) || a.tid - b.tid; + break; + case "column-cpu-total": + order = this._compareCpu(a, b); + break; + case "column-memory-resident": + case null: + order = a.tid - b.tid; + break; + default: + throw new Error("Unsupported order: " + this._sortColumn); + } + if (!this._sortAscendent) { + order = -order; + } + return order; + }); + }, + _sortProcesses(counters) { + return counters.sort((a, b) => { + let order; + switch (this._sortColumn) { + case "column-name": + order = + String(a.origin).localeCompare(b.origin) || + String(a.type).localeCompare(b.type) || + a.pid - b.pid; + break; + case "column-cpu-total": + order = this._compareCpu(a, b); + break; + case "column-memory-resident": + order = b.totalRamSize - a.totalRamSize; + break; + case null: + // Default order: classify processes by group. + order = + a.displayRank - b.displayRank || + // Other processes are ordered by origin. + String(a.origin).localeCompare(b.origin); + break; + default: + throw new Error("Unsupported order: " + this._sortColumn); + } + if (!this._sortAscendent) { + order = -order; + } + return order; + }); + }, + _sortDOMWindows(windows) { + return windows.sort((a, b) => { + let order = + a.displayRank - b.displayRank || + a.documentTitle.localeCompare(b.documentTitle) || + a.documentURI.spec.localeCompare(b.documentURI.spec); + if (!this._sortAscendent) { + order = -order; + } + return order; + }); + }, + + // Assign a display rank to a process. + // + // The `browser` process comes first (rank 0). + // Then come web tabs (rank 1). + // Then come web frames (rank 2). + // Then come special processes (minus preallocated) (rank 3). + // Then come preallocated processes (rank 4). + _getDisplayGroupRank(data, windows) { + const RANK_BROWSER = 0; + const RANK_WEB_TABS = 1; + const RANK_WEB_FRAMES = 2; + const RANK_UTILITY = 3; + const RANK_PREALLOCATED = 4; + let type = data.type; + switch (type) { + // Browser comes first. + case "browser": + return RANK_BROWSER; + // Web content comes next. + case "webIsolated": + case "webServiceWorker": + case "withCoopCoep": { + if (windows.some(w => w.tab)) { + return RANK_WEB_TABS; + } + return RANK_WEB_FRAMES; + } + // Preallocated processes come last. + case "preallocated": + return RANK_PREALLOCATED; + // "web" is special, as it could be one of: + // - web content currently loading/unloading/... + // - a preallocated process. + case "web": + if (windows.some(w => w.tab)) { + return RANK_WEB_TABS; + } + if (windows.length >= 1) { + return RANK_WEB_FRAMES; + } + // For the time being, we do not display DOM workers + // (and there's no API to get information on them). + // Once the blockers for bug 1663737 have landed, we'll be able + // to find out whether this process has DOM workers. If so, we'll + // count this process as a content process. + return RANK_PREALLOCATED; + // Other special processes before preallocated. + default: + return RANK_UTILITY; + } + }, + + // Handle events on image controls. + _handleActivate(target) { + if (target.classList.contains("twisty")) { + this._handleTwisty(target); + return; + } + if (target.classList.contains("close-icon")) { + this._handleKill(target); + return; + } + + if (target.classList.contains("profiler-icon")) { + this._handleProfiling(target); + return; + } + + this._handleSelection(target); + }, + + // Open/close list of threads. + _handleTwisty(target) { + let row = target.parentNode.parentNode; + if (target.classList.toggle("open")) { + target.setAttribute("aria-expanded", "true"); + this._showThreads(row, this._maxSlopeCpu); + View.insertAfterRow(row); + } else { + target.setAttribute("aria-expanded", "false"); + this._removeSubtree(row); + } + }, + + // Kill process/close tab/close subframe. + _handleKill(target) { + let row = target.parentNode; + if (row.process) { + // Kill process immediately. + let pid = row.process.pid; + + // Make sure that the user can't click twice on the kill button. + // Otherwise, chaos might ensue. Plus we risk crashing under Windows. + View._killedRecently.push({ pid }); + + // Discard tab contents and show that the process and all its contents are getting killed. + row.classList.add("killing"); + for ( + let childRow = row.nextSibling; + childRow && !childRow.classList.contains("process"); + childRow = childRow.nextSibling + ) { + childRow.classList.add("killing"); + let win = childRow.win; + if (win) { + View._killedRecently.push({ pid: win.outerWindowId }); + if (win.tab && win.tab.tabbrowser) { + win.tab.tabbrowser.discardBrowser( + win.tab.tab, + /* aForceDiscard = */ true + ); + } + } + } + + // Finally, kill the process. + const ProcessTools = Cc["@mozilla.org/processtools-service;1"].getService( + Ci.nsIProcessToolsService + ); + ProcessTools.kill(pid); + } else if (row.win && row.win.tab && row.win.tab.tabbrowser) { + // This is a tab, close it. + row.win.tab.tabbrowser.removeTab(row.win.tab.tab, { + skipPermitUnload: true, + animate: true, + }); + View._killedRecently.push({ outerWindowId: row.win.outerWindowId }); + row.classList.add("killing"); + + // If this was the only root window of the process, show that the process is also getting killed. + if (row.previousSibling.classList.contains("process")) { + let parentRow = row.previousSibling; + let roots = 0; + for (let win of parentRow.process.windows) { + if (win.isProcessRoot) { + roots += 1; + } + } + if (roots <= 1) { + // Yes, we're the only process root, so the process is dying. + // + // It might actually become a preloaded process rather than + // dying. That's an acceptable error. Even if we display incorrectly + // that the process is dying, this error will last only one refresh. + View._killedRecently.push({ pid: parentRow.process.pid }); + parentRow.classList.add("killing"); + } + } + } + }, + + // Handle profiling of a process. + _handleProfiling(target) { + if (Services.profiler.IsActive()) { + return; + } + Services.profiler.StartProfiler( + 10000000, + 1, + ["default", "ipcmessages", "power"], + ["pid:" + target.parentNode.parentNode.process.pid] + ); + target.classList.add("profiler-active"); + setTimeout(() => { + ProfilerPopupBackground.captureProfile("aboutprofiling"); + target.classList.remove("profiler-active"); + }, PROFILE_DURATION * 1000); + }, + + // Handle selection changes. + _handleSelection(target) { + let row = target.closest("tr"); + if (!row) { + return; + } + if (this.selectedRow) { + this.selectedRow.removeAttribute("selected"); + if (this.selectedRow.rowId == row.rowId) { + // Clicking the same row again clears the selection. + this.selectedRow = null; + return; + } + } + row.setAttribute("selected", "true"); + this.selectedRow = row; + }, +}; + +window.onload = async function () { + Control.init(); + + // Display immediately the list of processes. CPU values will be missing. + await Control.update(); + + // After the minimum interval between samples, force an update to show + // valid CPU values asap. + await new Promise(resolve => + setTimeout(resolve, MINIMUM_INTERVAL_BETWEEN_SAMPLES_MS) + ); + await Control.update(true); + + // Then update at the normal frequency. + window.setInterval(() => Control.update(), UPDATE_INTERVAL_MS); +}; |