diff --git a/src/adapters/cache-storage/public-response-cache-adapter.ts b/src/adapters/cache-storage/public-response-cache-adapter.ts index c548575..12e6118 100644 --- a/src/adapters/cache-storage/public-response-cache-adapter.ts +++ b/src/adapters/cache-storage/public-response-cache-adapter.ts @@ -419,13 +419,23 @@ export function createPublicResponseCacheAdapter( normalized.manifestDigestHex, ); const existingNames = await dependencies.cacheStorage!.keys(); - if (existingNames.includes(cacheName)) { + // STO-RR-05. A candidate this call did not create may be the one + // currently serving traffic, so nothing about it is deleted before + // a replacement has been fetched and verified. + const preExistingCandidate = existingNames.includes(cacheName); + if (preExistingCandidate) { const existing = await dependencies.cacheStorage!.open(cacheName); const marker = await readMarker( existing, policy, dependencies.crypto, ); + if (!marker.ok && marker.error.code !== "CORRUPT_DATA") { + // STO-RR-04. A marker that could not be read is unknown, not + // damaged. Treating a transient storage error as proof of + // corruption would delete a healthy active release. + return rebaseFailure(marker.error, "CACHE_STAGE"); + } if ( marker.ok && marker.value && @@ -455,9 +465,6 @@ export function createPublicResponseCacheAdapter( return verified.failure; } } - // Only this owned candidate is removed; the network restage below - // repairs it. - await dependencies.cacheStorage!.delete(cacheName); } const cache = await dependencies.cacheStorage!.open(cacheName); @@ -513,7 +520,13 @@ export function createPublicResponseCacheAdapter( ); return browserDataSuccess(summaryFromMarker(marker)); } catch (error) { - await dependencies.cacheStorage!.delete(cacheName); + // STO-RR-05. Only a candidate this call created is removed. A + // repair that failed part-way leaves every entry it did replace + // and every entry it never touched in place, so the release that + // was serving traffic before still is. + if (!preExistingCandidate) { + await dependencies.cacheStorage!.delete(cacheName); + } throw error; } }, diff --git a/src/adapters/storage/opfs/opfs-byte-store-adapter.ts b/src/adapters/storage/opfs/opfs-byte-store-adapter.ts index e25d4dc..46542db 100644 --- a/src/adapters/storage/opfs/opfs-byte-store-adapter.ts +++ b/src/adapters/storage/opfs/opfs-byte-store-adapter.ts @@ -312,12 +312,23 @@ export function createOpfsByteStoreAdapter( prepared.value, request.signal, ); - if (finalized.ok) { - await dependencies.journal.complete( - transactionId, - begun.value.fencingToken, + if (!finalized.ok) { + // STO-RR-01. The commit fence already passed, so the payload is durable + // and the journal keeps its COMMITTED record for reconciliation to + // settle. What did not happen is finalization: the previous generation + // and the staging directory are still present. Reporting a plain + // success here would claim a settled state nobody observed, so the + // worker's own failure is surfaced and the record is left recoverable. + return observeFailure( + rebaseFailure(finalized.error, "OBJECT_WRITE"), + dependencies.observer, + request.source.byteLength!, ); } + await dependencies.journal.complete( + transactionId, + begun.value.fencingToken, + ); observeOpfsSafely(dependencies.observer, { operation: "OBJECT_WRITE", outcome: "SUCCEEDED", diff --git a/src/adapters/storage/opfs/opfs-worker-client.ts b/src/adapters/storage/opfs/opfs-worker-client.ts index 696d58f..b08a79d 100644 --- a/src/adapters/storage/opfs/opfs-worker-client.ts +++ b/src/adapters/storage/opfs/opfs-worker-client.ts @@ -3,11 +3,12 @@ import type { OpfsCleanupEffect, OpfsPreparedObject, } from "../../../application/ports/browser-file-storage/opfs-ports.ts"; -import type { - BrowserDataFailureCode, - BrowserDataOperation, - BrowserDataResult, - ByteSource, +import { + isBrowserDataFailureCode, + type BrowserDataFailureCode, + type BrowserDataOperation, + type BrowserDataResult, + type ByteSource, } from "../../../application/ports/browser-file-storage/shared.ts"; import { browserDataFailure, @@ -91,21 +92,25 @@ export function createOpfsWorkerGateway( const onMessage = (event: MessageEvent): void => { if (disposed) return; - if (!isWorkerResponse(event.data)) return; - const request = pending.get(event.data.requestId); + const requestId = ownStringField(event.data, "requestId"); + if (requestId === null) return; + const request = pending.get(requestId); if (!request) return; - pending.delete(event.data.requestId); + pending.delete(requestId); clearTimeout(request.timeout); request.removeAbortListener(); - if (event.data.kind !== request.expectedKind) { - // STO-07. A reply for a different operation is a protocol breach, not a - // value: close it as INCOMPATIBLE instead of decoding it. - // UNSUPPORTED is the closed-taxonomy code for "this runtime - // cannot serve this"; no new failure code is invented. + // STO-07 / STO-RR-03. A reply is decoded, never adopted. A different + // operation, an unknown kind, an unknown failure code, an inherited or + // extra field and a hostile accessor are all protocol breaches, and each + // closes the request rather than leaving it to time out. + // UNSUPPORTED is the closed-taxonomy code for "this runtime cannot serve + // this"; no new failure code is invented. + const decoded = decodeWorkerResponse(event.data, request.expectedKind); + if (decoded === null) { request.reject(new OpfsRpcError("UNSUPPORTED")); return; } - request.resolve(event.data); + request.resolve(decoded); }; const onWorkerFailure = (): void => { if (disposed) return; @@ -697,34 +702,108 @@ function parseOrphanDeleteResult( } /** - * STO-07. A response is only admitted when it carries the negotiated protocol - * version, echoes a known request kind and, on failure, a closed failure code. - * Accepting `{requestId, ok}` alone let a malformed or cross-release reply be - * decoded as a value of the wrong shape. + * STO-07 / STO-RR-03. A response is admitted only when every field survives an + * exact own-data decode: the negotiated protocol version, the exact request + * kind this call is waiting for and, on failure, a code inside the closed + * `BrowserDataFailure` taxonomy with a boolean `retryable`. + * + * The decoder returns a fresh frozen value, so a worker that mutates its own + * message object after posting it cannot change what the caller already read. */ -function isWorkerResponse(value: unknown): value is OpfsWorkerResponse { - if ( - !value || - typeof value !== "object" || - !("requestId" in value) || - typeof value.requestId !== "string" || - !("ok" in value) || - typeof value.ok !== "boolean" || - !("protocolVersion" in value) || - value.protocolVersion !== OPFS_WORKER_PROTOCOL_VERSION || - !("kind" in value) || - typeof value.kind !== "string" - ) { +const WORKER_RESPONSE_KEYS: ReadonlySet = new Set([ + "requestId", + "protocolVersion", + "kind", + "ok", + "value", + "failure", +]); + +const WORKER_FAILURE_KEYS: ReadonlySet = new Set(["code", "retryable"]); + +/** Reads one own data property, treating an accessor or a trap as absent. */ +function ownField(source: unknown, key: string): unknown { + if (source === null || typeof source !== "object") return undefined; + try { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if (!descriptor || !("value" in descriptor)) return undefined; + return descriptor.value; + } catch { + return undefined; + } +} + +function ownStringField(source: unknown, key: string): string | null { + const value = ownField(source, key); + return typeof value === "string" && value.length > 0 ? value : null; +} + +function hasOnlyOwnDataKeys( + source: object, + allowed: ReadonlySet, +): boolean { + try { + if (Object.getOwnPropertySymbols(source).length > 0) return false; + for (const key of Object.getOwnPropertyNames(source)) { + if (!allowed.has(key)) return false; + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if (!descriptor || !("value" in descriptor)) return false; + } + return true; + } catch { return false; } - if (value.ok) return true; - const failure = (value as { failure?: unknown }).failure; - return Boolean( - failure && - typeof failure === "object" && - "code" in failure && - typeof (failure as { code?: unknown }).code === "string" && - "retryable" in failure && - typeof (failure as { retryable?: unknown }).retryable === "boolean", - ); +} + +function decodeWorkerResponse( + value: unknown, + expectedKind: OpfsWorkerRequest["kind"], +): OpfsWorkerResponse | null { + if (value === null || typeof value !== "object") return null; + if (!hasOnlyOwnDataKeys(value, WORKER_RESPONSE_KEYS)) return null; + const requestId = ownStringField(value, "requestId"); + if (requestId === null || requestId.length > 128) return null; + if (ownField(value, "protocolVersion") !== OPFS_WORKER_PROTOCOL_VERSION) { + return null; + } + if (ownField(value, "kind") !== expectedKind) return null; + const ok = ownField(value, "ok"); + if (typeof ok !== "boolean") return null; + + if (ok) { + if (Object.hasOwn(value, "failure")) return null; + return Object.freeze( + Object.hasOwn(value, "value") + ? { + requestId, + protocolVersion: OPFS_WORKER_PROTOCOL_VERSION, + kind: expectedKind, + ok: true, + value: ownField(value, "value"), + } + : { + requestId, + protocolVersion: OPFS_WORKER_PROTOCOL_VERSION, + kind: expectedKind, + ok: true, + }, + ) as OpfsWorkerResponse; + } + + if (Object.hasOwn(value, "value")) return null; + const failure = ownField(value, "failure"); + if (failure === null || typeof failure !== "object") return null; + if (!hasOnlyOwnDataKeys(failure, WORKER_FAILURE_KEYS)) return null; + const code = ownField(failure, "code"); + const retryable = ownField(failure, "retryable"); + if (!isBrowserDataFailureCode(code) || typeof retryable !== "boolean") { + return null; + } + return Object.freeze({ + requestId, + protocolVersion: OPFS_WORKER_PROTOCOL_VERSION, + kind: expectedKind, + ok: false, + failure: Object.freeze({ code, retryable }), + }) as OpfsWorkerResponse; } diff --git a/src/adapters/storage/opfs/opfs-worker-runtime.ts b/src/adapters/storage/opfs/opfs-worker-runtime.ts index 3607249..77dca06 100644 --- a/src/adapters/storage/opfs/opfs-worker-runtime.ts +++ b/src/adapters/storage/opfs/opfs-worker-runtime.ts @@ -199,10 +199,15 @@ export function createOpfsWorkerRuntime( return Object.freeze({ async handleRequest(request: unknown) { if (!hasRequestId(request)) return null; + if (!isWorkerRequest(request)) { + // STO-RR-02. Only an envelope this runtime could not read produces a + // protocol-level failure. Everything below answers its own request. + return failure( + request.requestId, + mapRuntimeFailure(new OpfsRuntimeFailure("INVALID_INPUT")), + ); + } try { - if (!isWorkerRequest(request)) { - throw new OpfsRuntimeFailure("INVALID_INPUT"); - } switch (request.kind) { case "CAPABILITIES": return success(request.requestId, request.kind, capabilities); @@ -289,7 +294,14 @@ export function createOpfsWorkerRuntime( ); } } catch (error) { - return failure(request.requestId, mapRuntimeFailure(error)); + // STO-RR-02. The kind travels with the failure so the client's + // expected-kind check cannot mistake a quota, integrity or abort + // failure for a protocol breach. + return failure( + request.requestId, + mapRuntimeFailure(error), + request.kind, + ); } }, }); @@ -911,10 +923,13 @@ export function createOpfsWorkerRuntime( await objectDirectory.removeEntry(name, { recursive: true }); } } - await cleanupTransaction(descriptor.scope, transactionId, false); + // STO-RR-01. This path already holds the origin mutation lease, and a + // Web Lock is not reentrant: asking for it again here never returns, so + // an ordinary PUT would stop for good at FINALIZE. + await cleanupTransactionLocked(descriptor.scope, transactionId, false); } catch (error) { if (!isNotFound(error)) throw error; - await cleanupTransaction( + await cleanupTransactionLocked( preparedObject.descriptor.scope, transactionId, false, diff --git a/src/application/ports/browser-file-storage/shared.ts b/src/application/ports/browser-file-storage/shared.ts index 0e13c91..3844874 100644 --- a/src/application/ports/browser-file-storage/shared.ts +++ b/src/application/ports/browser-file-storage/shared.ts @@ -1,24 +1,42 @@ import type { Result } from "../../result.ts"; +/** + * STO-RR-03. The runtime membership set behind the closed failure taxonomy. A + * boundary decoder needs to test a value against it, and a type alone cannot + * stop an arbitrary string from reaching application code. + */ +export const BROWSER_DATA_FAILURE_CODES = Object.freeze([ + "ABORTED", + "BLOCKED", + "CONFLICT", + "CORRUPT_DATA", + "EXPIRED_RESOURCE", + "INTEGRITY_FAILED", + "INVALID_INPUT", + "LIMIT_EXCEEDED", + "MIGRATION_FAILED", + "NOT_FOUND", + "NOT_READABLE", + "PERMISSION_DENIED", + "POLICY_REJECTED", + "QUOTA_EXCEEDED", + "STALE_RESULT", + "STORAGE_EVICTED", + "UNAVAILABLE", + "UNSUPPORTED", +] as const); + +export function isBrowserDataFailureCode( + value: unknown, +): value is BrowserDataFailureCode { + return ( + typeof value === "string" && + (BROWSER_DATA_FAILURE_CODES as readonly string[]).includes(value) + ); +} + export type BrowserDataFailureCode = - | "ABORTED" - | "BLOCKED" - | "CONFLICT" - | "CORRUPT_DATA" - | "EXPIRED_RESOURCE" - | "INTEGRITY_FAILED" - | "INVALID_INPUT" - | "LIMIT_EXCEEDED" - | "MIGRATION_FAILED" - | "NOT_FOUND" - | "NOT_READABLE" - | "PERMISSION_DENIED" - | "POLICY_REJECTED" - | "QUOTA_EXCEEDED" - | "STALE_RESULT" - | "STORAGE_EVICTED" - | "UNAVAILABLE" - | "UNSUPPORTED"; + (typeof BROWSER_DATA_FAILURE_CODES)[number]; export type BrowserDataOperation = | "CACHE_ACTIVATE" diff --git a/tests/unit/opfs-byte-store.test.ts b/tests/unit/opfs-byte-store.test.ts index fd84757..d5e29e1 100644 --- a/tests/unit/opfs-byte-store.test.ts +++ b/tests/unit/opfs-byte-store.test.ts @@ -674,7 +674,13 @@ describe("OPFS byte-store coordinator", () => { expect(stored?.physicalSchemaVersion).toBe(1); }); - it("keeps a committed journal row for reconciliation when cleanup fails", async () => { + /** + * STO-RR-01. Finalization runs after the commit fence, so the payload is + * durable and the journal row must survive for reconciliation. What the + * caller must not be told is that the write settled: the previous generation + * and the staging directory are still there. + */ + it("reports a failed finalization instead of a plain success", async () => { const journal = createJournal(); const worker = createWorker({ async finalizePut() { @@ -706,7 +712,8 @@ describe("OPFS byte-store coordinator", () => { source: sourceFrom(new Uint8Array([1])), }); - expect(result.ok).toBe(true); + expect(result.ok).toBe(false); + expect(result.ok ? null : result.error.operation).toBe("OBJECT_WRITE"); expect( journal.transactions.get("transaction_12345678")?.phase, ).toBe("COMMITTED"); diff --git a/tests/unit/opfs-worker-runtime.test.ts b/tests/unit/opfs-worker-runtime.test.ts index 6d46af5..0d44ebe 100644 --- a/tests/unit/opfs-worker-runtime.test.ts +++ b/tests/unit/opfs-worker-runtime.test.ts @@ -764,3 +764,373 @@ describe("OPFS worker client lifecycle", () => { }); }); }); + +/** + * STO-RR-01. A Web Lock is not reentrant. Any path that re-acquires the origin + * mutation lease while already holding it stops making progress forever, and a + * lock the runtime waits on cannot be observed by a fake that hands out an + * unlimited number of leases. + */ +function strictNonReentrantLeases( + counters: { acquires: number; releases: number }, +): OpfsMutationLeaseManager { + let held = false; + return { + async acquire() { + if (held) { + // A second holder waits for the first to release. Nothing here ever + // does, which is exactly what a deadlock looks like. + return await new Promise(() => {}); + } + held = true; + counters.acquires += 1; + let released = false; + return { + release() { + if (released) return; + released = true; + held = false; + counters.releases += 1; + }, + }; + }, + }; +} + +function withTimeout( + operation: Promise, + label: string, + ms = 200, +): Promise { + return Promise.race([ + operation, + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error(`${label} did not settle`)), ms); + }), + ]); +} + +describe("STO-RR-01 OPFS finalization under a non-reentrant lock", () => { + async function completedPut( + runtime: ReturnType, + transactionId: string, + ): Promise { + expect( + await runtime.handleRequest( + beginRequest(`request_begin_${transactionId}`, transactionId, scopeA), + ), + ).toMatchObject({ ok: true }); + expect( + await runtime.handleRequest({ + requestId: `request_append_${transactionId}`, + protocolVersion: OPFS_WORKER_PROTOCOL_VERSION, + kind: "APPEND_CHUNK", + scope: scopeA, + transactionId, + sequence: 0, + bytes: new Uint8Array([9]).buffer, + }), + ).toMatchObject({ ok: true }); + const finished = await runtime.handleRequest({ + requestId: `request_finish_${transactionId}`, + protocolVersion: OPFS_WORKER_PROTOCOL_VERSION, + kind: "FINISH_PUT", + scope: scopeA, + transactionId, + }); + expect(finished).toMatchObject({ ok: true }); + return preparedValue(finished); + } + + it("finalizes a normal PUT with exactly one lock acquisition", async () => { + const root = new MemoryDirectory(); + const counters = { acquires: 0, releases: 0 }; + const runtime = createOpfsWorkerRuntime({ + root: root as unknown as FileSystemDirectoryHandle, + crypto: globalThis.crypto, + policy: runtimePolicy, + leaseManager: strictNonReentrantLeases(counters), + dedicatedWorker: true, + supportsSynchronousAccessHandles: false, + }); + const transactionId = "transaction_final_0001"; + const prepared = await completedPut(runtime, transactionId); + const before = counters.acquires; + + const finalized = await withTimeout( + runtime.handleRequest({ + requestId: "request_finalize_0001", + protocolVersion: OPFS_WORKER_PROTOCOL_VERSION, + kind: "FINALIZE_PUT", + transactionId, + preparedObject: prepared, + }), + "FINALIZE_PUT", + ); + + expect(finalized).toMatchObject({ ok: true }); + expect(counters.acquires - before).toBe(1); + expect(counters.acquires).toBe(counters.releases); + expect( + root.has([ + "authorities", + scopeA.authorityToken, + scopeA.namespaceToken, + scopeA.partitionToken, + "staging", + transactionId, + ]), + ).toBe(false); + }); + + it("leaves the lock free for the next mutation after a finalized PUT", async () => { + const root = new MemoryDirectory(); + const counters = { acquires: 0, releases: 0 }; + const runtime = createOpfsWorkerRuntime({ + root: root as unknown as FileSystemDirectoryHandle, + crypto: globalThis.crypto, + policy: runtimePolicy, + leaseManager: strictNonReentrantLeases(counters), + dedicatedWorker: true, + supportsSynchronousAccessHandles: false, + }); + const first = "transaction_final_0002"; + const prepared = await completedPut(runtime, first); + await withTimeout( + runtime.handleRequest({ + requestId: "request_finalize_0002", + protocolVersion: OPFS_WORKER_PROTOCOL_VERSION, + kind: "FINALIZE_PUT", + transactionId: first, + preparedObject: prepared, + }), + "first FINALIZE_PUT", + ); + + const second = "transaction_final_0003"; + await expect( + withTimeout(completedPut(runtime, second), "second PUT"), + ).resolves.toMatchObject({ descriptor: { objectId: "object_12345678" } }); + expect(counters.acquires).toBe(counters.releases); + }); +}); + +/** + * STO-RR-02. A failure raised while serving a validated request must answer + * that request. Defaulting the response kind to `CAPABILITIES` made the client's + * own expected-kind check reject it as a protocol breach, so a quota or + * integrity failure reached the caller as `UNSUPPORTED`. + */ +describe("STO-RR-02 worker failure responses echo the request kind", () => { + const failingRoot = { + async getDirectoryHandle(): Promise { + throw new DOMException("Out of room", "QuotaExceededError"); + }, + async getFileHandle(): Promise { + throw new DOMException("Out of room", "QuotaExceededError"); + }, + async removeEntry(): Promise { + throw new DOMException("Out of room", "QuotaExceededError"); + }, + async *entries(): AsyncIterableIterator {}, + } as unknown as FileSystemDirectoryHandle; + + it("keeps the validated kind on every failure path", async () => { + const counters = { acquires: 0, releases: 0 }; + const runtime = createOpfsWorkerRuntime({ + root: failingRoot, + crypto: globalThis.crypto, + policy: runtimePolicy, + leaseManager: strictNonReentrantLeases(counters), + dedicatedWorker: true, + supportsSynchronousAccessHandles: false, + }); + + const requests: readonly OpfsWorkerRequest[] = [ + beginRequest("request_kind_begin", "transaction_kind_0001", scopeA), + { + requestId: "request_kind_remove", + protocolVersion: OPFS_WORKER_PROTOCOL_VERSION, + kind: "REMOVE_OBJECT", + scope: scopeA, + objectId: "object_12345678", + generation: 1, + }, + { + requestId: "request_kind_cleanup", + protocolVersion: OPFS_WORKER_PROTOCOL_VERSION, + kind: "CLEANUP_TRANSACTION", + scope: scopeA, + transactionId: "transaction_kind_0001", + }, + { + requestId: "request_kind_orphans", + protocolVersion: OPFS_WORKER_PROTOCOL_VERSION, + kind: "LIST_ORPHAN_CANDIDATES", + scope: scopeA, + olderThanEpochMs: 1, + maxEntries: 1, + }, + ]; + + for (const request of requests) { + const response = await runtime.handleRequest(request); + expect(response).toMatchObject({ + ok: false, + kind: request.kind, + requestId: request.requestId, + protocolVersion: OPFS_WORKER_PROTOCOL_VERSION, + }); + } + }); + + it("still reports a protocol-level failure for an unreadable envelope", async () => { + const counters = { acquires: 0, releases: 0 }; + const runtime = createOpfsWorkerRuntime({ + root: failingRoot, + crypto: globalThis.crypto, + policy: runtimePolicy, + leaseManager: strictNonReentrantLeases(counters), + dedicatedWorker: true, + supportsSynchronousAccessHandles: false, + }); + + expect( + await runtime.handleRequest({ + requestId: "request_kind_broken", + protocolVersion: OPFS_WORKER_PROTOCOL_VERSION, + kind: "NOT_A_KIND", + }), + ).toMatchObject({ ok: false, kind: "CAPABILITIES" }); + }); +}); + +/** + * STO-RR-03. The client decoder is the trust boundary for anything a worker + * says. A `code` that is merely a string lets an arbitrary value escape the + * closed `BrowserDataFailure` taxonomy into application code. + */ +describe("STO-RR-03 worker responses are decoded against closed sets", () => { + function respondingWorker( + reply: (request: OpfsWorkerRequest) => unknown, + ): OpfsWorkerLike { + const listeners = new Set<(event: MessageEvent) => void>(); + return { + postMessage(message: unknown) { + const response = reply(message as OpfsWorkerRequest); + queueMicrotask(() => { + for (const listener of listeners) { + listener({ data: response } as MessageEvent); + } + }); + }, + addEventListener(_type: "message", listener: (event: MessageEvent) => void) { + listeners.add(listener); + }, + removeEventListener(_type: "message", listener: (event: MessageEvent) => void) { + listeners.delete(listener); + }, + } as unknown as OpfsWorkerLike; + } + + const hostileReplies: readonly (readonly [string, (request: OpfsWorkerRequest) => unknown])[] = [ + [ + "unknown failure code", + (request) => ({ + requestId: request.requestId, + protocolVersion: OPFS_WORKER_PROTOCOL_VERSION, + kind: request.kind, + ok: false, + failure: { code: "EVIL", retryable: false }, + }), + ], + [ + "unknown request kind", + (request) => ({ + requestId: request.requestId, + protocolVersion: OPFS_WORKER_PROTOCOL_VERSION, + kind: "NOT_A_KIND", + ok: false, + failure: { code: "UNAVAILABLE", retryable: false }, + }), + ], + [ + "non-boolean retryable", + (request) => ({ + requestId: request.requestId, + protocolVersion: OPFS_WORKER_PROTOCOL_VERSION, + kind: request.kind, + ok: false, + failure: { code: "UNAVAILABLE", retryable: "yes" }, + }), + ], + [ + "inherited failure fields", + (request) => ({ + requestId: request.requestId, + protocolVersion: OPFS_WORKER_PROTOCOL_VERSION, + kind: request.kind, + ok: false, + failure: Object.create({ code: "UNAVAILABLE", retryable: false }) as object, + }), + ], + [ + "throwing getter", + (request) => { + const response: Record = { + requestId: request.requestId, + protocolVersion: OPFS_WORKER_PROTOCOL_VERSION, + ok: false, + failure: { code: "UNAVAILABLE", retryable: false }, + }; + Object.defineProperty(response, "kind", { + enumerable: true, + get: () => { + throw new TypeError("hostile getter"); + }, + }); + return response; + }, + ], + [ + "extra own field", + (request) => ({ + requestId: request.requestId, + protocolVersion: OPFS_WORKER_PROTOCOL_VERSION, + kind: request.kind, + ok: false, + failure: { code: "UNAVAILABLE", retryable: false, injected: 1 }, + }), + ], + ]; + + for (const [label, reply] of hostileReplies) { + it(`closes a ${label} as UNSUPPORTED without rejecting`, async () => { + const gateway = createOpfsWorkerGateway({ + worker: respondingWorker(reply), + policy: runtimePolicy, + createRequestId: () => `request_hostile_${label.replace(/\W/gu, "")}`, + }); + const result = await withTimeout(gateway.capabilities(), label); + expect(result.ok).toBe(false); + expect(result.ok ? null : result.error.code).toBe("UNSUPPORTED"); + }); + } + + it("still admits a well-formed closed failure", async () => { + const gateway = createOpfsWorkerGateway({ + worker: respondingWorker((request) => ({ + requestId: request.requestId, + protocolVersion: OPFS_WORKER_PROTOCOL_VERSION, + kind: request.kind, + ok: false, + failure: { code: "QUOTA_EXCEEDED", retryable: true }, + })), + policy: runtimePolicy, + createRequestId: () => "request_wellformed_1234", + }); + const result = await withTimeout(gateway.capabilities(), "well-formed"); + expect(result.ok).toBe(false); + expect(result.ok ? null : result.error.code).toBe("QUOTA_EXCEEDED"); + }); +}); diff --git a/tests/unit/public-response-cache.test.ts b/tests/unit/public-response-cache.test.ts index 4201535..0578cfb 100644 --- a/tests/unit/public-response-cache.test.ts +++ b/tests/unit/public-response-cache.test.ts @@ -1369,3 +1369,199 @@ describe("public response Cache Storage adapter", () => { expect(await cacheStorage.keys()).toEqual([]); }); }); + +/** + * STO-RR-04 / STO-RR-05. A release cache that is currently serving traffic is + * the last thing a repair may destroy. A transient marker read failure is not + * evidence of damage, and a repair that has not yet fetched anything has not + * yet earned the right to delete what still works. + */ +describe("public response cache repair is failure-atomic", () => { + async function stagedRelease(releaseRegistryId: string) { + const policy = createDefaultPublicCachePolicy( + "https://assets.example.test", + ); + const firstBytes = new Uint8Array([1, 1, 1, 1]); + const secondBytes = new Uint8Array([2, 2, 2, 2]); + const assets: readonly PublicCacheAsset[] = [ + { + absoluteUrl: "https://assets.example.test/first.js", + expectedByteLength: firstBytes.byteLength, + expectedContentType: "application/javascript", + integrity: { + algorithm: "SHA-256", + digestHex: await digestHex(firstBytes), + }, + }, + { + absoluteUrl: "https://assets.example.test/second.js", + expectedByteLength: secondBytes.byteLength, + expectedContentType: "application/javascript", + integrity: { + algorithm: "SHA-256", + digestHex: await digestHex(secondBytes), + }, + }, + ]; + const bodies = new Map([ + [assets[0]!.absoluteUrl, firstBytes], + [assets[1]!.absoluteUrl, secondBytes], + ]); + const cacheStorage = new MemoryCacheStorage(); + const fetchLog: string[] = []; + let failFrom: string | null = null; + const adapter = createPublicResponseCacheAdapter({ + cacheStorage: cacheStorage as unknown as CacheStorage, + crypto: globalThis.crypto, + mutationLock: immediateLock, + policy, + fetcher: async (request: Request) => { + fetchLog.push(request.url); + if (failFrom !== null && request.url === failFrom) { + throw new TypeError("network is down"); + } + const body = bodies.get(request.url); + if (!body) throw new TypeError(`unknown asset ${request.url}`); + return new Response(Uint8Array.from(body), { + headers: { + "cache-control": "public", + "content-type": "application/javascript", + }, + }); + }, + }); + const manifest = await manifestFor(releaseRegistryId, assets, policy); + expect(await adapter.admin.stageRelease(manifest)).toMatchObject({ + ok: true, + }); + expect( + await adapter.admin.activateRelease( + manifest.releaseRegistryId, + manifest.manifestDigestHex, + ), + ).toMatchObject({ ok: true }); + const cacheName = [...cacheStorage.caches.keys()].find((name) => + name.includes(releaseRegistryId), + ); + if (!cacheName) throw new Error("staged cache missing"); + + return { + adapter, + assets, + cacheName, + cacheStorage, + fetchLog, + manifest, + setFailure(url: string | null) { + failFrom = url; + }, + }; + } + + it("does not delete an active candidate when the marker read fails transiently", async () => { + const release = await stagedRelease("transient-marker"); + const cache = release.cacheStorage.caches.get(release.cacheName)!; + const realMatch = cache.match.bind(cache); + let markerReads = 0; + const assetUrls = new Set(release.assets.map((asset) => asset.absoluteUrl)); + cache.match = async (request: RequestInfo | URL) => { + const url = + request instanceof Request ? request.url : String(request); + if (!assetUrls.has(url)) { + markerReads += 1; + throw new DOMException("Storage is busy", "InvalidStateError"); + } + return await realMatch(request); + }; + + const restaged = await release.adapter.admin.stageRelease(release.manifest); + + expect(markerReads).toBeGreaterThan(0); + expect(restaged.ok).toBe(false); + expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true); + cache.match = realMatch; + expect( + await release.adapter.responses.matchActiveExact({ + absoluteUrl: release.assets[0]!.absoluteUrl, + }), + ).toMatchObject({ ok: true }); + }); + + it("keeps every healthy asset when one repair fetch fails", async () => { + const release = await stagedRelease("partial-repair"); + const cache = release.cacheStorage.caches.get(release.cacheName)!; + // Corrupt only the first asset's stored bytes. + const corrupted = cache.responses.findIndex( + (entry) => entry.request.url === release.assets[0]!.absoluteUrl, + ); + expect(corrupted).toBeGreaterThanOrEqual(0); + cache.responses.splice(corrupted, 1); + + release.setFailure(release.assets[0]!.absoluteUrl); + const restaged = await release.adapter.admin.stageRelease(release.manifest); + expect(restaged.ok).toBe(false); + + // The cache still exists and the healthy asset is still served. + expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true); + expect( + await release.adapter.responses.matchActiveExact({ + absoluteUrl: release.assets[1]!.absoluteUrl, + }), + ).toMatchObject({ ok: true }); + }); + + it("still removes a candidate this call created when staging fails", async () => { + const policy = createDefaultPublicCachePolicy( + "https://assets.example.test", + ); + const bytes = new Uint8Array([7, 7, 7, 7]); + const asset: PublicCacheAsset = { + absoluteUrl: "https://assets.example.test/fresh.js", + expectedByteLength: bytes.byteLength, + expectedContentType: "application/javascript", + integrity: { + algorithm: "SHA-256", + digestHex: await digestHex(bytes), + }, + }; + const cacheStorage = new MemoryCacheStorage(); + const adapter = createPublicResponseCacheAdapter({ + cacheStorage: cacheStorage as unknown as CacheStorage, + crypto: globalThis.crypto, + mutationLock: immediateLock, + policy, + fetcher: async () => { + throw new TypeError("network is down"); + }, + }); + const manifest = await manifestFor("fresh-release", [asset], policy); + + expect(await adapter.admin.stageRelease(manifest)).toMatchObject({ + ok: false, + }); + expect(await cacheStorage.keys()).toEqual([]); + }); + + it("repairs an evicted asset in place and keeps the release usable", async () => { + const release = await stagedRelease("in-place-repair"); + const cache = release.cacheStorage.caches.get(release.cacheName)!; + const evicted = cache.responses.findIndex( + (entry) => entry.request.url === release.assets[1]!.absoluteUrl, + ); + cache.responses.splice(evicted, 1); + + expect( + await release.adapter.admin.stageRelease(release.manifest), + ).toMatchObject({ ok: true }); + expect( + await release.adapter.responses.matchActiveExact({ + absoluteUrl: release.assets[1]!.absoluteUrl, + }), + ).toMatchObject({ ok: true }); + expect( + await release.adapter.responses.matchActiveExact({ + absoluteUrl: release.assets[0]!.absoluteUrl, + }), + ).toMatchObject({ ok: true }); + }); +});