Skip to content

fix: make qemu guest sessions usable for all bundled tools - #76

Open
xai wants to merge 3 commits into
mainfrom
fix/qemu-guest-environment
Open

xai wants to merge 3 commits into
mainfrom
fix/qemu-guest-environment

Conversation

@xai

@xai xai commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

What it does

Fixes #75.

Three defects kept --backend qemu sessions from working with the bundled tools.

  • Bundles too large to boot. The guest has no disk: the initramfs is unpacked
    into a ramfs that becomes the rootfs, and during unpacking the kernel holds both
    the image and its contents. opencode's 1.4 GB image did not fit the fixed
    4096 MiB guest and panicked without an init. The builder now strips build-time
    caches (npm, pip, uv) and /boot, compresses the image with zstd, and records
    packed/unpacked sizes so the host derives guest memory from the actual bundle
    (never below qemuMinMemoryMiB). An undersized bundle is rejected on the host
    instead of dying inside the kernel. The compressed image is padded to a 4-byte
    boundary so the kernel still recognizes the per-run overlay archive appended to it.
  • Console reported 0x0. The emulated serial console carries no window size, so
    codex and opencode rendered one column wide and claude and pi fell back to a fixed
    80x24 box. The generated run script now seeds the console from the host terminal.
  • opencode's SQLite store failed on 9p. opencode.db runs in WAL mode inside the
    config store, and WAL shared-memory files need mmap semantics there; every query
    failed and the TUI painted an empty frame. opencode now declares
    qemuStoreCacheMmap, as codex already did. The bundle also gets a populated
    /etc/hosts — Docker bind-mounts it into the provisioning container, so the export
    left an empty placeholder and the guest answered ::1 for localhost first.

How to test

Requires Docker (bundle build) and qemu-system-x86_64. Resize your terminal to
something clearly wider than 80 columns first.

  1. enclave --backend qemu --tool opencode — the bundle builds, the guest boots, and
    the TUI comes up filling the terminal with no Failed query: PRAGMA wal_checkpoint
    errors. Send a prompt, exit, restart: the session history is still there.
  2. Same for --tool codex, --tool claude, --tool pi — each TUI uses the full
    terminal width, not one column and not 80x24.
  3. In a guest shell: stty size matches your host terminal, and
    getent hosts localhost answers 127.0.0.1.

Follow-ups

The console size is applied once at startup. Resizing the host terminal mid-session
does not reach the guest, because the console is not a controlling terminal there and
no SIGWINCH is delivered.

Breaking changes

  • This PR introduces breaking changes and has been coordinated with maintainers.

Review checklist

xai added 3 commits September 10, 2026 23:11
The opencode bundle's 1.4 GB uncompressed initramfs could not be unpacked in
the fixed 4096 MiB guest: the kernel holds the image and the ramfs it unpacks
into at the same time, so unpacking failed and the guest panicked without an
init.

Strip the npm/pip/uv caches and /boot from the rootfs, compress the image with
zstd, and derive the guest memory from the built sizes so a large bundle gets
the memory it needs. An undersized bundle is now rejected on the host instead
of dying inside the kernel.

The compressed image is padded to a 4-byte boundary and the per-run
concatenation keeps every segment aligned, because unpack_to_rootfs() only
recognizes the appended overlay archive there.
The emulated serial console carries no window size, so the guest console
reported 0x0. Terminal UIs that trust it rendered one column wide (codex,
opencode) and those with a fallback drew into a fixed 80x24 box regardless of
the real terminal (claude, pi).

