diff --git a/src/main/ports/local-workspace-port-scanner.test.ts b/src/main/ports/local-workspace-port-scanner.test.ts index 7af49e946..c3292390d 100644 --- a/src/main/ports/local-workspace-port-scanner.test.ts +++ b/src/main/ports/local-workspace-port-scanner.test.ts @@ -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() diff --git a/src/main/ports/local-workspace-port-scanner.ts b/src/main/ports/local-workspace-port-scanner.ts index ebee8d0d3..31fe8ae34 100644 --- a/src/main/ports/local-workspace-port-scanner.ts +++ b/src/main/ports/local-workspace-port-scanner.ts @@ -31,15 +31,21 @@ type ProcessMetadata = { cwd?: string } +type NormalizedWorkspacePortProbe = { + worktree: WorkspacePortProbe + normalizedPath: string +} + export async function scanWorkspacePorts( worktrees: WorkspacePortProbe[], urlWatcher: Pick = advertisedUrlWatcher ): Promise { 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, 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, + 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): Promise> { const result = new Map() + 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 ): 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 ): void { const observationsByWorktree = new Map() 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(matches: T[]): T | undefined { - return matches.sort((a, b) => b.normalizedPath.length - a.normalizedPath.length)[0] +function pickDeepestMatching( + 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 {