Merge pull request #777 from yanzhaohui1999/fix/native-registration-leak-v2

fix(native): 注册失败的 tab 类型必须释放,否则 id 永久占用(插件重载后文件树变空态)
This commit is contained in:
Atlas Zeng
2026-09-28 10:54:48 +08:00
committed by GitHub
2 changed files with 291 additions and 29 deletions
+94 -29
View File
@@ -156,6 +156,21 @@ interface Registration {
readonly dispose: () => void
}
/**
* 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.
* @param dispose - the disposer to run.
* @param what - the registration's name, for the log line.
*/
function disposeSafely(dispose: () => void, what: string): void {
try {
dispose()
} catch (error) {
console.error(`[dsh-better-sidebar] ${what} release failed:`, error)
}
}
/** Everything the registrations need. */
export interface NativeSurfaceDeps {
readonly ctx: Context
@@ -208,23 +223,44 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
return address !== undefined && address.scope === 'session' ? address.sessionId : undefined
}
/** Register the body + chip-title slots for one native implementation id. */
/**
* Register the body + chip-title slots for one native implementation id.
*
* A slot registration can fail long after the TYPE registration succeeded
* (`ctx.effect` on a context that is already inactive, i.e. during a
* plugin reload/disposal), so the disposers are collected as they are
* handed out and the partial set is released on the way out: the caller
* must be able to roll its type registration back WITHOUT losing track of
* a slot that did register.
*/
const registerSlots = (
id: string,
injected: Omit<NativeBodyInjected, 'sessionId'>,
params: Pick<NativeBodyInjected, 'paramsOf' | 'sessionIdOf'>,
): Array<() => void> => [
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
name: 'sidebar.right.pane.tab',
key: id,
inject: (sessionId: string) => ({ ...injected, ...params, sessionId }),
}, NativeTabBody)),
ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
name: 'sidebar.right.pane.tab.title',
key: id,
inject: () => ({ records, service, descriptorId: injected.descriptorId }),
}, NativeTabTitle)),
]
): Array<() => void> => {
// Collected AS THEY ARE HANDED OUT: a slot registration can fail long
// after the TYPE registration succeeded (a plugin reload makes
// `ctx.slots.inject` throw on the now-inactive context), and the caller
// must then be able to release the type again WITHOUT losing track of a
// slot that did register.
const disposers: Array<() => void> = []
try {
disposers.push(ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
name: 'sidebar.right.pane.tab',
key: id,
inject: (sessionId: string) => ({ ...injected, ...params, sessionId }),
}, NativeTabBody)))
disposers.push(ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
name: 'sidebar.right.pane.tab.title',
key: id,
inject: () => ({ records, service, descriptorId: injected.descriptorId }),
}, NativeTabTitle)))
} catch (error) {
for (const dispose of disposers) disposeSafely(dispose, `native tab slot "${id}"`)
throw error
}
return disposers
}
/** One descriptor's native type + body + title, as one disposable. */
const registerDescriptor = (descriptor: TabDescriptor): (() => void) => {
@@ -271,14 +307,25 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
}],
}),
})
const slots = registerSlots(
id,
{ ctx, store, service, records, descriptorId: descriptor.id },
isEditor ? { paramsOf: fileParamsOf, sessionIdOf: fileSessionIdOf } : {},
)
// The type is in the host's registry the moment `tabs.register`
// returns; a slot failure must release it here, or the id stays taken
// for the rest of the page's life (the host refuses a second
// registration of the same id) and the tab kind renders the host's
// "nothing can view this" face forever.
let slots: Array<() => void>
try {
slots = registerSlots(
id,
{ ctx, store, service, records, descriptorId: descriptor.id },
isEditor ? { paramsOf: fileParamsOf, sessionIdOf: fileSessionIdOf } : {},
)
} catch (error) {
disposeSafely(disposeType, `native tab type "${id}"`)
throw error
}
return () => {
for (const dispose of slots.reverse()) dispose()
disposeType()
for (const dispose of slots.reverse()) disposeSafely(dispose, `native tab slot "${id}"`)
disposeSafely(disposeType, `native tab type "${id}"`)
}
}
@@ -304,10 +351,16 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
...guideIconOf(editor?.icon),
}],
})
const slots = registerSlots(id, { ctx, store, service, records, descriptorId: EDITOR_KIND }, {})
let slots: Array<() => void>
try {
slots = registerSlots(id, { ctx, store, service, records, descriptorId: EDITOR_KIND }, {})
} catch (error) {
disposeSafely(disposeType, `native tab type "${id}"`)
throw error
}
return () => {
for (const dispose of slots.reverse()) dispose()
disposeType()
for (const dispose of slots.reverse()) disposeSafely(dispose, `native tab slot "${id}"`)
disposeSafely(disposeType, `native tab type "${id}"`)
}
}
@@ -319,14 +372,23 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
wanted.set(descriptor.id, () => registerDescriptor(descriptor))
}
for (const [descriptorId, registration] of live) {
if (wanted.has(descriptorId)) continue
registration.dispose()
// `files` is this module's OWN takeover, never a descriptor: it is
// owned by the editor-type switch below. Re-creating it here on every
// notification used to put its host-side type through a
// tear-down/re-register window on each store commit — and a
// re-registration attempted on an already-inactive context (a plugin
// reload) left the type registered with nobody holding its disposer,
// so the same id could never be registered again for the rest of the
// page's life.
if (descriptorId === FILES_KIND || wanted.has(descriptorId)) continue
disposeSafely(() => registration.dispose(), `native tab type "${descriptorId}"`)
live.delete(descriptorId)
}
for (const [descriptorId, create] of wanted) {
if (live.has(descriptorId)) continue
// One descriptor must not take the rest of the surface down with it:
// report and continue so the remaining types still register.
// report and continue so the remaining types still register. `create`
// has already released whatever it managed to install.
try {
live.set(descriptorId, { dispose: create() })
} catch (error) {
@@ -345,17 +407,20 @@ export function registerNativeSurface(deps: NativeSurfaceDeps): () => void {
}
}
if (!wantsFiles && hasFiles) {
live.get(FILES_KIND)?.dispose()
const takeover = live.get(FILES_KIND)
live.delete(FILES_KIND)
if (takeover !== undefined) disposeSafely(() => takeover.dispose(), 'the native "files" takeover')
}
}
const disposeSubscriptions = [service.subscribe(sync), store.subscribe(sync)]
sync()
return () => {
for (const registration of live.values()) registration.dispose()
for (const registration of live.values()) {
disposeSafely(() => registration.dispose(), 'a native tab registration')
}
live.clear()
for (const dispose of disposeSubscriptions.reverse()) dispose()
for (const dispose of disposeSubscriptions.reverse()) disposeSafely(dispose, 'a native tab subscription')
}
})
return () => {