feat: ttyd process pool + terminal instance cache for instant session switching

Two independent optimizations that eliminate the ~700ms-1.2s lag when switching sessions:

### Backend: ttyd process pool (ttyd.py + main.py)
Instead of killing and respawning a single ttyd process on every session switch, maintain a pool of ttyd processes - one per connected session, each on its own port (7682-7701). Revisiting a session reuses the existing process instantly.

- New pool API: get_or_spawn(), pool_port(), kill_session(), kill_all(), kill_orphans()
- Backward-compat aliases preserved: kill_ttyd(), spawn_ttyd(), kill_orphan_ttyd(), TTYD_PORT
- /connect endpoint now returns in ~0ms for already-connected sessions vs 700ms+ before
- WS proxy reads ?session= query param to route to correct ttyd port
- Per-port PID files for orphan recovery across restarts

### Frontend: terminal instance cache (terminal.js + app.js)
Keep up to 5 xterm.js Terminal instances alive in hidden container divs. Switching to an already-visited session is a CSS display swap + fit() call - no xterm.js teardown/recreation, no WebSocket reconnection, no addon reloading.

- New switchTerminal() entry point with LRU cache (max 5)
- Each cached entry has its own container div, term, WebSocket, addons
- Cache-aware /connect skip: if terminal is cached with live WS, bypass backend entirely
- Animation gate fixed: 0ms for session-to-session switches (was always 260ms)
- Cache cleanup on session delete

### Test changes (minimal)
- test_ttyd.py: Added pool cleanup in fixture for isolation
- test_api.py: Updated 4 tests to mock new pool API
- test_ws_proxy.py: Updated auto-spawn test for pool API

All 1,306 tests pass.

