# Spec: High-Performance Web Terminal ## Overview Complete rewrite of the terminal I/O pipeline for sub-frame latency and smooth rendering. ## Architecture ### Data Flow (New) ``` Container shell → docker exec PTY → host PTY master fd → asyncio.add_reader() callback (event-driven, zero polling) → output batcher (2ms window) → WebSocket.send_bytes() → WebSocket binary frame → frontend ArrayBuffer → xterm.js WebGL renderer → screen ``` ### Components #### 1. TerminalSession (backend) **Responsibilities:** - Create PTY via `pty.openpty()` - Spawn `docker exec -it` with slave fd attached - Read from PTY master fd using `asyncio.add_reader()` - Batch output (2ms window) before sending to WebSocket - Handle flow control (pause/resume reads based on client ack) - Resize via `TIOCSWINSZ` + `SIGWINCH` **Interface:** ```python class TerminalSession: async def start(self) -> None async def read_loop(self, websocket) -> None # event-driven async def write_input(self, data: bytes) -> None async def resize(self, cols: int, rows: int) -> None async def reset(self) -> None async def close(self) -> None # Flow control def acknowledge_data(self, char_count: int) -> None def pause_output(self) -> None def resume_output(self) -> None ``` #### 2. TerminalManager (backend) Unchanged responsibilities (session lifecycle, persistence, idle cleanup). #### 3. WebSocket Handler (backend) **Messages:** | Direction | Type | Payload | Description | |-----------|------|---------|-------------| | C → S | `input` | `{"data": "base64"}` | Keystrokes / input | | C → S | `resize` | `{"cols": 80, "rows": 24}` | Terminal resize | | C → S | `ack` | `{"chars": 1024}` | Flow control ack | | C → S | `reset` | `{}` | Reset session | | S → C | `binary` | raw bytes | Terminal output | | S → C | `status` | `{"status": "connected"}` | Connection status | | S → C | `ping` | `{}` | Heartbeat | **Key changes:** - Output is sent as **binary WebSocket frames**, not Blob - Flow control: server tracks unacknowledged bytes, pauses PTY reads at 64KB threshold #### 4. TerminalComponent (frontend) **Key changes:** - `ws.binaryType = "arraybuffer"` before connection - Binary frames written directly to xterm.js as `Uint8Array` - Flow control: send `ack` messages every 4096 processed bytes - WebGL renderer with DOM fallback - Batch resize messages (debounce 50ms) **xterm.js config:** ```typescript const term = new Terminal({ cursorBlink: true, fontSize: currentFontSize, fontFamily: 'Menlo, Monaco, "Courier New", monospace', lineHeight: 1.2, letterSpacing: 0, allowTransparency: false, scrollback: 10000, // Performance options ignoreBracketedPasteMode: false, fastScrollSensitivity: 5, scrollSensitivity: 1, }); ``` ### Flow Control Protocol **Server-side buffer tracking:** ```python self._unacknowledged_bytes = 0 self._flow_control_threshold = 64 * 1024 # 64KB self._paused = False def on_output(self, data: bytes) -> None: self._unacknowledged_bytes += len(data) if self._unacknowledged_bytes > self._flow_control_threshold: self.pause_output() def acknowledge_data(self, char_count: int) -> None: self._unacknowledged_bytes = max(0, self._unacknowledged_bytes - char_count) if self._paused and self._unacknowledged_bytes < self._flow_control_threshold / 2: self.resume_output() ``` **Client-side ack strategy:** - After every `term.write(data)`, accumulate processed bytes - Send `ack` message every 4096 bytes or 100ms ### Output Batching **Server-side batcher:** ```python self._batch_buffer = bytearray() self._batch_timer: asyncio.TimerHandle | None = None self._batch_window_ms = 2 def queue_output(self, data: bytes) -> None: self._batch_buffer.extend(data) if self._batch_timer is None: self._batch_timer = asyncio.get_event_loop().call_later( self._batch_window_ms / 1000, self._flush_batch ) async def _flush_batch(self) -> None: self._batch_timer = None if self._batch_buffer and websocket.open: await websocket.send_bytes(bytes(self._batch_buffer)) self._batch_buffer.clear() ``` ## Docker Exec Subprocess **Command:** ```bash docker exec -it -e TERM=xterm-256color bash -il ``` **Why keep docker exec:** - Docker SDK `attach()` doesn't support PTY allocation cleanly - `docker exec -it` is the standard way to get an interactive TTY - Subprocess overhead is acceptable compared to PTY latency improvements **Optimization:** Pre-warm the connection by reusing the same `docker exec` process for the session lifetime. ## Error Handling | Scenario | Behavior | |----------|----------| | WebGL init fails | Fall back to DOM renderer, log warning | | Flow control ack lost | Server resumes after timeout (5s) | | PTY fd closed | Close WebSocket with code 4004 | | Docker exec exits | Close WebSocket with code 4004, allow reconnect | | Binary frame too large | Split into multiple frames (max 64KB) | ## Testing Strategy 1. **Unit tests:** Mock PTY fd, verify batching, flow control, resize 2. **Integration tests:** Full WebSocket round-trip with test container 3. **Performance tests:** - `time cat /dev/zero | head -c 10M` — measure throughput - Rapid keypress script — measure input latency - Resize storm — measure resize latency 4. **Browser tests:** WebGL fallback on devices without GPU ## Migration Full rewrite — no migration needed. Old terminal code can be deleted.