fix(terminal): fence stale pane binds after rebind

This commit is contained in:
Neil
2026-08-31 05:43:12 -07:00
parent b185bac82b
commit 963d0cfe73
3 changed files with 56 additions and 2 deletions
@@ -708,6 +708,41 @@ describe('connectPanePty', () => {
expect(manager.closePane).not.toHaveBeenCalled()
})
it('ignores a late spawn callback after the pane adopted a provider replacement', async () => {
const { connectPanePty } = await import('./pty-connection')
let transportPtyId = 'terminal-old'
const transport = createMockTransport(transportPtyId)
transport.getPtyId = vi.fn(() => transportPtyId)
transportFactoryQueue.push(transport)
const manager = createManager(1)
const deps = createDeps()
const pane = createPane(1)
connectPanePty(pane as never, manager as never, deps as never)
const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as
| ((ptyId: string) => void)
| undefined
const onPtyRebind = createdTransportOptions[0]?.onPtyRebind as
| ((ptyId: string, replacedPtyId: string) => void)
| undefined
expect(onPtySpawn).toBeTypeOf('function')
expect(onPtyRebind).toBeTypeOf('function')
onPtySpawn?.('terminal-old')
transportPtyId = 'terminal-reconnected'
onPtyRebind?.('terminal-reconnected', 'terminal-old')
// The fixture dependency is intentionally lightweight, so mirror the live
// tab/layout commit that the real store performs atomically on replacement.
mockStoreState.tabsByWorktree['wt-1'][0]!.ptyId = 'terminal-reconnected'
mockStoreState.terminalLayoutsByTabId['tab-1']!.ptyIdsByLeafId![LEAF_1] = 'terminal-reconnected'
onPtySpawn?.('terminal-old')
expect(pane.container.dataset.ptyId).toBe('terminal-reconnected')
expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenLastCalledWith(1, 'terminal-old')
})
it('closes a split pane when an established PTY exits after output', async () => {
const { connectPanePty } = await import('./pty-connection')
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
@@ -22,4 +22,24 @@ describe('shouldIgnoreStalePanePtyLayoutBinding', () => {
})
).toBe(false)
})
it('rejects a stale callback after the pane already adopted the replacement', () => {
expect(
shouldIgnoreStalePanePtyLayoutBinding({
existingPtyId: 'pty-new',
nextPtyId: 'pty-old',
tabPtyId: 'pty-new'
})
).toBe(true)
})
it('allows a callback while the tab still owns the callback PTY', () => {
expect(
shouldIgnoreStalePanePtyLayoutBinding({
existingPtyId: 'pty-new',
nextPtyId: 'pty-old',
tabPtyId: 'pty-old'
})
).toBe(false)
})
})
@@ -54,8 +54,7 @@ export function installPanePtyVisibilityBind(session: ConnectPanePtySession): vo
existingPtyId,
nextPtyId: ptyId,
tabPtyId
}) &&
session.activePanePtyBinding === ptyId
})
) {
return
}