Skip to content

nvx-rs: let an execution drive a program that its caller started - #831

Merged
Enrique Saurez (esaurez) merged 1 commit into
devfrom
esaurez/exec-adopt-20261023
Oct 24, 2026
Merged

Enrique Saurez (esaurez) merged 1 commit into
devfrom
esaurez/exec-adopt-20261023

Conversation

@esaurez

Copy link
Copy Markdown
Contributor

An Execution drives only a program that spawn started: it waits for the launch report that Spawned holds, on the pipes that Pipes::new created. A caller that starts its programs by means of its own cannot use it, and keeps a loop of its own for each program's output, input, timeout, and ending. Such a caller launches with steps that neither spawn nor entry takes, such as joining namespaces that already exist or installing a seccomp filter, and it already knows how its launch went, and holds the program's pidfd and its own ends of the program's pipes.

Adoption. Execution::adopt drives such a program. It takes the Child that names the program and the caller's ends of the program's pipes, and from then on drives the program as one that spawn started: the same flow control, input, timeout, kills, cgroup, status collection or handover, and binding.

  • Child::from_pidfd names a child by a pidfd that the caller holds, such as the one that clone3 returns with CLONE_PIDFD. Nothing is opened by the child's ID then, so the caller's collector may even have taken the child's status first, and hands it over with observe. A child that only its ID names gets a pidfd that adopt opens by that ID, as Execution::new opens one, so nothing may collect its status before adopt returns; if opening it fails, adopt ends what the options name, as new does, by ID unless ESRCH tells that the status is gone, and closes the pipes before it kills the cgroup.
  • Pipes::from_ends takes the caller's ends, and refuses with EINVAL any end that is not a pipe's end going the way of its stream: a writer for the input, readers for the outputs. It makes each nonblocking and close-on-exec, as Pipes::new makes the execution's own; the program's ends of the same pipes are other open file descriptions, and keep blocking. The pipes must carry bytes, not packets, since no check on a reader can tell that its writer writes packets, as O_DIRECT makes it.
  • The caller saw the launch, so the execution waits on no launch report: it reports the launch at once, with no failure, and its descriptors include none for it. A binding hands that launch to its runner as it does a spawned program's.
  • A child whose status the caller's collector handed over before the adoption has ended: the execution closes the program's input, kills the options' cgroup if the output is still open, as observe does, and reads what remains of the output.

Waits. A launcher may start a child with an exit signal other than SIGCHLD, which the child keeps until it runs a program. A Child's waits now name its child whatever signal the child reports its end with, as __WALL makes them: before, a child that ended with another exit signal before it ran a program was ECHILD to them, and an execution that adopted it would have taken it for one whose status another collector took. A pidfd that its opener made nonblocking, as PIDFD_NONBLOCK does, makes Child::wait fail with WouldBlock while the child runs, as waitid through it does, which the documentation now says; an execution never blocks on a status, so it adopts such a child as it does any other.

Execution::new keeps its behavior: it and adopt share one constructor, and the launch report becomes one of the two launches that an execution reports. A pseudoterminal stays out of scope, since an execution reads and writes pipes alone.

Tests. An adopted program's launch comes first, at once, with no descriptor to wait on, and then its output and status; it reads the input that the execution writes to its end; one that only its ID names gets its pidfd by that ID and is cancelled with its process group; one whose status was observed before the adoption has its input closed, its cgroup killed, and its output read; adopting one whose status another collector took fails with ESRCH and kills nothing by ID, so a descendant in its process group lives on; a failed adoption without a spare descriptor ends the program and its cgroup; from_ends makes the caller's ends nonblocking and close-on-exec, leaves the program's ends blocking, and refuses an end that is no pipe's and one that goes the other way; a bound adopted program starts, streams, and settles as a spawned one does; Child::from_pidfd waits through the caller's pidfd, finds ECHILD once another collector took the status, takes it from observe, and refuses impossible IDs; an adopted program that a nonblocking pidfd names runs as any other; and a child that reports its end with no signal is waited for, through its pidfd and by its ID. The tests start their programs with the standard library's Command, a launcher other than spawn, and the one that makes descriptors inheritable for a moment runs in a process of its own.

Validation

