TabUnloader.sys.mjs (16503B)
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/. */ 5 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 = {}; 13 14 ChromeUtils.defineESModuleGetters(lazy, { 15 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 16 webrtcUI: "resource:///modules/webrtcUI.sys.mjs", 17 }); 18 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; 23 24 // Weight for non-discardable tabs. 25 const NEVER_DISCARD = 100000; 26 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" 31 ); 32 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], 41 ]; 42 43 // Indicies into the criteriaTypes lists. 44 let CRITERIA_METHOD = 0; 45 let CRITERIA_WEIGHT = 1; 46 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 } 58 59 return !tab.linkedBrowser.isConnected ? -1 : 0; 60 }, 61 62 isPinned(tab, weight) { 63 return tab.pinned ? weight : 0; 64 }, 65 66 isLoading() { 67 return 0; 68 }, 69 70 usingPictureInPicture(tab, weight) { 71 // This has higher weight even when paused. 72 return tab.pictureinpicture ? weight : 0; 73 }, 74 75 playingMedia(tab, weight) { 76 return tab.soundPlaying ? weight : 0; 77 }, 78 79 usingWebRTC(tab, weight) { 80 const browser = tab.linkedBrowser; 81 if (!browser) { 82 return 0; 83 } 84 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 }, 92 93 isPrivate(tab, weight) { 94 return lazy.PrivateBrowsingUtils.isBrowserPrivate(tab.linkedBrowser) 95 ? weight 96 : 0; 97 }, 98 99 getMinTabCount() { 100 return MIN_TABS_COUNT; 101 }, 102 103 getNow() { 104 return Date.now(); 105 }, 106 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 }, 114 115 *iterateBrowsingContexts(bc) { 116 yield bc; 117 for (let childBC of bc.children) { 118 yield* this.iterateBrowsingContexts(childBC); 119 } 120 }, 121 122 *iterateProcesses(tab) { 123 let bc = tab?.linkedBrowser?.browsingContext; 124 if (!bc) { 125 return; 126 } 127 128 const iter = this.iterateBrowsingContexts(bc); 129 for (let childBC of iter) { 130 if (childBC?.currentWindowGlobal) { 131 yield childBC.currentWindowGlobal.osPid; 132 } 133 } 134 }, 135 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 }, 154 }; 155 156 /** 157 * This module is responsible for detecting low-memory scenarios and unloading 158 * tabs in response to them. 159 */ 160 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 }, 171 172 isDiscardable(tab) { 173 if (!("weight" in tab)) { 174 return false; 175 } 176 return tab.weight < NEVER_DISCARD; 177 }, 178 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 ); 184 185 if (!Services.prefs.getBoolPref("browser.tabs.unloadOnLowMemory", true)) { 186 watcher.onUnloadAttemptCompleted(Cr.NS_ERROR_NOT_AVAILABLE); 187 return; 188 } 189 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 } 197 198 this._isUnloading = true; 199 const isTabUnloaded = 200 await this.unloadLeastRecentlyUsedTab(minInactiveDuration); 201 this._isUnloading = false; 202 203 watcher.onUnloadAttemptCompleted( 204 isTabUnloaded ? Cr.NS_OK : Cr.NS_ERROR_NOT_AVAILABLE 205 ); 206 }, 207 208 /** 209 * Get a list of tabs that can be discarded. This list includes all tabs in 210 * all windows and is sorted based on a weighting described below. 211 * 212 * @param minInactiveDuration If this value is a number, tabs that were accessed 213 * in the last |minInactiveDuration| msec are not unloaded even if they 214 * are least-recently-used. 215 * 216 * @param tabMethods an helper object with methods called by this algorithm. 217 * 218 * The algorithm used is: 219 * 1. Sort all of the tabs by a base weight. Tabs with a higher weight, such as 220 * those that are pinned or playing audio, will appear at the end. When two 221 * tabs have the same weight, sort by the order in which they were last. 222 * recently accessed Tabs that have a weight of NEVER_DISCARD are included in 223 * the list, but will not be discarded. 224 * 2. Exclude the last X tabs, where X is the value returned by getMinTabCount(). 225 * These tabs are considered to have been recently accessed and are not further 226 * reweighted. This also saves time when there are less than X tabs open. 227 * 3. Calculate the amount of processes that are used only by each tab, as the 228 * resources used by these proceses can be freed up if the tab is closed. Sort 229 * the tabs by the number of unique processes used and add a reweighting factor 230 * based on this. 231 * 4. Futher reweight based on an approximation of the amount of memory that each 232 * tab uses. 233 * 5. Combine these weights to produce a final tab discard order, and discard the 234 * first tab. If this fails, then discard the next tab in the list until no more 235 * non-discardable tabs are found. 236 * 237 * The tabMethods are used so that unit tests can use false tab objects and 238 * override their behaviour. 239 */ 240 async getSortedTabs( 241 minInactiveDuration = kMinInactiveDurationInMs, 242 tabMethods = DefaultTabUnloaderMethods 243 ) { 244 let tabs = []; 245 246 const now = tabMethods.getNow(); 247 248 let lowestWeight = 1000; 249 for (let tab of tabMethods.iterateTabs()) { 250 if ( 251 typeof minInactiveDuration == "number" && 252 now - tab.tab.lastAccessed < minInactiveDuration 253 ) { 254 // Skip "fresh" tabs, which were accessed within the specified duration. 255 continue; 256 } 257 258 let weight = determineTabBaseWeight(tab, tabMethods); 259 260 // Don't add tabs that have a weight of -1. 261 if (weight != -1) { 262 tab.weight = weight; 263 tabs.push(tab); 264 if (weight < lowestWeight) { 265 lowestWeight = weight; 266 } 267 } 268 } 269 270 tabs = tabs.sort((a, b) => { 271 if (a.weight != b.weight) { 272 return a.weight - b.weight; 273 } 274 275 return a.tab.lastAccessed - b.tab.lastAccessed; 276 }); 277 278 // If the lowest priority tab is not discardable, no need to continue. 279 if (!tabs.length || !this.isDiscardable(tabs[0])) { 280 return tabs; 281 } 282 283 // Determine the lowest weight that the tabs have. The tabs with the 284 // lowest weight (should be most non-selected tabs) will be additionally 285 // weighted by the number of processes and memory that they use. 286 let higherWeightedCount = 0; 287 for (let idx = 0; idx < tabs.length; idx++) { 288 if (tabs[idx].weight != lowestWeight) { 289 higherWeightedCount = tabs.length - idx; 290 break; 291 } 292 } 293 294 // Don't continue to reweight the last few tabs, the number of which is 295 // determined by getMinTabCount. This prevents extra work when there are 296 // only a few tabs, or for the last few tabs that have likely been used 297 // recently. 298 let minCount = tabMethods.getMinTabCount(); 299 if (higherWeightedCount < minCount) { 300 higherWeightedCount = minCount; 301 } 302 303 // If |lowestWeightedCount| is 1, no benefit from calculating 304 // the tab's memory and additional weight. 305 const lowestWeightedCount = tabs.length - higherWeightedCount; 306 if (lowestWeightedCount > 1) { 307 let processMap = getAllProcesses(tabs, tabMethods); 308 309 let higherWeightedTabs = tabs.splice(-higherWeightedCount); 310 311 await adjustForResourceUse(tabs, processMap, tabMethods); 312 tabs = tabs.concat(higherWeightedTabs); 313 } 314 315 return tabs; 316 }, 317 318 /** 319 * Select and discard one tab. 320 * 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); 327 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 } 334 335 const remoteType = tabInfo.tab?.linkedBrowser?.remoteType; 336 await tabInfo.gBrowser.prepareDiscardBrowser(tabInfo.tab); 337 if (tabInfo.gBrowser.discardBrowser(tabInfo.tab)) { 338 Services.console.logStringMessage( 339 `TabUnloader discarded <${remoteType}>` 340 ); 341 tabInfo.tab.updateLastUnloadedByTabUnloader(); 342 return true; 343 } 344 } 345 return false; 346 }, 347 348 QueryInterface: ChromeUtils.generateQI([ 349 "nsIObserver", 350 "nsISupportsWeakReference", 351 ]), 352 }; 353 354 /** 355 * Determine the base weight of the tab without accounting for resource use. 356 * 357 * @param tab tab to use 358 * @returns the tab's base weight 359 */ 360 function determineTabBaseWeight(tab, tabMethods) { 361 let totalWeight = 0; 362 363 for (let criteriaType of criteriaTypes) { 364 let weight = tabMethods[criteriaType[CRITERIA_METHOD]]( 365 tab.tab, 366 criteriaType[CRITERIA_WEIGHT] 367 ); 368 369 // If a criteria returns -1, then never discard this tab. 370 if (weight == -1) { 371 return -1; 372 } 373 374 totalWeight += weight; 375 } 376 377 return totalWeight; 378 } 379 380 /** 381 * Constuct a map of the processes that are used by the supplied tabs. 382 * The map will map process ids to an object with two properties: 383 * count - the number of tabs or subframes that use this process 384 * topCount - the number of top-level tabs that use this process 385 * tabSet - the indices of the tabs hosted by this process 386 * 387 * @param tabs array of tabs 388 * @param tabMethods an helper object with methods called by this algorithm. 389 * @returns process map 390 */ 391 function getAllProcesses(tabs, tabMethods) { 392 // Determine the number of tabs that reference each process. This 393 // is stored in the map 'processMap' where the key is the process 394 // and the value is that number of browsing contexts that use that 395 // process. 396 // XXXndeakin this should be unique processes per tab, in the case multiple 397 // subframes use the same process? 398 399 let processMap = new Map(); 400 401 for (let tabIndex = 0; tabIndex < tabs.length; ++tabIndex) { 402 const tab = tabs[tabIndex]; 403 404 // The per-tab map will map process ids to an object with three properties: 405 // isTopLevel - whether the process hosts the tab's top-level frame or not 406 // frameCount - the number of frames hosted by the process 407 // (a top frame contributes 2 and a sub frame contributes 1) 408 // entryToProcessMap - the reference to the object in |processMap| 409 tab.processes = new Map(); 410 411 let topLevel = true; 412 for (let pid of tabMethods.iterateProcesses(tab.tab)) { 413 let processInfo = processMap.get(pid); 414 if (processInfo) { 415 processInfo.count++; 416 processInfo.tabSet.add(tabIndex); 417 } else { 418 processInfo = { count: 1, topCount: 0, tabSet: new Set([tabIndex]) }; 419 processMap.set(pid, processInfo); 420 } 421 422 let tabProcessEntry = tab.processes.get(pid); 423 if (tabProcessEntry) { 424 ++tabProcessEntry.frameCount; 425 } else { 426 tabProcessEntry = { 427 isTopLevel: topLevel, 428 frameCount: 1, 429 entryToProcessMap: processInfo, 430 }; 431 tab.processes.set(pid, tabProcessEntry); 432 } 433 434 if (topLevel) { 435 topLevel = false; 436 processInfo.topCount = processInfo.topCount 437 ? processInfo.topCount + 1 438 : 1; 439 // top-level frame contributes two frame counts 440 ++tabProcessEntry.frameCount; 441 } 442 } 443 } 444 445 return processMap; 446 } 447 448 /** 449 * Adjust the tab info and reweight the tabs based on the process and memory 450 * use that is used, as described by getSortedTabs 451 452 * @param tabs array of tabs 453 * @param processMap map of processes returned by getAllProcesses 454 * @param tabMethods an helper object with methods called by this algorithm. 455 */ 456 async function adjustForResourceUse(tabs, processMap, tabMethods) { 457 // The second argument is needed for testing. 458 await tabMethods.calculateMemoryUsage(processMap, tabs); 459 460 let sortWeight = 0; 461 for (let tab of tabs) { 462 tab.sortWeight = ++sortWeight; 463 464 let uniqueCount = 0; 465 let totalMemory = 0; 466 for (const procEntry of tab.processes.values()) { 467 const processInfo = procEntry.entryToProcessMap; 468 if (processInfo.tabSet.size == 1) { 469 uniqueCount++; 470 } 471 472 // Guess how much memory the frame might be using using by dividing 473 // the total memory used by a process by the number of tabs and 474 // frames that are using that process. Assume that any subframes take up 475 // only half as much memory as a process loaded in a top level tab. 476 // So for example, if a process is used in four top level tabs and two 477 // subframes, the top level tabs share 80% of the memory and the subframes 478 // use 20% of the memory. 479 const perFrameMemory = 480 processInfo.memory / 481 (processInfo.topCount * 2 + (processInfo.count - processInfo.topCount)); 482 totalMemory += perFrameMemory * procEntry.frameCount; 483 } 484 485 tab.uniqueCount = uniqueCount; 486 tab.memory = totalMemory; 487 } 488 489 tabs.sort((a, b) => { 490 return b.uniqueCount - a.uniqueCount; 491 }); 492 sortWeight = 0; 493 for (let tab of tabs) { 494 tab.sortWeight += ++sortWeight; 495 if (tab.uniqueCount > 1) { 496 // If the tab has a number of processes that are only used by this tab, 497 // subtract off an additional amount to the sorting weight value. That 498 // way, tabs that use lots of processes are more likely to be discarded. 499 tab.sortWeight -= tab.uniqueCount - 1; 500 } 501 } 502 503 tabs.sort((a, b) => { 504 return b.memory - a.memory; 505 }); 506 sortWeight = 0; 507 for (let tab of tabs) { 508 tab.sortWeight += ++sortWeight; 509 } 510 511 tabs.sort((a, b) => { 512 if (a.sortWeight != b.sortWeight) { 513 return a.sortWeight - b.sortWeight; 514 } 515 return a.tab.lastAccessed - b.tab.lastAccessed; 516 }); 517 }