跳转至

[ISSUE-0026] 拦截恢复机制在 SW 重启时丢失状态 + setTimeout 不可靠

相关源码src/entrypoints/background/batch-controller.ts v0.10.37 引入;v0.10.40 修复

用户感知(潜在)

用户报"拦截后我点了'我已验证完',但任务一直没继续跑"或者"自动恢复有时不起作用"。

实际未必有显式 bug 报告 —— 这是深度审查发现的。

根因分析

Bug A:状态丢失(SW kill 场景)

// v0.10.37
let lastInterceptedAt = 0;      // ← module-level let
let verifyTabId: number | null = null;  // ← module-level let

MV3 痛点:service worker 闲置 30s+ 会被 Chrome 回收。每次 SW 重启,module-level let 都回到初始值。

链条: 1. 拦截 → globalStatus='intercepted'(写 storage ✅)+ verifyTabId=42(仅内存 ❌) 2. SW 被 kill 3. SW 重启 → restoreBatch 读 STATUS_KEY → globalStatus 恢复 ✅ 4. 但 verifyTabId=null(重置) 5. 用户验证完,sorry tab url 跳走 → tabs.onUpdated 触发:

if (tabId !== getVerifyTabId()) return;  // null !== 42 永远成立 → return
6. 永远不自动恢复

Bug B:setTimeout 在 SW kill 时丢失

// v0.10.37
if (wait > 0) {
  await new Promise((r) => setTimeout(r, wait));  // ← 30s
  if (globalStatus !== 'intercepted') return;
}

链条: 1. 用户点"我已验证完" → 进入 resumeAllIntercepted 2. 距上次拦截 < 30s → setTimeout(30000) 排队 3. SW 在 30s 内被 kill 4. setTimeout 丢失 —— 永远不触发后续 resume 5. 用户卡在 intercepted 状态,再点按钮也无效(globalStatus 还是 intercepted,函数从头跑,但又设新 setTimeout,又被 kill...)

Bug C:TaskStatus 扩 'intercepted' 概念错误(v0.10.39 引入)

// v0.10.39
export type TaskStatus = '...' | 'intercepted';  // ← 扩了 intercepted

MapTask.status持久化态,实际写入永远不会是 'intercepted'(onInterception 改的是 batch-controller 内部的 TaskState.status,与 MapTask 是两份数据)。扩了类型是 footgun

  • 未来开发者读 TaskStatus,会误以为可以 task.status = 'intercepted'
  • 写了之后 task 持久化层会出现非预期状态
  • 实际真正用 'intercepted' 的地方应该是展示派生态(结合 progress.status)

修复方案

A → 持久化 InterceptState

const INTERCEPT_STATE_KEY = 'local:scheduler:intercept-state' as `local:${string}`;

interface InterceptState {
  lastInterceptedAt: number;
  verifyTabId: number | null;
  pendingResumeAt?: number;
}

async function persistInterceptState() { ... }
async function loadInterceptState() { ... }

// onInterception / reopenVerifyTab 写完都 await persistInterceptState()
// restoreBatch 调 await loadInterceptState() —— 确保 SW 重启后 verifyTabId 跟踪不丢

B → setTimeout → chrome.alarms

chrome.alarms 由 Chrome 持久化,SW kill 不丢。

const RESUME_ALARM_NAME = 'resume-from-interception';

export async function resumeAllIntercepted() {
  if (wait > 0) {
    if (pendingResumeAt && Date.now() < pendingResumeAt) return;  // 防重排
    pendingResumeAt = Date.now() + wait;
    await persistInterceptState();
    browser.alarms.create(RESUME_ALARM_NAME, { when: pendingResumeAt });
    return;
  }
  await doResumeFromInterception();
}

// background/index alarm handler
if (alarm.name === INTERCEPT_RESUME_ALARM) {
  doResumeFromInterception().catch(...);
}

doResumeFromInterception() 提取为独立函数 — 立即恢复(同步)和 alarm 延迟恢复(异步)共享。

C → 回退 TaskStatus 扩展

- export type TaskStatus = 'queued' | 'running' | 'paused' | 'done' | 'stopped' | 'intercepted';
+ export type TaskStatus = 'queued' | 'running' | 'paused' | 'done' | 'stopped';

task-view STATUS_META 改回 Record<TaskStatus | 'intercepted', ...> —— 类型签名直接体现"intercepted 是展示派生态,不是持久态"。

改动文件

文件 改了什么
src/utils/task-store.ts 回退 TaskStatus 扩展(去掉 intercepted)
src/sections/task/task-view.tsx STATUS_META 类型改回 Record<TaskStatus \| 'intercepted', ...>
src/entrypoints/background/batch-controller.ts + InterceptState 持久化 + doResumeFromInterception + alarm 替代 setTimeout
src/entrypoints/background/index.ts + INTERCEPT_RESUME_ALARM 路由 alarms.onAlarm
package.json 0.10.39 → 0.10.40

验证方式

Bug A 验证

  1. 触发拦截 → 看 console "verifyTabId=42"
  2. 等 30s(让 MV3 SW 被回收)
  3. chrome://serviceworker-internals 看 SW 状态:terminated 后再触发任何消息让它重启
  4. 看 console:"loadInterceptState verifyTabId=42"
  5. 关掉 sorry tab → 重开(让它走 onUpdated 路径)
  6. 验证完 sorry 跳走 → 应触发自动恢复

Bug B 验证

  1. 触发拦截 → 用户点"我已验证完"
  2. console 看 "resume scheduled in 30000ms via alarm"
  3. chrome://alarms 看 resume-from-interception 排队
  4. 强制让 SW kill(30s 不动)
  5. 30s 到 → SW 自动唤醒(alarm 触发)→ doResume 执行

Bug C 验证

  • pnpm compile 0 错误
  • task.status 类型不再允许 'intercepted'(IDE 应高亮)

如何避免再犯

  • MV3 中 module-level let 不可靠 —— 任何重要状态必须 persist 到 storage,restore 时 load
  • MV3 中 setTimeout/setInterval 不可靠 —— 长延迟(>10s)改用 chrome.alarms
  • 持久化状态 vs 运行时派生态要分清 —— 不要为了"类型干净"扩持久化 union,应在使用点单独 union
  • 任何"在某段时间后做某事"的逻辑都要问:"如果 SW 在这段时间被 kill 会怎样"
  • alarm 排队前要去重 —— pendingResumeAt && Date.now() < pendingResumeAt 防用户连点

相关问题

  • [[0025-google-captcha-ux-poor|0025-Google拦截交互不到位]] — v0.10.37 引入拦截交互(埋下这两个 bug)
  • 扩展reload生命周期 — MV3 SW 生命周期沉淀
  • 同类风险待排查:是否其他地方也用 setTimeout 做关键长延迟逻辑?(v0.10.40 复盘建议)

复盘:为什么 v0.10.37 写时没发现

v0.10.37 写时的注意力在"用户感知层"(横幅、toast、按钮交互),没意识到:

  1. let + MV3 SW 寿命 = 不可靠
  2. setTimeout + MV3 SW 寿命 = 不可靠

这是 MV3 通用陷阱,但项目里 v0.10.15 watchdog 已经用 alarm 处理过类似问题(Tab生命周期与看门狗)。类似模式应该建肌肉记忆

建议:以后任何 background 新加的"在 X 时间后做 Y"逻辑,必须问: - 状态有没有持久化? - 延迟有没有走 alarm?