From d6e5b01e3bfaa60e12440e8c3d6c70cbe64d7d2e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:13:38 -0700 Subject: [PATCH] fix(chat): harden reconnect lifecycle --- .../native-chat-leaf-routing.test.ts | 26 +++++ .../native-chat/native-chat-leaf-routing.ts | 14 ++- .../components/terminal-pane/TerminalPane.tsx | 53 +++++++--- .../terminal-pane/pty-connection-types.ts | 1 + .../terminal-pane/pty-connection.test.ts | 26 +++++ .../terminal-pane/pty-connection.ts | 9 ++ .../terminal-pane/pty-transport-types.ts | 2 + .../remote-runtime-pty-transport.test.ts | 98 ++++++++++++++++++- .../remote-runtime-pty-transport.ts | 28 ++++-- .../use-terminal-pane-lifecycle.ts | 3 + 10 files changed, 232 insertions(+), 28 deletions(-) diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts index dca309fcb64..3aead4c8dda 100644 --- a/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.test.ts @@ -147,6 +147,32 @@ describe('resolveNativeChatLeafRoute', () => { ).toEqual({ chatLeafId: 'stopped-agent', exitChat: false }) }) + it('exits chat when the mounted agent has authoritatively returned to its shell', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'exited-agent', + activeLeafId: 'exited-agent', + chatLeafStillMounted: true, + activeLeafIsEligible: true, + chatLeafHasConfirmedAgentExit: true + }) + ).toEqual({ chatLeafId: null, exitChat: true }) + }) + + it('moves chat to an eligible sibling after the owning agent exits', () => { + expect( + resolveNativeChatLeafRoute({ + isChatViewMode: true, + chatLeafId: 'exited-agent', + activeLeafId: 'agent-sibling', + chatLeafStillMounted: true, + activeLeafIsEligible: true, + chatLeafHasConfirmedAgentExit: true + }) + ).toEqual({ chatLeafId: 'agent-sibling', exitChat: false }) + }) + it('attaches a tab-level chat request to the eligible active leaf', () => { expect( resolveNativeChatLeafRoute({ diff --git a/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts index edfc8f65df2..1262f010ca5 100644 --- a/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts +++ b/src/renderer/src/components/native-chat/native-chat-leaf-routing.ts @@ -72,11 +72,12 @@ export function resolveNativeChatLeafRoute(args: { activeLeafId: string | null chatLeafStillMounted: boolean activeLeafIsEligible: boolean + chatLeafHasConfirmedAgentExit?: boolean }): NativeChatLeafRoute { if (!args.isChatViewMode) { return { chatLeafId: null, exitChat: false } } - if (args.chatLeafId && args.chatLeafStillMounted) { + if (args.chatLeafId && args.chatLeafStillMounted && !args.chatLeafHasConfirmedAgentExit) { // Why: agent/title evidence can disappear while local, SSH, or runtime // transports reconnect. A mounted owning pane is not a terminal lifecycle // event, so keep its chat surface until the pane itself is removed. @@ -84,13 +85,16 @@ export function resolveNativeChatLeafRoute(args: { } // Manager hydration can briefly have no active pane; preserve the requested // mode until a concrete leaf exists instead of toggling it off during mount. - if (!args.activeLeafId) { + if (!args.activeLeafId && !args.chatLeafHasConfirmedAgentExit) { return { chatLeafId: args.chatLeafId, exitChat: false } } - if (args.activeLeafIsEligible) { + if ( + args.activeLeafIsEligible && + (!args.chatLeafHasConfirmedAgentExit || args.activeLeafId !== args.chatLeafId) + ) { return { chatLeafId: args.activeLeafId, exitChat: false } } - // Why: removing the chat-owning leaf must not move its composer onto a - // plain-shell sibling. Return the tab to terminal mode instead. + // Why: removing the owning leaf or confirming its agent exited must not leave + // the composer targeting a plain shell. Return the tab to terminal mode. return { chatLeafId: null, exitChat: true } } diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 603c2bb9ada..404f312e7af 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -103,7 +103,8 @@ import { shouldChatTakeOverMobileSurface } from '../native-chat/native-chat-send import { canToggleNativeChat } from '../native-chat/native-chat-availability' import { nativeChatLaunchAgentForLeaf, - resolveNativeChatLeafRoute + resolveNativeChatLeafRoute, + type NativeChatLeafRoute } from '../native-chat/native-chat-leaf-routing' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' @@ -402,6 +403,7 @@ export default function TerminalPane({ } | null>(null) const [quickCommandEditorOpen, setQuickCommandEditorOpen] = useState(false) const [chatLeafId, setChatLeafId] = useState(null) + const onAgentExitedRef = useRef<(leafId: string) => void>(() => {}) const [tabWideAgentHintLeafId, setTabWideAgentHintLeafId] = useState( undefined ) @@ -784,6 +786,39 @@ export default function TerminalPane({ resolveTitleAgentForLeaf ] ) + const applyNativeChatLeafRoute = useCallback( + (route: NativeChatLeafRoute): void => { + if (route.chatLeafId !== chatLeafId) { + setChatLeafId(route.chatLeafId) + } + if (route.exitChat && unifiedTabId) { + // Why: event/effect replay must not flip terminal mode back to chat. + setTabViewMode(unifiedTabId, 'terminal') + } + }, + [chatLeafId, setTabViewMode, unifiedTabId] + ) + const handleConfirmedAgentExit = useCallback( + (leafId: string): void => { + if (leafId !== chatLeafId) { + return + } + const panes = managerRef.current?.getPanes() ?? [] + const activeLeafId = managerRef.current?.getActivePane()?.leafId ?? null + applyNativeChatLeafRoute( + resolveNativeChatLeafRoute({ + isChatViewMode, + chatLeafId, + activeLeafId, + chatLeafStillMounted: panes.some((pane) => pane.leafId === chatLeafId), + activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId), + chatLeafHasConfirmedAgentExit: true + }) + ) + }, + [applyNativeChatLeafRoute, chatLeafId, isChatEligibleForLeaf, isChatViewMode] + ) + onAgentExitedRef.current = handleConfirmedAgentExit const canToggleChatForLeaf = useCallback( (leafId: string | null): boolean => { // Scope the "always allow toggling back" rule to the leaf actually showing @@ -1530,6 +1565,7 @@ export default function TerminalPane({ isActiveRef, isVisibleRef, onPtyExitRef, + onAgentExitedRef, onPtyErrorRef, clearTabPtyId, consumeSuppressedPtyExit: useAppStore((store) => store.consumeSuppressedPtyExit), @@ -1754,6 +1790,7 @@ export default function TerminalPane({ isActiveRef, isVisibleRef, onPtyExitRef, + onAgentExitedRef, onPtyErrorRef, clearTabPtyId, consumeSuppressedPtyExit: useAppStore.getState().consumeSuppressedPtyExit, @@ -1790,6 +1827,7 @@ export default function TerminalPane({ clearTerminalTabUnread, clearTerminalPaneUnread, showRestoredSessionBanner, + onAgentExitedRef, onPtyExitRef, setCacheTimerStartedAt, setRuntimePaneTitle, @@ -2989,21 +3027,14 @@ export default function TerminalPane({ chatLeafStillMounted, activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId) }) - if (route.chatLeafId !== chatLeafId) { - setChatLeafId(route.chatLeafId) - } - if (route.exitChat && unifiedTabId) { - // Why: effect replay must not flip terminal mode back to chat. - setTabViewMode(unifiedTabId, 'terminal') - } + applyNativeChatLeafRoute(route) }, [ isChatViewMode, chatLeafId, activePane?.leafId, chatLeafStillMounted, - isChatEligibleForLeaf, - unifiedTabId, - setTabViewMode + applyNativeChatLeafRoute, + isChatEligibleForLeaf ]) const chatPane = isChatViewMode && chatLeafId diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 8e151c6ab5c..67005266f7d 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -48,6 +48,7 @@ export type PtyConnectionDeps = { isActiveRef: React.RefObject isVisibleRef: React.RefObject onPtyExitRef: React.RefObject<(ptyId: string) => void> + onAgentExitedRef: React.RefObject<(leafId: string) => void> onPtyErrorRef?: React.RefObject<(paneId: number, message: string) => void> clearTabPtyId: (tabId: string, ptyId: string) => void consumeSuppressedPtyExit: (ptyId: string) => boolean 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 18e99dac5a7..f0febe190c3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -539,6 +539,7 @@ function createDeps(overrides: Record = {}) { isActiveRef: { current: true }, isVisibleRef: { current: true }, onPtyExitRef: { current: vi.fn() }, + onAgentExitedRef: { current: vi.fn() }, onPtyErrorRef: { current: vi.fn() }, clearTabPtyId: vi.fn(), consumeSuppressedPtyExit: vi.fn(() => false), @@ -2532,6 +2533,30 @@ describe('connectPanePty', () => { expect(manager.closePane).not.toHaveBeenCalled() }) + it('rebinds a provider replacement without granting fresh-spawn exit protection', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('terminal-old') + transportFactoryQueue.push(transport) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(createPane(1) as never, manager as never, deps as never) + const onPtyRebind = createdTransportOptions[0]?.onPtyRebind as + | ((ptyId: string) => void) + | undefined + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + expect(onPtyRebind).toBeTypeOf('function') + expect(onPtyExit).toBeTypeOf('function') + + onPtyRebind?.('terminal-reconnected') + onPtyExit?.('terminal-reconnected') + + expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, 'terminal-reconnected') + expect(deps.updateTabPtyId).toHaveBeenCalledWith('tab-1', 'terminal-reconnected') + expect(deps.onPtyExitRef.current).toHaveBeenCalledWith('terminal-reconnected') + expect(manager.closePane).not.toHaveBeenCalled() + }) + it('closes a split pane when an established PTY exits after output', async () => { const { connectPanePty } = await import('./pty-connection') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -18179,6 +18204,7 @@ describe('connectPanePty', () => { agentExitedHandler() expect(deps.setCacheTimerStartedAt).toHaveBeenCalledWith(makePaneKey('tab-1', LEAF_1), null) + expect(deps.onAgentExitedRef.current).toHaveBeenCalledWith(LEAF_1) expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 5716e535dfa..f240e0cd5a1 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -2795,6 +2795,11 @@ export function connectPanePty( // once the PTY exists, then let real hook events refine or complete it. bindActivePanePty(ptyId, { seedInitialAgentStatus: true }) } + const onPtyRebind = (ptyId: string): void => { + // Why: provider handle rotation keeps the existing pane/session generation; + // treating it as a fresh spawn can strand a later real exit as a newborn. + bindActivePanePty(ptyId) + } // ─── Attention signal: BEL ──────────────────────────────────────────── // // BEL (0x07) is the attention signal. A BEL raises tab- and worktree-level @@ -3083,6 +3088,9 @@ export function connectPanePty( } } const onAgentExited = (): void => { + // Why: eligibility can disappear transiently during reconnect, but a + // confirmed shell-title transition is authoritative for native-chat exit. + deps.onAgentExitedRef.current(pane.leafId) clearSuppressedTitleSideEffects() clearCommandInferredPaneAgent() requestKnownDroidReconfirmation() @@ -3298,6 +3306,7 @@ export function connectPanePty( ...(paneStartup?.telemetry ? { telemetry: paneStartup.telemetry } : {}), onPtyExit: onExit, onPtySpawn, + onPtyRebind, ...(mainSideEffectAuthority ? {} : { diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 1777091c376..0cd8570f6bf 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -157,6 +157,8 @@ export type IpcPtyTransportOptions = { onPtyExit?: (ptyId: string) => void onTitleChange?: (title: string, rawTitle: string) => void onPtySpawn?: (ptyId: string) => void + /** Rebind an existing pane after its provider replaces the PTY identity. */ + onPtyRebind?: (ptyId: string) => void onBell?: () => void onAgentBecameIdle?: (title: string) => void onAgentBecameWorking?: () => void diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 4a9dfbbea00..8d169e6c224 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -306,11 +306,13 @@ describe('createRemoteRuntimePtyTransport', () => { it('re-derives the host session handle after a transport close instead of resubscribing the stale one', async () => { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const onPtySpawn = vi.fn() + const onPtyRebind = vi.fn() const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1', tabId: 'web-terminal-tab-1', leafId: 'pane:1', - onPtySpawn + onPtySpawn, + onPtyRebind }) transport.attach({ @@ -364,7 +366,8 @@ describe('createRemoteRuntimePtyTransport', () => { expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-2' }) ) expect(transport.getPtyId()).toContain('terminal-2') - expect(onPtySpawn).toHaveBeenCalledWith(expect.stringContaining('terminal-2')) + expect(onPtySpawn).not.toHaveBeenCalled() + expect(onPtyRebind).toHaveBeenCalledWith(expect.stringContaining('terminal-2')) }) it('retires the mirror when the host no longer publishes the surface after a transport close', async () => { @@ -463,6 +466,8 @@ describe('createRemoteRuntimePtyTransport', () => { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const onError = vi.fn() const onPtyExit = vi.fn() + const onPtySpawn = vi.fn() + const onPtyRebind = vi.fn() const onExit = vi.fn() const onDisconnect = vi.fn() const renderedScreen: string[] = [] @@ -470,7 +475,9 @@ describe('createRemoteRuntimePtyTransport', () => { worktreeId: 'wt-1', tabId: 'web-terminal-tab-1', leafId: 'pane:1', - onPtyExit + onPtyExit, + onPtySpawn, + onPtyRebind }) transport.attach({ @@ -543,6 +550,9 @@ describe('createRemoteRuntimePtyTransport', () => { expect(onError).not.toHaveBeenCalled() expect(onPtyExit).not.toHaveBeenCalled() + expect(onPtySpawn).not.toHaveBeenCalled() + expect(onPtyRebind).toHaveBeenCalledOnce() + expect(onPtyRebind).toHaveBeenCalledWith('remote:env-1@@terminal-reconnected') expect(onExit).not.toHaveBeenCalled() expect(onDisconnect).not.toHaveBeenCalled() expect(transport.getPtyId()).toBe('remote:env-1@@terminal-reconnected') @@ -561,6 +571,88 @@ describe('createRemoteRuntimePtyTransport', () => { expect(subscribedTerminals).toEqual(['terminal-stale', 'terminal-reconnected']) }) + it('coalesces concurrent stale errors for the handle that was replaced', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onPtyExit = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-tab-1', + leafId: 'pane:1', + onPtyExit + }) + + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-stale', + cols: 80, + rows: 24, + callbacks: {} + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + + let resolveHostList: (response: unknown) => void = () => {} + const hostListResponse = new Promise((resolve) => { + resolveHostList = resolve + }) + let hostListCalls = 0 + runtimeCall.mockImplementation((args: { method: string }) => { + if (args.method === 'terminal.send') { + return Promise.resolve({ + ok: false, + error: { code: 'terminal_handle_stale', message: 'terminal_handle_stale' } + }) + } + if (args.method === 'session.tabs.list') { + hostListCalls += 1 + return hostListResponse + } + return Promise.resolve({ ok: true, result: {} }) + }) + + const sendInputAccepted = transport.sendInputAccepted + if (!sendInputAccepted) { + throw new Error('Expected acknowledged remote terminal input') + } + const sends = Promise.all([sendInputAccepted('first'), sendInputAccepted('second')]) + await vi.waitFor(() => expect(hostListCalls).toBe(1)) + await expect(sends).resolves.toEqual([false, false]) + + resolveHostList({ + ok: true, + result: { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: 'tab-1::pane:1', + activeTabType: 'terminal', + tabs: [ + { + type: 'terminal', + id: 'tab-1::pane:1', + parentTabId: 'tab-1', + leafId: 'pane:1', + title: 'Claude Code', + isActive: true, + status: 'ready', + terminal: 'terminal-reconnected' + } + ] + } + }) + + await vi.waitFor(() => + expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-reconnected' }) + ) + await Promise.resolve() + + // Why: the second stale response belonged to terminal-stale. Replaying it + // against the replacement would add another polling loop and retire it. + expect(hostListCalls).toBe(1) + expect(onPtyExit).not.toHaveBeenCalled() + expect(transport.getPtyId()).toBe('remote:env-1@@terminal-reconnected') + expect(transport.isConnected()).toBe(true) + }) + it('still retires the regular TUI surface after an explicit terminal exit', async () => { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const onPtyExit = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index dc3aa31e583..213eb368ba0 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -77,6 +77,7 @@ export function createRemoteRuntimePtyTransport( activate, onPtyExit, onPtySpawn, + onPtyRebind, onTitleChange, onBell, onAgentBecameIdle, @@ -94,8 +95,8 @@ export function createRemoteRuntimePtyTransport( let desiredViewport: { cols: number; rows: number } | null = null let storedCallbacks: Parameters[0]['callbacks'] = {} let resubscribing = false - let resubscribeRequested = false - let resubscribeRequiresReplacement = false + let resubscribeRequestedHandle: string | null = null + let resubscribeRequestedRequiresReplacement = false let subscriptionGeneration = 0 let pendingViewportClaim = false let pendingClaimInput = '' @@ -504,7 +505,9 @@ export function createRemoteRuntimePtyTransport( if (nextHandle !== previousHandle) { handle = nextHandle remotePtyId = toRemoteRuntimePtyId(nextHandle, currentRuntimeEnvironmentId) - onPtySpawn?.(remotePtyId) + // Why: the host replaced an existing mirror identity; rebinding must not + // apply fresh-spawn exit/agent seeding semantics to the mounted pane. + onPtyRebind?.(remotePtyId) } } await subscribeToHandle() @@ -515,8 +518,14 @@ export function createRemoteRuntimePtyTransport( return } if (resubscribing) { - resubscribeRequested = true - resubscribeRequiresReplacement ||= requireReplacement + // Why: concurrent stale errors belong to the handle that produced them. + // Do not carry an old handle's replacement requirement onto its successor. + if (resubscribeRequestedHandle !== handle) { + resubscribeRequestedHandle = handle + resubscribeRequestedRequiresReplacement = requireReplacement + } else { + resubscribeRequestedRequiresReplacement ||= requireReplacement + } return } resubscribing = true @@ -530,10 +539,11 @@ export function createRemoteRuntimePtyTransport( }) .finally(() => { resubscribing = false - if (resubscribeRequested) { - resubscribeRequested = false - const pendingRequiresReplacement = resubscribeRequiresReplacement - resubscribeRequiresReplacement = false + const pendingHandle = resubscribeRequestedHandle + const pendingRequiresReplacement = resubscribeRequestedRequiresReplacement + resubscribeRequestedHandle = null + resubscribeRequestedRequiresReplacement = false + if (pendingHandle && pendingHandle === handle) { scheduleResubscribeAfterTransportClose(pendingRequiresReplacement) } }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index e1fe139d445..a0b992846e0 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -268,6 +268,7 @@ type UseTerminalPaneLifecycleDeps = { isActiveRef: React.RefObject isVisibleRef: React.RefObject onPtyExitRef: React.RefObject<(ptyId: string) => void> + onAgentExitedRef: React.RefObject<(leafId: string) => void> onPtyErrorRef?: React.RefObject<(paneId: number, message: string) => void> clearTabPtyId: (tabId: string, ptyId: string) => void consumeSuppressedPtyExit: (ptyId: string) => boolean @@ -538,6 +539,7 @@ export function useTerminalPaneLifecycle({ isActiveRef, isVisibleRef, onPtyExitRef, + onAgentExitedRef, onPtyErrorRef, clearTabPtyId, consumeSuppressedPtyExit, @@ -757,6 +759,7 @@ export function useTerminalPaneLifecycle({ isActiveRef, isVisibleRef, onPtyExitRef, + onAgentExitedRef, onPtyErrorRef, clearTabPtyId, consumeSuppressedPtyExit,