Repository navigation
nvx-rs: move lifecycle locks and removal into the sandbox store - #754
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved directory-lifecycle races can prematurely fail provisioning or delete an empty replacement directory.
2 open issues
What changed in this PR
This PR centralizes sandbox lifecycle locking and removal in nvx::host::store, allowing the CLI, host::instance, and aci_edge_sandboxes to share the protocol described in #620.
Changes:
- Adds caller-file declarations, lifecycle locks, and removal with lingering-lock handling.
- Migrates callers to the shared store APIs and introduces
ErrorKind::NotFound. - Adds outcome-report staging cleanup, lifecycle regression coverage, and documentation.
| File | Description |
|---|---|
| scripts/test_microvm_tests.py | Updates staging-file cleanup and regression expectations. |
| scripts/nvx_tools/sandbox_lifecycle_tests.py | Requires an outcome report after a refused start. |
| nvx-rs/tests/fake_openvmm.rs | Supplies caller-file declarations to instance APIs. |
| nvx-rs/src/lib.rs | Updates the public instance example. |
| nvx-rs/src/host/store/tests.rs | Tests staging cleanup and missing-directory errors. |
| nvx-rs/src/host/store/mod.rs | Integrates lifecycle APIs and report staging cleanup. |
| nvx-rs/src/host/store/lifecycle/tests.rs | Covers lifecycle locking, removal, and concurrent transitions. |
| nvx-rs/src/host/store/lifecycle.rs | Implements shared locks, release, and removal. |
| nvx-rs/src/host/store/caller.rs | Defines and validates caller-owned file names. |
| nvx-rs/src/host/python/fs.rs | Makes directory identity support available without CLI features. |
| nvx-rs/src/host/instance.rs | Adopts lifecycle locks and exposes instance removal. |
| nvx-rs/src/host/cli/sandbox/lock.rs | Wraps store lifecycle APIs while preserving CLI messages. |
| nvx-rs/src/host/cli/sandbox/lifecycle.rs | Delegates deprovisioning to the store. |
| nvx-rs/src/error.rs | Adds the missing-state error kind and constructor. |
| nvx-rs/README.md | Documents shared lifecycle APIs and caller responsibilities. |
| doc/run.md | Explains lock release and outcome-report cleanup. |
| aci_edge_sandboxes/src/openvmm/state.rs | Delegates removal and stale-directory cleanup to the store. |
| aci_edge_sandboxes/README.md | Documents foreign-file handling during deprovisioning. |
Review effort: Balanced
dcb74ea to
dde4ecc
Compare
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
3 open issues
1 resolved since last review
Review effort: Lite
a73868d to
55a3e8f
Compare
2f9729c to
83f9d76
Compare
55a3e8f to
d73f87d
Compare
83f9d76 to
4dfde35
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Cross-process locking and Windows/NFS deletion semantics need final human review and platform CI confirmation, with an unresolved test race also requiring attention.
1 open issue
1 resolved since last review
Review effort: Balanced
d73f87d to
3e5a32d
Compare
|
Copilot's review of 785392d reported a previously missed issue in Fixed in 780e20e: both accept |
Each orchestrator of a sandbox kept its configuration record beside the store, with its own lock and removal protocol around the store's lock. The store now owns that protocol, so that every orchestrator shares it: - CallerFiles declares the caller's configuration record, whose presence means that the sandbox is provisioned, and its other files there. - SandboxStore::lock_for_provision creates the directory, again if a concurrent removal deletes it, and SandboxStore::lock_existing fails with the new ErrorKind::NotFound once the directory is gone. - A holder that leaves the directory without the configuration record removes its own lock file as it releases the lock. - LockedStore::remove refuses while a VM or a launch is recorded, removes the store's files and the caller's, the configuration record last, and refuses and keeps foreign entries. Otherwise it removes its lock file while it still holds the lock, and then the directory, or returns a Lingering removal while lock files of other transitions keep the emptied directory. RemovalError names each failure. SandboxStore::create and open fail with ErrorKind::NotFound rather than ErrorKind::Io for a missing parent or directory. On Windows, a lock file that a file system without POSIX deletion keeps after its removal is taken for a replaced one rather than failing the lock. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bba527e3-2680-407f-ba91-a21eeb2ea18a
The CLI's lifecycle lock is now a thin wrapper over the store's lifecycle locks, with config.json as the configuration record, and deprovision removes the sandbox through LockedStore::remove. The CLI's messages, and its goldens, are unchanged. Edge cases change: - The release removes the lock file only while it is still the holder's own, and only when config.json is definitely missing. - On Windows file systems without POSIX deletion, a transition other than provision that meets a removed lock file waits for it within its timeout rather than failing with access denied. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bba527e3-2680-407f-ba91-a21eeb2ea18a
Instance::create and Instance::open take the files that the caller keeps beside the instance, Instance::lock takes the store's lifecycle lock of an existing sandbox, and the new LockedInstance::remove removes a stopped instance, with the caller's files and its directory, through the store. aci_edge_sandboxes declares sandbox.json as its configuration record, and removes sandboxes and the directories of stale IDs through the store, which replaces its own removal: - A deprovision that finds files that are not the backend's removes the sandbox's own files and its record first, as the CLI's does: the ID is stale from then on, and the directory keeps only those files. - Deprovision waits for lingering lock files without a deadline, as its lock does. - The directory of a stale ID goes with the store's leftovers, such as the temporary file of an interrupted replacement; a directory that is not private to the user is left alone. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bba527e3-2680-407f-ba91-a21eeb2ea18a
A VMM that is killed while it writes its outcome report leaves the file in which it stages it, `.tmp` and six ASCII letters or digits, as the tempfile crate names it, which deprovision refused as a foreign file. The store now owns files of exactly that form. It removes them once no process of their launch runs: when a recovery or an abandoned launch ends it, unless that is indeterminate, in clear_runtime, when the next launch begins, and at removal. It keeps them while a process of the launch may still run. Other `.tmp*` names stay foreign, and callers cannot declare names of that form. The sandbox lifecycle acceptance now requires the outcome report of a refused managed start, which a leftover staging file no longer reveals. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bba527e3-2680-407f-ba91-a21eeb2ea18a
Three races in the store's lifecycle protocol: - A removal removed the directory by its path before it checked that the path still named the directory that it held, so an empty directory that replaced it, once another removal finished this one, could go. Each attempt to remove the directory now checks the path's identity first, in LockedStore::remove and in Lingering::wait, and the removal holds the directory from before it removes its lock file. - lock_for_provision failed with NotFound when a concurrent removal deleted the directory after it was created and before it was checked. It now creates the directory again while its parent exists, at once the first time and then every 25 ms until the deadline. - Without a configuration record, a transition that waited through a removal could not tell that the sandbox was gone, and kept the lock file that it created, which a lingering removal then waited for forever. A removal now needs the caller's configuration record, and a lock taken without one refuses it with RemovalError::Undeclared. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bba527e3-2680-407f-ba91-a21eeb2ea18a
A caller's file name now has only ASCII letters and digits, `.`, `_`, and `-`, and names are compared by ASCII case. File systems compare other names by rules of their own: they fold the case of other letters, macOS's take a composed letter for its decomposed form, and Windows also opens a file by its short name, such as `RUNTIM~1.JSO` for `runtime.json`, so a declared name could open one of the store's files, or another declared one. A removal no longer refuses a `.nfs*` entry that it finds as it empties the directory. NFS keeps a removed file that a handle still holds under such a name until the handle closes, so the removal leaves it, as it leaves lock files, and waits for it. A removal whose emptied directory another removal deletes after it removed its lock file now succeeds, as `Lingering::wait` does, rather than failing because the directory is not found. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bba527e3-2680-407f-ba91-a21eeb2ea18a
On Windows, a lifecycle lock retried every denied open of the lock file, which a file system without POSIX deletion gives for a removed lock file that other handles still hold. A lock file that refuses access for good, such as a read-only one or a directory, gives the same error, so a lock without a deadline waited for it forever. Windows refuses every open of a removed file, also one without access rights, which opens the others, so the lock now probes the path that way. It waits for a removed lock file, and fails with the denied access once a second open at once is refused too, as one that went and was replaced meanwhile opens then. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bba527e3-2680-407f-ba91-a21eeb2ea18a
The removal of the files in which a VMM staged its outcome report failed with an error of kind `Io` when it could not list the sandbox's directory. A directory that is gone now fails as `NotFound`, as `SandboxStore::open` reports it. The validation's fake CLI ignores a staging file that is gone by the time that it removes it, as the store does. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bba527e3-2680-407f-ba91-a21eeb2ea18a
A lock whose lock file another transition removed or replaced after the lock opened it took the lock again at once, every time. Since transitions remove their lock files as they release their locks on directories without a sandbox, transitions that kept doing so could keep a waiter past its deadline, and the CLI from checking for its timeout and for Ctrl-C. The locks now take the lock again at once the first time, and after a pause, until the deadline, when that happens again right after it, as a provision already did for a directory that keeps disappearing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bba527e3-2680-407f-ba91-a21eeb2ea18a
A lingering removal told a provision that took the lock after it from a foreign file by looking for the configuration record's name among the directory's entries. A file system that ignores case keeps the name that the provision gave the record, which can differ in case from the removal's, so the removal took the record for a foreign file. It now looks the record up by its path, as the release of a lock does, which such a file system resolves, and one that heeds case keeps apart from other names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bba527e3-2680-407f-ba91-a21eeb2ea18a
A process that a concurrent test spawns holds the test's descriptors from its fork until it executes its program, and with them the claim of the log that an abandoned launch closed, for longer than the claim release wait if the machine is loaded. The abandon then ends as indeterminate and keeps the launch's staging file. Accept that in the staging test, check that the file stays, and wait for the next recovery to end the launch with released(), as the test already does for the recovery before it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bba527e3-2680-407f-ba91-a21eeb2ea18a
An interrupted write_private or write_json leaves .NAME.NUMBER.tmp beside the file. The store refuses such files of a caller's records, which are neither its own nor the files that the caller declares, so aci_edge_sandboxes, whose removal removed every .*.tmp before it moved onto the store, kept the one that a provision that died while it wrote sandbox.json leaves: a later call with the stale ID left it and the directory behind, and a deprovision would refuse it. Add host::instance::remove_interrupted_writes, which removes exactly the names that write_private gives the temporary files of a path, and call it under the lock before both removals of aci_edge_sandboxes: the cleanup of a stale ID and deprovision. Other .*.tmp names stay foreign. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bba527e3-2680-407f-ba91-a21eeb2ea18a
A transition that opens the lock file after a removal removed it creates a new one, takes its lock, finds no sandbox, and removes its lock file as it releases it. If it does all that before the removal removes the directory, the removal finds the directory empty and returns no lingering removal, while the transition reports no sandbox rather than the directory gone. The two tests of transitions that wait through a removal accepted that outcome only when the removal lingered. Accept it either way, and keep the checks that no transition finds the sandbox provisioned, and that the directory goes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bba527e3-2680-407f-ba91-a21eeb2ea18a
Where a removed file stays while a handle holds it, as NFS keeps it under a .nfs name and a Windows file system without POSIX deletion under its own, a provision that waits for the lock of the removed lock file keeps the directory with its handle, and the removal lingers. Once the provision finds the lock file replaced and closes that handle, the directory empties, and the lingering removal can remove it before the provision creates it again, which ends the wait with Ok(()) rather than ProvisionedAgain. The two tests of provisions that race a removal accepted only ProvisionedAgain from a lingering removal. Accept both, and keep the checks of the provision's outcome and of the directory that it leaves. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bba527e3-2680-407f-ba91-a21eeb2ea18a
780e20e to
97827c9
Compare



