Skip to content

Harden hypervisor process liveness checks - #363

Open
yummybomb wants to merge 46 commits into
hypeship/generalize-vgpu-devicefrom
hypeship/hypervisor-liveness
Open

Harden hypervisor process liveness checks#363
yummybomb wants to merge 46 commits into
hypeship/generalize-vgpu-devicefrom
hypeship/hypervisor-liveness

Conversation

@yummybomb

@yummybomb yummybomb commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Layer 1 of the vendor VFIO vGPU stack (generalize-vgpu-devicethisvendor-vfio-backendvendor-vfio-vgpu). Pure hypervisor-process hardening with no vGPU-specific code; reviewable in isolation.

The upper layers guard vGPU release decisions on "is this instance's hypervisor still alive", so the liveness answer has to be trustworthy first:

  • Unify liveness checks on ProcessExists — one exported, EPERM-aware, zombie-filtering definition instead of scattered bare kill(pid, 0) probes. EPERM means the process exists but cannot be signaled; treating it as dead would be wrong.
  • Wait for non-child hypervisor exit before finishing kill — after a hypeman restart the hypervisor is not our child, so Wait4 returns ECHILD immediately and the kill loop finished before the process had exited. Poll for actual exit in that case.
  • Verify process identity before teardown — a bare PID probe treats any process that reused a stored hypervisor PID as the owning VMM. On Linux, require confirmed socket ownership or a matching PID/start-time token scoped to the current host boot, including the graceful stop/delete wait path.
  • Treat provable death as death — two disproofs unwedge stop/delete instead of failing closed forever: an identity token recorded under a different host boot cannot be the recorded VMM, and a socket with no owning listener proves the recorded VMM is gone even when a recycled PID is live. The recycled PID is never signaled.
  • Delete what the hardened resolver made redundant — the startup identity backfill (correctness never depended on persisted tokens: token-less metadata confirms through the expected-owner fast path, and an empty listener scan is provable death) and stop's unconditional force-kill after a shutdown that already confirmed exit.
  • Drop the command-line fallback from socket resolution — a process matching the socket path by argv only could never authorize teardown, so its sole effect was turning provable death into a wedge: a debug client (ch-remote, socat) holding the path in its argv blocked stop/delete with a 500 until it exited. The fd scan runs with root/CAP_SYS_PTRACE (documented on ResolveProcessPID), so a missing listener is proof of death on its own. If hypeman is ever de-privileged below that, this needs revisiting.

Failure contract

When the hypervisor can be confirmed dead or killed, delete completes synchronously (worst case ~2s graceful + SIGKILL waits). When ownership is genuinely unprovable — the /proc socket scan itself fails, or a process survives SIGKILL — stop/delete return an error (API 500) with metadata retained and the restart policy blocked, so a retried delete is safe and converges once the ambiguity clears. An earlier revision handled this with a background delete finalizer; it was removed in favor of the synchronous error after review — the ambiguous cases are rare, self-resolving, and better surfaced loudly than retried silently.

Testing

  • go build ./..., go vet clean
  • Identity/kill/shutdown suites additionally cross-compiled and run as root on a dev host against real /proc: all pass. Validated against the host's production firecracker binary: a live VMM resolves with no hint, the correct owner hint, and a wrong hint; a SIGKILLed VMM whose socket file remains on disk classifies as provably dead; a dead socket path held in a live process's argv resolves as provably dead instead of wedging
  • go test -race ./lib/instances/ targeted suites pass (TestCreateInstanceWithNetwork requires image pulls + iptables and fails in this environment on the unmodified base as well; TestStandbyAndRestore/TestForkCloudHypervisorFromRunningNetwork boot real VMs and cannot run in this sandbox — CI covers them)

Note

High Risk
Changes core stop/delete/standby and hypervisor kill paths; incorrect ownership logic could leave VMs running or signal wrong processes, though extensive tests target PID reuse and socket edge cases.

Overview
Hypervisor process identity and Linux socket resolution are reworked so stop, delete, and standby only signal processes that truly own the instance control socket.

On Linux, ResolveProcessPID now maps the bound path to a listening inode in /proc/net/unix, scans process fds (with optional ResolveProcessPIDForOwner when a stored PID might share the fd with a child), and returns ErrNoOwningProcess when no listener exists. The old cmdline fallback is removed so debug clients that only mention the socket path cannot block teardown.

Instance metadata gains embedded HypervisorProcessIdentity (PID + start time + boot ID, flat JSON keys unchanged). Boot/restore mints tokens only for a live spawned child or a confirmed socket owner; destructive paths use resolveLiveHypervisorPID to prefer matching identity, disprove stale PIDs after reboot or when the socket has no owner, and error if the fd scan fails ambiguously.

