diff --git a/src/renderer/src/components/terminal-pane/pty-connection-git-bash-console-capacity.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-git-bash-console-capacity.test.ts new file mode 100644 index 00000000000..b985c3e3536 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-git-bash-console-capacity.test.ts @@ -0,0 +1,480 @@ +import type * as React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + sendTerminalInputThroughPane, + temporarilySetNavigatorUserAgent +} from './pty-connection-test-dom' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type ConnectCallbacks, + type MockPane, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +const { + resetAndRefreshAllTerminalWebglAtlases, + scheduleTerminalWebglAtlasRecovery, + scheduleRuntimeGraphSync, + shouldSeedCacheTimerOnInitialTitle, + toastInfo, + notifyCodexPaneBoundForStaleSweep +} = vi.hoisted(() => ({ + resetAndRefreshAllTerminalWebglAtlases: vi.fn(), + scheduleTerminalWebglAtlasRecovery: vi.fn(), + scheduleRuntimeGraphSync: vi.fn(), + shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), + toastInfo: vi.fn(), + notifyCodexPaneBoundForStaleSweep: vi.fn() +})) + +let mockStoreState: StoreState +let transportFactoryQueue: MockTransport[] = [] +let createdTransportOptions: Record[] = [] +let storeSubscribers: ((state: StoreState) => void)[] = [] +let restoreUserAgent: (() => void) | null = null + +vi.mock('@/runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync +})) + +vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ + ...(await importOriginal>()), + resetAndRefreshAllTerminalWebglAtlases +})) + +vi.mock('./terminal-webgl-atlas-recovery', () => ({ + scheduleTerminalWebglAtlasRecovery +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState, + subscribe: (listener: (state: StoreState) => void) => { + storeSubscribers.push(listener) + return () => { + storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) + } + } + } +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') + return buildAgentStatusModuleMock(await importOriginal>()) +}) + +vi.mock('./cache-timer-seeding', () => ({ + shouldSeedCacheTimerOnInitialTitle +})) + +vi.mock('sonner', () => ({ + toast: { info: toastInfo } +})) + +vi.mock('@/lib/codex-stale-pane-sweep', () => ({ + notifyCodexPaneBoundForStaleSweep +})) + +// Why: the pane connection calls the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCallback: unknown>(fn: T): T => fn + } +}) + +vi.mock('./pty-transport', () => ({ + createIpcPtyTransport: vi.fn((options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + }) +})) + +vi.mock('./remote-runtime-pty-transport', () => ({ + createRemoteRuntimePtyTransport: vi.fn( + (_environmentId: string, options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + } + ) +})) + +vi.mock('./pty-dispatcher', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + getEagerPtyBufferHandle: vi.fn(() => undefined) + } +}) + +const WINDOWS_USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' +const MACOS_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)' +const CAPACITY_MARKER = 'too many consoles in use, max consoles is 128' +const CAPACITY_OUTPUT = `console device allocation failure - ${CAPACITY_MARKER}` + +function installSleepingCodexResumeState(restoredPtyId?: string) { + const paneKey = makePaneKey('tab-1', LEAF_1) + const launchConfig = { + agentCommand: "codex '--model' 'gpt-5'", + agentArgs: '--model gpt-5', + agentEnv: { CODEX_PROFILE: 'captured' } + } + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1', ...(restoredPtyId ? { ptyId: restoredPtyId } : {}) }] + }, + settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, + agentStatusByPaneKey: {}, + sleepingAgentSessionsByPaneKey: { + [paneKey]: { + paneKey, + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'codex', + providerSession: { key: 'session_id', id: 'codex-session-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1, + launchConfig + } + } + } as StoreState + return launchConfig +} + +type StartedPane = { + pane: MockPane + manager: ReturnType + deps: ReturnType + transport: MockTransport + emitOutput: (data: string) => void + spawn: (ptyId: string) => void + exit: (ptyId: string, exitCode?: number) => void +} + +/** Connect a local pane whose transport hands back `ptyId`, with its output and lifecycle callbacks exposed. */ +async function startPane( + options: { + userAgent?: string + ptyId?: string + paneCount?: number + deps?: Record + } = {} +): Promise { + restoreUserAgent = temporarilySetNavigatorUserAgent(options.userAgent ?? WINDOWS_USER_AGENT) + const { connectPanePty } = await import('./pty-connection') + const ptyId = options.ptyId ?? 'tab-pty' + const callbacks: ConnectCallbacks[] = [] + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation(async (connectOptions: { callbacks: ConnectCallbacks }) => { + callbacks.push(connectOptions.callbacks) + return ptyId + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const manager = createManager(options.paneCount ?? 1) + const deps = buildPaneConnectionDeps(() => mockStoreState, { + onPaneProcessDied: vi.fn(), + ...options.deps + }) + + connectPanePty(pane as never, manager as never, deps as never) + + const transportOptions = createdTransportOptions[0] ?? {} + return { + pane, + manager, + deps, + transport, + emitOutput: (data) => callbacks.at(-1)?.onData?.(data), + spawn: (spawnedPtyId) => (transportOptions.onPtySpawn as (id: string) => void)?.(spawnedPtyId), + exit: (exitedPtyId, exitCode) => + (transportOptions.onPtyExit as (id: string, code?: number) => void)?.(exitedPtyId, exitCode) + } +} + +function expectCapacityRetention(started: StartedPane, startup: unknown): void { + expect(started.deps.onPaneProcessDied).toHaveBeenCalledWith({ + paneId: 1, + exitCode: 1, + startup, + reason: 'git-bash-console-capacity' + }) + expect(started.deps.onPtyExitRef.current).not.toHaveBeenCalled() + expect(started.manager.closePane).not.toHaveBeenCalled() +} + +describe('Git Bash console-capacity pane retention (STA-5604)', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + restoreUserAgent = null + mockStoreState = createInitialStoreState(() => mockStoreState) + installTerminalTestGlobals() + }) + + afterEach(async () => { + restoreUserAgent?.() + await restoreTerminalTestGlobals() + }) + + it('retains a fresh native-Windows PTY that printed the exact capacity marker', async () => { + const startup = { command: 'codex --resume session-1' } + const started = await startPane({ deps: { startup } }) + + started.spawn('tab-pty') + started.emitOutput(CAPACITY_OUTPUT) + started.exit('tab-pty', 1) + + expectCapacityRetention(started, startup) + }) + + it('detects the marker when it is split across output chunks', async () => { + const startup = { command: 'codex' } + const started = await startPane({ deps: { startup } }) + + started.spawn('tab-pty') + started.emitOutput('console device allocation failure - too many consoles ') + started.emitOutput('in use, max consoles is 128\r\n') + started.exit('tab-pty', 1) + + expectCapacityRetention(started, startup) + }) + + it('does not classify near-miss console output as a capacity failure', async () => { + const started = await startPane() + + started.spawn('tab-pty') + started.emitOutput('too many consoles in use, max consoles is 127') + started.exit('tab-pty', 1) + + expect(started.deps.onPaneProcessDied).not.toHaveBeenCalled() + }) + + it('tears down an ordinary non-zero local exit the user asked for', async () => { + // Why (#16045 regression): `exit` after a failed command is a deliberate close, not a console-capacity failure. + const started = await startPane() + + started.spawn('tab-pty') + started.emitOutput('bash: nope: command not found\r\n') + sendTerminalInputThroughPane(started.pane, 'exit\r') + started.exit('tab-pty', 1) + + expect(started.deps.onPaneProcessDied).not.toHaveBeenCalled() + expect(started.deps.onPtyExitRef.current).toHaveBeenCalledWith('tab-pty') + }) + + it('does not retain a capacity marker printed after user input', async () => { + const started = await startPane() + + started.spawn('tab-pty') + sendTerminalInputThroughPane(started.pane, 'ls\r') + started.emitOutput(CAPACITY_OUTPUT) + started.exit('tab-pty', 1) + + expect(started.deps.onPaneProcessDied).not.toHaveBeenCalled() + expect(started.deps.onPtyExitRef.current).toHaveBeenCalledWith('tab-pty') + }) + + it('treats an unsettled user write as interaction when the exit beats its acknowledgement', async () => { + const started = await startPane() + started.transport.sendInputAccepted?.mockImplementation(() => new Promise(() => {})) + + started.spawn('tab-pty') + sendTerminalInputThroughPane(started.pane, '\u0003') + started.emitOutput(CAPACITY_OUTPUT) + started.exit('tab-pty', 1) + + // The unchanged sole-pane guard still keeps a never-typed-into newborn mounted, + // so the load-bearing assertion is that no capacity overlay claims this exit. + expect(started.deps.onPaneProcessDied).not.toHaveBeenCalled() + expect(started.manager.closePane).not.toHaveBeenCalled() + }) + + it('retains the cold-restore resume startup when its replacement hits capacity', async () => { + restoreUserAgent = temporarilySetNavigatorUserAgent(WINDOWS_USER_AGENT) + const { connectPanePty } = await import('./pty-connection') + const callbacks: ConnectCallbacks[] = [] + const transport = createMockTransport('resume-pty') + transport.connect.mockImplementation(async (options: { callbacks: ConnectCallbacks }) => { + callbacks.push(options.callbacks) + return 'resume-pty' + }) + transportFactoryQueue.push(transport) + const launchConfig = installSleepingCodexResumeState() + const deps = buildPaneConnectionDeps(() => mockStoreState, { + startup: { command: 'codex stale-startup' }, + onPaneProcessDied: vi.fn() + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + const transportOptions = createdTransportOptions[0] ?? {} + ;(transportOptions.onPtySpawn as (id: string) => void)?.('resume-pty') + callbacks[0]?.onData?.(CAPACITY_MARKER) + ;(transportOptions.onPtyExit as (id: string, code?: number) => void)?.('resume-pty', 1) + + expect(deps.onPaneProcessDied).toHaveBeenCalledWith({ + paneId: 1, + exitCode: 1, + startup: expect.objectContaining({ + command: expect.stringContaining("'resume' 'codex-session-1'"), + launchConfig, + resumeProviderSession: { key: 'session_id', id: 'codex-session-1' }, + launchAgent: 'codex', + showSessionRestoredBanner: true + }), + reason: 'git-bash-console-capacity' + }) + }) + + it('does not carry one PTY capacity match into its cold-restore replacement', async () => { + restoreUserAgent = temporarilySetNavigatorUserAgent(WINDOWS_USER_AGENT) + const { connectPanePty } = await import('./pty-connection') + const callbacks: ConnectCallbacks[] = [] + let currentPtyId = 'lost-pty' + const transport = createMockTransport(currentPtyId) + transport.getPtyId.mockImplementation(() => currentPtyId) + transport.connect.mockImplementation( + async (options: { sessionId?: string; callbacks: ConnectCallbacks }) => { + callbacks.push(options.callbacks) + if (options.sessionId) { + // Only the lost session printed the marker; its replacement must not inherit the match. + options.callbacks.onData?.(CAPACITY_MARKER) + return { id: currentPtyId, sessionExpired: true } + } + currentPtyId = 'resume-pty' + return currentPtyId + } + ) + transportFactoryQueue.push(transport) + installSleepingCodexResumeState('lost-pty') + const deps = buildPaneConnectionDeps(() => mockStoreState, { + onPaneProcessDied: vi.fn(), + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks(30) + expect(callbacks).toHaveLength(2) + const transportOptions = createdTransportOptions[0] ?? {} + ;(transportOptions.onPtySpawn as (id: string) => void)?.('resume-pty') + ;(transportOptions.onPtyExit as (id: string, code?: number) => void)?.('resume-pty', 1) + + expect(deps.onPaneProcessDied).not.toHaveBeenCalled() + }) + + it('does not retain a reattached PTY that was never freshly spawned', async () => { + const started = await startPane() + + // No spawn callback: reattach and cold-restore skip onPtySpawn (pty-transport.ts). + started.emitOutput(CAPACITY_OUTPUT) + started.exit('tab-pty', 1) + + expect(started.deps.onPaneProcessDied).not.toHaveBeenCalled() + expect(started.deps.onPtyExitRef.current).toHaveBeenCalledWith('tab-pty') + }) + + it('leaves a clean exit alone even when the marker is somewhere in its output', async () => { + const started = await startPane() + + started.spawn('tab-pty') + started.emitOutput(CAPACITY_OUTPUT) + started.exit('tab-pty', 0) + + expect(started.deps.onPaneProcessDied).not.toHaveBeenCalled() + }) + + it('leaves a Windows-client remote-runtime pane on the unchanged teardown path', async () => { + // Why: a paired-web mirrored pane keeps its worktree's local execution host, so the + // native-ConPTY verdict is true on a Windows client even though the PTY runs on the HUB. + restoreUserAgent = temporarilySetNavigatorUserAgent(WINDOWS_USER_AGENT) + const { connectPanePty } = await import('./pty-connection') + const tabId = 'web-terminal-host-tab' + const ptyId = 'remote:hub-web@@terminal-1' + const transport = createMockTransport(ptyId) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: tabId, ptyId }] }, + ptyIdsByTabId: { [tabId]: [ptyId] }, + worktreesByRepo: { + repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/srv/wt-1', hostId: 'local' }] + }, + repos: [{ id: 'repo1', connectionId: null, executionHostId: 'local' }] + } as StoreState + const deps = buildPaneConnectionDeps(() => mockStoreState, { + tabId, + onPaneProcessDied: vi.fn(), + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: ptyId } + }) + + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + const attachCallbacks = ( + transport.attach.mock.calls[0]?.[0] as { callbacks?: ConnectCallbacks } | undefined + )?.callbacks + expect(attachCallbacks?.onData).toBeTypeOf('function') + const transportOptions = createdTransportOptions[0] ?? {} + ;(transportOptions.onPtySpawn as (id: string) => void)?.(ptyId) + attachCallbacks?.onData?.(CAPACITY_OUTPUT) + ;(transportOptions.onPtyExit as (id: string, code?: number) => void)?.(ptyId, 1) + + expect(deps.onPaneProcessDied).not.toHaveBeenCalled() + }) + + it('leaves macOS and Linux local exits on the unchanged teardown path', async () => { + const started = await startPane({ userAgent: MACOS_USER_AGENT }) + + started.spawn('tab-pty') + started.emitOutput(CAPACITY_OUTPUT) + started.exit('tab-pty', 1) + + expect(started.deps.onPaneProcessDied).not.toHaveBeenCalled() + }) + + it('keeps a freshly split Windows pane visible when its newborn PTY hits the console ceiling', async () => { + const startup = { command: 'codex' } + const started = await startPane({ + paneCount: 2, + deps: { + startup, + paneTransportsRef: { current: new Map([[2, createMockTransport('pty-pane-2')]]) } + } + }) + + started.spawn('tab-pty') + started.emitOutput(CAPACITY_OUTPUT) + started.exit('tab-pty', 1) + + expectCapacityRetention(started, startup) + }) +}) 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 a577488b3e6..263b2e50a03 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 @@ -1,8 +1,6 @@ import type * as React from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makePaneKey } from '../../../../shared/stable-pane-id' import { sendTerminalInputThroughPane } from './pty-connection-test-dom' -import { flushAsyncTicks } from './pty-connection-test-async' import { LEAF_1, LEAF_2, @@ -131,38 +129,6 @@ function createDeps(overrides: Record = {}) { return buildPaneConnectionDeps(() => mockStoreState, overrides) } -function installSleepingCodexResumeState(restoredPtyId?: string) { - const paneKey = makePaneKey('tab-1', LEAF_1) - const launchConfig = { - agentCommand: "codex '--model' 'gpt-5'", - agentArgs: '--model gpt-5', - agentEnv: { CODEX_PROFILE: 'captured' } - } - mockStoreState = { - ...mockStoreState, - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1', ...(restoredPtyId ? { ptyId: restoredPtyId } : {}) }] - }, - settings: { ...mockStoreState.settings, agentCmdOverrides: {} }, - agentStatusByPaneKey: {}, - sleepingAgentSessionsByPaneKey: { - [paneKey]: { - paneKey, - tabId: 'tab-1', - worktreeId: 'wt-1', - agent: 'codex', - providerSession: { key: 'session_id', id: 'codex-session-1' }, - prompt: 'finish the task', - state: 'working', - capturedAt: 1, - updatedAt: 1, - launchConfig - } - } - } as StoreState - return launchConfig -} - describe('connectPanePty', () => { beforeEach(() => { vi.resetModules() @@ -422,7 +388,9 @@ describe('connectPanePty', () => { expect(manager.closePane).not.toHaveBeenCalled() }) - it('keeps a failed local terminal visible after user input', async () => { + it('tears down a failed local terminal the user typed into', async () => { + // Why (#16045 regression): a non-zero exit after user input is an ordinary + // teardown, not a console-capacity failure — the pane must not be retained. const { connectPanePty } = await import('./pty-connection') const pane = createPane(1) const transport = createMockTransport('tab-pty') @@ -438,17 +406,12 @@ describe('connectPanePty', () => { sendTerminalInputThroughPane(pane, 'agent startup\r') onPtyExit?.('tab-pty', 1) - expect(deps.onPaneProcessDied).toHaveBeenCalledWith({ - paneId: 1, - exitCode: 1, - startup: null, - reason: 'process-failed' - }) - expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + expect(deps.onPaneProcessDied).not.toHaveBeenCalled() + expect(deps.onPtyExitRef.current).toHaveBeenCalledWith('tab-pty') expect(manager.closePane).not.toHaveBeenCalled() }) - it('keeps a failed local split pane visible', async () => { + it('closes a failed local split pane whose PTY was never freshly spawned', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('tab-pty') transportFactoryQueue.push(transport) @@ -461,123 +424,8 @@ describe('connectPanePty', () => { | undefined onPtyExit?.('tab-pty', 1) - expect(deps.onPaneProcessDied).toHaveBeenCalledWith({ - paneId: 1, - exitCode: 1, - startup: null, - reason: 'process-failed' - }) - expect(manager.closePane).not.toHaveBeenCalled() - }) - - it('classifies the Git Bash capacity failure before retaining its pane', async () => { - const { connectPanePty } = await import('./pty-connection') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - const transport = createMockTransport('tab-pty') - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'tab-pty' - }) - transportFactoryQueue.push(transport) - const manager = createManager(1) - const startup = { command: 'codex --resume session-1' } - const deps = createDeps({ onPaneProcessDied: vi.fn(), startup }) - - connectPanePty(createPane(1) as never, manager as never, deps as never) - const onPtyExit = createdTransportOptions[0]?.onPtyExit as - | ((ptyId: string, exitCode?: number) => void) - | undefined - - capturedDataCallback.current?.( - 'console device allocation failure - too many consoles in use, max consoles is 128' - ) - onPtyExit?.('tab-pty', 1) - - expect(deps.onPaneProcessDied).toHaveBeenCalledWith({ - paneId: 1, - exitCode: 1, - startup, - reason: 'git-bash-console-capacity' - }) - expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() - }) - - it('retains the cold-restore resume startup when its replacement hits capacity', async () => { - const { connectPanePty } = await import('./pty-connection') - const callbacks: ConnectCallbacks[] = [] - const transport = createMockTransport('resume-pty') - transport.connect.mockImplementation(async (options: { callbacks: ConnectCallbacks }) => { - callbacks.push(options.callbacks) - return 'resume-pty' - }) - transportFactoryQueue.push(transport) - const launchConfig = installSleepingCodexResumeState() - const deps = createDeps({ - startup: { command: 'codex stale-startup' }, - onPaneProcessDied: vi.fn() - }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks(20) - callbacks[0]?.onData?.('too many consoles in use, max consoles is 128') - const onPtyExit = createdTransportOptions[0]?.onPtyExit as - | ((ptyId: string, exitCode?: number) => void) - | undefined - onPtyExit?.('resume-pty', 1) - - expect(deps.onPaneProcessDied).toHaveBeenCalledWith({ - paneId: 1, - exitCode: 1, - startup: expect.objectContaining({ - command: expect.stringContaining("'resume' 'codex-session-1'"), - launchConfig, - resumeProviderSession: { key: 'session_id', id: 'codex-session-1' }, - launchAgent: 'codex', - showSessionRestoredBanner: true - }), - reason: 'git-bash-console-capacity' - }) - }) - - it('does not carry capacity detection into a cold-restore replacement', async () => { - const { connectPanePty } = await import('./pty-connection') - const callbacks: ConnectCallbacks[] = [] - let currentPtyId = 'lost-pty' - const transport = createMockTransport(currentPtyId) - transport.getPtyId.mockImplementation(() => currentPtyId) - transport.connect.mockImplementation( - async (options: { sessionId?: string; callbacks: ConnectCallbacks }) => { - callbacks.push(options.callbacks) - if (options.sessionId) { - options.callbacks.onData?.('too many consoles in use, max consoles is 128') - return { id: currentPtyId, sessionExpired: true } - } - currentPtyId = 'resume-pty' - return currentPtyId - } - ) - transportFactoryQueue.push(transport) - installSleepingCodexResumeState('lost-pty') - const deps = createDeps({ - onPaneProcessDied: vi.fn(), - restoredLeafId: LEAF_1, - restoredPtyIdByLeafId: { [LEAF_1]: 'lost-pty' } - }) - - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) - await flushAsyncTicks(30) - expect(callbacks).toHaveLength(2) - const onPtyExit = createdTransportOptions[0]?.onPtyExit as - | ((ptyId: string, exitCode?: number) => void) - | undefined - onPtyExit?.('resume-pty', 1) - - expect(deps.onPaneProcessDied).toHaveBeenCalledWith({ - paneId: 1, - exitCode: 1, - startup: null, - reason: 'process-failed' - }) + expect(deps.onPaneProcessDied).not.toHaveBeenCalled() + expect(manager.closePane).toHaveBeenCalledWith(1) }) it('does not retain a failed direct-SSH terminal locally', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts index 0105c1c532d..9dce2989ba3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-exit-hibernate.ts @@ -25,10 +25,12 @@ export function installPtyExitHibernate(session: ConnectPanePtySession): void { type ProcessExitState = { startup: PtyPaneStartup detector: GitBashConsoleCapacityDetector + userInteracted: boolean } session.createProcessExitState = (startup: PtyPaneStartup): ProcessExitState => ({ startup, - detector: createGitBashConsoleCapacityDetector() + detector: createGitBashConsoleCapacityDetector(), + userInteracted: false }) session.currentProcessExitState = session.createProcessExitState(session.paneStartup) session.processExitStateByPtyId = new Map() @@ -41,6 +43,16 @@ export function installPtyExitHibernate(session: ConnectPanePtySession): void { session.processExitStateByPtyId.delete(replacedPtyId) } } + // Why: the capacity overlay may only claim a shell that died on startup. Mark + // at the write *attempt* (not the async accept) and key it to the PTY that was + // bound then, so an in-flight write counts as interaction and a late accept can + // never make a replacement PTY look typed-into. + session.noteTerminalInputForPty = (ptyId: string | null | undefined): void => { + const state: ProcessExitState = + (ptyId ? session.processExitStateByPtyId.get(ptyId) : undefined) ?? + session.currentProcessExitState + state.userInteracted = true + } session.focusSurvivingPtyPaneAfterKeptExit = (): void => { if (session.manager.getActivePane()?.id !== session.pane.id) { return @@ -280,15 +292,25 @@ export function installPtyExitHibernate(session: ConnectPanePtySession): void { return } session.manager.setPaneGpuRendering(session.pane.id, true) - const failedLocalProcess = - !session.connectionId && session.runtimeEnvironmentId === null && exitCode !== 0 - if (failedLocalProcess && session.deps.onPaneProcessDied) { - const gitBashConsoleCapacityFailure = processExitState.detector.detected() + // Why (#16045 regression): retaining every non-zero local exit is not a + // capacity detector — an ordinary shell exiting after a failed command hit it + // on macOS/Linux too. Claim the exit only for the Windows Git Bash console + // ceiling: a native local ConPTY whose *own* fresh spawn died non-zero before + // any input, having printed the exact MSYS marker. Everything else falls + // through to the unchanged teardown path below. + const gitBashConsoleCapacityFailure = + session.isNativeWindowsConpty && + session.runtimeEnvironmentId === null && + exitCode !== 0 && + session.spawnedFreshPtyId === ptyId && + !processExitState.userInteracted && + processExitState.detector.detected() + if (gitBashConsoleCapacityFailure && session.deps.onPaneProcessDied) { session.deps.onPaneProcessDied({ paneId: session.pane.id, exitCode, - startup: gitBashConsoleCapacityFailure ? processExitState.startup : null, - reason: gitBashConsoleCapacityFailure ? 'git-bash-console-capacity' : 'process-failed' + startup: processExitState.startup, + reason: 'git-bash-console-capacity' }) return } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts index 1c53e318dab..0a942dd6cb4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pty-input-forward.ts @@ -90,6 +90,10 @@ export function installPtyInputForward(session: ConnectPanePtySession): void { session.clearPendingTerminalInputIntent() return } + // Why here: the last chokepoint every dropped-input guard has already passed, + // and before any send branch — so paste/drop/quick-command input marks this + // PTY as interacted without threading a signal through each caller. + session.noteTerminalInputForPty(currentPtyId) const intent = session.pendingTerminalInputIntent // Why: real xterm can deliver the terminal byte even when our DOM keydown // listener missed the press. Exact Ctrl+C/Escape bytes are still safe to