diff --git a/docs/reference/ssh-execution-boundary.md b/docs/reference/ssh-execution-boundary.md index 88a4a3c0a0ee..a85c2c76773e 100644 --- a/docs/reference/ssh-execution-boundary.md +++ b/docs/reference/ssh-execution-boundary.md @@ -40,11 +40,15 @@ Two ways remote work _can_ actually stop: Reconnect re-attaches to the same live PTYs and replays a bounded buffer (`REPLAY_BUFFER_MAX`, a 102,400-code-unit tail). Output beyond that while you were away is lost to the client even though the process was never interrupted: **the transcript is truncated; the work stays `live`.** -## Updating Orca strands relay-backed terminals +## Updating Orca retires relay-backed terminals -There is a third outcome that is neither of the two above, and the vocabulary matters: the work does not stop, it becomes permanently unreachable. +There is a third outcome that is neither of the two above: an app update that changes the relay bundle hash starts a new generation. The work on the old generation is not reattached. -The relay's install directory — and therefore its socket path — is namespaced by a content hash of the relay bundle (`computeRemoteRelayDir` in `src/main/ssh/ssh-relay-versioned-install.ts`, consumed by `resolveRemoteInstallState` in `src/main/ssh/ssh-relay-deploy.ts`), and the daemon refuses any client whose bundle hash differs (`handleDaemonHandshakeFrame` in `src/relay/relay-handshake.ts`, exit `EXIT_CODE_VERSION_MISMATCH` 42). Two builds whose relay protocol is byte-identical still refuse each other. So the first reconnect after an app update deploys a new relay at a path the incumbent was never listening on, and cannot reach it even in principle. Every PTY the incumbent owns is `unverifiable` — running, unreachable, and never `exited`. The client's leases are attempted against a relay that never minted their ids, expired on the not-found answer (`handlePtyReattachFailure` in `src/main/ssh/ssh-relay-session.ts`), and the pane falls back to a cold-restore agent resume — or to a bare shell when no resumable provider session was captured for it. The old relay keeps its directory pinned against GC, because its socket really is live (`hasLiveRelaySocket` in `src/main/ssh/remote-install-gc.ts`). See #13852. +The relay's install directory — and therefore its socket path — is namespaced by a content hash of the relay bundle (`computeRemoteRelayDir` in `src/main/ssh/ssh-relay-versioned-install.ts`, consumed by `resolveRemoteInstallState` in `src/main/ssh/ssh-relay-deploy.ts`), and the daemon refuses any client whose bundle hash differs (`handleDaemonHandshakeFrame` in `src/relay/relay-handshake.ts`, exit `EXIT_CODE_VERSION_MISMATCH` 42). Two builds whose relay protocol is byte-identical still refuse each other. So the first reconnect after an app update deploys a new relay at a path the incumbent was never listening on, and cannot reach it even in principle. + +That unreachable generation is then retired, not left running. If the old relay is the sole socket holder — no other Orca still connected — the superseded sweep SIGTERMs it even when it still holds PTYs (`isReapableSupersededRelay` in `src/main/ssh/ssh-relay-endpoint-incumbent.ts`). The client expires its leases, does not cold-restore those panes, and closes the remote terminal tabs for that host. Open new terminals after updating. A probe that cannot answer stays `unverifiable` and is not signalled; a relay with another client still attached is retained. + +Ordinary disconnect and app quit are unchanged: `--grace-time 0` still keeps remote work alive until you reconnect to the _same_ relay generation. See #13852. The peer model does not have this failure, and that is the concrete reason behind "One host, one model" below. The daemon's endpoint is namespaced by a **semantic protocol version** rather than a build (`daemon-v.sock`, from `getDaemonSocketPath` in `src/main/daemon/daemon-spawner.ts`), every earlier protocol version stays attachable (`PROTOCOL_VERSION` in `src/main/daemon/daemon-protocol-version.ts`), and a daemon holding live sessions is preserved across a version change instead of replaced (`shouldPreserveDaemonWithLiveSessions` in `src/main/daemon/daemon-replacement-preflight.ts`). diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts b/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts index ef2f4d622588..833b2cd6d4a1 100644 --- a/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts +++ b/src/main/ssh/ssh-relay-endpoint-incumbent-shell.integration.test.ts @@ -15,7 +15,10 @@ import { relayEndpointIncumbentProbeCommand, type RelayEndpointIncumbent } from './ssh-relay-endpoint-incumbent' -import { reapEmptyRelayHuskCommand } from './ssh-relay-endpoint-takeover' +import { + reapEmptyRelayHuskCommand, + reapSupersededRelayCommand +} from './ssh-relay-endpoint-takeover' import { RELAY_DAEMON_SERVICE_ENTRY_FILENAMES } from '../../shared/relay-artifacts' const posixOnly = process.platform === 'win32' ? describe.skip : describe @@ -239,6 +242,13 @@ posixOnly('empty relay husk reap against a real process', () => { expect(relay.killed).toBe(false) }) + it('signals a superseded relay that still holds work', async () => { + const sockPath = join(workDir, 'superseded-work.sock') + const relay = await startFakeRelay(sockPath, { withChild: true }) + const output = await sh(reapSupersededRelayCommand(relay.pid!, sockPath)) + expect(output.trim()).toBe('GONE') + }) + it('terminates a relay whose only children are its own service processes (#13614)', async () => { const sockPath = join(workDir, 'service-husk.sock') const relay = await startFakeRelay(sockPath, { diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts b/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts index 40d2c49f5d92..e983536b267c 100644 --- a/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts +++ b/src/main/ssh/ssh-relay-endpoint-incumbent.test.ts @@ -11,6 +11,7 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ import { describeRelayEndpointIncumbent, isReapableRelayHusk, + isReapableSupersededRelay, mayLaunchOverRelayEndpoint, parseRelayEndpointIncumbentProbe, probeRelayEndpointIncumbent, @@ -291,6 +292,39 @@ describe('isReapableRelayHusk', () => { }) }) +describe('isReapableSupersededRelay', () => { + const husk = parseRelayEndpointIncumbentProbe( + SOCK, + probeOutput(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=500 yes 0 0']) + ) + + it('accepts a sole holder even when it still has unaccounted-for children', () => { + expect( + isReapableSupersededRelay({ + ...husk, + holders: [{ pid: 500, matchesRelayArgv: true, childCount: 13, unrecognizedChildCount: 11 }] + }) + ).toBe(true) + }) + + it('still refuses when another process holds the socket', () => { + expect( + isReapableSupersededRelay({ + ...husk, + holders: [ + { pid: 500, matchesRelayArgv: true, childCount: 13, unrecognizedChildCount: 11 }, + { pid: 501, matchesRelayArgv: false, childCount: 0, unrecognizedChildCount: 0 } + ] + }) + ).toBe(false) + }) + + it('still refuses unverifiable endpoints', () => { + expect(isReapableSupersededRelay({ ...husk, verdict: 'unverifiable' })).toBe(false) + expect(isReapableSupersededRelay({ ...husk, holdersEnumerable: false })).toBe(false) + }) +}) + describe('describeRelayEndpointIncumbent', () => { it('distinguishes "no holders" from "could not enumerate holders"', () => { const none = parseRelayEndpointIncumbentProbe( diff --git a/src/main/ssh/ssh-relay-endpoint-incumbent.ts b/src/main/ssh/ssh-relay-endpoint-incumbent.ts index 343f92cadecc..36ab35999cd8 100644 --- a/src/main/ssh/ssh-relay-endpoint-incumbent.ts +++ b/src/main/ssh/ssh-relay-endpoint-incumbent.ts @@ -279,6 +279,16 @@ export function mayLaunchOverRelayEndpoint(incumbent: RelayEndpointIncumbent): b return incumbent.verdict !== 'live' } +function isSoleIdentifiedRelayHolder(incumbent: RelayEndpointIncumbent): boolean { + if (incumbent.verdict !== 'live' || !incumbent.holdersEnumerable) { + return false + } + if (incumbent.holders.length !== 1) { + return false + } + return incumbent.holders[0]?.matchesRelayArgv === true +} + /** * A live relay that provably holds nothing: identity confirmed against its argv, exactly one * holder, and no child the host could not account for as one of the daemon's own service @@ -289,14 +299,18 @@ export function mayLaunchOverRelayEndpoint(incumbent: RelayEndpointIncumbent): b * gate was unreachable for any relay that had ever served a vault request (#13614). */ export function isReapableRelayHusk(incumbent: RelayEndpointIncumbent): boolean { - if (incumbent.verdict !== 'live' || !incumbent.holdersEnumerable) { - return false - } - if (incumbent.holders.length !== 1) { - return false - } - const [holder] = incumbent.holders - return holder.matchesRelayArgv && holder.unrecognizedChildCount === 0 + return ( + isSoleIdentifiedRelayHolder(incumbent) && incumbent.holders[0]?.unrecognizedChildCount === 0 + ) +} + +/** + * A superseded generation this client can no longer handshake with. Sole holder means no other + * Orca is attached, so SIGTERM destroys only unreachable work — including live PTYs. + * Same-path takeover must keep using {@link isReapableRelayHusk}; that path still preserves work. + */ +export function isReapableSupersededRelay(incumbent: RelayEndpointIncumbent): boolean { + return isSoleIdentifiedRelayHolder(incumbent) } export function describeRelayEndpointIncumbent(incumbent: RelayEndpointIncumbent): string { diff --git a/src/main/ssh/ssh-relay-endpoint-takeover.test.ts b/src/main/ssh/ssh-relay-endpoint-takeover.test.ts index af0a8b4b4524..2991a74f179d 100644 --- a/src/main/ssh/ssh-relay-endpoint-takeover.test.ts +++ b/src/main/ssh/ssh-relay-endpoint-takeover.test.ts @@ -15,6 +15,7 @@ import { RelayCredentialMismatchError } from './ssh-relay-credential-mismatch-er import { interpretRelayHuskReapOutput, reapEmptyRelayHuskCommand, + reapSupersededRelayCommand, resolveRelayEndpointBeforeRelaunch } from './ssh-relay-endpoint-takeover' import { RelayVersionMismatchError } from './ssh-relay-version-mismatch-error' @@ -205,6 +206,18 @@ describe('reapEmptyRelayHuskCommand', () => { }) }) +describe('reapSupersededRelayCommand', () => { + it('still re-checks argv, but does not refuse a daemon that still holds PTYs', () => { + const command = reapSupersededRelayCommand(4242, SOCK) + expect(command.indexOf('MISMATCH')).toBeLessThan(command.indexOf('kill -TERM')) + expect(command.indexOf('holder_count=')).toBeLessThan(command.indexOf('kill -TERM')) + expect(command.indexOf('BUSY')).toBeLessThan(command.indexOf('kill -TERM')) + expect(command).toContain('kill -TERM') + expect(command).not.toContain('unrecognized_kids') + expect(command).toContain('while [ $i -lt 50 ]') + }) +}) + describe('interpretRelayHuskReapOutput', () => { it('claims reaped only for a post-signal liveness check that failed', () => { expect(interpretRelayHuskReapOutput('GONE\n')).toBe('reaped') diff --git a/src/main/ssh/ssh-relay-endpoint-takeover.ts b/src/main/ssh/ssh-relay-endpoint-takeover.ts index 607608658754..262d411a399f 100644 --- a/src/main/ssh/ssh-relay-endpoint-takeover.ts +++ b/src/main/ssh/ssh-relay-endpoint-takeover.ts @@ -1,11 +1,10 @@ /** * Deciding whether a relay socket path is ours to take, and acting on the answer. * - * The only destructive action available here is a SIGTERM to a relay that has been proven — - * by argv, by socket-holder enumeration, and by a child census re-run on the host immediately - * before the signal — to hold nothing at all. Everything else is left running. - * Per docs/reference/ssh-execution-boundary.md, a relay we merely failed to reach is - * `unverifiable`, and `unverifiable` never authorizes a kill or a rebind. + * Same-path takeover may SIGTERM only a relay proven empty (argv, sole holder, no unaccounted + * children). The superseded-generation sweep uses a separate command that still re-checks argv + * but may signal a daemon that still holds PTYs, because that generation is unreachable after + * an app update. A relay we merely failed to reach is `unverifiable` and never authorized. */ import type { SshConnection } from './ssh-connection' import { shellEscape } from './ssh-connection-utils' @@ -32,28 +31,40 @@ import type { RemoteHostPlatform } from './ssh-remote-platform' export type RelayHuskReapResult = 'reaped' | 'reap-unconfirmed' | 'retained-live-work' const REAP_CONFIRM_ATTEMPTS = 15 +// Relay dispose waits up to IMMEDIATE_PTY_EXIT_TIMEOUT_MS (8s) for PTY SIGKILL to land. +const SUPERSEDED_REAP_CONFIRM_ATTEMPTS = 50 -/** - * Signal one relay, re-verifying identity and emptiness inside the same command. - * - * The re-verification is not belt-and-braces: a client can attach and spawn a PTY between the - * probe and the signal, and pids are reused. `MISMATCH`/`BUSY` abort without signalling. - */ -export function reapEmptyRelayHuskCommand(pid: number, sockPath: string): string { +function reapRelayDaemonCommand( + pid: number, + sockPath: string, + options?: { requireEmptyUnrecognizedChildren?: boolean; confirmAttempts?: number } +): string { + const requireEmpty = options?.requireEmptyUnrecognizedChildren !== false + const confirmAttempts = options?.confirmAttempts ?? REAP_CONFIRM_ATTEMPTS return [ `pid=${shellEscape(String(pid))}`, `sock=${shellEscape(sockPath)}`, 'args=$(ps -o args= -p "$pid" 2>/dev/null | tr "\\n" " ")', 'case "$args" in *relay.js*"$sock"*) ;; *) printf \'MISMATCH\\n\'; exit 0 ;; esac', - // Why the same census as the probe: `unknown` (no pgrep) and any child this host could - // not account for as a relay service both land on BUSY, so nothing is signalled. - ...relayDaemonChildCensusShell(), - `[ "$${RELAY_UNRECOGNIZED_CHILD_COUNT_VAR}" = "0" ] || { printf 'BUSY\\n'; exit 0; }`, + ...(requireEmpty + ? [ + // Why the same census as the probe: `unknown` (no pgrep) and any child this host could + // not account for as a relay service both land on BUSY, so nothing is signalled. + ...relayDaemonChildCensusShell(), + `[ "$${RELAY_UNRECOGNIZED_CHILD_COUNT_VAR}" = "0" ] || { printf 'BUSY\\n'; exit 0; }` + ] + : [ + // Sole-holder was a probe-time fact. Another client can attach before this signal. + // Why -a: lsof ORs selectors without it and reports unrelated unix-socket holders. + "command -v lsof >/dev/null 2>&1 || { printf 'BUSY\\n'; exit 0; }", + 'holder_count=$(lsof -t -a -U -- "$sock" 2>/dev/null | sort -u | wc -l | tr -d \' \')', + '[ "$holder_count" = "1" ] || { printf \'BUSY\\n\'; exit 0; }' + ]), // SIGTERM only: the relay's own handler disposes and unlinks. SIGKILL would leave the // socket inode behind and skip that shutdown path for no gain on an empty daemon. 'kill -TERM "$pid" 2>/dev/null || true', 'i=0', - `while [ $i -lt ${REAP_CONFIRM_ATTEMPTS} ]; do`, + `while [ $i -lt ${confirmAttempts} ]; do`, ' kill -0 "$pid" 2>/dev/null || { printf \'GONE\\n\'; exit 0; }', ' sleep 0.2', ' i=$((i+1))', @@ -62,6 +73,24 @@ export function reapEmptyRelayHuskCommand(pid: number, sockPath: string): string ].join('\n') } +/** + * Signal one empty relay, re-verifying identity and emptiness inside the same command. + * + * The re-verification is not belt-and-braces: a client can attach and spawn a PTY between the + * probe and the signal, and pids are reused. `MISMATCH`/`BUSY` abort without signalling. + */ +export function reapEmptyRelayHuskCommand(pid: number, sockPath: string): string { + return reapRelayDaemonCommand(pid, sockPath) +} + +/** SIGTERM a superseded generation even when it still holds PTYs. Argv is still re-checked. */ +export function reapSupersededRelayCommand(pid: number, sockPath: string): string { + return reapRelayDaemonCommand(pid, sockPath, { + requireEmptyUnrecognizedChildren: false, + confirmAttempts: SUPERSEDED_REAP_CONFIRM_ATTEMPTS + }) +} + export function interpretRelayHuskReapOutput(output: string): RelayHuskReapResult { const state = output.trim().split('\n').pop()?.trim() if (state === 'GONE') { @@ -75,9 +104,10 @@ export function interpretRelayHuskReapOutput(output: string): RelayHuskReapResul return 'reap-unconfirmed' } -export async function reapEmptyRelayHusk( +async function signalRelayDaemon( conn: SshConnection, incumbent: RelayEndpointIncumbent, + command: string, options?: { signal?: AbortSignal } ): Promise { const holder = incumbent.holders[0] @@ -85,11 +115,10 @@ export async function reapEmptyRelayHusk( return 'retained-live-work' } try { - const output = await execCommand( - conn, - reapEmptyRelayHuskCommand(holder.pid, incumbent.sockPath), - { wrapCommand: true, signal: options?.signal } - ) + const output = await execCommand(conn, command, { + wrapCommand: true, + signal: options?.signal + }) return interpretRelayHuskReapOutput(output) } catch (err) { if (isUnconfirmedSshCommandTermination(err)) { @@ -99,6 +128,40 @@ export async function reapEmptyRelayHusk( } } +export async function reapEmptyRelayHusk( + conn: SshConnection, + incumbent: RelayEndpointIncumbent, + options?: { signal?: AbortSignal } +): Promise { + const holder = incumbent.holders[0] + if (!holder) { + return 'retained-live-work' + } + return signalRelayDaemon( + conn, + incumbent, + reapEmptyRelayHuskCommand(holder.pid, incumbent.sockPath), + options + ) +} + +export async function reapSupersededRelay( + conn: SshConnection, + incumbent: RelayEndpointIncumbent, + options?: { signal?: AbortSignal } +): Promise { + const holder = incumbent.holders[0] + if (!holder) { + return 'retained-live-work' + } + return signalRelayDaemon( + conn, + incumbent, + reapSupersededRelayCommand(holder.pid, incumbent.sockPath), + options + ) +} + /** * Called when `--connect` to an existing socket failed and the caller is about to launch a * replacement at the same path. Resolves when the launch may proceed; throws diff --git a/src/main/ssh/ssh-relay-generation-reset.test.ts b/src/main/ssh/ssh-relay-generation-reset.test.ts new file mode 100644 index 000000000000..a1c2fecadea2 --- /dev/null +++ b/src/main/ssh/ssh-relay-generation-reset.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest' +import { + isSshRelayGenerationChange, + retireSshRelayGenerationLeases, + shouldBroadcastSshRelayGenerationRetired +} from './ssh-relay-generation-reset' + +describe('isSshRelayGenerationChange', () => { + it('is false until both sides have a build id', () => { + expect(isSshRelayGenerationChange(undefined, '0.1.0+new')).toBe(false) + expect(isSshRelayGenerationChange('0.1.0+old', undefined)).toBe(false) + expect(isSshRelayGenerationChange(undefined, undefined)).toBe(false) + }) + + it('is false when the relay bundle did not move', () => { + expect(isSshRelayGenerationChange('0.1.0+same', '0.1.0+same')).toBe(false) + }) + + it('is true only when this client last spoke a different bundle', () => { + expect(isSshRelayGenerationChange('0.1.0+old', '0.1.0+new')).toBe(true) + }) +}) + +describe('retireSshRelayGenerationLeases', () => { + it('expires every lease on the target so the new generation cannot reattach them', () => { + const store = { markSshRemotePtyLeases: vi.fn() } + retireSshRelayGenerationLeases(store, 'ssh-1') + expect(store.markSshRemotePtyLeases).toHaveBeenCalledWith('ssh-1', 'expired') + }) +}) + +describe('shouldBroadcastSshRelayGenerationRetired', () => { + it('does not broadcast for runtime-owned targets', () => { + expect(shouldBroadcastSshRelayGenerationRetired('runtime-ssh-vm-1')).toBe(false) + }) + + it('broadcasts for a user SSH target', () => { + expect(shouldBroadcastSshRelayGenerationRetired('ssh-1')).toBe(true) + }) +}) diff --git a/src/main/ssh/ssh-relay-generation-reset.ts b/src/main/ssh/ssh-relay-generation-reset.ts new file mode 100644 index 000000000000..cc8f43cbda3d --- /dev/null +++ b/src/main/ssh/ssh-relay-generation-reset.ts @@ -0,0 +1,21 @@ +import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host' + +/** True when this connect landed on a different relay bundle than the last successful one. */ +export function isSshRelayGenerationChange( + previousBuildId: string | undefined, + nextBuildId: string | undefined +): boolean { + return Boolean(previousBuildId && nextBuildId && previousBuildId !== nextBuildId) +} + +/** Drop leases so the new generation cannot reattach or cold-restore the unreachable PTYs. */ +export function retireSshRelayGenerationLeases( + store: { markSshRemotePtyLeases: (targetId: string, state: 'expired') => void }, + targetId: string +): void { + store.markSshRemotePtyLeases(targetId, 'expired') +} + +export function shouldBroadcastSshRelayGenerationRetired(targetId: string): boolean { + return !isRuntimeOwnedSshTargetId(targetId) +} diff --git a/src/main/ssh/ssh-relay-session-generation-reset.test.ts b/src/main/ssh/ssh-relay-session-generation-reset.test.ts new file mode 100644 index 000000000000..f1e13367f50c --- /dev/null +++ b/src/main/ssh/ssh-relay-session-generation-reset.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SshRelaySession } from './ssh-relay-session' +import { createMockDeps } from './ssh-relay-session-test-fixtures' + +const { muxRequestMock, openConsumerSessionMock } = vi.hoisted(() => ({ + muxRequestMock: vi.fn(), + openConsumerSessionMock: vi.fn() +})) + +vi.mock('./ssh-relay-deploy', () => ({ deployAndLaunchRelay: vi.fn() })) +vi.mock('./ssh-pty-consumer-session', () => ({ + openSshPtyConsumerSession: openConsumerSessionMock +})) +vi.mock('../ipc/ssh-pty-output-intake-registry', () => ({ + acceptSshPtyOutputData: vi.fn().mockResolvedValue(undefined), + acceptSshPtyOutputExit: vi.fn().mockResolvedValue(undefined), + allocateSshPtyProviderGeneration: vi.fn(() => 17), + beginSshPtyOutputGenerationMigration: vi.fn(() => ({ + byPty: new Map(), + completion: Promise.resolve() + })), + closeSshPtyOutputGeneration: vi.fn(), + getSshPtyAcceptedSourceCheckpoints: vi.fn(() => []), + applySshPtySourceCancellationProof: vi.fn(() => true), + applySshPtySourceRecoveryCancellationProof: vi.fn(() => true), + installSshPtySourceAckPublisher: vi.fn(() => () => {}), + installSshPtySourceCancellationPublisher: vi.fn(() => () => {}) +})) +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand: vi.fn().mockResolvedValue('') })) +vi.mock('./ssh-channel-multiplexer', () => ({ + SshChannelMultiplexer: class MockSshChannelMultiplexer { + notify = vi.fn() + notifyWithSettlement = vi.fn() + request = muxRequestMock + onNotification = vi.fn().mockReturnValue(() => {}) + onNotificationByMethod = 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', () => ({ + SshPtyProvider: class MockSshPtyProvider { + onData = vi.fn().mockReturnValue(() => {}) + onReplay = vi.fn().mockReturnValue(() => {}) + onExit = vi.fn().mockReturnValue(() => {}) + attach = vi.fn().mockResolvedValue(undefined) + attachForReconnect = vi.fn().mockResolvedValue({}) + 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(), + restorePtyIncarnation: vi.fn(), + isCurrentPtyExit: vi.fn(() => true) +})) +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 { deployAndLaunchRelay } = await import('./ssh-relay-deploy') + +describe('SshRelaySession generation retirement', () => { + beforeEach(() => { + vi.clearAllMocks() + muxRequestMock.mockReset() + muxRequestMock.mockResolvedValue([]) + }) + + it('retires a superseded generation even when establish fails after consumer admission', async () => { + const targetId = 'ssh-generation-retire-before-remember' + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + vi.mocked(mockStore.getSshPtyConsumerRecovery).mockReturnValue({ + targetId, + clientInstanceId: 'client-1', + serverBuildId: '0.1.0+old', + clientGeneration: 1, + ownerGeneration: 1, + ownerLease: 'lease-1' + }) + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: { write: vi.fn(), onData: vi.fn(), onClose: vi.fn() }, + platform: 'linux-x64', + serverBuildId: '0.1.0+new' + }) + openConsumerSessionMock.mockResolvedValue({ + state: { + mode: 'negotiated', + clientInstanceId: 'client-1', + clientGeneration: 1, + ownerGeneration: 2, + ownerLease: 'lease-2' + }, + resumed: false + }) + muxRequestMock.mockRejectedValueOnce(new Error('home failed')) + + const session = new SshRelaySession(targetId, getMainWindow, mockStore, mockPortForward) + await expect(session.establish(mockConn)).rejects.toThrow('home failed') + + expect(mockStore.markSshRemotePtyLeases).toHaveBeenCalledWith(targetId, 'expired') + expect(mockWindow.webContents.send).toHaveBeenCalledWith('ssh:relay-generation-retired', { + targetId + }) + }) +}) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 6108f990d269..5872ae81bc19 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -140,6 +140,11 @@ import { } from './ssh-pty-consumer-recovery' import { classifySshPtyFrameRejection, SshPtyFrameRejectionLog } from './ssh-pty-frame-rejection' import { SshPtyTargetedReattachQueue } from './ssh-pty-targeted-reattach-queue' +import { + isSshRelayGenerationChange, + retireSshRelayGenerationLeases, + shouldBroadcastSshRelayGenerationRetired +} from './ssh-relay-generation-reset' export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed' @@ -590,6 +595,10 @@ export class SshRelaySession { throw new Error('Session disposed during establish') } this.ptyConsumerSessionState = ptyConsumerSessionState + const previousRelayBuildId = getSshPtyConsumerRecovery(this.targetId)?.serverBuildId + if (isSshRelayGenerationChange(previousRelayBuildId, serverBuildId)) { + this.retireSupersededRelayGeneration() + } await this.rememberPtyConsumerRecovery(serverBuildId) if (!verifyRelayAttempt(mux, isAttemptCurrent, 'consumer recovery persistence')) { if (!mux.isDisposed()) { @@ -626,7 +635,12 @@ export class SshRelaySession { } // Why: explicit disconnect keeps PTY ownership, so a later manual connect must reattach those remote PTYs. - await this.reattachKnownPtys(mux, shouldContinue) + await this.restoreKnownPtysOrRetireGeneration( + mux, + shouldContinue, + previousRelayBuildId, + serverBuildId + ) if (!verifyRelayAttempt(mux, isAttemptCurrent, 'PTY reattach')) { throw new Error('Session disposed during establish') @@ -750,6 +764,10 @@ export class SshRelaySession { return } this.ptyConsumerSessionState = ptyConsumerSessionState + const previousRelayBuildId = getSshPtyConsumerRecovery(this.targetId)?.serverBuildId + if (isSshRelayGenerationChange(previousRelayBuildId, serverBuildId)) { + this.retireSupersededRelayGeneration() + } await this.rememberPtyConsumerRecovery(serverBuildId) if (!verifyRelayAttempt(mux, isAttemptCurrent, 'consumer recovery persistence')) { if (!mux.isDisposed()) { @@ -790,7 +808,12 @@ export class SshRelaySession { return } - await this.reattachKnownPtys(mux, shouldContinue) + await this.restoreKnownPtysOrRetireGeneration( + mux, + shouldContinue, + previousRelayBuildId, + serverBuildId + ) if (!verifyRelayAttempt(mux, isAttemptCurrent, 'PTY reattach')) { return @@ -2306,6 +2329,33 @@ export class SshRelaySession { } } + private restoreKnownPtysOrRetireGeneration( + mux: SshChannelMultiplexer, + shouldContinue: () => boolean, + previousRelayBuildId: string | undefined, + serverBuildId: string | undefined + ): Promise { + if (isSshRelayGenerationChange(previousRelayBuildId, serverBuildId)) { + return Promise.resolve() + } + return this.reattachKnownPtys(mux, shouldContinue) + } + + private retireSupersededRelayGeneration(): void { + retireSshRelayGenerationLeases(this.store, this.targetId) + clearPtyOwnershipForConnection(this.targetId) + console.warn( + `[ssh-relay-session] Relay generation changed for ${this.targetId}; retiring persisted remote terminals` + ) + if (!shouldBroadcastSshRelayGenerationRetired(this.targetId)) { + return + } + const win = this.getMainWindow() + if (win && !win.isDestroyed()) { + win.webContents.send('ssh:relay-generation-retired', { targetId: this.targetId }) + } + } + private async reattachKnownPtys( mux: SshChannelMultiplexer, shouldContinue: () => boolean diff --git a/src/main/ssh/ssh-relay-superseded-endpoints.test.ts b/src/main/ssh/ssh-relay-superseded-endpoints.test.ts index 03f7e478905c..4cb2c2ba0492 100644 --- a/src/main/ssh/ssh-relay-superseded-endpoints.test.ts +++ b/src/main/ssh/ssh-relay-superseded-endpoints.test.ts @@ -63,7 +63,7 @@ describe('supersededRelayEndpointListCommand', () => { }) describe('classifySupersededRelay', () => { - it('retains a live relay that still owns PTYs', () => { + it('nominates a sole-holder relay for reaping even when it still owns PTYs', () => { expect( classifySupersededRelay( incumbent([ @@ -73,10 +73,24 @@ describe('classifySupersededRelay', () => { 'HOLDER=3669803 yes 13 11' ]) ) + ).toBe('reap-candidate') + }) + + it('retains a live relay that still has another socket holder', () => { + expect( + classifySupersededRelay( + incumbent([ + 'PRESENT=yes', + 'LISTEN=accepted', + 'HOLDERS_SOURCE=lsof', + 'HOLDER=3669803 yes 13 11', + 'HOLDER=3669804 no 0 0' + ]) + ) ).toBe('retained-live-work') }) - it('nominates only a proven empty relay for reaping', () => { + it('nominates a proven empty relay for reaping', () => { expect( classifySupersededRelay( incumbent(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=80583 yes 2 0']) @@ -117,17 +131,35 @@ describe('sweepSupersededRelayEndpoints', () => { expect(issuedCommands()).toHaveLength(2) }) - it('leaves an upgrade-orphaned relay that still owns terminals running, untouched', async () => { + it('reaps an upgrade-orphaned relay that still owns terminals, once the host confirms it is gone', async () => { execCommand .mockResolvedValueOnce(`${OLD_SOCK}\n`) .mockResolvedValueOnce( probe(['PRESENT=yes', 'LISTEN=accepted', 'HOLDERS_SOURCE=lsof', 'HOLDER=3669803 yes 13 11']) ) + .mockResolvedValueOnce('GONE\n') const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) expect(findings).toHaveLength(1) + expect(findings[0]).toMatchObject({ sockPath: OLD_SOCK, outcome: 'reaped' }) + expect(issuedCommands()[2]).toContain('kill -TERM "$pid"') + expect(issuedCommands()[2]).not.toContain('unrecognized_kids') + }) + + it('leaves a superseded relay running when another client still holds the socket', async () => { + execCommand + .mockResolvedValueOnce(`${OLD_SOCK}\n`) + .mockResolvedValueOnce( + probe([ + 'PRESENT=yes', + 'LISTEN=accepted', + 'HOLDERS_SOURCE=lsof', + 'HOLDER=3669803 yes 13 11', + 'HOLDER=3669804 no 0 0' + ]) + ) + const findings = await sweepSupersededRelayEndpoints(CONN, HOST, SWEEP) expect(findings[0]).toMatchObject({ sockPath: OLD_SOCK, outcome: 'retained-live-work' }) expect(issuedCommands().some((command) => /\bkill\s/.test(command))).toBe(false) - expect(issuedCommands().some((command) => /\brm -f\b/.test(command))).toBe(false) }) it('reaps the empty husk an upgrade leaves behind, once the host confirms it is gone', async () => { diff --git a/src/main/ssh/ssh-relay-superseded-endpoints.ts b/src/main/ssh/ssh-relay-superseded-endpoints.ts index 4b1ad5637ef8..4f02339f0cc2 100644 --- a/src/main/ssh/ssh-relay-superseded-endpoints.ts +++ b/src/main/ssh/ssh-relay-superseded-endpoints.ts @@ -4,17 +4,15 @@ * Every relay build installs to `~/.orca-remote/relay-/` and binds its socket * inside it, so the socket path moves on every app update even though the filename component * is stable. After an update the new client binds a path the previous relay's PTYs were never - * associated with, and the previous relay is never contacted again (#13614, #13852). Nothing - * signals it and nothing reclaims it: with `--grace-time 0` it keeps its shells and agents - * alive forever. + * associated with, and the previous relay is never contacted again (#13614, #13852). Ordinary + * disconnect still uses `--grace-time 0`; only this sweep SIGTERMs a superseded generation + * that is the sole socket holder. * - * This sweep makes that population *visible and deliberate* rather than silent. It does not - * make it recoverable — the daemon handshake compares the build's content hash exactly - * (`relay-handshake.ts`), so a new client cannot speak to an old daemon at all. See the report - * on this change for what a real cross-version handoff would require. - * - * The one thing it will terminate is a relay that provably holds nothing. Everything else is - * retained, including everything it merely failed to reach. + * This sweep cannot make that generation recoverable — the daemon handshake compares the + * build's content hash exactly (`relay-handshake.ts`), so a new client cannot speak to an old + * daemon at all. After an app update it therefore SIGTERMs a superseded relay that still holds + * PTYs, as long as it is the sole socket holder (no other Orca attached). Unverifiable + * endpoints and relays with another client still connected are retained. */ import type { SshConnection } from './ssh-connection' import { shellEscape } from './ssh-connection-utils' @@ -23,11 +21,11 @@ import { SHORT_RELAY_SOCKET_DIR_PREFIX } from './relay-socket-path-limit' import { execCommand } from './ssh-relay-deploy-helpers' import { describeRelayEndpointIncumbent, - isReapableRelayHusk, + isReapableSupersededRelay, probeRelayEndpointIncumbent, type RelayEndpointIncumbent } from './ssh-relay-endpoint-incumbent' -import { reapEmptyRelayHusk } from './ssh-relay-endpoint-takeover' +import { reapSupersededRelay } from './ssh-relay-endpoint-takeover' import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' /** @@ -109,7 +107,7 @@ export function classifySupersededRelay( if (incumbent.verdict !== 'live') { return 'unverifiable' } - return isReapableRelayHusk(incumbent) ? 'reap-candidate' : 'retained-live-work' + return isReapableSupersededRelay(incumbent) ? 'reap-candidate' : 'retained-live-work' } export async function sweepSupersededRelayEndpoints( @@ -175,7 +173,7 @@ async function applySupersededRelayDecision( if (decision !== 'reap-candidate') { return decision } - return reapEmptyRelayHusk(conn, incumbent, { signal: options.signal }) + return reapSupersededRelay(conn, incumbent, { signal: options.signal }) } function logSupersededRelayFindings(findings: SupersededRelayFinding[]): void { @@ -183,7 +181,7 @@ function logSupersededRelayFindings(findings: SupersededRelayFinding[]): void { const detail = describeRelayEndpointIncumbent(finding.incumbent) if (finding.outcome === 'retained-live-work') { console.warn( - `[ssh-relay] Superseded relay retained (holds live work; not signalled): ${detail}` + `[ssh-relay] Superseded relay retained (another client still holds the socket; not signalled): ${detail}` ) continue } diff --git a/src/preload/api/ssh-api.ts b/src/preload/api/ssh-api.ts index af198ad1080e..3621d8cc3edc 100644 --- a/src/preload/api/ssh-api.ts +++ b/src/preload/api/ssh-api.ts @@ -36,6 +36,7 @@ export type SshApi = { onStateChanged: ( callback: (data: { targetId: string; state: SshConnectionState }) => void ) => () => void + onRelayGenerationRetired: (callback: (data: { targetId: string }) => void) => () => void addPortForward: (args: { targetId: string localPort: number diff --git a/src/preload/api/ssh-bridge.ts b/src/preload/api/ssh-bridge.ts index 03c10f9bc07b..a31999aec483 100644 --- a/src/preload/api/ssh-bridge.ts +++ b/src/preload/api/ssh-bridge.ts @@ -95,6 +95,16 @@ export const sshApi = { return () => ipcRenderer.removeListener('ssh:state-changed', listener) }, + onRelayGenerationRetired: (callback: (data: { targetId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { targetId: string }): void => { + if (typeof data?.targetId === 'string' && data.targetId.length > 0) { + callback({ targetId: data.targetId }) + } + } + ipcRenderer.on('ssh:relay-generation-retired', listener) + return () => ipcRenderer.removeListener('ssh:relay-generation-retired', listener) + }, + addPortForward: (args: { targetId: string localPort: number diff --git a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts index c5964d77feca..d1155bdefecb 100644 --- a/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/app-lifetime-ipc-bridge.ts @@ -11,6 +11,7 @@ import { registerBrowserStateIpcBridge } from './browser-state-ipc-bridge' import { registerContentCreationIpcBridge } from './content-creation-ipc-bridge' import { createDirectSshBridgeRuntime } from './direct-ssh-bridge-runtime' import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge' +import { registerSshRelayGenerationRetiredIpcBridge } from './ssh-relay-generation-retired-ipc-bridge' import { registerMobileAndTerminalCloseIpcBridge } from './mobile-terminal-close-ipc-bridge' import { registerMobileDriverIpcBridge } from './mobile-driver-ipc-bridge' import { registerOrcaProfileAuthIpcBridge } from './orca-profile-auth-ipc-bridge' @@ -124,6 +125,7 @@ export function installAppLifetimeIpcEvents( registerTabLifecycleIpcBridge(unsubs) registerRateLimitIpcBridge(unsubs) registerDirectSshStateIpcBridge(unsubs, directSshRuntime) + registerSshRelayGenerationRetiredIpcBridge(unsubs) registerRemoteWorkspaceIpcBridge(unsubs, directSshRuntime) registerZoomIpcBridge(unsubs) const agentStatusBridge = registerAgentStatusIpcBridge(unsubs) diff --git a/src/renderer/src/hooks/ipc-events/ssh-relay-generation-retired-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/ssh-relay-generation-retired-ipc-bridge.ts new file mode 100644 index 000000000000..fc338b47e432 --- /dev/null +++ b/src/renderer/src/hooks/ipc-events/ssh-relay-generation-retired-ipc-bridge.ts @@ -0,0 +1,11 @@ +import { useAppStore } from '../../store' + +/** Closes SSH terminal tabs when main retires an unreachable relay generation after an app update. */ +export function registerSshRelayGenerationRetiredIpcBridge(unsubs: (() => void)[]): void { + const unsubscribe = window.api.ssh.onRelayGenerationRetired?.(({ targetId }) => { + useAppStore.getState().retireDirectSshTerminalsForRelayGeneration(targetId) + }) + if (unsubscribe) { + unsubs.push(unsubscribe) + } +} diff --git a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts index 2e13d9382e4e..3b1730a14709 100644 --- a/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts +++ b/src/renderer/src/hooks/useIpcEvents-lifecycle.test.ts @@ -36,6 +36,7 @@ const EXPECTED_DIRECT_CALLBACK_METHODS = [ 'ssh.onCredentialResolved', 'ssh.onDetectedPortsChanged', 'ssh.onPortForwardsChanged', + 'ssh.onRelayGenerationRetired', 'ssh.onStateChanged', 'ui.onActivateWorktree', 'ui.onCloseActiveTab', @@ -191,6 +192,7 @@ const EXPECTED_CALLBACK_REGISTRATION_SEQUENCE = [ 'ssh.onDetectedPortsChanged', 'ssh.onStateChanged', 'ui.onSystemResumed', + 'ssh.onRelayGenerationRetired', 'remoteWorkspace.onChanged', 'ui.onTerminalZoom', 'agentStatus.onSet', diff --git a/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts b/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts index 26c3bfd27603..ada638232833 100644 --- a/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts +++ b/src/renderer/src/store/terminals/direct-ssh-terminal-bindings.ts @@ -16,15 +16,29 @@ import { resolveDirectSshTerminalKeys } from './terminal-pty-identities' +export function collectDirectSshTerminalTabIds( + state: Parameters[0], + targetId: string +): string[] { + const tabIds: string[] = [] + for (const workspaceKey of resolveDirectSshTerminalKeys(state, targetId)) { + for (const tab of state.tabsByWorktree[workspaceKey] ?? []) { + tabIds.push(tab.id) + } + } + return tabIds +} + export function createDirectSshTerminalBindingActions( set: TerminalStoreSet, - _get: TerminalStoreGet + get: TerminalStoreGet ): Pick< TerminalSlice, | 'clearDirectSshTargetPtyBindings' | 'invalidateStaleDirectSshTargetPtyBindings' | 'retryDirectSshTargetPanes' | 'settleDirectSshPaneRetry' + | 'retireDirectSshTerminalsForRelayGeneration' > { return { clearDirectSshTargetPtyBindings: (targetId) => { @@ -100,6 +114,17 @@ export function createDirectSshTerminalBindingActions( ) return retry.patch ? { ...settledState, ...retry.patch } : settledState }) + }, + retireDirectSshTerminalsForRelayGeneration: (targetId) => { + const tabIds = collectDirectSshTerminalTabIds(get(), targetId) + for (const tabId of tabIds) { + get().closeTab(tabId, { + reason: 'cleanup', + localPtyTeardownOwnedExternally: true, + remoteCloseOwnedByHost: true + }) + } + return tabIds.length } } } diff --git a/src/renderer/src/store/terminals/ssh-relay-generation-tab-retirement.test.ts b/src/renderer/src/store/terminals/ssh-relay-generation-tab-retirement.test.ts new file mode 100644 index 000000000000..19c707bafbbb --- /dev/null +++ b/src/renderer/src/store/terminals/ssh-relay-generation-tab-retirement.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { + createTestStore, + makeTab, + makeWorktree, + seedStore, + TEST_REPO +} from '../slices/store-test-helpers' +import { createStoreCascadesMockApi } from '../slices/store-cascades-test-harness' + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + restorePtyDataHandlersAfterFailedShutdown: vi.fn(), + unregisterPtyDataHandlers: vi.fn<() => unknown[]>(() => []) +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => ({ + ...(await importOriginal()), + detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) +})) + +const mockApi = createStoreCascadesMockApi() + +const REMOTE_WORKTREE = 'remote-repo::/srv/app' +const LOCAL_WORKTREE = 'local-repo::/tmp/app' + +function storeWithBothWorktrees(): ReturnType { + const store = createTestStore() + seedStore(store, { + repos: [ + { + ...TEST_REPO, + id: 'remote-repo', + path: '/srv/app', + displayName: 'app', + connectionId: 'ssh-1' + }, + { ...TEST_REPO, id: 'local-repo', path: '/tmp/app', displayName: 'app' } + ], + worktreesByRepo: { + 'remote-repo': [ + makeWorktree({ id: REMOTE_WORKTREE, repoId: 'remote-repo', path: '/srv/app' }) + ], + 'local-repo': [makeWorktree({ id: LOCAL_WORKTREE, repoId: 'local-repo', path: '/tmp/app' })] + }, + tabsByWorktree: { + [REMOTE_WORKTREE]: [makeTab({ id: 'remote-tab', worktreeId: REMOTE_WORKTREE })], + [LOCAL_WORKTREE]: [makeTab({ id: 'local-tab', worktreeId: LOCAL_WORKTREE })] + } + }) + return store +} + +describe('retireDirectSshTerminalsForRelayGeneration', () => { + beforeEach(() => { + vi.clearAllMocks() + mockApi.worktrees.updateMeta.mockResolvedValue({}) + }) + + it('closes SSH terminal tabs for the updated host and leaves local tabs alone', () => { + const store = storeWithBothWorktrees() + + expect(store.getState().retireDirectSshTerminalsForRelayGeneration('ssh-1')).toBe(1) + + expect(store.getState().tabsByWorktree[REMOTE_WORKTREE]).toEqual([]) + expect(store.getState().tabsByWorktree[LOCAL_WORKTREE]?.map((tab) => tab.id)).toEqual([ + 'local-tab' + ]) + expect(store.getState().closedTerminalTabTombstonesByTabId['remote-tab']).toBeUndefined() + }) + + it('is a no-op for a host with no terminal tabs', () => { + const store = storeWithBothWorktrees() + expect(store.getState().retireDirectSshTerminalsForRelayGeneration('ssh-other')).toBe(0) + expect(store.getState().tabsByWorktree[REMOTE_WORKTREE]?.map((tab) => tab.id)).toEqual([ + 'remote-tab' + ]) + }) +}) diff --git a/src/renderer/src/store/terminals/terminal-actions.ts b/src/renderer/src/store/terminals/terminal-actions.ts index 4038ed22091c..19912d867bd9 100644 --- a/src/renderer/src/store/terminals/terminal-actions.ts +++ b/src/renderer/src/store/terminals/terminal-actions.ts @@ -148,6 +148,8 @@ export type TerminalActions = { invalidateStaleDirectSshTargetPtyBindings: (authority: DirectSshAuthority) => number retryDirectSshTargetPanes: (authority: DirectSshAuthority, now?: number) => number settleDirectSshPaneRetry: (result: DirectSshPaneRetryResult, now?: number) => void + /** Closes SSH terminal tabs after an app update that started a new relay generation. */ + retireDirectSshTerminalsForRelayGeneration: (targetId: string) => number /** Awaited verified shutdown; unlike closeTab, physical teardown gates the state commit. */ shutdownWorktreeTerminals: ( worktreeId: string, diff --git a/src/renderer/src/web/preload-api/web-terminal-api.ts b/src/renderer/src/web/preload-api/web-terminal-api.ts index a3e4964cb8f4..5609c61927fc 100644 --- a/src/renderer/src/web/preload-api/web-terminal-api.ts +++ b/src/renderer/src/web/preload-api/web-terminal-api.ts @@ -163,6 +163,7 @@ export function createSshApi(): NonNullable['ssh']> { error: translate('auto.web.web.preload.api.31bfe8ae1a', 'Unavailable in the web client.') }), onStateChanged: () => noopUnsubscribe, + onRelayGenerationRetired: () => noopUnsubscribe, addPortForward: () => Promise.reject(new Error('SSH port forwarding is unavailable in the web client.')), updatePortForward: () =>