Replies: 33 comments 6 replies
|
现有回复里「web 组合包关掉了 HMR,所以不该撞上这条报错」只说对了一半。 为什么
|
|
补充两个从源码里挖到的治理侧发现,其中一处顺带更正:
想请维护者确认一个治理侧方向:是否值得加一条 verify 脚本扫描 vendor 下空 catch(或把 AGENTS.md 纪律覆盖范围扩到 vendor、并显式开启 no-empty),让这类静默失败在 CI 就被抓住?运行时降级与报错文案两条,我支持你上面的建议。 |
|
NODE_OPTIONS 那段我写错了,按你的实测改一下。 仓库 node --expose-internals --import tsx/esm apps/cli/src/bin.ts web根 降级还是整次失败、vendor 空 catch 要不要进 CI,还是留给维护者。 |
|
@FirmaSpring 把 watch-only 重挂和 helper 路径挖得很透了。我从第一性原则把整条链路又捋了一遍,补两层讨论里还没展开的,顺带把全局修复路径整理出来,供维护者参考:
(依赖不对称和错误文案静默两层,@FirmaSpring 前面已覆盖,不重复。) 全局修复路径(按优先级): P0 架构:watch-only 与 module-reload 解耦——唯一能让 dsh web 无 flag 起得来的根本修法; |
|
架构错配这条成立: 补一处落地约束:即使把构造函数那道无条件检查拿掉, 测试盲区属实: |
|
@FirmaSpring 对,我再顺着把 6 个使用点全过了一遍:this.internal 在 hmr 里共 6 处(L120-121 构造检查、L221 init、L193-195 _resolve、L332 getLinked、L419 reload 区、L466-484 backup/rollback)。watch-only(root:[])下只有 L121 + L221 两处必然触达,其余 4 处都在模块重载路径上,watch-only 永远走不到——所以「两处一起改」恰好覆盖 watch-only 的全部必达路径,没漏。 但解耦落地时建议按 root 分支而非全局放开:watch-only 分支跳过 internal 检查 + init 段 guard;module-reload 分支保留原检查(L193-484 都依赖 internal,放开会让正常重载静默失效,比报错更糟)。这样既修了 web 启动,又不破坏正常 profile 的重载契约。 |
|
@FirmaSpring 顺着你的思路又挖出两点,其中一点要修正我们此前的建议: 「打日志降级继续」这条建议应撤回。 profile-boot.ts:276-277 注释明言 "A silent skip would break the documented hot-reload contract"——官方把热重载视为文档化契约,降级=悄悄破坏契约,违背设计意图。第一性原则下正解只有架构解耦(watch-only 不依赖 internal,契约在无 flag 下也成立),不是降级。 另外补一条 L265 的第三道防线(前两条上一条说过):即使主 watcher 收到事件,onChange 里 L250-253 的 config-reload 分支会先命中提前 return(cordis.patch.yml 是 loader include),所以 L265 确凿到不了。落地清单保持:L91 类型 + L120/L123/L221 三处,module-reload 原检查全保留。 |
|
撤回「打日志降级继续」没问题。 L265 也对: |
|
补一个测试盲区的更深一层(之前没展开):packages/boot/app-boot/tests/hmr-config.spec.ts 其实有 HMR 配置热重载测试、默认 root 也是 []——但它是用测试 Context() + Loader 插件启动的,测试 loader 的 internal 恒定存在(L46 能直接 ctx.loader.internal!.loadCache.has 非空断言成功)。而真实 dsh web 里 loader.internal 可能为 undefined——「internal 为 undefined」这条崩溃路径在测试环境根本构造不出来,HMR 构造函数检查在测试里永远通过。这就是为什么测试齐全、却还是带着这个 bug 发布:测试 loader 的 internal 恒在,让崩溃路径不可达。黄金路径冒烟(真实 loader 参与启动)正是补这个洞。 |
|
补一个「官方新门禁的盲区」,可能比黄金路径冒烟更治本: rc.7 新增的 verify-optional-dependency-imports(commit 7b973e2)专门拦「optional dependency 的模块作用域静态 import」——但 #2699 恰好落在它的三重盲区里: vendor 不扫:PUBLISHED_SOURCE 正则只匹配 packages///src 和 apps//src(verify-optional-dependency-imports.ts:36),vendor 完全不检查——而 loader/internal.ts 恰好在 vendor/loader 豁免区; 所以这是「治理对自家代码严、对 vendor 信任假设」的系统性盲区——#2699 能穿透所有门禁,不只是测试没覆盖,是门禁的边界设计就没把 vendor 动态 require 纳入。建议把门禁扩展为「扫描 vendor 的动态 require 并强制处理缺失」,比只加黄金路径冒烟更接近根因。 |
|
@XuJianrui 测试盲区那条成立。 门禁三重覆盖也属实: |
|
@FirmaSpring 顺着「为什么官方以为修好了」再补一条历史证据,正好印证你说的「冒烟要造出 internal === undefined」:官方 7/23 合并的 PR #576("drop obsolete --expose-internals launches",eb0cc4eb18)把 flag 从 bin/dsh 和教程 06 里删了,教程明确改成 "HMR reads Node's loader internals through the Loader's native helper"——删 flag 时动过测试(loader-smoke 的 example-launch.spec.ts 删掉了 "prepends --expose-internals" 用例),但删的是 flag 相关断言,没加「binding 缺失时无 flag 启动不炸」的层间契约测试——官方假设 node-addon 总能用,而 pnpm 布局 allowBuilds:false 恰好让它不可用。现在 loader-smoke 已改名 test-support/loader-smoke,测试只剩 spawn 参数构造和进程隔离,loader 层「internal 是否可用」的验证在 #576 后彻底消失。所以不是没测,是当时的测试只覆盖「正常路径的参数构造」、不覆盖「依赖缺失路径」——正是你说的「造出 internal===undefined」。 |
|
补一条发布层证据(含一处修正):官方其实有「真实 dsh web(无 flag)+ 真实浏览器 HMR」的 e2e——apps/web/tests/hmr-live.e2e.ts(L95 直接 bin.js web --port 0,无 --expose-internals,真实 Chromium 验证 HMR),也被 vitest.web.config.ts 收录。它确实会进 CI:PR CI 经 node-24-consumers job(ci.yml:178,if: pull_request)执行 check:ci:consumers(ci.yml:257)→ webSnapshotGate → test:web:built 真实启动(run-gates.ts:197/416-419);serial-linux-selfhosted 另在 push 到 master 时执行(ci.yml:578)。但两条前提让它仍没拦住 #2699: PR CI 跑了但通过——官方 CI 环境 node-addon binding 就位,internal === undefined 这条崩溃路径在官方 CI 里构造不出来; 所以真正的盲区不是「存在但没跑」,而是「跑了但环境不对齐」:官方 CI 与用户 pnpm 布局(allowBuilds:false)的 binding 状态从未对齐——这正是「冒烟要造出 internal===undefined」的原因,冒烟应在等价的用户 pnpm 布局里跑。 |
|
补一个讨论里还没涉及的维度——上游定性:这条「watch-only 无条件要 internal」的链路不是deepseek-harness引入的,是 vendored Cordis 框架上游的原生设计。我对照了上游 cordiverse/cordis:hmr 构造函数同样的 "--expose-internals is required for HMR service"(上游 L80-81 = vendor L120-121)、同样的 loader.internal!.loadCache.has(上游 L138 = vendor L265)、loader 同样的 execArgv 检查 + node-addon 兜底(上游 L101/L107 = vendor L110/L116)——几乎逐行相同。上游从未修过:cordiverse/cordis 的 hmr 最近提交都是 logger/事件增强,npm latest @cordisjs/plugin-hmr 是 1.0.15(deepseek-harness的 vendor 1.0.16 甚至领先上游)。 好消息是 deepseek-harness拥有修复权限:vendor/README.md 明言 "fully owns its framework layer (auditable, patchable, pinned)",且 Local modifications 日志已有先例(cordis/src/fiber.ts 做过大量本地生命周期加固)。所以按 root.length===0 分支的解耦可以直接落在 vendor/hmr 本地,不必等上游;如果愿意,也可以上修到 cordiverse/cordis 让整个框架受益。 |
|
补最深一层,可能比「watch-only 不需要 internal」更根本:Cordis 框架内部的契约断裂。loader 的 fromInternal(): ModuleLoader | undefined(上游 cordiverse/cordis 的 loader/src/internal.ts:111 = vendor L120)明确声明 internal 可缺失——loader 内部三重容忍:空 catch、?.getOrInitializeCascadedLoader() 可选链、undefined 是合法返回值。但同仓库的 hmr 插件消费时把它当必有:构造函数 if (!this.ctx.loader.internal) throw(hmr L80-81)+ 非可选类型强赋值。同一框架内 producer 声明 optional、consumer 当 required——上游 Cordis 原生问题,deepseek-harness只是受害者。 另外确认 internal 依赖 native hack 是 Node 平台能力缺口(internal/modules/esm/loader 的 loadCache 无公开 API,实测 process.getBuiltinModule 对 internal 模块返回 undefined),module-reload 的 native 依赖属平台必要。所以修复可以更小:hmr 尊重 loader 已有的 | undefined 契约,把「必有」断言移到真正使用 internal 的路径——甚至不一定是「按 root 分支」的重构,而是契约对齐。 |
|
补三个讨论里还没涉及的角度,都和落地相关: ① 修复参照——仓库内已有正确的消费模式:loader.internal 的其余消费者都已实现 undefined 保护——packages/boot/app-boot/src/index.ts:498-503(if (internal === undefined) return super.import(...),注释 "preserves the original diagnostic for hypothetical embedders without it")、packages/preset/agent-presets/src/mount.ts:87-92(同款)、vendor/loader/src/config/tree.ts:154(if 检查)——hmr 是唯一不带保护的消费者。修复不必发明新模式:vendor/hmr/src/index.ts:91 的 private internal: ModuleLoader 改 ModuleLoader | undefined、watch-only 分支跳过 internal 检查与 loadCache 访问——照抄集成层已有写法即可。 ② CI 平台盲区:windows-native job(ci.yml:447,真实 Windows 自托管)PR 时跑 build+coverage,但 webSnapshotGate 不在其 gate 列表;macOS 主 CI 零覆盖(却发布 node-addon-require-builtin-darwin-* 平台包);node 22.19/26 矩阵(ci.yml:276-277)跑 check:node-compat(typecheck + source-worker/jsonl-zstd 兼容性冒烟),不含 web/HMR 运行——dsh web 在 Windows 的真实运行、HMR 链在 22.19/26 上的验证,至今都未进入任何 CI。 ③ npm 发布面:npx @deepseek-ai/dsh(README 第一推荐方式)发布包 engines 缺失(apps/cli/package.json 源码即 {},node 版本零提示);@deepseek-ai/cordis-plugin-hmr@1.0.16 构建产物(lib/index.js L107-108)实测与源码一致携带同款 throw——npm 用户同样暴露在这条契约断裂下。 |
|
接着上面契约断裂的讨论(感谢 @FirmaSpring 对根因与落地方案的确认),补一个治理侧的机制视角:vendor 在深度治理的每一层都是「黑盒信任」—— ① lint 双层豁免:lefthook.yml:20 与 .oxlintrc.json ignorePatterns(vendor/**)都排除 vendor; |
|
Revision 2 correction: the failure is not Web-only. I updated the source-backed operator worksheet in place: https://sandbaseai.github.io/deepseek-harness-handbook/hmr-expose-internals.html The current guide now keeps four conclusions explicit:
The visible If the direct command changes the result, it implicates loader-internal discovery; it does not make the flag a supported deployment fix. The worksheet separates source, npx, global virtual-store, and packaged layouts so evidence from one is not generalized to another. Disclosure: independent community guide, verified against rc.7 source; not an official fix conclusion. |
|
补依赖层最深的机制验证,把「binding 脆弱」从现象落到机制(均有源码位置与实测方式): ① 平台包解析只有两条路:node-addon-native-custom-loader@0.1.4 的 lib/index.js:482(optionalPackageCandidates)——候选① require(平台包名)(L484):Node 解析起点是 custom-loader 自身位置;候选② packageDir/../平台后缀(L487-490):入口包同级物理路径。(该包未声明 repository,源码位置 = 安装后的 node_modules/.pnpm/node-addon-native-custom-loader@0.1.4/node_modules/node-addon-native-custom-loader/lib/index.js:482,可在本仓库 checkout 中核对) ② binding 就位的分界线(实测):在 pnpm 11 正确安装的本仓库环境下,用 createRequire 从 custom-loader 位置执行 require.resolve('node-addon-require-builtin-win32-x64-msvc') 成功解析到 node_modules/.pnpm/node-addon-require-builtin-win32-x64-msvc@0.1.4/...——因为 .pnpm/node_modules/ 虚拟 hoist 根包含平台包;候选②(入口包同级 ../ 物理路径)实测也存在。binding 就位与否 = 平台包是否在 custom-loader 的 require 解析链上可达——旧 pnpm(依赖树不同、平台包未进虚拟根)、global virtual-store(虚拟根不含)都失败于同一点:不是「包没装」,是「从 custom-loader 视角解析不到」。 ③ 虚拟 hoist 根是 pnpm 默认行为,非显式配置:pnpm-workspace.yaml 无任何 hoistPattern/node-linker 等配置——binding 就位依赖 pnpm 11 的默认布局,未来默认值变化会悄悄破坏(隐性依赖;仓库可通过显式配置消除这个不确定性)。 ④ allowBuilds:false 的遗留:仓库在 vendor 再生成时声明 loader 依赖 node-addon-require-builtin@^0.1.4(vendor/README.md 本地修改日志第 2 条:"loader requires node-addon-require-builtin@^0.1.4 to match the runtime used by published app packages"),而 0.1.4 只有 build:js、无 install 脚本——pnpm-workspace.yaml:50 的 allowBuilds: false(源于 0.1.0 时代该包存在 install 脚本、发布安装下必然失败的历史)在 0.1.4 下是 no-op 但未清理——配置跟着历史走、没跟上依赖升级(若确认无必要可清理或加注释说明)。 |
|
补一个受影响面的精确结论 + 文档传播的角度: ① 受影响面 = web 场景:hmr 的挂载声明在 base bundle(cordis.patch.yml:20 root:['.']),但 headless / web-app 的 patch 都显式覆盖 disabled: true(headless/cordis.patch.yml:14-15、web-app/cordis.patch.yml:22-23)——base 的声明是「中性默认」,实际生效的 hmr 挂载 = web 场景由 profile-boot L283 挂载的 watch-only(root:[])——#2699 的受影响面精确定位为 web 场景(headless 等默认 profile 因 bundle 覆盖而安全;也说明 base 的「默认声明」需配合各 bundle 的覆盖层才能确定实际行为)。 ② 教程受众与环境前提:教程 06(docs/cordis-tutorial/06-composition-and-hmr.md:44)通过 VitePress 对外发布(website/docs.ts:231),但 npm 发布包不含 docs(tarball 实测)——npm 用户(npx 方式)唯一文档入口是 README(零版本指引)。教程的正常路径描述未提环境前提(binding 需就位、pnpm 版本)——用户按教程在旧 pnpm 下跑会得到误导报错。文档断层完整:README(npm 用户唯一入口,零指引)→ development.md(贡献者,有版本声明)→ 教程(学习者,未提环境前提)——npm 用户是文档保护最薄弱的群体。 |
|
@XuJianrui 第 23 条把受影响面收成「只有 web;headless 因 bundle 覆盖而安全」,源码对不上。
# packages/bundle/headless/cordis.patch.yml:12-15
# The shared module-reload HMR row stays off; the launcher's watch-only
# fallback still keeps the user patch layers live until the run exits.
- id: hmr
disabled: true同文件 L265–267 把这次 watching 写成无条件的:one-shot 也要先挂上,再靠 bounded shutdown 拆掉。headless 在 fiber 仍 所以 #2699 的爆炸半径是:凡是走 base 默认 |
|
补一个报错姿势的现成先例:tool-str-replace-editor(packages/fs)也在构造时强依赖一个环境相关的服务(ctx.sandboxPolicy),但它是有条件检查(文件系统受限才要求)+ 报错直接说缺什么("ctx.sandboxPolicy is missing")。hmr 的差别在于无条件检查 + 文案指向一个已从启动脚本移除的 flag。如果修复保持 fail-loud(module-reload 路径保留检查),照这个姿势就与仓库既有模式一致。 |
|
一个可能被忽略的细节:我报 #2699 时的 node(22.22.2/24.15.0)其实完全符合 engines(^22.19 || >=24)——所以根本没有「版本 warning 被忽略」这回事,node 约束是碰巧满足。真正的触发变量是 pnpm 版本,而 pnpm 版本在仓库里没有任何执行机制:engines 只声明 node(无 engines.pnpm);packageManager: pnpm@11.7.0 靠 corepack(默认不启用,development.md:12 只是建议);pnpm 11 的 devEngines 机制也未采用;lockfile 是 v9.0(pnpm 9 原生兼容,不构成拦截)。我当时用 PATH 里的旧版 pnpm 9.15.1 安装,全程没有任何拦截。如果想让这类「版本不符」在用户侧早期暴露:源码侧 --engine-strict(node)或 CI 校验 packageManager 匹配;npx 侧需给发布包补 engines 声明(现在 npm 完全不校验)。 |
|
给上游定性补一个逐点源码证据:对照 cordiverse main,上游 hmr 与 vendored 的 internal 引用逐点对应(15 处对 15 处)——构造断言(上游 L80-83 = vendored L120-123)、resolve v1/v2(L91-93 = L193-195)、loadCache 的 get/delete/set(L118/161/248/295/297/313 = L221/332/419/466/468/484)、非空断言(L138 = L265)。行号偏移来自已知修改(#1 i18n 移除在 Config schema 区、#9 registerConfig 新增字段+方法插在构造区后),且backup/rollback 的 loadCache 区与上游 diff 为空(#12 改的是 watcher 参数不触及该区);vendor/README 的 Local modifications 日志(18 条)也无任何 internal 契约条目。#2699 的契约断裂是上游原样继承,不是 vendoring 引入或加深的。 |
|
补充一个视角:verify-cordis-config(rc.7 引入的配置门禁)为什么没拦住 #2699——我本地把它跑了一遍(122 个配置文件全部通过,含 base 的 hmr 行、web-app/headless 的 disabled 行),也翻了它的检查逻辑:它检查的是「配置结构」(表达式节点、行归属、插件依赖声明),对 base/web-app/headless 的 patch 都覆盖到了(cordisConfigFiles glob **/cordis.yml 含所有 bundle 的 cordis.patch.yml;bundle 依赖检查 glob packages/bundle/*/,verify-cordis-config.ts:280-284)——但没有任何一个维度会往下推演「运行时行为」:组合结果里没有 HMR 服务时,runProfile 启动收尾会重挂一个 watch-only hmr(profile-boot.ts:270-283),而它构造时仍要求 loader.internal——这正是 #2699 的炸点,门禁的视野止步于「声明层合法」。另外覆盖层行平面检查只显式处理 base + web-app(verify-cordis-config.ts:149-151 注释 "The shipped Web surface";headless 是显式 profile 不参与这个维度——设计边界)。 顺带一个观察:门禁自己的测试(spec)只有 4 个用例、全在表达式检查;覆盖层 disabled 逻辑(L154-160)是 08-06 加的(ef089ed6ec),当时没配测试,后续也没补——和仓库「修改配测试」的惯例(vendor/README 每条都标 Covered by)在 scripts 侧不太一致。 这类「声明合法、运行时炸」的问题,可能更适合启动路径测试(黄金路径冒烟)来兜——门禁管声明,冒烟管执行。 |
|
补一个 #576「半迁移」的精确化:官方源码里 --expose-internals 残留只有 2 处——hmr 的 throw 文案(vendor/hmr/src/index.ts:121)和 loader 的 execArgv 检查(vendor/loader/src/internal.ts:110)——#576 删的只是启动脚本层(bin/dsh、package.json script、教程),loader 内核的 flag 优先路径完整保留(internal.ts:106-117,execArgv 检查在前、node-addon fallback 在后)——flag 从「默认启用」降级为「手动 opt-in」:在启动命令里手动加 flag(不是 NODE_OPTIONS——它进不了 execArgv,FirmaSpring 前面已更正这一点)依然有效——所以 hmr 的文案「技术上仍指向有效路径」,误导在于提示 flag 而非查 binding 的真实原因。 |
|
补一个「pnpm 布局脆弱区」的官方自述证据:client-modules 的解析锚点注释(packages/client/modules/src/index.ts:205-213)明言 "The modules package's own URL would miss sibling packages under pnpm's isolated node_modules"——官方在自家代码里绕开了这个脆弱区(用配置树 baseUrl 做解析锚点)。但 binding 平台包(node-addon-native-custom-loader 从自身位置 require)没有类似的锚点机制——而 custom-loader 是第三方独立包(无 repository),解析逻辑不受本仓库控制——#2699 恰好落在「官方在自家代码绕开、第三方路径无法控制」的边界上。 |
|
补一个「重挂 fallback 的环境假设」视角:profile-boot 的注释(L272-277)明言重挂 watch-only 是有意的 fallback——"the web bundle disables the shared module-reload hmr row...so when the composition leaves no HMR service, mount a watch-only instance" + "A silent skip would break the documented hot-reload contract"——注释本身预见了「组合无 HMR → 重挂」的形态。但 fallback 的实现继承了主路径的环境假设:watch-only hmr 构造仍要求 loader.internal(binding 就位才存在)——重挂路径的「binding 就位」前提与主路径一样未被校验(与 pnpm 版本零执行那条的环境零防线呼应)。这也解释了为什么 #2699 的修复要同时覆盖「声明层(disabled→重挂的设计)+ 环境层(binding 解析)+ 契约层(internal 可选)」。 |
|
补一个「为什么 hmr 触发重挂」的角度:web surface 的能力行 disabled 后走 preset 接管——web-app 注释(L281)明言 "lets each session mount a preset instead"(tool-bash/tool-pwsh 等);但 hmr 是基础设施(HMR 服务),不在 per-session preset 概念里——实测 4 个 preset 均无 hmr——它 disabled 后没有 preset 接管路径,只能由 runProfile 启动收尾的重挂兜底(profile-boot.ts:270-283;重挂块还包括它注入的 timer,L281)——而重挂恰好是 #2699 的炸点。web surface 的「disabled 处置双路径」:能力行走 preset、基础设施行(hmr+timer)走重挂——hmr 是重挂路径上撞 #2699 的那一个。 |
|
@XuJianrui L281 那段不是 hmr 的处置路径。 那一块标题是「the agent plane moves behind agent presets」,下面关掉的是 # packages/bundle/web-app/cordis.patch.yml:21-23
# TODO: Re-enable shared HMR for Web after its reload lifecycle is tested.
- id: hmr
disabled: true注释写的是 reload lifecycle 没测完,不是「让 preset 接管」。preset 里没有 hmr 是预期,它本来就不该进 per-session 那套。 重挂也不是「基础设施行没人接、只好兜底」。 timer 也不是 web 关掉才补的。base 里已经插了 |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
环境
复现步骤
预期结果
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)
根因分析
web配置(profile)启用了@deepseek-ai/cordis-plugin-hmr热更新插件。该插件构造函数会检查
this.ctx.loader.internal,若 Node 启动时未带--expose-internals就直接抛错(vendor/hmr/src/index.ts:121),而
loader.internal仅在process.execArgv含该标志时才有效(vendor/loader/src/internal.ts:110)。
根目录 package.json(第 136 行)的
dsh脚本为:"dsh": "node --import tsx/esm apps/cli/src/bin.ts"
它漏掉了
--expose-internals,导致官方文档写的pnpm dsh web起不来。(tsx 源码模式本身没问题,缺的只是这一个启动参数。)
建议修复
将第 136 行改为:
"dsh": "node --expose-internals --import tsx/esm apps/cli/src/bin.ts"
改完
pnpm dsh web即可正常启动(已本地验证)。(根据 CONTRIBUTING 说明,目前暂不接受外部 PR,故以 Discussion 形式反馈。)
All reactions