fix: preserve owner-unverified terminal bindings

This commit is contained in:
Jinjing
2026-08-23 19:14:57 -04:00
committed by Jinwoo-H
parent e98b695675
commit c088653f11
2 changed files with 62 additions and 0 deletions
@@ -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.
@@ -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,