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:
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user