Skip to content

Instantly share code, notes, and snippets.

@diegomarino
Last active June 9, 2026 17:58
Show Gist options
  • Select an option

  • Save diegomarino/f8b247fa1a75b4e5544ee70082f4eea5 to your computer and use it in GitHub Desktop.

Select an option

Save diegomarino/f8b247fa1a75b4e5544ee70082f4eea5 to your computer and use it in GitHub Desktop.
grab-port — find the first available TCP port starting from a preferred one
#!/usr/bin/env bash
# =============================================================================
# grab-port — find the first available TCP port starting from a preferred one
# =============================================================================
#
# DESCRIPTION
# Accepts a preferred port (defaults to 3000) and prints to stdout the first
# free port found starting from that number. If the preferred port is in use,
# it increments by one until a free port is found.
#
# For Node.js projects, prefer get-port-cli as a devDependency instead:
# npm install --save-dev get-port-cli
# # package.json scripts: "dev": "vite --port $(get-port 3000)"
# This shell function is intended for projects that do not use npm or where
# adding a Node.js dependency is not desirable.
#
# USAGE
# grab-port [start_port] [options]
#
# ARGUMENTS
# start_port First TCP port to try. Default: 3000.
# Must be an integer in the range 1–65535.
# Ports >= 1024 are recommended to avoid root requirements.
#
# OPTIONS
# --max-port N Highest port to try before giving up. Default: 65535.
# Must be >= start_port.
# --host HOST Host/interface used for port checks. Default: 127.0.0.1.
# Use 0.0.0.0 if your server binds to all interfaces.
# --quiet, -q Suppress all informational messages on stderr.
# Only the port number is printed to stdout.
# --no-double-check
# Skip the second verification pass. Slightly faster but
# offers less protection against race conditions.
# --self-check Report available detector methods and exit (code 0).
# Useful for debugging environments or CI setup validation.
# --help, -h Show this help and exit.
#
# OUTPUT
# stdout The free port number found (e.g. "4173").
# stderr Informational and error messages. Suppress with --quiet.
# exit code 0 A free port was found (or --self-check ran successfully).
# exit code 1 No free port found in the given range.
# exit code 2 Invalid argument or runtime/detector failure.
#
# TYPICAL USAGE (command substitution)
# grab-port # → 3000 or first free from there
# grab-port 4173 # → 4173 or first free from there
# grab-port 4173 --max-port 4300 # bounded search
# grab-port --host 0.0.0.0 # check on all interfaces
# vite preview --port $(grab-port 4173)
# next dev --port $(grab-port 3000)
# PORT=$(grab-port 8080) && echo "Server on :$PORT"
# vite preview --port $(grab-port 4173 --quiet) # suppress diagnostic output
#
# COMPATIBILITY
# Detector methods are discovered automatically, in order of preference:
# 1. lsof — native on macOS; available on most Linux with lsof installed
# 2. ss — modern Linux (iproute2); faster replacement for netstat
# 3. netstat — legacy; available on older systems
# 4. nc — netcat; widely available on macOS and Linux
# 5. bash /dev/tcp — pure bash fallback; no external dependencies
#
# If a detector fails for a given probe, the next one is tried automatically.
# Tested on: macOS 13+, Ubuntu 20.04+, Debian 11+, Alpine 3.18+
#
# INSTALLATION
# Source this file from your .zshrc or .bashrc:
# source /path/to/grab-port.sh
#
# Or copy the function body directly into your shell configuration file.
# The script also supports direct execution (not just sourcing):
# bash grab-port.sh 4173
#
# NOTE ON RACE CONDITIONS
# There is always a small window between detecting a free port and binding to
# it. The function performs an optional second check after a short pause to
# reduce (not eliminate) this risk. Use --no-double-check to skip it.
#
# =============================================================================
grab-port() {
# ---------------------------------------------------------------------------
# Internal constants
# ---------------------------------------------------------------------------
local DEFAULT_START_PORT=3000
local DEFAULT_MAX_PORT=65535
local MAX_RESERVED_PORT=1023 # Ports <= 1023 require root on Linux
local DOUBLE_CHECK_DELAY="0.05" # Seconds between 1st and 2nd check
# ---------------------------------------------------------------------------
# Parse state
# ---------------------------------------------------------------------------
local start_port="$DEFAULT_START_PORT"
local max_port="$DEFAULT_MAX_PORT"
local host="127.0.0.1"
local quiet=0
local no_double_check=0
local self_check=0
local selected_method=""
local -a methods=()
# Optional timeout wrapper to prevent detectors from hanging.
# Wraps each probe call when timeout or gtimeout is available.
local -a timeout_cmd=()
if command -v timeout >/dev/null 2>&1; then
timeout_cmd=(timeout 1)
elif command -v gtimeout >/dev/null 2>&1; then
timeout_cmd=(gtimeout 1)
fi
# ---------------------------------------------------------------------------
# Utility helpers
# ---------------------------------------------------------------------------
# Returns 0 if argument is a valid TCP port integer (1–65535).
_fp_is_valid_port() {
local n="$1"
[[ "$n" =~ ^[0-9]+$ ]] && (( n >= 1 && n <= 65535 ))
}
# Informational message to stderr (suppressed by --quiet).
_fp_log_info() {
(( quiet )) || echo "[grab-port] $1" >&2
}
# Error message to stderr (always shown).
_fp_log_error() {
echo "[grab-port] ERROR: $1" >&2
}
_fp_print_help() {
cat <<'USAGE'
Usage: grab-port [start_port] [options]
Find the first free TCP port starting from start_port (default: 3000).
Arguments:
start_port First port to try (1–65535). Default: 3000.
Options:
--max-port N Highest port to try. Default: 65535.
--host HOST Host/interface to check (default: 127.0.0.1).
-q, --quiet Suppress informational messages on stderr.
--no-double-check Skip the second verification pass.
--self-check Show available detectors and exit.
-h, --help Show this help and exit.
Examples:
grab-port
grab-port 4173
grab-port 4173 --max-port 4300
grab-port --host 0.0.0.0
grab-port --self-check
vite preview --port $(grab-port 4173 --quiet)
USAGE
}
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
--help|-h)
_fp_print_help
return 0
;;
--quiet|-q)
quiet=1
shift
;;
--no-double-check)
no_double_check=1
shift
;;
--self-check)
self_check=1
shift
;;
--host)
if [[ -z "$2" || "$2" == --* ]]; then
_fp_log_error "--host requires a non-empty value"
return 2
fi
host="$2"
shift 2
;;
--host=*)
host="${1#*=}"
if [[ -z "$host" ]]; then
_fp_log_error "--host requires a non-empty value"
return 2
fi
shift
;;
--max-port)
if [[ -z "$2" || "$2" == --* ]]; then
_fp_log_error "--max-port requires an integer argument"
return 2
fi
if ! _fp_is_valid_port "$2"; then
_fp_log_error "--max-port '$2' is not a valid port (1–65535)"
return 2
fi
max_port="$2"
shift 2
;;
--max-port=*)
max_port="${1#*=}"
if ! _fp_is_valid_port "$max_port"; then
_fp_log_error "--max-port '$max_port' is not a valid port (1–65535)"
return 2
fi
shift
;;
--)
shift
break
;;
--*)
_fp_log_error "unknown option '$1'. Use --help for usage."
return 2
;;
*)
# Positional argument: starting port
if ! _fp_is_valid_port "$1"; then
_fp_log_error "invalid port '$1' (must be an integer 1–65535)"
return 2
fi
start_port="$1"
shift
;;
esac
done
# ---------------------------------------------------------------------------
# Argument validation
# ---------------------------------------------------------------------------
if (( max_port < start_port )); then
_fp_log_error "--max-port ($max_port) cannot be less than start port ($start_port)"
return 2
fi
if (( start_port <= MAX_RESERVED_PORT )); then
_fp_log_info "WARNING: port $start_port is reserved (<= $MAX_RESERVED_PORT) and may require root privileges"
fi
# ---------------------------------------------------------------------------
# Detector discovery (order matters: fastest/most reliable first)
# ---------------------------------------------------------------------------
methods=()
command -v lsof >/dev/null 2>&1 && methods+=(lsof)
command -v ss >/dev/null 2>&1 && methods+=(ss)
command -v netstat >/dev/null 2>&1 && methods+=(netstat)
command -v nc >/dev/null 2>&1 && methods+=(nc)
[[ -n "$BASH_VERSION" ]] && methods+=(bash_tcp)
if (( ${#methods[@]} == 0 )); then
_fp_log_error "no compatible port detector found"
echo "[grab-port] Install one of: lsof, ss, netstat, nc (or run under bash for /dev/tcp fallback)" >&2
return 2
fi
# ---------------------------------------------------------------------------
# Self-check mode: report environment and exit
# ---------------------------------------------------------------------------
if (( self_check == 1 )); then
local timeout_label="none"
(( ${#timeout_cmd[@]} > 0 )) && timeout_label="${timeout_cmd[*]}"
echo "[grab-port] Available detectors : ${methods[*]}"
echo "[grab-port] Default detector : ${methods[0]}"
echo "[grab-port] Timeout wrapper : $timeout_label"
echo "[grab-port] Host : $host"
echo "[grab-port] Double-check : $(( ! no_double_check ))"
return 0
fi
_fp_log_info "searching ports $start_port$max_port on $host (detector: ${methods[0]})"
# ---------------------------------------------------------------------------
# Detector implementations
#
# Contract:
# return 0 → port is IN USE
# return 1 → port is FREE
# return 2 → detector failed (caller should try next method)
# ---------------------------------------------------------------------------
_fp_probe_with_method() {
local method="$1" port="$2" rc=0
case "$method" in
lsof)
# -iTCP:PORT match the TCP port
# -sTCP:LISTEN only listening sockets (excludes ESTABLISHED)
# -t PIDs only; no headers, faster output
"${timeout_cmd[@]+"${timeout_cmd[@]}"}" \
lsof -iTCP:"$port" -sTCP:LISTEN -t &>/dev/null
rc=$?
;;
ss)
# Modern Linux. sport matches the locally bound port.
"${timeout_cmd[@]+"${timeout_cmd[@]}"}" \
ss -tlnH "sport = :$port" 2>/dev/null | grep -q .
rc=$?
;;
netstat)
# Legacy fallback. Uses awk to match the exact port token and avoid
# substring matches (e.g. ":808" wrongly matching ":8080").
"${timeout_cmd[@]+"${timeout_cmd[@]}"}" \
netstat -tln 2>/dev/null \
| awk -v p="$port" '$4 ~ (":" p "([^0-9]|$)") { found=1; exit } END { exit !found }'
rc=$?
;;
nc)
# -z: scan-only mode (zero I/O, no payload sent)
# -w 1: 1-second connect timeout
# Consistent behavior across BSD nc (macOS) and GNU nc (Linux).
"${timeout_cmd[@]+"${timeout_cmd[@]}"}" \
nc -z -w 1 "$host" "$port" &>/dev/null
rc=$?
;;
bash_tcp)
# Pure bash: attempt a TCP connection via the /dev/tcp virtual device.
# Runs in a subshell to isolate fd 3 and suppress "connection refused".
( exec 3<>/dev/tcp/"$host"/"$port" ) &>/dev/null
rc=$?
;;
*)
return 2
;;
esac
# Normalize: 0 = in use, 1 = free, 2 = detector error
case "$rc" in
0) return 0 ;;
1) return 1 ;;
*) return 2 ;;
esac
}
# Try each method in order; fall through to the next on detector failure.
_fp_port_in_use() {
local port="$1" m rc
for m in "${methods[@]}"; do
_fp_probe_with_method "$m" "$port"
rc=$?
if (( rc != 2 )); then
selected_method="$m"
return $rc
fi
done
return 2 # all methods exhausted
}
# ---------------------------------------------------------------------------
# Main search loop
# ---------------------------------------------------------------------------
local port="$start_port" rc=0
while (( port <= max_port )); do
_fp_port_in_use "$port"
rc=$?
if (( rc == 2 )); then
_fp_log_error "all detectors failed at port $port"
return 2
fi
if (( rc == 1 )); then
# Port appears free. Optionally double-check to reduce race conditions.
if (( no_double_check == 0 )); then
sleep "$DOUBLE_CHECK_DELAY" 2>/dev/null || true
_fp_port_in_use "$port"
rc=$?
if (( rc == 2 )); then
_fp_log_error "all detectors failed on double-check at port $port"
return 2
fi
if (( rc == 0 )); then
_fp_log_info "race condition on port $port; trying next"
(( port++ ))
continue
fi
fi
# Confirmed free: emit result.
if (( port != start_port )); then
_fp_log_info "port $start_port is in use. Using $port."
fi
echo "$port"
return 0
fi
# rc == 0: port in use, try the next one.
(( port++ ))
done
# ---------------------------------------------------------------------------
# Range exhausted
# ---------------------------------------------------------------------------
_fp_log_error "no free port found between $start_port and $max_port"
return 1
}
# ---------------------------------------------------------------------------
# Standalone execution support.
# When sourced: exports the function into the caller's shell.
# When executed directly: runs the function with the given arguments.
# ---------------------------------------------------------------------------
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
grab-port "$@"
fi

