fix(vm): QEMU stdout/stderr to shared log file (SIGPIPE survives daemon restart)

QEMU stdout/stderr were piped to daemon-side scanner goroutines.
When the daemon exits (e.g. `systemctl restart spinifex-daemon`),
the pipe read ends close; QEMU's next write triggers SIGPIPE and
the guest dies — defeating DDIL Scenario B's "daemon restart
preserves running instances" guarantee.

Open `<runtimeDir>/qemu.log` and assign it to cmd.Stdout/cmd.Stderr
instead. The FD is dup'd into QEMU at fork, so closing it on the
daemon side after Start() leaves QEMU writing into the file
indefinitely. POSIX O_APPEND makes concurrent QEMU writes
interleave atomically per line, so a single shared log is safe and
avoids per-instance clutter in RuntimeDir.

Trade-off: QEMU lines no longer land in journalctl -u
spinifex-daemon. Operators inspect <runtimeDir>/qemu.log directly
(path logged at instance start under the qemu_log field).

mulga-siv-58 (DDIL Scenario B iteration 4)
This commit is contained in:
joshsiv-mulga
2026-05-11 22:22:25 +10:00
parent 49393d4c9a
commit 8a9a6f9f6e
+19 -24
View File
@@ -1,7 +1,6 @@
package vm
import (
"bufio"
"encoding/json"
"errors"
"fmt"
@@ -280,6 +279,8 @@ func (m *Manager) startQEMU(instance *VM) error {
exitChan := make(chan int, 1)
startupConfirmed := make(chan bool, 1)
qemuLogPath := filepath.Join(utils.RuntimeDir(), "qemu.log")
go func() {
cmd, err := instance.Config.Execute()
if err != nil {
@@ -288,45 +289,39 @@ func (m *Manager) startQEMU(instance *VM) error {
return
}
stdout, err := cmd.StdoutPipe()
// QEMU stdout/stderr go to a single shared file rather than pipes
// back to daemon-side goroutines. If the daemon exits (e.g.
// `systemctl restart spinifex-daemon`), a pipe's read end closes
// and QEMU's next write triggers SIGPIPE, killing the guest —
// defeating DDIL Scenario B's "daemon restart preserves running
// instances" guarantee. With a file, the FD is dup'd into QEMU at
// fork and survives daemon exit. POSIX O_APPEND makes concurrent
// short writes from multiple QEMUs interleave atomically per
// line, so a single shared log is safe and avoids per-instance
// clutter in RuntimeDir.
logFile, err := os.OpenFile(qemuLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
slog.Error("Failed to pipe STDOUT VM", "err", err)
processChan <- 0
return
}
stderr, err := cmd.StderrPipe()
if err != nil {
slog.Error("Failed to pipe STDERR VM", "err", err)
slog.Error("Failed to open QEMU log file", "path", qemuLogPath, "err", err)
processChan <- 0
return
}
cmd.Stdout = logFile
cmd.Stderr = logFile
if err := cmd.Start(); err != nil {
slog.Error("Failed to start VM", "err", err)
_ = logFile.Close()
processChan <- 0
return
}
_ = logFile.Close()
slog.Info("VM started successfully", "pid", cmd.Process.Pid)
slog.Info("VM started successfully", "pid", cmd.Process.Pid, "qemu_log", qemuLogPath)
if err := utils.SetOOMScore(cmd.Process.Pid, 500); err != nil {
slog.Warn("Failed to set QEMU OOM score", "pid", cmd.Process.Pid, "err", err)
}
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
slog.Info("[qemu]", "line", scanner.Text())
}
}()
go func() {
scanner := bufio.NewScanner(stderr)
slog.Info("QEMU stderr reader started")
for scanner.Scan() {
slog.Error("[qemu-stderr]", "line", scanner.Text())
}
}()
processChan <- cmd.Process.Pid
waitErr := cmd.Wait()