Use one persisted storage lock

This commit is contained in:
Eric Bailey
2026-08-31 13:55:57 -05:00
parent 3f31e1dd72
commit 262cb37998
8 changed files with 59 additions and 94 deletions
+4 -12
View File
@@ -270,7 +270,7 @@ Network refreshes do not run while holding a Web Lock. A refresh captures the ge
const baseRefreshJti = getRefreshJti(session.refreshJwt) const baseRefreshJti = getRefreshJti(session.refreshJwt)
const refreshed = await refreshSession() const refreshed = await refreshSession()
await navigator.locks.request(`bsky-session:${did}`, async () => { await navigator.locks.request('bsky-persisted-storage', async () => {
const latest = readAccountFromLocalStorage(did) const latest = readAccountFromLocalStorage(did)
// Commit only if latest is active and still has baseRefreshJti. // Commit only if latest is active and still has baseRefreshJti.
}) })
@@ -278,19 +278,11 @@ await navigator.locks.request(`bsky-session:${did}`, async () => {
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.
All credential-changing commits use the same per-account lock: All persisted values share one localStorage blob, so every write uses the same persisted-storage lock. This includes successful refresh reconciliation, expiration, logout, account removal, login replacement, and unrelated preference writes. One root lock is sufficient: it serializes the complete read-modify-write operation, so nesting per-account locks would add no coordination.
- successful refresh reconciliation;
- expiration;
- logout;
- account removal; and
- login replacing an existing account.
Because all persisted values share one localStorage blob, every write also takes a root persisted-storage lock. The root lock prevents an unrelated preference write from racing the session read-modify-write; the per-account lock expresses credential ownership and gives account operations a consistent order.
Feature-detect the Web Locks API. If `navigator.locks.request` is unavailable, run the operation without a lock rather than failing startup or session operations. Generation-specific conditional commits still reject stale work in this fallback mode, but localStorage read-modify-write is not fully serialized across tabs. Feature-detect the Web Locks API. If `navigator.locks.request` is unavailable, run the operation without a lock rather than failing startup or session operations. Generation-specific conditional commits still reject stale work in this fallback mode, but localStorage read-modify-write is not fully serialized across tabs.
If Tab A holds the locks, Tab B waits. Once Tab A writes and releases them, Tab B acquires them and rereads Tab A's new localStorage state before deciding what to commit. If Tab A holds the lock, Tab B waits. Once Tab A writes and releases it, Tab B acquires it and rereads Tab A's new localStorage state before deciding what to commit.
The complete refresh flow is: The complete refresh flow is:
@@ -301,7 +293,7 @@ Capture base refresh jti
Perform network refresh without a lock Perform network refresh without a lock
| |
v v
Acquire root + per-account Web Locks Acquire persisted-storage Web Lock
| |
v v
Read authoritative localStorage state Read authoritative localStorage state
@@ -1,6 +1,6 @@
import {afterEach, describe, expect, it, jest} from '@jest/globals' import {afterEach, describe, expect, it, jest} from '@jest/globals'
import {runWithSessionCredentialLock} from '../session-lock.web' import {runWithPersistedStorageLock} from '../storage-lock.web'
const originalNavigatorDescriptor = Object.getOwnPropertyDescriptor( const originalNavigatorDescriptor = Object.getOwnPropertyDescriptor(
globalThis, globalThis,
@@ -22,7 +22,7 @@ afterEach(() => {
} }
}) })
describe('session credential locks on unsupported browsers', () => { describe('persisted storage lock on unsupported browsers', () => {
it.each([ it.each([
['navigator is unavailable', undefined], ['navigator is unavailable', undefined],
['navigator.locks is unavailable', {}], ['navigator.locks is unavailable', {}],
@@ -31,12 +31,23 @@ describe('session credential locks on unsupported browsers', () => {
setNavigator(navigatorValue) setNavigator(navigatorValue)
const operation = jest.fn(() => 'result') const operation = jest.fn(() => 'result')
await expect( await expect(runWithPersistedStorageLock({operation})).resolves.toBe(
runWithSessionCredentialLock({ 'result',
accountDids: ['did:plc:example'], )
operation,
}),
).resolves.toBe('result')
expect(operation).toHaveBeenCalledTimes(1) expect(operation).toHaveBeenCalledTimes(1)
}) })
it('uses one root lock when Web Locks are available', async () => {
const request = jest.fn(
(_name: string, operation: () => string | Promise<string>) =>
Promise.resolve(operation()),
)
setNavigator({locks: {request}})
await expect(
runWithPersistedStorageLock({operation: () => 'result'}),
).resolves.toBe('result')
expect(request).toHaveBeenCalledTimes(1)
expect(request.mock.calls[0]?.[0]).toBe('bsky-persisted-storage')
})
}) })
+3 -5
View File
@@ -8,11 +8,11 @@ import {
tryParse, tryParse,
tryStringify, tryStringify,
} from '#/state/persisted/schema' } from '#/state/persisted/schema'
import {runWithSessionCredentialLock} from './session-lock'
import { import {
applySessionUpdate, applySessionUpdate,
type SessionCredentialMutation, type SessionCredentialMutation,
} from './session-merge' } from './session-merge'
import {runWithPersistedStorageLock} from './storage-lock'
import {type PersistedApi} from './types' import {type PersistedApi} from './types'
import {normalizeData} from './util' import {normalizeData} from './util'
@@ -76,8 +76,7 @@ export function write<K extends keyof Schema>(
"Session state must be written through '#/state/persisted/session'", "Session state must be written through '#/state/persisted/session'",
) )
} }
return runWithSessionCredentialLock({ return runWithPersistedStorageLock({
accountDids: [],
operation: () => { operation: () => {
const next = readFromStorage() const next = readFromStorage()
if (next) { if (next) {
@@ -145,8 +144,7 @@ export function onUpdate<K extends keyof Schema>(
onUpdate satisfies PersistedApi['onUpdate'] onUpdate satisfies PersistedApi['onUpdate']
export function clearStorage(): Promise<void> { export function clearStorage(): Promise<void> {
return runWithSessionCredentialLock({ return runWithPersistedStorageLock({
accountDids: [],
operation: () => { operation: () => {
try { try {
localStorage.removeItem(BSKY_STORAGE) localStorage.removeItem(BSKY_STORAGE)
-45
View File
@@ -1,45 +0,0 @@
const PERSISTED_STORAGE_LOCK = 'bsky-persisted-storage'
const SESSION_LOCK_PREFIX = 'bsky-session:'
export function runWithSessionCredentialLock<T>({
accountDids,
operation,
}: {
accountDids: string[]
operation: () => T | Promise<T>
}): Promise<T> {
const lockManager = getLockManager()
if (!lockManager) {
try {
return Promise.resolve(operation())
} catch (error) {
return Promise.reject(
error instanceof Error
? error
: new Error('Session credential operation failed', {cause: error}),
)
}
}
const accountLockNames = [...new Set(accountDids)]
.sort()
.map(did => `${SESSION_LOCK_PREFIX}${did}`)
/* All values share one localStorage blob, so every write also takes its lock. */
const lockNames = [PERSISTED_STORAGE_LOCK, ...accountLockNames]
const run = (index: number): Promise<T> => {
const lockName = lockNames[index]
if (!lockName) return Promise.resolve(operation())
return lockManager.request(lockName, () => run(index + 1))
}
return run(0)
}
function getLockManager(): LockManager | undefined {
if (typeof navigator === 'undefined' || !('locks' in navigator)) {
return undefined
}
const lockManager = navigator.locks
return typeof lockManager?.request === 'function' ? lockManager : undefined
}
+1 -11
View File
@@ -1,9 +1,9 @@
import * as persisted from './index' import * as persisted from './index'
import {type Schema} from './schema' import {type Schema} from './schema'
import {runWithSessionCredentialLock} from './session-lock'
import {type SessionCredentialMutation} from './session-merge' import {type SessionCredentialMutation} from './session-merge'
export type {SessionCredentialMutation} from './session-merge' export type {SessionCredentialMutation} from './session-merge'
export {runWithPersistedStorageLock as runWithCredentialLock} from './storage-lock'
export function read(): Schema['session'] { export function read(): Schema['session'] {
return persisted.get('session') return persisted.get('session')
@@ -28,16 +28,6 @@ export function write({
}) })
} }
export function runWithCredentialLock<T>({
accountDids,
operation,
}: {
accountDids: string[]
operation: () => T | Promise<T>
}): Promise<T> {
return runWithSessionCredentialLock({accountDids, operation})
}
export function onUpdate( export function onUpdate(
callback: (session: Schema['session']) => void, callback: (session: Schema['session']) => void,
): () => void { ): () => void {
@@ -1,18 +1,15 @@
export function runWithSessionCredentialLock<T>({ export function runWithPersistedStorageLock<T>({
accountDids,
operation, operation,
}: { }: {
accountDids: string[]
operation: () => T | Promise<T> operation: () => T | Promise<T>
}): Promise<T> { }): Promise<T> {
void accountDids
try { try {
return Promise.resolve(operation()) return Promise.resolve(operation())
} catch (error) { } catch (error) {
return Promise.reject( return Promise.reject(
error instanceof Error error instanceof Error
? error ? error
: new Error('Session credential operation failed', {cause: error}), : new Error('Persisted storage operation failed', {cause: error}),
) )
} }
} }
+30
View File
@@ -0,0 +1,30 @@
const PERSISTED_STORAGE_LOCK = 'bsky-persisted-storage'
export function runWithPersistedStorageLock<T>({
operation,
}: {
operation: () => T | Promise<T>
}): Promise<T> {
const lockManager = getLockManager()
if (!lockManager) {
try {
return Promise.resolve(operation())
} catch (error) {
return Promise.reject(
error instanceof Error
? error
: new Error('Persisted storage operation failed', {cause: error}),
)
}
}
return lockManager.request(PERSISTED_STORAGE_LOCK, operation)
}
function getLockManager(): LockManager | undefined {
if (typeof navigator === 'undefined' || !('locks' in navigator)) {
return undefined
}
const lockManager = navigator.locks
return typeof lockManager?.request === 'function' ? lockManager : undefined
}
-8
View File
@@ -168,7 +168,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
: sessionData?.refreshJwt : sessionData?.refreshJwt
return persistedSession.runWithCredentialLock({ return persistedSession.runWithCredentialLock({
accountDids: [accountDid],
operation: async () => { 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
@@ -355,7 +354,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return return
} }
await persistedSession.runWithCredentialLock({ await persistedSession.runWithCredentialLock({
accountDids: [account.did],
operation: () => operation: () =>
store.dispatch( store.dispatch(
{ {
@@ -399,7 +397,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return return
} }
await persistedSession.runWithCredentialLock({ await persistedSession.runWithCredentialLock({
accountDids: [account.did],
operation: () => operation: () =>
store.dispatch( store.dispatch(
{ {
@@ -441,7 +438,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
if (accountDid) { if (accountDid) {
void persistedSession void persistedSession
.runWithCredentialLock({ .runWithCredentialLock({
accountDids: [accountDid],
operation: () => operation: () =>
store.dispatch({type: 'logged-out-current-account', accountDid}, [ store.dispatch({type: 'logged-out-current-account', accountDid}, [
{type: 'logout', accountDid}, {type: 'logout', accountDid},
@@ -488,7 +484,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
] ]
void persistedSession void persistedSession
.runWithCredentialLock({ .runWithCredentialLock({
accountDids,
operation: () => operation: () =>
store.dispatch( store.dispatch(
{type: 'logged-out-every-account'}, {type: 'logged-out-every-account'},
@@ -555,7 +550,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return return
} }
const committedSession = await persistedSession.runWithCredentialLock({ const committedSession = await persistedSession.runWithCredentialLock({
accountDids: [account.did],
operation: () => operation: () =>
store.dispatch( store.dispatch(
{ {
@@ -625,7 +619,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
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 persistedSession.runWithCredentialLock({
accountDids: [data.did],
operation: () => operation: () =>
store.dispatch({ store.dispatch({
type: 'partial-refresh-session', type: 'partial-refresh-session',
@@ -713,7 +706,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
cancelPendingTask() cancelPendingTask()
void persistedSession void persistedSession
.runWithCredentialLock({ .runWithCredentialLock({
accountDids: [account.did],
operation: () => operation: () =>
store.dispatch( store.dispatch(
{ {