Files
orca/src/relay/port-scan-handler.ts
T
Neil 9e9b80cb37 perf(relay): stop two unbounded growth terms behind the long-session SSH slowdown (#17818)
Two costs grew for the life of an SSH session and never came back down.

1. The relay port scan walked every process in /proc and readlink'd every fd
   even after every listening socket already had an owner. Cost was
   O(host processes x fds) per scan, repeating for the session's life. Exit as
   soon as every inode is attributed.

2. SshPtyModelAdmission kept closed provider generations in a Set<number>.
   Provider generations are a process-global monotonic counter shared by every
   SSH target, so the set gained one entry per relay reconnect forever. After
   500k reconnects main retains ~10,234 KB / 500,000 entries; with this change,
   18 KB / 1 range.

Closed generations now live in SshPtyClosedGenerationRanges, which collapses
contiguous closed runs. Membership stays exact -- a generation below the
high-water mark can still be live on another host, so a high-water
approximation would reject a healthy target's output.

The range container's has()/add() were a linear scan; both are now binary
search. has() is on the per-output-chunk admission path, so a scan would have
traded a bounded Set lookup for one that degrades with fragmentation. This also
speeds up ssh-pty-output-generation-guard.ts, which already uses this container
on main.

Known limitation, deliberately not addressed here: the closed-generation set is
bounded in the healthy case (one range) but unbounded when generations leak,
since each leaked generation leaves a permanent gap. Sublinear is not bounded. A
live-generation set would be bounded by construction and is the better
long-term design; that is a follow-up.
2026-09-02 21:33:34 -07:00

284 lines
7.7 KiB
TypeScript

import { readFile, readdir, readlink } from 'node:fs/promises'
import { getProcessOutputFields } from '../shared/process-output-field-scanner'
import type { RelayDispatcher, RequestContext } from './dispatcher'
import { scanWindowsListeningPorts } from './windows-port-scan'
// Keep in sync with src/shared/ssh-types.ts — DetectedPort
export type DetectedPort = {
port: number
host: string
pid?: number
processName?: string
}
const SYSTEM_PORTS_TO_EXCLUDE = new Set([22])
const MAX_DETECTED_PORTS = 50
export class PortScanHandler {
constructor(dispatcher: Pick<RelayDispatcher, 'onRequest'>) {
dispatcher.onRequest('ports.detect', async (_params, context: RequestContext) => {
if (process.platform === 'linux') {
return {
ports: await this.scanLinuxListeningPorts(context.signal),
platform: process.platform
}
}
if (process.platform === 'win32') {
return {
ports: await scanWindowsListeningPorts(context.signal),
platform: process.platform
}
}
return {
ports: [],
platform: process.platform
}
})
}
private async scanLinuxListeningPorts(signal?: AbortSignal): Promise<DetectedPort[]> {
signal?.throwIfAborted()
const [tcp4, tcp6] = await Promise.all([
this.readProcNet('/proc/net/tcp', signal),
this.readProcNet('/proc/net/tcp6', signal)
])
signal?.throwIfAborted()
const listeningSockets = [...tcp4, ...tcp6]
if (listeningSockets.length === 0) {
return []
}
const inodeSet = new Set(listeningSockets.map((s) => s.inode))
const inodeToPid = await this.mapInodesToPids(inodeSet, signal)
const seen = new Set<string>()
const results: DetectedPort[] = []
const relayPid = process.pid
const relayParentPid = process.ppid
for (const socket of listeningSockets) {
signal?.throwIfAborted()
const key = `${socket.host}:${socket.port}`
if (seen.has(key)) {
continue
}
seen.add(key)
if (SYSTEM_PORTS_TO_EXCLUDE.has(socket.port)) {
continue
}
const pid = inodeToPid.get(socket.inode)
if (pid === relayPid || pid === relayParentPid) {
continue
}
const processName = pid != null ? await this.getProcessName(pid, signal) : undefined
if (processName === 'sshd') {
continue
}
results.push({
port: socket.port,
host: socket.host,
pid: pid ?? undefined,
processName
})
}
// Why: sort before capping so the visible set is deterministic (lowest
// port numbers first) regardless of /proc enumeration order.
results.sort((a, b) => a.port - b.port)
return results.slice(0, MAX_DETECTED_PORTS)
}
private async readProcNet(
path: string,
signal?: AbortSignal
): Promise<{ port: number; host: string; inode: number }[]> {
signal?.throwIfAborted()
let content: string
try {
content = await readFile(path, 'utf-8')
} catch {
signal?.throwIfAborted()
return []
}
signal?.throwIfAborted()
const lines = content.split('\n')
const results: { port: number; host: string; inode: number }[] = []
for (let i = 1; i < lines.length; i++) {
const fields = getProcessOutputFields(lines[i], 10)
if (fields.length < 10) {
continue
}
// State field (index 3): 0A = TCP_LISTEN
if (fields[3] !== '0A') {
continue
}
const localAddress = fields[1]
const parsed = parseHexAddress(localAddress)
if (!parsed) {
continue
}
const inode = Number.parseInt(fields[9], 10)
if (Number.isNaN(inode) || inode === 0) {
continue
}
results.push({ port: parsed.port, host: parsed.host, inode })
}
return results
}
private async mapInodesToPids(
inodes: Set<number>,
signal?: AbortSignal
): Promise<Map<number, number>> {
signal?.throwIfAborted()
const result = new Map<number, number>()
if (inodes.size === 0) {
return result
}
let pids: string[]
try {
pids = (await readdir('/proc')).filter((name) => /^\d+$/.test(name))
} catch {
signal?.throwIfAborted()
return result
}
signal?.throwIfAborted()
for (const pidStr of pids) {
signal?.throwIfAborted()
// Why: every remaining pid costs a readdir plus one readlink per fd, and this scan repeats for
// the life of the session. Without this the walk was O(all host processes x all fds) even once
// every listener was already attributed, so its cost grew with the remote's process count and
// never came back down — the shape behind "SSH gets slower the longer Orca stays open".
if (result.size === inodes.size) {
return result
}
const fdDir = `/proc/${pidStr}/fd`
let fds: string[]
try {
fds = await readdir(fdDir)
} catch {
signal?.throwIfAborted()
continue
}
signal?.throwIfAborted()
const pid = Number.parseInt(pidStr, 10)
for (const fd of fds) {
signal?.throwIfAborted()
let link: string
try {
link = await readlink(`${fdDir}/${fd}`)
} catch {
signal?.throwIfAborted()
continue
}
signal?.throwIfAborted()
const match = link.match(/^socket:\[(\d+)\]$/)
if (!match) {
continue
}
const inode = Number.parseInt(match[1], 10)
if (inodes.has(inode)) {
result.set(inode, pid)
if (result.size === inodes.size) {
return result
}
}
}
}
return result
}
private async getProcessName(pid: number, signal?: AbortSignal): Promise<string | undefined> {
signal?.throwIfAborted()
try {
const cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf-8')
signal?.throwIfAborted()
if (!cmdline) {
return undefined
}
const exe = cmdline.split('\0')[0]
if (!exe) {
return undefined
}
const parts = exe.split('/')
return parts.at(-1)
} catch {
signal?.throwIfAborted()
return undefined
}
}
}
// Why: /proc/net/tcp encodes addresses as hex pairs in host-byte-order.
// IPv4: 8 hex chars for address + ':' + 4 hex chars for port.
// IPv6: 32 hex chars for address + ':' + 4 hex chars for port.
export function parseHexAddress(hexAddr: string): { host: string; port: number } | null {
const parts = hexAddr.split(':')
if (parts.length !== 2) {
return null
}
const port = Number.parseInt(parts[1], 16)
if (Number.isNaN(port) || port === 0) {
return null
}
const addrHex = parts[0]
if (addrHex.length === 8) {
const b1 = Number.parseInt(addrHex.substring(6, 8), 16)
const b2 = Number.parseInt(addrHex.substring(4, 6), 16)
const b3 = Number.parseInt(addrHex.substring(2, 4), 16)
const b4 = Number.parseInt(addrHex.substring(0, 2), 16)
const host = `${b1}.${b2}.${b3}.${b4}`
return { host, port }
}
if (addrHex.length === 32) {
if (addrHex === '00000000000000000000000000000000') {
return { host: '::', port }
}
if (addrHex === '00000000000000000000000001000000') {
return { host: '::1', port }
}
return { host: formatIPv6(addrHex), port }
}
return null
}
function formatIPv6(hex: string): string {
const groups: string[] = []
for (let i = 0; i < 32; i += 8) {
const chunk = hex.substring(i, i + 8)
const reversed =
chunk.substring(6, 8) + chunk.substring(4, 6) + chunk.substring(2, 4) + chunk.substring(0, 2)
groups.push(reversed.substring(0, 4))
groups.push(reversed.substring(4, 8))
}
return groups.map((g) => g.replace(/^0+/, '') || '0').join(':')
}