目录

我写的 DSH 插件把宿主搞崩了四次,最后发现是同一个 bug 的两个副本

20

一句话结论:删代码要删「能力」,不是删「函数」。 我第一次「修好」的时候只删掉了函数名,同一段逻辑还藏在另一个分支里——两天后又崩了两次。

另一个同等重要的结论:「测试全绿」从来不等于「功能正确」。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 exceeded

DSH 整个进程直接退出。

根因

我当时加了个「自愈」机制:发现自己的 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 / 200180
同步深度计数200 / 2000

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 的插件,希望这篇能帮你少崩几次。*