mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 17:22:58 +00:00
211 lines
11 KiB
Markdown
211 lines
11 KiB
Markdown
# Threaded PTY Service Core
|
|
|
|
Status: threaded core and persistent service API implemented
|
|
|
|
## Objective
|
|
|
|
Prove that one Rust service can own multiple portable PTYs while each terminal
|
|
uses isolated blocking threads, authoritative libghostty parsing, bounded raw
|
|
replay, and explicit lifecycle state.
|
|
|
|
## Invariants
|
|
|
|
- One reader consumes each PTY output stream.
|
|
- One writer serializes every byte written to each PTY.
|
|
- libghostty state is created, accessed, and dropped on one actor thread.
|
|
- libghostty callbacks never perform blocking PTY writes or IPC.
|
|
- Actor and writer channels are bounded.
|
|
- Raw replay is bounded and uses absolute byte offsets.
|
|
- Replay truncation is explicit.
|
|
- PTY resize and parser resize occur in one ordered actor command.
|
|
- Every resize publishes an authoritative post-resize VT checkpoint before subsequent output.
|
|
- Child exit and PTY EOF are separate events.
|
|
- One terminal exiting does not stop the service or another terminal.
|
|
- Service shutdown attempts to terminate and join every terminal runtime.
|
|
- A terminal has at most one controller; observers cannot write or resize until interaction promotes them.
|
|
- Promotion demotes the previous controller without closing its subscription.
|
|
- Promotion, canonical resize, and input are one ordered actor command.
|
|
- Subscription replay and live events cross one ordered actor boundary.
|
|
- OSC 0/2 title changes update authoritative terminal metadata and publish ordered subscription events.
|
|
- A user title set by rename takes precedence over OSC titles; title changes publish only when the effective title changes.
|
|
- The deepest TTY-attached process in the foreground process group updates authoritative process metadata.
|
|
- Controller delivery applies backpressure; observers tolerate bounded output bursts and are disconnected when their backlog remains full.
|
|
- Adjacent output is coalesced before fanout, flushing at 8 KiB, after 1 ms, or before the next non-output event.
|
|
|
|
## Data Flow
|
|
|
|
```text
|
|
child PTY output
|
|
-> blocking reader thread
|
|
-> bounded actor queue
|
|
-> append bounded raw replay
|
|
-> libghostty vt_write
|
|
-> collect parser responses
|
|
-> bounded writer queue
|
|
-> blocking writer thread
|
|
-> child PTY input
|
|
```
|
|
|
|
User input also enters through the actor and the same writer queue.
|
|
|
|
## Snapshot
|
|
|
|
A snapshot is built on the actor thread and contains owned values only:
|
|
|
|
- Terminal metadata and lifecycle
|
|
- Rows and columns
|
|
- Cursor position
|
|
- Plain parsed screen text
|
|
- VT checkpoint synthesized by libghostty formatter
|
|
- Raw replay head and tail offsets
|
|
- Current effective title and user title
|
|
- Current foreground process derived from PTY process state
|
|
|
|
No borrowed libghostty object crosses a thread boundary.
|
|
|
|
## Row Reads
|
|
|
|
`Request::ReadRows { id, rows: Option<u16> }` (`op: "read_rows"`) returns
|
|
`Response::Rows { terminal, lines, cursor_x, cursor_y }` (`type: "rows"`). Both
|
|
the service and Rust client expose `read_rows(id, rows)`. Omitted or null rows
|
|
defaults to the current terminal height; zero is rejected. A larger count
|
|
includes retained history, up to all available rows of the active buffer.
|
|
Counts cannot exceed `u16::MAX`.
|
|
|
|
The actor selects the last physical rows using libghostty's total active-buffer
|
|
row count and full-screen grid coordinates, independent of the viewport.
|
|
Each row is formatted separately as plain text with soft-wrap unwrapping
|
|
disabled and trailing whitespace trimmed. Blank rows remain empty strings,
|
|
including internal and trailing blanks. Alternate-screen reads cannot include
|
|
primary-screen history. Resizes are observed in actor order, including reflow.
|
|
Rows, terminal metadata, and zero-based active-screen cursor coordinates are
|
|
captured in the same actor command. Cursor coordinates are not rebased to the
|
|
returned history rows. No viewport, selection, controller, snapshot,
|
|
checkpoint, or replay state is changed.
|
|
|
|
Row reads have a 1 MiB budget for JSON-escaped row strings, array punctuation,
|
|
and owned-string slots (including blank rows); formatting buffers are also
|
|
bounded. Results exceeding that budget fail rather than truncate. Complete
|
|
responses remain subject to the transport's 8 MiB frame limit. Protocol 7
|
|
supports `read_rows` alongside the existing snapshot and replay operations.
|
|
|
|
## Titles
|
|
|
|
`TerminalInfo.title` is the effective title: the user title, else the latest
|
|
OSC 0/2 title, else the title given at creation. An empty OSC title (a program
|
|
resetting it) or an empty user title counts as unset. `TerminalInfo.user_title`
|
|
is always serialized, as `null` when unset.
|
|
|
|
`Request::Rename { id, title: Option<String> }` (`op: "rename"`) sets the user
|
|
title, or clears it when `title` is null or empty, and returns `Response::Ok`.
|
|
The terminal actor handles it as one command, so it is ordered with output.
|
|
OSC titles keep being tracked while a user title is set and show through again
|
|
once it is cleared.
|
|
|
|
Rename and OSC updates publish `TitleChanged` with the effective title only
|
|
when it changes; an OSC change hidden behind a user title publishes nothing.
|
|
|
|
`list` returns terminals in creation order.
|
|
|
|
## Pipe Processes
|
|
|
|
`Create` takes `mode: "pty" | "pipe"` (default `"pty"`). A pipe process runs
|
|
any command without a terminal; it is Unix-only for now, and only daemons that
|
|
list the `pipe` feature accept it. Older daemons ignore `mode` and would start
|
|
a PTY instead, so clients check `features` first.
|
|
|
|
- `title`, `cols` and `rows` are optional for pipe processes; the title
|
|
defaults to the program's file name and the size is reported as `0x0`.
|
|
- The child gets its own process group, an empty signal mask with default
|
|
actions (the daemon blocks SIGTERM and SIGINT, and std keeps the mask across
|
|
exec), stdin on `/dev/null`, and one pipe for
|
|
both stdout and stderr, so their bytes stay in order. No `TERM` or
|
|
`COLORTERM` is added, and nothing parses the output: there is no emulator,
|
|
no terminal replies, no OSC title and no foreground process.
|
|
- Output goes through the same raw replay and subscriptions as a PTY, with the
|
|
same absolute offsets. A pipe has no controller or observer distinction for
|
|
writing, since it accepts no input.
|
|
- `write`, `resize`, `control`, `input`, `snapshot`, `read_rows` and `rename`
|
|
fail for pipe processes. `replay`, `subscribe` and `terminate`
|
|
work.
|
|
- Exit is published once both the exit status and the end of output are
|
|
known. A descendant that keeps the pipe open (`cmd &`) delays that by at
|
|
most one second after the child exits; output after that is not read.
|
|
- `Exited` (the lifecycle and the subscription response) carries `signal`,
|
|
the signal's name such as `"SIGTERM"`, when a pipe process was killed by
|
|
one; `exit_code` is then null. The field is left out when there is no
|
|
signal, so PTY exits serialize as before.
|
|
- An exited pipe process stays listed, as a zombie, until `terminate`. The
|
|
zombie keeps its PID and process group ID reserved, so `terminate` can kill
|
|
any descendants left in the group without reaching an unrelated process.
|
|
`terminate` sends SIGKILL to the group and to the child, reaps it, and never
|
|
waits on a descendant that left the group and still holds the pipe.
|
|
|
|
`List` takes `mode` too. Without it, `list` returns PTYs only, so servers that
|
|
predate pipe processes never see them. Clients leave `mode` out of the request
|
|
when it is unset: older daemons reject a `list` with any extra field.
|
|
|
|
## Failure Boundary
|
|
|
|
Losing the owning server connection stops `opencode-pty` and its terminals.
|
|
An explicitly prepared restart handoff allows a replacement server to claim
|
|
the same daemon before a fixed deadline. If `opencode-pty` itself dies, its
|
|
terminals may die; live replacement of the daemon is not supported.
|
|
|
|
## Persistent Service Boundary
|
|
|
|
Every daemon requires one authenticated owner connection within five seconds
|
|
of startup. Owner loss stops the daemon unless a handoff was prepared on that
|
|
connection. Handoff tickets expire 120 seconds after preparation and are
|
|
consumed by successful replacement ownership. A valid ticket replaces even a
|
|
connected owner; the superseded connection can no longer prepare handoffs or
|
|
stop the daemon, and its disconnect does not affect the new owner. The playground holds ownership until it exits; other CLI commands
|
|
only observe or operate an existing daemon and never start one.
|
|
|
|
Protocol v7 uses four-byte big-endian framing with bounded UTF-8 JSON control
|
|
frames and tagged raw binary output frames. It supports ping, create, list,
|
|
write, resize, atomic interaction promotion, snapshot, read_rows, replay, replay-to-live
|
|
subscriptions, title updates, rename, controller takeover, terminate, and destructive service
|
|
shutdown, ownership acquisition, and restart handoff preparation.
|
|
|
|
`Pong` and the registration carry `features`, the daemon's additive
|
|
capabilities (currently `["rename", "pipe"]` on Unix and `["rename"]` on Windows). Additive changes, such as a new request
|
|
or field older clients can ignore, extend `features` and keep the protocol
|
|
version; clients check `features` before sending a new request, since a daemon
|
|
that cannot parse one drops the connection. Only breaking changes bump
|
|
`PROTOCOL_VERSION`, which clients reject on mismatch. A missing `features`
|
|
means none.
|
|
|
|
Registration is written atomically with private permissions and contains an
|
|
instance ID, PID, protocol version, features, socket path, and random credential. A
|
|
service lock elects one process and protects stale socket cleanup. On Unix, the
|
|
socket uses a fixed-length hash of the canonical runtime path
|
|
under a private per-user `/tmp` directory to stay below platform path limits.
|
|
|
|
Every command requires `--name NAME`; the runtime directory is `DIR/NAME`,
|
|
where `--runtime-dir DIR` defaults to OpenCode's state directory,
|
|
`${XDG_STATE_HOME:-~/.local/state}/opencode/pty`. A name is one path component
|
|
of letters, digits, `.`, `_`, or `-`. Registration defaults to a temporary directory only when no home
|
|
directory can be found: macOS deletes regular files there that are unaccessed for three
|
|
days, even while the daemon runs. Temporary cleaners skip sockets, so the
|
|
socket stays in `/tmp`. On exit the daemon removes its registration and socket
|
|
only if they are still its own, then its empty runtime directory. At startup
|
|
it removes sibling runtime directories in `DIR` that are over ten
|
|
minutes old, contain only registration files, and have either a lock it can
|
|
acquire or, without a lock file, a valid registration whose PID no longer
|
|
exists. Directories without that evidence, including empty ones, are kept.
|
|
|
|
On Windows the endpoint is a random per-instance named pipe instead of a
|
|
socket. The runtime directory, lock, and registration carry a protected
|
|
current-user-only ACL, and registration is atomically replaced with POSIX
|
|
rename semantics. Clients verify the registration's owner and ACL, and check
|
|
that the pipe server process matches the registered PID before sending the
|
|
token. On exit the daemon removes its registration if still its own, then the
|
|
lock file and runtime directory; the forced-exit watchdog removes only the
|
|
registration, leaving the rest to a later sweep. The sweep checks liveness
|
|
through the registered process object.
|
|
|
|
OpenCode chooses a fresh UUID runtime directory for each server, independent of
|
|
the database. It starts the daemon only when the first terminal is created.
|
|
Only an explicit restart handoff descriptor lets a replacement server reuse
|
|
the previous daemon's runtime directory and claim its ownership.
|