Move session locking into persistence

This commit is contained in:
Eric Bailey
2026-08-31 14:58:38 -05:00
parent 5b823c3cf8
commit 732cf8cdd4
12 changed files with 259 additions and 318 deletions
@@ -77,31 +77,21 @@ No action. This whole-root localStorage read-modify-write race predates the vers
Compatibility reference: [MDN `Navigator.locks`](https://developer.mozilla.org/docs/Web/API/Navigator/locks).
## 3. Enforce the session lock invariant
## 3. Keep lock ownership inside persistence
### Problem
`writeSession()` performs the conditional session read-modify-write but does not acquire the persisted-storage lock itself. Current session persistence callsites run it inside `runWithCredentialLock()`, which aliases the root storage lock, but this is a convention rather than an enforced API invariant.
`writeSession()` previously relied on every caller to enter the persisted-storage lock before dispatching a session action. The lock ownership was transitive and invisible at the write API, so a future direct caller could silently bypass serialization.
The lock cannot simply be added inside `writeSession()` while callers retain the outer lock because Web Locks are not reentrant. A nested request for the same exclusive lock would deadlock.
### Resolution
A future caller could accidentally do this:
`writeSession()` now owns the persisted-storage lock on web. Its lock callback contains exactly the shared-state commit:
```ts
await persisted.writeSession({
nextSession,
credentialMutations,
})
```
1. read the authoritative root from localStorage;
2. conditionally merge the session mutation;
3. write the updated root; and
4. broadcast the committed update.
without first entering the root lock.
Session callsites no longer acquire the lock. Network requests, reducer work, expiry rescue, and local bundle reconciliation remain outside it. Native retains its existing serialized AsyncStorage queue inside `writeSession()`.
### Possible direction
Potential enforcement options include:
1. Add a development assertion tracking whether the current realm is inside `runWithPersistedStorageLock()`.
2. Expose the unlocked commit only through a capability passed to the lock callback.
3. Move lock ownership into a higher-level session transaction API that performs the authoritative read, reconciliation, and write together.
Any enforcement should preserve the existing requirement that network refreshes happen outside the lock.
This makes every call to `writeSession()` safe by construction, avoids non-reentrant nested Web Locks, and keeps the lock scoped to the operation that actually needs cross-tab exclusion.
+8 -10
View File
@@ -134,7 +134,7 @@ Tab A sends refresh token A to the PDS
Tab A receives successor generation B
```
Before committing the response, Tab A acquires the credential lock and synchronously rereads localStorage. The source of truth may have changed while its network request was in flight.
Before committing the response, `writeSession()` acquires the persisted-storage lock and synchronously rereads localStorage. The source of truth may have changed while the network request was in flight.
### Case 1: nothing else changed
@@ -276,16 +276,14 @@ Tab A plans: write version 9
Tab B plans: write version 9
```
Network refreshes do not run while holding a Web Lock. A refresh captures the generation it uses, performs the network request, and acquires the lock only to reconcile and commit its result:
Network refreshes do not run while holding a Web Lock. A refresh captures the generation it uses and performs the network request first. The resulting session mutation is then passed to `writeSession()`, which owns the lock around reconciliation and persistence:
```ts
const baseRefreshJti = getRefreshJti(session.refreshJwt)
const refreshed = await refreshSession()
await navigator.locks.request('bsky-persisted-storage', async () => {
const latest = readAccountFromLocalStorage(did)
// Commit only if latest is active and still has baseRefreshJti.
})
```text
Capture refresh generation A
Perform network refresh A -> B
Call writeSession with base A and result B
writeSession acquires the persisted-storage lock
writeSession rereads, conditionally merges, and persists
```
This avoids holding a cross-tab lock over network I/O. Multiple refresh requests may be in flight concurrently; server-side convergence and the generation-specific conditional commit make their results safe.
+1 -4
View File
@@ -16,10 +16,6 @@ import {type PersistedApi} from './types'
import {normalizeData} from './util'
export type {SessionCredentialMutation} from './session-merge'
export {
runWithPersistedStorageLock as runWithCredentialLock,
runWithPersistedStorageLock,
} from './storage-lock'
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
export {defaults} from '#/state/persisted/schema'
@@ -78,6 +74,7 @@ export function write<K extends keyof Schema>(
}
write satisfies PersistedApi['write']
/** Queue a conditional session merge with other root storage writes. */
export function writeSession({
nextSession,
credentialMutations,
+18 -18
View File
@@ -17,10 +17,6 @@ import {type PersistedApi} from './types'
import {normalizeData} from './util'
export type {SessionCredentialMutation} from './session-merge'
export {
runWithPersistedStorageLock as runWithCredentialLock,
runWithPersistedStorageLock,
} from './storage-lock'
export type {PersistedAccount, Schema} from '#/state/persisted/schema'
export {defaults} from '#/state/persisted/schema'
@@ -112,8 +108,8 @@ export function write<K extends keyof Schema>(
}
write satisfies PersistedApi['write']
// eslint-disable-next-line @typescript-eslint/require-await
export async function writeSession({
/** Commit a conditional session merge while holding the root storage lock. */
export function writeSession({
nextSession,
credentialMutations,
currentAccountDid,
@@ -122,19 +118,23 @@ export async function writeSession({
credentialMutations: SessionCredentialMutation[]
currentAccountDid?: string
}): Promise<Schema['session']> {
const stored = readFromStorage() ?? _state
const session = applySessionUpdate({
storedSession: stored.session,
nextSession,
credentialMutations,
currentAccountDid,
})
const updated = normalizeData({...stored, session})
writeToStorage(updated)
_state = updated
return runWithPersistedStorageLock({
operation: () => {
const stored = readFromStorage() ?? _state
const session = applySessionUpdate({
storedSession: stored.session,
nextSession,
credentialMutations,
currentAccountDid,
})
const updated = normalizeData({...stored, session})
writeToStorage(updated)
_state = updated
broadcastUpdate({key: 'session'})
return session
broadcastUpdate({key: 'session'})
return session
},
})
}
writeSession satisfies PersistedApi['writeSession']
-3
View File
@@ -1,5 +1,3 @@
import {type PersistedApi} from './types'
export function runWithPersistedStorageLock<T>({
operation,
}: {
@@ -15,4 +13,3 @@ export function runWithPersistedStorageLock<T>({
)
}
}
runWithPersistedStorageLock satisfies PersistedApi['runWithPersistedStorageLock']
-4
View File
@@ -1,5 +1,3 @@
import {type PersistedApi} from './types'
const PERSISTED_STORAGE_LOCK = 'bsky-persisted-storage'
export function runWithPersistedStorageLock<T>({
@@ -22,8 +20,6 @@ export function runWithPersistedStorageLock<T>({
return lockManager.request(PERSISTED_STORAGE_LOCK, operation)
}
runWithPersistedStorageLock satisfies PersistedApi['runWithPersistedStorageLock']
function getLockManager(): LockManager | undefined {
if (typeof navigator === 'undefined' || !('locks' in navigator)) {
return undefined
-3
View File
@@ -21,9 +21,6 @@ export type PersistedApi = {
/** Omit to preserve the current account read from persisted storage. */
currentAccountDid?: string
}): Promise<Schema['session']>
runWithPersistedStorageLock<T>(args: {
operation: () => T | Promise<T>
}): Promise<T>
onUpdate<K extends keyof Schema>(
key: K,
cb: (v: Schema[K]) => void,
@@ -17,8 +17,6 @@ jest.mock('#/state/persisted', () => {
readLatest: () => defaults.session,
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
Promise.resolve(nextSession),
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
Promise.resolve(operation()),
onUpdate: () => () => {},
}
})
@@ -21,8 +21,6 @@ jest.mock('#/state/persisted', () => {
readLatest: () => defaults.session,
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
Promise.resolve(nextSession),
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
Promise.resolve(operation()),
onUpdate: () => () => {},
}
})
@@ -20,8 +20,6 @@ jest.mock('#/state/persisted', () => {
readLatest: () => defaults.session,
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
Promise.resolve(nextSession),
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
Promise.resolve(operation()),
onUpdate: () => () => {},
}
})
@@ -49,8 +49,6 @@ jest.mock('#/state/persisted', () => ({
mockPersisted.latest = committed
return Promise.resolve(committed)
},
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
Promise.resolve(operation()),
onUpdate: (_key: 'session', callback: (value: Schema['session']) => void) => {
mockPersistedListeners.push(callback)
return () => {}
+222 -248
View File
@@ -161,7 +161,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const onSessionChangeRef = useRef<OnSessionChange | null>(null)
const onSessionChange = useCallback(
(
async (
bundle: SessionBundle,
accountDid: string,
sessionEvent: AtpSessionEvent,
@@ -172,164 +172,159 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
? bundle.session.session.refreshJwt
: sessionData?.refreshJwt
return persistedSession.runWithCredentialLock({
operation: async () => {
/*
* Only the live bundle may reset the expiry-rescue bookkeeping: a stale
* bundle's late update would otherwise clear the failed-generation set
* that bounds the rescue loop. (Its dispatch below is separately dropped
* by the reducer's identity guard.)
*/
if (
sessionEvent === 'update' &&
sessionData &&
(store.getState().currentBundleState.bundle as unknown as
SessionBundle | PublicSessionBundle) === bundle
) {
failedExpiryTokensRef.current.get(accountDid)?.clear()
}
/*
* Only the live bundle may reset the expiry-rescue bookkeeping: a stale
* bundle's late update would otherwise clear the failed-generation set
* that bounds the rescue loop. (Its dispatch below is separately dropped
* by the reducer's identity guard.)
*/
if (
sessionEvent === 'update' &&
sessionData &&
(store.getState().currentBundleState.bundle as unknown as
SessionBundle | PublicSessionBundle) === bundle
) {
failedExpiryTokensRef.current.get(accountDid)?.clear()
}
/*
* PasswordSession invokes its hooks before updating its live getter. Use
* the delivered payload so a refresh persists the newly rotated tokens.
*
* A refresh payload carries no didDoc unless the server sends one, so the
* stored account's `pdsUrl` is threaded in as the fallback. Without it the
* refresh would persist `pdsUrl: undefined` and the next cold start would
* route pre-refresh requests to the entryway instead of the PDS.
*/
const refreshedAccount =
sessionEvent === 'update' && sessionData
? sessionDataToSessionAccount(
sessionData,
sessionData.service,
store.getState().accounts.find(a => a.did === accountDid)
?.pdsUrl,
)
: undefined
/*
* A stale tab may expire a token after another tab has already rotated it.
* Prefer a newer persisted or reducer generation over logging every tab
* out. Failed generations are recorded and bounded to guarantee that a
* repeatedly expiring session eventually falls through to logout.
*/
if (sessionEvent === 'expired') {
const current = store.getState()
const currentBundle = current.currentBundleState
.bundle as unknown as SessionBundle | PublicSessionBundle
const dyingRefreshJwt = sessionData?.refreshJwt
// Stale bundle events are handled by the reducer's identity guard.
if (
currentBundle === bundle &&
current.currentBundleState.did === accountDid &&
dyingRefreshJwt
) {
let failedSet = failedExpiryTokensRef.current.get(accountDid)
if (!failedSet) {
failedSet = new Set()
failedExpiryTokensRef.current.set(accountDid, failedSet)
}
failedSet.add(dyingRefreshJwt)
const persistedCandidate = persistedSession
.readLatest('session')
.accounts.find(a => a.did === accountDid)
const reducerCandidate = current.accounts.find(
a => a.did === accountDid,
)
const candidate = pickExpiryRescueCandidate({
dyingRefreshJwt,
candidates: [persistedCandidate, reducerCandidate],
failedRefreshJwts: failedSet,
})
if (candidate) {
const rebuilt = createSessionBundleFromStoredAccount(
candidate,
onSessionChangeRef.current!,
)
if (rebuilt) {
await store.dispatch({
type: 'replaced-current-bundle',
newBundle: rebuilt.bundle,
newAccount: rebuilt.account,
})
return
}
}
}
}
// Only the current bundle may report that its session was dropped.
if (
sessionEvent === 'expired' &&
store.getState().currentBundleState.bundle === bundle
) {
emitSessionDropped()
}
const credentialMutations: SessionCredentialMutation[] = []
if (sessionEvent === 'update' && refreshedAccount) {
credentialMutations.push({
type: 'refresh',
accountDid,
baseRefreshJwt,
resultRefreshJwt: refreshedAccount.refreshJwt,
})
} else if (sessionEvent === 'expired') {
credentialMutations.push({
type: 'expire',
accountDid,
baseRefreshJwt,
})
}
// Bundle identity prevents stale sessions from changing the active account.
const committedSession = await store.dispatch(
{
type: 'received-session-event',
bundle,
refreshedAccount,
accountDid,
sessionEvent,
},
credentialMutations,
)
if (sessionEvent === 'update' && committedSession) {
const committedAccount = committedSession.accounts.find(
account => account.did === accountDid,
/*
* PasswordSession invokes its hooks before updating its live getter. Use
* the delivered payload so a refresh persists the newly rotated tokens.
*
* A refresh payload carries no didDoc unless the server sends one, so the
* stored account's `pdsUrl` is threaded in as the fallback. Without it the
* refresh would persist `pdsUrl: undefined` and the next cold start would
* route pre-refresh requests to the entryway instead of the PDS.
*/
const refreshedAccount =
sessionEvent === 'update' && sessionData
? sessionDataToSessionAccount(
sessionData,
sessionData.service,
store.getState().accounts.find(a => a.did === accountDid)?.pdsUrl,
)
if (
committedAccount?.refreshJwt &&
committedAccount.refreshJwt !== refreshedAccount?.refreshJwt &&
store.getState().currentBundleState.bundle === bundle
) {
const rebuilt = createSessionBundleFromStoredAccount(
committedAccount,
onSessionChangeRef.current!,
)
if (rebuilt) {
await store.dispatch({
type: 'replaced-current-bundle',
newBundle: rebuilt.bundle,
newAccount: rebuilt.account,
})
}
} else if (
!committedAccount?.refreshJwt &&
store.getState().currentBundleState.bundle === bundle
) {
: undefined
/*
* A stale tab may expire a token after another tab has already rotated it.
* Prefer a newer persisted or reducer generation over logging every tab
* out. Failed generations are recorded and bounded to guarantee that a
* repeatedly expiring session eventually falls through to logout.
*/
if (sessionEvent === 'expired') {
const current = store.getState()
const currentBundle = current.currentBundleState.bundle as unknown as
SessionBundle | PublicSessionBundle
const dyingRefreshJwt = sessionData?.refreshJwt
// Stale bundle events are handled by the reducer's identity guard.
if (
currentBundle === bundle &&
current.currentBundleState.did === accountDid &&
dyingRefreshJwt
) {
let failedSet = failedExpiryTokensRef.current.get(accountDid)
if (!failedSet) {
failedSet = new Set()
failedExpiryTokensRef.current.set(accountDid, failedSet)
}
failedSet.add(dyingRefreshJwt)
const persistedCandidate = persistedSession
.readLatest('session')
.accounts.find(a => a.did === accountDid)
const reducerCandidate = current.accounts.find(
a => a.did === accountDid,
)
const candidate = pickExpiryRescueCandidate({
dyingRefreshJwt,
candidates: [persistedCandidate, reducerCandidate],
failedRefreshJwts: failedSet,
})
if (candidate) {
const rebuilt = createSessionBundleFromStoredAccount(
candidate,
onSessionChangeRef.current!,
)
if (rebuilt) {
await store.dispatch({
type: 'synced-accounts',
syncedAccounts: committedSession.accounts,
syncedCurrentDid: committedSession.currentAccount?.did,
type: 'replaced-current-bundle',
newBundle: rebuilt.bundle,
newAccount: rebuilt.account,
})
return
}
}
}
}
// Only the current bundle may report that its session was dropped.
if (
sessionEvent === 'expired' &&
store.getState().currentBundleState.bundle === bundle
) {
emitSessionDropped()
}
const credentialMutations: SessionCredentialMutation[] = []
if (sessionEvent === 'update' && refreshedAccount) {
credentialMutations.push({
type: 'refresh',
accountDid,
baseRefreshJwt,
resultRefreshJwt: refreshedAccount.refreshJwt,
})
} else if (sessionEvent === 'expired') {
credentialMutations.push({
type: 'expire',
accountDid,
baseRefreshJwt,
})
}
// Bundle identity prevents stale sessions from changing the active account.
const committedSession = await store.dispatch(
{
type: 'received-session-event',
bundle,
refreshedAccount,
accountDid,
sessionEvent,
},
})
credentialMutations,
)
if (sessionEvent === 'update' && committedSession) {
const committedAccount = committedSession.accounts.find(
account => account.did === accountDid,
)
if (
committedAccount?.refreshJwt &&
committedAccount.refreshJwt !== refreshedAccount?.refreshJwt &&
store.getState().currentBundleState.bundle === bundle
) {
const rebuilt = createSessionBundleFromStoredAccount(
committedAccount,
onSessionChangeRef.current!,
)
if (rebuilt) {
await store.dispatch({
type: 'replaced-current-bundle',
newBundle: rebuilt.bundle,
newAccount: rebuilt.account,
})
}
} else if (
!committedAccount?.refreshJwt &&
store.getState().currentBundleState.bundle === bundle
) {
await store.dispatch({
type: 'synced-accounts',
syncedAccounts: committedSession.accounts,
syncedCurrentDid: committedSession.currentAccount?.did,
})
}
}
},
[store],
)
@@ -358,23 +353,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
disposeBundle(bundle)
return
}
await persistedSession.runWithCredentialLock({
operation: () =>
store.dispatch(
{
type: 'switched-to-account',
newBundle: bundle,
newAccount: account,
},
[
{
type: 'login',
accountDid: account.did,
resultRefreshJwt: account.refreshJwt,
},
],
),
})
await store.dispatch(
{
type: 'switched-to-account',
newBundle: bundle,
newAccount: account,
},
[
{
type: 'login',
accountDid: account.did,
resultRefreshJwt: account.refreshJwt,
},
],
)
ax.metric('account:create:success', metrics, {
session: utils.accountToSessionMetadata(account),
})
@@ -401,23 +393,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
disposeBundle(bundle)
return
}
await persistedSession.runWithCredentialLock({
operation: () =>
store.dispatch(
{
type: 'switched-to-account',
newBundle: bundle,
newAccount: account,
},
[
{
type: 'login',
accountDid: account.did,
resultRefreshJwt: account.refreshJwt,
},
],
),
})
await store.dispatch(
{
type: 'switched-to-account',
newBundle: bundle,
newAccount: account,
},
[
{
type: 'login',
accountDid: account.did,
resultRefreshJwt: account.refreshJwt,
},
],
)
ax.metric(
'account:loggedIn',
{logContext, withPassword: true},
@@ -441,13 +430,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const prevState = store.getState()
const accountDid = prevState.currentBundleState.did
if (accountDid) {
void persistedSession
.runWithCredentialLock({
operation: () =>
store.dispatch({type: 'logged-out-current-account', accountDid}, [
{type: 'logout', accountDid},
]),
})
void store
.dispatch({type: 'logged-out-current-account', accountDid}, [
{type: 'logout', accountDid},
])
.catch(() => {})
}
ax.metric(
@@ -489,17 +475,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
.accounts.map(account => account.did),
]),
]
void persistedSession
.runWithCredentialLock({
operation: () =>
store.dispatch(
{type: 'logged-out-every-account'},
accountDids.map(accountDid => ({
type: 'logout' as const,
accountDid,
})),
),
})
void store
.dispatch(
{type: 'logged-out-every-account'},
accountDids.map(accountDid => ({
type: 'logout' as const,
accountDid,
})),
)
.catch(() => {})
ax.metric(
'account:loggedOut',
@@ -556,24 +539,21 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
disposeBundle(bundle)
return
}
const committedSession = await persistedSession.runWithCredentialLock({
operation: () =>
store.dispatch(
{
type: 'switched-to-account',
newBundle: bundle,
newAccount: account,
},
[
{
type: 'refresh',
accountDid: account.did,
baseRefreshJwt: latestStoredAccount.refreshJwt,
resultRefreshJwt: account.refreshJwt,
},
],
),
})
const committedSession = await store.dispatch(
{
type: 'switched-to-account',
newBundle: bundle,
newAccount: account,
},
[
{
type: 'refresh',
accountDid: account.did,
baseRefreshJwt: latestStoredAccount.refreshJwt,
resultRefreshJwt: account.refreshJwt,
},
],
)
const committedAccount = committedSession?.accounts.find(
candidate => candidate.did === account.did,
)
@@ -625,21 +605,18 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
/* getSession targets the PDS; only the persisted account fields are patched. */
const data = await bundle.pdsClient.call(com.atproto.server.getSession, {})
if (signal.aborted) return
await persistedSession.runWithCredentialLock({
operation: () =>
store.dispatch({
type: 'partial-refresh-session',
/*
* Read the did off the response rather than the session: the bundle may
* have been disposed while the request was in flight, and the live
* getters throw in that state.
*/
accountDid: data.did,
patch: {
emailConfirmed: data.emailConfirmed,
emailAuthFactor: data.emailAuthFactor,
},
}),
await store.dispatch({
type: 'partial-refresh-session',
/*
* Read the did off the response rather than the session: the bundle may
* have been disposed while the request was in flight, and the live
* getters throw in that state.
*/
accountDid: data.did,
patch: {
emailConfirmed: data.emailConfirmed,
emailAuthFactor: data.emailAuthFactor,
},
})
}, [store, cancelPendingTask])
@@ -711,17 +688,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
account: redactAccount(account),
})
cancelPendingTask()
void persistedSession
.runWithCredentialLock({
operation: () =>
store.dispatch(
{
type: 'removed-account',
accountDid: account.did,
},
[{type: 'remove', accountDid: account.did}],
),
})
void store
.dispatch(
{
type: 'removed-account',
accountDid: account.did,
},
[{type: 'remove', accountDid: account.did}],
)
.catch(() => {})
addSessionDebugLog({
type: 'method:end',