fix: harden Service Worker activation and install lifecycle
SW-06: correlate activation, reset and drain replies by source object identity against the captured waiting worker or controller, so an arbitrary same-origin source cannot close this page's admission, and end a request immediately as PROTOCOL_MISMATCH when the source is swapped instead of waiting for the drain timeout. requestActivation() and resetOwnedCaches() are single-flight, so ten concurrent callers share one nonce, listener and postMessage. SW-07: an empty in-scope client set is vacuously drained rather than rejecting a waiting worker when the requester already closed. SW-08: isolate per-client postMessage failures. A client that cannot receive the drain request fails immediately instead of holding pending state to the timeout, skipWaiting() is the activation commit and its failure is a rejection, and the accepted and reload notifications are sent afterwards as best effort. SW-09: fence late install work. A fenced worker starts no new candidate work, a late response body from a non-cooperative fetch is cancelled, a throwing digest maps to a closed outcome, and a second exact delete of the owned candidate cache is registered once the abandoned install settles - without extending the public 60s bound. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
db52f02d73
commit
976c8a8da4
@@ -277,49 +277,64 @@ export function createServiceWorkerRuntime(
|
||||
parsed.message.sourceBuildId,
|
||||
);
|
||||
if (!drained) {
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "ACTIVATE_REJECTED",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
}
|
||||
notifyClients(clients, "ACTIVATE_REJECTED", parsed.message.sourceBuildId, nonce);
|
||||
return "REJECTED";
|
||||
}
|
||||
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "ACTIVATE_ACCEPTED",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await scope.skipWaiting();
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "ACTIVATED_RELOAD_REQUIRED",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
// SW-08. `skipWaiting()` is the activation commit. It must succeed before
|
||||
// any client is told the activation was accepted, and its failure is a
|
||||
// rejection rather than an accepted-then-failed activation.
|
||||
try {
|
||||
await scope.skipWaiting();
|
||||
} catch {
|
||||
notifyClients(clients, "ACTIVATE_REJECTED", parsed.message.sourceBuildId, nonce);
|
||||
return "REJECTED";
|
||||
}
|
||||
// Post-commit notifications are per-client best effort.
|
||||
notifyClients(clients, "ACTIVATE_ACCEPTED", parsed.message.sourceBuildId, nonce);
|
||||
notifyClients(
|
||||
clients,
|
||||
"ACTIVATED_RELOAD_REQUIRED",
|
||||
parsed.message.sourceBuildId,
|
||||
nonce,
|
||||
);
|
||||
return "ACCEPTED";
|
||||
}
|
||||
|
||||
/**
|
||||
* SW-08. One client's `postMessage()` throwing must not break the whole
|
||||
* activation event; delivery is isolated per client.
|
||||
*/
|
||||
function notifyClients(
|
||||
clients: readonly WorkerClientLike[],
|
||||
kind: "ACTIVATE_REJECTED" | "ACTIVATE_ACCEPTED" | "ACTIVATED_RELOAD_REQUIRED",
|
||||
targetBuildId: string,
|
||||
nonce: string,
|
||||
): void {
|
||||
for (const client of clients) {
|
||||
try {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind,
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// A dead client cannot change the already committed activation.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function drainClients(
|
||||
clients: readonly WorkerClientLike[],
|
||||
nonce: string,
|
||||
requesterBuildId: string,
|
||||
): Promise<boolean> {
|
||||
if (clients.length === 0) return false;
|
||||
// SW-07. No in-scope client means nothing dirty to drain, so the set is
|
||||
// vacuously drained. A `clients.matchAll()` failure still rejects upstream.
|
||||
if (clients.length === 0) return true;
|
||||
const drained = new Promise<boolean>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingActivations.delete(nonce);
|
||||
@@ -336,15 +351,24 @@ export function createServiceWorkerRuntime(
|
||||
}),
|
||||
);
|
||||
});
|
||||
// SW-08. A client that cannot receive the drain request can never
|
||||
// acknowledge it, so it fails immediately instead of holding the pending
|
||||
// state until the timeout.
|
||||
for (const client of clients) {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CLIENT_DRAIN_REQUEST",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: requesterBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
client.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CLIENT_DRAIN_REQUEST",
|
||||
sourceBuildId: config.identity.buildId,
|
||||
targetBuildId: requesterBuildId,
|
||||
nonce,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
const pending = pendingActivations.get(nonce);
|
||||
if (pending) settlePendingActivation(nonce, pending, false);
|
||||
return await drained;
|
||||
}
|
||||
}
|
||||
return drained;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@ export function createServiceWorkerPageController(
|
||||
let registration: ServiceWorkerRegistration | null = null;
|
||||
let messageListener: ((event: MessageEvent) => void) | null = null;
|
||||
let updateTimer: ReturnType<typeof setInterval> | null = null;
|
||||
/** SW-06. Single-flight command state. */
|
||||
let activationInFlight: Promise<ServiceWorkerActivationOutcome> | null = null;
|
||||
let resetInFlight: Promise<ServiceWorkerResetOutcome> | null = null;
|
||||
let stopped = false;
|
||||
const pendingStops = new Set<() => void>();
|
||||
|
||||
@@ -217,6 +220,13 @@ export function createServiceWorkerPageController(
|
||||
observe("client_drain", "MALFORMED");
|
||||
return;
|
||||
}
|
||||
// SW-06. An arbitrary same-origin source must not be able to close this
|
||||
// page's admission. The request has to come from the worker we are
|
||||
// actually waiting on or the one currently controlling us.
|
||||
if (!isExpectedWorkerSource(source)) {
|
||||
observe("client_drain", "SOURCE_MISMATCH");
|
||||
return;
|
||||
}
|
||||
const rejected = isBlocked();
|
||||
source.postMessage(
|
||||
createServiceWorkerMessage({
|
||||
@@ -234,6 +244,23 @@ export function createServiceWorkerPageController(
|
||||
container.addEventListener("message", messageListener);
|
||||
}
|
||||
|
||||
/**
|
||||
* SW-06. Source identity is checked by object identity against the
|
||||
* registration's waiting/installing/active worker and the container's
|
||||
* controller. An empty `event.origin` is never used as a trust signal.
|
||||
*/
|
||||
function isExpectedWorkerSource(source: unknown): boolean {
|
||||
const expected = [
|
||||
registration?.waiting,
|
||||
registration?.installing,
|
||||
registration?.active,
|
||||
dependencies.container?.controller,
|
||||
];
|
||||
return expected.some(
|
||||
(candidate) => candidate != null && candidate === source,
|
||||
);
|
||||
}
|
||||
|
||||
function scheduleUpdateChecks(): void {
|
||||
// §17.14. At most one check per 6 hours, and none while the page is hidden.
|
||||
if (updateTimer) return;
|
||||
@@ -251,7 +278,16 @@ export function createServiceWorkerPageController(
|
||||
* §17.11. Activation is a handshake: every controlled client must close new
|
||||
* admission and acknowledge within 30s. One missing client rejects it.
|
||||
*/
|
||||
async function requestActivation(): Promise<ServiceWorkerActivationOutcome> {
|
||||
function requestActivation(): Promise<ServiceWorkerActivationOutcome> {
|
||||
// SW-06. Concurrent callers share one command: a second call must not issue
|
||||
// a second nonce, a second listener or a second postMessage.
|
||||
activationInFlight ??= runActivation().finally(() => {
|
||||
activationInFlight = null;
|
||||
});
|
||||
return activationInFlight;
|
||||
}
|
||||
|
||||
async function runActivation(): Promise<ServiceWorkerActivationOutcome> {
|
||||
const waiting = registration?.waiting;
|
||||
if (!waiting) return Object.freeze({ kind: "NO_WAITING_WORKER" as const });
|
||||
if (isBlocked()) {
|
||||
@@ -287,6 +323,17 @@ 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) {
|
||||
finish(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const }));
|
||||
return;
|
||||
}
|
||||
if (registration?.waiting !== waiting) {
|
||||
finish(Object.freeze({ kind: "PROTOCOL_MISMATCH" as const }));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
parsed.message.kind === "ACTIVATE_REJECTED" &&
|
||||
parsed.message.nonce === nonce
|
||||
@@ -329,11 +376,21 @@ export function createServiceWorkerPageController(
|
||||
}
|
||||
|
||||
/** §18.10. Static caches only; the registration itself is left in place. */
|
||||
async function resetOwnedCaches(): Promise<ServiceWorkerResetOutcome> {
|
||||
function resetOwnedCaches(): Promise<ServiceWorkerResetOutcome> {
|
||||
// SW-06. Single-flight, like activation.
|
||||
resetInFlight ??= runReset().finally(() => {
|
||||
resetInFlight = null;
|
||||
});
|
||||
return resetInFlight;
|
||||
}
|
||||
|
||||
async function runReset(): Promise<ServiceWorkerResetOutcome> {
|
||||
const container = dependencies.container;
|
||||
if (!container?.controller) {
|
||||
return Object.freeze({ kind: "NOT_CONTROLLED" as const });
|
||||
}
|
||||
// SW-06. The reply must come from the controller this request was sent to.
|
||||
const requestedController = container.controller;
|
||||
const nonce = nonces.issue();
|
||||
return new Promise<ServiceWorkerResetOutcome>((resolve) => {
|
||||
let settled = false;
|
||||
@@ -357,6 +414,18 @@ export function createServiceWorkerPageController(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(event.source !== null && event.source !== requestedController) ||
|
||||
container.controller !== requestedController
|
||||
) {
|
||||
finish(
|
||||
Object.freeze({
|
||||
kind: "FAILED" as const,
|
||||
code: "PROTOCOL_MISMATCH",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
finish(
|
||||
Object.freeze({
|
||||
kind: "RESET" as const,
|
||||
|
||||
@@ -149,6 +149,19 @@ export async function installStaticAssets(
|
||||
|
||||
if (outcome.kind === "REJECTED") {
|
||||
await dependencies.caches.delete(cacheName).catch(() => false);
|
||||
// SW-09. A non-cooperative fetch, digest or `cache.put` started before the
|
||||
// deadline cannot be cancelled, so it may recreate the candidate cache
|
||||
// after that delete. The public result already closed at the deadline; a
|
||||
// second exact delete is registered once the abandoned work settles. It is
|
||||
// deliberately not awaited, so the public bound is not extended.
|
||||
if (deadlineExceeded) {
|
||||
void installation
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await dependencies.caches.delete(cacheName).catch(() => false);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
@@ -173,6 +186,11 @@ async function installCandidate(
|
||||
const worker = async (): Promise<void> => {
|
||||
for (;;) {
|
||||
if (failure) return;
|
||||
// SW-09. Once fenced, no new candidate work is started.
|
||||
if (signal.aborted) {
|
||||
failure ??= rejected("INSTALL_DEADLINE_EXCEEDED");
|
||||
return;
|
||||
}
|
||||
const asset = queue.shift();
|
||||
if (!asset) return;
|
||||
const outcome = await storeAsset(asset, cache, dependencies, signal);
|
||||
@@ -205,15 +223,22 @@ async function storeAsset(
|
||||
if (signal.aborted) return rejected("FETCH_FAILED");
|
||||
let response: Response;
|
||||
try {
|
||||
const fetched = await abortable(
|
||||
dependencies.fetcher(asset.url, {
|
||||
cache: "no-store",
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
signal,
|
||||
}),
|
||||
// SW-09. A non-cooperative fetch that ignores the signal still settles
|
||||
// later; its body is compensated so an abandoned response is not left open.
|
||||
const pending = dependencies.fetcher(asset.url, {
|
||||
cache: "no-store",
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
signal,
|
||||
);
|
||||
});
|
||||
const fetched = await abortable(pending, signal);
|
||||
if (fetched === ABORTED) {
|
||||
void pending
|
||||
.then(async (late) => {
|
||||
await late.body?.cancel();
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
if (fetched === ABORTED) return rejected("FETCH_FAILED");
|
||||
response = fetched;
|
||||
} catch {
|
||||
@@ -234,7 +259,17 @@ async function storeAsset(
|
||||
if (!body.ok) return rejected(body.code);
|
||||
const bytes = body.bytes;
|
||||
|
||||
const digest = await abortable(dependencies.digest(bytes), signal);
|
||||
// SW-09. A digest dependency that throws becomes a closed typed outcome
|
||||
// rather than an escaping rejection.
|
||||
let digest: string | typeof ABORTED;
|
||||
try {
|
||||
digest = await abortable(
|
||||
Promise.resolve(dependencies.digest(bytes)),
|
||||
signal,
|
||||
);
|
||||
} catch {
|
||||
return rejected("INTEGRITY_MISMATCH");
|
||||
}
|
||||
if (digest === ABORTED) return rejected("FETCH_FAILED");
|
||||
if (digest !== asset.sha256) return rejected("INTEGRITY_MISMATCH");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user