DSH plugin 源码模式启动报 expose-internals?HMR 服务排查与解决

故障排查发布于 2026-09-12作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSH pluginexpose-internalsHMR service源码模式
源码模式按官方文档跑 pnpm dsh web,进程立刻退出并报 --expose-internals is required for HMR service?根因不是缺 flag,而是 web 关掉 HMR 后启动收尾又重挂了 watch-only 实例。

如果你按官方文档在源码仓库里跑 pnpm install && pnpm run build && pnpm dsh web,进程立即退出,报 --expose-internals is required for HMR service——那么缺 flag 只是表象。 真正的链条是:web 组合包关掉了共享 HMR 行,但启动收尾又会重挂一个 root: [] 的 watch-only HMR 实例;这个实例本不需要 loader.internal,构造函数却无条件要求它;而产品预期的 native helper 在 pnpm 隔离布局下解析失败,且失败被空 catch {} 吞掉(#2699)。

DSH plugin 报错现象:pnpm dsh web 立刻退出

这个报错最误导的地方在于:它把一条开发期 Node 内部开关讲成了硬性要求,而真正的失败点(native helper 不可用)一个字都没提。 具体表现:

  1. 复现路径就是官方文档那条pnpm installpnpm run buildpnpm dsh web;预期是 Web UI 在 http://127.0.0.1:3080 正常启动,实际是进程立即退出,打印:
Error: failed to apply loader entry ... (@deepseek-ai/cordis-plugin-hmr):
--expose-internals is required for HMR service
[cause]: Error: --expose-internals is required for HMR service
at runProfile (apps/cli/src/profile-boot.ts:283)
at new Hmr (vendor/hmr/src/index.ts:121)

#2699) 2. 环境与仓库路径无关:Windows 11、Node v22.22.2(pnpm)/ v24.15.0 均可复现、0.1.0-rc.5(commit 47f943859b)。这一点值得先说清——它不是路径含中文之类的问题,纯粹是启动参数与依赖状态的问题(#2699)。 3. 根 package.json 的 dsh 脚本确实漏了 flag:第 136 行是 "dsh": "node --import tsx/esm apps/cli/src/bin.ts",缺少 --expose-internals。改成 node --expose-internals --import tsx/esm apps/cli/src/bin.tspnpm dsh web 就能起来(已本地验证)——所以这是一层真实的、但只是表象的问题(#2699)。 4. 为什么「web 已经关掉 HMR」不能作为反驳packages/bundle/web-app/cordis.patch.yml 里那段「等 Web 的 reload 生命周期测完再重新启用共享 HMR」的注释只说明共享模块热重载行被禁用。但 watch-only 重挂是另一段代码,它正是在「组合结果里没有 HMR 服务」时才触发的(#2699)。 5. 失败是「整次启动失败」而不是「HMR 不可用」:这次挂载发生在插件树已经 ACTIVE 之后;suppressShutdownError(定义在 apps/cli/src/profile-boot.ts:195,调用在 :296)在 fiber 仍活着时会把 setup 错误重新抛出。注意位置:它不在 vendor/hmr/src/index.ts,这是讨论中被更正过的一处细节(#2699)。

DSH plugin 机制:web 关掉 HMR 后,启动收尾又重挂 watch-only 实例

把「谁要求 internal」「internal 为什么是 undefined」「为什么失败会炸死整次启动」三件事串起来,才能看到这个 bug 的四层结构。 逐层拆解:

  1. 重挂点就在启动收尾apps/cli/src/profile-boot.ts:272-283 的注释写得很清楚——web 组合禁用了共享的 module-reload hmr 行,所以当组合结果没留下 HMR 服务时,要挂一个没有 module root 的 watch-only 实例:
ts
if (ctx.get('hmr') === undefined) {
  if (ctx.get('timer') === undefined) {
    await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' })
  }
  await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } })
}

而 HMR 构造函数在 vendor/hmr/src/index.ts:120-121 无条件要求 internal

ts
if (!this.ctx.loader.internal) {
  throw new Error('--expose-internals is required for HMR service')
}

