fix(native): 钉牢 files 接管泄漏的守护断言 + 记录两条不变量

#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。
This commit is contained in:
Menghuan1918
2026-09-28 10:57:10 +08:00
parent 4099700bfa
commit 0b40f74ce8
3 changed files with 130 additions and 42 deletions
+2 -2
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,50 @@
# `files` 接管 id 泄漏:客户端条目替换后报错刷屏 + 文件树空态
日期:2026-09-28 分支:`fix/native-registration-leak-v2`(PR #777,已合并)→ 后续 `fix/native-leak-guard-tests`
关联 issue:[#771](https://github.com/omdsh-dev/DSH-better-sidebar/issues/771)(同串报错的独立复现与定位)、[#770](https://github.com/omdsh-dev/DSH-better-sidebar/issues/770)(同串报错,另一条 inject re-prove 猜测)、[#766](https://github.com/omdsh-dev/DSH-better-sidebar/pull/766)(「吸收」方案,已按本文结论关闭)
## 现象
```
[dsh-better-sidebar] native register files error: sidebarRight: tab type id "dsh-better-sidebar:files" is already registered
```
红色诊断条钉在页面左下角、触发时**同一毫秒刷出多条**;不致命,但 `files`(文件树)接管会**静默缺位**——原生右侧栏的「文件」入口渲染成宿主的空态,直到**刷新页面**才恢复。0.21.1 与 0.22.0(npm `latest`)都带此缺陷。
## 根因(真机日志 + 代码双向验证)
官方桌面壳日志 `~/Library/Application Support/dsh-tauri/logs/desktop.frontdesk.log:2370`(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
```
随后同一毫秒(.339–.340)**6 条** `already registered`(栈:`register ← registerFilesKind ← sync ← notify`),13 秒后新激活的 `_reload` 路径又报 2 条。链条:
1. `service.notify()` 是同步内联(`src/client/service.ts:696`),而 `registerNativeSurface` 把 `sync()` 订阅到它和 store 上(`src/client/native/index.ts:353`)。
2. `sync()` 的清理循环把 `live` 里**两类归属不同的条目**一视同仁:描述符类型(键 = descriptor id)与 `files` 接管(键 = `FILES_KIND`,**永远不在 `wanted` 里**)。于是**每次**通知都会释放接管、并在同一轮里重建它。
3. 插件 teardown 时,内置描述符逐个注销 → 每次注销 notify → `sync()` 在**已经 inactive** 的插件 ctx 上运行。此时 `tabs.register` 建在**宿主** ctx 上(宿主 ctx 仍活着)→ id 被取走且返回了 disposer;紧随的 `ctx.slots.inject` 建在**插件** ctx 上 → cordis `assertActive()` 抛 `INACTIVE_EFFECT`(`@deepseek-ai/cordis@4.0.4` `effect()` 首行)→ `live.set(FILES_KIND, …)` 永不执行 → **该 id 在本页生命周期内再也不能注册**。
4. 之后每次 notify(日志里还有 6 次)都重试同一个被占用的 id → `already registered` 刷屏;新一次激活同样注册不上 → 文件树空态。
**两条一般化的不变量**(已写进 [AGENTS.md](../../AGENTS.md) §3.4 第 9 条):
- 清理循环必须**跳过 `FILES_KIND`**;接管只由编辑器类型的 `wantsFiles/hasFiles` 分支与 seat disposer 管理。
- 「注册成功一半」是**可达状态**(宿主 ctx 与插件 ctx 的寿命不同),因此任何在宿主取走 id **之后**失败的注册必须**回滚释放该 type**(并释放已建槽位),插件侧只留一个下次通知可重试的失败。
## 修复
PR #777(贡献者 @yanzhaohui1999)落地上面两条:`sync()` 的清理循环跳过 `FILES_KIND`;`registerSlots` 失败时释放已建槽位;`registerDescriptor` / `registerFilesKind` 在槽位注册失败后回滚已占用的 type;teardown / 清理循环逐个安全释放(一个释放抛错不再中断其余)。
**否决备选方案**(PR #766 的「吸收」):把宿主抛出的 `already registered` 当作「宿主已持有该行」继续注册槽位。它能让日志安静,但会在 `live` 里记下一份**不拥有**的注册(空 disposer)——旧持有者随后释放该 id 时无人补注册,`files` 接管可能永久缺位;而且在 `tabs.register` 抛错(id 守卫,原子,未取走任何东西)与「取走后才失败」两种情形之间不做区分,掩盖了真正的账本错误。
## 验证
- **单测红→绿(实测)**:`tests/native-registration.spec.ts` 在 v0.22.0 源码上 4/4 红,在 PR head 上 4/4 绿;head 上 `pnpm typecheck` / `pnpm lint` / `pnpm test`(114 files / 1136 passed / 9 skipped)全绿。
- 首版用例的两处断言(「失败后还能重新注册」)写在**活着的 id 集合**上,在未修复代码上会**同样通过**(泄漏的 id 恰好构成同一个集合)。跟进 PR 把它们改写到注册表的**事件日志**上,并把槽位失败判据从 `key` 改成 `name::key` —— 两个槽位共用一个 `key`,按 `key` 失败永远只能失败第一个槽,`registerSlots` 的部分回滚分支从未被执行;改后该分支有覆盖,且 4 例在 v0.22.0 上仍全部红。
## 已知限制 / 未做
- **跨激活的瞬时重复**仍未消除:页面内条目替换时,新激活可能先于旧激活释放 id 而撞上 `already registered`(一次日志噪音,随后自愈)。要消掉它需要「吸收 + 旧持有者释放后补注册」的机制,与上面否决的方案同源,暂不做。
- `disposeSafely` 的释放失败只写 `console.error`、**不弹诊断条**(teardown 阶段弹条是噪音;真被占住的 id 会在下一次注册尝试里经 `reportFailure` 显式报出)。理由写在源码注释里。
+78 -40
View File
@@ -14,12 +14,19 @@
* `tab type id "…" is already registered`. The kind then renders the host's
* "nothing here can view this kind of content" face until a page refresh.
*
* Two properties keep that impossible, and both are asserted here:
* Three properties keep that impossible, and all are asserted here:
* 1. an unrelated notification never re-creates the `files` takeover (the
* tear-down/re-register window the reload used to walk into);
* 2. a registration that fails after its type was registered releases the
* type again, so the id stays registrable and one broken descriptor cannot
* take the others down with it.
* take the others down with it;
* 3. slots installed before the failing one are released too (the partial
* release inside `registerSlots`).
*
* The assertions are written against the fake registry's EVENT LOG, not only
* against the set of live ids: an orphaned type leaves the key set identical to
* a healthy one, so a key-set assertion would pass on the unfixed code and pin
* nothing.
*/
import { describe, expect, it, vi } from 'vitest'
import { registerNativeSurface } from '../src/client/native/index.ts'
@@ -58,19 +65,28 @@ function createRegistry() {
/**
* A fake client context: `slots.inject` runs its callback synchronously (the
* real one resolves the declaration and calls `ctx.effect`, which throws while
* `inactive`), `slots.register` throws for a key listed in `failKeys`, and
* `inactive`), a slot listed in `failSlots` throws from `slots.register`, and
* `inject` hands back the registry while honouring the callback's own teardown.
*
* Slot failures are keyed by `name::key` — NOT by `key` alone. Both slots of a
* descriptor share the same key, so a key-keyed failure can only ever fail the
* FIRST slot; failing the second one for a single descriptor is the only shape
* that exercises `registerSlots`' partial release (the first slot is already
* installed when the second throws).
*/
function createHost() {
const registry = createRegistry()
/** Slot registrations and releases, in order (the partial-release witness). */
const slotEvents: string[] = []
// One mutable state object the ctx closures read: the test flips it after
// the host was built.
const state = {
/** True while the plugin's context is being torn down (the reload case). */
inactive: false,
/** Native ids whose slot registration fails. */
failKeys: new Set<string>(),
/** `name::key` slots whose registration throws. */
failSlots: new Set<string>(),
}
const slotId = (options: { name: string; key?: string }): string => `${options.name}::${options.key ?? ''}`
const ctx = {
inject: (
_deps: readonly string[],
@@ -85,15 +101,15 @@ function createHost() {
if (state.inactive) throw new Error('cannot create effect on inactive context')
return typeof callback === 'function' ? callback() : () => {}
},
register: (options: { key?: string }) => {
if (options.key !== undefined && state.failKeys.has(options.key)) {
throw new Error(`slot registration for "${options.key}" failed`)
}
return () => {}
register: (options: { name: string; key?: string }) => {
const id = slotId(options)
if (state.failSlots.has(id)) throw new Error(`slot registration for "${id}" failed`)
slotEvents.push(`register ${id}`)
return () => { slotEvents.push(`release ${id}`) }
},
},
}
return { registry, state, ctx }
return { registry, slotEvents, state, ctx }
}
/** A store + service with two enabled descriptors, plus the record registry. */
@@ -115,31 +131,42 @@ function silenceFailures(): { restore: () => void; messages: string[] } {
return { restore: () => { spy.mockRestore() }, messages }
}
/**
* The phase labels a surface reports through the client's visible diagnostic
* channel (`reportFailure` → `fail('native ' + phase, error)`).
*/
function collectReports(): { phases: string[]; report: (phase: string) => void } {
const phases: string[] = []
return { phases, report: (phase: string) => { phases.push(phase) } }
}
describe('native tab registration lifecycle', () => {
it('releases a type whose slot registration failed, and can register it again later', () => {
const host = createHost()
const { store, service, records } = createPlugins()
const log = silenceFailures()
try {
// The reload: the context goes inactive while notifications are still
// arriving, so every slot registration fails after its type registered.
host.state.inactive = true
expect(() => registerNativeSurface({ ctx: host.ctx as never, store, service, records })).not.toThrow()
expect([...host.registry.registered.keys()], 'a failed registration leaves no type behind').toEqual([])
const reports = collectReports()
// The reload: the context goes inactive while notifications are still
// arriving, so every slot registration fails after its type registered.
host.state.inactive = true
expect(() => registerNativeSurface({
ctx: host.ctx as never, store, service, records, reportFailure: reports.report,
})).not.toThrow()
expect([...host.registry.registered.keys()], 'a failed registration leaves no type behind').toEqual([])
// Each of them is reported through the visible channel, not swallowed.
expect(reports.phases.sort()).toEqual(['register editor', 'register files', 'register git'])
// The context comes back (the reload's next activation): the same ids
// must be registrable — no "already registered" from the orphan.
host.state.inactive = false
host.state.failKeys.clear()
store.setPrefs(store.getPrefs())
expect([...host.registry.registered.keys()].sort()).toEqual([
'dsh-better-sidebar:editor',
'dsh-better-sidebar:files',
'dsh-better-sidebar:git',
])
} finally {
log.restore()
}
// The context comes back (the reload's next activation): the same ids must
// be registrable — no "already registered" from the orphan. The event log
// is the discriminating assertion: a leaked type would keep the key set
// looking correct while the retry throws into the same catch.
host.state.inactive = false
host.registry.events.length = 0
store.setPrefs(store.getPrefs())
expect(host.registry.events.sort()).toEqual([
'register dsh-better-sidebar:editor',
'register dsh-better-sidebar:files',
'register dsh-better-sidebar:git',
])
})
it('never re-creates the "files" takeover on an unrelated notification', () => {
@@ -159,23 +186,34 @@ describe('native tab registration lifecycle', () => {
it('keeps the other tab types when one descriptor cannot register', () => {
const host = createHost()
const { store, service, records } = createPlugins()
host.state.failKeys.add('dsh-better-sidebar:git')
const reports = collectReports()
// The SECOND slot of ONE descriptor: its type and its body slot are
// already installed when this one throws.
host.state.failSlots.add('sidebar.right.pane.tab.title::dsh-better-sidebar:git')
const log = silenceFailures()
try {
expect(() => registerNativeSurface({ ctx: host.ctx as never, store, service, records })).not.toThrow()
expect(() => registerNativeSurface({
ctx: host.ctx as never, store, service, records, reportFailure: reports.report,
})).not.toThrow()
expect([...host.registry.registered.keys()].sort()).toEqual([
'dsh-better-sidebar:editor',
'dsh-better-sidebar:files',
])
expect(reports.phases).toEqual(['register git'])
// The body slot installed before the failure was released; the failing
// one never registered, so there is nothing to release for it.
expect(host.slotEvents.filter(event => event === 'release sidebar.right.pane.tab::dsh-better-sidebar:git'))
.toHaveLength(1)
expect(host.slotEvents.filter(event => event === 'release sidebar.right.pane.tab.title::dsh-better-sidebar:git'))
.toEqual([])
// The broken descriptor is retried on the next sync instead of being
// written off, and the retry succeeds once it can register.
host.state.failKeys.clear()
// written off — and the retry REGISTERS it (event log), rather than
// merely ending up with a key set a leaked type produces as well.
host.state.failSlots.clear()
host.registry.events.length = 0
store.setPrefs(store.getPrefs())
expect([...host.registry.registered.keys()].sort()).toEqual([
'dsh-better-sidebar:editor',
'dsh-better-sidebar:files',
'dsh-better-sidebar:git',
])
expect(host.registry.events).toEqual(['register dsh-better-sidebar:git'])
} finally {
log.restore()
}