Skip to content

controller/vm: data race in Stats(), nil-handle Close, and Invalid-state overwrite in waitForVMExit #2713

@shreyanshjain7174

Description

@shreyanshjain7174

Summary

Three findings in internal/controller/vm/vm.go discovered during code review of #2634. None are introduced by that PR; all are pre-existing in upstream/main.

1. Data race in Stats() on c.vmmemProcess (most important)

Location: internal/controller/vm/vm.go:464-471

c.mu.RLock()
defer c.mu.RUnlock()
...
// Initialization of vmmemProcess to calculate stats properly for VA-backed UVMs.
if c.vmmemProcess == 0 {
    vmmemHandle, err := vmutils.LookupVMMEM(ctx, c.uvm.RuntimeID(), &iwin.WinAPI{})
    if err != nil {
        return nil, fmt.Errorf("cannot get stats: %w", err)
    }
    c.vmmemProcess = vmmemHandle  // ← write under RLock
}

Stats() writes c.vmmemProcess while holding only c.mu.RLock(). The same field is mutated under exclusive c.mu.Lock() in TerminateVM (vm.go:541) and read under exclusive lock in other paths.

Stats() is called by the shim's SandboxMetrics RPC, which containerd polls on a timer for every running pod. Two concurrent metric scrapes on the same Controller can both:

  1. Acquire read lock (allowed concurrently)
  2. Both observe vmmemProcess == 0
  3. Both call LookupVMMEM (succeeds, returns a handle)
  4. Both assign to c.vmmemProcess — race + handle leak (one of the two opened handles is overwritten without being closed; the other is "lucky" to be the kept value)

Reproduction: add a parallel-Stats test under -race and watch the data-race detector fire.

Fix sketch:

  • Take c.mu.Lock() (exclusive) for the duration of Stats(), OR
  • Lazy-initialize vmmemProcess once during StartVM (under exclusive lock), OR
  • Use sync.Once keyed on c for the lookup.

2. windows.Close(0) when vmmemProcess was never initialized

Location: internal/controller/vm/vm.go:541

// Best effort attempt to clean up the open vmmem handle.
_ = windows.Close(c.vmmemProcess)

If Stats() was never called (e.g., a sandbox that was Created and Terminated without ever transitioning to Running long enough to be scraped), c.vmmemProcess is 0. windows.Close(0) returns ERROR_INVALID_HANDLE. The error is swallowed by _ = so there is no functional damage, but it is technically wrong and produces ETW noise.

Fix: add a nil-handle guard:

if c.vmmemProcess != 0 {
    _ = windows.Close(c.vmmemProcess)
}

3. waitForVMExit overwrites StateInvalid to StateTerminated

Location: internal/controller/vm/vm.go:328-333

c.mu.Lock()
if c.vmState != StateTerminated {
    c.vmState = StateTerminated
}
c.mu.Unlock()

Behaviour question rather than a clear bug: the guard if c.vmState != StateTerminated overwrites StateInvalid to StateTerminated. StateInvalid is set by TerminateVM when uvm.Close fails, indicating a leaked HCS handle. If the background waitForVMExit then runs and sees the VM has actually exited, it currently masks the Invalid signal.

The state-transition table in state.go documents StateInvalid → StateTerminated only via "TerminateVM called". The current code adds a second path through waitForVMExit.

Possible interpretations:

  • Intentional: once the VM is actually gone, Terminated is the truthful state regardless of how the controller arrived. StateInvalid was about the leaked handle, which windows.Close in TerminateVM already attempted.
  • Unintentional: the recovery contract assumes StateInvalid persists until a successful TerminateVM retry. If waitForVMExit sneaks in first and clears it, callers cannot distinguish "cleanly terminated" from "terminated with a leaked handle".

A regression guard test pinning the current behaviour was added in #2634 (TestWaitForVMExit_OverwritesInvalidToTerminated); that test should be updated if the intent is changed.

Suggested fix (if unintentional): narrow the guard to if c.vmState == StateRunning.

Out of scope

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions