Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
13ffee0
feat: add workarounds for database corruption scenarios
gauthierpetetin Jan 5, 2026
ba96097
Fix linting issues
gauthierpetetin Jan 5, 2026
8a521e8
Add showDatabaseCorruptionToast to snapshots and fixtures
gauthierpetetin Jan 5, 2026
5811292
Add showDatabaseCorruptionToast to Sentry state
gauthierpetetin Jan 5, 2026
8ec2ba1
Place showDatabaseCorruptionToast at the right location based on alph…
gauthierpetetin Jan 5, 2026
ea89449
Add missing showDatabaseCorruptionToast property
gauthierpetetin Jan 5, 2026
b91a0c4
Remove showDatabaseCorruptionToast from before-init snapshots
gauthierpetetin Jan 5, 2026
acd13e0
Merge branch 'main' into database-corruption-workarounds
gauthierpetetin Jan 5, 2026
957b08d
Improve logic that determines when to show the toast
gauthierpetetin Jan 6, 2026
04d4af6
Merge branch 'main' into database-corruption-workarounds
gauthierpetetin Jan 6, 2026
9d109d7
Consolidate vault recovery logic and respect validateVault flag
gauthierpetetin Jan 7, 2026
a0849b3
Rename variables
gauthierpetetin Jan 7, 2026
ed13ab3
Rename toast variables for more generic name
gauthierpetetin Jan 7, 2026
b3fa0af
Merge branch 'main' into database-corruption-workarounds
gauthierpetetin Jan 7, 2026
67776f7
Update comments
gauthierpetetin Jan 7, 2026
c6a7e67
Prevent backup failures
gauthierpetetin Jan 7, 2026
a6ac6dd
Update copies
gauthierpetetin Jan 7, 2026
04766ce
Update copy
gauthierpetetin Jan 7, 2026
84b2ee7
Call notifySetFailed on every error
gauthierpetetin Jan 8, 2026
7a23cab
Merge branch 'main' into database-corruption-workarounds
gauthierpetetin Jan 9, 2026
03a318d
Merge branch 'main' into database-corruption-workarounds
gauthierpetetin Jan 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/_locales/en/messages.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions app/_locales/en_GB/messages.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion app/scripts/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ global.stateHooks.getStorageKind = () => persistenceManager.storageKind;

/**
* A helper function to log the current state of the vault. Useful for debugging
* purposes, to, in the case of database corruption, an possible way for an end
* purposes, to, in the case of storage errors, a possible way for an end
* user to recover their vault. Hopefully this is never needed.
*/
global.logEncryptedVault = () => {
Expand Down Expand Up @@ -1209,6 +1209,11 @@ export function setupController(
cronjobControllerStorageManager,
});

// Wire up the callback to notify the UI when set operations fail
persistenceManager.setOnSetFailed(() => {
controller.appStateController.setShowStorageErrorToast(true);
});

