14 KiB
Plan: Fix code-review findings for PR 11592 (session persistence rework)
Source: adversarial code review of PR 11592 (versioned localStorage sessions,
see versioned-localstorage-sessions.md). 16 deduped candidates, 2 refuted,
10 confirmed/plausible findings reported. This plan groups them into six
workstreams ordered by severity and shared root cause. No implementation has
started.
Root-cause summary
The PR makes persistence writes awaited and failable where they were previously
fire-and-forget (void persisted.write(...)). Three systemic consequences fell
out of that change and account for most findings:
- Inconsistent failure handling at call sites. Some paths swallow write failures silently, some rethrow into callers that treat any rejection as total failure, and none distinguish "server operation succeeded but persistence failed" from "operation failed."
- In-memory state (
_state) is now only adopted after a successful write, so a storage failure also corrupts the in-memory view - a regression vs main, where_statewas updated before the (swallowed) write. - Persistence now sits inside the awaited token-refresh chain, so its locking behavior can block the request path.
Findings-to-workstream map
| # | Finding (short) | File anchor | Workstream |
|---|---|---|---|
| 1 | writeToStorage rethrow breaks private mode | src/state/persisted/index.web.ts:221 |
A |
| 2 | Indefinite Web Lock can hang token refresh in all tabs | src/state/persisted/storage-lock.web.ts:21 |
B |
| 3 | Stale bundle's token rotation silently not persisted | src/state/session/index.tsx:119 |
C |
| 4 | Failed logout write can resurrect session on cold start | src/state/session/index.tsx:449 |
A |
| 5 | resumeSession no-op resolves as success | src/state/session/index.tsx:532 |
D |
| 6 | resumeSession rejects after switch already committed | src/state/session/index.tsx:554 |
A |
| 7 | Removed empty-page cursor HACKFIX | src/lib/api/feed/likes.ts:52 |
E |
| 8 | Background refresh reorders account list | src/state/persisted/session-merge.ts:292 |
D |
| 9 | try/finally rewrite can strand pending flags | src/screens/Login/ChooseAccountForm.tsx:62 |
F |
| 10 | refreshSession throws despite successful rotation | src/state/session/index.tsx:688 |
A |
Workstream A: unify persistence-failure semantics (findings 1, 4, 6, 10)
This is the core of the fix. Decide on one policy and apply it everywhere, rather than patching the four symptomatic sites independently.
Proposed policy
- In-memory state always commits first. A storage write failure must never
leave
_state(or the session reducer) behind the truth. Restore the main behavior: update_statebefore attempting the write, on both platforms (src/state/persisted/index.web.ts:104,src/state/persisted/index.ts:95-96). - Persistence failures are reported, not thrown, on the session paths.
Callers of
login/createAccount/refreshSession/resumeSession/logoutshould see success when the server-side operation succeeded. Storage failure becomes a logged warning (with asafeMessage) plus, where it matters, a one-time user-facing signal - not a rejection racing up into callers likeChangeHandleDialog.tsx:176/331andDeactivated.tsx:77that cannot distinguish the two. - Expected environmental failures stay quiet. Web private mode /
quota-exceeded is expected; restore the main-era swallow (with a debug log)
in
writeToStorage(index.web.ts:216-221) so ~25 fire-and-forgetpersisted.write()call sites (e.g.src/state/preferences/languages.tsx:46,src/state/shell/onboarding.tsx:40) do not each produce an unhandled rejection.
Tasks
- Restore commit-before-write ordering for
_stateon web (index.web.ts:104) and native (index.ts:95-96). This alone fixes the "second language toggle loses the first" in-memory regression (finding 1) and the logout-resurrection vector (finding 4), since a later successful write of any key spreads{..._state, [key]: value}from a_statethat already reflects the logout. - Stop rethrowing from
writeToStorageon web for quota/private-mode errors; keep rethrow (or structured error) only for unexpected failures. - Sweep every
store.dispatch(...)persistence promise insrc/state/session/index.tsx(lines ~119-139, 371-375, 417-421, 449, 498, 532-554, 688-693, 732) and make handling uniform per the policy above. Today: login/createAccount.catch()and continue, logout/removeAccount.catch(() => {})silently, resumeSession awaits uncaught, refreshSession rethrows viatakeSessionChangeError. All four shapes should collapse to one helper, e.g.persistOrWarn(promise, context). - resumeSession (finding 6): the in-memory switch commits synchronously at
index.tsx:114-115; the awaited write atindex.tsx:554must not turn a committed switch into a caller-visible failure (useAccountSwitcher.ts:49-57currently shows "Please sign in as @handle" and routes to login whilehasSessionis already true). - refreshSession (finding 10): once persistence failures no longer throw,
confirm
ChangeHandleDialogandDeactivatedclose/succeed correctly. If we decide some callers DO need to know about persistence failure, expose it as a discriminated result, not a rejection. - Audit for remaining unhandled-rejection paths: every
persisted.write()and sessiondispatchcall site either awaits with handling or is explicitlyvoid-ed against a non-throwing promise.
Open decision
Whether logout should surface persistence failure to the user (it is the one
case where a swallowed failure has a security flavor: tokens remain on disk).
Options: (a) toast "couldn't fully clear stored credentials"; (b) retry harder
then give up silently once _state ordering is fixed (the resurrection bug is
gone either way); (c) queue a tombstone re-write on next successful write.
Recommend (b) plus a logger.error - the resurrection vector, not the disk
residue, was the real bug, and native disk-full is rare.
Workstream B: bound the Web Lock so it cannot hang token refresh (finding 2)
navigator.locks.request(PERSISTED_STORAGE_LOCK, op) at
storage-lock.web.ts:21 is exclusive, indefinite, and now sits inside the
awaited chain fetchHandler -> refresh() -> onUpdated -> dispatch -> writeSession (password-session.js:115-116, index.web.ts:121). A tab paused
in devtools (or hung in the critical section) blocks token refresh - and with
it every authenticated request - in all other tabs.
Tasks
- Add an
AbortSignaltimeout to the lock request (a few seconds). On timeout, proceed per Workstream A policy: in-memory state is already committed; log and skip (or retry once in the background) the persisted write. Never let the request path wait indefinitely on another tab. - Alternatively/additionally: move the persisted write out of the awaited
refresh chain entirely - resolve the session promise after the reducer
dispatch, and let
writeSessionrun fire-and-forget behind the lock. Evaluate against the cross-tab reconciliation logic before choosing; the write ordering guarantees insession-merge.tsmay depend on awaiting. - Add a regression test simulating a held lock: second tab's refresh must complete (in memory) within the timeout budget.
Workstream C: persist successful rotations from stale bundles (finding 3)
The reducer's bundle-identity guard (src/state/session/reducer.ts:84-90)
returns state unchanged for a mutation from a superseded bundle, so
needsPersist stays false and dispatch never calls writeSession
(index.tsx:119-139). But the server has already rotated the refresh token;
bundle disposal is deferred to a post-commit effect (index.tsx:887-899), so
account A's onUpdated legitimately fires mid-switch. Storage keeps the
consumed refresh token; resuming A after the PDS grace period (~2h) logs the
user out.
Tasks
- Decouple "update reducer state" from "persist credentials": a valid
refreshmutation for a known account should reach storage (via the session-merge path, which already reconciles by account DID and generation) even when the reducer rejects it for bundle identity. Concretely: inonSessionChange/ dispatch, if the reducer returns unchanged due to bundle mismatch but the mutation carries newer credentials for a stored account, still callwriteSessionwith a credentials-only merge. - Guard against the inverse: a rotation from a stale bundle must not
clobber NEWER stored credentials for the same account - rely on the
existing generation comparison in
session-merge.ts(the "Log rejected refresh generations" commit suggests the machinery exists). - Test: switch A -> B while A has an in-flight refresh; assert storage ends with A's rotated token, reducer stays on B.
Workstream D: resumeSession divergence and account ordering (findings 5, 8)
Finding 5: silent no-op resume
if (!latestStoredAccount?.refreshJwt) return at index.tsx:532 resolves as
success without dispatching, so useAccountSwitcher.ts:38-42 shows "Signed in
as @handle" and fires the account:loggedIn metric while the previous account
is still active.
- Make the no-credentials path throw a typed error (e.g.
SessionResumeError('no-stored-credentials')) so the existing caller catch shows "Please sign in as @handle" and routes to login, matching main's behavior. Keep the early return only if there is a case where silent success is genuinely correct (none identified in review).
Finding 8: background refresh reorders the account list
replaceAccount (session-merge.ts:292) unshifts to index 0 and is called by
both refresh (line 205) and login (line 238) mutations. Cross-tab sync
adopts the order verbatim (reducer.ts:238-241), so AccountList.tsx:54
reorders under the user's finger during a background refresh in another tab.
- Preserve list position for
refreshmutations (replace in place); move-to-front only for explicit user actions (login, switch). If "current account first" is an invariant elsewhere, derive display order in the UI instead of mutating storage order. - Test: refresh mutation for account at index 2 leaves it at index 2; login mutation still fronts it.
Workstream E: restore empty-page cursor protection in feed APIs (finding 7)
The removed HACKFIX ("-sfn" comment) in src/lib/api/feed/likes.ts:52 and
src/lib/api/feed/custom.ts:99 handled servers returning a cursor with zero
items. The PR's seenCursors guard exists only in MergeFeedAPI
(merge.ts:226-236). useAutoPagination's repeated-cursor guard never matches
an advancing cursor and its 50-attempt failsafe resets whenever itemCount
changes; afterward hasNextPage stays true and every onEndReached refetches
empty pages with a flashing spinner (PostFeed.tsx:773, 964).
- Either restore the empty-page-drops-cursor behavior in
LikesFeedAPIandCustomFeedAPI, or lift theseenCursorsguard into shared code all feed APIs use. Prefer the shared guard - it also covers future APIs. - Test: mock a feed returning
{items: [], cursor: <advancing>}pages; assert pagination terminates andhasNextPagegoes false.
Workstream F: restore finally-guaranteed cleanup (finding 9)
The systematic try/finally -> try/catch + trailing-statement rewrite (~14
sites) skips cleanup when the catch body itself throws. Confirmed sites:
src/screens/Login/ChooseAccountForm.tsx:62-setPendingDid(null)skipped ifonSelectAccount(account)(line 60) throws; theif (pendingDid) returnguard (line 33) then ignores every tap until remount.App.tsx:130-setIsReadyskipped -> stuck splash screen.ChangePasswordDialog,ExportCarDialog,ReportDialog,useAccountSwitcher-setIsProcessing/setLoadingflags, withToast.showin the catch as the potential thrower.
Tasks
- Enumerate all rewritten sites in the PR diff (search the diff for removed
finallyblocks) and restorefinallyfor every state-flag cleanup. Keepcatchfor the error handling; this istry/catch/finally, not a revert. - If any site intentionally moved cleanup out of
finally(e.g. cleanup must NOT run on failure), add a comment stating why.
Sequencing
- A first - it defines the failure-semantics policy the other session workstreams build on, and fixes the two worst user-facing regressions (private-mode breakage, logout resurrection).
- B and C next - both touch the dispatch/writeSession chain A refactors; doing them after A avoids rework. B's "move write off the refresh chain" option interacts with A's helper.
- D after A (finding 5's fix shape depends on A's error-vs-result decision for resumeSession).
- E and F are independent of A-D and of each other; can be parallelized or done in separate small PRs at any point.
Verification
pnpm test,pnpm typecheck,pnpm lintclean.- New unit tests called out in B, C, D, E above.
- Manual web pass in Safari private mode: toggle preferences (no unhandled rejections, both of two consecutive language additions stick), log in, change handle (dialog closes), switch accounts, log out.
- Manual two-tab pass: background refresh in tab B while tab A has the account switcher open (no reorder); pause tab B in devtools and confirm tab A's requests still complete after token expiry.
- Native smoke: login, switch, logout, relaunch (no session resurrection).
Explicitly out of scope (cut or refuted in review)
- uriSize.web helper claim - refuted (blob URLs, not data URIs).
- No-Web-Locks fallback - documented accepted tradeoff.
- Cleanup-level items cut under the finding cap: duplicated reconciliation
blocks, writeSession fast-path/full-root round-trip, storage-lock
duplication,
persistWithRetryvsretry(),messages.pochurn. Revisit in a follow-up/simplifypass after the correctness fixes land. - Native
readLatestlagging the write queue during rapid account switches - weakest plausible race, mitigated by the PDS grace period; not planned.