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 78c7c43548f..4a9dfbbea00 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 @@ -490,39 +490,51 @@ describe('createRemoteRuntimePtyTransport', () => { const draft = 'QA regular reconnect draft - keep this unsent' emitOutput(initialStreamId, draft) - runtimeCall.mockImplementation(async (args: { method: string }) => - args.method === 'session.tabs.list' - ? { - 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' - } - ] - } + let hostListCalls = 0 + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (args.method === 'session.tabs.list') { + hostListCalls += 1 + const terminal = + hostListCalls === 1 + ? 'terminal-stale' + : hostListCalls === 2 + ? null + : 'terminal-reconnected' + return { + ok: true, + result: { + worktree: 'wt-1', + publicationEpoch: 'epoch-1', + snapshotVersion: hostListCalls + 1, + 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: terminal ? 'ready' : 'pending-handle', + terminal + } + ] } - : { ok: true, result: {} } - ) + } + } + return { ok: true, result: {} } + }) subscriptionCallbacks?.onResponse({ ok: true, result: { type: 'error', streamId: initialStreamId, message: 'terminal_handle_stale' } }) + await vi.waitFor(() => expect(hostListCalls).toBeGreaterThanOrEqual(1)) + expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-stale' }) + expect(onPtyExit).not.toHaveBeenCalled() await vi.waitFor(() => expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-reconnected' }) ) @@ -536,6 +548,17 @@ describe('createRemoteRuntimePtyTransport', () => { expect(transport.getPtyId()).toBe('remote:env-1@@terminal-reconnected') expect(transport.isConnected()).toBe(true) expect(renderedScreen.at(-1)).toBe(draft) + expect(hostListCalls).toBe(3) + const subscribedTerminals = subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .flatMap((frame) => { + if (frame?.opcode !== TerminalStreamOpcode.Subscribe) { + return [] + } + const payload = decodeTerminalStreamJson<{ terminal: string }>(frame.payload) + return payload ? [payload.terminal] : [] + }) + expect(subscribedTerminals).toEqual(['terminal-stale', 'terminal-reconnected']) }) it('still retires the regular TUI surface after an explicit terminal exit', async () => { 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 829c30106a7..dc3aa31e583 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 @@ -40,6 +40,7 @@ import { isWebTerminalSurfaceTabId, toHostSessionTabId } from '@/runtime/web-ter const REMOTE_TERMINAL_INPUT_FLUSH_MS = 8 const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33 const HOST_SESSION_ATTACH_POLL_MS = 150 +const HOST_SESSION_REPLACEMENT_POLL_MAX_MS = 1_000 const HOST_SESSION_ATTACH_TIMEOUT_MS = 15_000 function isRemoteTerminalStaleMessage(message: string): boolean { @@ -94,6 +95,7 @@ export function createRemoteRuntimePtyTransport( let storedCallbacks: Parameters[0]['callbacks'] = {} let resubscribing = false let resubscribeRequested = false + let resubscribeRequiresReplacement = false let subscriptionGeneration = 0 let pendingViewportClaim = false let pendingClaimInput = '' @@ -212,6 +214,39 @@ export function createRemoteRuntimePtyTransport( return findReadyHostSessionHandle(listed, hostTabId) } + async function waitForReplacementHostSessionHandle( + hostTabId: string, + previousHandle: string + ): Promise { + if (!worktreeId) { + return null + } + const worktree = toRuntimeWorktreeSelector(worktreeId) + const startedAt = Date.now() + let pollMs = HOST_SESSION_ATTACH_POLL_MS + while (!destroyed && connected && handle === previousHandle) { + const listed = await callRuntime('session.tabs.list', { + worktree + }) + const nextHandle = findReadyHostSessionHandle(listed, hostTabId) + if (nextHandle && nextHandle !== previousHandle) { + return nextHandle + } + if (!hasHostSessionTerminalSurface(listed, hostTabId)) { + return null + } + const remainingMs = HOST_SESSION_ATTACH_TIMEOUT_MS - (Date.now() - startedAt) + if (remainingMs <= 0) { + return null + } + // Why: a stale response can precede publication of its replacement. + // Bounded backoff avoids retrying the known-stale handle in a hot loop. + await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, remainingMs))) + pollMs = Math.min(pollMs * 2, HOST_SESSION_REPLACEMENT_POLL_MAX_MS) + } + return null + } + async function attachHostSessionMirror( options: Parameters[0] ): Promise { @@ -429,7 +464,7 @@ export function createRemoteRuntimePtyTransport( // Why: reconnect can re-mint a mirrored pane's handle while its host tab // remains alive. Keep xterm/composer state mounted while we re-resolve it. closeMultiplexedStream() - scheduleResubscribeAfterTransportClose() + scheduleResubscribeAfterTransportClose(true) } else { retireRemoteTerminalId() } @@ -448,9 +483,15 @@ export function createRemoteRuntimePtyTransport( // handle (reconnect, epoch or PTY change). Re-derive it from the current // session snapshot instead of resubscribing the stale closure value, which // would mirror (and type into) whatever PTY now sits behind it (#7718). - async function resubscribeAfterTransportClose(previousHandle: string): Promise { + async function resubscribeAfterTransportClose( + previousHandle: string, + requireReplacement: boolean + ): Promise { if (tabId && isWebTerminalSurfaceTabId(tabId)) { - const nextHandle = await listHostSessionHandle(toHostSessionTabId(tabId)) + const hostTabId = toHostSessionTabId(tabId) + const nextHandle = requireReplacement + ? await waitForReplacementHostSessionHandle(hostTabId, previousHandle) + : await listHostSessionHandle(hostTabId) if (destroyed || !connected || handle !== previousHandle) { return } @@ -469,17 +510,18 @@ export function createRemoteRuntimePtyTransport( await subscribeToHandle() } - function scheduleResubscribeAfterTransportClose(): void { + function scheduleResubscribeAfterTransportClose(requireReplacement = false): void { if (destroyed || !connected || !handle) { return } if (resubscribing) { resubscribeRequested = true + resubscribeRequiresReplacement ||= requireReplacement return } resubscribing = true const resubscribeHandle = handle - void resubscribeAfterTransportClose(resubscribeHandle) + void resubscribeAfterTransportClose(resubscribeHandle, requireReplacement) .catch((error) => { if (!destroyed && connected && handle) { clearPendingViewportClaim() @@ -490,7 +532,9 @@ export function createRemoteRuntimePtyTransport( resubscribing = false if (resubscribeRequested) { resubscribeRequested = false - scheduleResubscribeAfterTransportClose() + const pendingRequiresReplacement = resubscribeRequiresReplacement + resubscribeRequiresReplacement = false + scheduleResubscribeAfterTransportClose(pendingRequiresReplacement) } }) }