#2699) 2. internal 的探测其实有两条路径,报错只提了一条vendor/loader/src/internal.ts:108-117——① 只有 process.execArgv 已经带 --expose-internals 时才 require('internal/modules/esm/loader');② 否则走 require('node-addon-require-builtin').requireBuiltin(id);③ 两步都失败返回 undefined报错文案把开发期的 Node 内部开关说成硬性要求,却把产品预期的 native helper 路径藏起来了#2699)。 3. helper 为什么在 pnpm 布局下不可用apps/cli/package.jsonnode-addon-require-builtin 写成 CLI 的正式依赖,而 vendor/loader/package.json 里它只是 optional peerfromInternal() 用的是 createRequire(import.meta.url),解析起点是 loader 包自己的目录而不是 apps/cli,所以 pnpm 隔离布局下会出现「CLI 装到了、loader 侧 require() 仍失败」,失败又被空 catch {} 吞掉。社区实测进一步坐实:JS wrapper 在 pnpm 布局下其实能 resolve(vendor/loader 侧有 symlink),但 requireBuiltin() 一调用就抛 No usable native binding found——主包目录下没有 .node binding 文件(#2699)。 4. binding 是怎么就位的(以及为什么有人能起、有人不能)pnpm-workspace.yaml 里的 allowBuilds.node-addon-require-builtin: false 对这个包其实是 no-op(它只有 build:js、没有安装脚本,放行没有意义)。binding 实际靠平台包(如 node-addon-require-builtin-win32-x64-msvc)的 prebuilt 复制到 %LOCALAPPDATA%/node-addon-native-custom-loader/native-cache 后加载;pnpm 版本不符(例如 PATH 里挂着旧版 pnpm)会导致平台包链接异常而 fail closed。用 corepack [email protected] 正确安装后实测 requireBuiltin() 正常返回模块、binding 就位——这可能是「用户环境与官方 CI 表现不同」的精确解释(#2699)。 5. 架构错配才是根因:watch-only 根本不需要 internalthis.internal 在 HMR 里的 6 个使用点是:L120-121 构造检查、L221 init、L193-195 _resolve、L332 getLinked、L419 reload 区、L466-484 backup/rollback。其中只有 L121 与 L221 在 root: [] 下必然触达,其余 4 处都在模块热重载路径上、watch-only 永远走不到。而 watch-only 真正做的事(registerConfig 走 chokidar 监听配置)完全不碰 this.internal#2699)。 6. 一个必须一起改的落地约束:即使把构造函数那道无条件检查拿掉,[Service.init] 在 root 为空时仍会先跑 this.internal.loadCache.get(mainUrl)只删检查、不把这段 loadCache 读改成可选,watch-only 会从现在的 --expose-internals 报错变成 TypeError,启动照样死——解耦时这两处必须一起改(#2699)。 7. 还有第三道防线值得知道:即使主 watcher 真收到事件,onChange 里 L250-253 的 config-reload 分支会先命中并提前 return(因为 cordis.patch.yml 是 loader include),所以 L265 那处 loadCache.has 确实到不了。这进一步说明 watch-only 的必达路径就是那两处(#2699)。

DSH plugin 修复:watch-only 与 module-reload 解耦,加上 flag、依赖与门禁三层

