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
+113 -5
View File
@@ -49,6 +49,8 @@ function pageContainer(options: { waiting?: boolean; controlled?: boolean } = {}
} as unknown as ServiceWorkerContainer;
return {
container,
waiting,
controlled,
registration,
waitingMessages,
controllerMessages,
@@ -384,7 +386,12 @@ describe("service worker page protocol", () => {
const browser = pageContainer({ waiting: true });
const controller = pageController(browser.container);
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(
createServiceWorkerMessage({
@@ -396,7 +403,7 @@ describe("service worker page protocol", () => {
source,
);
expect(source.postMessage).toHaveBeenCalledWith(
expect(sourceMessages).toHaveBeenCalledWith(
expect.objectContaining({
kind: "CLIENT_DRAINED",
sourceBuildId: "page-build",
@@ -444,7 +451,7 @@ describe("service worker page protocol", () => {
nonce: request.nonce,
}),
cachesDeleted: 2,
});
}, browser.controlled ?? undefined);
await expect(result).resolves.toEqual({ kind: "RESET", cachesDeleted: 2 });
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 () => {
vi.useFakeTimers();
const deleteCache = vi.fn(async () => true);
const deleteCache = vi.fn(async (_name: string) => true);
const fetcher = vi.fn(
(_input: RequestInfo | URL, init?: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
@@ -725,7 +732,47 @@ describe("service worker static asset install", () => {
code: "INSTALL_DEADLINE_EXCEEDED",
});
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();
});
@@ -807,4 +854,65 @@ describe("service worker static asset install", () => {
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();
});
});