diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index 738c5b55bcc..66d9b7fe541 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -17,6 +17,7 @@ "../src/renderer/src/components/native-chat/structured-agent-session-queued-cards.ts", "../src/renderer/src/components/native-chat/structured-agent-session-pending-sends.ts", "../src/renderer/src/components/native-chat/native-chat-composer-primary-action.ts", + "../src/renderer/src/components/terminal-pane/legacy-relay-bridge-panes.ts", "../src/preload/**/*", "../src/shared/**/*", "../src/packages/process-host/src/__fixtures__/**/*", diff --git a/src/main/ipc/ssh-session-teardown.ts b/src/main/ipc/ssh-session-teardown.ts index c922ec7865e..8931b38c164 100644 --- a/src/main/ipc/ssh-session-teardown.ts +++ b/src/main/ipc/ssh-session-teardown.ts @@ -10,7 +10,10 @@ import { import { connectionManager, persistedStore, portForwardManager } from './ssh-ipc-context' import { runTargetLifecycle } from './ssh-target-lifecycle-queue' import { errorMessage } from '../../shared/error-message' -import { stopLegacyRelayBridge } from '../ssh/legacy-relay-bridge/legacy-relay-bridge-registry' +import { + forgetLegacyRelayTarget, + stopLegacyRelayBridge +} from '../ssh/legacy-relay-bridge/legacy-relay-bridge-registry' export async function disconnectRegisteredSshTarget(targetId: string): Promise { invalidateConnectAttempt(targetId) @@ -33,6 +36,7 @@ export async function removeRegisteredSshTarget(targetId: string): Promise console.warn(`[ssh] Failed to disconnect removed target ${targetId}: ${errorMessage(err)}`) } persistedStore?.removeSshRemotePtyLeases(targetId) + forgetLegacyRelayTarget(targetId) store.removeTarget(targetId) // Why: removal is the storage boundary — the target's browser cookie jars // must not outlive the record that scoped them. diff --git a/src/main/ssh/legacy-relay-bridge/README.md b/src/main/ssh/legacy-relay-bridge/README.md index b34400d06a1..452fcccecf6 100644 --- a/src/main/ssh/legacy-relay-bridge/README.md +++ b/src/main/ssh/legacy-relay-bridge/README.md @@ -16,7 +16,8 @@ nobody is prompted. deploys, starts, stops or deletes a relay. - Leased panes the relay lists reattach in their old tabs through `renderer/.../legacy-relay-bridge-transport.ts`. -- A dropped connection is `unverifiable` and reattaches later. A pane retires (lease `terminated`) +- A dropped connection, or a disconnect that hangs the bridge up, is `unverifiable` and reattaches + later; removing the target is what empties its panes. A pane retires (lease `terminated`) only when its relay reports the exit, or every relay for the target answered without it. **Touches** `ipc/ssh.ts`, `ipc/ssh-connect-flow.ts`, `ipc/ssh-host-server-on-connect-wiring.ts`, diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-registry.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-registry.ts index 9ee2d2fbae5..2dbf8e0b4cd 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-registry.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-registry.ts @@ -16,7 +16,7 @@ import { isWindowsRemoteHost } from '../ssh-remote-platform' import { LegacyRelayClient } from './legacy-relay-client' import { listLegacyRelayEndpoints, type LegacyRelayEndpoint } from './legacy-relay-endpoints' import { LegacyRelayTargetBridge } from './legacy-relay-target-bridge' -import { isBridgeableLease } from './legacy-relay-bridge-leases' +import { bridgeableLeasesByRelayId, isBridgeableLease } from './legacy-relay-bridge-leases' /** How long a connect waits for the first look at its relays before publishing the host. */ const START_BUDGET_MS = 20_000 @@ -37,12 +37,18 @@ export type LegacyRelayBridgeHost = { let host: LegacyRelayBridgeHost | null = null const bridges = new Map() +/** + * Panes a hung-up bridge served, held `unverifiable` until the target's next bridge lists its own + * or the leases go: a hang-up is loss of contact, not exit (docs/reference/ssh-execution-boundary.md). + */ +const heldPanes = new Map() export function installLegacyRelayBridge(next: LegacyRelayBridgeHost | null): void { for (const bridge of bridges.values()) { bridge.dispose() } bridges.clear() + heldPanes.clear() host = next } @@ -97,7 +103,13 @@ function bridgeFor(targetId: string): LegacyRelayTargetBridge | null { ) }), endpointExited: (endpoint) => posixEndpointExited(connection(), endpoint), - onPanesChanged: () => installed.onPanesChanged(legacyRelayBridgedPanes()), + onPanesChanged: () => { + // A running bridge's own listing answers for its target; a disposed one's does not. + if (bridges.get(targetId) === bridge) { + heldPanes.delete(targetId) + } + installed.onPanesChanged(legacyRelayBridgedPanes()) + }, onData: installed.onData, onExit: installed.onExit, onTerminalExited: () => installed.report(targetId, 'terminal_exited', 0) @@ -129,6 +141,8 @@ async function posixEndpointExited( */ export async function startLegacyRelayBridge(targetId: string): Promise { if (!hasBridgeableLeases(targetId)) { + // Leases retired while hung up publish nothing themselves; this connect is the next answer. + releaseHeldPanes(targetId) return } const bridge = bridgeFor(targetId) @@ -154,13 +168,38 @@ export async function startLegacyRelayBridge(targetId: string): Promise { export function stopLegacyRelayBridge(targetId: string): void { const bridge = bridges.get(targetId) if (bridge) { + const held = bridge.snapshot().map((pane) => ({ ...pane, state: 'unverifiable' as const })) + if (held.length > 0) { + heldPanes.set(targetId, held) + } bridges.delete(targetId) + // Disposing publishes the list, now with the held panes instead of the bridge's. bridge.dispose() } } +/** The target and its leases are gone, so nothing it served is held any longer. */ +export function forgetLegacyRelayTarget(targetId: string): void { + stopLegacyRelayBridge(targetId) + releaseHeldPanes(targetId) +} + +function releaseHeldPanes(targetId: string): void { + if (heldPanes.delete(targetId)) { + host?.onPanesChanged(legacyRelayBridgedPanes()) + } +} + export function legacyRelayBridgedPanes(): LegacyRelayBridgedPane[] { - return [...bridges.values()].flatMap((bridge) => bridge.snapshot()) + return [ + ...[...bridges.values()].flatMap((bridge) => bridge.snapshot()), + ...[...heldPanes].flatMap(([targetId, panes]) => stillLeased(targetId, panes)) + ] +} + +function stillLeased(targetId: string, panes: LegacyRelayBridgedPane[]): LegacyRelayBridgedPane[] { + const leased = bridgeableLeasesByRelayId(host?.leases(targetId) ?? []) + return panes.filter((pane) => leased.has(parseAppSshPtyId(pane.ptyId)?.relayPtyId ?? '')) } export async function attachLegacyRelayPane(ptyId: string): Promise { diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-teardown.test.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-teardown.test.ts new file mode 100644 index 00000000000..2979857a879 --- /dev/null +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-teardown.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { LegacyRelayBridgedPane } from '../../../shared/legacy-relay-bridge-contract' +import { toAppSshPtyId } from '../../../shared/ssh-pty-id' +import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import type { LegacyRelayTargetBridgeDeps } from './legacy-relay-target-bridge' + +const TARGET = 'ssh-box' +const PTY = toAppSshPtyId(TARGET, 'pty-1') + +const renderer = vi.hoisted(() => ({ + listeners: new Set<(panes: LegacyRelayBridgedPane[]) => void>() +})) + +// The bridge's relay listing is out of scope: this one lists the leased pane live on refresh. +vi.mock('./legacy-relay-target-bridge', () => ({ + LegacyRelayTargetBridge: class { + private listed: LegacyRelayBridgedPane[] = [] + constructor(private readonly deps: LegacyRelayTargetBridgeDeps) {} + snapshot(): LegacyRelayBridgedPane[] { + return this.listed + } + livePaneCount(): number { + return this.listed.length + } + async refresh(): Promise { + this.listed = this.deps.leases().map((row) => ({ + ptyId: toAppSshPtyId(this.deps.targetId, row.ptyId), + targetId: this.deps.targetId, + tabId: row.tabId ?? '', + ...(row.leafId ? { leafId: row.leafId } : {}), + state: 'live' as const + })) + this.deps.onPanesChanged() + } + dispose(): void { + this.listed = [] + this.deps.onPanesChanged() + } + } +})) + +vi.stubGlobal('window', { + api: { + legacyRelayBridge: { + listPanes: async () => [], + onPanes: (listener: (panes: LegacyRelayBridgedPane[]) => void) => { + renderer.listeners.add(listener) + return () => renderer.listeners.delete(listener) + } + } + } +}) + +const registry = await import('./legacy-relay-bridge-registry') +const { legacyRelayBridgePtyIdForPane } = + await import('../../../renderer/src/components/terminal-pane/legacy-relay-bridge-panes') + +let leases: SshRemotePtyLease[] + +function lease(): SshRemotePtyLease { + return { + targetId: TARGET, + ptyId: 'pty-1', + tabId: 'tab-1', + leafId: 'leaf-1', + state: 'detached', + createdAt: 1, + updatedAt: 1 + } +} + +beforeEach(async () => { + leases = [lease()] + registry.installLegacyRelayBridge({ + leases: () => leases, + retireLease: vi.fn(), + connection: () => null, + // Main publishes to the window as legacy-relay-bridge-ipc.ts does. + onPanesChanged: (panes) => renderer.listeners.forEach((listener) => listener(panes)), + onData: vi.fn(), + onExit: vi.fn(), + report: vi.fn() + }) + await registry.startLegacyRelayBridge(TARGET) +}) + +describe('a hung-up legacy relay bridge (loss of contact is not exit)', () => { + it('keeps the panes it served as unverifiable while their leases are held', () => { + registry.stopLegacyRelayBridge(TARGET) + + expect(registry.legacyRelayBridgedPanes()).toEqual([ + { ptyId: PTY, targetId: TARGET, tabId: 'tab-1', leafId: 'leaf-1', state: 'unverifiable' } + ]) + }) + + it('empties the list once the target and its leases are removed', () => { + registry.stopLegacyRelayBridge(TARGET) + expect(registry.legacyRelayBridgedPanes()).toHaveLength(1) + + leases = [] + registry.forgetLegacyRelayTarget(TARGET) + + expect(registry.legacyRelayBridgedPanes()).toEqual([]) + }) + + it("hands the target back to the next bridge's own listing", async () => { + registry.stopLegacyRelayBridge(TARGET) + await registry.startLegacyRelayBridge(TARGET) + + expect(registry.legacyRelayBridgedPanes()).toEqual([ + { ptyId: PTY, targetId: TARGET, tabId: 'tab-1', leafId: 'leaf-1', state: 'live' } + ]) + }) + + it('publishes an empty list when the next connect finds the leases retired', async () => { + registry.stopLegacyRelayBridge(TARGET) + const published: LegacyRelayBridgedPane[][] = [] + const record = (panes: LegacyRelayBridgedPane[]): void => void published.push(panes) + renderer.listeners.add(record) + + leases = [] + await registry.startLegacyRelayBridge(TARGET) + renderer.listeners.delete(record) + + expect(published.at(-1)).toEqual([]) + }) + + it('still sends a pane that remounts during the reconnect gap to the bridge', () => { + // Disconnect, then the pane's transport is rebuilt before the next connect starts a bridge. + registry.stopLegacyRelayBridge(TARGET) + + expect( + legacyRelayBridgePtyIdForPane({ + tabId: 'web-terminal-tab-1', + leafId: 'leaf-1', + boundPtyId: null + }) + ).toBe(PTY) + }) +})