perf(ssh): cancel abandoned remote port scans (#13548)

This commit is contained in:
Neil
2026-08-10 20:49:21 -07:00
committed by GitHub
parent 5538584c74
commit 0acf04a985
4 changed files with 228 additions and 13 deletions
+34
View File
@@ -235,6 +235,40 @@ describe('PortScanner', () => {
expect(scanner.getDetectedPorts('t1')).toEqual([])
})
it('aborts in-flight requests before stopping or replacing a target scan', async () => {
const harness = createVisibilityHarness(true)
const signals: AbortSignal[] = []
const request = vi.fn(
(_method: string, _params?: Record<string, unknown>, options?: { signal?: AbortSignal }) =>
new Promise<never>((_resolve, reject) => {
const signal = options?.signal
if (!signal) {
reject(new Error('missing request signal'))
return
}
signals.push(signal)
signal.addEventListener('abort', () => reject(signal.reason), { once: true })
})
)
const mux = { request } as unknown as SshChannelMultiplexer
const scanner = new PortScanner(harness.visibility)
scanner.startScanning('t1', mux, vi.fn())
expect(signals).toHaveLength(1)
expect(signals[0].aborted).toBe(false)
scanner.startScanning('t1', mux, vi.fn())
expect(signals).toHaveLength(2)
expect(signals[0].aborted).toBe(true)
expect(signals[1].aborted).toBe(false)
scanner.stopScanning('t1')
expect(signals[1].aborted).toBe(true)
await vi.advanceTimersByTimeAsync(10 * BASE)
expect(request).toHaveBeenCalledTimes(2)
expect(harness.listenerCount()).toBe(0)
})
it('keeps targets independent: stopping one host leaves the other scanning', async () => {
const harness = createVisibilityHarness(true)
let nextA = 3000
+12 -1
View File
@@ -19,6 +19,7 @@ export type PortScannerWindowVisibility = {
type ScanHandle = {
timer: ReturnType<typeof setTimeout> | null
requestAbortController: AbortController | null
intervalMs: number
// Why: while the window is hidden the scan chain is parked outright — no
// timer wakeups, no remote requests — and the visibility listener resumes
@@ -48,6 +49,7 @@ export class PortScanner {
const handle: ScanHandle = {
timer: null,
requestAbortController: null,
intervalMs: SSH_PORT_SCAN_BASE_INTERVAL_MS,
parkedWhileHidden: false,
unsubscribeVisibility: () => {},
@@ -65,8 +67,12 @@ export class PortScanner {
return
}
polling = true
const requestAbortController = new AbortController()
handle.requestAbortController = requestAbortController
try {
const result = (await mux.request('ports.detect')) as {
const result = (await mux.request('ports.detect', undefined, {
signal: requestAbortController.signal
})) as {
ports: DetectedPort[]
platform: string
}
@@ -94,6 +100,9 @@ export class PortScanner {
} catch {
// Relay disconnected or request timed out — retry on next interval
} finally {
if (handle.requestAbortController === requestAbortController) {
handle.requestAbortController = null
}
polling = false
}
}
@@ -142,6 +151,8 @@ export class PortScanner {
if (handle.timer) {
clearTimeout(handle.timer)
}
handle.requestAbortController?.abort()
handle.requestAbortController = null
handle.unsubscribeVisibility()
this.handles.delete(targetId)
}
+150 -2
View File
@@ -1,7 +1,155 @@
import { describe, expect, it, vi } from 'vitest'
import { parseHexAddress } from './port-scan-handler'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { MethodHandler, RequestContext } from './dispatcher'
const { readFileMock, readdirMock, readlinkMock } = vi.hoisted(() => ({
readFileMock: vi.fn(),
readdirMock: vi.fn(),
readlinkMock: vi.fn()
}))
vi.mock('node:fs/promises', () => ({
readFile: readFileMock,
readdir: readdirMock,
readlink: readlinkMock
}))
import { parseHexAddress, PortScanHandler } from './port-scan-handler'
import { parseWindowsNetstatOutput, parseWindowsPowerShellPortRows } from './windows-port-scan'
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
})
afterEach(() => {
if (originalPlatformDescriptor) {
Object.defineProperty(process, 'platform', originalPlatformDescriptor)
}
})
function capturePortDetectHandler(): MethodHandler {
let handler: MethodHandler | undefined
new PortScanHandler({
onRequest: (method, nextHandler) => {
expect(method).toBe('ports.detect')
handler = nextHandler
}
})
if (!handler) {
throw new Error('ports.detect handler was not registered')
}
return handler
}
function requestContext(signal?: AbortSignal): RequestContext {
return { clientId: 1, isStale: () => signal?.aborted ?? false, signal }
}
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve = (_value: T): void => {
throw new Error('deferred promise was not initialized')
}
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve
})
return { promise, resolve }
}
function mockLinuxProcScan({
pidCount,
fdCount,
firstReadlink
}: {
pidCount: number
fdCount: number
firstReadlink?: Promise<string>
}): void {
const tcpHeader =
'sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode'
const tcpRow =
'0: 0100007F:0BB8 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 11111'
readFileMock.mockImplementation(async (path: string) => {
if (path === '/proc/net/tcp') {
return `${tcpHeader}\n${tcpRow}\n`
}
if (path === '/proc/net/tcp6') {
return `${tcpHeader}\n`
}
if (path.endsWith('/cmdline')) {
return '/usr/bin/node\0server.js'
}
throw new Error(`unexpected readFile: ${path}`)
})
const pids = Array.from({ length: pidCount }, (_, index) => String(1_000 + index))
const fds = Array.from({ length: fdCount }, (_, index) => String(index))
readdirMock.mockImplementation(async (path: string) => {
if (path === '/proc') {
return pids
}
if (path.endsWith('/fd')) {
return fds
}
throw new Error(`unexpected readdir: ${path}`)
})
let first = true
readlinkMock.mockImplementation(() => {
if (first && firstReadlink) {
first = false
return firstReadlink
}
first = false
return Promise.resolve('socket:[11111]')
})
}
describe('PortScanHandler Linux cancellation', () => {
it('does not touch procfs for an already-cancelled request', async () => {
const controller = new AbortController()
controller.abort()
await expect(
capturePortDetectHandler()({}, requestContext(controller.signal))
).rejects.toMatchObject({ name: 'AbortError' })
expect(readFileMock).not.toHaveBeenCalled()
expect(readdirMock).not.toHaveBeenCalled()
expect(readlinkMock).not.toHaveBeenCalled()
})
it('stops a large pid and fd walk at the filesystem operation already in flight', async () => {
const firstReadlink = createDeferred<string>()
mockLinuxProcScan({ pidCount: 1_000, fdCount: 100, firstReadlink: firstReadlink.promise })
const controller = new AbortController()
const scan = capturePortDetectHandler()({}, requestContext(controller.signal))
await vi.waitFor(() => expect(readlinkMock).toHaveBeenCalledTimes(1))
controller.abort()
firstReadlink.resolve('socket:[11111]')
await expect(scan).rejects.toMatchObject({ name: 'AbortError' })
expect(readdirMock).toHaveBeenCalledTimes(2)
expect(readdirMock).toHaveBeenNthCalledWith(1, '/proc')
expect(readdirMock).toHaveBeenNthCalledWith(2, '/proc/1000/fd')
expect(readlinkMock).toHaveBeenCalledTimes(1)
expect(readlinkMock).toHaveBeenCalledWith('/proc/1000/fd/0')
})
it('preserves detected port results when the request stays live', async () => {
mockLinuxProcScan({ pidCount: 1, fdCount: 1 })
await expect(
capturePortDetectHandler()({}, requestContext(new AbortController().signal))
).resolves.toEqual({
ports: [{ host: '127.0.0.1', port: 3000, pid: 1_000, processName: 'node' }],
platform: 'linux'
})
})
})
describe('parseHexAddress', () => {
it('parses IPv4 localhost (127.0.0.1)', () => {
// 127.0.0.1 in little-endian hex: 0100007F
+32 -10
View File
@@ -16,11 +16,11 @@ const SYSTEM_PORTS_TO_EXCLUDE = new Set([22])
const MAX_DETECTED_PORTS = 50
export class PortScanHandler {
constructor(dispatcher: RelayDispatcher) {
constructor(dispatcher: Pick<RelayDispatcher, 'onRequest'>) {
dispatcher.onRequest('ports.detect', async (_params, context: RequestContext) => {
if (process.platform === 'linux') {
return {
ports: await this.scanLinuxListeningPorts(),
ports: await this.scanLinuxListeningPorts(context.signal),
platform: process.platform
}
}
@@ -37,11 +37,13 @@ export class PortScanHandler {
})
}
private async scanLinuxListeningPorts(): Promise<DetectedPort[]> {
private async scanLinuxListeningPorts(signal?: AbortSignal): Promise<DetectedPort[]> {
signal?.throwIfAborted()
const [tcp4, tcp6] = await Promise.all([
this.readProcNet('/proc/net/tcp'),
this.readProcNet('/proc/net/tcp6')
this.readProcNet('/proc/net/tcp', signal),
this.readProcNet('/proc/net/tcp6', signal)
])
signal?.throwIfAborted()
const listeningSockets = [...tcp4, ...tcp6]
if (listeningSockets.length === 0) {
@@ -49,7 +51,7 @@ export class PortScanHandler {
}
const inodeSet = new Set(listeningSockets.map((s) => s.inode))
const inodeToPid = await this.mapInodesToPids(inodeSet)
const inodeToPid = await this.mapInodesToPids(inodeSet, signal)
const seen = new Set<string>()
const results: DetectedPort[] = []
@@ -57,6 +59,7 @@ export class PortScanHandler {
const relayParentPid = process.ppid
for (const socket of listeningSockets) {
signal?.throwIfAborted()
const key = `${socket.host}:${socket.port}`
if (seen.has(key)) {
continue
@@ -72,7 +75,7 @@ export class PortScanHandler {
continue
}
const processName = pid != null ? await this.getProcessName(pid) : undefined
const processName = pid != null ? await this.getProcessName(pid, signal) : undefined
if (processName === 'sshd') {
continue
@@ -93,14 +96,18 @@ export class PortScanHandler {
}
private async readProcNet(
path: string
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 }[] = []
@@ -133,7 +140,11 @@ export class PortScanHandler {
return results
}
private async mapInodesToPids(inodes: Set<number>): Promise<Map<number, number>> {
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
@@ -143,27 +154,35 @@ export class PortScanHandler {
try {
pids = (await readdir('/proc')).filter((name) => /^\d+$/.test(name))
} catch {
signal?.throwIfAborted()
return result
}
signal?.throwIfAborted()
for (const pidStr of pids) {
signal?.throwIfAborted()
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) {
@@ -180,9 +199,11 @@ export class PortScanHandler {
return result
}
private async getProcessName(pid: number): Promise<string | undefined> {
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
}
@@ -195,6 +216,7 @@ export class PortScanHandler {
const parts = exe.split('/')
return parts.at(-1)
} catch {
signal?.throwIfAborted()
return undefined
}
}