Scope pending PTY spawns to terminal panes (#1182)

This commit is contained in:
Jinwoo Hong
2026-04-27 11:55:56 -07:00
committed by GitHub
parent ead2368230
commit d8d2c0f033
2 changed files with 66 additions and 5 deletions
@@ -168,6 +168,14 @@ function createDeps(overrides: Record<string, unknown> = {}) {
}
}
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolveDeferred!: (value: T) => void
const promise = new Promise<T>((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<string>()
const setupSpawn = createDeferred<string>()
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
@@ -18,7 +18,7 @@ import {
} from './layout-serialization'
import { warnTerminalLifecycleAnomaly } from './terminal-lifecycle-diagnostics'
const pendingSpawnByTabId = new Map<string, Promise<string | null>>()
const pendingSpawnByPaneKey = new Map<string, Promise<string | null>>()
// 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) => {