diff --git a/scripts/lib/freePort.ts b/scripts/lib/freePort.ts index 6953e18e7..faeeee7fa 100644 --- a/scripts/lib/freePort.ts +++ b/scripts/lib/freePort.ts @@ -6,11 +6,16 @@ * (via `prompt`) whether to kill them and take over. Default answer is * "yes" — `Enter` accepts. Anything else cancels and exits 1. * - * macOS/Linux only — uses `lsof` and `ps`. The CMS isn't supported on - * Windows for local dev today, so this is intentionally Unix-only. + * Cross-platform: holder lookup shells out to `lsof` + `ps` on macOS/Linux + * and to `netstat` + `tasklist` on Windows. Probing, killing, and the + * wait/escalate loop use `Bun.serve`, signal-based `process.kill`, and + * `Bun.sleepSync`, which behave the same on every platform. */ +import { platform } from 'node:os' + const decoder = new TextDecoder() +const isWindows = platform() === 'win32' interface PortHolder { pid: number @@ -19,10 +24,13 @@ interface PortHolder { /** * Returns the PID(s) currently listening on `port` along with the - * holding process's `comm` name. Empty array when the port is free. + * holding process's name. Empty array when the port is free. */ function findPortHolders(port: number): PortHolder[] { - if (process.platform === 'win32') return [] + return isWindows ? findWindowsPortHolders(port) : findUnixPortHolders(port) +} + +function findUnixPortHolders(port: number): PortHolder[] { const lsof = Bun.spawnSync( ['lsof', '-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], { stdout: 'pipe', stderr: 'ignore' }, @@ -37,17 +45,51 @@ function findPortHolders(port: number): PortHolder[] { .map((s) => Number(s)) .filter((n) => Number.isInteger(n) && n > 0) - const holders: PortHolder[] = [] - for (const pid of pids) { - const ps = Bun.spawnSync(['ps', '-p', String(pid), '-o', 'comm='], { - stdout: 'pipe', - stderr: 'ignore', - }) - const command = - ps.exitCode === 0 ? decoder.decode(ps.stdout).trim() : '' - holders.push({ pid, command }) + return [...new Set(pids)].map((pid) => ({ pid, command: unixProcessName(pid) })) +} + +function unixProcessName(pid: number): string { + const ps = Bun.spawnSync(['ps', '-p', String(pid), '-o', 'comm='], { + stdout: 'pipe', + stderr: 'ignore', + }) + if (ps.exitCode !== 0) return '' + return decoder.decode(ps.stdout).trim() || '' +} + +function findWindowsPortHolders(port: number): PortHolder[] { + // `netstat -ano` prints one row per socket: Proto, Local Address, + // Foreign Address, State, PID. Match TCP rows in LISTENING state whose + // local address ends in `:` (covers 0.0.0.0, 127.0.0.1, [::], …). + const netstat = Bun.spawnSync(['netstat', '-ano'], { + stdout: 'pipe', + stderr: 'ignore', + }) + if (netstat.exitCode !== 0) return [] + + const pids = new Set() + for (const line of decoder.decode(netstat.stdout).split('\n')) { + const cols = line.trim().split(/\s+/) + if (cols.length < 5 || cols[0] !== 'TCP' || cols[3] !== 'LISTENING') continue + const local = cols[1] + const localPort = Number(local.slice(local.lastIndexOf(':') + 1)) + if (localPort !== port) continue + const pid = Number(cols[4]) + if (Number.isInteger(pid) && pid > 0) pids.add(pid) } - return holders + + return [...pids].map((pid) => ({ pid, command: windowsProcessName(pid) })) +} + +function windowsProcessName(pid: number): string { + // `tasklist` CSV row: "ImageName","PID","Session","Session#","MemUsage". + const tasklist = Bun.spawnSync( + ['tasklist', '/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], + { stdout: 'pipe', stderr: 'ignore' }, + ) + if (tasklist.exitCode !== 0) return '' + const match = decoder.decode(tasklist.stdout).trim().match(/^"([^"]+)"/) + return match ? match[1] : '' } function probePort(port: number): 'free' | 'busy' { @@ -73,7 +115,7 @@ function killHolder(holder: PortHolder, log: (msg: string) => void): boolean { } // Wait briefly for graceful exit, then escalate. for (let i = 0; i < 20; i++) { - Bun.spawnSync(['sleep', '0.1']) + Bun.sleepSync(100) try { process.kill(holder.pid, 0) } catch { @@ -108,16 +150,14 @@ export async function ensurePortFree( const holders = findPortHolders(port) if (holders.length === 0) { - if (process.platform === 'win32') { - // On Windows we can't enumerate holders easily; just wait a bit - // and re-probe in case a previous dev server is shutting down. - for (let i = 0; i < 30; i++) { - if (probePort(port) === 'free') return - Bun.spawnSync(['cmd', '/c', 'timeout', '/t', '1', '/nobreak', '>nul', '2>&1']) - } - } + // Port is busy but we couldn't enumerate the holder (rare — usually a + // permissions issue, or the lookup tool is unavailable). Fall through + // to the manual message. + const inspect = isWindows + ? `netstat -ano | findstr :${port}` + : `lsof -i :${port} -P -n` log(`Port ${port} (${name}) is in use, but the holding process could not be identified.`) - log(`Run \`lsof -i :${port} -P -n\` to inspect it manually.`) + log(`Run \`${inspect}\` to inspect it manually.`) process.exit(1) } @@ -146,7 +186,7 @@ export async function ensurePortFree( // Re-probe to confirm the OS has released the socket. for (let i = 0; i < 20; i++) { if (probePort(port) === 'free') return - Bun.spawnSync(['sleep', '0.1']) + Bun.sleepSync(100) } log(`Port ${port} is still busy after killing its holders. Aborting.`) process.exit(1)