From a22024fcf8020d0dc4834b0a0c39006797fa2118 Mon Sep 17 00:00:00 2001 From: Viorel Munteanu Date: Sat, 5 Sep 2026 09:50:49 +0000 Subject: [PATCH] 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. --- CLAUDE.md | 22 +++++- tests/integration/test_rootless_run.cpp | 93 +++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 749f22d..a4392e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -430,7 +430,27 @@ Source layout (all under `src/`): mixes slocker-lite's own status/log output in with the sandboxed command's real output; fixed by having the sandboxed command bracket its own output between two unique markers and extracting only what's - strictly between them. + strictly between them. **Also has a regression test for `run_bwrap()`'s + own automatic post-exit straggler sweep** (`bwrap.cpp`/`session_cgroup.h`'s + "Resolved" entries), reproducing the user's own reported shape end to + end with a real container: `nohup sleep 137 & exit` inside the sandbox, + then `any_process_cmdline_contains()`/`process_cmdline_gone_within()` + (`.cpp`-local, same `/proc`-scanning shape as `bwrap.cpp`'s own + `find_fuse_overlayfs_pid()`) confirm the backgrounded process is gone + from the *host's* own process table afterward — checked by cmdline + substring, not by pid, since a pid seen from 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 guarantee (covering this case for free on any kernel that + supports pid namespaces, this dev machine included) not necessarily + being synchronously complete by the instant `dispatch_command()` + returns. Passing here proves the outward, visible contract ("a stray + process never survives a session") end to end, though on a + pid-namespace-capable host it doesn't by itself prove the cgroup sweep + specifically fired — see `test_session_cleanup.cpp`'s own + `[integration][root]` test (below) for one that exercises + `kill_via_cgroup()` directly, since the real target device's own + no-pid-namespace escape shape can't be forced via the CLI at all. - `tests/integration/test_root_networking.cpp` (`[integration][root][net]`) — the persistent-netns/tap-relay/dns-resolver tests that originally lived directly in this file, ported to tagged `TEST_CASE`s (every diff --git a/tests/integration/test_rootless_run.cpp b/tests/integration/test_rootless_run.cpp index 5c22e58..992dbf4 100644 --- a/tests/integration/test_rootless_run.cpp +++ b/tests/integration/test_rootless_run.cpp @@ -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 #include #include #include +#include +#include +#include #include #include +#include #include #include @@ -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//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(cmdline_file)), std::istreambuf_iterator()); + 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)); +}