Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 34 additions & 3 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,9 @@ jobs:
# (~5-20x slower; a ~100s+ boot is expected, not a hang — see
# docs/design/vm-rnd-log.md). On a KVM-capable runner class accel: auto
# would pick KVM for free. The kernel+rootfs build is the long pole; the
# savevm boot-cache (#49) is the planned lever to skip it on repeat runs.
# savevm boot-cache (#49, keyed by scripts/vm_cache_key.py) restores the
# booted overlay and resumes (-loadvm) to skip the cold TCG boot on repeat
# runs.
if: >-
github.event_name != 'pull_request' ||
contains(github.event.pull_request.labels.*.name, 'e2e')
Expand Down Expand Up @@ -399,14 +401,43 @@ jobs:
)
PY

- name: "Create + boot a binder:vm instance under QEMU/TCG"
- name: "Create a binder:vm instance (boot_cache on)"
run: |
set -euxo pipefail
uv run beetroot create vmphone
printf 'binder: vm\n' >> vmphone/beetroot.yaml
# boot_cache: true checkpoints the booted guest (savevm) into
# vm-overlay.qcow2 so a downstream job can restore it and resume
# (-loadvm, ~10s) instead of cold-booting under TCG (issue #49).
printf 'binder: vm\nvm:\n boot_cache: true\n' >> vmphone/beetroot.yaml
# `apply` switches the registered backend to the micro-VM engine;
# `up` refuses a binder:vm instance still registered as redroid.
uv run beetroot apply vmphone

# The cache key is the kernel+rootfs hash (scripts/vm_cache_key.py): a
# checkpoint booted against different artifacts must not be restored, so
# the key changes the instant either input changes (issue #49).
- name: Derive the savevm boot-cache key
id: vmkey
run: |
set -euo pipefail
key="$(uv run python scripts/vm_cache_key.py "$BEETROOT_VM_KERNEL" "$BEETROOT_VM_ROOTFS")"
echo "key=$key" >> "$GITHUB_OUTPUT"

# Restore a previously checkpointed overlay (resume in seconds) or, on a
# miss, let the cold boot below checkpoint it for next time. The overlay
# carries the booted machine state (a few GiB) — actions/cache compresses
# it and evicts under the ~10GB budget per the issue caveat.
- name: Restore/persist the booted savevm overlay
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
with:
path: vmphone/vm-overlay.qcow2
key: ${{ runner.os }}-vm-savevm-${{ steps.vmkey.outputs.key }}

- name: "Boot the binder:vm instance under QEMU/TCG"
run: |
set -euxo pipefail
# Resumes from the restored overlay on a cache hit (~10s), else
# cold-boots and checkpoints for the next run.
uv run beetroot up vmphone

- name: "Wait for Android boot (TCG: slow, not hung)"
Expand Down
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,13 @@
pre-abort rows in its `results` attribute).

