Skip to content

fix: recover from force-closed IndexedDB connections - #137

Merged
sea-snake merged 4 commits into
mainfrom
fix/idb-connection-resilience
Aug 4, 2026
Merged

sea-snake merged 4 commits into
mainfrom
fix/idb-connection-resilience

Conversation

@sea-snake

@sea-snake sea-snake commented Jul 31, 2026 •

Copy link
Copy Markdown
Contributor

Closes #120.

Root cause

The error reported in #120 — InvalidStateError: Failed to execute 'transaction' on 'IDBDatabase': The database connection is closing — means the connection was already closed by the time a storage operation ran. Nothing in this library (or in idb) ever calls db.close(), so the closure comes from the browser: Chrome is known to force-close IndexedDB connections out from under pages (see e.g. Dexie #613, localForage #581), and constant full-page reload churn on Vite localhost dev servers makes this far more likely than on a deployed asset canister. IdbKeyVal cached one connection for its whole lifetime with no recovery path, so a single force-close turned every subsequent storage call into this error.

Changes

  • IdbKeyVal now survives force-closed connections (src/client/db.ts): the connection promise is dropped via openDB's terminated hook when the browser reports an abnormal close, and — since Chrome does not always fire that event — an InvalidStateError thrown by an operation also drops the dead connection, reopens, and retries the operation once. A failed open is never cached.
  • IdbStorage._db memoizes the in-flight creation (src/client/storage.ts): concurrent first accesses previously each started their own IdbKeyVal.create(), opening duplicate connections that leaked (the fix suggested in Race condition in IdbStorage causes 'IDBDatabase connection is closing' error on localhost dev servers #120). This became internally reachable with the 8.0.0 redirect flow, which touches storage concurrently with session hydration.
  • signIn()/signOut() wait for session hydration (src/client/auth-client.ts): both now await the memoized #init() before touching storage, so their writes/deletes cannot interleave with the constructor's restore reads. In signIn() the await is placed after openChannel() — which must stay the first await so a popup opens in the same tick as the user's click — and after the eager session-key acquisition start, preserving the same-tick transport.memoize batching hold from fix(auth-client): keep the redirect flow batched and its derivation origin stable #124.

Tests

  • tests/client/db-recovery.test.ts: closes the underlying connection out from under IdbKeyVal (via a pass-through openDB wrapper) and verifies reads/writes transparently reopen and succeed. This reproduces the retry path; the terminated event path is not reproducible under fake-indexeddb, which never force-closes.
  • tests/client/storage.test.ts: verifies concurrent first accesses share a single IdbKeyVal.create() call, and that a failed open is not cached.

pnpm exec vitest run (92 tests), pnpm codestyle:check, and pnpm build all pass.

🤖 Generated with Claude Code

Addresses the intermittent InvalidStateError ("The database connection
is closing") reported in #120:

- IdbKeyVal no longer caches a connection beyond its lifetime: a
  `terminated` hook drops force-closed connections, and an
  InvalidStateError from an operation (Chrome does not always fire
  `terminated`) drops the dead connection, reopens, and retries once.
- IdbStorage._db memoizes the in-flight IdbKeyVal creation so
  concurrent first accesses share one connection instead of each
  opening their own.
- signIn()/signOut() now wait for the constructor's session hydration
  before touching storage, so their writes cannot interleave with the
  restore reads.

Closes #120

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sea-snake
sea-snake requested a review from a team as a code owner July 31, 2026 16:18
sea-snake and others added 2 commits July 31, 2026 21:44
A popup opened by openChannel() must run in the same tick as the click
that called signIn(), or the browser may block it as not
user-initiated. Await session hydration after the channel is open
instead — still before any storage write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@marc0olo

marc0olo commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Independently verified and reviewed this — LGTM. ✅

Verification

Deterministic (unit): ran the PR's tests/client/db-recovery.test.ts against origin/main's src/ (no fix) → throws at db.put (the connection-closing path), 2 failed. Against this branch → 4 passed.

End-to-end (real browser): wired the fix into rust/vetkeys/basic_bls_signing in dfinity/examples, removed that example's localhost LocalStorage + Ed25519 workaround so it uses the default IdbStorage, then force-closed the live IDB connection the way Chrome does during Vite reload churn (db.close(), mirroring the test's opened.dbs[0].close()) and issued a write:

@icp-sdk/auth build Result
8.0.3 (released latest, no fix) ❌ InvalidStateError: Failed to execute 'transaction' on 'IDBDatabase': The database connection is closing.
this branch (8.0.2 + fix) ✅ write + read-back succeed — connection transparently reopened

The two builds are source-identical apart from the 3 fix commits (the 8.0.2→8.0.3 delta touches only changelog/version/lockfile), so the behavior change is attributable to the fix alone. The real-Chrome error string matches #120 exactly.

Implementation review

Addresses both root causes, not just the promise-dedup suggested in #120:

  • db.ts — terminated hook + retry-once on InvalidStateError recovers from force-closed connections (the actual cause of the reported error).
  • storage.ts — _db now memoizes the in-flight promise, so concurrent first accesses share one IdbKeyVal.create() instead of leaking duplicate connections.
  • auth-client.ts — signIn/signOut await #init() before touching storage, closing the hydration-vs-write race while keeping openChannel() the first await (popup-blocker constraint).

The if (this._dbPromise === promise) guard on every reset path makes concurrent recovery correct — traced it: two concurrent ops on a dead connection reopen exactly once and share the fresh one; a terminated event mid-op is handled. No public API breakage.

Minor, non-blocking: the terminated-event path isn't unit-tested (not reproducible under fake-indexeddb); recovery is a single retry, so a burst of force-closes during the retry could still surface. Both are reasonable tradeoffs.

Once released, this lets us drop the localhost LocalStorage/Ed25519 workaround from the 11 vetkeys examples currently carrying it — tracked in dfinity/examples#1467.

Copilot AI lite review requested due to automatic review settings August 4, 2026 13:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR hardens the client’s IndexedDB-backed storage against browser-forced connection closures and concurrent initialization races that can surface as InvalidStateError: ... database connection is closing, especially during rapid localhost reload + interaction cycles.

Changes:

  • Add IndexedDB connection recovery in IdbKeyVal by dropping/reopening dead connections and retrying once on InvalidStateError.
  • Memoize the in-flight IdbKeyVal.create() promise in IdbStorage to dedupe concurrent first access and avoid leaked duplicate connections.
  • Ensure signIn()/signOut() await session hydration (#init()) before performing storage writes/deletes to prevent interleaving with restore reads.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
src/client/db.ts Adds connection promise caching, terminated-hook handling, and an InvalidStateError retry path for IndexedDB operations.
src/client/storage.ts Memoizes the in-flight DB creation promise to dedupe concurrent initialization and avoid caching failed opens.
src/client/auth-client.ts Awaits session hydration in signIn()/signOut() to prevent storage read/write races.
tests/client/db-recovery.test.ts Adds coverage for reopening/retrying operations after an underlying connection is closed.
tests/client/storage.test.ts Adds coverage for concurrent first access deduplication and “failed open not cached” behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/client/db.ts
@sea-snake
sea-snake merged commit 3cf6063 into main Aug 4, 2026
11 checks passed
@sea-snake
sea-snake deleted the fix/idb-connection-resilience branch August 4, 2026 15:41
marc0olo added a commit to marc0olo/pw-manager-vetkeys that referenced this pull request Aug 27, 2026
dfinity/icp-js-auth#137 ("recover from force-closed IndexedDB connections") is
merged upstream but in no release — 8.0.3 is the newest published and is what we
use. All three parts are verifiably absent from the shipped code:

  IdbKeyVal reopens a force-closed connection   absent (no `terminated`, no
                                                InvalidStateError retry in db.js)
  IdbStorage._db memoizes in-flight creation    not memoized: `get _db()` starts a
                                                fresh IdbKeyVal.create() until
                                                initializedDb is set
  signIn/signOut await hydration                absent: signOut (:301) calls
                                                deleteStorage immediately; only
                                                getIdentity awaits #init

Why it reaches us
  resumeSession() calls signOut() on its two most common refusal paths — a stale
  mark, and no mark — and both ran before anything awaited hydration. So those
  loads deleted the delegation store while the constructor's fire-and-forget
  restore might still be reading it, with both paths racing to open their own
  connection. The force-close half matters too: upstream notes Vite dev reload
  churn makes it far likelier than on a deployed canister, and `npm run dev` is
  exactly that.

  Not affected: isAuthenticated() reads a localStorage mirror of the delegation
  expiry, so it answers correctly without touching IndexedDB and is safe before
  hydration. That was the first thing I suspected, and it is fine.

Impact was low and in the safe direction — #hydrate only reads, so a restore that
won the race set in-memory state the refusal path never consumes; and if a storage
call throws, signOut's try/finally still clears the activity mark, so the next load
refuses again. But it was a race we could simply not have.

Fix: resumeSession() awaits authClient.getIdentity() first, the only method that
awaits #init(). That serialises hydration ahead of every storage write on the load
path, closing both the interleaving and the duplicate-connection window for our
call path without waiting for the release. It does not cover a browser force-close
mid-session; only upstream's retry can.

Both comments point at issue #6, which records what to remove once a release
carries the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
marc0olo added a commit to marc0olo/pw-manager-vetkeys that referenced this pull request Aug 27, 2026
From review round 6. The previous commit's hydration barrier introduced a
regression in the invariant the rest of this PR exists to establish.

`await authClient.getIdentity()` gated the whole decision, and hydration can
reject: restoreKey's `await storage.get(...)` sits outside its try/catch
(auth-client.js:446), and `#initPromise` memoizes the rejection so every later
call rejects too. So a storage failure propagated out of resumeSession, App's
`.catch(() => setIdentity(null))` swallowed it, and the user got a bare lock
screen while the purge silently did not run — key store and activity mark both
left behind.

Verified by writing the tests first and watching them fail against 91285e3:

  stale mark + hydration rejects   before: rejects, store and mark SURVIVE
                                   after:  lockReason "idle", both purged

The trade had gone the wrong way on strictness. The original race was correctly
assessed as low and in the safe direction — #hydrate only reads — and the
mitigation replaced it with a path that skips the cleanup. Worse, the failure mode
that makes hydration reject is precisely the force-closed-connection bug from
dfinity/icp-js-auth#137 that the barrier was added to work around.

Fix: hydration informs the decision rather than gating it.

  const hydrated = await authClient.getIdentity().then(() => true, () => false);
  const hadDelegation = hydrated && authClient.isAuthenticated();

Gating `hadDelegation` matters and is not the same as catching at the top:
isAuthenticated() consults only a localStorage mirror of the expiry, so on its own
it happily reports a delegation that no longer loads. A hydration failure now
falls into an existing refusal branch — purge runs, mark clears, and the lock
screen explains itself.

Both halves are mutation-checked:
  gate the decision again (the regression)     -> 2 fail
  catch at the top but don't gate hadDelegation -> 1 fails

This is the behaviour a future upstream bump must preserve when the workaround is
removed under #6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Race condition in IdbStorage causes 'IDBDatabase connection is closing' error on localhost dev servers

4 participants