cloudhypervisor: route serial through a hypeman-owned unix socket#210
cloudhypervisor: route serial through a hypeman-owned unix socket#210sjmiller609 merged 6 commits intomainfrom
Conversation
Cloud Hypervisor's serial.mode=File opens the log file with plain O_WRONLY|O_CREAT and never reopens on signal. When the file is externally truncated (e.g. by rotateLogIfNeeded's copytruncate), CH's next write lands at its stale fd offset, leaving a sparse hole of NUL bytes from byte 0 to that offset. Downstream log readers chunk those NULs and choke (they JSON-encode at ~6x expansion, so a 64KiB chunk becomes a ~384KiB record and small batches blow past the receiver's 1MiB body limit). CH's ConsoleConfig has no append flag, so we can't ask CH to use O_APPEND directly. Switch to mode=Socket: hypeman binds a unix socket in the instance directory, CH connects to it as a client, and a small goroutine in the cloudhypervisor package copies bytes from the socket into the log file opened with O_APPEND. Because hypeman now owns the writer fd, copytruncate is safe — O_APPEND atomically seeks to EOF on every write, so post-truncate writes correctly resume at byte 0. Snapshot fork rewrites are updated to migrate legacy File-mode serial config to Socket on fork. Adds: - TestSerialReader_CopiesBytesToLog — basic byte path - TestSerialReader_NoSparseHoleAfterCopytruncate — regression - integration TestSystemdMode/SerialLogSurvivesCopytruncate — boots a real CH VM, copies+truncates app.log mid-run, asserts the post-rotation file is non-sparse and starts with non-NUL bytes
Cloud Hypervisor's mode=Socket calls UnixListener::bind() inside vm.create. The previous implementation made hypeman bind the socket first, which caused EADDRINUSE on CH side. Flip the design: hypeman opens the log with O_APPEND up front, then dials the socket with retry once CH binds it during boot. Also use a short /tmp temp dir in unit tests so socket paths stay under the platform sun_path limit (104 bytes on macOS).
Long test temp paths plus /logs/serial.sock pushed the unix socket
path over Linux's 108-byte sun_path limit, so CH's UnixListener::bind
returned EINVAL ("path must be shorter than SUN_LEN"). Place the
socket next to ch.sock at the instance dir instead of under logs/.
Pre-fix snapshots embed serial.mode=File, so a plain restore on an upgraded hypeman would still write directly to app.log without O_APPEND and reproduce the copytruncate sparse-hole bug. Mirror the fork-time rewrite in RestoreVM so legacy snapshots are migrated to mode=Socket pointing at the per-instance reader. Idempotent for post-fix snapshots already on Socket.
|
Firetiger deploy monitoring skipped This PR didn't match the auto-monitor filter configured on your GitHub connection:
Reason: PR modifies the cloudhypervisor package, not the kernel API endpoints (packages/api/cmd/api/) or Temporal workflows (packages/api/lib/temporal) specified in the filter. To monitor this PR anyway, reply with |
rgarcia
left a comment
There was a problem hiding this comment.
-
PR body contradicts the code in two places. body says "
serialReaderlistens on the socket, accepts CH's connection. Listener is closed after first accept." — code does the opposite (net.Dial, hypeman is client). body also says "Snapshots taken with the old File-mode config are restored as-is by CH" — butrewriteSerialConfigForRestoreactively migrates File→Socket. earlier paragraphs in the body have it right; the "Changes" + "Backward compatibility" sections are stale. update before merge so future readers aren't misled. -
small race in
Close()betweendialUnixWithRetryreturning a conn and the goroutine assigning it tos.connunder mu. ifClose()runs in that window, it seess.conn == nil, doesn't close anything, hits the 2s timeout, returns. the goroutine then assignss.connand io.Copy blocks forever on a leaked conn. window is tiny (betweenreturn conn, nilands.mu.Lock()), but cleanly fixable: stores.connbefore releasing it fromdialUnixWithRetry, or check ctx after the lock and bail. -
net.Dial("unix", path)doesn't honor ctx. loop checks ctx between attempts, but a singleDialsyscall is uninterruptible. on a missing path Dial returns instantly so it's not really an issue in practice — flagging as it'll bite if someone changes the path semantics later.(&net.Dialer{}).DialContext(ctx, "unix", path)is the ctx-aware form. -
serialSocketPathderives the instance dir viafilepath.Dir(filepath.Dir(logPath)). fragile ifInstanceAppLoglayout ever changes. minor — apaths.InstanceSerialSocket(id)helper would be cleaner and consistent with the rest oflib/paths/paths.go. low priority.
Bugbot flagged that on the success path of StartVM/RestoreVM the serialReader was constructed but never retained anywhere — so its Close() could not be called from Shutdown, and the unix socket file persisted on disk after the VM went away (CH unlinks on Drop, but nothing guarantees CH actually exits cleanly). Hand the reader to the CloudHypervisor client and call Close() in Shutdown. Also defer os.Remove(socketPath) inside the run goroutine so the socket is unlinked on goroutine exit even if Close() is never called (e.g. CH crash). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related fixes in serialReader: 1. Race in run/Close. Between dialUnixWithRetry returning a conn and the goroutine assigning it under s.mu, Close() could acquire the lock, see s.conn == nil, and time out without closing the dialed conn. The goroutine would then publish the conn and io.Copy would block forever on a connection nobody can reach. Fix: re-check ctx.Err() under the lock and close the conn ourselves if Close already fired. 2. Use net.Dialer.DialContext instead of net.Dial so a single dial attempt is interruptible. The retry loop already checks ctx between attempts, but a single Dial syscall is not. In practice ENOENT returns instantly, but DialContext is the correct form. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
thanks @rgarcia, all four addressed:
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 02bb2de. Configure here.

