Skip to content

Track nvx-rs host VMM supervisor bugs: a non-UTF-8 process name at a reused PID blocks reconcile, reattach, and stop #720

Description

Bug-hunt tracking issue for the nvx-rs host VMM supervisor (launch journal, stop, reattach), added in #656, and for the host process identity through which it, the store's recovery, and nvx::host::instance::Instance examine a recorded VMM.

Baseline: origin/dev at f63b4b1f6e4eadd7cc0dab1eea8fd49d886b7549. Every permalink in the findings and in this pass's notes uses this SHA. The earlier pass, collapsed at the end, keeps its own baseline. Finding 1 is still present at the newer head 3da0cfab72182196afc551e56c30a3afb38af529, which changed none of the files that it links.

Hunt: reopened by a second time-boxed pass of about 45 minutes. It started at 01:00 −03:00 on 2026-10-22 and stopped at about 01:35, when this bug was confirmed. No repository files were modified and nothing was committed. The reproduction ran as an integration test in a scratch copy of the baseline's nvx-rs (git archive), outside the repository, under WSL 2 (Linux 6.18) with Rust 1.95.0.

Selection: the bug is in how the host examines a recorded VMM process, which the supervisor's reattach and stop rely on, so it belongs to this issue's component. No other bug-hunt issue covers it. #741 (open) covers the nightly CI reporter, and #737 (open) covers the guest managed agent's output limit. #464 covers the managed control client's pipe writes, and #733 covers sandbox resource limits. This issue's first pass had found nothing, so it's reopened and updated rather than duplicated.

Findings

# Severity Finding Status
1 Medium impact, low likelihood A live process whose command name isn't valid UTF-8 can't be examined, so a stale VMM record whose PID it reused can't be reconciled, reattached, or stopped Confirmed; open
  • Read /proc/<pid>/stat as bytes in platform::linux::read_stat, and add a regression test with a non-UTF-8 command name.

1. A reused PID whose process has a non-UTF-8 command name blocks reconcile, reattach, and stop of a VMM that has exited

Severity: medium impact, low likelihood.

  • Impact: when it triggers, every lifecycle operation of the affected sandbox fails until an unrelated process exits. For a long-lived daemon, that means until someone intervenes by hand.
  • Likelihood: the PID of a recorded VMM must be reused by a process whose command name isn't valid UTF-8.
    • Linux truncates comm to 15 bytes without regard to UTF-8. So any process whose name is longer than that and has a multibyte character across the boundary qualifies, such as a localized program name.
    • Stale records themselves are routine. Any VMM that exits or crashes on its own leaves one until the next reconcile, reattach, or stop.

Where

Trigger: two things happen in turn:

  1. A VMM that a runtime record or a supervisor store names exits without its record being cleared. It may crash or be killed, or its guest may power off.
  2. Before the next reconcile, reattach, or stop, a process whose comm isn't valid UTF-8 takes its PID.

Expected: the stat line is read, and its start time differs from the recorded one, so the VMM counts as exited:

  • process_start_time returns Some(start).
  • Process::open returns None.
  • reconcile() returns None and clears the record.
  • reattach returns Exited.

Actual: each of them fails with InvalidData until that unrelated process exits:

Reproduction: a scratch integration test, in a copy of the baseline's nvx-rs, exercises the public API:

  1. It starts a live process and names it with prctl(PR_SET_NAME).
  2. It writes a runtime record that names that PID with another start time, as a reused PID would have.
  3. It examines the record through the public API.

The control case names the process vmm-reused-pid. The other names it aデータ処理, 16 bytes, which Linux truncates to 15 in the middle of 理.

Test source (nvx-rs/tests/bughunt_non_utf8_comm_v2.rs in the scratch copy)
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};

use nvx::host::instance::{Instance, RuntimeRecord, STATE_FORMAT};
use nvx::host::platform::process_start_time;
use nvx::host::process::{BootId, Process, ProcessRef};

