|
#!/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 |