Files
headquarter/openspec/specs/terminal-responsiveness.md
T
Alex Blank c754984df8 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
2026-06-02 14:40:32 +02:00

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 -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:

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:

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 ack message 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 -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.