Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,12 @@ jobs:
env:
HYPEMAN_TEST_REGISTRY: 127.0.0.1:5001
run: |
export HYPEMAN_TEST_PREWARM_DIR="$HOME/.cache/hypeman-ci/linux-amd64"
# Prewarm cache must live on the same filesystem as the test TMPDIR
# (/ci): the image build symlinks the oci-cache from here into each
# per-test cache and builds rootfs there, and those operations are not
# cross-filesystem safe. Keeping both on /ci avoids that.
export HYPEMAN_TEST_PREWARM_DIR="/ci/prewarm/linux-amd64"
mkdir -p "$HYPEMAN_TEST_PREWARM_DIR"
go run ./cmd/test-prewarm

- name: Check gofmt
Expand Down Expand Up @@ -145,13 +150,18 @@ jobs:
TLS_TEST_DOMAIN: "test.hypeman-development.com"
TLS_ALLOWED_DOMAINS: '*.hypeman-development.com'
HYPEMAN_TEST_PREWARM_STRICT: "1"
HYPEMAN_TEST_REFLINK_STRICT: "1"
HYPEMAN_TEST_REGISTRY: 127.0.0.1:5001
HYPEMAN_UFFD_PAGER_BINARY: ${{ runner.temp }}/hypeman-uffd-pager-${{ github.run_id }}-${{ github.run_attempt }}
HYPEMAN_UFFD_SYSTEMD_INSTANCE_PREFIX: ci-${{ github.run_id }}-${{ github.run_attempt }}
# Fast scratch filesystem provisioned on the runner. Must be reflink-capable
# (the VM disk fork fast path uses FICLONE) and kept short to stay under the
# 108-char AF_UNIX sun_path limit for VMM control sockets.
TMPDIR: /ci
run: |
cp "$PWD/bin/hypeman-uffd-pager" "$HYPEMAN_UFFD_PAGER_BINARY"
chmod +x "$HYPEMAN_UFFD_PAGER_BINARY"
export HYPEMAN_TEST_PREWARM_DIR="$HOME/.cache/hypeman-ci/linux-amd64"
export HYPEMAN_TEST_PREWARM_DIR="/ci/prewarm/linux-amd64"
make test TEST_TIMEOUT=20m

- name: Cleanup
Expand Down
2 changes: 2 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ test-linux: ensure-ch-binaries ensure-firecracker-binaries ensure-caddy-binaries
if [ -n "$(TEST)" ]; then \
echo "Running specific test: $(TEST)"; \
sudo env "PATH=$$TEST_PATH" "DOCKER_CONFIG=$${DOCKER_CONFIG:-$$HOME/.docker}" "CI=$${CI:-}" \
"TMPDIR=$${TMPDIR:-/tmp}" \
"HYPEMAN_UFFD_PAGER_BINARY=$${HYPEMAN_UFFD_PAGER_BINARY:-$(UFFD_PAGER_BINARY)}" \
"HYPEMAN_UFFD_SYSTEMD_INSTANCE_PREFIX=$${HYPEMAN_UFFD_SYSTEMD_INSTANCE_PREFIX:-}" \
"HYPEMAN_TEST_PREWARM_DIR=$${HYPEMAN_TEST_PREWARM_DIR:-}" \
Expand All @@ -308,6 +309,7 @@ test-linux: ensure-ch-binaries ensure-firecracker-binaries ensure-caddy-binaries
go test -tags containers_image_openpgp -run=$(TEST) $$VERBOSE_FLAG -timeout=$(TEST_TIMEOUT) ./...; \
else \
sudo env "PATH=$$TEST_PATH" "DOCKER_CONFIG=$${DOCKER_CONFIG:-$$HOME/.docker}" "CI=$${CI:-}" \
"TMPDIR=$${TMPDIR:-/tmp}" \

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reflink strict env not forwarded

Low Severity

CI sets HYPEMAN_TEST_REFLINK_STRICT=1, but test-linux runs go test under sudo env with only an explicit variable list. Unlike TMPDIR and HYPEMAN_TEST_PREWARM_STRICT, reflink strict is not forwarded, so reflinkStrict() is always false under make test and gated fork reflink checks can be skipped when the probe fails.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a681add. Configure here.

"HYPEMAN_UFFD_PAGER_BINARY=$${HYPEMAN_UFFD_PAGER_BINARY:-$(UFFD_PAGER_BINARY)}" \
"HYPEMAN_UFFD_SYSTEMD_INSTANCE_PREFIX=$${HYPEMAN_UFFD_SYSTEMD_INSTANCE_PREFIX:-}" \
"HYPEMAN_TEST_PREWARM_DIR=$${HYPEMAN_TEST_PREWARM_DIR:-}" \
Expand Down
187 changes: 187 additions & 0 deletions lib/hypervisor/qemu/diagnostics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package qemu

