fix: report OPFS completion honestly and bound public cache staging

A durable write whose journal transaction could not be completed returned
plain success with `SUCCEEDED` telemetry. The payload was committed but
the transaction stayed `COMMITTED`, so the reconcile backlog and its quota
pressure grew while every caller was told the write had settled. That is
now a `RECONCILE` failure with the effect certainty preserved, and an
unfinished delete is observed `DEGRADED` rather than clean.

The worker seam lost causes in both directions. A bootstrap failure
answered every request with kind `CAPABILITIES`, so the gateway read a
kind mismatch and replaced the real `BLOCKED` or `QUOTA_EXCEEDED` with a
generic `UNSUPPORTED`; the envelope's correlation is now captured once at
the listener. On the client, the pending row and its timer were released
before the reply was decoded, so a trap that threw inside the decoder left
the public promise pending with nothing left to time it out, and a
throwing `requestId` getter produced a timeout instead of a prompt
protocol failure.

Public cache staging handed its signal to each `Request` and called that
ownership. A fetch that ignored it held the mutation lock forever, and a
digest that finished after the abort still wrote both the asset and the
activation marker — publishing a release nobody was waiting for. One
terminal owner now covers the whole staging body and every await
re-checks it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-15 01:25:26 +09:00
co-authored by Claude Opus 5
parent df18349682
commit 632b230c82
8 changed files with 692 additions and 42 deletions
+220
View File
@@ -22,7 +22,9 @@ import type {
} from "../../src/adapters/storage/opfs/opfs-worker-protocol.ts";
import {
createOpfsWorkerRuntime,
startBrowserOpfsDedicatedWorker,
type OpfsMutationLeaseManager,
type OpfsWorkerMessageHost,
} from "../../src/adapters/storage/opfs/opfs-worker-runtime.ts";
const scopeA: OpfsStorageScope = Object.freeze({
@@ -1117,6 +1119,144 @@ describe("STO-RR-03 worker responses are decoded against closed sets", () => {
});
}
/**
* NS-06. Correlation was read separately and the pending row and its timer
* were removed before the reply was decoded. A trap that threw inside the
* decoder therefore left the public promise pending forever, and a throwing
* `requestId` getter produced a timeout instead of a prompt protocol failure.
*/
const uncorrelatableReplies: readonly (readonly [
string,
(request: OpfsWorkerRequest) => unknown,
])[] = [
[
"throwing requestId getter",
(request) =>
Object.defineProperty(
{
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
kind: request.kind,
ok: true,
value: {},
},
"requestId",
{
enumerable: true,
get: () => {
throw new TypeError("hostile requestId getter");
},
},
),
],
[
"throwing ownKeys trap",
(request) =>
new Proxy(
{
requestId: request.requestId,
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
kind: request.kind,
ok: true,
value: {},
},
{
ownKeys() {
throw new TypeError("hostile ownKeys trap");
},
},
),
],
[
"descriptor trap that throws after correlation",
(request) => {
let reads = 0;
return new Proxy(
{
requestId: request.requestId,
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
kind: request.kind,
ok: true,
value: {},
},
{
getOwnPropertyDescriptor(target, key) {
reads += 1;
if (reads > 1) {
throw new TypeError("stateful descriptor trap");
}
return Reflect.getOwnPropertyDescriptor(target, key);
},
},
);
},
],
[
"symbol-keyed field",
(request) => ({
requestId: request.requestId,
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
kind: request.kind,
ok: true,
value: {},
[Symbol.for("injected")]: true,
}),
],
[
"non-enumerable own field",
(request) =>
Object.defineProperty(
{
requestId: request.requestId,
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
kind: request.kind,
ok: true,
value: {},
},
"injected",
{ enumerable: false, value: true },
),
],
];
for (const [label, reply] of uncorrelatableReplies) {
it(`closes a ${label} promptly as UNSUPPORTED`, async () => {
const gateway = createOpfsWorkerGateway({
worker: respondingWorker(reply),
policy: { ...runtimePolicy, rpcTimeoutMs: 60_000 },
createRequestId: () => `request_uncorr_${label.replace(/\W/gu, "")}`,
});
// The RPC timeout is far beyond the test budget, so a pass here means the
// reply itself closed the request rather than the timer.
const result = await withTimeout(gateway.capabilities(), label);
expect(result.ok).toBe(false);
expect(result.ok ? null : result.error.code).toBe("UNSUPPORTED");
});
}
it("keeps serving requests after a malformed reply", async () => {
let replies = 0;
const gateway = createOpfsWorkerGateway({
worker: respondingWorker((request) => {
replies += 1;
if (replies === 1) return { requestId: request.requestId };
return {
requestId: request.requestId,
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
kind: request.kind,
ok: false,
failure: { code: "QUOTA_EXCEEDED", retryable: true },
};
}),
policy: { ...runtimePolicy, rpcTimeoutMs: 60_000 },
createRequestId: () => `request_sequence_${replies}`,
});
const first = await withTimeout(gateway.capabilities(), "first");
expect(first.ok ? null : first.error.code).toBe("UNSUPPORTED");
const second = await withTimeout(gateway.capabilities(), "second");
expect(second.ok ? null : second.error.code).toBe("QUOTA_EXCEEDED");
});
it("still admits a well-formed closed failure", async () => {
const gateway = createOpfsWorkerGateway({
worker: respondingWorker((request) => ({
@@ -1134,3 +1274,83 @@ describe("STO-RR-03 worker responses are decoded against closed sets", () => {
expect(result.ok ? null : result.error.code).toBe("QUOTA_EXCEEDED");
});
});
/**
* NS-05. When the runtime never came up, the message host answered with a
* default `CAPABILITIES` kind. The gateway saw that as an expected-kind
* mismatch and replaced the real cause — `BLOCKED`, `QUOTA_EXCEEDED` — with a
* generic `UNSUPPORTED` protocol breach, so the outage was misreported.
*/
describe("NS-05 a bootstrap failure answers the request it belongs to", () => {
function hostFor(): Readonly<{
host: OpfsWorkerMessageHost;
posted: OpfsWorkerResponse[];
deliver(message: unknown): void;
}> {
const listeners: ((event: MessageEvent<unknown>) => void)[] = [];
const posted: OpfsWorkerResponse[] = [];
return {
host: {
addEventListener(_type, listener) {
listeners.push(listener);
},
postMessage(message) {
posted.push(message);
},
},
posted,
deliver(message: unknown) {
for (const listener of listeners) {
listener({ data: message } as MessageEvent<unknown>);
}
},
};
}
const requestKinds = [
"CAPABILITIES",
"BEGIN_PUT",
"APPEND_CHUNK",
"FINISH_PUT",
"ABORT_PUT",
"VERIFY_OBJECT",
"READ_CHUNK",
"REMOVE_OBJECT",
"CLEANUP_TRANSACTION",
"FINALIZE_PUT",
"LIST_ORPHAN_CANDIDATES",
"DELETE_ORPHAN_CHUNK",
] as const;
for (const kind of requestKinds) {
it(`preserves the ${kind} correlation when bootstrap fails`, async () => {
const { host, posted, deliver } = hostFor();
const start = startBrowserOpfsDedicatedWorker(host, {
storageManager: {
getDirectory: () =>
Promise.reject(
new DOMException("blocked", "SecurityError"),
),
} as unknown as StorageManager,
crypto: globalThis.crypto,
});
// The bootstrap rejection must not escape the worker entry point either.
await expect(start).rejects.toBeInstanceOf(Error);
deliver({
requestId: "request_bootstrap_failure",
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
kind,
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(posted).toHaveLength(1);
expect(posted[0]).toMatchObject({
requestId: "request_bootstrap_failure",
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
kind,
ok: false,
});
});
}
});