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