fix(tui): release the adapter-registration listener on channel detach

Review follow-up: the llm/adapters-updated listener's disposer was
discarded, leaving it firing (harmlessly, behind isDisposed()) between
TUI shutdown and fiber disposal, asymmetric with the sibling channel
listeners. The controller now exposes detach(), and the channel's
detachListeners() calls it on both the dispose() and startup-failure
paths.
This commit is contained in:
Turtle
2026-07-31 15:11:24 +08:00
parent 899d25dfb3
commit 732c9e6d95
6 changed files with 38 additions and 6 deletions

View File

@@ -37,6 +37,8 @@ export interface ModelController {
resetContextResolution(): void
/** Forget the tracked selector overlay (shutdown). */
clearOverlay(): void
/** Remove the adapter-registration listener (channel detach). */
detach(): void
}
type ContextResolution =
@@ -88,8 +90,9 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
// The wait cannot go stale against `target.current`: every target change
// re-enters resolveContextWindow, which clears it. A commit that still
// lacks the route parks the resolution again rather than erroring, so
// unrelated topology changes stay silent.
ctx.on('llm/adapters-updated', () => {
// unrelated topology changes stay silent. The disposer rides the channel's
// detachListeners() through detach(), matching the sibling listeners.
const disposeAdapterListener = ctx.on('llm/adapters-updated', () => {
if (deps.isDisposed() || !awaitingAdapter) return
resolveContextWindow(target.current)
})
@@ -206,5 +209,8 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle
clearOverlay(): void {
modelOverlay = undefined
},
detach(): void {
disposeAdapterListener()
},
}
}

View File

@@ -1563,6 +1563,7 @@ export function createTuiChat(
disposeAgent()
disposeSchemeListener()
disposeTargetListeners()
modelController.detach()
}
// Sweep reveal of the whole banner: the header wipes in left-to-right over

View File

@@ -3671,6 +3671,31 @@ describe('pi-tui chat lifecycle and transcript', () => {
await dispose(result)
})
it('stops listening for adapter registrations after channel detach', async () => {
// The listener disposer rides detachListeners() through the controller's
// detach(): after dispose, a registry commit must not re-enter resolution
// at all (the isDisposed() guard is a fallback, not the removal).
const calls: string[] = []
const result = await setup({
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },
catalog: {
providers: [],
models: [],
resolveModelInfo: (provider) => {
calls.push(provider)
return Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER'))
},
},
})
await tick()
const callsAtDetach = calls.length
await result.controller.dispose()
result.ctx.emit('llm/adapters-updated')
await tick()
expect(calls.length).toBe(callsAtDetach)
await result.ctx.fiber.dispose()
})
it('drops a deferred NO_ADAPTER resolution when the target moved before the adapter registered', async () => {
const result = await setup({
agentOptions: { provider: 'openai-codex', model: 'gpt-x' },