Repository navigation
Track managed sandbox lifecycle bugs: on Windows, provision waits out its timeout for a lock file that refuses access for good #464
Description
Current bug hunt: 2026-10-23
Baseline: origin/dev at c406f80d. Every permalink below uses this SHA.
Hunt: one time-boxed pass of about 45 minutes, which started at 06:00 −03:00 on 2026-10-23 and stopped when finding 1 was confirmed, at about 06:27. It focused on the nvx-rs host store's lifecycle locks and removal, which #754 reworked, and on the nvx-rs sandbox lifecycle lock over them, which no open bug-hunt issue covers. This issue is reopened because the sandbox lifecycle that it tracks, whose lifecycle lock #476 added, now lives in nvx-rs sandbox. No repository files were modified and nothing was committed. The reproduction ran on Windows 11 (NTFS) with Rust 1.95.0, as a scratch test in a git archive copy of the baseline's nvx-rs, outside the checkout.
| # | Severity | Finding | Status |
|---|---|---|---|
| 1 | Low (Windows) | sandbox provision waits out its whole --timeout, 60 s by default, for a sandbox.lock that refuses access for good, such as a read-only one or a directory, and then reports another lifecycle transition of the sandbox is still in progress instead of the denied access |
Confirmed; open |
- Make
provisionfail at once with the denied access, asstart,stop, anddeprovisiondo, and add a CLI regression test.
1. On Windows, provision waits out its timeout for a lock file that refuses access for good, and reports it as a transition in progress
- Impact: the provision fails anyway, but only after the whole lifecycle timeout, and with an error that points to a concurrent transition that does not exist, hiding the real cause. A caller that retries
transition in progresserrors keeps retrying a failure that can't clear on its own. - Likelihood: low. It needs an existing state directory, without
config.json, whosesandbox.lockWindows refuses to open for writing for good: a read-only file, as a copy or restore can leave, or a directory at that path.
Trace:
provisionaccepts an existing state directory and then callsLifecycleLock::acquire(&state_dir, timeout, true).LifecycleLock::acquiretries the store's lock every 25 ms with a deadline that has passed, so that it can check Ctrl-C between tries, and classifies each failure by the PythonOSErrorclass of its cause (os_error_class). For a provision on Windows, itsPermissionDeniedarm retries every denial, for "a removed lock file until its last handle closes", until the deadline, and then fails withtransition_in_progress().- Since
1b42703("nvx-rs: stop waiting for a lock file that Windows refuses for good"), the store tells those cases apart itself.take_lifecycle_lockprobes a denied lock file with an open without access rights. It waits for a removed one and reportsErrorKind::TimedOutonce the deadline passes. It fails with the denied access once a second open at once is refused too, aslock_existing's contract says. The store tests check both:locks_wait_for_a_removed_lock_file_that_a_handle_keeps(both locks reportTimedOut, whichacquire's first arm already retries) andlocks_refuse_a_lock_file_that_refuses_access_for_good(bothlock_for_provisionandlock_existingfail at once for a read-only lock file and for a directory). - That commit changed only the store. The CLI arm, from
345eac3, still swallows the denial that the store now reports as permanent. The arm applies only toprovision:start,stop,exec, anddeprovisionreport the same denial at once.
Trigger: on Windows, a private state directory without config.json whose sandbox.lock is read-only, or is a directory, then nvx-rs sandbox provision --state-dir DIR …, or nvx.py sandbox provision, which runs it.
Expected: provision fails at once, as start and deprovision on the same directory do: error: cannot lock DIR\sandbox.lock: [WinError 5] Access is denied.
Actual: provision retries for its whole --timeout (60 s by default), and then fails with error: another lifecycle transition of the sandbox is still in progress.
Reproduction: this test, appended to a scratch copy of nvx-rs/tests/cli.rs, uses its populated_root, provision, lifecycle, run, and text helpers with the fake OpenVMM, and passes --timeout 5 to keep the run short. Run it with cargo +1.95.0 test --locked --features cli,testing --test cli zz_repro -- --nocapture:
#[cfg(windows)]
#[test]
fn zz_repro_provision_waits_out_a_lock_file_refused_for_good() {
let root = populated_root();
// Control: a fresh state directory provisions at once.
let started = Instant::now();
let mut arguments = provision(&root, "control", "fake_guest_features=16");
arguments.extend(["--timeout".to_owned(), "5".to_owned()]);
let output = run(&root, &arguments);
println!(
"control provision: status {:?} after {:.2?}; stderr: {:?}",
output.status.code(),
started.elapsed(),
text(&output.stderr)
);
for kind in ["read-only", "directory"] {
let state = root.path.join(kind);
nvx::host::store::SandboxStore::create(&state).unwrap();
let lock = state.join("sandbox.lock");
if kind == "read-only" {
fs::write(&lock, b"").unwrap();
let mut permissions = fs::metadata(&lock).unwrap().permissions();
permissions.set_readonly(true);
fs::set_permissions(&lock, permissions).unwrap();
} else {
fs::create_dir(&lock).unwrap();
}
for operation in ["provision", "start", "deprovision"] {
let mut arguments = if operation == "provision" {
provision(&root, kind, "fake_guest_features=16")
} else {
lifecycle(operation, &root, kind)
};
arguments.extend(["--timeout".to_owned(), "5".to_owned()]);
let started = Instant::now();
let output = run(&root, &arguments);
println!(
"{kind} sandbox.lock, {operation}: status {:?} after {:.2?}; stderr: {:?}",
output.status.code(),
started.elapsed(),
root.normalize(&text(&output.stderr))
);
}
if kind == "read-only" {
let mut permissions = fs::metadata(&lock).unwrap().permissions();
#[allow(clippy::permissions_set_readonly_false)]
permissions.set_readonly(false);
fs::set_permissions(&lock, permissions).unwrap();
}
}
}Reproduction output ({root} is the test's temporary root):
control provision: status Some(0) after 303.37ms; stderr: ""
read-only sandbox.lock, provision: status Some(1) after 5.02s; stderr: "error: another lifecycle transition of the sandbox is still in progress\n"
read-only sandbox.lock, start: status Some(1) after 21.38ms; stderr: "error: cannot lock {root}\\read-only\\sandbox.lock: [WinError 5] Access is denied\n"
read-only sandbox.lock, deprovision: status Some(1) after 16.16ms; stderr: "error: cannot lock {root}\\read-only\\sandbox.lock: [WinError 5] Access is denied\n"
directory sandbox.lock, provision: status Some(1) after 5.02s; stderr: "error: another lifecycle transition of the sandbox is still in progress\n"
directory sandbox.lock, start: status Some(1) after 16.89ms; stderr: "error: cannot lock {root}\\directory\\sandbox.lock: [WinError 5] Access is denied\n"
directory sandbox.lock, deprovision: status Some(1) after 16.33ms; stderr: "error: cannot lock {root}\\directory\\sandbox.lock: [WinError 5] Access is denied\n"
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 27 filtered out; finished in 10.44s
Suggested fix: remove the arm at lock.rs#L138-L140, so that a denial that the store reports goes to error_with_cause, as it does for the other transitions. The store already reports a removed lock file as TimedOut, which the first arm retries. If a provision must also wait for a state directory that a concurrent deprovision left pending deletion, on a file system without POSIX deletion, handle that in the store's provision_lock with the same no-access probe. Don't retry every denial in the CLI. Add Windows tests at the CLI level: a provision with a read-only sandbox.lock fails at once with the denied access, and a provision still waits for a delete-pending lock file (with a helper like the store tests' delete_pending) and succeeds once its handle closes.
Fix check: the scratch copy was rerun with only those three lines removed, then restored. A second scratch test made sandbox.lock delete-pending, as a file system without POSIX deletion keeps a removed lock file, with SetFileInformationByHandle(FileDispositionInfo) like the store tests' delete_pending. It held that handle for 1 s while provision ran with --timeout 10.
# baseline (c406f80d)
delete-pending sandbox.lock held 1s, provision: status Some(0) after 1.01s; stderr: ""
read-only sandbox.lock, provision: status Some(1) after 5.02s; stderr: "error: another lifecycle transition of the sandbox is still in progress\n"
directory sandbox.lock, provision: status Some(1) after 5.02s; stderr: "error: another lifecycle transition of the sandbox is still in progress\n"
# with lock.rs#L138-L140 removed
delete-pending sandbox.lock held 1s, provision: status Some(0) after 1.02s; stderr: ""
read-only sandbox.lock, provision: status Some(1) after 17.73ms; stderr: "error: cannot lock {root}\\read-only\\sandbox.lock: [WinError 5] Access is denied\n"
directory sandbox.lock, provision: status Some(1) after 18.38ms; stderr: "error: cannot lock {root}\\directory\\sandbox.lock: [WinError 5] Access is denied\n"
With the arm removed, cargo +1.95.0 test --locked --features cli,testing passed --lib host::cli::sandbox::lock (4 tests), --lib host::store (52 passed, 1 ignored), and --test cli (27 tests, including the recorded Windows scenarios).
The delete-pending scratch test
#[cfg(windows)]
#[allow(unsafe_code)]
#[test]
fn zz_repro_provision_waits_for_a_delete_pending_lock_file() {
use std::os::windows::fs::OpenOptionsExt;
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::Storage::FileSystem::{
DELETE, FILE_DISPOSITION_INFO, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
FileDispositionInfo, SetFileInformationByHandle,
};
let root = populated_root();
let state = root.path.join("pending");
nvx::host::store::SandboxStore::create(&state).unwrap();
let lock = state.join("sandbox.lock");
fs::write(&lock, b"").unwrap();
let file = fs::OpenOptions::new()
.access_mode(DELETE)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
.open(&lock)
.unwrap();
let disposition = FILE_DISPOSITION_INFO { DeleteFile: true };
// SAFETY: the handle is open, and the information is a FILE_DISPOSITION_INFO of its size.
let set = unsafe {
SetFileInformationByHandle(
file.as_raw_handle() as HANDLE,
FileDispositionInfo,
(&raw const disposition).cast(),
size_of::<FILE_DISPOSITION_INFO>() as u32,
)
};
assert_ne!(set, 0, "{}", std::io::Error::last_os_error());
let mut arguments = provision(&root, "pending", "fake_guest_features=16");
arguments.extend(["--timeout".to_owned(), "10".to_owned()]);
let started = Instant::now();
let output = thread::scope(|scope| {
let worker = scope.spawn(|| run(&root, &arguments));
thread::sleep(Duration::from_secs(1));
drop(file);
worker.join().unwrap()
});
println!(
"delete-pending sandbox.lock held 1s, provision: status {:?} after {:.2?}; stderr: {:?}",
output.status.code(),
started.elapsed(),
root.normalize(&text(&output.stderr))
);
}Checked, not filed
- Store lock retries (
nvx-rs/src/host/store/mod.rs):take_lock'stry_lockloop,pause, andRaces::losttry once with a deadline that has passed, and alternate an immediate retry with a paused one, so lost races neither spin nor outlive the deadline. - Store removal (
nvx-rs/src/host/store/lifecycle.rs):remove_contentsrefuses whilelaunch.jsonorruntime.jsonexists, removes the configuration record last, and refuses foreign entries.remove_directory_afterandLingering::waitremove the directory only while the path still names the held identity, andLingering::waitreports a reprovision by looking up the configuration record by path.LockedStore'sDropremoves only its own lock file, and only when the record is missing. Reviewed only; no reproduction. - Caller file declarations (
nvx-rs/src/host/store/caller.rs):CallerFilesrefuses separators, Windows-reserved characters and device names, non-portable characters, the store's names in any ASCII case, both temporary-file forms, and.nfs*.is_store_temporaryandis_report_stagingmatch the names thatwrite_privateand thetempfilecrate give. Reviewed only.
Validation limits
- Only Windows 11 on NTFS was exercised, with the fake OpenVMM of
nvx-rs's CLI tests. No hypervisor or real OpenVMM was needed or used. Linux takes neither the CLI arm (cfg!(windows)) nor the store's Windows probe, so it isn't affected. - A removed lock file that a file system without POSIX deletion keeps was simulated on NTFS with a non-POSIX delete disposition, as the store tests do, not on such a file system. A state directory that becomes pending deletion between
prepare_state_directoryand the lock, which the CLI arm may also have retried, was reviewed only. python scripts\nvx.py verifywasn't needed for thisnvx-rs-only reproduction. Theopenvmmsubmodule of the checkout is uninitialized and was left untouched.
Earlier findings
Earlier hunt of 2026-10-11, on 6020d5d4: a blocked Windows control-pipe write bypassed the managed control operation timeout (closed as completed on 2026-10-13), and the concurrent-start bug that #476 addressed
Current bug hunt: 2026-10-11
Revision: latest fetched origin/dev, 6020d5d49e2e4839feb46cbadbc8db7e8cf334e2.
This issue is reopened for a newly confirmed bug in the managed sandbox control client. The earlier concurrent-start bug was addressed by #476; the finding below is not a claim that its lifecycle-lock fix regressed.
The focused area was control-session framing and timeout/error handling, which the open bug-hunt issues #490 (guest artifact cache provenance) and #486 (live shares at /run targets) do not cover. Investigation stopped at the first confirmed bug, within the 45-minute budget. No repository files were edited and no commits were created.
| # | Severity | Finding | Status |
|---|---|---|---|
| 1 | Medium | A blocked Windows control-pipe write bypasses the managed control operation timeout | Confirmed; open |
- Bound managed control writes by the operation deadline and add a stalled-peer regression test.
1. Windows managed exec can hang beyond its control operation timeout
Severity: Medium. A stalled control endpoint can hang an orchestration call indefinitely rather than returning an error within the configured timeout. A guest workload timeout does not help while its exec request has not finished reaching the endpoint.
Source and caller trace
- The CLI documents
--timeoutas a control operation timeout, validates that it is finite and positive, and passes it toexec_workloadasresponse_timeout:nvx.pytimeout option,command_sandboxexec call. exec_workloadestablishes the authenticated session, then forwards that timeout toControlSession.exec.ControlSession.connectselects_NamedPipeStreamon Windows. The reproduction below completes its real capability-attach/READY exchange.ControlSession.execcalls_send_appbefore establishing the response deadline._send_appultimately calls the transport writer without a deadline._NamedPipeStream.write_alluses synchronousos.write. When the connected server stops draining its pipe, a write larger than the available pipe quota blocks; there is no timer or cancellation path to enforce--timeout.
Trigger
On Windows, the control endpoint completes authentication and then stops reading. Send a valid exec request larger than its available inbound pipe quota. The reproduction uses a real byte-mode duplex named pipe with a 1 KiB quota and a valid 4,093-byte wire request: /bin/true plus one 4,000-byte argument. This is comfortably within both the 4,096-byte per-argument limit and the 64 KiB application-frame limit.
Expected versus actual
Expected: The control operation times out with an explicit timeout error, even if it is blocked sending the request. An incomplete frame must not leave a reusable session.
Actual: With response_timeout=0.05, the call remains blocked after approximately 0.35 seconds. It returns only after the reproduction closes the server handle, with a pipe-closure error rather than a timeout. If that peer stays open without reading, the synchronous write has no timeout bound.
Smallest reproduction
Windows, Python 3.14.7; no hypervisor, OpenVMM executable, guest images, external Python packages, or changes to the checkout are needed. Save this script outside the repository, then run:
python -B C:\Temp\repro_control_write_timeout.py C:\path\to\nvximport sys
import threading
import time
import uuid
from pathlib import Path
import _winapi
sys.dont_write_bytecode = True
sys.path.insert(0, str(Path(sys.argv[1]).resolve() / "scripts"))
from nvx_tools import control_session
def read_exact(handle, length):
data = bytearray()
while len(data) < length:
chunk, status = _winapi.ReadFile(handle, length - len(data))
assert status == 0 and chunk, (status, chunk)
data.extend(chunk)
return bytes(data)
name = rf"\\.\pipe\nvx-write-timeout-{uuid.uuid4().hex}"
server = _winapi.CreateNamedPipe(
name, _winapi.PIPE_ACCESS_DUPLEX, _winapi.PIPE_WAIT,
1, 1024, 1024, 0, _winapi.NULL,
)
capability = b"\x11" * 32
instance = b"\x22" * 16
attached = threading.Event()
finished = threading.Event()
observed = {}
timeout = 0.05
def client():
try:
with control_session.ControlSession.connect(
Path(name), capability, 2.0
) as session:
observed["started"] = time.monotonic()
attached.set()
session.exec(
("/bin/true", "a" * 4000),
timeout_ms=0,
response_timeout=timeout,
)
except Exception as error:
observed["error"] = f"{type(error).__name__}: {error}"
finally:
observed["finished"] = time.monotonic()
finished.set()
worker = threading.Thread(target=client, daemon=True)
worker.start()
try:
try:
_winapi.ConnectNamedPipe(server)
except OSError as error:
if error.winerror != 535: # ERROR_PIPE_CONNECTED
raise
header = control_session.OUTER_HEADER.unpack(
read_exact(server, control_session.OUTER_HEADER.size)
)
assert header[:4] == (
b"NVXS", 1, control_session.OUTER_HOST_ATTACH, 0
), header
assert read_exact(server, header[-1]) == capability
ready = control_session.OUTER_HEADER.pack(
b"NVXS", 1, control_session.OUTER_READY, 0, instance, 1, 0, 0
)
written, status = _winapi.WriteFile(server, ready)
assert status == 0 and written == len(ready), (written, status)
assert attached.wait(2.0), observed
blocked = not finished.wait(0.35)
elapsed = time.monotonic() - observed["started"]
print(f"authenticated attach completed: {attached.is_set()}")
print(f"response_timeout: {timeout:.3f}s")
print(f"still blocked after {elapsed:.3f}s: {blocked}")
assert blocked, observed
finally:
_winapi.CloseHandle(server)
worker.join(2.0)
assert not worker.is_alive(), "client did not exit after server closed"
duration = observed["finished"] - observed["started"]
print(f"returned after server closed, at {duration:.3f}s")
print(f"result: {observed.get('error', 'unexpected success')}")
assert str(observed.get("error", "")).startswith(
("ControlEndpointClosed:", "BrokenPipeError:")
), observed
print("CONFIRMED: a blocked pipe write bypasses the operation timeout")Observed output from executing the exact self-contained issue reproduction:
authenticated attach completed: True
response_timeout: 0.050s
still blocked after 0.351s: True
returned after server closed, at 0.352s
result: BrokenPipeError: [Errno 32] Broken pipe
CONFIRMED: a blocked pipe write bypasses the operation timeout
The self-contained reproduction exited with code 0. The server intentionally remains connected beyond the deadline and is closed only to release the blocked client during cleanup. Neither accepted closure exception is a timeout.
Existing tests and coverage gap
python -B -m unittest scripts\test_control_session.py -v ran 17 tests: 16 passed, 1 skipped (Linux-only Unix-domain-socket closure semantics).
python -B -m unittest scripts\test_nvx_tools.py -k sandbox_timeout -k sandbox_exec_forwards -v ran 3 tests, all passed, covering CLI timeout validation and forwarding of managed execution options.
- Invalid response-timeout tests ensure invalid values are rejected before sending; they do not cover a valid timeout while sending blocks.
- Ordinary exec test promptly drains the request.
- Named-pipe closure test closes the server before writing; it does not leave a connected, non-reading peer.
- CLI timeout-validation test covers finite/positive parsing, not transport backpressure.
Suggested fix
Establish the operation deadline before sending and propagate it into transport writes. On Windows, use deadline-aware overlapped pipe writes with cancellation/cleanup rather than an unbounded synchronous os.write. Close the session if a write times out after emitting only part of a frame. Apply the same bounded-write contract to attach, ping, and stop so that their operation timeouts do not have the same unbounded write path. Preserve existing capability authentication and closure/error reporting.
Add a regression test that authenticates over a real named pipe, stalls its reader, and sends a valid request larger than its quota. Assert that the operation terminates with a timeout within a small scheduling tolerance without closing the server to unblock it, and that a timed-out partial frame cannot be reused.
Checked, not filed
- Argument and environment bounds, omitted versus empty environments, supported exit categories, refusal diagnostics, and reset-versus-closure distinctions: traced and covered by the passing control-session tests; no additional bug confirmed.
- The connection and attach phases each calculate a fresh timeout budget: reviewed only, not independently reproduced or filed.
- Non-finite CLI timeout inputs: the current parser rejects them; the earlier low-impact unchecked input concern is not a current finding.
Validation limits and repository state
- This proves an unbounded host-side write using the real Windows named-pipe transport and unmodified NVX client, including its authenticated attach. A deliberately stalled protocol peer stands in for an unresponsive control endpoint. It does not demonstrate how frequently a real OpenVMM process stalls.
- No real OpenVMM or WHP guest was started.
python -B scripts\nvx.py verifyfailed withinitialized OpenVMM submodule not found: ...\openvmm\Cargo.toml; the submodule is uninitialized and was left untouched. No Linux/KVM/MSHV runtime or build gates were claimed. - The worktree HEAD is
c29f3d1b032938503369ba9c14a0063241317627, but the completescriptstree is byte-identical to the fetchedorigin/devrevision used in all source permalinks: tree ID4992e798a691eedaf2efea551cb3cfe804692d0dat both revisions. The intervening changes affect only performance baseline CSVs and distribution documentation. - Bytecode generation was disabled; reproduction and issue-draft artifacts are outside the repository. Final
git status --shortwas empty, matching the initial clean checkout; the submodule remained uninitialized at its original gitlink. No repository files were changed, no submodule was initialized, and no commits were made.
Earlier finding
- Concurrent
sandbox startcould orphan a running OpenVMM. Addressed by sandbox: serialize the lifecycle transitions of a state directory #476, which added per-state-directory lifecycle serialization and ownership-aware failed-start cleanup. Retained here as resolved history, not reported as a new or reproduced regression.
Activity
sandbox: serialize the lifecycle transitions of a state directory
sandbox: serialize the lifecycle transitions of a state directory
sandbox: serialize the lifecycle transitions of a state directory
sandbox: serialize the lifecycle transitions of a state directory
sandbox: serialize the lifecycle transitions of a state directory
sandbox: serialize the lifecycle transitions of a state directory
sandbox: serialize the lifecycle transitions of a state directory
sandbox: serialize the lifecycle transitions of a state directory
sandbox: serialize the lifecycle transitions of a state directory
sandbox: serialize the lifecycle transitions of a state directory
sandbox: serialize the lifecycle transitions of a state directory
7 remaining items
[-]Track managed sandbox lifecycle bugs: control pipe writes bypass timeouts[/-][+]Track managed sandbox lifecycle bugs: on Windows, provision waits out its timeout for a lock file that refuses access for good[/+]nvx-rs: test the lifecycles of managed sandboxes
nvx-rs: map each recorded CLI scenario to the tests that replace it
nvx-rs: test the lifecycles of managed sandboxes
nvx-rs: map each recorded CLI scenario to the tests that replace it
nvx-rs: test the lifecycles of managed sandboxes
nvx-rs: map each recorded CLI scenario to the tests that replace it
nvx-rs: test the lifecycles of managed sandboxes
nvx-rs: map each recorded CLI scenario to the tests that replace it
nvx-rs: map each recorded CLI scenario to the tests that replace it
nvx-rs: test the lifecycles of managed sandboxes
nvx-rs: map each recorded CLI scenario to the tests that replace it
nvx-rs: map each recorded CLI scenario to the tests that replace it