import (
"context"
"os"
"path/filepath"
"strconv"
"strings"

"github.com/kernel/hypeman/lib/logger"
)

// logVsockCIDConflict is a deliberate, log-only diagnostic used to confirm the
// vsock guest-CID teardown/handoff race hypothesis on shared CI runners. It is
// NOT part of normal control flow: it never fails the caller and only emits
// logs (via the package logger so they land in CI test output).
//
// It activates only when qemuOutput looks like a vsock guest-CID collision
// ("vhost-vsock: unable to set guest cid: Address already in use"). When it
// does, it logs the attempted CID and scans the host's /proc to identify which
// process is currently holding that CID, plus a summary of all live VMM
// processes (firecracker/cloud-hypervisor don't carry the CID in argv, so a
// holder may not be a qemu process we can match by CID).
func logVsockCIDConflict(ctx context.Context, cid int64, qemuOutput string) {
lower := strings.ToLower(qemuOutput)
if !strings.Contains(lower, "guest cid") || !strings.Contains(lower, "in use") {
return
}

log := logger.FromContext(ctx)
log.WarnContext(ctx, "vsock guest CID conflict detected; scanning host for holder (diagnostic)",
"attempted_cid", cid)

// 1. Find qemu processes whose argv references this specific guest-cid.
cidArg := "guest-cid=" + strconv.FormatInt(cid, 10)
for _, proc := range scanProcesses() {
for _, arg := range proc.argv {
if strings.Contains(arg, cidArg) {
log.WarnContext(ctx, "vsock CID holder found (qemu argv matches guest-cid)",
"attempted_cid", cid,
"holder_pid", proc.pid,
"holder_ppid", proc.ppid,
"holder_orphaned", proc.ppid == 1,
"holder_tmp_dir", proc.tmpDir)
break
}
}
}

// 2. Summarize all live VMM processes. firecracker/cloud-hypervisor do not
// embed the CID in argv, so this lets us correlate a holder even when the
// match above finds nothing.
for _, proc := range scanProcesses() {
if !isVMMComm(proc.comm) {
continue
}
log.WarnContext(ctx, "live VMM process (diagnostic summary)",
"comm", proc.comm,
"pid", proc.pid,
"ppid", proc.ppid,
"orphaned", proc.ppid == 1,
"tmp_dir", proc.tmpDir)
}
}

type procInfo struct {
pid int
ppid int
comm string
argv []string
tmpDir string
}

// scanProcesses reads /proc and returns one entry per live process. It is
// defensive: any unreadable proc entry is skipped. On non-Linux hosts /proc is
// absent and this returns an empty slice.
func scanProcesses() []procInfo {
entries, err := os.ReadDir("/proc")
if err != nil {
return nil
}
var procs []procInfo
for _, entry := range entries {
pid, err := strconv.Atoi(entry.Name())
if err != nil {
continue // not a PID directory
}
procDir := filepath.Join("/proc", entry.Name())

argv := readCmdline(filepath.Join(procDir, "cmdline"))
if len(argv) == 0 {
continue // kernel thread or exited
}

procs = append(procs, procInfo{
pid: pid,
ppid: readPPID(filepath.Join(procDir, "stat")),
comm: filepath.Base(argv[0]),
argv: argv,
tmpDir: extractTmpDir(argv),
})
}
return procs
}

// readCmdline reads a /proc/<pid>/cmdline file (NUL-separated argv).
func readCmdline(path string) []string {
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
return nil
}
parts := strings.Split(string(data), "\x00")
argv := parts[:0]
for _, p := range parts {
if p != "" {
argv = append(argv, p)
}
}
return argv
}

// readPPID parses the parent PID from /proc/<pid>/stat. The comm field is
// parenthesized and may contain spaces/parens, so we parse after the final ')'.
func readPPID(path string) int {
data, err := os.ReadFile(path)
if err != nil {
return 0
}
s := string(data)
close := strings.LastIndex(s, ")")
if close < 0 || close+2 >= len(s) {
return 0
}
fields := strings.Fields(s[close+2:])
// After "(comm) ": field[0]=state, field[1]=ppid.
if len(fields) < 2 {
return 0
}
ppid, err := strconv.Atoi(fields[1])
if err != nil {
return 0
}
return ppid
}

