fix: migrate legacy SSH leases after reattach

This commit is contained in:
Brennan Benson
2026-07-13 18:01:52 -07:00
parent f6da9ef4bf
commit 92a693b1e9
5 changed files with 197 additions and 16 deletions
+32
View File
@@ -6604,6 +6604,38 @@ export class Store {
return true
}
migrateLegacySshRemotePtyLeaseGeneration(
targetId: string,
ptyId: string,
relayInstanceId: string
): boolean {
const relayPtyId = this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId)
const lease = this.state.sshRemotePtyLeases?.find(
(entry) =>
entry.targetId === targetId &&
entry.ptyId === relayPtyId &&
entry.relayInstanceId === undefined
)
if (!lease) {
return false
}
const now = Date.now()
lease.relayInstanceId = relayInstanceId
lease.state = 'attached'
lease.updatedAt = now
lease.lastAttachedAt = now
// Why: crash-safe adoption must remain O(1) per restored pane instead of
// synchronously serializing the full persisted store during startup.
this.persistTerminalTeardownIntents({
kind: 'ssh-migrate-generation',
targetId,
ptyId: relayPtyId,
relayInstanceId,
attachedAt: now
})
return true
}
markSshRemotePtyShutdownRequested(targetId: string, ptyId: string): boolean {
const requestedGeneration = parseAppSshPtyId(ptyId)?.relayInstanceId
const relayPtyId = this.getRelayPtyIdForSshLeaseStorage(targetId, ptyId)
@@ -1,7 +1,20 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { SshRelaySession } from './ssh-relay-session'
import { Store } from '../persistence'
import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures'
vi.mock('electron', () => ({
app: { getPath: () => process.cwd() },
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('encrypted:'.length)
}
}))
vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() }))
vi.mock('./ssh-channel-multiplexer', () => ({
@@ -17,8 +30,10 @@ vi.mock('./ssh-channel-multiplexer', () => ({
}))
vi.mock('../providers/ssh-pty-provider', () => ({
isSshPtyNotFoundError: () => false,
isSshPtyIdentityMismatchError: () => false,
isSshPtyNotFoundError: (err: unknown) =>
(err instanceof Error ? err.message : String(err)).includes('not found'),
isSshPtyIdentityMismatchError: (err: unknown) =>
(err instanceof Error ? err.message : String(err)).includes('identity mismatch'),
SshPtyProvider: class MockSshPtyProvider {
onData = vi.fn().mockReturnValue(() => {})
onReplay = vi.fn().mockReturnValue(() => {})
@@ -133,4 +148,93 @@ describe('SSH relay generation reattach', () => {
code: -1
})
})
it('migrates a legacy lease only after the current relay proves it can attach', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-legacy-ssh-lease-'))
const dataFile = join(dir, 'state.json')
const store = new Store({ dataFile })
const reloadedStores: Store[] = []
try {
store.upsertSshRemotePtyLease({
targetId: 'target-1',
ptyId: 'pty-legacy',
state: 'detached'
})
const synchronousFlush = vi.spyOn(store, 'flush')
const { mockConn, mockPortForward, getMainWindow } = createMockDeps()
const attachForReconnect = vi.fn().mockResolvedValue({})
vi.mocked(getSshPtyProvider).mockReturnValue({
attachForReconnect,
getRelayInstanceId: () => 'relay-current',
dispose: vi.fn()
} as unknown as ReturnType<typeof getSshPtyProvider>)
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
const session = new SshRelaySession('target-1', getMainWindow, store, mockPortForward)
await session.establish(mockConn)
expect(attachForReconnect).toHaveBeenCalledWith('pty-legacy')
expect(store.getSshRemotePtyLeases('target-1')).toEqual([
expect.objectContaining({
ptyId: 'pty-legacy',
relayInstanceId: 'relay-current',
state: 'attached',
lastAttachedAt: expect.any(Number)
})
])
expect(synchronousFlush).not.toHaveBeenCalled()
const reloaded = new Store({ dataFile })
reloadedStores.push(reloaded)
expect(reloaded.getSshRemotePtyLeases('target-1')[0]).toEqual(
expect.objectContaining({ relayInstanceId: 'relay-current', state: 'attached' })
)
} finally {
store.flush()
for (const reloaded of reloadedStores) {
reloaded.flush()
}
rmSync(dir, { recursive: true, force: true })
}
})
it('expires a failed legacy identity without adopting the current relay generation', async () => {
const dir = mkdtempSync(join(tmpdir(), 'orca-stale-ssh-lease-'))
const store = new Store({ dataFile: join(dir, 'state.json') })
try {
store.upsertSshRemotePtyLease({
targetId: 'target-1',
ptyId: 'pty-recycled',
state: 'detached'
})
const { mockConn, mockPortForward, getMainWindow, mockWindow } = createMockDeps()
const attachForReconnect = vi
.fn()
.mockRejectedValue(new Error('PTY "pty-recycled" not found (identity mismatch)'))
vi.mocked(getSshPtyProvider).mockReturnValue({
attachForReconnect,
getRelayInstanceId: () => 'relay-current',
dispose: vi.fn()
} as unknown as ReturnType<typeof getSshPtyProvider>)
vi.mocked(getPtyIdsForConnection).mockReturnValue([])
const session = new SshRelaySession('target-1', getMainWindow, store, mockPortForward)
await session.establish(mockConn)
expect(store.getSshRemotePtyLeases('target-1')).toEqual([
expect.objectContaining({ ptyId: 'pty-recycled', state: 'expired' })
])
expect(store.getSshRemotePtyLeases('target-1')[0]?.relayInstanceId).toBeUndefined()
expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:exit', {
id: 'ssh:target-1@@pty-recycled',
code: -1
})
expect(setPtyOwnership).not.toHaveBeenCalledWith(
'ssh:target-1@@relay-current@@pty-recycled',
'target-1'
)
} finally {
store.flush()
rmSync(dir, { recursive: true, force: true })
}
})
})
+27 -14
View File
@@ -1244,6 +1244,7 @@ export class SshRelaySession {
})
.filter((entry): entry is [string, ExpectedPtyIdentity] => entry !== null)
)
const leaseByPtyId = new Map(attachableLeases.map((lease) => [lease.ptyId, lease]))
// Why: after app restart, ptyOwnership is empty but durable SSH leases
// still describe remote PTYs that survived in the relay grace window.
const ownedPtyIds = getPtyIdsForConnection(this.targetId).flatMap((appPtyId) => {
@@ -1276,15 +1277,35 @@ export class SshRelaySession {
return
}
const appPtyId = toAppSshPtyId(this.targetId, ptyId, relayInstanceId)
const lease = leaseByPtyId.get(ptyId)
if (lease && !lease.relayInstanceId && relayInstanceId) {
// Why: only a proven attach can bind a pre-generation lease to the
// relay boot that actually owns it; strict normal writes stay fenced.
const migrated = this.store.migrateLegacySshRemotePtyLeaseGeneration(
this.targetId,
ptyId,
relayInstanceId
)
if (!migrated && !this.store.markSshRemotePtyLease(this.targetId, appPtyId, 'attached')) {
continue
}
} else {
this.store.markSshRemotePtyLease(this.targetId, appPtyId, 'attached')
}
setPtyOwnership(appPtyId, this.targetId)
this.store.markSshRemotePtyLease(this.targetId, appPtyId, 'attached')
this.forwardReattachReplay(appPtyId, attachResult.replay ?? '')
} catch (err) {
if (!isSshPtyNotFoundError(err)) {
throw err
}
const appPtyId = toAppSshPtyId(this.targetId, ptyId, relayInstanceId)
if (isSshPtyIdentityMismatchError(err)) {
const lease = leaseByPtyId.get(ptyId)
const isLegacyLease = Boolean(lease && !lease.relayInstanceId && relayInstanceId)
const appPtyId = toAppSshPtyId(
this.targetId,
ptyId,
isLegacyLease ? undefined : relayInstanceId
)
if (isSshPtyIdentityMismatchError(err) && !isLegacyLease) {
console.warn(
`[ssh-relay-session] Ignoring stale PTY ${ptyId} for ${this.targetId} after relay identity mismatch: ${
err instanceof Error ? err.message : String(err)
@@ -1297,17 +1318,9 @@ export class SshRelaySession {
err instanceof Error ? err.message : String(err)
}`
)
clearProviderPtyState(appPtyId)
deletePtyOwnership(appPtyId)
this.forwardedReattachReplayByPty.delete(appPtyId)
this.store.markSshRemotePtyLease(this.targetId, appPtyId, 'expired')
// Why: if the new relay cannot reattach this id, the remote backing
// process is gone. Tell the renderer so it clears stale pane bindings
// instead of keeping a cursor-only terminal.
const win = this.getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('pty:exit', { id: appPtyId, code: -1 })
}
// Why: a failed legacy attach has not earned the new generation. Retire
// and notify its original id so a recycled current relay id stays intact.
this.expireReattachPty(ptyId, appPtyId)
}
}
}
@@ -69,6 +69,15 @@ export const terminalTeardownIntentJournalRecordSchema = z.discriminatedUnion('k
journalBaseSchema
.extend({ kind: z.literal('runtime-remove-environment'), environmentId: nonEmptyString })
.strict(),
journalBaseSchema
.extend({
kind: z.literal('ssh-migrate-generation'),
targetId: nonEmptyString,
ptyId: nonEmptyString,
relayInstanceId: nonEmptyString,
attachedAt: timestamp
})
.strict(),
journalBaseSchema
.extend({
kind: z.literal('ssh-set'),
@@ -93,6 +102,13 @@ export type TerminalTeardownIntentMutation =
| { kind: 'runtime-upsert'; request: PersistedRuntimeTerminalClose }
| { kind: 'runtime-remove'; environmentId: string; handle: string }
| { kind: 'runtime-remove-environment'; environmentId: string }
| {
kind: 'ssh-migrate-generation'
targetId: string
ptyId: string
relayInstanceId: string
attachedAt: number
}
| {
kind: 'ssh-set'
targetId: string
@@ -182,6 +182,22 @@ function applyJournalRecord(
(entry) => entry.environmentId !== record.environmentId
)
return
case 'ssh-migrate-generation': {
const lease = state.sshRemotePtyLeases?.find(
(candidate) =>
candidate.targetId === record.targetId &&
candidate.ptyId === record.ptyId &&
candidate.relayInstanceId === undefined
)
if (!lease) {
return
}
lease.relayInstanceId = record.relayInstanceId
lease.state = 'attached'
lease.updatedAt = Math.max(lease.updatedAt, record.attachedAt)
lease.lastAttachedAt = Math.max(lease.lastAttachedAt ?? 0, record.attachedAt)
return
}
case 'ssh-set': {
const lease = state.sshRemotePtyLeases?.find(
(candidate) =>