summaryrefslogtreecommitdiffstats
path: root/browser/components/sessionstore/SessionFile.jsm
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /browser/components/sessionstore/SessionFile.jsm
parentInitial commit. (diff)
downloadfirefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz
firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/sessionstore/SessionFile.jsm')
-rw-r--r--browser/components/sessionstore/SessionFile.jsm532
1 files changed, 532 insertions, 0 deletions
diff --git a/browser/components/sessionstore/SessionFile.jsm b/browser/components/sessionstore/SessionFile.jsm
new file mode 100644
index 0000000000..2d1a77bb63
--- /dev/null
+++ b/browser/components/sessionstore/SessionFile.jsm
@@ -0,0 +1,532 @@
+/* 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 = ["SessionFile"];
+
+/**
+ * Implementation of all the disk I/O required by the session store.
+ * This is a private API, meant to be used only by the session store.
+ * It will change. Do not use it for any other purpose.
+ *
+ * Note that this module implicitly depends on one of two things:
+ * 1. either the asynchronous file I/O system enqueues its requests
+ * and never attempts to simultaneously execute two I/O requests on
+ * the files used by this module from two distinct threads; or
+ * 2. the clients of this API are well-behaved and do not place
+ * concurrent requests to the files used by this module.
+ *
+ * Otherwise, we could encounter bugs, especially under Windows,
+ * e.g. if a request attempts to write sessionstore.js while
+ * another attempts to copy that file.
+ *
+ * This implementation uses OS.File, which guarantees property 1.
+ */
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+const { AsyncShutdown } = ChromeUtils.import(
+ "resource://gre/modules/AsyncShutdown.jsm"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "Telemetry",
+ "@mozilla.org/base/telemetry;1",
+ "nsITelemetry"
+);
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ RunState: "resource:///modules/sessionstore/RunState.jsm",
+ SessionStore: "resource:///modules/sessionstore/SessionStore.jsm",
+ SessionWorker: "resource:///modules/sessionstore/SessionWorker.jsm",
+});
+
+const PREF_UPGRADE_BACKUP = "browser.sessionstore.upgradeBackup.latestBuildID";
+const PREF_MAX_UPGRADE_BACKUPS =
+ "browser.sessionstore.upgradeBackup.maxUpgradeBackups";
+
+const PREF_MAX_SERIALIZE_BACK = "browser.sessionstore.max_serialize_back";
+const PREF_MAX_SERIALIZE_FWD = "browser.sessionstore.max_serialize_forward";
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "kMaxWriteFailures",
+ "browser.sessionstore.max_write_failures",
+ 5
+);
+
+var SessionFile = {
+ /**
+ * Read the contents of the session file, asynchronously.
+ */
+ read() {
+ return SessionFileInternal.read();
+ },
+ /**
+ * Write the contents of the session file, asynchronously.
+ */
+ write(aData) {
+ return SessionFileInternal.write(aData);
+ },
+ /**
+ * Wipe the contents of the session file, asynchronously.
+ */
+ wipe() {
+ return SessionFileInternal.wipe();
+ },
+
+ /**
+ * Return the paths to the files used to store, backup, etc.
+ * the state of the file.
+ */
+ get Paths() {
+ return SessionFileInternal.Paths;
+ },
+
+ get MaxWriteFailures() {
+ return kMaxWriteFailures;
+ },
+};
+
+Object.freeze(SessionFile);
+
+var Path = OS.Path;
+var profileDir = OS.Constants.Path.profileDir;
+
+var SessionFileInternal = {
+ Paths: Object.freeze({
+ // The path to the latest version of sessionstore written during a clean
+ // shutdown. After startup, it is renamed `cleanBackup`.
+ clean: Path.join(profileDir, "sessionstore.jsonlz4"),
+
+ // The path at which we store the previous version of `clean`. Updated
+ // whenever we successfully load from `clean`.
+ cleanBackup: Path.join(
+ profileDir,
+ "sessionstore-backups",
+ "previous.jsonlz4"
+ ),
+
+ // The directory containing all sessionstore backups.
+ backups: Path.join(profileDir, "sessionstore-backups"),
+
+ // The path to the latest version of the sessionstore written
+ // during runtime. Generally, this file contains more
+ // privacy-sensitive information than |clean|, and this file is
+ // therefore removed during clean shutdown. This file is designed to protect
+ // against crashes / sudden shutdown.
+ recovery: Path.join(profileDir, "sessionstore-backups", "recovery.jsonlz4"),
+
+ // The path to the previous version of the sessionstore written
+ // during runtime (e.g. 15 seconds before recovery). In case of a
+ // clean shutdown, this file is removed. Generally, this file
+ // contains more privacy-sensitive information than |clean|, and
+ // this file is therefore removed during clean shutdown. This
+ // file is designed to protect against crashes that are nasty
+ // enough to corrupt |recovery|.
+ recoveryBackup: Path.join(
+ profileDir,
+ "sessionstore-backups",
+ "recovery.baklz4"
+ ),
+
+ // The path to a backup created during an upgrade of Firefox.
+ // Having this backup protects the user essentially from bugs in
+ // Firefox or add-ons, especially for users of Nightly. This file
+ // does not contain any information more sensitive than |clean|.
+ upgradeBackupPrefix: Path.join(
+ profileDir,
+ "sessionstore-backups",
+ "upgrade.jsonlz4-"
+ ),
+
+ // The path to the backup of the version of the session store used
+ // during the latest upgrade of Firefox. During load/recovery,
+ // this file should be used if both |path|, |backupPath| and
+ // |latestStartPath| are absent/incorrect. May be "" if no
+ // upgrade backup has ever been performed. This file does not
+ // contain any information more sensitive than |clean|.
+ get upgradeBackup() {
+ let latestBackupID = SessionFileInternal.latestUpgradeBackupID;
+ if (!latestBackupID) {
+ return "";
+ }
+ return this.upgradeBackupPrefix + latestBackupID;
+ },
+
+ // The path to a backup created during an upgrade of Firefox.
+ // Having this backup protects the user essentially from bugs in
+ // Firefox, especially for users of Nightly.
+ get nextUpgradeBackup() {
+ return this.upgradeBackupPrefix + Services.appinfo.platformBuildID;
+ },
+
+ /**
+ * The order in which to search for a valid sessionstore file.
+ */
+ get loadOrder() {
+ // If `clean` exists and has been written without corruption during
+ // the latest shutdown, we need to use it.
+ //
+ // Otherwise, `recovery` and `recoveryBackup` represent the most
+ // recent state of the session store.
+ //
+ // Finally, if nothing works, fall back to the last known state
+ // that can be loaded (`cleanBackup`) or, if available, to the
+ // backup performed during the latest upgrade.
+ let order = ["clean", "recovery", "recoveryBackup", "cleanBackup"];
+ if (SessionFileInternal.latestUpgradeBackupID) {
+ // We have an upgradeBackup
+ order.push("upgradeBackup");
+ }
+ return order;
+ },
+ }),
+
+ // Number of attempted calls to `write`.
+ // Note that we may have _attempts > _successes + _failures,
+ // if attempts never complete.
+ // Used for error reporting.
+ _attempts: 0,
+
+ // Number of successful calls to `write`.
+ // Used for error reporting.
+ _successes: 0,
+
+ // Number of failed calls to `write`.
+ // Used for error reporting.
+ _failures: 0,
+
+ // Object that keeps statistics that should help us make informed decisions
+ // about the current status of the worker.
+ _workerHealth: {
+ failures: 0,
+ },
+
+ // `true` once we have started initialization of the worker.
+ _initializationStarted: false,
+
+ // A string that will be set to the session file name part that was read from
+ // disk. It will be available _after_ a session file read() is done.
+ _readOrigin: null,
+
+ // `true` if the old, uncompressed, file format was used to read from disk, as
+ // a fallback mechanism.
+ _usingOldExtension: false,
+
+ // The ID of the latest version of Gecko for which we have an upgrade backup
+ // or |undefined| if no upgrade backup was ever written.
+ get latestUpgradeBackupID() {
+ try {
+ return Services.prefs.getCharPref(PREF_UPGRADE_BACKUP);
+ } catch (ex) {
+ return undefined;
+ }
+ },
+
+ async _readInternal(useOldExtension) {
+ let result;
+ let noFilesFound = true;
+ this._usingOldExtension = useOldExtension;
+
+ // Attempt to load by order of priority from the various backups
+ for (let key of this.Paths.loadOrder) {
+ let corrupted = false;
+ let exists = true;
+ try {
+ let path;
+ let startMs = Date.now();
+
+ let options = { encoding: "utf-8" };
+ if (useOldExtension) {
+ path = this.Paths[key]
+ .replace("jsonlz4", "js")
+ .replace("baklz4", "bak");
+ } else {
+ path = this.Paths[key];
+ options.compression = "lz4";
+ }
+ let source = await OS.File.read(path, options);
+ let parsed = JSON.parse(source);
+
+ if (
+ !SessionStore.isFormatVersionCompatible(
+ parsed.version || [
+ "sessionrestore",
+ 0,
+ ] /* fallback for old versions*/
+ )
+ ) {
+ // Skip sessionstore files that we don't understand.
+ Cu.reportError(
+ "Cannot extract data from Session Restore file " +
+ path +
+ ". Wrong format/version: " +
+ JSON.stringify(parsed.version) +
+ "."
+ );
+ continue;
+ }
+ result = {
+ origin: key,
+ source,
+ parsed,
+ useOldExtension,
+ };
+ Telemetry.getHistogramById("FX_SESSION_RESTORE_CORRUPT_FILE").add(
+ false
+ );
+ Telemetry.getHistogramById("FX_SESSION_RESTORE_READ_FILE_MS").add(
+ Date.now() - startMs
+ );
+ break;
+ } catch (ex) {
+ if (ex instanceof OS.File.Error && ex.becauseNoSuchFile) {
+ exists = false;
+ } else if (ex instanceof OS.File.Error) {
+ // The file might be inaccessible due to wrong permissions
+ // or similar failures. We'll just count it as "corrupted".
+ console.error("Could not read session file ", ex, ex.stack);
+ corrupted = true;
+ } else if (ex instanceof SyntaxError) {
+ console.error(
+ "Corrupt session file (invalid JSON found) ",
+ ex,
+ ex.stack
+ );
+ // File is corrupted, try next file
+ corrupted = true;
+ }
+ } finally {
+ if (exists) {
+ noFilesFound = false;
+ Telemetry.getHistogramById("FX_SESSION_RESTORE_CORRUPT_FILE").add(
+ corrupted
+ );
+ }
+ }
+ }
+ return { result, noFilesFound };
+ },
+
+ // Find the correct session file, read it and setup the worker.
+ async read() {
+ // Load session files with lz4 compression.
+ let { result, noFilesFound } = await this._readInternal(false);
+ if (!result) {
+ // No result? Probably because of migration, let's
+ // load uncompressed session files.
+ let r = await this._readInternal(true);
+ result = r.result;
+ }
+
+ // All files are corrupted if files found but none could deliver a result.
+ let allCorrupt = !noFilesFound && !result;
+ Telemetry.getHistogramById("FX_SESSION_RESTORE_ALL_FILES_CORRUPT").add(
+ allCorrupt
+ );
+
+ if (!result) {
+ // If everything fails, start with an empty session.
+ result = {
+ origin: "empty",
+ source: "",
+ parsed: null,
+ useOldExtension: false,
+ };
+ }
+ this._readOrigin = result.origin;
+
+ result.noFilesFound = noFilesFound;
+
+ // Initialize the worker (in the background) to let it handle backups and also
+ // as a workaround for bug 964531.
+ this._initWorker();
+
+ return result;
+ },
+
+ // Initialize the worker in the background.
+ // Since this called _before_ any other messages are posted to the worker (see
+ // `_postToWorker()`), we know that this initialization process will be completed
+ // on time.
+ // Thus, effectively, this blocks callees on its completion.
+ // In case of a worker crash/ shutdown during its initialization phase,
+ // `_checkWorkerHealth()` will detect it and flip the `_initializationStarted`
+ // property back to `false`. This means that we'll respawn the worker upon the
+ // next request, followed by the initialization sequence here. In other words;
+ // exactly the same procedure as when the worker crashed/ shut down 'regularly'.
+ //
+ // This will never throw an error.
+ _initWorker() {
+ return new Promise(resolve => {
+ if (this._initializationStarted) {
+ resolve();
+ return;
+ }
+
+ if (!this._readOrigin) {
+ throw new Error(
+ "_initWorker called too early! Please read the session file from disk first."
+ );
+ }
+
+ this._initializationStarted = true;
+ SessionWorker.post("init", [
+ this._readOrigin,
+ this._usingOldExtension,
+ this.Paths,
+ {
+ maxUpgradeBackups: Services.prefs.getIntPref(
+ PREF_MAX_UPGRADE_BACKUPS,
+ 3
+ ),
+ maxSerializeBack: Services.prefs.getIntPref(
+ PREF_MAX_SERIALIZE_BACK,
+ 10
+ ),
+ maxSerializeForward: Services.prefs.getIntPref(
+ PREF_MAX_SERIALIZE_FWD,
+ -1
+ ),
+ },
+ ])
+ .catch(err => {
+ // Ensure that we report errors but that they do not stop us.
+ Promise.reject(err);
+ })
+ .then(resolve);
+ });
+ },
+
+ // Post a message to the worker, making sure that it has been initialized first.
+ async _postToWorker(...args) {
+ await this._initWorker();
+ return SessionWorker.post(...args);
+ },
+
+ /**
+ * For good measure, terminate the worker when we've had over `kMaxWriteFailures`
+ * amount of failures to deal with. This will spawn a fresh worker upon the next
+ * write.
+ * This also resets the `_workerHealth` stats.
+ */
+ _checkWorkerHealth() {
+ if (this._workerHealth.failures >= kMaxWriteFailures) {
+ SessionWorker.terminate();
+ // Flag as not-initialized, to ensure that the worker state init is performed
+ // upon the next request.
+ this._initializationStarted = false;
+ // Reset the counter and report to telemetry.
+ this._workerHealth.failures = 0;
+ }
+ },
+
+ write(aData) {
+ if (RunState.isClosed) {
+ return Promise.reject(new Error("SessionFile is closed"));
+ }
+
+ let isFinalWrite = false;
+ if (RunState.isClosing) {
+ // If shutdown has started, we will want to stop receiving
+ // write instructions.
+ isFinalWrite = true;
+ RunState.setClosed();
+ }
+
+ let performShutdownCleanup = isFinalWrite && !SessionStore.willAutoRestore;
+
+ this._attempts++;
+ let options = { isFinalWrite, performShutdownCleanup };
+ let promise = this._postToWorker("write", [aData, options]);
+
+ // Wait until the write is done.
+ promise = promise.then(
+ msg => {
+ // Record how long the write took.
+ this._recordTelemetry(msg.telemetry);
+ this._successes++;
+ if (msg.result.upgradeBackup) {
+ // We have just completed a backup-on-upgrade, store the information
+ // in preferences.
+ Services.prefs.setCharPref(
+ PREF_UPGRADE_BACKUP,
+ Services.appinfo.platformBuildID
+ );
+ }
+ },
+ err => {
+ // Catch and report any errors.
+ console.error("Could not write session state file ", err, err.stack);
+ this._failures++;
+ this._workerHealth.failures++;
+ // By not doing anything special here we ensure that |promise| cannot
+ // be rejected anymore. The shutdown/cleanup code at the end of the
+ // function will thus always be executed.
+ }
+ );
+
+ // Ensure that we can write sessionstore.js cleanly before the profile
+ // becomes unaccessible.
+ AsyncShutdown.profileBeforeChange.addBlocker(
+ "SessionFile: Finish writing Session Restore data",
+ promise,
+ {
+ fetchState: () => ({
+ options,
+ attempts: this._attempts,
+ successes: this._successes,
+ failures: this._failures,
+ }),
+ }
+ );
+
+ // This code will always be executed because |promise| can't fail anymore.
+ // We ensured that by having a reject handler that reports the failure but
+ // doesn't forward the rejection.
+ return promise.then(() => {
+ // Remove the blocker, no matter if writing failed or not.
+ AsyncShutdown.profileBeforeChange.removeBlocker(promise);
+
+ if (isFinalWrite) {
+ Services.obs.notifyObservers(
+ null,
+ "sessionstore-final-state-write-complete"
+ );
+ } else {
+ this._checkWorkerHealth();
+ }
+ });
+ },
+
+ wipe() {
+ return this._postToWorker("wipe").then(() => {
+ // After a wipe, we need to make sure to re-initialize upon the next read(),
+ // because the state variables as sent to the worker have changed.
+ this._initializationStarted = false;
+ });
+ },
+
+ _recordTelemetry(telemetry) {
+ for (let id of Object.keys(telemetry)) {
+ let value = telemetry[id];
+ let samples = [];
+ if (Array.isArray(value)) {
+ samples.push(...value);
+ } else {
+ samples.push(value);
+ }
+ let histogram = Telemetry.getHistogramById(id);
+ for (let sample of samples) {
+ histogram.add(sample);
+ }
+ }
+ },
+};