/// Runs a live process named `name`, as a recorded OpenVMM's reused ID would hold, and checks it.
fn check(name: &str) {
    let script = format!(
        "import ctypes, time\n\
         ctypes.CDLL(None).prctl(15, {name:?}.encode(), 0, 0, 0)\n\
         print('ready', flush=True)\n\
         time.sleep(60)\n"
    );
    let mut child = Command::new("python3")
        .args(["-c", &script])
        .stdout(Stdio::piped())
        .spawn()
        .unwrap();
    let mut line = String::new();
    BufReader::new(child.stdout.take().unwrap())
        .read_line(&mut line)
        .unwrap();
    let pid = child.id();
    let stat = std::fs::read(format!("/proc/{pid}/stat")).unwrap();
    let end = stat.iter().position(|&byte| byte == b')').unwrap() + 1;
    println!("== /proc/{pid}/stat begins {:?}", String::from_utf8_lossy(&stat[..end]));
    println!("   comm bytes: {:?}", &stat[stat.iter().position(|&b| b == b'(').unwrap()..end]);
    println!("   process_start_time({pid}) = {:?}", process_start_time(pid));
    // What the supervisor's reattach and the store's recovery hold a recorded VMM with.
    let stale = ProcessRef {
        pid,
        start_time: 1,
        boot: BootId::current().unwrap(),
    };
    println!(
        "   Process::open(pid {pid}, start time 1) is_some = {:?}",
        Process::open(&stale).map(|process| process.is_some())
    );

    let dir = std::env::temp_dir().join(format!("nvx-bughunt-{pid}"));
    std::fs::create_dir_all(&dir).unwrap();
    let instance = Instance::new(&dir, "demo");
    // A stale record of an OpenVMM that exited and whose ID this process reused: the recorded
    // start time is not this process's.
    instance
        .write_runtime(&RuntimeRecord {
            format: STATE_FORMAT,
            pid,
            start_time: 1,
            endpoint: dir.join("control.sock").display().to_string(),
        })
        .unwrap();
    println!("   Instance::reconcile() = {:?}", instance.reconcile());
    println!(
        "   runtime record kept afterwards: {:?}",
        instance.runtime().map(|r| r.is_some())
    );
    child.kill().unwrap();
    child.wait().unwrap();
    std::fs::remove_dir_all(&dir).unwrap();
}

#[test]
fn reused_pid_with_ascii_command_name() {
    check("vmm-reused-pid");
}

#[test]
fn reused_pid_with_non_utf8_command_name() {
    // 16 bytes, which Linux truncates to 15 in the middle of the last 3-byte character.
    check("a\u{30C7}\u{30FC}\u{30BF}\u{51E6}\u{7406}");
}
$ cargo test --locked --test bughunt_non_utf8_comm_v2 -- --nocapture --test-threads=1
test reused_pid_with_ascii_command_name ... == /proc/26994/stat begins "26994 (vmm-reused-pid)"
   comm bytes: [40, 118, 109, 109, 45, 114, 101, 117, 115, 101, 100, 45, 112, 105, 100, 41]
   process_start_time(26994) = Ok(Some(26512))
   Process::open(pid 26994, start time 1) is_some = Ok(false)
   Instance::reconcile() = Ok(None)
   runtime record kept afterwards: Ok(false)
ok
test reused_pid_with_non_utf8_command_name ... == /proc/26996/stat begins "26996 (aデータ処�)"
   comm bytes: [40, 97, 227, 131, 135, 227, 131, 188, 227, 130, 191, 229, 135, 166, 231, 144, 41]
   process_start_time(26996) = Err(Error { kind: InvalidData, message: "stream did not contain valid UTF-8" })
   Process::open(pid 26996, start time 1) is_some = Err(Error { kind: InvalidData, message: "stream did not contain valid UTF-8" })
   Instance::reconcile() = Err(Error { kind: Io, message: "cannot determine whether OpenVMM process 26996 of sandbox demo is running", source: Some(Error { kind: InvalidData, message: "stream did not contain valid UTF-8" }) })
   runtime record kept afterwards: Ok(true)
ok

Suggested fix: read the line as bytes, as the CLI's copy does. The smallest change keeps read_stat's String:

match fs::read(format!("/proc/{pid}/stat")) {
    // A command name may hold any bytes; only the ASCII fields after its last `)` are parsed.
    Ok(stat) => Ok(Some(String::from_utf8_lossy(&stat).into_owned())),

) is ASCII and never part of a multibyte sequence, so lossy decoding keeps the last ) and every field after it. I checked the change in the scratch copy:

  • Both cases above print Ok(Some(..)), Ok(false), Ok(None), and Ok(false).
  • The platform, process, instance, and store unit tests pass: cargo test --lib -- host::platform host::process host::instance host::store reports 38 passed.

For a regression test, name a child with prctl(PR_SET_NAME) as above. Alternatively, have read_stat return bytes and feed start_time_from_stat a line with invalid UTF-8.

This pass: explored and verified (no bug)

