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:
DongHyeonka
2026-08-14 02:03:07 +09:00
co-authored by Claude Opus 5
parent db52f02d73
commit 976c8a8da4
5 changed files with 295 additions and 59 deletions
@@ -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;
}