fix: time out workspace port commands (#3805)

This commit is contained in:
Neil 2026-05-30 10:57:27 -07:00 committed by GitHub
parent 8c2b119d3d
commit 45e85f96b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 86 additions and 10 deletions

View File

@ -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()
})
})

View File

@ -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<typeof execFile> | 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<string | undefined> {