diff --git a/db/sqlite_open_wasm.go b/db/sqlite_open_wasm.go index 38e97de3b..98d05bc85 100644 --- a/db/sqlite_open_wasm.go +++ b/db/sqlite_open_wasm.go @@ -27,6 +27,14 @@ func openSQLiteDatabase(cfg SQLiteOpenConfig) (*SQLiteOpenResult, error) { values.Set("vfs", wasmSQLiteVFS) values.Set("mode", "rwc") + // A wallet-grade database silently degrading to the in-memory VFS + // would lose every write on page close, so fail closed when no + // persistent OPFS VFS can be opened. The most common trigger is + // another tab of the same origin holding the exclusive OPFS handles; + // failing here surfaces that as a clear locked-database error instead + // of a later migration failure against a throwaway database. + values.Set("require_persistent", "true") + pragmas := make([]string, 0, len(cfg.Pragmas)+1) for _, pragma := range cfg.Pragmas { switch strings.ToLower(pragma.Name) { @@ -87,7 +95,7 @@ func openWASMSQLiteWithRetry(dsn string) (*sql.DB, error) { } _ = db.Close() - if !isWASMCantOpen(err) { + if !isWASMRetryableOpen(err) { return nil, err } @@ -98,10 +106,16 @@ func openWASMSQLiteWithRetry(dsn string) (*sql.DB, error) { return nil, lastErr } -// isWASMCantOpen identifies the SQLite error returned while OPFS still holds a -// file lock from a just-unloaded page runtime. -func isWASMCantOpen(err error) bool { +// isWASMRetryableOpen identifies the SQLite errors returned while OPFS still +// holds a file lock from a just-unloaded page runtime: SQLITE_CANTOPEN while +// the previous runtime's handles are still being torn down, and SQLITE_BUSY +// now that require_persistent surfaces lock contention as an open failure +// instead of an in-memory fallback. A tab whose lock holder never goes away +// exhausts the retries and returns the locked-database error to the caller. +func isWASMRetryableOpen(err error) bool { return strings.Contains(err.Error(), "SQLITE_CANTOPEN") || + strings.Contains(err.Error(), "SQLITE_BUSY") || + strings.Contains(err.Error(), "database is locked") || strings.Contains(err.Error(), "unable to open database file") } diff --git a/lwwallet/walletdb_wasm.go b/lwwallet/walletdb_wasm.go index 987ab09dc..68f4e3d6a 100644 --- a/lwwallet/walletdb_wasm.go +++ b/lwwallet/walletdb_wasm.go @@ -93,7 +93,7 @@ func openWASMWalletDB(dbDir string) (walletdb.DB, error) { if err == nil { return db, nil } - if !isWASMWalletCantOpen(err) { + if !isWASMWalletRetryableOpen(err) { return nil, fmt.Errorf("open OPFS wallet database: %w", err) } @@ -111,6 +111,12 @@ func wasmWalletDBDSN(dbDir string) string { values.Set("file", wasmWalletDBFileName(dbDir)) values.Set("vfs", "opfs") values.Set("mode", "rwc") + + // The wallet database must never silently degrade to the in-memory + // VFS (every write would be lost on page close), so fail closed when + // no persistent OPFS VFS can be opened, e.g. while another tab of the + // same origin holds the exclusive OPFS handles. + values.Set("require_persistent", "true") values.Set("busy_timeout", wasmWalletDBBusyTimeoutMS) values.Set("journal_mode", "WAL") values.Set( @@ -142,9 +148,14 @@ func wasmWalletDBFileName(dbDir string) string { return fmt.Sprintf(wasmWalletDBFileNamePattern, hasher.Sum64()) } -// isWASMWalletCantOpen identifies the SQLite error returned while OPFS still -// holds the wallet database from a just-unloaded page runtime. -func isWASMWalletCantOpen(err error) bool { +// isWASMWalletRetryableOpen identifies the SQLite errors returned while OPFS +// still holds the wallet database from a just-unloaded page runtime: +// SQLITE_CANTOPEN while the previous runtime's handles are still being torn +// down, and SQLITE_BUSY now that require_persistent surfaces lock contention +// as an open failure instead of an in-memory fallback. +func isWASMWalletRetryableOpen(err error) bool { return strings.Contains(err.Error(), "SQLITE_CANTOPEN") || + strings.Contains(err.Error(), "SQLITE_BUSY") || + strings.Contains(err.Error(), "database is locked") || strings.Contains(err.Error(), "unable to open database file") }