diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 76659d82fd7..b1ec6e939fd 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -168,6 +168,14 @@ function createDeps(overrides: Record = {}) { } } +function createDeferred(): { promise: Promise; resolve: (value: T) => void } { + let resolveDeferred!: (value: T) => void + const promise = new Promise((resolve) => { + resolveDeferred = resolve + }) + return { promise, resolve: resolveDeferred } +} + describe('connectPanePty', () => { const originalRequestAnimationFrame = globalThis.requestAnimationFrame const originalCancelAnimationFrame = globalThis.cancelAnimationFrame @@ -273,6 +281,56 @@ describe('connectPanePty', () => { ) }) + it('does not reuse a sibling split pane pending spawn after remount', async () => { + const { connectPanePty } = await import('./pty-connection') + + const mainSpawn = createDeferred() + const setupSpawn = createDeferred() + + const mainTransport = createMockTransport() + mainTransport.connect.mockImplementation(async () => mainSpawn.promise) + const setupTransport = createMockTransport() + setupTransport.connect.mockImplementation(async () => setupSpawn.promise) + const remountTransport = createMockTransport() + transportFactoryQueue.push(mainTransport, setupTransport, remountTransport) + + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + repos: [{ id: 'repo1', connectionId: null }] + } + + const sharedTransportsRef = { current: new Map() } + connectPanePty( + createPane(1) as never, + createManager(2) as never, + createDeps({ paneTransportsRef: sharedTransportsRef }) as never + ) + connectPanePty( + createPane(2) as never, + createManager(2) as never, + createDeps({ + startup: { command: 'bash setup-runner.sh' }, + paneTransportsRef: sharedTransportsRef + }) as never + ) + + const remountDeps = createDeps() + connectPanePty(createPane(1) as never, createManager(2) as never, remountDeps as never) + + setupSpawn.resolve('pty-setup') + mainSpawn.resolve('pty-main') + for (let i = 0; i < 20; i++) { + await Promise.resolve() + } + + expect(remountTransport.attach).toHaveBeenCalledWith( + expect.objectContaining({ existingPtyId: 'pty-main' }) + ) + expect(remountDeps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'pty-main') + expect(remountDeps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'pty-main') + }) + it('drops xterm onData while pane is replaying restored bytes', async () => { // Regression: during cold-restore / snapshot replay, xterm auto-replies // to embedded query sequences (DA1, DECRQM, OSC 10/11, focus, CPR) via diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 8129aa8372c..7917838a0ed 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -18,7 +18,7 @@ import { } from './layout-serialization' import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics' -const pendingSpawnByTabId = new Map>() +const pendingSpawnByPaneKey = new Map>() // Why: when multiple panes/tabs need the same deferred SSH connection, // the first one calls ssh.connect() and subsequent ones must wait for it @@ -105,6 +105,7 @@ export function connectPanePty( // Why: cache timer state is keyed per-pane (not per-tab) so split-pane tabs // can track each Claude session independently without overwriting each other. const cacheKey = `${deps.tabId}:${pane.id}` + const pendingSpawnKey = `${deps.tabId}:${paneLeafId(pane.id)}` const onExit = (ptyId: string): void => { deps.syncPanePtyLayoutBinding(pane.id, null) @@ -397,11 +398,13 @@ export function connectPanePty( ) .catch(() => null) .finally(() => { - if (pendingSpawnByTabId.get(deps.tabId) === spawnPromise) { - pendingSpawnByTabId.delete(deps.tabId) + if (pendingSpawnByPaneKey.get(pendingSpawnKey) === spawnPromise) { + pendingSpawnByPaneKey.delete(pendingSpawnKey) } }) - pendingSpawnByTabId.set(deps.tabId, spawnPromise) + // Why: split panes in the same tab can spawn concurrently. Key by pane + // as well as tab so a remount cannot attach to a sibling setup pane's PTY. + pendingSpawnByPaneKey.set(pendingSpawnKey, spawnPromise) } // Why: replay bytes (eager-buffer flush, attach-time screen clear) must @@ -755,7 +758,7 @@ export function connectPanePty( allowInitialIdleCacheSeed = false const pendingSpawn = hasExistingPaneTransport ? undefined - : pendingSpawnByTabId.get(deps.tabId) + : pendingSpawnByPaneKey.get(pendingSpawnKey) if (pendingSpawn) { void pendingSpawn .then((spawnedPtyId) => {