Skip to content

Carry hibernate and resume over the control channel - #261

Open
bkearns wants to merge 4 commits into
mainfrom
feat/vm-hibernate-control
Open

Carry hibernate and resume over the control channel#261
bkearns wants to merge 4 commits into
mainfrom
feat/vm-hibernate-control

Conversation

@bkearns

@bkearns bkearns commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Adds vm_hibernate and vm_resume to the control channel, so a phone can ask a
machine to put a microVM to sleep and wake it again. The coordinator side is in
ferrosa-coordinator; this is the half that carries the request.

What is here

The two commands live in ferrosa-control-vocabulary. That crate exists so a
name added once is a name both sides know — it was created after coordinator_offer
was added to the listener and the streamer built cleanly against a different
worktree, producing a binary that did not know the command. Nothing failed; the app
simply waited.

The classification matters more than the spelling. vm_list sits beside these in
every listing and is a Read; copying that across would put a command that stops a
running machine behind a read-only guard. Both are Write, and there is a test
saying so by name.

The VM id is validated before it goes in a URL. It arrives inside a frame
written by a peer and lands in a path segment: a slash re-points the request at a
different endpoint, and an encoded one walks out of /v1/vms entirely. A whitelist,
not an escaper.

control-connect is rebuilt without an API key. An older branch had this
subcommand taking --api-key. That credential no longer exists — control-listen
says so in its own help — and the controller half had not been carried across the
change. It now uses Credential::device, the same as device-approve and
control-listen.

Frames are built in code, not at a shell prompt. The listener requires
command_id to be a UUID of version seven; a v4 is refused with a message about
the id, which reads as malformed rather than as the wrong kind of id.

Verification

  • 12 tests in the vocabulary crate, 267 in ferrosa-memory-sync with
    --features webrtc-transport.
  • Run the sync tests with that feature. Without it, control_session is not
    compiled at all, and a plain cargo build reports success while none of this is
    checked. The exhaustive dispatch match did catch the two missing arms — but only
    with the feature on.

What is NOT verified

The command has not been carried over a live WebRTC session end to end. The listener
on the target host answers and both sides exchange candidates through the gateway,
but ICE does not complete from the machine I am testing from — its application
firewall drops inbound UDP to a freshly built binary. The same listener binds
sessions from the phone without trouble, so this is a property of the test client,
not of this change. Stated here rather than left to be discovered.

The command names lived in two places -- ControlCommandType in the
mobile core and CoordinatorCommand in the listener -- and had to match
exactly or a command silently became 'unknown'. They drifted the first
time anyone added one: coordinator_offer went into the listener, a build
against a different worktree of the same repo compiled cleanly, and the
resulting binary did not know the command. Nothing failed. The app
simply waited.

ferrosa-control-vocabulary defines the names, the read/write
classification, which commands the coordinator answers, and which frame
may carry a secret. Both sides compile it, so a name they disagree about
cannot be expressed.

No dependencies, deliberately: the phone, the desktop app, the listener
and the streamer all compile it, and anything it pulls in they all pull
in. Names and classifications need nothing.

An unknown command is classified as a WRITE. Guessing 'read' for
something a build cannot classify would let an unrecognised name past a
read-only guard, and the safe direction for an unknown is restrictive.

A path dependency, not a pinned rev -- these repos are all ours and a pin
would be a second place to bump. The cost is that a consumer must sit
beside ferrosa-memory on disk, which is what ferrosa-streamer's CI
already arranges and what ferrosa-mobile's CI now does too.

8 vocabulary tests, plus agreement tests on both sides asserting each
knows every name the other can send.
The coordinator can hibernate a microVM to disk and wake it again. Nothing
could ask it to over WebRTC, because the shared vocabulary had no name for
either.

Both are added in ferrosa-control-vocabulary, which is the point of that
crate existing: a name added once is a name both sides know, and the
listener's effect/capability answers follow from it rather than being
restated. The classification is the part worth checking rather than the
spelling -- vm_list sits beside these in every listing and is a Read, and
copying that across would put a command that stops a running machine
behind a read-only guard.

The VM id arrives inside a frame written by a peer and goes straight into
a URL path segment, so it is validated here as well as by the coordinator:
a slash re-points the request at a different endpoint and an encoded one
walks out of /v1/vms. A whitelist, not an escaper.

Worth recording for the next person: the dispatch match is exhaustive and
DID catch the two missing arms -- but only with --features webrtc-transport.
Without it, control_session is not compiled at all, so a plain `cargo build`
reported success while none of this was checked.
There was a control-connect subcommand on an old branch and it took
--api-key. That credential no longer exists: control-listen says so in
its own help -- "the identity signs every request, so there is no bearer
secret sitting on the machine" -- and the controller half had simply not
been carried across the change.

So it is rebuilt on Credential::device, the same way device-approve and
control-listen authenticate, and it sends a real command rather than a
line of text.

The frame is built in code rather than at a shell prompt because the
listener is strict in three ways that are each easy to get wrong by hand
and each refuse differently: frame_id must be present and at most 128
bytes, command_id must be a UUID of version SEVEN specifically, and
command_type must be a name the shared vocabulary knows. A v4 id is
refused with a message about the id, which reads as malformed rather than
as the wrong kind of id.
The vocabulary had no way to say it. /v1/launch is HTTP on loopback,
reachable by the process beside the coordinator and by nothing else, so a
phone could hibernate and resume VMs it had no way to create.

vm_launch, not launch: it is one word from agent_launch, which starts a
teammate INSIDE a runtime rather than creating one, and the listener would
accept either.

The payload is forwarded to the coordinator verbatim. It was built on the
controller by shared Rust from the same offering this machine published,
and rebuilding it here would give the two sides two ideas of what was
asked for. The coordinator validates it again regardless; it does not
trust the controller's copy either.
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