Repository navigation
fix: recover from force-closed IndexedDB connections - #137
Conversation
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>
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>
|
Independently verified and reviewed this — LGTM. ✅ VerificationDeterministic (unit): ran the PR's End-to-end (real browser): wired the fix into
The two builds are source-identical apart from the 3 fix commits (the Implementation reviewAddresses both root causes, not just the promise-dedup suggested in #120:
The Minor, non-blocking: the Once released, this lets us drop the localhost |
There was a problem hiding this comment.
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
IdbKeyValby dropping/reopening dead connections and retrying once onInvalidStateError. - Memoize the in-flight
IdbKeyVal.create()promise inIdbStorageto 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.
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>
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>
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 inidb) ever callsdb.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.IdbKeyValcached one connection for its whole lifetime with no recovery path, so a single force-close turned every subsequent storage call into this error.Changes
IdbKeyValnow survives force-closed connections (src/client/db.ts): the connection promise is dropped viaopenDB'sterminatedhook when the browser reports an abnormal close, and — since Chrome does not always fire that event — anInvalidStateErrorthrown by an operation also drops the dead connection, reopens, and retries the operation once. A failed open is never cached.IdbStorage._dbmemoizes the in-flight creation (src/client/storage.ts): concurrent first accesses previously each started their ownIdbKeyVal.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. InsignIn()the await is placed afteropenChannel()— 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-ticktransport.memoizebatching 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 underIdbKeyVal(via a pass-throughopenDBwrapper) and verifies reads/writes transparently reopen and succeed. This reproduces the retry path; theterminatedevent path is not reproducible underfake-indexeddb, which never force-closes.tests/client/storage.test.ts: verifies concurrent first accesses share a singleIdbKeyVal.create()call, and that a failed open is not cached.pnpm exec vitest run(92 tests),pnpm codestyle:check, andpnpm buildall pass.🤖 Generated with Claude Code