Files
muxplex/docs/superpowers/specs/2026-05-28-terminal-architecture-redesign.md
T

283 lines
17 KiB
Markdown

# Terminal Architecture Redesign: muxterm + ghostty-web
## Goal
Replace the multi-hop terminal data path (xterm.js → Python WS proxy → ttyd → tmux) with a direct architecture (ghostty-web → muxterm Go binary → tmux) that eliminates the reliability failures and latency inherent in managing ttyd as an external process through a Python relay.
## Background
The current terminal data path has fundamental architectural problems causing reliability failures (blank terminals, race conditions, process lifecycle hell) and performance issues (~700ms-1.2s session switch latency, up to 2.5s worst case):
```
Browser (xterm.js) ←WS→ Python FastAPI (dumb byte relay) ←WS→ ttyd ←PTY→ tmux attach
```
Every keystroke and terminal output byte traverses two WebSocket hops through a Python async relay that adds zero value during active use. The relay is a 100% dumb byte pipe — zero inspection, zero logging, zero transformation of frames. It exists solely because Caddy was removed to make muxplex distributable as a single-port Python tool (`uv tool install`), and the Python proxy replaced Caddy's WS forwarding role. Auth became a load-bearing side benefit.
ttyd is locked to one command per process, forcing a kill/spawn cycle on every session switch. This spawned increasing layers of complexity — process pools, port range allocation (7682-7701), PID file management, terminal instance caches, sleep delays, lsof fallbacks — all patching symptoms of the wrong architecture.
The "must be pure Python" constraint that drove this design doesn't actually exist. Python packages can include platform-specific compiled binaries via wheels — ruff (Rust), cmake (C++), and the Zig compiler all ship this way via `uv tool install`.
## Approach
**Approach A (chosen): Go terminal multiplexer binary, Python keeps the dashboard.**
A small, purpose-built Go binary (`muxterm`) owns the entire terminal data path. One process, one WebSocket hop, direct PTY ownership via `creack/pty`. Session switching is server-side: muxterm holds a PTY per attached session and routes the active one to the WebSocket. No ttyd. No proxy.
Python keeps what it's good at: the dashboard API, session enumeration, settings, federation orchestration, poll loop.
**Rejected alternatives:**
- **Approach B (Python owns PTYs directly):** Single language, simplest distribution. But Python is still in every byte's path. `asyncio` PTY I/O works but isn't great under heavy output. Signal handling and PTY lifecycle in Python requires careful work. Fighting the language for a systems task.
- **Approach C (expose ttyd directly, bypass Python):** Eliminates the relay, but ttyd is still one-command-per-process. Session switching still requires process lifecycle management. Moves the complexity rather than eliminating it.
## Architecture
Replace the terminal data path only. The Python dashboard/API stays.
**What changes:** ttyd + Python WS proxy → muxterm (Go binary), xterm.js → ghostty-web
**What doesn't change:** Python FastAPI (dashboard, API, settings, federation, session enumeration, poll loop), frontend JS (app.js, sidebar, grid, views, settings, style.css), sessions.py, index.html (SPA shell)
```
UNCHANGED: Browser ←HTTP→ Python FastAPI (dashboard, API, settings, federation)
CHANGED: Browser ←WS→ muxterm (Go) ←PTY→ tmux attach
```
Full stack:
```
Browser Server
┌──────────────────┐ ┌─────────────────────────────┐
│ ghostty-web │ │ muxterm (Go) │
│ (WASM parser) │◄──── WS ──► PTY pool (creack/pty) │
│ + Canvas render │ │ session routing │
│ │ │ control protocol │
└──────────────────┘ └─────────────────────────────┘
┌──────────────────┐ │ process mgmt only
│ app.js │ ┌────────┴────────────────────┐
│ (dashboard, │◄── HTTP ──►│ Python FastAPI │
│ sidebar, grid, │ │ dashboard API, settings, │
│ settings) │ │ session enum, federation, │
└──────────────────┘ │ auth, poll loop │
└─────────────────────────────┘
```
Python starts muxterm as a managed subprocess on startup (one process, started once). muxterm listens on a localhost TCP port (e.g., 7682 — recycling the old ttyd port). The browser connects directly to muxterm's WebSocket endpoint after obtaining a short-lived auth token from Python. Python is never in the terminal data path — not even for the initial WebSocket handshake.
## Components
### muxterm (Go binary)
muxterm manages a map of sessions → PTYs. Each entry:
```
session "dev-server" → {
pty_fd: file descriptor (master side of PTY pair)
process: child process handle (tmux attach -t dev-server)
size: {cols, rows}
}
```
**Lifecycle:**
- **Attach:** Client requests session → muxterm checks the map. If no entry, calls `pty.Start(exec.Command("tmux", "attach", "-t", session_name))` → stores fd + process. If entry exists and process is alive, reuses it. ~1ms cold start.
- **Detach:** When the last WebSocket disconnects from a session, the PTY stays alive (tmux is still attached). Next connection reuses it instantly.
- **Cleanup:** On tmux session death (process exits), muxterm removes the map entry and closes the fd. Detected via process wait.
**Session switching over one WebSocket:** Binary frames for terminal I/O (hot path), text frames for control messages (cold path). When the browser sends an attach command, muxterm swaps which PTY fd the relay goroutines read/write. The old PTY stays alive in the map. Switch time: a pointer swap — microseconds.
**Size:** ~500-1000 lines of Go. Dependencies: `creack/pty` for PTY management, a WebSocket library (gorilla or nhooyr) for the browser-facing endpoint.
### ghostty-web (frontend terminal)
Drop-in replacement for xterm.js. Same `new Terminal()` API. Uses libghostty-vt compiled to WebAssembly — the same VT parser / terminal state machine that runs the native Ghostty desktop terminal. Battle-tested, fuzz-tested, 3+ years of production use.
**What it provides:** Terminal state management, VT/ANSI/XTERM escape sequence parsing, cursor position tracking, styles, text reflow, scrollback, Unicode/grapheme handling, Kitty keyboard protocol, mouse tracking.
**What it doesn't provide:** PTY management, networking, WebSocket — those are muxterm's job.
**Bundle size:** ~400KB WASM.
### Python FastAPI (unchanged role)
Keeps all non-terminal responsibilities:
- Dashboard API (session list, delete, rename, state)
- Settings management
- Federation orchestration
- Session enumeration and snapshot polling
- Auth cookie/bearer/localhost management
- Static file serving (SPA)
- muxterm process supervision (start, restart on crash)
## Data Flow
### Terminal I/O (hot path)
```
Keystroke → ghostty-web → WS binary frame → muxterm → write(pty_fd) → tmux
tmux output → read(pty_fd) → muxterm → WS binary frame → ghostty-web → render
```
One hop. Raw bytes. No relay, no framing overhead, no Python in the path.
### Session switch
1. User clicks sidebar tile → `app.js` sends `ws.send(JSON.stringify({attach: "dev-server"}))`
2. muxterm receives text frame, parses JSON
3. muxterm checks PTY map — spawns `tmux attach -t dev-server` if needed (~1ms), reuses existing PTY if present (0ms)
4. muxterm sends SIGWINCH to the new PTY → tmux repaints the full screen
5. muxterm swaps relay goroutines to the new PTY fd
6. muxterm sends `{"attached": "dev-server"}` text frame
7. Browser receives confirmation, then full screen repaint arrives as normal binary terminal data
8. ghostty-web renders it — user sees current state of the session immediately
No explicit "screen dump" mechanism. Lean on tmux's native redraw behavior (same as resizing a real terminal).
### Auth flow
1. Browser requests `GET /api/terminal-token` from Python FastAPI
2. Python runs auth check (cookie/bearer/localhost — same as today)
3. If authorized, returns a short-lived HMAC token (30-second TTL, shared secret with muxterm)
4. Browser opens WebSocket directly to muxterm's port, including the token
5. muxterm validates the HMAC locally (no callback to Python) and accepts the connection
6. Python is never in the terminal data path — not even for the initial handshake
muxterm validates tokens via HMAC with a shared secret configured at startup. No network calls to Python during connection.
## Wire Protocol
Two frame types over one WebSocket. WebSocket natively distinguishes text and binary frame types — no additional framing needed.
### Binary frames — terminal I/O (hot path)
- **Client → server:** Raw bytes from keyboard, sent directly to active PTY
- **Server → client:** Raw bytes from PTY, sent directly to ghostty-web
- No type prefix byte, no envelope. Raw bytes in, raw bytes out. Zero overhead per frame.
### Text frames — control messages (cold path)
**Client → Server:**
| Message | Purpose |
|---------|---------|
| `{"attach": "session-name"}` | Switch active PTY |
| `{"resize": {"cols": 120, "rows": 40}}` | Resize active PTY |
| `{"detach": true}` | Detach (keep PTY alive) |
**Server → Client:**
| Message | Purpose |
|---------|---------|
| `{"attached": "session-name"}` | Confirm switch complete |
| `{"error": "session not found"}` | Attach failed |
| `{"exited": "session-name"}` | tmux session died, PTY closed |
### Design rationale (informed by Zellij)
Zellij uses two separate WebSocket connections for terminal data and control. They noted this separation "ended up not being necessary" because their application layer already batches messages efficiently. Our single-channel approach with binary/text frame types achieves the same logical separation without managing two connections.
The SIGWINCH-based screen sync on session switch also comes from studying Zellij's approach — instead of building a screen dump mechanism, leverage tmux's native redraw behavior.
## Error Handling
| Failure | Response |
|---------|----------|
| `tmux attach` fails (session doesn't exist) | Send `{"error": "session not found"}` over control channel. Browser shows toast. |
| PTY process exits (tmux session killed) | Send `{"exited": "session-name"}`. Browser returns to dashboard grid. Clean up map entry. |
| muxterm crashes | Python detects child exit, restarts muxterm. Browser WS reconnect fires, reattaches. PTYs die with the process, but tmux sessions survive — new PTYs created on reattach. |
| WebSocket drops (network) | Browser reconnects, sends `{"attach": "last-session"}`. muxterm reuses existing PTY. Seamless. |
| Python crashes | muxterm keeps running (independent process). Terminal stays live. Dashboard API down until Python restarts, but terminal is uninterrupted. |
**Key resilience property:** tmux sessions are the source of truth. Everything else (PTYs, WebSockets, muxterm, Python) is ephemeral and reconstructable. Nothing persists that can get stale or corrupt.
## Distribution & Lifecycle
### muxterm binary
Built as a Go binary, cross-compiled for linux/amd64, linux/arm64, darwin/amd64, darwin/arm64. Bundled into the Python package as platform-specific wheels (same pattern as ruff). `uv tool install muxplex` gets you everything — Python dashboard + Go terminal server.
### ghostty-web
Vendored into `muxplex/frontend/vendor/` alongside existing vendor pattern. WASM bundle is ~400KB. xterm.js files get removed.
### Process lifecycle
- Python FastAPI starts muxterm as a managed subprocess on startup (one process, started once)
- muxterm listens on a localhost TCP port (e.g., 7682)
- If muxterm dies, Python detects it and restarts it (simple process supervision)
- On shutdown, Python sends SIGTERM to muxterm, which gracefully closes all PTYs
- PTYs are just file descriptors — tmux sessions survive muxterm restarts (tmux is independent)
### What this replaces
- No `kill_orphan_ttyd()` — single known process, Python holds the handle
- No port range allocation — single fixed port, not a dynamic pool
- No PID file management — Python holds the process handle directly
## What Gets Deleted
- `ttyd.py` — all of it (process pool, PID files, port allocation, kill/spawn)
- Python WS proxy in `main.py` (`terminal_ws_proxy`, `_ttyd_is_listening`, auto-spawn logic)
- `POST /api/sessions/{name}/connect` endpoint — muxterm handles attach directly
- Terminal cache in `terminal.js` (no need for multiple xterm instances)
- ttyd as a system dependency
- xterm.js vendor files (replaced by ghostty-web)
## What Gets Added
- `muxterm/` — Go module, small binary (~500-1000 lines)
- ghostty-web npm package vendored into `frontend/vendor/`
## Testing Strategy
### muxterm (Go)
- Unit tests for PTY map operations (attach, detach, cleanup on process exit)
- Unit tests for wire protocol parsing (control message JSON, binary frame routing)
- Integration tests: spawn real tmux sessions, attach via WebSocket, verify I/O round-trip
- Stress test: rapid session switching, concurrent connections
### Frontend
- Verify ghostty-web drop-in compatibility (same Terminal API as xterm.js)
- Test session switch flow: send attach control message, verify screen repaint
- Test error paths: attach to non-existent session, handle exited notification
### Integration
- End-to-end: browser → Python auth → muxterm → tmux, verify terminal interactivity
- Failure scenarios: kill muxterm mid-session (verify Python restarts it, browser reconnects)
- Verify WebSocket drop recovery (disconnect network, reconnect, verify session resumes)
## Migration Path
Migration is incremental, not a big bang. Two independent phases:
**Phase 1 — ghostty-web swap:** Replace xterm.js with ghostty-web in `frontend/vendor/`. Update script tags in `index.html`. API-compatible drop-in. Existing ttyd+proxy architecture stays. Immediate benefit: better VT parsing, fewer rendering bugs.
**Phase 2 — muxterm Go binary:** Build the Go binary, add it to the Python package, update Python startup to launch it, wire the WS endpoint. Delete `ttyd.py`, the Python WS proxy, the terminal cache, the process pool.
Either phase can ship and be tested independently.
## Design Decisions & Rationale
1. **Go for the terminal binary, not Rust:** Go's goroutines are purpose-built for the "copy bytes between two fds" pattern. `creack/pty` is mature. Cross-compilation is trivial. The binary is ~500-1000 lines — Go's simplicity is a feature at this scale.
2. **Single WebSocket with binary/text frame separation, not dual channels (Zellij pattern):** Zellij uses two separate WebSocket connections for terminal data and control. They noted the separation "ended up not being necessary." Our frame-type approach achieves the same logical separation without managing two connections.
3. **SIGWINCH for screen sync on session switch:** Instead of building a screen dump mechanism, leverage tmux's native redraw (triggered by SIGWINCH). This is exactly what happens when you resize a terminal — tmux repaints everything.
4. **Token-based auth, not socket relay:** Python issues short-lived HMAC tokens; the browser connects directly to muxterm. Eliminates any Python relay (even at the socket level). muxterm validates tokens locally with a shared secret — no callback to Python during connection. This is the cleanest "Python out of the data path" design.
5. **ghostty-web over xterm.js:** API-compatible drop-in with better VT100 parsing via WASM. The terminal industry is moving this direction (Coder's ghostty-web has 2.5K stars). Reduces parser bugs that are outside our control.
## Open Questions
- ~~**Socket handoff mechanism (RESOLVED):**~~ muxterm owns its own WebSocket listener on a localhost TCP port. Python is never in the terminal data path. Auth works via short-lived HMAC tokens: Python's `/api/terminal-token` endpoint authenticates the user (cookie/bearer/localhost) and returns a 30-second HMAC token. The browser includes this token when connecting to muxterm's WebSocket. muxterm validates the HMAC locally (shared secret with Python, no callback). This eliminates any form of Python relay or socket splicing.
- **ghostty-web maturity:** How stable is the API surface? Are there xterm.js addon equivalents (search, web links, fit) or do those need custom implementation?
- **WASM bundle caching:** Does the ~400KB ghostty-web WASM bundle cache well across page loads, or does it need explicit Cache-Control headers?
- **muxterm port:** Should the localhost port be configurable (env var / settings), or is a fixed default (e.g., 7682) sufficient?
- **Federation implications:** Remote muxplex instances currently proxy through the Python WS relay. Does muxterm need its own federation-aware mode, or does Python remain the federation proxy (now forwarding to remote muxterm instances)?