Generated with Amplifier

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
This commit is contained in:
Ken
2026-05-28 00:48:21 +00:00
parent 967b30f8e0
commit e032d540b0
7 changed files with 661 additions and 143 deletions
+57 -51
View File
@@ -78,7 +78,16 @@ from muxplex.settings import (
from muxplex.pruning import load_pruning_state, save_pruning_state
from muxplex.views import normalize_session_keys, prune_stale_keys
from muxplex.identity import load_device_id
from muxplex.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd, TTYD_PORT
from muxplex.ttyd import (
TTYD_PORT,
_ttyd_is_listening,
get_or_spawn,
kill_orphan_ttyd,
kill_session,
kill_ttyd, # noqa: F401 — backward compat re-export used by tests
pool_port,
spawn_ttyd, # noqa: F401 — backward compat re-export used by tests
)
# ---------------------------------------------------------------------------
# Configuration
@@ -715,10 +724,11 @@ async def create_session(payload: CreateSessionPayload) -> dict:
async def connect_session(name: str) -> dict:
"""Connect to a tmux session via ttyd.
Kills any existing ttyd process, spawns a new one attached to *name*,
and updates the active_session in persistent state.
Uses the process pool: if the session already has a live ttyd, returns
immediately. Otherwise spawns a new ttyd on a free port, polls until
listening, and returns the port.
Returns {active_session: name, ttyd_port: 7682}.
Returns {active_session: name, ttyd_port: <port>}.
Raises HTTP 404 if *name* is not in the known session list (when non-empty).
"""
known = get_session_list()
@@ -726,40 +736,34 @@ async def connect_session(name: str) -> dict:
raise HTTPException(status_code=404, detail=f"Session '{name}' not found")
_log.info("Connecting to session '%s'", name)
await kill_ttyd()
await spawn_ttyd(name)
# Wait for ttyd to actually bind its port before returning.
# This eliminates the 0.8s blind sleep in the WebSocket proxy path —
# the client can connect immediately when this endpoint responds.
for _attempt in range(20): # up to ~1s (20 × 50ms)
if _ttyd_is_listening():
break
await asyncio.sleep(0.05)
port = await get_or_spawn(name)
async with state_lock:
state = load_state()
state["active_session"] = name
save_state(state)
return {"active_session": name, "ttyd_port": TTYD_PORT}
return {"active_session": name, "ttyd_port": port}
@app.delete("/api/sessions/current")
async def delete_current_session() -> dict:
"""Disconnect the current ttyd session.
Kills the running ttyd process and clears active_session in persistent state.
Kills the pooled ttyd for the active session and clears active_session
in persistent state.
Returns {active_session: None}.
"""
await kill_ttyd()
async with state_lock:
state = load_state()
active = state.get("active_session")
state["active_session"] = None
save_state(state)
if active:
await kill_session(active)
return {"active_session": None}
@@ -807,6 +811,10 @@ async def delete_session(name: str) -> dict:
except Exception:
_log.warning("Delete command failed: %r", command)
# Clean up the pool entry (ttyd will die when tmux session dies,
# but clean up immediately so the port is freed).
await kill_session(name)
return {"ok": True, "name": name}
@@ -988,19 +996,7 @@ async def instance_info() -> dict:
# ---------------------------------------------------------------------------
def _ttyd_is_listening() -> bool:
"""Return True if something is accepting TCP connections on TTYD_PORT.
Uses a raw socket connect (no WebSocket handshake, no PTY spawned).
Takes < 1 ms on localhost when ttyd is running; fails immediately with
ConnectionRefusedError when it's not. OSError/TimeoutError are also
caught so the caller always gets a bool.
"""
try:
with socket.create_connection(("127.0.0.1", TTYD_PORT), timeout=0.5):
return True
except (ConnectionRefusedError, OSError, TimeoutError):
return False
# _ttyd_is_listening is imported from muxplex.ttyd (pool-aware, accepts port param)
async def _ws_auth_check(websocket: WebSocket) -> bool:
@@ -1033,9 +1029,10 @@ async def _ws_auth_check(websocket: WebSocket) -> bool:
async def terminal_ws_proxy(websocket: WebSocket) -> None:
"""Proxy WebSocket frames between the browser and ttyd.
Checks that ttyd is alive BEFORE accepting the browser WebSocket. If ttyd
is not listening (e.g. after a service restart), auto-spawns it using the
active_session from state, then waits briefly for it to bind its port.
Resolves the target ttyd port from the process pool BEFORE accepting the
browser WebSocket. If ttyd is not in the pool (e.g. after a service
restart), auto-spawns it using the session from query params or
active_session from state, then waits for it to bind its port.
Only after ttyd is confirmed reachable does the function call
websocket.accept() — so the browser's 'open' event only fires once a real
@@ -1047,29 +1044,38 @@ async def terminal_ws_proxy(websocket: WebSocket) -> None:
if not await _ws_auth_check(websocket):
return
# Ensure ttyd is reachable BEFORE accepting the browser WS.
# After a service restart ttyd is dead but clients reconnect immediately.
# Auto-spawn from active_session so the browser's 'open' event only fires
# when a real relay is possible — eliminates the 0→1→0→1 counter bounce.
if not _ttyd_is_listening():
# Determine which session (and port) to connect to.
# Priority: ?session= query param > active_session from state.
session = websocket.query_params.get("session", "")
if not session:
try:
async with state_lock:
state = load_state()
session_name = state.get("active_session")
if session_name:
_log.info(
"WS proxy: ttyd not listening, auto-spawning for '%s'",
session_name,
)
await kill_ttyd()
await spawn_ttyd(session_name)
await asyncio.sleep(0.8) # wait for ttyd to bind its port
except Exception as exc:
_log.warning("WS proxy: failed to auto-spawn ttyd: %s", exc)
session = state.get("active_session") or ""
except Exception:
session = ""
# Look up port from pool, or auto-spawn if not listening
port = pool_port(session) if session else None
if port is None and session:
if not _ttyd_is_listening(pool_port(session) or TTYD_PORT):
_log.info(
"WS proxy: ttyd not listening, auto-spawning for '%s'",
session,
)
try:
port = await get_or_spawn(session)
except Exception as exc:
_log.warning("WS proxy: failed to auto-spawn ttyd: %s", exc)
if port is None:
# Fallback to TTYD_PORT (legacy single-process or externally managed ttyd)
port = TTYD_PORT
await websocket.accept(subprotocol="tty")
ttyd_url = f"ws://localhost:{TTYD_PORT}/ws"
ttyd_url = f"ws://localhost:{port}/ws"
try:
async with websockets.connect(
ttyd_url, subprotocols=[Subprotocol("tty")]