[ISSUE-0026] 拦截恢复机制在 SW 重启时丢失状态 + setTimeout 不可靠¶
相关源码:
src/entrypoints/background/batch-controller.tsv0.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 触发:
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 引入)¶
但 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 验证¶
- 触发拦截 → 看 console "verifyTabId=42"
- 等 30s(让 MV3 SW 被回收)
- chrome://serviceworker-internals 看 SW 状态:terminated 后再触发任何消息让它重启
- 看 console:"loadInterceptState verifyTabId=42"
- 关掉 sorry tab → 重开(让它走 onUpdated 路径)
- 验证完 sorry 跳走 → 应触发自动恢复
Bug B 验证¶
- 触发拦截 → 用户点"我已验证完"
- console 看 "resume scheduled in 30000ms via alarm"
- chrome://alarms 看
resume-from-interception排队 - 强制让 SW kill(30s 不动)
- 30s 到 → SW 自动唤醒(alarm 触发)→ doResume 执行
Bug C 验证¶
pnpm compile0 错误- 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、按钮交互),没意识到:
let+ MV3 SW 寿命 = 不可靠setTimeout+ MV3 SW 寿命 = 不可靠
这是 MV3 通用陷阱,但项目里 v0.10.15 watchdog 已经用 alarm 处理过类似问题(Tab生命周期与看门狗)。类似模式应该建肌肉记忆。
建议:以后任何 background 新加的"在 X 时间后做 Y"逻辑,必须问: - 状态有没有持久化? - 延迟有没有走 alarm?