// extractTmpDir returns the first /tmp or /ci path referenced in argv (the test
// scratch dir), to identify which test run owns the process.
func extractTmpDir(argv []string) string {
for _, arg := range argv {
for _, field := range strings.Split(arg, "=") {
if strings.HasPrefix(field, "/tmp/") || strings.HasPrefix(field, "/ci/") ||
field == "/tmp" || field == "/ci" {
return field
}
}
}
return ""
}

// guestCIDFromArgs extracts the vhost-vsock guest-cid from a built QEMU argv,
// matching the "vhost-vsock-pci,guest-cid=%d" device argument. Returns 0 if not
// found.
func guestCIDFromArgs(args []string) int64 {
const marker = "guest-cid="
for _, arg := range args {
idx := strings.Index(arg, marker)
if idx < 0 {
continue
}
rest := arg[idx+len(marker):]
// guest-cid value may be followed by another comma-separated option.
if comma := strings.IndexByte(rest, ','); comma >= 0 {
rest = rest[:comma]
}
if cid, err := strconv.ParseInt(rest, 10, 64); err == nil {
return cid
}
}
return 0
}

// isVMMComm reports whether a process command name is a known VMM binary.
func isVMMComm(comm string) bool {
return strings.HasPrefix(comm, "qemu-system") ||
strings.Contains(comm, "firecracker") ||
strings.Contains(comm, "cloud-hypervisor")
}
12 changes: 10 additions & 2 deletions lib/hypervisor/qemu/process.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,11 @@ func (s *Starter) startQEMUProcess(ctx context.Context, p *paths.Paths, version
processSpan.RecordError(err)
processSpan.SetStatus(codes.Error, err.Error())
cu.Clean()
return 0, nil, nil, appendVMMLog(err, logsDir)
wrapped := appendVMMLog(err, logsDir)
// Diagnostic (log-only): if this was a vsock guest-CID collision, capture
// the colliding CID and which host process is holding it.
logVsockCIDConflict(processCtx, guestCIDFromArgs(args), wrapped.Error())
return 0, nil, nil, wrapped
}
log.DebugContext(processCtx, "QMP socket ready", "duration_ms", time.Since(socketWaitStart).Milliseconds())

Expand All @@ -284,7 +288,11 @@ func (s *Starter) startQEMUProcess(ctx context.Context, p *paths.Paths, version
processSpan.RecordError(err)
processSpan.SetStatus(codes.Error, err.Error())
cu.Clean()
return 0, nil, nil, appendVMMLog(err, logsDir)
wrapped := appendVMMLog(err, logsDir)
// Diagnostic (log-only): if this was a vsock guest-CID collision,
// capture the colliding CID and which host process is holding it.
logVsockCIDConflict(processCtx, guestCIDFromArgs(args), wrapped.Error())
return 0, nil, nil, wrapped
}

hv, err = New(socketPath)
Expand Down
38 changes: 38 additions & 0 deletions lib/instances/compression_integration_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ package instances

import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -126,6 +129,7 @@ func setupCompressionTestManagerForHypervisor(t *testing.T, hvType hypervisor.Ty
func runStandbyRestoreCompressionScenarios(t *testing.T, harness compressionIntegrationHarness) {
t.Helper()
harness.requirePrereqs(t)
acquireHeavyIO(t)

mgr, tmpDir := harness.setup(t)
ctx := context.Background()
Expand Down Expand Up @@ -296,6 +300,12 @@ func execCommandWithRetry(ctx context.Context, inst *Instance, timeout time.Dura
lastExitCode = exitCode
lastErr = err

// Only retry transient vsock/gRPC connection blips (e.g. right after a
// restore/resume under load); surface real failures immediately.
if !isTransientExecError(err) {
return lastOutput, lastExitCode, lastErr
}

if time.Now().After(deadline) {
return lastOutput, lastExitCode, lastErr
}
Expand All @@ -304,6 +314,34 @@ func execCommandWithRetry(ctx context.Context, inst *Instance, timeout time.Dura
}
}

// isTransientExecError reports whether an in-guest exec error is a momentary
// vsock/gRPC connection blip worth retrying, as opposed to a genuine failure.
// These show up intermittently right after a VM resumes under heavy shared-runner
// I/O contention, before the guest agent's vsock listener is fully back up.
func isTransientExecError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, io.EOF) || errors.Is(err, context.DeadlineExceeded) {
return true
}
msg := strings.ToLower(err.Error())
for _, marker := range []string{
"eof",
"unavailable",
"client connection is closing",
"transport is closing",
"connection refused",
"connection reset",
"broken pipe",
} {
if strings.Contains(msg, marker) {
return true
}
}
return false
}

func waitForCompressionJobStart(t *testing.T, mgr *manager, key string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
Expand Down
Loading
Loading