grab-port

Find the first available TCP port starting from a preferred one. If the preferred port is in use, increments by one until a free port is found.

For Node.js projects, prefer get-port-cli as a devDependency instead — this script is intended for projects that don't use npm or where adding a runtime dependency is not desirable.

Install

Source it from your .zshrc or .bashrc:

source /path/to/grab-port.sh

Or copy the function body directly into your shell config. The script also supports direct execution without sourcing:

bash grab-port.sh 4173

Usage

grab-port                              # → 3000, or first free from there
grab-port 4173                         # → 4173, or first free from there
grab-port 4173 --max-port 4300         # bounded search
grab-port --host 0.0.0.0              # check on all interfaces
grab-port --self-check                 # report available detectors and exit

# Command substitution (most common use case)
vite preview --port $(grab-port 4173)
next dev --port $(grab-port 3000)
PORT=$(grab-port 8080) && echo "Server on :$PORT"

# Suppress diagnostic output for clean embedding
vite preview --port $(grab-port 4173 --quiet)

Options

Usage: grab-port [start_port] [options]

Find the first free TCP port starting from start_port (default: 3000).

Arguments:
  start_port           First port to try (1–65535). Default: 3000.

Options:
  --max-port N         Highest port to try. Default: 65535.
  --host HOST          Host/interface to check (default: 127.0.0.1).
  -q, --quiet          Suppress informational messages on stderr.
  --no-double-check    Skip the second verification pass.
  --self-check         Show available detectors and exit.
  -h, --help           Show this help and exit.

Output

Stream Content
stdout The free port number (e.g. 4173)
stderr Informational and error messages (suppress with --quiet)
Exit code Meaning
0 Free port found
1 No free port in range
2 Invalid argument or detector failure

Compatibility

Detectors are discovered automatically, in order of preference:

  1. lsof — native on macOS; available on most Linux systems
  2. ss — modern Linux (iproute2); faster replacement for netstat
  3. netstat — legacy; available on older systems
  4. nc — netcat; widely available on macOS and Linux
  5. bash /dev/tcp — pure bash fallback; no external dependencies

If a detector fails for a given probe, the next one is tried automatically. Tested on macOS 13+, Ubuntu 20.04+, Debian 11+, Alpine 3.18+.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment