From 8a9a6f9f6e7f9b231dd3218d435e3357110d7e7d Mon Sep 17 00:00:00 2001 From: joshsiv-mulga Date: Mon, 11 May 2026 22:22:25 +1000 Subject: [PATCH] fix(vm): QEMU stdout/stderr to shared log file (SIGPIPE survives daemon restart) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `/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 /qemu.log directly (path logged at instance start under the qemu_log field). mulga-siv-58 (DDIL Scenario B iteration 4) --- spinifex/vm/lifecycle.go | 43 ++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/spinifex/vm/lifecycle.go b/spinifex/vm/lifecycle.go index a5f149123..cf5639f3d 100644 --- a/spinifex/vm/lifecycle.go +++ b/spinifex/vm/lifecycle.go @@ -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()