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
+21 -1
View File
@@ -430,7 +430,27 @@ Source layout (all under `src/`):
mixes slocker-lite's own status/log output in with the sandboxed mixes slocker-lite's own status/log output in with the sandboxed
command's real output; fixed by having the sandboxed command bracket command's real output; fixed by having the sandboxed command bracket
its own output between two unique markers and extracting only what's 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]`) - `tests/integration/test_root_networking.cpp` (`[integration][root][net]`)
— the persistent-netns/tap-relay/dns-resolver tests that originally — the persistent-netns/tap-relay/dns-resolver tests that originally
lived directly in this file, ported to tagged `TEST_CASE`s (every lived directly in this file, ported to tagged `TEST_CASE`s (every
+93
View File
@@ -46,12 +46,17 @@
// wouldn't prove anything either way, so both checks below only ever look // wouldn't prove anything either way, so both checks below only ever look
// at types `detect_bwrap_unshare_args()` reports as real. // at types `detect_bwrap_unshare_args()` reports as real.
#include <time.h>
#include <unistd.h> #include <unistd.h>
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <cerrno>
#include <filesystem>
#include <fstream>
#include <sstream> #include <sstream>
#include <string> #include <string>
#include <system_error>
#include <vector> #include <vector>
#include <catch2/catch_test_macros.hpp> #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(); 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 } // namespace
TEST_CASE("rootless -r/--run: network namespace is genuinely isolated, loopback present", "[integration][net]") { 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())); 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));
}