Backed out changeset 496886cb30a5 (bug 1867152) for bc failures on browser_user_input...
[gecko.git] / browser / modules / TabUnloader.sys.mjs
bloba0c1233f274bad49ea15f39b4bf50f673caeb1a7
1 /* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */
2 /* This Source Code Form is subject to the terms of the Mozilla Public
3  * License, v. 2.0. If a copy of the MPL was not distributed with this
4  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
6 /*
7  * TabUnloader is used to discard tabs when memory or resource constraints
8  * are reached. The discarded tabs are determined using a heuristic that
9  * accounts for when the tab was last used, how many resources the tab uses,
10  * and whether the tab is likely to affect the user if it is closed.
11  */
12 const lazy = {};
14 ChromeUtils.defineESModuleGetters(lazy, {
15   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
16   webrtcUI: "resource:///modules/webrtcUI.sys.mjs",
17 });
19 // If there are only this many or fewer tabs open, just sort by weight, and close
20 // the lowest tab. Otherwise, do a more intensive compuation that determines the
21 // tabs to close based on memory and process use.
22 const MIN_TABS_COUNT = 10;
24 // Weight for non-discardable tabs.
25 const NEVER_DISCARD = 100000;
27 // Default minimum inactive duration.  Tabs that were accessed in the last
28 // period of this duration are not unloaded.
29 const kMinInactiveDurationInMs = Services.prefs.getIntPref(
30   "browser.tabs.min_inactive_duration_before_unload"
33 let criteriaTypes = [
34   ["isNonDiscardable", NEVER_DISCARD],
35   ["isLoading", 8],
36   ["usingPictureInPicture", NEVER_DISCARD],
37   ["playingMedia", NEVER_DISCARD],
38   ["usingWebRTC", NEVER_DISCARD],
39   ["isPinned", 2],
40   ["isPrivate", NEVER_DISCARD],
43 // Indicies into the criteriaTypes lists.
44 let CRITERIA_METHOD = 0;
45 let CRITERIA_WEIGHT = 1;
47 /**
48  * This is an object that supplies methods that determine details about
49  * each tab. This default object is used if another one is not passed
50  * to the tab unloader functions. This allows tests to override the methods
51  * with tab specific data rather than creating test tabs.
52  */
53 let DefaultTabUnloaderMethods = {
54   isNonDiscardable(tab, weight) {
55     if (tab.undiscardable || tab.selected) {
56       return weight;
57     }
59     return !tab.linkedBrowser.isConnected ? -1 : 0;
60   },
62   isPinned(tab, weight) {
63     return tab.pinned ? weight : 0;
64   },
66   isLoading(tab, weight) {
67     return 0;
68   },
70   usingPictureInPicture(tab, weight) {
71     // This has higher weight even when paused.
72     return tab.pictureinpicture ? weight : 0;
73   },
75   playingMedia(tab, weight) {
76     return tab.soundPlaying ? weight : 0;
77   },
79   usingWebRTC(tab, weight) {
80     const browser = tab.linkedBrowser;
81     if (!browser) {
82       return 0;
83     }
85     // No need to iterate browser contexts for hasActivePeerConnection
86     // because hasActivePeerConnection is set only in the top window.
87     return lazy.webrtcUI.browserHasStreams(browser) ||
88       browser.browsingContext?.currentWindowGlobal?.hasActivePeerConnections()
89       ? weight
90       : 0;
91   },
93   isPrivate(tab, weight) {
94     return lazy.PrivateBrowsingUtils.isBrowserPrivate(tab.linkedBrowser)
95       ? weight
96       : 0;
97   },
99   getMinTabCount() {
100     return MIN_TABS_COUNT;
101   },
103   getNow() {
104     return Date.now();
105   },
107   *iterateTabs() {
108     for (let win of Services.wm.getEnumerator("navigator:browser")) {
109       for (let tab of win.gBrowser.tabs) {
110         yield { tab, gBrowser: win.gBrowser };
111       }
112     }
113   },
115   *iterateBrowsingContexts(bc) {
116     yield bc;
117     for (let childBC of bc.children) {
118       yield* this.iterateBrowsingContexts(childBC);
119     }
120   },
122   *iterateProcesses(tab) {
123     let bc = tab?.linkedBrowser?.browsingContext;
124     if (!bc) {
125       return;
126     }
128     const iter = this.iterateBrowsingContexts(bc);
129     for (let childBC of iter) {
130       if (childBC?.currentWindowGlobal) {
131         yield childBC.currentWindowGlobal.osPid;
132       }
133     }
134   },
136   /**
137    * Add the amount of memory used by each process to the process map.
138    *
139    * @param tabs array of tabs, used only by unit tests
140    * @param map of processes returned by getAllProcesses.
141    */
142   async calculateMemoryUsage(processMap) {
143     let parentProcessInfo = await ChromeUtils.requestProcInfo();
144     let childProcessInfoList = parentProcessInfo.children;
145     for (let childProcInfo of childProcessInfoList) {
146       let processInfo = processMap.get(childProcInfo.pid);
147       if (!processInfo) {
148         processInfo = { count: 0, topCount: 0, tabSet: new Set() };
149         processMap.set(childProcInfo.pid, processInfo);
150       }
151       processInfo.memory = childProcInfo.memory;
152     }
153   },
157  * This module is responsible for detecting low-memory scenarios and unloading
158  * tabs in response to them.
159  */
161 export var TabUnloader = {
162   /**
163    * Initialize low-memory detection and tab auto-unloading.
164    */
165   init() {
166     const watcher = Cc["@mozilla.org/xpcom/memory-watcher;1"].getService(
167       Ci.nsIAvailableMemoryWatcherBase
168     );
169     watcher.registerTabUnloader(this);
170   },
172   isDiscardable(tab) {
173     if (!("weight" in tab)) {
174       return false;
175     }
176     return tab.weight < NEVER_DISCARD;
177   },
179   // This method is exposed on nsITabUnloader
180   async unloadTabAsync(minInactiveDuration = kMinInactiveDurationInMs) {
181     const watcher = Cc["@mozilla.org/xpcom/memory-watcher;1"].getService(
182       Ci.nsIAvailableMemoryWatcherBase
183     );
185     if (!Services.prefs.getBoolPref("browser.tabs.unloadOnLowMemory", true)) {
186       watcher.onUnloadAttemptCompleted(Cr.NS_ERROR_NOT_AVAILABLE);
187       return;
188     }
190     if (this._isUnloading) {
191       // Don't post multiple unloading requests.  The situation may be solved
192       // when the active unloading task is completed.
193       Services.console.logStringMessage("Unloading a tab is in progress.");
194       watcher.onUnloadAttemptCompleted(Cr.NS_ERROR_ABORT);
195       return;
196     }
198     this._isUnloading = true;
199     const isTabUnloaded = await this.unloadLeastRecentlyUsedTab(
200       minInactiveDuration
201     );
202     this._isUnloading = false;
204     watcher.onUnloadAttemptCompleted(
205       isTabUnloaded ? Cr.NS_OK : Cr.NS_ERROR_NOT_AVAILABLE
206     );
207   },
209   /**
210    * Get a list of tabs that can be discarded. This list includes all tabs in
211    * all windows and is sorted based on a weighting described below.
212    *
213    * @param minInactiveDuration If this value is a number, tabs that were accessed
214    *        in the last |minInactiveDuration| msec are not unloaded even if they
215    *        are least-recently-used.
216    *
217    * @param tabMethods an helper object with methods called by this algorithm.
218    *
219    * The algorithm used is:
220    *   1. Sort all of the tabs by a base weight. Tabs with a higher weight, such as
221    *      those that are pinned or playing audio, will appear at the end. When two
222    *      tabs have the same weight, sort by the order in which they were last.
223    *      recently accessed Tabs that have a weight of NEVER_DISCARD are included in
224    *       the list, but will not be discarded.
225    *   2. Exclude the last X tabs, where X is the value returned by getMinTabCount().
226    *      These tabs are considered to have been recently accessed and are not further
227    *      reweighted. This also saves time when there are less than X tabs open.
228    *   3. Calculate the amount of processes that are used only by each tab, as the
229    *      resources used by these proceses can be freed up if the tab is closed. Sort
230    *      the tabs by the number of unique processes used and add a reweighting factor
231    *      based on this.
232    *   4. Futher reweight based on an approximation of the amount of memory that each
233    *      tab uses.
234    *   5. Combine these weights to produce a final tab discard order, and discard the
235    *      first tab. If this fails, then discard the next tab in the list until no more
236    *      non-discardable tabs are found.
237    *
238    * The tabMethods are used so that unit tests can use false tab objects and
239    * override their behaviour.
240    */
241   async getSortedTabs(
242     minInactiveDuration = kMinInactiveDurationInMs,
243     tabMethods = DefaultTabUnloaderMethods
244   ) {
245     let tabs = [];
247     const now = tabMethods.getNow();
249     let lowestWeight = 1000;
250     for (let tab of tabMethods.iterateTabs()) {
251       if (
252         typeof minInactiveDuration == "number" &&
253         now - tab.tab.lastAccessed < minInactiveDuration
254       ) {
255         // Skip "fresh" tabs, which were accessed within the specified duration.
256         continue;
257       }
259       let weight = determineTabBaseWeight(tab, tabMethods);
261       // Don't add tabs that have a weight of -1.
262       if (weight != -1) {
263         tab.weight = weight;
264         tabs.push(tab);
265         if (weight < lowestWeight) {
266           lowestWeight = weight;
267         }
268       }
269     }
271     tabs = tabs.sort((a, b) => {
272       if (a.weight != b.weight) {
273         return a.weight - b.weight;
274       }
276       return a.tab.lastAccessed - b.tab.lastAccessed;
277     });
279     // If the lowest priority tab is not discardable, no need to continue.
280     if (!tabs.length || !this.isDiscardable(tabs[0])) {
281       return tabs;
282     }
284     // Determine the lowest weight that the tabs have. The tabs with the
285     // lowest weight (should be most non-selected tabs) will be additionally
286     // weighted by the number of processes and memory that they use.
287     let higherWeightedCount = 0;
288     for (let idx = 0; idx < tabs.length; idx++) {
289       if (tabs[idx].weight != lowestWeight) {
290         higherWeightedCount = tabs.length - idx;
291         break;
292       }
293     }
295     // Don't continue to reweight the last few tabs, the number of which is
296     // determined by getMinTabCount. This prevents extra work when there are
297     // only a few tabs, or for the last few tabs that have likely been used
298     // recently.
299     let minCount = tabMethods.getMinTabCount();
300     if (higherWeightedCount < minCount) {
301       higherWeightedCount = minCount;
302     }
304     // If |lowestWeightedCount| is 1, no benefit from calculating
305     // the tab's memory and additional weight.
306     const lowestWeightedCount = tabs.length - higherWeightedCount;
307     if (lowestWeightedCount > 1) {
308       let processMap = getAllProcesses(tabs, tabMethods);
310       let higherWeightedTabs = tabs.splice(-higherWeightedCount);
312       await adjustForResourceUse(tabs, processMap, tabMethods);
313       tabs = tabs.concat(higherWeightedTabs);
314     }
316     return tabs;
317   },
319   /**
320    * Select and discard one tab.
321    * @returns true if a tab was unloaded, otherwise false.
322    */
323   async unloadLeastRecentlyUsedTab(
324     minInactiveDuration = kMinInactiveDurationInMs
325   ) {
326     const sortedTabs = await this.getSortedTabs(minInactiveDuration);
328     for (let tabInfo of sortedTabs) {
329       if (!this.isDiscardable(tabInfo)) {
330         // Since |sortedTabs| is sorted, once we see an undiscardable tab
331         // no need to continue the loop.
332         return false;
333       }
335       const remoteType = tabInfo.tab?.linkedBrowser?.remoteType;
336       if (tabInfo.gBrowser.discardBrowser(tabInfo.tab)) {
337         Services.console.logStringMessage(
338           `TabUnloader discarded <${remoteType}>`
339         );
340         tabInfo.tab.updateLastUnloadedByTabUnloader();
341         return true;
342       }
343     }
344     return false;
345   },
347   QueryInterface: ChromeUtils.generateQI([
348     "nsIObserver",
349     "nsISupportsWeakReference",
350   ]),
353 /** Determine the base weight of the tab without accounting for
354  *  resource use
355  * @param tab tab to use
356  * @returns the tab's base weight
357  */
358 function determineTabBaseWeight(tab, tabMethods) {
359   let totalWeight = 0;
361   for (let criteriaType of criteriaTypes) {
362     let weight = tabMethods[criteriaType[CRITERIA_METHOD]](
363       tab.tab,
364       criteriaType[CRITERIA_WEIGHT]
365     );
367     // If a criteria returns -1, then never discard this tab.
368     if (weight == -1) {
369       return -1;
370     }
372     totalWeight += weight;
373   }
375   return totalWeight;
379  * Constuct a map of the processes that are used by the supplied tabs.
380  * The map will map process ids to an object with two properties:
381  *   count - the number of tabs or subframes that use this process
382  *   topCount - the number of top-level tabs that use this process
383  *   tabSet - the indices of the tabs hosted by this process
385  * @param tabs array of tabs
386  * @param tabMethods an helper object with methods called by this algorithm.
387  * @returns process map
388  */
389 function getAllProcesses(tabs, tabMethods) {
390   // Determine the number of tabs that reference each process. This
391   // is stored in the map 'processMap' where the key is the process
392   // and the value is that number of browsing contexts that use that
393   // process.
394   // XXXndeakin this should be unique processes per tab, in the case multiple
395   // subframes use the same process?
397   let processMap = new Map();
399   for (let tabIndex = 0; tabIndex < tabs.length; ++tabIndex) {
400     const tab = tabs[tabIndex];
402     // The per-tab map will map process ids to an object with three properties:
403     //   isTopLevel - whether the process hosts the tab's top-level frame or not
404     //   frameCount - the number of frames hosted by the process
405     //                (a top frame contributes 2 and a sub frame contributes 1)
406     //   entryToProcessMap - the reference to the object in |processMap|
407     tab.processes = new Map();
409     let topLevel = true;
410     for (let pid of tabMethods.iterateProcesses(tab.tab)) {
411       let processInfo = processMap.get(pid);
412       if (processInfo) {
413         processInfo.count++;
414         processInfo.tabSet.add(tabIndex);
415       } else {
416         processInfo = { count: 1, topCount: 0, tabSet: new Set([tabIndex]) };
417         processMap.set(pid, processInfo);
418       }
420       let tabProcessEntry = tab.processes.get(pid);
421       if (tabProcessEntry) {
422         ++tabProcessEntry.frameCount;
423       } else {
424         tabProcessEntry = {
425           isTopLevel: topLevel,
426           frameCount: 1,
427           entryToProcessMap: processInfo,
428         };
429         tab.processes.set(pid, tabProcessEntry);
430       }
432       if (topLevel) {
433         topLevel = false;
434         processInfo.topCount = processInfo.topCount
435           ? processInfo.topCount + 1
436           : 1;
437         // top-level frame contributes two frame counts
438         ++tabProcessEntry.frameCount;
439       }
440     }
441   }
443   return processMap;
447  * Adjust the tab info and reweight the tabs based on the process and memory
448  * use that is used, as described by getSortedTabs
450  * @param tabs array of tabs
451  * @param processMap map of processes returned by getAllProcesses
452  * @param tabMethods an helper object with methods called by this algorithm.
453  */
454 async function adjustForResourceUse(tabs, processMap, tabMethods) {
455   // The second argument is needed for testing.
456   await tabMethods.calculateMemoryUsage(processMap, tabs);
458   let sortWeight = 0;
459   for (let tab of tabs) {
460     tab.sortWeight = ++sortWeight;
462     let uniqueCount = 0;
463     let totalMemory = 0;
464     for (const procEntry of tab.processes.values()) {
465       const processInfo = procEntry.entryToProcessMap;
466       if (processInfo.tabSet.size == 1) {
467         uniqueCount++;
468       }
470       // Guess how much memory the frame might be using using by dividing
471       // the total memory used by a process by the number of tabs and
472       // frames that are using that process. Assume that any subframes take up
473       // only half as much memory as a process loaded in a top level tab.
474       // So for example, if a process is used in four top level tabs and two
475       // subframes, the top level tabs share 80% of the memory and the subframes
476       // use 20% of the memory.
477       const perFrameMemory =
478         processInfo.memory /
479         (processInfo.topCount * 2 + (processInfo.count - processInfo.topCount));
480       totalMemory += perFrameMemory * procEntry.frameCount;
481     }
483     tab.uniqueCount = uniqueCount;
484     tab.memory = totalMemory;
485   }
487   tabs.sort((a, b) => {
488     return b.uniqueCount - a.uniqueCount;
489   });
490   sortWeight = 0;
491   for (let tab of tabs) {
492     tab.sortWeight += ++sortWeight;
493     if (tab.uniqueCount > 1) {
494       // If the tab has a number of processes that are only used by this tab,
495       // subtract off an additional amount to the sorting weight value. That
496       // way, tabs that use lots of processes are more likely to be discarded.
497       tab.sortWeight -= tab.uniqueCount - 1;
498     }
499   }
501   tabs.sort((a, b) => {
502     return b.memory - a.memory;
503   });
504   sortWeight = 0;
505   for (let tab of tabs) {
506     tab.sortWeight += ++sortWeight;
507   }
509   tabs.sort((a, b) => {
510     if (a.sortWeight != b.sortWeight) {
511       return a.sortWeight - b.sortWeight;
512     }
513     return a.tab.lastAccessed - b.tab.lastAccessed;
514   });