Lifecycle behavior changes: killHypervisor / shutdownHypervisor resolve the live owner before SIGKILL, wait for exit via shared killProcessAndWait / ProcessExists (EPERM-aware, zombie-aware), and remove the socket only after the VMM is gone. Failed hypervisor kill on delete or shutdown on standby now abort with metadata retained instead of continuing cleanup. Stop waits on the socket owner, not a bare stored PID, and drops the redundant force-kill after a successful shutdown.

Reviewed by Cursor Bugbot for commit 5bad493. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread lib/hypervisor/socket_pid_linux.go
@yummybomb
yummybomb force-pushed the hypeship/hypervisor-liveness branch 2 times, most recently from 76b9f42 to 78fc483 Compare August 7, 2026 20:52
@yummybomb
yummybomb force-pushed the hypeship/hypervisor-liveness branch from 78fc483 to f8fbe79 Compare August 8, 2026 01:05
Comment thread lib/instances/delete.go Outdated
Comment thread lib/instances/delete.go Outdated
Comment thread lib/instances/query.go Outdated
@yummybomb
yummybomb force-pushed the hypeship/hypervisor-liveness branch from 9c1fbaf to 1a1ff46 Compare August 10, 2026 15:37
@yummybomb
yummybomb force-pushed the hypeship/hypervisor-liveness branch from 7dbe8e7 to be6c8ff Compare August 10, 2026 18:26
@yummybomb
yummybomb force-pushed the hypeship/hypervisor-liveness branch from be6c8ff to fdd7b9a Compare August 10, 2026 18:34
@yummybomb
yummybomb force-pushed the hypeship/hypervisor-liveness branch from fdd7b9a to bba964c Compare August 10, 2026 18:58
resolveLiveHypervisorPID used the recorded boot ID only as a positive
signal. When the stored boot ID differed from the current host boot and
the instance socket was gone, a live process wearing the recycled PID
made the resolver fail closed, so stop/delete aborted forever on an
instance whose hypervisor provably cannot be running.

A boot-scoped identity from a different host boot cannot identify a
live hypervisor on this boot — HypervisorProcessIdentityExists already
treats it as dead. Zero the stored PID before socket resolution so
teardown proceeds while the unrelated PID holder is left untouched.
When legacy metadata carries a live stored PID but no boot-scoped
identity, resolveLiveHypervisorPID failed closed on ErrNoOwningProcess,
wedging stop and delete forever once the PID was recycled. That error
means both the socket-listener scan and the full command-line scan
found nothing, and a live hypervisor always holds its control-socket
listener - the same signal already treated as dead when the stored PID
no longer exists. Return dead instead of erroring so pre-upgrade
instances stay deletable after PID reuse.

Also document that HypervisorProcessExists fails open by design.
resolveRuntimeHypervisorPID minted a boot-scoped identity token for a
fallback PID that ProcessExists had just disproven, stamping the current
boot ID (and, if the PID were recycled mid-call, a live start time) onto
a process that is not the hypervisor. Record the bare PID with a cleared
token instead, matching the command-line-only branch.
shutdownHypervisor SIGKILLed the raw stored PID and removed the control
socket without the ownership checks the other kill paths enforce, so a
recycled PID could be signaled and the fail-closed stop fallback was
undermined. Resolve the live owner first: kill the resolved PID only,
skip the kill when the recorded hypervisor is provably gone, and fail
closed before any teardown when ownership cannot be confirmed, keeping
the socket as evidence for the hardened kill path.
When the control client cannot be built but the resolved socket owner is
alive, shutdownHypervisor force kills the owner instead of reporting a
completed shutdown and unlinking the socket over a running VMM.

A command-line-only match that is already dead now classifies as
provable death rather than failing closed: the socket-owner scan found
nothing and no live process matches the command line, the same
conclusion as ErrNoOwningProcess. The classification moves into
classifyResolvedHypervisorOwner so that race is directly testable.
refreshHypervisorPID ran resolveLiveHypervisorPID on every hydration of
every running instance, so each list/get paid socket resolution — worst
case a full /proc scan — for instances mid-transition. Hydration is
read-only and its answer never authorizes teardown: stop, delete,
standby, and the vGPU release guards all re-resolve identity before
acting. Restore the cheap contract: trust a live stored PID, resolve
the socket only when it is dead, and mint the identity token only for
a confirmed owner. Durable token backfill for legacy metadata stays
with BackfillHypervisorProcessIdentities at startup, which persists it;
hydration's in-memory upgrade never did.
Embed HypervisorProcessIdentity in StoredMetadata anonymously so the
persisted JSON keys are unchanged, replace the nine hand-cleared
(PID, StartTime, BootID) sites with Set/SetUnconfirmed/Clear methods,
and pass the struct to resolveLiveHypervisorPID instead of three
positional fields.
killProcessAndWait replaces the kill blocks in killHypervisor,
forceKillHypervisorProcess, and forceKillHypervisorPID. All callers now
get the process-group escalation standby already had, and the SIGKILL
wait used by stop and delete is a named constant.
Count backfilled, skipped, and failed instances and emit one info line
so a wholesale backfill failure is visible without debug logging.
A hypervisor that survives SIGKILL is stuck in uninterruptible sleep and
no amount of waiting unsticks it, so the long wait only slowed down stop
and delete. killProcessAndWait still escalates to the process group and
grants a 2s grace period after the initial wait.
Delete no longer fails when the hypervisor cannot be confirmed dead or
instance data removal fails. Instead the instance is marked
pending-delete: it disappears from list, get, and name lookups (freeing
its name for reuse), the delete lifecycle event fires, and the API call
returns success. A background finalizer retries the remaining teardown
until the hypervisor is provably gone, including a pass at startup where
the boot-scoped process identity settles the stuck case immediately
after a host reboot.

