/* -*- 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/. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { PictureInPicture: "resource://gre/modules/PictureInPicture.sys.mjs", }); XPCOMUtils.defineLazyPreferenceGetter( lazy, "gTabWarmingEnabled", "browser.tabs.remote.warmup.enabled" ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "gTabWarmingMax", "browser.tabs.remote.warmup.maxTabs" ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "gTabWarmingUnloadDelayMs", "browser.tabs.remote.warmup.unloadDelayMs" ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "gTabCacheSize", "browser.tabs.remote.tabCacheSize" ); XPCOMUtils.defineLazyPreferenceGetter( lazy, "gTabUnloadDelay", "browser.tabs.remote.unloadDelayMs", 300 ); /** * The tab switcher is responsible for asynchronously switching * tabs in e10s. It waits until the new tab is ready (i.e., the * layer tree is available) before switching to it. Then it * unloads the layer tree for the old tab. * * The tab switcher is a state machine. For each tab, it * maintains state about whether the layer tree for the tab is * available, being loaded, being unloaded, or unavailable. It * also keeps track of the tab currently being displayed, the tab * it's trying to load, and the tab the user has asked to switch * to. The switcher object is created upon tab switch. It is * released when there are no pending tabs to load or unload. * * The following general principles have guided the design: * * 1. We only request one layer tree at a time. If the user * switches to a different tab while waiting, we don't request * the new layer tree until the old tab has loaded or timed out. * * 2. If loading the layers for a tab times out, we show the * spinner and possibly request the layer tree for another tab if * the user has requested one. * * 3. We discard layer trees on a delay. This way, if the user is * switching among the same tabs frequently, we don't continually * load the same tabs. * * It's important that we always show either the spinner or a tab * whose layers are available. Otherwise the compositor will draw * an entirely black frame, which is very jarring. To ensure this * never happens when switching away from a tab, we assume the * old tab might still be drawn until a MozAfterPaint event * occurs. Because layout and compositing happen asynchronously, * we don't have any other way of knowing when the switch * actually takes place. Therefore, we don't unload the old tab * until the next MozAfterPaint event. */ export class AsyncTabSwitcher { constructor(tabbrowser) { this.log("START"); // How long to wait for a tab's layers to load. After this // time elapses, we're free to put up the spinner and start // trying to load a different tab. this.TAB_SWITCH_TIMEOUT = 400; // ms // When the user hasn't switched tabs for this long, we unload // layers for all tabs that aren't in use. this.UNLOAD_DELAY = lazy.gTabUnloadDelay; // ms // The next three tabs form the principal state variables. // See the assertions in postActions for their invariants. // Tab the user requested most recently. this.requestedTab = tabbrowser.selectedTab; // Tab we're currently trying to load. this.loadingTab = null; // We show this tab in case the requestedTab hasn't loaded yet. this.lastVisibleTab = tabbrowser.selectedTab; // Auxilliary state variables: this.visibleTab = tabbrowser.selectedTab; // Tab that's on screen. this.spinnerTab = null; // Tab showing a spinner. this.blankTab = null; // Tab showing blank. this.lastPrimaryTab = tabbrowser.selectedTab; // Tab with primary="true" this.tabbrowser = tabbrowser; this.window = tabbrowser.ownerGlobal; this.loadTimer = null; // TAB_SWITCH_TIMEOUT nsITimer instance. this.unloadTimer = null; // UNLOAD_DELAY nsITimer instance. // Map from tabs to STATE_* (below). this.tabState = new Map(); // True if we're in the midst of switching tabs. this.switchInProgress = false; // Transaction id for the composite that will show the requested // tab for the first tab after a tab switch. // Set to -1 when we're not waiting for notification of a // completed switch. this.switchPaintId = -1; // Set of tabs that might be visible right now. We maintain // this set because we can't be sure when a tab is actually // drawn. A tab is added to this set when we ask to make it // visible. All tabs but the most recently shown tab are // removed from the set upon MozAfterPaint. this.maybeVisibleTabs = new Set([tabbrowser.selectedTab]); // This holds onto the set of tabs that we've been asked to warm up, // and tabs are evicted once they're done loading or are unloaded. this.warmingTabs = new WeakSet(); this.STATE_UNLOADED = 0; this.STATE_LOADING = 1; this.STATE_LOADED = 2; this.STATE_UNLOADING = 3; // re-entrancy guard: this._processing = false; // For telemetry, keeps track of what most recently cleared // the loadTimer, which can tell us something about the cause // of tab switch spinners. this._loadTimerClearedBy = "none"; this._useDumpForLogging = false; this._logInit = false; this._logFlags = []; this.window.addEventListener("MozAfterPaint", this); this.window.addEventListener("MozLayerTreeReady", this); this.window.addEventListener("MozLayerTreeCleared", this); this.window.addEventListener("TabRemotenessChange", this); this.window.addEventListener("SwapDocShells", this, true); this.window.addEventListener("EndSwapDocShells", this, true); this.window.document.addEventListener("visibilitychange", this); let initialTab = this.requestedTab; let initialBrowser = initialTab.linkedBrowser; let tabIsLoaded = !initialBrowser.isRemoteBrowser || initialBrowser.frameLoader.remoteTab?.hasLayers; // If we minimized the window before the switcher was activated, // we might have set the preserveLayers flag for the current // browser. Let's clear it. initialBrowser.preserveLayers(false); if (!this.windowHidden) { this.log("Initial tab is loaded?: " + tabIsLoaded); this.setTabState( initialTab, tabIsLoaded ? this.STATE_LOADED : this.STATE_LOADING ); } for (let ppBrowser of this.tabbrowser._printPreviewBrowsers) { let ppTab = this.tabbrowser.getTabForBrowser(ppBrowser); let state = ppBrowser.hasLayers ? this.STATE_LOADED : this.STATE_LOADING; this.setTabState(ppTab, state); } } destroy() { if (this.unloadTimer) { this.clearTimer(this.unloadTimer); this.unloadTimer = null; } if (this.loadTimer) { this.clearTimer(this.loadTimer); this.loadTimer = null; } this.window.removeEventListener("MozAfterPaint", this); this.window.removeEventListener("MozLayerTreeReady", this); this.window.removeEventListener("MozLayerTreeCleared", this); this.window.removeEventListener("TabRemotenessChange", this); this.window.removeEventListener("SwapDocShells", this, true); this.window.removeEventListener("EndSwapDocShells", this, true); this.window.document.removeEventListener("visibilitychange", this); this.tabbrowser._switcher = null; } // Wraps nsITimer. Must not use the vanilla setTimeout and // clearTimeout, because they will be blocked by nsIPromptService // dialogs. setTimer(callback, timeout) { let event = { notify: callback, }; var timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); timer.initWithCallback(event, timeout, Ci.nsITimer.TYPE_ONE_SHOT); return timer; } clearTimer(timer) { timer.cancel(); } getTabState(tab) { let state = this.tabState.get(tab); // As an optimization, we lazily evaluate the state of tabs // that we've never seen before. Once we've figured it out, // we stash it in our state map. if (state === undefined) { state = this.STATE_UNLOADED; if (tab && tab.linkedPanel) { let b = tab.linkedBrowser; if (b.renderLayers && b.hasLayers) { state = this.STATE_LOADED; } else if (b.renderLayers && !b.hasLayers) { state = this.STATE_LOADING; } else if (!b.renderLayers && b.hasLayers) { state = this.STATE_UNLOADING; } } this.setTabStateNoAction(tab, state); } return state; } setTabStateNoAction(tab, state) { if (state == this.STATE_UNLOADED) { this.tabState.delete(tab); } else { this.tabState.set(tab, state); } } setTabState(tab, state) { if (state == this.getTabState(tab)) { return; } this.setTabStateNoAction(tab, state); let browser = tab.linkedBrowser; let { remoteTab } = browser.frameLoader; if (state == this.STATE_LOADING) { this.assert(!this.windowHidden); // If we're not in the process of warming this tab, we // don't need to delay activating its DocShell. if (!this.warmingTabs.has(tab)) { browser.docShellIsActive = true; } if (remoteTab) { browser.renderLayers = true; remoteTab.priorityHint = true; } if (browser.hasLayers) { this.onLayersReady(browser); } } else if (state == this.STATE_UNLOADING) { this.unwarmTab(tab); // Setting the docShell to be inactive will also cause it // to stop rendering layers. browser.docShellIsActive = false; if (remoteTab) { remoteTab.priorityHint = false; } if (!browser.hasLayers) { this.onLayersCleared(browser); } } else if (state == this.STATE_LOADED) { this.maybeActivateDocShell(tab); } if (!tab.linkedBrowser.isRemoteBrowser) { // setTabState is potentially re-entrant, so we must re-get the state for // this assertion. let nonRemoteState = this.getTabState(tab); // Non-remote tabs can never stay in the STATE_LOADING // or STATE_UNLOADING states. By the time this function // exits, a non-remote tab must be in STATE_LOADED or // STATE_UNLOADED, since the painting and the layer // upload happen synchronously. this.assert( nonRemoteState == this.STATE_UNLOADED || nonRemoteState == this.STATE_LOADED ); } } get windowHidden() { return this.window.document.hidden; } get tabLayerCache() { return this.tabbrowser._tabLayerCache; } finish() { this.log("FINISH"); this.assert(this.tabbrowser._switcher); this.assert(this.tabbrowser._switcher === this); this.assert(!this.spinnerTab); this.assert(!this.blankTab); this.assert(!this.loadTimer); this.assert(!this.loadingTab); this.assert(this.lastVisibleTab === this.requestedTab); this.assert( this.windowHidden || this.getTabState(this.requestedTab) == this.STATE_LOADED ); this.destroy(); this.window.document.commandDispatcher.unlock(); let event = new this.window.CustomEvent("TabSwitchDone", { bubbles: true, cancelable: true, }); this.tabbrowser.dispatchEvent(event); } // This function is called after all the main state changes to // make sure we display the right tab. updateDisplay() { let requestedTabState = this.getTabState(this.requestedTab); let requestedBrowser = this.requestedTab.linkedBrowser; // It is often more desirable to show a blank tab when appropriate than // the tab switch spinner - especially since the spinner is usually // preceded by a perceived lag of TAB_SWITCH_TIMEOUT ms in the // tab switch. We can hide this lag, and hide the time being spent // constructing BrowserChild's, layer trees, etc, by showing a blank // tab instead and focusing it immediately. let shouldBeBlank = false; if (requestedBrowser.isRemoteBrowser) { // If a tab is remote and the window is not minimized, we can show a // blank tab instead of a spinner in the following cases: // // 1. The tab has just crashed, and we haven't started showing the // tab crashed page yet (in this case, the RemoteTab is null) // 2. The tab has never presented, and has not finished loading // a non-local-about: page. // // For (2), "finished loading a non-local-about: page" is // determined by the busy state on the tab element and checking // if the loaded URI is local. let isBusy = this.requestedTab.hasAttribute("busy"); let isLocalAbout = requestedBrowser.currentURI.schemeIs("about"); let hasSufficientlyLoaded = !isBusy && !isLocalAbout; let fl = requestedBrowser.frameLoader; shouldBeBlank = !this.windowHidden && (!fl.remoteTab || (!hasSufficientlyLoaded && !fl.remoteTab.hasPresented)); if (this.logging()) { let flag = shouldBeBlank ? "blank" : "nonblank"; this.addLogFlag( flag, this.windowHidden, fl.remoteTab, isBusy, isLocalAbout, fl.remoteTab ? fl.remoteTab.hasPresented : 0 ); } } if (requestedBrowser.isRemoteBrowser) { this.addLogFlag("isRemote"); } // Figure out which tab we actually want visible right now. let showTab = null; if ( requestedTabState != this.STATE_LOADED && this.lastVisibleTab && this.loadTimer && !shouldBeBlank ) { // If we can't show the requestedTab, and lastVisibleTab is // available, show it. showTab = this.lastVisibleTab; } else { // Show the requested tab. If it's not available, we'll show the spinner or a blank tab. showTab = this.requestedTab; } // First, let's deal with blank tabs, which we show instead // of the spinner when the tab is not currently set up // properly in the content process. if (!shouldBeBlank && this.blankTab) { this.blankTab.linkedBrowser.removeAttribute("blank"); this.blankTab = null; } else if (shouldBeBlank && this.blankTab !== showTab) { if (this.blankTab) { this.blankTab.linkedBrowser.removeAttribute("blank"); } this.blankTab = showTab; this.blankTab.linkedBrowser.setAttribute("blank", "true"); } // Show or hide the spinner as needed. let needSpinner = this.getTabState(showTab) != this.STATE_LOADED && !this.windowHidden && !shouldBeBlank && !this.loadTimer; if (!needSpinner && this.spinnerTab) { this.noteSpinnerHidden(); this.tabbrowser.tabpanels.removeAttribute("pendingpaint"); this.spinnerTab.linkedBrowser.removeAttribute("pendingpaint"); this.spinnerTab = null; } else if (needSpinner && this.spinnerTab !== showTab) { if (this.spinnerTab) { this.spinnerTab.linkedBrowser.removeAttribute("pendingpaint"); } else { this.noteSpinnerDisplayed(); } this.spinnerTab = showTab; this.tabbrowser.tabpanels.toggleAttribute("pendingpaint", true); this.spinnerTab.linkedBrowser.toggleAttribute("pendingpaint", true); } // Switch to the tab we've decided to make visible. if (this.visibleTab !== showTab) { this.tabbrowser._adjustFocusBeforeTabSwitch(this.visibleTab, showTab); this.visibleTab = showTab; this.maybeVisibleTabs.add(showTab); let tabpanels = this.tabbrowser.tabpanels; let showPanel = this.tabbrowser.tabContainer.getRelatedElement(showTab); let index = Array.prototype.indexOf.call(tabpanels.children, showPanel); if (index != -1) { this.log(`Switch to tab ${index} - ${this.tinfo(showTab)}`); tabpanels.updateSelectedIndex(index); if (showTab === this.requestedTab) { if (requestedTabState == this.STATE_LOADED) { // The new tab will be made visible in the next paint, record the expected // transaction id for that, and we'll mark when we get notified of its // completion. this.switchPaintId = this.window.windowUtils.lastTransactionId + 1; } else { this.noteMakingTabVisibleWithoutLayers(); } this.tabbrowser._adjustFocusAfterTabSwitch(showTab); this.window.gURLBar.afterTabSwitchFocusChange(); this.maybeActivateDocShell(this.requestedTab); } } // This doesn't necessarily exist if we're a new window and haven't switched tabs yet if (this.lastVisibleTab) { this.lastVisibleTab._visuallySelected = false; } this.visibleTab._visuallySelected = true; } this.lastVisibleTab = this.visibleTab; } assert(cond) { if (!cond) { dump("Assertion failure\n" + Error().stack); // Don't break a user's browser if an assertion fails. if (AppConstants.DEBUG) { throw new Error("Assertion failure"); } } } maybeClearLoadTimer(caller) { if (this.loadingTab) { this._loadTimerClearedBy = caller; this.loadingTab = null; if (this.loadTimer) { this.clearTimer(this.loadTimer); this.loadTimer = null; } } } // We've decided to try to load requestedTab. loadRequestedTab() { this.assert(!this.loadTimer); this.assert(!this.windowHidden); // loadingTab can be non-null here if we timed out loading the current tab. // In that case we just overwrite it with a different tab; it's had its chance. this.loadingTab = this.requestedTab; this.log("Loading tab " + this.tinfo(this.loadingTab)); this.loadTimer = this.setTimer( () => this.handleEvent({ type: "loadTimeout" }), this.TAB_SWITCH_TIMEOUT ); this.setTabState(this.requestedTab, this.STATE_LOADING); } maybeActivateDocShell(tab) { // If we've reached the point where the requested tab has entered // the loaded state, but the DocShell is still not yet active, we // should activate it. let browser = tab.linkedBrowser; let state = this.getTabState(tab); let canCheckDocShellState = !browser.mDestroyed && (browser.docShell || browser.frameLoader.remoteTab); if ( tab == this.requestedTab && canCheckDocShellState && state == this.STATE_LOADED && !browser.docShellIsActive && !this.windowHidden ) { browser.docShellIsActive = true; this.logState( "Set requested tab docshell to active and preserveLayers to false" ); // If we minimized the window before the switcher was activated, // we might have set the preserveLayers flag for the current // browser. Let's clear it. browser.preserveLayers(false); } } // This function runs before every event. It fixes up the state // to account for closed tabs. preActions() { this.assert(this.tabbrowser._switcher); this.assert(this.tabbrowser._switcher === this); for (let i = 0; i < this.tabLayerCache.length; i++) { let tab = this.tabLayerCache[i]; if (!tab.linkedBrowser) { this.tabState.delete(tab); this.tabLayerCache.splice(i, 1); i--; } } for (let [tab] of this.tabState) { if (!tab.linkedBrowser) { this.tabState.delete(tab); this.unwarmTab(tab); } } if (this.lastVisibleTab && !this.lastVisibleTab.linkedBrowser) { this.lastVisibleTab = null; } if (this.lastPrimaryTab && !this.lastPrimaryTab.linkedBrowser) { this.lastPrimaryTab = null; } if (this.blankTab && !this.blankTab.linkedBrowser) { this.blankTab = null; } if (this.spinnerTab && !this.spinnerTab.linkedBrowser) { this.noteSpinnerHidden(); this.spinnerTab = null; } if (this.loadingTab && !this.loadingTab.linkedBrowser) { this.maybeClearLoadTimer("preActions"); } } // This code runs after we've responded to an event or requested a new // tab. It's expected that we've already updated all the principal // state variables. This function takes care of updating any auxilliary // state. postActions(eventString) { // Once we finish loading loadingTab, we null it out. So the state should // always be LOADING. this.assert( !this.loadingTab || this.getTabState(this.loadingTab) == this.STATE_LOADING ); // We guarantee that loadingTab is non-null iff loadTimer is non-null. So // the timer is set only when we're loading something. this.assert(!this.loadTimer || this.loadingTab); this.assert(!this.loadingTab || this.loadTimer); // If we're switching to a non-remote tab, there's no need to wait // for it to send layers to the compositor, as this will happen // synchronously. Clearing this here means that in the next step, // we can load the non-remote browser immediately. if (!this.requestedTab.linkedBrowser.isRemoteBrowser) { this.maybeClearLoadTimer("postActions"); } // If we're not loading anything, try loading the requested tab. let stateOfRequestedTab = this.getTabState(this.requestedTab); if ( !this.loadTimer && !this.windowHidden && (stateOfRequestedTab == this.STATE_UNLOADED || stateOfRequestedTab == this.STATE_UNLOADING || this.warmingTabs.has(this.requestedTab)) ) { this.assert(stateOfRequestedTab != this.STATE_LOADED); this.loadRequestedTab(); } let numBackgroundCached = 0; for (let tab of this.tabLayerCache) { if (tab !== this.requestedTab) { numBackgroundCached++; } } // See how many tabs still have work to do. let numPending = 0; let numWarming = 0; for (let [tab, state] of this.tabState) { if (!this.shouldDeactivateDocShell(tab.linkedBrowser)) { continue; } if ( state == this.STATE_LOADED && tab !== this.requestedTab && !this.tabLayerCache.includes(tab) ) { numPending++; if (tab !== this.visibleTab) { numWarming++; } } if (state == this.STATE_LOADING || state == this.STATE_UNLOADING) { numPending++; } } this.updateDisplay(); // It's possible for updateDisplay to trigger one of our own event // handlers, which might cause finish() to already have been called. // Check for that before calling finish() again. if (!this.tabbrowser._switcher) { return; } this.maybeFinishTabSwitch(); if (numBackgroundCached > 0) { this.deactivateCachedBackgroundTabs(); } if (numWarming > lazy.gTabWarmingMax) { this.logState("Hit tabWarmingMax"); if (this.unloadTimer) { this.clearTimer(this.unloadTimer); } this.unloadNonRequiredTabs(); } if (numPending == 0) { this.finish(); } this.logState("/" + eventString); } // Fires when we're ready to unload unused tabs. onUnloadTimeout() { this.unloadTimer = null; this.unloadNonRequiredTabs(); } deactivateCachedBackgroundTabs() { for (let tab of this.tabLayerCache) { if (tab !== this.requestedTab) { let browser = tab.linkedBrowser; browser.preserveLayers(true); browser.docShellIsActive = false; } } } // If there are any non-visible and non-requested tabs in // STATE_LOADED, sets them to STATE_UNLOADING. Also queues // up the unloadTimer to run onUnloadTimeout if there are still // tabs in the process of unloading. unloadNonRequiredTabs() { this.warmingTabs = new WeakSet(); let numPending = 0; // Unload any tabs that can be unloaded. for (let [tab, state] of this.tabState) { if (!this.shouldDeactivateDocShell(tab.linkedBrowser)) { continue; } let isInLayerCache = this.tabLayerCache.includes(tab); if ( state == this.STATE_LOADED && !this.maybeVisibleTabs.has(tab) && tab !== this.lastVisibleTab && tab !== this.loadingTab && tab !== this.requestedTab && !isInLayerCache ) { this.setTabState(tab, this.STATE_UNLOADING); } if ( state != this.STATE_UNLOADED && tab !== this.requestedTab && !isInLayerCache ) { numPending++; } } if (numPending) { // Keep the timer going since there may be more tabs to unload. this.unloadTimer = this.setTimer( () => this.handleEvent({ type: "unloadTimeout" }), this.UNLOAD_DELAY ); } } // Fires when an ongoing load has taken too long. onLoadTimeout() { this.maybeClearLoadTimer("onLoadTimeout"); } // Fires when the layers become available for a tab. onLayersReady(browser) { let tab = this.tabbrowser.getTabForBrowser(browser); if (!tab) { // We probably got a layer update from a tab that got before // the switcher was created, or for browser that's not being // tracked by the async tab switcher (like the preloaded about:newtab). return; } this.logState(`onLayersReady(${tab._tPos}, ${browser.isRemoteBrowser})`); this.assert( this.getTabState(tab) == this.STATE_LOADING || this.getTabState(tab) == this.STATE_LOADED ); this.setTabState(tab, this.STATE_LOADED); this.unwarmTab(tab); if (this.loadingTab === tab) { this.maybeClearLoadTimer("onLayersReady"); } } // Fires when we paint the screen. Any tab switches we initiated // previously are done, so there's no need to keep the old layers // around. onPaint(event) { this.addLogFlag( "onPaint", this.switchPaintId != -1, event.transactionId >= this.switchPaintId ); this.notePaint(event); this.maybeVisibleTabs.clear(); } // Called when we're done clearing the layers for a tab. onLayersCleared(browser) { let tab = this.tabbrowser.getTabForBrowser(browser); if (!tab) { return; } this.logState(`onLayersCleared(${tab._tPos})`); this.assert( this.getTabState(tab) == this.STATE_UNLOADING || this.getTabState(tab) == this.STATE_UNLOADED ); this.setTabState(tab, this.STATE_UNLOADED); } // Called when a tab switches from remote to non-remote. In this case // a MozLayerTreeReady notification that we requested may never fire, // so we need to simulate it. onRemotenessChange(tab) { this.logState( `onRemotenessChange(${tab._tPos}, ${tab.linkedBrowser.isRemoteBrowser})` ); if (!tab.linkedBrowser.isRemoteBrowser) { if (this.getTabState(tab) == this.STATE_LOADING) { this.onLayersReady(tab.linkedBrowser); } else if (this.getTabState(tab) == this.STATE_UNLOADING) { this.onLayersCleared(tab.linkedBrowser); } } else if (this.getTabState(tab) == this.STATE_LOADED) { // A tab just changed from non-remote to remote, which means // that it's gone back into the STATE_LOADING state until // it sends up a layer tree. this.setTabState(tab, this.STATE_LOADING); } } onTabRemoved(tab) { if (this.lastVisibleTab == tab) { this.handleEvent({ type: "tabRemoved", tab }); } } // Called when a tab has been removed, and the browser node is // about to be removed from the DOM. onTabRemovedImpl(tab) { this.lastVisibleTab = null; } onVisibilityChange() { if (this.windowHidden) { for (let [tab, state] of this.tabState) { if (!this.shouldDeactivateDocShell(tab.linkedBrowser)) { continue; } if (state == this.STATE_LOADING || state == this.STATE_LOADED) { this.setTabState(tab, this.STATE_UNLOADING); } } this.maybeClearLoadTimer("onSizeModeOrOcc"); } else { // We're no longer minimized or occluded. This means we might want // to activate the current tab's docShell. this.maybeActivateDocShell(this.tabbrowser.selectedTab); } } onSwapDocShells(ourBrowser, otherBrowser) { // This event fires before the swap. ourBrowser is from // our window. We save the state of otherBrowser since ourBrowser // needs to take on that state at the end of the swap. let otherTabbrowser = otherBrowser.ownerGlobal.gBrowser; let otherState; if (otherTabbrowser && otherTabbrowser._switcher) { let otherTab = otherTabbrowser.getTabForBrowser(otherBrowser); let otherSwitcher = otherTabbrowser._switcher; otherState = otherSwitcher.getTabState(otherTab); } else { otherState = otherBrowser.docShellIsActive ? this.STATE_LOADED : this.STATE_UNLOADED; } if (!this.swapMap) { this.swapMap = new WeakMap(); } this.swapMap.set(otherBrowser, { state: otherState, }); } onEndSwapDocShells(ourBrowser, otherBrowser) { // The swap has happened. We reset the loadingTab in // case it has been swapped. We also set ourBrowser's state // to whatever otherBrowser's state was before the swap. // Clearing the load timer means that we will // immediately display a spinner if ourBrowser isn't // ready yet. Typically it will already be ready // though. If it's not, we're probably in a new window, // in which case we have no other tabs to display anyway. this.maybeClearLoadTimer("onEndSwapDocShells"); let { state: otherState } = this.swapMap.get(otherBrowser); this.swapMap.delete(otherBrowser); let ourTab = this.tabbrowser.getTabForBrowser(ourBrowser); if (ourTab) { this.setTabStateNoAction(ourTab, otherState); } } /** * Check if the browser should be deactivated. If the browser is a print preivew or * PiP browser then we won't deactive it. * @param browser The browser to check if it should be deactivated * @returns false if a print preview or PiP browser else true */ shouldDeactivateDocShell(browser) { return !( this.tabbrowser._printPreviewBrowsers.has(browser) || lazy.PictureInPicture.isOriginatingBrowser(browser) ); } shouldActivateDocShell(browser) { let tab = this.tabbrowser.getTabForBrowser(browser); let state = this.getTabState(tab); return state == this.STATE_LOADING || state == this.STATE_LOADED; } activateBrowserForPrintPreview(browser) { let tab = this.tabbrowser.getTabForBrowser(browser); let state = this.getTabState(tab); if (state != this.STATE_LOADING && state != this.STATE_LOADED) { this.setTabState(tab, this.STATE_LOADING); this.logState( "Activated browser " + this.tinfo(tab) + " for print preview" ); } } canWarmTab(tab) { if (!lazy.gTabWarmingEnabled) { return false; } if (!tab) { return false; } // If the tab is not yet inserted, closing, not remote, // crashed, already visible, or already requested, warming // up the tab makes no sense. if ( this.windowHidden || !tab.linkedPanel || tab.closing || !tab.linkedBrowser.isRemoteBrowser || !tab.linkedBrowser.frameLoader.remoteTab ) { return false; } return true; } shouldWarmTab(tab) { if (this.canWarmTab(tab)) { // Tabs that are already in STATE_LOADING or STATE_LOADED // have no need to be warmed up. let state = this.getTabState(tab); if (state === this.STATE_UNLOADING || state === this.STATE_UNLOADED) { return true; } } return false; } unwarmTab(tab) { this.warmingTabs.delete(tab); } warmupTab(tab) { if (!this.shouldWarmTab(tab)) { return; } this.logState("warmupTab " + this.tinfo(tab)); this.warmingTabs.add(tab); this.setTabState(tab, this.STATE_LOADING); this.queueUnload(lazy.gTabWarmingUnloadDelayMs); } cleanUpTabAfterEviction(tab) { this.assert(tab !== this.requestedTab); let browser = tab.linkedBrowser; if (browser) { browser.preserveLayers(false); } this.setTabState(tab, this.STATE_UNLOADING); } evictOldestTabFromCache() { let tab = this.tabLayerCache.shift(); this.cleanUpTabAfterEviction(tab); } maybePromoteTabInLayerCache(tab) { if ( lazy.gTabCacheSize > 1 && tab.linkedBrowser.isRemoteBrowser && tab.linkedBrowser.currentURI.spec != "about:blank" ) { let tabIndex = this.tabLayerCache.indexOf(tab); if (tabIndex != -1) { this.tabLayerCache.splice(tabIndex, 1); } this.tabLayerCache.push(tab); if (this.tabLayerCache.length > lazy.gTabCacheSize) { this.evictOldestTabFromCache(); } } } // Called when the user asks to switch to a given tab. requestTab(tab) { if (tab === this.requestedTab) { return; } let tabState = this.getTabState(tab); this.noteTabRequested(tab, tabState); this.logState("requestTab " + this.tinfo(tab)); this.startTabSwitch(); let oldBrowser = this.requestedTab.linkedBrowser; oldBrowser.deprioritize(); this.requestedTab = tab; if (tabState == this.STATE_LOADED) { this.maybeVisibleTabs.clear(); } tab.linkedBrowser.setAttribute("primary", "true"); if (this.lastPrimaryTab && this.lastPrimaryTab != tab) { this.lastPrimaryTab.linkedBrowser.removeAttribute("primary"); } this.lastPrimaryTab = tab; this.queueUnload(this.UNLOAD_DELAY); } queueUnload(unloadTimeout) { this.handleEvent({ type: "queueUnload", unloadTimeout }); } onQueueUnload(unloadTimeout) { if (this.unloadTimer) { this.clearTimer(this.unloadTimer); } this.unloadTimer = this.setTimer( () => this.handleEvent({ type: "unloadTimeout" }), unloadTimeout ); } handleEvent(event, delayed = false) { if (this._processing) { this.setTimer(() => this.handleEvent(event, true), 0); return; } if (delayed && this.tabbrowser._switcher != this) { // if we delayed processing this event, we might be out of date, in which // case we drop the delayed events return; } this._processing = true; try { this.preActions(); switch (event.type) { case "queueUnload": this.onQueueUnload(event.unloadTimeout); break; case "unloadTimeout": this.onUnloadTimeout(); break; case "loadTimeout": this.onLoadTimeout(); break; case "tabRemoved": this.onTabRemovedImpl(event.tab); break; case "MozLayerTreeReady": { let browser = event.originalTarget; if (!browser.renderLayers) { // By the time we handle this event, it's possible that something // else has already set renderLayers to false, in which case this // event is stale and we can safely ignore it. return; } this.onLayersReady(browser); break; } case "MozAfterPaint": this.onPaint(event); break; case "MozLayerTreeCleared": { let browser = event.originalTarget; if (browser.renderLayers) { // By the time we handle this event, it's possible that something // else has already set renderLayers to true, in which case this // event is stale and we can safely ignore it. return; } this.onLayersCleared(browser); break; } case "TabRemotenessChange": this.onRemotenessChange(event.target); break; case "visibilitychange": this.onVisibilityChange(); break; case "SwapDocShells": this.onSwapDocShells(event.originalTarget, event.detail); break; case "EndSwapDocShells": this.onEndSwapDocShells(event.originalTarget, event.detail); break; } this.postActions(event.type); } finally { this._processing = false; } } /* * Telemetry and Profiler related helpers for recording tab switch * timing. */ startTabSwitch() { this.noteStartTabSwitch(); this.switchInProgress = true; } /** * Something has occurred that might mean that we've completed * the tab switch (layers are ready, paints are done, spinners * are hidden). This checks to make sure all conditions are * satisfied, and then records the tab switch as finished. */ maybeFinishTabSwitch() { if ( this.switchInProgress && this.requestedTab && (this.getTabState(this.requestedTab) == this.STATE_LOADED || this.requestedTab === this.blankTab) ) { if (this.requestedTab !== this.blankTab) { this.maybePromoteTabInLayerCache(this.requestedTab); } this.noteFinishTabSwitch(); this.switchInProgress = false; let event = new this.window.CustomEvent("TabSwitched", { bubbles: true, detail: { tab: this.requestedTab, }, }); this.tabbrowser.dispatchEvent(event); } } /* * Debug related logging for switcher. */ logging() { if (this._useDumpForLogging) { return true; } if (this._logInit) { return this._shouldLog; } let result = Services.prefs.getBoolPref( "browser.tabs.remote.logSwitchTiming", false ); this._shouldLog = result; this._logInit = true; return this._shouldLog; } tinfo(tab) { if (tab) { return tab._tPos + "(" + tab.linkedBrowser.currentURI.spec + ")"; } return "null"; } log(s) { if (!this.logging()) { return; } if (this._useDumpForLogging) { dump(s + "\n"); } else { Services.console.logStringMessage(s); } } addLogFlag(flag, ...subFlags) { if (this.logging()) { if (subFlags.length) { flag += `(${subFlags.map(f => (f ? 1 : 0)).join("")})`; } this._logFlags.push(flag); } } logState(suffix) { if (!this.logging()) { return; } let getTabString = tab => { let tabString = ""; let state = this.getTabState(tab); let isWarming = this.warmingTabs.has(tab); let isCached = this.tabLayerCache.includes(tab); let isClosing = tab.closing; let linkedBrowser = tab.linkedBrowser; let isActive = linkedBrowser && linkedBrowser.docShellIsActive; let isRendered = linkedBrowser && linkedBrowser.renderLayers; let isPiP = linkedBrowser && lazy.PictureInPicture.isOriginatingBrowser(linkedBrowser); if (tab === this.lastVisibleTab) { tabString += "V"; } if (tab === this.loadingTab) { tabString += "L"; } if (tab === this.requestedTab) { tabString += "R"; } if (tab === this.blankTab) { tabString += "B"; } if (this.maybeVisibleTabs.has(tab)) { tabString += "M"; } let extraStates = ""; if (isWarming) { extraStates += "W"; } if (isCached) { extraStates += "C"; } if (isClosing) { extraStates += "X"; } if (isActive) { extraStates += "A"; } if (isRendered) { extraStates += "R"; } if (isPiP) { extraStates += "P"; } if (extraStates != "") { tabString += `(${extraStates})`; } switch (state) { case this.STATE_LOADED: { tabString += "(loaded)"; break; } case this.STATE_LOADING: { tabString += "(loading)"; break; } case this.STATE_UNLOADING: { tabString += "(unloading)"; break; } case this.STATE_UNLOADED: { tabString += "(unloaded)"; break; } } return tabString; }; let accum = ""; // This is a bit tricky to read, but what we're doing here is collapsing // identical tab states down to make the overal string shorter and easier // to read, and we move all simply unloaded tabs to the back of the list. // I.e., we turn // "0:(unloaded) 1:(unloaded) 2:(unloaded) 3:(loaded)"" // into // "3:(loaded) 0...2:(unloaded)" let tabStrings = this.tabbrowser.tabs.map(t => getTabString(t)); let lastMatch = -1; let unloadedTabsStrings = []; for (let i = 0; i <= tabStrings.length; i++) { if (i > 0) { if (i < tabStrings.length && tabStrings[i] == tabStrings[lastMatch]) { continue; } if (tabStrings[lastMatch] == "(unloaded)") { if (lastMatch == i - 1) { unloadedTabsStrings.push(lastMatch.toString()); } else { unloadedTabsStrings.push(`${lastMatch}...${i - 1}`); } } else if (lastMatch == i - 1) { accum += `${lastMatch}:${tabStrings[lastMatch]} `; } else { accum += `${lastMatch}...${i - 1}:${tabStrings[lastMatch]} `; } } lastMatch = i; } if (unloadedTabsStrings.length) { accum += `${unloadedTabsStrings.join(",")}:(unloaded) `; } accum += "cached: " + this.tabLayerCache.length + " "; if (this._logFlags.length) { accum += `[${this._logFlags.join(",")}] `; this._logFlags = []; } // It can be annoying to read through the entirety of a log string just // to check if something changed or not. So if we can tell that nothing // changed, just write "unchanged" to save the reader's time. let logString; if (this._lastLogString == accum) { accum = "unchanged"; } else { this._lastLogString = accum; } logString = `ATS: ${accum}{${suffix}}`; if (this._useDumpForLogging) { dump(logString + "\n"); } else { Services.console.logStringMessage(logString); } } noteMakingTabVisibleWithoutLayers() { // We're making the tab visible even though we haven't yet got layers for it. // It's hard to know which composite the layers will first be available in (and // the parent process might not even get MozAfterPaint delivered for it), so just // give up measuring this for now. :( Glean.performanceInteraction.tabSwitchComposite.cancel( this._tabswitchTimerId ); this._tabswitchTimerId = null; } notePaint(event) { if (this.switchPaintId != -1 && event.transactionId >= this.switchPaintId) { if (this._tabswitchTimerId) { Glean.performanceInteraction.tabSwitchComposite.stopAndAccumulate( this._tabswitchTimerId ); this._tabswitchTimerId = null; } let { innerWindowId } = this.window.windowGlobalChild; ChromeUtils.addProfilerMarker("AsyncTabSwitch:Composited", { innerWindowId, }); this.switchPaintId = -1; } } noteTabRequested(tab, tabState) { if (lazy.gTabWarmingEnabled) { let warmingState = "disqualified"; if (this.canWarmTab(tab)) { if (tabState == this.STATE_LOADING) { warmingState = "stillLoading"; } else if (tabState == this.STATE_LOADED) { warmingState = "loaded"; } else if ( tabState == this.STATE_UNLOADING || tabState == this.STATE_UNLOADED ) { // At this point, if the tab's browser was being inserted // lazily, we never had a chance to warm it up, and unfortunately // there's no great way to detect that case. Those cases will // end up in the "notWarmed" bucket, along with legitimate cases // where tabs could have been warmed but weren't. warmingState = "notWarmed"; } } Services.telemetry .getHistogramById("FX_TAB_SWITCH_REQUEST_TAB_WARMING_STATE") .add(warmingState); } } noteStartTabSwitch() { TelemetryStopwatch.cancel("FX_TAB_SWITCH_TOTAL_E10S_MS", this.window); TelemetryStopwatch.start("FX_TAB_SWITCH_TOTAL_E10S_MS", this.window); if (this._tabswitchTimerId) { Glean.performanceInteraction.tabSwitchComposite.cancel( this._tabswitchTimerId ); } this._tabswitchTimerId = Glean.performanceInteraction.tabSwitchComposite.start(); let { innerWindowId } = this.window.windowGlobalChild; ChromeUtils.addProfilerMarker("AsyncTabSwitch:Start", { innerWindowId }); } noteFinishTabSwitch() { // After this point the tab has switched from the content thread's point of view. // The changes will be visible after the next refresh driver tick + composite. let time = TelemetryStopwatch.timeElapsed( "FX_TAB_SWITCH_TOTAL_E10S_MS", this.window ); if (time != -1) { TelemetryStopwatch.finish("FX_TAB_SWITCH_TOTAL_E10S_MS", this.window); this.log("DEBUG: tab switch time = " + time); let { innerWindowId } = this.window.windowGlobalChild; ChromeUtils.addProfilerMarker("AsyncTabSwitch:Finish", { innerWindowId }); } } noteSpinnerDisplayed() { this.assert(!this.spinnerTab); let browser = this.requestedTab.linkedBrowser; this.assert(browser.isRemoteBrowser); TelemetryStopwatch.start("FX_TAB_SWITCH_SPINNER_VISIBLE_MS", this.window); // We have a second, similar probe for capturing recordings of // when the spinner is displayed for very long periods. TelemetryStopwatch.start( "FX_TAB_SWITCH_SPINNER_VISIBLE_LONG_MS", this.window ); let { innerWindowId } = this.window.windowGlobalChild; ChromeUtils.addProfilerMarker("AsyncTabSwitch:SpinnerShown", { innerWindowId, }); Services.telemetry .getHistogramById("FX_TAB_SWITCH_SPINNER_VISIBLE_TRIGGER") .add(this._loadTimerClearedBy); if (AppConstants.NIGHTLY_BUILD) { Services.obs.notifyObservers(null, "tabswitch-spinner"); } } noteSpinnerHidden() { this.assert(this.spinnerTab); this.log( "DEBUG: spinner time = " + TelemetryStopwatch.timeElapsed( "FX_TAB_SWITCH_SPINNER_VISIBLE_MS", this.window ) ); TelemetryStopwatch.finish("FX_TAB_SWITCH_SPINNER_VISIBLE_MS", this.window); TelemetryStopwatch.finish( "FX_TAB_SWITCH_SPINNER_VISIBLE_LONG_MS", this.window ); let { innerWindowId } = this.window.windowGlobalChild; ChromeUtils.addProfilerMarker("AsyncTabSwitch:SpinnerHidden", { innerWindowId, }); // we do not get a onPaint after displaying the spinner this._loadTimerClearedBy = "none"; } }