mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Scope pending PTY spawns to terminal panes (#1182)
This commit is contained in:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user