perf(terminal): prepaint parked SSH sessions (#12610)
* perf(terminal): prepaint parked SSH sessions * fix(terminal): fence parked SSH prepaint
This commit is contained in:
parent
7287ca8ae2
commit
b4dca4d12a
|
|
@ -20639,6 +20639,249 @@ describe('connectPanePty', () => {
|
|||
expect(api.pty.signal).toHaveBeenCalledWith('leaf-session', 'SIGWINCH')
|
||||
})
|
||||
|
||||
it('paints a parked SSH model before the remote connection settles', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const sshPtyId = toAppSshPtyId('conn-1', 'relay-pty-1')
|
||||
const sshConnect = createDeferred<SshConnectionState | null>()
|
||||
const transport = createMockTransport()
|
||||
transportFactoryQueue.push(transport)
|
||||
vi.mocked(window.api.ssh.connect).mockReturnValue(sshConnect.promise)
|
||||
vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue({
|
||||
data: 'PARKED-SSH-PAINTED-WITHOUT-NETWORK\r\n',
|
||||
cols: 101,
|
||||
rows: 31,
|
||||
seq: 123,
|
||||
source: 'headless'
|
||||
})
|
||||
await parkTabForReveal('tab-1', sshPtyId)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: sshPtyId }] },
|
||||
ptyIdsByTabId: { 'tab-1': [sshPtyId] },
|
||||
repos: [{ id: 'repo1', connectionId: 'conn-1' }],
|
||||
sshConnectionStates: new Map([['conn-1', { status: 'disconnected' }]]),
|
||||
deferredSshReconnectTargets: ['conn-1'],
|
||||
deferredSshSessionIdsByTabId: { 'tab-1': sshPtyId }
|
||||
}
|
||||
|
||||
const pane = createPane(1)
|
||||
const { writes } = captureCallbackTerminalWrites(pane)
|
||||
const binding = connectPanePty(
|
||||
pane as never,
|
||||
createManager(1) as never,
|
||||
createDeps({
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId }
|
||||
}) as never
|
||||
)
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(window.api.ssh.connect).toHaveBeenCalledWith({ targetId: 'conn-1' })
|
||||
expect(window.api.pty.getMainBufferSnapshot).toHaveBeenCalledOnce()
|
||||
expect(writes.join('')).toContain('PARKED-SSH-PAINTED-WITHOUT-NETWORK')
|
||||
expect(transport.connect).not.toHaveBeenCalled()
|
||||
|
||||
binding.dispose()
|
||||
sshConnect.resolve({
|
||||
targetId: 'conn-1',
|
||||
status: 'connected',
|
||||
error: null,
|
||||
reconnectAttempt: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('drops an in-flight parked SSH prepaint after its retry lease is replaced', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const sshPtyId = toAppSshPtyId('conn-1', 'relay-pty-1')
|
||||
const snapshot = createDeferred<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq: number
|
||||
source: 'headless'
|
||||
}>()
|
||||
const sshConnect = createDeferred<SshConnectionState | null>()
|
||||
const pendingRetry = {
|
||||
attemptId: 'attempt-prepaint',
|
||||
authority: {
|
||||
targetId: 'conn-1',
|
||||
providerEpoch: 'epoch-1',
|
||||
connectionGeneration: 3
|
||||
},
|
||||
tabGeneration: 7,
|
||||
startedAt: 1
|
||||
}
|
||||
transportFactoryQueue.push(createMockTransport())
|
||||
vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(snapshot.promise)
|
||||
vi.mocked(window.api.ssh.connect).mockReturnValue(sshConnect.promise)
|
||||
await parkTabForReveal('tab-1', sshPtyId)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: sshPtyId, generation: 7 }] },
|
||||
ptyIdsByTabId: { 'tab-1': [sshPtyId] },
|
||||
repos: [{ id: 'repo1', connectionId: 'conn-1' }],
|
||||
sshConnectionStates: new Map([
|
||||
[
|
||||
'conn-1',
|
||||
{
|
||||
status: 'disconnected',
|
||||
providerEpoch: 'epoch-1',
|
||||
connectionGeneration: 3
|
||||
}
|
||||
]
|
||||
]),
|
||||
deferredSshReconnectTargets: ['conn-1'],
|
||||
deferredSshSessionIdsByTabId: { 'tab-1': sshPtyId },
|
||||
directSshPaneRetryByTabId: { 'tab-1': pendingRetry }
|
||||
}
|
||||
|
||||
const pane = createPane(1)
|
||||
const { writes } = captureCallbackTerminalWrites(pane)
|
||||
const binding = connectPanePty(
|
||||
pane as never,
|
||||
createManager(1) as never,
|
||||
createDeps({
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId }
|
||||
}) as never
|
||||
)
|
||||
await flushAsyncTicks(8)
|
||||
expect(window.api.pty.getMainBufferSnapshot).toHaveBeenCalledOnce()
|
||||
|
||||
mockStoreState.sshConnectionStates = new Map([
|
||||
[
|
||||
'conn-1',
|
||||
{
|
||||
status: 'disconnected',
|
||||
providerEpoch: 'epoch-1',
|
||||
connectionGeneration: 4
|
||||
}
|
||||
]
|
||||
])
|
||||
mockStoreState.directSshPaneRetryByTabId = {
|
||||
'tab-1': {
|
||||
...pendingRetry,
|
||||
attemptId: 'attempt-prepaint-new',
|
||||
authority: { ...pendingRetry.authority, connectionGeneration: 4 }
|
||||
}
|
||||
}
|
||||
snapshot.resolve({
|
||||
data: 'OBSOLETE-LEASE-SNAPSHOT\r\n',
|
||||
cols: 101,
|
||||
rows: 31,
|
||||
seq: 124,
|
||||
source: 'headless'
|
||||
})
|
||||
await flushAsyncTicks(12)
|
||||
|
||||
expect(writes.join('')).not.toContain('OBSOLETE-LEASE-SNAPSHOT')
|
||||
binding.dispose()
|
||||
sshConnect.resolve(null)
|
||||
await flushAsyncTicks(4)
|
||||
})
|
||||
|
||||
it('does not prepaint a parked SSH snapshot owned by another connection', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const foreignPtyId = toAppSshPtyId('conn-2', 'relay-pty-1')
|
||||
const sshConnect = createDeferred<SshConnectionState | null>()
|
||||
transportFactoryQueue.push(createMockTransport())
|
||||
vi.mocked(window.api.ssh.connect).mockReturnValue(sshConnect.promise)
|
||||
vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue({
|
||||
data: 'FOREIGN-CONNECTION-SNAPSHOT\r\n',
|
||||
cols: 101,
|
||||
rows: 31,
|
||||
seq: 125,
|
||||
source: 'headless'
|
||||
})
|
||||
await parkTabForReveal('tab-1', foreignPtyId)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: foreignPtyId }] },
|
||||
ptyIdsByTabId: { 'tab-1': [foreignPtyId] },
|
||||
repos: [{ id: 'repo1', connectionId: 'conn-1' }],
|
||||
sshConnectionStates: new Map([['conn-1', { status: 'disconnected' }]]),
|
||||
deferredSshReconnectTargets: ['conn-1'],
|
||||
deferredSshSessionIdsByTabId: { 'tab-1': foreignPtyId }
|
||||
}
|
||||
|
||||
const pane = createPane(1)
|
||||
const { writes } = captureCallbackTerminalWrites(pane)
|
||||
const binding = connectPanePty(
|
||||
pane as never,
|
||||
createManager(1) as never,
|
||||
createDeps({
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: foreignPtyId }
|
||||
}) as never
|
||||
)
|
||||
await flushAsyncTicks(12)
|
||||
|
||||
expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled()
|
||||
expect(writes.join('')).not.toContain('FOREIGN-CONNECTION-SNAPSHOT')
|
||||
binding.dispose()
|
||||
sshConnect.resolve(null)
|
||||
await flushAsyncTicks(4)
|
||||
})
|
||||
|
||||
it('does not paint a delayed parked snapshot over an expired-session replacement', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const sshPtyId = toAppSshPtyId('conn-1', 'relay-pty-expired')
|
||||
const freshPtyId = toAppSshPtyId('conn-1', 'relay-pty-fresh')
|
||||
const snapshot = createDeferred<{
|
||||
data: string
|
||||
cols: number
|
||||
rows: number
|
||||
seq: number
|
||||
source: 'headless'
|
||||
}>()
|
||||
const transport = createMockTransport()
|
||||
transport.connect.mockImplementation(async (opts) => {
|
||||
if (opts.sessionId) {
|
||||
opts.callbacks?.onError?.(`SSH_SESSION_EXPIRED: ${opts.sessionId}`)
|
||||
return undefined
|
||||
}
|
||||
transport.getPtyId.mockReturnValue(freshPtyId)
|
||||
return freshPtyId
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(snapshot.promise)
|
||||
await parkTabForReveal('tab-1', sshPtyId)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: sshPtyId }] },
|
||||
ptyIdsByTabId: { 'tab-1': [sshPtyId] },
|
||||
repos: [{ id: 'repo1', connectionId: 'conn-1' }],
|
||||
sshConnectionStates: new Map([['conn-1', { status: 'connected' }]]),
|
||||
deferredSshReconnectTargets: ['conn-1'],
|
||||
deferredSshSessionIdsByTabId: { 'tab-1': sshPtyId }
|
||||
}
|
||||
|
||||
const pane = createPane(1)
|
||||
const { writes } = captureCallbackTerminalWrites(pane)
|
||||
const binding = connectPanePty(
|
||||
pane as never,
|
||||
createManager(1) as never,
|
||||
createDeps({
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId }
|
||||
}) as never
|
||||
)
|
||||
await flushAsyncTicks(30)
|
||||
expect(transport.connect).toHaveBeenCalledTimes(2)
|
||||
|
||||
snapshot.resolve({
|
||||
data: 'EXPIRED-SESSION-SNAPSHOT\r\n',
|
||||
cols: 101,
|
||||
rows: 31,
|
||||
seq: 126,
|
||||
source: 'headless'
|
||||
})
|
||||
await flushAsyncTicks(20)
|
||||
|
||||
expect(writes.join('')).not.toContain('EXPIRED-SESSION-SNAPSHOT')
|
||||
binding.dispose()
|
||||
})
|
||||
|
||||
it('restores configured paired scrollback after an ordinary park reveal', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const remotePtyId = 'remote:env-1@@terminal-1'
|
||||
|
|
|
|||
|
|
@ -1054,6 +1054,7 @@ export function connectPanePty(
|
|||
// that follows this mount, so connect time is the only moment a pane can tell
|
||||
// a reveal remount from an in-place reattach.
|
||||
let mountFollowsTerminalPark = isTerminalTabParked(deps.tabId)
|
||||
let authoritativeReattachGeneration = 0
|
||||
exposeE2eTerminalPtyOutputDebug()
|
||||
let disposed = false
|
||||
const structuralReplayCoordinator = createTerminalStructuralReplayCoordinator(pane.terminal)
|
||||
|
|
@ -5147,6 +5148,7 @@ export function connectPanePty(
|
|||
// about to unmount — so skip the doomed respawn instead of racing it.
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
authoritativeReattachGeneration += 1
|
||||
clearPaneMode2031State()
|
||||
clearHiddenOutputRestoreState()
|
||||
// Why: a canceled old replay clear can preserve xterm's native
|
||||
|
|
@ -7753,12 +7755,107 @@ export function connectPanePty(
|
|||
return true
|
||||
}
|
||||
|
||||
let parkedSshSnapshotPrefetch: {
|
||||
ptyId: string
|
||||
fetch: () => Promise<PtyBufferSnapshot | null>
|
||||
} | null = null
|
||||
|
||||
const createSshMainModelSnapshotProbe = (
|
||||
ptyId: string
|
||||
): (() => Promise<PtyBufferSnapshot | null>) =>
|
||||
memoizeSshReattachModelSnapshotProbe(async (): Promise<PtyBufferSnapshot | null> => {
|
||||
const sshParkingEnabled = useAppStore.getState().settings?.terminalSshViewParking !== false
|
||||
if (!shouldFetchSshReattachModelSnapshot({ ptyId, sshParkingEnabled })) {
|
||||
return null
|
||||
}
|
||||
const snapshot = await resolveSshReattachModelSnapshotWithTimeout(
|
||||
window.api.pty.getMainBufferSnapshot(ptyId, {
|
||||
scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback)
|
||||
})
|
||||
)
|
||||
return snapshot &&
|
||||
decideSshReattachPaintSource({ ptyId, sshParkingEnabled, snapshot }) ===
|
||||
'main-model-snapshot'
|
||||
? snapshot
|
||||
: null
|
||||
})
|
||||
|
||||
const getSshMainModelSnapshotProbe = (
|
||||
ptyId: string
|
||||
): (() => Promise<PtyBufferSnapshot | null>) => {
|
||||
if (parkedSshSnapshotPrefetch?.ptyId !== ptyId) {
|
||||
parkedSshSnapshotPrefetch = { ptyId, fetch: createSshMainModelSnapshotProbe(ptyId) }
|
||||
}
|
||||
return parkedSshSnapshotPrefetch.fetch
|
||||
}
|
||||
|
||||
const prepaintParkedSshSnapshot = (ptyId: string | null): void => {
|
||||
const parsedPtyId = ptyId ? parseAppSshPtyId(ptyId) : null
|
||||
if (
|
||||
!ptyId ||
|
||||
!mountFollowsTerminalPark ||
|
||||
parsedPtyId?.connectionId !== connectionId ||
|
||||
!capturedDirectSshRetryLeaseMatches()
|
||||
) {
|
||||
return
|
||||
}
|
||||
const capturedGeneration = authoritativeReattachGeneration
|
||||
const isCurrent = (): boolean =>
|
||||
!disposed &&
|
||||
mountFollowsTerminalPark &&
|
||||
authoritativeReattachGeneration === capturedGeneration &&
|
||||
capturedDirectSshRetryLeaseMatches()
|
||||
const fetchSnapshot = getSshMainModelSnapshotProbe(ptyId)
|
||||
void fetchSnapshot()
|
||||
.then(async (snapshot) => {
|
||||
if (!snapshot || !isCurrent()) {
|
||||
return
|
||||
}
|
||||
await structuralReplayCoordinator.run(
|
||||
async () => {
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
const modelData = `${snapshot.scrollbackAnsi ?? ''}${snapshot.data}`
|
||||
rememberReattachPayloadAgentSignal(modelData, { fullScreenReplay: true })
|
||||
if (
|
||||
hasPositiveTerminalDimensions(snapshot.cols, snapshot.rows) &&
|
||||
(pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows)
|
||||
) {
|
||||
suppressStructuralReplayPtyResize = true
|
||||
try {
|
||||
pane.terminal.resize(snapshot.cols, snapshot.rows)
|
||||
} finally {
|
||||
suppressStructuralReplayPtyResize = false
|
||||
}
|
||||
}
|
||||
kittyKeyboardModes.scanReplay(modelData)
|
||||
for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot)) {
|
||||
writeReplayData(replayChunk)
|
||||
}
|
||||
writeReplayData(reattachReplayResetSequence(modelData))
|
||||
if (snapshot.pendingEscapeTailAnsi) {
|
||||
writeReplayData(snapshot.pendingEscapeTailAnsi)
|
||||
}
|
||||
recordTerminalOutput(pane.terminal)
|
||||
await waitForTerminalReplayWritesParsed(pane.terminal)
|
||||
if (isCurrent()) {
|
||||
manager.rebuildPaneWebgl(pane.id)
|
||||
}
|
||||
},
|
||||
{ shouldRestore: isCurrent }
|
||||
)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const handleReattachResult = async (
|
||||
result: PtyConnectResult | string | void,
|
||||
staleSessionId?: string | null,
|
||||
coldRestoreStartup?: ColdRestoreAgentResumeStartup | null,
|
||||
attemptGeneration = transportStreamGeneration
|
||||
): Promise<boolean> => {
|
||||
authoritativeReattachGeneration += 1
|
||||
if (disposed) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -7886,28 +7983,7 @@ export function connectPanePty(
|
|||
// deadlock on the coordinator's tail chain.
|
||||
// Memoized: the prefetch and the payload task share one probe result, so a
|
||||
// null prefetch can never buy a second timeout before the relay paint.
|
||||
const fetchSshMainModelReattachSnapshot = memoizeSshReattachModelSnapshotProbe(
|
||||
async (): Promise<PtyBufferSnapshot | null> => {
|
||||
const sshParkingEnabled =
|
||||
useAppStore.getState().settings?.terminalSshViewParking !== false
|
||||
if (!shouldFetchSshReattachModelSnapshot({ ptyId, sshParkingEnabled })) {
|
||||
return null
|
||||
}
|
||||
const snapshot = await resolveSshReattachModelSnapshotWithTimeout(
|
||||
window.api.pty.getMainBufferSnapshot(ptyId, {
|
||||
scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback)
|
||||
})
|
||||
)
|
||||
if (
|
||||
!snapshot ||
|
||||
decideSshReattachPaintSource({ ptyId, sshParkingEnabled, snapshot }) !==
|
||||
'main-model-snapshot'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
)
|
||||
const fetchSshMainModelReattachSnapshot = getSshMainModelSnapshotProbe(ptyId)
|
||||
// Why consume-once: only the first reattach of a reveal remount may pay
|
||||
// the probe; a later in-place reconnect on this same mount must not buy a
|
||||
// second timeout before the relay paint.
|
||||
|
|
@ -8189,6 +8265,7 @@ export function connectPanePty(
|
|||
|
||||
const attachRetainedLegacyPty = (ptyId: string): boolean => {
|
||||
try {
|
||||
authoritativeReattachGeneration += 1
|
||||
clearPaneMode2031State()
|
||||
clearHiddenOutputRestoreState()
|
||||
const outputCallbacks = captureTransportOutputCallbacks(reportError)
|
||||
|
|
@ -8246,6 +8323,8 @@ export function connectPanePty(
|
|||
)
|
||||
const legacyWorkerOwnsPane = isLegacyWorkerAutomaticResumeBlocked()
|
||||
if (gate.enterDeferredFlow && (!legacyWorkerOwnsPane || !gate.sshConnected)) {
|
||||
// Paint main's parked model while SSH recovery continues off the render path.
|
||||
prepaintParkedSshSnapshot(pendingSessionId)
|
||||
void (async () => {
|
||||
// Why: for a passphrase target with no cached credential, don't auto-fire ssh.connect — a prompt popping just from focusing a tab / Cmd+J would surprise the user.
|
||||
// Wait for a user-initiated connect first; no-passphrase targets return false here and auto-connect as before.
|
||||
|
|
@ -8608,6 +8687,7 @@ export function connectPanePty(
|
|||
if (deferredReattachSessionId) {
|
||||
allowInitialIdleCacheSeed = true
|
||||
recordPtyConnectDiagnostic(`pane=${pane.id} -> REATTACH ${deferredReattachSessionId}`)
|
||||
prepaintParkedSshSnapshot(deferredReattachSessionId)
|
||||
|
||||
// Why: pre-signal (declare) before the reattach connect so the cooperation gate suppresses the daemon seed for this paneKey; Electron preserves IPC order.
|
||||
// See docs/mobile-prefer-renderer-scrollback.md (Renderer-side prerequisite requirement #4).
|
||||
|
|
|
|||
Loading…
Reference in New Issue