Move port scanning from shell to app-side with batching (#100)

* Move port scanning from shell to app-side with batching

Replace per-shell `ps -axo + lsof` scanning with a centralized
PortScanner singleton in the app. Each shell now sends lightweight
`report_tty` (once per session) and `ports_kick` (on preexec/precmd)
socket messages. The app coalesces kicks across all panels and runs a
single `ps -t <ttys> + lsof -p <pids>` covering every active panel.

Also fixes a macOS 26 Tahoe regression where `getsockopt(LOCAL_PEERPID)`
returns ENOTCONN on accepted sockets when the peer disconnects before
the handler thread starts. This was silently breaking ALL socket
commands sent via ncat --send-only. The fix captures the peer PID in
the accept loop immediately after accept(), and falls back to
LOCAL_PEERCRED (uid check) when the PID lookup fails.

* Fix PR review feedback: burst timing and auth comment clarity

- P2: burstDelays were accumulating (0.5+1.5+3+... = ~22.5s) instead of
  firing at absolute offsets from burst start. Now uses burstStart anchor
  so scans fire at 0.5s, 1.5s, 3s, 5s, 7.5s, 10s as intended.

- P1: Clarify LOCAL_PEERCRED fallback rationale — same security boundary
  as socket file permissions (0600), does not widen attack surface.
  Long-lived connections still get full descendant check via LOCAL_PEERPID.
This commit is contained in:
Lawrence Chen 2026-02-19 01:04:47 -08:00 committed by GitHub
parent 3193e602d4
commit 9642bb59fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 516 additions and 158 deletions

View file

@ -30,7 +30,34 @@ _CMUX_GIT_LAST_RUN="${_CMUX_GIT_LAST_RUN:-0}"
_CMUX_GIT_JOB_PID="${_CMUX_GIT_JOB_PID:-}"
_CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
_CMUX_PORTS_JOB_PID="${_CMUX_PORTS_JOB_PID:-}"
_CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}"
_CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}"
_cmux_report_tty_once() {
# Send the TTY name to the app once per session so the batched port scanner
# knows which TTY belongs to this panel.
(( _CMUX_TTY_REPORTED )) && return 0
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
[[ -n "$_CMUX_TTY_NAME" ]] || return 0
_CMUX_TTY_REPORTED=1
{
_cmux_send "report_tty $_CMUX_TTY_NAME --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
} >/dev/null 2>&1 &
}
_cmux_ports_kick() {
# Lightweight: just tell the app to run a batched scan for this panel.
# The app coalesces kicks across all panels and runs a single ps+lsof.
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
_CMUX_PORTS_LAST_RUN=$SECONDS
{
_cmux_send "ports_kick --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
} >/dev/null 2>&1 &
}
_cmux_prompt_command() {
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
@ -39,13 +66,17 @@ _cmux_prompt_command() {
local now=$SECONDS
local pwd="$PWD"
local tty_name=""
tty_name="$(tty 2>/dev/null || true)"
tty_name="${tty_name##*/}"
if [[ "$tty_name" == "not a tty" ]]; then
tty_name=""
# Resolve TTY name once.
if [[ -z "$_CMUX_TTY_NAME" ]]; then
local t
t="$(tty 2>/dev/null || true)"
t="${t##*/}"
[[ "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
fi
_cmux_report_tty_once
# CWD: keep the app in sync with the actual shell directory.
if [[ "$pwd" != "$_CMUX_PWD_LAST_PWD" ]]; then
_CMUX_PWD_LAST_PWD="$pwd"
@ -57,68 +88,29 @@ _cmux_prompt_command() {
# Git branch/dirty can change without a directory change (e.g. `git checkout`),
# so update on every prompt (still async + de-duped by the running-job check).
local should_git=1
if (( should_git )); then
if [[ -n "$_CMUX_GIT_JOB_PID" ]] && kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then
:
else
_CMUX_GIT_LAST_PWD="$pwd"
_CMUX_GIT_LAST_RUN=$now
{
local branch dirty_opt=""
branch=$(git branch --show-current 2>/dev/null)
if [[ -n "$branch" ]]; then
local first
first=$(git status --porcelain -uno 2>/dev/null | head -1)
[[ -n "$first" ]] && dirty_opt="--status=dirty"
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID"
else
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID"
fi
} >/dev/null 2>&1 &
_CMUX_GIT_JOB_PID=$!
fi
if [[ -n "$_CMUX_GIT_JOB_PID" ]] && kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then
:
else
_CMUX_GIT_LAST_PWD="$pwd"
_CMUX_GIT_LAST_RUN=$now
{
local branch dirty_opt=""
branch=$(git branch --show-current 2>/dev/null)
if [[ -n "$branch" ]]; then
local first
first=$(git status --porcelain -uno 2>/dev/null | head -1)
[[ -n "$first" ]] && dirty_opt="--status=dirty"
_cmux_send "report_git_branch $branch $dirty_opt --tab=$CMUX_TAB_ID"
else
_cmux_send "clear_git_branch --tab=$CMUX_TAB_ID"
fi
} >/dev/null 2>&1 &
_CMUX_GIT_JOB_PID=$!
fi
# Ports: lightweight kick to the app's batched scanner every ~10s.
if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then
if [[ -n "$_CMUX_PORTS_JOB_PID" ]] && kill -0 "$_CMUX_PORTS_JOB_PID" 2>/dev/null; then
: # previous scan still running
else
_CMUX_PORTS_LAST_RUN=$now
{
local ports=()
local pids_csv=""
if [[ -n "$tty_name" ]]; then
pids_csv="$(ps -axo pid=,tty= 2>/dev/null | awk -v tty="$tty_name" '$2 == tty {print $1}' | tr '\n' ',' || true)"
pids_csv="${pids_csv%,}"
fi
if [[ -n "$pids_csv" ]]; then
local line name port
while IFS= read -r line; do
[[ "$line" == n* ]] || continue
name="${line#n}"
name="${name%%->*}"
port="${name##*:}"
port="${port%%[^0-9]*}"
[[ -n "$port" ]] && ports+=("$port")
done < <(
lsof -nP -a -p "$pids_csv" -iTCP -sTCP:LISTEN -F n 2>/dev/null || true
)
fi
if ((${#ports[@]} > 0)); then
local ports_sorted
ports_sorted=$(printf '%s\n' "${ports[@]}" | sort -n | uniq | tr '\n' ' ')
ports_sorted="${ports_sorted%% }"
_cmux_send "report_ports $ports_sorted --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
else
_cmux_send "clear_ports --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
fi
} >/dev/null 2>&1 &
_CMUX_PORTS_JOB_PID=$!
fi
_cmux_ports_kick
fi
}

View file

@ -36,9 +36,9 @@ typeset -g _CMUX_GIT_HEAD_MTIME=0
typeset -g _CMUX_HAVE_ZSTAT=0
typeset -g _CMUX_PORTS_LAST_RUN=0
typeset -g _CMUX_PORTS_JOB_PID=""
typeset -g _CMUX_CMD_START=0
typeset -g _CMUX_TTY_NAME=""
typeset -g _CMUX_TTY_REPORTED=0
_cmux_ensure_zstat() {
# zstat is substantially cheaper than spawning external `stat`.
@ -103,89 +103,30 @@ _cmux_git_head_mtime() {
print -r -- 0
}
_cmux_ports_scan() {
[[ -n "$CMUX_PANEL_ID" ]] || return 0
# Report listening TCP ports for the current shell session only (so a fresh
# tab doesn't inherit unrelated machine-wide ports). We restrict the scan to
# the current controlling TTY which keeps this cheap enough to run often.
local -a ports
local line name port
# Best-effort: restrict to the current controlling TTY so a fresh tab doesn't
# inherit unrelated machine-wide ports. This is a pragmatic heuristic that
# works well for typical dev servers started from that shell.
local tty_name pids_csv
tty_name="$_CMUX_TTY_NAME"
if [[ -z "$tty_name" ]]; then
local t
t="$(tty 2>/dev/null || true)"
t="${t##*/}"
[[ "$t" != "not a tty" ]] && tty_name="$t"
fi
if [[ -z "$tty_name" ]]; then
_cmux_send "clear_ports --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
return 0
fi
pids_csv="$(ps -axo pid=,tty= 2>/dev/null | awk -v tty="$tty_name" '$2 == tty {print $1}' | tr '\n' ',' || true)"
pids_csv="${pids_csv%,}"
if [[ -z "$pids_csv" ]]; then
_cmux_send "clear_ports --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
return 0
fi
while IFS= read -r line; do
[[ "$line" == n* ]] || continue
name="${line#n}"
# Defensive: if the format ever includes a remote endpoint, keep the local side.
name="${name%%->*}"
port="${name##*:}"
# Strip anything non-numeric (paranoia: "8000 (LISTEN)" etc).
port="${port%%[^0-9]*}"
[[ -n "$port" ]] && ports+=("$port")
done < <(
lsof -nP -a -p "$pids_csv" -iTCP -sTCP:LISTEN -F n 2>/dev/null || true
)
ports=("${(@u)ports}")
ports=("${(@on)ports}")
if (( ${#ports[@]} > 0 )); then
_cmux_send "report_ports ${(j: :)ports} --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
else
_cmux_send "clear_ports --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
fi
}
_cmux_ports_kick() {
# De-duped, async scans (with a short burst) so we still update when a command
# runs in the foreground (no prompt updates while it is running).
_cmux_report_tty_once() {
# Send the TTY name to the app once per session so the batched port scanner
# knows which TTY belongs to this panel.
(( _CMUX_TTY_REPORTED )) && return 0
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
[[ -n "$_CMUX_TTY_NAME" ]] || return 0
_CMUX_TTY_REPORTED=1
{
_cmux_send "report_tty $_CMUX_TTY_NAME --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
} >/dev/null 2>&1 &!
}
_cmux_ports_kick() {
# Lightweight: just tell the app to run a batched scan for this panel.
# The app coalesces kicks across all panels and runs a single ps+lsof.
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
if [[ -n "$_CMUX_PORTS_JOB_PID" ]] && kill -0 "$_CMUX_PORTS_JOB_PID" 2>/dev/null; then
return 0
fi
_CMUX_PORTS_LAST_RUN=$EPOCHSECONDS
{
# Scan over ~10 seconds so slow-starting servers (e.g. `npm run dev`)
# still show ports while the command is in the foreground.
sleep 0.5 2>/dev/null || true
_cmux_ports_scan
sleep 1.0 2>/dev/null || true
_cmux_ports_scan
sleep 1.5 2>/dev/null || true
_cmux_ports_scan
sleep 2.0 2>/dev/null || true
_cmux_ports_scan
sleep 2.5 2>/dev/null || true
_cmux_ports_scan
sleep 2.5 2>/dev/null || true
_cmux_ports_scan
_cmux_send "ports_kick --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
} >/dev/null 2>&1 &!
_CMUX_PORTS_JOB_PID=$!
}
_cmux_preexec() {
@ -204,8 +145,8 @@ _cmux_preexec() {
_CMUX_GIT_FORCE=1
fi
# Ports can change due to long-running foreground commands (servers), so start
# a short scan burst after command launch.
# Register TTY + kick batched port scan for foreground commands (servers).
_cmux_report_tty_once
_cmux_ports_kick
}
@ -222,6 +163,8 @@ _cmux_precmd() {
[[ -n "$t" && "$t" != "not a tty" ]] && _CMUX_TTY_NAME="$t"
fi
_cmux_report_tty_once
local now=$EPOCHSECONDS
local pwd="$PWD"
local cmd_start="$_CMUX_CMD_START"
@ -302,7 +245,7 @@ _cmux_precmd() {
fi
fi
# Ports:
# Ports: lightweight kick to the app's batched scanner.
# - Periodic scan to avoid stale values.
# - Forced scan when a long-running command returns to the prompt (common when stopping a server).
local cmd_dur=0