Skip to content

fix(native): 注册失败的 tab 类型必须释放,否则 id 永久占用(插件重载后文件树变空态) - #777

Merged
Menghuan1918 merged 1 commit into
omdsh-dev:mainfrom
yanzhaohui1999:fix/native-registration-leak-v2
Sep 28, 2026
Merged

Menghuan1918 merged 1 commit into
omdsh-dev:mainfrom
yanzhaohui1999:fix/native-registration-leak-v2

Conversation

@yanzhaohui1999

Copy link
Copy Markdown
Contributor

问题

插件重载(或禁用→启用)后,files 接管类型的 id 被永久占用:

sidebarRight: tab type id "dsh-better-sidebar:files" is already registered

此后插件文件树 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 永远被占。

修复

  1. drop 循环跳过 files 接管——它的寿命只由编辑器类型开关决定,无关通知不该让它走一遍 tear-down / re-register 窗口;
  2. 失败的注册把 type 释放回去(含已收集的 slot disposer),使该 id 保持可注册、重试能成功;
  3. 单个描述符注册失败不再中断整轮 sync,其余类型照常起来;
  4. teardown 时即便某个释放抛错,也释放完其余全部。

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 --noEmit 0 错、eslint . 干净
  • 全量单测 114 文件 / 1136 例通过
  • 新增 spec 4 例(红→绿均实测)

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 Menghuan1918 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 的「吸收」方案。已合并。感谢定位与补测!

@Menghuan1918
Menghuan1918 merged commit 4099700 into omdsh-dev:main Sep 28, 2026
3 checks passed
Menghuan1918 added a commit that referenced this pull request Sep 28, 2026
独立 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 绿、清空负载后全量绿)。
sdwhwzp pushed a commit to sdwhwzp/DSH-better-sidebar that referenced this pull request Sep 28, 2026
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。
yanzhaohui1999 added a commit to yanzhaohui1999/DSH-better-sidebar that referenced this pull request Sep 28, 2026
`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.
sdwhwzp pushed a commit to sdwhwzp/DSH-better-sidebar that referenced this pull request Sep 28, 2026
- 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。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants