/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; import { clearTimeout, setTimeoutWithTarget, } from "resource://gre/modules/Timer.sys.mjs"; function debug(msg) { Services.console.logStringMessage("SessionStoreContent: " + msg); } 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.jsm. 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(event) { 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: debug("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), ]; XPCOMUtils.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: debug("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. } }