Skip to content

Detach QEMU from agv instead of passing -daemonize - #5

Merged
einarfd merged 3 commits into
mainfrom
qemu-self-daemonize
Aug 26, 2026
Merged

einarfd merged 3 commits into
mainfrom
qemu-self-daemonize

Conversation

@einarfd

@einarfd einarfd commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Fixes agv suspend killing QEMU on Apple Silicon, and makes a QEMU that dies say why.

The bug

QEMU's -daemonize forks without exec'ing. On macOS the Objective-C runtime refuses to initialize a class for the first time in a forked child and aborts the process — and savevm reaches exactly that, because HVF's GIC state save calls into Hypervisor.framework, which is Objective-C underneath. So agv suspend on a QEMU-backend VM died with SIGABRT, and agv reported only QMP socket closed unexpectedly.

Confirmed from the crash report (termination: OBJC, SIGABRT):

hmp_savevm → save_snapshot → qemu_savevm_state → vmstate_save
  → hvf_gic_opaque_state_save → hv_gic_state_create
    → GicState::encode → -[__NSDictionaryM __setObject:forKey:]
      → performForkChildInitialize → _objc_fatal → abort

Isolated to the flag: same VM, same 37-argument command line, -daemonize present → QEMU dies; absent → savevm returns and QEMU survives.

Upstream has this confirmed and unfixed since August 2024 — qemu-project/qemu#2515 — where it also hits emulated targets on macOS, so it is not suspend-specific and waiting is not a plan. Linux is unaffected: HVF is macOS-only, and platform_args() gates -accel hvf behind cfg(target_os = "macos").

The change

spawn_detached takes over what the flag did, in the shape agv already uses for the AVF runner and forward supervisors: own process group, stdio to a log file, PID written from Rust.

  • -pidfile goes too — agv writes pid itself, so it exists before start returns.
  • The "QEMU is up" signal that -daemonize's parent exit gave us is rebuilt as wait_for_qmp_ready.
  • QEMU's stdout/stderr land in <instance>/qemu.log instead of a pipe read only on startup failure. Apple's abort message names the problem outright; agv used to discard it.

Two bugs found while building it

Zombies. Copying the AVF pattern exactly — including mem::forget(child) — broke qemu_start_and_force_stop. Under -daemonize the spawned process exited at once and real QEMU was orphaned to init, which reaped it, so a PID check was always honest. Spawned directly, QEMU is agv's own child, and an unwaited child becomes a zombie whose PID keeps answering kill(pid, 0). A tokio task now holds the handle and waits.

Readiness by file existence was too weak (caught in review). QEMU creates qmp.sock before it opens -drive files or applies -loadvm, and does not unlink it on error exit — measured: socket at 6.1ms, exit status 1 at 6.1ms, socket still on disk. Readiness is now a completed QmpClient::connect (greeting + qmp_capabilities). That in turn surfaced a latent bug: QEMU's sun_path check is > where every client's is >=, so at exactly sizeof(sun_path) it binds a socket nothing can connect to. Now rejected up front.

Verification (macOS aarch64)

  • suspend_and_resume_preserves_state and auto_suspend_idle_vm_suspends pass, having failed before this
  • create_test --include-ignored 14/14 (was 12/2)
  • every other test file green with --include-ignored
  • just verify and cargo +1.88 check --all-targets --locked clean
  • a corrupt disk image now fails agv start in 0.11s quoting QEMU's own message

🤖 Generated with Claude Code

einarfd and others added 3 commits August 26, 2026 22:36
QEMU's own daemonize forks without exec'ing. On macOS the Objective-C
runtime refuses to initialize a class for the first time in a forked
child and aborts the process — and that is reachable from `savevm`,
because HVF's GIC state save calls into Hypervisor.framework, which is
Objective-C underneath. So `agv suspend` on Apple Silicon killed QEMU
with SIGABRT, and agv saw only "QMP socket closed unexpectedly".

