diff options
Diffstat (limited to '')
-rw-r--r-- | browser/modules/TabUnloader.jsm | 523 |
1 files changed, 523 insertions, 0 deletions
diff --git a/browser/modules/TabUnloader.jsm b/browser/modules/TabUnloader.jsm new file mode 100644 index 0000000000..1baf177df7 --- /dev/null +++ b/browser/modules/TabUnloader.jsm @@ -0,0 +1,523 @@ +/* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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"; + +/* + * TabUnloader is used to discard tabs when memory or resource constraints + * are reached. The discarded tabs are determined using a heuristic that + * accounts for when the tab was last used, how many resources the tab uses, + * and whether the tab is likely to affect the user if it is closed. + */ +var EXPORTED_SYMBOLS = ["TabUnloader"]; + +const lazy = {}; + +ChromeUtils.defineModuleGetter( + lazy, + "webrtcUI", + "resource:///modules/webrtcUI.jsm" +); +ChromeUtils.defineESModuleGetters(lazy, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", +}); + +// If there are only this many or fewer tabs open, just sort by weight, and close +// the lowest tab. Otherwise, do a more intensive compuation that determines the +// tabs to close based on memory and process use. +const MIN_TABS_COUNT = 10; + +// Weight for non-discardable tabs. +const NEVER_DISCARD = 100000; + +// Default minimum inactive duration. Tabs that were accessed in the last +// period of this duration are not unloaded. +const kMinInactiveDurationInMs = Services.prefs.getIntPref( + "browser.tabs.min_inactive_duration_before_unload" +); + +let criteriaTypes = [ + ["isNonDiscardable", NEVER_DISCARD], + ["isLoading", 8], + ["usingPictureInPicture", NEVER_DISCARD], + ["playingMedia", NEVER_DISCARD], + ["usingWebRTC", NEVER_DISCARD], + ["isPinned", 2], + ["isPrivate", NEVER_DISCARD], +]; + +// Indicies into the criteriaTypes lists. +let CRITERIA_METHOD = 0; +let CRITERIA_WEIGHT = 1; + +/** + * This is an object that supplies methods that determine details about + * each tab. This default object is used if another one is not passed + * to the tab unloader functions. This allows tests to override the methods + * with tab specific data rather than creating test tabs. + */ +let DefaultTabUnloaderMethods = { + isNonDiscardable(tab, weight) { + if (tab.selected) { + return weight; + } + + return !tab.linkedBrowser.isConnected ? -1 : 0; + }, + + isPinned(tab, weight) { + return tab.pinned ? weight : 0; + }, + + isLoading(tab, weight) { + return 0; + }, + + usingPictureInPicture(tab, weight) { + // This has higher weight even when paused. + return tab.pictureinpicture ? weight : 0; + }, + + playingMedia(tab, weight) { + return tab.soundPlaying ? weight : 0; + }, + + usingWebRTC(tab, weight) { + const browser = tab.linkedBrowser; + if (!browser) { + return 0; + } + + // No need to iterate browser contexts for hasActivePeerConnection + // because hasActivePeerConnection is set only in the top window. + return lazy.webrtcUI.browserHasStreams(browser) || + browser.browsingContext?.currentWindowGlobal?.hasActivePeerConnections() + ? weight + : 0; + }, + + isPrivate(tab, weight) { + return lazy.PrivateBrowsingUtils.isBrowserPrivate(tab.linkedBrowser) + ? weight + : 0; + }, + + getMinTabCount() { + return MIN_TABS_COUNT; + }, + + getNow() { + return Date.now(); + }, + + *iterateTabs() { + for (let win of Services.wm.getEnumerator("navigator:browser")) { + for (let tab of win.gBrowser.tabs) { + yield { tab, gBrowser: win.gBrowser }; + } + } + }, + + *iterateBrowsingContexts(bc) { + yield bc; + for (let childBC of bc.children) { + yield* this.iterateBrowsingContexts(childBC); + } + }, + + *iterateProcesses(tab) { + let bc = tab?.linkedBrowser?.browsingContext; + if (!bc) { + return; + } + + const iter = this.iterateBrowsingContexts(bc); + for (let childBC of iter) { + if (childBC?.currentWindowGlobal) { + yield childBC.currentWindowGlobal.osPid; + } + } + }, + + /** + * Add the amount of memory used by each process to the process map. + * + * @param tabs array of tabs, used only by unit tests + * @param map of processes returned by getAllProcesses. + */ + async calculateMemoryUsage(processMap) { + let parentProcessInfo = await ChromeUtils.requestProcInfo(); + let childProcessInfoList = parentProcessInfo.children; + for (let childProcInfo of childProcessInfoList) { + let processInfo = processMap.get(childProcInfo.pid); + if (!processInfo) { + processInfo = { count: 0, topCount: 0, tabSet: new Set() }; + processMap.set(childProcInfo.pid, processInfo); + } + processInfo.memory = childProcInfo.memory; + } + }, +}; + +/** + * This module is responsible for detecting low-memory scenarios and unloading + * tabs in response to them. + */ + +var TabUnloader = { + /** + * Initialize low-memory detection and tab auto-unloading. + */ + init() { + const watcher = Cc["@mozilla.org/xpcom/memory-watcher;1"].getService( + Ci.nsIAvailableMemoryWatcherBase + ); + watcher.registerTabUnloader(this); + }, + + isDiscardable(tab) { + if (!("weight" in tab)) { + return false; + } + return tab.weight < NEVER_DISCARD; + }, + + // This method is exposed on nsITabUnloader + async unloadTabAsync(minInactiveDuration = kMinInactiveDurationInMs) { + const watcher = Cc["@mozilla.org/xpcom/memory-watcher;1"].getService( + Ci.nsIAvailableMemoryWatcherBase + ); + + if (!Services.prefs.getBoolPref("browser.tabs.unloadOnLowMemory", true)) { + watcher.onUnloadAttemptCompleted(Cr.NS_ERROR_NOT_AVAILABLE); + return; + } + + if (this._isUnloading) { + // Don't post multiple unloading requests. The situation may be solved + // when the active unloading task is completed. + Services.console.logStringMessage("Unloading a tab is in progress."); + watcher.onUnloadAttemptCompleted(Cr.NS_ERROR_ABORT); + return; + } + + this._isUnloading = true; + const isTabUnloaded = await this.unloadLeastRecentlyUsedTab( + minInactiveDuration + ); + this._isUnloading = false; + + watcher.onUnloadAttemptCompleted( + isTabUnloaded ? Cr.NS_OK : Cr.NS_ERROR_NOT_AVAILABLE + ); + }, + + /** + * Get a list of tabs that can be discarded. This list includes all tabs in + * all windows and is sorted based on a weighting described below. + * + * @param minInactiveDuration If this value is a number, tabs that were accessed + * in the last |minInactiveDuration| msec are not unloaded even if they + * are least-recently-used. + * + * @param tabMethods an helper object with methods called by this algorithm. + * + * The algorithm used is: + * 1. Sort all of the tabs by a base weight. Tabs with a higher weight, such as + * those that are pinned or playing audio, will appear at the end. When two + * tabs have the same weight, sort by the order in which they were last. + * recently accessed Tabs that have a weight of NEVER_DISCARD are included in + * the list, but will not be discarded. + * 2. Exclude the last X tabs, where X is the value returned by getMinTabCount(). + * These tabs are considered to have been recently accessed and are not further + * reweighted. This also saves time when there are less than X tabs open. + * 3. Calculate the amount of processes that are used only by each tab, as the + * resources used by these proceses can be freed up if the tab is closed. Sort + * the tabs by the number of unique processes used and add a reweighting factor + * based on this. + * 4. Futher reweight based on an approximation of the amount of memory that each + * tab uses. + * 5. Combine these weights to produce a final tab discard order, and discard the + * first tab. If this fails, then discard the next tab in the list until no more + * non-discardable tabs are found. + * + * The tabMethods are used so that unit tests can use false tab objects and + * override their behaviour. + */ + async getSortedTabs( + minInactiveDuration = kMinInactiveDurationInMs, + tabMethods = DefaultTabUnloaderMethods + ) { + let tabs = []; + + const now = tabMethods.getNow(); + + let lowestWeight = 1000; + for (let tab of tabMethods.iterateTabs()) { + if ( + typeof minInactiveDuration == "number" && + now - tab.tab.lastAccessed < minInactiveDuration + ) { + // Skip "fresh" tabs, which were accessed within the specified duration. + continue; + } + + let weight = determineTabBaseWeight(tab, tabMethods); + + // Don't add tabs that have a weight of -1. + if (weight != -1) { + tab.weight = weight; + tabs.push(tab); + if (weight < lowestWeight) { + lowestWeight = weight; + } + } + } + + tabs = tabs.sort((a, b) => { + if (a.weight != b.weight) { + return a.weight - b.weight; + } + + return a.tab.lastAccessed - b.tab.lastAccessed; + }); + + // If the lowest priority tab is not discardable, no need to continue. + if (!tabs.length || !this.isDiscardable(tabs[0])) { + return tabs; + } + + // Determine the lowest weight that the tabs have. The tabs with the + // lowest weight (should be most non-selected tabs) will be additionally + // weighted by the number of processes and memory that they use. + let higherWeightedCount = 0; + for (let idx = 0; idx < tabs.length; idx++) { + if (tabs[idx].weight != lowestWeight) { + higherWeightedCount = tabs.length - idx; + break; + } + } + + // Don't continue to reweight the last few tabs, the number of which is + // determined by getMinTabCount. This prevents extra work when there are + // only a few tabs, or for the last few tabs that have likely been used + // recently. + let minCount = tabMethods.getMinTabCount(); + if (higherWeightedCount < minCount) { + higherWeightedCount = minCount; + } + + // If |lowestWeightedCount| is 1, no benefit from calculating + // the tab's memory and additional weight. + const lowestWeightedCount = tabs.length - higherWeightedCount; + if (lowestWeightedCount > 1) { + let processMap = getAllProcesses(tabs, tabMethods); + + let higherWeightedTabs = tabs.splice(-higherWeightedCount); + + await adjustForResourceUse(tabs, processMap, tabMethods); + tabs = tabs.concat(higherWeightedTabs); + } + + return tabs; + }, + + /** + * Select and discard one tab. + * @returns true if a tab was unloaded, otherwise false. + */ + async unloadLeastRecentlyUsedTab( + minInactiveDuration = kMinInactiveDurationInMs + ) { + const sortedTabs = await this.getSortedTabs(minInactiveDuration); + + for (let tabInfo of sortedTabs) { + if (!this.isDiscardable(tabInfo)) { + // Since |sortedTabs| is sorted, once we see an undiscardable tab + // no need to continue the loop. + return false; + } + + const remoteType = tabInfo.tab?.linkedBrowser?.remoteType; + if (tabInfo.gBrowser.discardBrowser(tabInfo.tab)) { + Services.console.logStringMessage( + `TabUnloader discarded <${remoteType}>` + ); + tabInfo.tab.updateLastUnloadedByTabUnloader(); + return true; + } + } + return false; + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), +}; + +/** Determine the base weight of the tab without accounting for + * resource use + * @param tab tab to use + * @returns the tab's base weight + */ +function determineTabBaseWeight(tab, tabMethods) { + let totalWeight = 0; + + for (let criteriaType of criteriaTypes) { + let weight = tabMethods[criteriaType[CRITERIA_METHOD]]( + tab.tab, + criteriaType[CRITERIA_WEIGHT] + ); + + // If a criteria returns -1, then never discard this tab. + if (weight == -1) { + return -1; + } + + totalWeight += weight; + } + + return totalWeight; +} + +/** + * Constuct a map of the processes that are used by the supplied tabs. + * The map will map process ids to an object with two properties: + * count - the number of tabs or subframes that use this process + * topCount - the number of top-level tabs that use this process + * tabSet - the indices of the tabs hosted by this process + * + * @param tabs array of tabs + * @param tabMethods an helper object with methods called by this algorithm. + * @returns process map + */ +function getAllProcesses(tabs, tabMethods) { + // Determine the number of tabs that reference each process. This + // is stored in the map 'processMap' where the key is the process + // and the value is that number of browsing contexts that use that + // process. + // XXXndeakin this should be unique processes per tab, in the case multiple + // subframes use the same process? + + let processMap = new Map(); + + for (let tabIndex = 0; tabIndex < tabs.length; ++tabIndex) { + const tab = tabs[tabIndex]; + + // The per-tab map will map process ids to an object with three properties: + // isTopLevel - whether the process hosts the tab's top-level frame or not + // frameCount - the number of frames hosted by the process + // (a top frame contributes 2 and a sub frame contributes 1) + // entryToProcessMap - the reference to the object in |processMap| + tab.processes = new Map(); + + let topLevel = true; + for (let pid of tabMethods.iterateProcesses(tab.tab)) { + let processInfo = processMap.get(pid); + if (processInfo) { + processInfo.count++; + processInfo.tabSet.add(tabIndex); + } else { + processInfo = { count: 1, topCount: 0, tabSet: new Set([tabIndex]) }; + processMap.set(pid, processInfo); + } + + let tabProcessEntry = tab.processes.get(pid); + if (tabProcessEntry) { + ++tabProcessEntry.frameCount; + } else { + tabProcessEntry = { + isTopLevel: topLevel, + frameCount: 1, + entryToProcessMap: processInfo, + }; + tab.processes.set(pid, tabProcessEntry); + } + + if (topLevel) { + topLevel = false; + processInfo.topCount = processInfo.topCount + ? processInfo.topCount + 1 + : 1; + // top-level frame contributes two frame counts + ++tabProcessEntry.frameCount; + } + } + } + + return processMap; +} + +/** + * Adjust the tab info and reweight the tabs based on the process and memory + * use that is used, as described by getSortedTabs + + * @param tabs array of tabs + * @param processMap map of processes returned by getAllProcesses + * @param tabMethods an helper object with methods called by this algorithm. + */ +async function adjustForResourceUse(tabs, processMap, tabMethods) { + // The second argument is needed for testing. + await tabMethods.calculateMemoryUsage(processMap, tabs); + + let sortWeight = 0; + for (let tab of tabs) { + tab.sortWeight = ++sortWeight; + + let uniqueCount = 0; + let totalMemory = 0; + for (const procEntry of tab.processes.values()) { + const processInfo = procEntry.entryToProcessMap; + if (processInfo.tabSet.size == 1) { + uniqueCount++; + } + + // Guess how much memory the frame might be using using by dividing + // the total memory used by a process by the number of tabs and + // frames that are using that process. Assume that any subframes take up + // only half as much memory as a process loaded in a top level tab. + // So for example, if a process is used in four top level tabs and two + // subframes, the top level tabs share 80% of the memory and the subframes + // use 20% of the memory. + const perFrameMemory = + processInfo.memory / + (processInfo.topCount * 2 + (processInfo.count - processInfo.topCount)); + totalMemory += perFrameMemory * procEntry.frameCount; + } + + tab.uniqueCount = uniqueCount; + tab.memory = totalMemory; + } + + tabs.sort((a, b) => { + return b.uniqueCount - a.uniqueCount; + }); + sortWeight = 0; + for (let tab of tabs) { + tab.sortWeight += ++sortWeight; + if (tab.uniqueCount > 1) { + // If the tab has a number of processes that are only used by this tab, + // subtract off an additional amount to the sorting weight value. That + // way, tabs that use lots of processes are more likely to be discarded. + tab.sortWeight -= tab.uniqueCount - 1; + } + } + + tabs.sort((a, b) => { + return b.memory - a.memory; + }); + sortWeight = 0; + for (let tab of tabs) { + tab.sortWeight += ++sortWeight; + } + + tabs.sort((a, b) => { + if (a.sortWeight != b.sortWeight) { + return a.sortWeight - b.sortWeight; + } + return a.tab.lastAccessed - b.tab.lastAccessed; + }); +} |