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:
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user