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
5.5 KiB
5.5 KiB
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 -itwith 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:
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
ackmessages every 4096 processed bytes - WebGL renderer with DOM fallback
- Batch resize messages (debounce 50ms)
xterm.js config:
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:
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
ackmessage every 4096 bytes or 100ms
Output Batching
Server-side batcher:
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:
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 -itis 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
- Unit tests: Mock PTY fd, verify batching, flow control, resize
- Integration tests: Full WebSocket round-trip with test container
- Performance tests:
time cat /dev/zero | head -c 10M— measure throughput- Rapid keypress script — measure input latency
- Resize storm — measure resize latency
- Browser tests: WebGL fallback on devices without GPU
Migration
Full rewrite — no migration needed. Old terminal code can be deleted.