Add regression test: a nohup-backgrounded process doesn't survive a session

Reproduces the user's reported real-world shape end to end with a real
busybox container ("nohup sleep 137 & exit"), verifying via the host's own
/proc that the backgrounded process is actually gone afterward -- checked
by cmdline substring, not pid, since a pid seen inside an isolated pid
namespace doesn't correspond to the same-numbered host pid. A bounded 2s
poll guards against the pid namespace's own kernel collapse-on-pid-1-exit
timing (which already covers this case for free on this dev machine).

Complements test_session_cleanup.cpp's existing [integration][root] test,
which exercises kill_via_cgroup() directly -- this one instead proves the
outward, visible contract holds through the real -r/--run path.
This commit is contained in:
2026-09-05 09:50:49 +00:00
parent cbec986e78
commit a22024fcf8
2 changed files with 114 additions and 1 deletions
+93
View File
@@ -46,12 +46,17 @@
// wouldn't prove anything either way, so both checks below only ever look
// at types `detect_bwrap_unshare_args()` reports as real.
#include <time.h>
#include <unistd.h>
#include <algorithm>
#include <cctype>
#include <cerrno>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
#include <vector>
#include <catch2/catch_test_macros.hpp>
@@ -131,6 +136,60 @@ 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) {
std::error_code ec;
auto it = std::filesystem::directory_iterator("/proc", ec);
if (ec) {
return false;
}
for (const auto& entry : it) {
const std::string name = entry.path().filename().string();
if (!std::all_of(name.begin(), name.end(), [](unsigned char c) { return std::isdigit(c); })) {
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) {
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) {
for (int waited = 0; waited < timeout_ms; waited += 50) {
if (!any_process_cmdline_contains(needle)) {
return true;
}
struct timespec ts {
0, 50L * 1000000L
};
while (nanosleep(&ts, &ts) != 0 && errno == EINTR) {
}
}
return !any_process_cmdline_contains(needle);
}
} // namespace
TEST_CASE("rootless -r/--run: network namespace is genuinely isolated, loopback present", "[integration][net]") {
@@ -208,3 +267,37 @@ TEST_CASE("rootless -r/--run: every kernel-supported namespace type differs from
CHECK(lines[i] != read_own_namespace_link(to_check[i].c_str()));
}
}
// Regression test for run_bwrap()'s own automatic post-exit straggler sweep
// (bwrap.cpp/session_cgroup.h's "Resolved" entries in CLAUDE.md) -- the
// concrete real-world shape the user reported: a container backgrounds a
// long-running process with nohup (so it survives its own parent shell's
// exit and ignores SIGHUP) and exits immediately, and that process must not
// keep running after the session itself has ended. Unlike
// test_session_cleanup.cpp's own [integration][root] test (which exercises
// kill_via_cgroup() directly against two plain forked processes, since the
// real target device's own no-pid-namespace escape shape can't be forced
// via the CLI), this goes through the exact real -r/--run path end to end
// with a real container -- on a kernel that supports pid namespaces (this
// dev machine included), the kernel's own collapse-on-pid-1-exit guarantee
// already covers this case for free, so passing here doesn't by itself
// prove the cgroup sweep specifically fired; it proves the outward, visible
// contract this feature exists for ("a stray process never survives a
// session") holds end to end regardless of which mechanism provided it.
TEST_CASE("rootless -r/--run: a nohup-backgrounded process does not survive the session", "[integration][net]") {
auto image = find_busybox_fixture();
if (!image) {
SKIP("no busybox fixture (images/busybox.tar) -- see tests/setup-tests.py");
}
// 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));
ScratchXdgDirs scratch;
run_in_fixture(*image, {"sh", "-c", "nohup sleep 137 >/dev/null 2>&1 & exit"});
CHECK(process_cmdline_gone_within(marker, 2000));
}