Files
orca/src/main/ssh/ssh-connection-manager.ts
T
4f81dfc128 perf(ssh): cut warm high-latency connects from 88.7s to 7.7s (#9015)
Move managed agent-hook filesystem work behind one relay RPC so high-latency SSH connects pay one WAN round trip instead of hundreds. Keep installers serial, lock shared account config across relay processes, and fence cancelled connection generations from replacement state.

Co-authored-by: nasagong <zinho2000@gachon.ac.kr>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-07-20 12:18:57 -07:00

109 lines
3.3 KiB
TypeScript

import type { SshTarget, SshConnectionState } from '../../shared/ssh-types'
import { SshConnection, type SshConnectionCallbacks } from './ssh-connection'
// ── Connection Manager ──────────────────────────────────────────────
// Why: extracted from ssh-connection.ts to keep each file under the
// 300-line oxlint max-lines threshold while preserving a clear
// single-responsibility boundary (connection lifecycle vs. pool management).
export class SshConnectionManager {
private connections = new Map<string, SshConnection>()
private callbacks: SshConnectionCallbacks
// Why: attempt identity lets disconnect unblock a replacement without the
// cancelled attempt later clearing the replacement's state.
private connectingTargets = new Map<string, symbol>()
constructor(callbacks: SshConnectionCallbacks) {
this.callbacks = callbacks
}
setCallbacks(callbacks: SshConnectionCallbacks): void {
this.callbacks = callbacks
for (const connection of this.connections.values()) {
connection.setCallbacks(callbacks)
}
}
async connect(target: SshTarget): Promise<SshConnection> {
const existing = this.connections.get(target.id)
if (existing?.getState().status === 'connected') {
return existing
}
if (this.connectingTargets.has(target.id)) {
throw new Error(`Connection to ${target.label} is already in progress`)
}
const attempt = Symbol(target.id)
this.connectingTargets.set(target.id, attempt)
try {
if (existing) {
await existing.disconnect()
}
const conn = new SshConnection(target, this.callbacks)
this.connections.set(target.id, conn)
try {
await conn.connect()
} catch (err) {
if (this.connections.get(target.id) === conn) {
this.connections.delete(target.id)
}
throw err
}
return conn
} finally {
if (this.connectingTargets.get(target.id) === attempt) {
this.connectingTargets.delete(target.id)
}
}
}
async disconnect(targetId: string): Promise<void> {
// Why: disconnect invalidates the old attempt immediately so a reconnect
// need not wait for the cancelled socket's late completion.
this.connectingTargets.delete(targetId)
const conn = this.connections.get(targetId)
if (!conn) {
return
}
await conn.disconnect()
if (this.connections.get(targetId) === conn) {
this.connections.delete(targetId)
}
}
async reconnect(targetId: string): Promise<void> {
const conn = this.connections.get(targetId)
if (!conn) {
return
}
await conn.reconnect()
}
getConnection(targetId: string): SshConnection | undefined {
return this.connections.get(targetId)
}
getState(targetId: string): SshConnectionState | null {
return this.connections.get(targetId)?.getState() ?? null
}
getAllStates(): Map<string, SshConnectionState> {
const states = new Map<string, SshConnectionState>()
for (const [id, conn] of this.connections) {
states.set(id, conn.getState())
}
return states
}
async disconnectAll(): Promise<void> {
const disconnects = Array.from(this.connections.values()).map((c) => c.disconnect())
await Promise.allSettled(disconnects)
this.connections.clear()
}
}