6c8cfe9157
Implements a resilient, responsive web terminal that survives network blips, provides instant typing feedback, and restores scrollback on reconnect. Backend changes: - Add heartbeat tracking (15s ping interval, 60s idle timeout) - Add message batching (16ms flush window) for efficient I/O - Add termios echo detection and set_echo_state control messages - Add graceful session_ended notification before close - Add ping/pong protocol support Frontend changes: - Rewrite TerminalComponent with status bar, connection indicator, session-ended overlay, reconnect banner, and ResizeObserver - Add useTerminalConnection hook with: - Exponential backoff auto-reconnect (1s → 30s max, 10 attempts) - Heartbeat/ping-pong with latency tracking - Local echo for printable ASCII with server deduplication - Resize debounce (200ms) + throttle (500ms) - Scrollback serialization via xterm-addon-serialize - Ctrl+Shift+R manual reconnect shortcut - Add WebSocket protocol types and encoding utilities - Add xterm-addon-serialize dependency Tests: - 16 backend unit tests (TerminalSession + TerminalManager) - 13 frontend hook tests (connection lifecycle, reconnect, resize, scrollback, callbacks) Quality gates: - Frontend typecheck: clean - Frontend lint: clean - Frontend tests: 48 passed - Backend unit tests: 101 passed - Backend ruff: clean SDD artifacts: openspec/changes/responsive-terminal/
16 KiB
16 KiB
Design: Responsive Web Terminal
Architecture Overview
┌─────────────────────────────────────────────────────────────────────────┐
│ BROWSER │
│ ┌──────────────┐ ┌─────────────────┐ ┌──────────────────────────┐ │
│ │ TerminalPage │ │ TerminalComponent │ │ TerminalConnection │ │
│ │ (router) │◄──│ (xterm.js + UI) │◄──│ (WS + heartbeat + echo) │ │
│ └──────────────┘ └─────────────────┘ └──────────────────────────┘ │
│ │ │ │
│ ┌─────┴─────┐ ┌──────┴──────┐ │
│ │ xterm.js │ │ sessionStorage│ │
│ │ + addons │ │ (scrollback) │ │
│ └───────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│ WebSocket
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ FASTAPI │
│ ┌──────────────────┐ ┌──────────────────┐ ┌─────────────────────┐ │
│ │ terminal.py │ │ TerminalManager │ │ TerminalSession │ │
│ │ (WS endpoint) │◄──│ (session mgmt) │◄──│ (PTY + docker exec) │ │
│ └──────────────────┘ └──────────────────┘ └─────────────────────┘ │
│ │ │
│ ┌────┴────┐ │
│ │ docker │ │
│ │ exec │ │
│ └─────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
Connection State Machine
Client State Machine
┌─────────────┐
│ IDLE │
└──────┬──────┘
│ mount
▼
┌─────────────┐
│ CONNECTING │◄────────────────────────┐
└──────┬──────┘ │
│ onopen │
▼ │
┌─────────────────────────┐ │
│ CONNECTED │ │
│ (heartbeat active) │ │
└──────┬──────────┬───────┘ │
│ │ │
onclose/ │ │ ping timeout │
onerror │ │ │
▼ ▼ │
┌─────────────────────────┐ │
│ RECONNECTING │───────────────────┘
│ (backoff: 1→2→4→8→30s) │ onopen (success)
└──────┬──────────────────┘
│ max retries (10)
▼
┌─────────────────────────┐
│ DISCONNECTED │
│ (manual reconnect │
│ or navigate away) │
└─────────────────────────┘
Server State Machine (per session)
┌─────────────┐
│ PENDING │
└──────┬──────┘
│ ws.accept()
▼
┌─────────────┐
┌────►│ ACTIVE │◄────┐
│ │ (I/O loops │ │
│ │ + heartbeat) │
│ └──────┬──────┘ │
│ │ │
│ ws close│ new ws │
│ ▼ │
│ ┌─────────────┐ │
└─────┤ CLOSED ├──────┘
│ (cleanup) │
└─────────────┘
Protocol Specification
Message Types
All control messages are JSON text frames. Raw terminal I/O uses binary frames.
Client → Server
| Type | Payload | When |
|---|---|---|
ping |
{ id: number } |
Every 15s of inactivity |
pong |
{ id: number } |
Response to server ping |
resize |
{ cols: number, rows: number } |
Terminal size changes (debounced) |
input |
{ data: string } |
User keystrokes (base64-encoded) |
Server → Client
| Type | Payload | When |
|---|---|---|
pong |
{ id: number } |
Response to client ping |
status |
{ status: "connected" | "reconnected" } |
After auth + session ready |
set_echo_state |
{ enabled: boolean } |
When PTY echo flag changes |
session_ended |
{ reason: string } |
When container process exits |
Binary Frame Convention
- Client → Server: Raw UTF-8 bytes of user input. No wrapping.
- Server → Client: Raw bytes from PTY master read. No wrapping.
This avoids the current Blob→ArrayBuffer async conversion and JSON parsing overhead for the hot path.
Frontend Design
New Files
apps/web/src/
├── components/
│ └── terminal.tsx (rewrite: state machine + reconnect)
├── hooks/
│ └── use-terminal-connection.ts (NEW: WS lifecycle, heartbeat, reconnect)
├── utils/
│ └── terminal-protocol.ts (NEW: message encoding/decoding)
└── types/
└── terminal.ts (NEW: protocol types)
useTerminalConnection Hook
Responsibilities:
- WebSocket lifecycle: Open, close, reconnect with backoff
- Heartbeat: Send ping every 15s, expect pong within 5s
- Local echo: Write printable chars to xterm immediately, deduplicate server echo
- Resize: Debounce resize events, send JSON control message
- Scrollback: Serialize on disconnect, restore on reconnect
- State reporting: Expose
status,latency,attemptto UI
interface TerminalConnectionState {
status: "connecting" | "connected" | "reconnecting" | "disconnected";
attempt: number;
latency: number | null; // last RTT in ms
error: string | null;
}
interface TerminalConnection {
state: TerminalConnectionState;
sendInput: (data: string) => void;
sendResize: (cols: number, rows: number) => void;
reconnect: () => void; // manual, bypasses backoff
onData: (callback: (data: Uint8Array) => void) => void;
onControl: (callback: (msg: ServerControlMessage) => void) => void;
}
Local Echo Algorithm
1. User types character c
2. IF c is printable ASCII AND echo is enabled:
a. Write c to xterm immediately
b. Add c to "pending echo" buffer
c. Send c to server via WebSocket
3. ELSE (control char, arrow, escape sequence):
a. Send c to server only
b. Do NOT write to xterm
4. When server sends data:
a. For each char in server data:
- IF char matches head of "pending echo" buffer:
→ Pop from buffer (deduplication)
- ELSE:
→ Write char to xterm
b. If "pending echo" buffer grows > 100 chars (stale):
→ Flush buffer to xterm (server echo was lost)
Scrollback Serialization
ON disconnect:
1. buffer = xterm.serialize({ scrollback: 10000 })
2. sessionStorage.setItem(`hq-terminal-${instanceId}`, buffer)
ON reconnect:
1. buffer = sessionStorage.getItem(`hq-terminal-${instanceId}`)
2. IF buffer:
xterm.write(buffer)
xterm.write("\r\n\x1b[90m--- Reconnected ---\x1b[0m\r\n")
3. sessionStorage.removeItem(`hq-terminal-${instanceId}`)
Resize Debouncing
Use ResizeObserver on the terminal container instead of window.resize:
const resizeObserver = new ResizeObserver(
debounce((entries) => {
fitAddon.fit();
sendResize(term.cols, term.rows);
}, 200)
);
Rate limit: max 1 resize message per 500ms.
Backend Design
Modified Files
apps/api/src/
├── api/terminal.py (modify: ping/pong, session_ended)
├── services/terminal_manager.py (rewrite: heartbeat tracking, batching)
└── services/terminal_session.py (modify: batching read, echo detection)
TerminalManager Changes
Heartbeat tracking:
- Track
last_ping_atper session - Background task: if
last_ping_atis older than 60s, close the WebSocket
Message batching in read_loop:
async def _read_loop(self, session, websocket):
buffer = bytearray()
last_flush = time.monotonic()
while session.is_alive() and not session._closed:
data = await session.read_output()
if data:
buffer.extend(data)
now = time.monotonic()
if buffer and (now - last_flush >= 0.016 or not data):
await websocket.send_bytes(bytes(buffer))
buffer.clear()
last_flush = now
elif not data:
await asyncio.sleep(0.001)
Reconnect support:
- When a new WebSocket connects for the same instance, terminate the old session and spawn a new one
- This is the docker exec limitation — we cannot resume a PTY, only replace it
TerminalSession Changes
Echo state detection:
import termios
def _detect_echo_state(self) -> bool:
if self._master_fd is None:
return True
try:
attrs = termios.tcgetattr(self._master_fd)
return bool(attrs[3] & termios.ECHO)
except:
return True
Call _detect_echo_state() after each resize and periodically (every 1s) during active I/O. Send set_echo_state to client when it changes.
Batch-friendly read:
- Change
read_output()to useasyncio.wait_for(select, timeout)instead of blockingselect.selectwith 0.1s timeout - Return immediately when data is available, sleep briefly when not
Terminal Endpoint Changes
- Accept
pingmessages, respond withpong - On session end (process exit), send
session_endedbefore closing with code 1000 - Distinguish between container exit (friendly) and error (unexpected)
Data Flow: Typing with Local Echo
User presses 'a'
│
▼
┌─────────────────┐
│ onData handler │──► xterm.write('a') [instant feedback]
│ │──► pendingEcho.push('a')
│ │──► ws.send(binary 'a')
└─────────────────┘
│
▼ (network)
┌─────────────────┐
│ TerminalSession │──► os.write(master_fd, b'a')
│ │──► docker exec PTY echoes 'a' back
│ │──► os.read(master_fd) → b'a'
└─────────────────┘
│
▼ (WebSocket)
┌─────────────────┐
│ onMessage │──► data = b'a'
│ (binary frame) │──► IF data[0] == pendingEcho[0]:
│ │ pendingEcho.shift() // dedup
│ │ ELSE:
│ │ xterm.write(data)
└─────────────────┘
Data Flow: Reconnection
WebSocket closes (code 1006)
│
▼
┌─────────────────┐
│ ConnectionState │──► status = "reconnecting"
│ │──► attempt = 1
│ │──► scrollback = xterm.serialize()
│ │──► sessionStorage.setItem(key, scrollback)
│ │──► schedule reconnect in 1s
└─────────────────┘
│
▼ (1s later)
┌─────────────────┐
│ Reconnect │──► new WebSocket(url)
│ │──► onopen: send scrollback from storage
│ │──► xterm.write(restored + divider)
│ │──► status = "connected"
└─────────────────┘
Component Responsibilities
| Component | Responsibilities |
|---|---|
TerminalPage |
Routing, layout, back button |
TerminalComponent |
xterm.js lifecycle, addons, theme, status bar UI |
useTerminalConnection |
WebSocket, heartbeat, reconnect, local echo, resize |
terminal-protocol |
Encode/decode control messages, base64 helper |
terminal.py (API) |
Auth, WebSocket accept, route control messages |
TerminalManager |
Session lifecycle, heartbeat tracking, read/write loops |
TerminalSession |
PTY + docker exec, echo detection, batching read |
Tradeoffs
| Decision | Option A (Chosen) | Option B | Why A |
|---|---|---|---|
| Reconnect strategy | Exponential backoff, max 30s | Instant reconnect with no backoff | Backoff prevents server overload during outages |
| Local echo scope | Printable ASCII only | All characters | Control chars/escapes need server-side processing (shell state) |
| Scrollback storage | sessionStorage (tab-scoped) |
localStorage (persistent) |
Privacy: terminal may contain secrets |
| Scrollback cap | 10,000 lines | Unlimited | Memory safety; 10K lines covers typical session |
| Heartbeat interval | 15s client → server | 5s | Balance between detection speed and server load |
| Binary vs text I/O | Binary frames for raw data | JSON-wrapped base64 | Binary is ~33% more efficient, zero parse overhead |
| Resize trigger | ResizeObserver on container | window.resize | Container-level is more accurate for flex layouts |
| Echo detection | Server inspects PTY termios | Client guesses from input | Server is authoritative; client cannot know shell state |
| New docker exec on reconnect | Accept limitation | Implement persistent session | PTY resumption across connections is extremely complex; scrollback continuity is the pragmatic fix |
Quality Gates
cd apps/web && npm run typecheck— TypeScript compilescd apps/web && npm run lint— ESLint passescd apps/web && npm test— Vitest passes (new tests for protocol + hook)make test— Backend pytest passes- Manual test: disconnect/reconnect, type latency, resize, container exit