tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 }