Part of #620. Refs #535.
Every orchestrator of a sandbox keeps its configuration beside the sandbox store, and needs provisions, other transitions, and removals of the sandbox to keep the store's directory consistent, from any process. The CLI did that with a private lifecycle-lock and removal protocol around the store's lock. This PR moves that protocol into
nvx::host::store, so the CLI,host::instance,aci_edge_sandboxes, and any other orchestrator share one implementation. It also makes the store own the file in which the VMM stages its outcome report, which deprovision used to refuse.The store
CallerFilesdeclares the caller's configuration record, whose presence means that the sandbox is provisioned, and its other files in the directory:CallerFiles::none(),CallerFiles::new(config),.with(name). Each name is one plain file name in the directory, not.or.., of only ASCII letters and digits,.,_, and-, so that it names the same file on every host and no file system takes it for another name: file systems fold the case of other letters by rules of their own, macOS's take a composed letter for its decomposed form, and Windows also opens a file by its short name, such asRUNTIM~1.JSOforruntime.json. Ignoring ASCII case, it collides with none of the store'sFILE_NAMES(the lock included), neither temporary form that the store owns,.nfs*, or another declared name. Declarations are checked when they are built, before any store is created, opened, locked, or removed with them.SandboxStore::lock_for_provision(dir, files, deadline)creates the directory if needed, and again if a concurrent removal deletes it, even while it is created and checked.SandboxStore::lock_existing(dir, files, deadline)fails with the newErrorKind::NotFoundwhen the directory is gone, also after it waited through a concurrent removal; the caller checks its configuration record under the lock. Both retry every 25 ms until the caller's deadline, including, on Windows file systems without POSIX deletion, the open of a removed lock file that other handles still hold. A lock file that another transition removes or replaces after an attempt opened it is locked again at once, and then every 25 ms until the deadline, asSandboxStore::locknow does too. A lock file that Windows refuses for good, such as a read-only one, which unlike a removed one opens without access rights, fails the lock with the denied access, also without a deadline.LockedStore::remove()needs the caller's configuration record: a lock taken without one, withCallerFiles::none()or the store's ownSandboxStore::lock, refuses withRemovalError::Undeclared, since a transition that waits through the removal could not otherwise tell that the sandbox is gone. It refuses while a runtime record or a launch journal exists (RemovalError::Launched): callers reattach or recover first. It removes the store's files, the caller's files with the configuration record last, and the temporary files that the store owns. It then refuses foreign entries, naming them, and keeps the directory (RemovalError::Foreign), or removes its lock file while it still holds the lock, and then the directory. Lock files and.nfs*names are not foreign, since open files can keep the emptied directory for a while: NFS keeps a removed file that a handle still holds, such as a lock file, as.nfs*, Windows file systems without POSIX deletion keep a removed lock file under its name, and a transition that opens the lock file after it was removed creates a new one. The removal then releases the lock and returns aLingeringremoval that holds the directory's identity.Lingering::wait(deadline)removes the directory once it empties, returns once the path names another directory, and reportsRemovalError::ProvisionedAgainif a provision took the lock meanwhile, which it tells by looking the configuration record up by its path, so that a file system that ignores case finds it whatever the case of the name that the provision gave it. Each attempt to remove the directory first checks that the path still names the one that the removal holds, and a removal whose directory another removal deleted succeeds.outcome.jsonin atempfilenamed.tmpand six ASCII letters or digits, which a VMM killed while it writes the report leaves. The store owns names of exactly that form. It removes them once no process of the launch runs: when a recovery or an abandoned launch ends the launch (not when that is indeterminate), inclear_runtime, when the next launch begins, and at removal. It keeps them while a process of the launch may still run. Other.tmp*names stay foreign.SandboxStore::createandSandboxStore::openreport a missing parent or directory asErrorKind::NotFoundrather thanErrorKind::Io, and so does the removal of staging files when the directory is gone. The store's format stays 4.Callers
LifecycleLockis a thin wrapper over the store's locks, withconfig.jsonas the configuration record. It keeps the Ctrl-C handling and the Python-compatible messages.deprovisionfinds the VM as before, then removes throughLockedStore::remove.provision's already-provisioned check and the path checks ofprepare_state_directorystay in the CLI. The goldens innvx-rs/tests/fixtures/cli.jsonare unchanged.host::instance.Instance::createandInstance::opentake the caller'sCallerFiles,Instance::locktakes the lifecycle lock of an existing sandbox, and the newLockedInstance::remove(deadline)removes a stopped instance, with the caller's files and its directory, through the store. The newremove_interrupted_writes(path)removes the temporary files that interrupted writes of a caller's file withwrite_privateorwrite_jsonleft, exactly the names thatwrite_privategives them, which the store refuses, as neither its own nor the caller's declared files.aci_edge_sandboxes. It declaressandbox.jsonas its configuration record, and replaces its own removal and stale-directory cleanup with the store's, after it removes the temporary files of interrupted writes ofsandbox.jsonitself, under the lock.Behavior differences
No golden or CLI message changes. In edge cases:
provisionthat meets a removed lock file now waits for it within its timeout, where it failed with access denied. A lock file that Windows refuses for good, such as a read-only one, still fails such a transition at once with access denied. A waiter for which Windows refuses to identify a removed lock file takes the lock again rather than failing.fstatfailure on the held state directory duringdeprovisionnow names the state directory..tmpXXXXXXis removed once OpenVMM has exited, and atdeprovision. The sandbox lifecycle acceptance now requires the refused managed start'soutcome.json, rather than relying ondeprovisionto refuse the staging file.test_acceptance_detects_a_leaked_report_staging_filebecame thefailed-start-kills-reportercase oftest_acceptance_detects_lifecycle_regressions.aci_edge_sandboxes:sandbox.jsonfirst, as the CLI's does, so the ID is stale from then on and the directory keeps only those files;.*.tmpfiles that are the sandbox's, the store's and those of interrupted writes ofsandbox.json, and now refuses other.*.tmpnames, where it removed them all;sandbox.jsonleft, and leaves alone a directory that is not private to the user..nfs*entry, which NFS leaves for a removed file that a handle still holds, now waits for it within its timeout, where it refused it as a file not owned by NVX.API
nvx::host::store::{CallerFiles, Lingering, RemovalError}SandboxStore::lock_for_provision,SandboxStore::lock_existing,LockedStore::files,LockedStore::remove,Lingering::{dir, wait}Instance::create(dir, name, files),Instance::open(dir, name, files),Instance::files,LockedInstance::remove,nvx::host::instance::remove_interrupted_writesErrorKind::NotFound,Error::not_foundValidation
At this branch's head, on
dev's9b70ec97, the merge of #721:nvx-rs, withNVX_BUNDLE=skip:cargo fmt --all --check;cargo clippy --locked --all-targets --all-features -- -D warningsandcargo clippy --locked --lib --no-default-features -- -D warnings;cargo test --locked --all-features, thencargo test --locked, with the CLI goldens unchanged;cargo doc --locked --no-deps --all-featureswithRUSTDOCFLAGS=-D warnings;cargo +1.89 check --locked --all-targets --all-features.aci_edge_sandboxes: the same commands with--workspace, andpython scripts/nvx.py test-agent-bundling --toolchain 1.95.0.tests/runtime-adapter: the protocol-only and host-only configurations, as check-nvx-rs runs them.ruff checkandruff format --checkofscripts .github/specula,pyright --pythonplatform Linuxand--pythonplatform Windows,scripts/check_markdown_links.py,compileall, validate-nvx'sunittestlist,scripts/test_adversarial.py,test_hosts.py, and thenvx.py --helpchecks. All pass buttest_setup_azure, which fails here with 18 failures, identically ondevwithout this PR.nvx-rsandaci_edge_sandboxescommands, plus check-nvx-rs's artifact-bundling test and both macOScargo check --target aarch64-apple-darwinruns; all three runtime-adapter configurations, the guest one forx86_64-unknown-linux-musl;test-agent-bundling;compileall, andpython3 -m unittest scripts/test_microvm_tests.py scripts/test_nvx_tools.py scripts/test_performance.py. Before the rebases onto nvx-rs: move host::instance onto the store and supervisor #721's later heads anddev, they also passed in WSL 2 (Ubuntu).nvx-rsandaci_edge_sandboxeson Windows.v0.1.0-dev.d2fff11705bc, the newest release underdev, built from the same guest, kernel and OpenVMM inputs as9b70ec97, whose OpenVMM provenance names the gitlink7ff31ed7, and this branch'snvx-rs:python scripts/nvx.py test-microvm --backend whp --scenario sandbox-lifecycle --scenario managed-exec-config --scenario managed-lifecycle, andpython scripts/nvx.py test-aci-edge-sandboxes --backend whp(18 passed)./dev/mshvis root-only there): the same scenarios pass.python3 scripts/nvx.py test-aci-edge-sandboxes --backend mshvpasses 13 of 18: as root, the five read-write mapping tests fail, since the workload cannot write to the directories that root creates on the host, exactly as atdev's9b70ec97without this PR, where the scenarios pass too. This VM cannot present its CPU's built-in profile (E_PROFILE_UNSUPPORTED), so OpenVMM ran with--cpu-profile hostthrough a temporary wrapper.dev, with the artifacts ofv0.1.0-dev.e714acbd47bfand the gitlink9f04d05c: the same scenarios, andtest-aci-edge-sandboxes --backend kvm(18 passed), with OpenVMM run through the same wrapper.Left to CI: the KVM, MSHV and WHP matrix, the guest-binary steps, shellcheck, and the Specula wiring tests.