Merge pull request #785 from omdsh-dev/fix/native-leak-guard-tests
fix(native): 钉牢 files 接管泄漏的守护断言 + 记录两条不变量
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
# `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)**7 条** `already registered`(栈:`register ← registerFilesKind ← sync ← notify`),13 秒后新激活的 `_reload` 路径又报 2 条(该 burst 合计 9 条,与「7 个内置描述符注销 → 7 次 notify」一一对应)。链条:
|
||||
|
||||
1. `service.notify()` 是同步内联(`src/client/service.ts:696`),而 `registerNativeSurface` 把 `sync()` 订阅到它和 store 上(`src/client/native/index.ts:353`;本文行号按 v0.22.0,main 因 #777 的插入而顺移)。
|
||||
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(同一 burst 里还有 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 红,在修复分支上 4/4 绿;`pnpm typecheck` / `pnpm lint` / `pnpm test` 全绿(修复分支的 base 早于 #781,故为 114 files / 1136 passed;合入 main 后为 122 files / 1292 passed / 9 skipped)。
|
||||
- **部署级红→绿(实测)**:`tests/e2e/native-reload.e2e.ts` 在 scratch profile 里对 npm 上的 `dsh-better-sidebar@0.22.0` 触发页面内条目替换(`utimesSync` 已安装的 `lib/client.js`),**3/3 红**且每次都是本文那两条栈;换成本地打包的修复版 **3/3 绿**。该用例已随本 PR 进入挂载 lane(CI `plugin-mount` 绿),触发方式依赖钉住的宿主 0.1.7-rc.1 的 rev 规则(`mtime/ctime/size` 的 sha1)——pin 上调时需同步复核。
|
||||
- 首版用例的两处断言(「失败后还能重新注册」)写在**活着的 id 集合**上,在未修复代码上会**同样通过**(泄漏的 id 恰好构成同一个集合)。跟进 PR 把它们改写到注册表的**事件日志**上,并把槽位失败判据从 `key` 改成 `name::key` —— 两个槽位共用一个 `key`,按 `key` 失败时**第一个**槽就抛,回收循环虽然进了、但手里的 disposer 列表永远是空的(即 `registerSlots` 的部分回滚**释放动作**从未被执行);改后可以让某个描述符的**第二个**槽失败,回收列表非空、释放路径真正被覆盖,且 4 例在 v0.22.0 上仍全部红。
|
||||
|
||||
## 已知限制 / 未做
|
||||
|
||||
- **跨激活的瞬时重复**仍未消除:页面内条目替换时,新激活可能先于旧激活释放 id 而撞上 `already registered`(一次日志噪音,随后自愈)。要消掉它需要「吸收 + 旧持有者释放后补注册」的机制,与上面否决的方案同源,暂不做。
|
||||
- `disposeSafely` 的释放失败只写 `console.error`、**不弹诊断条**(teardown 阶段弹条是噪音;真被占住的 id 会在下一次注册尝试里经 `reportFailure` 显式报出)。理由写在源码注释里。
|
||||
@@ -160,6 +160,13 @@ interface Registration {
|
||||
* Run one registration disposer, reporting a failure instead of propagating
|
||||
* it: releasing a registration must never mask the error being handled, and
|
||||
* one broken entry must not leave the others registered.
|
||||
*
|
||||
* A release failure is logged, NOT routed through `reportFailure` (the visible
|
||||
* diagnostic strip), on purpose: both callers run while the surface is being
|
||||
* taken down — the drop loop and the seat teardown — where a strip would just
|
||||
* be noise, and an id that really stays taken cannot hide anyway: the next
|
||||
* registration attempt for it goes through the strip as
|
||||
* `register … error: … already registered`.
|
||||
* @param dispose - the disposer to run.
|
||||
* @param what - the registration's name, for the log line.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* In-page CLIENT ENTRY REPLACEMENT ("plugin reload") — the deployment-level
|
||||
* guard for the `dsh-better-sidebar:files` orphaned-tab-type bug.
|
||||
*
|
||||
* Field symptom (issues #770/#771, reproduced on the official desktop shell):
|
||||
* a client-side replacement of the plugin entry — a plugin-market update, a
|
||||
* disable→enable in the Plugins page, an HMR rebundle — left the host's tab
|
||||
* type id `dsh-better-sidebar:files` registered with nobody holding its
|
||||
* disposer, so the plugin could never register it again: every later `sync()`
|
||||
* reported `native register files error: … already registered` and the Files
|
||||
* takeover rendered the host's "nothing here can view this kind of content"
|
||||
* face until a page refresh.
|
||||
*
|
||||
* Root cause (fixed in v0.22.1): `sync()`'s drop loop treated the plugin's own
|
||||
* `files` takeover like a service descriptor, so every notification tore it
|
||||
* down and re-created it in the same pass. During the reload the re-creation
|
||||
* ran on the dying plugin context: `tabs.register` (host context, still alive)
|
||||
* took the id and `ctx.slots.inject` (plugin context) then threw
|
||||
* `cannot create effect on inactive context`, losing the disposer.
|
||||
*
|
||||
* The trigger here is the same one the host's own HMR uses: the installed
|
||||
* client bundle is stat-polled, and a changed revision (`mtime`/`ctime`/`size`)
|
||||
* makes the host tear the entry's fiber down and re-import it IN PAGE — no
|
||||
* full page navigation, which is what made the old failure permanent.
|
||||
*/
|
||||
import { mkdirSync, utimesSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
import { PAGE_URL, createHostApi, hostRpc, sendFirstMessage } from './host'
|
||||
|
||||
/** Workspace the sidebar renders against (created by this lane's seeding). */
|
||||
const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-workspace')
|
||||
|
||||
/** A file seeded into the workspace, opened through the Files takeover. */
|
||||
const SEEDED_FILE = 'hello.txt'
|
||||
|
||||
/**
|
||||
* The installed client bundle the host's HMR stat-poll watches. Derived from
|
||||
* the profile the server was booted with (`DSH_HOME`), overridable for a
|
||||
* hand-run lane.
|
||||
*/
|
||||
const CLIENT_JS = process.env.DSH_E2E_PLUGIN_CLIENT_JS
|
||||
?? (process.env.DSH_HOME === undefined
|
||||
? undefined
|
||||
: join(process.env.DSH_HOME, 'profiles/web/node_modules/dsh-better-sidebar/lib/client.js'))
|
||||
|
||||
/** The strip `fail()` pins to <body>; the plugin's registration failures land here. */
|
||||
const REGISTRATION_ERROR = /native register .* error/
|
||||
|
||||
let api: APIRequestContext
|
||||
|
||||
test.beforeAll(async () => {
|
||||
mkdirSync(WORKSPACE_PATH, { recursive: true })
|
||||
writeFileSync(join(WORKSPACE_PATH, SEEDED_FILE), 'hello from the reload lane\n')
|
||||
api = await createHostApi()
|
||||
const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(
|
||||
api, 'workspace.create', { path: WORKSPACE_PATH },
|
||||
)
|
||||
await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId })
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await api?.dispose()
|
||||
})
|
||||
|
||||
test('the native tab types survive an in-page client entry replacement', async ({ page }) => {
|
||||
// A hard failure, not a skip: this gate's whole value is that it cannot
|
||||
// pass quietly when the harness stops telling us where the bundle lives.
|
||||
if (CLIENT_JS === undefined) {
|
||||
throw new Error('DSH_HOME (or DSH_E2E_PLUGIN_CLIENT_JS) must point at the profile running this server')
|
||||
}
|
||||
const consoleErrors: string[] = []
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text())
|
||||
})
|
||||
|
||||
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
|
||||
const sidebar = page.locator('[data-dsh-better-sidebar]')
|
||||
await expect(sidebar).toBeAttached({ timeout: 90_000 })
|
||||
|
||||
// A keyless boot stacks onboarding takeovers over the shell (versioned
|
||||
// welcome notice, then the provider-config dialog). Same bounded dismissal
|
||||
// loop as the mount lane: a masked click is retried, an absent takeover is
|
||||
// fine.
|
||||
try {
|
||||
await expect
|
||||
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
|
||||
.toBeGreaterThan(0)
|
||||
} catch {
|
||||
console.warn('[e2e] no onboarding takeover appeared; proceeding without dismissal')
|
||||
}
|
||||
for (let round = 0; round < 8; round++) {
|
||||
let dismissed = false
|
||||
for (const name of ['Continue', 'Configure later']) {
|
||||
const button = page.getByRole('button', { name, exact: true }).first()
|
||||
if ((await button.count()) === 0) continue
|
||||
try {
|
||||
await button.click({ timeout: 4_000 })
|
||||
dismissed = true
|
||||
await page.waitForTimeout(1_000)
|
||||
} catch {
|
||||
// Masked by the takeover stacked above it; the next round tries again.
|
||||
}
|
||||
}
|
||||
if (!dismissed) break
|
||||
}
|
||||
|
||||
// The session header (and with it the native right sidebar's controls) only
|
||||
// exists once the session has a turn — the mount lane does the same.
|
||||
await sendFirstMessage(page)
|
||||
await page.locator('[data-sidebar-right-expand]').first().click()
|
||||
const pane = page.locator('[data-sidebar-right-panel]')
|
||||
await expect(page.locator('[data-sidebar-right-guide]')).toBeVisible({ timeout: 30_000 })
|
||||
// The takeover is ALIVE before the reload: its guide row opens the plugin's
|
||||
// explorer and the seeded file is listed.
|
||||
await page.locator('[data-sidebar-right-guide-entry="files"]').click()
|
||||
await expect(pane.locator(`[role="button"][title$="${SEEDED_FILE}"]:visible`)).toHaveCount(1, { timeout: 30_000 })
|
||||
|
||||
// ── Trigger: bump the installed bundle's revision ─────────────────────
|
||||
// Tag the CURRENT host element so the replacement is observable: a disposal
|
||||
// removes it (`host.remove()`), and the new activation appends a fresh one.
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('[data-dsh-better-sidebar]')?.setAttribute('data-e2e-generation', 'before-reload')
|
||||
})
|
||||
const now = new Date()
|
||||
utimesSync(CLIENT_JS!, now, new Date(now.getTime() + 2_000))
|
||||
await expect(
|
||||
page.locator('[data-dsh-better-sidebar][data-e2e-generation="before-reload"]'),
|
||||
'the host must replace the plugin entry in page (new activation)',
|
||||
).toHaveCount(0, { timeout: 60_000 })
|
||||
// Let the fresh activation finish its registrations.
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 60_000 })
|
||||
await page.waitForTimeout(2_000)
|
||||
|
||||
// ── Assertions: no registration failure, takeover usable ──────────────
|
||||
const registrationErrors = consoleErrors.filter(message => REGISTRATION_ERROR.test(message))
|
||||
expect(registrationErrors, registrationErrors.join('\n')).toEqual([])
|
||||
await expect(
|
||||
page.locator('body > div', { hasText: 'native register files error' }),
|
||||
'the failure strip must not be pinned',
|
||||
).toHaveCount(0)
|
||||
// The Files takeover works again after the replacement: the guide row is
|
||||
// offered and the explorer still lists the seeded file.
|
||||
if (await page.locator('[data-sidebar-right-guide]').count() === 0) {
|
||||
await page.locator('[data-dockkit-add-tab]').first().click()
|
||||
}
|
||||
const filesEntry = page.locator('[data-sidebar-right-guide-entry="files"]')
|
||||
await expect(filesEntry, 'the Files guide row must be offered again').toHaveCount(1, { timeout: 30_000 })
|
||||
await filesEntry.click()
|
||||
// The explorer must really render again — assert the CONTENT first (a bare
|
||||
// "no unavailable marker" check passes on an empty pane), then the host's
|
||||
// "a kind with no registrant" fallback must be absent.
|
||||
await expect(pane.locator(`[role="button"][title$="${SEEDED_FILE}"]:visible`)).toHaveCount(1, { timeout: 30_000 })
|
||||
await expect(
|
||||
pane.locator('[data-sidebar-right-unavailable]'),
|
||||
'the host must not fall back to its "nothing can view this kind" face',
|
||||
).toHaveCount(0)
|
||||
})
|
||||
@@ -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'
|
||||
@@ -44,8 +51,14 @@ function createRegistry() {
|
||||
throw new Error(`sidebarRight: tab type id "${definition.id}" is already registered`)
|
||||
}
|
||||
events.push(`register ${definition.id}`)
|
||||
// The host's disposer is IDEMPOTENT (a cordis effect disposer returns
|
||||
// the same task on a second call), so a repeated release must neither
|
||||
// log again nor pretend to free something twice.
|
||||
let released = false
|
||||
const dispose = (): void => {
|
||||
if (released) return
|
||||
if (failReleaseOf === definition.id) throw new Error(`cannot release ${definition.id}`)
|
||||
released = true
|
||||
registered.delete(definition.id)
|
||||
events.push(`release ${definition.id}`)
|
||||
}
|
||||
@@ -58,19 +71,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 +107,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 +137,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 +192,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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user