Admission capacity stays held until finalization since the stuck
hypervisor may still pin its memory and devices, and TAP GC preserves
pending-delete TAPs for the same reason. A new
hypeman_instances_pending_delete_total gauge exposes wedged teardowns.
HypervisorProcessIdentityExists and HypervisorProcessExists have no callers
in this change; the vGPU work that consumes them introduces them itself.
killProcessAndWait always waited 2s regardless of which of the two
identically-valued constants callers passed, so the parameter goes away.
The resolver disproof branches (boot mismatch, start-time mismatch) were
each tested through killHypervisor with subprocess scaffolding; test them
at the resolver level instead and keep one wrapper-level no-op test.
Fold the classify fail-closed/provable-death cases into one table test
and drop the kill-level duplicate. Replace the three backfill tests that
only exercised the needsHypervisorIdentityBackfill predicate with a
direct table test.
Both resolved the live owner and called killProcessAndWait; the only
differences were an early return already handled inside
resolveLiveHypervisorPID and a trailing socket remove that is a no-op on
stop's path (shutdownHypervisor already removed it).
standbyInstance logged and continued when shutdownHypervisor failed,
then released the TAP and cleared the process identity — written when
that error meant only "graceful API failed after we killed the raw
PID". With fail-closed ownership checks the error can now mean nothing
was killed, so continuing would release the network under a live paused
VMM and erase the only identity that could ever find it again.

Standby now resumes the VM and returns the error; the snapshot on disk
is harmless and a retried standby redoes it. shutdownHypervisor's
trailing graceful-API error is demoted to a warning since it only
fires after the process is confirmed gone, giving the function a
single contract: error means the hypervisor may still be running.
@yummybomb
yummybomb force-pushed the hypeship/hypervisor-liveness branch from aafd431 to aba44c2 Compare August 14, 2026 20:43
shutdownHypervisor unlinked the control socket via a defer on every
return and again before waiting for the process to exit. On the paths
where the kill fails and standby resumes the VM, that left a live
hypervisor with no socket file, so a later graceful standby or stop
could not connect and fell straight into the force-kill path. Unlink
the socket only once the hypervisor is provably gone, matching the
contract killHypervisor already follows on delete and stop.
A process matching the socket path by command line only can never
authorize teardown, so the fallback's sole effect was distinguishing
provable death from ambiguity. The fd scan runs with CAP_SYS_PTRACE
(hypeman runs as root or with full caps), so it cannot miss a live
owner and a missing listener already proves the hypervisor is gone.
The fallback was also actively harmful: a debug client holding the
socket path in its argv (ch-remote, socat) resolved as an unconfirmed
live match and wedged stop/delete until it exited.

Resolution now trusts the listener scan alone: a confirmed owner is
returned, no owner classifies as provable death, and only a failed
scan fails closed.
shutdownHypervisor returns nil only when the hypervisor is confirmed
gone, so the unconditional killHypervisor that followed was a redundant
re-resolution. Run it only when shutdown fails.

Also make pidBySocketRef prefer the expected owner when the full scan
finds the listener fd in multiple processes: the scan's observation is
the same evidence the fast path uses, so a child transiently sharing
the inherited fd must not turn a proven owner into a fail-closed error
when the fast path misses on a transient fd-dir read failure.
The backfill existed to persist identity tokens for legacy metadata so
destructive paths could skip socket resolution. Correctness never
depended on it: a token-less live PID is confirmed through the
expected-owner fast path (one /proc/net/unix read plus one fd-dir read),
and after a reboot the empty listener scan classifies the recorded
hypervisor as provably dead. Stop, delete, and standby re-resolve
ownership on every call regardless, and instances mint tokens on
create/restore, so the startup pass and its metadata rewrites bought
nothing.
@yummybomb
yummybomb force-pushed the hypeship/hypervisor-liveness branch from 4833e14 to 5bad493 Compare August 17, 2026 06:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants