fix(ssh): a disconnect keeps an old relay terminal's tab on that terminal instead of starting a new shell (#28344)

* fix(ssh): a disconnect keeps an old relay terminal's pane on the bridge instead of a new shell

* fix(ssh): release held panes when a reconnect finds their leases retired

* test(ssh): let the node typecheck see the renderer's bridged-pane selection
This commit is contained in:
Jinwoo Hong
2026-10-13 14:39:40 -04:00
committed by GitHub
parent b8e49222a3
commit 32fc38d575
5 changed files with 190 additions and 5 deletions
+1
View File
@@ -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__/**/*",
+5 -1
View File
@@ -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<void> {
invalidateConnectAttempt(targetId)
@@ -33,6 +36,7 @@ export async function removeRegisteredSshTarget(targetId: string): Promise<void>
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.
+2 -1
View File
@@ -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`,
@@ -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<string, LegacyRelayTargetBridge>()
/**
* 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<string, LegacyRelayBridgedPane[]>()
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<void> {
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<void> {
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<LegacyRelayBridgeAttachResult> {
@@ -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<void> {
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)
})
})