问题描述
在 Chrome MV3 路径中,Service Worker 发送消息到 offscreen document 时存在一个可实际触发的就绪竞态。
当前 main 的 ServiceWorkerMessageSend.init() 只调用一次 clients.matchAll()。如果此时 offscreen client 还没有出现,target 会保持为 undefined;随后 connect() / sendMessage() 直接调用 this.target!.postMessage()。同时,src/service_worker.ts 会先初始化 manager,再异步创建 offscreen document,这两个操作存在并发窗口。
相关代码:
当前触发错误的关键实现是:
async init() {
if (!this.target && self.clients) {
const list = await self.clients.matchAll({
includeUncontrolled: true,
type: "window",
});
this.target = list.find(
(client) => client.url == chrome.runtime.getURL("src/offscreen.html")
) as PostMessage;
}
}
async sendMessage<T = any>(data: TMessage): Promise<T> {
await this.init();
return new Promise((resolve) => {
const messageId = uuidv4();
const body = {
messageId,
type: "sendMessage",
data,
};
this.EE.addListener(`response:${messageId}`, (response) => {
this.EE.removeAllListeners(`response:${messageId}`);
resolve(response.data as T);
});
// 当 init() 没找到 offscreen client 时,this.target 仍是 undefined。
this.target!.postMessage(body);
});
}
可执行的 Chromium 重现
以下测试代码就是本次实际运行并复现错误的代码,不是伪代码。把它保存为:
e2e/scratch/sw-offscreen-client-race/verify.spec.ts
它依赖仓库已有的 e2e/fixtures.ts 与 e2e/utils.ts,不需要新增生产代码:
import { test, expect } from "../../fixtures";
import type { BrowserContext, Worker } from "@playwright/test";
import { openOptionsPage, installScriptByCode } from "../../utils";
const SCRIPT_NAME = "E2E Offscreen Client Readiness Reproduction";
const backgroundScript = `// ==UserScript==
// @name ${SCRIPT_NAME}
// @namespace https://e2e.test
// @version 1.0.0
// @description minimal offscreen readiness reproduction
// @background
// ==/UserScript==
"offscreen-client-readiness-reproduction";
`;
async function getBackgroundWorker(context: BrowserContext): Promise<Worker> {
const [worker] = context.serviceWorkers();
return worker ?? context.waitForEvent("serviceworker");
}
test("offscreen client readiness can be reproduced across a real SW restart", async ({
context,
extensionId,
}) => {
const page = await openOptionsPage(context, extensionId);
const background = await getBackgroundWorker(context);
const cdp = await context.newCDPSession(page);
try {
await installScriptByCode(context, extensionId, backgroundScript);
const uuid = await page.evaluate((scriptName) => {
return new Promise<string>((resolve, reject) => {
chrome.storage.local.get(null, (all) => {
const entry = Object.values(all).find(
(value: any) => value?.name === scriptName
) as { uuid?: string } | undefined;
if (entry?.uuid) resolve(entry.uuid);
else reject(new Error(`script not found: ${scriptName}`));
});
});
}, SCRIPT_NAME);
await expect
.poll(() =>
background.evaluate(async () => {
const contexts = await chrome.runtime.getContexts({
contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT],
});
return contexts.length;
})
)
.toBeGreaterThan(0);
// 关闭真实 offscreen document,制造 client 消失窗口。
await background.evaluate(async () => {
await chrome.offscreen.closeDocument();
});
// 停止当前 Service Worker;下一条扩展消息会重新启动它。
const { targetInfos } = await cdp.send("Target.getTargets");
const workerTarget = targetInfos.find(
(target) =>
target.type === "service_worker" && target.url === background.url()
);
expect(workerTarget?.targetId).toBeTruthy();
await cdp.send("Target.closeTarget", {
targetId: workerTarget!.targetId,
});
// 在新的 Service Worker 创建 offscreen client 前,立即发送三次。
const responses = await page.evaluate(async (scriptUuid) => {
const results: unknown[] = [];
for (let attempt = 0; attempt < 3; attempt++) {
try {
results.push(
await chrome.runtime.sendMessage({
action: "serviceWorker/runtime/runScript",
data: scriptUuid,
})
);
} catch (error) {
results.push({ thrown: String(error) });
}
}
return results;
}, uuid);
console.log(JSON.stringify(responses));
} finally {
await cdp.detach().catch(() => {});
await page.close();
}
});
运行命令:
pnpm run build
pnpm exec playwright test \
--config playwright.scratch.config.ts \
-g "offscreen client readiness"
重现步骤
- 在当前
main 构建并加载扩展。
- 运行上面的 Playwright 测试。
- 测试会安装最小的
@background 脚本并记录 UUID。
- 测试通过真实
chrome.offscreen.closeDocument() 关闭 offscreen document。
- 测试通过 Chrome DevTools Protocol 停止当前扩展 Service Worker。
- 下一条消息立即重新启动 Service Worker,并在 offscreen client 创建完成前调用
serviceWorker/runtime/runScript。
关闭 offscreen document、重启 Service Worker 是为了稳定制造启动时序,等价于验证同一个“SW 已开始处理请求,但 offscreen client 尚未出现”的窗口。
实际结果
第一次请求返回:
{"code":-1,"message":"Cannot read properties of undefined (reading 'postMessage')"}
等待 offscreen client 出现后再次发送,同一请求返回 {"code":0}。这说明脚本 UUID 和路由本身有效,失败发生在首次发送时的 target 尚未就绪;首次请求不会自动重试。
在当前 checkout(1d2250c80bd9410f87270ac4e0d3daba6ec341c5)的真实 Chromium scratch run 中,连续三次请求的结果为:
[verify] immediate runScript responses = [{"code":-1,"message":"Cannot read properties of undefined (reading 'postMessage')"},{"code":0},{"code":0}]
预期结果
offscreen client 尚未出现时,消息不应因对 undefined 调用 postMessage 而失败。实现可以选择等待、重试或返回明确的可恢复状态,但不能丢失首次请求;修复策略不应预设未经验证的固定轮询时长。
影响范围
这影响 Chrome 的 Service Worker → offscreen 消息路径,例如后台脚本执行、offscreen runtime 事件和其他通过 ServiceWorkerMessageSend 发送的请求。当前证据确认的是“首次请求失败”,不将其扩大描述为所有 offscreen 消息都会失败,也不声称 Firefox event-page 路径受影响。
验证信息
pnpm exec vitest run packages/message/window_message.test.ts:13/13 通过;现有单测没有覆盖真实 client 创建时序。
pnpm run typecheck:通过。
pnpm run build:通过。
- 真实 Chromium 重现:首次响应为上述
postMessage 错误,后续响应成功。
相关 PR:scriptscat/scriptcat#1581。该 PR 同时包含另外两个已经过时或已有后续处理的候选项;本 issue 只保留这个经过独立 runtime 验证的窄问题。
问题描述
在 Chrome MV3 路径中,Service Worker 发送消息到 offscreen document 时存在一个可实际触发的就绪竞态。
当前
main的ServiceWorkerMessageSend.init()只调用一次clients.matchAll()。如果此时 offscreen client 还没有出现,target会保持为undefined;随后connect()/sendMessage()直接调用this.target!.postMessage()。同时,src/service_worker.ts会先初始化 manager,再异步创建 offscreen document,这两个操作存在并发窗口。相关代码:
packages/message/window_message.tssrc/service_worker.ts当前触发错误的关键实现是:
可执行的 Chromium 重现
以下测试代码就是本次实际运行并复现错误的代码,不是伪代码。把它保存为:
它依赖仓库已有的
e2e/fixtures.ts与e2e/utils.ts,不需要新增生产代码:运行命令:
重现步骤
main构建并加载扩展。@background脚本并记录 UUID。chrome.offscreen.closeDocument()关闭 offscreen document。serviceWorker/runtime/runScript。关闭 offscreen document、重启 Service Worker 是为了稳定制造启动时序,等价于验证同一个“SW 已开始处理请求,但 offscreen client 尚未出现”的窗口。
实际结果
第一次请求返回:
{"code":-1,"message":"Cannot read properties of undefined (reading 'postMessage')"}等待 offscreen client 出现后再次发送,同一请求返回
{"code":0}。这说明脚本 UUID 和路由本身有效,失败发生在首次发送时的 target 尚未就绪;首次请求不会自动重试。在当前 checkout(
1d2250c80bd9410f87270ac4e0d3daba6ec341c5)的真实 Chromium scratch run 中,连续三次请求的结果为:预期结果
offscreen client 尚未出现时,消息不应因对
undefined调用postMessage而失败。实现可以选择等待、重试或返回明确的可恢复状态,但不能丢失首次请求;修复策略不应预设未经验证的固定轮询时长。影响范围
这影响 Chrome 的 Service Worker → offscreen 消息路径,例如后台脚本执行、offscreen runtime 事件和其他通过
ServiceWorkerMessageSend发送的请求。当前证据确认的是“首次请求失败”,不将其扩大描述为所有 offscreen 消息都会失败,也不声称 Firefox event-page 路径受影响。验证信息
pnpm exec vitest run packages/message/window_message.test.ts:13/13 通过;现有单测没有覆盖真实 client 创建时序。pnpm run typecheck:通过。pnpm run build:通过。postMessage错误,后续响应成功。相关 PR:
scriptscat/scriptcat#1581。该 PR 同时包含另外两个已经过时或已有后续处理的候选项;本 issue 只保留这个经过独立 runtime 验证的窄问题。