/**
* @type {Array<string>} List of controller store keys that have changed since initialization.
*/
Expand Down
1 change: 1 addition & 0 deletions app/scripts/constants/sentry-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ export const SENTRY_BACKGROUND_STATE = {
lastUpdatedAt: true,
shieldEndingToastLastClickedOrClosed: true,
shieldPausedToastLastClickedOrClosed: true,
showStorageErrorToast: true,
isWalletResetInProgress: false,
pna25Acknowledged: false,
},
Expand Down
3 changes: 3 additions & 0 deletions app/scripts/controllers/app-state-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -807,6 +807,7 @@ describe('AppStateController', () => {
"showNetworkBanner": true,
"showPermissionsTour": true,
"showShieldEntryModalOnce": null,
"showStorageErrorToast": false,
"showTestnetMessageInDropdown": true,
"sidePanelGasPollTokens": [],
"signatureSecurityAlertResponses": {},
Expand Down Expand Up @@ -900,6 +901,7 @@ describe('AppStateController', () => {
"showNetworkBanner": true,
"showPermissionsTour": true,
"showShieldEntryModalOnce": null,
"showStorageErrorToast": false,
"showTestnetMessageInDropdown": true,
"sidePanelGasPollTokens": [],
"signatureSecurityAlertResponses": {},
Expand Down Expand Up @@ -1073,6 +1075,7 @@ describe('AppStateController', () => {
"showNetworkBanner": true,
"showPermissionsTour": true,
"showShieldEntryModalOnce": null,
"showStorageErrorToast": false,
"sidePanelGasPollTokens": [],
"signatureSecurityAlertResponses": {},
"slides": [],
Expand Down
25 changes: 25 additions & 0 deletions app/scripts/controllers/app-state-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ export type AppStateControllerState = {
* Whether the wallet reset is in progress.
*/
isWalletResetInProgress: boolean;

/**
* Whether to show the storage error toast.
* This is set to true when set operations fail (storage.local or IndexedDB).
*/
showStorageErrorToast: boolean;
};

const controllerName = 'AppStateController';
Expand Down Expand Up @@ -326,6 +332,7 @@ const getDefaultAppStateControllerState = (): AppStateControllerState => ({
pendingShieldCohortTxType: null,
isWalletResetInProgress: false,
dappSwapComparisonData: {},
showStorageErrorToast: false,
...getInitialStateOverrides(),
});

Expand Down Expand Up @@ -711,6 +718,12 @@ const controllerMetadata: StateMetadata<AppStateControllerState> = {
includeInDebugSnapshot: false,
usedInUi: true,
},
showStorageErrorToast: {
includeInStateLogs: true,
persist: false,
includeInDebugSnapshot: true,
usedInUi: true,
},
};

export class AppStateController extends BaseController<
Expand Down Expand Up @@ -938,6 +951,18 @@ export class AppStateController extends BaseController<
});
}

/**
* Sets whether to show the storage error toast.
* This is called when set operations fail (storage.local or IndexedDB).
*
* @param show - Whether to show the toast
*/
setShowStorageErrorToast(show: boolean): void {
this.update((state) => {
state.showStorageErrorToast = show;
});
}

/**
* Replaces slides in state with new slides. If a slide with the same id
* already exists, it will be merged with the new slide.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ const mockBrokenPersistence = (error: Error): PersistenceManager =>
getBackup: jest.fn().mockRejectedValue(error),
}) as unknown as PersistenceManager;

// The cause error message that is always included in PersistenceError
// to test that causeMessage is properly extracted and passed to the UI
const CAUSE_ERROR_MESSAGE = 'Error: An unexpected error occurred';

describe('CorruptionHandler.handleStateCorruptionError', () => {
let corruptionHandler: CorruptionHandler;
beforeEach(() => {
Expand Down Expand Up @@ -107,10 +111,14 @@ describe('CorruptionHandler.handleStateCorruptionError', () => {

// some cases of Corruption detection will have a `backup` already
// present in the `error` object, this sets that case up.
// We always include a cause to test that causeMessage is properly
// extracted and passed to the UI across all scenarios.
const cause = new Error(CAUSE_ERROR_MESSAGE);
const error = new PersistenceError(
'Corrupted',
// `backup` is not always a `Backup`, but in reality that is also true
backupHasErr ? (backup as Backup) : null,
cause,
);

// handle the case where `getBackup` function returns an error. We can't
Expand Down Expand Up @@ -172,12 +180,13 @@ describe('CorruptionHandler.handleStateCorruptionError', () => {
);

// make sure the `corruptionFn` was called with the expected error
// message
// message, including the causeMessage extracted from the cause
expect(corruptionFn).toHaveBeenCalledWith({
error: {
message: error.message,
name: error.name,
stack: error.stack,
causeMessage: CAUSE_ERROR_MESSAGE,
},
...result,
});
Expand Down
29 changes: 28 additions & 1 deletion app/scripts/lib/state-corruption/state-corruption-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import {
METHOD_DISPLAY_STATE_CORRUPTION_ERROR,
METHOD_REPAIR_DATABASE,
} from '../../../../shared/constants/state-corruption';
import { type Backup, PersistenceManager } from '../stores/persistence-manager';
import {
type Backup,
PersistenceError,
PersistenceManager,
} from '../stores/persistence-manager';
import { ErrorLike } from '../../../../shared/constants/errors';
import { tryPostMessage } from '../start-up-errors/start-up-errors';
import { RELOAD_WINDOW } from '../../../../shared/constants/start-up-errors';
Expand Down Expand Up @@ -102,6 +106,25 @@ function maybeGetCurrentLocale(backup: Backup | null): string | null {
return null;
}

/**
* Attempts to get the cause message from a PersistenceError.
* This provides additional context about the original error that caused
* the persistence failure (e.g., Firefox's "Error: An unexpected error occurred").
*
* @param error - The error to extract the cause message from.
* @returns The cause message if available, otherwise null.
*/
function maybeGetCauseMessage(error: ErrorLike): string | null {
if (
error instanceof PersistenceError &&
error.cause instanceof Error &&
error.cause.message
) {
return error.cause.message;
}
return null;
}

/**
* Checks if the backup object has a vault.
*
Expand Down Expand Up @@ -156,13 +179,17 @@ export class CorruptionHandler {
// it is not worth claiming we have a backup if the vault doesn't actually
// exist
const hasBackup = Boolean(hasVault(backup));
// Extract cause message if available (e.g., Firefox's "Error: An unexpected error occurred")
// This helps users and customer support debug issues
const causeMessage = maybeGetCauseMessage(error);

// send the `error` to the UI for this port
const sent = tryPostMessage(port, METHOD_DISPLAY_STATE_CORRUPTION_ERROR, {
error: {
message: error.message,
name: error.name,
stack: error.stack,
causeMessage,
},
currentLocale,
hasBackup,
Expand Down
5 changes: 3 additions & 2 deletions app/scripts/lib/stores/persistence-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jest.mock('./extension-store', () => {
});
jest.mock('loglevel', () => ({
error: jest.fn(),
info: jest.fn(),
}));
jest.mock('../../../../shared/lib/sentry', () => ({
captureException: jest.fn(),
Expand Down Expand Up @@ -109,14 +110,14 @@ describe('PersistenceManager', () => {

describe('get', () => {
it('returns undefined and clears mostRecentRetrievedState if store returns empty', async () => {
mockStoreGet.mockReturnValueOnce({});
mockStoreGet.mockResolvedValueOnce({});
const result = await manager.get({ validateVault: false });
expect(result).toBeUndefined();
expect(manager.mostRecentRetrievedState).toBeNull();
});

it('returns undefined if store returns null', async () => {
mockStoreGet.mockReturnValueOnce(null);
mockStoreGet.mockResolvedValueOnce(null);
const result = await manager.get({ validateVault: false });
expect(result).toBeUndefined();
expect(manager.mostRecentRetrievedState).toBeNull();
Expand Down
Loading
Loading