Guest process + upgrade protocol (v1)
Guest process + upgrade protocol (v1)
Wire contract for the managed-process and agent-upgrade verbs on the invm-agent
vsock channel (AF_VSOCK :18047). Additive only: every verb here is new, and an
agent that predates them answers unknown op: <verb>, which callers MUST treat
as "this guest is too old for managed processes" rather than an error.
Compatibility discipline
There is no handshake. Capability discovery is one round trip:
{"op":"agent_info"} -> {"ok":true,"agent_version":"...","image_sha256":"...","caps":["proc","upgrade",...]}Old agents answer {"ok":false,"error":"unknown op: agent_info"}. That negative
answer IS the version signal: no agent_info means no proc_*, no agent_upgrade.
Callers cache the result per boot, never per host.
caps is an unordered set of coarse feature tags, not a version number. Add tags,
never remove or repurpose them.
Managed processes
A managed process outlives the RPC connection that started it. It is owned by the
agent's registry, not by a socket. This is the whole point: exec SIGKILLs its
process group on return, so nothing survives; a managed process survives until it
exits, is signalled, or the VM stops.
Handles are opaque strings (p_<16 hex>), unique per agent boot. They are stable
across an agent_upgrade handover.
proc_start
req: {"op":"proc_start","cmd":["python","-m","http.server","8000"],
"cwd":"/workspace","env":["FOO=bar"],"pty":false,"cols":80,"rows":24,
"timeout_ms":0}
resp: {"ok":true,"handle":"p_a1b2...","pid":142}cmdrequired, non-empty. argv, NOT a shell string. Wrap insh -lcyourself.cwdoptional, defaults to/. Must exist, elseok:false.envoptional, appended to the agent's environ (KEY=VALUEform).pty:trueallocates a PTY. stdout and stderr are then inherently merged: all output arrives on the stdout stream andstderris always empty.timeout_ms0 (default) means no timeout: the process runs until it exits or is signalled. >0 arms a deadline, after which the group is SIGKILLed and the record is markedtimed_out.- The process is started in its OWN process group (
Setpgid) so signals can reach the whole tree, and is explicitly NOT killed when the RPC returns.
Rejected with ok:false when the registry is full (max_procs, default 64). Exited
records are garbage-collected first, so a full registry means 64 genuinely-running
processes.
proc_list
resp: {"ok":true,"procs":[{"handle":"p_...","pid":142,"cmd":["python","..."],
"started_at":1754650000,"running":true,"exit_code":0,"exited_at":0,
"pty":false,"timed_out":false,"stdout_len":4096,"stderr_len":0}]}exit_code is meaningless while running is true. Includes exited-but-not-yet-GC'd
records so a caller that missed the exit can still collect the status and final logs.
A signal-terminated row carries signal as well:
{"handle":"p_...","running":false,"exit_code":143,"signal":15,"timed_out":false,...}Exit status encoding
One rule, and both reaping paths (a process this agent forked, and one it adopted
across an agent_upgrade) produce identical answers for it:
| outcome | exit_code | signal |
|---|---|---|
| exited normally | the process's own status (0-255) | absent |
| killed by signal N | 128 + N (SIGTERM → 143, SIGKILL → 137) | N |
| status unrecoverable | -1 | absent |
- 128+N is the shell/SDK convention, so a client that does nothing special still reports something true. It is not a Platinum invention.
signalis present ONLY for a signal death, because 0 is not a signal a process can be killed by — absent and 0 mean the same thing and nothing is lost. Read it rather than reversing the arithmetic: it also distinguishes a SIGTERM from a program that genuinely calledexit(143).-1means the agent could not obtain the status (the process was reaped by someone else between an upgrade's state file being written and the new image starting). It NO LONGER doubles as "killed by a signal", which is what made it useless: a-1used to mean either, with no way to tell.- A process killed by its own
timeout_msis a SIGKILL, so it reportsexit_code:137, signal:9, timed_out:true. - The legacy
execverb is deliberately NOT part of this. It still answers -1 for a signalled or timed-out command, because host agents and CP builds older than a given guest parse those replies and a guest is upgraded independently of them.
proc_logs
Reconnectable, offset-addressed read. This is what makes a dropped connection recoverable.
req: {"op":"proc_logs","handle":"p_...","offset":0,"stderr_offset":0,"limit":65536}
resp: {"ok":true,"stdout":"<base64>","stderr":"<base64>",
"stdout_offset":4096,"stderr_offset":0,
"stdout_dropped":0,"stderr_dropped":0,
"running":true,"drained":false,"exit_code":0,"timed_out":false}offset/stderr_offsetare ABSOLUTE byte counts since process start, not buffer positions. Pass back the*_offsetfrom the previous reply to continue exactly where you left off.- Payloads are base64. Process output is arbitrary bytes; the legacy
execverb returns it as a JSON string, which silently mangles non-UTF-8 through U+FFFD replacement. Managed processes do not repeat that mistake. *_droppedis the count of bytes that were evicted from the ring buffer before the caller read them. Non-zero means the caller fell behind and there is a hole: the returned data starts atoffset + dropped. Callers should surface this rather than silently concatenate across the gap.- Ring buffers are bounded (default 256 KiB per stream,
PT_PROC_BUF_BYTES). A slow reader costs bytes, never guest memory. limitcaps bytes returned per stream (default 64 KiB, hard max 4 MiB).runningandtimed_outare ALWAYS present, including when false — see "Booleans that mean false" below.signalappears only for a signal death.drainedis always present on new agents and becomes true only after both output pumps reached EOF. A tail must not infer that one quiet read afterrunning:falsemeans the final bytes have arrived. Older agents omit it, so callers retain the quiet-read fallback only for backward compatibility.
proc_stdin
Writes have a 5 s guest-side deadline. A child that stops reading its pipe therefore cannot park unbounded agent handlers; a partial write reports both the error and the number of bytes accepted.
req: {"op":"proc_stdin","handle":"p_...","data":"<base64>","close_stdin":false}
resp: {"ok":true,"written":12}close_stdin:true closes the pipe after writing data (which may be empty), which
is how you get an EOF-terminated reader to finish. Writing after close is ok:false.
In PTY mode the bytes go to the PTY master, so this is also how you deliver
keystrokes and control characters.
proc_signal
req: {"op":"proc_signal","handle":"p_...","signal":15,"group":true}
resp: {"ok":true}signal is a numeric signal. group:true (default) sends to the whole process
group, which is what actually stops a shell that spawned children; group:false
targets only the direct child. Signalling an exited process is ok:false with
error:"process not running".
proc_kill
req: {"op":"proc_kill","handle":"p_..."}SIGKILL to the group, then the record is retained (not deleted) so the caller can still read final logs and the exit code.
proc_resize
req: {"op":"proc_resize","handle":"p_...","cols":120,"rows":40}PTY-mode processes only; ok:false otherwise. The legacy pty op hardcodes 80x24
with no resize path — this is the fix.
proc_wait
req: {"op":"proc_wait","handle":"p_...","timeout_ms":30000}
resp: {"ok":true,"running":false,"exit_code":0,"timed_out":false}
{"ok":true,"running":false,"exit_code":143,"signal":15,"timed_out":false}
{"ok":true,"running":true,"timed_out":true} ← the WAIT timed outBlocks until exit or timeout_ms. timed_out:true means the WAIT timed out, not
that the process did; the process is left running. Capped at 300s to stay under the
host's vsock deadline. running and timed_out are always present on this verb —
they are what tells you whether exit_code means anything.
Booleans that mean false
On the proc_* verbs, running and timed_out are transmitted even when false.
This is a correction, not a nicety. They used to be omitted when false, so
running:false — "the process has exited, the exit_code beside it is real" —
serialized as nothing at all, and a client could not tell it from a reply that
never mentioned liveness. Callers MUST NOT treat an absent running on a
proc_logs/proc_wait reply as false; an agent that omits it is one that predates
this change.
The older verbs are untouched: exec still carries timed_out only when true, and
stat_file's is_dir, ranged read_file's eof and the rest keep their existing
absent-means-false shape, because replies parsed by host agents and CP builds older
than the guest must stay byte-identical.
Lifecycle and retention
Exited records are retained for PT_PROC_RETAIN_SEC (default 600s) so a caller that
reconnects after the process finished still gets the exit code and tail. After that a
janitor GCs them. proc_list and proc_logs work throughout the retention window.
Managed processes survive pause/resume for free: the registry lives in guest memory, which is exactly what the memory snapshot captures.
Agent upgrade
agent_upgrade
req: {"op":"agent_upgrade","path":"/tmp/invm-agent.new","sha256":"<hex>"}
resp: {"ok":true,"agent_version":"<new>"} (sent BEFORE the handover)Replaces the running agent in place via execve(2), preserving the vsock listener
and every managed process, including their output ring buffers.
Stage it anywhere writable. /tmp is the obvious choice and it works, even
though /tmp is a tmpfs and the agent lives on the root disk — see step 3. The one
rule is that the caller MUST NOT write directly over the running binary: that
returns ETXTBSY.
Sequence:
- Caller stages the new binary with
write_file(or any other means) to a path of its choosing. agent_upgradeverifies the SHA-256 matchessha256, that the file is a valid ELF, and that<path> --versionruns and prints a version. A binary that fails any check is rejected, the running agent is untouched, and nothing has been written next to it.- The agent copies the staged bytes to a temp file IN THE INSTALL DIRECTORY, hashes them against the same digest as they are written, fsyncs and chmods 0755, then renames that copy. This works across filesystems and closes the pathname substitution window on the same-filesystem case too.
- State is serialized: registry records, ring buffer contents, and the file
descriptor numbers of the listener and every managed process's pipes/PTY. The
FD_CLOEXECflag is cleared on each so they survive the exec. rename(2)over the installed path — atomic, and the only way to replace a binary that is currently executing.syscall.Execthe installed path withPT_AGENT_HANDOFF=<state path>in the environment.
The staged file is CONSUMED on success, whichever route it took: it is either renamed away or removed after the copy. Leaving a spare agent binary in a tmpfs costs the guest several MiB of RAM in a box whose floor is 512 MiB. On failure it is left exactly where the caller put it, so a retry needs no re-upload.
Because this is execve and not a restart, the process keeps its PID, keeps its
children (so it is still their parent and can still reap them), and keeps its open
file descriptors. Two consequences worth stating plainly:
- It works when the agent is PID 1. Basic rootfs images
execthe agent as PID 1, where a kill/restart would panic the kernel.execvein place is the only upgrade mechanism that is safe there, and it is safe there. - In-flight RPCs on other connections are lost. Their sockets close with the exec.
Managed processes are unaffected. Callers should not upgrade while an
execthey care about is in flight.
Adopted processes are reaped with a targeted wait4(pid) rather than Go's
exec.Cmd.Wait, since the new image has no exec.Cmd for a process it did not
start. A targeted wait does not steal exit statuses from concurrent exec/pty
calls the way a global wait4(-1) reaper would.
agent_info
resp: {"ok":true,"agent_version":"...","caps":["proc","upgrade","resize"],
"image_sha256":"<64 lowercase hex>",
"pid":1,"uptime_sec":8123,"proc_count":3}Cheap, side-effect free, and the capability probe described at the top.
Reserved response fields
handle, pid, procs, stdout_offset, stderr_offset, stdout_dropped,
stderr_dropped, running, drained, signal, written, agent_version, image_sha256, caps,
image_sha256, uptime_sec, proc_count. None of them appears in a reply to one of the older
verbs, so no existing reply changes shape.
running (and timed_out, which the older exec verb shares) is a tri-state on
the wire: absent on every non-proc_* reply, and an explicit true/false on the
proc_* ones. "Absent" is a property of the VERB, never of the value.