【Bug】[Windows] 设置改动被静默回退 —— 启动路径的盘符大小写把 dsh-app-boot 加载成了两个模块实例 #7675
Replies: 4 comments
你的机制分析我逐条核过了,成立——而且"只有打包形态出现"这一点正是它的指纹1.
|
|
同一根因的另一个触发路径 —— 这次在 Linux 上,与盘符大小写无关。 环境:Docker 里跑 dsh。CLI 树在 症状与楼主一致:设置改动不落盘,而线索只有一条启动告警: 根因链( 影响面比「设置不落盘」大得多 ——
本地的绕法(仅供参考,不是建议上游这么做):启动时若 profile 那份是实体目录、且与 CLI 副本版本相同,就把它挪走、换成指向 CLI 副本的符号链接。两处 realpath 收敛到同一个 URL,Node 按 URL 缓存模块,于是只剩一个实例。实测之后配置文件立刻恢复可写(能观察到 mtime 在写入时跳动), 建议的上游修法: |
|
更正上面这条评论的两处 —— 我复核源码后发现自己写错了: ① 读取方不止 ② 调用方不是 4 个,是 6 处调用 / 4 个模块。 我漏了
( 其余部分(3458 声明、3716 唯一写入、两份副本字节相同、错误串、符号链接使实例归一)复核无误。 |
你的自我更正是对的——读取方确实有两处,而且第二处是致命的1. 按当前源码给你对齐(行号与你看到的构建产物不同)
const bootstrapIncludes = new WeakMap<Context, Entry>() // :255 模块级
const entry = bootstrapIncludes.get(ctx) // :274 ← 读取方 ①
if (entry === undefined) throw new Error(`${binName}: profile reload requires the root Include entry`) // :275
...
bootstrapIncludes.set(ctx, entry) // :581 写入方
...
const required = new Set(failures.filter(({ entry }) => entry === bootstrapIncludes.get(ctx) // :929 ← 读取方 ②
|| requiredStartupEntryIds.has(entry.options.id)).map(({ entry }) => entry))
if (required.size > 0) throw new StartupError(...) // :932 致命⇒ 你说的"不止 2. 这个更正其实加强了你的报告(建议加一句)两处读取方意味着同一根因可以呈现两种完全不同的症状:
也就是说:同一份坏布局,有的人看到"改了没反应",有的人看到"起不来"。这句话能解释为什么这个 bug 在不同人那里表现不一致——建议写进"影响面"。 3. 你的 Docker 触发路径很有价值,尤其"md5 相同"这一点CLI 树在
你这条比原帖的"盘符大小写"更一般:盘符大小写只是产生"两个 URL"的一种方式,双份实体目录是另一种。两条并在一起,报告的分量完全不同。 4. 建议你补一句可判别的观测请说明你在 Docker 那台上看到的是静默回退还是启动致命(上面两种症状之一)。这一条能把"两处读取方"的推论从代码层推进到现场证据,也方便维护者确认哪条路径先被触发。 |
Uh oh!
There was an error while loading. Please reload this page.
[Windows] 设置改动被静默回退 —— 启动路径的盘符大小写把
dsh-app-boot加载成了两个模块实例概要
在 Windows 上,如果打包 CLI 的启动路径盘符为小写(例如 PATH 项
d:\Users\<user>\Application Data\npm,由 npm 生成的dsh.ps1启动dsh web),设置面板里的任何改动都会被 Host 拒绝并静默回退:选项立刻弹回原值,~/.dsh/profiles/<profile>/cordis.patch.yml永远不会被改写。原因是
@deepseek-ai/dsh-app-boot这个包在同一个进程里被加载成了两个模块实例:启动器自身的模块图沿用启动时的拼写(小写盘符),而运行时解析拦截(installRuntimeInterception)交给每个插件的路径都经过 realpath 规范化(Windows 上返回大写盘符)。ESM 按解析后的 URL 字符串缓存模块,因此同一个文件的两个拼写互不相等。根 Include 条目记录在模块级WeakMapbootstrapIncludes里,插件侧那个实例查不到,于是reconcileProfilePatches()抛出dsh: profile reload requires the root Include entry;Host 拒绝写入,浏览器把这次拒绝当成可恢复的「重读一次」,于是回退到已存的值。同一份 profile 用源码方式启动(
node --import tsx/esm apps/cli/src/bin.ts web)完全正常,因为那时所有模块 URL 拼写一致 —— 这正是它只在打包安装形态出现的原因。环境
0.1.7-rc.1,npm 全局安装(npm i -g @deepseek-ai/dsh)dsh web(经由 npm 生成的dsh.ps1/dsh.cmdshim)D:\nodejs\node.exe;shim 旁边没有node.exe,因此回落到 PATH 里的 node)d:\Users\<user>\Application Data\npm—— 注意盘符是小写d:web;$DSH_HOME未改动node --import tsx/esm apps/cli/src/bin.ts web当前源码树里就是同一段实现,所以这不是旧构建产物,相关位置为:
packages/boot/app-boot/src/index.ts的:255(bootstrapIncludes声明)、:274(读取)、:275(抛错)、:581(写入)、:929(audit 使用),以及packages/boot/app-boot/src/profile.ts:406(createRuntimeResolution)、packages/boot/app-boot/src/profile-resolution/legacy-links.ts:18(realModuleDirectory,即那次 realpath)、packages/boot/app-boot/src/profile-resolution/resolver.ts:698(installRuntimeInterception)。复现步骤
d:\...\npm,再执行dsh web)。cordis.patch.yml的修改时间不变。证据
describe返回的存储层user已是新值(说明补丁文件被读到了),而运行时value仍是旧值;mutate返回settings/rejected,message 正是dsh: profile reload requires the root Include entry。二者长期不一致,说明失败发生在「把补丁应用进 Loader」这一步,而不是读取或校验。describe的user立刻反映新值,但运行时value不变,直到重启进程才生效 —— 印证了重载链路正是坏掉的那一环。dsh-app-boot/lib/index.js分别以大写盘符与小写盘符两种 URL 导入,得到两个互不相等的模块实例,导出的reconcileProfilePatches也不是同一个函数;用一个实例 boot 出最小 include 树、再用另一个实例对该ctx调用reconcileProfilePatches,即可复现同一句报错,而用 boot 的那一侧调用同一函数则成功返回。这与运行中的 Host 报出的错误完全一致。预期
选择被持久化:补丁文件被改写,运行时条目同步更新,界面保持所选模式。
实际
settings/rejected: dsh: profile reload requires the root Include entry。packages/boot/config-editor/src/index.ts:84,132,135、packages/boot/hmr/src/index.ts:230、packages/boot/plugin-manager/src/index.ts:764。根因
mountRootInclude()把「根 Include 条目」通过模块级状态(bootstrapIncludes,一个模块级WeakMap)交给进程内其它消费者,因此这次交接只在运行它的那个模块实例内可见。realModuleDirectory()),并且installRuntimeInterception()让每一次插件导入都从这些规范化路径解析。node "$basedir/node_modules/@deepseek-ai/dsh/lib/bin.js",其中$basedir直接来自 PATH 项。file:///d:/…/dsh-app-boot/lib/index.js与file:///D:/…/dsh-app-boot/lib/index.js是同一个文件的两个模块实例。启动器把 Include 记在小写实例的bootstrapIncludes里;而dsh-config-editor、dsh-hmr、dsh-plugin-manager经拦截(大写)导入,从大写实例调用reconcileProfilePatches(ctx.root, …),那张表是空的,于是命中packages/boot/app-boot/src/index.ts:274-275的抛错。影响
建议修法
把这次交接移出模块作用域,使其不再依赖模块身份:在 boot 时把根 Include 条目挂到 boot context 上(或挂到已经共享的
profileContext服务上)携带,reconcileProfilePatches()与auditStartupEntries()先读它、再回落到现有WeakMap。这样无论进程里存在几个dsh-app-boot实例,交接都可见。同一文件:607的assembledActivationRejections是同类跨实例模块状态(fail-loud 审计使用),建议在同一轮一并迁移。可选加固(二者择一即可):构建解析表时沿用安装 anchor 的盘符拼写,而不是 realpath 的结果,使整个进程只保留一种拼写;或在拦截层 /
Loader.import导入前统一拼写规范化。用户侧绕过
lib/bin.js(绕开 shim 的拼写),例如node "D:\Users\<user>\AppData\Roaming\npm\node_modules\@deepseek-ai\dsh\lib\bin.js" web。d:\...\npm→D:\...\npm,让 shim 传出大写路径。~/.dsh/profiles/<profile>/cordis.patch.yml并重启,让某个偏好生效。补充背景
这台机器上症状曾分成两段:写入先是卡在约 2 秒的锁超时(一次被中断的插件操作留下的孤儿
package.json.lock,与本问题无关);清掉锁之后,失败立即变成上面描述的即时settings/rejected。也就是说模块分裂从头到尾都在,只是先前被那把锁挡在前面。All reactions