fix: time out workspace port commands (#3805)
This commit is contained in:
parent
8c2b119d3d
commit
45e85f96b6
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue