fix(terminal): preserve teardown ownership until confirmed

This commit is contained in:
Brennan Benson
2026-07-12 16:31:19 -07:00
parent 8ea0622587
commit 3dd79e7433
15 changed files with 455 additions and 145 deletions
@@ -0,0 +1,133 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SshRelaySession } from './ssh-relay-session'
import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures'
vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() }))
vi.mock('./ssh-channel-multiplexer', () => ({
SshChannelMultiplexer: class MockSshChannelMultiplexer {
notify = vi.fn()
request = vi.fn().mockResolvedValue([])
onNotification = vi.fn().mockReturnValue(() => {})
onRequest = vi.fn().mockReturnValue(() => {})
onDispose = vi.fn().mockReturnValue(() => {})
dispose = vi.fn()
isDisposed = vi.fn().mockReturnValue(false)
}
}))
vi.mock('../providers/ssh-pty-provider', () => ({
isSshPtyNotFoundError: () => false,
isSshPtyIdentityMismatchError: () => false,
SshPtyProvider: class MockSshPtyProvider {
onData = vi.fn().mockReturnValue(() => {})
onReplay = vi.fn().mockReturnValue(() => {})
onExit = vi.fn().mockReturnValue(() => {})
dispose = vi.fn()
}
}))
vi.mock('../providers/ssh-filesystem-provider', () => ({
SshFilesystemProvider: class MockSshFilesystemProvider {
dispose = vi.fn()
}
}))
vi.mock('../providers/ssh-git-provider', () => ({
SshGitProvider: class MockSshGitProvider {}
}))
vi.mock('../ipc/pty', () => ({
registerSshPtyProvider: vi.fn(),
unregisterSshPtyProvider: vi.fn(),
getSshPtyProvider: vi.fn(),
getPtyIdsForConnection: vi.fn().mockReturnValue([]),
clearPtyOwnershipForConnection: vi.fn(),
clearProviderPtyState: vi.fn(),
deletePtyOwnership: vi.fn(),
setPtyOwnership: vi.fn(),
answerStartupTerminalColorQueriesForPty: vi.fn((_id: string, data: string) => data)
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
registerSshFilesystemProvider: vi.fn(),
unregisterSshFilesystemProvider: vi.fn(),
getSshFilesystemProvider: vi.fn().mockReturnValue({ dispose: vi.fn() })
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
registerSshGitProvider: vi.fn(),
unregisterSshGitProvider: vi.fn()
}))
const { getSshPtyProvider, getPtyIdsForConnection, setPtyOwnership } = await import('../ipc/pty')
describe('SSH relay generation reattach', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDeploySuccess()
})
it('reattaches a durable lease only within the relay generation that minted it', async () => {
const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps()
const attachForReconnect = vi.fn().mockResolvedValue({})
vi.mocked(getSshPtyProvider).mockReturnValue({
attachForReconnect,
getRelayInstanceId: () => 'relay-generation',
dispose: vi.fn()
} as unknown as ReturnType<typeof getSshPtyProvider>)
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([
{
targetId: 'target-1',
ptyId: 'pty-live',
relayInstanceId: 'relay-generation',
state: 'detached'
},
{ targetId: 'target-1', ptyId: 'pty-expired', state: 'expired' }
] as ReturnType<typeof mockStore.getSshRemotePtyLeases>)
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
await session.establish(mockConn)
expect(attachForReconnect).toHaveBeenCalledWith('pty-live')
expect(attachForReconnect).not.toHaveBeenCalledWith('pty-expired')
expect(setPtyOwnership).toHaveBeenCalledWith(
'ssh:target-1@@relay-generation@@pty-live',
'target-1'
)
})
it('expires an older-generation lease before its recycled relay id can attach', async () => {
const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps()
const attachForReconnect = vi.fn()
vi.mocked(getSshPtyProvider).mockReturnValue({
attachForReconnect,
getRelayInstanceId: () => 'relay-current',
dispose: vi.fn()
} as unknown as ReturnType<typeof getSshPtyProvider>)
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([
{
targetId: 'target-1',
ptyId: 'pty-recycled',
relayInstanceId: 'relay-old',
state: 'detached'
}
] as ReturnType<typeof mockStore.getSshRemotePtyLeases>)
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
await session.establish(mockConn)
expect(attachForReconnect).not.toHaveBeenCalled()
expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith(
'target-1',
'pty-recycled',
'expired'
)
expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:exit', {
id: 'ssh:target-1@@relay-old@@pty-recycled',
code: -1
})
})
})
-26
View File
@@ -556,32 +556,6 @@ describe('SshRelaySession', () => {
expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('target-1', 'pty-1', 'attached')
})
it('establish re-attaches durable leases after app restart', async () => {
const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps()
const { getSshPtyProvider } = await import('../ipc/pty')
const mockAttach = vi.fn().mockResolvedValue(undefined)
vi.mocked(getSshPtyProvider).mockReturnValue({
attachForReconnect: mockAttach,
getRelayInstanceId: () => 'relay-generation',
dispose: vi.fn()
} as unknown as ReturnType<typeof getSshPtyProvider>)
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
vi.mocked(mockStore.getSshRemotePtyLeases).mockReturnValue([
{ targetId: 'target-1', ptyId: 'pty-live', state: 'detached' },
{ targetId: 'target-1', ptyId: 'pty-expired', state: 'expired' }
] as ReturnType<typeof mockStore.getSshRemotePtyLeases>)
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
await session.establish(mockConn)
expect(mockAttach).toHaveBeenCalledWith('pty-live')
expect(mockAttach).not.toHaveBeenCalledWith('pty-expired')
const expectedAppPtyId = 'ssh:target-1@@relay-generation@@pty-live'
expect(setPtyOwnership).toHaveBeenCalledWith(expectedAppPtyId, 'target-1')
expect(mockStore.markSshRemotePtyLease).toHaveBeenCalledWith('target-1', 'pty-live', 'attached')
})
it('forwards a lease tab identity to reattach so a reset relay cannot cross-wire it', async () => {
const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps()
const { getSshPtyProvider } = await import('../ipc/pty')
+46 -16
View File
@@ -19,7 +19,7 @@ import {
isSshPtyIdentityMismatchError,
isSshPtyNotFoundError
} from '../providers/ssh-pty-provider'
import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id'
import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider'
import { SshGitProvider } from '../providers/ssh-git-provider'
import { agentHookServer } from '../agent-hooks/server'
@@ -1200,16 +1200,43 @@ export class SshRelaySession {
}
}
private expireReattachPty(relayPtyId: string, appPtyId: string): void {
clearProviderPtyState(appPtyId)
deletePtyOwnership(appPtyId)
this.forwardedReattachReplayByPty.delete(appPtyId)
this.store.markSshRemotePtyLease(this.targetId, relayPtyId, 'expired')
const win = this.getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('pty:exit', { id: appPtyId, code: -1 })
}
}
private async reattachKnownPtys(shouldContinue: () => boolean): Promise<void> {
const ptyProvider = getSshPtyProvider(this.targetId) as SshPtyProvider | undefined
if (!ptyProvider) {
return
}
const relayInstanceId = ptyProvider.getRelayInstanceId?.()
const activeLeases = this.store
.getSshRemotePtyLeases(this.targetId)
.filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired')
const leasedPtyIds = activeLeases.map((lease) => lease.ptyId)
const attachableLeases = activeLeases.filter((lease) => {
if (!lease.relayInstanceId || !relayInstanceId || lease.relayInstanceId === relayInstanceId) {
return true
}
const staleAppPtyId = toAppSshPtyId(this.targetId, lease.ptyId, lease.relayInstanceId)
console.warn(
`[ssh-relay-session] Expiring stale PTY ${lease.ptyId} for ${this.targetId} after relay generation changed`
)
this.expireReattachPty(lease.ptyId, staleAppPtyId)
return false
})
const leasedPtyIds = attachableLeases.map((lease) => lease.ptyId)
// Why: carry each lease's pane identity into the attach so the relay can
// reject cross-generation id collisions even between split panes in one tab.
// Older leases may lack leafId, so tabId remains the compatibility fallback.
const expectedIdentityByPtyId = new Map(
activeLeases
attachableLeases
.map((lease): [string, ExpectedPtyIdentity] | null => {
const expected = expectedIdentityForLease(lease)
return expected ? [lease.ptyId, expected] : null
@@ -1218,19 +1245,22 @@ export class SshRelaySession {
)
// Why: after app restart, ptyOwnership is empty but durable SSH leases
// still describe remote PTYs that survived in the relay grace window.
const ptyIds = Array.from(
new Set([
...getPtyIdsForConnection(this.targetId).map((ptyId) =>
toRelaySshPtyId(this.targetId, ptyId)
),
...leasedPtyIds
])
)
const ptyProvider = getSshPtyProvider(this.targetId) as SshPtyProvider | undefined
if (!ptyProvider) {
return
}
const relayInstanceId = ptyProvider.getRelayInstanceId?.()
const ownedPtyIds = getPtyIdsForConnection(this.targetId).flatMap((appPtyId) => {
const parsed = parseAppSshPtyId(appPtyId)
if (
parsed?.relayInstanceId &&
relayInstanceId &&
parsed.relayInstanceId !== relayInstanceId
) {
console.warn(
`[ssh-relay-session] Expiring stale PTY ${parsed.relayPtyId} for ${this.targetId} after relay generation changed`
)
this.expireReattachPty(parsed.relayPtyId, appPtyId)
return []
}
return [toRelaySshPtyId(this.targetId, appPtyId)]
})
const ptyIds = Array.from(new Set([...ownedPtyIds, ...leasedPtyIds]))
for (const ptyId of ptyIds) {
if (!shouldContinue()) {
return