正解是按 root 分支解耦,而不是降级或放宽全部检查。 具体做法:

  1. P0 架构解耦(唯一能让 dsh web 无 flag 起得来的根本修法):在 watch-only 分支(root: [])跳过构造函数的 internal 检查并给 init 段的 loadCache 读取加 guard;module-reload 分支保留原检查——因为 L193-484 都依赖 internal,全局放开会让正常重载静默失效,那比报错更糟。落地清单是 L91 类型可空 + 构造 L120/L123 + init L221 三处,其余不动(#2699)。
  2. 明确撤回「打日志降级继续」这条建议profile-boot.ts:276-277 的注释写着 A silent skip would break the documented hot-reload contract——官方把热重载当作文档化契约,降级等于悄悄破坏契约,违背设计意图。第一性原则下正解只有架构解耦,让契约在无 flag 下也成立(#2699)。
  3. 立即可用的临时绕过:直接带 flag 启动。注意 --expose-internals 进不了 NODE_OPTIONS——仓库 engines^22.19.0 || >=24.0.0,这条线上 Node 会直接拒绝,而 internal.tsprocess.execArgv.includes('--expose-internals') 也只认 CLI argv。所以正确写法是:
bash
node --expose-internals --import tsx/esm apps/cli/src/bin.ts web

只是让 loader 走 Node 内部模块,不是安全加固,也不是长期方案#2699)。 4. P1 依赖修复:把 helper 从 loader 的 optional peer 提成 loader 自己的 dependency,或把 require 起点改到 CLI 安装树,避免 pnpm 隔离布局下「CLI 有、loader 看不见」;同时修正 prebuilt 的分发查找链,让 fail 的原因可诊断(#2699)。 5. P1 错误可见性:空 catch 至少要打日志,报错文案要同时提到 node-addon-require-builtin 解析失败,而不是只写 --expose-internals。这里有个治理侧发现值得记下:vendor 代码在 lint 里是双层不可见的——lefthook.yml:20 的 staged oxlint 排除 vendor/*/src/**.oxlintrc.json:25ignorePatterns 同样排除 vendor/**;更关键的是 categories.correctness 整体是 "off".oxlintrc.json:5),而空 catch 的 no-empty 规则正属于该类别。于是 AGENTS.md 里「空 catch 必须说明吞了什么」的纪律对 vendor 代码实际失效#2699)。 6. P0 发布流程:把官方黄金路径纳入 CI。现有测试构造不出这条崩溃路径apps/cli/tests/built-bin.e2e.ts:329 的测试明确「不激活启动依赖行」(测的是 --help / --host 0.0.0.0 这类路径);packages/boot/app-boot/tests/hmr-config.spec.ts 虽然测了 HMR 配置热重载、默认 root 也是 [],但它用测试 Context() + Loader 插件启动,测试 loader 的 internal 恒定存在(能直接 ctx.loader.internal!.loadCache.has 非空断言成功);CI 的 nodeCompatSmokeGates() 也只跑 source-worker / jsonl-zstd / source-launch / vitest-jsdom 等兼容冒烟,没有一条是真实的 dsh web 启动。所以「internal === undefined」这条路径在测试环境里根本不可达——加一条真实启动到 127.0.0.1:3080 的黄金路径冒烟,这类「官方文档命令起不来」的 bug 发布前就会被拦住(#2699)。 7. rc.7 新门禁的盲区(治理层):新增的 verify-optional-dependency-imports(commit 7b973e27)专门拦「optional dependency 的模块作用域静态 import」,但 #2699 恰好落在它的三重盲区里——① PUBLISHED_SOURCE 正则只匹配 packages/*/*/srcapps/*/src:36),vendor 完全不扫,而 loader/internal.ts 正在 vendor/loader 豁免区;② 它只遍历 AST 的 ImportDeclaration / ExportDeclarationL171-172),loader 用的运行时 require() + 空 catch 不在检查范围;③ 门禁文档自己承认动态 import 是 "last resort"(L15),而 loader 正是那个模式且未被检测;其 fixtures 也全是 packages/f/* 的静态 import 用例,没有 vendor、没有动态 require。这是「治理对自家代码严、对 vendor 信任假设」的系统性盲区(#2699)。 8. 给插件作者的启示:你在 DSH Plugin Hub 上分发 DSH插件、或者自己写 DeepSeek插件 的 HMR 类监听逻辑时,不要让「可选能力的缺失」变成致命失败——尤其是当这个能力只在一条分支上真正需要时。做法就是这里给出的形态:按是否需要分支持有依赖,需要时才要求,不需要时不要求;同时别用空 catch 吞掉「为什么没拿到」这一信息(#2699)。

DSH plugin 排查注意事项

先记住缺 flag 只是表象——真正的失败点是「启动收尾重新挂上的 watch-only HMR 实例」撞上一条本不需要的依赖,所以只加 flag 并不是正解。 八条要点:

  1. 缺 flag 只是表象:真正的失败点是 watch-only HMR 重挂 + native helper 解析失败。
  2. web 关掉共享 HMR 不代表不挂 HMR:启动收尾会补一个 root: [] 的 watch-only 实例。
  3. NODE_OPTIONS 放不进这个 flag:必须写在 CLI argv 上。
  4. 降级不是正解:热重载是文档化契约,silent skip 与注释里的设计意图相悖。
  5. 两处必须一起改:构造函数检查与 [Service.init]loadCache 读取,否则报错会变成 TypeError
  6. 解耦要按 root 分支:全局放开会让正常模块重载静默失效,比报错更糟。
  7. 测试构造不出这条路径:测试 loader 的 internal 恒在,需要真实启动的黄金路径冒烟。
  8. pnpm 版本会影响 binding:平台包链接异常会 fail closed,用 corepack 对齐版本可排除环境变量。
DSH Plugin Hub 插件市场:安装与分发含 HMR 监听逻辑的插件、核对版本

来源:Discussion #2699PR #576

常见问题

DSH plugin 的 web 组合包不是把 HMR 关掉了吗,为什么启动时还会撞上 HMR 的报错?

DSH plugin 里「关掉共享 HMR」和「不挂任何 HMR」是两件事,web 组合包禁用共享 HMR 不代表启动时不会挂 HMR。packages/bundle/web-app/cordis.patch.yml 确实把 hmrdisabled: true,但 apps/cli/src/profile-boot.ts:272-283boot() 成功之后会检查 ctx.get('hmr');发现组合结果里没有 HMR 服务时,它会**再挂一个 root: [] 的 watch-only 实例**专门盯 cordis.patch.yml。报错入口就在这里(来源:Discussion #2699)。

为什么 DeepSeek Harness 里一次 HMR 挂载失败,就会把整个 dsh web 启动直接弄死?

DeepSeek Harness 的这次挂载发生在插件树已经 ACTIVE 之后,所以一次失败会拖垮整次启动,而不是只让 HMR 不可用。具体来说,suppressShutdownError(定义在 apps/cli/src/profile-boot.ts:195,调用在 :296)在 fiber 仍然活着时会**把 setup 错误重新抛出**,于是整次启动失败——而不是「HMR 不可用但 Web 还能起来」。这也是为什么不能简单地改成打日志降级:profile-boot.ts:276-277 的注释明说 A silent skip would break the documented hot-reload contract,官方把热重载视为文档化契约(来源:Discussion #2699)。

DSH plugin 报的 `--expose-internals` 错误说的是真的吗,加上这个 flag 就能修好吗?

DSH plugin 的这条 --expose-internals 报错只说了一半真相:加 flag 能让它跑起来,但那不是产品预期路径。vendor/loader/src/internal.ts:108-117 的探测顺序是:只有 process.execArgv **已经带了** --expose-internals 时才去 require('internal/modules/esm/loader');否则走 require('node-addon-require-builtin').requireBuiltin(id);两步都失败才返回 undefined。也就是说产品预期的路径其实是那个 native helper,报错却把它藏起来、只提了开发期的 Node 内部开关。所以加 flag 能让它跑起来,但那**不是产品预期路径,也不是长期方案**(来源:Discussion #2699)。

为什么 DeepSeek Harness 官方会以为这个 DSH plugin 启动问题早就修好了?

DeepSeek Harness 官方以为早就修好,是因为 7/23 合并的 PR #576(eb0cc4eb18,"drop obsolete --expose-internals launches")把 flag 从 bin/dsh 和教程 06 里删掉了。当时动过测试——删掉的是 loader-smoke 里 prepends --expose-internals 那条用例——但**没有补「binding 缺失时无 flag 启动不炸」的层间契约测试**。官方假设 node-addon 总是可用,而 pnpm 隔离布局下的安装状态恰好让它不可用。于是变成「不是没测,而是当时的测试只覆盖正常路径的参数构造,不覆盖依赖缺失路径」(来源:Discussion #2699)。

相关术语

watch-only HMR
watch-only HMR 是只监听 cordis.patch.yml 这类配置层变化、不重载任何模块的 HMR 实例(config 为 root: [])。它本来不需要 loader.internal,却因为构造函数里的无条件检查而依赖这条链——这正是架构错配所在。https://github.com/deepseek-ai/deepseek-harness/discussions/2699
loader.internal
loader.internal 是 ModuleLoader 暴露 Node 内部 ESM loader 的入口。其探测有两步:execArgv 带 --expose-internals 时用 require('internal/modules/esm/loader'),否则用 node-addon-require-builtin 的 requireBuiltin();两步失败返回 undefined,且两段空 catch 会吞掉原因。https://github.com/deepseek-ai/deepseek-harness/discussions/2699
golden-path smoke(黄金路径冒烟)
golden-path smoke 是把官方文档里那条命令(pnpm dsh web 真实启动到 127.0.0.1:3080)纳入 CI 的冒烟测试。现有 e2e 刻意不激活启动依赖行,兼容门禁也不跑真实 dsh web,所以「官方文档命令起不来」这类 bug 发布前无人拦截。https://github.com/deepseek-ai/deepseek-harness/discussions/2699

来源