From 38ff418ca53c66d3c8b39e72305f898057bca365 Mon Sep 17 00:00:00 2001 From: jamaljsr <1356600+jamaljsr@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:07:19 -0500 Subject: [PATCH 1/2] db: require persistent OPFS storage for the wasm SQLite store A second browser tab cannot acquire the wallet database's exclusive OPFS handles, and the SQLite worker used to degrade that open failure to an in-memory database. The daemon then booted against a throwaway store and died much later inside migrations with an unrelated-looking SQLITE_CANTOPEN, while any writes that had succeeded were doomed to vanish on page close (issue wavelength-sdk#48). Set require_persistent on the DSN so the open fails closed with the real locked-database error instead. Widen the open retry predicate to SQLITE_BUSY, which is how lock contention now surfaces: the retries still absorb the brief handle-release race after a reload, and a lock holder that never goes away exhausts them and returns the locked error to the caller, where the SDK maps it to a typed wallet_locked failure. --- db/sqlite_open_wasm.go | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) 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") } From e7a7ee6d464be047715bdc3d1e52843a35a42a44 Mon Sep 17 00:00:00 2001 From: jamaljsr <1356600+jamaljsr@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:07:19 -0500 Subject: [PATCH 2/2] lwwallet: require persistent OPFS storage for the wallet database Mirror the db store's fail-closed behavior for btcwallet's OPFS-backed walletdb: never fall back to an in-memory database when the persistent OPFS VFS cannot be opened, and treat SQLITE_BUSY as retryable alongside SQLITE_CANTOPEN, since lock contention now surfaces as an open failure rather than a silent in-memory downgrade. --- lwwallet/walletdb_wasm.go | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) 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") }