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
228 lines
9.6 KiB
Markdown
228 lines
9.6 KiB
Markdown
# Design: High-Performance Web Terminal
|
|
|
|
## Component Diagram
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ Browser │
|
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
|
│ │ Terminal │ │ WebSocket │ │ xterm.js │ │
|
|
│ │ Component │──│ Client │──│ + WebGL addon │ │
|
|
│ │ │ │ (binary) │ │ + DOM fallback │ │
|
|
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
|
|
│ │ │ │
|
|
│ │ Flow control ack │ │
|
|
│ └──────────────────────────────────────────────┘ │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
│ WebSocket
|
|
│ (binary frames)
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ API Container │
|
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
|
|
│ │ Terminal │ │ WebSocket │ │ TerminalSession │ │
|
|
│ │ Manager │──│ Endpoint │──│ (new) │ │
|
|
│ │ (lifecycle) │ │ (router) │ │ - asyncio fd reader │ │
|
|
│ └─────────────┘ └─────────────┘ │ - output batcher │ │
|
|
│ │ - flow control │ │
|
|
│ └─────────────────────┘ │
|
|
│ │ │
|
|
│ ┌────────┴────────┐ │
|
|
│ │ docker exec -it │ │
|
|
│ │ (subprocess) │ │
|
|
│ └────────┬────────┘ │
|
|
│ │ │
|
|
│ ┌────────┴────────┐ │
|
|
│ │ Tool Container │ │
|
|
│ │ (bash shell) │ │
|
|
│ └─────────────────┘ │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
## File Changes
|
|
|
|
### New Files
|
|
- `apps/api/src/services/terminal_session_v2.py` — New TerminalSession implementation
|
|
|
|
### Modified Files
|
|
- `apps/api/src/services/terminal_session.py` — Delete (or keep as legacy, user said no legacy needed)
|
|
- `apps/api/src/services/terminal_manager.py` — Update to use new TerminalSession
|
|
- `apps/api/src/api/terminal.py` — Update WebSocket handler for binary frames + flow control
|
|
- `apps/web/src/components/terminal.tsx` — Binary mode, WebGL, flow control ack
|
|
- `apps/web/package.json` — Add `xterm-addon-webgl`
|
|
|
|
## TerminalSession Implementation
|
|
|
|
```python
|
|
class TerminalSession:
|
|
"""High-performance terminal session with asyncio-native I/O."""
|
|
|
|
BUFFER_SIZE = 10 * 1024
|
|
IDLE_TIMEOUT = 30 * 60
|
|
BATCH_WINDOW_MS = 2
|
|
FLOW_CONTROL_THRESHOLD = 64 * 1024
|
|
FLOW_CONTROL_RESUME = 32 * 1024
|
|
|
|
def __init__(self, session_id, instance_id, container_id, ...):
|
|
self._master_fd: int | None = None
|
|
self._process: asyncio.subprocess.Process | None = None
|
|
self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE)
|
|
self._websockets: set[WebSocket] = set()
|
|
self._batch_buffer = bytearray()
|
|
self._batch_timer: asyncio.TimerHandle | None = None
|
|
self._unacknowledged_bytes = 0
|
|
self._paused = False
|
|
self._read_handler_set = False
|
|
|
|
async def start(self):
|
|
self._master_fd, slave_fd = pty.openpty()
|
|
self._set_terminal_size(80, 24)
|
|
|
|
self._process = await asyncio.create_subprocess_exec(
|
|
"docker", "exec", "-it", "-e", "TERM=xterm-256color",
|
|
self.container_id, "bash", "-il",
|
|
stdin=slave_fd, stdout=slave_fd, stderr=slave_fd,
|
|
)
|
|
os.close(slave_fd)
|
|
|
|
def _start_reading(self):
|
|
"""Register fd with asyncio event loop for event-driven reading."""
|
|
if self._read_handler_set or self._master_fd is None:
|
|
return
|
|
loop = asyncio.get_event_loop()
|
|
loop.add_reader(self._master_fd, self._on_fd_readable)
|
|
self._read_handler_set = True
|
|
|
|
def _on_fd_readable(self):
|
|
"""Callback when PTY fd has data available."""
|
|
if self._master_fd is None or self._paused:
|
|
return
|
|
try:
|
|
data = os.read(self._master_fd, 4096)
|
|
if data:
|
|
self._add_to_buffer(data)
|
|
self._queue_output(data)
|
|
self.last_activity = time.time()
|
|
except (OSError, IOError):
|
|
pass
|
|
|
|
def _queue_output(self, data: bytes):
|
|
"""Add to batch buffer, schedule flush."""
|
|
self._batch_buffer.extend(data)
|
|
self._unacknowledged_bytes += len(data)
|
|
|
|
if self._unacknowledged_bytes > self.FLOW_CONTROL_THRESHOLD:
|
|
self._pause_output()
|
|
|
|
if self._batch_timer is None:
|
|
loop = asyncio.get_event_loop()
|
|
self._batch_timer = loop.call_later(
|
|
self.BATCH_WINDOW_MS / 1000, self._flush_batch
|
|
)
|
|
|
|
def _flush_batch(self):
|
|
"""Flush batched output to all WebSockets."""
|
|
self._batch_timer = None
|
|
if not self._batch_buffer:
|
|
return
|
|
|
|
payload = bytes(self._batch_buffer)
|
|
self._batch_buffer.clear()
|
|
|
|
dead = set()
|
|
for ws in self._websockets:
|
|
try:
|
|
asyncio.create_task(ws.send_bytes(payload))
|
|
except Exception:
|
|
dead.add(ws)
|
|
|
|
self._websockets -= dead
|
|
|
|
def acknowledge_data(self, char_count: int):
|
|
"""Client acknowledges processed bytes."""
|
|
self._unacknowledged_bytes = max(0, self._unacknowledged_bytes - char_count)
|
|
if self._paused and self._unacknowledged_bytes < self.FLOW_CONTROL_RESUME:
|
|
self._resume_output()
|
|
|
|
def _pause_output(self):
|
|
"""Pause reading from PTY."""
|
|
if self._read_handler_set and self._master_fd is not None:
|
|
loop = asyncio.get_event_loop()
|
|
loop.remove_reader(self._master_fd)
|
|
self._read_handler_set = False
|
|
self._paused = True
|
|
|
|
def _resume_output(self):
|
|
"""Resume reading from PTY."""
|
|
self._paused = False
|
|
self._start_reading()
|
|
```
|
|
|
|
## Frontend Changes
|
|
|
|
### WebSocket Binary Mode
|
|
```typescript
|
|
const ws = new WebSocket(wsUrl);
|
|
ws.binaryType = "arraybuffer"; // Receive ArrayBuffer directly
|
|
|
|
ws.onmessage = (event) => {
|
|
if (event.data instanceof ArrayBuffer) {
|
|
const data = new Uint8Array(event.data);
|
|
termRef.current?.write(data);
|
|
|
|
// Flow control: acknowledge processed bytes
|
|
ackAccumulator += data.length;
|
|
if (ackAccumulator >= 4096) {
|
|
ws.send(JSON.stringify({ type: "ack", chars: ackAccumulator }));
|
|
ackAccumulator = 0;
|
|
}
|
|
}
|
|
};
|
|
```
|
|
|
|
### WebGL Renderer
|
|
```typescript
|
|
import { WebglAddon } from "xterm-addon-webgl";
|
|
|
|
const webglAddon = new WebglAddon();
|
|
try {
|
|
term.loadAddon(webglAddon);
|
|
} catch (e) {
|
|
console.warn("WebGL failed, using DOM renderer", e);
|
|
}
|
|
```
|
|
|
|
## WebSocket Protocol
|
|
|
|
### Message Types
|
|
|
|
**Client → Server:**
|
|
- `{"type": "input", "data": "base64_encoded"}` — Keystrokes
|
|
- `{"type": "resize", "cols": 80, "rows": 24}` — Resize
|
|
- `{"type": "ack", "chars": 4096}` — Flow control acknowledgment
|
|
- `{"type": "reset"}` — Reset session
|
|
|
|
**Server → Client:**
|
|
- Binary frame: raw terminal output bytes
|
|
- `{"type": "status", "status": "connected"}` — Connection status
|
|
- `{"type": "ping"}` — Heartbeat (server → client)
|
|
|
|
## Testing
|
|
|
|
### Unit Tests
|
|
- `test_terminal_session_v2.py` — Test batching, flow control, resize, reset
|
|
- `test_terminal_manager.py` — Test session lifecycle with new session class
|
|
|
|
### Integration Tests
|
|
- `test_terminal_websocket.py` — Full WebSocket round-trip
|
|
|
|
### Performance Tests
|
|
- `benchmark_terminal_latency.py` — Measure input/output latency
|
|
- `benchmark_terminal_throughput.py` — Measure max throughput
|
|
|
|
## Rollback Plan
|
|
|
|
Since this is a full rewrite with no legacy support:
|
|
- Keep a backup branch of the old terminal code
|
|
- Feature flag in frontend: `?terminal=v2` to test before full rollout
|
|
- Monitor error rates after deployment
|