diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 8feb90a3298..48321b3b11d 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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) diff --git a/src/main/ssh/ssh-relay-generation-reattach.test.ts b/src/main/ssh/ssh-relay-generation-reattach.test.ts index 210ca3af436..1ca543af27c 100644 --- a/src/main/ssh/ssh-relay-generation-reattach.test.ts +++ b/src/main/ssh/ssh-relay-generation-reattach.test.ts @@ -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) + 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) + 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 }) + } + }) }) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 2215d984292..3a95c59dfda 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -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) } } } diff --git a/src/main/terminal-teardown-intent-records.ts b/src/main/terminal-teardown-intent-records.ts index b60932af778..f5ba04497b9 100644 --- a/src/main/terminal-teardown-intent-records.ts +++ b/src/main/terminal-teardown-intent-records.ts @@ -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 diff --git a/src/main/terminal-teardown-intent-snapshot.ts b/src/main/terminal-teardown-intent-snapshot.ts index 297383330e0..a6b5d3224d0 100644 --- a/src/main/terminal-teardown-intent-snapshot.ts +++ b/src/main/terminal-teardown-intent-snapshot.ts @@ -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) =>