Upstream has this confirmed and unfixed since August 2024
(https://gitlab.com/qemu-project/qemu/-/work_items/2515), where it also
hits emulated targets on macOS, so waiting for a fix is not a plan.

`spawn_detached` takes over what the flag did, in the shape agv already
uses for the AVF runner and the forward supervisors: own process group,
stdio to a log file, PID written from Rust. Three consequences:

- `-pidfile` goes too. agv writes `pid` itself, so it exists before
  `start` returns rather than whenever QEMU gets to it.
- The "QEMU is up" signal that `-daemonize`'s parent exit provided has
  to be rebuilt: `wait_for_qmp_socket` polls for the socket and fails
  fast if the process dies first, mirroring `wait_for_avf_socket`.
- QEMU's stdout/stderr go to `<instance>/qemu.log` instead of a pipe
  that was only read if startup failed. Apple's abort message names the
  problem outright; agv used to throw it away. `agv suspend` now reports
  a QEMU that died with the tail of that log attached.

The child is reaped rather than forgotten, which the other detached
spawns get away with but this one cannot. Under `-daemonize` the
process agv spawned exited at once and the real QEMU was orphaned to
init, so a PID check was always honest. Spawned directly, QEMU is agv's
own child, and an unwaited child becomes a zombie whose PID keeps
answering `kill(pid, 0)` — `is_process_alive` would call a dead VM
running for as long as the agv process lived. `qemu_start_and_force_stop`
and `qemu_start_and_graceful_stop` caught exactly that. A tokio task
holds the handle and waits; if agv exits first, init reaps as before.

Verified on macOS aarch64: `suspend_and_resume_preserves_state` and
`auto_suspend_idle_vm_suspends` both pass, having failed before this;
the full slow suite is 14/14 where it was 12/2, and every other test
file passes with --include-ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six findings, all real; the first was a regression this branch
introduced.

Startup success was inferred from `qmp.sock` existing. QEMU creates
that socket very early — before it opens -drive files, binds the
hostfwd port, realizes devices, or applies -loadvm — and does not
unlink it when it then exits with an error. Measured here with a
missing disk image: socket at 6.1ms, exit status 1 at 6.1ms, socket
still on disk. So `agv start` returned Ok for a QEMU that was already
gone, the VM was marked running, and the user met an unrelated SSH
timeout minutes later while qemu.log held the real cause. Worst on
`agv resume`, where a failed -loadvm would take a VM from suspended to
broken with the snapshot still intact. Readiness is now a completed
`QmpClient::connect` — greeting plus qmp_capabilities — which proves
QEMU is serving and past the point those failures occur, and liveness
is checked before the socket rather than after.

The rest:

- A stale qmp.sock is swept before spawn. Nothing else removes one:
  `reconcile_status` cleans up only when the recorded status is
  `running`, so a `broken` VM whose QEMU later dies keeps the file, and
  the next start would have been satisfied by the previous boot's.
- The post-EOF liveness check now allows 2s for the exit to land. An
  aborting QEMU has its fds closed by the kernel — which is the EOF we
  just saw — while kill(pid, 0) keeps succeeding until it is reaped, so
  an immediate check concluded "still alive" exactly when the qemu.log
  excerpt was worth printing.
- A failed PID-file write kills the child instead of leaking a QEMU
  that holds the qcow2 lock and hostfwd port with nothing able to
  address it.
- force_kill is limited to the timeout path. On the exited path the
  reaper has already collected the process, so the PID may belong to
  something else by then.
- The log excerpt is one flat message rather than an anyhow context
  layer. `main.rs` renders with `{err:#}`, which joins the chain with
  ": ", so a multi-line excerpt came out ahead of the reason it
  belonged to.

Fixing the readiness check surfaced a latent bug it had been hiding.
QEMU's sun_path length check is `>` where every client's is `>=`, so at
exactly sizeof(sun_path) it binds a socket nothing can connect to —
confirmed on macOS at 104 bytes, with QEMU creating it and both Rust
and Python refusing it as "path too long". Such a VM started and was
then uncontrollable, failing later and confusingly in stop or suspend.
`build_qemu_args` now rejects it up front via `SocketAddr::from_pathname`,
which applies exactly the check the client will.

That in turn broke `auto_suspend_active_session_keeps_vm_running`,
correctly: macOS TMPDIR is 48 characters, leaving 24 for a VM name, and
that one is 25. `test_data_dir` anchors at /tmp, raising the budget to
68.

Verified on macOS aarch64: create_test 14/14 with --include-ignored,
every other test file green the same way, `just verify` and
`cargo +1.88 check --all-targets --locked` clean. A corrupt disk image
now fails `agv start` in 0.11s quoting QEMU's own message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@einarfd
einarfd merged commit 849c72f into main Aug 26, 2026
4 checks passed
@einarfd
einarfd deleted the qemu-self-daemonize branch August 26, 2026 21:56
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