Skip to content

Instantly share code, notes, and snippets.

@nicholaswmin
Created March 5, 2026 05:48
Show Gist options
  • Select an option

  • Save nicholaswmin/05d5bf0d1de802352e2084c42a72aefe to your computer and use it in GitHub Desktop.

Select an option

Save nicholaswmin/05d5bf0d1de802352e2084c42a72aefe to your computer and use it in GitHub Desktop.
Free up a TCP port by killing all processes listening on it, properly
/**
* Free-up a TCP port by killing all processes listening on it.
*
* - Sends SIGTERM, polls until dead or timeout
* - Escalates to SIGKILL on timeout
* - Returns array of killed PIDs, empty if none
*
* await unbind(3000) // => [] or [pid, ...]
* await unbind(3000, { timeout: 5000 }) // longer grace period
* await unbind(3000, { // slower polling
* poll: { interval: 100 }
* })
*/
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { setTimeout as wait } from 'node:timers/promises'
export const unbind = (port, {
timeout = 3000,
poll: { interval = 50 } = {}
} = {}) =>
Promise.resolve(port)
.then(port => Number.isFinite(port) && port >= 1 && port <= 65535
? port
: Promise.reject(new RangeError(`port: ${port}, must be 1 - 65535`)))
.then(port => promisify(execFile)('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN']))
.then(({ stdout }) => stdout.trim().split('\n').map(Number).filter(Boolean))
.then(pids => pids.map(pid => (process.kill(pid, 'SIGTERM'), pid)))
.then(function poll(pids, start = Date.now()) {
const live = pids.filter(pid => {
try { return process.kill(pid, 0), true }
catch (err) {
if (err.code === 'ESRCH') return false
throw err
}
})
return !live.length ? pids :
Date.now() - start >= timeout
? (live.forEach(pid => process.kill(pid, 'SIGKILL')), pids)
: wait(Math.max(10, interval)).then(() => poll(pids, start))
})
.catch(err => err.code === 1 ? [] : Promise.reject(err))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment