Backed out changeset 496886cb30a5 (bug 1867152) for bc failures on browser_user_input...
[gecko.git] / browser / components / sessionstore / SessionFile.sys.mjs
blob1e5a3bf718a632df04a8a7052208ba99a52398fe
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
3  * You can obtain one at http://mozilla.org/MPL/2.0/. */
5 /**
6  * Implementation of all the disk I/O required by the session store.
7  * This is a private API, meant to be used only by the session store.
8  * It will change. Do not use it for any other purpose.
9  *
10  * Note that this module depends on SessionWriter and that it enqueues its I/O
11  * requests and never attempts to simultaneously execute two I/O requests on
12  * the files used by this module from two distinct threads.
13  * Otherwise, we could encounter bugs, especially under Windows,
14  * e.g. if a request attempts to write sessionstore.js while
15  * another attempts to copy that file.
16  */
18 const lazy = {};
20 ChromeUtils.defineESModuleGetters(lazy, {
21   RunState: "resource:///modules/sessionstore/RunState.sys.mjs",
22   SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
23   SessionWriter: "resource:///modules/sessionstore/SessionWriter.sys.mjs",
24 });
26 const PREF_UPGRADE_BACKUP = "browser.sessionstore.upgradeBackup.latestBuildID";
27 const PREF_MAX_UPGRADE_BACKUPS =
28   "browser.sessionstore.upgradeBackup.maxUpgradeBackups";
30 const PREF_MAX_SERIALIZE_BACK = "browser.sessionstore.max_serialize_back";
31 const PREF_MAX_SERIALIZE_FWD = "browser.sessionstore.max_serialize_forward";
33 export var SessionFile = {
34   /**
35    * Read the contents of the session file, asynchronously.
36    */
37   read() {
38     return SessionFileInternal.read();
39   },
40   /**
41    * Write the contents of the session file, asynchronously.
42    * @param aData - May get changed on shutdown.
43    */
44   write(aData) {
45     return SessionFileInternal.write(aData);
46   },
47   /**
48    * Wipe the contents of the session file, asynchronously.
49    */
50   wipe() {
51     return SessionFileInternal.wipe();
52   },
54   /**
55    * Return the paths to the files used to store, backup, etc.
56    * the state of the file.
57    */
58   get Paths() {
59     return SessionFileInternal.Paths;
60   },
63 Object.freeze(SessionFile);
65 const profileDir = PathUtils.profileDir;
67 var SessionFileInternal = {
68   Paths: Object.freeze({
69     // The path to the latest version of sessionstore written during a clean
70     // shutdown. After startup, it is renamed `cleanBackup`.
71     clean: PathUtils.join(profileDir, "sessionstore.jsonlz4"),
73     // The path at which we store the previous version of `clean`. Updated
74     // whenever we successfully load from `clean`.
75     cleanBackup: PathUtils.join(
76       profileDir,
77       "sessionstore-backups",
78       "previous.jsonlz4"
79     ),
81     // The directory containing all sessionstore backups.
82     backups: PathUtils.join(profileDir, "sessionstore-backups"),
84     // The path to the latest version of the sessionstore written
85     // during runtime. Generally, this file contains more
86     // privacy-sensitive information than |clean|, and this file is
87     // therefore removed during clean shutdown. This file is designed to protect
88     // against crashes / sudden shutdown.
89     recovery: PathUtils.join(
90       profileDir,
91       "sessionstore-backups",
92       "recovery.jsonlz4"
93     ),
95     // The path to the previous version of the sessionstore written
96     // during runtime (e.g. 15 seconds before recovery). In case of a
97     // clean shutdown, this file is removed.  Generally, this file
98     // contains more privacy-sensitive information than |clean|, and
99     // this file is therefore removed during clean shutdown.  This
100     // file is designed to protect against crashes that are nasty
101     // enough to corrupt |recovery|.
102     recoveryBackup: PathUtils.join(
103       profileDir,
104       "sessionstore-backups",
105       "recovery.baklz4"
106     ),
108     // The path to a backup created during an upgrade of Firefox.
109     // Having this backup protects the user essentially from bugs in
110     // Firefox or add-ons, especially for users of Nightly. This file
111     // does not contain any information more sensitive than |clean|.
112     upgradeBackupPrefix: PathUtils.join(
113       profileDir,
114       "sessionstore-backups",
115       "upgrade.jsonlz4-"
116     ),
118     // The path to the backup of the version of the session store used
119     // during the latest upgrade of Firefox. During load/recovery,
120     // this file should be used if both |path|, |backupPath| and
121     // |latestStartPath| are absent/incorrect.  May be "" if no
122     // upgrade backup has ever been performed. This file does not
123     // contain any information more sensitive than |clean|.
124     get upgradeBackup() {
125       let latestBackupID = SessionFileInternal.latestUpgradeBackupID;
126       if (!latestBackupID) {
127         return "";
128       }
129       return this.upgradeBackupPrefix + latestBackupID;
130     },
132     // The path to a backup created during an upgrade of Firefox.
133     // Having this backup protects the user essentially from bugs in
134     // Firefox, especially for users of Nightly.
135     get nextUpgradeBackup() {
136       return this.upgradeBackupPrefix + Services.appinfo.platformBuildID;
137     },
139     /**
140      * The order in which to search for a valid sessionstore file.
141      */
142     get loadOrder() {
143       // If `clean` exists and has been written without corruption during
144       // the latest shutdown, we need to use it.
145       //
146       // Otherwise, `recovery` and `recoveryBackup` represent the most
147       // recent state of the session store.
148       //
149       // Finally, if nothing works, fall back to the last known state
150       // that can be loaded (`cleanBackup`) or, if available, to the
151       // backup performed during the latest upgrade.
152       let order = ["clean", "recovery", "recoveryBackup", "cleanBackup"];
153       if (SessionFileInternal.latestUpgradeBackupID) {
154         // We have an upgradeBackup
155         order.push("upgradeBackup");
156       }
157       return order;
158     },
159   }),
161   // Number of attempted calls to `write`.
162   // Note that we may have _attempts > _successes + _failures,
163   // if attempts never complete.
164   // Used for error reporting.
165   _attempts: 0,
167   // Number of successful calls to `write`.
168   // Used for error reporting.
169   _successes: 0,
171   // Number of failed calls to `write`.
172   // Used for error reporting.
173   _failures: 0,
175   // `true` once we have initialized SessionWriter.
176   _initialized: false,
178   // A string that will be set to the session file name part that was read from
179   // disk. It will be available _after_ a session file read() is done.
180   _readOrigin: null,
182   // `true` if the old, uncompressed, file format was used to read from disk, as
183   // a fallback mechanism.
184   _usingOldExtension: false,
186   // The ID of the latest version of Gecko for which we have an upgrade backup
187   // or |undefined| if no upgrade backup was ever written.
188   get latestUpgradeBackupID() {
189     try {
190       return Services.prefs.getCharPref(PREF_UPGRADE_BACKUP);
191     } catch (ex) {
192       return undefined;
193     }
194   },
196   async _readInternal(useOldExtension) {
197     let result;
198     let noFilesFound = true;
199     this._usingOldExtension = useOldExtension;
201     // Attempt to load by order of priority from the various backups
202     for (let key of this.Paths.loadOrder) {
203       let corrupted = false;
204       let exists = true;
205       try {
206         let path;
207         let startMs = Date.now();
209         let options = {};
210         if (useOldExtension) {
211           path = this.Paths[key]
212             .replace("jsonlz4", "js")
213             .replace("baklz4", "bak");
214         } else {
215           path = this.Paths[key];
216           options.decompress = true;
217         }
218         let source = await IOUtils.readUTF8(path, options);
219         let parsed = JSON.parse(source);
221         if (parsed._cachedObjs) {
222           try {
223             let cacheMap = new Map(parsed._cachedObjs);
224             for (let win of parsed.windows.concat(
225               parsed._closedWindows || []
226             )) {
227               for (let tab of win.tabs.concat(win._closedTabs || [])) {
228                 tab.image = cacheMap.get(tab.image) || tab.image;
229               }
230             }
231           } catch (e) {
232             // This is temporary code to clean up after the backout of bug
233             // 1546847. Just in case there are problems in the format of
234             // the parsed data, continue on. Favicons might be broken, but
235             // the session will at least be recovered
236             console.error(e);
237           }
238         }
240         if (
241           !lazy.SessionStore.isFormatVersionCompatible(
242             parsed.version || [
243               "sessionrestore",
244               0,
245             ] /* fallback for old versions*/
246           )
247         ) {
248           // Skip sessionstore files that we don't understand.
249           console.error(
250             "Cannot extract data from Session Restore file ",
251             path,
252             ". Wrong format/version: " + JSON.stringify(parsed.version) + "."
253           );
254           continue;
255         }
256         result = {
257           origin: key,
258           source,
259           parsed,
260           useOldExtension,
261         };
262         Services.telemetry
263           .getHistogramById("FX_SESSION_RESTORE_CORRUPT_FILE")
264           .add(false);
265         Services.telemetry
266           .getHistogramById("FX_SESSION_RESTORE_READ_FILE_MS")
267           .add(Date.now() - startMs);
268         break;
269       } catch (ex) {
270         if (DOMException.isInstance(ex) && ex.name == "NotFoundError") {
271           exists = false;
272         } else if (
273           DOMException.isInstance(ex) &&
274           ex.name == "NotAllowedError"
275         ) {
276           // The file might be inaccessible due to wrong permissions
277           // or similar failures. We'll just count it as "corrupted".
278           console.error("Could not read session file ", ex);
279           corrupted = true;
280         } else if (ex instanceof SyntaxError) {
281           console.error(
282             "Corrupt session file (invalid JSON found) ",
283             ex,
284             ex.stack
285           );
286           // File is corrupted, try next file
287           corrupted = true;
288         }
289       } finally {
290         if (exists) {
291           noFilesFound = false;
292           Services.telemetry
293             .getHistogramById("FX_SESSION_RESTORE_CORRUPT_FILE")
294             .add(corrupted);
295         }
296       }
297     }
298     return { result, noFilesFound };
299   },
301   // Find the correct session file and read it.
302   async read() {
303     // Load session files with lz4 compression.
304     let { result, noFilesFound } = await this._readInternal(false);
305     if (!result) {
306       // No result? Probably because of migration, let's
307       // load uncompressed session files.
308       let r = await this._readInternal(true);
309       result = r.result;
310     }
312     // All files are corrupted if files found but none could deliver a result.
313     let allCorrupt = !noFilesFound && !result;
314     Services.telemetry
315       .getHistogramById("FX_SESSION_RESTORE_ALL_FILES_CORRUPT")
316       .add(allCorrupt);
318     if (!result) {
319       // If everything fails, start with an empty session.
320       result = {
321         origin: "empty",
322         source: "",
323         parsed: null,
324         useOldExtension: false,
325       };
326     }
327     this._readOrigin = result.origin;
329     result.noFilesFound = noFilesFound;
331     return result;
332   },
334   // Initialize SessionWriter and return it as a resolved promise.
335   getWriter() {
336     if (!this._initialized) {
337       if (!this._readOrigin) {
338         return Promise.reject(
339           "SessionFileInternal.getWriter() called too early! Please read the session file from disk first."
340         );
341       }
343       this._initialized = true;
344       lazy.SessionWriter.init(
345         this._readOrigin,
346         this._usingOldExtension,
347         this.Paths,
348         {
349           maxUpgradeBackups: Services.prefs.getIntPref(
350             PREF_MAX_UPGRADE_BACKUPS,
351             3
352           ),
353           maxSerializeBack: Services.prefs.getIntPref(
354             PREF_MAX_SERIALIZE_BACK,
355             10
356           ),
357           maxSerializeForward: Services.prefs.getIntPref(
358             PREF_MAX_SERIALIZE_FWD,
359             -1
360           ),
361         }
362       );
363     }
365     return Promise.resolve(lazy.SessionWriter);
366   },
368   write(aData) {
369     if (lazy.RunState.isClosed) {
370       return Promise.reject(new Error("SessionFile is closed"));
371     }
373     let isFinalWrite = false;
374     if (lazy.RunState.isClosing) {
375       // If shutdown has started, we will want to stop receiving
376       // write instructions.
377       isFinalWrite = true;
378       lazy.RunState.setClosed();
379     }
381     let performShutdownCleanup =
382       isFinalWrite && !lazy.SessionStore.willAutoRestore;
384     this._attempts++;
385     let options = { isFinalWrite, performShutdownCleanup };
386     let promise = this.getWriter().then(writer => writer.write(aData, options));
388     // Wait until the write is done.
389     promise = promise.then(
390       msg => {
391         // Record how long the write took.
392         this._recordTelemetry(msg.telemetry);
393         this._successes++;
394         if (msg.result.upgradeBackup) {
395           // We have just completed a backup-on-upgrade, store the information
396           // in preferences.
397           Services.prefs.setCharPref(
398             PREF_UPGRADE_BACKUP,
399             Services.appinfo.platformBuildID
400           );
401         }
402       },
403       err => {
404         // Catch and report any errors.
405         console.error("Could not write session state file ", err, err.stack);
406         this._failures++;
407         // By not doing anything special here we ensure that |promise| cannot
408         // be rejected anymore. The shutdown/cleanup code at the end of the
409         // function will thus always be executed.
410       }
411     );
413     // Ensure that we can write sessionstore.js cleanly before the profile
414     // becomes unaccessible.
415     IOUtils.profileBeforeChange.addBlocker(
416       "SessionFile: Finish writing Session Restore data",
417       promise,
418       {
419         fetchState: () => ({
420           options,
421           attempts: this._attempts,
422           successes: this._successes,
423           failures: this._failures,
424         }),
425       }
426     );
428     // This code will always be executed because |promise| can't fail anymore.
429     // We ensured that by having a reject handler that reports the failure but
430     // doesn't forward the rejection.
431     return promise.then(() => {
432       // Remove the blocker, no matter if writing failed or not.
433       IOUtils.profileBeforeChange.removeBlocker(promise);
435       if (isFinalWrite) {
436         Services.obs.notifyObservers(
437           null,
438           "sessionstore-final-state-write-complete"
439         );
440       }
441     });
442   },
444   async wipe() {
445     const writer = await this.getWriter();
446     await writer.wipe();
447     // After a wipe, we need to make sure to re-initialize upon the next read(),
448     // because the state variables as sent to the writer have changed.
449     this._initialized = false;
450   },
452   _recordTelemetry(telemetry) {
453     for (let id of Object.keys(telemetry)) {
454       let value = telemetry[id];
455       let samples = [];
456       if (Array.isArray(value)) {
457         samples.push(...value);
458       } else {
459         samples.push(value);
460       }
461       let histogram = Services.telemetry.getHistogramById(id);
462       for (let sample of samples) {
463         histogram.add(sample);
464       }
465     }
466   },