fix: bound the activation marker in bytes and give a click one observer

The marker read added a whole chunk to a running total and compared the
total afterwards, so a corrupt body could hand activation a 1 MiB chunk
against a 257-byte ceiling. It now reads at most the remaining allowance —
through a BYOB reader where the source offers one, and by refusing an
oversized chunk before copying it otherwise. A declared oversize cancels
the body it refuses instead of leaving the stream open, and the reader
lock is released on every path.

The build generator and the runtime decoder shared only the extension
table, not the path grammar. The generator happily emitted
`/assets/bad@name-abcdefgh.js`, which the decoder then refused — a correct
build failing at install time. Both now use one exported canonical path
predicate and the generator decodes its own output before returning it.

The notification click handler emitted its terminal record from inside
`process` and again from the `waitUntil` wrapper, so an ordinary click was
counted twice. Worse, a late rejection downgraded `MAYBE_APPLIED` to
`NOT_APPLIED` — telling operators the click had definitely not been
applied when nobody knew that — and the late observation ran outside
`waitUntil`, so a worker shutdown lost the evidence. There is one
observation authority per click now, certainty is monotone, only an
explicit null window confirms `NOT_APPLIED`, and the bounded tail is owned
by `waitUntil` without extending the public deadline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-15 01:26:14 +09:00
co-authored by Claude Opus 5
parent 39a4a973a8
commit d7b35cfca3
7 changed files with 668 additions and 86 deletions
+138
View File
@@ -916,3 +916,141 @@ describe("service worker static asset install", () => {
await controller.stop();
});
});
/**
* SW-01. The marker read must be bounded in bytes, not only in logic. Adding a
* whole chunk and *then* comparing the running total meant a corrupt body could
* hand activation a 1 MiB chunk against a 257-byte ceiling, and the reader lock
* was never released.
*/
describe("SW-01 the activation marker read is bounded in bytes", () => {
function scopeWithMarkerBody(
body: ReadableStream<Uint8Array> | null,
headers: Readonly<Record<string, string>> = {},
) {
const markerUrl = "__service-worker-activation-v1__";
const response = body
? new Response(body, { status: 200, headers })
: new Response("", { status: 200, headers });
const cache = {
match: vi.fn(async (request: RequestInfo | URL) =>
String(request).includes(markerUrl) ? response : undefined,
),
put: vi.fn(async () => {}),
delete: vi.fn(async () => true),
} as unknown as Cache;
return {
response,
scope: {
caches: {
open: vi.fn(async () => cache),
// The current static cache must exist for its marker to be read.
keys: vi.fn(async () => [
staticCacheName(identity.staticAssetSetDigest),
]),
delete: vi.fn(async () => true),
match: vi.fn(),
},
clients: { matchAll: vi.fn(async () => []) },
skipWaiting: vi.fn(async () => {}),
fetcher: vi.fn(),
digest: vi.fn(),
},
};
}
const runtimeFor = (scope: unknown) =>
createServiceWorkerRuntime(scope as never, {
identity,
handlers: ["PWA_STATIC_ASSETS"],
manifest: {
schemaVersion: 1,
buildId: identity.buildId,
releaseId: identity.releaseId,
setDigest: identity.staticAssetSetDigest as `sha256:${string}`,
assets: [],
},
runtimeConfigUrl: "/runtime-config.json",
releaseManifestUrl: "/release-manifest.json",
});
it("never retains a single chunk larger than the marker ceiling", async () => {
let delivered = 0;
let cancels = 0;
const oversized = new ReadableStream<Uint8Array>({
pull(controller) {
delivered += 1;
controller.enqueue(new Uint8Array(1_048_576));
},
cancel() {
cancels += 1;
},
});
const fixture = scopeWithMarkerBody(oversized);
// Activation still completes; the marker is simply not admitted.
await expect(runtimeFor(fixture.scope).onActivate()).resolves.toBeTypeOf(
"number",
);
// Every oversized chunk that arrived was refused before being retained,
// and the reader that saw it was cancelled. The marker is probed once per
// candidate cache, so the counts track each other rather than a constant.
expect(cancels).toBeGreaterThanOrEqual(1);
expect(delivered).toBeLessThanOrEqual(2);
expect(fixture.response.body?.locked).toBe(false);
});
it("bounds a stream that never produces a chunk", async () => {
let cancels = 0;
const stalled = new ReadableStream<Uint8Array>({
pull() {
return new Promise<void>(() => {});
},
cancel() {
cancels += 1;
},
});
const fixture = scopeWithMarkerBody(stalled);
await expect(
runtimeFor(fixture.scope).onActivate(),
).resolves.toBeTypeOf("number");
expect(cancels).toBeGreaterThanOrEqual(1);
}, 10_000);
it("cancels the body it refuses for a declared oversize", async () => {
let cancels = 0;
const declared = new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(new Uint8Array(8));
},
cancel() {
cancels += 1;
},
});
const fixture = scopeWithMarkerBody(declared, {
"content-length": "1048576",
});
await expect(
runtimeFor(fixture.scope).onActivate(),
).resolves.toBeTypeOf("number");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(cancels).toBeGreaterThanOrEqual(1);
});
it("refuses a marker body that is not valid UTF-8", async () => {
const invalid = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array([0xff, 0xfe, 0xfd]));
controller.close();
},
});
const fixture = scopeWithMarkerBody(invalid);
await expect(
runtimeFor(fixture.scope).onActivate(),
).resolves.toBeTypeOf("number");
expect(fixture.response.body?.locked).toBe(false);
});
});