Skip to content

[BUG] Chrome MV3:Service Worker 在 offscreen client 就绪前发送首条请求会失败 #1667

Description

@cyfung1031

问题描述

在 Chrome MV3 路径中,Service Worker 发送消息到 offscreen document 时存在一个可实际触发的就绪竞态。

当前 mainServiceWorkerMessageSend.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.tse2e/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"

重现步骤

  1. 在当前 main 构建并加载扩展。
  2. 运行上面的 Playwright 测试。
  3. 测试会安装最小的 @background 脚本并记录 UUID。
  4. 测试通过真实 chrome.offscreen.closeDocument() 关闭 offscreen document。
  5. 测试通过 Chrome DevTools Protocol 停止当前扩展 Service Worker。
  6. 下一条消息立即重新启动 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 验证的窄问题。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions