Fix a race in create_session_cgroup(); skip the straggler test when unreachable

create_session_cgroup() used to be called from run_bwrap()'s on_start
callback, in the parent, concurrently with the just-forked child execing
into bwrap and bwrap then doing its own internal clone() of the sandboxed
target. Without --unshare-pid, bwrap has little enough setup work to do
that it could reliably win that race, cloning its target before the
parent's own write into cgroup.procs completed -- leaving that target, and
everything it later spawns, permanently outside the tracked cgroup, so the
post-exit sweep found nothing to reap. Found via the user's own request to
test "pid namespace off, cgroup on" as root: confirmed directly by
inspecting cgroup.procs mid-session, showing only bwrap's own pid.

Fixed by giving run_process_foreground() a new before_exec hook, invoked in
the child synchronously right before execvp() -- the child cannot proceed
to exec (and thus cannot trigger any of bwrap's own internal forking) until
this has already returned, closing the race structurally rather than by
timing luck. run_bwrap() now creates the session cgroup there instead of in
on_start; the parent side just reconstructs the deterministic path
unconditionally, since the downstream sweep/cleanup functions already
tolerate a nonexistent directory gracefully either way.

Also fixes a false positive found while verifying this: the regression
test's own process-matching did a substring search across a whole cmdline
blob, which matched an unrelated manual `pkill -f 'sleep 137'` diagnostic
command run by hand during the investigation. Tightened to an exact
argv[0]/argv[1] match.

Finally, the regression test now SKIP()s (instead of failing) when neither
a pid namespace nor a working session cgroup is available for the current
effective config -- a documented, known residual limitation, not a
regression -- checked directly via two new helpers rather than assumed from
e.g. geteuid().
This commit is contained in:
2026-09-05 10:28:07 +00:00
parent 4dd0f462a5
commit e047d243f2
6 changed files with 267 additions and 50 deletions
+103 -31
View File
@@ -66,6 +66,7 @@
#include "commands.h"
#include "config_file.h"
#include "fixtures.h"
#include "session_cgroup.h"
namespace {
@@ -141,17 +142,39 @@ std::string run_in_fixture(const std::filesystem::path& image, const std::vector
return capture.contents();
}
// Scans /proc for any process whose cmdline contains `needle` -- used below
// to confirm a backgrounded process detached inside a container doesn't
// survive the session, checked from the *host's* own process table. This
// works regardless of whether the session's own pid namespace is isolated
// or not: every process, wherever it lives namespace-wise, is still a
// perfectly ordinary task on the host with its own real pid and
// /proc/<pid>/cmdline entry -- only the pid *number* a process sees for
// itself differs inside an isolated namespace, not whether it shows up
// here at all. Same directory-scanning shape as bwrap.cpp's own
// find_fuse_overlayfs_pid().
bool any_process_cmdline_contains(const std::string& needle) {
// Splits a raw /proc/<pid>/cmdline blob (NUL-separated, per Linux's own
// documented format) into its individual arguments.
std::vector<std::string> split_cmdline_args(const std::string& raw) {
std::vector<std::string> args;
size_t start = 0;
while (start < raw.size()) {
size_t end = raw.find('\0', start);
if (end == std::string::npos) {
end = raw.size();
}
args.push_back(raw.substr(start, end - start));
start = end + 1;
}
return args;
}
// True if some process on the *host* is genuinely running `sleep <arg>` --
// checked from the host's own process table (works regardless of whether
// the session's own pid namespace is isolated or not: every process,
// wherever it lives namespace-wise, is still a perfectly ordinary task on
// the host with its own real pid and /proc/<pid>/cmdline entry -- only the
// pid *number* a process sees for itself differs inside an isolated
// namespace, not whether it shows up here at all). Deliberately checks
// argv[0]'s basename and argv[1] exactly, not a substring search across the
// whole cmdline -- a real bug found via this exact test session: an early
// version searched for the literal text "sleep 137" anywhere in the
// cmdline, which produced a false positive by matching this test's *own*
// diagnostic `pkill -f 'sleep 137'` cleanup commands (run by hand, outside
// the test, while investigating a separate issue) -- their own cmdline
// literally contains that text as a pkill pattern argument, despite not
// being a sleep process at all. Same directory-scanning shape as
// bwrap.cpp's own find_fuse_overlayfs_pid().
bool sleep_process_running(const std::string& arg) {
std::error_code ec;
auto it = std::filesystem::directory_iterator("/proc", ec);
if (ec) {
@@ -163,27 +186,28 @@ bool any_process_cmdline_contains(const std::string& needle) {
continue;
}
std::ifstream cmdline_file(entry.path() / "cmdline", std::ios::binary);
std::string cmdline((std::istreambuf_iterator<char>(cmdline_file)), std::istreambuf_iterator<char>());
std::replace(cmdline.begin(), cmdline.end(), '\0', ' ');
if (cmdline.find(needle) != std::string::npos) {
std::string raw((std::istreambuf_iterator<char>(cmdline_file)), std::istreambuf_iterator<char>());
auto args = split_cmdline_args(raw);
if (args.size() == 2 && std::filesystem::path(args[0]).filename() == "sleep" && args[1] == arg) {
return true;
}
}
return false;
}
// Bounded (2s, 50ms interval) poll for `needle` to disappear from the
// host's own process table -- a session ending via a pid namespace's own
// kernel-guaranteed collapse-on-pid-1-exit isn't necessarily synchronously
// complete by the instant run_in_fixture() above returns (that guarantee is
// about eventual termination, not that every other task in the namespace
// has already been fully reaped), so a single instantaneous check right
// after could be flaky. The straggler sweep this test actually targets
// (run_bwrap()'s own post-exit cgroup sweep, bwrap.cpp) already applies a
// real grace period internally for the same reason.
bool process_cmdline_gone_within(const std::string& needle, int timeout_ms) {
// Bounded (2s, 50ms interval) poll for the `sleep <arg>` process to
// disappear from the host's own process table -- a session ending via a
// pid namespace's own kernel-guaranteed collapse-on-pid-1-exit isn't
// necessarily synchronously complete by the instant run_in_fixture() above
// returns (that guarantee is about eventual termination, not that every
// other task in the namespace has already been fully reaped), so a single
// instantaneous check right after could be flaky. The straggler sweep this
// test actually targets (run_bwrap()'s own post-exit cgroup sweep,
// bwrap.cpp) already applies a real grace period internally for the same
// reason.
bool sleep_process_gone_within(const std::string& arg, int timeout_ms) {
for (int waited = 0; waited < timeout_ms; waited += 50) {
if (!any_process_cmdline_contains(needle)) {
if (!sleep_process_running(arg)) {
return true;
}
struct timespec ts {
@@ -192,7 +216,44 @@ bool process_cmdline_gone_within(const std::string& needle, int timeout_ms) {
while (nanosleep(&ts, &ts) != 0 && errno == EINTR) {
}
}
return !any_process_cmdline_contains(needle);
return !sleep_process_running(arg);
}
// Whether the current -t run's effective config (g_test_app_config,
// fixtures.h) would actually get bwrap to request --unshare-pid for a
// container it creates -- both the config's own policy *and* live kernel
// support (detect_bwrap_unshare_args(), bwrap.h) have to allow it, the same
// two-gate check build_bwrap_args() itself applies.
bool pid_namespace_would_isolate() {
if (!g_test_app_config.unshare_pid.value_or(true)) {
return false;
}
auto supported = detect_bwrap_unshare_args();
return std::find(supported.begin(), supported.end(), "--unshare-pid") != supported.end();
}
// Whether run_bwrap()'s own session cgroup (session_cgroup.h) would actually
// be creatable/writable right now -- tried directly against a throwaway,
// never-joined path rather than guessing from e.g. geteuid(), since the real
// failure mode this needs to detect (no delegated subtree when rootless) is
// exactly a permissions question create_directories()/access() can answer
// directly. Deliberately never writes this process's own pid into the
// probe directory's cgroup.procs (unlike the real create_session_cgroup()),
// so cleanup is just removing the still-empty directory -- no need to
// un-join anything first.
bool session_cgroup_would_work() {
if (!cgroup_v2_available()) {
return false;
}
auto probe_path = session_cgroup_path("selftest-cgroup-probe", getpid());
std::error_code ec;
std::filesystem::create_directories(probe_path, ec);
if (ec) {
return false;
}
bool writable = access((probe_path / "cgroup.procs").c_str(), W_OK) == 0;
std::filesystem::remove(probe_path, ec);
return writable;
}
} // namespace
@@ -295,14 +356,25 @@ TEST_CASE("rootless -r/--run: a nohup-backgrounded process does not survive the
SKIP("no busybox fixture (images/busybox.tar) -- see tests/setup-tests.py");
}
// With neither a pid namespace nor a working session cgroup available,
// there is genuinely no mechanism left that could reap a reparented
// straggler -- a documented, known limitation (CLAUDE.md's
// session_cgroup.{h,cpp} entry, "Known residual limitation"), not a
// regression this test should be flagging. Skip rather than fail so a
// deliberately-crippled config (e.g. via -c/--config-file, g_test_app_config
// above) doesn't produce a misleading failure here.
if (!pid_namespace_would_isolate() && !session_cgroup_would_work()) {
SKIP("neither a pid namespace nor a working session cgroup is available under the "
"current config/kernel -- no mechanism exists to reap a reparented straggler here");
}
// A distinctive duration -- not a realistic value anything else on this
// host would coincidentally already be sleeping for -- so scanning
// /proc for it can't produce a false positive either way.
const std::string marker = "sleep 137";
REQUIRE_FALSE(any_process_cmdline_contains(marker));
// host would coincidentally already be sleeping for.
const std::string marker = "137";
REQUIRE_FALSE(sleep_process_running(marker));
ScratchXdgDirs scratch;
run_in_fixture(*image, {"sh", "-c", "nohup sleep 137 >/dev/null 2>&1 & exit"});
CHECK(process_cmdline_gone_within(marker, 2000));
CHECK(sleep_process_gone_within(marker, 2000));
}