opencode/packages/process-daemon/SPEC.md

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.