From c088653f110d4ec0516a2b32d868dfdc58d2804c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:47:24 -0700 Subject: [PATCH] fix: preserve owner-unverified terminal bindings --- .../pty-connection-reattach-binding.test.ts | 44 +++++++++++++++++++ .../deferred-session-reattach-connect.ts | 18 ++++++++ 2 files changed, 62 insertions(+) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts index 047ab764814..e5ba0d40d6f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-reattach-binding.test.ts @@ -484,6 +484,50 @@ describe('connectPanePty', () => { expect(mockStoreState.deferredSshSessionIdsByTabId['tab-1']).toBe(restoredPtyId) } ) + it('preserves a restored pane binding when its daemon owner is unverifiable', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transport.connect.mockImplementation( + async (opts: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + opts.callbacks?.onError?.('terminal_pane_owner_unverified') + return undefined + } + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'unverifiable-pty' }] }, + ptyIdsByTabId: { 'tab-1': ['unverifiable-pty'] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_2 }, + activeLeafId: LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_2]: 'unverifiable-pty' } + } + } + } as StoreState + const deps = createDeps({ + restoredLeafId: LEAF_2, + restoredPtyIdByLeafId: { [LEAF_2]: 'unverifiable-pty' } + }) + + connectPanePty(createPane(2) as never, createManager(2) as never, deps as never) + await flushAsyncTicks() + + expect(transport.connect).toHaveBeenCalledTimes(1) + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: 'unverifiable-pty' }) + ) + expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(deps.clearTabPtyId).not.toHaveBeenCalled() + expect(deps.syncPanePtyLayoutBinding).not.toHaveBeenCalled() + expect(mockStoreState.tabsByWorktree['wt-1'][0]?.ptyId).toBe('unverifiable-pty') + expect(mockStoreState.ptyIdsByTabId?.['tab-1']).toEqual(['unverifiable-pty']) + expect(mockStoreState.terminalLayoutsByTabId?.['tab-1']?.ptyIdsByLeafId).toEqual({ + [LEAF_2]: 'unverifiable-pty' + }) + }) it('reattaches via the tab-level SSH pty id when deferred bookkeeping missed the tab', async () => { // Why: restore can miss the deferred maps; the tab's SSH pty id must still drive connect-then-reattach, not a fresh spawn into a missing provider. diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts index 9de53ef07ec..90d8e9f3eed 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-session-reattach-connect.ts @@ -6,6 +6,8 @@ import { toProcessExitStartup } from './process-exit-startup' import { recoverUnverifiableDirectSshReattach } from './direct-ssh-reattach-recovery' import type { ConnectPanePtySession } from './connect-pane-pty-session' +const PANE_OWNER_UNVERIFIED_ERROR = 'terminal_pane_owner_unverified' + export function startDeferredSessionReattach( session: ConnectPanePtySession, deferredReattachSessionId: string @@ -22,6 +24,7 @@ export function startDeferredSessionReattach( : window.api.pty.declarePendingPaneSerializer(session.cacheKey).catch(() => null) let expiredReattachError = false + let paneOwnerUnverified = false const coldRestoreStartup = session.buildColdRestoreAgentResumeStartup() const outputCallbacks = session.captureTransportOutputCallbacks( (message) => { @@ -29,6 +32,9 @@ export function startDeferredSessionReattach( expiredReattachError = true return } + if (message.includes(PANE_OWNER_UNVERIFIED_ERROR)) { + paneOwnerUnverified = true + } if (!session.isCapturedDirectSshReattachCurrent(deferredReattachSessionId)) { return } @@ -75,6 +81,14 @@ export function startDeferredSessionReattach( } return } + if (!result && paneOwnerUnverified) { + session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {}) + } + return + } if (!result && expiredReattachError) { session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise @@ -133,6 +147,10 @@ export function startDeferredSessionReattach( if (session.rejectObsoleteDirectSshReattach(deferredReattachSessionId)) { return } + if (message.includes(PANE_OWNER_UNVERIFIED_ERROR)) { + session.reportError(message) + return + } warnTerminalLifecycleAnomaly('restored PTY reattach threw', { tabId: session.deps.tabId, worktreeId: session.deps.worktreeId,