c754984df8
Complete rewrite of the terminal pipeline for VS Code Server-level responsiveness. Key improvements: Backend: - Replace blocking select.select(0.1) with asyncio.add_reader() for event-driven PTY reading (eliminates ~110ms polling latency) - Add output batching (2ms window) to reduce WebSocket frame overhead - Add flow control: client acks processed bytes, server pauses PTY reads at 64KB threshold, resumes at 32KB - Add 5s ack timeout fallback to prevent stuck sessions Frontend: - Switch WebSocket to binary mode (binaryType = 'arraybuffer') - Eliminate Blob -> arrayBuffer async conversion overhead - Add flow control ack messages (every 4096 bytes or 100ms) - Add xterm-addon-webgl with graceful DOM fallback - Add performance tuning (scrollback=10000, fastScrollSensitivity) SDD artifacts: - openspec/explorations/terminal-responsiveness.md - openspec/proposals/terminal-responsiveness.md - openspec/specs/terminal-responsiveness.md - openspec/designs/terminal-responsiveness.md - openspec/tasks/terminal-responsiveness.md Quality gates: pytest (19 passed, 1 skipped), tsc --noEmit clean
161 lines
6.5 KiB
Markdown
161 lines
6.5 KiB
Markdown
# 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
|