Repository navigation
[Bug] dsh-hmr: 热重载后 apply() 定时器永久携带 executing=true,导致 runExclusive 嵌套报错 #11121
Replies: 1 comment 4 replies
|
定时器保留 ALS 标记这个判断,与当前 master 的实现是一致的。不过方案 A 不能直接用在现有的队列上:它把 这样真正的嵌套调用就不会再被拒绝: await hmr.runExclusive(async () => {
await hmr.runExclusive(async () => {})
})内层任务排在外层后面,外层又在等内层,两个 Promise 都无法结束。另外,现有的 可以考虑保留事务上下文,但给每一次事务单独保存一个有生命周期的状态。例如,保留现有队列,只把标记改成: private readonly executing = new AsyncLocalStorage<{ active: boolean }>()
runExclusive<T>(operation: () => Promise<T>): Promise<T> {
if (this.executing.getStore()?.active) {
return Promise.reject(new Error('HMR transactions cannot be nested'))
}
const task = this.operations.then(async () => {
if (this.closing) throw new Error('HMR is disposed')
const state = { active: true }
return this.executing.run(state, async () => {
try {
return await operation()
} finally {
state.active = false
}
})
})
this.operations = task.catch(() => {})
return task
}这样事务结束后,定时器即使还引用着旧对象,读到的也是 这还需要一起调整同文件里另外三处读取: 我用 Codex 辅助核对了源码,并在 Windows / Node 24.22.1 上提取当前 |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
自己用DSH许愿写的插件,热插拔更新后一直跳出“HMR transactions cannot be nested”的刷屏信息,查找了一下原因,说是DSH内核中的一个固有问题,发上来报告一下。
dsh-hmr:插件热重载后,apply()里注册的定时器永久携带executing=true,导致该插件后续任何runExclusive调用被误判为嵌套事务TL;DR
Hmr#runExclusive()用AsyncLocalStorage标记「事务进行中」,但没有在进入事务前剥掉调用方残留的上下文。插件通过 HMR 热重载时,
apply()是在runExclusive事务里被调用的;此时apply()里注册的setInterval/setTimeout会把这个事务标记捕获并永久带着——事务结束后定时器仍在跳,其回调里的任何
hmr.runExclusive()调用都会被自己的嵌套检查拒绝,直到进程重启都无法自愈。
表现为:该插件所有需要写配置的能力(
configEditor.edit()、agentDefaultModel.saveSelection()等)静默失效,并每周期打印一条错误。环境
@deepseek-ai/dsh0.2.1-rc.2(npmlatest)@deepseek-ai/dsh-hmr0.2.1-rc.2/0.2.1-rc.3(均已核实受影响)v24.16.0已确认 rc.3 未修复:
@deepseek-ai/dsh-hmr@0.2.1-rc.3的lib/index.js:752-760与本机 rc.2 的runExclusive实现逐字相同,缺陷依然存在。
复现步骤
写一个插件,在
apply()里注册定时器,并在回调里做需要配置写入的操作:冷启动 DSH → 一切正常。回调和事务里
hmr.executing.getStore()为undefined。用 HMR 热重载该插件(改文件触发 watcher / profile patch 的
disabled: true→ 撤销 / 插件管理 off-on)。从此刻起,每一次定时器回调里
hmr.executing.getStore()都是true。回调中任何走到
hmr.runExclusive()的调用都会被dsh-hmr/lib/index.js:753拒绝:该状态持续到进程重启,无法自愈。
最小复现脚本
不依赖 DSH,用
AsyncLocalStorage+setInterval复刻runExclusive语义:实测输出
同一份插件代码,冷启动 0 次失败,热重载后 100% 失败。
根因
dsh-hmr/lib/index.js:污染链条:
dispatch()在runExclusive内执行(:895);partialReload()的reload()(:1098-1126,调用点在:1123)在同一个事务上下文里await fiber.await()激活插件 fiber,于是插件的apply()在事务上下文里执行;apply()里ctx.interval()/ctx.timeout()内部是setInterval/setTimeout。Node 的
AsyncLocalStorage会被定时器钉在注册点所在的上下文——此后每一次回调都沿用该上下文;executing=true;saveSelection()→configEditor.edit()→hmr.runExclusive()(dsh-config-editor/lib/index.js:134)在第 753 行被判定为「事务里再开事务」而拒绝。
注意这不是
setInterval的 bug,是 Node 的既定语义。问题在于runExclusive只防了「重入」,没防「调用方本身带着一个陈旧的事务标记」。
一个内部佐证
同一文件
:776的watchConfig里,团队已经显式剥离过上下文:说明「ALS 上下文需要显式清理」这个语义团队是认可的,只是没覆盖到「插件
apply()里注册的定时器」这条路径。建议修复
方案 A(推荐,最小改动)
在进入事务前主动剥掉调用方残留的上下文:
AsyncLocalStorage.exit()保证operation在「无事务标记」的上下文里开始执行。语义上这正是
runExclusive的意图——一次独立事务,不该继承调用方残留的上下文。已实测验证:把该改动施加到上面的最小复现(唯一差别是标 ★ 的那一行),
hot场景失败数从 3/3 降到 0:方案 B(更彻底,可选叠加)
不要在热重载路径上让插件 fiber 继承事务上下文——在
reload()/fiber.await()那段用this.executing.exit(...)包住,使插件apply()从一开始就在干净上下文里执行。这能同时保护那些「自己注册定时器、但回调里不经过
runExclusive」的插件逻辑(例如它们若在回调里读
hmr.executing.getStore()判断状态,也会读到错误的值)。方案 C(防御性,可选)
把
executing的标记值从真值true改为带身份的对象并校验身份,避免任何「看起来像事务」的残留值命中检查:影响面
apply()里注册周期性任务、且回调中含配置写入的插件:热重载后该能力静默失效,每周期一条
HMR transactions cannot be nested,只有重启 DSH 才能恢复。apply()里注册了 15 秒心跳,心跳中调ctx.agentDefaultModel.saveSelection()做自动切换模型。热重载后自动切换全线哑火,控制台每 15 秒一条该错误,用户看到的是「插件在报错」而不知道切换其实已经失效。
hmr.executing是实例属性、非公开 API,插件侧只能靠ctx.get('hmr').executing.exit()临时迂回,这属于依赖内部实现,希望官方在内核侧修掉。
附:临时规避(供其他插件作者参考)
在插件侧把定时器注册包进「剥掉上下文」的包装里(
executing非公开 API,需做存在性兜底):期望行为
runExclusive(operation)执行operation时,其上下文不应继承调用方残留的事务标记;热重载插件的
apply()也不应被视为运行在 HMR 事务内。All reactions