Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions docs/perf-notes-2026-05-06.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Perf notes — 2026-05-06

Session of targeted runtime fixes against `feat/auto-theme-follows-system`.
Focus: idle CPU, session-switch latency, agent-emit fanout, and the
restart-race that produced multi-server zombies.

## Headline numbers

| Metric | Before | After | Change |
|---|---|---|---|
| Steady-state idle CPU (4 TUI clients) | 1.8 – 9.8% with 3 s pulse | 0.2 – 4.1% no pulse | ~3× lower mean, ~5× lower peak |
| Theme detection | `defaults read` every 3 s (~28,800 spawns/day) | kqueue file watch on `~/Library/Preferences/.GlobalPreferences.plist`, push-driven | subprocess work eliminated |
| Session-switch enforce dance (`ensure-sidebar` → `enforce START` → `ensure checking window`) | ~645 ms | ~175 ms | ~3.7× faster |
| User-felt session switch (`/switch-index` → `/ensure-sidebar` settled) | ~940 ms | ~200 ms | ~4.7× faster (residual is tmux's own switch-client redraw) |
| Broadcasts per `agent-emit` storm | 1 : 1 | 5 : 21 (~76% suppressed) | hash-dedup catches no-op status pings |
| EADDRINUSE on respawn | hit on every restart inside TIME_WAIT | impossible (singleton PID-file probe) | clean restarts |
| Idle-timeout grace window | 30 s | 5 min | enough room for `ensure_server` to bring the sidebar up after a code change |

RSS sat at 60 MB before, 65–72 MB after — within noise; the slight bump is
from the additional fs watcher and the larger broadcast hash buffer.

## Changes

| Commit | Layer | What it does |
|---|---|---|
| `a733f28` | runtime | Push-based macOS appearance watcher; broadcast hash-dedup over the serialized state |
| `540dee6` | runtime | Drop dangling `watcherBroadcastTimer` ref in `cleanup()` (latent bug, every shutdown threw) |
| `7aa9903` | runtime | `enforceSidebarWidth(reuseCache)` honored from `ensureSidebarInWindow`, halving `tmux list-panes -a` calls per switch |
| `fb3bb5c` | wire | Drop `eventTimestamps` from `SessionData` broadcast — unused by the TUI, prevented hash-dedup from working under chatty agents |
| `d48cb30` (reverted by `a082440`) | server | Tried `reusePort: true`; broke singleton invariant on macOS Bun |
| `8b7d9a0` | server | `SERVER_IDLE_TIMEOUT_MS` 30 s → 5 min |
| `a082440` | server | Singleton guard via PID-file `process.kill(pid, 0)` probe; revert reusePort |

## How the wins were measured

- **CPU / RSS:** `/bin/ps -o %cpu,rss -p $PID` sampled at 5 s intervals over a 30 s window with 4 TUI clients connected and ambient agent activity (Claude Code in `personal_assistant`, opencode in `warp` and `arcwave`).
- **Session-switch latency:** real switches captured in `/tmp/opensessions-debug.log`. Compared `[http] POST /switch-index` → first `[http] POST /ensure-sidebar` → final `[ensure] checking window` timestamps.
- **Dedup ratio:** 30 s windows of `[agent-emit]` vs `[getCurrentSession]` lines after the `eventTimestamps` removal. Pre-fix runs from a prior 8-day-warm process showed every `agent-emit` triggering a `getCurrentSession`; post-fix shows the inverse.
- **Singleton:** verified by attempting `bun run apps/server/src/main.ts` twice in succession; second invocation prints `opensessions: another server is already running (pid X). Exiting.` and exits cleanly.

## Residual cost

What remains in the user-felt session-switch latency (~200 ms) is dominated
by tmux's own `switch-client` redraw on long-running sessions with deep
scrollback. Server-side has been pushed about as far as it goes without a
protocol change. If the next painful target is shaving more off this, the
options are:

- Reduce `tmux history-limit` for everyday work,
- Cull background panes the user no longer needs in long-running sessions,
- Or move to a protocol that ships state diffs instead of full state snapshots (the natural follow-up to Palani's Ratatui PR #36, which already preserves the WS contract by design).
2 changes: 1 addition & 1 deletion integrations/tmux-plugin/scripts/focus.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ ensure_server || exit 0

CTX="$(tmux display-message -p '#{client_tty}|#{session_name}|#{window_id}' 2>/dev/null)"

curl -s -o /dev/null -m 0.2 --connect-timeout 0.1 -X POST "http://${HOST}:${PORT}/toggle" -d "$CTX"
curl -s -o /dev/null -m 1.5 --connect-timeout 0.3 -X POST "http://${HOST}:${PORT}/toggle" -d "$CTX" >/dev/null 2>&1 || true

attempt=0
while [ "$attempt" -lt 20 ]; do
Expand Down
40 changes: 36 additions & 4 deletions integrations/tmux-plugin/scripts/server-common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,18 @@ else
PID_FILE="/tmp/opensessions.pid"
fi

PLUGIN_DIR="$(tmux show-environment -g OPENSESSIONS_DIR 2>/dev/null | cut -d= -f2)"
PLUGIN_DIR="${PLUGIN_DIR:-$(cd "$SCRIPT_DIR/../../.." && pwd)}"
BUN_PATH="${BUN_PATH:-$(command -v bun 2>/dev/null || echo "$HOME/.bun/bin/bun")}"
SERVER_ENTRY="$PLUGIN_DIR/apps/server/src/main.ts"
# Defer plugin-dir / bun lookup until the cold-start path actually needs it.
# tmux show-environment forks tmux (~10-20ms); command -v bun is cheap but
# the conditional below ensures hot paths (alive cache hit) skip both.
SERVER_LOG="/tmp/opensessions-server.log"

resolve_cold_start_paths() {
PLUGIN_DIR="$(tmux show-environment -g OPENSESSIONS_DIR 2>/dev/null | cut -d= -f2)"
PLUGIN_DIR="${PLUGIN_DIR:-$(cd "$SCRIPT_DIR/../../.." && pwd)}"
BUN_PATH="${BUN_PATH:-$(command -v bun 2>/dev/null || echo "$HOME/.bun/bin/bun")}"
SERVER_ENTRY="$PLUGIN_DIR/apps/server/src/main.ts"
}

show_startup_error() {
message="$1"
tmux display-message "$message" >/dev/null 2>&1 || true
Expand All @@ -59,11 +65,36 @@ server_alive() {
curl -s -o /dev/null -m 0.2 "http://${HOST}:${PORT}/" 2>/dev/null
}

# Cache the alive-check result. If we confirmed the server alive within
# ALIVE_CACHE_TTL_S seconds, trust it and skip the ~80ms curl. Hot keypress
# paths (switch-index, focus, toggle) all call ensure_server first; without
# this they each pay the curl tax even though the server is fine.
ALIVE_CACHE_FILE="${PID_FILE%.pid}.alive"
ALIVE_CACHE_TTL_S=5

alive_cache_fresh() {
[ -f "$ALIVE_CACHE_FILE" ] || return 1
# Cross-platform mtime delta. macOS stat -f %m, Linux stat -c %Y.
now=$(date +%s)
mtime=$(stat -f %m "$ALIVE_CACHE_FILE" 2>/dev/null || stat -c %Y "$ALIVE_CACHE_FILE" 2>/dev/null)
[ -n "$mtime" ] || return 1
delta=$((now - mtime))
[ "$delta" -lt "$ALIVE_CACHE_TTL_S" ]
}

ensure_server() {
if alive_cache_fresh; then
return 0
fi

if server_alive; then
: > "$ALIVE_CACHE_FILE" 2>/dev/null
return 0
fi

# Cold start: only now do we need plugin dir + bun resolution
resolve_cold_start_paths

if [ ! -x "$BUN_PATH" ]; then
show_startup_error "opensessions: bun not found. Install bun and retry."
return 1
Expand All @@ -75,6 +106,7 @@ ensure_server() {
while [ "$attempt" -lt 30 ]; do
sleep 0.1
if server_alive; then
: > "$ALIVE_CACHE_FILE" 2>/dev/null
return 0
fi
attempt=$((attempt + 1))
Expand Down
42 changes: 37 additions & 5 deletions integrations/tmux-plugin/scripts/switch-index.sh
Original file line number Diff line number Diff line change
@@ -1,13 +1,45 @@
#!/usr/bin/env sh
# Switch to the Nth visible opensessions session (1-indexed).
#
# Args:
# $1 = index (required)
# $2 = pre-expanded ctx string "client_tty|session|window_id" (optional)
# When $2 is supplied (from tmux format-string expansion at bind-key time),
# we skip the ~16ms `tmux display-message` fork.

INDEX="${1:?Usage: switch-index.sh <index>}"
INDEX="${1:?Usage: switch-index.sh <index> [ctx]}"
CTX="${2:-}"

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
. "$SCRIPT_DIR/server-common.sh"

ensure_server || exit 0
# Fast path: read the ordering file the server writes on every state change
# and call `tmux switch-client -t <name>` directly. This avoids a round-trip
# through the server's HTTP handler and its own synchronous tmux fork —
# user-perceived latency drops to one tmux subprocess fork (~30-50ms) plus
# shell overhead. The server still gets notified asynchronously via tmux
# hooks (client-session-changed → POST /focus).
ORDERING_FILE="${PID_FILE%.pid}.ordering"
if [ -f "$ORDERING_FILE" ]; then
TARGET=$(awk -v idx="$INDEX" 'NR == idx { print; exit }' "$ORDERING_FILE")
if [ -n "$TARGET" ]; then
tmux switch-client -t "$TARGET" >/dev/null 2>&1
# Fire-and-forget POST so the server can update side effects (sidebar
# focus, agent unseen flags, custom ordering) async. Timeout generous;
# exit code swallowed so tmux's status line never shows curl errors.
if [ -z "$CTX" ]; then
CTX="|$TARGET|"
fi
(curl -s -o /dev/null -m 1.5 --connect-timeout 0.3 -X POST "http://${HOST}:${PORT}/switch-index?index=${INDEX}" -d "$CTX" >/dev/null 2>&1 || true) &
exit 0
fi
fi

CTX=$(tmux display-message -p '#{client_tty}|#{session_name}|#{window_id}' 2>/dev/null)
curl -s -o /dev/null -m 0.2 --connect-timeout 0.1 -X POST "http://${HOST}:${PORT}/switch-index?index=${INDEX}" -d "$CTX"
tmux switch-client -T root >/dev/null 2>&1
# Cold path: ordering file missing or empty. Server hasn't broadcast yet
# (cold boot). Fall back to the original server-mediated switch.
ensure_server || exit 0
if [ -z "$CTX" ]; then
CTX=$(tmux display-message -p '#{client_tty}|#{session_name}|#{window_id}' 2>/dev/null)
fi
curl -s -o /dev/null -m 1.5 --connect-timeout 0.3 -X POST "http://${HOST}:${PORT}/switch-index?index=${INDEX}" -d "$CTX" >/dev/null 2>&1 || true
exit 0
3 changes: 2 additions & 1 deletion integrations/tmux-plugin/scripts/toggle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ensure_server || exit 0

CTX=$(tmux display-message -p '#{client_tty}|#{session_name}|#{window_id}' 2>/dev/null)
curl -s -o /dev/null -m 0.2 --connect-timeout 0.1 -X POST "http://${HOST}:${PORT}/toggle" -d "$CTX"
curl -s -o /dev/null -m 1.5 --connect-timeout 0.3 -X POST "http://${HOST}:${PORT}/toggle" -d "$CTX" >/dev/null 2>&1 || true
tmux switch-client -T root >/dev/null 2>&1
exit 0
20 changes: 11 additions & 9 deletions opensessions.tmux
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,15 @@ bind_global_key() {
local key="$1"
local command="$2"
[ -n "$key" ] || return
tmux bind-key -n "$key" run-shell "$command"
tmux bind-key -n "$key" run-shell -b "$command"
}

bind_global_index_keys() {
local index=1
local key
for key in $INDEX_KEYS; do
[ "$index" -le 9 ] || break
tmux bind-key -n "$key" run-shell "sh '$SCRIPTS_DIR/switch-index.sh' $index"
tmux bind-key -n "$key" run-shell -b "sh '$SCRIPTS_DIR/switch-index.sh' $index '#{client_tty}|#{session_name}|#{window_id}'"
index=$((index + 1))
done
}
Expand Down Expand Up @@ -88,21 +88,23 @@ fi
if [ -n "$PREFIX_KEY" ]; then
tmux bind-key "$PREFIX_KEY" switch-client -T "$COMMAND_TABLE"
tmux bind-key -T "$COMMAND_TABLE" Any switch-client -T root
tmux bind-key -T "$COMMAND_TABLE" s run-shell "sh '$SCRIPTS_DIR/focus.sh'"
tmux bind-key -T "$COMMAND_TABLE" t run-shell "sh '$SCRIPTS_DIR/toggle.sh'"
tmux bind-key -T "$COMMAND_TABLE" e run-shell "sh '$SCRIPTS_DIR/even-horizontal.sh' '#{window_id}' '#{pane_id}'"
tmux bind-key -T "$COMMAND_TABLE" s run-shell -b "sh '$SCRIPTS_DIR/focus.sh'"
tmux bind-key -T "$COMMAND_TABLE" t run-shell -b "sh '$SCRIPTS_DIR/toggle.sh'"
tmux bind-key -T "$COMMAND_TABLE" e run-shell -b "sh '$SCRIPTS_DIR/even-horizontal.sh' '#{window_id}' '#{pane_id}'"
# Pass tmux-expanded ctx as $2 so the script doesn't need to fork
# `tmux display-message`. Then immediately reset keytable.
for i in 1 2 3 4 5 6 7 8 9; do
tmux bind-key -T "$COMMAND_TABLE" "$i" run-shell "sh '$SCRIPTS_DIR/switch-index.sh' $i"
tmux bind-key -T "$COMMAND_TABLE" "$i" run-shell -b "sh '$SCRIPTS_DIR/switch-index.sh' $i '#{client_tty}|#{session_name}|#{window_id}'" \; switch-client -T root
done
fi

# Direct prefix bindings for programmatic use (terminal emulator shortcuts).
# C-s/C-t are single-byte Ctrl codes; M-1..9 are 2-byte Alt sequences.
# Both are safe to send as text from terminal emulators without timing issues.
tmux bind-key C-s run-shell "sh '$SCRIPTS_DIR/focus.sh'"
tmux bind-key C-t run-shell "sh '$SCRIPTS_DIR/toggle.sh'"
tmux bind-key C-s run-shell -b "sh '$SCRIPTS_DIR/focus.sh'"
tmux bind-key C-t run-shell -b "sh '$SCRIPTS_DIR/toggle.sh'"
for i in 1 2 3 4 5 6 7 8 9; do
tmux bind-key "M-$i" run-shell "sh '$SCRIPTS_DIR/switch-index.sh' $i"
tmux bind-key "M-$i" run-shell -b "sh '$SCRIPTS_DIR/switch-index.sh' $i '#{client_tty}|#{session_name}|#{window_id}'"
done

bind_global_key "$FOCUS_GLOBAL_KEY" "sh '$SCRIPTS_DIR/focus.sh'"
Expand Down
2 changes: 2 additions & 0 deletions packages/mux/contract/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type {
WindowCapable,
SidebarCapable,
BatchCapable,
AsyncReadCapable,
FullMuxProvider,
MuxProvider,
MuxProviderSettings,
Expand All @@ -20,5 +21,6 @@ export {
isWindowCapable,
isSidebarCapable,
isBatchCapable,
isAsyncReadCapable,
isFullSidebarCapable,
} from "./types";
19 changes: 18 additions & 1 deletion packages/mux/contract/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,18 @@ export interface BatchCapable {
getAllPaneCounts(): Map<string, number>;
}

/**
* Async-capable read methods for hot paths in the runtime. Providers that
* implement these allow the server to await tmux/mux subprocess work
* instead of blocking the event loop. The sync siblings remain available
* as fallbacks for non-async call sites.
*/
export interface AsyncReadCapable {
listSessionsAsync(): Promise<MuxSessionInfo[]>;
getCurrentSessionAsync(): Promise<string | null>;
getAllPaneCountsAsync?(): Promise<Map<string, number>>;
}

// ─── Composite types ─────────────────────────────────────────────────────────

/**
Expand All @@ -108,7 +120,7 @@ export type FullMuxProvider = MuxProviderV1 & WindowCapable & SidebarCapable & B
*
* Like ai-sdk's LanguageModel = V2 | V3 | V4 — accepts any level of capability.
*/
export type MuxProvider = MuxProviderV1 & Partial<WindowCapable & SidebarCapable & BatchCapable>;
export type MuxProvider = MuxProviderV1 & Partial<WindowCapable & SidebarCapable & BatchCapable & AsyncReadCapable>;

// ─── Type guards ─────────────────────────────────────────────────────────────
// Runtime narrowing — like ai-sdk's isInstance() pattern, but for capabilities.
Expand Down Expand Up @@ -136,6 +148,11 @@ export function isBatchCapable(p: MuxProvider): p is MuxProviderV1 & BatchCapabl
return typeof p.getAllPaneCounts === "function";
}

/** Check if a provider implements the async read API */
export function isAsyncReadCapable(p: MuxProvider): p is MuxProviderV1 & AsyncReadCapable {
return typeof p.listSessionsAsync === "function" && typeof p.getCurrentSessionAsync === "function";
}

/** Check if a provider supports full sidebar management (window + sidebar) */
export function isFullSidebarCapable(
p: MuxProvider,
Expand Down
Loading