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
177 lines
5.5 KiB
Markdown
177 lines
5.5 KiB
Markdown
# 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.
|