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:
+57
-51
@@ -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")]
|
||||
|
||||
Reference in New Issue
Block a user