summaryrefslogtreecommitdiffstats
path: root/browser/modules/TabUnloader.sys.mjs
blob: 2bd6097b5d1bfbffd68ecd8769366488bd748081 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
/* -*- 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/. */

/*
 * 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.
 */
const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
  webrtcUI: "resource:///modules/webrtcUI.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.undiscardable || tab.selected) {
      return weight;
    }

    return !tab.linkedBrowser.isConnected ? -1 : 0;
  },

  isPinned(tab, weight) {
    return tab.pinned ? weight : 0;
  },

  isLoading() {
    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.
 */

export 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;
  });
}