PLATINUM DOCS
Internal

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}
  • cmd required, non-empty. argv, NOT a shell string. Wrap in sh -lc yourself.
  • cwd optional, defaults to /. Must exist, else ok:false.
  • env optional, appended to the agent's environ (KEY=VALUE form).
  • pty:true allocates a PTY. stdout and stderr are then inherently merged: all output arrives on the stdout stream and stderr is always empty.
  • timeout_ms 0 (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 marked timed_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:

outcomeexit_codesignal
exited normallythe process's own status (0-255)absent
killed by signal N128 + N (SIGTERM → 143, SIGKILL → 137)N
status unrecoverable-1absent
  • 128+N is the shell/SDK convention, so a client that does nothing special still reports something true. It is not a Platinum invention.
  • signal is 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 called exit(143).
  • -1 means 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 -1 used to mean either, with no way to tell.
  • A process killed by its own timeout_ms is a SIGKILL, so it reports exit_code:137, signal:9, timed_out:true.
  • The legacy exec verb 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_offset are ABSOLUTE byte counts since process start, not buffer positions. Pass back the *_offset from the previous reply to continue exactly where you left off.
  • Payloads are base64. Process output is arbitrary bytes; the legacy exec verb returns it as a JSON string, which silently mangles non-UTF-8 through U+FFFD replacement. Managed processes do not repeat that mistake.
  • *_dropped is 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 at offset + 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.
  • limit caps bytes returned per stream (default 64 KiB, hard max 4 MiB).
  • running and timed_out are ALWAYS present, including when false — see "Booleans that mean false" below. signal appears only for a signal death.
  • drained is always present on new agents and becomes true only after both output pumps reached EOF. A tail must not infer that one quiet read after running:false means 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 out

Blocks 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:

  1. Caller stages the new binary with write_file (or any other means) to a path of its choosing.
  2. agent_upgrade verifies the SHA-256 matches sha256, that the file is a valid ELF, and that <path> --version runs 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.
  3. 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.
  4. 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_CLOEXEC flag is cleared on each so they survive the exec.
  5. rename(2) over the installed path — atomic, and the only way to replace a binary that is currently executing.
  6. syscall.Exec the installed path with PT_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 exec the agent as PID 1, where a kill/restart would panic the kernel. execve in 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 exec they 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.