Repository navigation
nvx-rs: add a guest exec driver and run the managed agent on it - #668
Merged
Merged
Conversation
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Exit handling can prevent completion indefinitely, and constructor failure can leave cgroup descendants running.
2 open issues
What changed in this PR
Adds a reusable guest execution driver and makes the managed agent its NVXC adapter, building on the guest spawn, cgroup, and child-status APIs.
Changes:
- Introduces caller-driven execution with output flow control, stdin, cancellation, and timeouts.
- Moves managed workload execution onto the shared driver.
- Adds execution tests, synchronizes console capture, and updates documentation.
| File | Description |
|---|---|
| nvx-rs/tests/managed_lifecycle.rs | Waits for console connection before driving the guest. |
| nvx-rs/src/lib.rs | Documents the execution API. |
| nvx-rs/src/guest/sys/signal.rs | Adds SIGPIPE-safe writes and tests. |
| nvx-rs/src/guest/sys/process.rs | Adds process killing and returns group-kill errors. |
| nvx-rs/src/guest/sys/poll.rs | Supports variable descriptor lists. |
| nvx-rs/src/guest/sys/mod.rs | Exports new syscall helpers. |
| nvx-rs/src/guest/sys/fd.rs | Adds directory-relative opens and descriptor borrowing. |
| nvx-rs/src/guest/spawn.rs | Exposes launch-report polling internally. |
| nvx-rs/src/guest/mod.rs | Exports and documents the execution module. |
| nvx-rs/src/guest/managed_agent/tty.rs | Removes the obsolete nonblocking helper. |
| nvx-rs/src/guest/managed_agent/mod.rs | Documents the adapter architecture. |
| nvx-rs/src/guest/managed_agent/exec.rs | Drives workloads through the shared execution API. |
| nvx-rs/src/guest/managed_agent/dispatch.rs | Tests output limits, cancellation, and timeout behavior. |
| nvx-rs/src/guest/managed_agent/cgroup.rs | Supplies execution options and forwards observed statuses. |
| nvx-rs/src/guest/exec/tests.rs | Tests execution lifecycle, concurrency, and flow control. |
| nvx-rs/src/guest/exec.rs | Implements the public execution driver. |
| nvx-rs/src/guest/cgroup.rs | Pins cgroup directories for stable kill targets. |
| nvx-rs/README.md | Describes the driver and managed-agent integration. |
| doc/project-structure.md | Updates the guest-library overview. |
Review effort: Balanced
Enrique Saurez (esaurez)
force-pushed
the
esaurez/a1-guest-g3-exec-20261020
branch
from
October 21, 2026 04:34
057f336 to
bf886ee
Compare
Enrique Saurez (esaurez)
force-pushed
the
esaurez/a1-guest-g3-exec-20261020
branch
from
October 21, 2026 05:50
bf886ee to
b196b0d
Compare
Enrique Saurez (esaurez)
force-pushed
the
esaurez/a1-guest-g3-exec-20261020
branch
from
October 21, 2026 13:16
ce2be90 to
fde69bb
Compare
Enrique Saurez (esaurez)
marked this pull request as ready for review
October 21, 2026 14:40
Enrique Saurez (esaurez)
requested a review
from Pedro Henrique Penna (ppenna)
October 21, 2026 20:25
Enrique Saurez (esaurez)
force-pushed
the
esaurez/a1-guest-g3-exec-20261020
branch
from
October 21, 2026 23:18
715116c to
aca3130
Compare
Add `nvx::guest::exec`, through which any guest init or agent drives each program that it spawned as one execution, from its own loop and without NVX's control protocol or an async runtime. An `Execution` takes the program's `Spawned` and the execution's ends of the `Pipes` that it created, and reports each step as an event: the launch report, the bytes of each output stream, kill failures, and the outcome, with the program's status and why the execution ended it. It never blocks: the caller polls the descriptors that it lists, which include the program's pidfd, until its deadline, and takes its events until none is left. It reads a stream only within the allowance that the caller grants, its flow-control seam, into a buffer that the caller lends, and reads the two streams in turn, so a stalled sink stalls its own execution alone and neither stream starves the other. It writes the program's input without `SIGPIPE`, whatever the process's disposition of it, and leaves a `SIGPIPE` that was pending before the write. Once it knows the program's status, it closes the input, so that a descendant that still reads it sees its end. A cancellation, a timeout, or a disconnection, which also discards both streams, ends the program with `SIGKILL`: the program or its process group, while no collector has taken the program's status, as the program's pidfd tells without taking it, since only until then do those IDs name them; and the processes of a cgroup that the caller names with an `ExecutionCgroup`, through the directory that the handle holds, so that a kill never reaches a later cgroup at the same path. Once the program has ended with its output still open, the execution kills that cgroup again. The execution collects the program's status itself, by its pidfd, and never another child's. A caller that collects every child's status itself, such as a PID 1, hands the program's status over with `observe`, and `Options::caller_reaps` makes that collector the program's only one. In the default mode, a collector that takes the status first makes the next collection fail once with `ECHILD`, after which the execution waits for `observe`. If `Execution::new` cannot open the program's pidfd, it ends the program unless it was reaped, closes the descriptors that it took, and then kills the cgroup, so that a start that ran out of descriptors still kills it. The managed agent becomes its NVXC adapter. It keeps the NVXC framing, the null device as the workload's input, the 1 MiB output cap checked for each read of up to 32 KiB, admission, identity selection, mode-specific cleanup, the precedence of a cancellation, a timeout, and the cap over the workload's own status, its 25 ms poll, and its diagnostics. Its loop now also wakes when the workload exits or its launch report arrives. A workload status that the agent cannot collect, as when the kernel collects it because `SIGCHLD` is ignored, now also ends a request that no cause ended, with status 125 `failed` and the `exec-wait` diagnostic. The managed lifecycle test waits until its copy of the guest console has connected before it drives the guest, and gives up each connection attempt after 100 ms. On a fast host the whole lifecycle can end before Windows refuses one loopback connection, which it retries for about two seconds, so the copy could miss it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4f1cb1a1-0d06-46b2-a35a-54af8eb9583c
An execution signalled its program, or the process group that the program leads, by their IDs once the program's pidfd told it that no collector had taken the program's status. A collector of the caller's on another thread could take the status between that check and the signal, after which the IDs could name other processes. The execution now sends SIGKILL through the program's pidfd, which fails with ESRCH once a collector has taken the status, and then signals the group through the same pidfd with PIDFD_SIGNAL_PROCESS_GROUP, which names that group whatever takes its ID. A kernel before Linux 6.9, which refuses the flag, has the group signalled by its ID right after the program's own signal found the program unreaped. Execution::new, which opens the program's pidfd by its ID, needs that nothing collects the status before it returns. A collection that fails with ECHILD tells the execution that the program has ended, as its status would. The execution now closes the program's input then, and kills the processes of its cgroup if the program's output is still open. A descendant that holds the output of a direct workload whose status the kernel collected, as when SIGCHLD is ignored, no longer keeps the managed agent's request open until it ends. write_without_sigpipe took back no SIGPIPE when one was pending before the write, as sigpending reports the thread's pending signals and the process's together. One pending for the whole process does not absorb the write's own, which then stayed pending for the thread. When sigpending reports a SIGPIPE, the write now reads the thread's own pending signals from its status file, and takes back its own signal unless the thread had one pending itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4f1cb1a1-0d06-46b2-a35a-54af8eb9583c
A signal through the program's pidfd that failed otherwise than with ESRCH made the program count as reaped, so ending it set no cause and left the timeout armed. Every later step then ended the program for the timeout again and reported another failed kill, a busy loop that lasted until the program exited by itself. Only ESRCH tells that the program is gone: the kernel finds the process before it checks, for example, the permission to signal it. The program now still counts as unreaped after another refusal. The execution reports the failure once, still signals the process group, and keeps the cause, which clears the deadline, then goes on with the program's output and status. A test-only hook has the kernel refuse a thread's signals through pidfds, with which a test checks that such a timeout fires once. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4f1cb1a1-0d06-46b2-a35a-54af8eb9583c
The managed-agent test that ignores SIGCHLD ran a second workload that exited at once. Nothing holds a direct workload between its spawn and Execution::new, which opens its pidfd by its ID, and with SIGCHLD ignored the kernel collects an ended child's status at once. An agent thread descheduled in between left Execution::new with ESRCH, and the request answered launch-failed instead of EXIT 125 failed. The workload now opens a FIFO that the test holds open, writes a line, and reads the FIFO until the test closes its end, which the test does only once that line has arrived: the agent reads the workload's output only through its execution, so the pidfd was open by then. A failing test closes its end too, so the workload never outlives it. With a 500 ms delay injected between the spawn and Execution::new, the old test fails with launch-failed and the new one passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4f1cb1a1-0d06-46b2-a35a-54af8eb9583c
Once the agent learned that it could not collect a workload's status, it kept treating the workload as running while the output drained: the status stayed unknown. A cancellation that arrived then, or the timeout passing, became the request's outcome, EXIT 137 cancelled or EXIT 124 timeout, although the workload had already ended on its own, which a known status would have prevented. The agent now counts a workload as running only while its status is neither known nor lost, in its loop and in the guards of the cancellation, the timeout and the wait that follows a cause. A cause from before the loss still decides the outcome, and the output cap still applies, as it does after a known status. A new test makes a workload end on its own while a descendant holds its output open, behind FIFOs that the test controls. Once the agent's kill of the fake cgroup shows that it learned of the lost status, the test cancels one such workload and lets the timeout of another pass: both still end with EXIT 125 failed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4f1cb1a1-0d06-46b2-a35a-54af8eb9583c
An execution read a stream that it discarded into an 8 KiB buffer of its own, so that discarding did not depend on the caller's buffer. Nothing defined that size, and the C agent drained a workload's output through the same 32 KiB buffer that it forwarded output from. Discarding now reads into the buffer that the caller lends next_event, as output does, so that the buffer bounds every read, and the managed agent drains in 32 KiB reads again. An empty buffer reads nothing, as it already read no output. The exec tests name the size of the buffer that they lend and of the allowance that they grant each stream at a time, MAX_EVENT_BYTES, and check that each discarded read fits that buffer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4f1cb1a1-0d06-46b2-a35a-54af8eb9583c
When Execution::new could not open the program's pidfd, it signalled the process group that the program had led instead of the program, if the options named the group. A program that had joined another group since, as it may itself, left that group without a process, so the signal reached nothing, and without a cgroup the program kept running after new failed, for example with EMFILE. new now signals the program by its ID first, and then that group, as the execution's own kill does through the pidfd. Nothing may collect the program's status before new returns, so the ID still names the program. A test runs its own executable again as a program that joins the test's process group before new fails for want of a descriptor, and checks that new ends it. 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-g3-exec-20261020
branch
from
October 22, 2026 00:39
b3d6d95 to
46bf9cc
Compare
Enrique Saurez (esaurez)
deleted the
esaurez/a1-guest-g3-exec-20261020
branch
October 22, 2026 01:14
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 G3 of #619: any guest init or agent can run each program as one execution, with its launch, bounded and backpressure-aware output, input, ending and outcome, without NVX's control protocol or an async runtime. NVX's managed agent becomes its NVXC adapter.
What changed
nvx::guest::execPipes::new(input)creates the program's standard output and error pipes, and its input pipe if asked. The execution's ends are nonblocking, with open file descriptions of their own, and the program's ends (ProgramEnds) stay blocking, for the caller'sspawn::Command.Execution::new(spawned, pipes, options)drives the program that G1'sCommandspawned.Optionschooses what ending the program kills, the program alone or itsprocess_group(), plus acgroup(&ExecutionCgroup, Scope); an optionaltimeout; and who collects the program's status (caller_reaps()).A failed start:
newopens the program's pidfd by the program's ID, so nothing may collect the program's status beforenewreturns, as its docs andcaller_reaps()' now say. If opening the pidfd fails,newkills the program by its ID and then, withprocess_group(), the group that it led, unlessESRCHsays that the status is gone already, so that a program that joined another group since ends too. It then closes the pipes and the launch report, and kills the cgroup through the directory that itsExecutionCgroupholds (nvx-rs: add caller-owned guest cgroup and child-status APIs #655), so that a failure for want of descriptors still leaves one for that kill. The status stays the caller's to collect.The caller drives it.
next_event(buffer)takes one step without blocking and returns anEvent, orNoneonce every descriptor would block.descriptors()lists what to poll, each with itsSource: the launch report, an output stream, or the program's pidfd.deadline()gives the timeout. Executions share the caller's loop or reactor and never wait for each other.Events:
Launch, as G1'sLaunchReport::waitreturns it;Output(stream, bytes);Discarded;Closed;ReadFailed;KillFailed(target, error); and finallyEnded(Outcome), with the program's status, theCauseif the execution ended it, and the discarded byte counts.Flow control:
grant(stream, bytes)is the seam. The execution reads a stream only within its allowance (usize::MAXnever runs out), into the buffer that the caller lends, and alternates between the two streams one read at a time. A stalled sink stalls only its own execution, and the program blocks once its pipe fills.discard(stream)reads the rest into the same buffer and drops it, so that the buffer bounds every read, as the C agent's drain did with its 32 KiB output buffer. nvx-rs: exec v2 with credit-based streaming #623's credit engine extends this seam: it grants each stream what the peer credits.Input:
write_inputwrites what the pipe takes now and never raisesSIGPIPE, whatever the process's disposition. It blocks the signal in the writing thread and takes back the one that a failed write left pending, unless the thread itself already had one pending, which absorbs the write's and stays. ASIGPIPEpending for the whole process doesn't absorb it, so it stays beside the write's, which the write takes back.sigpendingreports both queues at once, so when it shows aSIGPIPE, the write reads the thread's own from theSigPndline of/proc/thread-self/status, into a stack buffer.close_inputsends EOF, andinput()lends the writer for polling. Once the program has ended, the execution closes the input, so that a descendant that still reads it sees EOF.Ending:
cancel(),terminate(), the timeout, anddisconnect(), which also discards both streams. The first cause while the program is unreaped becomes the outcome's cause. Each closes the input and sendsSIGKILL:ESRCHonce a collector has taken the program's status, so that checking and signalling are one step;process_group(), to the group that the program leads, through the same pidfd withPIDFD_SIGNAL_PROCESS_GROUP, which names that group whatever later takes its ID. A kernel before Linux 6.9 refuses the flag, and the execution signals the group by its ID instead, right after the program's own signal found it unreaped;ExecutionCgroupholds, so that a kill never reaches a later cgroup at the same path.Only
ESRCHtells that the program is gone. A signal that the kernel refuses otherwise, for example withEPERM, is oneKillFailedevent: the program still counts as unreaped, so the cause stands, the timeout fires once, and the execution goes on with the program's output and status.Once the program has ended with its output still open, the execution kills the cgroup again. Ending the program doesn't end its streams: the caller takes or discards what remains, so normal completion loses no byte.
Status: by default, the execution collects its program's status itself, by its pidfd, and never another child's.
waitpid(-1)loop, hands the program's status over withobserve(G2).Options::caller_reaps(), the caller's collector is the program's only one: the execution never waits for the program, and doesn't list its pidfd.next_eventfails once withECHILD. The program has ended, so the execution closes its input and kills the cgroup if the output is still open, as once it has the status. It then stops collecting and signalling, and waits forobserve.Dropping an unfinished execution kills its program without waiting.
The managed agent as the NVXC adapter.
managed_agent/exec.rsnow drives each workload as anExecution. The agent keeps:BUSYfor other requests, cancellation, timeout, and reset handling, with a cancellation taking precedence over a timeout, the timeout over the cap, and the cap over the workload's own status;exec-cgroup-kill.The direct agent's kill of descendants that hold the output open is now the execution's cgroup kill, through
cgroup::options. Itswaitpid(-1)loop hands the workload's status over withobserve, from the agent's one thread, in the default mode. A workload status that the agent can't collect, which nvx-rs: add caller-owned guest cgroup and child-status APIs #655 handles once the agent has ended the workload for a cause, now also ends a request without one, withEXIT 125 failedand theexec-waitdiagnostic, logged once per request, even while a descendant holds the workload's output, which the cgroup kill then ends.Internals:
sysgainswrite_without_sigpipe,kill_processandkill_through_pidfd.readable_or_hung_uppolls any number of descriptors, andkill_process_groupreturns its result.managed_agent::cgroup::terminategoes: its last caller, the agent's launch-failure path, now only reaps the workload thatExecution::newhas ended.Test harness:
tests/managed_lifecycle.rsnow waits until its copy of the guest console has connected before it drives the guest, and each connection attempt gives up after 100 ms.capture_consoleafter the lifecycle had passed.Docs: the crate README's module table and guest-binaries section,
lib.rs, the guest and managed-agent module docs, anddoc/project-structure.mddescribe the driver.Behavior
The NVXC frames that the agent sends change in three ways, and no fixture depends on any of them:
SIGCHLDis ignored, the request ends withEXIT 125 failedand theexec-waitdiagnostic, even while a descendant holds the workload's output, which the cgroup kill then ends. A cancellation or a timeout that comes after the agent learned of the lost status, while the output drains, changes that outcome no more than it would a known status; a cause from before stands. nvx-rs: add caller-owned guest cgroup and child-status APIs #655's agent kept waiting for that status until a cancellation or a reset ended the request.There are also two new failure points before the workload runs:
Execution::newends the workload and its cgroup. The agent then logs anexec-launchdiagnostic, reaps the workload, and answerslaunch-failed.Tests
guest::exec, 29 tests on real programs:cat. With the defaultSIGPIPEdisposition, each in a process of its own, a program that closes its input, and four threads that write at once, both getEPIPEwithoutSIGPIPE. The program's end closes its input, which a background descendant still reads.EPERM, which fires once, reports each failed kill once, and still delivers the program's output and status; disconnection; causes ignored once the program's status is known;observe; a dropped execution kills its program; and a cancellation, without a cgroup, ends a descendant left in the program's process group.caller_reaps(), the execution takes every other step but leaves the status for the caller's collector.ECHILDonce at most, and the deadline goes. Cancellation and disconnection signal nothing: a descendant left in the program's process group survives them.newsignals nothing by ID for a program that was reaped before it started.ECHILDofnext_eventtells;RLIMIT_NOFILE, in a process of its own.guest::sys::signal: underSIG_DFL, in a process of its own, a write without a reader fails withEPIPE, leaves noSIGPIPEpending, and restores the mask. In a thread that blocksSIGPIPE, the write takes its own signal back, and a signal that was pending for the thread before the write stays. In a forked single-threaded child, which keeps aSIGPIPEsent to the whole process pending, the write takes its own back and leaves exactly that one.guest::sys::process: aSIGKILLthrough a pidfd succeeds for an ended but unreaped child and fails withESRCHonce its status is collected. A group signal through a leader's pidfd still reaches a member of its group once the leader's status was collected, which is the interleaving of a collection between the program's signal and the group's (on a kernel before Linux 6.9, the test only checks the member's end).managed_agent::dispatch, through the direct agent's whole request path:EXIT 125 output-limit. It never reaches the marker that it would write afterwards.PINGgetsBUSY, and a cancel of another request changes nothing. The workload's own cancel ends it withEXIT 137 cancelled, and a 100 ms timeout ends the next one withEXIT 124 timeout.SIGCHLDin a process of its own, now also runs a workload without a timeout after the timed-out one. That workload exits on its own, but only once its first output frame has shown that the agent opened its pidfd: it blocks on a FIFO that the test closes then, as a status that the kernel collects beforeExecution::newwould answerlaunch-failed. It ends withEXIT 125 failed, and each request logsexec-waitonce. In a second test, a workload exits on its own while a descendant holds its output open, both behind FIFOs that the test controls. Once the agent's cgroup kill shows that it learned of the lost status, the test cancels the first such workload, and lets the 1 s timeout of the second pass, before it lets the descendant end. Both still end withEXIT 125 failed.next_eventor the wait after a cause reports, send no exit for one without a cause, or let a later cancellation or timeout override it;ECHILDfromnext_eventorwait, leave the program unended then, keep the deadline after it, or ignorecaller_reaps();sys;SIGPIPE: take back a signal that was pending before the write, leave the write's own pending in a thread that blocks the signal, count one pending for the process as the thread's, or read the process's pending set instead of the thread's.Sizes
I built the guest binaries and the Alpine initramfs natively on Linux, the same way at this PR's base and with this change. The base is
devat 068f8c8, which this PR is rebased on, built as root in a worktree of its own. The binaries use theguestprofile, which strips them.nvx-managed-agentinitramfs.cpio.gz(Alpine)The other guest binaries keep their sizes. Uncompressed, the initramfs grows by the agent's 4,096 B alone.
Adapter contract
Yes, additively: the guest configuration gains one public module,
nvx::guest::exec. No existing public item, persisted format or wire format changes, and the runtime adapter builds unchanged in every configuration (C0).Validation
python scripts\nvx.py test-microvm --backend whppasses every scenario of the default suite (43 runs), and so does a run with--scenario managed-exec-config --scenario sandbox-lifecycle --scenario managed-lifecycle. Both use the Alpine initramfs built from this change, with the pinned OpenVMM and kernel.--cpu-profile autoselects. With a local wrapper that starts OpenVMM with--cpu-profile hostinstead, the same three managed scenarios pass with--backend kvm, which covers the direct and sandbox agents.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 unprivileged, as CI runs them, and as root, which theguest::cgrouptests need to change the real hierarchyguest::exec,guest::sys::signalandguest::sys::processtests, pinned to two CPUs, 20 times unprivileged and 10 times as rootmanaged_agent::dispatchtests, pinned to two CPUs, 30 times unprivileged and 15 times as rootcheck-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, which
devhas too, and which #739 tracks: the CLI golden test's caller-owned share scenarios refuse their temporary share because UID 0 owns it, as--mount-owner callerrequires. The unprivileged run passes.I didn't run MSHV, or the KVM suite beyond the managed scenarios. CI runs the KVM, MSHV and WHP matrix.