From 963d0cfe7392ab34bb66e431dbf61cb4dd8cb5d6 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 31 Aug 2026 04:22:22 -0700 Subject: [PATCH] fix(terminal): fence stale pane binds after rebind --- .../pty-connection-pty-exit-teardown.test.ts | 35 +++++++++++++++++++ .../pane-pty-layout-binding.test.ts | 20 +++++++++++ .../pane-pty-visibility-bind.ts | 3 +- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.test.ts index 1448ef7a5ee..23e521bd0eb 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.test.ts @@ -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 } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-layout-binding.test.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-layout-binding.test.ts index 852818b8643..2eff30c3233 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-layout-binding.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-layout-binding.test.ts @@ -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) + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts index 76fa13bffe0..ef6f3062c94 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-pty-visibility-bind.ts @@ -54,8 +54,7 @@ export function installPanePtyVisibilityBind(session: ConnectPanePtySession): vo existingPtyId, nextPtyId: ptyId, tabPtyId - }) && - session.activePanePtyBinding === ptyId + }) ) { return }