At this branch's head, d00841e, on dev at ffb03cd:

  • Linux, a 64-CPU host, in a container without a seccomp filter, since Docker's default one refuses clone3 and the newer mount calls, as a normal user: in nvx-rs, with NVX_BUNDLE=skip, cargo fmt --all --check, cargo clippy --locked --all-targets --all-features -- -D warnings, cargo clippy --locked --lib --no-default-features -- -D warnings, cargo test --locked --all-features, cargo test --locked, the build and test without default features, the bundled-artifacts test, cargo doc with RUSTDOCFLAGS=-D warnings, cargo +1.89 check, and the macOS check; nvx.py check-guest-binaries, build-guest-binaries, and test-guest-binaries; tests/runtime-adapter in its default, host, and musl guest configurations, each with its cargo +1.89 check; the aci_edge_sandboxes checks with test-agent-bundling; and the Markdown links.
  • Each new test fails without the part of the change that it covers: without the pipe check, the direction check, or close-on-exec, the from_ends test fails; without closing an observed child's input, or with a pidfd opened by the ID of an observed child, the observed-status test fails; without the pidfd that adopt opens by ID, the cancellation, ESRCH, and failed-adoption tests fail; with a kill by ID after ESRCH, the ESRCH test fails; without ending the program on a failed adoption, the failed-adoption test fails; with an adopted launch that reports a failure, six exec tests and the binding test fail; with from_pidfd dropping its pidfd, its test fails; and without __WALL, the wait for a child with no exit signal finds no child.
  • On that host, both_streams_take_turns_while_each_has_output fails at dev too, in 41 of 100 runs of the exec and process tests, and in 49 of 100 with this change: once both pipes are drained, it expects their ends at once, while a child that another test's thread forked may still hold the program's ends until it runs its own program. None of the last 40 CI runs failed it.

Left to CI: KVM, MSHV, and WHP, whose VM suites run the managed agent and the runtime adapter, whose executions Execution::new still starts; the Windows runs of the nvx-rs and aci_edge_sandboxes checks; and the quality gates of the Python and shell code, which this change does not touch.

An Execution drove only a program that spawn started: it waited for the
launch report that Spawned holds, on pipes that Pipes::new created. A
caller that starts its programs by means of its own, as one that joins
namespaces that already exist, or that installs a seccomp filter, which
neither spawn nor entry does, could not use it, and kept a loop of its
own for the program's output, input, timeout, and ending.

Execution::adopt now drives such a program. The caller hands it the
Child that names the program, and its own ends of the program's pipes.
Child::from_pidfd names a child by a pidfd that the caller holds, such
as the one that clone3 returns with CLONE_PIDFD; a child that only its
ID names gets a pidfd opened by that ID, as Execution::new opens one,
and a failure to open it ends what the options name, as for new.
Pipes::from_ends takes the caller's ends once it has checked that each
is a pipe's end that goes the way of its stream, and makes each
nonblocking and close-on-exec, as Pipes::new makes the execution's own.

A launcher may start a child with an exit signal other than SIGCHLD,
which the child keeps until it runs a program. A Child's waits now name
its child whatever signal the child reports its end with, as __WALL
makes them. A pidfd that its opener made nonblocking makes Child::wait
fail with WouldBlock while the child runs, as waitid through it does,
but an execution, which never blocks on a status, adopts its child as
it does any other.

The caller saw how the program's launch went, so the execution waits on
no launch report: its next event reports the launch at once, with no
failure, which a binding hands its runner as it does a spawned
program's. From then on the execution drives the program as one that
spawn started. A child whose status the caller's collector handed over
before the adoption has ended: the execution closes the program's input,
and kills its cgroup if its output is still open, as an observation
does.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d4e92c43-6bfc-4784-9c86-3ddd2a4aa409
Copilot AI balanced review requested due to automatic review settings October 24, 2026 00:12

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.

🔵 Needs a closer look

The expanded process-identity, status-collection, and termination semantics warrant human review despite no concrete defects being identified.

No open issues 🎉

What changed in this PR

This PR lets NVX’s guest execution driver manage programs started by external launchers, reusing its existing input/output, timeout, termination, and binding behavior.

Changes:

  • Adds Execution::adopt, Child::from_pidfd, and validated pipe-end adoption.
  • Extends child waits to handle exit signals other than SIGCHLD.
  • Adds adoption and binding tests and documents the new APIs.
File Description
nvx-rs/​src/​guest/​sys/​process.rs Extends wait semantics and adds a test child helper.
nvx-rs/​src/​guest/​sys/​mod.rs Exports the new test helper.
nvx-rs/​src/​guest/​sys/​fd.rs Validates pipe ends and sets descriptor flags.
nvx-rs/​src/​guest/​process.rs Accepts caller-owned pidfds and tests child waits.
nvx-rs/​src/​guest/​exec/​tests.rs Tests adoption, streams, cancellation, and cleanup.
nvx-rs/​src/​guest/​exec/​binding/​tests.rs Tests adopted executions through a binding.
nvx-rs/​src/​guest/​exec/​binding.rs Documents support for adopted executions.
nvx-rs/​src/​guest/​exec.rs Implements adoption and shared execution initialization.
nvx-rs/​README.md Documents adoption in the guest API overview.

Review effort: Balanced

@esaurez
Enrique Saurez (esaurez) merged commit 36f8a62 into dev Oct 24, 2026
44 checks passed
@esaurez
Enrique Saurez (esaurez) deleted the esaurez/exec-adopt-20261023 branch October 24, 2026 00:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants