Skip to content

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 provision fail at once with the denied access, as start, stop, and deprovision do, 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 progress errors keeps retrying a failure that can't clear on its own.
  • Likelihood: low. It needs an existing state directory, without config.json, whose sandbox.lock Windows 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:

  • provision accepts an existing state directory and then calls LifecycleLock::acquire(&state_dir, timeout, true).
  • LifecycleLock::acquire tries 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 Python OSError class of its cause (os_error_class). For a provision on Windows, its PermissionDenied arm retries every denial, for "a removed lock file until its last handle closes", until the deadline, and then fails with transition_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_lock probes a denied lock file with an open without access rights. It waits for a removed one and reports ErrorKind::TimedOut once the deadline passes. It fails with the denied access once a second open at once is refused too, as lock_existing's contract says. The store tests check both: locks_wait_for_a_removed_lock_file_that_a_handle_keeps (both locks report TimedOut, which acquire's first arm already retries) and locks_refuse_a_lock_file_that_refuses_access_for_good (both lock_for_provision and lock_existing fail 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 to provision: start, stop, exec, and deprovision report 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's try_lock loop, pause, and Races::lost try 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_contents refuses while launch.json or runtime.json exists, removes the configuration record last, and refuses foreign entries. remove_directory_after and Lingering::wait remove the directory only while the path still names the held identity, and Lingering::wait reports a reprovision by looking up the configuration record by path. LockedStore's Drop removes 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): CallerFiles refuses 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_temporary and is_report_staging match the names that write_private and the tempfile crate 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_directory and the lock, which the CLI arm may also have retried, was reviewed only.
  • python scripts\nvx.py verify wasn't needed for this nvx-rs-only reproduction. The openvmm submodule 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 --timeout as a control operation timeout, validates that it is finite and positive, and passes it to exec_workload as response_timeout: nvx.py timeout option, command_sandbox exec call.
  • exec_workload establishes the authenticated session, then forwards that timeout to ControlSession.exec.
  • ControlSession.connect selects _NamedPipeStream on Windows. The reproduction below completes its real capability-attach/READY exchange.
  • ControlSession.exec calls _send_app before establishing the response deadline.
  • _send_app ultimately calls the transport writer without a deadline.
  • _NamedPipeStream.write_all uses synchronous os.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\nvx
import 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.

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 verify failed with initialized 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 complete scripts tree is byte-identical to the fetched origin/dev revision used in all source permalinks: tree ID 4992e798a691eedaf2efea551cb3cfe804692d0d at 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 --short was 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

Activity

  1. added 11 commits that reference this issue on Oct 10, 2026
    3fd5161
    12745d8
    e771ea8
    acf0617
    b20bb41
    995588b
    8780d61
    2403cb7
    60def5e
    0e878a9
    ed60feb
  2. 7 remaining items

  3. changed the title [-]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[/+] on Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

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