feat: high-performance web terminal with asyncio-native I/O

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
This commit is contained in:
Alex Blank
2026-06-02 14:40:32 +02:00
parent 906aab3b73
commit c754984df8
11 changed files with 984 additions and 114 deletions
+176
View File
@@ -0,0 +1,176 @@
# 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 <container_id> 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.