ANDLER (ANDLER = Android Linux Emulator & Runtime) is a Rust monorepo for running and managing virtual machines (Android and Linux guests) on Linux/KVM. It follows a daemon + thin CLI architecture, with QEMU as the current hypervisor backend and gRPC as the communication protocol.
- Domain-driven separation: Pure domain types in
andler-corewith no infrastructure dependencies. All I/O (QEMU, filesystem, network, database) lives in separate service/backend crates. - Backend abstraction: The
HypervisorBackendtrait defines a hypervisor-agnostic interface. Adding a new hypervisor means implementing this trait — no changes to daemon, CLI, or RPC. - Explicit state machine: Instance lifecycle is governed by a strict FSM with named states and validated transitions. No implicit state changes.
- Persistence is optional: SQLite holds only snapshot metadata; the daemon works with or without a store. Instance configs live in
instance.tomlfiles and are re-read on every state transition — the file, not memory, is the source of truth for config. Store errors are logged but don't fail operations. - No global state: Each crate has clear responsibilities.
andler-coreknows nothing about QEMU, gRPC, or SQL.andler-qemuknows nothing about the daemon or CLI. - User-mode by default: All paths under
~/.andler/. No root/sudo required for normal operation.
andler-core (no workspace dependencies — bottom layer)
↑
├── andler-qemu (depends on: core)
├── andler-disk (depends on: core types only via error, mostly standalone)
├── andler-net (depends on: core)
├── andler-store (depends on: core)
├── andler-firmware (depends on: no workspace crates — standalone, uses sysfs/nvml)
└── andler-rpc (depends on: core)
↑
andler-daemon (depends on: core, qemu, firmware, disk, store, rpc)
↑
andler-cli (depends on: rpc only — thin client)
The foundation crate. Defines all public types that other crates depend on.
Key types:
HypervisorBackendtrait: The contract for all hypervisor implementations (17 methods)InstanceConfig: 9-section configuration struct (disk, cpu, memory, display, gpu, network, audio, input, firmware)InstanceState/InstanceEvent: FSM with 7 states and 7 eventsResourceMetrics: All-Optional metrics struct (CPU, RAM, disk I/O, net I/O, GPU)CloneMode:Linked|FullStandalone|SharedBaseAndroidProfile: Android version + root mode + app store configurationBackendError/FsmError: Domain error typespaths: Unified path resolution (runtime_dir(),current_uid(),ensure_private_dir())
~50 unit tests, fully testable without QEMU or /dev/kvm.
Must not depend on any other workspace crate.
Implements HypervisorBackend for QEMU via process management, QMP communication, and /proc-based metrics.
Key components:
cmdline.rs: Pure function translatingInstanceConfigto QEMU CLI arguments (argument blocks, each tested independently against reference configuration). Includes the guest-agent block: asocketchardev (*.qga.sock,server=on,wait=off) wired to avirtserialportnamedorg.qemu.guest_agent.0, plus a-fw_cfg name=opt/andler/display-resolution,string=WxHentry when a resolution is configuredprocess.rs:QemuProcess— spawn, terminate, force-kill, log/metrics broadcast channels,qga_socket_path()qmp.rs:QmpClient— QMP protocol over unix socket (handshake, pause/resume/status, snapshot job API) and the QEMU guest agent (QGA) protocol viaconnect_agent()on the*.qga.sockchardev (guest-ping/guest-exec/guest-exec-status/guest-file-open/...). Async events (SHUTDOWN, DEVICE_DELETED, VSERPORT_CHANGED, BLOCK_IO_ERROR, ...) arrive on a dedicated second QMP monitor (-qmp <qmp.sock>.events.sock):QmpEventReaderconnects there exclusively and publishes events to a broadcast (it never sends commands, so event delivery cannot interleave with command/reply traffic on the command monitor — a reader task sharing the command socket wedged one gRPC connection on QEMU death, which is why the monitors are separate).QemuBackendspawns a reconnecting reader per instance and relays records onto its own broadcast; the daemon maps the handle back to the instance id and emitsDaemonEvent::Qmpon the bus (StreamEventsRPC /andler events).backend.rs:QemuBackend— ties everything together, managesRunningInstanceregistry; guest operations (guest_exec_package,set_guest_display_resolution, ...) talk to the agent chardev socket, not the QMP monitor (QEMU ≥ 9 no longer registersguest-*commands on QMP)metrics.rs: Background poller reading/proc/<pid>/stat,/proc/<pid>/status,/sys/block/*/stat,/proc/<net/dev(per-VM); calls intoandler-firmware::metricsfor the GPU fields (host-level, not per-VM — moved there to sit next to GPU vendor detection). Sample collection runs intokio::task::spawn_blocking— the/procand sysfs reads are synchronous I/O and must not block the async runtime.
Tests: unit + integration, across cmdline/process/qmp/backend/metrics (GPU metrics tests moved to services/andler-firmware).
Wrapper around qemu-img for disk creation/cloning/resizing, plus guest tools offline provisioning.
Key components:
qcow2.rs: 7 async functions wrappingqemu-imgCLIoverlay.rs: Android-specific overlay disk creation + factory resetclone.rs: 3 clone modes (linked, full-standalone, shared-base)guest_tools.rs: zero-root offline guest package work through the libguestfs appliance — detects the package manager (apt-get/dnf/pacman) by probing four paths in one batch, gates on the manager's own query, then runs the whole recipe (appliance network, guest resolver, index refresh, install/remove, systemd-unit enablement) as shell commands chrooted into the guest as root. No/dev/nbd*, no root, no sudoers rules, no FUSE mountarm_translator.rs: ARM translator package staging in guest images (atomic staging + rename)boot_mode.rs: Android/Linux boot-mode switching by re-pointing the guest'sdefault.targetsymlink through aGuestMutator(switch_boot_mode_with) — offline via theGuestfsMutatorappliance, online via QGA; reading the mode is config-backed, no disk accessdiskspace.rs: free-space pre-check before snapshots
~45 unit tests (+ ignored integration tests requiring qemu-img).
GuestfsMutator, one of the two real implementations of
andler_core::GuestMutator (the other is QgaMutator in
backends/andler-qemu). Drives the libguestfs appliance (guestfish)
against a guest image: the appliance boots its own unprivileged QEMU once,
mounts the filesystem under an exclusive qemu image lock, and then stays
listening — every MutatorOp batch, probe and read is a cheap client of that
one booted session (measured: a nine-question install went from 20.8s to 7.7s
with two boots instead of nine), and the session is stopped with the mutator. Zero root, and the same
mechanism serves every offline operation, package install/remove
included — a RunShell command runs via the guest's /bin/sh with the
guest root as / and the appliance's /dev, /dev/pts, /proc and
/sys in place, so the guest's own package manager runs as root without
root on the host. GuestfsMutator::for_packages is that session: QEMU
user networking (guestfish --network) plus the package-step budget
(ANDLERD_GUEST_PACKAGE_TIMEOUT_SECS), because the appliance has no
network of its own. The trait + batch contract + shared conformance
suite live in andler-core.
Implements bridge and isolated network modes for QEMU VMs via host-side network configuration.
Key components:
lib.rs:NetworkServicetrait andDefaultNetworkServiceimplementation usingiproute2. Bridge mode builds and tears down a host bridge and a per-instance tap;setup_isolatedreturns anIsolatedNet— the tap name plus the argv prefix that starts the guest's QEMU inside a fresh unprivileged user + network namespace, where that tap is the only interface and there is no route to any host network (QMP and the guest-agent chardev are UNIX sockets, so host-side control survives the namespace; the tap name derives from the instance id, so a daemon that restarted can still tear it down). A host that cannot provide the namespace is refused with an actionable reason before QEMU is touched.
Standalone crate for firmware discovery, hardware auto-detection, and GPU metrics. No workspace dependencies — communicates with hardware via sysfs and vendor CLIs.
Key components:
detect/: Hardware auto-detection (GPU, OVMF, ARM translator, audio, network passt)metrics/: GPU metrics collection (NVIDIA via NVML + nvidia-smi fallback, AMD via sysfs, Intel via i915 delta)HardwareDefaultsstruct:detect_all()returns detected hardware for wizard defaults
~50 tests across detect/ and metrics/.
SQLite store for snapshot metadata only.
Schema:
snapshots(id, instance_id, tag, description, created_at, layer_path, parent_id, branch)— chain metadata (layer path, parent, branch)config_migrationmarker: legacy databases (pre-phase-1, with aninstancestable) are migrated on daemon startup — each stored config is written out asinstance.toml(refusing to overwrite a differing file), then the instances table is dropped. The daemon itself never persists instances to SQLite.
~20 tests using in-memory SQLite, including legacy-database migration.
Protobuf definitions and generated code via tonic/prost.
RPC surface covering instance lifecycle, monitoring, snapshots, clone/export, hotplug, guest provisioning and observability — count from proto/andler.proto.
~45 conversion tests for bidirectional proto↔domain type mapping.
Orchestrates all operations. Holds backend registry, per-instance supervisors, optional persistence.
Key design:
Daemon::new()/Daemon::restore(store)— two construction paths (restore also takes a root for tests)- Instance registry on disk: each instance is a directory
~/.andler/instances/<id>/whoseinstance.tomlis the single source of truth for the config; the daemon re-reads it on every state transition (so hand-edits survive daemon restarts).Daemon::restore()scans the instances directory, tracks entries with missing/invalid toml as broken (listed with the reason, removable, never fatal to startup), migrates legacy store configs, and reconnects to any QEMU process that survived a crash (adopt, below). The SQLite store holds only snapshot metadata — instance configs and states never touch it. - Instance supervisor (task per instance): each registered instance runs one tokio task that owns the FSM state, the backend handle and the config — the only writer of all three. Everything else reads them through
tokio::sync::watchsnapshots (SupervisorHandle::state()/backend_handle()/config()) and mutates them through an mpsc command channel (transition,set_handle,set_config), which acknowledges only after the change is applied and persisted. This replaces the old sharedRwLock<HashMap<…>>as the daemon's structural state. Long-running operations (snapshot restore is the first) run as supervisor sub-tasks that ack their start immediately through a oneshot and stream progress back (RunOperation/CancelOperation/GetActiveOp/JoinActiveindaemon/ops.rs) — one operation per instance, cancellation via a watch token checked at per-file phase boundaries; the accept rule (join a same-key op, refuse a different key, start fresh when nothing is in flight or the in-flight op was cancelled) lives inJoinDecision, shared byRunOperationand byJoinActive, the query a request uses when the instance is mid-transition and cannot start its own operation;Operationevents land on the event bus and in the instance audit log. QMP/metrics ownership moves under the supervisor in a later phase. - Daemon restart semantics: a graceful SIGTERM/Ctrl+C is a deliberate shutdown —
shutdown_signalstops every Running/Paused instance before the daemon exits andkill_on_dropguards the rest. Reconnect is therefore a crash-recovery path, not a graceful-restart path: on startupDaemon::restore()tries to adopt any instance whose QEMU process is still alive by connecting to its QMP socket, resolving the surviving QEMU's pid (query-processes, falling back to the/procprocess=<name>cmdline marker), verifying identity, and re-creating the backend handle around the pidfd. Only if the process cannot be found/recovered does the instance land inStopped. Instances that were mid-operation (Starting/Stopping) at crash time are never guessed at: they becomeStopped, andstartis the documented recovery path. - Autostart: an instance whose
instance.tomlhasautostart = trueis started automatically when the daemon starts. The start is issued through the exact samedo_start_instancepath as theStartInstanceRPC (same port-conflict check, same FSM transitions, same exit watcher), so supervisor guarantees never diverge between a manual and an automatic start. The pass runs as a supervised background task afterrestore()— one start per matching instance, never a retry loop: a failing start leaves the instance inError/Stoppedand the daemon continues. Only instances inStoppedare autostarted; adopted running VMs and instances in any other state are left alone. The flag is settable at create (create --fileautostart = true), in the file directly, or viaandler config set <id> autostart true. - CPU pinning: an instance with
cpu.affinity = [..]in its config is launched undertaskset -c <set>, so every QEMU thread (vCPU, iothread, main) is restricted to those host CPUs — the version-independent equivalent of QEMU'sthread-contextobject, whose accel binding is unavailable on the QEMU versions this project targets. Two pinned instances with overlapping sets would silently contend for the same cores, so a start whose pin overlaps anotherRunning/Paused/Startingpinned instance is refused (CpuAffinityConflict); unpinned instances are scheduler-managed and never conflict. An affinity index at or beyond the host's CPU count (available_parallelism) fails validation before spawn. - Memory overcommit guard: before spawning, the daemon sums the guest RAM of every running instance plus the requested instance's
size_bytesand refuses the start when the total exceeds the host's physical RAM (read from/proc/meminfoMemTotal, falling back to the requested size when it cannot be read) withMemoryOvercommit. This is a hard safety check, not a soft limit: the QEMU process would otherwise fail to allocate the requested RAM and the instance would land inError. Unpinned instances are never blocked by the affinity gate; the memory gate applies to every instance regardless of affinity. - Event bus: the daemon owns a
broadcast::Sender<DaemonEvent>; supervisors publishLifecycleevents on every applied FSM transition (with theFailreason). Consumers subscribe viaDaemon::subscribe_events()(event types live inandler-core::events, pure serde types). - Instance lifecycle via FSM transitions (applied by the supervisor)
InstanceDirGuardRAII for cleanup on partial failurecreate_linux_instance()/create_android_instance()— high-level resource creation + registrationresolve_instance_id()— Docker-style partial ID resolution (12-char hex prefix)update_instance_config()— Edit config via gRPC, protects id/kind/disk.pathDaemonService— thin gRPC wrapper, one method per Daemon method- Error mapping:
DaemonError(count fromerror.rs) →ErrorKind(single exhaustive match) → gRPC status codes
~120 unit tests (some ignored) + gRPC round-trip tests (real TCP).
Thin gRPC client. Each subcommand = one gRPC request + print response.
Key features:
- Unified
createcommand (TOML for LinuxVm, CLI flags for AndroidVm) - Interactive wizard with smart defaults and hardware auto-detection
- Real-time metrics streaming with GPU columns (AMD/NVIDIA/Intel)
- Snapshot CRUD with per-operation timeout
- Disk management (create, info, resize with shrink protection, compact)
- Shell completions (bash, zsh, fish)
- Colored status output with
IsTerminalgating doctorcommand for environment diagnostics (KVM/QEMU/OVMF,CAP_NET_ADMINfor bridge networking, the zero-root offline prerequisites, daemon reachability, base images)op list/op cancel— long-operation progress and cancellationconnect(console/ssh/adb/exec) — guest access levels: console attaches straight to the serial chardev socket (raw termios via libc, piped-stdin safe); ssh/adb spawn the external client againstnetwork.port_forwards;execruns through the guest agent and relays the exit code.autopicks by the effective(kind, boot_mode)profilecreate --template— VM templates (built-ins +~/.andler/templates/), merged defaults < template < flags- Version handshake:
GetVersionis queried before every daemon-bound command; a mismatched daemon build is rejected with a restart hint ~130 tests (TOML parsing, helpers, create, wizard, status, doctor, connect).
User
↓ (CLI command)
andler-cli (gRPC client)
↓ (protobuf over TCP)
andler-daemon (gRPC server)
↓ (trait methods)
andler-qemu (HypervisorBackend)
↓ (process + QMP)
QEMU process → VM
↓ (/proc + sysfs)
Metrics poller → broadcast → StreamResourceMetrics → CLI display
↓ (SQLite, snapshot metadata only)
andler-store (andlerd.db)
Package management and resolution changes inside a guest use a two-tier strategy:
Online (VM Running, guest agent available): commands run inside the guest via the QEMU guest agent (QGA) — guest-exec/guest-exec-status (package install/remove, package-manager detection) and guest-file-* (config file writes, e.g. display.conf on resolution change). The agent wire runs over the dedicated *.qga.sock chardev (virtserialport name=org.qemu.guest_agent.0), never the QMP monitor. This requires qemu-guest-agent inside the guest, which the base image ships enabled (qemu-guest-agent.service).
Offline path (VM stopped) — the guest's own root filesystem is the root of the command: the libguestfs appliance (guestfish, zero root) mounts the disk and MutatorOp::RunShell commands run via the guest's /bin/sh as root inside the appliance, so a package manager sees the real guest (dpkg's access(R_OK|W_OK) check on /var/lib/dpkg included) without any privilege on the host:
- package manager detected by probing
/usr/bin/apt-get,/usr/bin/dnf,/usr/bin/yumand/usr/bin/pacmanin one appliance batch; the manager's own query (dpkg -l/rpm -q/pacman -Qi) decides whether the package is already there - the session's own network and resolver: the guest has no network while its disk is idle, so the recipe brings up the appliance's QEMU user networking (
eth0,169.254.2.15/16, gateway169.254.2.2), writesnameserver 169.254.2.3into a tmpfs on/runand bind-mounts that over the guest's/etc/resolv.conf(the appliance's own resolver file is a read-only bind with no nameservers when the host has nodhcpcd). Nothing of this reaches the guest image - package index is refreshed first (
apt-get update/dnf makecache/pacman -Sy), then the install/remove runs, then a package's systemd unit is linked intomulti-user.target.wants(deb-systemd-helpercannot enable one without a running systemd) - the whole recipe is one
applybatch — one appliance session, not one per step; the appliance's/dev,/dev/pts,/procand/sysmounts are provided by libguestfs, so the old bind-mount preamble is gone with the FUSE kitchen - nothing is privileged and nothing is mounted on the host: the appliance needs
guestfish,/dev/kvmand an image lock, and no path requires unprivileged user namespaces any more; the old qemu-nbd +andler-helpersudoers surface and theguestmountFUSE mount are gone entirely
ARM translators (libndk/libhoudini) use the same appliance sessions via switch_arm_translator: version-keyed download (MD5-verified, cached under ~/.andler/cache/arm-translators/), staged into var/lib/waydroid/overlay/system, build.prop updated, old translator removed only after the new one is fully staged.
The wizard (and andler guest apply <id>) does not carry a separate list of "things the user picked": the instance's instance.toml is the record, and andler_core::guest_profile::guest_selections turns it back into work —
kind.android_profile.arm_translator (when not none) → the ARM translator, input.clipboard_enabled → the spice-vdagent package. The daemon then applies each selection through the state-appropriate path (QGA when the VM is running, the libguestfs appliance when the disk is idle — never the auto-start maintenance path, so applying a profile cannot boot a VM the user did not start) and returns one classified outcome per selection:
| Outcome | Meaning |
|---|---|
applied |
installed now |
already_present |
the guest already runs it (the translator switch and the package query both report this for free) |
skipped |
cannot apply in this state — e.g. a disk with no installed guest OS (DiskError::NoGuestOs), or a translator switch while the VM runs; the message carries the command that finishes the job later |
failed |
the attempt errored; the message carries the retry command |
One failing selection never aborts the others: the clipboard agent is still installed when the translator download fails, and creation itself is already done by then — the VM exists and stays usable. andler create's wizard runs this immediately after creating, which is why the summary screen lists what "will be installed inside the VM".
andler CLI → gRPC → daemon
├─ running: backend.qga socket (*.qga.sock) → qemu-ga (guest) → guest-exec/file ops
└─ stopped: andler-disk → guestfish appliance (QEMU user network + resolver) → guest's own shell as root → package manager
An Android (or Linux-from-the-same-image) instance boots an overlay whose backing file is a base image: a bootable qcow2 built by docker/images/build.sh (Arch + CachyOS kernel + Waydroid), one build per (Android version, package set) — VANILLA or GAPPS.
Cache layout (~/.andler/cache/base-images/): <stem>.qcow2 with a <stem>.manifest.json sidecar, either flat in the cache root (legacy) or in a one-level android<major>-<variant>/ subdirectory — the layout build.sh writes and the one base_image::resolve scans. The manifest is the image's identity: android_major, android_variant, built_at, sha256, file_size_bytes, and — for published builds — compression plus the parts list. base_image::resolve(profile) picks the newest matching build; the daemon calls it when a client omits base_image_path, and BaseImagePin (id + sha256) records what an instance was created from. Deriving that sha256 means reading the whole image, so the daemon memoizes it per (path, size, mtime) in ~/.andler/cache/base-image-digests.json (daemon::base_image_digest): a create that finds a matching entry skips the read, and a replaced image changes its size or mtime and is hashed again. The memo is a cache, not a check — an image whose identity cannot be read is still hashed. Deriving that sha256 means reading the whole image, so the daemon memoizes it per (path, size, mtime) in ~/.andler/cache/base-image-digests.json (daemon::base_image_digest): a create that finds a matching entry skips the read, and a replaced image changes its size or mtime and is hashed again. The memo is a cache, not a check — an image whose identity cannot be read is still hashed.
Release downloads: the same builds are published as GitHub releases (base-image-android<major>-<variant>-<ts> tags, one <stem>.manifest.json asset plus <stem>.qcow2.zst.NN.part assets — zstd-compressed and split because a release asset is capped at 2 GiB). services/andler-disk/src/base_image_download.rs reads that catalog, verifies every part against the sha256 its manifest declares, streams the concatenated parts through a zstd decoder into a staging file, verifies the unpacked qcow2 against the manifest's sha256, and only then renames it (plus the manifest bytes exactly as published) into the cache. Verified parts of an interrupted download are reused; a build already in the cache is reported as reused without a request. ANDLERD_IMAGE_REPO and ANDLERD_IMAGE_API_BASE point the daemon at a mirror or a fixture server.
The daemon is the only component that talks HTTP: ListRemoteBaseImages (unary) and DownloadBaseImage (server-streaming progress) are the route the CLI uses today and a GUI will use later, and the wizard offers a download when no local image matches the requested configuration.
┌─────────┐
│ Created │
└────┬────┘
│ Start
▼
┌─────────┐
│ Starting│
└────┬────┘
│ StartCompleted
▼
┌──── ┌─────────┐ ────┐
Pause│ │ Running │ │Stop
│ └────┬────┘ │
▼ │ ▼
┌─────────┐ │ ┌─────────┐
│ Paused │ │ │Stopping │
└────┬────┘ │ └────┬────┘
│Resume │ │StopCompleted
└────►┌────┘ ▼
│ ┌─────────┐
│ │ Stopped │──(Start)──►Starting
│ └─────────┘
│
Fail(msg)
▼
┌─────────┐
│ Error │──(Start)──►Starting
└─────────┘
Stopped/Error both accept Start and return to Starting — restarting
the same instance record works the same way a fresh Created instance does
(Daemon::start_instance is generic over the source state). is_terminal()
returns true for both — that means "this run has ended", not "no
transitions remain".
Any active state (Created/Starting/Running/Paused/Stopping) can transition to Error via Fail(msg).
The daemon's status reports the guest's readiness as an explicit level, not a
guess from log text. The contract (decision from the architecture rework,
documented here) is a monotonic ladder — each level implies the previous one:
| Level | Meaning | Observed by |
|---|---|---|
SerialUp |
VM process is up, serial chardev responds | the backend's own live status (QMP query-status) |
QgaUp |
QEMU guest agent answers probes (QGA chardev socket) | QGA handshake (guest-ping) |
DisplayApplied |
configured display resolution was applied inside the guest | QGA read of the guest's own record, /etc/andler/display.conf |
GuestOsUp |
guest OS finished booting (systemd / init reports ready) | QGA systemctl is-system-running |
WaydroidReady |
Android session: Waydroid container is running (AndroidVm only) | QGA systemctl is-active waydroid-container.service |
The serial chardev serves exactly one client and the daemon never takes it
away from an andler connect --level console session, so SerialUp is read
from the backend's live status instead of by connecting to the socket.
Effective profile, not just kind: the levels a run can reach depend on
the (kind, boot_mode) pair, because an AndroidVm switched to Linux
boot-mode never brings up Waydroid. andler-core resolves the pair into a
ReadinessProfile (LinuxGuest, AndroidLinuxMode, AndroidWaydroid),
which fixes the profile's level sequence and its terminal level:
| Profile | Ladder | Terminal level |
|---|---|---|
LinuxGuest (LinuxVm) |
SerialUp → QgaUp → DisplayApplied → GuestOsUp |
GuestOsUp |
AndroidLinuxMode (AndroidVm, boot_mode = linux) |
SerialUp → QgaUp → DisplayApplied → GuestOsUp |
GuestOsUp |
AndroidWaydroid (AndroidVm, boot_mode = android) |
SerialUp → QgaUp → DisplayApplied → GuestOsUp → WaydroidReady |
WaydroidReady |
A level outside a profile's ladder cannot be observed for that profile: it
is never reported for it, neither as reached nor as failed. WaydroidReady
for a Linux guest is the case this rule exists for. A switch of the Android
boot mode re-derives the ladder and clears it: a run of a different profile is
a different ladder, not a continuation.
Probes answer in three states, not two. Ready only ever comes from the
guest saying so. NotReady means the probe measured the level and the guest
reports it has not reached it. Unobservable means the level could not be
measured at all — no agent answered, the guest carries no reporter for it (a
guest whose init keeps no readiness state), or its profile never reaches it.
A probe that cannot answer is never rounded up to a reached level. Probing
walks the profile's ladder weakest-first and stops at the first non-Ready
answer; levels above QgaUp are observed through the agent, so a guest
without one reports QgaUp and stops there.
Monotonic by construction. Only a Ready probe moves a run's position,
only upwards in the profile's own order, and never onto a level the profile
cannot reach — so a probe that flips back, a re-observed level, or an
out-of-order observation of a stronger level can never move a run backwards.
The position belongs to the run: it is cleared when a run starts (Starting),
stops (Stopping/Stopped) or fails (Error), and preserved across
pause/resume.
The per-instance supervisor is the only writer of the position, so a probe
pass cannot race another one. Each advance is published on the daemon event
bus as DaemonEvent::Readiness { level } and appended to the instance's
events.jsonl audit trail; GetInstanceStatus runs the probe pass for the
instance it is asked about and reports the position (readiness) together
with the profile's terminal level (terminal_readiness). Guest access
(andler connect --level auto) picks ssh/adb from the reached level and
falls back to the serial console with a note when the terminal level has not
been reached — an unobservable level degrades the choice, it never blocks it.
The probe pass is skipped entirely while another operation holds the guest
agent, because the QGA chardev serves one client.
Disk-only external qcow2 snapshots — no VM-state (RAM) serialization, so they work on
any GPU/audio/CPU configuration (the old snapshot-save VM-state path is blocked by QEMU's
migration machinery on every accelerated default: virtio-sound, virgl, and the invtsc
CPU flag are all non-migratable by design). Every snapshot of a qcow2 disk creates an
overlay layer file; the disk the VM actually uses is always disk.qcow2, and layers
live in <instance-dir>/disk.snapshots/<uuid>.qcow2 (staging files are
disk.snapshots/.tmp-<uuid>.qcow2 and are never scanned as layers).
- Create (live, instance must be Running/Paused): the primary disk must be qcow2
(
SnapshotRequiresQcow2otherwise — convert first). A staging overlay is created (qemu-img create), its backing reference is pointed at the future layer path withqemu-img rebase -u -F qcow2(the file does not exist yet), then QMPblockdev-add(overlay node withbacking:null,file.locking:off) +blockdev-snapshot {node: drive-disk0, overlay: snap-<uuid>}switches the live disk graph. Finally a rename pair moves the old active file intodisk.snapshots/<uuid>.qcow2and the overlay into place as the newdisk.qcow2. Metadata is persisted last; a crash anywhere before that leaves a recoverable state (see Reconciliation below). - Restore (offline, instance must NOT be Running/Paused):
- Default (discard): the target must be on the main branch
(
RestoreTargetOnArchivedBranchotherwise — pass--branchto switch to it). Every layer newer than the target on the main branch is deleted (file + metadata), the active disk is removed, and a freshdisk.qcow2overlay is created on top of the target — the linear history continues from the target. --branch: nothing is deleted. The current active chain is archived as a branch: the active disk becomes a layer taggedpre-branch-<ts>, and every main-branch layer record is marked withbranch-<ts>. A newdisk.qcow2overlay is created on top of the target. If the target lives on an archived branch, that branch's ancestors are moved back to the main branch (its layers become restorable again) and the new active disk continues from the target. Branches are read-only in this phase: layers on archived branches can be listed and deleted, but discard-restore refuses them.
- Default (discard): the target must be on the main branch
(
- Delete (offline, instance must NOT be Running/Paused): deleting a layer commits its
data into its parent (
qemu-img commit), re-points its direct children at the parent (qemu-img rebase -u, including the active disk when it backs onto the layer), then removes the layer file and its metadata entry. The base layer (no parent) cannot be deleted (CannotDeleteBaseLayer). Legacy internal snapshots (created before this phase) are still deleted withqemu-img snapshot -d; they are read-only for restore (SnapshotInternalNotRestorable). - List: metadata rows (all branches) merged with internal snapshots still reported by
QEMU
query-block; each entry carries itsbranch(empty = main branch). - Clone protection: linked clones derive their disk from the source's active file,
so their backing chain reaches the source's layers. Restore is refused while live
clones exist (
RestoreWouldBreakClones— the active disk is rebuilt, orphaning them), and deleting a layer is refused while another instance's chain contains it (DeleteWouldBreakClones, discovered by walking qcow2 backing files).
Snapshot metadata (id, tag, description, created_at, layer path, parent layer id, branch)
is stored in SQLite snapshots (schema v3; legacy v0–v2 tables migrate in place). Maximum
20 snapshots per instance (MAX_SNAPSHOTS_PER_INSTANCE); free space is pre-checked via
statvfs(2) with guest RAM size as a conservative upper bound (InsufficientDiskSpace).
RestoreSnapshot/DeleteSnapshot on a running instance fails with
FAILED_PRECONDITION (InstanceMustBeStopped) — stop the instance first, then restore or
delete, then start again.
On every daemon startup the daemon reconciles each qcow2 instance's on-disk chain against
the store (disk_chain::reconcile_chain). With a live adopted VM only metadata is touched;
otherwise the disk is repaired too:
disk.qcow2missing + an orphaned.tmp-*overlay present → the overlay becomes the active disk (crash between the rename pair; the chain is fully recoverable).disk.qcow2missing + layers present → a fresh active disk is rebuilt on top of the newest layer (crash mid-restore).- Layer file without a metadata entry → entry is inserted as
recovered-<uuid8>. - Metadata entry whose layer file vanished → entry is deleted (tampering/accidental removal is not silently re-created).
- Stray
.tmp-*files are removed. A chain that cannot be inspected is left alone with a warning; orphaned layer files not referenced by any record are also left alone.
Known limitation (serialization): snapshot operations are only serialized by the state
machine — create requires Running/Paused, restore/delete require Stopped — so
create never races restore/delete. Two simultaneous restore (or delete) calls on
a stopped instance are not yet guarded by a per-instance op-mutex; the single-writer
discipline for chains lands with the DiskOps phase (see docs/ROADMAP.md). Until then the
daemon's RPC handler processes restore/delete synchronously, which makes the window
negligible but not formally excluded.
Extra disks and network devices can be attached to a Running/Paused instance and detached again, and are re-created automatically at the next boot.
- Persistence: attached devices live in
InstanceConfig.extra_disks/extra_networks(serde-defaultedVecs, so oldinstance.tomlfiles load unchanged). The daemon appends to the list after the backend confirms the live device, and persists via the sameinstance.tomlrewrite path as any config change. - Identity: devices are addressed by list index; the backend derives QEMU ids from it (
drive-extraN/extraNblockdev/device,net-extraNnetdev/device, bridge tapstap<instance-id>-eNso multiple VMs on one bridge never collide). Detach identifies disks by path and networks by index, so cmdline wiring at boot and QMP hot-plug always agree. - Live attach (QMP):
blockdev-add({"driver":"qcow2","node-name":"drive-extraN",...}) +device_add(virtio-blk-pci), ornetdev_add(user/tap/passt) +device_add(net model). On failure the backend rolls back:blockdev-delafter a faileddevice_add, host tap teardown after a failed netdev setup. - Live detach (QMP):
device_delis asynchronous — QEMU completes it on its own schedule, so the follow-upblockdev-del/netdev-delmay hitDeviceInUse. The daemon retries only while QEMU reports the device in use (DeviceInUseclass orin usedescription, 250ms × 15s); any otherCommandFailedfails immediately. - Boot re-attach: the cmdline builder emits the extra
-drive/-device/-netdevargs in list order; host taps for bridge mode are created before spawn and torn down on stop. - Limitations: snapshots (
snapshot create) cover the primarydrive-disk0only — extra disks are not snapshotted. Attached disks are identified by absolute path; a relative or bare-name path resolves into the instance directory at attach time.
All host-side metrics from /proc — no QMP communication needed for metrics:
| Metric | Source | Calculation |
|---|---|---|
| CPU% | /proc/<pid>/stat (utime+stime), /proc/uptime |
delta(utime+stime) / delta(uptime) / num_cpus * 100 |
| RAM | /proc/<pid>/status (VmRSS) |
Direct read |
| Disk I/O | /proc/<pid>/io |
Delta-based bytes/sec |
| Net I/O | /proc/<net/dev> |
Delta-based bytes/sec |
| VRAM Used | AMD: mem_info_vram_used, NVIDIA: NVML (nvml-wrapper) / nvidia-smi fallback, Intel: not available |
Vendor-specific |
| VRAM Total | AMD: mem_info_vram_total, NVIDIA: NVML / nvidia-smi fallback, Intel: not available |
Vendor-specific |
| GPU Load | AMD: gpu_busy_percent, NVIDIA: NVML / nvidia-smi fallback, Intel: power/rc6_residency_ms idle-time delta |
Vendor-specific |
Polling interval: 1 second. Broadcast via tokio::sync::broadcast.
GPU vendor detection priority: AMD → NVIDIA → Intel (first found wins). AMD uses direct sysfs reads. NVIDIA uses NVML (nvml-wrapper crate, primary) with nvidia-smi CLI fallback. Intel uses i915 sysfs power/rc6_residency_ms (documented idle-time ABI) for GPU load, derived from a real elapsed-time delta; Intel has no VRAM metric (stolen-memory accounting is a debugfs, not sysfs, interface).
Instance logs must not grow without bound (a long-running guest filled 4.4 GB of disk with logs). Policy (decision from the architecture rework):
qemu.logandconsole.login the instance directory rotate logrotate-style by size, enforced by the daemon (not by the guest): when a file exceeds a size cap (default 16 MiB), it is renamed to.<N>generations (default 3 kept, newest-highest), rotation happens at a safe point (daemon-driven, not mid-write). Rotated generations are capped by the same size rule.- The daemon's own log directory (
~/.andler/logs, when used) applies retention by age + total size: generations older than 30 days or beyond a total budget (default 256 MiB) are pruned. - Rotation is a daemon mechanism, not a CLI concern:
andler logsreads through the same file, so a rotated file never breaks the stream contract.
The offline package path (--offline) never mounts anything on the host. The
libguestfs appliance mounts the disk, and a RunShell op runs through the
guest's /bin/sh with the guest root as /, so the guest's package
manager executes as root inside the appliance's own VM.
That root is what makes dpkg work: it verifies its database directory with
access(R_OK|W_OK), and a FUSE-mounted guest filesystem denies that check to
a non-root process on some kernels (observed on cachyos 7.6 with libfuse2:
dpkg aborts with "required read/write access to the dpkg database
directory"). The FUSE kitchen tried -o uid=<euid> -o gid=<egid> and
-o default_permissions to make the kernel answer access(2) favourably; the
appliance removes the question. The test -w /var/lib/dpkg probe that used to
gate the offline install is gone with it.
Two details an appliance session owns itself, both because the guest has no network while its disk is idle:
- User networking:
GuestfsMutator::for_packagespasses--network, and the recipe configureseth0(169.254.2.15/16, gateway169.254.2.2— libguestfs's link-local user-net subnet) rather than relying on the appliance's boot-time DHCP, which needs adhcpcdthe host does not always have installed. - Resolver: the appliance bind-mounts its own read-only
/etc/resolv.confinto the guest, and that file has no nameservers when its DHCP never ran. The recipe writesnameserver 169.254.2.3(slirp's resolver, one address above the gateway) into a tmpfs on/runand bind-mounts that file over the guest's/etc/resolv.conffor the session. The guest image is not written to; the tmpfs also givesgpg-agent, which pacman needs for signatures, a writable/run.
subuid/newuidmap mapping and unprivileged user namespaces are not needed
any more: no path in the daemon uses them. The smart online path is unaffected.
The daemon log (and therefore the andler logs daemon ring, which carries
the same bytes) never contains guest-controlled or guest-sourced content at
the default INFO level:
- guest-exec stdout/stderr is never logged (package install/remove,
andler exec, mutator commands) — it can carry passwords or tokens the guest printed; failures surface through returned errors instead. - QEMU/guest output lines are debug-only — the guest can print anything
into the console, so qemu.log +
andler logs <id>are the guest-log stream, never the daemon log. - Provision manifests log op counts only, never file contents or host paths of uploads; guest file reads for diagnostics (resolv.conf) are debug-only.
- Env values are never logged; instance ids, paths, and error text (the actionable, sanitized message, not raw stderr) are the allowed fields.
Every CLI request carries a request_id metadata header (32-hex, generated
per request); the daemon's RPC layer wraps each handler in an
rpc{request_id=…, method=…} span, so any event emitted while handling a
request shows up in the daemon log with its id. Long operations keep their
own op_id on the event bus; together they reconstruct
CLI → RPC → operation from one log line. Retry loops (QMP events monitor,
health checks) log coalesced — first failure, every 50th attempt, and a
recovery summary.
Honest model, written down because it was absent. Scope: single-user Linux desktop running QEMU/KVM.
- Boundary: the VM is the isolation boundary — guests are untrusted. The daemon runs as the user, not root, and performs no privileged operations at all: offline guest work runs the guest's own tools as root inside the libguestfs appliance, a QEMU VM of its own, and the former privileged helper and its sudoers rules were removed. There is no root-capable surface left for a compromised daemon to abuse — it is not a sandbox; the boundary is against bugs and other users.
- IPC boundary:
andlerdlistens on TCP loopback (ANDLERD_LISTEN_ADDR, default127.0.0.1:50051). Loopback bounds the network, not the user; the rework explicitly chose the documented assumption one host user per machine (multi-user trust is out of scope; a unix-socket transport with0600is the documented upgrade path if that assumption ever changes). - Untrusted artifacts: snapshots and base-image manifests may be corrupted or malicious (a broken snapshot must not wedge the daemon): manifests are validated (shape, required fields, size limits) before use; snapshot restore validates the image before switching the disk; resource limits (time/size) apply to guest-driven operations.
- Per-instance files: instance directory, QMP/QGA sockets, and SSH key
material use the same private-permissions discipline as the rest of
~/.andler(0700/0600); QMP/QGA sockets live under$XDG_RUNTIME_DIR/andler/which is user-private by construction.
- Bridge network mode: Implemented in
andler-netusingiproute2for bridge creation and network configuration. Isolated mode is config-representable but not yet implemented (setup_isolatedreturns an explicit error) - GPU passthrough: VFIO-based
RenderBackend::Passthrough - GUI: Tauri-based client (planned, not started)
- Guest image pipelines: Automated Android image builds with Waydroid
The daemon (andlerd) exposes a gRPC API for instance management. Protocol schema: services/andler-rpc/proto/andler.proto.
The RPC layer mirrors Daemon trait methods — each RPC method corresponds to a real Daemon implementation. Adding an RPC method without a matching Daemon method would design the protocol "in the blind", which was explicitly avoided.
| RPC | Domain Method | Description |
|---|---|---|
| CreateInstance | Daemon::create_instance | Creates LinuxVm from explicit InstanceConfig |
| CreateAndroidInstance | Daemon::create_android_instance | Resolves profile, creates overlay, registers instance |
| CloneInstance | Daemon::clone_instance | Clone AndroidVm only (requires managed instances_root) |
| ExportInstanceDisk | Daemon::export_instance_disk | Export disk without creating instance |
| StreamResourceMetrics | metrics stream | CPU, RAM, disk I/O, network I/O, VRAM, GPU load |
| StreamInstanceLogs | log stream | Streams stdout/stderr of hypervisor process |
Proto messages mirror andler_core domain types:
- Enums: CpuPriority, DiskFormat, DisplayEngine, CdromBus, InstanceState
- oneof: RenderBackend (carries gpu_pci_id), NetworkMode (carries interface)
- Configuration: Proto types correspond 1:1 to
andler-core/src/config/types
Conversion logic: services/andler-rpc/src/convert.rs.
- CloneInstance/ExportInstanceDisk support both AndroidVm and LinuxVm
- CreateInstance is limited to LinuxVm (field iso_path, no oneof kind) — AndroidVm path is served by separate CreateAndroidInstance to avoid two ways to create Android instances
- CreateInstance intentionally doesn't accept id/backend — daemon generates instance_id, backend is always QEMU
Configuration is organized into 9 sections in InstanceConfig (plus metadata fields id, name, kind, backend), two hotplug lists (extra_disks, extra_networks), and the primary config sections:
- disk: Path, format (qcow2/raw/vdi), size, base image, thin provisioning, trim/compact on shutdown, snapshot timeout. The instance disk is always named
disk.qcow2inside the instance directory. The CD-ROM is not part ofDiskConfig— it lives inInstanceKind::LinuxVmascdrom_bus(VirtioScsi/Ide, pre-resolved fromautoviarecommended_for_iso_filename()) - cpu: vCPU count (
cores/sockets/threads), affinity (CPU pinning), priority class (Low/Normal/High) - memory:
size_bytes(not MiB), ballooning, zram, ksm, mem_lock, hugepages - display:
resolution(width/height), dpi, fps_limit, display engine (Sdl/Gtk/Spice/Dbus/None), fullscreen. Clipboard lives ininput, not here. The resolution is applied inside the guest: passed as QEMU fw_cfg (opt/andler/display-resolution) and read by guest units (andler-display-resolution.service→/etc/andler/display.conf); a running VM can be switched live viaset_guest_display_resolutionover the guest agent - gpu: Render backend (Venus, VirtioGpu, VirGl, Cpu, Passthrough), hostmem bytes, blob, gl
- network: Mode (Nat/Bridge/Isolated), NAT backend (Slirp/Passt), interface. Isolated is accepted by config but not implemented in
andler-net - audio: Backend (
Pipewire/Pulseaudio/None), device (VirtioSound/Ich9Hda) - input: Pointer mode (Tablet/Mouse), hide_host_cursor, clipboard_enabled
- firmware: enable_uefi, OVMF code/vars paths. The ARM translator is not part of firmware — it lives in
AndroidProfile
Each section has reasonable defaults from hardware auto-detection (see services/andler-firmware). InstanceConfig::validate() rejects zero cores/memory/disk sizes and zero resolutions.
Disk defaults: 256 GiB thin-provisioned qcow2.
The Daemon struct orchestrates all operations. Key methods:
| Method | Description |
|---|---|
resolve_instance_id |
Resolves user-supplied instance reference to concrete InstanceId |
create_instance |
Registers new instance with Created state |
create_android_instance |
Resolves AndroidProfile to full instance and registers it |
create_linux_instance |
Creates Linux instance with disk and OVMF VARS |
start_instance |
Starts instance (Created/Stopped/Error → Starting → Running) |
stop_instance |
Stops instance (graceful/force) |
pause_instance |
Pauses running instance |
resume_instance |
Resumes paused instance |
remove_instance |
Removes instance record (with optional disk purge) |
install_guest_agent |
Installs package in guest OS |
remove_guest_agent |
Removes package from guest OS |
list_guest_packages |
Lists installed packages in guest OS |
switch_arm_translator |
Switches ARM translator in offline mode |
set_instance_config |
Partial config update |
attach_disk / detach_disk |
Hot-plug/unplug an extra disk (Running/Paused only) |
attach_network / detach_network |
Hot-plug/unplug an extra network device (Running/Paused only) |
Each method corresponds to a real Daemon implementation and follows the FSM transitions.