/* 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/. */ "use strict"; var EXPORTED_SYMBOLS = ["ContentSessionStore"]; ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm", this); ChromeUtils.import("resource://gre/modules/Timer.jsm", this); ChromeUtils.import("resource://gre/modules/Services.jsm", this); function debug(msg) { Services.console.logStringMessage("SessionStoreContent: " + msg); } ChromeUtils.defineModuleGetter( this, "ContentRestore", "resource:///modules/sessionstore/ContentRestore.jsm" ); ChromeUtils.defineModuleGetter( this, "SessionHistory", "resource://gre/modules/sessionstore/SessionHistory.jsm" ); // 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; } get stateChangeNotifier() { return this.store.stateChangeNotifier; } } /** * Listens for state change notifcations from webProgress and notifies each * registered observer for either the start of a page load, or its completion. */ class StateChangeNotifier extends Handler { constructor(store) { super(store); this._observers = new Set(); let ifreq = this.mm.docShell.QueryInterface(Ci.nsIInterfaceRequestor); let webProgress = ifreq.getInterface(Ci.nsIWebProgress); webProgress.addProgressListener( this, Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT ); } /** * Adds a given observer |obs| to the set of observers that will be notified * when when a new document starts or finishes loading. * * @param obs (object) */ addObserver(obs) { this._observers.add(obs); } /** * Notifies all observers that implement the given |method|. * * @param method (string) */ notifyObservers(method) { for (let obs of this._observers) { if (typeof obs[method] == "function") { obs[method](); } } } /** * @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.notifyObservers("onPageLoadStarted"); } else if (stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { this.notifyObservers("onPageLoadCompleted"); } } } StateChangeNotifier.prototype.QueryInterface = ChromeUtils.generateQI([ Ci.nsIWebProgressListener, Ci.nsISupportsWeakReference, ]); /** * 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. If we're not currently // restoring a tab state then this call will simply be a noop. 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; // The state change observer is needed to handle initial subframe loads. // It will redundantly invalidate with the SHistoryListener in some cases // but these invalidations are very cheap. this.stateChangeNotifier.addObserver(this); // 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); // Collect data if we start with a non-empty shistory. if (!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.messageQueue.send(); } // Listen for page title changes. this.mm.addEventListener("DOMTitleChanged", this); } uninit() { let sessionHistory = this.mm.docShell.QueryInterface(Ci.nsIWebNavigation) .sessionHistory; if (sessionHistory) { sessionHistory.legacySHistory.removeSHistoryListener(this); } } 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.messageQueue.push("historychange", () => { if (this._fromIdx === kNoIndex) { return null; } let history = SessionHistory.collect(this.mm.docShell, this._fromIdx); this._fromIdx = kNoIndex; return history; }); } handleEvent(event) { this.collect(); } onPageLoadCompleted() { this.collect(); } onPageLoadStarted() { this.collect(); } OnHistoryNewEntry(newURI, oldIndex) { // We ought to collect the previously current entry as well, see bug 1350567. this.collectFrom(oldIndex); } 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(); } } SessionHistoryListener.prototype.QueryInterface = ChromeUtils.generateQI([ Ci.nsISHistoryListener, Ci.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; } return val; } 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:becomeActiveProcess", ]; class ContentSessionStore { constructor(mm) { this.mm = mm; this.messageQueue = new MessageQueue(this); this.stateChangeNotifier = new StateChangeNotifier(this); this.epoch = 0; this.contentRestoreInitialized = false; XPCOMUtils.defineLazyGetter(this, "contentRestore", () => { this.contentRestoreInitialized = true; return new ContentRestore(mm); }); this.handlers = [ new EventListener(this), new SessionHistoryListener(this), this.stateChangeNotifier, this.messageQueue, ]; MESSAGES.forEach(m => mm.addMessageListener(m, this)); // If we're browsing from the tab crashed UI to a blacklisted URI that keeps // this browser non-remote, we'll handle that in a pagehide event. mm.addEventListener("pagehide", 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.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:becomeActiveProcess": SessionHistoryListener.collect(); break; default: debug("received unknown message '" + name + "'"); break; } } restoreHistory({ epoch, tabData, loadArguments, isRemotenessUpdate }) { 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.jsm 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.jsm 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.jsm. let didStartLoad = this.contentRestore.restoreTabContent( loadArguments, isRemotenessUpdate, () => { // Tell SessionStore.jsm 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 == "pagehide") { this.handleRevivedTab(); } else 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 }); // If we're browsing from the tab crashed UI to a URI that causes the tab // to go remote again, we catch this in the unload event handler, because // swapping out the non-remote browser for a remote one in // tabbrowser.xml's updateBrowserRemoteness doesn't cause the pagehide // event to be fired. this.handleRevivedTab(); 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. } handleRevivedTab() { let { content } = this.mm; if (!content) { this.mm.removeEventListener("pagehide", this); return; } if (content.document.documentURI.startsWith("about:tabcrashed")) { if ( Services.appinfo.processType != Services.appinfo.PROCESS_TYPE_DEFAULT ) { // Sanity check - we'd better be loading this in a non-remote browser. throw new Error( "We seem to be navigating away from about:tabcrashed in " + "a non-remote browser. This should really never happen." ); } this.mm.removeEventListener("pagehide", this); // Notify the parent. this.mm.sendAsyncMessage("SessionStore:crashedTabRevived"); } } }