From 74c6c64083df65c446e3fd18ac29641868a52ede Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:08:52 -0700 Subject: [PATCH] fix(terminal): only the Windows console-limit failure keeps a dead terminal open #16045 retained every non-zero local PTY exit and only used the Git Bash console-capacity marker to pick the overlay's label, so an ordinary shell exiting after a failed command left a dead pane behind on macOS and Linux too. Claim the exit for the capacity overlay only when all of it holds: a native local Windows ConPTY pane, a non-zero code, the same PTY whose fresh spawn was observed, no user input on that PTY (an unsettled write counts), and the exact MSYS marker in that PTY's own output. Every other exit falls through to the unchanged teardown path. Per-PTY interaction rides on the existing per-PTY process-exit record, marked at the input-forward chokepoint so a late write acknowledgement can never label a replacement PTY. STA-5604 --- ...nnection-git-bash-console-capacity.test.ts | 480 ++++++++++++++++++ .../pty-connection-pty-exit-teardown.test.ts | 168 +----- .../pty-connection/pty-exit-hibernate.ts | 36 +- .../pty-connection/pty-input-forward.ts | 4 + 4 files changed, 521 insertions(+), 167 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-git-bash-console-capacity.test.ts 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