docs: add terminal websocket design spec

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-03 11:48:13 +08:00
co-authored by Claude
parent a7be9d3f0c
commit 741ff63424
@@ -0,0 +1,206 @@
# Terminal WebSocket Design
Date: 2026-07-03
Branch: feat/terminal-websocket
## Goal
Connect the existing `TerminalPanel` in the web IDE to the running OpenCode
process for the current workspace, so the user can see real stdout/stderr and
type input that is sent to the process's stdin. Stop the terminal from being
a static mock.
The feature must keep the existing HTTP process controls (start/stop/restart)
working, must not break `internal/api/router.go` shape for non-WebSocket
clients, and must not couple the WebSocket lifecycle to the workspace CRUD
path.
## Decisions
- One WebSocket per workspace, keyed by workspace ID. Multiple browser tabs on
the same workspace open multiple sockets; each gets a copy of the stream.
History replay is **not** required for v1: a socket that connects after
output is produced does not see the past output.
- Bidirectional: server streams stdout+stderr, client streams stdin. Both
directions are text frames carrying raw bytes (UTF-8). We do not use a
custom envelope or JSON framing in v1.
- Backend process model gets two new responsibilities: capture child
stdout/stderr into an in-memory ring buffer plus a list of subscriber
channels, and accept stdin writes. This stays inside `internal/process`;
the API layer is a thin WebSocket adapter.
- The existing `Manager` interface grows one method: `Subscribe(workspaceID)
(Subscription, error)`. We also add an `Input` accessor on the service
layer for stdin. The surface stays small.
- Workspace root deletion protection and path-escape protection are unchanged.
The WebSocket path is `/api/workspaces/:id/process/ws`. It does not accept
a `path` query, so there is nothing to validate.
- Backward compatibility: the existing `/process/start|stop|restart|status`
endpoints keep their signatures and 204 / 200 responses. We only add one
new endpoint.
- Frontend: `TerminalPanel` now takes `workspaceId: string | null`. If
`workspaceId` is null, show "select a workspace" empty state. If the
workspace's process is not running, the panel still opens the socket;
the server closes it with a code that the client surfaces as "process not
running, start it from the toolbar".
- Process exit on the server side: server sends a single text frame
containing the literal string `\r\n[process exited with code N]\r\n`
(or "signal: name" for signal exits) and then closes the WebSocket with
close code 1000. This is the only framed message — all other bytes are
raw stream output.
## Architecture
### Backend additions
`internal/process/manager.go`
- Add `Subscribe(workspaceID string) (Subscription, error)` to the `Manager`
interface. `Subscription` is a new type defined in `internal/process`
with two methods:
- `Output() <-chan []byte` — receives chunks of stdout+stderr merged.
Buffered to a small constant (e.g. 32) to avoid blocking the producer.
- `Close() error` — drops the subscription. Idempotent.
- `LocalManager` keeps per-session:
- a `bytes.Buffer` of recent output (cap, e.g. 64 KiB) for any future
replay — implemented but **not exposed in v1**.
- a `[]chan []byte` of subscribers, guarded by `sync.Mutex`.
- a `io.WriteCloser` for stdin (created via `cmd.Stdin = ...`).
- On `Start`, set `cmd.Stdout` and `cmd.Stderr` to a pipe. A new goroutine
per session reads from the pipe, writes to the ring buffer, and fans out
to every subscriber channel. A non-blocking send (`select` with
`default`) drops output for slow clients. A 4 KiB read buffer is fine.
- `Wait()` is called in a separate goroutine; on exit, the manager closes
all subscriber channels and stores the exit status on the session.
`internal/service/process_service.go`
- New method `Subscribe(workspaceID string) (process.Subscription, error)`
that first calls `workspaces.Get` (so an unknown workspace returns
`CodeNotFound` — same as Status) and then delegates to `processes.Subscribe`.
- New method `Input(workspaceID string) (io.WriteCloser, error)` that
returns the session's stdin writer, or `CodeNotFound` if the process is
not running, or `CodeUnavailable` if the session is shutting down.
- New method `ExitStatus(workspaceID string) (process.ExitInfo, error)`
for the handler to retrieve the exit reason after the process terminates.
`internal/api/process_handler.go`
- New handler `ws` that:
1. Looks up the workspace; 404 if missing.
2. Looks up the session; if not running, writes 409 Conflict with a JSON
`{"error":"process not running"}` and returns.
3. Upgrades the connection to a WebSocket using `gorilla/websocket`
(single new dep — see Open Questions).
4. Spawns two goroutines: a read pump that copies WS text frames to the
stdin writer, and a write pump that copies the `Subscription.Output()`
channel to the WS. A `done` channel cancels both.
5. On the read pump seeing a `Close` from the client, it calls
`stdin.Close()` and cancels the writer.
6. On the write pump seeing the subscription channel close, it reads
`ExitStatus` and writes the exit banner text frame, then closes the
socket with code 1000.
`go.mod`
- Add `github.com/gorilla/websocket v1.5.x` (one new dependency, Apache-2.0,
widely used, small).
`internal/api/router.go`
- Register `api.GET("/workspaces/:id/process/ws", procHandler.ws)`.
### Frontend additions
`web/src/lib/api/process.ts`
- Add `useProcessWebSocket(workspaceId, opts?)` hook that:
- Opens `ws[s]://<host>/api/workspaces/:id/process/ws` when `workspaceId`
is set and the workspace is selected.
- Exposes `{ status, send, close }` where:
- `status: "connecting" | "open" | "closed" | "error"`
- `send(data: string)` — no-op when not open.
- `close()` — graceful close.
- Reconnects with exponential backoff (max 3 attempts) only if the close
was unexpected. We do **not** reconnect if the server sent the exit
banner (recognizable by the literal prefix `[process exited`).
- Hook returns an `onData(cb)` subscription mechanism via a ref that
`TerminalPanel` registers into.
`web/src/components/terminal/TerminalPanel.tsx`
- Replace the mock welcome banner and `writeln` calls with a subscription
to the live WebSocket.
- Forward user keystrokes (already captured by xterm's `onData`) to
`send(...)`.
- On `status === "closed"` with exit banner, print the banner and stop
accepting input (set `isDead` flag).
- On `status === "error"`, render an inline "terminal error" message but
keep xterm mounted.
- Keep the ResizeObserver fix from the previous task; it stays untouched.
`web/src/components/layout/WorkspaceShell.tsx`
- Pass `workspaceId` down to `TerminalPanel`.
## State and Data Flow
- The `process.Manager` is the **single source of truth** for whether a
process is running and what its output is. The WebSocket handler is a
read-only consumer of `Subscription` plus a writer to stdin; it never
mutates the process state directly.
- The WebSocket handler does **not** call `processes.Start`. If the process
is not running, the socket is refused with 409. The user must use the
existing toolbar button.
- The existing `Status` polling and the WebSocket are independent. They
can disagree transiently (process just exited, status query not yet
refetched). The exit banner inside the WS is the authoritative "this
process is gone" signal for the terminal panel.
## Backward Compatibility
- `internal/api/router.go` adds one route. The four existing routes are
unchanged.
- `internal/process.Manager` interface gains one method. The concrete
`LocalManager` implements it. **There are no other implementations** in
this repo (verified by grep), so this is not a breaking change for
downstream code.
- Existing tests in `internal/process/manager_test.go` continue to compile
and pass — the new method is additive, and tests that don't call
`Subscribe` are unaffected.
- `ProcessStatusResponse` is unchanged. No wire-format break for any
client that calls `/process/status`.
- Frontend: the four existing API hooks for workspaces/files/process are
unchanged. The terminal panel's mock data is removed, but the panel is
not yet part of any contract that other components rely on.
## Open Questions
1. **WebSocket library**: `gorilla/websocket` is the obvious choice and is
already used in countless Go services. We could also use the
newer `coder/websocket` (formerly `nhooyr.io/websocket`). The spec
picks `gorilla/websocket` for stability. Confirm in plan.
2. **Backpressure / client-slow policy**: v1 silently drops output for
slow clients via non-blocking sends. This is acceptable for an
interactive terminal because xterm will show whatever it has and the
user can resize. The plan should pick a specific drop policy.
3. **Reconnect**: spec says reconnect on transport error, not on exit
banner. Should we add a manual "reconnect" button in the terminal
panel header? Out of scope for v1.
4. **Authentication**: the existing HTTP API has none. The WebSocket
follows suit. Out of scope.
5. **Workspace deletion during a live socket**: today `DELETE /workspaces/:id`
stops the process. The plan should verify this also closes any open
WebSockets for that workspace.
## Self-Review Notes
- The `Subscription` type and the `Input` writer are the two new
capabilities the manager must grow. They are small and orthogonal.
- The ring buffer for replay is implemented but unused in v1. This is
dead weight. **Defer**: the spec keeps it for symmetry with future
replay work. Plan may drop it if not needed.
- The exit banner format (`\r\n[process exited with code N]\r\n`) is a
simple, debuggable string. We are not building a structured protocol.
- All v1 design decisions aim to keep the backend diff small, the
contract clean, and the frontend's existing terminal lifecycle
untouched (same ResizeObserver pattern, same component name).