### Quality & internals
- **Fixed self-contradicting `vm.py` comments that claimed `adb connect` succeeds
at QEMU `hostfwd`-bind (#241).** It only attaches once the post-boot in-guest
relay is listening; the comments and the `_wait_for_boot_completed` docstring
now match `guest-init.sh`.
- **The e2e `tier-vm-qemu` job caches the booted `binder: vm` savevm overlay and
resumes it on repeat runs (#49).** Keyed by the kernel+rootfs hash, it skips the
~100s cold TCG boot for functional/post-boot jobs.
- **The supported Android-version list is now drift-checked and the "add a new
version" path is documented + tested (#98).** `config._VALID_ANDROID_VERSIONS`
has always been the single source of truth, but the human-readable "11, 12,
Expand Down Expand Up @@ -603,6 +610,46 @@
are absent), so shell regressions are caught locally before the push.

### Bug fixes
- **`binder: vm` `down`/`restart`/`destroy` verify the recorded PID still names
this instance's QEMU before signalling (#162).** A stale or reused pidfile can
no longer SIGTERM/SIGKILL an unrelated process; `is_running()` and the
kill-escalation both route through a `/proc/<pid>/cmdline` identity check tied
to this instance's argv.
- **`binder: vm` `doctor` no longer reports a healthy VM as broken (#163).** The
upstream-redroid VM guest ships no Magisk, so the `magisk.zygisk` /
`magisk.denylist` health rows (which always failed there) are dropped
alongside the already-skipped `frida.handshake` row.
- **A `binder: vm` `up` that times out waiting for ADB now terminates the QEMU it
just launched (#174).** Previously it orphaned the emulator, trapping the next
`up`; the post-launch waits now tear the guest down on any failure before
re-raising.
- **`binder: vm` boot_cache recreates the qcow2 overlay before a cold boot when
the existing overlay carries no snapshot (#175).** An interrupted first boot no
longer leaves a dirty COW layer that later cold boots accumulate over; the
cache-key sidecar is also written atomically.
- **`binder: vm` boot_cache re-checks QEMU liveness during the ADB wait and falls
back to a single cold boot when a warm `-loadvm` resume dies (#176).** An
unrestorable snapshot no longer burns the full `vm_adb_connect_timeout` with a
misleading TCG-slowness error.
- **The `binder: vm` ADB-connect wait is accelerator-aware (#160).** Under TCG it
uses a boot-completed-scale deadline instead of the flat 60s, so a cold TCG
boot (~222s on Android 14) no longer fails `beetroot up` before the guest
exposes ADB; KVM keeps the short configurable default.
- **`binder: vm` boot_cache folds the resolved `-smp` / `-m` geometry into the
overlay staleness fingerprint (#161).** Editing `vm.smp` / `vm.memory_mib` (or
an `smp: "auto"` host-core change) now invalidates the checkpoint and cold-boots
once instead of `-loadvm`-resuming into a geometry QEMU rejects.
- **`compute_cache_key` / `boot_cache.base_identity` break basename ties on
content hash (#235).** Two inputs sharing a basename now hash to the same key
regardless of argument order, honoring the documented order-independence; each
input is hashed exactly once.
- **The `binder: vm` ADB port is fixed to a single `5555` contract (#237).** The
in-guest relay can no longer diverge from the QEMU `hostfwd` target via a stray
`ADB_TCP_PORT` override.
- **Hardened the `binder: vm` adb relay (#238).** It now targets IPv6 loopback
(dropping the `bindv6only` dependency), verifies `eth0` received `10.0.2.15`,
surfaces sysctl/bring-up failures as distinct warnings, and loops `adbprobe`'s
read so a short read never prints uninitialized bytes.
- **The default-rendered `memswap_limit` no longer silently disables container
swap (#169).** The bundled compose template defaulted `memswap_limit` to
`${MEMSWAP_LIMIT:-${MEM_LIMIT:-3g}}`, so an all-defaults instance resolved to
Expand Down
5 changes: 4 additions & 1 deletion examples/vm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
#
# accel: auto prefers KVM when /dev/kvm is available (near-native) and
# falls back to TCG (software emulation, ~5-20x slower) otherwise. A slow
# first boot under TCG is expected, not a hang.
# first boot under TCG is expected, not a hang — `up`'s ADB-connect wait is
# accel-aware, so under TCG it waits out the minutes-long cold boot instead of
# timing out (raise BEETROOT_VM_ADB_CONNECT_TIMEOUT only on an unusually slow
# host; issue #160).
#
# Faster TCG iteration: the default Android version is 14, which cold-boots in
# ~190-200 s under TCG; redroid 11 boots in ~100 s (~40% faster) on the same
Expand Down
13 changes: 9 additions & 4 deletions scripts/vm_cache_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,9 @@ def compute_cache_key(paths: list[Path], *, prefix: str = DEFAULT_PREFIX) -> str
Compute a stable cache key over a set of input files.

The key folds each file's *basename* and content hash into one digest,
sorted by basename so the result is independent of the order the paths are
passed. Including the basename means renaming an input (e.g. swapping which
sorted by ``(basename, content-hash)`` so the result is independent of the
order the paths are passed — even when two inputs share a basename (issue
#235). Including the basename means renaming an input (e.g. swapping which
rootfs is staged) changes the key even if two files share content.

Args:
Expand All @@ -83,11 +84,15 @@ def compute_cache_key(paths: list[Path], *, prefix: str = DEFAULT_PREFIX) -> str
"""
if not paths:
raise ValueError("compute_cache_key needs at least one input path")
# Hash each input exactly once (a rootfs is multi-GB; hashing it inside the
# sort key AND again in the fold would double its cost). Precompute the
# {path: digest} map, then sort + fold off it.
digests = {path: hash_file(path) for path in paths}
combined = hashlib.sha256()
for path in sorted(paths, key=lambda p: p.name):
for path in sorted(digests, key=lambda p: (p.name, digests[p])):
combined.update(path.name.encode())
combined.update(b"\0")
combined.update(hash_file(path).encode())
combined.update(digests[path].encode())
combined.update(b"\0")
return f"{prefix}-{combined.hexdigest()[:_KEY_HEX_LEN]}"

Expand Down
Loading