Each suspect was traced through its callers and existing tests, and then checked by reasoning or with the smallest available run.

  1. Distro binding and file seals (c3bfa40, 21efc02, 8e9008e).
    • Code: DistroBinding, FileSeal, and the Windows seal.
    • provision re-verifies a layer whose link names another manifest at its resolved path, and start checks the binding at that path. The UUID is lowercased at parse time.
    • The recorded binding survives the dumps round trip, including 64-bit NTFS volume serials above i64::MAX, because Value holds integers of any size.
    • On Windows, the guarded open shares reads, so it's compatible with OpenVMM's read-only open and with other sandboxes that use the same layer.
  2. Guest mapping API (f70adb9, 97c930d).
    • The path rules of Mapping::new match the decoder's, and a test pins that.
    • apply_with keeps the agent's earlier behavior: the modes, the O_EXCL | O_NOFOLLOW file target, and the remount flags.
  3. CI suite selection (7ccb8d5, 9ad0468).
    • required_ci_expected_results matches the if: of each job in ci.yml: the artifact, debug-kernel, and build jobs, both OpenVMM test suites, the microVM and debug jobs, the platform jobs, and performance-gate.
    • A failed detector fails the required check.
    • The only .md files under suite input trees are READMEs.
  4. Guest build dep-info check (c65bb3d, 930e3fb).
    • _dep_info_paths reads Cargo's single-rule format with \ -escaped spaces, including a literal backslash before an escaped space.
    • SOURCE_PATTERN and guest_binary_source_files agree, and ci.py embeds the pattern anchored.
  5. Store and release retries.
    • The store's log-claim recheck (4fcbeb4) never sleeps past its deadline.
    • The release upload (cf8bac7) retries "release not found" only after it saw the draft without the asset.
  6. Windows host platform.
    • The store's privacy checks behave as intended:
      • The owner must be the user, SYSTEM, or Administrators.
      • Unknown ACE types are refused.
      • A directory must pass entries on to its files.
    • The command-line quoting of spawn_detached follows the MSVC runtime's rules: it quotes on a space or tab, and doubles backslashes only before a quote and before the closing quote.
  7. Azure runner package (build_package.py): the path validators, the overlap checks, and the 65,535-byte decoded customData limit hold.

Checked, not filed

