Repository navigation
ci: managed agent finishes a direct-mode exec before reaping its killed processes, failing the aci_edge_sandboxes lifecycle test on Linux/MSHV #360
Description
Summary
NVX microVM tests / Linux / MSHV failed on the dev push of #333 (0f95ddbc) in Run aci_edge_sandboxes lifecycle test on Linux. openvmm_lifecycle_on_a_real_hypervisor runs setsid sleep 60 & sleep 60 & echo spawned. It then asserts that the next exec, pgrep -x sleep || echo none, prints none (openvmm_e2e.rs L834-L845). It printed 61.
The managed agent's settle_exec_cgroup() returns as soon as the exec cgroup reports populated 0, and that field counts only live processes. In do_exit(), a killed task leaves the count in cgroup_exit() before exit_notify() turns it into a zombie that PID 1 has to reap. The agent reaps before it reads populated, and it does not reap again until the next exec is already running. A killed sleep that finishes exiting between those two steps therefore stays in /proc as an unreaped zombie, and the next workload's pgrep finds it.
Nothing escaped containment: every leftover had already received SIGKILL and was only waiting to be reaped. The defect came with #307. #333 did not cause it, and the mechanism does not depend on MSHV.
Incident
- Workflow: CI run 37233677654, attempt 1, a
pushtodev(run nvx-rs: template mode, repair and activation in NVX's init #852). - Head:
0f95ddbc1c517486e6bf8b190d58eafe2b742f2f, the merge of Support a working directory for each execution #333 (which closes Support a working directory for each execution #273). It is still the head ofdev, so nothing superseded the run. - Failed job:
NVX microVM tests / Linux / MSHV, stepRun aci_edge_sandboxes lifecycle test on Linux(python3 scripts/nvx.py test-aci-edge-sandboxes --backend mshv). - Runner:
azure-mshv-5(self-hosted,linux,mshv,virtual-machine). Guest: Linux 6.18.38 with one vCPU. - Every earlier step of the job passed, as did the other six
openvmm_e2etests.NVX microVM tests / Linux / KVMpassed the same test at the same commit. When I filed this issue, no other job in the run had failed, and the Windows/WHP microVM job was still running. - Effect on
dev:Publish development releaseandPersist performance baselinerequirenvx-microvm-tests-mshvto succeed. Unless the failed job is rerun,0f95ddbcgets neither.Required status checkfails.
Decisive output:
test environments_leave_the_workload_contained ... ok
thread 'openvmm_lifecycle_on_a_real_hypervisor' (3702374) panicked at tests/openvmm_e2e.rs:845:5:
assertion `left == right` failed
left: [54, 49, 10]
right: [110, 111, 110, 101, 10]
test openvmm_lifecycle_on_a_real_hypervisor ... FAILED
test result: FAILED. 6 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 7.97s
left is "61\n", the PID that pgrep -x sleep printed. right is "none\n".
The log narrows down what PID 61 can be:
- The same check after the cancellation exec (L816-L832) printed
none, so nosleepwas running before the detached exec. - The detached exec succeeded (L841-L843), so
settle_exec_cgroup()returned 0 for it. - The
pgrepexec passed the agent's containment pre-check (L1757-L1764). That check fails an exec withcontainment-failedunless the exec cgroup reportspopulated 0. - The guest's init runs
sleeponly in the foreground before itexecs the agent on the managed path (initL223-L235). Its only background jobs belong to thenetsnapbenchmark path, which comes after thatexecand never runs here.
PID 61 was therefore a sleep from the detached exec that the cgroup no longer counted as live but that was still in /proc. The uploaded nvx-microvm-tests-mshv artifact contains only the host-side OpenVMM log of this sandbox, with no guest console, so it cannot show the process state.
Recurrence
The test and the settle code both reached dev with #307 (a2c9006ad, 2026-10-03). I used the Actions API to scan every attempt of all 123 CI runs created since 2026-10-03:
| Backend | Executions of the Linux lifecycle step | Failures at L845 | Other failures |
|---|---|---|---|
| Linux/MSHV | 57 | 1 (this run) | 1 |
| Linux/KVM | 56 | 0 | 1 |
The two other failures both happened in run 37222122862, on the unmerged branch fix/issue-272-per-execution-environment. They hit a different assertion, in environments_leave_the_workload_contained at tests/openvmm_e2e.rs:95, and are unrelated. The PR run for #333's final head f503854f (37232065285) passed this step on both backends.
Root cause
Repository permalinks point to 0f95ddbc. Kernel permalinks point to v6.18.38, the guest kernel version.
- Containment model. In direct mode, the agent is PID 1, because the managed lifecycle
execs it from init (initL232-L234). Every workload runs in the exec cgroup, which "every process of the workload inherits, whatever session or process group it moves to" (nvx-managed-agent.cL820-L839). - The exec can end while its killed processes are still exiting. The shell exits after
echo spawned. Bothsleeps are reparented to PID 1 and keep the output pipes open. The poll loop therefore writescgroup.kill(L1959-L1965) and keeps polling until both pipes reach EOF (L1880).do_exit()releases a task's files inexit_files()/exit_task_work()before it callscgroup_exit()andexit_notify()(exit.cL967-L984). The loop can therefore end while a killedsleepis still exiting. - Settle trusts
populated.settle_exec_cgroup()(L970-L1002) writescgroup.killagain and then loops. Each pass reaps withwaitpid(-1, WNOHANG)(L987) and readscgroup.events(L988). It returns 0 as soon as it readspopulated 0(L992-L994). populated 0does not mean reaped. According to the cgroup v2 documentation,populated"is 0 if there is no live process in the cgroup and its descendants", and a cgroup "associated only with zombie processes is considered empty".cgroup_exit()drops the task from that count beforeexit_notify()makes it a zombie child of PID 1. If asleeppasses both points between L987 and L988, settle never reaps it, yet it satisfiespopulated 0, and the agent reports the exec as finished.- Nothing reaps it before the next workload starts. The agent reaps only inside
run_exec(), inreap_children()(L947-L968), which runs at L987 and L1959. It has no SIGCHLD handler and does not reap between requests. The next exec's pre-check (L1757-L1764) reads the samepopulatedsignal, so the next workload starts while the zombiesleepis still in/proc.pgrep -x sleepthen matches it by name.
The window is narrow. The guest has a single vCPU: launch.rs passes no --processors, and OpenVMM defaults to 1. Its kernel uses voluntary preemption (config-microvm L124-L130). The agent therefore has to be preempted between L987 and L988 while a killed sleep is runnable. As with #320, every observed failure ran on an MSHV runner VM, but the mechanism does not depend on the backend.
The same check, pgrep -x sleep || echo none right after an exec whose processes were killed, also appears at L831-L832 and in environments_leave_the_workload_contained (L654-L678). Both checks go through the same settle path and can fail the same way.
Classification: a product defect in the guest agent's direct-mode exec containment. Its exec-completion barrier does not cover reaping. A correct but timing-sensitive e2e assertion exposes it. It is not a containment leak, because every leftover had already been killed. #333 adds a launch-pipe read just before settle (L1978-L1991). It does not change the kill, reap, or populated logic, which came with #307.
Reproduction
The race reproduces without a hypervisor or root on WSL2 Ubuntu (kernel 6.18.33.2, 20 logical CPUs, cgroup v2, systemd as PID 1). The harness below stands in for the agent:
- It is a child subreaper, standing in for PID 1.
- It runs the workload in an
execcgroup inside a delegatedsystemd-run --user --scope -p Delegate=yessubtree. - It copies
reap_children(), the direct-mode exit path of therun_exec()poll loop (including the straggler kill), andsettle_exec_cgroup(). - After settle returns, it lists its own children and runs
pgrep -x -P <self> sleep, standing in for the next exec. - It enables the
pidscontroller on the subtree and readspids.currentwhen settle seespopulated 0. That counter drops only when a task is reaped:release_task()callscgroup_release(), which callspids_release()(exit.cL244-L260,pids.cL294-L298).
| Workload | Conditions | Iterations | Settle returned with unreaped tasks¹ | pgrep -x sleep printed a PID |
Alive 2 s later |
|---|---|---|---|---|---|
| L839 command | Idle | 300 | 0 | 0 | 0 |
| L839 command | Pinned to one CPU | 300 | 0 | 0 | 0 |
| L839 command | Pinned to one CPU with 3 busy loops on it | 2,300 | 0 | 0 | 0 |
| L839 command | 40 busy loops across 20 CPUs | 2,300 | 4 | 1 | 0 |
L839 command, sleep output sent to /dev/null |
Idle | 600 | 9 | 8 | 0 |
| L839 command | Idle, 500 µs injected between L987 and L988 | 200 | 0 | 0 | 0 |
L839 command, sleep output sent to /dev/null |
Idle, 500 µs injected between L987 and L988 | 300 | 127 | 119 | 0 |
¹ pids.current > 0 at the moment settle saw populated 0. Each row combines one or two runs.
A failing iteration with the exact L839 command printed:
iteration 1773: settle saw populated 0 with pids.current=1; leftovers after settle [15614(sleep):Z+exiting]; pgrep printed [15614 ]
Every leftover was in state Z with PF_EXITING set, and was a child of the reaper. The next reap collected each one, so none was a live process: "Alive 2 s later" is 0 in every row.
When the sleeps hold the output pipes, as they do at L839, the loop has usually reaped them before settle runs. The race then needs one sleep to still be exiting when the loop ends. That is why this workload needs load to show the race, and why a delay inside settle alone does not trigger it. When the leftovers do not hold the pipes, settle's own kill ends them, and the race shows up even on an idle host.
Pinned to one CPU, the harness did not reproduce the race in 2,600 iterations. On the single-vCPU guest, hitting the window depends on how the guest kernel schedules the agent against the dying sleep. The MSHV runners evidently hit it more often, which is consistent with 1 failure in 57 executions. I did not reproduce the failure on a Linux/MSHV host or inside a guest. The evidence is the CI log, the guest configuration, and the harness.
Harness: settle_race.c
/*
* Reproduces the direct-mode exec settle race of guest/common/nvx-managed-agent.c
* outside a VM. The harness acts as the agent: it is a child subreaper (the
* agent is PID 1), runs `setsid sleep 60 & sleep 60 & echo spawned` in an exec
* cgroup, kills stragglers that hold the output pipes open, settles the cgroup
* exactly as settle_exec_cgroup() does, and then runs `pgrep -x sleep`
* restricted to its own children, as the next exec of openvmm_e2e.rs:844 does.
*
* Run inside a delegated cgroup v2 subtree, for example:
* systemd-run --user --scope -p Delegate=yes -p TasksMax=4096 ./settle_race 300
*/
#define _GNU_SOURCE
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <poll.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/prctl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#define PF_EXITING_FLAG 0x00000004U
static char scope[4096];
static char exec_cgroup[4200];
static int pids_enabled;
/* Fault injection: microseconds between the reap and the populated read in settle. */
static long settle_gap_us;
static int write_text(const char *path, const char *text)
{
int fd = open(path, O_WRONLY | O_CLOEXEC);
ssize_t count;
int saved;
if (fd < 0) {
return -1;
}
count = write(fd, text, strlen(text));
saved = errno;
close(fd);
errno = saved;
return count == (ssize_t)strlen(text) ? 0 : -1;
}
static long read_number(const char *path)
{
char buffer[64];
int fd = open(path, O_RDONLY | O_CLOEXEC);
ssize_t count;
if (fd < 0) {
return -1;
}
count = read(fd, buffer, sizeof(buffer) - 1);
close(fd);
if (count <= 0) {
return -1;
}
buffer[count] = '\0';
return strtol(buffer, NULL, 10);
}
static int kill_exec_cgroup(void)
{
char path[4300];
snprintf(path, sizeof(path), "%s/cgroup.kill", exec_cgroup);
return write_text(path, "1");
}
/* Mirrors exec_cgroup_populated() in the agent. */
static int exec_cgroup_populated(void)
{
char path[4300];
char buffer[256];
char *line;
ssize_t count;
int fd;
snprintf(path, sizeof(path), "%s/cgroup.events", exec_cgroup);
fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0) {
return -1;
}
count = read(fd, buffer, sizeof(buffer) - 1);
close(fd);
if (count <= 0) {
return -1;
}
buffer[count] = '\0';
line = strstr(buffer, "populated ");
return line == NULL ? -1 : line[10] == '1';
}
/* Verbatim from the agent. */
static void reap_children(pid_t child, int *child_exited, int *wait_status)
{
for (;;) {
int status;
pid_t pid = waitpid(-1, &status, WNOHANG);
if (pid < 0 && errno == EINTR) {
continue;
}
if (pid <= 0) {
return;
}
if (pid == child && !*child_exited) {
*child_exited = 1;
*wait_status = status;
}
}
}
/* Mirrors settle_exec_cgroup() in the agent, plus a pids.current probe. */
static int settle_exec_cgroup(long *pids_current)
{
const struct timespec delay = {.tv_sec = 0, .tv_nsec = 10L * 1000L * 1000L};
int exited = 1;
int status = 0;
unsigned int attempt;
*pids_current = -1;
if (kill_exec_cgroup() != 0) {
return -1;
}
for (attempt = 0; attempt <= 200; ++attempt) {
int populated;
reap_children(0, &exited, &status);
if (settle_gap_us > 0) {
usleep((useconds_t)settle_gap_us);
}
populated = exec_cgroup_populated();
if (populated < 0) {
return -1;
}
if (populated == 0) {
if (pids_enabled) {
char path[4300];
snprintf(path, sizeof(path), "%s/pids.current", exec_cgroup);
*pids_current = read_number(path);
}
return 0;
}
if (attempt == 200) {
break;
}
nanosleep(&delay, NULL);
}
errno = ETIMEDOUT;
return -1;
}
/* Lists this process's children (all of them, or only those named `name`) with their states. */
static int scan_children(pid_t self, const char *name, char *states, size_t capacity)
{
DIR *proc = opendir("/proc");
struct dirent *entry;
int count = 0;
size_t used = 0;
states[0] = '\0';
while (proc != NULL && (entry = readdir(proc)) != NULL) {
char path[300];
char buffer[1024];
char *open_paren;
char *close_paren;
char state;
int ppid;
unsigned int flags;
ssize_t length;
int fd;
if (entry->d_name[0] < '0' || entry->d_name[0] > '9') {
continue;
}
snprintf(path, sizeof(path), "/proc/%s/stat", entry->d_name);
fd = open(path, O_RDONLY | O_CLOEXEC);
if (fd < 0) {
continue;
}
length = read(fd, buffer, sizeof(buffer) - 1);
close(fd);
if (length <= 0) {
continue;
}
buffer[length] = '\0';
open_paren = strchr(buffer, '(');
close_paren = strrchr(buffer, ')');
if (open_paren == NULL || close_paren == NULL) {
continue;
}
*close_paren = '\0';
if (sscanf(close_paren + 2, "%c %d %*d %*d %*d %*d %u", &state, &ppid, &flags) !=
3 ||
(name != NULL && strcmp(open_paren + 1, name) != 0) || ppid != self) {
continue;
}
++count;
if (used < capacity) {
used += (size_t)snprintf(
states + used,
capacity - used,
"%s%s(%s):%c%s",
count > 1 ? "," : "",
entry->d_name,
open_paren + 1,
state,
(flags & PF_EXITING_FLAG) != 0 ? "+exiting" : "");
}
}
if (proc != NULL) {
closedir(proc);
}
return count;
}
/* Runs `pgrep -x -P <self> sleep`, the next exec's check, without reaping anything else. */
static int run_pgrep(pid_t self, char *output, size_t capacity)
{
int fds[2];
pid_t pid;
size_t used = 0;
ssize_t count;
int status;
if (pipe2(fds, O_CLOEXEC) != 0) {
return -1;
}
pid = fork();
if (pid == 0) {
char parent[32];
dup2(fds[1], STDOUT_FILENO);
snprintf(parent, sizeof(parent), "%d", (int)self);
execlp("pgrep", "pgrep", "-x", "-P", parent, "sleep", (char *)NULL);
_exit(127);
}
close(fds[1]);
while ((count = read(fds[0], output + used, capacity - 1 - used)) > 0) {
used += (size_t)count;
}
output[used] = '\0';
close(fds[0]);
while (waitpid(pid, &status, 0) < 0 && errno == EINTR) {
}
return used > 0;
}
static int drain(int fd)
{
char buffer[32768];
ssize_t count = read(fd, buffer, sizeof(buffer));
if (count == 0) {
return 1;
}
return 0;
}
/* Reaps everything; returns the number of children still alive after 2 s. */
static int reset_children(void)
{
struct timespec start;
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &start);
for (;;) {
int status;
pid_t pid = waitpid(-1, &status, WNOHANG);
if (pid > 0 || (pid < 0 && errno == EINTR)) {
continue;
}
if (pid < 0 && errno == ECHILD) {
return 0;
}
clock_gettime(CLOCK_MONOTONIC, &now);
if (now.tv_sec - start.tv_sec >= 2) {
char states[1024];
int alive = scan_children(getpid(), NULL, states, sizeof(states));
fprintf(stderr, "escaped (still alive after 2 s): %s\n", states);
kill(0, SIGKILL);
return alive;
}
{
const struct timespec delay = {.tv_nsec = 1000000L};
nanosleep(&delay, NULL);
}
}
}
int main(int argc, char **argv)
{
int iterations = argc > 1 ? atoi(argv[1]) : 200;
pid_t self = getpid();
char line[4096];
char path[4300];
FILE *cgroup_file;
int index;
int settle_failed = 0;
int containment_failed = 0;
int immediate = 0;
int pgrep_saw = 0;
int pids_gap = 0;
int escaped = 0;
int examples = 0;
const char *gap = getenv("SETTLE_GAP_US");
const char *workload = getenv("WORKLOAD");
if (workload == NULL) {
workload = "setsid sleep 60 & sleep 60 & echo spawned";
}
settle_gap_us = gap == NULL ? 0 : strtol(gap, NULL, 10);
cgroup_file = fopen("/proc/self/cgroup", "r");
if (cgroup_file == NULL || fgets(line, sizeof(line), cgroup_file) == NULL ||
strncmp(line, "0::", 3) != 0) {
fprintf(stderr, "requires a cgroup v2 hierarchy\n");
return 2;
}
fclose(cgroup_file);
line[strcspn(line, "\n")] = '\0';
snprintf(scope, sizeof(scope), "/sys/fs/cgroup%s", line + 3);
snprintf(path, sizeof(path), "%s/agent", scope);
if ((mkdir(path, 0755) != 0 && errno != EEXIST)) {
perror("mkdir agent cgroup");
return 2;
}
snprintf(path, sizeof(path), "%s/agent/cgroup.procs", scope);
if (write_text(path, "0") != 0) {
perror("join agent cgroup");
return 2;
}
snprintf(path, sizeof(path), "%s/cgroup.subtree_control", scope);
pids_enabled = write_text(path, "+pids") == 0;
snprintf(exec_cgroup, sizeof(exec_cgroup), "%s/exec", scope);
if (mkdir(exec_cgroup, 0755) != 0 && errno != EEXIST) {
perror("mkdir exec cgroup");
return 2;
}
if (prctl(PR_SET_CHILD_SUBREAPER, 1) != 0) {
perror("PR_SET_CHILD_SUBREAPER");
return 2;
}
for (index = 0; index < iterations; ++index) {
int out[2];
int err[2];
int stdout_open = 1;
int stderr_open = 1;
int child_exited = 0;
int wait_status = 0;
int stragglers_killed = 0;
long pids_current;
char states[1024];
char pgrep_output[256];
int leftovers;
int seen;
pid_t child;
if (exec_cgroup_populated() != 0) {
++containment_failed;
}
if (pipe2(out, O_CLOEXEC) != 0 || pipe2(err, O_CLOEXEC) != 0) {
perror("pipe2");
return 2;
}
child = fork();
if (child == 0) {
char pid_text[32];
int null_fd;
setpgid(0, 0);
null_fd = open("/dev/null", O_RDONLY);
dup2(null_fd, STDIN_FILENO);
close(null_fd);
dup2(out[1], STDOUT_FILENO);
dup2(err[1], STDERR_FILENO);
snprintf(pid_text, sizeof(pid_text), "%d\n", (int)getpid());
snprintf(path, sizeof(path), "%s/cgroup.procs", exec_cgroup);
if (write_text(path, pid_text) != 0) {
_exit(125);
}
execl("/bin/sh", "sh", "-c", workload, (char *)NULL);
_exit(125);
}
setpgid(child, child);
close(out[1]);
close(err[1]);
fcntl(out[0], F_SETFL, O_NONBLOCK);
fcntl(err[0], F_SETFL, O_NONBLOCK);
/* The agent's run_exec() loop, reduced to the direct-mode exit path. */
while (!child_exited || stdout_open || stderr_open) {
struct pollfd descriptors[2] = {
{.fd = stdout_open ? out[0] : -1, .events = POLLIN | POLLHUP},
{.fd = stderr_open ? err[0] : -1, .events = POLLIN | POLLHUP},
};
poll(descriptors, 2, 25);
if (stdout_open && descriptors[0].revents != 0 && drain(out[0]) == 1) {
close(out[0]);
stdout_open = 0;
}
if (stderr_open && descriptors[1].revents != 0 && drain(err[0]) == 1) {
close(err[0]);
stderr_open = 0;
}
reap_children(child, &child_exited, &wait_status);
if (child_exited && !stragglers_killed && (stdout_open || stderr_open)) {
stragglers_killed = 1;
(void)kill_exec_cgroup();
}
}
if (settle_exec_cgroup(&pids_current) != 0) {
++settle_failed;
}
/* The agent now reports the exit and idles until the next request. */
leftovers = scan_children(self, NULL, states, sizeof(states));
seen = run_pgrep(self, pgrep_output, sizeof(pgrep_output));
if (leftovers > 0) {
++immediate;
}
if (seen > 0) {
++pgrep_saw;
}
if (pids_current > 0) {
++pids_gap;
}
if ((leftovers > 0 || seen > 0) && examples < 5) {
char *newline;
++examples;
while ((newline = strchr(pgrep_output, '\n')) != NULL) {
*newline = ' ';
}
printf(
"iteration %d: settle saw populated 0 with pids.current=%ld; "
"leftovers after settle [%s]; pgrep printed [%s]\n",
index,
pids_current,
states,
pgrep_output);
}
escaped += reset_children();
}
printf(
"iterations=%d settle_failed=%d containment_precheck_failed=%d "
"leftover_after_settle=%d pgrep_saw_sleep=%d "
"pids_current_nonzero_at_populated0=%d escaped_live=%d pids_probe=%s "
"settle_gap_us=%ld\n",
iterations,
settle_failed,
containment_failed,
immediate,
pgrep_saw,
pids_gap,
escaped,
pids_enabled ? "on" : "off",
settle_gap_us);
return 0;
}Build and run:
gcc -O2 -Wall -o settle_race settle_race.c
export XDG_RUNTIME_DIR=/run/user/$(id -u)
scope() { systemd-run --user --scope -p Delegate=yes -p TasksMax=4096 --quiet "$@"; }
# The exact L839 workload, with 2x nproc busy loops.
for i in $(seq 1 $(( $(nproc) * 2 ))); do timeout 900 sh -c 'while :; do :; done' & done
scope ./settle_race 2000
# Leftovers that do not hold the output pipes, on an idle host.
detached='setsid sleep 60 >/dev/null 2>&1 & sleep 60 >/dev/null 2>&1 & echo spawned'
WORKLOAD="$detached" scope ./settle_race 300
# The same, with 500 us between the reap and the populated read.
WORKLOAD="$detached" SETTLE_GAP_US=500 scope ./settle_race 200Related work
- ci: Linux containment unit tests race asynchronous reaping of killed descendants (flaky on MSHV runners) #320, closed by Fix adversarial process-tree cleanup and network canary races #323, is the same class of race on the host side. The Python
terminate_process_tree()killed descendants that were still waiting for an asynchronous reaper when the test checked them, and that failure also appeared only on Linux/MSHV runners. This issue is in a different component and code path, and it is not a regression of that fix. - Add aci_edge_sandboxes with an OpenVMM sandbox backend #307 (
a2c9006ad) introducedsettle_exec_cgroup()and the L834-L845 assertion. - Support a working directory for each execution #333, which closes Support a working directory for each execution #273, is the merge under test. It did not change the settle path.
- nvx-rs: replace the guest C programs with Rust binaries #358 plans to port
nvx-managed-agentto Rust. Among the behaviors to keep, it lists "reaping orphans as PID 1" and containment that "kills throughcgroup.killand waits for the cgroup to empty". The port should carry over the corrected barrier from this issue rather than waiting for the cgroup to empty.
Suggested fix
I have not implemented a fix. settle_exec_cgroup() should return only after every process of the workload has been reaped, not as soon as the exec cgroup reports populated 0. The existing 2 s budget and the containment-failed outcome on timeout stay the same. The options, roughly in order of preference:
- Wait for
pids.currentto reach 0. The guest kernel enablesCONFIG_CGROUP_PIDS(config-microvmL190).prepare_exec_cgroup()would enable+pidsin the root cgroup'scgroup.subtree_control, and settle would keep reaping untilexec/pids.currentreads 0, instead of stopping atpopulated 0.pids.currentdrops only inrelease_task(), after PID 1 has reaped the task. In the harness,pids.currentwas nonzero in exactly the iterations that left a zombie. The pre-check at L1757-L1764 could use the same signal to refuse an exec cgroup that still holds unreaped tasks. - Keep reaping after
populated 0. One morereap_children()call narrows the window but does not close it, because a task can be betweencgroup_exit()andexit_notify()at that moment. To close the window, settle would poll until PID 1 has no exited or exiting children left. Waiting forwaitpid(-1, WNOHANG)to returnECHILDworks only if PID 1 has no other children in direct mode. Otherwise, settle would scan/procfor children of PID 1 in stateZor withPF_EXITINGset. - Freeze, enumerate, kill, then wait on pidfds. Write
cgroup.freeze, readcgroup.procs, callpidfd_open()on each process, writecgroup.kill, and wait until every pidfd reports exit before reaping. This is precise, but it needs more code than option 1.
The e2e assertions at L677-L678, L831-L832, and L844-L845 are correct as written and should stay unchanged.
Acceptance criteria
- A direct-mode exec finishes only after every process of its workload has been reaped, or it fails with
containment-failedwhen the existing settle budget runs out. - The next exec's containment pre-check refuses an exec cgroup that still holds unreaped tasks.
- A harness or guest stress test of the scenarios above shows no leftover in at least 5,000 iterations, while a negative control without the new barrier still reproduces leftovers.
openvmm_lifecycle_on_a_real_hypervisorandenvironments_leave_the_workload_containedpass with theirpgrepassertions unchanged, and the Linux/MSHV lifecycle step no longer fails at L845.
Activity
guest: settle direct-mode execs only once their processes are reaped