上一轮(10-23 的深入体检)没有改代码,只记了 5 个 P0 + 9 个 P1。 这一轮把它们全部关掉。
前三个 P0 是三件不同的事,但根子是同一个:
两个上下文在写同一份账,而规则只认得其中一个。
后面几组各自有自己的根因,每一节单独写。最后一组(P1-10/11/12)不是产品 缺陷,而是门禁本身在撒谎 —— 报告的东西和实际检查的东西不是一回事。
src/stores/usage-store.ts 里,每个月只有一份「本月累计」的累加器记录。
worker 记了一笔 120 token,一个更早 hydrate 过的上下文(比如设置页
打开着)再记一笔 10 token,它那份不含 worker 数据的快照就把 120 覆盖
成了 10:
after worker write: acc-2026-10 byDay tokens=120
after stale ctx2 write: acc-2026-10 byDay tokens=10 <- 120 消失
FINAL summary: {"translations":1,"billableCalls":1,"tokens":10}
用户看到的账单比真实的少。明细记录还在,所以能看出少了一次调用; 但那 120 token 本身没了 —— 累加器是被替换掉的,不是被合并的。
累加器的 id 只带月份:acc-2026-10。
两个上下文算出同一个 id,于是 unionRecords 认为「这是同一条记录的
两个版本」,按「时间戳新的赢」取一个。而这个规则只对同一个写者的同一
条血脉成立:worker 自己的多次写入,保留新的那份是对的。两个不同的
写者共用一个 id 时,它变成了一场「谁后写谁赢」——后写的那个把前一个的
数字整个替换掉。
id 里带上写者身份:
const WRITER_ID =
typeof window === 'undefined' ? 'worker' : `page-${CONTEXT_ID}`;
'worker' —— 它是 MV3 的 service worker,空闲 30 秒就
被回收,30–60 秒就要重启一次;id 必须跨重启稳定,否则每重启一次就多
一份累加器。page-${CONTEXT_ID} —— 每个页面实例一份,互不
干扰。两个写者现在握着互不相交的总额,collectUsage 把它们加起来。写者
变了,id 就变了,冲突的前提就消失了。
这里用
typeof window === 'undefined'区分 worker 和页面:service worker 没有window,扩展页面有。这不是取巧,是这条边界上唯一一个能同时在 两侧成立的判据。
两个测试,都做过变异验证(改坏实现 → 测试必须变红):
| 测试 | 变异后 |
|---|---|
| 「早 hydrate 的上下文不会让账单变小」 | 红:expected 120 to be 130 |
| 「一个月一份累计,不是每重启一份」 | 红:累加器变成 2 份 |
第二个测试用 vi.stubGlobal('window', undefined) 模拟 worker,
vi.resetModules() + 动态 import() 造出第二个上下文 —— 两个进程
共用一个 chrome.storage.local 的真实形态。
src/providers/openai-compatible-base.ts 两处 408 的判断,正好反了:
| 位置 | 请求 | 原来 | 应该 |
|---|---|---|---|
:346 |
GET /models(列举,模型没跑) |
记了账单 | 不记 |
:412 |
chat completion(真跑了模型) | 不记 | 记 |
更糟的是 allowBillableProbe: false(设置页每 15 分钟的健康轮询用的正是
这个参数)只挡住了 404 分支,挡不住这条。于是:网关一慢,用户的账上就
每 15 分钟多一次「计费调用」,而服务商根本没为他跑过任何模型。
计费规则被写在错误的调用点上。规则的正确表述是「跑了模型的请求
才计费」,但代码把它写成了「返回了错误的请求才计费」——两个不同的问
题。/models 是个列举接口,它问网关「你有哪些模型」,从没跑过一个。
规则跟着问题走:
/models 超时 → 不记。这是列举,模型没跑,没有东西要收钱。src/providers/usage-recording.ts 的文档也同步改了 —— 之前那段注释
写着「只有超时算」,读的人会以为超时一律算。
三个测试(列表超时、轮询超时、chat 探针超时),全部做过变异验证:
改回去之后分别红成 1 vs 0、1 vs 0、0 vs 1。
凭据边界有两道门(scripts/check-release-consistency.mjs):
checkCredentialBoundary):走 import 图,看内容脚本
有没有碰到凭据模块。checkNoCredentialsInContentScript):扫 dist/content.js
的字节。第二道是真的,第一道有洞。而且第一道跑的位置也不对。
洞:判定 type-only import 的正则漏了三种形态。
| 形态 | 原来 |
|---|---|
import { X } from '...' |
CAUGHT |
import X from '...' |
CAUGHT |
import * as X from '...' |
CAUGHT |
import X, { type Y } from '...' |
MISSED |
| 缩进的 import | MISSED |
位置:--source-only 跑在 inject-env-config.mjs 之前。而源码门禁
要看的正是 src/shared/env-credentials.generated.ts —— 那个文件是注入
脚本生成的。干净 checkout 里它还不存在,门禁于是无图可走,给出一个
虚假的绿灯。
(所以这一条的严重级别不是「密钥会泄漏」—— 产物门禁拦得住。真实的 严重级别是「CI 的第一道门空转」,以及它让人以为源码层已经被守住了。)
type 前缀、
且没有 default / namespace 绑定时,才算 type-only。package.json 里把 inject-env-config.mjs 挪到 --source-only 之前
(build 和 build:public 都是)。三个测试:带 default 绑定的 type-only 混写、缩进的 import、以及一条
读 package.json 断言注入顺序的测试。全部做过变异验证。
这五条共用一个根因:translationCancelled 是模块级布尔量。
任何一处取消都会把它置上,而只有”整页翻译”会在开始时把它清回 false。 于是它同时坏在两头:
每一轮翻译有自己的身份。
interface RunToken {
readonly epoch: number; // 这一轮开始时页面的纪元
cancelled: boolean; // 用户按了取消
reason: AbortReason; // 被什么中止的
}
cancelled 属于一轮。取消发给所有正在跑的轮次,但下一轮从零开始。epoch 属于页面。清除、换章、卸载会推进纪元:还在路上的一轮发现
自己的纪元过时了,就知道自己拿到的一切都属于旧的那一页——不画,也不记。isRunDead(token) = 被取消了 或 已经过时。请求发出前、结果回来后、
以及这一轮收尾写状态之前,都是这一个判据。
请求飞着的时候页面换了画面,回来的是旧画面的译文。修法是在发请求 前记住这张图的地址,回来时比一次:不一样就丢掉这份结果,并且不动那张图 的”已翻译”标记——它还得留在候选里,等下一次请求。
判据和”画面换了就撤掉旧译文”用的是同一条(isSameArtwork:换掉查询串
之后还是同一个文件就是同一张画面),所以两处不会各说各话。
被取消的那一轮还在收尾(在路上的请求撤不回来),但它不会再发新的请求 了。以前这一次的点击被静默丢掉——用户看着页面毫无反应。
现在:互斥锁被一个已被取消的轮次占着的时候,这一次点击会等它收完, 然后接上去。真的有一轮在跑的时候,点击仍然被挡下(用户没有要求跑两遍)。
五个测试,全部做过变异验证:
| 测试 | 变异 | 结果 |
|---|---|---|
| 旧轮的答案不会画到新一轮的页面上 | isRunDead 恒为 false |
红 |
| 换过画面的图不会被画上旧译文 | 去掉画面比对 | 红 |
| 旧章的结果不会把新章标成已翻译 | isRunDead 恒为 false |
红 |
| 取消之后再点一次会真的开始 | 去掉”等上一轮收完” | 红 |
| 右键单图翻译不会被上一轮的取消连累 | 取消退回页面级布尔量 | 红(其余四个也一起红) |
最后一条那个变异就是旧代码本身:取消重新变成页面的事实,五个测试全部 变红。
像素精修把模型给的框吸附到页面上真的墨迹上。它有一道闸门:找到的字如果 和模型要的框不是同一段文字,就不精修(否则译文面板会盖住邻居的原文)。
闸门比的是「包含度」:两个框里小的那个有多大比例落在大的里面。
问题是第四种:墨迹 ⊇ 模型框(墨迹把模型框整个包住)。这时交集就是模型框 本身,比例恒等于 1 —— 闸门根本不响。
而这一种里藏着两件完全不同的事:
包含度分不开它们,因为它们在几何上是同一件事。
换一个问题问:模型框能解释这团墨迹的多少?
MIN_INK_COVERAGE = 0.2。这个数不是拍的:合法情形是 0.328(两行里框住
一行),越界情形是 0.029(一个小框落在一整段文字里)。
一个测试,两次变异验证:
| 变异 | 结果 |
|---|---|
| 去掉新闸门 | 红:「does not let a small box swallow a whole block of text」 |
| 阈值改成 0.5(太严) | 红:「snaps a short box onto both lines of text」 |
第二次变异同时证明这个阈值是有边的:再严一点,模型少框一行这种最常见的 情况也会被拦掉,精修就白做了。
这三条不是产品缺陷,是门禁本身在撒谎。它们的共同点是:门禁报告的东西 和它实际检查的东西不是一回事。
smoke-test-public-build.mjs 打开一个 file:// 页面,给内容脚本发一条
GET_STATE。file:// 页面只有在扩展开了「允许访问文件网址」时才能收到
消息,所以「没收到」有两种原因,而原代码把它们合成了一种:
| 实际情况 | 原判定 | 应该 |
|---|---|---|
| 内容脚本答了 | 通过 | 通过 |
浏览器拦了 file://,内容脚本没被送达 |
通过 | 未验证(失败) |
| 浏览器没拦,内容脚本仍然没答 | 通过 | 失败 |
第二行那个「通过」是致命的:一个内容脚本完全没注入的构建,只要跑测试的 机器没开文件访问权限,就能过 smoke。门禁把「没验证」写成了「已验证」。
修法:向 worker 问 chrome.extension.isAllowedFileSchemeAccess()。有权限
还拒收 = 内容脚本真的死了 = 失败;没权限 = 这次没验证成 = 失败,并在
detail 里写明是哪种。
判定逻辑抽到 scripts/lib/smoke-verdicts.mjs,因为它不需要浏览器就能测。
expect(true).toBe(true)keepalive 和并发度热更新这两条路径,原来的测试最后一行是
expect(true).toBe(true) —— 中间所有调用都只是「跑一遍没抛异常」。删掉
startKeepalive() 调用、删掉 syncQueueLimit() 调用,测试一样绿。
现在的断言落在可观察的东西上:
chrome.alarms.create('manga-translator-keepalive', {0.5, 0.5});chrome.alarms.clear(...);chrome.storage.local)→ 闹钟不动;最后一条在唯一能说真话的地方观察:同时在飞的 provider 调用数。
docs/store-claims.md 把每条对外承诺绑到一个代码锚点上,
check-store-claims.mjs 验证锚点还在。但它是在原始文件里搜字符串:
删掉 OverlayRenderer 这个类、留一行提到它的注释,锚点照样「存在」。
锚点说的是「代码在做这件事」,注释不是代码。
修法:先把注释剥掉再验。扫描器同时跟踪字符串字面量(否则 URL 里的 //
会被当成注释,把同一行后面的真代码一起吞掉 —— 那是误报,和漏报一样有害)。
这一改立刻抓出两条现存的坏锚点:
| 承诺 | 原锚点 | 问题 | 新锚点 |
|---|---|---|---|
| OCR 语言包从 jsdelivr 下载 | text-detector.ts:jsdelivr |
只在注释里 | text-detector.ts:!langPath |
| 出站请求只有三类 | 同上 | 同上 | text-detector.ts:!langPath |
| 没有统计、遥测、崩溃上报 | usage-store.ts:按天聚合 |
只在文件头注释里 | usage-store.ts:accumulator |
!符号名 是新加的锚点形式,表达「这条承诺靠的是这个符号没有被赋值」。
jsdelivr 那条正是这样:tesseract.js 在 langPath 未设置时才用 jsdelivr,
所以承诺的事实就是「我们不设 langPath」。这种「靠缺席成立」的承诺没有别的
锚法,不引入这个形式,它就只能继续锚在注释上。
| 测试 | 变异 | 结果 |
|---|---|---|
| 拒收 = 失败 / 未验证 | 拒收算通过(旧行为) | 红 2 条 |
| 拦了 vs 死了要分开 | 合并成一句 | 红 1 条 |
| keepalive 建立/清除 | 不调 startKeepalive |
红 |
| 无关写入不动闹钟 | 任何本地写入都重设闹钟 | 红 |
| 并发度热更新 | 监听器不同步并发度 | 红(超时) |
| 并发度热更新 | updateMaxConcurrent 不 drain |
红(超时) |
| 注释里的符号不算 | 检查原始文件(旧行为) | 红 2 条 |
字符串里的 // 不算注释 |
不跟踪字符串 | 红 |
!symbol 形式 |
不支持 | 红 2 条 |
| 标识符边界 | 退回子串搜索 | 红 |
写测试时有一条一开始没通过变异验证:最初的「注释里的撇号」用例在去掉 字符串跟踪后依然绿,因为那个撇号本来就在行注释里。换成「字符串里含 URL」 才真正压住这个分支。变异验证的价值就在这里 —— 它把「看起来在测」和 「真的在测」分开了。
项目已经确立了「新测试必须做变异验证」,但这套规矩只覆盖了产品代码 的测试,没覆盖门禁脚本。#10 和 #12 就是这么来的:一个把「内容脚本 完全失聪」当通过,一个把「符号删了、注释还在」当通过。
这一轮补上了这三道门禁的变异验证,但没有系统性地给 scripts/ 下每一个
门禁做同样的事。那仍然是一项待办。
自动化已经就位(pnpm release:publish 会打包、上传、发布),但
Chrome Web Store 的 dashboard item、PUBLISHER_ID / ITEM_ID、Google
Cloud 的 API 凭据、支持页 URL —— 这些在仓库里一份都没有,只有 Jasper
能建。