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
@@ -144,10 +144,10 @@ Rollout state starts at `NOT_STARTED`; documented-unimplemented items start at
|
|||||||
| SW-03 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | false removal success | Red `unregister() === false` reported as UNREGISTERED → green FAILED |
|
| SW-03 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | false removal success | Red `unregister() === false` reported as UNREGISTERED → green FAILED |
|
||||||
| SW-04 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | removal outcome misreport | Red removal modes always DISABLED → green outcome matrix (ABSENT/UNREGISTERED/PURGED→DISABLED, OWNERSHIP_MISMATCH→INCOMPATIBLE, FAILED→FAILED) |
|
| SW-04 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | removal outcome misreport | Red removal modes always DISABLED → green outcome matrix (ABSENT/UNREGISTERED/PURGED→DISABLED, OWNERSHIP_MISMATCH→INCOMPATIBLE, FAILED→FAILED) |
|
||||||
| SW-05 | Build gate | `corepack pnpm exec vitest run tests/unit/service-worker-build-input.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | build admission rejection | Red tamper table (stale digest, byte length, cross-origin URL, dot segment, extension mismatch, unknown field, duplicate URL) → green; build gate decodes through the shared codec and recomputes the canonical digest |
|
| SW-05 | Build gate | `corepack pnpm exec vitest run tests/unit/service-worker-build-input.test.ts` | `fix: make Service Worker cache and removal outcomes truthful` | `FIXED_NOT_RELEASED` | build admission rejection | Red tamper table (stale digest, byte length, cross-origin URL, dot segment, extension mismatch, unknown field, duplicate URL) → green; build gate decodes through the shared codec and recomputes the canonical digest |
|
||||||
| SW-06 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | activation handshake failure | — |
|
| SW-06 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: harden Service Worker activation and install lifecycle` | `FIXED_NOT_RELEASED` | activation handshake failure | Red foreign-source drain, source swap and 10 concurrent activations → green 24/24; replies correlate by source object identity against the captured waiting worker or controller, and activation/reset are single-flight |
|
||||||
| SW-07 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | activation blocked with zero clients | — |
|
| SW-07 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: harden Service Worker activation and install lifecycle` | `FIXED_NOT_RELEASED` | activation blocked with zero clients | An empty in-scope client set is vacuously drained; `clients.matchAll()` failure still rejects |
|
||||||
| SW-08 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | per-client failure escalation | — |
|
| SW-08 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: harden Service Worker activation and install lifecycle` | `FIXED_NOT_RELEASED` | per-client failure escalation | Per-client `postMessage` isolation; `skipWaiting()` is the commit point and its failure is REJECTED, with accepted/reload notifications sent only afterwards as best effort |
|
||||||
| SW-09 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `NOT_STARTED` | late install work observed | — |
|
| SW-09 | Composed when capability selected | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | `fix: harden Service Worker activation and install lifecycle` | `FIXED_NOT_RELEASED` | late install work observed | Red late-fetch case → green; a fenced worker starts no new candidate work, late response bodies are cancelled, digest throws map to a closed outcome, and a second exact-delete runs once the abandoned install settles without extending the public bound |
|
||||||
| SW-10 | Protocol V2 migration | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `DEFERRED_TO_MIGRATION` | V1/V2 mismatch fail-close | Not closed here. Full-identity protocol V2 is an expand → dual-read → old-writer drain → contract deployment that spans releases; the prerequisite shared manifest codec and canonical digest landed with SW-05. |
|
| SW-10 | Protocol V2 migration | `corepack pnpm exec vitest run tests/unit/service-worker-runtime.test.ts` | — | `DEFERRED_TO_MIGRATION` | V1/V2 mismatch fail-close | Not closed here. Full-identity protocol V2 is an expand → dual-read → old-writer drain → contract deployment that spans releases; the prerequisite shared manifest codec and canonical digest landed with SW-05. |
|
||||||
| WP-01 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-fence-store.test.ts` | `fix: bind Web Push mutations to exact authority` | `FIXED_NOT_RELEASED` | CAS receipt rejection | Red stale/skipped/huge revision receipts → green; write and remove share one exact-next-revision validator |
|
| WP-01 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-fence-store.test.ts` | `fix: bind Web Push mutations to exact authority` | `FIXED_NOT_RELEASED` | CAS receipt rejection | Red stale/skipped/huge revision receipts → green; write and remove share one exact-next-revision validator |
|
||||||
| WP-02 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-fence-store.test.ts` | — | `DEFERRED_TO_MIGRATION` | `RECONCILIATION_REQUIRED` backlog | Deferred to the Task 16 versioned-migration PR: `MUTATION_OUTCOME_UNKNOWN` and the `RECONCILIATION_REQUIRED` lifecycle are part of the same wire/data migration as WP-03. |
|
| WP-02 | Web Push `NOT_SELECTED` | `corepack pnpm exec vitest run tests/unit/web-push-fence-store.test.ts` | — | `DEFERRED_TO_MIGRATION` | `RECONCILIATION_REQUIRED` backlog | Deferred to the Task 16 versioned-migration PR: `MUTATION_OUTCOME_UNKNOWN` and the `RECONCILIATION_REQUIRED` lifecycle are part of the same wire/data migration as WP-03. |
|
||||||
|
|||||||
@@ -277,41 +277,54 @@ export function createServiceWorkerRuntime(
|
|||||||
parsed.message.sourceBuildId,
|
parsed.message.sourceBuildId,
|
||||||
);
|
);
|
||||||
if (!drained) {
|
if (!drained) {
|
||||||
for (const client of clients) {
|
notifyClients(clients, "ACTIVATE_REJECTED", parsed.message.sourceBuildId, nonce);
|
||||||
client.postMessage(
|
|
||||||
createServiceWorkerMessage({
|
|
||||||
kind: "ACTIVATE_REJECTED",
|
|
||||||
sourceBuildId: config.identity.buildId,
|
|
||||||
targetBuildId: parsed.message.sourceBuildId,
|
|
||||||
nonce,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return "REJECTED";
|
return "REJECTED";
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const client of clients) {
|
// SW-08. `skipWaiting()` is the activation commit. It must succeed before
|
||||||
client.postMessage(
|
// any client is told the activation was accepted, and its failure is a
|
||||||
createServiceWorkerMessage({
|
// rejection rather than an accepted-then-failed activation.
|
||||||
kind: "ACTIVATE_ACCEPTED",
|
try {
|
||||||
sourceBuildId: config.identity.buildId,
|
|
||||||
targetBuildId: parsed.message.sourceBuildId,
|
|
||||||
nonce,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await scope.skipWaiting();
|
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) {
|
for (const client of clients) {
|
||||||
|
try {
|
||||||
client.postMessage(
|
client.postMessage(
|
||||||
createServiceWorkerMessage({
|
createServiceWorkerMessage({
|
||||||
kind: "ACTIVATED_RELOAD_REQUIRED",
|
kind,
|
||||||
sourceBuildId: config.identity.buildId,
|
sourceBuildId: config.identity.buildId,
|
||||||
targetBuildId: parsed.message.sourceBuildId,
|
targetBuildId,
|
||||||
nonce,
|
nonce,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
} catch {
|
||||||
|
// A dead client cannot change the already committed activation.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return "ACCEPTED";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function drainClients(
|
async function drainClients(
|
||||||
@@ -319,7 +332,9 @@ export function createServiceWorkerRuntime(
|
|||||||
nonce: string,
|
nonce: string,
|
||||||
requesterBuildId: string,
|
requesterBuildId: string,
|
||||||
): Promise<boolean> {
|
): 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 drained = new Promise<boolean>((resolve) => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
pendingActivations.delete(nonce);
|
pendingActivations.delete(nonce);
|
||||||
@@ -336,7 +351,11 @@ 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) {
|
for (const client of clients) {
|
||||||
|
try {
|
||||||
client.postMessage(
|
client.postMessage(
|
||||||
createServiceWorkerMessage({
|
createServiceWorkerMessage({
|
||||||
kind: "CLIENT_DRAIN_REQUEST",
|
kind: "CLIENT_DRAIN_REQUEST",
|
||||||
@@ -345,6 +364,11 @@ export function createServiceWorkerRuntime(
|
|||||||
nonce,
|
nonce,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
} catch {
|
||||||
|
const pending = pendingActivations.get(nonce);
|
||||||
|
if (pending) settlePendingActivation(nonce, pending, false);
|
||||||
|
return await drained;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return drained;
|
return drained;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ export function createServiceWorkerPageController(
|
|||||||
let registration: ServiceWorkerRegistration | null = null;
|
let registration: ServiceWorkerRegistration | null = null;
|
||||||
let messageListener: ((event: MessageEvent) => void) | null = null;
|
let messageListener: ((event: MessageEvent) => void) | null = null;
|
||||||
let updateTimer: ReturnType<typeof setInterval> | 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;
|
let stopped = false;
|
||||||
const pendingStops = new Set<() => void>();
|
const pendingStops = new Set<() => void>();
|
||||||
|
|
||||||
@@ -217,6 +220,13 @@ export function createServiceWorkerPageController(
|
|||||||
observe("client_drain", "MALFORMED");
|
observe("client_drain", "MALFORMED");
|
||||||
return;
|
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();
|
const rejected = isBlocked();
|
||||||
source.postMessage(
|
source.postMessage(
|
||||||
createServiceWorkerMessage({
|
createServiceWorkerMessage({
|
||||||
@@ -234,6 +244,23 @@ export function createServiceWorkerPageController(
|
|||||||
container.addEventListener("message", messageListener);
|
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 {
|
function scheduleUpdateChecks(): void {
|
||||||
// §17.14. At most one check per 6 hours, and none while the page is hidden.
|
// §17.14. At most one check per 6 hours, and none while the page is hidden.
|
||||||
if (updateTimer) return;
|
if (updateTimer) return;
|
||||||
@@ -251,7 +278,16 @@ export function createServiceWorkerPageController(
|
|||||||
* §17.11. Activation is a handshake: every controlled client must close new
|
* §17.11. Activation is a handshake: every controlled client must close new
|
||||||
* admission and acknowledge within 30s. One missing client rejects it.
|
* 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;
|
const waiting = registration?.waiting;
|
||||||
if (!waiting) return Object.freeze({ kind: "NO_WAITING_WORKER" as const });
|
if (!waiting) return Object.freeze({ kind: "NO_WAITING_WORKER" as const });
|
||||||
if (isBlocked()) {
|
if (isBlocked()) {
|
||||||
@@ -287,6 +323,17 @@ export function createServiceWorkerPageController(
|
|||||||
) {
|
) {
|
||||||
return;
|
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 (
|
if (
|
||||||
parsed.message.kind === "ACTIVATE_REJECTED" &&
|
parsed.message.kind === "ACTIVATE_REJECTED" &&
|
||||||
parsed.message.nonce === nonce
|
parsed.message.nonce === nonce
|
||||||
@@ -329,11 +376,21 @@ export function createServiceWorkerPageController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** §18.10. Static caches only; the registration itself is left in place. */
|
/** §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;
|
const container = dependencies.container;
|
||||||
if (!container?.controller) {
|
if (!container?.controller) {
|
||||||
return Object.freeze({ kind: "NOT_CONTROLLED" as const });
|
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();
|
const nonce = nonces.issue();
|
||||||
return new Promise<ServiceWorkerResetOutcome>((resolve) => {
|
return new Promise<ServiceWorkerResetOutcome>((resolve) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -357,6 +414,18 @@ export function createServiceWorkerPageController(
|
|||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
(event.source !== null && event.source !== requestedController) ||
|
||||||
|
container.controller !== requestedController
|
||||||
|
) {
|
||||||
|
finish(
|
||||||
|
Object.freeze({
|
||||||
|
kind: "FAILED" as const,
|
||||||
|
code: "PROTOCOL_MISMATCH",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
finish(
|
finish(
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
kind: "RESET" as const,
|
kind: "RESET" as const,
|
||||||
|
|||||||
@@ -149,6 +149,19 @@ export async function installStaticAssets(
|
|||||||
|
|
||||||
if (outcome.kind === "REJECTED") {
|
if (outcome.kind === "REJECTED") {
|
||||||
await dependencies.caches.delete(cacheName).catch(() => false);
|
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;
|
return outcome;
|
||||||
}
|
}
|
||||||
@@ -173,6 +186,11 @@ async function installCandidate(
|
|||||||
const worker = async (): Promise<void> => {
|
const worker = async (): Promise<void> => {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
if (failure) return;
|
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();
|
const asset = queue.shift();
|
||||||
if (!asset) return;
|
if (!asset) return;
|
||||||
const outcome = await storeAsset(asset, cache, dependencies, signal);
|
const outcome = await storeAsset(asset, cache, dependencies, signal);
|
||||||
@@ -205,15 +223,22 @@ async function storeAsset(
|
|||||||
if (signal.aborted) return rejected("FETCH_FAILED");
|
if (signal.aborted) return rejected("FETCH_FAILED");
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
const fetched = await abortable(
|
// SW-09. A non-cooperative fetch that ignores the signal still settles
|
||||||
dependencies.fetcher(asset.url, {
|
// later; its body is compensated so an abandoned response is not left open.
|
||||||
|
const pending = dependencies.fetcher(asset.url, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
credentials: "omit",
|
credentials: "omit",
|
||||||
redirect: "error",
|
redirect: "error",
|
||||||
signal,
|
signal,
|
||||||
}),
|
});
|
||||||
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");
|
if (fetched === ABORTED) return rejected("FETCH_FAILED");
|
||||||
response = fetched;
|
response = fetched;
|
||||||
} catch {
|
} catch {
|
||||||
@@ -234,7 +259,17 @@ async function storeAsset(
|
|||||||
if (!body.ok) return rejected(body.code);
|
if (!body.ok) return rejected(body.code);
|
||||||
const bytes = body.bytes;
|
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 === ABORTED) return rejected("FETCH_FAILED");
|
||||||
if (digest !== asset.sha256) return rejected("INTEGRITY_MISMATCH");
|
if (digest !== asset.sha256) return rejected("INTEGRITY_MISMATCH");
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ function pageContainer(options: { waiting?: boolean; controlled?: boolean } = {}
|
|||||||
} as unknown as ServiceWorkerContainer;
|
} as unknown as ServiceWorkerContainer;
|
||||||
return {
|
return {
|
||||||
container,
|
container,
|
||||||
|
waiting,
|
||||||
|
controlled,
|
||||||
registration,
|
registration,
|
||||||
waitingMessages,
|
waitingMessages,
|
||||||
controllerMessages,
|
controllerMessages,
|
||||||
@@ -384,7 +386,12 @@ describe("service worker page protocol", () => {
|
|||||||
const browser = pageContainer({ waiting: true });
|
const browser = pageContainer({ waiting: true });
|
||||||
const controller = pageController(browser.container);
|
const controller = pageController(browser.container);
|
||||||
await controller.start();
|
await controller.start();
|
||||||
const source = { postMessage: vi.fn() };
|
// SW-06. Replies are correlated by source identity, so the fake request
|
||||||
|
// comes from the registration's waiting worker.
|
||||||
|
const source = browser.waiting as unknown as {
|
||||||
|
postMessage(message: unknown): void;
|
||||||
|
};
|
||||||
|
const sourceMessages = vi.spyOn(source, "postMessage");
|
||||||
|
|
||||||
browser.dispatch(
|
browser.dispatch(
|
||||||
createServiceWorkerMessage({
|
createServiceWorkerMessage({
|
||||||
@@ -396,7 +403,7 @@ describe("service worker page protocol", () => {
|
|||||||
source,
|
source,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(source.postMessage).toHaveBeenCalledWith(
|
expect(sourceMessages).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
kind: "CLIENT_DRAINED",
|
kind: "CLIENT_DRAINED",
|
||||||
sourceBuildId: "page-build",
|
sourceBuildId: "page-build",
|
||||||
@@ -444,7 +451,7 @@ describe("service worker page protocol", () => {
|
|||||||
nonce: request.nonce,
|
nonce: request.nonce,
|
||||||
}),
|
}),
|
||||||
cachesDeleted: 2,
|
cachesDeleted: 2,
|
||||||
});
|
}, browser.controlled ?? undefined);
|
||||||
|
|
||||||
await expect(result).resolves.toEqual({ kind: "RESET", cachesDeleted: 2 });
|
await expect(result).resolves.toEqual({ kind: "RESET", cachesDeleted: 2 });
|
||||||
await controller.stop();
|
await controller.stop();
|
||||||
@@ -700,7 +707,7 @@ describe("service worker static asset install", () => {
|
|||||||
|
|
||||||
it("aborts and rolls back a candidate cache at the overall install deadline", async () => {
|
it("aborts and rolls back a candidate cache at the overall install deadline", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const deleteCache = vi.fn(async () => true);
|
const deleteCache = vi.fn(async (_name: string) => true);
|
||||||
const fetcher = vi.fn(
|
const fetcher = vi.fn(
|
||||||
(_input: RequestInfo | URL, init?: RequestInit) =>
|
(_input: RequestInfo | URL, init?: RequestInit) =>
|
||||||
new Promise<Response>((_resolve, reject) => {
|
new Promise<Response>((_resolve, reject) => {
|
||||||
@@ -725,7 +732,47 @@ describe("service worker static asset install", () => {
|
|||||||
code: "INSTALL_DEADLINE_EXCEEDED",
|
code: "INSTALL_DEADLINE_EXCEEDED",
|
||||||
});
|
});
|
||||||
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
|
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
|
||||||
expect(deleteCache).toHaveBeenCalledTimes(1);
|
// SW-09. The public result closes at the deadline with one exact delete,
|
||||||
|
// and a second exact delete is registered once the abandoned install work
|
||||||
|
// actually settles. Both target the same owned candidate cache.
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(deleteCache).toHaveBeenCalledTimes(2);
|
||||||
|
expect(new Set(deleteCache.mock.calls.map((call) => call[0])).size).toBe(1);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("observes and cleans non-cooperative late install work", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const deleteCache = vi.fn(async (_name: string) => true);
|
||||||
|
const cancel = vi.fn(async () => {});
|
||||||
|
let releaseFetch: ((response: Response) => void) | undefined;
|
||||||
|
// A fetch that ignores the abort signal entirely.
|
||||||
|
const fetcher = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<Response>((resolve) => {
|
||||||
|
releaseFetch = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = installStaticAssets(manifest, {
|
||||||
|
caches: {
|
||||||
|
open: vi.fn(async () => ({ put: vi.fn() }) as unknown as Cache),
|
||||||
|
delete: deleteCache,
|
||||||
|
},
|
||||||
|
fetcher: fetcher as typeof fetch,
|
||||||
|
digest: vi.fn(),
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(SERVICE_WORKER_BOUNDS.installDeadlineMs);
|
||||||
|
await expect(result).resolves.toEqual({
|
||||||
|
kind: "REJECTED",
|
||||||
|
code: "INSTALL_DEADLINE_EXCEEDED",
|
||||||
|
});
|
||||||
|
|
||||||
|
// The late response arrives after the public bound; its body is cancelled
|
||||||
|
// and no unhandled rejection escapes.
|
||||||
|
releaseFetch?.({ body: { cancel } } as unknown as Response);
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(cancel).toHaveBeenCalledOnce();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -807,4 +854,65 @@ describe("service worker static asset install", () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts replies only from the captured waiting or controller source", async () => {
|
||||||
|
const browser = pageContainer({ waiting: true });
|
||||||
|
const controller = pageController(browser.container);
|
||||||
|
await controller.start();
|
||||||
|
const foreign = { postMessage: vi.fn() };
|
||||||
|
|
||||||
|
// SW-06. A same-origin but unrecognised source must not close admission.
|
||||||
|
browser.dispatch(
|
||||||
|
createServiceWorkerMessage({
|
||||||
|
kind: "CLIENT_DRAIN_REQUEST",
|
||||||
|
sourceBuildId: "worker-build",
|
||||||
|
targetBuildId: "page-build",
|
||||||
|
nonce: "drain-foreign",
|
||||||
|
}),
|
||||||
|
foreign,
|
||||||
|
);
|
||||||
|
expect(foreign.postMessage).not.toHaveBeenCalled();
|
||||||
|
await controller.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coalesces concurrent activation and reset commands", async () => {
|
||||||
|
const browser = pageContainer({ waiting: true });
|
||||||
|
const controller = pageController(browser.container);
|
||||||
|
await controller.start();
|
||||||
|
|
||||||
|
// SW-06. Ten concurrent callers issue exactly one request.
|
||||||
|
const activations = Array.from({ length: 10 }, () =>
|
||||||
|
controller.requestActivation(),
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(browser.waitingMessages).toHaveLength(1);
|
||||||
|
expect(new Set(activations).size).toBe(1);
|
||||||
|
|
||||||
|
await controller.stop();
|
||||||
|
await Promise.allSettled(activations);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends an activation whose reply source was swapped", async () => {
|
||||||
|
const browser = pageContainer({ waiting: true });
|
||||||
|
const controller = pageController(browser.container);
|
||||||
|
await controller.start();
|
||||||
|
const activation = controller.requestActivation();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const request = browser.waitingMessages.at(-1) as { nonce?: string };
|
||||||
|
// A different worker answers: terminate immediately rather than waiting for
|
||||||
|
// the drain timeout.
|
||||||
|
browser.dispatch(
|
||||||
|
createServiceWorkerMessage({
|
||||||
|
kind: "ACTIVATED_RELOAD_REQUIRED",
|
||||||
|
sourceBuildId: "worker-build",
|
||||||
|
targetBuildId: "page-build",
|
||||||
|
nonce: request.nonce,
|
||||||
|
}),
|
||||||
|
{ postMessage: vi.fn() },
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(activation).resolves.toEqual({ kind: "PROTOCOL_MISMATCH" });
|
||||||
|
await controller.stop();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user