mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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__/**/*",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user