perf: reuse port scan worktree attribution (#4274)
This commit is contained in:
parent
0cf4f6010e
commit
57caa94116
|
|
@ -1,4 +1,5 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import path from 'path'
|
||||
import {
|
||||
attributePortToWorkspace,
|
||||
isContainerProcess,
|
||||
|
|
@ -142,6 +143,62 @@ describe('container process classification', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('scanWorkspacePorts attribution work', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
execFileMock.mockReset()
|
||||
})
|
||||
|
||||
it('normalizes worktree paths once per scan instead of once per port phase', async () => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
const resolveSpy = vi.spyOn(path, 'resolve')
|
||||
const invokeCallback = (callback: unknown, stdout: string): void => {
|
||||
if (typeof callback !== 'function') {
|
||||
throw new Error('missing execFile callback')
|
||||
}
|
||||
const execCallback = callback as (error: Error | null, stdout: string) => void
|
||||
execCallback(null, stdout)
|
||||
}
|
||||
execFileMock.mockImplementation(
|
||||
(command: string, args: string[], _options: unknown, callback: unknown) => {
|
||||
if (command === 'lsof' && args.includes('-iTCP')) {
|
||||
invokeCallback(
|
||||
callback,
|
||||
['p123', 'cnode', 'n127.0.0.1:3000', 'p124', 'cnode', 'n127.0.0.1:3001'].join('\n')
|
||||
)
|
||||
} else if (command === 'lsof') {
|
||||
invokeCallback(
|
||||
callback,
|
||||
['p123', 'n/repo/service', 'p124', 'n/repo/worktrees/feature/app'].join('\n')
|
||||
)
|
||||
} else if (command === 'ps') {
|
||||
invokeCallback(
|
||||
callback,
|
||||
[
|
||||
'123 node /repo/service/server.js',
|
||||
'124 node /repo/worktrees/feature/app/server.js'
|
||||
].join('\n')
|
||||
)
|
||||
} else {
|
||||
invokeCallback(callback, '')
|
||||
}
|
||||
return { kill: vi.fn() }
|
||||
}
|
||||
)
|
||||
|
||||
const scan = await scanWorkspacePorts(worktrees, {
|
||||
lookup: () => undefined,
|
||||
reconcileScan: vi.fn()
|
||||
})
|
||||
|
||||
expect(scan.ports.filter((port) => port.kind === 'workspace')).toHaveLength(2)
|
||||
const worktreePathResolveCalls = resolveSpy.mock.calls.filter(
|
||||
([input]) => input === '/repo' || input === '/repo/worktrees/feature'
|
||||
)
|
||||
expect(worktreePathResolveCalls).toHaveLength(worktrees.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scanWorkspacePorts command timeout', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
|
|
|
|||
|
|
@ -31,15 +31,21 @@ type ProcessMetadata = {
|
|||
cwd?: string
|
||||
}
|
||||
|
||||
type NormalizedWorkspacePortProbe = {
|
||||
worktree: WorkspacePortProbe
|
||||
normalizedPath: string
|
||||
}
|
||||
|
||||
export async function scanWorkspacePorts(
|
||||
worktrees: WorkspacePortProbe[],
|
||||
urlWatcher: Pick<AdvertisedUrlWatcher, 'lookup' | 'reconcileScan'> = advertisedUrlWatcher
|
||||
): Promise<WorkspacePortScanResult> {
|
||||
try {
|
||||
const rawPorts = await scanPlatformListeningPorts()
|
||||
reconcileAdvertisedUrls(rawPorts, worktrees, urlWatcher)
|
||||
const normalizedWorktrees = normalizeWorkspacePortProbes(worktrees)
|
||||
reconcileAdvertisedUrls(rawPorts, normalizedWorktrees, urlWatcher)
|
||||
const ports = rawPorts
|
||||
.map((port) => enrichPort(port, worktrees, urlWatcher))
|
||||
.map((port) => enrichPort(port, normalizedWorktrees, urlWatcher))
|
||||
.sort(compareWorkspacePorts)
|
||||
.slice(0, MAX_PORTS)
|
||||
return { platform: process.platform, scannedAt: Date.now(), ports }
|
||||
|
|
@ -57,17 +63,31 @@ export async function scanWorkspacePorts(
|
|||
export function attributePortToWorkspace(
|
||||
port: Pick<RawListeningPort, 'cwd' | 'commandLine'>,
|
||||
worktrees: WorkspacePortProbe[]
|
||||
): WorkspacePortOwner | undefined {
|
||||
return attributePortToNormalizedWorkspaces(port, normalizeWorkspacePortProbes(worktrees))
|
||||
}
|
||||
|
||||
function normalizeWorkspacePortProbes(
|
||||
worktrees: readonly WorkspacePortProbe[]
|
||||
): NormalizedWorkspacePortProbe[] {
|
||||
return worktrees.map((worktree) => ({
|
||||
worktree,
|
||||
normalizedPath: normalizeComparablePath(worktree.path)
|
||||
}))
|
||||
}
|
||||
|
||||
function attributePortToNormalizedWorkspaces(
|
||||
port: Pick<RawListeningPort, 'cwd' | 'commandLine'>,
|
||||
worktrees: readonly NormalizedWorkspacePortProbe[]
|
||||
): WorkspacePortOwner | undefined {
|
||||
const cwd = port.cwd ? normalizeComparablePath(port.cwd) : null
|
||||
const commandLine = port.commandLine ? normalizeComparableText(port.commandLine) : null
|
||||
|
||||
const cwdMatches = cwd
|
||||
? worktrees
|
||||
.map((worktree) => ({ worktree, normalizedPath: normalizeComparablePath(worktree.path) }))
|
||||
.filter(({ normalizedPath }) => isSameOrDescendant(cwd, normalizedPath))
|
||||
: []
|
||||
|
||||
const cwdMatch = pickDeepestMatch(cwdMatches)
|
||||
const cwdMatch = cwd
|
||||
? pickDeepestMatching(worktrees, ({ normalizedPath }) =>
|
||||
isSameOrDescendant(cwd, normalizedPath)
|
||||
)
|
||||
: undefined
|
||||
if (cwdMatch) {
|
||||
return toOwner(cwdMatch.worktree, 'cwd')
|
||||
}
|
||||
|
|
@ -76,10 +96,9 @@ export function attributePortToWorkspace(
|
|||
return undefined
|
||||
}
|
||||
|
||||
const commandMatches = worktrees
|
||||
.map((worktree) => ({ worktree, normalizedPath: normalizeComparablePath(worktree.path) }))
|
||||
.filter(({ normalizedPath }) => includesPathBoundary(commandLine, normalizedPath))
|
||||
const commandMatch = pickDeepestMatch(commandMatches)
|
||||
const commandMatch = pickDeepestMatching(worktrees, ({ normalizedPath }) =>
|
||||
includesPathBoundary(commandLine, normalizedPath)
|
||||
)
|
||||
return commandMatch ? toOwner(commandMatch.worktree, 'command') : undefined
|
||||
}
|
||||
|
||||
|
|
@ -220,6 +239,9 @@ async function readProcNet(
|
|||
|
||||
async function mapLinuxInodesToPids(inodes: Set<number>): Promise<Map<number, number>> {
|
||||
const result = new Map<number, number>()
|
||||
if (inodes.size === 0) {
|
||||
return result
|
||||
}
|
||||
let pids: string[]
|
||||
try {
|
||||
pids = (await readdir('/proc')).filter((entry) => /^\d+$/.test(entry))
|
||||
|
|
@ -393,10 +415,10 @@ async function readTextIfAvailable(filePath: string): Promise<string | undefined
|
|||
|
||||
function enrichPort(
|
||||
port: RawListeningPort,
|
||||
worktrees: WorkspacePortProbe[],
|
||||
worktrees: readonly NormalizedWorkspacePortProbe[],
|
||||
urlWatcher: Pick<AdvertisedUrlWatcher, 'lookup'>
|
||||
): WorkspacePort {
|
||||
const owner = attributePortToWorkspace(port, worktrees)
|
||||
const owner = attributePortToNormalizedWorkspaces(port, worktrees)
|
||||
const base = {
|
||||
id: `${port.host}:${port.port}:${port.pid ?? 'unknown'}`,
|
||||
bindHost: port.host,
|
||||
|
|
@ -428,15 +450,15 @@ function enrichPort(
|
|||
|
||||
function reconcileAdvertisedUrls(
|
||||
ports: RawListeningPort[],
|
||||
worktrees: WorkspacePortProbe[],
|
||||
worktrees: readonly NormalizedWorkspacePortProbe[],
|
||||
urlWatcher: Pick<AdvertisedUrlWatcher, 'reconcileScan'>
|
||||
): void {
|
||||
const observationsByWorktree = new Map<string, { port: number; pid?: number }[]>()
|
||||
for (const worktree of worktrees) {
|
||||
observationsByWorktree.set(worktree.id, [])
|
||||
observationsByWorktree.set(worktree.worktree.id, [])
|
||||
}
|
||||
for (const port of ports) {
|
||||
const owner = attributePortToWorkspace(port, worktrees)
|
||||
const owner = attributePortToNormalizedWorkspaces(port, worktrees)
|
||||
if (!owner) {
|
||||
continue
|
||||
}
|
||||
|
|
@ -485,8 +507,20 @@ function toOwner(
|
|||
}
|
||||
}
|
||||
|
||||
function pickDeepestMatch<T extends { normalizedPath: string }>(matches: T[]): T | undefined {
|
||||
return matches.sort((a, b) => b.normalizedPath.length - a.normalizedPath.length)[0]
|
||||
function pickDeepestMatching<T extends { normalizedPath: string }>(
|
||||
candidates: readonly T[],
|
||||
predicate: (candidate: T) => boolean
|
||||
): T | undefined {
|
||||
let best: T | undefined
|
||||
for (const candidate of candidates) {
|
||||
if (!predicate(candidate)) {
|
||||
continue
|
||||
}
|
||||
if (!best || candidate.normalizedPath.length > best.normalizedPath.length) {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
function isSameOrDescendant(candidate: string, parent: string): boolean {
|
||||
|
|
|
|||
Loading…
Reference in New Issue