Repository navigation
nvx-rs: keep processes spawned during a launch from holding its VMM's log on Windows #966
Description
Baseline: dev at 1853252c. Every link uses that revision.
Goal. On Windows, NVX makes a VMM's standard handles inheritable while it creates the VMM. A process that the caller of the launch spawns meanwhile without a handle list, as std::process::Command spawns one, inherits those handles, its log's among them, and keeps them until it exits. The launch's claim on its log then outlives the VMM. A recovery that finds the VMM gone cannot end the launch, which remains, with whatever it holds, until a recovery after that process exits. Tests that launch VMMs while other tests spawn processes fail intermittently on WHP because of it. Close that window to processes that the caller spawns through NVX, and make the tests spawn their processes that way.
Today:
spawnpasses the VMM its standard handles: the read end of the pipe that carries its capability, and two handles of its log (spawn.rs#L86-L99).spawn_listedmakes them inheritable (windows.rs#L694-L703), names them and the handlesinheritin the VMM's handle list (#L704-L746), creates the VMM (#L751-L766), and only then closes them (#L772-L775).- The list keeps the caller's other handles out of the VMM. It does not keep the VMM's handles out of other processes: a process that another thread creates meanwhile, with inheritance and no list, inherits every handle of the caller that is inheritable then. The docs of
spawn_detached(#L591-L597) and ofInherited(inherit.rs#L36-L40) say so. INHERITANCE(windows.rs#L524-L529) serializes only NVX's spawns that allow handlesinherit, and no other spawn.- So NVX's own launches never take each other's handles, but processes that a test or an embedding application spawns with
std::processcan.
- On Windows, a launch's claim on its log is the VMM's handles themselves: while any process holds the log, an open of it that shares nothing fails (
store/mod.rs#L115-L129).- Recovery checks a held claim again for up to a second (
#L307-L313,claim_heldat#L822-L850). That covers a process that holds the log on Linux, which it does only from its fork until its exec. - Windows closes no handle when a process starts, so a process that inherited the log holds the claim until it exits.
- When the VMM has exited,
end_identified(#L852-L887) finds no process that matches it, but a held claim, and reports the launch as indeterminate: "a process still holds its log, and no process matches its VMM process N".
- Recovery checks a held claim again for up to a second (
- That process also holds the read end of the capability pipe, from which it could read the capability before the VMM does.
- Seen on WHP:
- runtime-adapter: start clones of templates #954's
vm_clonetesta_clone_whose_activation_fails_ends_before_it_is_readylaunches a VM in the test process while other tests spawnruntime-adapter-hostprocesses, which run for seconds to minutes.- It failed in the first attempt of CI run 40747944093 (job 123032287845, "NVX microVM tests / Windows / WHP / Guests and sandboxes") with "cannot activate it; the launch could not be ended, and remains for recovery: a process still holds its log, and no process matches its VMM process 7404". It passed on the rerun.
- It failed in 2 of 4 WHP runs on one branch, and in every parallel WHP run on another. It passes with
RUST_TEST_THREADS=1.
- nvx-rs: attribute a clone's failures, record them, and reattach it #951's library test
host::template::clone::tests::restore::a_status_with_which_the_guest_ended_the_clone_s_vm_attributes_its_failurefailed once, withUnresolved("a process still holds its log, and no process matches its VMM process …")instead ofExited. 5 of 5 reruns passed.- Meanwhile, other library tests spawn processes with
std::process, such aslink_dir'scmd /C mklink /J(store/lifecycle/tests.rs#L468-L488,template/store/tests.rs#L115-L136) and the minute-longsleeper(process.rs#L466-L482).
- Meanwhile, other library tests spawn processes with
- runtime-adapter: start clones of templates #954's
Implementation:
- One lock over the whole window.
spawn_listedtakes a process-wide lock before it makes any handle inheritable. It releases the lock once it has closed the standard handles and restored the handlesinherit.INHERITANCEbecomes that lock; today it covers the handlesinheritalone, and no spawn without them. - The caller's spawns under it. NVX exposes, on every host, a guard of that lock and a function that spawns a
std::process::Commandwhile it holds the lock, so that a process spawned that way cannot inherit a VMM's handles.- On Linux, they need not lock anything: a process holds the log only until its exec, and the claim's recheck covers that.
- The docs of the guard,
spawn_detached,Inherited, and the log claim say that on Windows, a process spawned without the guard can still hold a VMM's log until it exits, and leave its launch to a later recovery.
- Tests spawn through it:
- nvx-rs's tests that spawn processes while others launch, such as
link_dirandsleeperabove; - the runtime adapter's harness, which spawns
runtime-adapter-hostwhile a test launches a VM in its own process.
- nvx-rs's tests that spawn processes while others launch, such as
Not in scope: neither step covers a process that the caller spawns without the guard. A claim that only the VMM can hold would cover it, but either approach is a larger change:
- OpenVMM could open its log itself, from a path that NVX passes, so that no handle of the log is ever inheritable in NVX's caller. That needs an OpenVMM option, and leaves the capability pipe.
- A recovery could prove the VMM's exit through a job object of its launch, whatever holds its log. That changes the store's recovery.
Tests:
- On Windows, a test of
spawn_listedwith a hook that holds a spawn in its window:- A process that the guard's function spawns meanwhile does not inherit the spawned process's log: the log's claim is released once that process exits, while the other process still runs.
- The control: a process that
std::process::Commandspawns in that window holds the claim.
- The two tests above pass in 10 consecutive parallel WHP runs, without
RUST_TEST_THREADS=1.
Done when: merged, with:
- the window under one lock;
- the guard and its spawn function public and documented;
- NVX's and the runtime adapter's tests spawning through them;
- the affected tests passing in parallel WHP runs.
Activity
ppenna commentedon Oct 29, 2026
Also on
dev. The push ofe4ca7acaa(#959) failed the same way in CI run 40896635809, attempt 1 (job 123543947721, "NVX microVM tests / Windows / WHP / Guests and sandboxes" onazure-windows-8, step "Run runtime adapter scenarios on Windows"). Links below use that revision.vm_clone, 15 tests passed, anda_clone_whose_activation_fails_ends_before_it_is_readyfailed in theMessage::Startcase ofrefuses_early(vm_clone.rs#L1126):Earlycontrol saw the VMM exit before the teardown, since the assertion oncontrol.exitedpassed, so only the log's claim was still held.devpushes passed the job, butvm_clone.rs,tests/common/, andnvx-rs/src/host/are unchanged frome4ca7acaathrough9a84f5722, so the window remains.vm_clone,refuses_earlyis the only test that launches in the test process (vm_clone.rs#L1087-L1090). Every other test runsruntime-adapter-hostthroughSandbox::start, which spawns it withstd::process::Command(common/mod.rs#L234-L243), so step 3's harness change covers this binary.Reproduced without a hypervisor. On a Windows host, with Rust 1.95.0 and nvx-rs at
9a84f5722, whosesrc/host/matchese4ca7acaa. In each iteration, a binary creates aSandboxStore, begins a launch, spawnscmd /c exit 0with the launch's log throughsupervisor::spawn, records the process, waits for it to exit, and abandons the launch, as a clone whose activation fails does. Meanwhile, another thread spawnsping -n 3 127.0.0.1, which runs for about 2 s, withstd::process::Command, 5 ms apart:ExitedIndeterminateEach indeterminate abandon took about 1.0 s, the claim's recheck, and reported "a process still holds its log, and no process matches its VMM process N". The second row has the shape of steps 1 and 2.
Reproduction source
Cargo.toml:src/main.rs, run ascargo run -q -- alone 20,cargo run -q -- serialized 40, andcargo run -q -- concurrent 40:ppenna commentedon Oct 30, 2026
Reopening: #1029 fixed this for processes that the caller spawns under the spawn lock, but not for processes that it spawns otherwise, which this issue left out of scope.
Another occurrence, before #1029. CI run 40989896779, attempt 1, a
devpush of2fe86fe0(the merge of #1022), failed in job 123871462085 ("NVX microVM tests / Windows / WHP / Guests and sandboxes", runnerazure-windows-9), step "Run runtime adapter scenarios on Windows":2fe86fe0predates #1029 (merged as78fd0229):spawn_listedstill made the VMM's standard handles inheritable outsideINHERITANCE, and the harness startedruntime-adapter-hostwith a plainCommand::spawn. Only that job failed. "Required status check" failed because of it, and the run's performance gate, baseline, release, and crate jobs were skipped.After #1029. The first
devrun with it, 40994225569 at78fd0229, passed the same job (123882816388):vm_clone34/34, this test included. That is one sample of an intermittent race. On Windows without a hypervisor,host::platform::windows::testspassed 16/16 in 6 of 6 runs atfc6ccf62.What remains. The control test
a_process_spawned_without_the_spawn_lock_in_a_window_holds_the_vmms_logshows that a process spawned without the lock, asstd::process::Commandspawns one, still holds a VMM's log, and with it the launch's claim, for as long as it runs.end_identifiedthen cannot end a launch whose VMM has exited. An application that embedsnvxand spawns processes on other threads without the lock, itself or through a library, still gets "a process still holds its log, and no process matches its VMM process N" from an abandon or a recovery, and the launch stays until that process exits. No open issue tracks this.Plan.
std::process::Command, let the stand-in VMM exit, and abandon the launch through the store. Today that endsIndeterminatewith the message above on every run. Run it on the Windows/WHP test host too, and check the real-VM scenarios there.PROC_THREAD_ATTRIBUTE_JOB_LIST, so the VMM never runs outside it. A process that only inherits the log never joins the job. The job's handle is inheritable for the whole window in which the log's handles are, so a process that inherits the log also inherits the job's handle and keeps the job open. A claim check that opens the job decides by the job's active processes. One that finds no job falls back to today's exclusive open of the log, so a launch by an earlier version is still checked as before.