From 45e85f96b6eceff1c7f84d876d41c55aeff4e3a3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 10:57:27 -0700 Subject: [PATCH] fix: time out workspace port commands (#3805) --- .../local-workspace-port-scanner.test.ts | 45 +++++++++++++++- .../ports/local-workspace-port-scanner.ts | 51 ++++++++++++++++--- 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/src/main/ports/local-workspace-port-scanner.test.ts b/src/main/ports/local-workspace-port-scanner.test.ts index be21fa7bc..7af49e946 100644 --- a/src/main/ports/local-workspace-port-scanner.test.ts +++ b/src/main/ports/local-workspace-port-scanner.test.ts @@ -1,12 +1,19 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { attributePortToWorkspace, isContainerProcess, parseLsofListeningOutput, parseNetstatListeningOutput, - parseProcNetTcp + parseProcNetTcp, + scanWorkspacePorts } from './local-workspace-port-scanner' +const execFileMock = vi.hoisted(() => vi.fn()) + +vi.mock('child_process', () => ({ + execFile: execFileMock +})) + const worktrees = [ { id: 'repo::/repo', @@ -134,3 +141,37 @@ describe('container process classification', () => { expect(isContainerProcess({ processName: 'node', commandLine: 'node server.js' })).toBe(false) }) }) + +describe('scanWorkspacePorts command timeout', () => { + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + execFileMock.mockReset() + }) + + it('returns an unavailable scan when lsof never reports completion', async () => { + vi.useFakeTimers() + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const killMock = vi.fn() + execFileMock.mockImplementation(() => ({ kill: killMock })) + + let settled = false + const scanPromise = scanWorkspacePorts([], { + lookup: () => undefined, + reconcileScan: vi.fn() + }).then((scan) => { + settled = true + return scan + }) + + await vi.advanceTimersByTimeAsync(4_000) + + expect(settled).toBe(true) + await expect(scanPromise).resolves.toMatchObject({ + platform: 'darwin', + ports: [], + unavailableReason: 'Port scanning is unavailable on darwin.' + }) + expect(killMock).toHaveBeenCalled() + }) +}) diff --git a/src/main/ports/local-workspace-port-scanner.ts b/src/main/ports/local-workspace-port-scanner.ts index 8691b31af..ebee8d0d3 100644 --- a/src/main/ports/local-workspace-port-scanner.ts +++ b/src/main/ports/local-workspace-port-scanner.ts @@ -1,7 +1,6 @@ /* eslint-disable max-lines -- Why: the platform-specific scan paths share parsing, attribution, and normalization rules that must stay in lockstep. */ import { execFile } from 'child_process' -import { promisify } from 'util' import { readFile, readdir, readlink } from 'fs/promises' import path from 'path' import type { @@ -12,8 +11,6 @@ import type { } from '../../shared/workspace-ports' import { advertisedUrlWatcher, type AdvertisedUrlWatcher } from './advertised-url-watcher' -const execFileAsync = promisify(execFile) - const COMMAND_TIMEOUT_MS = 4_000 const MAX_PORTS = 200 const HTTP_PORTS = new Set([80, 3000, 3001, 4200, 5000, 5173, 5174, 8000, 8080, 8888]) @@ -340,12 +337,50 @@ async function loadWindowsProcessMetadata( } async function runCommand(command: string, args: string[]): Promise<{ stdout: string }> { - const { stdout } = await execFileAsync(command, args, { - timeout: COMMAND_TIMEOUT_MS, - maxBuffer: 2 * 1024 * 1024, - windowsHide: true + return await new Promise((resolve, reject) => { + let settled = false + let child: ReturnType | undefined + const timer = setTimeout(() => { + if (settled) { + return + } + settled = true + child?.kill() + reject(new Error(`${command} timed out after ${COMMAND_TIMEOUT_MS}ms`)) + }, COMMAND_TIMEOUT_MS) + + const settle = (callback: () => void): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + callback() + } + + // Why: Node's execFile timeout only signals the child; if the callback + // never arrives, the workspace port scan would otherwise hang forever. + try { + child = execFile( + command, + args, + { + timeout: COMMAND_TIMEOUT_MS, + maxBuffer: 2 * 1024 * 1024, + windowsHide: true + }, + (error, stdout) => { + if (error) { + settle(() => reject(error)) + return + } + settle(() => resolve({ stdout: String(stdout) })) + } + ) + } catch (error) { + settle(() => reject(error)) + } }) - return { stdout: String(stdout) } } async function readTextIfAvailable(filePath: string): Promise {