diff --git a/packages/message/message_queue.test.ts b/packages/message/message_queue.test.ts index 67669fb55..553a1e567 100644 --- a/packages/message/message_queue.test.ts +++ b/packages/message/message_queue.test.ts @@ -192,10 +192,13 @@ describe("MessageQueueGroup", () => { const sendSpy = vi.spyOn(chrome.runtime, "sendMessage"); group.publish("test-sendChromeMessage", { data: "test-sendChromeMessage" }); - expect(sendSpy).toHaveBeenCalledWith({ - msgQueue: "api-sendChromeMessage/test-sendChromeMessage", - data: { action: "message", message: { data: "test-sendChromeMessage" } }, - }); + expect(sendSpy).toHaveBeenCalledWith( + { + msgQueue: "api-sendChromeMessage/test-sendChromeMessage", + data: { action: "message", message: { data: "test-sendChromeMessage" } }, + }, + expect.any(Function) + ); }); it("emit 方法应该只在本地发布", () => { @@ -209,6 +212,35 @@ describe("MessageQueueGroup", () => { expect(handler).toHaveBeenCalledWith({ data: "test-emitLocal" }); expect(sendSpy).not.toHaveBeenCalled(); }); + + it("publish 应传入回调并在回调中读取 chrome.runtime.lastError(接收端不存在时不产生未检查错误)", () => { + // 模拟浏览器行为: 没有任何接收方时, 回调执行期间 lastError 被设置; + // 若回调中不读取 lastError(或根本没传回调), 浏览器会报 + // "Unchecked runtime.lastError: Could not establish connection. Receiving end does not exist." + const lastErrorGetter = vi.fn(() => ({ + message: "Could not establish connection. Receiving end does not exist.", + })); + const sendSpy = vi.spyOn(chrome.runtime, "sendMessage").mockImplementation((( + _message: any, + callback?: (resp: any) => void + ) => { + Object.defineProperty(chrome.runtime, "lastError", { get: lastErrorGetter, configurable: true }); + try { + callback?.(undefined); + } finally { + delete (chrome.runtime as any).lastError; + } + }) as any); + + const group = messageQueue.group("api-lastError"); + expect(() => group.publish("test-lastError", { data: "test-lastError" })).not.toThrow(); + + expect(sendSpy).toHaveBeenCalledTimes(1); + // 必须传入回调, 否则 MV3 下无回调的 sendMessage 返回的 Promise 会产生 unhandled rejection + expect(typeof sendSpy.mock.calls[0]![1]).toBe("function"); + // 回调中必须读取 lastError, 否则浏览器会报 Unchecked runtime.lastError + expect(lastErrorGetter).toHaveBeenCalled(); + }); }); describe("取消订阅功能", () => { diff --git a/packages/message/message_queue.ts b/packages/message/message_queue.ts index 9864c2523..049eb186b 100644 --- a/packages/message/message_queue.ts +++ b/packages/message/message_queue.ts @@ -73,10 +73,22 @@ export class MessageQueue implements IMessageQueue { } publish(topic: string, message: NonNullable) { - chrome.runtime.sendMessage({ - msgQueue: topic, - data: { action: "message", message }, - }); + chrome.runtime.sendMessage( + { + msgQueue: topic, + data: { action: "message", message }, + }, + () => { + // 广播为 fire-and-forget: 其他上下文(offscreen/popup/options)可能不存在或不回应, + // 必须传入回调并读取 lastError, 否则产生 "Unchecked runtime.lastError" / unhandled rejection + const lastError = chrome.runtime.lastError; + if (lastError) { + LoggerCore.getInstance() + .logger({ service: "messageQueue" }) + .trace("publish lastError", { topic, error: lastError.message }); + } + } + ); this.EE.emit(topic, message); //@ts-ignore LoggerCore.getInstance().logger({ service: "messageQueue" }).trace("publish", { topic, message }); diff --git a/packages/message/window_message.test.ts b/packages/message/window_message.test.ts index 358e7f33f..921f86f9e 100644 --- a/packages/message/window_message.test.ts +++ b/packages/message/window_message.test.ts @@ -132,6 +132,92 @@ describe("ServiceWorkerMessageSend", () => { }); }); +describe("ServiceWorkerMessageSend 目标客户端解析", () => { + const offscreenUrl = "chrome-extension://mock-extension-id/src/offscreen.html"; + + afterEach(() => { + vi.useRealTimers(); + }); + + it("offscreen 客户端尚未出现时 sendMessage 应等待客户端出现后再发送", async () => { + vi.useFakeTimers(); + vi.spyOn(chrome.runtime, "getURL").mockReturnValue(offscreenUrl); + + const swSend = new ServiceWorkerMessageSend(); + const clientPostMessage = vi.fn((body: WindowMessageBody) => { + // offscreen 收到请求后回复响应 + swSend.messageHandle({ messageId: body.messageId, type: "respMessage", data: { code: 0, data: "pong" } }); + }); + + // 前两次查询 offscreen 文档尚未创建完成, 之后出现(模拟 SW 启动早于 offscreen 创建的竞态) + const matchAll = (self as any).clients.matchAll as ReturnType; + matchAll + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValue([{ url: offscreenUrl, postMessage: clientPostMessage }]); + + const promise = swSend.sendMessage({ action: "offscreen/test", data: "ping" }); + const assertion = expect(promise).resolves.toEqual({ code: 0, data: "pong" }); + await vi.advanceTimersByTimeAsync(3000); + await assertion; + expect(clientPostMessage).toHaveBeenCalledTimes(1); + }); + + it("offscreen 客户端始终不可用时应以描述性错误拒绝而不是抛 TypeError", async () => { + vi.useFakeTimers(); + vi.spyOn(chrome.runtime, "getURL").mockReturnValue(offscreenUrl); + + const swSend = new ServiceWorkerMessageSend(); + // matchAll 始终返回 [](offscreen 一直不存在) + const promise = swSend.sendMessage({ action: "offscreen/test", data: "ping" }); + const assertion = expect(promise).rejects.toThrow(/offscreen/i); + await vi.advanceTimersByTimeAsync(60_000); + await assertion; + }); + + it("收到来自 offscreen 的消息后应刷新 target, 无需 matchAll 即可发送", async () => { + vi.spyOn(chrome.runtime, "getURL").mockReturnValue(offscreenUrl); + + const swSend = new ServiceWorkerMessageSend(); + const clientPostMessage = vi.fn((body: WindowMessageBody) => { + if (body.type === "sendMessage") { + swSend.messageHandle({ messageId: body.messageId, type: "respMessage", data: { code: 0, data: "ok" } }); + } + }); + + // offscreen 先主动发来一条请求(例如 preparationOffscreen), source 即当前有效的 offscreen 客户端 + swSend.messageHandle({ messageId: "m1", type: "sendMessage", data: { action: "x" } }, { + url: offscreenUrl, + postMessage: clientPostMessage, + } as any); + + // SW 主动发消息应直接使用刷新后的 target(即使 matchAll 查不到客户端) + await expect(swSend.sendMessage({ action: "offscreen/test", data: 1 })).resolves.toEqual({ + code: 0, + data: "ok", + }); + expect((self as any).clients.matchAll).not.toHaveBeenCalled(); + }); + + it("非 offscreen 页面的消息来源不应污染 target", async () => { + vi.useFakeTimers(); + vi.spyOn(chrome.runtime, "getURL").mockReturnValue(offscreenUrl); + + const swSend = new ServiceWorkerMessageSend(); + const strangerPostMessage = vi.fn(); + swSend.messageHandle({ messageId: "m2", type: "sendMessage", data: { action: "x" } }, { + url: "chrome-extension://mock-extension-id/src/options.html", + postMessage: strangerPostMessage, + } as any); + + const promise = swSend.sendMessage({ action: "offscreen/test", data: 1 }); + const assertion = expect(promise).rejects.toThrow(/offscreen/i); + await vi.advanceTimersByTimeAsync(60_000); + await assertion; + expect(strangerPostMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "sendMessage" })); + }); +}); + describe("ServiceWorkerClientMessage", () => { it("controller 可用时直接使用", () => { const clientMsg = new ServiceWorkerClientMessage(); diff --git a/packages/message/window_message.ts b/packages/message/window_message.ts index cc22fce5a..68d702eb7 100644 --- a/packages/message/window_message.ts +++ b/packages/message/window_message.ts @@ -217,10 +217,17 @@ export class WindowMessageConnect implements MessageConnect { // 不进行监听的话又无法从service_worker主动发送消息 // 所以service_worker与offscreen使用ServiceWorker的方式进行通信 // 现在同时支持接收来自offscreen的请求(实现完整Message接口),使双向通道都走postMessage(结构化克隆,支持Blob) + +const OFFSCREEN_DOCUMENT_PATH = "src/offscreen.html"; +// offscreen客户端出现前的轮询间隔与等待上限(SW启动早于offscreen文档创建完成的竞态窗口) +const OFFSCREEN_TARGET_POLL_INTERVAL = 200; +const OFFSCREEN_TARGET_WAIT_TIMEOUT = 30_000; + export class ServiceWorkerMessageSend implements Message { EE = new EventEmitter(); private target: PostMessage | undefined = undefined; + private targetWaiter: Promise | undefined = undefined; constructor() { // 在构造函数中设置监听,确保能接收来自offscreen的请求 @@ -229,15 +236,50 @@ export class ServiceWorkerMessageSend implements Message { }); } - async init() { + private async queryTarget() { if (!this.target && self.clients) { const list = await self.clients.matchAll({ includeUncontrolled: true, type: "window" }); // 找到offscreen.html窗口 - this.target = list.find((client) => client.url == chrome.runtime.getURL("src/offscreen.html")) as PostMessage; + this.target = list.find((client) => client.url == chrome.runtime.getURL(OFFSCREEN_DOCUMENT_PATH)) as PostMessage; + } + } + + async init() { + await this.queryTarget(); + } + + // offscreen文档可能尚未创建完成,轮询等待其出现;超时则以可诊断的错误拒绝,而不是对undefined调用postMessage + private waitForTarget(): Promise { + if (this.target) { + return Promise.resolve(this.target); + } + if (!this.targetWaiter) { + this.targetWaiter = (async () => { + const deadline = Date.now() + OFFSCREEN_TARGET_WAIT_TIMEOUT; + for (;;) { + await this.queryTarget(); + if (this.target) { + return this.target; + } + if (Date.now() >= deadline) { + throw new Error("ServiceWorkerMessageSend: offscreen document client not found (timeout)"); + } + await new Promise((resolve) => setTimeout(resolve, OFFSCREEN_TARGET_POLL_INTERVAL)); + } + })().finally(() => { + // 成功后走 this.target 快路径;失败后允许后续调用重新等待 + this.targetWaiter = undefined; + }); } + return this.targetWaiter; } messageHandle(data: WindowMessageBody, source?: PostMessage) { + // 来自offscreen的消息自带最新的客户端引用,用于刷新target(offscreen重建后旧Client已失效) + const sourceUrl = (source as { url?: string } | undefined)?.url; + if (sourceUrl && sourceUrl === chrome.runtime.getURL(OFFSCREEN_DOCUMENT_PATH)) { + this.target = source; + } // 处理消息 if (data.type === "sendMessage" && source) { // 接收到来自offscreen的请求消息 @@ -281,19 +323,19 @@ export class ServiceWorkerMessageSend implements Message { } async connect(data: TMessage): Promise { - await this.init(); + const target = await this.waitForTarget(); const body: WindowMessageBody = { messageId: uuidv4(), type: "connect", data, }; - this.target!.postMessage(body); - return new WindowMessageConnect(body.messageId, this.EE, this.target!); + target.postMessage(body); + return new WindowMessageConnect(body.messageId, this.EE, target); } // 发送消息 注意不进行回调的内存泄漏 async sendMessage(data: TMessage): Promise { - await this.init(); + const target = await this.waitForTarget(); return new Promise((resolve: ((value: T) => void) | null) => { const messageId = uuidv4(); const body: WindowMessageBody = { @@ -307,7 +349,7 @@ export class ServiceWorkerMessageSend implements Message { resolve!(body.data as T); resolve = null; // 设为 null 提醒JS引擎可以GC }); - this.target!.postMessage(body); + target.postMessage(body); }); } } diff --git a/src/app/service/service_worker/offscreen_setup.test.ts b/src/app/service/service_worker/offscreen_setup.test.ts new file mode 100644 index 000000000..8b0380987 --- /dev/null +++ b/src/app/service/service_worker/offscreen_setup.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const OFFSCREEN_URL = "chrome-extension://mock-extension-id/src/offscreen.html"; + +let getContextsMock: ReturnType; +let createDocumentMock: ReturnType; + +// setupOffscreenDocument 使用模块级 latch, 每个用例用全新模块实例避免状态串扰 +async function importModule() { + vi.resetModules(); + return await import("./offscreen_setup.ts"); +} + +beforeEach(() => { + getContextsMock = vi.fn().mockResolvedValue([]); + createDocumentMock = vi.fn().mockResolvedValue(undefined); + (chrome.runtime as any).getContexts = getContextsMock; + (chrome.runtime as any).ContextType = { OFFSCREEN_DOCUMENT: "OFFSCREEN_DOCUMENT" }; + vi.spyOn(chrome.runtime, "getURL").mockReturnValue(OFFSCREEN_URL); + (chrome as any).offscreen = { + createDocument: createDocumentMock, + Reason: { BLOBS: "BLOBS", CLIPBOARD: "CLIPBOARD", DOM_SCRAPING: "DOM_SCRAPING", LOCAL_STORAGE: "LOCAL_STORAGE" }, + }; + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + delete (chrome as any).offscreen; + delete (chrome.runtime as any).getContexts; + delete (chrome.runtime as any).ContextType; +}); + +describe("setupOffscreenDocument", () => { + it("文档已存在时不再调用 createDocument", async () => { + getContextsMock.mockResolvedValue([{ contextType: "OFFSCREEN_DOCUMENT" }]); + const { setupOffscreenDocument } = await importModule(); + + await expect(setupOffscreenDocument()).resolves.toBe(true); + expect(createDocumentMock).not.toHaveBeenCalled(); + }); + + it("文档不存在时创建成功并返回 true", async () => { + const { setupOffscreenDocument } = await importModule(); + + await expect(setupOffscreenDocument()).resolves.toBe(true); + expect(createDocumentMock).toHaveBeenCalledTimes(1); + }); + + it("并发调用共享同一次创建", async () => { + const { setupOffscreenDocument } = await importModule(); + + const [a, b] = await Promise.all([setupOffscreenDocument(), setupOffscreenDocument()]); + expect(a).toBe(true); + expect(b).toBe(true); + expect(createDocumentMock).toHaveBeenCalledTimes(1); + }); + + it("createDocument 因文档已存在而失败时(TOCTOU 竞态)视为成功", async () => { + createDocumentMock.mockRejectedValue(new Error("Only a single offscreen document may be created.")); + // 第一次检查(创建前)不存在, 失败后复查时已存在 + getContextsMock.mockResolvedValueOnce([]).mockResolvedValue([{ contextType: "OFFSCREEN_DOCUMENT" }]); + const { setupOffscreenDocument } = await importModule(); + + await expect(setupOffscreenDocument()).resolves.toBe(true); + }); + + it("createDocument 暂时性失败后应退避重试并最终成功", async () => { + vi.useFakeTimers(); + createDocumentMock.mockRejectedValueOnce(new Error("transient failure")).mockResolvedValue(undefined); + const { setupOffscreenDocument } = await importModule(); + + const promise = setupOffscreenDocument(); + await vi.advanceTimersByTimeAsync(10_000); + await expect(promise).resolves.toBe(true); + expect(createDocumentMock).toHaveBeenCalledTimes(2); + }); + + it("createDocument 持续失败时返回 false, 且 latch 被重置允许后续重试成功", async () => { + vi.useFakeTimers(); + createDocumentMock.mockRejectedValue(new Error("persistent failure")); + const { setupOffscreenDocument } = await importModule(); + + const promise = setupOffscreenDocument(); + await vi.advanceTimersByTimeAsync(60_000); + await expect(promise).resolves.toBe(false); + + // 失败后 latch 必须重置: 下一次调用可重新尝试并成功 + createDocumentMock.mockResolvedValue(undefined); + const retryPromise = setupOffscreenDocument(); + await vi.advanceTimersByTimeAsync(60_000); + await expect(retryPromise).resolves.toBe(true); + }); + + it("浏览器不支持 chrome.offscreen 时返回 false 而不抛错(Firefox)", async () => { + delete (chrome as any).offscreen; + const { setupOffscreenDocument } = await importModule(); + + await expect(setupOffscreenDocument()).resolves.toBe(false); + }); +}); diff --git a/src/app/service/service_worker/offscreen_setup.ts b/src/app/service/service_worker/offscreen_setup.ts new file mode 100644 index 000000000..231c07fdf --- /dev/null +++ b/src/app/service/service_worker/offscreen_setup.ts @@ -0,0 +1,67 @@ +const OFFSCREEN_DOCUMENT_PATH = "src/offscreen.html"; + +// 创建失败后的重试退避间隔 +const RETRY_DELAYS = [500, 1000, 2000]; + +// 单例latch: 并发调用共享同一次创建;失败后重置以允许后续重试 +let creating: Promise | null = null; + +export async function hasOffscreenDocument(): Promise { + const offscreenUrl = chrome.runtime.getURL(OFFSCREEN_DOCUMENT_PATH); + const existingContexts = await chrome.runtime.getContexts({ + contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], + documentUrls: [offscreenUrl], + }); + return existingContexts.length > 0; +} + +async function createOffscreenDocumentWithRetry(): Promise { + for (let attempt = 0; ; attempt++) { + if (await hasOffscreenDocument()) { + return true; + } + try { + await chrome.offscreen.createDocument({ + url: OFFSCREEN_DOCUMENT_PATH, + reasons: [ + chrome.offscreen.Reason.BLOBS, + chrome.offscreen.Reason.CLIPBOARD, + chrome.offscreen.Reason.DOM_SCRAPING, + chrome.offscreen.Reason.LOCAL_STORAGE, + ], + justification: "offscreen page", + }); + return true; + } catch (e) { + // 与 hasOffscreenDocument 检查之间存在竞态: 文档实际已存在时视为成功 + if (await hasOffscreenDocument()) { + return true; + } + if (attempt >= RETRY_DELAYS.length) { + console.error("setupOffscreenDocument: chrome.offscreen.createDocument failed", e); + return false; + } + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAYS[attempt])); + } + } +} + +// offscreen文档承载后台/定时脚本的沙箱环境,创建失败必须可重试, +// 不能让一次暂时性失败把 latch 永久卡在 rejected 状态直到 SW 重启 +export function setupOffscreenDocument(): Promise { + if (typeof chrome.offscreen?.createDocument !== "function") { + // Firefox does not support offscreen + console.error("Your browser does not support chrome.offscreen.createDocument"); + return Promise.resolve(false); + } + if (!creating) { + creating = createOffscreenDocumentWithRetry().then((ok) => { + if (!ok) { + // 失败时重置latch,允许后续调用重新尝试 + creating = null; + } + return ok; + }); + } + return creating; +} diff --git a/src/service_worker.ts b/src/service_worker.ts index aec4d1ae1..3b0fe5a0a 100644 --- a/src/service_worker.ts +++ b/src/service_worker.ts @@ -9,57 +9,11 @@ import { ServiceWorkerMessageSend } from "@Packages/message/window_message"; import { EventPageOffscreenManager } from "./app/service/offscreen/event_page_manager"; import migrate, { migrateChromeStorage } from "./app/migrate"; import { cleanInvalidKeys } from "./app/repo/resource"; +import { setupOffscreenDocument } from "./app/service/service_worker/offscreen_setup"; migrate(); migrateChromeStorage(); -const OFFSCREEN_DOCUMENT_PATH = "src/offscreen.html"; - -let creating: Promise | null | boolean = null; - -async function hasDocument() { - const offscreenUrl = chrome.runtime.getURL(OFFSCREEN_DOCUMENT_PATH); - const existingContexts = await chrome.runtime.getContexts({ - contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT], - documentUrls: [offscreenUrl], - }); - return existingContexts.length > 0; -} - -async function setupOffscreenDocument() { - if (typeof chrome.offscreen?.createDocument !== "function") { - // Firefox does not support offscreen - console.error("Your browser does not support chrome.offscreen.createDocument"); - return; - } - //if we do not have a document, we are already setup and can skip - if (!(await hasDocument())) { - // create offscreen document - if (!creating) { - const promise = chrome.offscreen - .createDocument({ - url: OFFSCREEN_DOCUMENT_PATH, - reasons: [ - chrome.offscreen.Reason.BLOBS, - chrome.offscreen.Reason.CLIPBOARD, - chrome.offscreen.Reason.DOM_SCRAPING, - chrome.offscreen.Reason.LOCAL_STORAGE, - ], - justification: "offscreen page", - }) - .then(() => { - if (creating !== promise) { - console.log("setupOffscreenDocument() calling is invalid."); - return; - } - creating = true; // chrome.offscreen.createDocument 只执行一次 - }); - creating = promise; - } - await creating; - } -} - function main() { cleanInvalidKeys(); // 初始化管理器