These are low-impact, by design, or unreproduced.

  • A dev push that changes only README.md or THIRD_PARTY_NOTICES.md publishes no development release.
    • Every release package ships both files (release.py#L1763, #L1799-L1800).
    • Even so, the classifier treats them as documentation (ci.py#L113), unlike LICENSE, which is a platform input.
    • The next non-documentation push publishes them, and doc/ci.md documents the rule.
  • Linux seals key on st_dev. On btrfs subvolumes and other filesystems with anonymous device numbers, st_dev can change across a remount or reboot. start would then ask to provision again. That's the intended response to a binding that no longer holds. Not reproduced.
  • Instance runtime records carry no boot ID, unlike the supervisor's ProcessRef.
    • On Linux, the start time counts clock ticks since boot, so the same PID and tick could recur after a reboot.
    • Not reproduced, and very unlikely.
  • A Windows seal of a handle that isn't a disk file fails with Io, from code reading only. For a layer path such as NUL, seal fails with Io rather than the documented Unavailable "not found". Only the error kind of an invalid input differs.

Validation limits

  • The host ran Windows 11 with Python 3.14.7 and Rust 1.95.0. Linux checks ran under WSL 2 Ubuntu (6.18.33.2-microsoft-standard-WSL2).
    • KVM, MSHV, and WHP weren't used, and no OpenVMM or guest artifacts were built.
    • Neither the microVM scenarios nor test-aci-edge-sandboxes ran.
  • The reproduction exercised process_start_time, Process::open, and Instance::reconcile directly. The following follow from code reading of the call sites linked above:
    • the supervisor's reattach
    • Instance::stop
    • the store's recovery
    • the session's connect loop
    • the aci_edge_sandboxes operations
  • The suggested fix was checked only in the scratch copy, against the reproduction and 38 unit tests, not the full suites.
  • The guest exec driver changed often during the hunt: five follow-up fixes landed on 2026-10-20 and 2026-10-21. It was skipped because it borders Track guest managed agent bugs: output limit forwards later output after a dropped chunk #737.
Earlier pass, 2026-10-21: none confirmed in this component (supervisor launch, stop, and reattach; harness changes)

The first pass stopped after about 12 minutes without a finding in this component, and its follow-up confirmed a bug in sandbox resource limits, which #733 tracks.

Baseline: origin/dev at 7269dbf9ece67fee46257a9d9994ffe260423822. Every permalink below uses this SHA.

Explored and verified (no bug)

Each suspect was traced through its callers and the existing tests, and checked by reasoning or with the smallest available reproduction.

  1. Supervisor launch, commit, and abandon paths (launch, abandon_after, Starting::commit, Drop for Starting).
    • Every failing step ends the launch.
    • An unrecorded VMM is killed through its handle before Launch::abandon.
    • If that kill can't be confirmed, the held log claim makes end_unidentified report Indeterminate and keep the journal.
    • A failed Launch::commit keeps the journal, and the VMM is killed.
    • A later recover either drops a journal superseded by the record of the same launch or ends the launch through end_identified.
  2. Stop and reattach (Vmm::stop, stop_process, reattach_with).
    • The forced bound is validated before any signal.
    • A failed graceful step only polls for the exit.
    • The runtime record is cleared only for the same launch, and only when the outcome isn't TimedOut.
    • An unexaminable recorded VMM is reported Indeterminate without clearing state.
  3. Windows process waits (ProcessHandle::wait): sub-millisecond deadlines round up, and the wait never passes INFINITE.
  4. Runtime-adapter framing (Decoder::push, tests: serve the control session in the runtime adapter's guest agent #640): header and body splits at every offset, zero-length bodies, and malformed headers, which clear the state before any body byte is taken.
  5. command-line-limit scenario (run_command_line_limit, openvmm: refuse kernel command lines that the guest kernel would truncate #661).
    • The padding arithmetic is correct: L₁ + (2048 − L₁) − 1 = 2047, and the over-limit boot is 2048.
    • _single_marker_value is line-anchored, so the echoed printf 'NVX-COMMAND-LINE: …' script line can't count as a second marker.
  6. OpenVMM transcript streaming (OpenvmmProcess.close, microvm tests: stream OpenVMM transcripts to disk as they are read #659): InteractiveProcess.close stops the reader, with a bound, before the drain, so the final chunks reach the transcript.
  7. Windows Azure metadata snapshot (ci: recover Windows runners after VMSS image upgrades #669).
    • The Python reader is _azure_vm_provenance, and the writer is ConvertTo-SafeAzureComputeMetadata.
    • The writer was extracted through the PowerShell AST and run, with no side effects, in Windows PowerShell 5.1.26100 on synthetic IMDS documents with one and two data disks.
    • dataDisks stays a JSON array, including with one element, which the Python reader accepts:
      {"vmSize":"Standard_D8s_v5","storageProfile":{"osDisk":{…},"dataDisks":[{"diskSizeGB":"256",…,"lun":"0",…}],"resourceDisk":{"size":"0"}}}
      
  8. benchmarkRunnerName (build_package.py, Get-InstanceRunnerLabels).
    • Python's re.fullmatch and PowerShell's -ceq are both case-sensitive.
    • Runner numbers have no leading zeros on either side.
  9. CI change classification (classify_ci_changes, ci: run the OpenVMM suites on dev pushes only when their inputs change #660): it rejects schedule and workflow_dispatch, but ci.yml selects the full matrix for those events before calling it.

Checked, not filed

These are low-impact or unreachable today, and none was reproduced as a failure.

  • LaunchedProcess::terminate and wait_timeout can wait for INFINITE (Windows).
    • Location: windows.rs#L313-L338.
    • Both use u32::try_from(timeout.as_millis()).unwrap_or(INFINITE - 1), so a timeout of exactly 4,294,967,295 ms maps to INFINITE. ProcessHandle::wait clamps to INFINITE - 1 instead.
    • Unreachable today: every caller passes 0 s, 5 s, a poll slice, or KILL_TIMEOUT (10 s).
    • Suggested hardening: .map_or(INFINITE - 1, |ms| ms.min(INFINITE - 1)).
  • A transcript write error can mask the original failure.
    • Location: OpenvmmProcess._fail → close → _drain_available.
    • If writing the transcript fails while draining, for example on a full disk, close() raises that OSError, and _fail never raises the timeout or time ABI error that led there.
    • Only reachable on log I/O failure.
  • A write error leaves a gap in the transcript.
    • Location: OpenvmmProcess._record.
    • The chunk is kept in memory before the log write. A failed write followed by the drain in close() leaves the transcript with a gap, while the in-memory output stays complete.
    • Only reachable on log I/O failure.
  • OpenvmmProcess.__init__ builds TimeAbiMonitor(command) after OpenVMM and its reader have started, outside the cleanup try. A failure there would leak the process and the log handle, but the constructor only parses options and can't fail on a valid command.

Validation limits

Activity

  1. ppenna commented on Oct 22, 2026

    @ppenna
    ContributorAuthor

    Reopening: a second bug-hunt pass at f63b4b1 confirmed a bug in this component. A live process whose command name isn't valid UTF-8 makes a stale VMM record whose PID it reused unexaminable, which blocks reconcile, reattach, and stop. See the updated description.

  2. added
    confirmedIssue affects multiple people.
    and removed
    bugSomething isn't working
    on Oct 22, 2026
  3. changed the title [-]Track nvx-rs host VMM supervisor bugs: none confirmed (launch, stop, reattach)[/-] [+]Track nvx-rs host VMM supervisor bugs: a non-UTF-8 process name at a reused PID blocks reconcile, reattach, and stop[/+] on Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bug-huntFindings from focused bug huntsconfirmedIssue affects multiple people.good first issueGood for newcomers

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions