# 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.
