fix: bound Service Worker marker reads and attribute native effects
SW-RR-01. The activation marker was read with response.text() whenever no Content-Length was present, so a large or non-terminating body could consume the whole activation step. It now reads through a bounded reader that stops one byte past the ceiling, cancels its reader, applies a read deadline and decodes UTF-8 fatally. SW-RR-02. A matching nonce is not identity. An activation or reset result whose event.source is null can no longer stand in for the expected worker; only a strict identity match is admitted. SW-RR-03. The build generator and the shared manifest decoder now read one exported extension table, so .mjs and .png stop being emitted-then-refused. .json is deliberately outside it: every JSON file in a build output is a control document the generator already excludes, not a cacheable asset. SW-RR-04. Both caches.open and cache.match are closed as a miss. Letting a match rejection propagate rejected respondWith itself, so the entry never reached its network fallback. WP-RR-01. focus and openWindow now carry the certainty phase showNotification already had — NOT_APPLIED, MAYBE_APPLIED, CONFIRMED — and an effect that lands after the handler deadline is observed exactly once. The evidence never authorizes a retry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bd90e0c983
commit
efc577de63
@@ -224,13 +224,18 @@ export function createServiceWorkerRuntime(
|
||||
? staticCacheName(config.manifest.setDigest)
|
||||
: null;
|
||||
if (!currentCacheName) return null;
|
||||
// SW-RR-04. Both `open` and `match` are storage calls that can throw
|
||||
// synchronously or reject. Either one escaping here rejects `respondWith`
|
||||
// itself, so the entry never reaches its network fallback and the page
|
||||
// gets a network error instead of the live response.
|
||||
let cached: Response | undefined;
|
||||
let currentCache: Cache;
|
||||
try {
|
||||
currentCache = await scope.caches.open(currentCacheName);
|
||||
cached = await currentCache.match(request.url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const cached = await currentCache.match(request.url);
|
||||
if (!cached) return null;
|
||||
if (cached.status !== 200 || cached.type === "opaque") {
|
||||
// §18.6. An invalid hit is deleted from the cache it was read from and
|
||||
@@ -474,6 +479,69 @@ function isClientWithinRegistrationScope(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SW-RR-01. Reads at most one byte beyond the marker ceiling, cancels the
|
||||
* reader as soon as that byte arrives, and fails closed on invalid UTF-8. The
|
||||
* reader is also raced against a deadline so a stream that never produces a
|
||||
* chunk cannot hold `activate` open.
|
||||
*/
|
||||
const ACTIVATION_MARKER_READ_DEADLINE_MS = 5_000;
|
||||
|
||||
async function readBoundedMarkerText(
|
||||
response: Response,
|
||||
): Promise<string | null> {
|
||||
if (!response.body) {
|
||||
try {
|
||||
const text = await response.text();
|
||||
return new TextEncoder().encode(text).byteLength >
|
||||
ACTIVATION_MARKER_MAX_BYTES
|
||||
? null
|
||||
: text;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<"DEADLINE">((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => resolve("DEADLINE"),
|
||||
ACTIVATION_MARKER_READ_DEADLINE_MS,
|
||||
);
|
||||
});
|
||||
try {
|
||||
for (;;) {
|
||||
const next = await Promise.race([reader.read(), deadline]);
|
||||
if (next === "DEADLINE") return null;
|
||||
if (next.done) break;
|
||||
if (!next.value) continue;
|
||||
total += next.value.byteLength;
|
||||
if (total > ACTIVATION_MARKER_MAX_BYTES) return null;
|
||||
chunks.push(next.value);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
// Never awaited: cancelling a stream whose source ignores cancellation can
|
||||
// itself hang, and the marker read already has its answer.
|
||||
void reader.cancel().catch(() => {});
|
||||
}
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function readActivationMarker(
|
||||
cache: Cache,
|
||||
expectedCacheName: string,
|
||||
@@ -489,10 +557,11 @@ async function readActivationMarker(
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (new TextEncoder().encode(text).byteLength > ACTIVATION_MARKER_MAX_BYTES) {
|
||||
return null;
|
||||
}
|
||||
// SW-RR-01. A Content-Length is a claim, not a bound. Without one the
|
||||
// previous `response.text()` read the whole body, so a large or
|
||||
// non-terminating stream could consume the activation step indefinitely.
|
||||
const text = await readBoundedMarkerText(response);
|
||||
if (text === null) return null;
|
||||
const value: unknown = JSON.parse(text);
|
||||
if (
|
||||
value === null ||
|
||||
|
||||
@@ -323,10 +323,11 @@ export function createServiceWorkerPageController(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// SW-06. The reply must come from the exact worker this request was
|
||||
// sent to. A source swap ends the request immediately as a protocol
|
||||
// mismatch rather than waiting for the drain timeout.
|
||||
if (event.source !== null && event.source !== waiting) {
|
||||
// SW-06 / SW-RR-02. The reply must come from the exact worker this
|
||||
// request was sent to. A matching nonce is not identity: `null`
|
||||
// source means the sender cannot be established, so it is refused
|
||||
// like any other mismatch rather than accepted as this worker.
|
||||
if (event.source !== waiting) {
|
||||
finish(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const }));
|
||||
return;
|
||||
}
|
||||
@@ -414,8 +415,10 @@ export function createServiceWorkerPageController(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// SW-RR-02. An unattributable reset result is never proof this
|
||||
// controller performed the reset.
|
||||
if (
|
||||
(event.source !== null && event.source !== requestedController) ||
|
||||
event.source !== requestedController ||
|
||||
container.controller !== requestedController
|
||||
) {
|
||||
finish(
|
||||
|
||||
Reference in New Issue
Block a user