Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 65 additions & 25 deletions scripts/lib/freePort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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' },
Expand All @@ -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() : '<unknown>'
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 '<unknown>'
return decoder.decode(ps.stdout).trim() || '<unknown>'
}

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 `:<port>` (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<number>()
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 '<unknown>'
const match = decoder.decode(tasklist.stdout).trim().match(/^"([^"]+)"/)
return match ? match[1] : '<unknown>'
}

function probePort(port: number): 'free' | 'busy' {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
Expand Down