Add regression test for the session-straggler sweep; resolve TODO entry
test_session_cleanup.cpp exercises kill_via_cgroup() directly against two
plain forked processes (one setsid()-ing away from the other before it
exits), confirming a reparented straggler is actually reaped -- reproducing
the real escape shape (no pid namespace support at all) through a full
mount/bwrap session isn't possible from the CLI on a single run, since
--unshare-pid is a config-file-only setting, not a flag.
Also resolves TODO.md's SIGINT/SIGTERM entry and extends the relevant
CLAUDE.md sections (bwrap.{h,cpp}, session_cgroup.{h,cpp}, kill_session.{h,cpp})
with the fix's rationale and its known residual limitation (a kernel with
neither cgroup v2 nor pid namespace support still can't be reached
automatically).
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
// Copyright (C) 2026 Viorel Munteanu
|
||||
//
|
||||
// This program is free software; you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation; either version 2 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License along
|
||||
// with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
// [integration][root]: regression test for run_bwrap()'s own automatic
|
||||
// post-exit straggler sweep (bwrap.cpp) -- exercises the exported
|
||||
// kill_via_cgroup() (kill_session.h) directly against a real cgroup, rather
|
||||
// than going through the full mount/bwrap pipeline. That's a deliberate
|
||||
// choice, not a shortcut: the actual bug this targets is a process that
|
||||
// daemonizes/double-forks and setsid()'s away, reparenting outside the
|
||||
// tracked process's own tree (e.g. no pid namespace support at all, the
|
||||
// real target device's own kernel) -- reproducing that specific escape
|
||||
// through a real bwrap sandbox would need to fake the kernel's own pid
|
||||
// namespace absence (not controllable from the CLI at all -- it's a
|
||||
// config-file-only NamespaceConfig field, see bwrap.h), whereas the actual
|
||||
// mechanism under test (cgroup membership surviving reparenting, and
|
||||
// kill_via_cgroup() reaping it) is fully exercised with two plain forked
|
||||
// processes and no container/image/bwrap involved at all.
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include <csignal>
|
||||
#include <string>
|
||||
|
||||
#include <sys/wait.h>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "kill_session.h"
|
||||
#include "session_cgroup.h"
|
||||
|
||||
TEST_CASE("session cgroup sweep: kill_via_cgroup reaps a reparented straggler", "[integration][root]") {
|
||||
if (geteuid() != 0) {
|
||||
SKIP("requires root");
|
||||
}
|
||||
|
||||
const std::string container_name = "selftest-cgroup-sweep";
|
||||
|
||||
// sync_pipe makes the child wait until it's actually been moved into the
|
||||
// session cgroup before it forks its own straggler -- otherwise the
|
||||
// straggler could be forked (and thus inherit cgroup membership) before
|
||||
// create_session_cgroup() below has run, racing the exact same way
|
||||
// run_bwrap()'s own on_start callback races against bwrap forking its
|
||||
// own children.
|
||||
int sync_pipe[2];
|
||||
REQUIRE(pipe(sync_pipe) == 0);
|
||||
|
||||
pid_t tracked_pid = fork();
|
||||
REQUIRE(tracked_pid >= 0);
|
||||
if (tracked_pid == 0) {
|
||||
close(sync_pipe[1]);
|
||||
char buf = 0;
|
||||
ssize_t unused = read(sync_pipe[0], &buf, 1);
|
||||
(void)unused;
|
||||
close(sync_pipe[0]);
|
||||
|
||||
// Reset to the default disposition before forking: the straggler
|
||||
// below is expected to receive a real SIGTERM later (from
|
||||
// kill_via_cgroup()) and terminate via it -- Catch2 installs its own
|
||||
// fatal-signal handler around a running TEST_CASE, which a forked
|
||||
// child inherits (only the disposition *at fork time* matters), so
|
||||
// without this reset the straggler's own ordinary shutdown signal
|
||||
// gets caught by that inherited handler instead, producing a
|
||||
// spurious "FAILED ... due to a fatal error condition: SIGTERM"
|
||||
// report interleaved into this test's real output -- the exact same
|
||||
// lesson test_root_networking.cpp's tap-relay test already learned
|
||||
// for its own forked relay child. Confirmed cosmetic only when hit:
|
||||
// the assertions below still ran and passed correctly either way.
|
||||
signal(SIGTERM, SIG_DFL);
|
||||
|
||||
// Mirrors a container process that daemonizes and exits successfully
|
||||
// (or is signaled and dies) while a backgrounded child of its own
|
||||
// keeps running, detached into a new session -- the exact shape
|
||||
// that used to escape bwrap's own pid tree with nothing left to
|
||||
// reap it.
|
||||
pid_t straggler = fork();
|
||||
if (straggler == 0) {
|
||||
setsid();
|
||||
pause();
|
||||
_exit(0);
|
||||
}
|
||||
_exit(0);
|
||||
}
|
||||
close(sync_pipe[0]);
|
||||
|
||||
// Mirrors create_session_cgroup() being called from run_bwrap()'s own
|
||||
// on_start callback, the instant the tracked pid is known -- before it's
|
||||
// had any real chance to fork further.
|
||||
auto cgroup = create_session_cgroup(container_name, tracked_pid);
|
||||
if (!cgroup) {
|
||||
char go = 1;
|
||||
ssize_t unused = write(sync_pipe[1], &go, 1);
|
||||
(void)unused;
|
||||
close(sync_pipe[1]);
|
||||
int status = 0;
|
||||
waitpid(tracked_pid, &status, 0);
|
||||
SKIP("could not create a session cgroup on this host (see session_cgroup.h)");
|
||||
}
|
||||
|
||||
char go = 1;
|
||||
REQUIRE(write(sync_pipe[1], &go, 1) == 1);
|
||||
close(sync_pipe[1]);
|
||||
|
||||
int status = 0;
|
||||
// Mirrors run_process_foreground()'s own waitpid() on the tracked pid --
|
||||
// by the time this returns, `tracked_pid` is already gone from
|
||||
// cgroup.procs (a dead process is removed from its cgroup
|
||||
// automatically), leaving only the straggler behind, exactly like a
|
||||
// real bwrap exit would.
|
||||
waitpid(tracked_pid, &status, 0);
|
||||
|
||||
auto before = session_cgroup_pids(*cgroup);
|
||||
// Confirms the straggler really did escape into the cgroup in the first
|
||||
// place -- otherwise the kill_via_cgroup() check below would trivially
|
||||
// "pass" against an already-empty cgroup and prove nothing.
|
||||
REQUIRE_FALSE(before.empty());
|
||||
|
||||
CHECK(kill_via_cgroup(*cgroup, /*grace_period_seconds=*/2));
|
||||
CHECK(session_cgroup_pids(*cgroup).empty());
|
||||
|
||||
remove_session_cgroup(*cgroup);
|
||||
}
|
||||
Reference in New Issue
Block a user