commit 36bd15ca9dfdf8bc59e8f8b8de134f7f1889750c
parent 2cb6d175faeb898a9f06659143349146e6865e9e
Author: Luca Greco <lgreco@mozilla.com>
Date: Wed, 8 Oct 2025 18:25:01 +0000
Bug 1885297 - Make sure storage.local.clear can successfully drop an underlying corrupted IDB database. r=robwu
Differential Revision: https://phabricator.services.mozilla.com/D265632
Diffstat:
4 files changed, 243 insertions(+), 7 deletions(-)
diff --git a/toolkit/components/extensions/ExtensionStorageIDB.sys.mjs b/toolkit/components/extensions/ExtensionStorageIDB.sys.mjs
@@ -169,6 +169,8 @@ var ErrorsTelemetry = {
};
export class ExtensionStorageLocalIDB extends IndexedDB {
+ #storagePrincipal;
+
onupgradeneeded(event) {
if (event.oldVersion < 1) {
this.createObjectStore(IDB_DATA_STORENAME);
@@ -184,6 +186,7 @@ export class ExtensionStorageLocalIDB extends IndexedDB {
let result = await super.openForPrincipal(storagePrincipal, IDB_NAME, {
version: IDB_VERSION,
});
+ result.#storagePrincipal = storagePrincipal;
const isMissingObjestStore = db =>
!db.objectStoreNames.contains(IDB_DATA_STORENAME) &&
@@ -248,6 +251,19 @@ export class ExtensionStorageLocalIDB extends IndexedDB {
});
}
+ async dropAndReopen() {
+ // Forcefully drop the corrupted IndexedDB database.
+ await ExtensionStorageLocalIDB.resetForPrincipal(this.#storagePrincipal);
+ // Reopen the database after it has been reset and retrive the
+ // underlying wrapped IndexedDB database instance to become
+ // the active one for the current IndexedDB database wrapper
+ // instance.
+ const newInstance = await ExtensionStorageLocalIDB.openForPrincipal(
+ this.#storagePrincipal
+ );
+ this.db = newInstance.db;
+ }
+
async isEmpty() {
const cursor = await this.objectStore(
IDB_DATA_STORENAME,
@@ -462,15 +478,37 @@ export class ExtensionStorageLocalIDB extends IndexedDB {
const objectStore = this.objectStore(IDB_DATA_STORENAME, "readwrite");
- const cursor = await objectStore.openCursor();
- while (!cursor.done) {
- changes[cursor.key] = { oldValue: cursor.value };
- changed = true;
- await cursor.continue();
+ try {
+ const cursor = await objectStore.openCursor();
+ while (!cursor.done) {
+ changes[cursor.key] = { oldValue: cursor.value };
+ changed = true;
+ await cursor.continue();
+ }
+ await objectStore.clear();
+ } catch (err) {
+ // Error names expected to be raised on known corrupted storage
+ // issues that storage.local.clear method may be hitting.
+ const KNOWN_CORRUPTED_ERROR_NAMES = ["UnknownError"];
+ const errorName = ErrorsTelemetry.getErrorName(err);
+ if (
+ lazy.disabledAutoResetOnCorrupted ||
+ !KNOWN_CORRUPTED_ERROR_NAMES.includes(errorName)
+ ) {
+ throw err;
+ } else {
+ // Drop and reopen the database if iterating over the
+ // IDB objectStore keys or clearing the objectStore
+ // has hit unexpected rejections.
+ Cu.reportError(err);
+ Glean.extensionsData.storageLocalCorruptedReset.record({
+ addon_id: this.#storagePrincipal.addonId,
+ reason: `RejectedClear:${errorName}`,
+ });
+ await this.dropAndReopen();
+ }
}
- await objectStore.clear();
-
return changed ? changes : null;
}
}
diff --git a/toolkit/components/extensions/metrics.yaml b/toolkit/components/extensions/metrics.yaml
@@ -408,6 +408,9 @@ extensions.data:
database. e.g.
- "ObjectStoreNotFound": (The IndexedDB instance is missing the expected
ObjectStorage name).
+ - "RejectedClear:ERROR_NAME": (The storage.local.clear method has hit an unexpected
+ error while iterating over the database keys, ERROR_NAME is either a DOMException
+ error name or "OtherError" in case of non-DOMException errors).
type: string
after_reset:
description: |
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_local_corrupted_idb.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_local_corrupted_idb.js
@@ -36,6 +36,9 @@ add_task(async function test_idb_autoreset_default() {
// This test the same kind of unexpected corruption of the underlying
// idb database tracked by Bug 1979997.
add_task(async function test_idb_reset_on_missing_object_store() {
+ // Clear any previously collected telemetry.
+ Services.fog.testResetFOG();
+
const id = "test-corrupted-idb@test-ext";
// NOTE: this test extension is only used to derive the storagePrincipal,
@@ -224,3 +227,194 @@ add_task(async function test_idb_reset_on_missing_object_store() {
await extension.unload();
});
+
+add_task(async function test_corrupted_idb_key() {
+ // Clear any previously collected telemetry.
+ Services.fog.testResetFOG();
+
+ const id = "test-corrupted-idb-key@test-ext";
+
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "permanent",
+ manifest: {
+ permissions: ["storage", "unlimitedStorage"],
+ browser_specific_settings: {
+ gecko: { id },
+ },
+ },
+ background() {
+ browser.test.onMessage.addListener(async msg => {
+ let res = {};
+ switch (msg) {
+ case "write-data":
+ await browser.storage.local
+ .set({
+ "test-key": "test-value",
+ // The value set on this key should be big enough to
+ // be stored as a separate file outside of the sqlite
+ // database, that file will be purposely removed by
+ // this test to simulate the database corruption issue
+ // that some users have been hitting in their Firefox
+ // profile.
+ "test-to-be-corrupted-key": new Array(100000).fill("x"),
+ })
+ .catch(err => {
+ res.error = `${err}`;
+ });
+ break;
+ case "read-data":
+ await browser.storage.local.get(null).catch(err => {
+ res.error = `${err}`;
+ });
+ break;
+ case "clear-data":
+ await browser.storage.local.clear().catch(err => {
+ res.error = `${err}`;
+ });
+ break;
+ default:
+ browser.test.fail(`Got unexpected test message: ${msg}`);
+ }
+ browser.test.sendMessage(`${msg}:done`, res);
+ });
+ },
+ });
+
+ await extension.startup();
+ const { uuid } = extension;
+
+ extension.sendMessage("write-data");
+ let writeResult = await extension.awaitMessage("write-data:done");
+ Assert.equal(
+ writeResult.error,
+ null,
+ "Expect no error to be hit while writing data into storage.local"
+ );
+
+ const { AddonManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+ );
+
+ let addon = await AddonManager.getAddonByID(id);
+ await addon.disable();
+
+ const baseDataPath = PathUtils.join(
+ PathUtils.profileDir,
+ "storage",
+ "default",
+ `moz-extension+++${uuid}^userContextId=${WEBEXT_STORAGE_USER_CONTEXT_ID}`,
+ "idb"
+ );
+
+ let sqliteFilePath = (await IOUtils.getChildren(baseDataPath)).find(
+ filePath => filePath.endsWith(".sqlite")
+ );
+
+ let db = await Sqlite.openConnection({ path: sqliteFilePath });
+ let rows = await db.execute(
+ "SELECT * FROM object_data WHERE file_ids IS NOT NULL;"
+ );
+ // Sanity check.
+ Assert.equal(
+ rows[0]?.getResultByName("file_ids"),
+ ".1",
+ "object_data entry with associated file_ids expected to be found"
+ );
+ await db.close();
+
+ let idbDataFileBasePath = (await IOUtils.getChildren(baseDataPath)).find(
+ filePath => filePath.endsWith(".files")
+ );
+ await IOUtils.remove(PathUtils.join(idbDataFileBasePath, "1"), {
+ ignoreAbsent: false,
+ });
+
+ await addon.enable();
+ await extension.awaitStartup();
+
+ // Confirm that the database is corrupted and the extension
+ // is unable to retrieve the data
+ info(
+ "Verify that reading and writing on the corrupted storage.local key fails as expected"
+ );
+ extension.sendMessage("read-data");
+ let readResult = await extension.awaitMessage("read-data:done");
+ Assert.equal(
+ readResult.error,
+ "Error: An unexpected error occurred",
+ "Expect a rejection to be hit while retrieving data from the corrupted storage.local key"
+ );
+ extension.sendMessage("write-data");
+ writeResult = await extension.awaitMessage("write-data:done");
+ Assert.equal(
+ writeResult.error,
+ "Error: An unexpected error occurred",
+ "Expect a rejection to be hit while writing data into the corrupted storage.local key"
+ );
+
+ info(
+ "Verify clearing the storage.local corrupted key fails as expected if auto-reset is disabled"
+ );
+ // Disable automatically drop corrupted database.
+ Services.prefs.setBoolPref(
+ "extensions.webextensions.keepStorageOnCorrupted.storageLocal",
+ true
+ );
+ extension.sendMessage("clear-data");
+ let clearResult = await extension.awaitMessage("clear-data:done");
+ Assert.equal(
+ clearResult.error,
+ "Error: An unexpected error occurred",
+ "Expect a rejection to be hit while clearing storage.local with corrupted key if auto-reset is disabled"
+ );
+
+ info(
+ "Verify clearing the storage.local corrupted key succeeded as expected if auto-reset is enabled"
+ );
+ // Enable automatically drop corrupted database.
+ Services.prefs.setBoolPref(
+ "extensions.webextensions.keepStorageOnCorrupted.storageLocal",
+ false
+ );
+ // Call storage.local.clear and confirm that it doesn't hit a rejection
+ // due to the underlying database corruption and then verify that
+ // storage.local.get and storage.local.set do not hit a rejection anymore.
+ extension.sendMessage("clear-data");
+ clearResult = await extension.awaitMessage("clear-data:done");
+ Assert.equal(
+ clearResult.error,
+ null,
+ "Expect no rejection to be hit while clearing the entire corrupted storage.local data"
+ );
+
+ extension.sendMessage("write-data");
+ Assert.deepEqual(
+ await extension.awaitMessage("write-data:done"),
+ {},
+ "Expect no rejection while writing storage.local data after successful storage.local.clear"
+ );
+
+ extension.sendMessage("read-data");
+ Assert.deepEqual(
+ await extension.awaitMessage("read-data:done"),
+ {},
+ "Expect no rejection while reading storage.local data after successful storage.local.clear"
+ );
+
+ await Services.fog.testFlushAllChildren();
+ const gleanEvents = Glean.extensionsData.storageLocalCorruptedReset
+ .testGetValue()
+ ?.map(event => event.extra);
+ Assert.deepEqual(
+ gleanEvents ?? [],
+ [
+ {
+ addon_id: extension.id,
+ reason: "RejectedClear:UnknownError",
+ },
+ ],
+ "Got the expected telemetry event recorded when the UnknownError is being hit by storage.local.clear API calls"
+ );
+
+ await extension.unload();
+});
diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-common.toml b/toolkit/components/extensions/test/xpcshell/xpcshell-common.toml
@@ -569,6 +569,7 @@ skip-if = [
skip-if = ["os == 'android' && debug"]
["test_ext_storage_local_corrupted_idb.js"]
+skip-if = ["os == 'android'"] # Uncaught rejection failure while running in in-process-webextensions mode.
["test_ext_storage_managed.js"]
skip-if = ["os == 'android'"]