Harden hypervisor process liveness checks - #363
Open
yummybomb wants to merge 46 commits into
Open
Conversation
This was referenced Aug 6, 2026
yummybomb
marked this pull request as ready for review
August 6, 2026 19:20
yummybomb
force-pushed
the
hypeship/hypervisor-liveness
branch
from
August 6, 2026 19:26
f9e6580 to
3c8b92f
Compare
yummybomb
force-pushed
the
hypeship/hypervisor-liveness
branch
from
August 6, 2026 19:40
3c8b92f to
915f0ef
Compare
yummybomb
force-pushed
the
hypeship/hypervisor-liveness
branch
from
August 7, 2026 14:02
915f0ef to
a187984
Compare
yummybomb
force-pushed
the
hypeship/hypervisor-liveness
branch
2 times, most recently
from
August 7, 2026 20:52
76b9f42 to
78fc483
Compare
yummybomb
force-pushed
the
hypeship/hypervisor-liveness
branch
from
August 8, 2026 01:05
78fc483 to
f8fbe79
Compare
yummybomb
force-pushed
the
hypeship/hypervisor-liveness
branch
from
August 9, 2026 07:52
ea4603c to
f17f33e
Compare
This was referenced Aug 9, 2026
yummybomb
force-pushed
the
hypeship/hypervisor-liveness
branch
from
August 10, 2026 15:37
9c1fbaf to
1a1ff46
Compare
yummybomb
force-pushed
the
hypeship/hypervisor-liveness
branch
from
August 10, 2026 18:26
7dbe8e7 to
be6c8ff
Compare
yummybomb
force-pushed
the
hypeship/hypervisor-liveness
branch
from
August 10, 2026 18:34
be6c8ff to
fdd7b9a
Compare
yummybomb
force-pushed
the
hypeship/hypervisor-liveness
branch
from
August 10, 2026 18:58
fdd7b9a to
bba964c
Compare
resolveRuntimeHypervisorPID discarded the confirmed flag from ResolveProcessPID, so a process matched only by its command line could receive the boot-scoped PID/start-time identity token. Later destructive paths short-circuit on that token without re-confirming socket ownership, elevating an unconfirmed match to a trusted owner. Record the full identity only for the direct child we spawned or a confirmed socket owner; a command-line-only match stores the bare PID so stop/delete must confirm ownership through the socket before acting on it. Restore reuses the same helper instead of minting a second token.
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.
This reverts commit 2559a7d.
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
force-pushed
the
hypeship/hypervisor-liveness
branch
from
August 14, 2026 20:43
aafd431 to
aba44c2
Compare
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 drop the owner-preference parameter from pidBySocketRef: the expected owner is already confirmed by the fd fast path before the full scan runs, so the preference loop was unreachable.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Layer 1 of the vendor VFIO vGPU stack (
generalize-vgpu-device← this ←vendor-vfio-backend←vendor-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:
ProcessExists— one exported, EPERM-aware, zombie-filtering definition instead of scattered barekill(pid, 0)probes. EPERM means the process exists but cannot be signaled; treating it as dead would be wrong.Wait4returns ECHILD immediately and the kill loop finished before the process had exited. Poll for actual exit in that case.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 onResolveProcessPID), 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 vetclean/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 wedginggo test -race ./lib/instances/targeted suites pass (TestCreateInstanceWithNetworkrequires image pulls + iptables and fails in this environment on the unmodified base as well;TestStandbyAndRestore/TestForkCloudHypervisorFromRunningNetworkboot real VMs and cannot run in this sandbox — CI covers them)Note
High Risk
Changes authentication-adjacent lifecycle behavior: wrong ownership resolution could SIGKILL the wrong process or leave a live VMM while releasing network/GPU; ambiguous cases now error instead of best-effort cleanup.
Overview
Hardens how hypeman decides which process owns an instance’s hypervisor control socket before stop, standby, or delete tear down network, vGPU, and disks.
On Linux, socket resolution now maps only listening Unix sockets (via
__SO_ACCEPTCON), scans/procfds (withResolveProcessPIDForOwnerfor expected owners), drops cmdline matching so debug clients can’t block teardown, and surfacesErrNoOwningProcesswhen the listener is gone. Instance metadata gainsHypervisorProcessIdentity(PID + start time + boot ID, flat JSON) withSet/SetUnconfirmed/Clear;resolveLiveHypervisorPIDtrusts matching tokens or confirmed socket ownership and treats provable death (wrong boot, no listener) as gone without signaling a recycled PID.Stop/delete/standby wait on the resolved owner, use shared
killProcessAndWait/ exportedProcessExists(EPERM-aware, zombie-aware), and abort resource teardown when kill or ownership confirmation fails (delete retains metadata; standby resumes the VM on shutdown failure). Boot/restore paths record identity throughresolveRuntimeHypervisorPID.Reviewed by Cursor Bugbot for commit 6cb7738. Bugbot is set up for automated code reviews on this repo. Configure here.