Skip to content

cli: serialize sandbox exec admission across CLI processes (SL3.9) - #1469

Draft
Enrique Saurez (esaurez) wants to merge 1 commit into
feature/nvx-refactorfrom
esaurez/sl39-exec-lock-cd47-20261110
Draft

Enrique Saurez (esaurez) wants to merge 1 commit into
feature/nvx-refactorfrom
esaurez/sl39-exec-lock-cd47-20261110

Conversation

@esaurez

@esaurez Enrique Saurez (esaurez) commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Part of #1111 (SL3.9: one exec runs at a time per sandbox, across CLI processes).

What and why

Concurrent nvx-rs processes could run two sandbox exec workloads in one sandbox at once (#819). This PR does SL3.9's Scope and no more:

  • host::cli::exec_lock (new) owns one OS file lock per sandbox, exec.lock in its state directory. Under the lifecycle lock, after the configuration check, an exec opens it as the store opens its own lock file (no link followed, a private regular file), tries the lock without waiting, and checks that the directory entry still names the locked file. The exec holds it through connect and Session::exec; the lifecycle lock is released before connecting, as before.
  • sandbox exec --wait SECONDS (60 by default): an exec that finds the lock held releases the lifecycle lock, pauses for the store's 25 ms retry interval and tries again — once at least, and only until --wait has passed, even when a retry waited past it for the lifecycle lock (--timeout still bounds that wait) — then fails with sandbox-busy.
  • sandbox stop takes no exec lock, and proceeds while a workload runs.
  • The lock file stays, as sandbox.lock does, so that no release unlinks a file that another exec has open; deprovision removes it, also when a Windows file system without POSIX deletion keeps it delete-pending after the VM exited. It is the exec lock module's own file, not a StateFile, so the store's temporary-name rules and snapshot capture's owned files do not change.
  • Docs: doc/usage.md (--wait, sandbox-busy), doc/run.md (lifecycle rules, sandbox-lifecycle acceptance), nvx-rs/README.md (state files, exec rules, error codes), and ErrorCode::SandboxBusy's rustdoc. The sandbox acceptance (scripts/nvx_tools/sandbox_lifecycle_tests.py) expects exec.lock from a sandbox's first exec until deprovision.

Not in this PR (per #1111): a queue, daemon or fairness; more than one exec per sandbox or a global lock; stop taking the lock; other flags.

Takeover. Enrique Saurez (@esaurez)'s Draft (9 commits on an earlier head of #1460) was replayed onto feature/nvx-refactor, now that #1460 (SL3.5b) has merged, and squashed into one commit that keeps his authorship, with: the deadline recheck and its regression test (review thread), the --wait docs (review thread), the acceptance update, which the earlier commits lacked (the public sandbox scenarios check exact state listings), and exec.lock as the module's own file instead of StateFile::ExecLock, which had made snapshot capture's recovery treat a foreign exec.lock as its own.

Size. 82 core lines (estimate 60, stop 120). 6 counted files: host/cli/exec_lock.rs (new, 39), sandbox/lifecycle/start.rs (25), sandbox/lock.rs (7), sandbox/options.rs (7), cli/mod.rs (1), sandbox/mod.rs (1). Not counted: store/locked.rs, which only makes open_lock crate-private (1 line), store/mod.rs, with the re-export that this forces and one doc line, and error.rs, rustdoc only. BOOT_CONTRACT_VERSION, CONTROL_CONTRACT_REVISION and the public API are unchanged.

Validation

Check Result
cargo fmt --check; Clippy -D warnings: --all-targets --all-features, --lib, --lib --no-default-features, --lib --bins --features cli, --lib --bins --no-default-features --features testing Passed on Windows and on Linux (nvx-kvm-test)
cargo test --all-features --test cli Linux: 100 passed. Windows: the 6 exec_lock cases pass; see the note below on load-sensitive timing tests
cargo test --all-features / cargo test / --lib --no-default-features Linux: every integration target passes; the lib's 28 guest:: failures are the same set at the base on that host (isolated child processes of that environment). Windows: see note
cargo doc with -D warnings, MSRV 1.89 check, macOS check, python3 scripts/public_api.py, runtime adapter protocol-only and host-only fmt, Clippy, tests and MSRV Passed (Linux; doc and MSRV also on Windows)
New regression exec_lock::a_retry_that_waits_for_the_lifecycle_lock_past_the_deadline_is_busy Fails without the deadline recheck (the exec ran and exited 0), passes with it; passed in 6 repeated runs on Windows and 5 on Linux
check_test_layout.py, check_markdown_links.py, Ruff check and format, Pyright (Linux and Windows) Passed
python -m unittest scripts/test_microvm_tests.py -k PublicSandbox -k PublicManagedExec; scripts.test_nvx_tools test_usage_documents_every_option_of_nvx_rs with NVX_RS set Passed
nvx.py test-microvm --scenario managed-exec-config / sandbox-lifecycle / sandbox-proxy-only / sandbox-ram-upper, KVM, nvx-kvm-test (Azure Linux 3, virtual machine) All passed
Same four scenarios, WHP, Windows 11 developer machine All passed

The scenarios used the guest artifacts that #1460's CI built (run 42979403236), OpenVMM 14dc4116 from CI, and nvx-rs built from this commit.

How the deadline regression synchronizes. The race that it checks needs an exec whose retry waits for the lifecycle lock until after its --wait deadline, while the exec lock becomes free. The test holds the exec lock itself and starts an exec with --wait 4. That exec holds the lifecycle lock only while it tries the exec lock, so the test polls the lifecycle lock until it finds one of these tries, and then takes the lock. When it took it within the first half of --wait, the exec's deadline, which lies at least --wait after the spawn, is still at least 2 s away, so the exec sleeps one 25 ms retry interval and tries again, and that try waits for the lifecycle lock: this is the precondition. Such a round then has no way out but its assertions: it holds the lifecycle lock until the deadline has surely passed, fails unless the exec is still waiting, frees the exec lock, releases the lifecycle lock, and requires sandbox-busy with the workload not run. Rounds repeat only to reach the precondition: a round that misses every try, or finds one too late, releases the lifecycle lock and requires the sandbox-busy of an exec that waited out --wait before another starts, and the test fails if 5 rounds never reach it. No outcome is retried.

Note: on the loaded Windows developer machine, a few timing-sensitive tests in modules that this PR does not touch failed under load and passed alone (one_shot::a_ready_console_stream_is_not_throttled_by_idle_sleeps, host::template::clone::tests::restore::the_restore_to_ready_deadline_runs_from_the_launch_through_every_step, host::platform::windows::tests::children_inherit_only_their_standard_handles); host::cli::snapshots::capture::tests::a_capture_ack_and_successful_output_without_timely_exit_are_stopped_but_never_finished fails the same way at the base in the same batch. The new exec_processes_contend_but_other_sandboxes_and_stop_proceed bounds its refused exec and its stop by 30 s, which still rules out the 60-second default and the minute-long workload, since a refused exec took up to 7 s there under load.

Left to CI: MSHV, the guest-binary checks (no guest code changes), the adapter's guest-only configuration, and the remaining scripts/test_*.py modules.

Changes to existing tests (tests-modified)

check_test_changes.py --base origin/feature/nvx-refactor: 16 changes, 33 added tests, none relocated. Every other test of the area is kept.

  • cli/lifecycle: check_lifecycle, lifecycle_of_a_sandbox_whose_upper_is_in_ram, stops_keep_an_outcome_report_that_the_caller_reads_whatever_its_umask, managed_execs_exit_with_the_workload_status — their exact state listings include exec.lock after the sandbox's first exec (with_exec). refusals_of_a_running_sandbox — a refused exec of a stopped sandbox leaves exec.lock, since admission creates it before it finds no VM; the listing after stop is unchanged.
  • cli: a_restarted_cli_drives_only_a_vm_of_its_runtime_identity, a_sandbox_whose_openvmm_exited_reports_it_once_and_starts_again — their exact listings include the exec.lock of their earlier execs.
  • cli/usage: HELP — sandbox exec --help lists --wait WAIT.
  • host::cli::sandbox::lock::tests: removals_refuse_live_records_and_keep_foreign_files — deprovision also removes the owned exec.lock and keeps exec.lock.foreign, .exec.lock.42.tmp, .exec.lock.x.tmp and .exec.lock.42.tmp.more as foreign; removal_cleans_owned_temporaries_without_touching_a_neighbour — exec.lock is among the owned files that removal deletes.
  • scripts/nvx_tools/sandbox_lifecycle_tests.py: LifecycleAcceptance.__init__, invoke, require_entries — the acceptance expects exec.lock in a state directory from an exec of the provisioned sandbox until deprovision removes the configuration, in the lifecycle, proxy-only and RAM-upper scenarios.
  • scripts/test_microvm_tests.py: _FakeSandboxCli._dispatch, _deprovision — the fake CLI creates exec.lock for an exec of a provisioned sandbox and deprovision removes it; test_acceptance_detects_lifecycle_regressions — the new exec-skips-lock fault must fail the acceptance.

@esaurez Enrique Saurez (esaurez) added tests-modified Changes existing tests, test data, or test wiring feat-nvx-refactor labels Nov 11, 2026
@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

This pull request changes existing tests

It modifies or removes 16 existing test asset(s), listed below. Review each with care: a change to a test can hide a regression that the test caught. See doc/contribute.md#changes-to-existing-tests.

Test code

Change What Before After
modified helper fn check_lifecycle in cli/lifecycle (tests) nvx-rs/tests/cli/lifecycle.rs:549 nvx-rs/tests/cli/lifecycle.rs:555
modified helper fn refusals_of_a_running_sandbox in cli/lifecycle (tests) nvx-rs/tests/cli/lifecycle.rs:1121 nvx-rs/tests/cli/lifecycle.rs:1137
modified helper const HELP in cli/usage (tests) nvx-rs/tests/cli/usage.rs:675 nvx-rs/tests/cli/usage.rs:675
modified helper def _FakeSandboxCli._dispatch in scripts/test_microvm_tests.py scripts/test_microvm_tests.py:1006 scripts/test_microvm_tests.py:1006
modified helper def _FakeSandboxCli._deprovision in scripts/test_microvm_tests.py scripts/test_microvm_tests.py:1393 scripts/test_microvm_tests.py:1401
modified test fn removals_refuse_live_records_and_keep_foreign_files in host::cli::sandbox::lock::tests nvx-rs/src/host/cli/sandbox/lock/tests.rs:100 nvx-rs/src/host/cli/sandbox/lock/tests.rs:100
modified test fn removal_cleans_owned_temporaries_without_touching_a_neighbour in host::cli::sandbox::lock::tests nvx-rs/src/host/cli/sandbox/lock/tests.rs:131 nvx-rs/src/host/cli/sandbox/lock/tests.rs:145
modified test fn a_restarted_cli_drives_only_a_vm_of_its_runtime_identity in cli (tests) nvx-rs/tests/cli.rs:834 nvx-rs/tests/cli.rs:837
modified test fn a_sandbox_whose_openvmm_exited_reports_it_once_and_starts_again in cli (tests) nvx-rs/tests/cli.rs:1193 nvx-rs/tests/cli.rs:1196
modified test fn lifecycle_of_a_sandbox_whose_upper_is_in_ram in cli/lifecycle (tests) nvx-rs/tests/cli/lifecycle.rs:636 nvx-rs/tests/cli/lifecycle.rs:647
modified test fn stops_keep_an_outcome_report_that_the_caller_reads_whatever_its_umask in cli/lifecycle (tests) nvx-rs/tests/cli/lifecycle.rs:719 nvx-rs/tests/cli/lifecycle.rs:735
modified test fn managed_execs_exit_with_the_workload_status in cli/lifecycle (tests) nvx-rs/tests/cli/lifecycle.rs:1027 nvx-rs/tests/cli/lifecycle.rs:1043
modified test def PublicSandboxLifecycleTests.test_acceptance_detects_lifecycle_regressions in scripts/test_microvm_tests.py scripts/test_microvm_tests.py:1680 scripts/test_microvm_tests.py:1689

End-to-end tests

Change What Before After
modified helper def LifecycleAcceptance.__init__ in scripts/nvx_tools/sandbox_lifecycle_tests.py scripts/nvx_tools/sandbox_lifecycle_tests.py:411 scripts/nvx_tools/sandbox_lifecycle_tests.py:414
modified helper def LifecycleAcceptance.invoke in scripts/nvx_tools/sandbox_lifecycle_tests.py scripts/nvx_tools/sandbox_lifecycle_tests.py:486 scripts/nvx_tools/sandbox_lifecycle_tests.py:491
modified helper def LifecycleAcceptance.require_entries in scripts/nvx_tools/sandbox_lifecycle_tests.py scripts/nvx_tools/sandbox_lifecycle_tests.py:531 scripts/nvx_tools/sandbox_lifecycle_tests.py:543

Relocated unchanged: 0; added: 33. Compared the merge base 821de193f45f with 120f2b0c773a.

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.

🟡 Changes recommended

A retry can launch a workload after the --wait admission deadline expires, and missing --wait documentation causes the existing usage validation to fail when NVX_RS is set.

2 open issues
What changed in this PR

Implements SL3.9’s cross-process serialization of sandbox exec using a persistent per-sandbox file lock, while allowing Stop to proceed independently.

Changes:

  • Adds exec admission locking and a --wait option with a 60-second default.
  • Integrates the persistent lock into state ownership and deprovision cleanup, including Windows delete-pending handling.
  • Adds admission and cleanup tests and updates existing file-inventory expectations.
File Description
nvx-rs/​tests/​cli/​usage.rs Checks that sandbox help includes --wait.
nvx-rs/​tests/​cli/​lifecycle.rs Updates persistent-lock inventories and exposes a Windows test helper.
nvx-rs/​tests/​cli/​exec_lock.rs Adds cross-process admission, release, Stop, and cleanup tests.
nvx-rs/​tests/​cli.rs Registers admission tests and updates state inventories.
nvx-rs/​src/​host/​cli/​store/​names.rs Registers exec.lock without accepting temporary lookalikes.
nvx-rs/​src/​host/​cli/​store/​mod.rs Documents the lock and re-exports its opening helper.
nvx-rs/​src/​host/​cli/​store/​locked.rs Makes the existing lock-opening helper reusable.
nvx-rs/​src/​host/​cli/​sandbox/​tests.rs Tests --wait defaults and numeric validation.
nvx-rs/​src/​host/​cli/​sandbox/​options.rs Defines, parses, and describes --wait.
nvx-rs/​src/​host/​cli/​sandbox/​mod.rs Passes the admission timeout to managed exec.
nvx-rs/​src/​host/​cli/​sandbox/​lock/​tests.rs Tests owned-lock cleanup and foreign-file preservation.
nvx-rs/​src/​host/​cli/​sandbox/​lock.rs Handles confirmed delete-pending exec locks during cleanup.
nvx-rs/​src/​host/​cli/​sandbox/​lifecycle/​start.rs Adds admission retries and holds the guard through execution.
nvx-rs/​src/​host/​cli/​mod.rs Registers the exec-lock module.
nvx-rs/​src/​host/​cli/​exec_lock/​tests.rs Tests lock identity, privacy, contention, and persistence.
nvx-rs/​src/​host/​cli/​exec_lock.rs Implements the nonblocking OS lock and release guard.

Review effort: Balanced

Comment on lines 284 to +286
let mut lock = LifecycleLock::acquire(&state_dir, response_timeout, false)?;
running_vm(&state_dir, &mut lock, artifacts)?
require_config(&state_dir)?;
if let Some(exec) = ExecLock::try_acquire(lock.locked())? {
Comment thread nvx-rs/src/host/cli/sandbox/options.rs Outdated
--timeout TIMEOUT control operation timeout, and the longest wait for
another lifecycle transition of the sandbox, in
seconds (default: 60)
--wait WAIT wait for another sandbox exec, in seconds (default: 60)
@ppenna
Pedro Henrique Penna (ppenna) force-pushed the esaurez/sl35b-lifecycle-4d48405a-20261110 branch from 369ad2d to 5b7882a Compare November 11, 2026 03:57
@ppenna
Pedro Henrique Penna (ppenna) force-pushed the esaurez/sl39-exec-lock-cd47-20261110 branch from 9715f91 to 9ddd53d Compare November 11, 2026 08:01
@ppenna
Pedro Henrique Penna (ppenna) force-pushed the esaurez/sl35b-lifecycle-4d48405a-20261110 branch from d9c518e to bcaaa60 Compare November 11, 2026 08:02
@ppenna
Pedro Henrique Penna (ppenna) force-pushed the esaurez/sl39-exec-lock-cd47-20261110 branch from 9ddd53d to 9ca2a26 Compare November 11, 2026 08:13
Base automatically changed from esaurez/sl35b-lifecycle-4d48405a-20261110 to feature/nvx-refactor November 11, 2026 08:15
Concurrent nvx-rs processes could run two `sandbox exec` workloads in
one sandbox at once (#819). An exec now takes a per-sandbox OS file
lock, `exec.lock` in the state directory, which the new
`host::cli::exec_lock` module owns, and holds it until its workload
ends. Under the lifecycle lock, after it checks the configuration, it
opens the lock file as the store opens its own, without following
links and refusing one that is not a private regular file, tries the
lock without waiting, and checks that the directory entry still names
the locked file. An exec that finds the lock held releases the
lifecycle lock, pauses for the store's retry interval and tries again:
once at least, and only until `--wait` seconds, 60 by default, have
passed, even when a retry waited past them for the lifecycle lock. It
then fails with `sandbox-busy`. `--timeout` still bounds each wait for
the lifecycle lock, and `stop` takes no exec lock, so it proceeds while
a workload runs.

The lock file stays, as `sandbox.lock` does, so that no release
unlinks a file that another exec may hold open. `deprovision` removes
it with the store's files, also when a file system without POSIX
deletion keeps it delete-pending after the VM exited. It is the exec
lock module's own file rather than one of the store's, so the store's
temporary names and snapshot capture's owned files do not change. The
docs describe `--wait`, the exec lock and the new cause of
`sandbox-busy`, and the sandbox acceptance expects the lock in the
state directory from the first exec on.

Tests: tests/cli/exec_lock.rs runs CLI processes against the fake
OpenVMM: a held lock makes a second exec fail with `sandbox-busy` after
`--wait`, while other sandboxes and `stop` proceed; results, errors and
cancellation release it; a retry that waits for the lifecycle lock past
the deadline is busy; a removed configuration or a replaced lock file
refuses admission; and deprovision waits for a delete-pending lock on
Windows. Unit tests cover the lock's privacy and identity, the `--wait`
default and number rules, and deprovision's removal of the file, and
the exact state listings of existing tests include the lock.

Part of #1111 (SL3.9).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ppenna
Pedro Henrique Penna (ppenna) force-pushed the esaurez/sl39-exec-lock-cd47-20261110 branch from 9ca2a26 to 120f2b0 Compare November 11, 2026 08:28
@ppenna Pedro Henrique Penna (ppenna) changed the title cli: serialize sandbox exec admission (SL3.9) cli: serialize sandbox exec admission across CLI processes (SL3.9) Nov 11, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat-nvx-refactor tests-modified Changes existing tests, test data, or test wiring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants