# SDD Exploration: Responsive Web Terminal ## Status **Phase:** explore **Date:** 2026-06-02 **Owner:** el Gentleman (parent session) **Scope:** Terminal I/O latency, rendering performance, connection stability ## Goal Achieve VS Code Server-level terminal responsiveness: near-local latency on keystrokes, smooth scrolling, no jank on output bursts, and instant resize reactions. ## Current Architecture ### Data Flow ``` Container shell → docker exec PTY → host PTY master fd → select.select(0.1s) → Python read loop (10ms sleep fallback) → WebSocket.send_bytes() → WebSocket (Blob mode) → frontend arrayBuffer decode → xterm.js.write() ``` ### Key Files | File | Responsibility | |------|---------------| | `apps/web/src/components/terminal.tsx` | xterm.js, WebSocket client, FitAddon | | `apps/api/src/api/terminal.py` | WebSocket endpoint, auth, read/write/heartbeat loops | | `apps/api/src/services/terminal_session.py` | PTY creation, docker exec subprocess, I/O | | `apps/api/src/services/terminal_manager.py` | Session lifecycle, persistence, idle cleanup | ### Current Bottlenecks #### 1. Blocking Read with 100ms Timeout ```python # terminal_session.py:read_output() readable, _, _ = select.select([self._master_fd], [], [], 0.1) if readable: data = os.read(self._master_fd, 4096) ``` **Problem:** `select.select` blocks up to 100ms when no data is available. With the read loop in `terminal.py` doing `asyncio.sleep(0.01)` between calls, worst-case latency from shell output to WebSocket is ~110ms. **VS Code approach:** node-pty uses libuv's epoll/kqueue watchers — event-driven, no polling timeout. #### 2. WebSocket Blob → arrayBuffer Conversion ```typescript // terminal.tsx ws.onmessage = (event) => { if (event.data instanceof Blob) { event.data.arrayBuffer().then((buffer) => { const data = new Uint8Array(buffer); termRef.current?.write(data); }); } } ``` **Problem:** Blob → arrayBuffer is async and adds microtask latency. Also forces GC pressure from transient Blobs. **VS Code approach:** Uses `ws.binaryType = "arraybuffer"` — receives ArrayBuffer directly, zero-copy into Uint8Array. #### 3. No asyncio-Native PTY Reading The PTY master fd is read with synchronous `os.read()` inside an async coroutine. This blocks the event loop thread for the duration of the read. **VS Code approach:** node-pty's C++ binding hooks into libuv's event loop natively — true async I/O. #### 4. Docker Exec Subprocess Overhead ```python # terminal_session.py:start() self.process = await asyncio.create_subprocess_exec( "docker", "exec", "-it", "-e", "TERM=xterm", self.container_id, "bash", "-c", shell_cmd, stdin=self._slave_fd, stdout=self._slave_fd, stderr=self._slave_fd, ) ``` **Problem:** Spawns a new `docker exec` process on the host. Adds process startup latency and an extra process hop. **Alternative:** Docker Engine API's `attach` endpoint with `logs=0&stream=1&stdin=1&stdout=1&stderr=1` — streams directly to the API container via Unix socket. No host subprocess. #### 5. No Flow Control / Backpressure If a command dumps output faster than the WebSocket can send (e.g., `cat /dev/urandom | base64`), data piles up in: - The PTY kernel buffer (limited, ~4KB) - Python's deque circular buffer (10KB) - WebSocket's internal buffer (unbounded in some implementations) - xterm.js parser queue **VS Code approach:** Implements explicit flow control — pauses the PTY when the client buffer exceeds a threshold, resumes when drained. #### 6. xterm.js Renderer Current: DOM renderer (default). **VS Code approach:** Canvas renderer with WebGL addon for GPU-accelerated rendering. ## Measurement Baseline Before optimization, we need metrics: | Metric | How to Measure | Target | |--------|---------------|--------| | Input latency | Time from keypress to character appearing | < 16ms (1 frame) | | Output throughput | Bytes/sec for `cat /dev/zero` | > 1 MB/s | | Resize latency | Time from resize message to shell reacting | < 50ms | | Reconnection time | Time from disconnect to full replay | < 200ms | | Frame drops | Dropped frames during `yes` command | 0 | ## Improvement Directions ### Direction A: Low-Latency Read Loop (Quick Win) Replace `select.select` + `os.read` with `asyncio` native approach: - Use `loop.add_reader()` to register a callback when fd is readable - Or use `asyncio.to_thread()` with blocking `os.read` and immediate wake - Eliminate the 100ms timeout and 10ms sleep ### Direction B: WebSocket Binary Mode (Quick Win) Set `ws.binaryType = "arraybuffer"` on frontend, send binary frames directly. Eliminates Blob → arrayBuffer conversion. ### Direction C: Docker Engine API Attach (Medium) Replace `docker exec` subprocess with direct container attach via Docker SDK or HTTP API: ```python from docker import DockerClient client = DockerClient() container = client.containers.get(container_id) socket = container.attach_socket(params={...}) # socket is a raw TCP/Unix socket — read with asyncio ``` **Pros:** No subprocess overhead, direct stream to container **Cons:** Requires Docker SDK or raw HTTP over Unix socket; needs `docker` group permissions ### Direction D: Flow Control (Medium) Add backpressure mechanism: 1. Measure WebSocket send buffer depth on backend 2. Pause reading from PTY when buffer exceeds threshold (e.g., 64KB) 3. Resume when buffer drains below threshold 4. Frontend: measure xterm.js parser queue depth, pause via control message ### Direction E: WebGL Renderer (Quick Win) Add xterm-addon-webgl: ```typescript import { WebglAddon } from 'xterm-addon-webgl'; term.loadAddon(new WebglAddon()); ``` **Pros:** GPU-accelerated, much faster for large output bursts **Cons:** Falls back to canvas/DOM if WebGL unavailable; slightly higher init time ### Direction F: Output Batching (Quick Win) Batch small writes before sending over WebSocket: - Collect output for 1-2ms - Send as single binary frame - Reduces WebSocket frame overhead for high-frequency small writes (e.g., progress bars) ## Recommended Next Steps 1. **Measure baseline** with synthetic benchmarks 2. **Implement Directions A + B + F** (low-risk, high-impact) 3. **Evaluate Direction C** (Docker API attach) vs keeping docker exec 4. **Add Direction D** (flow control) if throughput tests show issues 5. **Add Direction E** (WebGL) as frontend enhancement ## Risks - Docker API attach may not support PTY mode as cleanly as `docker exec -it` - WebGL addon may have compatibility issues on older GPUs - Flow control adds complexity; premature optimization risk - Changes to core I/O loop could introduce stability regressions