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). 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 ### 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 1. read the authoritative root from localStorage;
await persisted.writeSession({ 2. conditionally merge the session mutation;
nextSession, 3. write the updated root; and
credentialMutations, 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 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.
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.
+8 -10
View File
@@ -134,7 +134,7 @@ Tab A sends refresh token A to the PDS
Tab A receives successor generation B 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 ### Case 1: nothing else changed
@@ -276,16 +276,14 @@ Tab A plans: write version 9
Tab B 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 ```text
const baseRefreshJti = getRefreshJti(session.refreshJwt) Capture refresh generation A
const refreshed = await refreshSession() Perform network refresh A -> B
Call writeSession with base A and result B
await navigator.locks.request('bsky-persisted-storage', async () => { writeSession acquires the persisted-storage lock
const latest = readAccountFromLocalStorage(did) writeSession rereads, conditionally merges, and persists
// Commit only if latest is active and still has baseRefreshJti.
})
``` ```
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. 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' import {normalizeData} from './util'
export type {SessionCredentialMutation} from './session-merge' export type {SessionCredentialMutation} from './session-merge'
export {
runWithPersistedStorageLock as runWithCredentialLock,
runWithPersistedStorageLock,
} from './storage-lock'
export type {PersistedAccount, Schema} from '#/state/persisted/schema' export type {PersistedAccount, Schema} from '#/state/persisted/schema'
export {defaults} 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'] write satisfies PersistedApi['write']
/** Queue a conditional session merge with other root storage writes. */
export function writeSession({ export function writeSession({
nextSession, nextSession,
credentialMutations, credentialMutations,
+6 -6
View File
@@ -17,10 +17,6 @@ import {type PersistedApi} from './types'
import {normalizeData} from './util' import {normalizeData} from './util'
export type {SessionCredentialMutation} from './session-merge' export type {SessionCredentialMutation} from './session-merge'
export {
runWithPersistedStorageLock as runWithCredentialLock,
runWithPersistedStorageLock,
} from './storage-lock'
export type {PersistedAccount, Schema} from '#/state/persisted/schema' export type {PersistedAccount, Schema} from '#/state/persisted/schema'
export {defaults} 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'] write satisfies PersistedApi['write']
// eslint-disable-next-line @typescript-eslint/require-await /** Commit a conditional session merge while holding the root storage lock. */
export async function writeSession({ export function writeSession({
nextSession, nextSession,
credentialMutations, credentialMutations,
currentAccountDid, currentAccountDid,
@@ -122,6 +118,8 @@ export async function writeSession({
credentialMutations: SessionCredentialMutation[] credentialMutations: SessionCredentialMutation[]
currentAccountDid?: string currentAccountDid?: string
}): Promise<Schema['session']> { }): Promise<Schema['session']> {
return runWithPersistedStorageLock({
operation: () => {
const stored = readFromStorage() ?? _state const stored = readFromStorage() ?? _state
const session = applySessionUpdate({ const session = applySessionUpdate({
storedSession: stored.session, storedSession: stored.session,
@@ -135,6 +133,8 @@ export async function writeSession({
broadcastUpdate({key: 'session'}) broadcastUpdate({key: 'session'})
return session return session
},
})
} }
writeSession satisfies PersistedApi['writeSession'] writeSession satisfies PersistedApi['writeSession']
-3
View File
@@ -1,5 +1,3 @@
import {type PersistedApi} from './types'
export function runWithPersistedStorageLock<T>({ export function runWithPersistedStorageLock<T>({
operation, 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' const PERSISTED_STORAGE_LOCK = 'bsky-persisted-storage'
export function runWithPersistedStorageLock<T>({ export function runWithPersistedStorageLock<T>({
@@ -22,8 +20,6 @@ export function runWithPersistedStorageLock<T>({
return lockManager.request(PERSISTED_STORAGE_LOCK, operation) return lockManager.request(PERSISTED_STORAGE_LOCK, operation)
} }
runWithPersistedStorageLock satisfies PersistedApi['runWithPersistedStorageLock']
function getLockManager(): LockManager | undefined { function getLockManager(): LockManager | undefined {
if (typeof navigator === 'undefined' || !('locks' in navigator)) { if (typeof navigator === 'undefined' || !('locks' in navigator)) {
return undefined return undefined
-3
View File
@@ -21,9 +21,6 @@ export type PersistedApi = {
/** Omit to preserve the current account read from persisted storage. */ /** Omit to preserve the current account read from persisted storage. */
currentAccountDid?: string currentAccountDid?: string
}): Promise<Schema['session']> }): Promise<Schema['session']>
runWithPersistedStorageLock<T>(args: {
operation: () => T | Promise<T>
}): Promise<T>
onUpdate<K extends keyof Schema>( onUpdate<K extends keyof Schema>(
key: K, key: K,
cb: (v: Schema[K]) => void, cb: (v: Schema[K]) => void,
@@ -17,8 +17,6 @@ jest.mock('#/state/persisted', () => {
readLatest: () => defaults.session, readLatest: () => defaults.session,
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) => writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
Promise.resolve(nextSession), Promise.resolve(nextSession),
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
Promise.resolve(operation()),
onUpdate: () => () => {}, onUpdate: () => () => {},
} }
}) })
@@ -21,8 +21,6 @@ jest.mock('#/state/persisted', () => {
readLatest: () => defaults.session, readLatest: () => defaults.session,
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) => writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
Promise.resolve(nextSession), Promise.resolve(nextSession),
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
Promise.resolve(operation()),
onUpdate: () => () => {}, onUpdate: () => () => {},
} }
}) })
@@ -20,8 +20,6 @@ jest.mock('#/state/persisted', () => {
readLatest: () => defaults.session, readLatest: () => defaults.session,
writeSession: ({nextSession}: {nextSession: typeof defaults.session}) => writeSession: ({nextSession}: {nextSession: typeof defaults.session}) =>
Promise.resolve(nextSession), Promise.resolve(nextSession),
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
Promise.resolve(operation()),
onUpdate: () => () => {}, onUpdate: () => () => {},
} }
}) })
@@ -49,8 +49,6 @@ jest.mock('#/state/persisted', () => ({
mockPersisted.latest = committed mockPersisted.latest = committed
return Promise.resolve(committed) return Promise.resolve(committed)
}, },
runWithCredentialLock: ({operation}: {operation: () => unknown}) =>
Promise.resolve(operation()),
onUpdate: (_key: 'session', callback: (value: Schema['session']) => void) => { onUpdate: (_key: 'session', callback: (value: Schema['session']) => void) => {
mockPersistedListeners.push(callback) mockPersistedListeners.push(callback)
return () => {} return () => {}
+20 -46
View File
@@ -161,7 +161,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const onSessionChangeRef = useRef<OnSessionChange | null>(null) const onSessionChangeRef = useRef<OnSessionChange | null>(null)
const onSessionChange = useCallback( const onSessionChange = useCallback(
( async (
bundle: SessionBundle, bundle: SessionBundle,
accountDid: string, accountDid: string,
sessionEvent: AtpSessionEvent, sessionEvent: AtpSessionEvent,
@@ -172,8 +172,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
? bundle.session.session.refreshJwt ? bundle.session.session.refreshJwt
: sessionData?.refreshJwt : sessionData?.refreshJwt
return persistedSession.runWithCredentialLock({
operation: async () => {
/* /*
* Only the live bundle may reset the expiry-rescue bookkeeping: a stale * Only the live bundle may reset the expiry-rescue bookkeeping: a stale
* bundle's late update would otherwise clear the failed-generation set * bundle's late update would otherwise clear the failed-generation set
@@ -203,8 +201,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
? sessionDataToSessionAccount( ? sessionDataToSessionAccount(
sessionData, sessionData,
sessionData.service, sessionData.service,
store.getState().accounts.find(a => a.did === accountDid) store.getState().accounts.find(a => a.did === accountDid)?.pdsUrl,
?.pdsUrl,
) )
: undefined : undefined
@@ -216,8 +213,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
*/ */
if (sessionEvent === 'expired') { if (sessionEvent === 'expired') {
const current = store.getState() const current = store.getState()
const currentBundle = current.currentBundleState const currentBundle = current.currentBundleState.bundle as unknown as
.bundle as unknown as SessionBundle | PublicSessionBundle SessionBundle | PublicSessionBundle
const dyingRefreshJwt = sessionData?.refreshJwt const dyingRefreshJwt = sessionData?.refreshJwt
// Stale bundle events are handled by the reducer's identity guard. // Stale bundle events are handled by the reducer's identity guard.
if ( if (
@@ -329,8 +326,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
} }
} }
}, },
})
},
[store], [store],
) )
/* /*
@@ -358,9 +353,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
disposeBundle(bundle) disposeBundle(bundle)
return return
} }
await persistedSession.runWithCredentialLock({ await store.dispatch(
operation: () =>
store.dispatch(
{ {
type: 'switched-to-account', type: 'switched-to-account',
newBundle: bundle, newBundle: bundle,
@@ -373,8 +366,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
resultRefreshJwt: account.refreshJwt, resultRefreshJwt: account.refreshJwt,
}, },
], ],
), )
})
ax.metric('account:create:success', metrics, { ax.metric('account:create:success', metrics, {
session: utils.accountToSessionMetadata(account), session: utils.accountToSessionMetadata(account),
}) })
@@ -401,9 +393,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
disposeBundle(bundle) disposeBundle(bundle)
return return
} }
await persistedSession.runWithCredentialLock({ await store.dispatch(
operation: () =>
store.dispatch(
{ {
type: 'switched-to-account', type: 'switched-to-account',
newBundle: bundle, newBundle: bundle,
@@ -416,8 +406,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
resultRefreshJwt: account.refreshJwt, resultRefreshJwt: account.refreshJwt,
}, },
], ],
), )
})
ax.metric( ax.metric(
'account:loggedIn', 'account:loggedIn',
{logContext, withPassword: true}, {logContext, withPassword: true},
@@ -441,13 +430,10 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const prevState = store.getState() const prevState = store.getState()
const accountDid = prevState.currentBundleState.did const accountDid = prevState.currentBundleState.did
if (accountDid) { if (accountDid) {
void persistedSession void store
.runWithCredentialLock({ .dispatch({type: 'logged-out-current-account', accountDid}, [
operation: () =>
store.dispatch({type: 'logged-out-current-account', accountDid}, [
{type: 'logout', accountDid}, {type: 'logout', accountDid},
]), ])
})
.catch(() => {}) .catch(() => {})
} }
ax.metric( ax.metric(
@@ -489,17 +475,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
.accounts.map(account => account.did), .accounts.map(account => account.did),
]), ]),
] ]
void persistedSession void store
.runWithCredentialLock({ .dispatch(
operation: () =>
store.dispatch(
{type: 'logged-out-every-account'}, {type: 'logged-out-every-account'},
accountDids.map(accountDid => ({ accountDids.map(accountDid => ({
type: 'logout' as const, type: 'logout' as const,
accountDid, accountDid,
})), })),
), )
})
.catch(() => {}) .catch(() => {})
ax.metric( ax.metric(
'account:loggedOut', 'account:loggedOut',
@@ -556,9 +539,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
disposeBundle(bundle) disposeBundle(bundle)
return return
} }
const committedSession = await persistedSession.runWithCredentialLock({ const committedSession = await store.dispatch(
operation: () =>
store.dispatch(
{ {
type: 'switched-to-account', type: 'switched-to-account',
newBundle: bundle, newBundle: bundle,
@@ -572,8 +553,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
resultRefreshJwt: account.refreshJwt, resultRefreshJwt: account.refreshJwt,
}, },
], ],
), )
})
const committedAccount = committedSession?.accounts.find( const committedAccount = committedSession?.accounts.find(
candidate => candidate.did === account.did, candidate => candidate.did === account.did,
) )
@@ -625,9 +605,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
/* getSession targets the PDS; only the persisted account fields are patched. */ /* getSession targets the PDS; only the persisted account fields are patched. */
const data = await bundle.pdsClient.call(com.atproto.server.getSession, {}) const data = await bundle.pdsClient.call(com.atproto.server.getSession, {})
if (signal.aborted) return if (signal.aborted) return
await persistedSession.runWithCredentialLock({ await store.dispatch({
operation: () =>
store.dispatch({
type: 'partial-refresh-session', type: 'partial-refresh-session',
/* /*
* Read the did off the response rather than the session: the bundle may * Read the did off the response rather than the session: the bundle may
@@ -639,7 +617,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
emailConfirmed: data.emailConfirmed, emailConfirmed: data.emailConfirmed,
emailAuthFactor: data.emailAuthFactor, emailAuthFactor: data.emailAuthFactor,
}, },
}),
}) })
}, [store, cancelPendingTask]) }, [store, cancelPendingTask])
@@ -711,17 +688,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
account: redactAccount(account), account: redactAccount(account),
}) })
cancelPendingTask() cancelPendingTask()
void persistedSession void store
.runWithCredentialLock({ .dispatch(
operation: () =>
store.dispatch(
{ {
type: 'removed-account', type: 'removed-account',
accountDid: account.did, accountDid: account.did,
}, },
[{type: 'remove', accountDid: account.did}], [{type: 'remove', accountDid: account.did}],
), )
})
.catch(() => {}) .catch(() => {})
addSessionDebugLog({ addSessionDebugLog({
type: 'method:end', type: 'method:end',