Repository navigation
nvx-rs: add caller-owned guest cgroup and child-status APIs - #655
Merged
Pedro Henrique Penna (ppenna) merged 4 commits intoOct 21, 2026
Merged
Conversation
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Child-status handling, launch-failure reporting, and privileged-test reliability need correction.
4 open issues
What changed in this PR
Advances the guest-runtime library extraction in #619 with caller-owned cgroups and child-status collection, building on #629’s spawn API.
Changes:
- Adds public cgroup and named-child status APIs.
- Moves managed-agent launch and cleanup onto these libraries.
- Adds lifecycle tests and documents the public interfaces.
| File | Description |
|---|---|
| nvx-rs/tests/managed_lifecycle.rs | Tests detached-descendant cleanup. |
| nvx-rs/src/lib.rs | Documents guest libraries. |
| nvx-rs/src/guest/sys/spawn.rs | Implements prepared fork/exec operations. |
| nvx-rs/src/guest/sys/process.rs | Adds named waits and pidfd support. |
| nvx-rs/src/guest/sys/mod.rs | Exposes internal syscall helpers. |
| nvx-rs/src/guest/sys/fd.rs | Supports descriptor ownership transfer. |
| nvx-rs/src/guest/spawn.rs | Provides the public spawn API. |
| nvx-rs/src/guest/process.rs | Adds caller-owned child-status handles. |
| nvx-rs/src/guest/mod.rs | Exports guest libraries. |
| nvx-rs/src/guest/managed_agent/seam.rs | Adapts launch seams and test helpers. |
| nvx-rs/src/guest/managed_agent/mod.rs | Documents library integration. |
| nvx-rs/src/guest/managed_agent/loopback.rs | Exercises real launch operations. |
| nvx-rs/src/guest/managed_agent/launch.rs | Builds workload launch commands. |
| nvx-rs/src/guest/managed_agent/exec.rs | Uses child handles and launch reports. |
| nvx-rs/src/guest/managed_agent/dispatch.rs | Adds launch-failure and directory tests. |
| nvx-rs/src/guest/managed_agent/cgroup.rs | Adapts cleanup to public cgroup handles. |
| nvx-rs/src/guest/cgroup.rs | Adds cgroup operations and tests. |
| nvx-rs/README.md | Documents the new APIs. |
| doc/project-structure.md | Updates the guest-library description. |
Review effort: Balanced
Enrique Saurez (esaurez)
force-pushed
the
esaurez/a1-guest-g1-spawn-20261019
branch
from
October 20, 2026 20:10
f11fac8 to
4d25959
Compare
Enrique Saurez (esaurez)
force-pushed
the
esaurez/a1-guest-g2-cgroups-20261020
branch
2 times, most recently
from
October 20, 2026 20:43
5e75304 to
11f9f53
Compare
Enrique Saurez (esaurez)
force-pushed
the
esaurez/a1-guest-g1-spawn-20261019
branch
from
October 20, 2026 20:45
4d25959 to
4d90885
Compare
Enrique Saurez (esaurez)
force-pushed
the
esaurez/a1-guest-g2-cgroups-20261020
branch
from
October 20, 2026 20:59
11f9f53 to
0bf080c
Compare
Enrique Saurez (esaurez)
force-pushed
the
esaurez/a1-guest-g2-cgroups-20261020
branch
from
October 20, 2026 21:13
0bf080c to
d55fa44
Compare
Enrique Saurez (esaurez)
force-pushed
the
esaurez/a1-guest-g2-cgroups-20261020
branch
from
October 20, 2026 22:21
d55fa44 to
a2db0aa
Compare
Enrique Saurez (esaurez)
force-pushed
the
esaurez/a1-guest-g2-cgroups-20261020
branch
from
October 20, 2026 22:50
a2db0aa to
7fbf87c
Compare
Enrique Saurez (esaurez)
marked this pull request as ready for review
October 20, 2026 22:55
Add two guest libraries on which any guest init or agent builds, without NVX's control protocol, PID 1, or an async runtime. `nvx::guest::cgroup` takes the agent's cgroup and the workload cgroup from the caller, as a `Layout`, instead of the fixed `nvx-exec`, and refuses a workload cgroup that holds the agent's. An `ExecutionCgroup` is the cgroup of one execution: the workload cgroup itself, or a child cgroup of it, which `prepare` creates exclusively, with the resource values that the caller chooses. Through it, the caller places a process, at spawn or afterwards, checks occupancy through `pids.current`, kills with `cgroup.kill`, and settles, over the execution's cgroup or the whole workload subtree, as the caller's `Scope` chooses. Settling calls the caller's status collector between its checks and collects no status itself, `remove` refuses an occupied cgroup, and a `prepare` that fails removes the execution cgroup that it created. `nvx::guest::process` gives each child one status collector: the caller. A `Child` waits only for the child that it names, with `waitid` on its pidfd or on its process ID, so every other child's status stays for its collector. A caller that collects every status itself, such as a PID 1 that adopts orphans, hands each one to its `Child` with `observe`. Statuses are `std::process::ExitStatus`, real-time signals included. Only an exit or a terminating signal ends a child: a stop doesn't, not even one that a wait reports to the child's tracer. The managed agent becomes a caller. The direct agent keeps its own `waitpid(-1)` loop, as PID 1, and hands the workload's status to the workload's `Child`; it prepares, kills, and settles `nvx-exec` through an `ExecutionCgroup`, over the whole subtree, as before. The sandbox agent places each launcher in `container` through the same handles. Admission, mode-specific cleanup, diagnostics, and the agent's test fixtures are unchanged, except that a direct workload that makes the agent its tracer and stops no longer counts as ended. A test on a real cgroup v2 hierarchy, which runs as root, shows that a descendant that leaves the workload's session and holds its output pipe is found, killed, and settled while a sibling execution keeps running, and another shows that the cgroup operations leave an unrelated child's status to its collector. The managed lifecycle test gains a workload whose detached descendant holds its output: it ends at once, and the next workload runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4f1cb1a1-0d06-46b2-a35a-54af8eb9583c
Enrique Saurez (esaurez)
force-pushed
the
esaurez/a1-guest-g2-cgroups-20261020
branch
from
October 21, 2026 00:13
7fbf87c to
450dc94
Compare
Pedro Henrique Penna (ppenna)
approved these changes
Oct 21, 2026
An `ExecutionCgroup` now holds the directory of its cgroup open, and the workload cgroup's for an execution with a cgroup of its own, and each operation opens the interface files that it uses in them. A handle that the caller keeps after it removed the cgroup and prepared another at the same path so reaches nothing: its kill, occupancy check and placements fail with `ENOENT`, and its removal finds the cgroup gone, leaving the new one alone. Opening a handle now needs the cgroup to exist. Clones share the directories, and dropping the last one closes them, without killing or removing the cgroup. The managed agent no longer drops a failure to collect the status of a workload that it ended: it reports the `exec-wait` diagnostic, and a request whose workload it ended for a cause ends with that cause instead of waiting for the status forever. Its collection of orphans, which cannot fail, no longer returns a result. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4f1cb1a1-0d06-46b2-a35a-54af8eb9583c
ExecutionCgroup::remove checked its held cgroup, then removed the cgroup at its path. Between the two, a clone of the handle could remove the cgroup, and the caller could prepare another at the same path, which the delayed removal then deleted. The process's removals of execution cgroups now take turns, through one process-wide lock, with its preparations of executions' own cgroups. In its turn, a removal checks that its cgroup is still there before it removes the cgroup at its path, which is that cgroup, as cgroup v2 never renames one. A preparation holds its turn from the creation of its cgroup until it returns, so that its handle holds the cgroup that it created, and a failure removes that cgroup alone. An execution that runs in the workload cgroup itself, as the direct agent's does, takes no turn. Two tests on a real cgroup v2 hierarchy pause a removal after its check, and a preparation once it created its cgroup, while another handle removes the cgroup and prepares another at its path, which stays. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4f1cb1a1-0d06-46b2-a35a-54af8eb9583c
ExecutionCgroup::remove refuses a cgroup that a process, a zombie included, occupies when it checks. The kernel removes a cgroup that only zombies occupy, so a process that the caller placed in the cgroup after that check, and that ended before the removal, would be a zombie that the removal hides. Its documentation now says that the caller places no process in a cgroup that it removes meanwhile, with `place` or with a spawned child whose launch has yet to report. No lock of the process covers a child's placement, which the child makes itself after `fork`, unless it is held across every spawn. The test of a failed preparation's rollback could fail when its racer started late: the preparation went on after half a second, and removed its cgroup before the racer had opened it. A racer's setup, such as that open, now takes place within the pause, which waits for it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4f1cb1a1-0d06-46b2-a35a-54af8eb9583c
This was referenced Oct 21, 2026
Pedro Henrique Penna (ppenna)
deleted the
esaurez/a1-guest-g2-cgroups-20261020
branch
October 21, 2026 12:55
This was referenced Oct 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Part of #619
Refs #535
Step G2 of #619: any guest init or agent can place, account, kill and settle its executions in cgroups that it names, and collect exactly its own children's statuses, without NVX's control protocol, its PID 1, or an async runtime. NVX's managed agent becomes one caller.
What changed
nvx::guest::cgroupLayout::new(agent, workload): the caller names its own cgroup and the workload cgroup, instead of the fixednvx-exec. A relative path, a..component, or a workload cgroup that is or holds the agent's isEINVAL, so ending a workload never ends the agent.ExecutionCgroup: the handle of one execution's cgroup, either the workload cgroup itself (open(&layout, None)), for a caller that runs one execution at a time, or a child cgroup of it (Some(name)).ENOENT, and itsremovefinds the cgroup gone. The new cgroup is left alone.opentherefore needs the cgroup to exist.kill,settle(with its status collector) andremove, whose failures it sees.prepare(&layout, name, resources)creates the workload cgroup if needed, and an execution's own cgroup exclusively (EEXISTif it exists). It enablespidsand each resource's controller with onecgroup.subtree_controlwrite per level, then writes the caller's resource values, such as("pids.max", "64"), an empty one as a newline, so that the kernel applies or refuses it. A resource file that is not a controller's interface file is refused before anything changes.placement()returns G1'sspawn::Cgroupfor the held directory, which a spawned child joins before its program runs;place(id)moves a running process.is_occupied(scope)readspids.current, which counts zombies too;kill(scope)writescgroup.kill; andsettle(scope, timeout, reap)waits until the scope is empty, calling the caller's status collector between its checks. It never collects a status itself.Scope::Executioncovers the execution's cgroup andScope::Workloadthe whole workload subtree, which is how the direct mode settles today.remove()refuses an occupied cgroup (EBUSY), so that a removal never hides a status that the caller has yet to collect. Apreparethat fails once it created an execution's own cgroup removes that cgroup again, so its name can be prepared anew.placeor with a spawned child whose launch has yet to report. The kernel removes a cgroup that only zombies occupy, so a process that joined after the check and ended before the removal would escape it. No in-process lock can cover a child's placement, which the child makes itself afterfork, without being held across every spawn, so this is the caller's part of the contract.nvx::guest::process:Childgives each child one status collector, the caller.Child::with_pidfd(id)waits withwaitid(P_PIDFD)and lends its pidfd for polling.Child::new(id)waits withwaitid(P_PID). Neither waits for an arbitrary child.waitpid(-1)loop and hands each status to itsChildwithobserve(id, status).std::process::ExitStatus, real-time signals included. The waits use libc'swaitid, becausenixrejects real-time signals when it decodes a status.waitidreports to the caller as the child's tracer, whatever the options, or one that a collector withWUNTRACEDhands toobserve.nvx-exec. It prepares, kills and settlesnvx-execthrough anExecutionCgroupoverScope::Workload, within the same 2 s, with itswaitpid(-1)loop as the collector. Theexec-cgroup-killdiagnostic stays.process::Child. The agent's loop hands it over withobserve, and the launch-failure and termination paths wait for that child alone.exec-waitdiagnostic. Once the agent has ended the workload for a cancellation, a timeout, the output cap or a lost session, the outcome is that cause, so such a failure ends the loop, which used to wait for the status forever.containerthrough the same handles, and still ends only the workload's process group.cgroup,dispatchandloopbacktests pass as they are.sys::open_at, andspawn::Cgroup::open_in, crate-private, which openscgroup.procsin a held directory.doc/project-structure.mddescribe the two libraries.Behavior
Two edge cases change in the agent:
PTRACE_TRACEME, and then stops, was reported as ended with status 125 (failed) as soon as it stopped. Now its stop doesn't end it: like any other stopped workload, it runs until it exits, its timeout, a cancellation or a lost session ends it.SIGCHLDis ignored, the request now ends with that cause and theexec-waitdiagnostic. It used to stay unanswered.Everything else that the agent reports is unchanged.
Tests
guest::process: named waits, by process ID and by pidfd, for an exit,SIGTERMand real-time signal 40, leave an unrelated child's status in place. Also pidfd readiness,observe, which ignores stops and continuations,ECHILDthrough a pidfd after another collector took the status, IDs that no process can have, and a traced child whose stop ends neither a non-blocking nor a blocking wait.guest::cgroup, on fake hierarchies: layouts and names, andopenof a missing cgroup (ENOENT) or a file (ENOTDIR); combined controller enabling; refused resources that change nothing; exclusive named executions; a failed preparation that leaves no cgroup behind; an empty resource value that still reaches its file; scoped occupancy and kill; settling (collector calls, a failing collector,ETIMEDOUT,EPROTO); placement; removal; andcgroup_operations_leave_an_unrelated_child_s_status.a_handle_reaches_its_own_cgroups_alone_once_another_takes_a_path: once its cgroup is replaced at the same path, an old handle and its clone fail to kill, check, place and make placements, and their removal succeeds. The new cgroup's files stay untouched, and the old handle's workload scope still works.guest::cgroup, on a real cgroup v2 hierarchy, as root where the hierarchy lets the test create a cgroup (it returns early otherwise): indescendants_that_hold_pipes_are_found_and_settled, a descendant that leaves the session withsetsidand holds the execution's output pipe is found, killed and settled while a sibling execution keeps running; settling the workload scope then ends the sibling too. Apids.maxvalue that the kernel refuses, an empty one included, leaves no cgroup behind. Once the first execution's cgroup is removed and another is prepared at its path, its old handle's kill and occupancy check fail withENOENT, and its removal leaves the new cgroup.a_removal_leaves_a_cgroup_prepared_at_its_path_by_a_concurrent_onepauses one clone's removal after its check, while another clone removes the cgroup and prepares another at its path.a_failed_preparation_removes_its_own_cgroup_alonepauses a preparation once it created its cgroup, while another handle opens that cgroup, removes it and prepares another at its path, and then fails the preparation with a value that the kernel refuses. In both, the cgroup prepared at the path stays. Each racer's setup, such as that open, takes place within the pause, which waits for it however late the racer's thread starts.managed_agent::dispatch:a_workload_status_that_the_agent_cannot_collect_still_ends_its_requestignoresSIGCHLDin a process of its own, so that the kernel collects the workload's status. A workload with a 100 ms timeout still ends withEXIT 124 timeoutand theexec-waitdiagnostic, where the agent used to hang.tests/managed_lifecycle.rs: a workload whose detachedsetsid sleep 30descendant holds its output ends at once with its own status, and the next workload runs.removemiss a removed cgroup; let a removal or a preparation skip its turn, or a removal take its turn only after its check; keep the agent's loop waiting for a lost status; or drop its diagnostic.Sizes
I built the guest binaries and the Alpine initramfs natively on Linux, the same way at this PR's base,
devat b47a9f8, and with this change. The binaries use theguestprofile, which strips them.nvx-managed-agentinitramfs.cpio.gz(Alpine)The other guest binaries keep their sizes.
Adapter contract
Yes, additively: the guest configuration gains two public modules,
nvx::guest::cgroupandnvx::guest::process. No existing item, persisted format or wire format changes. The one behavior change, for a direct workload that makes the agent its tracer and stops, is described above, and needs no migration. The runtime adapter builds unchanged in every configuration (C0). The public-API check (#630) isn't ondevyet: if it lands first, this PR adds the snapshot update.Validation
python scripts\nvx.py test-microvm --backend whp, the default suite, then--scenario managed-exec-config --scenario sandbox-lifecycle, with the Alpine initramfs built from this change and the pinned OpenVMM and kernel. Every scenario passes, includingmanaged-lifecyclewith its new case.nvx-rswithNVX_BUNDLE=skip:cargo fmt --all --checkcargo clippy --locked --all-targets --all-features -- -D warningscargo clippy --locked --lib --no-default-features -- -D warningscargo test --locked --all-features, thencargo test --lockedbundledtestcargo doc --locked --no-deps --all-features, withRUSTDOCFLAGS=-D warningscargo +1.89 check --locked --all-targets --all-featurescargo check --locked --all-targets --all-features --target aarch64-apple-darwinpython3 scripts/nvx.py check-guest-binaries --toolchain 1.95.0build-guest-binaries, thentest-guest-binariesnvx-rstests both as root, which runs the real-cgroup test, and unprivileged, as CI runs themcheck-nvx-rsruns them: protocol-only and host-only on both OSes, and guest-only on Linux.aci_edge_sandboxes, on both OSes: its fmt, Clippy, test, doc and MSRV steps, andtest-agent-bundling. On Linux, its macOS check too.python scripts/nvx.py verifyandpython scripts/check_markdown_links.py.Everything passes except one environmental failure on Linux as root: the CLI golden test's caller-owned-share scenario refuses its temporary share because UID 0 owns it, as
--mount-owner callerrequires. The base fails the same way there, and the unprivileged run passes.KVM, nested on the same Windows 11 machine: this machine's nested KVM can't boot the built-in CPU profile that
--cpu-profile autoselects (E_PROFILE_UNSUPPORTED). With a local wrapper that starts OpenVMM with--cpu-profile hostinstead,test-microvm --backend kvm --scenario managed-lifecycle --scenario managed-exec-config --scenario sandbox-lifecyclepasses, which covers the direct and sandbox agents. I didn't run the rest of the KVM suite, which depends on the built-in profile, or MSHV. CI runs the KVM, MSHV and WHP matrix once this targetsdev.