Skip to content

fix(native): absorb a duplicate tab-type id instead of reporting already registered - #766

Closed
MYCF711 wants to merge 1 commit into
omdsh-dev:mainfrom
MYCF711:fix/native-files-double-register
Closed

MYCF711 wants to merge 1 commit into
omdsh-dev:mainfrom
MYCF711:fix/native-files-double-register

Conversation

@MYCF711

@MYCF711 MYCF711 commented Sep 25, 2026

Copy link
Copy Markdown

问题 / Problem

真机(DSH 0.1.7-rc.2 + 本插件 0.21.1)上反复出现:

[dsh-better-sidebar] native register files error: sidebarRight: tab type id "dsh-better-sidebar:files" is already registered

reportFailure(#700 那条线)把崩溃降级成了日志,所以症状不是白屏,而是该次 sync() 里 files 接管静默缺位,外加持续的日志噪音。

根因

sync 同时订阅了描述符注册表和 sidebar store,而 SidebarStore.notify() 是内联跑 listener 的 —— 每一次偏好写入、会话切换、状态变更都会同步调用 sync,一帧内多次。

宿主注册表的时序是错开的:

// dsh-client-ui-sidebar-right
if (this.ids.has(id)) throw new Error(`sidebarRight: tab type id "${id}" is already registered`);   // 同步
...
const dispose = this.ctx.effect(() => {
  this.ids.add(id);       // ← 在 effect 内

于是一次 register 调用如果在宿主已经取走 id 之后抛出,调用方就拿不到任何句柄(live.set(...) 从未执行)。下一次 sync 再对同一个仍被占用的 id 调 register,守卫就抛 already registered。

改动 / Change

  • registerFilesKind 与 registerDescriptor 把 is already registered 视为「宿主已持有该行」:复用该类型,同时继续注册 body/title 槽位,而不是中断整个描述符。
    用消息匹配是唯一可行的判据 —— 宿主没有导出错误类,id 与 kind 两个守卫是两个独立的 throw new Error。
  • 新增回归测试:在一次半失败注册之后,通过 store 通知驱动第二次 sync,断言不再上报 files 失败。

验证 / Verification

项目 结果
pnpm typecheck ✅ pass
pnpm exec eslint(两个改动文件) ✅ pass
tests/native-surface.spec.ts ✅ 21/21 pass
tests/native-surface + service + builtins ✅ 104/104 pass

回归测试的有效性(这是关键):在未打补丁的 main 上,该测试失败并精确复现了线上报错:

AssertionError: a re-entrant sync must not report `already registered` for an id it no longer owns:
expected [ 'register files' ] to deeply equal []

既有失败(与本 PR 无关):plugin-meta 1 例、smoke 3 例(log 分页、revert、cherry-pick)在未改动的 main 上以相同方式失败,已在纯净 checkout 上复现核对;fs-operations 的并发用例为负载抖动。

环境:Windows / node 26。

备注 / Notes

…eady registered`

`sync` is subscribed to BOTH the descriptor registry and the sidebar store, and
`SidebarStore.notify()` runs its listeners INLINE — so every preference write,
session switch and state mutation calls `sync` synchronously, several times per
frame. The host registry takes an id inside the registration's effect (its
`ids.add(id)`) while its duplicate guard is a synchronous `ids.has(id)`, so a
`register` call that throws AFTER the host has taken the id leaves the caller
with no handle: `live.set(...)` never runs.

The next `sync` then calls `register` for an id the host still holds and the
guard throws `sidebarRight: tab type id "dsh-better-sidebar:files" is already
registered`, surfacing on a real 0.21.1 + DSH 0.1.7-rc.2 profile as:

    [dsh-better-sidebar] native register files error: sidebarRight: tab type
    id "dsh-better-sidebar:files" is already registered

Because `reportFailure` (the omdsh-dev#700 line of work) catches it, the symptom is not
a crash — it is a missing takeover plus permanent log noise, and the `files`
explorer silently stays unregistered for that sync.

Two changes:

- `registerFilesKind` and `registerDescriptor` treat an `is already registered`
  rejection as "the host already owns this row": the type is reused and the
  body/title slots still register, instead of aborting the whole descriptor.
  Matching on the message is the only handle available — the host exports no
  error class and the id/kind guards are separate `throw new Error` sites.
- A new regression test drives `sync` a second time through a store
  notification after a half-failed registration, and asserts no `files`
  failure is reported. It fails on the unpatched tree with
  `expected [ 'register files' ] to deeply equal []`.

Verified: `pnpm typecheck`, `pnpm exec eslint` on both files, and
`tests/native-surface.spec.ts` (21 tests) all clean. The 4 pre-existing
failures in `plugin-meta` / `smoke` reproduce on an unmodified main checkout.
@zangxx66

Copy link
Copy Markdown

补充一下大肥鱼发现的相同问题的补充:

跨平台独立复现确认

在另一套环境上复现了同一个报错,与你的 Windows / node 26 不同平台:

项 值
DSH 0.1.7-rc.2(Web profile web)
dsh-better-sidebar 0.21.1(= npm latest,26 个版本里的最新)
系统 macOS 27.0(build 26A428)arm64
Node v24.13.1
[dsh-better-sidebar] native register files error: sidebarRight: tab type id "dsh-better-sidebar:files" is already registered

触发条件补充(与"一帧内多次 sync"不完全相同的一条路径):报告者是插件环境变动时必现——即往 profile 里安装/卸载任意插件之后。插件集变更会触发客户端插件整体重挂载,此时新一次激活的 sync() 跑在旧激活的作用域释放之前,撞的是同一个 id。稳定复现,不是偶发。

补充证据:残留 id 不是消费方漏包 ctx.effect

接入指南 §9 把这类报错归因于"注册没包在 ctx.effect 里,HMR / 插件禁用后注册残留"。但本插件在这条路径上是合规的:

  • registerNativeSurface(...) 整体包在一个 ctx.effect 里(src/client/index.tsx:171-176),且 cleanup 里逐个 registration.dispose()(src/client/native/index.ts:355-359)
  • 注册表自身也把 ids.add(id) 包在 this.ctx.effect 里,注销在它的 cleanup
    所以残留来自跨作用域时序(新激活先于旧激活释放),而不是消费方没包 effect。这支持"由注册表/本 PR 吸收"的修法方向,而不是要求三方插件自查。

一个需要确认的点(可能影响回归测试范围)

absorb 之后如果 live.set(FILES_KIND, …) 记下的是空 disposer,那么当旧持有者随后释放该 id时,不会有人重新注册:

T0  新激活 sync() → register 撞车 → absorb(live.has('files') = true,记空 disposer)
T1  旧激活作用域释放 → 注册表 ids.delete('dsh-better-sidebar:files')
T2  后续 sync() → hasFiles 为 true → 不再重试 → files 接管永久缺位

也就是说,"先撞车、后释放"这个顺序下,absorb 只解决了日志噪音,接管可能一直缺位到下次页面加载。
想确认的是:这条顺序是否被回归测试覆盖?你写的用例是"一次半失败注册后,通过 store 通知驱动第二次 sync"(同帧重入),而上面是跨激活的顺序,两者的解不同——前者靠 absorb 即可,后者可能要"absorb 后仍监听旧持有者释放并补注册"或者干脆不 absorb、退到下一个 tick 重试。

另注

顺带发现 fail() 的诊断红条没有任何移除路径(只 appendChild、无 id/class、无 remove()),每次失败叠一条、刷新前不消失。与 #700 相关但不重叠(#700 管"让 reject 可见",这条管"可见之后不清理"),我另外单独开了一条。

环境/命令(供复核)

node -p "require('dsh-better-sidebar/package.json').version"   # 0.21.1
grep -n "native register files error\|is already registered" ~/.dsh/logs -r   # 空:纯客户端

@Menghuan1918

Copy link
Copy Markdown
Collaborator

关闭:根因已由 #777 以「释放」而非「吸收」的方式修复

感谢这个 PR——它把「半失败注册(宿主已取走 id,调用方拿不到句柄)」这一步说清楚了,而且本 PR 的注释块正是我们最终确认的机制。差别只在怎么收尾:

我们选后者的理由(也是 zangxx66 在评论里点出的风险):吸收之后,当旧持有者随后释放该 id 时不会有人补注册,files 接管可能永久缺位,而日志已经安静——即「用一个不可见的持久故障换掉一条可见的报错」。而「释放」保持了一个可证伪的不变量:注册失败之后,该 id 必须仍可注册。

另外,触发链比本 PR 描述的多一环,真机日志(desktop.frontdesk.log 2026-09-27 15:51:39.338)显示每次失败的起点是清理循环误删 FILES_KIND 接管、在同一轮 sync() 里重建,而重建发生在已经 inactive 的插件 ctx 上:tabs.register 建在宿主 ctx 上照样成功,ctx.slots.inject 建在插件 ctx 上抛 cannot create effect on inactive context —— 所以「同帧多次 sync」并不是必要条件,一次 teardown 内的通知就够。两条一起修(清理循环跳过 FILES_KIND + 失败回滚)才不会留下这个窗口。

后续:#785 把守护断言改写到注册表事件日志上、补了「部分回滚」覆盖与 reportFailure 契约,并新增部署级回归门 tests/e2e/native-reload.e2e.ts(npm 0.22.0 上 3/3 红、修复版 3/3 绿)。事故记录见 docs/plans/2026-09-28-native-files-takeover-reload-leak.md。欢迎在那边继续讨论——若你有反例证明「吸收」在此场景更安全,我们随时重开。

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.

3 participants