Reduce shell integration prompt latency (#2109)

* Reduce shell integration prompt latency

Three changes to cut ~10-15ms from every precmd/preexec cycle:

1. Use zsh/net/unix (zsocket) for socket sends when available. Eliminates
   fork+exec of ncat/socat/nc for every telemetry send (~3ms per send,
   3-4 sends per prompt cycle). Falls back to external tools if the
   module is unavailable.

2. Replace _cmux_kill_process_tree (synchronous /bin/ps -ax | awk) with
   direct kill in _cmux_stop_pr_poll_loop. The tree-kill enumerated all
   system processes on every command (~5-13ms). Orphaned children (gh,
   sleep) finish on their own within seconds.

3. Minor savings: guard _cmux_patch_ghostty_semantic_redraw after first
   success, make _cmux_clear_pr_for_panel async, cache bash send tool.

* Address review: process-group kill, fix clear_pr race, reorder bash init

1. Use kill -KILL -- -$PID (process-group kill) instead of plain kill.
   Background jobs are process-group leaders, so this kills all
   descendants (gh, sleep) without /bin/ps overhead.

2. Keep bash _cmux_clear_pr_for_panel synchronous to prevent race
   with the next report_pr from the poll loop. Zsh version uses
   _cmux_send_bg which is synchronous when zsocket is available.

3. Move _cmux_detect_send_tool after _cmux_fix_path in bash so the
   cached tool lookup runs with the final PATH.

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
Lawrence Chen 2026-03-24 23:08:20 -07:00 committed by GitHub
parent 23253e6ddf
commit 71828fe86e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 72 additions and 49 deletions

View file

@ -1,26 +1,35 @@
# cmux shell integration for bash
# Cache which send tool is available to avoid repeated PATH lookups.
_CMUX_SEND_TOOL=""
_cmux_detect_send_tool() {
if command -v ncat >/dev/null 2>&1; then
_CMUX_SEND_TOOL=ncat
elif command -v socat >/dev/null 2>&1; then
_CMUX_SEND_TOOL=socat
elif command -v nc >/dev/null 2>&1; then
_CMUX_SEND_TOOL=nc
fi
}
# Detection deferred to after _cmux_fix_path (end of file).
_cmux_send() {
local payload="$1"
if command -v ncat >/dev/null 2>&1; then
printf '%s\n' "$payload" | ncat -w 1 -U "$CMUX_SOCKET_PATH" --send-only
elif command -v socat >/dev/null 2>&1; then
printf '%s\n' "$payload" | socat -T 1 - "UNIX-CONNECT:$CMUX_SOCKET_PATH" >/dev/null 2>&1
elif command -v nc >/dev/null 2>&1; then
# Some nc builds don't support unix sockets, but keep as a last-ditch fallback.
#
# Important: macOS/BSD nc will often wait for the peer to close the socket
# after it has finished writing. cmux keeps the connection open, so
# a plain `nc -U` can hang indefinitely and leak background processes.
#
# Prefer flags that guarantee we exit after sending, and fall back to a
# short timeout so we never block sidebar updates.
if printf '%s\n' "$payload" | nc -N -U "$CMUX_SOCKET_PATH" >/dev/null 2>&1; then
:
else
printf '%s\n' "$payload" | nc -w 1 -U "$CMUX_SOCKET_PATH" >/dev/null 2>&1 || true
fi
fi
case "$_CMUX_SEND_TOOL" in
ncat)
printf '%s\n' "$payload" | ncat -w 1 -U "$CMUX_SOCKET_PATH" --send-only
;;
socat)
printf '%s\n' "$payload" | socat -T 1 - "UNIX-CONNECT:$CMUX_SOCKET_PATH" >/dev/null 2>&1
;;
nc)
if printf '%s\n' "$payload" | nc -N -U "$CMUX_SOCKET_PATH" >/dev/null 2>&1; then
:
else
printf '%s\n' "$payload" | nc -w 1 -U "$CMUX_SOCKET_PATH" >/dev/null 2>&1 || true
fi
;;
esac
}
_cmux_restore_scrollback_once() {
@ -271,6 +280,7 @@ _cmux_clear_pr_for_panel() {
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
# Synchronous: must arrive before the next report_pr from the poll loop.
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
}
@ -445,9 +455,10 @@ _cmux_run_pr_probe_with_timeout() {
_cmux_stop_pr_poll_loop() {
if [[ -n "$_CMUX_PR_POLL_PID" ]]; then
# Use SIGKILL directly to avoid blocking sleep in preexec.
# The poll loop is lightweight and safe to kill abruptly.
_cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL
# Process-group kill: background jobs are process-group leaders, so
# negative PID kills the loop + all descendants (gh, sleep) without
# the synchronous /bin/ps + awk of tree-kill (~5-13ms).
kill -KILL -- -"$_CMUX_PR_POLL_PID" 2>/dev/null || true
_CMUX_PR_POLL_PID=""
fi
}
@ -702,4 +713,6 @@ _cmux_fix_path() {
_cmux_fix_path
unset -f _cmux_fix_path
_cmux_detect_send_tool
_cmux_install_prompt_command

View file

@ -1,21 +1,29 @@
# cmux shell integration for zsh
# Injected automatically — do not source manually
# Prefer zsh/net/unix for socket sends (no fork, ~0.2ms per send vs ~3ms
# for fork+exec of ncat/socat/nc). Falls back to external tools if the
# module is unavailable.
typeset -g _CMUX_HAS_ZSOCKET=0
if zmodload zsh/net/unix 2>/dev/null; then
_CMUX_HAS_ZSOCKET=1
fi
_cmux_send() {
local payload="$1"
if (( _CMUX_HAS_ZSOCKET )); then
local fd
zsocket "$CMUX_SOCKET_PATH" 2>/dev/null || return 1
fd=$REPLY
print -u $fd -r -- "$payload" 2>/dev/null
exec {fd}>&- 2>/dev/null
return 0
fi
if command -v ncat >/dev/null 2>&1; then
print -r -- "$payload" | ncat -w 1 -U "$CMUX_SOCKET_PATH" --send-only
elif command -v socat >/dev/null 2>&1; then
print -r -- "$payload" | socat -T 1 - "UNIX-CONNECT:$CMUX_SOCKET_PATH" >/dev/null 2>&1
elif command -v nc >/dev/null 2>&1; then
# Some nc builds don't support unix sockets, but keep as a last-ditch fallback.
#
# Important: macOS/BSD nc will often wait for the peer to close the socket
# after it has finished writing. cmux keeps the connection open, so
# a plain `nc -U` can hang indefinitely and leak background processes.
#
# Prefer flags that guarantee we exit after sending, and fall back to a
# short timeout so we never block sidebar updates.
if print -r -- "$payload" | nc -N -U "$CMUX_SOCKET_PATH" >/dev/null 2>&1; then
:
else
@ -24,6 +32,16 @@ _cmux_send() {
fi
}
# Fire-and-forget send: synchronous when zsocket is available (fast, no fork),
# backgrounded otherwise.
_cmux_send_bg() {
if (( _CMUX_HAS_ZSOCKET )); then
_cmux_send "$1"
else
{ _cmux_send "$1" } >/dev/null 2>&1 &!
fi
}
_cmux_restore_scrollback_once() {
local path="${CMUX_RESTORE_SCROLLBACK_FILE:-}"
[[ -n "$path" ]] || return 0
@ -337,9 +355,7 @@ _cmux_report_tty_once() {
[[ -n "$payload" ]] || return 0
_CMUX_TTY_REPORTED=1
{
_cmux_send "$payload"
} >/dev/null 2>&1 &!
_cmux_send_bg "$payload"
}
_cmux_report_shell_activity_state() {
@ -350,9 +366,7 @@ _cmux_report_shell_activity_state() {
[[ -n "$CMUX_PANEL_ID" ]] || return 0
[[ "$_CMUX_SHELL_ACTIVITY_LAST" == "$state" ]] && return 0
_CMUX_SHELL_ACTIVITY_LAST="$state"
{
_cmux_send "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
} >/dev/null 2>&1 &!
_cmux_send_bg "report_shell_state $state --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
}
_cmux_ports_kick() {
@ -362,9 +376,7 @@ _cmux_ports_kick() {
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
_CMUX_PORTS_LAST_RUN=$EPOCHSECONDS
{
_cmux_send "ports_kick --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
} >/dev/null 2>&1 &!
_cmux_send_bg "ports_kick --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
}
_cmux_report_git_branch_for_path() {
@ -392,7 +404,7 @@ _cmux_clear_pr_for_panel() {
[[ -S "$CMUX_SOCKET_PATH" ]] || return 0
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
_cmux_send "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
_cmux_send_bg "clear_pr --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
}
_cmux_pr_output_indicates_no_pull_request() {
@ -567,9 +579,10 @@ _cmux_run_pr_probe_with_timeout() {
_cmux_stop_pr_poll_loop() {
if [[ -n "$_CMUX_PR_POLL_PID" ]]; then
# Use SIGKILL directly to avoid blocking sleep in preexec.
# The poll loop is lightweight and safe to kill abruptly.
_cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL
# Process-group kill: background jobs are process-group leaders, so
# negative PID kills the loop + all descendants (gh, sleep) without
# the synchronous /bin/ps + awk of tree-kill (~5-13ms).
kill -KILL -- -"$_CMUX_PR_POLL_PID" 2>/dev/null || true
_CMUX_PR_POLL_PID=""
fi
}
@ -682,7 +695,7 @@ _cmux_precmd() {
_cmux_report_shell_activity_state prompt
# Handle cases where Ghostty integration initializes after this file.
_cmux_patch_ghostty_semantic_redraw
(( _CMUX_GHOSTTY_SEMANTIC_PATCHED )) || _cmux_patch_ghostty_semantic_redraw
if [[ -z "$_CMUX_TTY_NAME" ]]; then
local t
@ -717,11 +730,8 @@ _cmux_precmd() {
# This is also the simplest way to test sidebar directory behavior end-to-end.
if [[ "$pwd" != "$_CMUX_PWD_LAST_PWD" ]]; then
_CMUX_PWD_LAST_PWD="$pwd"
{
# Quote to preserve spaces.
local qpwd="${pwd//\"/\\\"}"
_cmux_send "report_pwd \"${qpwd}\" --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
} >/dev/null 2>&1 &!
local qpwd="${pwd//\"/\\\"}"
_cmux_send_bg "report_pwd \"${qpwd}\" --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
fi
# Git branch/dirty: update immediately on directory change, otherwise every ~3s.