fix(native): 注册失败的 tab 类型必须释放,否则 id 永久占用(插件重载后文件树变空态) - #777
Conversation
A plugin reload (or a disable/enable) tears the client fiber down while this module's own service/store subscriptions are still live: unregistering each built-in descriptor notifies the plugin's service, and every notification ran `sync()` again. The `files` takeover is never a descriptor — its key is `FILES_KIND` — so the drop loop released it every single time, and the block below re-created it. Re-registering on the now-inactive context throws inside `registerSlots` (`ctx.slots.inject` cannot create an effect on an inactive context) *after* `tabs.register` already put the type into the host registry, so that registration's disposer was lost with the exception. The id then stayed taken for the rest of the page's life: every later attempt was refused with sidebarRight: tab type id "dsh-better-sidebar:files" is already registered and the kind rendered the host's "nothing here can view this kind of content yet" face until a page refresh. - `sync()` skips the `files` takeover in its drop loop: the takeover is owned by the editor-type switch, so an unrelated notification no longer puts its host-side type through a tear-down/re-register window; - a registration that fails after its type was registered releases the type again (and whatever slot disposers it collected), so the same id stays registrable and a retry can succeed; - one descriptor that cannot register no longer aborts the whole sync, so the remaining types are still brought up; - teardown releases every registration even when one release throws. Verified: tests/native-registration.spec.ts (new, 4 cases) is red 4/4 against the unfixed code and green with the fix; tsc/eslint clean and the full unit suite passes.
Menghuan1918
left a comment
There was a problem hiding this comment.
Review 通过 —— 根因已在真机日志与代码两侧验证
红→绿实测(本机、非静态推断):把本 PR 的 tests/native-registration.spec.ts 放到当前 main(606339f)的源码上跑 → 4/4 失败;切到本 PR head(3728e90)跑同一文件 → 4/4 通过。另在 head 上跑 pnpm typecheck / pnpm lint / pnpm test → 114 files / 1136 passed / 9 skipped,全绿。
根因链(与官方桌面壳日志逐字吻合):官方桌面壳日志(desktop.frontdesk.log 2026-09-27 15:51:39.338 起)第一条就是
native register files error: Error: cannot create effect on inactive context
assertActive ← effect ← inject(slots) ← registerSlots ← registerFilesKind ← sync ← notify
即 teardown 期间 service.notify()(同步内联)驱动 sync():清理循环把不属于描述符的 files 接管释放,同一次 sync 又重建它 —— tabs.register 建在宿主 ctx 上(宿主 ctx 仍活着)→ id 被取走;ctx.slots.inject 建在插件 ctx 上 → 抛 INACTIVE_EFFECT → live.set 永不执行 → 该 id 在整页生命周期内不可再注册。紧接着同一毫秒 6 条 already registered,随后新激活(_reload 栈)也注册不上 → 文件树 tab 空态直到刷新页面。
本 PR 的两处改动正好覆盖这条链的两半:跳过 FILES_KIND(去掉 teardown/re-register 窗口) + 注册失败回滚已占用的 id(id 永远可回收),两者都是根因级修法;且回滚不会越权释放(宿主 register 的守卫全部在 ctx.effect 之前,原子),失败路径不留 live 条目、下次通知自然重试 —— 因此不需要 #766 的「吸收」方案。已合并。感谢定位与补测!
独立 review(对照 606339f / 4099700 静态复核 + 日志逐行核对)提出的问题,逐条修正: 1. **补回被我自己抹掉的源码注释**:上次验证「未修复代码上 4/4 红」时用了 `git checkout 606339f -- src/client/native/index.ts`,随后 `git checkout HEAD -- <同一文件>` 把**尚未提交**的 `disposeSafely` 注释一起还原掉了 —— PR 描述里承诺的 「说明释放失败为何只走 console.error」因此在 diff 里根本不存在。已重新落盘。 2. **事故文档三处事实修正**:同一毫秒的 `already registered` 是 **7 条**(.339 一条 + .340 六条,合计 burst 9 条),不是 6;验证段的「114 files / 1136 passed」是修复分支 (base 早于 #781)的数字,补上口径并给出合入 main 后的 122/1292;`index.ts:353` 标注 为 v0.22.0 行号(main 上因 #777 顺移)。 3. **撤回一处措辞过头**:「部分回滚分支从未被执行」→ 回收集合虽然进了、但 disposer 列表恒为空(释放动作未执行),改后能真正覆盖。 4. **e2e 门不再可能静默通过**:`test.skip(CLIENT_JS === undefined, …)` 改成硬失败—— 这个门的价值就在于 harness 以后不再导出 DSH_HOME 时必须报错而不是变成 skip。 同时把「无 `[data-sidebar-right-unavailable]`」移到文件渲染断言**之后**(前者在空 pane 上恒真,不是真空但也没重量)。 5. **测试替身补齐保真度**:假注册表的 disposer 改为幂等(真宿主的 disposer 是幂等的 ——cordis effect 二次释放返回同一个 task),避免「二次 release 又被记一笔」这种与 真实宿主不一致的行为被后续用例当成事实。 6. **AGENTS §2 的 HMR 机制加版本限定**:`mtime/ctime/size` 的 sha1 rev 是**钉住的宿主 0.1.7-rc.1** 的行为;DSH 源码树新版本改成只比 `mtime+size` 并对内容取 sha1,pin 上调 时该触发器必须同步复核(否则会红在替换断言上,而不是静默失效)。 验证:typecheck / lint 通过;pnpm test 122 files / 1292 passed / 9 skipped(重启前机器上有 两个 scratch dsh 服务时的 fs-watch 超时是负载抖动,单独跑 5/5 绿、清空负载后全量绿)。
omdsh-dev#777 已修掉根因(清理循环跳过 FILES_KIND、注册失败回滚已占用的 id),但它的两处 断言写在「活着的 id 集合」上——泄漏的 id 恰好构成同一个集合,未修复代码上同样 通过;且槽位失败判据按 key 命中,而一个描述符的两个槽共用一个 key,所以 registerSlots 的「部分回滚」分支从未被执行。 - tests/native-registration.spec.ts:断言改写到注册表的事件日志(孤儿 id 会让键 集合看起来完全正常);槽位失败判据改为 name::key,从而可以只让某个描述符的 第二个槽失败、真正走到部分回滚;补 reportFailure 的 phase 断言。实测:在 v0.22.0 源码上 4/4 红,在修复后的源码上 4/4 绿。 - src/client/native/index.ts:给 disposeSafely 补注释,说明释放失败只写 console.error、不弹诊断条的理由(两个调用点都在 teardown 路径上;真被占住的 id 会在下一次注册尝试里经 reportFailure 显式报出)。 - AGENTS.md:§3.4 第 9 条补上这两条不变量(含真机日志实证的链接),§6 把 native-registration.spec.ts 纳入关键测试守护。 - docs/plans/2026-09-28-native-files-takeover-reload-leak.md:事故/证据/否决方案 记录(含「吸收」方案为何被否)。 验证:pnpm typecheck / pnpm lint 通过;pnpm test 122 files / 1292 passed / 9 skipped。
`sync()`'s cleanup loop compared the live registrations against the DESCRIPTOR list, which never contains the `files` takeover (its key is `FILES_KIND`, not a descriptor id). Every store/service notification — a Session switch is one — therefore disposed and immediately rebuilt it: [native] DISPOSE type dsh-better-sidebar:files [native] register type dsh-better-sidebar:files Disposal unmounts that kind's body, so the explorer's own component state (its scroll offset) was rebuilt on every pulse even when the plugin-side record survived. Its lifetime really belongs to the editor type's switch, which the loop below already handles; skip it in the cleanup pass. Pinned by "keeps the files takeover registered across a Session switch pulse": reverting the guard turns it red (`expected 2 to be 1` — 2 is the rebuilt registration count). Rebased onto omdsh-dev#777 (`4099700`): the guard itself now lives on `main` — its cleanup pass already skips `FILES_KIND` and releases through `disposeSafely` — so what remains in this commit is the comment above that guard, recording the second reason it is load-bearing (a re-registration on an already inactive context orphaned the id), plus the regression spec below.
- package.json / dsh.plugin.json / SIDEBAR_SERVICE_VERSION → 0.22.1(三者锁步由 tests/service.spec.ts 与 tests/manifest-consistency.spec.ts 断言) - README / README_EN:新增 v0.22.1 条目、同步三处徽章与 latest/支持线表格、 切回 npm 通道的示例版本号;「最近更新」保持两条(v0.22.1 + v0.22.0), 更早版本指针区间随之下移为 v0.21.1 → v0.12.3 - docs/external-plugin-guide.md:当前版本行 → v0.22.1 - CHANGELOG / CHANGELOG_EN:补 v0.22.1 条目(两个缺陷的根因链、真机证据、 修复要点与守卫清单) 支持线**不变**:仅 DSH 0.1.7-rc.1+(peer 下限 ^0.1.7-rc.1,CI 钉 0.1.7-rc.1); 0.21.1 / 0.22.0 用户直接升级;0.1.6-alpha.2 及更早仍固定 v0.19.1。 内容提要:① 修复客户端条目替换后 `dsh-better-sidebar:files` id 被孤儿化 (omdsh-dev#777 + omdsh-dev#785:清理循环跳过 FILES_KIND + 注册失败回滚已占用的 id 与槽位, 并新增部署级回归门 tests/e2e/native-reload.e2e.ts);② 修复 macOS 桌面版 窗口拖拽/双击缩放失效(omdsh-dev#773 + omdsh-dev#786:视口层用 initial !important 退出 app-region 计算,补齐放大视图,探针改用真实级联计算值)。 验证:make check 全绿(typecheck / lint / build / test 122 files 1293 passed 9 skipped / check:consumer-types);此外本地 pnpm test:mount 与 pnpm test:mount:aggregate 绿,npm 0.22.0 与修复版在 scratch profile 上的 红→绿对照见 PR omdsh-dev#785 / omdsh-dev#786。
问题
插件重载(或禁用→启用)后,
files接管类型的 id 被永久占用:此后插件文件树 tab 渲染成宿主的「此类型暂无内容」空态,只有刷新页面才能恢复。
根因
插件 reload 时 cordis 拆掉 client fiber,而本模块自己的 service/store 订阅仍活着:逐个注销内置描述符 → 每次注销都 notify 插件的 service →
sync()被再次触发。而
files接管不是描述符(键是FILES_KIND),于是sync()的 drop 循环把它每一次都释放,紧接着下面的代码块又把registerFilesKind跑一遍重建。正常路径下重建成功、无事发生;但 reload 期间 ctx 已 inactive,重建会在tabs.register已经把 type 放进宿主注册表之后,ctx.slots.inject(无法在 inactive context 上建 effect)抛错。异常把那个 registratio 的 disposer 一起带走了,于是 id 永远被占。
修复
files接管——它的寿命只由编辑器类型开关决定,无关通知不该让它走一遍 tear-down / re-register 窗口;SidebarSurface等公开签名零改动。复现
真机:
dsh web+ 真实 profile → 设置页禁用 better-sidebar 再启用(或改插件源码触发 HMR)。随后文件树 tab 显示宿主空态。注意路径上有个前提:单纯改设置(关/开某个 tab 类型)不会出症状——同一次
sync()会重建成功。只有「重建那一刻 ctx 恰好 inactive」才泄漏,所以复现要禁用→启用这种真正触发 fiber teardown 的操作。单测:
tests/native-registration.spec.ts(新增 4 例)在未修复的代码上 4/4 红(已实测反向确认),修复后全绿。该 spec 用一个镜像宿主两条硬规则的假注册表(一个 id 只能注册一次、返回的 disposer 是唯一释放途径)锁住这个生命周期。验证
tsc --noEmit0 错、eslint .干净