Add review docs, WIP

This commit is contained in:
Eric Bailey
2026-09-02 09:17:37 -05:00
parent 348eb03c52
commit 4f1b90621b
2 changed files with 262 additions and 0 deletions
+259
View File
@@ -0,0 +1,259 @@
# 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:
1. **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."
2. **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 `_state` was updated before the (swallowed) write.
3. **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 `_state` before 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` /
`logout` should see success when the server-side operation succeeded. Storage
failure becomes a logged warning (with a `safeMessage`) plus, where it
matters, a one-time user-facing signal - not a rejection racing up into
callers like `ChangeHandleDialog.tsx:176/331` and `Deactivated.tsx:77` that
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-forget
`persisted.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 `_state` on 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 `_state` that
already reflects the logout.
- [ ] Stop rethrowing from `writeToStorage` on web for quota/private-mode
errors; keep rethrow (or structured error) only for unexpected failures.
- [ ] Sweep every `store.dispatch(...)` persistence promise in
`src/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 via `takeSessionChangeError`. 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 at `index.tsx:554` must not turn a
committed switch into a caller-visible failure
(`useAccountSwitcher.ts:49-57` currently shows "Please sign in as
@handle" and routes to login while `hasSession` is already true).
- [ ] refreshSession (finding 10): once persistence failures no longer throw,
confirm `ChangeHandleDialog` and `Deactivated` close/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 session `dispatch` call site either awaits with handling or is
explicitly `void`-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 `AbortSignal` timeout 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 `writeSession` run fire-and-forget behind the lock.
Evaluate against the cross-tab reconciliation logic before choosing;
the write ordering guarantees in `session-merge.ts` may 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
`refresh` mutation 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: in `onSessionChange` / dispatch, if the reducer returns
unchanged due to bundle mismatch but the mutation carries newer
credentials for a stored account, still call `writeSession` with 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 `refresh` mutations (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 `LikesFeedAPI`
and `CustomFeedAPI`, or lift the `seenCursors` guard 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 and `hasNextPage` goes 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
if `onSelectAccount(account)` (line 60) throws; the `if (pendingDid) return`
guard (line 33) then ignores every tap until remount.
- `App.tsx:130` - `setIsReady` skipped -> stuck splash screen.
- `ChangePasswordDialog`, `ExportCarDialog`, `ReportDialog`,
`useAccountSwitcher` - `setIsProcessing`/`setLoading` flags, with
`Toast.show` in the catch as the potential thrower.
### Tasks
- [ ] Enumerate all rewritten sites in the PR diff (search the diff for removed
`finally` blocks) and restore `finally` for every state-flag cleanup.
Keep `catch` for the error handling; this is `try/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
1. **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).
2. **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.
3. **D** after A (finding 5's fix shape depends on A's error-vs-result
decision for resumeSession).
4. **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 lint` clean.
- [ ] 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, `persistWithRetry` vs `retry()`, `messages.po` churn. Revisit
in a follow-up `/simplify` pass after the correctness fixes land.
- Native `readLatest` lagging the write queue during rapid account switches -
weakest plausible race, mitigated by the PDS grace period; not planned.
+3
View File
@@ -0,0 +1,3 @@
# Current review
- [ ] Clarify what “rebuilt” means in `const rebuilt = createSessionBundleFromStoredAccount`.