diff --git a/services/ai/aiInferenceCacheService.ts b/services/ai/aiInferenceCacheService.ts index dd516312..1b3875a0 100644 --- a/services/ai/aiInferenceCacheService.ts +++ b/services/ai/aiInferenceCacheService.ts @@ -1,5 +1,6 @@ // QNBS-v3: Two-layer inference cache keeps hot reads in memory while the durable layer is encrypted. import { logger } from '../logger'; +import { withProtectedWriteAdmission } from '../storage/protectedWriteAdmission'; import { assertSecureStorageReadable, assertSecureStorageWritableForMutation, @@ -168,8 +169,11 @@ export class AiInferenceCacheService { ): Promise { if (!this.db) return; try { - const encoded = await this.encodeEntry(key, result, timestamp); - await this.persistEntry(encoded); + // QNBS-v3: shares the writer-admission lock so this opportunistic write cannot land mid-migration-batch either. + await withProtectedWriteAdmission(async () => { + const encoded = await this.encodeEntry(key, result, timestamp); + await this.persistEntry(encoded); + }); } catch { // QNBS-v3: best-effort; a failed opportunistic re-encrypt is not user-visible and TTL still bounds exposure. } @@ -254,9 +258,12 @@ export class AiInferenceCacheService { await this.dbReady; if (!this.db) return; try { - const entry = await this.encodeEntry(key, result, Date.now()); - await this.idbEvictOldest(); - await this.persistEntry(entry); + // QNBS-v3: shares the writer-admission lock so eviction/persist cannot run mid-migration-batch and produce a false verification shortfall (#338). + await withProtectedWriteAdmission(async () => { + const entry = await this.encodeEntry(key, result, Date.now()); + await this.idbEvictOldest(); + await this.persistEntry(entry); + }); } catch { // QNBS-v3: The encrypted durable cache is non-authoritative; lock or migration state must not fail inference. } diff --git a/services/sceneRevisionService.ts b/services/sceneRevisionService.ts index 668d92b7..c5d761a0 100644 --- a/services/sceneRevisionService.ts +++ b/services/sceneRevisionService.ts @@ -1,6 +1,7 @@ // QNBS-v3: Standalone IDB for scene revisions avoids a shared schema upgrade and keeps history bounded. import type { SceneRevision } from '../types'; import { createLogger } from './logger'; +import { withProtectedWriteAdmission } from './storage/protectedWriteAdmission'; import { assertSecureStorageReadable, assertSecureStorageWritableForMutation, @@ -228,10 +229,14 @@ export async function saveRevision( ...(authorName !== undefined ? { authorName } : {}), }; - // QNBS-v3: Encrypt before IDB work so WebCrypto cannot make a write transaction inactive. - const stored = await encodeRevision(revision); - const db = await getDb(); - await saveStoredRevisionWithRetention(db, stored); + // QNBS-v3: shares the writer-admission lock with primary-store writes so encode-through-commit + // cannot straddle a migration batch and land under a superseded key/generation (#338). + await withProtectedWriteAdmission(async () => { + // QNBS-v3: Encrypt before IDB work so WebCrypto cannot make a write transaction inactive. + const stored = await encodeRevision(revision); + const db = await getDb(); + await saveStoredRevisionWithRetention(db, stored); + }); return revision; } @@ -266,9 +271,12 @@ export async function listRevisions(sectionId: string): Promise /** Deletes a single revision by ID. */ export async function deleteRevision(id: string): Promise { - await assertSecureStorageWritableForMutation(); - const db = await getDb(); - await deleteRevisions(db, [id]); + // QNBS-v3: shares the writer-admission lock so this delete cannot land mid-migration-batch either. + await withProtectedWriteAdmission(async () => { + await assertSecureStorageWritableForMutation(); + const db = await getDb(); + await deleteRevisions(db, [id]); + }); } /** Reset the singleton and close its handle so tests cannot retain a stale database connection. */ diff --git a/services/storage/idbAssetStore.ts b/services/storage/idbAssetStore.ts index ac6b7024..cae5af3c 100644 --- a/services/storage/idbAssetStore.ts +++ b/services/storage/idbAssetStore.ts @@ -9,6 +9,7 @@ import type { BinderAssetMeta, BinderAssetPayload } from '../storageBackend'; import { makeBinderAssetIdsPrefix, makeBinderAssetStorageKey } from '../storageBackend'; import { getUserFriendlyDbError, retryDb } from './idbCore'; import { IdbSnapshotStore } from './idbSnapshotStore'; +import { withProtectedWriteAdmission } from './protectedWriteAdmission'; import { assertIdbProtectedWriteAllowed, assertNoActiveEncryptionMigration, @@ -23,19 +24,21 @@ export class IdbAssetStore extends IdbSnapshotStore { // --- Image Store Methods --- async saveImage(id: string, base64: string): Promise { - // QNBS-v3: Resolve the write key BEFORE opening the transaction — `await idbEncryptWithKey` - // yields the event loop, which auto-commits an already-open IDB transaction - // (TransactionInactiveError on put), and re-reading isIdbEncryptionReady() after any - // later await could race with Lock Session and silently fall back to plaintext. - const writeKey = await resolveProtectedWriteKey(); - const payload = writeKey ? await idbEncryptWithKey(writeKey, base64) : base64; - // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. - await assertNoActiveEncryptionMigration(); - const store = await this.getObjectStore(IMAGES_STORE, 'readwrite'); - return new Promise((resolve, reject) => { - const request = store.put(payload, id); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); + return withProtectedWriteAdmission(async () => { + // QNBS-v3: Resolve the write key BEFORE opening the transaction — `await idbEncryptWithKey` + // yields the event loop, which auto-commits an already-open IDB transaction + // (TransactionInactiveError on put), and re-reading isIdbEncryptionReady() after any + // later await could race with Lock Session and silently fall back to plaintext. + const writeKey = await resolveProtectedWriteKey(); + const payload = writeKey ? await idbEncryptWithKey(writeKey, base64) : base64; + // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. + await assertNoActiveEncryptionMigration(); + const store = await this.getObjectStore(IMAGES_STORE, 'readwrite'); + return new Promise((resolve, reject) => { + const request = store.put(payload, id); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); }); } @@ -66,13 +69,15 @@ export class IdbAssetStore extends IdbSnapshotStore { } async deleteImage(id: string): Promise { - // QNBS-v3: A locked session must not be able to destroy protected images it cannot read. - await assertIdbProtectedWriteAllowed(); - const store = await this.getObjectStore(IMAGES_STORE, 'readwrite'); - return new Promise((resolve, reject) => { - const request = store.delete(id); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); + return withProtectedWriteAdmission(async () => { + // QNBS-v3: A locked session must not be able to destroy protected images it cannot read. + await assertIdbProtectedWriteAllowed(); + const store = await this.getObjectStore(IMAGES_STORE, 'readwrite'); + return new Promise((resolve, reject) => { + const request = store.delete(id); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); }); } @@ -84,30 +89,32 @@ export class IdbAssetStore extends IdbSnapshotStore { data: ArrayBuffer, meta: BinderAssetMeta, ): Promise { - return retryDb(async () => { - const writeKey = await resolveProtectedWriteKey(); - const key = makeBinderAssetStorageKey(projectId, assetId); - const fullMeta = { ...meta, byteSize: data.byteLength }; - // QNBS-v3: idbEncrypt serialises via JSON.stringify, which silently drops a Blob ({} → no data). - // When encrypting, persist the raw bytes; otherwise store a structured-clone-friendly Blob. - const payload = writeKey - ? await idbEncryptWithKey(writeKey, { - meta: fullMeta, - bytes: Array.from(new Uint8Array(data)), - }) - : { - meta: fullMeta, - blob: new Blob([data], { type: meta.mimeType || 'application/octet-stream' }), - }; - // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. - await assertNoActiveEncryptionMigration(); - const store = await this.getObjectStore(BINDER_ASSETS_STORE, 'readwrite'); - return new Promise((resolve, reject) => { - const req = store.put(payload, key); - req.onsuccess = () => resolve(); - req.onerror = () => reject(getUserFriendlyDbError(req.error)); - }); - }); + return retryDb(() => + withProtectedWriteAdmission(async () => { + const writeKey = await resolveProtectedWriteKey(); + const key = makeBinderAssetStorageKey(projectId, assetId); + const fullMeta = { ...meta, byteSize: data.byteLength }; + // QNBS-v3: idbEncrypt serialises via JSON.stringify, which silently drops a Blob ({} → no data). + // When encrypting, persist the raw bytes; otherwise store a structured-clone-friendly Blob. + const payload = writeKey + ? await idbEncryptWithKey(writeKey, { + meta: fullMeta, + bytes: Array.from(new Uint8Array(data)), + }) + : { + meta: fullMeta, + blob: new Blob([data], { type: meta.mimeType || 'application/octet-stream' }), + }; + // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. + await assertNoActiveEncryptionMigration(); + const store = await this.getObjectStore(BINDER_ASSETS_STORE, 'readwrite'); + return new Promise((resolve, reject) => { + const req = store.put(payload, key); + req.onsuccess = () => resolve(); + req.onerror = () => reject(getUserFriendlyDbError(req.error)); + }); + }), + ); } async getBinderAsset(projectId: string, assetId: string): Promise { @@ -136,17 +143,19 @@ export class IdbAssetStore extends IdbSnapshotStore { } async deleteBinderAsset(projectId: string, assetId: string): Promise { - return retryDb(async () => { - // QNBS-v3: A locked session must not be able to destroy protected binder assets it cannot read. - await assertIdbProtectedWriteAllowed(); - const key = makeBinderAssetStorageKey(projectId, assetId); - const store = await this.getObjectStore(BINDER_ASSETS_STORE, 'readwrite'); - return new Promise((resolve, reject) => { - const req = store.delete(key); - req.onsuccess = () => resolve(); - req.onerror = () => reject(getUserFriendlyDbError(req.error)); - }); - }); + return retryDb(() => + withProtectedWriteAdmission(async () => { + // QNBS-v3: A locked session must not be able to destroy protected binder assets it cannot read. + await assertIdbProtectedWriteAllowed(); + const key = makeBinderAssetStorageKey(projectId, assetId); + const store = await this.getObjectStore(BINDER_ASSETS_STORE, 'readwrite'); + return new Promise((resolve, reject) => { + const req = store.delete(key); + req.onsuccess = () => resolve(); + req.onerror = () => reject(getUserFriendlyDbError(req.error)); + }); + }), + ); } async listBinderAssetIds(projectId: string): Promise { @@ -176,6 +185,13 @@ export class IdbAssetStore extends IdbSnapshotStore { } async deleteAllBinderAssetsForProject(projectId: string): Promise { + return withProtectedWriteAdmission(() => + this.deleteAllBinderAssetsForProjectUnadmitted(projectId), + ); + } + + // QNBS-v3: unwrapped core for deleteProject() to call inside its own single outer admission — nesting withProtectedWriteAdmission (same shared lock name, same call stack) can deadlock if an exclusive migration request queues between the outer and inner acquisition. + protected async deleteAllBinderAssetsForProjectUnadmitted(projectId: string): Promise { return retryDb(async () => { await assertIdbProtectedWriteAllowed(); const ids = await this.listBinderAssetIds(projectId); diff --git a/services/storage/idbCodexStore.ts b/services/storage/idbCodexStore.ts index 859fbc39..cb1a73a4 100644 --- a/services/storage/idbCodexStore.ts +++ b/services/storage/idbCodexStore.ts @@ -8,6 +8,7 @@ import type { StoryCodex } from '../../types'; import { CODEX_STORE, RAG_VECTORS_STORE } from '../dbConstants'; import { compressData, decompressData } from './idbCore'; import { IdbKeyStore } from './idbKeyStore'; +import { withProtectedWriteAdmission } from './protectedWriteAdmission'; import { assertIdbProtectedWriteAllowed, assertNoActiveEncryptionMigration, @@ -20,32 +21,34 @@ import { export class IdbCodexStore extends IdbKeyStore { async saveStoryCodex(codex: StoryCodex): Promise { - // QNBS-v3: Resolve the write key BEFORE opening the transaction — `await idbEncryptWithKey` - // yields the event loop, which auto-commits an already-open transaction - // (TransactionInactiveError), and re-reading isIdbEncryptionReady() after any later - // await could race with Lock Session and silently fall back to plaintext. - const writeKey = await resolveProtectedWriteKey(); - const processed = writeKey ? await idbEncryptWithKey(writeKey, codex) : compressData(codex); - // QNBS-v3: three shapes — encrypted Uint8Array, LZ-compressed string, or (small codex) the raw - // object. compressData() returns the original object when JSON is below the compress - // threshold, so the previous `Array.from(processed as Uint8Array)` turned a small, - // unencrypted codex into [] — silent data loss for new/small projects (encryption OFF, - // the default). Keep the raw-object path so small codexes round-trip via decompressData. - let record: object; - if (processed instanceof Uint8Array) { - record = { projectId: codex.projectId, encrypted: Array.from(processed) }; - } else if (typeof processed === 'string') { - record = { projectId: codex.projectId, compressedUtf16: processed }; - } else { - record = processed as object; - } - // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. - await assertNoActiveEncryptionMigration(); - const store = await this.getObjectStore(CODEX_STORE, 'readwrite'); - return new Promise((resolve, reject) => { - const request = store.put(record); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); + return withProtectedWriteAdmission(async () => { + // QNBS-v3: Resolve the write key BEFORE opening the transaction — `await idbEncryptWithKey` + // yields the event loop, which auto-commits an already-open transaction + // (TransactionInactiveError), and re-reading isIdbEncryptionReady() after any later + // await could race with Lock Session and silently fall back to plaintext. + const writeKey = await resolveProtectedWriteKey(); + const processed = writeKey ? await idbEncryptWithKey(writeKey, codex) : compressData(codex); + // QNBS-v3: three shapes — encrypted Uint8Array, LZ-compressed string, or (small codex) the raw + // object. compressData() returns the original object when JSON is below the compress + // threshold, so the previous `Array.from(processed as Uint8Array)` turned a small, + // unencrypted codex into [] — silent data loss for new/small projects (encryption OFF, + // the default). Keep the raw-object path so small codexes round-trip via decompressData. + let record: object; + if (processed instanceof Uint8Array) { + record = { projectId: codex.projectId, encrypted: Array.from(processed) }; + } else if (typeof processed === 'string') { + record = { projectId: codex.projectId, compressedUtf16: processed }; + } else { + record = processed as object; + } + // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. + await assertNoActiveEncryptionMigration(); + const store = await this.getObjectStore(CODEX_STORE, 'readwrite'); + return new Promise((resolve, reject) => { + const request = store.put(record); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); }); } @@ -89,72 +92,76 @@ export class IdbCodexStore extends IdbKeyStore { } async deleteStoryCodex(projectId: string): Promise { - // QNBS-v3: A locked session must not be able to destroy protected codex records it cannot read. - await assertIdbProtectedWriteAllowed(); - const store = await this.getObjectStore(CODEX_STORE, 'readwrite'); - return new Promise((resolve, reject) => { - const request = store.delete(projectId); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); + return withProtectedWriteAdmission(async () => { + // QNBS-v3: A locked session must not be able to destroy protected codex records it cannot read. + await assertIdbProtectedWriteAllowed(); + const store = await this.getObjectStore(CODEX_STORE, 'readwrite'); + return new Promise((resolve, reject) => { + const request = store.delete(projectId); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); }); } // --- RAG Vector Methods --- async saveRagVectors(projectId: string, vectors: unknown[]): Promise { - // QNBS-v3: Resolve the write key BEFORE opening the transaction — `await idbEncryptWithKey` - // yields the event loop and would auto-commit the open transaction before the put - // (TransactionInactiveError), and re-reading isIdbEncryptionReady() after any later - // await could race with Lock Session and silently fall back to plaintext. - const writeKey = await resolveProtectedWriteKey(); - const encryptedPayload = writeKey - ? Array.from(await idbEncryptWithKey(writeKey, { projectId, vectors })) - : null; - // QNBS-v3: only the migration guard is re-checked here — the lock check already happened atomically inside resolveProtectedWriteKey(); this function's multiple sequential IDB ops (clear then write) still leave a residual window, but re-running the lock check too would wrongly reject an already-safely-encrypted write if the session locks mid-write. - await assertNoActiveEncryptionMigration(); - const store = await this.getObjectStore(RAG_VECTORS_STORE, 'readwrite'); - // Clear existing vectors for this project then write the full set - const index = store.index('projectId'); - const keysToDelete: IDBValidKey[] = []; - await new Promise((resolve, reject) => { - const req = index.getAllKeys(projectId); - req.onsuccess = () => { - keysToDelete.push(...(req.result as IDBValidKey[])); - resolve(); - }; - req.onerror = () => reject(req.error); - }); - for (const key of keysToDelete) { + return withProtectedWriteAdmission(async () => { + // QNBS-v3: Resolve the write key BEFORE opening the transaction — `await idbEncryptWithKey` + // yields the event loop and would auto-commit the open transaction before the put + // (TransactionInactiveError), and re-reading isIdbEncryptionReady() after any later + // await could race with Lock Session and silently fall back to plaintext. + const writeKey = await resolveProtectedWriteKey(); + const encryptedPayload = writeKey + ? Array.from(await idbEncryptWithKey(writeKey, { projectId, vectors })) + : null; + // QNBS-v3: only the migration guard is re-checked here — the lock check already happened atomically inside resolveProtectedWriteKey(); this function's multiple sequential IDB ops (clear then write) still leave a residual window, but re-running the lock check too would wrongly reject an already-safely-encrypted write if the session locks mid-write. + await assertNoActiveEncryptionMigration(); + const store = await this.getObjectStore(RAG_VECTORS_STORE, 'readwrite'); + // Clear existing vectors for this project then write the full set + const index = store.index('projectId'); + const keysToDelete: IDBValidKey[] = []; await new Promise((resolve, reject) => { - const req = store.delete(key); - req.onsuccess = () => resolve(); + const req = index.getAllKeys(projectId); + req.onsuccess = () => { + keysToDelete.push(...(req.result as IDBValidKey[])); + resolve(); + }; req.onerror = () => reject(req.error); }); - } - // QNBS-v3: Store the encrypted vector set as one blob when the session key is active. - if (encryptedPayload) { - await new Promise((resolve, reject) => { - // QNBS-v3: RAG_VECTORS_STORE has keyPath 'id' — the encrypted single-blob record MUST carry an - // id or IndexedDB throws DataError. Use a project-scoped sentinel id distinct from any - // real chunk id; the projectId field keeps it discoverable via the projectId index. - const req = store.put({ - id: `__enc__:${projectId}`, - projectId, - encrypted: encryptedPayload, - _enc: true, + for (const key of keysToDelete) { + await new Promise((resolve, reject) => { + const req = store.delete(key); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); }); - req.onsuccess = () => resolve(); - req.onerror = () => reject(req.error); - }); - } else { - for (const vector of vectors) { + } + // QNBS-v3: Store the encrypted vector set as one blob when the session key is active. + if (encryptedPayload) { await new Promise((resolve, reject) => { - const req = store.put({ ...(vector as object), projectId }); + // QNBS-v3: RAG_VECTORS_STORE has keyPath 'id' — the encrypted single-blob record MUST carry an + // id or IndexedDB throws DataError. Use a project-scoped sentinel id distinct from any + // real chunk id; the projectId field keeps it discoverable via the projectId index. + const req = store.put({ + id: `__enc__:${projectId}`, + projectId, + encrypted: encryptedPayload, + _enc: true, + }); req.onsuccess = () => resolve(); req.onerror = () => reject(req.error); }); + } else { + for (const vector of vectors) { + await new Promise((resolve, reject) => { + const req = store.put({ ...(vector as object), projectId }); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + } } - } + }); } async getRagVectors(projectId: string): Promise { diff --git a/services/storage/idbProjectStore.ts b/services/storage/idbProjectStore.ts index f51ff03a..1797c00c 100644 --- a/services/storage/idbProjectStore.ts +++ b/services/storage/idbProjectStore.ts @@ -22,6 +22,7 @@ import { logger } from '../logger'; import type { SaveProjectInput } from '../storageBackend'; import { IdbAssetStore } from './idbAssetStore'; import { compressData, getUserFriendlyDbError, retryDb } from './idbCore'; +import { withProtectedWriteAdmission } from './protectedWriteAdmission'; import { assertIdbProtectedWriteAllowed, assertNoActiveEncryptionMigration, @@ -217,21 +218,23 @@ export class IdbProjectStore extends IdbAssetStore { sliceName: 'project' | 'settings', data: PersistedProjectState | Settings, ): Promise { - // QNBS-v3: Resolve the write key AND encrypt BEFORE opening the store — awaiting - // idbEncryptWithKey after getObjectStore would yield the event loop while the - // transaction is open, letting IDB auto-commit it before store.put runs - // (TransactionInactiveError); resolving the key first also removes the Lock-Session - // race, since the payload decision no longer depends on state read after an await. - const writeKey = await resolveProtectedWriteKey(); - // QNBS-v3: Plaintext is allowed only when encryption was never configured for this library. - const payload = writeKey ? await idbEncryptWithKey(writeKey, data) : compressData(data); - // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. - await assertNoActiveEncryptionMigration(); - const store = await this.getObjectStore(APP_DATA_STORE, 'readwrite'); - return new Promise((resolve, reject) => { - const request = store.put(payload, sliceName); - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); + return withProtectedWriteAdmission(async () => { + // QNBS-v3: Resolve the write key AND encrypt BEFORE opening the store — awaiting + // idbEncryptWithKey after getObjectStore would yield the event loop while the + // transaction is open, letting IDB auto-commit it before store.put runs + // (TransactionInactiveError); resolving the key first also removes the Lock-Session + // race, since the payload decision no longer depends on state read after an await. + const writeKey = await resolveProtectedWriteKey(); + // QNBS-v3: Plaintext is allowed only when encryption was never configured for this library. + const payload = writeKey ? await idbEncryptWithKey(writeKey, data) : compressData(data); + // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. + await assertNoActiveEncryptionMigration(); + const store = await this.getObjectStore(APP_DATA_STORE, 'readwrite'); + return new Promise((resolve, reject) => { + const request = store.put(payload, sliceName); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); }); } @@ -358,17 +361,20 @@ export class IdbProjectStore extends IdbAssetStore { } async deleteProject(projectId: string): Promise { - // QNBS-v3: Guard before the binder-asset cascade too — a locked session must not delete - // protected assets even indirectly via a project-delete request. - await assertIdbProtectedWriteAllowed(); - await this.deleteAllBinderAssetsForProject(projectId); - return retryDb(async () => { - const store = await this.getObjectStore(APP_DATA_STORE, 'readwrite'); - return new Promise((resolve, reject) => { - const req = store.delete('project'); - req.onsuccess = () => resolve(); - req.onerror = () => reject(getUserFriendlyDbError(req.error)); - }); - }); + // QNBS-v3: one outer admission spans guard+cascade+delete; calls the cascade's unadmitted core since nesting the same lock name can deadlock behind a queued exclusive migration request. + return withProtectedWriteAdmission(() => + retryDb(async () => { + await assertIdbProtectedWriteAllowed(); + await this.deleteAllBinderAssetsForProjectUnadmitted(projectId); + // QNBS-v3: re-checked immediately before the final mutation — the cascade above took real async time under the same admission hold, and a Lock Session (not a migration, which admission already excludes) could still fire during it. + await assertIdbProtectedWriteAllowed(); + const store = await this.getObjectStore(APP_DATA_STORE, 'readwrite'); + return new Promise((resolve, reject) => { + const req = store.delete('project'); + req.onsuccess = () => resolve(); + req.onerror = () => reject(getUserFriendlyDbError(req.error)); + }); + }), + ); } } diff --git a/services/storage/idbSnapshotStore.ts b/services/storage/idbSnapshotStore.ts index ed34722f..0da8306f 100644 --- a/services/storage/idbSnapshotStore.ts +++ b/services/storage/idbSnapshotStore.ts @@ -10,6 +10,7 @@ import type { ProjectSnapshot } from '../../types'; import { SNAPSHOTS_STORE } from '../dbConstants'; import { IdbCodexStore } from './idbCodexStore'; import { compressData, getUserFriendlyDbError, retryDb } from './idbCore'; +import { withProtectedWriteAdmission } from './protectedWriteAdmission'; import { assertIdbProtectedWriteAllowed, assertNoActiveEncryptionMigration, @@ -25,31 +26,35 @@ export class IdbSnapshotStore extends IdbCodexStore { protected readonly MAX_AUTO_SNAPSHOTS = 20; async createSnapshot(data: ProjectData, name?: string): Promise { + // QNBS-v3: pure CPU work computed before acquiring admission, not inside the lock hold. const wordCount = data.manuscript.reduce( (sum, section) => sum + (section.content?.split(/\s+/).filter(Boolean).length || 0), 0, ); - // QNBS-v3: Resolve the write key in one atomic snapshot rather than re-reading - // isIdbEncryptionReady() later, so Lock Session during this async call cannot - // silently downgrade an already-approved snapshot to plaintext. - const writeKey = await resolveProtectedWriteKey(); - // QNBS-v3: Plaintext snapshots are allowed only before encryption is configured. - const snapshotPayload = writeKey ? await idbEncryptWithKey(writeKey, data) : compressData(data); - const snapshotData = { - date: new Date().toISOString(), - name: name ?? 'Automatic Snapshot', - wordCount, - data: snapshotPayload, - }; - - return retryDb(async () => { - // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. - await assertNoActiveEncryptionMigration(); - const store = await this.getObjectStore(SNAPSHOTS_STORE, 'readwrite'); - return new Promise((resolve, reject) => { - const request = store.add(snapshotData); - request.onsuccess = () => resolve(request.result as number); - request.onerror = () => reject(getUserFriendlyDbError(request.error)); + return withProtectedWriteAdmission(() => { + return retryDb(async () => { + // QNBS-v3: Resolve the write key in one atomic snapshot rather than re-reading + // isIdbEncryptionReady() later, so Lock Session during this async call cannot + // silently downgrade an already-approved snapshot to plaintext. + const writeKey = await resolveProtectedWriteKey(); + // QNBS-v3: Plaintext snapshots are allowed only before encryption is configured. + const snapshotPayload = writeKey + ? await idbEncryptWithKey(writeKey, data) + : compressData(data); + const snapshotData = { + date: new Date().toISOString(), + name: name ?? 'Automatic Snapshot', + wordCount, + data: snapshotPayload, + }; + // QNBS-v3: only the migration guard is re-checked here — resolveProtectedWriteKey() already made its own lock check atomically with the key snapshot, so re-running that too would wrongly reject an already-safely-encrypted write if the session locks mid-write. + await assertNoActiveEncryptionMigration(); + const store = await this.getObjectStore(SNAPSHOTS_STORE, 'readwrite'); + return new Promise((resolve, reject) => { + const request = store.add(snapshotData); + request.onsuccess = () => resolve(request.result as number); + request.onerror = () => reject(getUserFriendlyDbError(request.error)); + }); }); }); } @@ -106,15 +111,27 @@ export class IdbSnapshotStore extends IdbCodexStore { } async deleteSnapshot(id: number): Promise { - return retryDb(async () => { - // QNBS-v3: A locked session must not be able to destroy protected snapshot records. - await assertIdbProtectedWriteAllowed(); - const store = await this.getObjectStore(SNAPSHOTS_STORE, 'readwrite'); - return new Promise((resolve, reject) => { + return retryDb(() => withProtectedWriteAdmission(() => this.deleteSnapshotsUnadmitted([id]))); + } + + // QNBS-v3: one transaction + one admission hold for N deletes, not N round trips — used by both deleteSnapshot() and pruneAutoSnapshots(). + private async deleteSnapshotsUnadmitted(ids: readonly number[]): Promise { + if (ids.length === 0) return; + await assertIdbProtectedWriteAllowed(); + const store = await this.getObjectStore(SNAPSHOTS_STORE, 'readwrite'); + const transaction = store.transaction; + return new Promise((resolve, reject) => { + let failure: string | undefined; + for (const id of ids) { const request = store.delete(id); - request.onsuccess = () => resolve(); - request.onerror = () => reject(getUserFriendlyDbError(request.error)); - }); + request.onerror = () => { + failure = getUserFriendlyDbError(request.error); + transaction.abort(); + }; + } + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(failure ?? getUserFriendlyDbError(transaction.error)); }); } @@ -134,8 +151,8 @@ export class IdbSnapshotStore extends IdbCodexStore { .sort((a, b) => a - b) .slice(0, allKeys.length - this.MAX_AUTO_SNAPSHOTS); - for (const key of toDelete) { - await this.deleteSnapshot(key); - } + await retryDb(() => + withProtectedWriteAdmission(() => this.deleteSnapshotsUnadmitted(toDelete)), + ); } } diff --git a/services/storage/protectedStoreMigration.ts b/services/storage/protectedStoreMigration.ts index de32c0da..10129c36 100644 --- a/services/storage/protectedStoreMigration.ts +++ b/services/storage/protectedStoreMigration.ts @@ -11,6 +11,7 @@ import { releaseEncryptionMigrationOwnership, updateEncryptionMigrationJournal, } from './encryptionMigrationJournal'; +import { withMigrationAdmission } from './protectedWriteAdmission'; import { assertIdbMigrationTargetKeyMatchesVerifier } from './storageEncryptionService'; export interface EncryptionMigrationKeys { @@ -244,7 +245,10 @@ export async function runProtectedStoreMigration( for (const adapter of adapters) { let checkpoint = checkpointFor(journal, adapter.id); while (!checkpoint.done) { - const batch = await adapter.migrateNext(migrationContext(journal, checkpoint, keys)); + // QNBS-v3: exclusive admission bounds the race window to one batch, not the whole run — closes the write-vs-migration TOCTOU gap (#338) while still letting writers proceed between batches. + const batch = await withMigrationAdmission(() => + adapter.migrateNext(migrationContext(journal, checkpoint, keys)), + ); checkpoint = nextCheckpoint(checkpoint, batch); journal = await updateEncryptionMigrationJournal(journal, { phase: 'migrating', diff --git a/services/storage/protectedWriteAdmission.ts b/services/storage/protectedWriteAdmission.ts new file mode 100644 index 00000000..33dc1492 --- /dev/null +++ b/services/storage/protectedWriteAdmission.ts @@ -0,0 +1,107 @@ +/** + * Cross-tab admission for protected-store writes vs. an active encryption migration. + * QNBS-v3: replaces the standalone-read assertNoActiveEncryptionMigration() preflight, which left a race window between a writer's key resolution and its transaction commit, with real mutual exclusion. + */ + +import { createLogger } from '../logger'; + +const LOCK_NAME = 'worldscript:idb-protected-write-v1'; +const logger = createLogger('protectedWriteAdmission'); + +let warnedNoLocksApi = false; + +function hasLocksApi(): boolean { + return typeof navigator !== 'undefined' && typeof navigator.locks?.request === 'function'; +} + +// QNBS-v3: in-process fallback reader/writer lock for runtimes without navigator.locks — same-tab-only mutual exclusion, weaker than Web Locks (no cross-tab), but strictly better than running unguarded. +type FallbackMode = 'shared' | 'exclusive'; +interface FallbackWaiter { + mode: FallbackMode; + grant: () => void; +} +let fallbackActiveShared = 0; +let fallbackExclusiveHeld = false; +const fallbackWaiters: FallbackWaiter[] = []; + +function fallbackHasQueuedExclusive(): boolean { + return fallbackWaiters.some((waiter) => waiter.mode === 'exclusive'); +} + +// QNBS-v3: claims ownership synchronously, in the same statement that decides the lock is free — a caller can never observe a moment where the lock looks free but no one has claimed it yet. +function fallbackTryClaim(mode: FallbackMode): boolean { + if (fallbackExclusiveHeld) return false; + if (mode === 'exclusive') { + if (fallbackActiveShared > 0) return false; + fallbackExclusiveHeld = true; + return true; + } + if (fallbackHasQueuedExclusive()) return false; + fallbackActiveShared++; + return true; +} + +// QNBS-v3: also claims ownership synchronously before grant() resolves the waiter's promise, so release-then-wake can never leave a gap where a fresh caller barges in ahead of an already-woken waiter. +function fallbackWakeNext(): void { + if (fallbackWaiters.length === 0) return; + if (fallbackWaiters[0]!.mode === 'exclusive') { + fallbackExclusiveHeld = true; + fallbackWaiters.shift()!.grant(); + return; + } + while (fallbackWaiters.length > 0 && fallbackWaiters[0]!.mode === 'shared') { + fallbackActiveShared++; + fallbackWaiters.shift()!.grant(); + } +} + +async function acquireFallback(mode: FallbackMode): Promise<() => void> { + if (!fallbackTryClaim(mode)) { + // QNBS-v3: fallbackWakeNext() already claimed ownership on this waiter's behalf before calling grant() — no re-check needed here. + await new Promise((grant) => fallbackWaiters.push({ mode, grant })); + } + if (mode === 'exclusive') { + return () => { + fallbackExclusiveHeld = false; + fallbackWakeNext(); + }; + } + return () => { + fallbackActiveShared--; + if (fallbackActiveShared === 0) fallbackWakeNext(); + }; +} + +async function withFallbackAdmission(mode: FallbackMode, fn: () => Promise): Promise { + if (!warnedNoLocksApi) { + warnedNoLocksApi = true; + logger.warn('navigator.locks unavailable — using an in-process (same-tab only) fallback lock'); + } + const release = await acquireFallback(mode); + try { + return await fn(); + } finally { + release(); + } +} + +/** + * Ordinary protected writers hold this in shared mode for their full key-resolution-through- + * transaction-commit span. Many shared holders can run concurrently; an exclusive migration + * admission (below) waits for all of them to release before it is granted, and blocks new shared + * requests until it releases — a standard fair reader/writer lock via the browser's own scheduler. + */ +export async function withProtectedWriteAdmission(fn: () => Promise): Promise { + if (!hasLocksApi()) return withFallbackAdmission('shared', fn); + return navigator.locks.request(LOCK_NAME, { mode: 'shared' }, () => fn()); +} + +/** + * A migration batch holds this in exclusive mode only for the span of one adapter.migrateNext() + * call (~batchSize records), not the whole migration run — bounding writer starvation while still + * making the store's actual read-transform-write atomic with respect to every ordinary writer. + */ +export async function withMigrationAdmission(fn: () => Promise): Promise { + if (!hasLocksApi()) return withFallbackAdmission('exclusive', fn); + return navigator.locks.request(LOCK_NAME, { mode: 'exclusive' }, () => fn()); +} diff --git a/tests/setup.ts b/tests/setup.ts index 8ef31f88..920e4155 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -135,6 +135,89 @@ if (typeof window !== 'undefined' && !('SpeechSynthesisUtterance' in window)) { SpeechSynthesisUtteranceMock; } +// navigator.locks (Web Locks API) — jsdom does not implement it, and Node has no navigator at all. +// QNBS-v3: minimal fair reader/writer mutex per lock name, enough fidelity for protectedWriteAdmission.ts without a full spec-accurate implementation. +if (typeof navigator === 'undefined') { + (globalThis as unknown as { navigator: unknown }).navigator = {}; +} +if (!('locks' in navigator) || !navigator.locks) { + const activeSharedByName = new Map(); + const exclusiveHeldByName = new Set(); + type Waiter = { mode: 'shared' | 'exclusive'; resolve: () => void }; + const waiters = new Map(); + + function hasQueuedExclusive(name: string): boolean { + return (waiters.get(name) ?? []).some((w) => w.mode === 'exclusive'); + } + + // QNBS-v3: wakes the queue's leading exclusive waiter alone, or every leading shared waiter together — prevents new shared requests from barging a queued exclusive one. + function wakeNext(name: string): void { + const queue = waiters.get(name); + if (!queue || queue.length === 0) return; + if (queue[0]!.mode === 'exclusive') { + queue.shift()!.resolve(); + return; + } + while (queue.length > 0 && queue[0]!.mode === 'shared') { + queue.shift()!.resolve(); + } + } + + async function acquire(name: string, mode: 'shared' | 'exclusive'): Promise<() => void> { + while ( + exclusiveHeldByName.has(name) || + (mode === 'exclusive' && (activeSharedByName.get(name) ?? 0) > 0) || + (mode === 'shared' && hasQueuedExclusive(name)) + ) { + await new Promise((resolve) => { + const queue = waiters.get(name) ?? []; + queue.push({ mode, resolve }); + waiters.set(name, queue); + }); + } + if (mode === 'exclusive') { + exclusiveHeldByName.add(name); + return () => { + exclusiveHeldByName.delete(name); + wakeNext(name); + }; + } + activeSharedByName.set(name, (activeSharedByName.get(name) ?? 0) + 1); + return () => { + const remaining = (activeSharedByName.get(name) ?? 1) - 1; + activeSharedByName.set(name, remaining); + if (remaining === 0) wakeNext(name); + }; + } + + Object.defineProperty(navigator, 'locks', { + configurable: true, + writable: true, + value: { + request: async ( + name: string, + optionsOrCallback: { mode?: 'shared' | 'exclusive' } | (() => T | Promise), + maybeCallback?: () => T | Promise, + ): Promise => { + const isCallbackOnly = typeof optionsOrCallback === 'function'; + const callback = isCallbackOnly ? optionsOrCallback : maybeCallback!; + // QNBS-v3: LockManager.request()'s 2-arg form defaults to 'exclusive' per spec, not 'shared'. + const mode = isCallbackOnly + ? 'exclusive' + : optionsOrCallback.mode === 'shared' + ? 'shared' + : 'exclusive'; + const release = await acquire(name, mode); + try { + return await callback(); + } finally { + release(); + } + }, + }, + }); +} + // ResizeObserver & IntersectionObserver (sehr häufig in modernen React-Komponenten) // QNBS-v3: These APIs are used by container-query components and lazy-loading features. if (typeof window !== 'undefined') { diff --git a/tests/unit/dbServiceRetry.test.ts b/tests/unit/dbServiceRetry.test.ts index e0a1eb04..96f1bcce 100644 --- a/tests/unit/dbServiceRetry.test.ts +++ b/tests/unit/dbServiceRetry.test.ts @@ -3,9 +3,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // QNBS-v3: Tests retryDb wrapper on saveProject/saveSettings by mocking saveSlice on the service // instance — avoids needing a real or stubbed IDB environment. -vi.mock('../../services/logger', () => ({ - logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, -})); +vi.mock('../../services/logger', () => { + const noopLogger = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }; + return { + logger: noopLogger, + // QNBS-v3: protectedWriteAdmission.ts (pulled in transitively via idbCodexStore.ts) calls + // createLogger() at module load — this mock must cover it too or import throws. + createLogger: () => ({ ...noopLogger, withContext: () => ({ ...noopLogger }) }), + }; +}); // Minimal fake IDB objects so dbService can load and setDb without crashing. const fakeStore = { diff --git a/tests/unit/dbServiceSnapshots.test.ts b/tests/unit/dbServiceSnapshots.test.ts index 80b3ad50..ec91fb89 100644 --- a/tests/unit/dbServiceSnapshots.test.ts +++ b/tests/unit/dbServiceSnapshots.test.ts @@ -30,7 +30,8 @@ const snapshotStore = new Map(); const appDataStore = new Map(); let nextSnapshotKey = 1; -function createSnapshotFakeStore() { +// QNBS-v3: pending tracks each request's completion promise so the owning transaction mock (below) can fire oncomplete only after every request queued on it has actually settled. +function createSnapshotFakeStore(pending: Promise[] = []) { return { add: (value: unknown) => { const key = nextSnapshotKey++; @@ -40,10 +41,12 @@ function createSnapshotFakeStore() { result: key, error: null, }; - Promise.resolve().then(() => { - snapshotStore.set(key, value as SnapshotRecord); - (req['onsuccess'] as (() => void) | null)?.(); - }); + pending.push( + Promise.resolve().then(() => { + snapshotStore.set(key, value as SnapshotRecord); + (req['onsuccess'] as (() => void) | null)?.(); + }), + ); return req; }, get: (key: number) => { @@ -53,9 +56,11 @@ function createSnapshotFakeStore() { result: snapshotStore.get(key), error: null, }; - Promise.resolve().then(() => { - (req['onsuccess'] as ((e: Event) => void) | null)?.({} as Event); - }); + pending.push( + Promise.resolve().then(() => { + (req['onsuccess'] as ((e: Event) => void) | null)?.({} as Event); + }), + ); return req; }, delete: (key: number) => { @@ -63,10 +68,12 @@ function createSnapshotFakeStore() { onsuccess: null, onerror: null, }; - Promise.resolve().then(() => { - snapshotStore.delete(key); - (req['onsuccess'] as ((e: Event) => void) | null)?.({} as Event); - }); + pending.push( + Promise.resolve().then(() => { + snapshotStore.delete(key); + (req['onsuccess'] as ((e: Event) => void) | null)?.({} as Event); + }), + ); return req; }, // openCursor with 'prev' direction for listSnapshots @@ -80,11 +87,19 @@ function createSnapshotFakeStore() { onerror: null, result: null, }; + // QNBS-v3: tracks the whole cursor walk (not just its first step) so the owning transaction's oncomplete waits for every continue()-driven iteration. + let resolveCursorDone!: () => void; + pending.push( + new Promise((resolve) => { + resolveCursorDone = resolve; + }), + ); const advance = () => { if (index >= entries.length) { req['result'] = null; (req['onsuccess'] as ((e: Event) => void) | null)?.({} as Event); + resolveCursorDone(); return; } const [key, value] = entries[index++] as [number, SnapshotRecord]; @@ -101,21 +116,43 @@ function createSnapshotFakeStore() { Promise.resolve().then(advance); return req; }, - // getAllKeys for pruneAutoSnapshots + // getAllKeys for pruneAutoSnapshots — always its own standalone transaction in production code + // (never batched with add/get/delete/openCursor), but still tracked into `pending` so this + // transaction's own oncomplete cannot fire before this, its only, request settles. getAllKeys: () => { const req: Record = { onsuccess: null, onerror: null, result: [...snapshotStore.keys()].sort((a, b) => a - b), }; - Promise.resolve().then(() => { - (req['onsuccess'] as ((e: Event) => void) | null)?.({} as Event); - }); + pending.push( + Promise.resolve().then(() => { + (req['onsuccess'] as ((e: Event) => void) | null)?.({} as Event); + }), + ); return req; }, }; } +// QNBS-v3: completes the fake transaction after every queued request (including full cursor walks) settles, mirroring real IDB transaction batching. +function createSnapshotFakeTransaction() { + const pending: Promise[] = []; + const txn: Record = { + oncomplete: null, + onerror: null, + onabort: null, + error: null, + }; + const store: Record = createSnapshotFakeStore(pending); + store['transaction'] = txn; + txn['objectStore'] = () => store; + queueMicrotask(() => { + void Promise.all(pending).then(() => (txn['oncomplete'] as (() => void) | null)?.()); + }); + return txn; +} + function createAppDataFakeStore() { return { count: () => { @@ -136,8 +173,8 @@ const SNAPSHOTS_STORE_NAME = 'snapshots-store'; const fakeMixedDb = { transaction: vi.fn().mockImplementation((storeName: string) => { - const store = - storeName === SNAPSHOTS_STORE_NAME ? createSnapshotFakeStore() : createAppDataFakeStore(); + if (storeName === SNAPSHOTS_STORE_NAME) return createSnapshotFakeTransaction(); + const store = createAppDataFakeStore(); return { objectStore: () => store }; }), }; diff --git a/tests/unit/fileSystemService.test.ts b/tests/unit/fileSystemService.test.ts index a39977b1..e38c6b96 100644 --- a/tests/unit/fileSystemService.test.ts +++ b/tests/unit/fileSystemService.test.ts @@ -29,9 +29,14 @@ vi.mock('@tauri-apps/api/path', () => ({ join: vi.fn((...parts: string[]) => Promise.resolve(parts.join('/'))), })); -vi.mock('../../services/logger', () => ({ - logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() }, -})); +vi.mock('../../services/logger', () => { + const noopLogger = { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() }; + return { + logger: noopLogger, + // QNBS-v3: protectedWriteAdmission.ts (pulled in transitively via the storage layer) calls createLogger() at module load. + createLogger: () => ({ ...noopLogger, withContext: () => ({ ...noopLogger }) }), + }; +}); // --------------------------------------------------------------------------- // Tests diff --git a/tests/unit/storage/protectedWriteAdmission.test.ts b/tests/unit/storage/protectedWriteAdmission.test.ts new file mode 100644 index 00000000..574a1559 --- /dev/null +++ b/tests/unit/storage/protectedWriteAdmission.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest'; +import { + withMigrationAdmission, + withProtectedWriteAdmission, +} from '../../../services/storage/protectedWriteAdmission'; + +describe('protectedWriteAdmission', () => { + it('allows multiple shared (ordinary writer) holders to run concurrently', async () => { + let concurrent = 0; + let maxConcurrent = 0; + const run = () => + withProtectedWriteAdmission(async () => { + concurrent++; + maxConcurrent = Math.max(maxConcurrent, concurrent); + await new Promise((resolve) => setTimeout(resolve, 10)); + concurrent--; + }); + + await Promise.all([run(), run(), run()]); + expect(maxConcurrent).toBeGreaterThan(1); + }); + + it('excludes ordinary writers while a migration batch holds exclusive admission', async () => { + const order: string[] = []; + const migration = withMigrationAdmission(async () => { + order.push('migration-start'); + await new Promise((resolve) => setTimeout(resolve, 20)); + order.push('migration-end'); + }); + // QNBS-v3: started slightly after the migration so it reliably queues behind the exclusive hold. + await new Promise((resolve) => setTimeout(resolve, 5)); + const writer = withProtectedWriteAdmission(async () => { + order.push('writer-start'); + order.push('writer-end'); + }); + + await Promise.all([migration, writer]); + expect(order).toEqual(['migration-start', 'migration-end', 'writer-start', 'writer-end']); + }); + + it('makes an exclusive migration wait for an already-admitted shared writer to finish', async () => { + const order: string[] = []; + const writer = withProtectedWriteAdmission(async () => { + order.push('writer-start'); + await new Promise((resolve) => setTimeout(resolve, 20)); + order.push('writer-end'); + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + const migration = withMigrationAdmission(async () => { + order.push('migration-start'); + order.push('migration-end'); + }); + + await Promise.all([writer, migration]); + expect(order).toEqual(['writer-start', 'writer-end', 'migration-start', 'migration-end']); + }); + + it('does not let a new shared writer barge a queued exclusive migration', async () => { + const order: string[] = []; + const holder = withProtectedWriteAdmission(async () => { + order.push('holder-start'); + await new Promise((resolve) => setTimeout(resolve, 15)); + order.push('holder-end'); + }); + // QNBS-v3: queues the exclusive request while the shared holder above is still running. + await new Promise((resolve) => setTimeout(resolve, 5)); + const migration = withMigrationAdmission(async () => { + order.push('migration-start'); + order.push('migration-end'); + }); + // QNBS-v3: this late writer must queue behind the already-queued exclusive migration, not ahead of it. + await new Promise((resolve) => setTimeout(resolve, 5)); + const lateWriter = withProtectedWriteAdmission(async () => { + order.push('late-writer-start'); + order.push('late-writer-end'); + }); + + await Promise.all([holder, migration, lateWriter]); + expect(order).toEqual([ + 'holder-start', + 'holder-end', + 'migration-start', + 'migration-end', + 'late-writer-start', + 'late-writer-end', + ]); + }); + + it('propagates the wrapped function result and rethrows its error', async () => { + await expect(withProtectedWriteAdmission(async () => 'ok')).resolves.toBe('ok'); + await expect( + withProtectedWriteAdmission(async () => { + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + }); + + // QNBS-v3: proves the fallback path stays usable (not just excluded/dropped) on runtimes without navigator.locks. + it('uses the in-process fallback lock and still returns results when navigator.locks is unavailable', async () => { + const original = navigator.locks; + // @ts-expect-error — simulating an older runtime without the Web Locks API + delete navigator.locks; + try { + const result = await withProtectedWriteAdmission(async () => 'fallback-ok'); + expect(result).toBe('fallback-ok'); + const migrationResult = await withMigrationAdmission(async () => 'migration-fallback-ok'); + expect(migrationResult).toBe('migration-fallback-ok'); + } finally { + Object.defineProperty(navigator, 'locks', { + configurable: true, + writable: true, + value: original, + }); + } + }); + + it('still excludes a writer from a migration batch via the in-process fallback lock', async () => { + const original = navigator.locks; + // @ts-expect-error — simulating an older runtime without the Web Locks API + delete navigator.locks; + try { + const order: string[] = []; + const migration = withMigrationAdmission(async () => { + order.push('migration-start'); + await new Promise((resolve) => setTimeout(resolve, 15)); + order.push('migration-end'); + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + const writer = withProtectedWriteAdmission(async () => { + order.push('writer-start'); + order.push('writer-end'); + }); + + await Promise.all([migration, writer]); + expect(order).toEqual(['migration-start', 'migration-end', 'writer-start', 'writer-end']); + } finally { + Object.defineProperty(navigator, 'locks', { + configurable: true, + writable: true, + value: original, + }); + } + }); +}); diff --git a/tests/unit/store.test.ts b/tests/unit/store.test.ts index 1210d843..fd0ad090 100644 --- a/tests/unit/store.test.ts +++ b/tests/unit/store.test.ts @@ -4,9 +4,14 @@ import { describe, expect, it, vi } from 'vitest'; // Mocks // --------------------------------------------------------------------------- -vi.mock('../../services/logger', () => ({ - logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() }, -})); +vi.mock('../../services/logger', () => { + const noopLogger = { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() }; + return { + logger: noopLogger, + // QNBS-v3: protectedWriteAdmission.ts (pulled in transitively via the storage-backed slices) calls createLogger() at module load. + createLogger: () => ({ ...noopLogger, withContext: () => ({ ...noopLogger }) }), + }; +}); vi.mock('../../features/settings/keyboardShortcutsDefaults', () => ({ getDefaultKeyboardShortcuts: vi.fn(() => []),