Skip to content

ci: managed agent finishes a direct-mode exec before reaping its killed processes, failing the aci_edge_sandboxes lifecycle test on Linux/MSHV #360

Closed

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

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 no sleep was running before the detached exec.
  • The detached exec succeeded (L841-L843), so settle_exec_cgroup() returned 0 for it.
  • The pgrep exec passed the agent's containment pre-check (L1757-L1764). That check fails an exec with containment-failed unless the exec cgroup reports populated 0.
  • The guest's init runs sleep only in the foreground before it execs the agent on the managed path (init L223-L235). Its only background jobs belong to the netsnap benchmark path, which comes after that exec and 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.

  1. Containment model. In direct mode, the agent is PID 1, because the managed lifecycle execs it from init (init L232-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.c L820-L839).
  2. The exec can end while its killed processes are still exiting. The shell exits after echo spawned. Both sleeps are reparented to PID 1 and keep the output pipes open. The poll loop therefore writes cgroup.kill (L1959-L1965) and keeps polling until both pipes reach EOF (L1880). do_exit() releases a task's files in exit_files()/exit_task_work() before it calls cgroup_exit() and exit_notify() (exit.c L967-L984). The loop can therefore end while a killed sleep is still exiting.
  3. Settle trusts populated. settle_exec_cgroup() (L970-L1002) writes cgroup.kill again and then loops. Each pass reaps with waitpid(-1, WNOHANG) (L987) and reads cgroup.events (L988). It returns 0 as soon as it reads populated 0 (L992-L994).
  4. populated 0 does 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 before exit_notify() makes it a zombie child of PID 1. If a sleep passes both points between L987 and L988, settle never reaps it, yet it satisfies populated 0, and the agent reports the exec as finished.
  5. Nothing reaps it before the next workload starts. The agent reaps only inside run_exec(), in reap_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 same populated signal, so the next workload starts while the zombie sleep is still in /proc. pgrep -x sleep then 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 exec cgroup inside a delegated systemd-run --user --scope -p Delegate=yes subtree.
  • It copies reap_children(), the direct-mode exit path of the run_exec() poll loop (including the straggler kill), and settle_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 pids controller on the subtree and reads pids.current when settle sees populated 0. That counter drops only when a task is reaped: release_task() calls cgroup_release(), which calls pids_release() (exit.c L244-L260, pids.c L294-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 200

Related work

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:

  1. Wait for pids.current to reach 0. The guest kernel enables CONFIG_CGROUP_PIDS (config-microvm L190). prepare_exec_cgroup() would enable +pids in the root cgroup's cgroup.subtree_control, and settle would keep reaping until exec/pids.current reads 0, instead of stopping at populated 0. pids.current drops only in release_task(), after PID 1 has reaped the task. In the harness, pids.current was 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.
  2. Keep reaping after populated 0. One more reap_children() call narrows the window but does not close it, because a task can be between cgroup_exit() and exit_notify() at that moment. To close the window, settle would poll until PID 1 has no exited or exiting children left. Waiting for waitpid(-1, WNOHANG) to return ECHILD works only if PID 1 has no other children in direct mode. Otherwise, settle would scan /proc for children of PID 1 in state Z or with PF_EXITING set.
  3. Freeze, enumerate, kill, then wait on pidfds. Write cgroup.freeze, read cgroup.procs, call pidfd_open() on each process, write cgroup.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-failed when 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_hypervisor and environments_leave_the_workload_contained pass with their pgrep assertions unchanged, and the Linux/MSHV lifecycle step no longer fails at L845.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

confirmedIssue affects multiple people.

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions