Skip to content

sandbox: identify OpenVMM by its process ID and start time - #378

Merged
Pedro Henrique Penna (ppenna) merged 1 commit into
devfrom
fix/issue-369-sandbox-process-identity
Oct 5, 2026
Merged

Pedro Henrique Penna (ppenna) merged 1 commit into
devfrom
fix/issue-369-sandbox-process-identity

Conversation

@ppenna

Copy link
Copy Markdown
Contributor

nvx.py sandbox now identifies OpenVMM by its process ID and start time, so a recycled PID or an unreaped OpenVMM no longer wedges stop, deprovision, and exec.

Fixes #369

Problem

sandbox start recorded only OpenVMM's PID in runtime.json, and _process_running counted any process with that PID as OpenVMM:

  • On Linux, os.kill(pid, 0) succeeds for a zombie, and the PermissionError that another user's process raises also counted as running.
  • On Windows, any process that the caller could open and that had not exited counted.

Once OpenVMM was gone but its PID named a live process, no CLI operation recovered the sandbox: stop waited out --timeout on the dead control endpoint, deprovision refused ("sandbox must be stopped before deprovision"), and start refused the stale record. That happens after OpenVMM crashes or the host restarts with a persistent --state-dir. Separately, when nothing reaps OpenVMM (for example, a container whose PID 1 lacks --init), it stays a zombie, so even an acknowledged STOP ended in "OpenVMM did not terminate after managed stop" with runtime.json left behind.

Fix

The implementation follows the ProcessIdentity design of the aci_edge_sandboxes backend:

  • Record a start time: start writes start_time next to pid. On Linux, it is field 22 (starttime) of /proc/<pid>/stat; on Windows, the creation time from GetProcessTimes. start reads the time before it releases the child, so the PID cannot have been reused yet. If OpenVMM already exited or cannot be identified, start fails without publishing a record instead of waiting for the endpoint timeout.
  • Check the identity: a process counts as OpenVMM only while its start time matches the record, and a zombie (Z/X) counts as exited. On Windows, the check now waits on the process handle (WaitForSingleObject) instead of comparing the exit code with STILL_ACTIVE, which an exit status of 259 also matches. A process that the caller cannot open still counts as gone, because OpenVMM runs as the caller.
  • Older records: a runtime.json written by an earlier NVX version has no start_time and is still identified by its PID alone, as before, except that a zombie now counts as exited.
  • Inconclusive checks fail the operation: if the process state cannot be read (an unexpected /proc or OpenProcess error, or a malformed stat line), the operation fails with "cannot determine whether OpenVMM process N is running" instead of guessing. deprovision therefore never removes the state of a VM that may still run. A non-integer, boolean, or negative start_time is rejected.
  • Docs: doc/run.md describes the identity check and how older records are treated.

Tests

New tests in scripts/test_nvx_tools.py cover:

  • the stat parser, including a command name that contains spaces and parentheses
  • a start-time mismatch, and an exited child
  • a zombie child (Linux; created with waitid(WNOWAIT))
  • stop after an acknowledged STOP while OpenVMM stays a zombie (Linux)
  • a reused PID through stop, exec, and deprovision
  • older records and malformed start times
  • start failures when OpenVMM exits early or cannot be identified

The two existing start tests now also check the recorded identity.

Validation

I ran the real scripts/nvx.py sandbox CLI against real OpenVMM microVMs on bare-metal Linux/KVM, Linux/MSHV, and Windows/WHP hosts. The artifacts were the v0.1.0-dev.2472412feb45 release (OpenVMM 974f356), at a3d929a with and without this change.

Scenario Before After
OpenVMM exits but is never reaped: a subreaper that never reaps stands in for a container init (KVM, MSHV) stop fails with "OpenVMM did not terminate" after --timeout; start and deprovision refuse; runtime.json stays stop succeeds in 0.2–0.4 s and removes runtime.json
After a crash, the record names root-owned PID 1, as after a reboot (KVM, MSHV) exec and stop time out on the dead endpoint; start and deprovision refuse exec and stop report stale state in ≤0.4 s; deprovision succeeds
Real Linux PID reuse: about 4.19M forks over 27–34 s until an unrelated sleep received OpenVMM's PID (KVM, MSHV) Same failures Same as the previous row
Real Windows PID reuse: an unrelated cmd.exe received OpenVMM's PID after 1.9k–23.7k process creations (WHP) Same failures Same as the previous row
Normal lifecycle: provision, start, exec, stop, deprovision (all three) Passes Passes, and runtime.json includes start_time
Sandbox started by the previous code and stopped by this code (all three; also the zombie case on Linux) — Passes
test-microvm --scenario managed-exec-config — Passes on kvm, mshv, and whp

Other checks:

  • Unit tests: the validate-nvx unit suites (678 tests) pass on Windows, on the KVM host (Python 3.14), and on the MSHV host (Python 3.12). ruff check, ruff format --check, and strict pyright for both Linux and Windows are clean.
  • Rebase: the branch is based on ae0c3e9, which only updates the data/ baselines on top of a3d929a. I re-ran the unit suites and quality gates there.
  • Performance: in interleaved A/B runs on WHP, the median sandbox start time went from 1.943 s to 1.946 s. The identity check costs about 26–60 µs per call.

Notes

  • start still refuses any existing runtime.json, stale or not, which matches the expected behavior in the issue. To recover, run deprovision (which now succeeds) and provision again. Having start or stop clear stale records automatically would be a separate behavior change.
  • This change does not touch the adversarial executor's own _process_running.

`nvx.py sandbox` recorded only OpenVMM's process ID in runtime.json and
counted any process with that ID as OpenVMM. On Linux, os.kill(pid, 0)
succeeds for a zombie, and the PermissionError that another user's
process raises also counted as running. On Windows, any process that
the caller could open and that had not exited counted. So once OpenVMM
was gone but its ID named a live process, the sandbox wedged: stop
waited out --timeout on the dead control endpoint, deprovision refused
because the sandbox seemed to run, and start refused the stale record.
The ID names another process after OpenVMM crashes or the host restarts
with a persistent --state-dir. And when no process reaps OpenVMM, as
under a container init without --init, it stays a zombie, so even an
acknowledged STOP ended in "OpenVMM did not terminate after managed
stop" with runtime.json left behind.

start now records OpenVMM's start time next to its ID, as the
aci_edge_sandboxes backend does: the starttime field of
/proc/<pid>/stat on Linux and the creation time from GetProcessTimes on
Windows. It reads the time before it releases the child, so the ID
cannot have been reused yet, and it fails without publishing a record
if OpenVMM already exited or cannot be identified. A process counts as
OpenVMM only while its start time matches the record, and a zombie
counts as exited. On Windows, the check now waits on the process handle
instead of comparing the exit code with STILL_ACTIVE, which an exit
status of 259 also matches. A record that an earlier NVX version wrote
has no start time and is still identified by its ID alone. When the
process state cannot be read, the operation fails instead of guessing,
so deprovision never removes the state of a VM that may still run.

New unit tests cover the stat parser, a start-time mismatch, an exited
and a zombie child, stop after an acknowledged STOP while OpenVMM stays
a zombie, a reused ID through stop, exec, and deprovision, legacy and
malformed records, and start failures. On bare-metal Linux/KVM,
Linux/MSHV, and Windows/WHP hosts with real OpenVMM guests, the code
before this change wedged in every case of the issue: an unreaped
OpenVMM under a subreaper that never reaps, a record that names
root-owned PID 1, a Linux PID that wraparound gave to an unrelated
process, and a Windows PID recycled to cmd.exe. With this change, stop
completes for the unreaped OpenVMM, and in the other cases exec and
stop report stale state at once and deprovision succeeds. A sandbox
that the previous code started still stops cleanly, and the
managed-exec-config scenario passes on all three backends.

Fixes #369

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 5, 2026 04:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟢 Approval recommended

The implementation matches the stated lifecycle requirements and includes comprehensive cross-platform coverage.

Review effort: Balanced
Findings: None

What changed in this PR

Adds robust OpenVMM process identity tracking to prevent stale or recycled PIDs from wedging managed sandbox operations.

Changes:

  • Records and validates process start time across Linux and Windows.
  • Treats exited and zombie processes as stopped while preserving legacy-record compatibility.
  • Adds lifecycle tests and user documentation.
File Description
scripts/​nvx_tools/​sandbox_lifecycle.py Implements PID/start-time identity checks.
scripts/​test_nvx_tools.py Covers reuse, zombies, malformed records, and startup failures.
doc/​run.md Documents process identity and legacy behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@ppenna
Pedro Henrique Penna (ppenna) merged commit e33c0ba into dev Oct 5, 2026
31 checks passed
@ppenna
Pedro Henrique Penna (ppenna) deleted the fix/issue-369-sandbox-process-identity branch October 5, 2026 06:43
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.

Track nvx.py managed sandbox bugs: PID-only liveness check wedges stop, deprovision, and start

2 participants