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 refreshed = await refreshSession()
await navigator.locks.request(`bsky-session:${did}`, async () => {
await navigator.locks.request('bsky-persisted-storage', async () => {
const latest = readAccountFromLocalStorage(did)
// 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.
All credential-changing commits use the same per-account lock:
- 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.
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.
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:
@@ -301,7 +293,7 @@ Capture base refresh jti
Perform network refresh without a lock
|
v
Acquire root + per-account Web Locks
Acquire persisted-storage Web Lock
|
v
Read authoritative localStorage state
@@ -1,6 +1,6 @@
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(
globalThis,
@@ -22,7 +22,7 @@ afterEach(() => {
}
})
describe('session credential locks on unsupported browsers', () => {
describe('persisted storage lock on unsupported browsers', () => {
it.each([
['navigator is unavailable', undefined],
['navigator.locks is unavailable', {}],
@@ -31,12 +31,23 @@ describe('session credential locks on unsupported browsers', () => {
setNavigator(navigatorValue)
const operation = jest.fn(() => 'result')
await expect(
runWithSessionCredentialLock({
accountDids: ['did:plc:example'],
operation,
}),
).resolves.toBe('result')
await expect(runWithPersistedStorageLock({operation})).resolves.toBe(
'result',
)
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,
tryStringify,
} from '#/state/persisted/schema'
import {runWithSessionCredentialLock} from './session-lock'
import {
applySessionUpdate,
type SessionCredentialMutation,
} from './session-merge'
import {runWithPersistedStorageLock} from './storage-lock'
import {type PersistedApi} from './types'
import {normalizeData} from './util'
@@ -76,8 +76,7 @@ export function write<K extends keyof Schema>(
"Session state must be written through '#/state/persisted/session'",
)
}
return runWithSessionCredentialLock({
accountDids: [],
return runWithPersistedStorageLock({
operation: () => {
const next = readFromStorage()
if (next) {
@@ -145,8 +144,7 @@ export function onUpdate<K extends keyof Schema>(
onUpdate satisfies PersistedApi['onUpdate']
export function clearStorage(): Promise<void> {
return runWithSessionCredentialLock({
accountDids: [],
return runWithPersistedStorageLock({
operation: () => {
try {
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 {type Schema} from './schema'
import {runWithSessionCredentialLock} from './session-lock'
import {type SessionCredentialMutation} from './session-merge'
export type {SessionCredentialMutation} from './session-merge'
export {runWithPersistedStorageLock as runWithCredentialLock} from './storage-lock'
export function read(): Schema['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(
callback: (session: Schema['session']) => void,
): () => void {
@@ -1,18 +1,15 @@
export function runWithSessionCredentialLock<T>({
accountDids,
export function runWithPersistedStorageLock<T>({
operation,
}: {
accountDids: string[]
operation: () => T | Promise<T>
}): Promise<T> {
void accountDids
try {
return Promise.resolve(operation())
} catch (error) {
return Promise.reject(
error instanceof 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
return persistedSession.runWithCredentialLock({
accountDids: [accountDid],
operation: async () => {
/*
* Only the live bundle may reset the expiry-rescue bookkeeping: a stale
@@ -355,7 +354,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return
}
await persistedSession.runWithCredentialLock({
accountDids: [account.did],
operation: () =>
store.dispatch(
{
@@ -399,7 +397,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return
}
await persistedSession.runWithCredentialLock({
accountDids: [account.did],
operation: () =>
store.dispatch(
{
@@ -441,7 +438,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
if (accountDid) {
void persistedSession
.runWithCredentialLock({
accountDids: [accountDid],
operation: () =>
store.dispatch({type: 'logged-out-current-account', accountDid}, [
{type: 'logout', accountDid},
@@ -488,7 +484,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
]
void persistedSession
.runWithCredentialLock({
accountDids,
operation: () =>
store.dispatch(
{type: 'logged-out-every-account'},
@@ -555,7 +550,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
return
}
const committedSession = await persistedSession.runWithCredentialLock({
accountDids: [account.did],
operation: () =>
store.dispatch(
{
@@ -625,7 +619,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
const data = await bundle.pdsClient.call(com.atproto.server.getSession, {})
if (signal.aborted) return
await persistedSession.runWithCredentialLock({
accountDids: [data.did],
operation: () =>
store.dispatch({
type: 'partial-refresh-session',
@@ -713,7 +706,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
cancelPendingTask()
void persistedSession
.runWithCredentialLock({
accountDids: [account.did],
operation: () =>
store.dispatch(
{