diff --git a/src/main/ipc/pty/runtime/kill-legacy-relay-bridged.test.ts b/src/main/ipc/pty/runtime/kill-legacy-relay-bridged.test.ts new file mode 100644 index 00000000000..aae166cacc4 --- /dev/null +++ b/src/main/ipc/pty/runtime/kill-legacy-relay-bridged.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { SshRemotePtyLease } from '../../../../shared/ssh-types' +import { installLegacyRelayBridge } from '../../../ssh/legacy-relay-bridge/legacy-relay-bridge-registry' +import type { PtyRuntimeControllerDeps } from './controller-deps' +import { killPtyFromRuntimeController } from './kill' + +const TARGET = 'ssh-box' +const lease: SshRemotePtyLease = { + targetId: TARGET, + ptyId: 'pty2:epoch:1', + tabId: 'tab-1', + state: 'detached', + createdAt: 1, + updatedAt: 1 +} + +describe('a generic kill of a terminal an older relay still runs', () => { + it('leaves its lease to the bridge instead of tombstoning a shell it never stopped', () => { + const retireLease = vi.fn() + installLegacyRelayBridge({ + leases: () => [lease], + retireLease, + // Disconnected: the bridge can only record the stop for the next connect. + connection: () => null, + onPanesChanged: () => {}, + onData: () => {}, + onReplay: () => {}, + onExit: () => {}, + report: () => {} + }) + const finishPtyShutdown = vi.fn() + const deps = { + runtime: null, + store: null, + getLocalPtyProviderStartupPromise: () => null, + shutdownProviderAndDetectExit: vi.fn(), + finishPtyShutdown, + retiredRejectedPtyIds: new Set() + } + const accepted = killPtyFromRuntimeController( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the bridged branch returns before reading any other dep. + deps as unknown as PtyRuntimeControllerDeps, + toAppSshPtyId(TARGET, lease.ptyId) + ) + expect(accepted).toBe(true) + expect(finishPtyShutdown).not.toHaveBeenCalled() + expect(deps.shutdownProviderAndDetectExit).not.toHaveBeenCalled() + expect(retireLease).not.toHaveBeenCalled() + installLegacyRelayBridge(null) + }) +}) diff --git a/src/main/ipc/pty/runtime/kill.ts b/src/main/ipc/pty/runtime/kill.ts index 8d8015d9811..2f66f4a4aea 100644 --- a/src/main/ipc/pty/runtime/kill.ts +++ b/src/main/ipc/pty/runtime/kill.ts @@ -9,6 +9,10 @@ import { import { isPtyAlreadyGoneError, delay, verifyPtyStopped } from '../provider/liveness' import type { PtyRuntimeControllerDeps } from './controller-deps' import { errorMessage } from '../../../../shared/error-message' +import { + closeLegacyRelayPane, + isLegacyRelayBridgedPty +} from '../../../ssh/legacy-relay-bridge/legacy-relay-bridge-registry' // Bounds the wait for an observed exit to reach the runtime record when the caller set no deadline. const OBSERVED_EXIT_RECORD_WAIT_MS = 10_000 @@ -38,6 +42,12 @@ export function killPtyFromRuntimeController( } = deps runtime?.markPtyStopRequested?.(ptyId) const connectionId = getPtySshConnectionId(ptyId) + // COMPAT(legacy-relay-bridge, revisit 2027-01-15): an older relay's shell stops through the + // bridge, which retires its lease only on the relay's proof; tombstoning it here would orphan it. + if (connectionId && isLegacyRelayBridgedPty(ptyId)) { + void closeLegacyRelayPane(ptyId) + return true + } // Why: close runtime tails without clearing provider ownership, so // a retry can still target a PTY that survived the failed shutdown. const reportStopFailure = (err: unknown): void => { diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-ipc.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-ipc.ts index 033d9049a0b..0462aaae4e3 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-ipc.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-ipc.ts @@ -13,6 +13,7 @@ import { knownSshHostPlatform } from '../ssh-host-platform-memo' import { hostProps } from '../ssh-host-server-telemetry' import { attachLegacyRelayPane, + closeLegacyRelayPane, detachLegacyRelayPane, installLegacyRelayBridge, legacyRelayBridgedPanes, @@ -74,6 +75,7 @@ export function registerLegacyRelayBridgeIpc(args: { }) ipcMain.removeHandler(CHANNELS.listPanes) ipcMain.removeHandler(CHANNELS.attach) + ipcMain.removeHandler(CHANNELS.close) for (const channel of [ CHANNELS.write, CHANNELS.resize, @@ -87,6 +89,10 @@ export function registerLegacyRelayBridgeIpc(args: { const args = PaneArgsSchema.safeParse(payload) return args.success ? attachLegacyRelayPane(args.data.ptyId) : { status: 'not-bridged' } }) + ipcMain.handle(CHANNELS.close, async (_event, payload: unknown) => { + const args = PaneArgsSchema.safeParse(payload) + return args.success ? closeLegacyRelayPane(args.data.ptyId) : { status: 'not-bridged' } + }) ipcMain.on(CHANNELS.write, (_event, payload: unknown) => { const args = WriteArgsSchema.safeParse(payload) if (args.success) { diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-leases.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-leases.ts index 48594ce705b..12fa0178fc8 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-leases.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-bridge-leases.ts @@ -2,6 +2,7 @@ * Which relay-era leases the bridge may serve. * COMPAT(legacy-relay-bridge): revisit 2027-01-15. */ +import { toAppSshPtyId } from '../../../shared/ssh-pty-id' import type { SshRemotePtyLease } from '../../../shared/ssh-types' import type { LegacyRelayBridgedPane } from '../../../shared/legacy-relay-bridge-contract' import { parsePaneKey } from '../../../shared/stable-pane-id' @@ -35,6 +36,14 @@ export function bridgeableLeasesByRelayId( return new Map(leases.filter(isBridgeableLease).map((lease) => [lease.ptyId, lease])) } +export function leasedAppPtyIds(targetId: string, leases: SshRemotePtyLease[]): Set { + return new Set( + [...bridgeableLeasesByRelayId(leases).keys()].map((relayPtyId) => + toAppSshPtyId(targetId, relayPtyId) + ) + ) +} + /** One leased pane the bridge tracks, and the relay endpoint that listed it. */ export type Pane = { lease: SshRemotePtyLease 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 b53700109a6..5ce0415b0e7 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 @@ -5,6 +5,7 @@ import type { EventProps } from '../../../shared/telemetry-events' import type { LegacyRelayBridgeAttachResult, + LegacyRelayBridgeCloseResult, LegacyRelayBridgedPane } from '../../../shared/legacy-relay-bridge-contract' import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' @@ -19,7 +20,12 @@ import { createLegacyRelayCliServer } from './legacy-relay-cli-server' import { listLegacyRelayEndpoints, type LegacyRelayEndpoint } from './legacy-relay-endpoints' import { LegacyRelayTargetBridge } from './legacy-relay-target-bridge' import { LegacyRelayHiddenOutput } from './legacy-relay-hidden-output' -import { bridgeableLeasesByRelayId, isBridgeableLease } from './legacy-relay-bridge-leases' +import { LegacyRelayPaneStops } from './legacy-relay-pane-stops' +import { + bridgeableLeasesByRelayId, + isBridgeableLease, + leasedAppPtyIds +} 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 @@ -53,6 +59,7 @@ const bridges = new Map() * or the leases go: a hang-up is loss of contact, not exit (docs/reference/ssh-execution-boundary.md). */ const heldPanes = new Map() +const paneStops = new Map() export function installLegacyRelayBridge(next: LegacyRelayBridgeHost | null): void { for (const bridge of bridges.values()) { @@ -60,6 +67,8 @@ export function installLegacyRelayBridge(next: LegacyRelayBridgeHost | null): vo } bridges.clear() heldPanes.clear() + paneStops.forEach((stops) => stops.dispose()) + paneStops.clear() host = next hiddenOutput = next ? new LegacyRelayHiddenOutput({ @@ -135,6 +144,7 @@ function bridgeFor(targetId: string): LegacyRelayTargetBridge | null { // A running bridge's own listing answers for its target; a disposed one's does not. if (bridges.get(targetId) === bridge) { heldPanes.delete(targetId) + stopsFor(targetId).flush(bridge) } installed.onPanesChanged(legacyRelayBridgedPanes()) }, @@ -149,6 +159,16 @@ function bridgeFor(targetId: string): LegacyRelayTargetBridge | null { return bridge } +function stopsFor(targetId: string): LegacyRelayPaneStops { + const existing = paneStops.get(targetId) + if (existing) { + return existing + } + const created = new LegacyRelayPaneStops(targetId, () => host?.leases(targetId) ?? []) + paneStops.set(targetId, created) + return created +} + /** A dead socket is the one failed open that proves its relay gone; Windows pipes never are. */ async function posixEndpointExited( conn: SshConnection, @@ -213,6 +233,8 @@ export function stopLegacyRelayBridge(targetId: string): void { export function forgetLegacyRelayTarget(targetId: string): void { stopLegacyRelayBridge(targetId) releaseHeldPanes(targetId) + paneStops.get(targetId)?.dispose() + paneStops.delete(targetId) } function releaseHeldPanes(targetId: string): void { @@ -290,6 +312,22 @@ export function setLegacyRelayPaneOutputPaused(ptyId: string, paused: boolean): hiddenOutput?.setPaused(ptyId, paused) } +/** Whether this PTY is an older relay's terminal the bridge serves, so only the bridge stops it. */ +export function isLegacyRelayBridgedPty(ptyId: string): boolean { + const targetId = parseAppSshPtyId(ptyId)?.connectionId + return Boolean(targetId && host && leasedAppPtyIds(targetId, host.leases(targetId)).has(ptyId)) +} + +/** Its tab closed: the relay stops the shell, or is asked once it can be reached. */ +export async function closeLegacyRelayPane(ptyId: string): Promise { + const targetId = parseAppSshPtyId(ptyId)?.connectionId + if (!targetId || !host || !hasBridgeableLeases(targetId)) { + return { status: 'not-bridged' } + } + // Disconnected: recorded now, and the target's next connect lists the relay and sends the stop. + return stopsFor(targetId).stop(ptyId, host.connection(targetId) ? bridgeFor(targetId) : null) +} + export function detachLegacyRelayPane(ptyId: string): void { bridgeForPty(ptyId)?.detach(ptyId) } diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-client.test.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-client.test.ts index ade6bdfc519..e164f9b0bd1 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-client.test.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-client.test.ts @@ -243,6 +243,21 @@ describe('the legacy relay client', () => { expect(postOutput).toHaveBeenCalledWith(params, expect.any(AbortSignal)) }) + it('stops a terminal with the old relay’s own pty.shutdown request', async () => { + const client = await open() + fake.respond = () => undefined + await client.shutdown('pty-1') + expect(fake.requests.at(-1)).toEqual({ + method: 'pty.shutdown', + params: { id: 'pty-1', immediate: false, keepHistory: false } + }) + await client.shutdown('pty-1', 'inc-1') + expect(fake.requests.at(-1)).toEqual({ + method: 'pty.shutdown', + params: { id: 'pty-1', immediate: false, keepHistory: false, expectedIncarnationId: 'inc-1' } + }) + }) + it('hangs up only itself on close', async () => { const client = await open() const closed = vi.fn() diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-client.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-client.ts index 1ee0d2b427a..e63b8fa5b4a 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-client.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-client.ts @@ -212,6 +212,23 @@ export class LegacyRelayClient { this.mux.notify('pty.resize', { id: relayPtyId, cols, rows }) } + /** + * The old relay's own stop: it kills the shell and reports `pty.exit` (v1.4.2xx wire shape). + * The incarnation fence is omitted, never sent undefined, for relays that predate it. + */ + async shutdown(relayPtyId: string, expectedIncarnationId?: string): Promise { + await this.mux.request( + 'pty.shutdown', + { + id: relayPtyId, + immediate: false, + keepHistory: false, + ...(expectedIncarnationId ? { expectedIncarnationId } : {}) + }, + { timeoutMs: REQUEST_TIMEOUT_MS } + ) + } + onData(listener: DataListener): () => void { this.dataListeners.add(listener) return () => this.dataListeners.delete(listener) diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-pane-stops.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-pane-stops.ts new file mode 100644 index 00000000000..77d15a3169b --- /dev/null +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-pane-stops.ts @@ -0,0 +1,124 @@ +/** + * One target's stops for bridged panes whose tab closed, sent with the old relay's own + * `pty.shutdown`. COMPAT(legacy-relay-bridge): revisit 2027-01-15. + * + * Held outside the bridge, so a reconnect's new bridge still sends them. A stop is pending until + * the relay reports the exit or a listing proves the terminal gone; while the relay can't be asked + * the answer is `unverifiable` and the stop goes out once it can. Loss of contact is never exit. + */ +import type { SshRemotePtyLease } from '../../../shared/ssh-types' +import type { LegacyRelayBridgeCloseResult } from '../../../shared/legacy-relay-bridge-contract' +import { leasedAppPtyIds } from './legacy-relay-bridge-leases' +import { LegacyRelayRetry } from './legacy-relay-bridge-timers' +import type { LegacyRelayClient } from './legacy-relay-client' +import type { LegacyRelayTargetBridge } from './legacy-relay-target-bridge' + +type StopTarget = Pick< + LegacyRelayTargetBridge, + 'snapshot' | 'liveRoute' | 'listedIncarnation' | 'refresh' +> + +/** + * Who the closed tab's process was: the relay's incarnation when it reports one, else the live + * connection that showed it. Neither, for a stop recorded while the relay couldn't be asked. + */ +type PendingStop = { incarnationId?: string; client?: LegacyRelayClient } + +export class LegacyRelayPaneStops { + private readonly closing = new Map() + private bridge: StopTarget | null = null + private readonly relist = new LegacyRelayRetry(() => void this.bridge?.refresh()) + + constructor( + private readonly targetId: string, + private readonly leases: () => SshRemotePtyLease[] + ) {} + + /** `bridge` is null while the target is disconnected; its next listing sends the stop. */ + async stop(ptyId: string, bridge: StopTarget | null): Promise { + const pane = bridge?.snapshot().find((entry) => entry.ptyId === ptyId) + if (!pane && !leasedAppPtyIds(this.targetId, this.leases()).has(ptyId)) { + return { status: 'exited' } + } + // A repeated close while the relay can't be asked keeps the identity the first one saw. + const earlier = this.closing.get(ptyId) + const incarnationId = bridge?.listedIncarnation(ptyId) ?? earlier?.incarnationId + const client = bridge?.liveRoute(ptyId)?.client ?? earlier?.client + this.closing.set(ptyId, { + ...(incarnationId ? { incarnationId } : {}), + ...(client ? { client } : {}) + }) + if (!bridge || pane?.state !== 'live') { + return { status: 'unverifiable' } + } + const sent = await this.send(ptyId, bridge) + // Only the relay's exit report or a listing without the terminal proves the stop. + void bridge.refresh() + return sent ? { status: 'requested' } : { status: 'unverifiable' } + } + + /** Runs after each listing: re-sends pending stops, re-listing on backoff while one still runs. */ + flush(bridge: StopTarget): void { + this.bridge = bridge + const panes = new Map(bridge.snapshot().map((pane) => [pane.ptyId, pane])) + const leased = leasedAppPtyIds(this.targetId, this.leases()) + let pending = false + for (const ptyId of this.closing.keys()) { + const pane = panes.get(ptyId) + if (!pane) { + if (!leased.has(ptyId)) { + this.closing.delete(ptyId) + } + continue + } + pending = true + if (pane.state === 'live') { + void this.send(ptyId, bridge) + } + } + if (pending) { + this.relist.schedule() + } else { + this.relist.reset() + } + } + + dispose(): void { + this.relist.cancel() + this.closing.clear() + } + + get size(): number { + return this.closing.size + } + + /** The old relay's own stop, fenced to the process the tab ran; false when it can't be asked. */ + private async send(ptyId: string, bridge: StopTarget): Promise { + const route = bridge.liveRoute(ptyId) + const pending = this.closing.get(ptyId) + if (!route || !pending) { + return false + } + const listed = bridge.listedIncarnation(ptyId) + // Only positive identity sends a stop: a legacy `pty-N` id restarts at 1 with its relay, and an + // older relay ignores the incarnation fence. A `pty2:` id carries its relay's start epoch. + const sameProcess = route.client === pending.client || route.relayPtyId.startsWith('pty2:') + const proven = pending.incarnationId + ? listed === pending.incarnationId || (listed === undefined && sameProcess) + : sameProcess + if (!proven) { + this.closing.delete(ptyId) + return false + } + try { + await route.client.shutdown(route.relayPtyId, pending.incarnationId) + return true + } catch (error) { + // The relay's own fence refused it: that id now runs another process. + if (/incarnation mismatch/i.test(error instanceof Error ? error.message : String(error))) { + this.closing.delete(ptyId) + } + return false + } + } +} diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.test.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.test.ts index 248a5ac772e..b71851295eb 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.test.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.test.ts @@ -4,6 +4,7 @@ import type { SshRemotePtyLease } from '../../../shared/ssh-types' import { getRemoteHostPlatform } from '../ssh-remote-platform' import type { LegacyRelayClient } from './legacy-relay-client' import type { LegacyRelayEndpoint, LegacyRelayEndpointListing } from './legacy-relay-endpoints' +import { LegacyRelayPaneStops } from './legacy-relay-pane-stops' import { LegacyRelayTargetBridge, type LegacyRelayTargetBridgeDeps @@ -17,6 +18,12 @@ type FakeRelay = { attachError: Error | null clients: FakeClient[] openError: Error | null + shutdowns: string[] + shutdownError: Error | null + /** The process identity each listed PTY reports, when the relay reports one. */ + incarnations: Map + /** Whether a stop reports `pty.exit` (the relay's normal answer). */ + exitOnShutdown: boolean } type FakeClient = LegacyRelayClient & { @@ -43,13 +50,27 @@ function fakeClient(relay: FakeRelay): FakeClient { get closed() { return closed }, - list: async () => [...relay.ptys].map((relayPtyId) => ({ relayPtyId })), + list: async () => + [...relay.ptys].map((relayPtyId) => { + const incarnationId = relay.incarnations.get(relayPtyId) + return incarnationId ? { relayPtyId, incarnationId } : { relayPtyId } + }), attach: async () => { if (relay.attachError) { throw relay.attachError } return { replay: 'SCREEN' } }, + shutdown: async (id: string, expectedIncarnationId?: string) => { + if (relay.shutdownError) { + throw relay.shutdownError + } + relay.shutdowns.push(expectedIncarnationId ? `${id}#${expectedIncarnationId}` : id) + relay.ptys.delete(id) + if (relay.exitOnShutdown) { + exits.forEach((listener) => listener(id, 0)) + } + }, write: (_id: string, chunk: string) => writes.push(chunk), resize: () => {}, onData: (listener: (id: string, data: string) => void) => { @@ -99,10 +120,11 @@ let relay: FakeRelay let leases: SshRemotePtyLease[] let listing: LegacyRelayEndpointListing let endpointExited: boolean +let stops: LegacyRelayPaneStops let events: { data: string[]; exits: string[]; exitedTelemetry: number; panesChanged: number } function bridge(overrides: Partial = {}): LegacyRelayTargetBridge { - return new LegacyRelayTargetBridge({ + const subject: LegacyRelayTargetBridge = new LegacyRelayTargetBridge({ targetId: TARGET, leases: () => leases.filter((row) => row.state !== 'terminated'), retireLease: (relayPtyId) => { @@ -122,6 +144,7 @@ function bridge(overrides: Partial = {}): LegacyRel endpointExited: async () => endpointExited, onPanesChanged: () => { events.panesChanged += 1 + stops.flush(subject) }, onData: (_id, chunk) => events.data.push(chunk), onExit: (id) => events.exits.push(id), @@ -130,11 +153,22 @@ function bridge(overrides: Partial = {}): LegacyRel }, ...overrides }) + return subject } beforeEach(() => { vi.useFakeTimers() - relay = { ptys: new Set(['pty-1']), attachError: null, clients: [], openError: null } + relay = { + ptys: new Set(['pty-1']), + attachError: null, + clients: [], + openError: null, + shutdowns: [], + shutdownError: null, + incarnations: new Map(), + exitOnShutdown: true + } + stops = new LegacyRelayPaneStops(TARGET, () => leases.filter((row) => row.state !== 'terminated')) leases = [lease()] listing = { kind: 'listed', endpoints: [ENDPOINT], exhaustive: true } endpointExited = false @@ -297,6 +331,117 @@ describe('one target’s legacy relay bridge', () => { expect(relay.clients).toEqual([]) }) + it('stops a closed tab’s shell through the relay and retires it on the relay’s exit report', async () => { + const subject = bridge() + await subject.refresh() + await expect(stops.stop(PTY, subject)).resolves.toEqual({ status: 'requested' }) + expect(relay.shutdowns).toEqual(['pty-1']) + expect(events.exits).toEqual([PTY]) + expect(leases[0].state).toBe('terminated') + await vi.advanceTimersByTimeAsync(30_000) + expect(relay.shutdowns).toEqual(['pty-1']) + }) + + it('retires a stopped shell once a listing proves it gone, without an exit report', async () => { + relay.exitOnShutdown = false + const subject = bridge() + await subject.refresh() + await stops.stop(PTY, subject) + expect(leases[0].state).toBe('detached') + await vi.advanceTimersByTimeAsync(2_000) + expect(leases[0].state).toBe('terminated') + expect(stops.size).toBe(0) + }) + + it('holds a close for an unreachable relay as unverifiable and sends it once the relay answers', async () => { + relay.incarnations.set('pty-1', 'inc-1') + const subject = bridge() + await subject.refresh() + relay.openError = new Error('ssh down') + relay.clients[0].drop() + await expect(stops.stop(PTY, subject)).resolves.toEqual({ status: 'unverifiable' }) + expect(relay.shutdowns).toEqual([]) + // Loss of contact is never exit. + expect(leases[0].state).toBe('detached') + expect(events.exits).toEqual([]) + relay.openError = null + await vi.advanceTimersByTimeAsync(2_000) + // Fenced to the process the tab ran. + expect(relay.shutdowns).toEqual(['pty-1#inc-1']) + expect(leases[0].state).toBe('terminated') + }) + + it('keeps the identity a first close saw when the tab’s close repeats while unreachable', async () => { + relay.incarnations.set('pty-1', 'inc-1') + const subject = bridge() + await subject.refresh() + relay.openError = new Error('ssh down') + relay.clients[0].drop() + await stops.stop(PTY, subject) + await vi.advanceTimersByTimeAsync(2_000) + await stops.stop(PTY, subject) + relay.openError = null + await vi.advanceTimersByTimeAsync(10_000) + expect(relay.shutdowns).toEqual(['pty-1#inc-1']) + }) + + it('never stops a different process that took a closed tab’s recycled relay id', async () => { + relay.incarnations.set('pty-1', 'inc-1') + const subject = bridge() + await subject.refresh() + relay.openError = new Error('ssh down') + relay.clients[0].drop() + await stops.stop(PTY, subject) + relay.incarnations.set('pty-1', 'inc-2') + relay.openError = null + await vi.advanceTimersByTimeAsync(10_000) + expect(relay.shutdowns).toEqual([]) + expect(stops.size).toBe(0) + }) + + it('sends a close recorded while disconnected once the next bridge lists the pane', async () => { + const epochPty = toAppSshPtyId(TARGET, 'pty2:epoch:1') + leases = [lease({ ptyId: 'pty2:epoch:1' })] + relay.ptys = new Set(['pty2:epoch:1']) + await expect(stops.stop(epochPty, null)).resolves.toEqual({ status: 'unverifiable' }) + expect(leases[0].state).toBe('detached') + await bridge().refresh() + expect(relay.shutdowns).toEqual(['pty2:epoch:1']) + }) + + it('never replays a blind close for a legacy id that could name another shell', async () => { + await stops.stop(PTY, null) + await bridge().refresh() + expect(relay.shutdowns).toEqual([]) + expect(leases[0].state).toBe('detached') + }) + + it('never replays a fenced legacy stop to a relay that no longer reports the incarnation', async () => { + relay.incarnations.set('pty-1', 'inc-1') + const subject = bridge() + await subject.refresh() + relay.openError = new Error('ssh down') + relay.clients[0].drop() + await stops.stop(PTY, subject) + // A restarted or downgraded relay lists the same id with no identity, and may ignore the fence. + relay.incarnations.clear() + relay.openError = null + await vi.advanceTimersByTimeAsync(10_000) + expect(relay.shutdowns).toEqual([]) + }) + + it('never replays an unfenced legacy stop on a later connection, which may be a new relay', async () => { + const subject = bridge() + await subject.refresh() + relay.shutdownError = new Error('timed out') + await expect(stops.stop(PTY, subject)).resolves.toEqual({ status: 'unverifiable' }) + relay.shutdownError = null + relay.clients[0].drop() + await vi.advanceTimersByTimeAsync(10_000) + expect(relay.clients.length).toBeGreaterThan(1) + expect(relay.shutdowns).toEqual([]) + }) + it('hangs up every connection on dispose and leaves the relays to their own grace', async () => { const subject = bridge() await subject.refresh() diff --git a/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.ts b/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.ts index cdd9bfe788c..efcfeab01b3 100644 --- a/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.ts +++ b/src/main/ssh/legacy-relay-bridge/legacy-relay-target-bridge.ts @@ -20,6 +20,7 @@ import { ATTACH_WAIT_MS, bridgeableLeasesByRelayId, findLivePane, + leasedAppPtyIds, type LegacyRelayPty, PROVEN_EXITED, toBridgedPane, @@ -54,6 +55,8 @@ export class LegacyRelayTargetBridge { private readonly endpoints = new Map() private readonly panes = new Map() readonly livePaneForKey = (paneKey: string) => findLivePane(this.panes, paneKey) + /** The process identity the relay listed for a pane, which fences a stop against a recycled id. */ + readonly listedIncarnation = (ptyId: string) => this.panes.get(ptyId)?.listed?.incarnationId private readonly attached = new Map() private readonly waiters = new LegacyRelayWaiters() private readonly retry = new LegacyRelayRetry(() => void this.refresh()) @@ -101,9 +104,7 @@ export class LegacyRelayTargetBridge { pane = this.panes.get(ptyId) } if (!pane) { - const leased = [...bridgeableLeasesByRelayId(this.deps.leases()).keys()].some( - (relayPtyId) => toAppSshPtyId(this.deps.targetId, relayPtyId) === ptyId - ) + const leased = leasedAppPtyIds(this.deps.targetId, this.deps.leases()).has(ptyId) return leased ? { status: 'unverifiable' } : { status: 'exited' } } const client = pane.endpoint ? this.endpoints.get(pane.endpoint)?.client : null @@ -318,7 +319,8 @@ export class LegacyRelayTargetBridge { } } - private liveRoute(ptyId: string): { client: LegacyRelayClient; relayPtyId: string } | null { + /** The connection that carries a live pane, or null while its relay can't be asked. */ + liveRoute(ptyId: string): { client: LegacyRelayClient; relayPtyId: string } | null { const pane = this.panes.get(ptyId) const client = pane?.endpoint ? this.endpoints.get(pane.endpoint)?.client : null return pane?.state === 'live' && client ? { client, relayPtyId: pane.relayPtyId } : null diff --git a/src/preload/api/legacy-relay-bridge-api.ts b/src/preload/api/legacy-relay-bridge-api.ts index cad3f2ed201..06da01dda33 100644 --- a/src/preload/api/legacy-relay-bridge-api.ts +++ b/src/preload/api/legacy-relay-bridge-api.ts @@ -6,6 +6,7 @@ import { ipcRenderer, type IpcRendererEvent } from 'electron' import { LEGACY_RELAY_BRIDGE_CHANNELS as CHANNELS, type LegacyRelayBridgeAttachResult, + type LegacyRelayBridgeCloseResult, type LegacyRelayBridgedPane } from '../../shared/legacy-relay-bridge-contract' @@ -15,6 +16,8 @@ export type LegacyRelayBridgeApi = { write: (args: { ptyId: string; data: string }) => void resize: (args: { ptyId: string; cols: number; rows: number }) => void detach: (args: { ptyId: string }) => void + /** Stops the shell its closed tab ran. */ + close: (args: { ptyId: string }) => Promise setOutputPaused: (args: { ptyId: string; paused: boolean }) => void onPanes: (callback: (panes: LegacyRelayBridgedPane[]) => void) => () => void onData: (callback: (event: { ptyId: string; data: string }) => void) => () => void @@ -36,6 +39,7 @@ export const legacyRelayBridgeApi: LegacyRelayBridgeApi = { write: (args) => ipcRenderer.send(CHANNELS.write, args), resize: (args) => ipcRenderer.send(CHANNELS.resize, args), detach: (args) => ipcRenderer.send(CHANNELS.detach, args), + close: (args) => ipcRenderer.invoke(CHANNELS.close, args), setOutputPaused: (args) => ipcRenderer.send(CHANNELS.setOutputPaused, args), onPanes: (callback) => subscribe(CHANNELS.panes, callback), onData: (callback) => subscribe(CHANNELS.data, callback), diff --git a/src/renderer/src/components/terminal-pane/legacy-relay-bridge-panes.ts b/src/renderer/src/components/terminal-pane/legacy-relay-bridge-panes.ts index 19eda58d377..4802d16098a 100644 --- a/src/renderer/src/components/terminal-pane/legacy-relay-bridge-panes.ts +++ b/src/renderer/src/components/terminal-pane/legacy-relay-bridge-panes.ts @@ -14,6 +14,7 @@ const listeners = new Set<() => void>() /** Which pane (tab and leaf) holds each bridged PTY; a second pane never drives the same shell. */ const claims = new Map() let subscribed = false +let firstListing: Promise = Promise.resolve() function ensureSubscribed(): void { const api = typeof window === 'undefined' ? undefined : window.api?.legacyRelayBridge @@ -31,7 +32,13 @@ function ensureSubscribed(): void { listeners.forEach((listener) => listener()) } api.onPanes(apply) - void api.listPanes().then(apply, () => {}) + firstListing = api.listPanes().then(apply, () => {}) +} + +/** Settles once main's first pane list arrived, so a close never reads the empty initial cache. */ +export function legacyRelayBridgedPanesListed(): Promise { + ensureSubscribed() + return firstListing } ensureSubscribed() @@ -87,6 +94,26 @@ export function hasLegacyRelayBridgedPane(tabId: string): boolean { return bridgedPanesInTab(tabId, null).inTab.length > 0 } +/** + * The bridged PTYs a closing tab runs: the panes main lists for its relay-era tab (only the named + * leaf for a `tab::leaf` mirror), plus any bridged PTY the tab itself was bound to. + */ +export function legacyRelayBridgedPtyIdsForClosingTab( + tabId: string, + boundPtyIds: readonly string[] +): string[] { + const { inTab, leaf } = bridgedPanesInTab(tabId, null) + const named = + leaf === null ? inTab : [bridgedPaneFor(tabId, null)].filter((pane) => pane !== undefined) + const ids = new Set(named.map((pane) => pane.ptyId)) + for (const ptyId of boundPtyIds) { + if (panes.some((pane) => pane.ptyId === ptyId)) { + ids.add(ptyId) + } + } + return [...ids] +} + /** * The relay-era PTY a runtime pane should reattach through the bridge: one main reports serving * for this tab and leaf, or the `ssh:` id the pane itself still names from an earlier bridging. diff --git a/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.test.ts b/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.test.ts index 4720495cc73..11ca8a4de42 100644 --- a/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.test.ts @@ -9,6 +9,7 @@ const bridge = vi.hoisted(() => ({ write: vi.fn(), resize: vi.fn(), detach: vi.fn(), + close: vi.fn(async () => ({ status: 'requested' })), setOutputPaused: vi.fn(), listPanes: vi.fn(async (): Promise => []), panes: new Set<(panes: LegacyRelayBridgedPane[]) => void>(), @@ -25,6 +26,7 @@ vi.stubGlobal('window', { write: bridge.write, resize: bridge.resize, detach: bridge.detach, + close: bridge.close, setOutputPaused: bridge.setOutputPaused, onPanes: (listener: (panes: LegacyRelayBridgedPane[]) => void) => { bridge.panes.add(listener) @@ -48,7 +50,8 @@ vi.stubGlobal('window', { const { createLegacyRelayBridgeTransport: createTransport } = await import('./legacy-relay-bridge-transport') -const { legacyRelayBridgePtyIdForPane } = await import('./legacy-relay-bridge-panes') +const { legacyRelayBridgePtyIdForPane, legacyRelayBridgedPtyIdsForClosingTab } = + await import('./legacy-relay-bridge-panes') const created: PtyTransport[] = [] function createLegacyRelayBridgeTransport(ptyId: string, inner: PtyTransport): PtyTransport { @@ -280,6 +283,40 @@ describe('a runtime pane bridged to an older relay’s terminal', () => { await vi.waitFor(() => expect(cb.onExit).toHaveBeenCalledWith(-1)) }) + it('stops the old shell when its pane is closed, and never when it is only detached', async () => { + bridge.attach.mockResolvedValue({ status: 'attached', replay: '' }) + const detached = createLegacyRelayBridgeTransport(PTY, innerTransport()) + await detached.connect({ url: '', sessionId: PTY, callbacks: callbacks() }) + detached.detach?.() + expect(bridge.close).not.toHaveBeenCalled() + const closed = createLegacyRelayBridgeTransport(PTY, innerTransport()) + await closed.connect({ url: '', sessionId: PTY, callbacks: callbacks() }) + closed.destroy?.() + expect(bridge.close).toHaveBeenCalledWith({ ptyId: PTY }) + }) + + it('names the bridged shells a closing tab runs, or only its leaf for a split mirror', () => { + const second = 'ssh:ssh-box@@pty-2' + publishPanes([ + { ptyId: PTY, targetId: 'ssh-box', tabId: 'tab-1', leafId: 'leaf-1', state: 'live' }, + { + ptyId: second, + targetId: 'ssh-box', + tabId: 'tab-1', + leafId: 'leaf-2', + state: 'unverifiable' + }, + { ptyId: 'ssh:ssh-box@@pty-3', targetId: 'ssh-box', tabId: 'tab-2', state: 'live' } + ]) + expect(legacyRelayBridgedPtyIdsForClosingTab('web-terminal-tab-1', [])).toEqual([PTY, second]) + const leaf = `web-terminal-${encodeURIComponent('tab-1::leaf-2')}` + expect(legacyRelayBridgedPtyIdsForClosingTab(leaf, [])).toEqual([second]) + // A tab still bound to a bridged shell names it even under another id; others are ignored. + expect( + legacyRelayBridgedPtyIdsForClosingTab('local-tab', ['ssh:ssh-box@@pty-3', 'pty-local']) + ).toEqual(['ssh:ssh-box@@pty-3']) + }) + it('hands any other connect straight to the managed server’s transport', async () => { const inner = innerTransport() const transport = createLegacyRelayBridgeTransport(PTY, inner) diff --git a/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.ts b/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.ts index e90a00c90a7..6fe6cfaed78 100644 --- a/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.ts +++ b/src/renderer/src/components/terminal-pane/legacy-relay-bridge-transport.ts @@ -278,6 +278,10 @@ export function createLegacyRelayBridgeTransport(ptyId: string, inner: PtyTransp inner.detach?.(options) }, destroy: (options) => { + if (bridged()) { + // An explicit pane close: stop the shell, as the other transports' destroy does. + void api?.close({ ptyId }).catch(() => {}) + } if (bridged() || mode === 'pending') { release() mode = 'pending' diff --git a/src/renderer/src/store/terminals/legacy-relay-bridged-tab-close.test.ts b/src/renderer/src/store/terminals/legacy-relay-bridged-tab-close.test.ts new file mode 100644 index 00000000000..2ebee87edff --- /dev/null +++ b/src/renderer/src/store/terminals/legacy-relay-bridged-tab-close.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalTabRetirementPlan } from '../slices/terminal-tab-retirement' + +const PTY = 'ssh:ssh-box@@pty-1' +const TAB = 'web-terminal-tab-1' + +const api = vi.hoisted(() => { + let release: () => void = () => {} + const listed = new Promise((resolve) => { + release = resolve + }) + return { + close: vi.fn(async () => ({ status: 'requested' })), + // Main answers the first listing late, after the close already started. + listPanes: vi.fn(async () => { + await listed + return [{ ptyId: 'ssh:ssh-box@@pty-1', targetId: 'ssh-box', tabId: 'tab-1', state: 'live' }] + }), + release: () => release() + } +}) + +vi.stubGlobal('window', { + api: { + legacyRelayBridge: { listPanes: api.listPanes, close: api.close, onPanes: () => () => {} } + } +}) + +const { stopLegacyRelayBridgedShellsForTab } = await import('./legacy-relay-bridged-tab-close') + +function plan(overrides: Partial = {}): TerminalTabRetirementPlan { + return { + tabId: TAB, + worktreeId: 'wt-1', + ptyIds: [], + localOrSshPtyIds: [], + runtimeTerminals: [], + cleanupOnlyPtyIds: [], + sharedPtyIds: [], + unroutablePtyIds: [], + ...overrides + } +} + +function state(otherTabPtyId: string | null = null) { + return { + tabsByWorktree: { + 'wt-1': [ + { id: TAB, ptyId: PTY }, + ...(otherTabPtyId ? [{ id: 'moved-to', ptyId: otherTabPtyId }] : []) + ] + }, + unifiedTabsByWorktree: {}, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {}, + lastKnownRelayPtyIdByTabId: {}, + deferredSshSessionIdsByTabId: {}, + pendingReconnectPtyIdByTabId: {} + } +} + +beforeEach(() => { + api.close.mockClear() +}) + +describe('closing a tab whose shell an older relay still runs', () => { + it('stops that shell, waiting for main’s first pane list rather than reading it empty', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the close reads only the tab and pty maps this fixture fills. + const closing = stopLegacyRelayBridgedShellsForTab(state() as never, TAB, plan()) + await new Promise((resolve) => setTimeout(resolve, 5)) + expect(api.close).not.toHaveBeenCalled() + api.release() + await closing + expect(api.close).toHaveBeenCalledWith({ ptyId: PTY }) + }) + + it('never stops a shell another tab now shows, or one the plan says is shared', async () => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above. + await stopLegacyRelayBridgedShellsForTab(state(PTY) as never, TAB, plan()) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above. + await stopLegacyRelayBridgedShellsForTab(state() as never, TAB, plan({ sharedPtyIds: [PTY] })) + expect(api.close).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/terminals/legacy-relay-bridged-tab-close.ts b/src/renderer/src/store/terminals/legacy-relay-bridged-tab-close.ts new file mode 100644 index 00000000000..3a168f0a9f2 --- /dev/null +++ b/src/renderer/src/store/terminals/legacy-relay-bridged-tab-close.ts @@ -0,0 +1,38 @@ +/** + * A closed tab stops the shells an older Orca's relay still runs for it, as closing any other + * terminal tab ends its shell. COMPAT(legacy-relay-bridge): revisit 2027-01-15. + */ +import { + collectPtyIdsForTab, + type TerminalTabRetirementPlan, + type TerminalTabRetirementState +} from '../slices/terminal-tab-retirement' + +/** `state` is read before the close prunes the tab, so its own PTYs are still listed. */ +export async function stopLegacyRelayBridgedShellsForTab( + state: TerminalTabRetirementState, + tabId: string, + plan: TerminalTabRetirementPlan +): Promise { + const api = window.api?.legacyRelayBridge + if (!api) { + return + } + // Loaded lazily: the module subscribes to main's pane list on import. + const { legacyRelayBridgedPanesListed, legacyRelayBridgedPtyIdsForClosingTab } = + await import('@/components/terminal-pane/legacy-relay-bridge-panes') + await legacyRelayBridgedPanesListed() + // Why: a pane moved to another tab keeps its shell, and main's lease list can lag the move. + const keep = new Set(plan.sharedPtyIds) + for (const tabs of Object.values(state.tabsByWorktree)) { + for (const tab of tabs) { + if (tab.id !== tabId) { + collectPtyIdsForTab(state, tab.id, tab.ptyId).forEach((ptyId) => keep.add(ptyId)) + } + } + } + const ptyIds = legacyRelayBridgedPtyIdsForClosingTab(tabId, plan.ptyIds).filter( + (ptyId) => !keep.has(ptyId) + ) + await Promise.allSettled(ptyIds.map((ptyId) => api.close({ ptyId }))) +} diff --git a/src/renderer/src/store/terminals/terminal-tab-close.ts b/src/renderer/src/store/terminals/terminal-tab-close.ts index c1daffc0b74..4bd16af3d46 100644 --- a/src/renderer/src/store/terminals/terminal-tab-close.ts +++ b/src/renderer/src/store/terminals/terminal-tab-close.ts @@ -13,6 +13,7 @@ import { } from '../slices/terminal-tab-retirement' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' import { startTerminalTabProviderRetirement } from './terminal-tab-close-providers' +import { stopLegacyRelayBridgedShellsForTab } from './legacy-relay-bridged-tab-close' import { commitTerminalSurfaceClose } from './terminal-surface-close-intent' import { recordTerminalTabCloseThroughLayoutOwner } from './terminal-tab-close-layout-owner' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' @@ -51,6 +52,10 @@ export function createTerminalTabCloseActions( state: get(), tabId }) + if (opts?.localPtyTeardownOwnedExternally !== true) { + // Why regardless of a host-owned close: the managed server never ran a relay-era shell. + void stopLegacyRelayBridgedShellsForTab(get(), tabId, retirementPlan) + } } settleAttentionRemovedBy(set, get, () => set((s) => { diff --git a/src/shared/legacy-relay-bridge-contract.ts b/src/shared/legacy-relay-bridge-contract.ts index 4c5fb0d3976..995c7aed0f6 100644 --- a/src/shared/legacy-relay-bridge-contract.ts +++ b/src/shared/legacy-relay-bridge-contract.ts @@ -20,12 +20,22 @@ export type LegacyRelayBridgeAttachResult = /** The relay holding it can't be reached right now; the pane stays bound and waits. */ | { status: 'unverifiable' } +export type LegacyRelayBridgeCloseResult = + /** The relay accepted the stop; its own exit report retires the pane. */ + | { status: 'requested' } + /** No relay for the host can be holding it. */ + | { status: 'exited' } + | { status: 'not-bridged' } + /** The relay can't be asked now; the stop is sent once it can. Never read as exited. */ + | { status: 'unverifiable' } + export const LEGACY_RELAY_BRIDGE_CHANNELS = { listPanes: 'legacyRelayBridge:listPanes', attach: 'legacyRelayBridge:attach', write: 'legacyRelayBridge:write', resize: 'legacyRelayBridge:resize', detach: 'legacyRelayBridge:detach', + close: 'legacyRelayBridge:close', setOutputPaused: 'legacyRelayBridge:setOutputPaused', panes: 'legacyRelayBridge:panes', data: 'legacyRelayBridge:data',