SessionWriter.sys.mjs (13445B)
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 const lazy = {}; 6 7 ChromeUtils.defineESModuleGetters(lazy, { 8 sessionStoreLogger: "resource:///modules/sessionstore/SessionLogger.sys.mjs", 9 }); 10 11 /** 12 * We just started (we haven't written anything to disk yet) from 13 * `Paths.clean`. The backup directory may not exist. 14 */ 15 const STATE_CLEAN = "clean"; 16 /** 17 * We know that `Paths.recovery` is good, either because we just read 18 * it (we haven't written anything to disk yet) or because have 19 * already written once to `Paths.recovery` during this session. 20 * `Paths.clean` is absent or invalid. The backup directory exists. 21 */ 22 const STATE_RECOVERY = "recovery"; 23 /** 24 * We just started from `Paths.upgradeBackup` (we haven't written 25 * anything to disk yet). Both `Paths.clean`, `Paths.recovery` and 26 * `Paths.recoveryBackup` are absent or invalid. The backup directory 27 * exists. 28 */ 29 const STATE_UPGRADE_BACKUP = "upgradeBackup"; 30 /** 31 * We just started without a valid session store file (we haven't 32 * written anything to disk yet). The backup directory may not exist. 33 */ 34 const STATE_EMPTY = "empty"; 35 36 var sessionFileIOMutex = Promise.resolve(); 37 // Ensure that we don't do concurrent I/O on the same file. 38 // Example usage: 39 // const unlock = await lockIOWithMutex(); 40 // try { 41 // ... (Do I/O work here.) 42 // } finally { unlock(); } 43 function lockIOWithMutex() { 44 // Return a Promise that resolves when the mutex is free. 45 return new Promise(unlock => { 46 // Overwrite the mutex variable with a chained-on, new Promise. The Promise 47 // we returned to the caller can be called to resolve that new Promise 48 // and unlock the mutex. 49 sessionFileIOMutex = sessionFileIOMutex.then(() => { 50 return new Promise(unlock); 51 }); 52 }); 53 } 54 55 /** 56 * Interface dedicated to handling I/O for Session Store. 57 */ 58 export const SessionWriter = { 59 init(origin, useOldExtension, paths, prefs = {}) { 60 return SessionWriterInternal.init(origin, useOldExtension, paths, prefs); 61 }, 62 63 /** 64 * Write the contents of the session file. 65 * 66 * @param state - May get changed on shutdown. 67 */ 68 async write(state, options = {}) { 69 const unlock = await lockIOWithMutex(); 70 try { 71 return await SessionWriterInternal.write(state, options); 72 } finally { 73 unlock(); 74 } 75 }, 76 77 async wipe() { 78 const unlock = await lockIOWithMutex(); 79 try { 80 return await SessionWriterInternal.wipe(); 81 } finally { 82 unlock(); 83 } 84 }, 85 }; 86 87 const SessionWriterInternal = { 88 // Path to the files used by the SessionWriter 89 Paths: null, 90 91 /** 92 * The current state of the session file, as one of the following strings: 93 * - "empty" if we have started without any sessionstore; 94 * - one of "clean", "recovery", "recoveryBackup", "cleanBackup", 95 * "upgradeBackup" if we have started by loading the corresponding file. 96 */ 97 state: null, 98 99 /** 100 * A flag that indicates we loaded a session file with the deprecated .js extension. 101 */ 102 useOldExtension: false, 103 104 /** 105 * Number of old upgrade backups that are being kept 106 */ 107 maxUpgradeBackups: null, 108 109 /** 110 * Initialize (or reinitialize) the writer. 111 * 112 * @param {string} origin Which of sessionstore.js or its backups 113 * was used. One of the `STATE_*` constants defined above. 114 * @param {boolean} a flag indicate whether we loaded a session file with ext .js 115 * @param {object} paths The paths at which to find the various files. 116 * @param {object} prefs The preferences the writer needs to know. 117 */ 118 init(origin, useOldExtension, paths, prefs) { 119 if (!(origin in paths || origin == STATE_EMPTY)) { 120 throw new TypeError("Invalid origin: " + origin); 121 } 122 123 // Check that all required preference values were passed. 124 for (let pref of [ 125 "maxUpgradeBackups", 126 "maxSerializeBack", 127 "maxSerializeForward", 128 ]) { 129 if (!prefs.hasOwnProperty(pref)) { 130 throw new TypeError(`Missing preference value for ${pref}`); 131 } 132 } 133 134 this.useOldExtension = useOldExtension; 135 this.state = origin; 136 this.Paths = paths; 137 this.maxUpgradeBackups = prefs.maxUpgradeBackups; 138 this.maxSerializeBack = prefs.maxSerializeBack; 139 this.maxSerializeForward = prefs.maxSerializeForward; 140 this.upgradeBackupNeeded = paths.nextUpgradeBackup != paths.upgradeBackup; 141 return { result: true }; 142 }, 143 144 /** 145 * Write the session to disk. 146 * Write the session to disk, performing any necessary backup 147 * along the way. 148 * 149 * @param {object} state The state to write to disk. 150 * @param {object} options 151 * - performShutdownCleanup If |true|, we should 152 * perform shutdown-time cleanup to ensure that private data 153 * is not left lying around; 154 * - isFinalWrite If |true|, write to Paths.clean instead of 155 * Paths.recovery 156 */ 157 async write(state, options) { 158 let exn; 159 let telemetry = {}; 160 161 // Cap the number of backward and forward shistory entries on shutdown. 162 if (options.isFinalWrite) { 163 for (let window of state.windows) { 164 for (let tab of window.tabs) { 165 let lower = 0; 166 let upper = tab.entries.length; 167 168 if (this.maxSerializeBack > -1) { 169 lower = Math.max(lower, tab.index - this.maxSerializeBack - 1); 170 } 171 if (this.maxSerializeForward > -1) { 172 upper = Math.min(upper, tab.index + this.maxSerializeForward); 173 } 174 175 tab.entries = tab.entries.slice(lower, upper); 176 tab.index -= lower; 177 } 178 } 179 } 180 181 try { 182 if (this.state == STATE_CLEAN || this.state == STATE_EMPTY) { 183 // The backups directory may not exist yet. In all other cases, 184 // we have either already read from or already written to this 185 // directory, so we are satisfied that it exists. 186 await IOUtils.makeDirectory(this.Paths.backups); 187 } 188 189 if (this.state == STATE_CLEAN) { 190 // Move $Path.clean out of the way, to avoid any ambiguity as 191 // to which file is more recent. 192 if (!this.useOldExtension) { 193 await IOUtils.move(this.Paths.clean, this.Paths.cleanBackup); 194 } else { 195 // Since we are migrating from .js to .jsonlz4, 196 // we need to compress the deprecated $Path.clean 197 // and write it to $Path.cleanBackup. 198 let oldCleanPath = this.Paths.clean.replace("jsonlz4", "js"); 199 let d = await IOUtils.read(oldCleanPath); 200 await IOUtils.write(this.Paths.cleanBackup, d, { compress: true }); 201 } 202 } 203 204 let startWriteMs = Date.now(); 205 let fileStat; 206 207 if (options.isFinalWrite) { 208 // We are shutting down. At this stage, we know that 209 // $Paths.clean is either absent or corrupted. If it was 210 // originally present and valid, it has been moved to 211 // $Paths.cleanBackup a long time ago. We can therefore write 212 // with the guarantees that we erase no important data. 213 await IOUtils.writeJSON(this.Paths.clean, state, { 214 tmpPath: this.Paths.clean + ".tmp", 215 compress: true, 216 }); 217 fileStat = await IOUtils.stat(this.Paths.clean); 218 } else if (this.state == STATE_RECOVERY) { 219 // At this stage, either $Paths.recovery was written >= 15 220 // seconds ago during this session or we have just started 221 // from $Paths.recovery left from the previous session. Either 222 // way, $Paths.recovery is good. We can move $Path.backup to 223 // $Path.recoveryBackup without erasing a good file with a bad 224 // file. 225 await IOUtils.writeJSON(this.Paths.recovery, state, { 226 tmpPath: this.Paths.recovery + ".tmp", 227 backupFile: this.Paths.recoveryBackup, 228 compress: true, 229 }); 230 fileStat = await IOUtils.stat(this.Paths.recovery); 231 } else { 232 // In other cases, either $Path.recovery is not necessary, or 233 // it doesn't exist or it has been corrupted. Regardless, 234 // don't backup $Path.recovery. 235 await IOUtils.writeJSON(this.Paths.recovery, state, { 236 tmpPath: this.Paths.recovery + ".tmp", 237 compress: true, 238 }); 239 fileStat = await IOUtils.stat(this.Paths.recovery); 240 } 241 242 telemetry.writeFileMs = Date.now() - startWriteMs; 243 telemetry.fileSizeBytes = fileStat.size; 244 lazy.sessionStoreLogger.debug( 245 `SessionWriter.write wrote ${telemetry.fileSizeBytes} bytes in ${telemetry.writeFileMs}ms` 246 ); 247 } catch (ex) { 248 // Don't throw immediately 249 lazy.sessionStoreLogger.warn( 250 "SessionWriter.write, Caught exception:", 251 ex 252 ); 253 exn = exn || ex; 254 } 255 256 // If necessary, perform an upgrade backup 257 let upgradeBackupComplete = false; 258 if ( 259 this.upgradeBackupNeeded && 260 (this.state == STATE_CLEAN || this.state == STATE_UPGRADE_BACKUP) 261 ) { 262 try { 263 // If we loaded from `clean`, the file has since then been renamed to `cleanBackup`. 264 let path = 265 this.state == STATE_CLEAN 266 ? this.Paths.cleanBackup 267 : this.Paths.upgradeBackup; 268 await IOUtils.copy(path, this.Paths.nextUpgradeBackup); 269 this.upgradeBackupNeeded = false; 270 upgradeBackupComplete = true; 271 } catch (ex) { 272 // Don't throw immediately 273 lazy.sessionStoreLogger.warn( 274 "SessionWriter.write, Caught exception doing upgrade backup:", 275 ex 276 ); 277 exn = exn || ex; 278 } 279 280 // Find all backups 281 let backups = []; 282 283 try { 284 let children = await IOUtils.getChildren(this.Paths.backups); 285 backups = children.filter(path => 286 path.startsWith(this.Paths.upgradeBackupPrefix) 287 ); 288 } catch (ex) { 289 // Don't throw immediately 290 lazy.sessionStoreLogger.warn( 291 "SessionWriter.write, Caught exception looking for backups:", 292 ex 293 ); 294 exn = exn || ex; 295 } 296 297 // If too many backups exist, delete them 298 if (backups.length > this.maxUpgradeBackups) { 299 lazy.sessionStoreLogger.debug( 300 `SessionWriter.write, cleaning up ${backups.length - this.maxUpgradeBackups} backup files` 301 ); 302 // Use alphanumerical sort since dates are in YYYYMMDDHHMMSS format 303 backups.sort(); 304 // remove backup file if it is among the first (n-maxUpgradeBackups) files 305 for (let i = 0; i < backups.length - this.maxUpgradeBackups; i++) { 306 try { 307 await IOUtils.remove(backups[i]); 308 } catch (ex) { 309 lazy.sessionStoreLogger.warn( 310 "SessionWriter.write, exception on removing backup file", 311 ex 312 ); 313 exn = exn || ex; 314 } 315 } 316 } 317 } 318 319 if (options.performShutdownCleanup && !exn) { 320 // During shutdown, if auto-restore is disabled, we need to 321 // remove possibly sensitive data that has been stored purely 322 // for crash recovery. Note that this slightly decreases our 323 // ability to recover from OS-level/hardware-level issue. 324 325 // If an exception was raised, we assume that we still need 326 // these files. 327 await IOUtils.remove(this.Paths.recoveryBackup); 328 await IOUtils.remove(this.Paths.recovery); 329 } 330 331 this.state = STATE_RECOVERY; 332 333 if (exn) { 334 throw exn; 335 } 336 337 return { 338 result: { 339 upgradeBackup: upgradeBackupComplete, 340 }, 341 telemetry, 342 }; 343 }, 344 345 /** 346 * Wipes all files holding session data from disk. 347 */ 348 async wipe() { 349 // Don't stop immediately in case of error. 350 let exn = null; 351 352 // Erase main session state file 353 try { 354 await IOUtils.remove(this.Paths.clean); 355 // Remove old extension ones. 356 let oldCleanPath = this.Paths.clean.replace("jsonlz4", "js"); 357 await IOUtils.remove(oldCleanPath, { 358 ignoreAbsent: true, 359 }); 360 } catch (ex) { 361 // Don't stop immediately. 362 exn = exn || ex; 363 } 364 365 // Wipe the Session Restore directory 366 try { 367 await IOUtils.remove(this.Paths.backups, { recursive: true }); 368 } catch (ex) { 369 exn = exn || ex; 370 } 371 372 // Wipe legacy Session Restore files from the profile directory 373 try { 374 await this._wipeFromDir(PathUtils.profileDir, "sessionstore.bak"); 375 } catch (ex) { 376 exn = exn || ex; 377 } 378 379 this.state = STATE_EMPTY; 380 if (exn) { 381 throw exn; 382 } 383 384 return { result: true }; 385 }, 386 387 /** 388 * Wipe a number of files from a directory. 389 * 390 * @param {string} path The directory. 391 * @param {string} prefix Remove files whose 392 * name starts with the prefix. 393 */ 394 async _wipeFromDir(path, prefix) { 395 // Sanity check 396 if (!prefix) { 397 throw new TypeError("Must supply prefix"); 398 } 399 400 let exn = null; 401 402 let children = await IOUtils.getChildren(path, { 403 ignoreAbsent: true, 404 }); 405 for (let entryPath of children) { 406 if (!PathUtils.filename(entryPath).startsWith(prefix)) { 407 continue; 408 } 409 try { 410 let { type } = await IOUtils.stat(entryPath); 411 if (type == "directory") { 412 continue; 413 } 414 await IOUtils.remove(entryPath); 415 } catch (ex) { 416 // Don't stop immediately 417 exn = exn || ex; 418 } 419 } 420 421 if (exn) { 422 throw exn; 423 } 424 }, 425 };