Skip to content

feat(guest): run a session VM's helper as PID 1 with an API over vsock - #891

Draft
ilovecrayons wants to merge 8 commits into
aether/run-work-item-4-kernel-base-disks-an-w212tvpy6zfrom
aether/run-work-item-3-the-in-vm-helper-and-ygq840q53x
Draft

ilovecrayons wants to merge 8 commits into
aether/run-work-item-4-kernel-base-disks-an-w212tvpy6zfrom
aether/run-work-item-3-the-in-vm-helper-and-ygq840q53x

Conversation

@ilovecrayons

@ilovecrayons ilovecrayons commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #890.

What was wrong

Sessions are moving into Cloud Hypervisor VMs (docs/plans/2026-11-13-microvm-sessions.md). Docker gives a container its init, docker exec, docker attach and stats; a VM has none of them. Docker also cannot reattach an exec's streams after a server restart (internal/runtime/managed_exec.go:71-85), so a restarted server shows a recovered terminal as unavailable.

What happens now

aether-internal guest is PID 1 of every session VM (internal/guest/helper):

  • Boots from the read-only helper disk (feat(vmimage): build the guest kernel and the disks session VMs boot #890), mounts the serial=root disk on /mnt, moves /dev, /proc, /sys into it and switches root, staying PID 1.
  • Mounts /dev/pts, /dev/shm, cgroup2 and /run, then the virtio-fs share and the data disk binds named on the kernel command line. Sources are opened beneath their volume (RESOLVE_BENEATH), so a symlink planted on the persistent data disk cannot redirect a mount.
  • Configures lo and the VM's network device by netlink, plus hostname, /etc/hosts and /etc/resolv.conf.
  • Relays guest Unix sockets (/run/aether/coord3.sock, /run/aether-signing/agent.sock) to the host over vsock. While the server restarts, a relay holds the connection for up to 60 s, as long as the coordination client retries a refused one, so the call is delivered once instead of failing as "sent, may have applied". A socket inside a read-only share gets the helper's socket bound over it, and bound again when virtio-fs drops the bind after the server re-creates its socket.
  • Reaps every orphan, forwards HUP INT QUIT TERM USR1 USR2 ALRM WINCH to the main process like docker-init, and enforces the session's process limit with /sys/fs/cgroup/session/pids.max. dockerd gets --cgroup-parent /session, so nested containers count too.
  • Serves a gRPC API on vsock port 1024, to the host (CID 2) only: Boot (dockerd, the setup script once per boot, then the main process, with Docker's failure rules), Spawn with caller-chosen single-use IDs, offset-based Read and Write, Resize, Signal, Status, Stats, SetClock, PowerOff. Output stays in the VM under byte offsets, so a restarted server, or a client cut off by a save and restore, attaches by ID and reads on.

internal/guest is the client the VM engine (item 2) imports: Dial (Cloud Hypervisor's CONNECT handshake, redials by itself), Ping with the protocol check, Boot, Spawn, Process(id) for reattach, an Attachment that satisfies runtime.Attachment, Stats, SyncClock, PowerOff, and Config.KernelArgs, which builds and size-checks the kernel command line. Everything the guest sends is treated as untrusted: reply sizes, offsets and exit codes are checked and strings capped.

The API is internal/guest/guestpb/guest.proto; make proto regenerates it with pinned buf, protoc-gen-go and protoc-gen-go-grpc, needing only Go. acpkeeper.OpenPTY is exported for reuse instead of copied. New modules: github.com/mdlayher/vsock v1.3.0 (chosen over v1.4.0, which would bump five golang.org/x modules) and github.com/vishvananda/netlink. docs/vm-guest.md describes the helper.

How I verified it

Unit tests (go test ./internal/guest/..., run as a non-root user) drive the real helper service through the real client over a fake hybrid-vsock socket: pipe and terminal processes, resize, input deadlines, a client restart that reattaches and replays, lossless backpressure past the window, spawn failures (127/126, reused IDs, reserved IDs), signals, orphan reaping, Boot once-only and setup failure, stats, and the host-only listener.

TestKVMHelper (-tags kvm, docs/testing.md) boots ghcr.io/3xdevops/aether-standard:v0.8.7-beta.14 from internal/vmimage disks with the test binary as PID 1 and drives it through the client: mounts, a terminal with resize, both forwarded sockets, a call made while the coordination socket is down for 2 s and then re-created, reattaching from a new client, an OOM kill, the process limit setting, kill -USR1 1 reaching the main process, and power off. It passes on the KVM host in 28 s (helper answers about 1 s after the monitor starts).

Before that test existed, I drove the helper by hand on the KVM host (Cloud Hypervisor v54.1, item 4's 6.18 kernel, the same image, 2 GiB, 2 vCPUs):

  • The helper answers Ping 1.05 s after the monitor starts, boot_error empty; Boot with dockerd and a setup script takes 0.75-1.7 s; /proc/1/comm is init.
  • Mounts are as configured: share binds nosuid,nodev, ro where asked, a file bind at /usr/local/bin/aether-internal, data binds at /tmp and /var/lib/docker.
  • Interactive shell on a terminal: tty is /dev/pts/0, stty size follows a resize from 24x80 to 40x132, Ctrl-C ends sleep ($? 130), exit 5 returns 5.
  • Pipe commands as root and as 1000:1000; nested Docker 29.1.3 loads an image and runs a container in /session/<id>.
  • /run/aether/coord3.sock and /run/aether-signing/agent.sock reach their host sockets from uid 1000. After the host socket is unlinked and re-created, as a server restart does: one refused connect, then the helper has bound again and the retry gets the new server's answer.
  • A client process spawns a ticking shell and exits; a new client finds it running, replays its output from the start, writes input and reads the reply.
  • A Python memory hog is OOM-killed: exit 137, Stats.OOMKills 1, the kernel log names it.
  • 400 sleep & against PidsLimit 256: pids.current stays at 235, pids.events counts 39 refusals.
  • kill -USR1 1 inside the VM ends the main process with 138, and its exit code is read afterwards.
  • Save and restore (VM without a share): saved in 0.38 s, restored in 0.48 s, same boot ID. The guest clock was 12 s behind, SyncClock put it within 2 ms, and a new client reattached to the process that kept running.
  • PowerOff returns, and the console ends with reboot: Power down.

Item 2 also ran its engine against this branch end to end on the same host.

make fmt-check (pinned toolchain), make vet, make lint, make public-audit and go test -race ./internal/guest/... pass. The Greptile CLI is not installed in this session: greptile: command not found.

Open

  • No file service yet: per the coordinator, item 7 adds the home's FUSE file service to this API.
  • no-new-privileges per process (Docker sets it when a session has no sudo) belongs to item 9's tiers.

This PR was made with the help of Claude Code.

Opened from Aether run run-ygq840q53x

Sessions are moving into Cloud Hypervisor VMs (docs/plans/
2026-11-13-microvm-sessions.md). Docker gave a container its init,
exec, attach and stats; a VM has none of them. Docker also cannot
reattach an exec's streams after a server restart.

aether-internal guest is PID 1 of every session VM. It switches
from the read-only helper disk to the session's root disk, mounts
the system filesystems, the virtio-fs share and the data disk named
on the kernel command line, configures the network, relays the
coordination and signing sockets to the host over vsock, reaps
orphans and forwards PID 1's signals to the main process. Its gRPC
API (internal/guest/guestpb) boots the session (dockerd, the setup
script, then the main process), spawns processes on a terminal or
pipes, and keeps each stream's output under byte offsets, so a
restarted server or a restored VM reattaches where it left.
internal/guest is the client the VM engine builds on.
Go's poller registered /proc/self/mountinfo and took the kernel's
one-shot change mark first, so the helper never saw virtio-fs drop
the bind over /run/aether/coord3.sock after the host re-created it.
TestKVMHelper, behind the kvm build tag, boots an image from
internal/vmimage disks with the test binary as PID 1 and checks
mounts, a terminal, both forwarded sockets including a re-created
coordination socket, reattaching from a new client, an out-of-memory
kill, the process limit, signal forwarding and power off.
The coordination client retries a refused connection, but treats one
cut off after its request was sent as possibly applied. The helper
accepted, failed to reach the restarting server and closed, so a
call made during a restart failed. A relay now waits up to 60 s for
the host socket, as long as the client would retry, and closes a
pipe process's input when it exits.
A failed bind or socket stopped the helper from making the ones after
it, so one missing source could leave a session without its checkout.
The helper now records the failure in Info's boot_error and goes on.
The client also refuses a setup exit code outside 0-255 and caps the
message of a failed spawn.
@ilovecrayons
ilovecrayons force-pushed the aether/run-work-item-3-the-in-vm-helper-and-ygq840q53x branch from fdc1d6e to 5f10e95 Compare November 13, 2026 03:14
@greptile-apps

greptile-apps Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 1/5 Tier: apex

[High impact] The changes add process management and host-service access to every session VM that uses the guest helper.

Fix process identity, command lookup, input cancellation, and lossless output before merging.

Fix All in Claude CodeFindings

  1. P1 Old IDs run commands again ▶
  2. P1 Lossless output loses its end ▶
  3. P1 Relative commands fail to start ▶
  4. P1 Canceled writes break later input ▶
  5. P2 Retired commands keep resources ▶
  6. P2 Guest tests fail on macOS ▶
  7. P2 Permission errors report missing commands ▶
Fix with agent prompt
### Issue 1
internal/guest/helper/process_linux.go:357-360
Once 33 non-main processes have exited, `retire` removes the first ID from the only map that `spawn` checks. A delayed repeat of a lost `Spawn` request can then run the command again, despite the single-use promise. Existing handles also resolve to the replacement process. Keep used-ID markers after discarding old output and exit records.

### Issue 2
internal/guest/helper/output_linux.go:150-152
The exit deadline also applies to lossless streams. A command can fill `outputLimit`, leave its final bytes in the pipe, and exit. If the reader resumes after one second, `room` wakes but the expired deadline makes `pump` close the stream without reading those bytes. The caller receives truncated output followed by normal EOF. Preserve queued lossless bytes when applying the timeout for descendants that keep a pipe open.

### Issue 3
internal/guest/helper/process_linux.go:102-104
`lookPath` checks relative paths from the helper's `/`, before `ForkExec` changes to `spec.Dir`. For example, `Argv: []string{"./script"}` with `Dir: "/workspace"` checks `/script` and returns 127 even when `/workspace/script` exists. Relative entries in `PATH` have the same problem. Resolve and check these paths against the requested working directory.

### Issue 4
internal/guest/helper/process_linux.go:410-411
`defer stop()` does not wait for an already-started cancellation callback. If a write finishes as its context ends, the callback can run after `stdinMu` is released and expire the next write's freshly set deadline. It also reads `p.stdin` while `closeInput` may replace it. Wait for any running callback before releasing the lock, as the existing input writer does.

### Issue 5
internal/guest/helper/output_linux.go:58
A full lossless stream waits only for another acknowledgement. If its process exits without a reader, the file deadline cannot wake `room`. Even after `retire` removes the process record, the blocked pump keeps its buffer and pipe open forever. Add a retirement path that wakes and stops pumps once their output is no longer available by ID.

### Issue 6
internal/guest/helper/helper_test.go:1
This test file has no Linux build constraint. It imports `unix.Prctl` and uses helpers defined only in `_linux.go` files, so the documented `go test ./internal/guest/...` command fails to compile on macOS despite the package's `!linux` stub. Rename it to `helper_linux_test.go` or add `//go:build linux`.

### Issue 7
internal/guest/helper/process_linux.go:300-304
`lookPath` discards permission errors and always returns 127 when no candidate succeeds. If a command exists on `PATH` but has no execute permission, the documented result is 126, not “not found.” Keep the permission error while searching and return it if no executable candidate is found.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Adds a PID 1 helper for session VMs, a versioned API over vsock, reconnectable process streams, and boot tests.

  • The host can start, control, and reattach to VM processes.
  • Each session VM starts its helper as PID 1 and prepares its runtime.
  • Kernel boot arguments carry the settings the guest helper needs.
  • Guest Unix sockets can reach host services through vsock.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
    Host[Aether server] --> Client[Guest client]
    Client --> Vsock[Host-only vsock API]
    Vsock --> Helper[PID 1 helper]
    Helper --> Boot[Mounts and network]
    Helper --> Processes[Setup and session processes]
    Processes --> Output[Output held under byte offsets]
    Output --> Client
    Processes --> Sockets[Guest Unix sockets]
    Sockets --> Relay[Vsock relay]
    Relay --> Host
Loading

Reviews (1) · Last reviewed commit: "refactor(guest): cap the helper's string..." · Reviewed by Greptile

Comment on lines +357 to +360
t.exited = append(t.exited, p.id)
for len(t.exited) > maxExited {
delete(t.byID, t.exited[0])
t.exited = t.exited[1:]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Old IDs run commands again

Once 33 non-main processes have exited, retire removes the first ID from the only map that spawn checks. A delayed repeat of a lost Spawn request can then run the command again, despite the single-use promise. Existing handles also resolve to the replacement process. Keep used-ID markers after discarding old output and exit records.

Knowledge Base Used: Environment construction and execution

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/guest/helper/process_linux.go
Line: 357-360

Comment:
**Old IDs run commands again**

Once 33 non-main processes have exited, `retire` removes the first ID from the only map that `spawn` checks. A delayed repeat of a lost `Spawn` request can then run the command again, despite the single-use promise. Existing handles also resolve to the replacement process. Keep used-ID markers after discarding old output and exit records.

**Knowledge Base Used:** [Environment construction and execution](https://app.greptile.com/-/switch-tenant?tenant=aethercode&next=%2Fcustom-context%2Fknowledge-base%2F3xdevops%2Faether%2F-%2Fdocs%2Fenvironment-construction-and-execution.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment on lines +150 to +152
func drainAfter(f *os.File, done <-chan struct{}) {
<-done
_ = f.SetReadDeadline(time.Now().Add(exitDrain))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Lossless output loses its end

The exit deadline also applies to lossless streams. A command can fill outputLimit, leave its final bytes in the pipe, and exit. If the reader resumes after one second, room wakes but the expired deadline makes pump close the stream without reading those bytes. The caller receives truncated output followed by normal EOF. Preserve queued lossless bytes when applying the timeout for descendants that keep a pipe open.

Knowledge Base Used: Environment construction and execution

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/guest/helper/output_linux.go
Line: 150-152

Comment:
**Lossless output loses its end**

The exit deadline also applies to lossless streams. A command can fill `outputLimit`, leave its final bytes in the pipe, and exit. If the reader resumes after one second, `room` wakes but the expired deadline makes `pump` close the stream without reading those bytes. The caller receives truncated output followed by normal EOF. Preserve queued lossless bytes when applying the timeout for descendants that keep a pipe open.

**Knowledge Base Used:** [Environment construction and execution](https://app.greptile.com/-/switch-tenant?tenant=aethercode&next=%2Fcustom-context%2Fknowledge-base%2F3xdevops%2Faether%2F-%2Fdocs%2Fenvironment-construction-and-execution.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment on lines +102 to +104
program, err := lookPath(spec.Argv[0], envValue(env, "PATH"))
if err == nil {
err = t.start(p, spec, program, env, cred)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Relative commands fail to start

lookPath checks relative paths from the helper's /, before ForkExec changes to spec.Dir. For example, Argv: []string{"./script"} with Dir: "/workspace" checks /script and returns 127 even when /workspace/script exists. Relative entries in PATH have the same problem. Resolve and check these paths against the requested working directory.

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/guest/helper/process_linux.go
Line: 102-104

Comment:
**Relative commands fail to start**

`lookPath` checks relative paths from the helper's `/`, before `ForkExec` changes to `spec.Dir`. For example, `Argv: []string{"./script"}` with `Dir: "/workspace"` checks `/script` and returns 127 even when `/workspace/script` exists. Relative entries in `PATH` have the same problem. Resolve and check these paths against the requested working directory.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment on lines +410 to +411
stop := context.AfterFunc(ctx, func() { _ = p.stdin.SetWriteDeadline(time.Now()) })
defer stop()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Canceled writes break later input

defer stop() does not wait for an already-started cancellation callback. If a write finishes as its context ends, the callback can run after stdinMu is released and expire the next write's freshly set deadline. It also reads p.stdin while closeInput may replace it. Wait for any running callback before releasing the lock, as the existing input writer does.

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/guest/helper/process_linux.go
Line: 410-411

Comment:
**Canceled writes break later input**

`defer stop()` does not wait for an already-started cancellation callback. If a write finishes as its context ends, the callback can run after `stdinMu` is released and expire the next write's freshly set deadline. It also reads `p.stdin` while `closeInput` may replace it. Wait for any running callback before releasing the lock, as the existing input writer does.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

if free > 0 {
return min(free, 32<<10)
}
<-changed

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Retired commands keep resources

A full lossless stream waits only for another acknowledgement. If its process exits without a reader, the file deadline cannot wake room. Even after retire removes the process record, the blocked pump keeps its buffer and pipe open forever. Add a retirement path that wakes and stops pumps once their output is no longer available by ID.

Knowledge Base Used: Keep container faults from degrading server availability

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/guest/helper/output_linux.go
Line: 58

Comment:
**Retired commands keep resources**

A full lossless stream waits only for another acknowledgement. If its process exits without a reader, the file deadline cannot wake `room`. Even after `retire` removes the process record, the blocked pump keeps its buffer and pipe open forever. Add a retirement path that wakes and stops pumps once their output is no longer available by ID.

**Knowledge Base Used:** [Keep container faults from degrading server availability](https://app.greptile.com/-/switch-tenant?tenant=aethercode&next=%2Fcustom-context%2Fknowledge-base%2F3xdevops%2Faether%2F-%2Freverts%2Fincident-mitigation_136-20260915-container-availability-14cb623.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

@@ -0,0 +1,383 @@
package helper

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Guest tests fail on macOS

This test file has no Linux build constraint. It imports unix.Prctl and uses helpers defined only in _linux.go files, so the documented go test ./internal/guest/... command fails to compile on macOS despite the package's !linux stub. Rename it to helper_linux_test.go or add //go:build linux.

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/guest/helper/helper_test.go
Line: 1

Comment:
**Guest tests fail on macOS**

This test file has no Linux build constraint. It imports `unix.Prctl` and uses helpers defined only in `_linux.go` files, so the documented `go test ./internal/guest/...` command fails to compile on macOS despite the package's `!linux` stub. Rename it to `helper_linux_test.go` or add `//go:build linux`.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

Comment on lines +300 to +304
if err := executable(candidate); err == nil {
return candidate, nil
}
}
return "", &execError{code: 127, err: fmt.Errorf("%s: executable file not found in $PATH", name)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Permission errors report missing commands

lookPath discards permission errors and always returns 127 when no candidate succeeds. If a command exists on PATH but has no execute permission, the documented result is 126, not “not found.” Keep the permission error while searching and return it if no executable candidate is found.

Prompt To Fix With AI
This is a comment left during a code review.
Path: internal/guest/helper/process_linux.go
Line: 300-304

Comment:
**Permission errors report missing commands**

`lookPath` discards permission errors and always returns 127 when no candidate succeeds. If a command exists on `PATH` but has no execute permission, the documented result is 126, not “not found.” Keep the permission error while searching and return it if no executable candidate is found.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Codex

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant