Skip to content

feat(hvi): run hull on linux and move hvi instances between hosts - #122

Draft
pmoust wants to merge 33 commits into
mainfrom
feat/teleport
Draft

pmoust wants to merge 33 commits into
mainfrom
feat/teleport

Conversation

@pmoust

@pmoust pmoust commented Oct 18, 2026 •

Copy link
Copy Markdown
Member

Summary

brig runs sandboxes through hull on a Mac. To move a running sandbox to a Linux host and back, hull has to build and run there, and it has to drive the checkpoint, restore and live-move ops that hvi's feat/teleport branch adds on its control socket. This branch does both.

cmd/hull builds for arm64 Linux and runs the hvi backend with a block rootfs (the macOS-only helpers moved into _darwin files; the ociclient keeps image ownership on unprivileged unpacks). New verbs pause, resume, checkpoint and restore an hvi instance over its control socket. hull migrate NAME --to USER@HOST moves a running instance over ssh: it relays hvi's migrate stream to hvi restore --from-stream on the destination in one ssh session with an ack window, keeps a disk base per image by content digest with a spare for the way back, prepares a destination ahead of a move (bases, the pinned portable kernel), records both hosts' steps in a ledger with a txid and epoch so the guest never runs on two hosts, and recovers a fenced guest through the fence owner's restream when the relay dies.

Measured on a Pi pair over the LAN: an idle ubuntu guest moves in about 2.2 s with 47 to 58 ms of downtime, one writing 10 MiB/s to its disk in about 4.1 s with 77 to 91 ms; the way back without a prepare takes 2.4 to 3.1 s. A claude-code guest moved from a Mac to a Pi through brig teleport in 8.4 to 9.9 s with 188 to 387 ms of downtime, sending 208 to 260 MiB. docs/migration.md is the protocol, the records, the failure modes and the operator recovery, with the measured tables.

Related issues

Refs brig-sh/hvi-vmm#304, the hvi branch this one pins (hvi-vmm at da59b45, its head).

Changes

  • Linux build of cmd/hull with the hvi backend and a block rootfs; a linux CI lane (build, vet, lint, unit tests on ubuntu-24.04-arm).
  • hull pause and resume of an hvi instance, stop over its control socket, checkpoint into a bundle with --then continue|fence, and restore from a fenced bundle.
  • hull migrate --to, --status, --abort, --txid and hull migrate-prepare: the streamed move with pre-copy rounds and the disk rounds inside hvi's stream, one ssh session from the header to the resume, bounded waits on hvi's pause and on a stalled destination, and the ledger of committed moves.
  • Bases by content digest, one per image, with a spare copy kept for the next move; removal of bases nothing needs.
  • migrate-prepare --kernel-sha256 replaces a destination's portable kernel with the pinned one, so a moved guest runs the Image brig pins.
  • Portable machine profile for runs and moves (--profile portable-arm64-v1).
  • The fence is recorded without racing the transfer's stats; a stream close from hvi reads as failure and asks hvi's status.
  • go.mod: cilium/ebpf held at v0.18.0 for urunc's cgroups code on the native Linux build (the reason is in go.mod).
  • Docs: docs/migration.md (new), backends, build, checkpoint-restore, cli, images, running, storage.

Notes for review

  • 33 commits. The first fifteen are one capability each, counting the gateway fix that closes the netstack on stop; the ones from "build one base per image" to "replace a destination's kernel" are the streamed-move era in order, since those files changed together in every step. Every commit builds and passes vet.
  • The Go build does not read the submodule, so only the last commit moves the gitlink; a make hvi_vmm before it builds main's pin.
  • The in-guest agent that resets a moved guest's outside TCP connections lives in urunc's urunit-agent (feat/agent-migration-tcp-reset). This branch keeps go.mod's urunc pseudo-version unchanged; moving that pin together with hull-assets and community-images is a separate change.
  • docs/migration.md keeps its dated status notes as history. The README's backend table and the restore row of docs/cli.md still say an hvi or KVM bundle cannot be restored, which this branch makes untrue; a docs fix follows.
  • Tested on this head: gofmt, go vet and golangci-lint for darwin and linux/arm64, make test, and CI's race step (go test -race -p 2 ./..., cmd/hull in 162 s). The race step used to time out because a gateway test leaked two gvisor stacks whose idle workers starved every later test under -race; the gateway now closes its stack when it stops. Linux tests ran on a Pi 5. The live Pi-pair and Mac-to-Pi moves above ran on earlier heads of the same series.

Checklist

  • make test passes
  • make macos builds hull, vz-runner and hvi, if Go, Swift or Rust code changed
  • I have added or updated tests covering the change
  • I have run the e2e harnesses (test/*.py) for changes touching boot, console, or the run path
  • I have updated the affected docs (README, docs/)

clearStaleUnixSocket treats ECONNREFUSED as "a socket nobody is
listening on" and removes the path before binding. On macOS a connect
to a regular file fails with ENOTSOCK, which falls through to the
refusal, so the existing TestClaimUnixSocketRefusesANonSocketPath
passes there. Linux answers the same connect with ECONNREFUSED, so on a
Linux host the gateway deleted whatever regular file sat at the path it
was told to bind. The test fails on Linux without this change.

Lstat the path on ECONNREFUSED and refuse unless it is a socket.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
pmoust added 22 commits October 20, 2026 17:43
runGateway made a netgw.Network and never closed its gvisor stack, so
the stack's TCP workers outlived the gateway. A gateway runs in its own
process, where that cost nothing, but one cmd/hull test runs two of
them inside the test binary. Under -race their 30 idle workers spin on
about eight cores for the rest of the package, which made every later
test ten times slower: the streamed move tests broke their wall-clock
bounds and the race step hit its 10 minute package timeout.

netgw.Network now has Close, which closes the stack, and runGateway
defers it. Close does not wait for the endpoints, since a member still
attached at shutdown could open a connection that nothing aborts, and
the wait would then hold the gateway's exit. Both packages' tests check
that no TCP worker is left behind.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
A doubled blank line there since the first commit made `gofmt -l`
list the file, so the gofmt gate could not pass on the whole tree.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
cmd/hull is darwin-only because a handful of helpers in otherwise
portable files call macOS interfaces: the termios ioctl numbers, the
hdiutil store mount, the ps-based process identity check, the
kern.procargs2 and kern.osproductversion sysctls, Stat_t.Ctimespec,
Statfs_t.Fstypename, the Homebrew e2fsprogs paths, and urunc's
darwin-only VzVmm and VzDarwin symbols. Moving each behind a small
_darwin.go file is what lets the shared files build for Linux in the
next change, with a Linux counterpart beside each one.

Nothing changes on macOS. The moved code is the same code, with three
small differences. processIsAVMM and reapOrphanVMM now read process
facts through inspectProcess and listProcesses: the ps parsing they did
inline lives in proc_darwin.go, and the identity rules (base name,
recorded argv, start-time slack) stay in stop.go, where both platforms
share them. e2fsck is named by a constant like mke2fs and debugfs
instead of an inline Homebrew path, and buildBlockRootfs checks that
all three exist before mke2fs writes a 15 GiB image. A missing debugfs
used to surface only after the image was written, as an error about
the wrong step. The command list and usage line come from
commands_darwin.go.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
Brig's teleport plan needs a destination runtime on Linux/KVM that
drives hvi the way hull does on a Mac. cmd/hull did not compile for
Linux: the untagged files called darwin-only helpers. The shared files
drop their darwin tag and each helper moved into a _darwin.go file gets
a Linux counterpart: termios through TCGETS and TCSETS, process identity
from /proc, and a plain store directory in place of the hdiutil mount.

hull builds the Linux hvi argv itself instead of using urunc's Linux
monitor, which is written for the containerd shim and passes no
--agent-sock, --net-gateway or --net-mac. hviLinuxArgv is the darwin
argv minus the virtio-fs shares, and both builds take the vz name from
one constant. A darwin test fails if urunc's darwin argv grows, drops or
reorders a flag for the same share-less arguments.

hull on Linux offers the one configuration hvi on KVM can run: hvi with
a block rootfs. It refuses other backends, virtiofs and 9pfs roots, host
shares and --net shared without a gateway by name, before the store is
opened or the image pulled, and it refuses an image annotated for
another hypervisor instead of booting it under hvi.

make linux cross-builds the CLI, CI builds, vets, lints and unit-tests
hull for GOOS=linux, and backends.md and build.md describe what Linux
hull does, what differs from macOS and what was tested on a Raspberry Pi
5.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
Run as an ordinary user on Linux, an unpack cannot chown, so every file
came out owned by the user who ran the pull and mke2fs copied that into
the guest's block image. On a Raspberry Pi 5 alpine's /bin/busybox and
/etc/shadow were 1000:1000 in the guest, where sudo refuses to run and a
setuid binary drops its caller.

The unpack now records the tar header's mode, uid and gid in the
user.com.nofire.hvi.linux-attr attribute, the only namespace an
unprivileged process may write, and the block image build applies the
recorded owners and modes. Files keep owner read and directories owner
rwx on the host, so images that ship /etc/shadow at 0000 (Fedora, RHEL
and their relatives) can be read by mke2fs, and the builder writes the
image's own mode into the image with debugfs. Symlinks, which take no
user.* attributes, get their owners from a side record, and the
ownership record itself is stripped from the image so the guest never
sees it.

A Linux test drives the unprivileged unpack through to the block image,
and the docs say what Linux hull is for and what was checked on it.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
Unlinking an entry needs write permission on its directory, and an
image is free to ship directories at 0555 (Fedora has dozens under
/usr/lib). The unpack puts that mode on the host, so os.RemoveAll by
the owner stopped with EACCES, and hull rmi, prune, instance removal
and the pull's own sweeps of old and staging directories left the tree
behind.

RemoveTree retries a removal that failed on a permission error after
adding owner rwx to every directory, walking through an os.Root so a
directory symlink in the image cannot point the chmod at a host
directory.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
hull could only signal an hvi VMM. Every hvi run now gets --control-sock
and --profile-out in its 0700 instance directory, and the record keeps
both paths. hull pause and hull resume work on an hvi instance, and hull
stop asks hvi to stop before it signals.

The client sends one request per connection with "v":1, since hvi serves
one connection at a time, and reads the reply under a timeout longer
than the pause deadline hvi gives. A refusal comes back with hvi's code,
and a reply that never came is told apart from a refusal, because the op
may still complete in hvi.

hull ps shows a paused or fenced VM, which keeps its record at running,
and hull exec refuses to dial into one instead of hanging on its agent.
The control socket path is checked before the image is pulled, as the
agent socket path already was.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
hull checkpoint and hull restore now take an hvi instance as well as a
Vz one. Both verbs move to portable files and dispatch on the record,
and the Vz paths are unchanged. A checkpoint sends hvi's checkpoint op
with the checkpoint's txid into a 0700 bundle directory under the
--checkpoint-root every hvi run now gets, since hvi's confinement lets
it create files only there. --then fence leaves the guest parked for
good, the source side of a move, and --then continue resumes it.
Directory roots and host shares are refused, as no bundle captures them.

A restore builds hvi's command line from the one the instance last ran
through an allowlist, so a flag hull does not know is refused rather
than dropped, binds the restore to the bundle's manifest and txid, and
can adopt a later latest.json. It waits on the restore's own VMM, sees a
detached hvi that exits at once, and dials the gateway.

The control flags are passed only to an hvi that takes them, because the
hvi hull pins answers them with "unknown boot arg" and exits. A
per-instance migration lock serialises checkpoints and restores of one
instance, and a restored guest is recorded as run before anything can
run it, so a second restore cannot put the bundle's RAM over a disk the
guest has written since.

checkpoint-restore.md becomes a page for both backends and says what an
hvi bundle holds, which restore states exist, what the lock covers and
what was checked live.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
`hull migrate NAME --to USER@HOST` drives a move: preflight on both
hosts, reserve, fence, transfer, restore paused on the destination, then
commit. The fence comes before the transfer because the bundle is the
fenced checkpoint; there is no pre-copy in this form.

Both hosts keep <instance>/migration.json, written through a synced
rename before the step that relies on it, with a transition table per
side so no record can go back past the fence or the commit. The instance
record gains the migrating, migrated and incoming states and an owner
epoch that hvi records with the fence, so a destination can refuse a
commit meant for another move. A move's txid is 128 random bits,
migrate.lock serialises its steps, and the plain verbs refuse an
instance a move holds.

The source frames the bundle and a sparse disk into its own stream,
since archive/tar cannot write sparse entries, and runs the
destination's hidden verbs (migrate-probe, migrate-receive,
migrate-prepare, migrate-commit, migrate-abort) over ssh in batch mode
with every argument shell-quoted. Each step finds its effect already
there when run again, so `hull migrate NAME --txid T` resumes from the
record.

A refused probe leaves no record behind, and the probe says how to move
an instance back to the host it left. migration.md describes the steps,
the states, the stream, a failure table and what ran live through the
commit on HVF.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
A destination kept a move's record only in the instance's
migration.json. When the source lost the commit's acknowledgement and
the destination then moved the instance on or removed it, nothing there
remembered the commit, and an abort from the source could run the guest
on two hosts.

The destination now keeps a ledger of the moves it committed, with the
state change each entry records, and an abort that the ledger refuses
says whether the instance moved on or was removed. A committing source
stays fenced unless the destination confirms the abort, and takes
"unknown txid" as no answer. A destination answers an abort only once
nothing of the move can run there. A retry that finds the copy verified
checks the disk against the fence, a second --abort finishes what the
first left undone, and a txid resolves only to the instance that holds
it.

migration.md describes the ledger, what an abort needs, and the live
runs of a lost commit acknowledgement.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
hvi's portable-arm64-v1 profile shows the guest the same machine on an
Apple host and on a Linux arm64 host, and its guest keeps time through
the hvi-time device, so it needs a kernel built with that driver. `hull
assets pull --profile portable-arm64-v1` installs that kernel as a
variant of the boot bundle with its own provenance, from an OCI artifact
or a local file, checks the artifact holds a kernel before it writes
anything, and names the right pull when the kernel fails its check.

`hull run --hypervisor hvi --profile portable-arm64-v1`, or the
com.nofire.hvi.profile annotation, passes --profile to hvi and records
the profile in state.json. The run boots only the profile's kernel,
checked by its contents whatever names it, and refuses other backends,
non-block roots, host shares, an hvi without --profile and an hvi
without the control socket.

A move carries the profile. The probe refuses a portable instance when
the destination's hvi takes no --profile, relaxes the same-OS check to
the architecture for a portable guest, and the destination checks the
received bundle's profile against the move. The docs describe profiles,
the kernel, what require refuses, and the moves run on the Linux pair
and on this Mac.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
A move sent the guest's whole disk every time, although most of it is
the image hull built and both hosts could hold already. To send only the
blocks the guest changed, both hosts need that image and a name for it
that does not depend on where it is stored.

The disk digest hashes the size and every non-zero 64 KiB block with its
index, so a hole and written zeros hash alike, and a clone, a sparse
copy and a received image hash the same. An hvi run with a control
socket on a block rootfs now keeps its image under <store>/bases before
launch, cloned where the filesystem can, records the digest and whether
the VMM booted from it, and passes --disk-base-sha256 to an hvi that
takes it. A restore clears the "from boot" mark, since hvi's record of
dirty blocks restarts there.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
A move should not spend its time carrying what the destination could
have held before it started: the image hull built the disk from, the
portable kernel a later boot there needs, the OCI image. On the Pis
the B4 demo had to install the kernel by hand, and every move sent
the whole disk.

`hull migrate-prepare NAME --to USER@HOST` now puts them there while
the guest keeps running: the instance's base by its disk digest,
sent as lz4-compressed non-zero blocks and kept on the destination
only when what arrived hashes to that digest; the profile kernel this
store has installed, checked against its sha256 and installed through
the same import as a local kernel; and the image, pulled there by its
reference unless the store has its digest. Each step finds its effect
already there and sends nothing, and --json reports what was done and
what the destination holds.

The probe now lists the bases and profile kernels a store holds,
whether its hvi restores from a stream, and a capability (stream-v1)
that a hull from before this change does not list, so a source can
tell an old destination apart without a protocol bump that would make
the two refuse each other. The destination's hidden step of a move
keeps its name: `migrate-prepare --txid T` still brings a received
checkpoint up paused.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
hvi's streamed checkpoint (format 2) is read by `hvi restore
--from-stream -` on its standard input, and hull had no way to give a
VMM one: a detached VMM always read /dev/null, and a restore always
named a bundle directory and checked its manifest first.

launchVMM now takes the file a detached VMM reads as stdin. A restore
reads a stream in two cases. A streamed move's own restore on the
destination (an incoming instance whose command line restores from a
stream) reads its own stdin, which `migrate-receive --stream` feeds,
and starts while the move is still receiving: hvi checks every
component as it reads, and the commit still waits for the move to be
verified. A source that takes back a streamed move reads the stream it
spooled, but only once that spool was sealed; one that never was is
refused. The recorded --from-stream is dropped when the command line
is mapped into the next restore, as --from is.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
A whole-bundle move stops the guest for the whole transfer: 5.3 to 9.4 s
for the 1 GiB ubuntu guest between the Pis, 46 to 54 s for the
claude-code guest from the Mac. hvi now streams its checkpoint with RAM
pre-copy rounds, so the guest only has to stop for the last one.

When both hosts can, `hull migrate` runs one ssh session to
`migrate-receive --stream` in the HULLMIG2 format: disk batches that
differ from the base and hvi's components relayed with hvi's own headers
and checked against hvi's sha256, then a trailer with a running hash and
an account of every disk round that the source checks against hvi's
digests. The source spools the stream and seals it, so a fenced guest
whose move fails can be restored from the spool, and holds off SIGINT,
SIGTERM and SIGHUP while hvi's stream is not on record. The destination
accepts the header before hvi's migrate begins, so a refusal there costs
no fence.

The downtime fixes move every synced write they can out of the pause:
the spool's and the disk's syncs, the fence record and hvi's documents,
leaving one synced write per host between the seal and the commit, with
writeback started as the spool and the disk grow so the host does not
throttle. The last disk pre-copy round runs right before hvi's migrate.
A fake hvi models hvi's restore and pre-boot writes for the tests.

migration.md, cli.md and storage.md describe streamed moves, bases,
migrate-prepare and what a streamed move records.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
Every step of a move ran its own ssh and paid for a TCP and key exchange
handshake: 0.23 s from this Mac to rp1, against 0.02 s for a command
over an open connection. A streamed move runs its commit with the guest
stopped, so that handshake was downtime.

The steps now share one connection (ControlMaster=auto) kept for a
minute after the last one. The mux socket lives in a 0700 directory only
this user owns, not under a predictable name in /tmp, where another
local user who made the socket first could be handed the move's stream,
guest RAM included. HULL_MIGRATE_SSH_MUX=off gives every step its own
connection again.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
migrate-prepare installed the source's portable kernel whenever the
destination's differed. A profile run boots only the kernel its store
has installed (see the machine profile commit), and brig's
BRIG_PORTABLE_KERNEL must have those bytes, so preparing a host that
keeps its own (newer, or built for it) kernel replaced it without a
word, and the portable runs there then failed the kernel check.

The prepare now sends the kernel only to a destination that has none for
the profile, and reports a different one as kept. The destination verb
refuses to replace an installed kernel even when asked. A move needs
neither: the kernel is in the guest's RAM.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
Each hvi run kept its own base, a copy of the disk it had just built
with the instance's /urunit.conf (its environment and credentials)
inside. mke2fs writes a fresh UUID and timestamps into every image, so
no two instances shared a base and every new instance needed a whole
base sent before its first move.

The block rootfs is now built in two parts. The image part is built once
per image digest and size and kept read-only under <store>/bases, and
each instance's disk is a clone of it with the instance's own files
injected.

Bases are kept only while something needs them: an instance or move
record that names one, an image still in the store, or a base a prepare
received. hull rm removes an instance's base when nothing else needs it,
and hull prune removes the rest, the entries of removed images and
half-written copies.

storage.md describes the per-image bases and what removes them, and
migration.md gives the streamed moves measured with the hvi that can
make them, with the guest's downtime taken from the rise in hvi-time's
downtime total.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
A streamed receive copied the base to the instance's disk before it
accepted the stream. Where the filesystem has no clones (ext4 on the
Pis) that copy of the 1.6 GB claude-code base took 22 s of a 28 s move.

A destination now keeps a spare copy of a base it holds, made when a
prepare sends the base or by the new migrate-spare-base step when the
base is already there. The receive takes the spare by renaming it and
copies the base only when there is none. A base's passed check is
recorded beside it, so a move hashes it once.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
hull drove hvi's disk-dirty and disk-blocks ops itself, and since hvi
serves one control connection at a time the last disk round was read
after the fence, holding everything the guest wrote during the RAM
pre-copy, outside hvi's manifest and all of it downtime.

hvi now sends a disk round before each RAM round and takes the final one
in the pause, and its manifest covers them. The source relays that one
stream as hvi writes it, and the destination hands each frame to `hvi
restore --from-stream - --disk <copy> --disk-base-sha256 <base>` as it
arrives.

hvi puts disk rounds in the stream only for a disk booted with
--disk-base-sha256. A run on an image base compares the instance's disk
with the base after the injection, writes that bitmap beside the
instance, and passes it as --disk-written with the base digest, so hvi's
blocks written since boot include hull's own writes. A move that has to
send the whole bundle says why.

migration.md describes the stream-v3 move and its numbers.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
hvi fences a guest once its migrate manifest is in the socket's buffer.
A hull that dies before its spool holds the whole stream now recovers
the guest from the fenced hvi, which streams it again only for the txid
and epoch that fenced it. hull reads the first manifest's digest from
the stats of that reply and checks the second stream's documents against
the first stream's, which the record of the fence now keeps.

A move that a killed hull left verified on the destination commits that
copy, and its summary leaves out a stream it never sent. When hull
itself breaks a stream it asks hvi whether it fenced instead of saying
the guest runs on, a replay counts its downtime from the first attempt's
pause, and a failed second stream is never reported as a guest that runs
on.

migration.md records an abort through a second stream, a guest whose run
wrote over its base, the fence check and a replay's pause.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
A streamed move from the Mac to rp2 spent 2.9 to 3.1 s between hvi's
seal and the destination's resume, against 55 to 90 ms on the Pi pair,
and nothing on record said where. The destination now records when the
manifest arrived, when it recorded verified and each part of its commit,
the source keeps those steps with its own, and prints them as one line
in milliseconds after the seal.

The destination's resume is timed apart from its commit writes, the
downtime counts to the destination's resume, and the pause counts from
the end of hvi's I/O gate (gate_ms), which the hvi pinned at the end of
this series holds with the vCPUs running. migration.md has the Mac to
rp2 claude-code moves made through brig.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
On rp1 to rp2 with 100 ms and 10 Mbit/s of netem, a move spent 3043 ms
from the seal to the resume, 2566 ms of it before the destination read
the manifest: about 3 MB sat in hull's buffer, ssh's channel window and
the kernel when hvi sealed. The commit then took a new ssh session, and
the source polled migrate-status over new sessions.

A destination that lists stream-session now takes `migrate-receive
--stream --session` and writes accepted, acked and verified lines on
stdout. The source holds what it relays to a window of the fastest
recent rate times twice the round trip, measured again each second and
never under 20 ms, reads hvi's stream through 64 KiB, and sends the
commit in the same session. The destination waits less for that commit
than migrate-commit waits for the move's lock. The destination saves the
epoch with the manifest it received, and a restore polls hvi every 20
ms.

migration.md and cli.md describe the session and give the moves re-run
with it on the LAN and on the emulated WAN, counted from hvi's seal.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
`hull migrate --status` during a streamed move's rounds said the source
was fenced while the guest still ran, and took hvi's silence during its
migrate op as a guest still running, also through the seal and the
fence. For a streamed move in transferring the status now asks: a fence
on the record or hvi saying fenced is a fence, hvi saying running or
paused is a guest that runs here, and a silent hvi gets what is known of
the op's phase.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
A move back to the host a guest came from found no spare there and
copied the base first, which paused the guest 22.9 s on the Pi pair.
Once a streamed move commits, the source starts `hull
migrate-spare-base` in a session of its own, which makes the spare and
then checks the base, so the move back neither copies nor hashes it with
the guest paused.

A spare is made once, synced, and only for a held base, under a lock
that a move back's receive waits on, and a failed lock is recorded as a
wait given up. The destination's steps say whether a receive took the
spare or copied the base, and hull prune sweeps half-written spares.
migration.md says when a move back still waits for that step.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
The hvi this series pins waits up to 10 s for its I/O threads at a pause
and 2 s for the vCPUs, and asks clients for a reply timeout of at least
13 s. hull gave every op 5 s, so it could report as failed a pause that
then paused the guest. pause, resume, status and profile now get 15 s.

hvi gives the part of a move's stream it writes with the guest paused 10
s plus twice the last running round, and hull now waits for that part
only as long. hvi also closes the connection when a migrate fails after
its stream began, so hull takes that close as hvi's failure and asks
hvi's status after every close instead of reading frame bytes as a
refusal. The stub hvi's test cleanup waits for its goroutines and closes
its connections without a map. migration.md has the moves measured with
that hvi (94faf8f before hvi-vmm's history was rebuilt).

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
The source's window and its writes into ssh waited with no deadline, so
a destination hull stopped with SIGSTOP held the move's lock and its
signals until it was continued. A write to the destination now has an
idle bound: hvi's own reader bound of 30 s plus a margin while hvi's
migrate runs, the paused part's bound when that is shorter, and 2
minutes for a replay from the spool. migration.md measures the stalled
destination with the source's cut and the claude-code move from the Mac
on the LAN.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
The instance's record of the fence is written on a goroutine of its own
and reads hvi's reply from the move's stats, while the transfer wrote
that field again once the destination answered. go test -race caught the
race in about one run of 30. The transfer no longer rewrites the reply
that send stored at the seal, and the replay path stores it the same
way, so nothing writes the field while the record runs.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
brig now pins the five-patch portable kernel and refuses to boot any
other at its default place, and a prepare left a host's older kernel in
place. migrate-prepare takes --kernel-sha256, the profile kernel the
destination is to hold. When this host holds that kernel and the
destination another, the kernel is sent with --replace and
migrate-take-kernel installs it over the other one. A destination lists
the kernel-replace capability when it takes that, and one that does not
is told what to run.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
The move tests run their destination inside the test binary, so its
probe asked the test host whether it can open /dev/kvm. The CI
runners cannot, and every test that moves an instance failed there
with "it cannot run a VM". The unit tests boot nothing: the probe's
hypervisor check is now a variable, and the Linux TestMain answers it
as a host that can. The real check moves to hostHypervisorAvailable
on both platforms.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
The crash tests kill a hull once hvi has fenced the guest, then abort
or rerun the move at once. On macOS the dead process's flock on the
move is released a few ms after its parent has reaped it, so the next
step found the lock held and answered "a hull migrate of vm1 is
running". The harness now takes and releases the lock itself, with a
wait, before it goes on.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
@pmoust
pmoust force-pushed the feat/teleport branch 2 times, most recently from a52b05c to de5a196 Compare October 20, 2026 18:12
hull's code in this series drives hvi's control socket, checkpoint and
restore, the migrate stream with its disk rounds and gate, the fence
owner's second stream, and the close of a failed stream. da59b45 is
hvi-vmm's feat/teleport with all of that landed, rebased onto main's
PL031 RTC, ledger writer thread, panic abort and sandbox that stays on
for the life of the process. A native bundle now carries the PL031 in
its device state; the portable profile's devices are unchanged. A move
still needs exactly one writable disk, and hull gives a run one bare
--disk and carries it into every restore.

The Go build does not read the submodule, so the commits before this one
build against main's pin, c53a857. That hvi boots hull's runs, since
hull probes for the control flags, but cannot make a move.

Signed-off-by: Panagiotis Moustafellos <pmoust@nofire.ai>
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