mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
The process group in front of the pty when it is not the program's own (a job its shell runs), with the leader's name on macOS and Linux. Exposed as RemoteTerminal.foreground().
134 lines
8.7 KiB
Markdown
134 lines
8.7 KiB
Markdown
# pi-env wire protocol
|
||
|
||
Version 1. The client starts `pi-env serve --token <hex>` (over `ssh`, or directly in tests) and talks to it over the
|
||
process's stdin and stdout. stderr is diagnostic text for logs only.
|
||
|
||
## Sync
|
||
|
||
Shell startup files may print to stdout before the daemon runs. The daemon's first output is the line
|
||
`PI-ENV <token>\n`, with the token from its command line. The client discards everything before that line. After it,
|
||
stdout carries only frames.
|
||
|
||
## Frames
|
||
|
||
Every frame, in both directions:
|
||
|
||
```text
|
||
u32 length bytes after this field
|
||
u8 type
|
||
u32 id request id; 0 for frames that belong to no request
|
||
u32 jsonLength
|
||
json UTF-8 JSON object, jsonLength bytes
|
||
bytes raw payload, the remaining length - 9 - jsonLength bytes
|
||
```
|
||
|
||
All integers are big-endian. A frame is at most 16 MiB; larger transfers are split by the client. A request whose JSON
|
||
does not parse gets an `EINVAL` error; framing errors end the connection.
|
||
|
||
| type | name | direction | meaning |
|
||
|---|---|---|---|
|
||
| 1 | request | client → daemon | `json.op` names the operation |
|
||
| 2 | result | daemon → client | success of request `id`; the request is finished |
|
||
| 3 | error | daemon → client | failure of request `id`; the request is finished |
|
||
| 4 | event | daemon → client | progress of a running request (`exec` and `pty` output, `watch` changes), not finishing it |
|
||
| 5 | cancel | client → daemon | cancel request `id`; see below |
|
||
| 6 | ping | both | liveness; the receiver ignores it |
|
||
|
||
Errors are `{ code, message, syscall?, path? }`. `code` is a Node-style error code (`ENOENT`, `EISDIR`,
|
||
`ERR_FS_EISDIR`, ...) or one of the execution codes `shell_unavailable`, `spawn_error`, `timeout`, `aborted`, `unknown`.
|
||
Messages are diagnostic; only codes are part of the contract.
|
||
|
||
Paths are absolute JSON strings; the client resolves relative paths, `~` and `file://` URLs before sending. Strings are
|
||
sent well-formed (lone surrogates become U+FFFD) and encoded as UTF-8, as Node encodes string paths and arguments. File
|
||
contents and output travel as raw payload bytes.
|
||
|
||
## Cancellation
|
||
|
||
A cancel names one request. The daemon registers each request before running it, so a cancel sent right after its
|
||
request is never lost. `exec` and `pty` kill the command's process group and finish with `aborted`; `{ mode: "kill" }` kills
|
||
without aborting, so the command finishes with the killed process's status, as `cleanup()` does. `scanLines` stops
|
||
between chunks with `aborted`. `watch` stops and finishes with `{}`. Other operations are short and finish normally.
|
||
|
||
## Scheduling
|
||
|
||
File operations run on a fixed pool of worker threads; `exec`, `pty` and `watch` run on their own threads. `ptyWrite`,
|
||
`ptyResize` and `ptyForeground` are answered in arrival order by the thread that reads frames, so keystrokes never
|
||
swap. Chunk writes to one handle run in arrival order. Results, errors, pings and `watch` events are sent before queued
|
||
command output, and results with payloads above 64 KiB queue behind output too, so a slow link delays bulk data, not
|
||
replies. Output of a command without an output window waits while more than 4 MiB of output is unsent, which slows the
|
||
command as a full pipe would.
|
||
|
||
## Liveness
|
||
|
||
Each side sends `ping` every 5 seconds. The daemon kills every process group it started and exits after 30 seconds
|
||
without receiving any bytes, or when stdin ends. A dropped connection therefore stops remote commands. The client
|
||
fails requests of a lost connection with `{ code: "unknown", lost: true }` and starts a new daemon on the next request;
|
||
handles belong to the daemon that opened them, so requests on them fail instead of reaching the new one.
|
||
|
||
## Operations
|
||
|
||
`hello { protocol }` → `{ protocol, version, os, arch, home, tmpdir, cwd, driveCwds, pid }`. The first request.
|
||
`version` is the npm package version the daemon shipped with; `os` and `arch` are Rust's `std::env::consts` values;
|
||
`tmpdir` follows the remote Node's `os.tmpdir()` rules; `cwd` and `driveCwds` (Windows' `=C:` variables) are what
|
||
Node's `path.resolve` falls back to for drive-relative paths.
|
||
|
||
File operations take `{ path }` (and the listed fields) and return `{}` unless noted:
|
||
|
||
| op | fields | result |
|
||
|---|---|---|
|
||
| `lstat` | | `info` |
|
||
| `realpath` | | `{ path }` |
|
||
| `write` | `append`, `parents?` (default true), `keep?`, payload | creates missing parents like Node's recursive `mkdir`, opens like `writeFile` (`w`) or `appendFile` (`a`), writes the payload; with `keep`, `{ handle }` for `writeChunk` |
|
||
| `writeChunk` | `handle`, payload | appends to a kept write handle; after a failed chunk, later chunks fail with `EBADF` |
|
||
| `truncate` | `size` | |
|
||
| `fsync` | | |
|
||
| `rename` | `to` | |
|
||
| `mkdir` | `recursive` | |
|
||
| `rm` | `recursive`, `force` | Node `fs.rm` semantics |
|
||
| `mkdtemp` | (`path` is the prefix) | `{ path }` |
|
||
| `open` | `noFollow`, `mode?` | `{ handle, info }` for `openBinaryReader`: nonblocking, regular files only. With `mode: "read"`, Node's `open(path, "r")` of any file, blocking: `{ handle, stat }` or `{ handle, statError }` |
|
||
| `pread` | `handle`, `offset?`, `length` | payload; without `offset`, one read at the current position |
|
||
| `fstat` | `handle` | `info` |
|
||
| `scanLines` | `handle`, `startLine`, `endLine?` | `LineScan` |
|
||
| `opendir` | | `{ handle }` |
|
||
| `readdir` | `handle`, `max` | `{ entries, done }` |
|
||
| `close` | `handle` | |
|
||
|
||
`info` is `{ name, kind, size, mtimeSec, mtimeNsec, dev, ino }` with `kind` one of `file`, `directory`, `symlink`, `other`.
|
||
`readdir` entries are `{ name, raw?, info }` or `{ name, raw?, error }` when the entry's `lstat` failed. Names are
|
||
decoded as UTF-8 with replacement characters, and lstat'ed under that name, as Node does; `raw` holds the original
|
||
bytes of a name that is not valid UTF-8, for sorting. Handles are numbers, valid until `close` or the end of the
|
||
connection; opening more than 4096 fails with `EMFILE`.
|
||
|
||
`exec { command | argv, cwd, env, inheritEnv, shellPath?, timeoutMs?, spill?, window? }` runs a shell string or an
|
||
argv array. While it runs, `event` frames `{ kind: "output", stream, skipped? }` carry decoded output text as UTF-8
|
||
payload. With `window { maxBytes, maxLines, minIntervalMs, bytesPerSecond }`, output is coalesced and paced like the
|
||
caller's commits, at most one output frame per command is unsent, and output followed by more than the window is
|
||
replaced by `skipped { bytes, newlines, endsWithNewline }` on the next event, which carries all output after it
|
||
(`ShellOutputInfo.skipped`). The result is `{ exitCode, spillPath? }`; failures are errors with codes `timeout` or
|
||
`aborted` (carrying `spillPath`), `shell_unavailable`, `spawn_error`, or `unknown` (spill failure).
|
||
|
||
`watch { targets, mode?, pollIntervalMs?, maxDirectories? }` runs Durable's `NodeFileWatcher` in the daemon. `targets`
|
||
are `{ path, recursive, exclude: { hidden, names } }`. Once coverage is established, an event `{ kind: "ready", mode }`
|
||
follows; then `{ kind: "change", paths }` or `{ kind: "change", overflow: true, mode: "polling" }` (native watching
|
||
failed), or `{ kind: "error", code, message }`, after which nothing follows. The request lasts until cancelled.
|
||
Failing to establish coverage is an error of the request.
|
||
|
||
`pty { argv?, cwd, env, inheritEnv, cols, rows }` runs a program in a new pseudo-terminal of `cols` × `rows` cells
|
||
(1 to 4096), as the leader of a new session with the terminal as its controlling terminal. Without `argv`, it runs
|
||
`SHELL` (from `env`, else the daemon's environment, else `/bin/sh`) as a login shell (`-` before its name). The
|
||
environment is built as for `exec`; set `TERM` in `env`. Once the program runs, an event `{ kind: "started", pid }`
|
||
comes first; then `{ kind: "output" }` events carry the terminal's raw output bytes as payload (not decoded, unlike
|
||
`exec`). Output waits while more than 4 MiB of output is unsent, like a full pipe. After the program exits, output is
|
||
collected until the terminal closes or 100 ms pass without output. The result is `{ exitCode }`. Not supported on
|
||
Windows yet (`ENOTSUP`).
|
||
|
||
While a `pty` request runs, `ptyWrite { target }` writes its payload to the terminal's input, and
|
||
`ptyResize { target, cols, rows }` changes its size (the program gets `SIGWINCH`); `target` is the id of the `pty`
|
||
request. Both return `{}` at once; input is queued and written in order, waiting while the program does not read it.
|
||
Once the terminal has ended, they fail with `EBADF`.
|
||
|
||
`ptyForeground { target }` reports the terminal's foreground process group when it is not the program's own:
|
||
`{ foreground: { pid, name } }`, where `pid` is the group's id and `name` its leader's process name (`null` if unknown),
|
||
or `{ foreground: null }` while the program itself is in front. For a shell this tells a running job (an editor, a
|
||
build) from the prompt; background jobs are not reported. It fails with `EBADF` once the terminal has ended.
|