fix: make OPFS finalization and public cache repair failure-atomic

STO-RR-01. finalizePut re-acquired the origin mutation lease it was already
holding. A Web Lock is not reentrant, so an ordinary PUT stopped for good at
FINALIZE; it now calls the locked cleanup directly. A strict non-reentrant fake
lease manager pins one acquire and one release per finalization. The adapter no
longer reports a failed finalization as a plain write success either: the
journal row stays COMMITTED for reconciliation, but the caller is told the
write did not settle.

STO-RR-02. A failure raised while serving a validated request now carries that
request's kind. Defaulting every catch to CAPABILITIES made the client's own
expected-kind check reject genuine quota, integrity and abort failures as
protocol breaches and report them as UNSUPPORTED. Only an envelope the runtime
could not read still answers at protocol level.

STO-RR-03. The worker client decodes a response instead of adopting it: exact
own-data descriptors, the negotiated protocol version, the exact awaited kind,
a code inside the closed BrowserDataFailure set and a boolean retryable. An
accessor, a proxy trap, an inherited or extra field and an unknown code all
close the call as UNSUPPORTED rather than leaving it to time out.

STO-RR-04. A marker read that fails transiently is unknown, not damaged, so it
no longer deletes the candidate that may be serving traffic. Only a confirmed
corrupt or missing marker enters the repair path.

STO-RR-05. Staging never deletes a candidate it did not create. A repair
replaces exact entries in place, so a failed fetch leaves every healthy asset
and the active release usable; a candidate this call created is still removed
on failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-14 14:00:41 +09:00
co-authored by Claude Opus 5
parent ca210d3bc5
commit 6a8281a941
8 changed files with 785 additions and 76 deletions
@@ -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;
}
},
@@ -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",
+120 -41
View File
@@ -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<unknown>): 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<string> = new Set([
"requestId",
"protocolVersion",
"kind",
"ok",
"value",
"failure",
]);
const WORKER_FAILURE_KEYS: ReadonlySet<string> = 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<string>,
): 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;
}
@@ -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,
@@ -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"
+9 -2
View File
@@ -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");
+370
View File
@@ -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<never>(() => {});
}
held = true;
counters.acquires += 1;
let released = false;
return {
release() {
if (released) return;
released = true;
held = false;
counters.releases += 1;
},
};
},
};
}
function withTimeout<Value>(
operation: Promise<Value>,
label: string,
ms = 200,
): Promise<Value> {
return Promise.race([
operation,
new Promise<never>((_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<typeof createOpfsWorkerRuntime>,
transactionId: string,
): Promise<OpfsPreparedObject> {
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<FileSystemDirectoryHandle> {
throw new DOMException("Out of room", "QuotaExceededError");
},
async getFileHandle(): Promise<FileSystemFileHandle> {
throw new DOMException("Out of room", "QuotaExceededError");
},
async removeEntry(): Promise<void> {
throw new DOMException("Out of room", "QuotaExceededError");
},
async *entries(): AsyncIterableIterator<never> {},
} 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<unknown>) => void>();
return {
postMessage(message: unknown) {
const response = reply(message as OpfsWorkerRequest);
queueMicrotask(() => {
for (const listener of listeners) {
listener({ data: response } as MessageEvent<unknown>);
}
});
},
addEventListener(_type: "message", listener: (event: MessageEvent<unknown>) => void) {
listeners.add(listener);
},
removeEventListener(_type: "message", listener: (event: MessageEvent<unknown>) => 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<string, unknown> = {
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");
});
});
+196
View File
@@ -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<string, Uint8Array>([
[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 });
});
});