Summary
Switch CH's serial console from
mode=Filetomode=Socket. CH binds a unix socket in the instance directory and hypeman dials it as a client; a goroutine in thecloudhypervisorpackage copies bytes from the socket into the log file opened withO_APPEND.This is the CH-side counterpart to the QEMU O_APPEND fix. CH's
ConsoleConfighas no append flag, so we can't ask it to open the file correctly — the only way to get an O_APPEND writer is to make hypeman the writer.Why
CH's
serial.mode=Fileopens with plainO_WRONLY|O_CREAT, no O_APPEND, and never reopens on signal. WhenrotateLogIfNeededtruncates the log file out from under it, CH's next write lands at its stale fd offset and creates a sparse hole of NUL bytes from byte 0 onward. Downstream log readers chunk those NULs, JSON-encode them at ~6× expansion, and choke when batches exceed receiver body limits.With hypeman owning the writer fd,
O_APPENDatomically seeks to EOF on every write, so post-truncate writes correctly resume at byte 0.Changes
cloudhypervisor/config.go—serial.modeis nowSocketwith a derived socket path (one level abovelogs/, kept short forsun_path).cloudhypervisor/serial.go— newserialReaderdials CH's bound socket with retry (CH is the server inmode=Socket) and copies bytes intoapp.logopened withO_APPEND. The reader is owned by theCloudHypervisorclient soShutdowncan stop it.cloudhypervisor/process.go— wire the reader intoStartVMandRestoreVM. Reader is started before CH so it's ready to dial as soon as CH binds duringvm.create. Cleanup paths close it on failure.cloudhypervisor/fork_snapshot.go— fork rewrites andRestoreVMboth migrate snapshot configs from File-mode to Socket-mode so legacy snapshots get the fix on the next restore.Tests
TestSerialReader_CopiesBytesToLog— basic socket → file copy.TestSerialReader_NoSparseHoleAfterCopytruncate— regression: write bytes, copytruncate, write more, assert the post-truncate file is non-sparse and content is exactly the post-truncate bytes.TestRewriteSerialConfigForRestore— covers File→Socket migration, idempotence on already-Socket configs, and no-op when no serial block is present.TestRewriteSnapshotConfigForFork— updated for new shape, asserts legacyserial.fileis dropped on fork rewrite.TestSystemdMode/SerialLogSurvivesCopytruncate— boots a real CH VM with the existing systemd image, waits for serial output to accumulate, performs copytruncate againstapp.logmid-run, drives more serial output via/dev/kmsg, then asserts the post-rotation file is non-sparse (allocated blocks ≈ apparent size) and does not start with mostly NUL bytes.Backward compatibility
Snapshots taken with the old File-mode config are migrated in place on restore (and on fork), so the next time a legacy snapshot is restored or forked it switches to Socket mode and gets the fix. The rewrite is idempotent on already-Socket configs.
Test plan
go test ./lib/hypervisor/cloudhypervisor/...passes locallygo vet ./lib/hypervisor/cloudhypervisor/... ./integration/...cleanTestSystemdMode/SerialLogSurvivesCopytruncateruns end-to-end on CHNote
Medium Risk
Changes Cloud Hypervisor VM bring-up/restore/shutdown and snapshot config rewriting to route serial through a new socket reader goroutine; failures could impact VM startup, restore, or log capture. Risk is mitigated by unit tests plus an end-to-end integration regression test for the sparse-log issue.
Overview
Cloud Hypervisor serial logging is switched from
mode=Fileto socket-based serial with a hypeman-owned writer opened usingO_APPEND, preventing copytruncate rotation from creating sparse NUL holes inapp.log.This wires a new
serialReaderinto CHStartVM/RestoreVM(started before CH and closed on cleanup/Shutdown), and adds snapshot migration logic so both forked and restored legacy snapshots are rewritten from File-mode serial to Socket-mode.Adds focused unit tests for socket→file copying and the copytruncate regression, updates fork snapshot tests for the new serial config shape, and adds an integration regression test that performs a real copytruncate against a running CH VM and asserts the post-rotation log is non-sparse and not NUL-prefixed.
Reviewed by Cursor Bugbot for commit 02bb2de. Bugbot is set up for automated code reviews on this repo. Configure here.