Move session locking into persistence
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user