mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
fix: migrate legacy SSH leases after reattach
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user