fix(ssh): stop a relay-era shell when its bridged tab closes (#29620)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-16 04:08:56 -07:00
committed by GitHub
co-authored by Claude
parent 8eaaab88bd
commit f1677dbf4e
18 changed files with 637 additions and 10 deletions
@@ -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<string>()
}
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)
})
})
+10
View File
@@ -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 => {
@@ -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) {
@@ -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<string> {
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
@@ -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<string, LegacyRelayTargetBridge>()
* or the leases go: a hang-up is loss of contact, not exit (docs/reference/ssh-execution-boundary.md).
*/
const heldPanes = new Map<string, LegacyRelayBridgedPane[]>()
const paneStops = new Map<string, LegacyRelayPaneStops>()
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<LegacyRelayBridgeCloseResult> {
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)
}
@@ -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()
@@ -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<void> {
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)
@@ -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<string, PendingStop>()
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<LegacyRelayBridgeCloseResult> {
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<boolean> {
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
}
}
}
@@ -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<string, string>
/** 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<LegacyRelayTargetBridgeDeps> = {}): 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<LegacyRelayTargetBridgeDeps> = {}): 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<LegacyRelayTargetBridgeDeps> = {}): 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()
@@ -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<string, EndpointState>()
private readonly panes = new Map<string, Pane>()
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<string, number>()
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
@@ -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<LegacyRelayBridgeCloseResult>
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),
@@ -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<string, string>()
let subscribed = false
let firstListing: Promise<void> = 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<void> {
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.
@@ -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<LegacyRelayBridgedPane[]> => []),
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)
@@ -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'
@@ -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<void>((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> = {}): 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()
})
})
@@ -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<void> {
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 })))
}
@@ -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) => {
@@ -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',