diff --git a/browser/base/content/tabbrowser.js b/browser/base/content/tabbrowser.js index 6620e84baf35..adc79e525465 100644 --- a/browser/base/content/tabbrowser.js +++ b/browser/base/content/tabbrowser.js @@ -2051,6 +2051,18 @@ // doesn't keep the window alive. b.permanentKey = new (Cu.getGlobalForObject(Services).Object)(); + // Ensure that SessionStore has flushed any session history state from the + // content process before we this browser's remoteness. + if (!Services.appinfo.sessionHistoryInParent) { + b.prepareToChangeRemoteness = () => + SessionStore.prepareToChangeRemoteness(b); + b.afterChangeRemoteness = switchId => { + let tab = this.getTabForBrowser(b); + SessionStore.finishTabRemotenessChange(tab, switchId); + return true; + }; + } + const defaultBrowserAttributes = { contextmenu: "contentAreaContextMenu", message: "true", diff --git a/browser/base/content/test/performance/browser_startup_content.js b/browser/base/content/test/performance/browser_startup_content.js index 2ddd236d26e1..6d70736aeaa5 100644 --- a/browser/base/content/test/performance/browser_startup_content.js +++ b/browser/base/content/test/performance/browser_startup_content.js @@ -57,6 +57,12 @@ const known_scripts = { ]), }; +if (!Services.appinfo.sessionHistoryInParent) { + known_scripts.modules.add( + "resource:///modules/sessionstore/ContentSessionStore.sys.mjs" + ); +} + // Items on this list *might* load when creating the process, as opposed to // items in the main list, which we expect will always load. const intermittently_loaded_scripts = { diff --git a/browser/components/customizableui/CustomizeMode.sys.mjs b/browser/components/customizableui/CustomizeMode.sys.mjs index fafbd4b1fb3d..91454ddeedfb 100644 --- a/browser/components/customizableui/CustomizeMode.sys.mjs +++ b/browser/components/customizableui/CustomizeMode.sys.mjs @@ -28,6 +28,7 @@ ChromeUtils.defineESModuleGetters(lazy, { AddonManager: "resource://gre/modules/AddonManager.sys.mjs", BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.sys.mjs", DragPositionManager: "resource:///modules/DragPositionManager.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", URILoadingHelper: "resource:///modules/URILoadingHelper.sys.mjs", }); ChromeUtils.defineLazyGetter(lazy, "gWidgetsBundle", function () { @@ -220,6 +221,7 @@ CustomizeMode.prototype = { gTab = aTab; gTab.setAttribute("customizemode", "true"); + lazy.SessionStore.persistTabAttribute("customizemode"); if (gTab.linkedPanel) { gTab.linkedBrowser.stop(); diff --git a/browser/components/sessionstore/ContentRestore.sys.mjs b/browser/components/sessionstore/ContentRestore.sys.mjs new file mode 100644 index 000000000000..df50ad227c71 --- /dev/null +++ b/browser/components/sessionstore/ContentRestore.sys.mjs @@ -0,0 +1,435 @@ +/* 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/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", + Utils: "resource://gre/modules/sessionstore/Utils.sys.mjs", +}); + +/** + * This module implements the content side of session restoration. The chrome + * side is handled by SessionStore.sys.mjs. The functions in this module are called + * by content-sessionStore.js based on messages received from SessionStore.sys.mjs + * (or, in one case, based on a "load" event). Each tab has its own + * ContentRestore instance, constructed by content-sessionStore.js. + * + * In a typical restore, content-sessionStore.js will call the following based + * on messages and events it receives: + * + * restoreHistory(tabData, loadArguments, callbacks) + * Restores the tab's history and session cookies. + * restoreTabContent(loadArguments, finishCallback) + * Starts loading the data for the current page to restore. + * restoreDocument() + * Restore form and scroll data. + * + * When the page has been loaded from the network, we call finishCallback. It + * should send a message to SessionStore.sys.mjs, which may cause other tabs to be + * restored. + * + * When the page has finished loading, a "load" event will trigger in + * content-sessionStore.js, which will call restoreDocument. At that point, + * form data is restored and the restore is complete. + * + * At any time, SessionStore.sys.mjs can cancel the ongoing restore by sending a + * reset message, which causes resetRestore to be called. At that point it's + * legal to begin another restore. + */ +export function ContentRestore(chromeGlobal) { + let internal = new ContentRestoreInternal(chromeGlobal); + let external = {}; + + let EXPORTED_METHODS = [ + "restoreHistory", + "restoreTabContent", + "restoreDocument", + "resetRestore", + ]; + + for (let method of EXPORTED_METHODS) { + external[method] = internal[method].bind(internal); + } + + return Object.freeze(external); +} + +function ContentRestoreInternal(chromeGlobal) { + this.chromeGlobal = chromeGlobal; + + // The following fields are only valid during certain phases of the restore + // process. + + // The tabData for the restore. Set in restoreHistory and removed in + // restoreTabContent. + this._tabData = null; + + // Contains {entry, scrollPositions, formdata}, where entry is a + // single entry from the tabData.entries array. Set in + // restoreTabContent and removed in restoreDocument. + this._restoringDocument = null; + + // This listener is used to detect reloads on restoring tabs. Set in + // restoreHistory and removed in restoreTabContent. + this._historyListener = null; + + // This listener detects when a pending tab starts loading (when not + // initiated by sessionstore) and when a restoring tab has finished loading + // data from the network. Set in restoreHistory() and restoreTabContent(), + // removed in resetRestore(). + this._progressListener = null; +} + +/** + * The API for the ContentRestore module. Methods listed in EXPORTED_METHODS are + * public. + */ +ContentRestoreInternal.prototype = { + get docShell() { + return this.chromeGlobal.docShell; + }, + + /** + * Starts the process of restoring a tab. The tabData to be restored is passed + * in here and used throughout the restoration. The epoch (which must be + * non-zero) is passed through to all the callbacks. If a load in the tab + * is started while it is pending, the appropriate callbacks are called. + */ + restoreHistory(tabData, loadArguments, callbacks) { + this._tabData = tabData; + + // In case about:blank isn't done yet. + let webNavigation = this.docShell.QueryInterface(Ci.nsIWebNavigation); + webNavigation.stop(Ci.nsIWebNavigation.STOP_ALL); + + // Make sure currentURI is set so that switch-to-tab works before the tab is + // restored. We'll reset this to about:blank when we try to restore the tab + // to ensure that docshell doeesn't get confused. Don't bother doing this if + // we're restoring immediately due to a process switch. It just causes the + // URL bar to be temporarily blank. + let activeIndex = tabData.index - 1; + let activePageData = tabData.entries[activeIndex] || {}; + let uri = activePageData.url || null; + if (uri && !loadArguments) { + webNavigation.setCurrentURIForSessionStore(Services.io.newURI(uri)); + } + + lazy.SessionHistory.restore(this.docShell, tabData); + + // Add a listener to watch for reloads. + let listener = new HistoryListener(this.docShell, () => { + // On reload, restore tab contents. + this.restoreTabContent(null, false, callbacks.onLoadFinished); + }); + + webNavigation.sessionHistory.legacySHistory.addSHistoryListener(listener); + this._historyListener = listener; + + // Make sure to reset the capabilities and attributes in case this tab gets + // reused. + SessionStoreUtils.restoreDocShellCapabilities( + this.docShell, + tabData.disallow + ); + + // Add a progress listener to correctly handle browser.loadURI() + // calls from foreign code. + this._progressListener = new ProgressListener(this.docShell, { + onStartRequest: () => { + // Some code called browser.loadURI() on a pending tab. It's safe to + // assume we don't care about restoring scroll or form data. + this._tabData = null; + + // Listen for the tab to finish loading. + this.restoreTabContentStarted(callbacks.onLoadFinished); + + // Notify the parent. + callbacks.onLoadStarted(); + }, + }); + }, + + /** + * Start loading the current page. When the data has finished loading from the + * network, finishCallback is called. Returns true if the load was successful. + */ + restoreTabContent(loadArguments, isRemotenessUpdate, finishCallback) { + let tabData = this._tabData; + this._tabData = null; + + let webNavigation = this.docShell.QueryInterface(Ci.nsIWebNavigation); + + // Listen for the tab to finish loading. + this.restoreTabContentStarted(finishCallback); + + // Reset the current URI to about:blank. We changed it above for + // switch-to-tab, but now it must go back to the correct value before the + // load happens. Don't bother doing this if we're restoring immediately + // due to a process switch. + if (!isRemotenessUpdate) { + webNavigation.setCurrentURIForSessionStore( + Services.io.newURI("about:blank") + ); + } + + try { + if (loadArguments) { + // If the load was started in another process, and the in-flight channel + // was redirected into this process, resume that load within our process. + // + // NOTE: In this case `isRemotenessUpdate` must be true. + webNavigation.resumeRedirectedLoad( + loadArguments.redirectLoadSwitchId, + loadArguments.redirectHistoryIndex + ); + } else if (tabData.userTypedValue && tabData.userTypedClear) { + // If the user typed a URL into the URL bar and hit enter right before + // we crashed, we want to start loading that page again. A non-zero + // userTypedClear value means that the load had started. + // Load userTypedValue and fix up the URL if it's partial/broken. + let loadURIOptions = { + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP, + }; + webNavigation.fixupAndLoadURIString( + tabData.userTypedValue, + loadURIOptions + ); + } else if (tabData.entries.length) { + // Stash away the data we need for restoreDocument. + this._restoringDocument = { + formdata: tabData.formdata || {}, + scrollPositions: tabData.scroll || {}, + }; + + // In order to work around certain issues in session history, we need to + // force session history to update its internal index and call reload + // instead of gotoIndex. See bug 597315. + let history = webNavigation.sessionHistory.legacySHistory; + history.reloadCurrentEntry(); + } else { + // If there's nothing to restore, we should still blank the page. + let loadURIOptions = { + triggeringPrincipal: + Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY, + // Specify an override to force the load to finish in the current + // process, as tests rely on this behaviour for non-fission session + // restore. + remoteTypeOverride: Services.appinfo.remoteType, + }; + webNavigation.loadURI( + Services.io.newURI("about:blank"), + loadURIOptions + ); + } + + return true; + } catch (ex) { + if (ex instanceof Ci.nsIException) { + // Ignore page load errors, but return false to signal that the load never + // happened. + return false; + } + } + return null; + }, + + /** + * To be called after restoreHistory(). Removes all listeners needed for + * pending tabs and makes sure to notify when the tab finished loading. + */ + restoreTabContentStarted(finishCallback) { + // The reload listener is no longer needed. + this._historyListener.uninstall(); + this._historyListener = null; + + // Remove the old progress listener. + this._progressListener.uninstall(); + + // We're about to start a load. This listener will be called when the load + // has finished getting everything from the network. + this._progressListener = new ProgressListener(this.docShell, { + onStopRequest: () => { + // Call resetRestore() to reset the state back to normal. The data + // needed for restoreDocument() (which hasn't happened yet) will + // remain in _restoringDocument. + this.resetRestore(); + + finishCallback(); + }, + }); + }, + + /** + * Finish restoring the tab by filling in form data and setting the scroll + * position. The restore is complete when this function exits. It should be + * called when the "load" event fires for the restoring tab. Returns true + * if we're restoring a document. + */ + restoreDocument() { + if (!this._restoringDocument) { + return; + } + + let { formdata, scrollPositions } = this._restoringDocument; + this._restoringDocument = null; + + let window = this.docShell.domWindow; + + // Restore form data. + lazy.Utils.restoreFrameTreeData(window, formdata, (frame, data) => { + // restore() will return false, and thus abort restoration for the + // current |frame| and its descendants, if |data.url| is given but + // doesn't match the loaded document's URL. + return SessionStoreUtils.restoreFormData(frame.document, data); + }); + + // Restore scroll data. + lazy.Utils.restoreFrameTreeData(window, scrollPositions, (frame, data) => { + if (data.scroll) { + SessionStoreUtils.restoreScrollPosition(frame, data); + } + }); + }, + + /** + * Cancel an ongoing restore. This function can be called any time between + * restoreHistory and restoreDocument. + * + * This function is called externally (if a restore is canceled) and + * internally (when the loads for a restore have finished). In the latter + * case, it's called before restoreDocument, so it cannot clear + * _restoringDocument. + */ + resetRestore() { + this._tabData = null; + + if (this._historyListener) { + this._historyListener.uninstall(); + } + this._historyListener = null; + + if (this._progressListener) { + this._progressListener.uninstall(); + } + this._progressListener = null; + }, +}; + +/* + * This listener detects when a page being restored is reloaded. It triggers a + * callback and cancels the reload. The callback will send a message to + * SessionStore.sys.mjs so that it can restore the content immediately. + */ +function HistoryListener(docShell, callback) { + let webNavigation = docShell.QueryInterface(Ci.nsIWebNavigation); + webNavigation.sessionHistory.legacySHistory.addSHistoryListener(this); + + this.webNavigation = webNavigation; + this.callback = callback; +} +HistoryListener.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsISupportsWeakReference", + ]), + + uninstall() { + let shistory = this.webNavigation.sessionHistory.legacySHistory; + if (shistory) { + shistory.removeSHistoryListener(this); + } + }, + + OnHistoryGotoIndex() {}, + OnHistoryPurge() {}, + OnHistoryReplaceEntry() {}, + + // This will be called for a pending tab when loadURI(uri) is called where + // the given |uri| only differs in the fragment. + OnHistoryNewEntry(newURI) { + let currentURI = this.webNavigation.currentURI; + + // Ignore new SHistory entries with the same URI as those do not indicate + // a navigation inside a document by changing the #hash part of the URL. + // We usually hit this when purging session history for browsers. + if (currentURI && currentURI.spec == newURI.spec) { + return; + } + + // Reset the tab's URL to what it's actually showing. Without this loadURI() + // would use the current document and change the displayed URL only. + this.webNavigation.setCurrentURIForSessionStore( + Services.io.newURI("about:blank") + ); + + // Kick off a new load so that we navigate away from about:blank to the + // new URL that was passed to loadURI(). The new load will cause a + // STATE_START notification to be sent and the ProgressListener will then + // notify the parent and do the rest. + let loadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP; + let loadURIOptions = { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags, + }; + this.webNavigation.loadURI(newURI, loadURIOptions); + }, + + OnHistoryReload() { + this.callback(); + + // Cancel the load. + return false; + }, +}; + +/** + * This class informs SessionStore.sys.mjs whenever the network requests for a + * restoring page have completely finished. We only restore three tabs + * simultaneously, so this is the signal for SessionStore.sys.mjs to kick off + * another restore (if there are more to do). + * + * The progress listener is also used to be notified when a load not initiated + * by sessionstore starts. Pending tabs will then need to be marked as no + * longer pending. + */ +function ProgressListener(docShell, callbacks) { + let webProgress = docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_STATE_WINDOW); + + this.webProgress = webProgress; + this.callbacks = callbacks; +} + +ProgressListener.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + + uninstall() { + this.webProgress.removeProgressListener(this); + }, + + onStateChange(webProgress, request, stateFlags, _status) { + let { STATE_IS_WINDOW, STATE_STOP, STATE_START } = + Ci.nsIWebProgressListener; + if (!webProgress.isTopLevel || !(stateFlags & STATE_IS_WINDOW)) { + return; + } + + if (stateFlags & STATE_START && this.callbacks.onStartRequest) { + this.callbacks.onStartRequest(); + } + + if (stateFlags & STATE_STOP && this.callbacks.onStopRequest) { + this.callbacks.onStopRequest(); + } + }, +}; diff --git a/browser/components/sessionstore/ContentSessionStore.sys.mjs b/browser/components/sessionstore/ContentSessionStore.sys.mjs new file mode 100644 index 000000000000..d377b9ee543e --- /dev/null +++ b/browser/components/sessionstore/ContentSessionStore.sys.mjs @@ -0,0 +1,685 @@ +/* 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 { + clearTimeout, + setTimeoutWithTarget, +} from "resource://gre/modules/Timer.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ContentRestore: "resource:///modules/sessionstore/ContentRestore.sys.mjs", + SessionHistory: "resource://gre/modules/sessionstore/SessionHistory.sys.mjs", +}); + +// This pref controls whether or not we send updates to the parent on a timeout +// or not, and should only be used for tests or debugging. +const TIMEOUT_DISABLED_PREF = "browser.sessionstore.debug.no_auto_updates"; + +const PREF_INTERVAL = "browser.sessionstore.interval"; + +const kNoIndex = Number.MAX_SAFE_INTEGER; +const kLastIndex = Number.MAX_SAFE_INTEGER - 1; + +class Handler { + constructor(store) { + this.store = store; + } + + get contentRestore() { + return this.store.contentRestore; + } + + get contentRestoreInitialized() { + return this.store.contentRestoreInitialized; + } + + get mm() { + return this.store.mm; + } + + get messageQueue() { + return this.store.messageQueue; + } +} + +/** + * Listens for and handles content events that we need for the + * session store service to be notified of state changes in content. + */ +class EventListener extends Handler { + constructor(store) { + super(store); + + SessionStoreUtils.addDynamicFrameFilteredListener( + this.mm, + "load", + this, + true + ); + } + + handleEvent(event) { + let { content } = this.mm; + + // Ignore load events from subframes. + if (event.target != content.document) { + return; + } + + if (content.document.documentURI.startsWith("about:reader")) { + if ( + event.type == "load" && + !content.document.body.classList.contains("loaded") + ) { + // Don't restore the scroll position of an about:reader page at this + // point; listen for the custom event dispatched from AboutReader.sys.mjs. + content.addEventListener("AboutReaderContentReady", this); + return; + } + + content.removeEventListener("AboutReaderContentReady", this); + } + + if (this.contentRestoreInitialized) { + // Restore the form data and scroll position. + this.contentRestore.restoreDocument(); + } + } +} + +/** + * Listens for changes to the session history. Whenever the user navigates + * we will collect URLs and everything belonging to session history. + * + * Causes a SessionStore:update message to be sent that contains the current + * session history. + * + * Example: + * {entries: [{url: "about:mozilla", ...}, ...], index: 1} + */ +class SessionHistoryListener extends Handler { + constructor(store) { + super(store); + + this._fromIdx = kNoIndex; + + // By adding the SHistoryListener immediately, we will unfortunately be + // notified of every history entry as the tab is restored. We don't bother + // waiting to add the listener later because these notifications are cheap. + // We will likely only collect once since we are batching collection on + // a delay. + this.mm.docShell + .QueryInterface(Ci.nsIWebNavigation) + .sessionHistory.legacySHistory.addSHistoryListener(this); // OK in non-geckoview + + let webProgress = this.mm.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + + webProgress.addProgressListener( + this, + Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT + ); + + // Collect data if we start with a non-empty shistory. + if (!lazy.SessionHistory.isEmpty(this.mm.docShell)) { + this.collect(); + // When a tab is detached from the window, for the new window there is a + // new SessionHistoryListener created. Normally it is empty at this point + // but in a test env. the initial about:blank might have a children in which + // case we fire off a history message here with about:blank in it. If we + // don't do it ASAP then there is going to be a browser swap and the parent + // will be all confused by that message. + this.store.messageQueue.send(); + } + + // Listen for page title changes. + this.mm.addEventListener("DOMTitleChanged", this); + } + + get mm() { + return this.store.mm; + } + + uninit() { + let sessionHistory = this.mm.docShell.QueryInterface( + Ci.nsIWebNavigation + ).sessionHistory; + if (sessionHistory) { + sessionHistory.legacySHistory.removeSHistoryListener(this); // OK in non-geckoview + } + } + + collect() { + // We want to send down a historychange even for full collects in case our + // session history is a partial session history, in which case we don't have + // enough information for a full update. collectFrom(-1) tells the collect + // function to collect all data avaliable in this process. + if (this.mm.docShell) { + this.collectFrom(-1); + } + } + + // History can grow relatively big with the nested elements, so if we don't have to, we + // don't want to send the entire history all the time. For a simple optimization + // we keep track of the smallest index from after any change has occured and we just send + // the elements from that index. If something more complicated happens we just clear it + // and send the entire history. We always send the additional info like the current selected + // index (so for going back and forth between history entries we set the index to kLastIndex + // if nothing else changed send an empty array and the additonal info like the selected index) + collectFrom(idx) { + if (this._fromIdx <= idx) { + // If we already know that we need to update history fromn index N we can ignore any changes + // tha happened with an element with index larger than N. + // Note: initially we use kNoIndex which is MAX_SAFE_INTEGER which means we don't ignore anything + // here, and in case of navigation in the history back and forth we use kLastIndex which ignores + // only the subsequent navigations, but not any new elements added. + return; + } + + this._fromIdx = idx; + this.store.messageQueue.push("historychange", () => { + if (this._fromIdx === kNoIndex) { + return null; + } + + let history = lazy.SessionHistory.collect( + this.mm.docShell, + this._fromIdx + ); + this._fromIdx = kNoIndex; + return history; + }); + } + + handleEvent() { + this.collect(); + } + + OnHistoryNewEntry(newURI, oldIndex) { + // Collect the current entry as well, to make sure to collect any changes + // that were made to the entry while the document was active. + this.collectFrom(oldIndex == -1 ? oldIndex : oldIndex - 1); + } + + OnHistoryGotoIndex() { + // We ought to collect the previously current entry as well, see bug 1350567. + this.collectFrom(kLastIndex); + } + + OnHistoryPurge() { + this.collect(); + } + + OnHistoryReload() { + this.collect(); + return true; + } + + OnHistoryReplaceEntry() { + this.collect(); + } + + /** + * @see nsIWebProgressListener.onStateChange + */ + onStateChange(webProgress, request, stateFlags, _status) { + // Ignore state changes for subframes because we're only interested in the + // top-document starting or stopping its load. + if (!webProgress.isTopLevel || webProgress.DOMWindow != this.mm.content) { + return; + } + + // onStateChange will be fired when loading the initial about:blank URI for + // a browser, which we don't actually care about. This is particularly for + // the case of unrestored background tabs, where the content has not yet + // been restored: we don't want to accidentally send any updates to the + // parent when the about:blank placeholder page has loaded. + if (!this.mm.docShell.hasLoadedNonBlankURI) { + return; + } + + if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { + this.collect(); + } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + this.collect(); + } + } +} +SessionHistoryListener.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISHistoryListener", + "nsISupportsWeakReference", +]); + +/** + * A message queue that takes collected data and will take care of sending it + * to the chrome process. It allows flushing using synchronous messages and + * takes care of any race conditions that might occur because of that. Changes + * will be batched if they're pushed in quick succession to avoid a message + * flood. + */ +class MessageQueue extends Handler { + constructor(store) { + super(store); + + /** + * A map (string -> lazy fn) holding lazy closures of all queued data + * collection routines. These functions will return data collected from the + * docShell. + */ + this._data = new Map(); + + /** + * The delay (in ms) used to delay sending changes after data has been + * invalidated. + */ + this.BATCH_DELAY_MS = 1000; + + /** + * The minimum idle period (in ms) we need for sending data to chrome process. + */ + this.NEEDED_IDLE_PERIOD_MS = 5; + + /** + * Timeout for waiting an idle period to send data. We will set this from + * the pref "browser.sessionstore.interval". + */ + this._timeoutWaitIdlePeriodMs = null; + + /** + * The current timeout ID, null if there is no queue data. We use timeouts + * to damp a flood of data changes and send lots of changes as one batch. + */ + this._timeout = null; + + /** + * Whether or not sending batched messages on a timer is disabled. This should + * only be used for debugging or testing. If you need to access this value, + * you should probably use the timeoutDisabled getter. + */ + this._timeoutDisabled = false; + + /** + * True if there is already a send pending idle dispatch, set to prevent + * scheduling more than one. If false there may or may not be one scheduled. + */ + this._idleScheduled = false; + + this.timeoutDisabled = Services.prefs.getBoolPref(TIMEOUT_DISABLED_PREF); + this._timeoutWaitIdlePeriodMs = Services.prefs.getIntPref(PREF_INTERVAL); + + Services.prefs.addObserver(TIMEOUT_DISABLED_PREF, this); + Services.prefs.addObserver(PREF_INTERVAL, this); + } + + /** + * True if batched messages are not being fired on a timer. This should only + * ever be true when debugging or during tests. + */ + get timeoutDisabled() { + return this._timeoutDisabled; + } + + /** + * Disables sending batched messages on a timer. Also cancels any pending + * timers. + */ + set timeoutDisabled(val) { + this._timeoutDisabled = val; + + if (val && this._timeout) { + clearTimeout(this._timeout); + this._timeout = null; + } + } + + uninit() { + Services.prefs.removeObserver(TIMEOUT_DISABLED_PREF, this); + Services.prefs.removeObserver(PREF_INTERVAL, this); + this.cleanupTimers(); + } + + /** + * Cleanup pending idle callback and timer. + */ + cleanupTimers() { + this._idleScheduled = false; + if (this._timeout) { + clearTimeout(this._timeout); + this._timeout = null; + } + } + + observe(subject, topic, data) { + if (topic == "nsPref:changed") { + switch (data) { + case TIMEOUT_DISABLED_PREF: + this.timeoutDisabled = Services.prefs.getBoolPref( + TIMEOUT_DISABLED_PREF + ); + break; + case PREF_INTERVAL: + this._timeoutWaitIdlePeriodMs = + Services.prefs.getIntPref(PREF_INTERVAL); + break; + default: + console.error("received unknown message '" + data + "'"); + break; + } + } + } + + /** + * Pushes a given |value| onto the queue. The given |key| represents the type + * of data that is stored and can override data that has been queued before + * but has not been sent to the parent process, yet. + * + * @param key (string) + * A unique identifier specific to the type of data this is passed. + * @param fn (function) + * A function that returns the value that will be sent to the parent + * process. + */ + push(key, fn) { + this._data.set(key, fn); + + if (!this._timeout && !this._timeoutDisabled) { + // Wait a little before sending the message to batch multiple changes. + this._timeout = setTimeoutWithTarget( + () => this.sendWhenIdle(), + this.BATCH_DELAY_MS, + this.mm.tabEventTarget + ); + } + } + + /** + * Sends queued data when the remaining idle time is enough or waiting too + * long; otherwise, request an idle time again. If the |deadline| is not + * given, this function is going to schedule the first request. + * + * @param deadline (object) + * An IdleDeadline object passed by idleDispatch(). + */ + sendWhenIdle(deadline) { + if (!this.mm.content) { + // The frameloader is being torn down. Nothing more to do. + return; + } + + if (deadline) { + if ( + deadline.didTimeout || + deadline.timeRemaining() > this.NEEDED_IDLE_PERIOD_MS + ) { + this.send(); + return; + } + } else if (this._idleScheduled) { + // Bail out if there's a pending run. + return; + } + ChromeUtils.idleDispatch(deadline_ => this.sendWhenIdle(deadline_), { + timeout: this._timeoutWaitIdlePeriodMs, + }); + this._idleScheduled = true; + } + + /** + * Sends queued data to the chrome process. + * + * @param options (object) + * {flushID: 123} to specify that this is a flush + * {isFinal: true} to signal this is the final message sent on unload + */ + send(options = {}) { + // Looks like we have been called off a timeout after the tab has been + // closed. The docShell is gone now and we can just return here as there + // is nothing to do. + if (!this.mm.docShell) { + return; + } + + this.cleanupTimers(); + + let flushID = (options && options.flushID) || 0; + let histID = "FX_SESSION_RESTORE_CONTENT_COLLECT_DATA_MS"; + + let data = {}; + for (let [key, func] of this._data) { + if (key != "isPrivate") { + TelemetryStopwatch.startKeyed(histID, key); + } + + let value = func(); + + if (key != "isPrivate") { + TelemetryStopwatch.finishKeyed(histID, key); + } + + if (value || (key != "storagechange" && key != "historychange")) { + data[key] = value; + } + } + + this._data.clear(); + + try { + // Send all data to the parent process. + this.mm.sendAsyncMessage("SessionStore:update", { + data, + flushID, + isFinal: options.isFinal || false, + epoch: this.store.epoch, + }); + } catch (ex) { + if (ex && ex.result == Cr.NS_ERROR_OUT_OF_MEMORY) { + Services.telemetry + .getHistogramById("FX_SESSION_RESTORE_SEND_UPDATE_CAUSED_OOM") + .add(1); + this.mm.sendAsyncMessage("SessionStore:error"); + } + } + } +} + +/** + * Listens for and handles messages sent by the session store service. + */ +const MESSAGES = [ + "SessionStore:restoreHistory", + "SessionStore:restoreTabContent", + "SessionStore:resetRestore", + "SessionStore:flush", + "SessionStore:prepareForProcessChange", +]; + +export class ContentSessionStore { + constructor(mm) { + if (Services.appinfo.sessionHistoryInParent) { + throw new Error("This frame script should not be loaded for SHIP"); + } + + this.mm = mm; + this.messageQueue = new MessageQueue(this); + + this.epoch = 0; + + this.contentRestoreInitialized = false; + + this.handlers = [ + this.messageQueue, + new EventListener(this), + new SessionHistoryListener(this), + ]; + + ChromeUtils.defineLazyGetter(this, "contentRestore", () => { + this.contentRestoreInitialized = true; + return new lazy.ContentRestore(mm); + }); + + MESSAGES.forEach(m => mm.addMessageListener(m, this)); + + mm.addEventListener("unload", this); + } + + receiveMessage({ name, data }) { + // The docShell might be gone. Don't process messages, + // that will just lead to errors anyway. + if (!this.mm.docShell) { + return; + } + + // A fresh tab always starts with epoch=0. The parent has the ability to + // override that to signal a new era in this tab's life. This enables it + // to ignore async messages that were already sent but not yet received + // and would otherwise confuse the internal tab state. + if (data && data.epoch && data.epoch != this.epoch) { + this.epoch = data.epoch; + } + + switch (name) { + case "SessionStore:restoreHistory": + this.restoreHistory(data); + break; + case "SessionStore:restoreTabContent": + this.restoreTabContent(data); + break; + case "SessionStore:resetRestore": + this.contentRestore.resetRestore(); + break; + case "SessionStore:flush": + this.flush(data); + break; + case "SessionStore:prepareForProcessChange": + // During normal in-process navigations, the DocShell would take + // care of automatically persisting layout history state to record + // scroll positions on the nsSHEntry. Unfortunately, process switching + // is not a normal navigation, so for now we do this ourselves. This + // is a workaround until session history state finally lives in the + // parent process. + this.mm.docShell.persistLayoutHistoryState(); + break; + default: + console.error("received unknown message '" + name + "'"); + break; + } + } + + // non-SHIP only + restoreHistory(data) { + let { epoch, tabData, loadArguments, isRemotenessUpdate } = data; + + this.contentRestore.restoreHistory(tabData, loadArguments, { + // Note: The callbacks passed here will only be used when a load starts + // that was not initiated by sessionstore itself. This can happen when + // some code calls browser.loadURI() or browser.reload() on a pending + // browser/tab. + + onLoadStarted: () => { + // Notify the parent that the tab is no longer pending. + this.mm.sendAsyncMessage("SessionStore:restoreTabContentStarted", { + epoch, + }); + }, + + onLoadFinished: () => { + // Tell SessionStore.sys.mjs that it may want to restore some more tabs, + // since it restores a max of MAX_CONCURRENT_TAB_RESTORES at a time. + this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", { + epoch, + }); + }, + }); + + if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT) { + // For non-remote tabs, when restoreHistory finishes, we send a synchronous + // message to SessionStore.sys.mjs so that it can run SSTabRestoring. Users of + // SSTabRestoring seem to get confused if chrome and content are out of + // sync about the state of the restore (particularly regarding + // docShell.currentURI). Using a synchronous message is the easiest way + // to temporarily synchronize them. + // + // For remote tabs, because all nsIWebProgress notifications are sent + // asynchronously using messages, we get the same-order guarantees of the + // message manager, and can use an async message. + this.mm.sendSyncMessage("SessionStore:restoreHistoryComplete", { + epoch, + isRemotenessUpdate, + }); + } else { + this.mm.sendAsyncMessage("SessionStore:restoreHistoryComplete", { + epoch, + isRemotenessUpdate, + }); + } + } + + restoreTabContent({ loadArguments, isRemotenessUpdate, reason }) { + let epoch = this.epoch; + + // We need to pass the value of didStartLoad back to SessionStore.sys.mjs. + let didStartLoad = this.contentRestore.restoreTabContent( + loadArguments, + isRemotenessUpdate, + () => { + // Tell SessionStore.sys.mjs that it may want to restore some more tabs, + // since it restores a max of MAX_CONCURRENT_TAB_RESTORES at a time. + this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", { + epoch, + isRemotenessUpdate, + }); + } + ); + + this.mm.sendAsyncMessage("SessionStore:restoreTabContentStarted", { + epoch, + isRemotenessUpdate, + reason, + }); + + if (!didStartLoad) { + // Pretend that the load succeeded so that event handlers fire correctly. + this.mm.sendAsyncMessage("SessionStore:restoreTabContentComplete", { + epoch, + isRemotenessUpdate, + }); + } + } + + flush({ id }) { + // Flush the message queue, send the latest updates. + this.messageQueue.send({ flushID: id }); + } + + handleEvent(event) { + if (event.type == "unload") { + this.onUnload(); + } + } + + onUnload() { + // Upon frameLoader destruction, send a final update message to + // the parent and flush all data currently held in the child. + this.messageQueue.send({ isFinal: true }); + + for (let handler of this.handlers) { + if (handler.uninit) { + handler.uninit(); + } + } + + if (this.contentRestoreInitialized) { + // Remove progress listeners. + this.contentRestore.resetRestore(); + } + + // We don't need to take care of any StateChangeNotifier observers as they + // will die with the content script. The same goes for the privacy transition + // observer that will die with the docShell when the tab is closed. + } +} diff --git a/browser/components/sessionstore/SessionStore.sys.mjs b/browser/components/sessionstore/SessionStore.sys.mjs index 273c80674365..cd2c397e09a7 100644 --- a/browser/components/sessionstore/SessionStore.sys.mjs +++ b/browser/components/sessionstore/SessionStore.sys.mjs @@ -109,6 +109,59 @@ const WINDOW_OPEN_FEATURES_MAP = { statusbar: "status", }; +// Messages that will be received via the Frame Message Manager. +const MESSAGES = [ + // The content script sends us data that has been invalidated and needs to + // be saved to disk. + "SessionStore:update", + + // The restoreHistory code has run. This is a good time to run SSTabRestoring. + "SessionStore:restoreHistoryComplete", + + // The load for the restoring tab has begun. We update the URL bar at this + // time; if we did it before, the load would overwrite it. + "SessionStore:restoreTabContentStarted", + + // All network loads for a restoring tab are done, so we should + // consider restoring another tab in the queue. The document has + // been restored, and forms have been filled. We trigger + // SSTabRestored at this time. + "SessionStore:restoreTabContentComplete", + + // The content script encountered an error. + "SessionStore:error", +]; + +// The list of messages we accept from s that have no tab +// assigned, or whose windows have gone away. Those are for example the +// ones that preload about:newtab pages, or from browsers where the window +// has just been closed. +const NOTAB_MESSAGES = new Set([ + // For a description see above. + "SessionStore:update", + + // For a description see above. + "SessionStore:error", +]); + +// The list of messages we accept without an "epoch" parameter. +// See getCurrentEpoch() and friends to find out what an "epoch" is. +const NOEPOCH_MESSAGES = new Set([ + // For a description see above. + "SessionStore:error", +]); + +// The list of messages we want to receive even during the short period after a +// frame has been removed from the DOM and before its frame script has finished +// unloading. +const CLOSED_MESSAGES = new Set([ + // For a description see above. + "SessionStore:update", + + // For a description see above. + "SessionStore:error", +]); + // These are tab events that we listen to. const TAB_EVENTS = [ "TabOpen", @@ -592,6 +645,10 @@ export var SessionStore = { SessionStoreInternal.deleteCustomGlobalValue(aKey); }, + persistTabAttribute: function ss_persistTabAttribute(aName) { + SessionStoreInternal.persistTabAttribute(aName); + }, + restoreLastSession: function ss_restoreLastSession() { SessionStoreInternal.restoreLastSession(); }, @@ -755,6 +812,18 @@ export var SessionStore = { } }, + /** + * Prepares to change the remoteness of the given browser, by ensuring that + * the local instance of session history is up-to-date. + */ + async prepareToChangeRemoteness(aTab) { + await SessionStoreInternal.prepareToChangeRemoteness(aTab); + }, + + finishTabRemotenessChange(aTab, aSwitchId) { + SessionStoreInternal.finishTabRemotenessChange(aTab, aSwitchId); + }, + /** * Clear session store data for a given private browsing window. * @param {ChromeWindow} win - Open private browsing window to clear data for. @@ -1285,6 +1354,8 @@ var SessionStoreInternal = { "privacy.resistFingerprinting" ); Services.prefs.addObserver("privacy.resistFingerprinting", this); + + this._shistoryInParent = Services.appinfo.sessionHistoryInParent; }, /** @@ -1363,26 +1434,33 @@ var SessionStoreInternal = { } break; case "browsing-context-did-set-embedder": - if ( - aSubject && - aSubject === aSubject.top && - aSubject.isContent && - aSubject.embedderElement && - aSubject.embedderElement.permanentKey - ) { - let permanentKey = aSubject.embedderElement.permanentKey; - this._browserSHistoryListener.get(permanentKey)?.unregister(); - this.getOrCreateSHistoryListener(permanentKey, aSubject, true); + if (Services.appinfo.sessionHistoryInParent) { + if ( + aSubject && + aSubject === aSubject.top && + aSubject.isContent && + aSubject.embedderElement && + aSubject.embedderElement.permanentKey + ) { + let permanentKey = aSubject.embedderElement.permanentKey; + this._browserSHistoryListener.get(permanentKey)?.unregister(); + this.getOrCreateSHistoryListener(permanentKey, aSubject, true); + } } break; case "browsing-context-discarded": - let permanentKey = aSubject?.embedderElement?.permanentKey; - if (permanentKey) { - this._browserSHistoryListener.get(permanentKey)?.unregister(); + if (Services.appinfo.sessionHistoryInParent) { + let permanentKey = aSubject?.embedderElement?.permanentKey; + if (permanentKey) { + this._browserSHistoryListener.get(permanentKey)?.unregister(); + } } break; case "browser-shutdown-tabstate-updated": - this.onFinalTabStateUpdateComplete(aSubject); + if (Services.appinfo.sessionHistoryInParent) { + // Non-SHIP code calls this when the frame script is unloaded. + this.onFinalTabStateUpdateComplete(aSubject); + } this._notifyOfClosedObjectsChange(); break; } @@ -1495,6 +1573,10 @@ var SessionStoreInternal = { } } + if (!Services.appinfo.sessionHistoryInParent) { + throw new Error("This function should only be used with SHIP"); + } + if (!permanentKey || browsingContext !== browsingContext.top) { return null; } @@ -1609,27 +1691,29 @@ var SessionStoreInternal = { return; } - let listener = this.getOrCreateSHistoryListener( - permanentKey, - browsingContext - ); + if (Services.appinfo.sessionHistoryInParent) { + let listener = this.getOrCreateSHistoryListener( + permanentKey, + browsingContext + ); - if (listener) { - let historychange = - // If it is not the scheduled update (tab closed, window closed etc), - // try to store the loading non-web-controlled page opened in _blank - // first. - (forStorage && - lazy.SessionHistory.collectNonWebControlledBlankLoadingSession( - browsingContext - )) || - listener.collect(permanentKey, browsingContext, { - collectFull: !!update.sHistoryNeeded, - writeToCache: false, - }); + if (listener) { + let historychange = + // If it is not the scheduled update (tab closed, window closed etc), + // try to store the loading non-web-controlled page opened in _blank + // first. + (forStorage && + lazy.SessionHistory.collectNonWebControlledBlankLoadingSession( + browsingContext + )) || + listener.collect(permanentKey, browsingContext, { + collectFull: !!update.sHistoryNeeded, + writeToCache: false, + }); - if (historychange) { - update.data.historychange = historychange; + if (historychange) { + update.data.historychange = historychange; + } } } @@ -1640,6 +1724,98 @@ var SessionStoreInternal = { this.onTabStateUpdate(permanentKey, win, update); }, + /** + * This method handles incoming messages sent by the session store content + * script via the Frame Message Manager or Parent Process Message Manager, + * and thus enables communication with OOP tabs. + */ + receiveMessage(aMessage) { + if (Services.appinfo.sessionHistoryInParent) { + throw new Error( + `received unexpected message '${aMessage.name}' with ` + + `sessionHistoryInParent enabled` + ); + } + + // If we got here, that means we're dealing with a frame message + // manager message, so the target will be a . + var browser = aMessage.target; + let win = browser.ownerGlobal; + let tab = win ? win.gBrowser.getTabForBrowser(browser) : null; + + // Ensure we receive only specific messages from s that + // have no tab or window assigned, e.g. the ones that preload + // about:newtab pages, or windows that have closed. + if (!tab && !NOTAB_MESSAGES.has(aMessage.name)) { + throw new Error( + `received unexpected message '${aMessage.name}' ` + + `from a browser that has no tab or window` + ); + } + + let data = aMessage.data || {}; + let hasEpoch = data.hasOwnProperty("epoch"); + + // Most messages sent by frame scripts require to pass an epoch. + if (!hasEpoch && !NOEPOCH_MESSAGES.has(aMessage.name)) { + throw new Error(`received message '${aMessage.name}' without an epoch`); + } + + // Ignore messages from previous epochs. + if (hasEpoch && !this.isCurrentEpoch(browser.permanentKey, data.epoch)) { + return; + } + + switch (aMessage.name) { + case "SessionStore:update": + // |browser.frameLoader| might be empty if the browser was already + // destroyed and its tab removed. In that case we still have the last + // frameLoader we know about to compare. + let frameLoader = + browser.frameLoader || + this._lastKnownFrameLoader.get(browser.permanentKey); + + // If the message isn't targeting the latest frameLoader discard it. + if (frameLoader != aMessage.targetFrameLoader) { + return; + } + + this.onTabStateUpdate(browser.permanentKey, browser.ownerGlobal, data); + + // SHIP code will call this when it receives "browser-shutdown-tabstate-updated" + if (data.isFinal) { + if (!Services.appinfo.sessionHistoryInParent) { + this.onFinalTabStateUpdateComplete(browser); + } + } else if (data.flushID) { + // This is an update kicked off by an async flush request. Notify the + // TabStateFlusher so that it can finish the request and notify its + // consumer that's waiting for the flush to be done. + lazy.TabStateFlusher.resolve(browser, data.flushID); + } + + break; + case "SessionStore:restoreHistoryComplete": + this._restoreHistoryComplete(browser); + break; + case "SessionStore:restoreTabContentStarted": + this._restoreTabContentStarted(browser, data); + break; + case "SessionStore:restoreTabContentComplete": + this._restoreTabContentComplete(browser, data); + break; + case "SessionStore:error": + lazy.TabStateFlusher.resolveAll( + browser, + false, + "Received error from the content process" + ); + break; + default: + throw new Error(`received unknown message '${aMessage.name}'`); + } + }, + /* ........ Window Event Handlers .............. */ /** @@ -1741,6 +1917,21 @@ var SessionStoreInternal = { // internal data about the window. aWindow.__SSi = this._generateWindowID(); + if (!Services.appinfo.sessionHistoryInParent) { + let mm = aWindow.getGroupMessageManager("browsers"); + MESSAGES.forEach(msg => { + let listenWhenClosed = CLOSED_MESSAGES.has(msg); + mm.addMessageListener(msg, this, listenWhenClosed); + }); + + // Load the frame script after registering listeners. + mm.loadFrameScript( + "chrome://browser/content/content-sessionStore.js", + true, + true + ); + } + // and create its data object this._windows[aWindow.__SSi] = { tabs: [], @@ -2211,6 +2402,11 @@ var SessionStoreInternal = { // Cache the window state until it is completely gone. DyingWindowCache.set(aWindow, winData); + if (!Services.appinfo.sessionHistoryInParent) { + let mm = aWindow.getGroupMessageManager("browsers"); + MESSAGES.forEach(msg => mm.removeMessageListener(msg, this)); + } + this._saveableClosedWindowData.delete(winData); delete aWindow.__SSi; }, @@ -4077,6 +4273,12 @@ var SessionStoreInternal = { this.saveStateDelayed(); }, + persistTabAttribute: function ssi_persistTabAttribute(aName) { + if (lazy.TabAttributes.persist(aName)) { + this.saveStateDelayed(); + } + }, + /** * Undoes the closing of a tab or window which corresponds * to the closedId passed in. @@ -5278,6 +5480,13 @@ var SessionStoreInternal = { tab.updateLastAccessed(tabData.lastAccessed); } + if ("attributes" in tabData) { + // Ensure that we persist tab attributes restored from previous sessions. + Object.keys(tabData.attributes).forEach(a => + lazy.TabAttributes.persist(a) + ); + } + if (!tabData.entries) { tabData.entries = []; } @@ -5447,6 +5656,7 @@ var SessionStoreInternal = { let browser = aTab.linkedBrowser; let window = aTab.ownerGlobal; + let tabbrowser = window.gBrowser; let tabData = lazy.TabState.clone(aTab, TAB_CUSTOM_VALUES.get(aTab)); let activeIndex = tabData.index - 1; let activePageData = tabData.entries[activeIndex] || null; @@ -5454,9 +5664,36 @@ var SessionStoreInternal = { this.markTabAsRestoring(aTab); + let isRemotenessUpdate = aOptions.isRemotenessUpdate; + let explicitlyUpdateRemoteness = !Services.appinfo.sessionHistoryInParent; + // If we aren't already updating the browser's remoteness, check if it's + // necessary. + if (explicitlyUpdateRemoteness && !isRemotenessUpdate) { + isRemotenessUpdate = tabbrowser.updateBrowserRemotenessByURL( + browser, + uri + ); + + if (isRemotenessUpdate) { + // We updated the remoteness, so we need to send the history down again. + // + // Start a new epoch to discard all frame script messages relating to a + // previous epoch. All async messages that are still on their way to chrome + // will be ignored and don't override any tab data set when restoring. + let epoch = this.startNextEpoch(browser.permanentKey); + + this._sendRestoreHistory(browser, { + tabData, + epoch, + loadArguments, + isRemotenessUpdate, + }); + } + } + this._sendRestoreTabContent(browser, { loadArguments, - isRemotenessUpdate: aOptions.isRemotenessUpdate, + isRemotenessUpdate, reason: aOptions.restoreContentReason || RESTORE_TAB_CONTENT_REASON.SET_STATE, }); @@ -6591,8 +6828,10 @@ var SessionStoreInternal = { // The browser is no longer in any sort of restoring state. TAB_STATE_FOR_BROWSER.delete(browser); - this._restoreListeners.get(browser.permanentKey)?.unregister(); - browser.browsingContext.clearRestoreState(); + if (Services.appinfo.sessionHistoryInParent) { + this._restoreListeners.get(browser.permanentKey)?.unregister(); + browser.browsingContext.clearRestoreState(); + } aTab.removeAttribute("pending"); @@ -6616,6 +6855,9 @@ var SessionStoreInternal = { return; } + if (!Services.appinfo.sessionHistoryInParent) { + browser.messageManager.sendAsyncMessage("SessionStore:resetRestore", {}); + } this._resetLocalTabRestoringState(tab); }, @@ -6901,6 +7143,10 @@ var SessionStoreInternal = { * history restores. */ _restoreHistory(browser, data) { + if (!Services.appinfo.sessionHistoryInParent) { + throw new Error("This function should only be used with SHIP"); + } + this._tabStateToRestore.set(browser.permanentKey, data); // In case about:blank isn't done yet. @@ -6992,6 +7238,10 @@ var SessionStoreInternal = { * history restores. */ _restoreTabContent(browser, options = {}) { + if (!Services.appinfo.sessionHistoryInParent) { + throw new Error("This function should only be used with SHIP"); + } + this._restoreListeners.get(browser.permanentKey)?.unregister(); this._restoreTabContentStarted(browser, options); @@ -7016,7 +7266,14 @@ var SessionStoreInternal = { }, _sendRestoreTabContent(browser, options) { - this._restoreTabContent(browser, options); + if (Services.appinfo.sessionHistoryInParent) { + this._restoreTabContent(browser, options); + } else { + browser.messageManager.sendAsyncMessage( + "SessionStore:restoreTabContent", + options + ); + } }, _restoreHistoryComplete(browser) { @@ -7160,12 +7417,68 @@ var SessionStoreInternal = { delete options.tabData.storage; } - this._restoreHistory(browser, options); + if (Services.appinfo.sessionHistoryInParent) { + this._restoreHistory(browser, options); + } else { + browser.messageManager.sendAsyncMessage( + "SessionStore:restoreHistory", + options + ); + } if (browser && browser.frameLoader) { browser.frameLoader.requestEpochUpdate(options.epoch); } }, + + // Flush out session history state so that it can be used to restore the state + // into a new process in `finishTabRemotenessChange`. + // + // NOTE: This codepath is temporary while the Fission Session History rewrite + // is in process, and will be removed & replaced once that rewrite is + // complete. (bug 1645062) + async prepareToChangeRemoteness(aBrowser) { + aBrowser.messageManager.sendAsyncMessage( + "SessionStore:prepareForProcessChange" + ); + await lazy.TabStateFlusher.flush(aBrowser); + }, + + // Handle finishing the remoteness change for a tab by restoring session + // history state into it, and resuming the ongoing network load. + // + // NOTE: This codepath is temporary while the Fission Session History rewrite + // is in process, and will be removed & replaced once that rewrite is + // complete. (bug 1645062) + finishTabRemotenessChange(aTab, aSwitchId) { + let window = aTab.ownerGlobal; + if (!window || !window.__SSi || window.closed) { + return; + } + + let tabState = lazy.TabState.clone(aTab, TAB_CUSTOM_VALUES.get(aTab)); + let options = { + restoreImmediately: true, + restoreContentReason: RESTORE_TAB_CONTENT_REASON.NAVIGATE_AND_RESTORE, + isRemotenessUpdate: true, + loadArguments: { + redirectLoadSwitchId: aSwitchId, + // As we're resuming a load which has been redirected from another + // process, record the history index which is currently being requested. + // It has to be offset by 1 to get back to native history indices from + // SessionStore history indicies. + redirectHistoryIndex: tabState.requestedIndex - 1, + }, + }; + + // Need to reset restoring tabs. + if (TAB_STATE_FOR_BROWSER.has(aTab.linkedBrowser)) { + this._resetLocalTabRestoringState(aTab); + } + + // Restore the state into the tab. + this.restoreTab(aTab, tabState, options); + }, }; /** diff --git a/browser/components/sessionstore/TabAttributes.sys.mjs b/browser/components/sessionstore/TabAttributes.sys.mjs index ea53156d129c..1c7f54b6abcb 100644 --- a/browser/components/sessionstore/TabAttributes.sys.mjs +++ b/browser/components/sessionstore/TabAttributes.sys.mjs @@ -2,13 +2,27 @@ * 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/. */ -// Tab attributes which are persisted & restored by SessionStore. -const PERSISTED_ATTRIBUTES = ["customizemode"]; +// We never want to directly read or write these attributes. +// 'image' should not be accessed directly but handled by using the +// gBrowser.getIcon()/setIcon() methods. +// 'muted' should not be accessed directly but handled by using the +// tab.linkedBrowser.audioMuted/toggleMuteAudio methods. +// 'pending' is used internal by sessionstore and managed accordingly. +const ATTRIBUTES_TO_SKIP = new Set([ + "image", + "muted", + "pending", + "skipbackgroundnotify", +]); // A set of tab attributes to persist. We will read a given list of tab // attributes when collecting tab data and will re-set those attributes when // the given tab data is restored to a new tab. export var TabAttributes = Object.freeze({ + persist(name) { + return TabAttributesInternal.persist(name); + }, + get(tab) { return TabAttributesInternal.get(tab); }, @@ -19,10 +33,21 @@ export var TabAttributes = Object.freeze({ }); var TabAttributesInternal = { + _attrs: new Set(), + + persist(name) { + if (this._attrs.has(name) || ATTRIBUTES_TO_SKIP.has(name)) { + return false; + } + + this._attrs.add(name); + return true; + }, + get(tab) { let data = {}; - for (let name of PERSISTED_ATTRIBUTES) { + for (let name of this._attrs) { if (tab.hasAttribute(name)) { data[name] = tab.getAttribute(name); } @@ -32,11 +57,15 @@ var TabAttributesInternal = { }, set(tab, data = {}) { - // Clear & Set attributes. - for (let name of PERSISTED_ATTRIBUTES) { + // Clear attributes. + for (let name of this._attrs) { tab.removeAttribute(name); - if (name in data) { - tab.setAttribute(name, data[name]); + } + + // Set attributes. + for (let [name, value] of Object.entries(data)) { + if (!ATTRIBUTES_TO_SKIP.has(name)) { + tab.setAttribute(name, value); } } }, diff --git a/browser/components/sessionstore/TabStateFlusher.sys.mjs b/browser/components/sessionstore/TabStateFlusher.sys.mjs index ed7953e41e8d..e391abc970ed 100644 --- a/browser/components/sessionstore/TabStateFlusher.sys.mjs +++ b/browser/components/sessionstore/TabStateFlusher.sys.mjs @@ -2,6 +2,11 @@ * 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/. */ +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + /** * A module that enables async flushes. Updates from frame scripts are * throttled to be sent only once per second. If an action wants a tab's latest @@ -27,6 +32,23 @@ export var TabStateFlusher = Object.freeze({ return TabStateFlusherInternal.flushWindow(window); }, + /** + * Resolves the flush request with the given flush ID. + * + * @param browser () + * The browser for which the flush is being resolved. + * @param flushID (int) + * The ID of the flush that was sent to the browser. + * @param success (bool, optional) + * Whether or not the flush succeeded. + * @param message (string, optional) + * An error message that will be sent to the Console in the + * event that a flush failed. + */ + resolve(browser, flushID, success = true, message = "") { + TabStateFlusherInternal.resolve(browser, flushID, success, message); + }, + /** * Resolves all active flush requests for a given browser. This should be * used when the content process crashed or the final update message was @@ -47,6 +69,9 @@ export var TabStateFlusher = Object.freeze({ }); var TabStateFlusherInternal = { + // Stores the last request ID. + _lastRequestID: 0, + // A map storing all active requests per browser. A request is a // triple of a map containing all flush requests, a promise that // resolve when a request for a browser is canceled, and the @@ -54,6 +79,7 @@ var TabStateFlusherInternal = { _requests: new WeakMap(), initEntry(entry) { + entry.perBrowserRequests = new Map(); entry.cancelPromise = new Promise(resolve => { entry.cancel = resolve; }).then(result => { @@ -70,6 +96,7 @@ var TabStateFlusherInternal = { * all the latest data. */ flush(browser) { + let id = ++this._lastRequestID; let nativePromise = Promise.resolve(); if (browser && browser.frameLoader) { /* @@ -79,6 +106,24 @@ var TabStateFlusherInternal = { nativePromise = browser.frameLoader.requestTabStateFlush(); } + if (!Services.appinfo.sessionHistoryInParent) { + /* + In the event that we have to trigger a process switch and thus change + browser remoteness, session store needs to register and track the new + browser window loaded and to have message manager listener registered + ** before ** TabStateFlusher send "SessionStore:flush" message. This fixes + the race where we send the message before the message listener is + registered for it. + */ + lazy.SessionStore.ensureInitialized(browser.ownerGlobal); + + let mm = browser.messageManager; + mm.sendAsyncMessage("SessionStore:flush", { + id, + epoch: lazy.SessionStore.getCurrentEpoch(browser), + }); + } + // Retrieve active requests for given browser. let permanentKey = browser.permanentKey; let request = this._requests.get(permanentKey); @@ -89,10 +134,22 @@ var TabStateFlusherInternal = { this._requests.set(permanentKey, request); } - // It's fine to resolve the request immediately after the native promise - // resolves, since SessionStore will have processed all updates from this - // browser by that point. - return Promise.race([nativePromise, request.cancelPromise]); + // Non-SHIP flushes resolve this after the "SessionStore:update" message. We + // don't use that message for SHIP, so it's fine to resolve the request + // immediately after the native promise resolves, since SessionStore will + // have processed all updates from this browser by that point. + let requestPromise = Promise.resolve(); + if (!Services.appinfo.sessionHistoryInParent) { + requestPromise = new Promise(resolve => { + // Store resolve() so that we can resolve the promise later. + request.perBrowserRequests.set(id, resolve); + }); + } + + return Promise.race([ + nativePromise.then(_ => requestPromise), + request.cancelPromise, + ]); }, /** @@ -109,6 +166,41 @@ var TabStateFlusherInternal = { return Promise.all(promises); }, + /** + * Resolves the flush request with the given flush ID. + * + * @param browser () + * The browser for which the flush is being resolved. + * @param flushID (int) + * The ID of the flush that was sent to the browser. + * @param success (bool, optional) + * Whether or not the flush succeeded. + * @param message (string, optional) + * An error message that will be sent to the Console in the + * event that a flush failed. + */ + resolve(browser, flushID, success = true, message = "") { + // Nothing to do if there are no pending flushes for the given browser. + if (!this._requests.has(browser.permanentKey)) { + return; + } + + // Retrieve active requests for given browser. + let { perBrowserRequests } = this._requests.get(browser.permanentKey); + if (!perBrowserRequests.has(flushID)) { + return; + } + + if (!success) { + console.error("Failed to flush browser: ", message); + } + + // Resolve the request with the given id. + let resolve = perBrowserRequests.get(flushID); + perBrowserRequests.delete(flushID); + resolve(success); + }, + /** * Resolves all active flush requests for a given browser. This should be * used when the content process crashed or the final update message was diff --git a/browser/components/sessionstore/content/content-sessionStore.js b/browser/components/sessionstore/content/content-sessionStore.js new file mode 100644 index 000000000000..a4bdea0bdc46 --- /dev/null +++ b/browser/components/sessionstore/content/content-sessionStore.js @@ -0,0 +1,13 @@ +/* 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/. */ + +/* eslint-env mozilla/frame-script */ + +"use strict"; + +const { ContentSessionStore } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/ContentSessionStore.sys.mjs" +); + +void new ContentSessionStore(this); diff --git a/browser/components/sessionstore/jar.mn b/browser/components/sessionstore/jar.mn index b31a4fb35192..7e5bc07dc639 100644 --- a/browser/components/sessionstore/jar.mn +++ b/browser/components/sessionstore/jar.mn @@ -5,3 +5,4 @@ browser.jar: * content/browser/aboutSessionRestore.xhtml (content/aboutSessionRestore.xhtml) content/browser/aboutSessionRestore.js (content/aboutSessionRestore.js) + content/browser/content-sessionStore.js (content/content-sessionStore.js) diff --git a/browser/components/sessionstore/moz.build b/browser/components/sessionstore/moz.build index 818c85f862d1..15368267331d 100644 --- a/browser/components/sessionstore/moz.build +++ b/browser/components/sessionstore/moz.build @@ -11,6 +11,8 @@ MARIONETTE_MANIFESTS += ["test/marionette/manifest.toml"] JAR_MANIFESTS += ["jar.mn"] EXTRA_JS_MODULES.sessionstore = [ + "ContentRestore.sys.mjs", + "ContentSessionStore.sys.mjs", "GlobalState.sys.mjs", "RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs", "RunState.sys.mjs", diff --git a/browser/components/sessionstore/test/browser.toml b/browser/components/sessionstore/test/browser.toml index 286467dd30ea..26fb4b4550d1 100644 --- a/browser/components/sessionstore/test/browser.toml +++ b/browser/components/sessionstore/test/browser.toml @@ -316,6 +316,7 @@ support-files = ["file_async_flushes.html"] run-if = ["crashreporter"] ["browser_async_remove_tab.js"] +skip-if = ["!sessionHistoryInParent"] ["browser_async_window_flushing.js"] https_first_disabled = true @@ -465,6 +466,7 @@ skip-if = [ ["browser_privatetabs.js"] ["browser_purge_shistory.js"] +skip-if = ["!sessionHistoryInParent"] # Bug 1271024 ["browser_remoteness_flip_on_restore.js"] @@ -514,6 +516,9 @@ skip-if = [ ["browser_scrollPositionsReaderMode.js"] +["browser_send_async_message_oom.js"] +skip-if = ["sessionHistoryInParent"] # Tests that the frame script OOMs, which is unused when SHIP is enabled. + ["browser_sessionHistory.js"] https_first_disabled = true support-files = ["file_sessionHistory_hashchange.html"] diff --git a/browser/components/sessionstore/test/browser_687710_2.js b/browser/components/sessionstore/test/browser_687710_2.js index 190b5a718a9d..81d3c55379b5 100644 --- a/browser/components/sessionstore/test/browser_687710_2.js +++ b/browser/components/sessionstore/test/browser_687710_2.js @@ -38,31 +38,61 @@ var state = { add_task(async function test() { let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); await promiseTabState(tab, state); + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(tab.linkedBrowser, [], function () { + function compareEntries(i, j, history) { + let e1 = history.getEntryAtIndex(i); + let e2 = history.getEntryAtIndex(j); - function compareEntries(i, j, history) { - let e1 = history.getEntryAtIndex(i); - let e2 = history.getEntryAtIndex(j); + ok(e1.sharesDocumentWith(e2), `${i} should share doc with ${j}`); + is(e1.childCount, e2.childCount, `Child count mismatch (${i}, ${j})`); - ok(e1.sharesDocumentWith(e2), `${i} should share doc with ${j}`); - is(e1.childCount, e2.childCount, `Child count mismatch (${i}, ${j})`); + for (let c = 0; c < e1.childCount; c++) { + let c1 = e1.GetChildAt(c); + let c2 = e2.GetChildAt(c); - for (let c = 0; c < e1.childCount; c++) { - let c1 = e1.GetChildAt(c); - let c2 = e2.GetChildAt(c); + ok( + c1.sharesDocumentWith(c2), + `Cousins should share documents. (${i}, ${j}, ${c})` + ); + } + } - ok( - c1.sharesDocumentWith(c2), - `Cousins should share documents. (${i}, ${j}, ${c})` - ); + let history = docShell.browsingContext.childSessionHistory.legacySHistory; + + is(history.count, 2, "history.count"); + for (let i = 0; i < history.count; i++) { + for (let j = 0; j < history.count; j++) { + compareEntries(i, j, history); + } + } + }); + } else { + function compareEntries(i, j, history) { + let e1 = history.getEntryAtIndex(i); + let e2 = history.getEntryAtIndex(j); + + ok(e1.sharesDocumentWith(e2), `${i} should share doc with ${j}`); + is(e1.childCount, e2.childCount, `Child count mismatch (${i}, ${j})`); + + for (let c = 0; c < e1.childCount; c++) { + let c1 = e1.GetChildAt(c); + let c2 = e2.GetChildAt(c); + + ok( + c1.sharesDocumentWith(c2), + `Cousins should share documents. (${i}, ${j}, ${c})` + ); + } } - } - let history = tab.linkedBrowser.browsingContext.sessionHistory; + let history = tab.linkedBrowser.browsingContext.sessionHistory; - is(history.count, 2, "history.count"); - for (let i = 0; i < history.count; i++) { - for (let j = 0; j < history.count; j++) { - compareEntries(i, j, history); + is(history.count, 2, "history.count"); + for (let i = 0; i < history.count; i++) { + for (let j = 0; j < history.count; j++) { + compareEntries(i, j, history); + } } } diff --git a/browser/components/sessionstore/test/browser_705597.js b/browser/components/sessionstore/test/browser_705597.js index 10f4f0886346..d497e46a979c 100644 --- a/browser/components/sessionstore/test/browser_705597.js +++ b/browser/components/sessionstore/test/browser_705597.js @@ -26,8 +26,14 @@ function test() { let browser = tab.linkedBrowser; promiseTabState(tab, tabState).then(() => { - let sessionHistory = browser.browsingContext.sessionHistory; - let entry = sessionHistory.getEntryAtIndex(0); + let entry; + if (!Services.appinfo.sessionHistoryInParent) { + let sessionHistory = browser.sessionHistory; + entry = sessionHistory.legacySHistory.getEntryAtIndex(0); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; + entry = sessionHistory.getEntryAtIndex(0); + } whenChildCount(entry, 1, function () { whenChildCount(entry, 2, function () { diff --git a/browser/components/sessionstore/test/browser_707862.js b/browser/components/sessionstore/test/browser_707862.js index 4559362e21e7..765c63257f63 100644 --- a/browser/components/sessionstore/test/browser_707862.js +++ b/browser/components/sessionstore/test/browser_707862.js @@ -26,14 +26,26 @@ function test() { let browser = tab.linkedBrowser; promiseTabState(tab, tabState).then(() => { - let sessionHistory = browser.browsingContext.sessionHistory; - let entry = sessionHistory.getEntryAtIndex(0); + let entry; + if (!Services.appinfo.sessionHistoryInParent) { + let sessionHistory = browser.sessionHistory; + entry = sessionHistory.legacySHistory.getEntryAtIndex(0); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; + entry = sessionHistory.getEntryAtIndex(0); + } whenChildCount(entry, 1, function () { whenChildCount(entry, 2, function () { promiseBrowserLoaded(browser).then(() => { - let newSessionHistory = browser.browsingContext.sessionHistory; - let newEntry = newSessionHistory.getEntryAtIndex(0); + let newEntry; + if (!Services.appinfo.sessionHistoryInParent) { + let newSessionHistory = browser.sessionHistory; + newEntry = newSessionHistory.legacySHistory.getEntryAtIndex(0); + } else { + let newSessionHistory = browser.browsingContext.sessionHistory; + newEntry = newSessionHistory.getEntryAtIndex(0); + } whenChildCount(newEntry, 0, function () { // Make sure that we reset the state. diff --git a/browser/components/sessionstore/test/browser_async_flushes.js b/browser/components/sessionstore/test/browser_async_flushes.js index d0bf039ff2f2..e35593dc30b4 100644 --- a/browser/components/sessionstore/test/browser_async_flushes.js +++ b/browser/components/sessionstore/test/browser_async_flushes.js @@ -44,6 +44,58 @@ add_task(async function test_flush() { gBrowser.removeTab(tab); }); +add_task(async function test_crash() { + if (Services.appinfo.sessionHistoryInParent) { + // This test relies on frame script message ordering. Since the frame script + // is unused with SHIP, there's no guarantee that we'll crash the frame + // before we've started the flush. + ok(true, "Test relies on frame script message ordering."); + return; + } + + // Create new tab. + let tab = BrowserTestUtils.addTab(gBrowser, URL); + gBrowser.selectedTab = tab; + let browser = tab.linkedBrowser; + await promiseBrowserLoaded(browser); + + // Flush to empty any queued update messages. + await TabStateFlusher.flush(browser); + + // There should be one history entry. + let { entries } = JSON.parse(ss.getTabState(tab)); + is(entries.length, 1, "there is a single history entry"); + + // Click the link to navigate. + await SpecialPowers.spawn(browser, [], async function () { + return new Promise(resolve => { + docShell.chromeEventHandler.addEventListener( + "hashchange", + () => resolve(), + { once: true, capture: true } + ); + + // Click the link. + content.document.querySelector("a").click(); + }); + }); + + // Crash the browser and flush. Both messages are async and will be sent to + // the content process. The "crash" message makes it first so that we don't + // get a chance to process the flush. The TabStateFlusher however should be + // notified so that the flush still completes. + let promise1 = BrowserTestUtils.crashFrame(browser); + let promise2 = TabStateFlusher.flush(browser); + await Promise.all([promise1, promise2]); + + // The pending update should be lost. + ({ entries } = JSON.parse(ss.getTabState(tab))); + is(entries.length, 1, "still only one history entry"); + + // Cleanup. + gBrowser.removeTab(tab); +}); + add_task(async function test_remove() { // Create new tab. let tab = BrowserTestUtils.addTab(gBrowser, URL); diff --git a/browser/components/sessionstore/test/browser_async_remove_tab.js b/browser/components/sessionstore/test/browser_async_remove_tab.js index 1e3a75adfa07..7f74c57b40ff 100644 --- a/browser/components/sessionstore/test/browser_async_remove_tab.js +++ b/browser/components/sessionstore/test/browser_async_remove_tab.js @@ -92,7 +92,15 @@ add_task(async function save_worthy_tabs_remote_final() { ok(browser.isRemoteBrowser, "browser is still remote"); // Remove the tab before the update arrives. - await promiseRemoveTabAndSessionState(tab); + let promise = promiseRemoveTabAndSessionState(tab); + + // With SHIP, we'll do the final tab state update sooner than we did before. + if (!Services.appinfo.sessionHistoryInParent) { + // No tab state worth saving (that we know about yet). + ok(!isValueInClosedData(r), "closed tab not saved"); + } + + await promise; // Turns out there is a tab state worth saving. ok(isValueInClosedData(r), "closed tab saved"); @@ -109,7 +117,15 @@ add_task(async function save_worthy_tabs_nonremote_final() { ok(!browser.isRemoteBrowser, "browser is not remote anymore"); // Remove the tab before the update arrives. - await promiseRemoveTabAndSessionState(tab); + let promise = promiseRemoveTabAndSessionState(tab); + + // With SHIP, we'll do the final tab state update sooner than we did before. + if (!Services.appinfo.sessionHistoryInParent) { + // No tab state worth saving (that we know about yet). + ok(!isValueInClosedData(r), "closed tab not saved"); + } + + await promise; // Turns out there is a tab state worth saving. ok(isValueInClosedData(r), "closed tab saved"); @@ -135,7 +151,15 @@ add_task(async function dont_save_empty_tabs_final() { await entryReplaced; // Remove the tab before the update arrives. - await promiseRemoveTabAndSessionState(tab); + let promise = promiseRemoveTabAndSessionState(tab); + + // With SHIP, we'll do the final tab state update sooner than we did before. + if (!Services.appinfo.sessionHistoryInParent) { + // Tab state deemed worth saving (yet). + ok(isValueInClosedData(r), "closed tab saved"); + } + + await promise; // Turns out we don't want to save the tab state. ok(!isValueInClosedData(r), "closed tab not saved"); diff --git a/browser/components/sessionstore/test/browser_async_window_flushing.js b/browser/components/sessionstore/test/browser_async_window_flushing.js index 42e24bdd835e..d346f9eb1fab 100644 --- a/browser/components/sessionstore/test/browser_async_window_flushing.js +++ b/browser/components/sessionstore/test/browser_async_window_flushing.js @@ -116,10 +116,17 @@ add_task(async function test_remove_uninteresting_window() { await SpecialPowers.spawn(browser, [], async function () { // Epic hackery to make this browser seem suddenly boring. docShell.setCurrentURIForSessionStore(Services.io.newURI("about:blank")); + + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + let { sessionHistory } = docShell.QueryInterface(Ci.nsIWebNavigation); + sessionHistory.legacySHistory.purgeHistory(sessionHistory.count); + } }); - let { sessionHistory } = browser.browsingContext; - sessionHistory.purgeHistory(sessionHistory.count); + if (SpecialPowers.Services.appinfo.sessionHistoryInParent) { + let { sessionHistory } = browser.browsingContext; + sessionHistory.purgeHistory(sessionHistory.count); + } // Once this windowClosed Promise resolves, we should have finished // the flush and revisited our decision to put this window into diff --git a/browser/components/sessionstore/test/browser_attributes.js b/browser/components/sessionstore/test/browser_attributes.js index 491ec5db223b..a0ee6d5b0c5b 100644 --- a/browser/components/sessionstore/test/browser_attributes.js +++ b/browser/components/sessionstore/test/browser_attributes.js @@ -38,68 +38,45 @@ add_task(async function test() { ok(tab.hasAttribute("muted"), "tab.muted exists"); // Make sure we do not persist 'image' and 'muted' attributes. + ss.persistTabAttribute("image"); + ss.persistTabAttribute("muted"); let { attributes } = JSON.parse(ss.getTabState(tab)); ok(!("image" in attributes), "'image' attribute not saved"); ok(!("muted" in attributes), "'muted' attribute not saved"); - ok(!("customizemode" in attributes), "'customizemode' attribute not saved"); + ok(!("custom" in attributes), "'custom' attribute not saved"); - // Test persisting a customizemode attribute. - { - let customizationReady = BrowserTestUtils.waitForEvent( - gNavToolbox, - "customizationready" - ); - gCustomizeMode.enter(); - await customizationReady; - } + // Test persisting a custom attribute. + tab.setAttribute("custom", "foobar"); + ss.persistTabAttribute("custom"); - let customizeIcon = gBrowser.getIcon(gBrowser.selectedTab); - ({ attributes } = JSON.parse(ss.getTabState(gBrowser.selectedTab))); - ok(!("image" in attributes), "'image' attribute not saved"); - is(attributes.customizemode, "true", "'customizemode' attribute is correct"); + ({ attributes } = JSON.parse(ss.getTabState(tab))); + is(attributes.custom, "foobar", "'custom' attribute is correct"); - { - let afterCustomization = BrowserTestUtils.waitForEvent( - gNavToolbox, - "aftercustomization" - ); - gCustomizeMode.exit(); - await afterCustomization; - } - - // Test restoring a customizemode tab. + // Make sure we're backwards compatible and restore old 'image' attributes. let state = { - entries: [], - attributes: { customizemode: "true", nonpersisted: "true" }, + entries: [{ url: "about:mozilla", triggeringPrincipal_base64 }], + attributes: { custom: "foobaz" }, + image: gBrowser.getIcon(tab), }; - // Customize mode doesn't like being restored on top of a non-blank tab. - // For the moment, it appears it isn't possible to restore customizemode onto - // an existing non-blank tab outside of tests, however this may be a latent - // bug if we ever try to do that in the future. - let principal = Services.scriptSecurityManager.createNullPrincipal({}); - tab.linkedBrowser.createAboutBlankDocumentViewer(principal, principal); - // Prepare a pending tab waiting to be restored. let promise = promiseTabRestoring(tab); ss.setTabState(tab, JSON.stringify(state)); await promise; ok(tab.hasAttribute("pending"), "tab is pending"); - ok(tab.hasAttribute("customizemode"), "tab is in customizemode"); - ok(!tab.hasAttribute("nonpersisted"), "tab has no nonpersisted attribute"); - is(gBrowser.getIcon(tab), customizeIcon, "tab has correct icon"); + is(gBrowser.getIcon(tab), state.image, "tab has correct icon"); ok(!state.attributes.image, "'image' attribute not saved"); // Let the pending tab load. gBrowser.selectedTab = tab; + await promiseTabRestored(tab); // Ensure no 'image' or 'pending' attributes are stored. ({ attributes } = JSON.parse(ss.getTabState(tab))); ok(!("image" in attributes), "'image' attribute not saved"); ok(!("pending" in attributes), "'pending' attribute not saved"); - ok(!("nonpersisted" in attributes), "'nonpersisted' attribute not saved"); - is(attributes.customizemode, "true", "'customizemode' attribute is correct"); + is(attributes.custom, "foobaz", "'custom' attribute is correct"); // Clean up. gBrowser.removeTab(tab); diff --git a/browser/components/sessionstore/test/browser_bfcache_telemetry.js b/browser/components/sessionstore/test/browser_bfcache_telemetry.js index c1e987750563..5faa2822eaa2 100644 --- a/browser/components/sessionstore/test/browser_bfcache_telemetry.js +++ b/browser/components/sessionstore/test/browser_bfcache_telemetry.js @@ -39,6 +39,7 @@ async function test_bfcache_telemetry(probeInParent) { add_task(async () => { await test_bfcache_telemetry( - Services.prefs.getBoolPref("fission.bfcacheInParent") + Services.appinfo.sessionHistoryInParent && + Services.prefs.getBoolPref("fission.bfcacheInParent") ); }); diff --git a/browser/components/sessionstore/test/browser_docshell_uuid_consistency.js b/browser/components/sessionstore/test/browser_docshell_uuid_consistency.js index 6fc212eb2b89..1b152139d73c 100644 --- a/browser/components/sessionstore/test/browser_docshell_uuid_consistency.js +++ b/browser/components/sessionstore/test/browser_docshell_uuid_consistency.js @@ -4,18 +4,38 @@ add_task(async function duplicateTab() { let tab = BrowserTestUtils.addTab(gBrowser, TEST_URL); await BrowserTestUtils.browserLoaded(tab.linkedBrowser); - let historyID = tab.linkedBrowser.browsingContext.historyID; - let shEntry = - tab.linkedBrowser.browsingContext.sessionHistory.getEntryAtIndex(0); - is(shEntry.docshellID.toString(), historyID.toString()); + if (!Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(tab.linkedBrowser, [], function () { + let docshell = content.window.docShell.QueryInterface( + Ci.nsIWebNavigation + ); + let shEntry = docshell.sessionHistory.legacySHistory.getEntryAtIndex(0); + is(shEntry.docshellID.toString(), docshell.historyID.toString()); + }); + } else { + let historyID = tab.linkedBrowser.browsingContext.historyID; + let shEntry = + tab.linkedBrowser.browsingContext.sessionHistory.getEntryAtIndex(0); + is(shEntry.docshellID.toString(), historyID.toString()); + } let tab2 = gBrowser.duplicateTab(tab); await BrowserTestUtils.browserLoaded(tab2.linkedBrowser); - historyID = tab2.linkedBrowser.browsingContext.historyID; - shEntry = - tab2.linkedBrowser.browsingContext.sessionHistory.getEntryAtIndex(0); - is(shEntry.docshellID.toString(), historyID.toString()); + if (!Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(tab2.linkedBrowser, [], function () { + let docshell = content.window.docShell.QueryInterface( + Ci.nsIWebNavigation + ); + let shEntry = docshell.sessionHistory.legacySHistory.getEntryAtIndex(0); + is(shEntry.docshellID.toString(), docshell.historyID.toString()); + }); + } else { + let historyID = tab2.linkedBrowser.browsingContext.historyID; + let shEntry = + tab2.linkedBrowser.browsingContext.sessionHistory.getEntryAtIndex(0); + is(shEntry.docshellID.toString(), historyID.toString()); + } BrowserTestUtils.removeTab(tab); BrowserTestUtils.removeTab(tab2); @@ -27,10 +47,24 @@ add_task(async function contentToChromeNavigate() { let tab = BrowserTestUtils.addTab(gBrowser, TEST_URL); await BrowserTestUtils.browserLoaded(tab.linkedBrowser); - let historyID = tab.linkedBrowser.browsingContext.historyID; - let sh = tab.linkedBrowser.browsingContext.sessionHistory; - is(sh.count, 1); - is(sh.getEntryAtIndex(0).docshellID.toString(), historyID.toString()); + if (!Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(tab.linkedBrowser, [], function () { + let docshell = content.window.docShell.QueryInterface( + Ci.nsIWebNavigation + ); + let sh = docshell.sessionHistory; + is(sh.count, 1); + is( + sh.legacySHistory.getEntryAtIndex(0).docshellID.toString(), + docshell.historyID.toString() + ); + }); + } else { + let historyID = tab.linkedBrowser.browsingContext.historyID; + let sh = tab.linkedBrowser.browsingContext.sessionHistory; + is(sh.count, 1); + is(sh.getEntryAtIndex(0).docshellID.toString(), historyID.toString()); + } // Force the browser to navigate to the chrome process. BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, "about:config"); @@ -40,17 +74,31 @@ add_task(async function contentToChromeNavigate() { let docShell = tab.linkedBrowser.frameLoader.docShell; // 'cause we're in the chrome process, we can just directly poke at the shistory. - sh = docShell.browsingContext.sessionHistory; + if (!Services.appinfo.sessionHistoryInParent) { + let sh = docShell.QueryInterface(Ci.nsIWebNavigation).sessionHistory; - is(sh.count, 2); - is( - sh.getEntryAtIndex(0).docshellID.toString(), - docShell.historyID.toString() - ); - is( - sh.getEntryAtIndex(1).docshellID.toString(), - docShell.historyID.toString() - ); + is(sh.count, 2); + is( + sh.legacySHistory.getEntryAtIndex(0).docshellID.toString(), + docShell.historyID.toString() + ); + is( + sh.legacySHistory.getEntryAtIndex(1).docshellID.toString(), + docShell.historyID.toString() + ); + } else { + let sh = docShell.browsingContext.sessionHistory; + + is(sh.count, 2); + is( + sh.getEntryAtIndex(0).docshellID.toString(), + docShell.historyID.toString() + ); + is( + sh.getEntryAtIndex(1).docshellID.toString(), + docShell.historyID.toString() + ); + } BrowserTestUtils.removeTab(tab); }); diff --git a/browser/components/sessionstore/test/browser_history_persist.js b/browser/components/sessionstore/test/browser_history_persist.js index 1cf8bf1b8d7e..f6749b02e3c7 100644 --- a/browser/components/sessionstore/test/browser_history_persist.js +++ b/browser/components/sessionstore/test/browser_history_persist.js @@ -25,27 +25,54 @@ add_task(async function check_history_not_persisted() { browser = tab.linkedBrowser; await promiseTabState(tab, state); - let sessionHistory = browser.browsingContext.sessionHistory; + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(browser, [], function () { + let sessionHistory = + docShell.browsingContext.childSessionHistory.legacySHistory; - is(sessionHistory.count, 1, "Should be a single history entry"); - is( - sessionHistory.getEntryAtIndex(0).URI.spec, - "about:blank", - "Should be the right URL" - ); + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + }); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; + + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + } // Load a new URL into the tab, it should replace the about:blank history entry BrowserTestUtils.startLoadingURIString(browser, "about:robots"); await promiseBrowserLoaded(browser, false, "about:robots"); + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(browser, [], function () { + let sessionHistory = + docShell.browsingContext.childSessionHistory.legacySHistory; - sessionHistory = browser.browsingContext.sessionHistory; + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:robots", + "Should be the right URL" + ); + }); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; - is(sessionHistory.count, 1, "Should be a single history entry"); - is( - sessionHistory.getEntryAtIndex(0).URI.spec, - "about:robots", - "Should be the right URL" - ); + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:robots", + "Should be the right URL" + ); + } // Cleanup. BrowserTestUtils.removeTab(tab); @@ -72,33 +99,64 @@ add_task(async function check_history_default_persisted() { tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); browser = tab.linkedBrowser; await promiseTabState(tab, state); + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(browser, [], function () { + let sessionHistory = + docShell.browsingContext.childSessionHistory.legacySHistory; - let sessionHistory = browser.browsingContext.sessionHistory; + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + }); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; - is(sessionHistory.count, 1, "Should be a single history entry"); - is( - sessionHistory.getEntryAtIndex(0).URI.spec, - "about:blank", - "Should be the right URL" - ); + is(sessionHistory.count, 1, "Should be a single history entry"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + } // Load a new URL into the tab, it should replace the about:blank history entry BrowserTestUtils.startLoadingURIString(browser, "about:robots"); await promiseBrowserLoaded(browser, false, "about:robots"); + if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) { + await SpecialPowers.spawn(browser, [], function () { + let sessionHistory = + docShell.browsingContext.childSessionHistory.legacySHistory; - sessionHistory = browser.browsingContext.sessionHistory; + is(sessionHistory.count, 2, "Should be two history entries"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + is( + sessionHistory.getEntryAtIndex(1).URI.spec, + "about:robots", + "Should be the right URL" + ); + }); + } else { + let sessionHistory = browser.browsingContext.sessionHistory; - is(sessionHistory.count, 2, "Should be two history entries"); - is( - sessionHistory.getEntryAtIndex(0).URI.spec, - "about:blank", - "Should be the right URL" - ); - is( - sessionHistory.getEntryAtIndex(1).URI.spec, - "about:robots", - "Should be the right URL" - ); + is(sessionHistory.count, 2, "Should be two history entries"); + is( + sessionHistory.getEntryAtIndex(0).URI.spec, + "about:blank", + "Should be the right URL" + ); + is( + sessionHistory.getEntryAtIndex(1).URI.spec, + "about:robots", + "Should be the right URL" + ); + } // Cleanup. BrowserTestUtils.removeTab(tab); diff --git a/browser/components/sessionstore/test/browser_send_async_message_oom.js b/browser/components/sessionstore/test/browser_send_async_message_oom.js new file mode 100644 index 000000000000..7e807f2fbd8a --- /dev/null +++ b/browser/components/sessionstore/test/browser_send_async_message_oom.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +const HISTOGRAM_NAME = "FX_SESSION_RESTORE_SEND_UPDATE_CAUSED_OOM"; + +/** + * Test that an OOM in sendAsyncMessage in a framescript will be reported + * to Telemetry. + */ + +add_setup(async function () { + Services.telemetry.canRecordExtended = true; +}); + +function frameScript() { + // Make send[A]syncMessage("SessionStore:update", ...) simulate OOM. + // Other operations are unaffected. + let mm = docShell.messageManager; + + let wrap = function (original) { + return function (name, ...args) { + if (name != "SessionStore:update") { + return original(name, ...args); + } + throw new Components.Exception( + "Simulated OOM", + Cr.NS_ERROR_OUT_OF_MEMORY + ); + }; + }; + + mm.sendAsyncMessage = wrap(mm.sendAsyncMessage.bind(mm)); + mm.sendSyncMessage = wrap(mm.sendSyncMessage.bind(mm)); +} + +add_task(async function () { + // Capture original state. + let snapshot = Services.telemetry.getHistogramById(HISTOGRAM_NAME).snapshot(); + + // Open a browser, configure it to cause OOM. + let newTab = BrowserTestUtils.addTab(gBrowser, "about:robots"); + let browser = newTab.linkedBrowser; + await ContentTask.spawn(browser, null, frameScript); + + let promiseReported = new Promise(resolve => { + browser.messageManager.addMessageListener("SessionStore:error", resolve); + }); + + // Attempt to flush. This should fail. + let promiseFlushed = TabStateFlusher.flush(browser); + promiseFlushed.then(success => { + if (success) { + throw new Error("Flush should have failed"); + } + }); + + // The frame script should report an error. + await promiseReported; + + // Give us some time to handle that error. + await new Promise(resolve => setTimeout(resolve, 10)); + + // By now, Telemetry should have been updated. + let snapshot2 = Services.telemetry + .getHistogramById(HISTOGRAM_NAME) + .snapshot(); + gBrowser.removeTab(newTab); + + Assert.ok(snapshot2.sum > snapshot.sum); +}); + +add_task(async function cleanup() { + Services.telemetry.canRecordExtended = false; +}); diff --git a/browser/components/sessionstore/test/browser_sessionHistory.js b/browser/components/sessionstore/test/browser_sessionHistory.js index 34b1ef7d097b..69dcc4995b09 100644 --- a/browser/components/sessionstore/test/browser_sessionHistory.js +++ b/browser/components/sessionstore/test/browser_sessionHistory.js @@ -296,9 +296,12 @@ add_task(async function test_slow_subframe_load() { * Ensure that document wireframes can be persisted when they're enabled. */ add_task(async function test_wireframes() { - // Wireframes only works when Fission is enabled. - if (!Services.appinfo.fissionAutostart) { - ok(true, "Skipping test_wireframes when Fission is not enabled."); + // Wireframes only works when Fission and SHIP are enabled. + if ( + !Services.appinfo.fissionAutostart || + !Services.appinfo.sessionHistoryInParent + ) { + ok(true, "Skipping test_wireframes when Fission or SHIP is not enabled."); return; } diff --git a/browser/components/sessionstore/test/head.js b/browser/components/sessionstore/test/head.js index 85db6e9d5e2d..531ab87e2f06 100644 --- a/browser/components/sessionstore/test/head.js +++ b/browser/components/sessionstore/test/head.js @@ -558,9 +558,35 @@ function setPropertyOfFormField(browserContext, selector, propName, newValue) { } function promiseOnHistoryReplaceEntry(browser) { - return new Promise(resolve => { - let sessionHistory = browser.browsingContext?.sessionHistory; - if (sessionHistory) { + if (SpecialPowers.Services.appinfo.sessionHistoryInParent) { + return new Promise(resolve => { + let sessionHistory = browser.browsingContext?.sessionHistory; + if (sessionHistory) { + var historyListener = { + OnHistoryNewEntry() {}, + OnHistoryGotoIndex() {}, + OnHistoryPurge() {}, + OnHistoryReload() { + return true; + }, + + OnHistoryReplaceEntry() { + resolve(); + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsISHistoryListener", + "nsISupportsWeakReference", + ]), + }; + + sessionHistory.addSHistoryListener(historyListener); + } + }); + } + + return SpecialPowers.spawn(browser, [], () => { + return new Promise(resolve => { var historyListener = { OnHistoryNewEntry() {}, OnHistoryGotoIndex() {}, @@ -579,8 +605,13 @@ function promiseOnHistoryReplaceEntry(browser) { ]), }; - sessionHistory.addSHistoryListener(historyListener); - } + var { sessionHistory } = this.docShell.QueryInterface( + Ci.nsIWebNavigation + ); + if (sessionHistory) { + sessionHistory.legacySHistory.addSHistoryListener(historyListener); + } + }); }); } diff --git a/tools/esmify/map.json b/tools/esmify/map.json index a3f2d6e092cd..114f9cc5c22b 100644 --- a/tools/esmify/map.json +++ b/tools/esmify/map.json @@ -263,6 +263,8 @@ "resource:///modules/policies/ProxyPolicies.jsm": "browser/components/enterprisepolicies/helpers/ProxyPolicies.jsm", "resource:///modules/policies/WebsiteFilter.jsm": "browser/components/enterprisepolicies/helpers/WebsiteFilter.jsm", "resource:///modules/policies/schema.jsm": "browser/components/enterprisepolicies/schemas/schema.jsm", + "resource:///modules/sessionstore/ContentRestore.jsm": "browser/components/sessionstore/ContentRestore.jsm", + "resource:///modules/sessionstore/ContentSessionStore.jsm": "browser/components/sessionstore/ContentSessionStore.jsm", "resource:///modules/sessionstore/GlobalState.jsm": "browser/components/sessionstore/GlobalState.jsm", "resource:///modules/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.jsm": "browser/components/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.jsm", "resource:///modules/sessionstore/RunState.jsm": "browser/components/sessionstore/RunState.jsm",