SessionFile.sys.mjs (17316B)
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/. */ 4 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 */ 17 18 const lazy = {}; 19 20 ChromeUtils.defineESModuleGetters(lazy, { 21 sessionStoreLogger: "resource:///modules/sessionstore/SessionLogger.sys.mjs", 22 RunState: "resource:///modules/sessionstore/RunState.sys.mjs", 23 SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", 24 SessionWriter: "resource:///modules/sessionstore/SessionWriter.sys.mjs", 25 }); 26 27 const PREF_UPGRADE_BACKUP = "browser.sessionstore.upgradeBackup.latestBuildID"; 28 const PREF_MAX_UPGRADE_BACKUPS = 29 "browser.sessionstore.upgradeBackup.maxUpgradeBackups"; 30 31 const PREF_MAX_SERIALIZE_BACK = "browser.sessionstore.max_serialize_back"; 32 const PREF_MAX_SERIALIZE_FWD = "browser.sessionstore.max_serialize_forward"; 33 34 export var SessionFile = { 35 /** 36 * Read the contents of the session file, asynchronously. 37 */ 38 read() { 39 return SessionFileInternal.read(); 40 }, 41 /** 42 * Write the contents of the session file, asynchronously. 43 * 44 * @param aData - May get changed on shutdown. 45 */ 46 write(aData) { 47 return SessionFileInternal.write(aData); 48 }, 49 /** 50 * Wipe the contents of the session file, asynchronously. 51 */ 52 wipe() { 53 return SessionFileInternal.wipe(); 54 }, 55 56 /** 57 * Return the paths to the files used to store, backup, etc. 58 * the state of the file. 59 */ 60 get Paths() { 61 return SessionFileInternal.Paths; 62 }, 63 }; 64 65 Object.freeze(SessionFile); 66 67 const profileDir = PathUtils.profileDir; 68 69 var SessionFileInternal = { 70 Paths: Object.freeze({ 71 // The path to the latest version of sessionstore written during a clean 72 // shutdown. After startup, it is renamed `cleanBackup`. 73 clean: PathUtils.join(profileDir, "sessionstore.jsonlz4"), 74 75 // The path at which we store the previous version of `clean`. Updated 76 // whenever we successfully load from `clean`. 77 cleanBackup: PathUtils.join( 78 profileDir, 79 "sessionstore-backups", 80 "previous.jsonlz4" 81 ), 82 83 // The directory containing all sessionstore backups. 84 backups: PathUtils.join(profileDir, "sessionstore-backups"), 85 86 // The path to the latest version of the sessionstore written 87 // during runtime. Generally, this file contains more 88 // privacy-sensitive information than |clean|, and this file is 89 // therefore removed during clean shutdown. This file is designed to protect 90 // against crashes / sudden shutdown. 91 recovery: PathUtils.join( 92 profileDir, 93 "sessionstore-backups", 94 "recovery.jsonlz4" 95 ), 96 97 // The path to the previous version of the sessionstore written 98 // during runtime (e.g. 15 seconds before recovery). In case of a 99 // clean shutdown, this file is removed. Generally, this file 100 // contains more privacy-sensitive information than |clean|, and 101 // this file is therefore removed during clean shutdown. This 102 // file is designed to protect against crashes that are nasty 103 // enough to corrupt |recovery|. 104 recoveryBackup: PathUtils.join( 105 profileDir, 106 "sessionstore-backups", 107 "recovery.baklz4" 108 ), 109 110 // The path to a backup created during an upgrade of Firefox. 111 // Having this backup protects the user essentially from bugs in 112 // Firefox or add-ons, especially for users of Nightly. This file 113 // does not contain any information more sensitive than |clean|. 114 upgradeBackupPrefix: PathUtils.join( 115 profileDir, 116 "sessionstore-backups", 117 "upgrade.jsonlz4-" 118 ), 119 120 // The path to the backup of the version of the session store used 121 // during the latest upgrade of Firefox. During load/recovery, 122 // this file should be used if both |path|, |backupPath| and 123 // |latestStartPath| are absent/incorrect. May be "" if no 124 // upgrade backup has ever been performed. This file does not 125 // contain any information more sensitive than |clean|. 126 get upgradeBackup() { 127 let latestBackupID = SessionFileInternal.latestUpgradeBackupID; 128 if (!latestBackupID) { 129 return ""; 130 } 131 return this.upgradeBackupPrefix + latestBackupID; 132 }, 133 134 // The path to a backup created during an upgrade of Firefox. 135 // Having this backup protects the user essentially from bugs in 136 // Firefox, especially for users of Nightly. 137 get nextUpgradeBackup() { 138 return this.upgradeBackupPrefix + Services.appinfo.platformBuildID; 139 }, 140 141 /** 142 * The order in which to search for a valid sessionstore file. 143 */ 144 get loadOrder() { 145 // If `clean` exists and has been written without corruption during 146 // the latest shutdown, we need to use it. 147 // 148 // Otherwise, `recovery` and `recoveryBackup` represent the most 149 // recent state of the session store. 150 // 151 // Finally, if nothing works, fall back to the last known state 152 // that can be loaded (`cleanBackup`) or, if available, to the 153 // backup performed during the latest upgrade. 154 let order = ["clean", "recovery", "recoveryBackup", "cleanBackup"]; 155 if (SessionFileInternal.latestUpgradeBackupID) { 156 // We have an upgradeBackup 157 order.push("upgradeBackup"); 158 } 159 return order; 160 }, 161 }), 162 163 // Number of attempted calls to `write`. 164 // Note that we may have _attempts > _successes + _failures, 165 // if attempts never complete. 166 // Used for error reporting. 167 _attempts: 0, 168 169 // Number of successful calls to `write`. 170 // Used for error reporting. 171 _successes: 0, 172 173 // Number of failed calls to `write`. 174 // Used for error reporting. 175 _failures: 0, 176 177 // `true` once we have initialized SessionWriter. 178 _initialized: false, 179 180 // A string that will be set to the session file name part that was read from 181 // disk. It will be available _after_ a session file read() is done. 182 _readOrigin: null, 183 184 // `true` if the old, uncompressed, file format was used to read from disk, as 185 // a fallback mechanism. 186 _usingOldExtension: false, 187 188 // The ID of the latest version of Gecko for which we have an upgrade backup 189 // or |undefined| if no upgrade backup was ever written. 190 get latestUpgradeBackupID() { 191 try { 192 return Services.prefs.getCharPref(PREF_UPGRADE_BACKUP); 193 } catch (ex) { 194 return undefined; 195 } 196 }, 197 198 async _readInternal(useOldExtension) { 199 let result; 200 let noFilesFound = true; 201 this._usingOldExtension = useOldExtension; 202 203 // Attempt to load by order of priority from the various backups 204 for (let key of this.Paths.loadOrder) { 205 let corrupted = false; 206 let exists = true; 207 try { 208 let path; 209 let startMs = Date.now(); 210 211 let options = {}; 212 if (useOldExtension) { 213 path = this.Paths[key] 214 .replace("jsonlz4", "js") 215 .replace("baklz4", "bak"); 216 } else { 217 path = this.Paths[key]; 218 options.decompress = true; 219 } 220 let source = await IOUtils.readUTF8(path, options); 221 let parsed = JSON.parse(source); 222 223 if (parsed._cachedObjs) { 224 try { 225 let cacheMap = new Map(parsed._cachedObjs); 226 for (let win of parsed.windows.concat( 227 parsed._closedWindows || [] 228 )) { 229 for (let tab of win.tabs.concat(win._closedTabs || [])) { 230 tab.image = cacheMap.get(tab.image) || tab.image; 231 } 232 } 233 } catch (e) { 234 // This is temporary code to clean up after the backout of bug 235 // 1546847. Just in case there are problems in the format of 236 // the parsed data, continue on. Favicons might be broken, but 237 // the session will at least be recovered 238 lazy.sessionStoreLogger.error(e); 239 } 240 } 241 242 if ( 243 !lazy.SessionStore.isFormatVersionCompatible( 244 parsed.version || [ 245 "sessionrestore", 246 0, 247 ] /* fallback for old versions*/ 248 ) 249 ) { 250 // Skip sessionstore files that we don't understand. 251 lazy.sessionStoreLogger.warn( 252 "Cannot extract data from Session Restore file ", 253 path, 254 ". Wrong format/version: " + JSON.stringify(parsed.version) + "." 255 ); 256 Glean.sessionRestore.backupCanBeLoadedSessionFile.record({ 257 can_load: "false", 258 path_key: key, 259 loadfail_reason: 260 "Wrong format/version: " + JSON.stringify(parsed.version) + ".", 261 }); 262 continue; 263 } 264 result = { 265 origin: key, 266 source, 267 parsed, 268 useOldExtension, 269 }; 270 Glean.sessionRestore.backupCanBeLoadedSessionFile.record({ 271 can_load: "true", 272 path_key: key, 273 loadfail_reason: "N/A", 274 }); 275 Glean.sessionRestore.corruptFile.false.add(); 276 Glean.sessionRestore.readFile.accumulateSingleSample( 277 Date.now() - startMs 278 ); 279 lazy.sessionStoreLogger.debug(`Successful file read of ${key} file`); 280 break; 281 } catch (ex) { 282 if (DOMException.isInstance(ex) && ex.name == "NotFoundError") { 283 exists = false; 284 Glean.sessionRestore.backupCanBeLoadedSessionFile.record({ 285 can_load: "false", 286 path_key: key, 287 loadfail_reason: "File doesn't exist.", 288 }); 289 // A file not existing can be normal and expected. 290 lazy.sessionStoreLogger.debug( 291 `Can't read session file which doesn't exist: ${key}` 292 ); 293 } else if ( 294 DOMException.isInstance(ex) && 295 ex.name == "NotReadableError" 296 ) { 297 // The file might incorrectly jsonlz4 encoded 298 // We'll count it as "corrupted". 299 lazy.sessionStoreLogger.error( 300 `NotReadableError when reading session file: ${key}`, 301 ex 302 ); 303 corrupted = true; 304 Glean.sessionRestore.backupCanBeLoadedSessionFile.record({ 305 can_load: "false", 306 path_key: key, 307 loadfail_reason: ` ${ex.name}: Could not read session file`, 308 }); 309 } else if ( 310 DOMException.isInstance(ex) && 311 ex.name == "NotAllowedError" 312 ) { 313 // The file might be inaccessible due to wrong permissions 314 // or similar failures. We'll just count it as "corrupted". 315 lazy.sessionStoreLogger.error( 316 `NotAllowedError when reading session file: ${key}`, 317 ex 318 ); 319 corrupted = true; 320 Glean.sessionRestore.backupCanBeLoadedSessionFile.record({ 321 can_load: "false", 322 path_key: key, 323 loadfail_reason: ` ${ex.name}: Could not read session file`, 324 }); 325 } else if (ex instanceof SyntaxError) { 326 lazy.sessionStoreLogger.error( 327 "Corrupt session file (invalid JSON found) ", 328 ex, 329 ex.stack 330 ); 331 // File is corrupted, try next file 332 corrupted = true; 333 Glean.sessionRestore.backupCanBeLoadedSessionFile.record({ 334 can_load: "false", 335 path_key: key, 336 loadfail_reason: ` ${ex.name}: Corrupt session file (invalid JSON found)`, 337 }); 338 } 339 } finally { 340 if (exists) { 341 noFilesFound = false; 342 Glean.sessionRestore.corruptFile[corrupted ? "true" : "false"].add(); 343 Glean.sessionRestore.backupCanBeLoadedSessionFile.record({ 344 can_load: (!corrupted).toString(), 345 path_key: key, 346 loadfail_reason: "N/A", 347 }); 348 } 349 } 350 } 351 return { result, noFilesFound }; 352 }, 353 354 // Find the correct session file and read it. 355 async read() { 356 // Load session files with lz4 compression. 357 let { result, noFilesFound } = await this._readInternal(false); 358 if (!result) { 359 // No result? Probably because of migration, let's 360 // load uncompressed session files. 361 let r = await this._readInternal(true); 362 result = r.result; 363 } 364 365 // All files are corrupted if files found but none could deliver a result. 366 let allCorrupt = !noFilesFound && !result; 367 Glean.sessionRestore.allFilesCorrupt[allCorrupt ? "true" : "false"].add(); 368 369 if (!result) { 370 // If everything fails, start with an empty session. 371 lazy.sessionStoreLogger.warn( 372 "No readable session files found to restore, starting with empty session" 373 ); 374 result = { 375 origin: "empty", 376 source: "", 377 parsed: null, 378 useOldExtension: false, 379 }; 380 } 381 this._readOrigin = result.origin; 382 383 result.noFilesFound = noFilesFound; 384 385 return result; 386 }, 387 388 // Initialize SessionWriter and return it as a resolved promise. 389 getWriter() { 390 if (!this._initialized) { 391 if (!this._readOrigin) { 392 return Promise.reject( 393 "SessionFileInternal.getWriter() called too early! Please read the session file from disk first." 394 ); 395 } 396 397 this._initialized = true; 398 lazy.SessionWriter.init( 399 this._readOrigin, 400 this._usingOldExtension, 401 this.Paths, 402 { 403 maxUpgradeBackups: Services.prefs.getIntPref( 404 PREF_MAX_UPGRADE_BACKUPS, 405 3 406 ), 407 maxSerializeBack: Services.prefs.getIntPref( 408 PREF_MAX_SERIALIZE_BACK, 409 10 410 ), 411 maxSerializeForward: Services.prefs.getIntPref( 412 PREF_MAX_SERIALIZE_FWD, 413 -1 414 ), 415 } 416 ); 417 } 418 419 return Promise.resolve(lazy.SessionWriter); 420 }, 421 422 write(aData) { 423 if (lazy.RunState.isClosed) { 424 return Promise.reject(new Error("SessionFile is closed")); 425 } 426 427 let isFinalWrite = false; 428 if (lazy.RunState.isClosing) { 429 // If shutdown has started, we will want to stop receiving 430 // write instructions. 431 isFinalWrite = true; 432 lazy.RunState.setClosed(); 433 } 434 435 let performShutdownCleanup = 436 isFinalWrite && !lazy.SessionStore.willAutoRestore; 437 438 this._attempts++; 439 let options = { isFinalWrite, performShutdownCleanup }; 440 let promise = this.getWriter().then(writer => writer.write(aData, options)); 441 442 // Wait until the write is done. 443 promise = promise.then( 444 msg => { 445 // Record how long the write took. 446 if (msg.telemetry.writeFileMs) { 447 Glean.sessionRestore.writeFile.accumulateSingleSample( 448 msg.telemetry.writeFileMs 449 ); 450 } 451 if (msg.telemetry.fileSizeBytes) { 452 Glean.sessionRestore.fileSizeBytes.accumulate( 453 msg.telemetry.fileSizeBytes 454 ); 455 } 456 457 this._successes++; 458 if (msg.result.upgradeBackup) { 459 // We have just completed a backup-on-upgrade, store the information 460 // in preferences. 461 Services.prefs.setCharPref( 462 PREF_UPGRADE_BACKUP, 463 Services.appinfo.platformBuildID 464 ); 465 } 466 }, 467 err => { 468 // Catch and report any errors. 469 lazy.sessionStoreLogger.error( 470 "Could not write session state file ", 471 err, 472 err.stack 473 ); 474 this._failures++; 475 // By not doing anything special here we ensure that |promise| cannot 476 // be rejected anymore. The shutdown/cleanup code at the end of the 477 // function will thus always be executed. 478 } 479 ); 480 481 // Ensure that we can write sessionstore.js cleanly before the profile 482 // becomes unaccessible. 483 IOUtils.profileBeforeChange.addBlocker( 484 "SessionFile: Finish writing Session Restore data", 485 promise, 486 { 487 fetchState: () => ({ 488 options, 489 attempts: this._attempts, 490 successes: this._successes, 491 failures: this._failures, 492 }), 493 } 494 ); 495 496 // This code will always be executed because |promise| can't fail anymore. 497 // We ensured that by having a reject handler that reports the failure but 498 // doesn't forward the rejection. 499 return promise.then(() => { 500 // Remove the blocker, no matter if writing failed or not. 501 IOUtils.profileBeforeChange.removeBlocker(promise); 502 503 if (isFinalWrite) { 504 Services.obs.notifyObservers( 505 null, 506 "sessionstore-final-state-write-complete" 507 ); 508 } 509 }); 510 }, 511 512 async wipe() { 513 const writer = await this.getWriter(); 514 await writer.wipe(); 515 // After a wipe, we need to make sure to re-initialize upon the next read(), 516 // because the state variables as sent to the writer have changed. 517 this._initialized = false; 518 }, 519 };