mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(terminal): preserve teardown ownership until confirmed
This commit is contained in:
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user