我写的 DSH 插件把宿主搞崩了四次,最后发现是同一个 bug 的两个副本
一句话结论:删代码要删「能力」,不是删「函数」。 我第一次「修好」的时候只删掉了函数名,同一段逻辑还藏在另一个分支里——两天后又崩了两次。
另一个同等重要的结论:「测试全绿」从来不等于「功能正确」。15 个测试全绿的同时,插件在热重载后其实是全 502 的。
先说结果
插件是 dsh-workbuddy-fix,解决 WorkBuddy / CodeBuddy 的 400 报错。
但本文不讲「怎么绕过关键词风控」,讲一个更难的东西:当你以为修好了、其实只修了一半时,怎么发现它。
这个坑有很强的普适性——任何包装 globalThis.fetch 的 Node/Electron 插件都可能踩到。
一、这个插件干什么
DSH 里很多插件会包装 globalThis.fetch 来拦截请求。我的插件做的是:在请求发出前,把提示词里的触发词用零宽空格(U+200B)打断,让上游的子串匹配不再命中,而模型读到的语义完全不变。
技术路线本身很干净:
const original = globalThis.fetch
const wrapped = async (input, init) => { /* 改写 body → 调 original */ }
globalThis.fetch = wrapped问题全出在热重载和多插件共存这两件事上。
二、第一次崩溃(第 1、2 次)
症状
RangeError: Maximum call stack size exceeded
dsh: fatal uncaught exception: RangeError: Maximum call stack size exceededDSH 整个进程直接退出。
根因
我当时加了个「自愈」机制:发现自己的 wrapper 不在最外层时,就把它抢回最外层。
// 危险代码
if (globalThis.fetch !== shared.wrapped) {
shared.wrapped.__wbFixPrev = globalThis.fetch
globalThis.fetch = shared.wrapped
}看起来挺合理。但如果另一个插件是这么写的:
const prev = globalThis.fetch // ← 抓住了「我们的 wrapper」
globalThis.fetch = (i, n) => prev(i, n)抢回最外层 + 记录 __wbFixPrev 之后,链条变成:
我们的 wrapper → __wbFixPrev(对方) → prev(仍是我们的 wrapper) → …∞无限递归 → 栈溢出 → 宿主崩溃。
修法:删掉 reassertFetchHook(),写了一大段注释,CHANGELOG 郑重记录,测试也补了。
那天我以为这事结束了。
三、第二次崩溃(第 3、4 次)—— 同一 bug 的第二副本
两天后,又是同样的栈溢出。
我一开始完全没往老地方想:reassertFetchHook 已经被删了,grep 也搜不到。
真正的原因:那段逻辑有两个副本。我删掉的是独立函数,但同一段代码还藏在 installFetchHook 的复用分支里:
export function installFetchHook(...) {
// 已经初始化过:复用同一个 wrapper
if (shared.wrapped !== null) {
shared.refs += 1
shared.handlers = { ... }
// 🔴 就是这几行 —— 我删了函数,却留下了它
if (globalThis.fetch !== shared.wrapped) {
shared.wrapped.__wbFixPrev = globalThis.fetch
globalThis.fetch = shared.wrapped
}
return makeUninstall(logger)
}
// ...
}热重载每 apply 一次就走一遍这个分支。连续几次之后环才闭合——所以它不是「装了立刻炸」,而是「用一阵子之后突然炸」。
这是最难查的一类 bug:延迟触发。它还有一个恶劣的特性——调试时高频重载反而会加速触发。那天我为了查另一个问题连续重载了七八次,正好把它逼了出来。
为什么「grep 函数名」救不了我
我当时的检查方式是:grep reassertFetchHook → 0 结果 → 认为干净了。
正确做法是搜逻辑,不是搜名字:
# 错的:搜函数名
grep reassertFetchHook
# 对的:搜它干的那件事
grep -n "globalThis.fetch = " # 所有赋值点
grep -n "__wbFixPrev" # 所有记录上游的地方按这个方式重查,一眼就看到了复用分支里那两行。
教训一:删代码时,要删的是「能力」,不是「函数」。
只要还有任何路径能执行到那段逻辑,就等于没删。
四、修的时候又踩了三个新坑
每修一个,就暴露下一个。
坑 1:旧 wrapper 读共享状态 → 跨代成环
即使删掉了「抢回最外层」,还有一条更隐蔽的环:
1. 别的插件包在我们外面,闭包抓着旧 wrapper
2. 热重载 → 卸载时发现「我不在最外层」,所以不还原
3. 重装 → original = globalThis.fetch 抓到的是对方
4. 旧 wrapper 内部读 shared.pristine → 读到「对方」→ 对方转回旧 wrapper → ∞
关键错误在第 4 步:旧 wrapper 不该读任何共享状态。
修法:每代 wrapper 闭包捕获自己那份不可变的 tail,配合 retired 弱集合:
const tail = pickSafeTail(original, shared.retired)
const wrapped = async function (input, init) {
if (shared.retired.has(wrapped)) {
// 已退役:只走自己的 tail,绝不读 shared.*
return tail(input, init)
}
// ...
}这样结构上就不可能再连到别的代。
坑 2:环保护用布尔标志 → 并发被误判
环保护原本用布尔:
if (shared.entering) return pristine(input, init) // 认为「重入了」= 环路
shared.entering = true
try { ... } finally { shared.entering = false }但 wrapper 是 async,await 期间 entering 一直是 true。正常并发请求被误判成环路,直接跳过改写。
实测(200 并发、同一批请求):
| 实现 | rewrite 调用 | 上游收到未改写 |
|---|---|---|
| 布尔标志 | 20 / 200 | 180 |
| 同步深度计数 | 200 / 200 | 0 |
90% 的请求被静默跳过。 这就是用户反馈的「改写时灵时不灵」——不是玄学,是并发。
修法:只统计同步进入次数。wrapper 本身不 await(同步返回 promise),finally 里立刻递减。正常并发互不重叠(深度恒为 1),真环路是纯同步调用,计数瞬间冲顶。
坑 3:HMR 重估后 tail=null → 所有请求 502
修完前面两个,我以为稳了。然后发现一个更严重的问题。
HMR 重新评估模块后,新的 shared 是空对象,但 globalThis.fetch 还指着上一个模块代的 wrapper。这时 original 带着我们的标记 → pickSafeTail 返回 null → tail = null → 所有 WB 请求返回 502。
用户看到的是「插件装了但请求全挂了」。而我当时的测试套件——全绿。
修法:pickSafeTail 遇到带标记的 wrapper 就穿透到它的 __wbFixTail:
function pickSafeTail(candidate, retiredSet, depth = 0) {
if (typeof candidate !== 'function') return null
if (depth > 8) return null
if (candidate.__wbFixWrapped === true) {
const inner = candidate.__wbFixTail
if (inner === candidate) return null // 自指,绝不跟随
return pickSafeTail(inner, retiredSet, depth + 1)
}
// ...
}实测:修复前 HTTP 502、上游调用 0 次;修复后 200、上游调用 1 次。
五、我是怎么验证「真的修好了」
要坦白一点:前面每次崩溃,我的测试都是全绿的。
15 个测试文件全绿的同时,插件在 HMR 后是全 502 的。「测试全绿」和「功能正确」之间隔着一条河。
关键:测试要能在「改坏后变红」
我做了对照实验——把修复临时改回坏的样子,看测试会不会红:
| 改回什么 | 哪个测试该红 | 实际 |
|---|---|---|
复用分支加回 globalThis.fetch = wrapped | 热重载测试 | ✅ 红 |
syncDepth 改成 await 期间保持计数 | 并发测试 | ✅ 红 |
环路兜底从 native 改回 tail | 环保护测试 | ✅ 红 |
只有验证过「会红」,测试才有意义。
新增的三个关键测试
① 真实重估模块(不是清变量)
// 用查询串强制 Node 重新求值模块 —— 这才是真实 HMR 的时序
const m1 = await import(HOOK + '?gen=1')
const u1 = m1.installFetchHook(...)
const m2 = await import(HOOK + '?gen=2') // 新的 shared,但 fetch 还指着上一代
const u2 = m2.installFetchHook(...)只清变量是测不出这个 bug 的——必须真的重新 import。
② 200 并发必须全部走改写
const reqs = Array.from({ length: 200 }, (_, i) => fetch(url, { ... }))
await Promise.all(reqs)
assert(rewriteCalls === 200) // 少一个就说明被误判为环路断言的是「全部」,不是「大部分」。因为「大部分正常工作」正是那个 bug 的表现。
③ 隔离进程压测
这一条最重要。我以前一直在当前会话自己所在的宿主上做热重载,等于一边开车一边换轮胎——期间插件曾一度从 bundles 里消失,所有路由 404。
后来建了个独立的 web profile(不同端口、独立进程),而且它同时装着另一个也包装 fetch 的插件——正好是崩溃的触发条件:
6 轮热重载:6/6 成功
30 次连续调用:30/30,结果零漂移
version=1.3.2 hook=active(全程不变)教训二:不要在正在使用的宿主上做破坏性测试。
哪怕它「应该」能自愈,你也会失去一个稳定的观测基准。
六、四条可以带走的经验
1. 删代码删的是能力,不是函数
删掉 reassertFetchHook() 不等于删掉「抢回最外层」这个行为。grep 要搜它干的事:
grep -n "globalThis.fetch =" # 找所有赋值点2. 布尔重入标志在 async 里几乎总是错的
只要函数是 async,entering 在 await 期间就一直为 true。正常并发会被误判成环路。
用同步深度计数或 AsyncLocalStorage。判据是「深度是否异常增长」,不是「是否有人正在执行」。
3. 环的终点必须结构上不可能指向自己
环保护需要一个兜底出口。这个出口不能是「当前全局 fetch」(可能是环的一部分),也不能是「上一代的上游」(同理)。
唯一安全的是第一次安装之前的那份 fetch——捕获它的时候,你的 wrapper 还不存在。而且只写一次,永不覆盖:
if (shared.native === null && typeof original === 'function') {
shared.native = original
}顺带一个坑:卸载时不要清空它。清了的话下次安装时 globalThis.fetch 可能已是别人的包装,就失去安全出口了。
4. UI 要用证据判断,不要用拓扑判断
我的面板曾经显示「主通道未接管」+ 红色,用户以为坏了——但下面「已处理 329 处关键词」证明改写完全正常。
「我们的 wrapper 不在最外层」是正常状态。 DSH 启动时十几个插件各自包装 fetch,谁在最外层取决于加载顺序。
正确的判据是实际计数,不是拓扑位置:
// 错的
const working = globalThis.fetch === ourWrapper
// 对的
const working = stats.rewrites > 0七、现在的状态
| 项 | 值 |
|---|---|
| 版本 | v1.3.2 |
| 测试 | 15 个文件,全部验证过「改坏会变红」 |
| 压测 | 隔离进程 6 轮热重载 + 30 次调用全过 |
| 崩溃日志 | 修复后无新增 |
代码和完整事故复盘都在仓库里:
github.com/balongTTY/dsh-workbuddy-fix
CHANGELOG 里记录了三次事故的完整根因链和每一次的实测数据——每一行都是付过代价的。
最后
写这个插件最大的收获,不是「零宽空格可以绕过关键词风控」这种技巧,而是:
「我修好了」这句话,只有在你能证明「改坏后会失败」的时候才成立。
前面三次崩溃,我每次都很确信自己修好了。证据是我跑了一遍测试——而那份测试根本覆盖不到出问题的路径。
现在我的标准是:先说清楚这个测试在什么情况下会红,再说它是绿的。
*如果你也在写包装 globalThis.fetch 的插件,希望这篇能帮你少崩几次。*