Seed the console from the host terminal in the generated run script. The size
is applied once at startup: the console is not a controlling terminal in the
guest, so a later resize delivers no SIGWINCH.
opencode keeps its state in opencode.db, a WAL-mode SQLite database inside its
config store, which the qemu backend mounts over 9p. WAL shared-memory files
need mmap semantics there, so every query failed ("Failed query: PRAGMA
wal_checkpoint(PASSIVE)"), the storage layer died before the app logged
anything, and the TUI painted an empty frame. codex already declares
qemuStoreCacheMmap for the same reason.

Also populate /etc/hosts in the bundle. Docker bind-mounts it into the
provisioning container, so the export left an empty placeholder and the guest
resolved "localhost" through musl built-ins, answering ::1 first.
@xai xai added the bug Something isn't working label Sep 10, 2026
@xai
xai marked this pull request as ready for review September 12, 2026 12:18
@xai

xai commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator Author

@EclipseSourceAI

@EclipseSourceAI EclipseSourceAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Note

Autonomous AI review.

This review was done by an AI agent and therefore may contain mistakes. Feel free to ignore any comment you disagree with. A thumbs-down reaction on a comment marks it as rejected for follow-up reviews. Noting why in a reply helps, since replies are read too.

Resolving all AI comments does not lead to an automatic approval. A maintainer still needs to review and sign off on the overall architecture and design.

To get an updated review after pushing changes, a maintainer may re-request a review from this account.

Running in Eclipse Enclave, submitted via review-guard-mcp

Three independent qemu-guest fixes in one PR: bundle sizing (zstd-compress the initramfs, strip build caches and /boot, record packed/unpacked sizes and derive guest memory from them), console sizing (seed stty rows/cols from the host terminal in the generated run script), and opencode's SQLite store (qemuStoreCacheMmap plus a populated /etc/hosts in the bundle).

The approach looks sound. qemuMinMemoryMiB correctly becomes a floor that feeds the bundle hash while the effective size is a build output, and dropping the config-content comparison from qemuBundleCurrent follows from that. The 4-byte alignment reasoning for the appended overlay matches unpack_to_rootfs(), and concatenateFiles now enforces it generically, which also covers the legacy uncompressed bundles. make test and shellcheck pass locally.

Points worth a maintainer's attention:

  • Derived guest memory is unbounded and never compared against host RAM. A large bundle now hands QEMU more than the host has and the session dies to the OOM killer mid-run rather than failing up front.
  • The undersized-bundle error advises --rebuild, which is exactly the flag that is ignored for the --image-name bundles most likely to hit it.
  • The builder counts the unpacked size through an unchecked pipeline, so a zstd failure there silently under-reports the size and reintroduces the boot failure this PR fixes.
  • The alignment invariant now lives in both the builder and concatenateFiles.

Scope is tight, no unrelated refactors, and the new comments explain non-obvious kernel and 9p behavior rather than restating the code.

if memoryMiB >= required {
return nil
}
return fmt.Errorf("qemu backend: bundle %s needs at least %d MiB to unpack its %d MiB initramfs but %s declares %d MiB; rebuild the bundle with --rebuild",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The --rebuild advice is a dead end for the case that actually trips this. A prebuilt bundle passed via --image-name typically has no enclave-vm-bundle.json, so resolveBundleMemoryMiB falls back to DefaultMemoryMiB and fails here, while --rebuild is explicitly ignored for --image-name (link). Falling back to RequiredMemoryMiB when the config is absent but the metadata is present would make that bundle just work.

return err
}
if memoryMiB > minMemoryMiB {
logx.Infof("Sizing qemu guest memory to %d MiB for the built initramfs.", memoryMiB)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The derived size is unbounded and never checked against host RAM. With a big enough bundle QEMU gets -m larger than the host has, starts fine, and the session dies to the OOM killer mid-run instead of failing up front. The old fixed 4096 MiB could not do that, so a maintainer should decide whether a cap or an upfront warning belongs here.

appendFile(attach.Out)
appendFile(attach.Err)
appendFile(attach.In)
candidates = append(candidates, os.Stdout, os.Stderr, os.Stdin)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This re-implements the nil-stdio fallback that readerOrDefault/writerOrDefault/stderrOrDefault already do (link). Running attach through those instead keeps the probed descriptors identical to the ones QEMU is actually handed.

initramfs=/out/initramfs.cpio.zst
( cd "$root" && find . -print | cpio -o -H newc --quiet ) | zstd -q -f -T0 -9 -o "$initramfs"
zstd -q -t "$initramfs"
uncompressed=$(zstd -q -d -c "$initramfs" | wc -c)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

sh has no pipefail under set -eu, so a zstd failure here is swallowed and wc reports a short count that lands in the metadata and under-sizes the guest, which is exactly the boot failure this PR fixes. The -t pass above catches a corrupt file but not a failure in this pass; set -o pipefail (busybox ash supports it) or an explicit status check would.

# run overlay - when it starts on a 4-byte boundary, so pad the compressed
# image out to one. The kernel skips the padding bytes before the segment.
compressed=$(wc -c < "$initramfs")
pad=$(( (4 - compressed % 4) % 4 ))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

concatenateFiles now aligns every segment before appending, so this padding is redundant for any bundle the host boots. Two copies of the same non-obvious kernel invariant in two languages will drift; worth picking one as authoritative.

and its unpacked contents at the same time. Guest memory therefore scales with
the bundle, and the host derives it from `enclave-vm-initramfs.json`
(`backendqemu.RequiredMemoryMiB`), never below the profile's
`qemuMinMemoryMiB`. Two consequences for the builder:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Guest memory scaling with the bundle is user-visible (opencode now gets 5 GiB instead of 4), but the QEMU section in docs/cli-reference.md still says nothing about memory. A sentence there would help, this README is a builder asset doc.

Comment thread docs/extensions/README.md
`sandbox.qemuMinMemoryMiB`, what the built initramfs needs). Use it for tools
that need more memory than the default to start reliably; it never has to
account for the bundle's own size, because the guest rootfs is the unpacked
initramfs and the builder already sizes memory to it. Set `sandbox.qemuStoreCacheMmap` when the tool's

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Line is 102 chars while the rest of the paragraph wraps at ~76. Rewrap.

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

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

qemu backend: sessions for tools other than claude and pi are unusable

2 participants