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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user