Add -k/--kill <pid> to fully stop a running session

A plain `kill <tracked_bwrap_pid>` doesn't kill everything a container
started: on a kernel without pid namespace support, a service that
daemonizes (double-forks and detaches) before the entrypoint execs into its
main command reparents all the way to the *host's* own pid 1, completely
disconnected from the sandboxed session -- confirmed via a real session log
from the user's own Android target device, where php-fpm and caddy both
kept running as orphans after killing the tracked pid.

kill_session() (src/kill_session.{h,cpp}) picks between three
independently-named strategies per session, based on what's actually
available for it:
- kill_via_cgroup(): preferred when the session has a dedicated cgroup
  (src/session_cgroup.{h,cpp}, set up at -r/--run time in run_bwrap()'s
  on_start callback). Reaches every process the session ever started,
  daemonized/reparented or not, regardless of pid namespace support.
- kill_via_pid_namespace(): used when --unshare-pid was genuinely in effect
  for the session (src/sandbox_process.{h,cpp}, shared with exec_session.cpp,
  which already needed resolve_namespace_pid()). Relies on the kernel's own
  guarantee that killing a pid namespace's pid 1 tears down everything in it.
- kill_via_tracked_pid(): fallback, signals the tracked pid directly -- no
  worse than today's manual kill. This is what the user's real target
  device currently falls back to (no pid namespace support there).

Each strategy sends SIGTERM, waits up to a 10s grace period, then forces a
SIGKILL. Verified locally (rootless dev machine, which does support pid
namespaces): a daemonizing test session was fully cleaned up via
kill_via_pid_namespace(), including the forced-SIGKILL escalation path,
with no leftover processes, mounts, or layers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
This commit is contained in:
2026-08-29 09:43:12 +00:00
parent 247e61d9d8
commit 8c4afb82e8
16 changed files with 852 additions and 114 deletions
+170 -20
View File
@@ -38,7 +38,12 @@ Source layout (all under `src/`):
`Mode::exec` dispatch arm) `-e/--exec <pid>`'s own pid parsing/validation
(`ParsedArgs::exec_pid`, a positive integer or a hard error) and its
trailing-command requirement (`ParsedArgs::command`, required non-empty).
Returns an exit code `main()` should return immediately (`0` for `-h`/`-V`,
`-k/--kill <pid>` (`ParsedArgs::kill_pid`) shares that same positive-integer
parsing/validation via a small extracted `parse_pid_arg()` helper (`.cpp`-local)
rather than duplicating the `strtol` dance a second time — unlike `-e/--exec`,
it takes no trailing command, so it's simply not added to the leftover-args
exemption list (`Mode::run`/`Mode::exec` only). Returns an exit code `main()`
should return immediately (`0` for `-h`/`-V`,
`1` for any parse error) when set; `nullopt` means `out` is ready for
`dispatch_command()`. `-D/--daemonize` (has a short form; `'D'` was free) is
a plain boolean flag (`ParsedArgs::daemonize_flag`, set in its own `case
@@ -257,7 +262,12 @@ Source layout (all under `src/`):
same `on_start` timing — `-D/--daemonize` (`daemonize.{h,cpp}`, see below)
hooks in here via `report_daemon_started()` to learn the real pid at the
same instant everything else that needs it does, rather than needing its own
separate pid-discovery mechanism.
separate pid-discovery mechanism. That same `on_start` lambda also calls
`create_session_cgroup()` (`session_cgroup.h`, see below), right alongside
`create_session_lock()`, so `-k/--kill` can later find every process the
session ever starts via its dedicated cgroup; `remove_session_cgroup()` is
called from the same post-`run_process_foreground()` spot
`release_session_lock()` already is.
- `priv_drop_helper.cpp` → the separate `slocker-lite-priv-drop` binary (its own
`executable()` target in `meson.build`, **built with `-static`**). Deliberately
has zero dependencies on the rest of this project (no fmt/spdlog/etc.) and is
@@ -325,7 +335,11 @@ Source layout (all under `src/`):
- `pid_file.{h,cpp}` — tracks one running `-r/--run` session (a live `bwrap`
process) as a locked pid file, so an outside process (or a later
`slocker-lite` invocation) can tell whether it's still running.
`session_pid_file_path()` resolves
`sanitize_for_filename()` (anything outside `[A-Za-z0-9._-]``_`, falling
back to `"container"` if that leaves nothing) is exported here (not just
`.cpp`-local) specifically so `session_cgroup.{h,cpp}` (see below) can reuse
the exact same `<name>-<pid>` naming rule for its own per-session cgroup
directory without drifting from this file's own. `session_pid_file_path()` resolves
`$XDG_STATE_HOME/slocker-lite/run/<container_name>-<pid>` (falling back to
`$HOME/.local/state/...` when `XDG_STATE_HOME` is unset/empty — same
resolution pattern as `config_file_path()` below, for state instead of
@@ -369,6 +383,76 @@ Source layout (all under `src/`):
a check and a later removal. Only files it actually removes are reported back
(as `SessionInfo`s with `running=false`); still-locked (running) files are
left untouched and not reported.
- `session_cgroup.{h,cpp}` — gives `-k/--kill` (`kill_session.{h,cpp}`, see
below) a reliable way to find every process a session ever started, however
deeply forked/daemonized/reparented, by putting it in a dedicated cgroup v2
group from the moment it starts. `cgroup_v2_available()` checks for
`/sys/fs/cgroup/cgroup.controllers` (the same signal systemd's own
unified-hierarchy detection uses) — only cgroup v2 is supported; v1 (which
splits per-controller into separate hierarchies with no unified
`cgroup.procs` at the top) is deliberately out of scope, since this
project's real target (Android) has used the unified v2 hierarchy by
default since Android 12. `session_cgroup_path()` is deterministic —
`/sys/fs/cgroup/slocker-lite/<name>-<pid>/`, reusing `pid_file.h`'s own
`sanitize_for_filename()` — so no separate lookup state is needed anywhere.
`create_session_cgroup()` is called from `run_bwrap()`'s `on_start` callback
(`bwrap.cpp`, see below), the same spot `create_session_lock()` already
fires from: `create_directories()`'s the leaf directory (also creating the
`slocker-lite/` parent the first time — a plain grouping cgroup, no resource
controllers are ever enabled on it via `cgroup.subtree_control`, so the "no
internal processes" restriction that comes with actually delegating
controllers never applies here) and writes the bwrap pid into its
`cgroup.procs`. From that point on, every process bwrap (or anything it
execs into) forks inherits this cgroup automatically, permanently —
including anything that later daemonizes/double-forks and gets reparented,
unlike pid-namespace child membership (only the processes `clone()` itself
creates) or process-group membership (many daemonizing services explicitly
`setpgid()`/`setsid()` away from it on purpose). Best-effort, mirroring
`create_session_lock()`: returns `nullopt` (logging a warning, never fatal)
if cgroup v2 isn't available, or the directory can't be created/written
(no delegated subtree when running rootless, or an SELinux policy blocking
cgroupfs writes even for a root-euid process, are both real, confirmed-by-
testing causes on the two environments this project actually runs on).
`remove_session_cgroup()` (called from the same post-`run_process_foreground()`
spot `release_session_lock()` already is) only succeeds once the cgroup is
empty — a straggler process still alive at normal exit (e.g. a daemonized
process that outlived the session's own main command, a pre-existing
exposure independent of this feature) leaves it in place with a warning, not
a fatal error. `session_cgroup_pids()` reads `cgroup.procs` — this is the
actual answer to "gather every process running inside the container":
unlike anything derived from `/proc` parent-pid chains or pid namespaces,
cgroup membership reliably includes every process the session ever started.
`session_cgroup_supports_kill()`/`kill_session_cgroup()` wrap the
`cgroup.kill` knob (Linux 5.14+): writing `"1"` to it atomically `SIGKILL`s
every process currently in the cgroup in one step.
- `sandbox_process.{h,cpp}` — process-tree/namespace-resolution utilities
shared by `exec_session.{h,cpp}` and `kill_session.{h,cpp}` (see both
below); pulled into their own file (rather than staying private to
`exec_session.cpp`, where `resolve_namespace_pid()` originally lived) once
`-k/--kill` needed the exact same "find the real sandboxed child" logic, to
avoid a second, drifting copy. `resolve_namespace_pid()` is unchanged from
its original `exec_session.cpp` form (see that entry for the full
reasoning: bwrap's own outer/tracked pid never actually enters the
pid/uts/ipc/cgroup namespaces it creates for its clone()'d child, only that
child does). Two new utilities added alongside it for `kill_session()`:
`pid_namespace_isolated(outer_pid, ns_pid)` compares
`/proc/<outer_pid>/ns/pid` and `/proc/<ns_pid>/ns/pid`'s own `readlink()`
targets directly — true only when bwrap's `clone()` actually created a
separate pid namespace for its child (`--unshare-pid` was requested *and*
the kernel supported it), the precondition for the kernel's own guarantee
that killing a pid namespace's pid 1 forcibly tears down every remaining
process in it. `collect_descendant_pids(root)` generalizes
`resolve_namespace_pid()`'s own `/proc/<n>/stat` ppid-scanning fallback to
collect a whole transitive tree (root included) instead of just one child,
sharing the actual stat-parsing loop between both via a private
`build_ppid_map()` (one `/proc` pass, used by both the single-child lookup
and the full-tree collection). Reliable specifically when `root` is a
genuinely isolated pid namespace's own pid 1: anything that reparents
within it (e.g. a daemonizing service) is guaranteed by the kernel to land
back on `root` itself, unlike on a kernel without pid namespace support,
where it escapes to the *host's* real pid 1 instead (see
`kill_session.{h,cpp}` below for exactly this scenario, confirmed on a real
target device).
- `daemonize.{h,cpp}` — implements `-D/--daemonize`'s fork/detach mechanics.
`daemonize(container_name)` sets up a `pipe2(..., O_CLOEXEC)` pair (so it
never leaks into `bwrap`/the sandboxed command, same reasoning as the pid
@@ -433,22 +517,9 @@ Source layout (all under `src/`):
itself never actually enters those namespaces — comparing
`/proc/<outer_pid>/ns/{pid,uts,ipc,cgroup}` against this process's own showed
them identical, while only `mnt`/`user` differed. `resolve_namespace_pid()`
reads `/proc/<pid>/task/<pid>/children` (the direct-children list `procfs`
exposes) to find that real inner process and joins *its* namespaces instead.
**Real bug found via testing on a real target device, not assumed**: that
file requires `CONFIG_CHECKPOINT_RESTORE`, which not every kernel enables —
confirmed absent (not just unreadable — the file doesn't exist at all) on a
real Android device, where `-e/--exec` then fell back to the outer `bwrap`
pid itself and failed outright (`nsenter: no namespace specified`, since
every namespace type either matched the outer process's own or couldn't be
read at all). Fixed by adding `find_child_by_scanning_proc()`, a portable
fallback used only when the children file is missing/empty: scans
`/proc/<n>/stat` for any process whose ppid field equals `pid` — the same
information `pstree` itself reads to build its tree, which is how the actual
sandboxed child was located and confirmed correct on the same device via a
manual `nsenter -t <child_pid> -a -- /bin/sh` before the fix was written.
Picks the lowest matching pid if more than one child exists, for a
deterministic result. For each of `{mnt→--mount, uts→--uts, ipc→--ipc, pid→--pid,
(`sandbox_process.{h,cpp}` — moved out of this file once `-k/--kill`
needed the exact same logic, see that entry) finds that real inner process
so this can join *its* namespaces instead. For each of `{mnt→--mount, uts→--uts, ipc→--ipc, pid→--pid,
cgroup→--cgroup, user→--user}` (`net` deliberately excluded — this project
never isolates networking, see `bwrap.cpp` below), `readlink()`s both
`/proc/<ns_pid>/ns/<type>` and `/proc/self/ns/<type>` and only passes
@@ -513,6 +584,85 @@ Source layout (all under `src/`):
real `250:250` via the fetched `/etc/passwd` and then fails clearly (helper not
bind-mounted, since the session itself had no declared user) rather than
silently running as the wrong identity.
- `kill_session.{h,cpp}` — implements `-k/--kill <pid>`, stopping a tracked,
running `-r/--run` session and everything it started. `kill_session()`
validates `pid` the same way `exec_in_session()` does (via `list_sessions()`,
`pid_file.h`). **Real bug reported by the user against their own actual
target device, confirmed via a captured session log**: a plain `kill
<tracked_bwrap_pid>` doesn't kill everything a container started — their
`/init` script `php-fpm --daemonize`s (double-forks, detaches) then `exec
caddy ...`s (replaces itself); after killing the tracked pid, both `caddy`
and the `php-fpm` master+workers kept running as orphans. **Root cause**: on
that device, `bwrap`'s `--unshare-pid` isn't actually in effect at all —
`detect_bwrap_unshare_args()` (`bwrap.cpp`) only requests `--unshare-xxx`
flags the kernel actually supports, and that kernel doesn't support pid
namespaces (independently confirmed elsewhere this session, see
`exec_session.{h,cpp}`'s own `CONFIG_CHECKPOINT_RESTORE` bug above) — so
`php-fpm --daemonize` reparents to the *host's own* pid 1, completely
disconnected from the sandboxed session; the classic "kill a pid namespace's
pid 1, the kernel guarantees the whole namespace collapses" trick simply
doesn't apply there. Per the user's own explicit request (they want to
choose the mechanism per host capability, and may need an even more basic
one later for some hypothetical older device), `kill_session()` picks
between three independently-named strategies, selected dynamically per
session (not a single cached host-wide capability flag, since e.g. cgroup
creation can fail for session-specific reasons like permissions even on a
host that generally supports cgroups) — each runs its own complete
`SIGTERM` → wait-up-to-`grace_period_seconds` (10s default, no CLI flag) →
forced-`SIGKILL` escalation internally, with no cross-strategy
fallback-after-failure chaining:
1. `kill_via_cgroup()` — preferred whenever the session has a non-empty
dedicated cgroup (`session_cgroup_pids()`, `session_cgroup.h`): `SIGTERM`
to every pid currently in it, and, if forcing is needed, either the
atomic `cgroup.kill` knob or a fresh re-read-and-`SIGKILL` sweep (fresh,
not the original snapshot, since a process could have forked a new child
after the graceful sweep but before dying). The **only** mechanism that
reliably reaches every process regardless of pid namespace support.
**Critical correctness point, caught during design review before this
shipped**: the "is it stopped yet" poll must gate on the *cgroup being
empty*, not `list_sessions()`'s running flag — that flag only reflects
the pid file's flock, released the moment the tracked outer `bwrap` pid
exits, and the `SIGTERM` sweep necessarily hits `bwrap` itself too (it's
a cgroup member) — `bwrap` dies and gets reaped in well under a second,
long before slower descendants (`caddy` shutting down gracefully,
`php-fpm` finishing in-flight requests) actually exit. Gating on the pid
file instead would make the poll resolve "done" almost immediately, the
forced-kill step would never run, and the original bug would reproduce
with unused machinery around it.
2. `kill_via_pid_namespace()` — used when no cgroup exists for the session,
but `resolve_namespace_pid()`/`pid_namespace_isolated()`
(`sandbox_process.h`) confirm `--unshare-pid` was genuinely in effect for
it. `SIGTERM`s `collect_descendant_pids(ns_pid)` (reliable here
specifically because reparenting within a genuinely isolated pid
namespace always lands back on that namespace's own pid 1); if forcing
is needed, a single `SIGKILL` to `ns_pid` alone is *guaranteed* complete
by the kernel itself, independent of whatever the graceful sweep missed.
"Stopped" is simply `kill(ns_pid, 0)` failing with `ESRCH`. Verified
end-to-end on this project's rootless dev machine (which does support
pid namespaces, unlike the user's real target device): a daemonized
busybox session running `sh -c 'sleep 300 & exec sleep 300'` (mirroring
the daemonize-then-exec shape of the original bug) was fully cleaned up
by `-k/--kill`, including the backgrounded child, with no leftover
processes, mounts, or layers; a second run using `sh -c 'trap "" TERM;
sleep 300'` (ignoring `SIGTERM` entirely) confirmed the forced-`SIGKILL`
escalation path too, taking the full 10s grace period before the pid
namespace's own collapse-on-kill guarantee cleaned it up regardless.
3. `kill_via_tracked_pid()` — fallback when neither of the above applies:
signals the tracked `bwrap` pid directly, `SIGTERM` then `SIGKILL`,
polling `list_sessions()` for "stopped" since that's the only signal
available without a cgroup or an isolated pid namespace to check
directly. Exactly today's manual-`kill` behavior — least complete, but
always available, and strictly no worse than before this feature
existed. This is the path the user's own real target device actually
takes today (no cgroup delegation confirmed working there yet; no pid
namespace support at all) — a future, even more basic strategy (for some
hypothetical still-more-limited device) would slot in here the same way,
per the user's own explicit request to keep this extensible.
`poll_until()`/`sleep_ms()` (`.cpp`-local) use `nanosleep()` in an
`EINTR`-retry loop — matching this project's existing direct-POSIX style
(`process.cpp` already retries `waitpid()` the same way) — rather than
`<thread>`/`<chrono>` (unused anywhere else in this project).
- `config_file.{h,cpp}``load_config_file()` reads and parses (via libyaml's
document API, `<yaml.h>`) the `global` and `volumes` sections of the local YAML
config file located by `config_file_path()` (`$XDG_CONFIG_HOME/slocker-lite/config.yaml`,
@@ -628,7 +778,7 @@ Build directory is `buildDir/` (already configured).
(see `priv_drop_helper.cpp` in "Project state")
- Run the executable: `./buildDir/slocker-lite -m <image.tar>` (see `--help` for the
full flag list: `-m/--mount`, `-r/--run`, `-u/--umount`, `-c/--cleanup`,
`-l/--list-images`, `-i/--inspect`, `-e/--exec`, `-n/--no-nsenter`, `-D/--daemonize`,
`-l/--list-images`, `-i/--inspect`, `-e/--exec`, `-k/--kill`, `-n/--no-nsenter`, `-D/--daemonize`,
`--user`, `--group`, `--hostname`, `--env`, `--env-file`, `-v/--volume`, `--list-volumes`, `--delete-volume`,
`--delete-volume-full`, `--list-processes`, `--clean-processes`, `-t/--test`, `--log-level`,
`-h/--help`, `-V/--version`)
+18
View File
@@ -59,6 +59,7 @@ slocker-lite -c|--cleanup <layer-id>
slocker-lite -l|--list-images <directory>
slocker-lite -i|--inspect <image.tar>
slocker-lite -e|--exec <pid> [-- <command> [args...]]
slocker-lite -k|--kill <pid>
slocker-lite -v|--volume <name> <directory>
slocker-lite --list-volumes
slocker-lite --delete-volume <name>
@@ -86,6 +87,7 @@ slocker-lite -V|--version
| `-l, --list-images <dir>` | List OCI Image Layout tars (`*.tar`, `*.tar.*`) found directly in `<dir>`, with their `name:tag`. |
| `-i, --inspect <image.tar>` | Print an image's declared user, exposed ports, env, volumes, and default command, without mounting or running it. |
| `-e, --exec <pid>` | Join an already-running `--run` session (`<pid>` must be one `--list-processes` shows as `running`) and run a command inside its container. Pass `-- <command> [args...]` to specify it. |
| `-k, --kill <pid>` | Stop a running `--run` session (`<pid>` must be one `--list-processes` shows as `running`): sends `SIGTERM`, waits up to 10s, then forces it with `SIGKILL`. Reaches every process the session started — including daemonized/reparented ones a plain `kill <pid>` would leave behind — via a dedicated cgroup when available, or the sandboxed pid namespace's own collapse-on-kill guarantee when not, falling back to signaling the tracked pid alone if neither applies. |
| `-v, --volume <name> <dir>` | Create a named volume mapped to a host directory (created if missing), recorded in the config file's `volumes` section. Fails if the name or directory is already used by an existing volume. Volume names can't contain `/`. With `--run`, instead mounts a volume into the sandbox (repeatable): `<name>` is an existing named volume, or, if it contains `/`, a host directory path (created if missing); `<dir>` is the absolute path inside the container to mount it at. If the host directory is empty and the image already has content there, that content is copied in first, preserving numeric ownership/permissions/links and, where the host filesystem supports them, extended attributes/ACLs (skipped with a warning otherwise). |
| `--list-volumes` | List all named volumes (see `-v/--volume`) with their host directory. |
| `--delete-volume <name>` | Remove a named volume from the config. The host directory is left untouched. |
@@ -212,6 +214,22 @@ rather than joining the outer `bwrap` process's own namespaces, so the joined
command sees the container's process tree and hostname too, not just its
filesystem.
`-k/--kill <pid>` stops a running session and everything it started — not just
the tracked `bwrap` process. A plain `kill <pid>` can leave processes behind: a
container whose entrypoint daemonizes a service (double-forks and detaches)
before `exec`-ing its main command can end up with that service reparented
somewhere `bwrap` dying never reaches, especially on a kernel without pid
namespace support, where it reparents all the way to the *host's* own pid 1.
`-k/--kill` picks between three mechanisms depending on what's actually
available for that session: a dedicated cgroup (set up at `-r/--run` time,
reliably includes every process the session ever started regardless of
daemonizing or pid namespace support — the most complete option, when the
kernel and permissions allow it), the sandboxed pid namespace's own
collapse-on-kill guarantee (when `--unshare-pid` was genuinely in effect for
that session), or, failing both, signaling the tracked process directly (no
worse than today's manual `kill`). Either way it sends `SIGTERM` first,
waits up to 10 seconds, then escalates to `SIGKILL`.
`-D/--daemonize` forks and detaches into the background by calling `setsid()`
itself, rather than re-enabling `bwrap`'s own `--new-session` — that flag only
detaches the deeply-nested sandboxed command, leaving `bwrap`/`nsenter` still
+2 -1
View File
@@ -20,7 +20,8 @@ slocker_lite = executable('slocker-lite',
['src/main.cpp', 'src/cli_args.cpp', 'src/commands.cpp', 'src/self_test.cpp',
'src/process.cpp', 'src/oci_image.cpp', 'src/containers_storage.cpp',
'src/bwrap.cpp', 'src/user_spec.cpp', 'src/config_file.cpp', 'src/volume_mount.cpp',
'src/pid_file.cpp', 'src/exec_session.cpp', 'src/env_spec.cpp', 'src/daemonize.cpp'],
'src/pid_file.cpp', 'src/exec_session.cpp', 'src/env_spec.cpp', 'src/daemonize.cpp',
'src/sandbox_process.cpp', 'src/session_cgroup.cpp', 'src/kill_session.cpp'],
include_directories : include_directories('.'),
dependencies : [fmt_dep, catch2_dep, yaml_dep, archive_dep, json_dep, spdlog_dep],
install : true)
+11
View File
@@ -34,6 +34,7 @@
#include "pid_file.h"
#include "process.h"
#include "session_cgroup.h"
// Locates the priv-drop helper installed next to this process's own binary (found
// via /proc/self/exe), which holds whether run from buildDir/ or after a proper
@@ -337,10 +338,17 @@ int run_bwrap(const std::string& root, const std::vector<std::string>& command,
}
std::optional<SessionLock> session_lock;
std::optional<SessionCgroup> session_cgroup;
int exit_code = run_process_foreground(
*argv,
[&](pid_t pid) {
session_lock = create_session_lock(container_name, pid);
// Best-effort, like the session lock above -- lets kill_session()
// (kill_session.cpp) reach every process this session ever starts,
// including anything that later daemonizes/double-forks and gets
// reparented, regardless of whether the kernel supports pid
// namespaces at all. See session_cgroup.h.
session_cgroup = create_session_cgroup(container_name, pid);
if (on_bwrap_pid_known) {
on_bwrap_pid_known(pid);
}
@@ -350,6 +358,9 @@ int run_bwrap(const std::string& root, const std::vector<std::string>& command,
if (session_lock) {
release_session_lock(*session_lock);
}
if (session_cgroup) {
remove_session_cgroup(*session_cgroup);
}
return exit_code;
}
+39 -6
View File
@@ -46,7 +46,7 @@ constexpr int env = 265;
constexpr int env_file = 266;
} // namespace options
constexpr std::array<struct option, 25> long_options = {{
constexpr std::array<struct option, 26> long_options = {{
{"help", no_argument, nullptr, 'h'},
{"version", no_argument, nullptr, 'V'},
{"test", no_argument, nullptr, 't'},
@@ -66,6 +66,7 @@ constexpr std::array<struct option, 25> long_options = {{
{"delete-volume-full", required_argument, nullptr, options::delete_volume_full},
{"inspect", required_argument, nullptr, 'i'},
{"exec", required_argument, nullptr, 'e'},
{"kill", required_argument, nullptr, 'k'},
{"hostname", required_argument, nullptr, options::hostname},
{"list-processes", no_argument, nullptr, options::list_processes},
{"clean-processes", no_argument, nullptr, options::clean_processes},
@@ -83,6 +84,7 @@ void print_usage(const char* prog) {
" {0} -l|--list-images <directory>\n"
" {0} -i|--inspect <image.tar>\n"
" {0} -e|--exec <pid> [-- <command> [args...]]\n"
" {0} -k|--kill <pid>\n"
" {0} -v|--volume <name> <directory>\n"
" {0} --list-volumes\n"
" {0} --delete-volume <name>\n"
@@ -151,6 +153,16 @@ void print_usage(const char* prog) {
" shown by --list-processes as \"running\") and run\n"
" a command inside its container; pass\n"
" -- <command> [args...] to specify it\n"
" -k, --kill <pid> stop a running --run session (pid must be one\n"
" shown by --list-processes as \"running\"): sends\n"
" SIGTERM, waits up to 10s, then forces it with\n"
" SIGKILL. Reaches every process the session\n"
" started -- including daemonized/reparented ones\n"
" a plain `kill <pid>` would leave behind -- via a\n"
" dedicated cgroup when available, or the sandboxed\n"
" pid namespace's own collapse-on-kill guarantee\n"
" when not, falling back to signaling the tracked\n"
" pid alone if neither applies\n"
" -v, --volume <name> <dir> create a named volume mapped to a host directory\n"
" (created if missing), recorded in the config\n"
" file's volumes section. With --run, instead\n"
@@ -193,6 +205,17 @@ void print_version() {
PACKAGE, VERSION);
}
// Shared by -e/--exec's exec_pid and -k/--kill's kill_pid -- both are a bare
// positive integer, nothing else.
std::optional<pid_t> parse_pid_arg(const std::string& text) {
char* end = nullptr;
long parsed = std::strtol(text.c_str(), &end, 10);
if (text.empty() || !end || *end != '\0' || parsed <= 0) {
return std::nullopt;
}
return static_cast<pid_t>(parsed);
}
} // namespace
bool apply_log_level(std::string_view name) {
@@ -211,7 +234,7 @@ bool apply_log_level(std::string_view name) {
std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
opterr = 0;
int opt;
while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:nl:v:i:e:D", long_options.data(), nullptr)) != -1) {
while ((opt = getopt_long(argc, argv, ":hVtm:u:r:c:nl:v:i:e:Dk:", long_options.data(), nullptr)) != -1) {
switch (opt) {
case 'h':
print_usage(argv[0]);
@@ -227,6 +250,7 @@ std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
case 'l':
case 'i':
case 'e':
case 'k':
case options::list_volumes:
case options::delete_volume:
case options::delete_volume_full:
@@ -258,6 +282,9 @@ std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
case 'e':
requested = Mode::exec;
break;
case 'k':
requested = Mode::kill;
break;
case options::list_volumes:
requested = Mode::list_volumes;
break;
@@ -376,13 +403,19 @@ std::optional<int> parse_args(int argc, char* argv[], ParsedArgs& out) {
print_usage(argv[0]);
return 1;
}
char* end = nullptr;
long parsed = std::strtol(out.mode_arg.c_str(), &end, 10);
if (out.mode_arg.empty() || !end || *end != '\0' || parsed <= 0) {
out.exec_pid = parse_pid_arg(out.mode_arg);
if (!out.exec_pid) {
spdlog::error("invalid pid: {}", out.mode_arg);
return 1;
}
}
if (out.mode == Mode::kill) {
out.kill_pid = parse_pid_arg(out.mode_arg);
if (!out.kill_pid) {
spdlog::error("invalid pid: {}", out.mode_arg);
return 1;
}
out.exec_pid = static_cast<pid_t>(parsed);
}
return std::nullopt;
+4 -1
View File
@@ -41,7 +41,8 @@ enum class Mode {
inspect,
list_processes,
clean_processes,
exec
exec,
kill
};
// Everything parse_args() extracts from argv, ready to hand to
@@ -61,6 +62,8 @@ struct ParsedArgs {
std::vector<std::string> command;
// Parsed and range-validated from mode_arg when mode == Mode::exec.
std::optional<pid_t> exec_pid;
// Parsed and range-validated from mode_arg when mode == Mode::kill.
std::optional<pid_t> kill_pid;
};
// Validates and applies a log-level name (trace/debug/info/warn/error/critical/off)
+3
View File
@@ -37,6 +37,7 @@
#include "daemonize.h"
#include "env_spec.h"
#include "exec_session.h"
#include "kill_session.h"
#include "oci_image.h"
#include "pid_file.h"
#include "process.h"
@@ -526,6 +527,8 @@ int dispatch_command(const ParsedArgs& args, const std::filesystem::path& config
return run_self_tests();
case Mode::exec:
return exec_in_session(*args.exec_pid, args.command, args.user_flag, args.group_flag);
case Mode::kill:
return kill_session(*args.kill_pid);
case Mode::run: {
// As root, containers-storage mount doesn't need to reexec into a private
// user namespace to gain privilege, so the mount is already directly
+1 -84
View File
@@ -18,7 +18,6 @@
#include <algorithm>
#include <array>
#include <cctype>
#include <filesystem>
#include <fstream>
#include <optional>
@@ -33,93 +32,11 @@
#include "bwrap.h"
#include "pid_file.h"
#include "process.h"
#include "sandbox_process.h"
#include "user_spec.h"
namespace {
// Fast path: /proc/<pid>/task/<pid>/children lists direct children with no
// scanning needed. Requires CONFIG_CHECKPOINT_RESTORE, which not every kernel
// enables -- absent (not just unreadable), confirmed by direct testing on a
// real Android target, where the file simply doesn't exist.
std::optional<pid_t> find_child_via_children_file(pid_t pid) {
std::ifstream children(fmt::format("/proc/{}/task/{}/children", pid, pid));
pid_t child = 0;
if (children >> child && child > 0) {
return child;
}
return std::nullopt;
}
// Portable fallback for kernels without the children file: scans /proc/<n>/stat
// for any process whose ppid field (the first whitespace-separated field after
// comm's closing ')' -- comm itself is parenthesized and may contain spaces or
// parens, so it can't just be split on whitespace) equals `pid`. This is the
// same information `pstree` itself reads to build its tree -- confirmed by
// direct testing: `pstree -p <bwrap_pid>` found the real sandboxed child on a
// device where the children file was missing. Picks the lowest matching pid if
// more than one child exists, for a deterministic result.
std::optional<pid_t> find_child_by_scanning_proc(pid_t pid) {
std::error_code ec;
std::optional<pid_t> found;
for (const auto& entry : std::filesystem::directory_iterator("/proc", ec)) {
if (ec) {
break;
}
const std::string name = entry.path().filename().string();
if (name.empty() || !std::all_of(name.begin(), name.end(),
[](unsigned char c) { return std::isdigit(c) != 0; })) {
continue;
}
std::ifstream stat_file(entry.path() / "stat");
std::string line;
if (!std::getline(stat_file, line)) {
continue;
}
auto close_paren = line.rfind(')');
if (close_paren == std::string::npos) {
continue;
}
std::istringstream rest(line.substr(close_paren + 1));
std::string state;
pid_t ppid = 0;
if (!(rest >> state >> ppid) || ppid != pid) {
continue;
}
pid_t candidate = std::stoi(name);
if (!found || candidate < *found) {
found = candidate;
}
}
return found;
}
// bwrap's own outer process (the one tracked in the session pid file) sets up the
// mount/user namespaces itself, then clone()s the actual sandboxed command into
// fresh pid/uts/ipc/cgroup/net namespaces -- clone()'s namespace-creation flags
// only ever affect the newly created child, never the caller, so the outer
// process itself never actually enters those namespaces (confirmed by direct
// testing: /proc/<outer_pid>/ns/{pid,uts,ipc,cgroup,net} all matched this
// process's own, while only mnt/user differed). The real sandboxed command is
// that direct child, which is what actually needs to be nsenter-target for a
// faithful join. Returns `pid` itself (best-effort fallback, not fatal) if the
// child can't be determined -- callers still get a mount/user-namespace join out
// of that, just not the rest.
pid_t resolve_namespace_pid(pid_t pid) {
if (auto child = find_child_via_children_file(pid)) {
return *child;
}
if (auto child = find_child_by_scanning_proc(pid)) {
spdlog::debug("pid {}: found sandboxed child {} by scanning /proc (no .../task/.../children file)",
pid, *child);
return *child;
}
spdlog::debug("could not determine pid {}'s sandboxed child process; joining its own namespaces only",
pid);
return pid;
}
struct JoinableNamespace {
const char* proc_name; // matches /proc/<pid>/ns/<proc_name>
const char* nsenter_flag;
+175
View File
@@ -0,0 +1,175 @@
// 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.
#include "kill_session.h"
#include <time.h>
#include <algorithm>
#include <cerrno>
#include <csignal>
#include <filesystem>
#include <functional>
#include <string>
#include <string_view>
#include <vector>
#include <fmt/core.h>
#include <spdlog/spdlog.h>
#include "pid_file.h"
#include "sandbox_process.h"
#include "session_cgroup.h"
namespace {
// EINTR-retry loop, matching this project's existing direct-POSIX style
// (process.cpp already retries waitpid() the same way) rather than pulling in
// <thread>/<chrono> (unused anywhere else in this project) for a single
// sleep.
void sleep_ms(int ms) {
struct timespec ts{ms / 1000, static_cast<long>(ms % 1000) * 1000000L};
while (nanosleep(&ts, &ts) == -1 && errno == EINTR) {
}
}
// Polls `done` every `interval_ms` until it returns true or `timeout_ms`
// elapses. Returns whatever `done` last reported.
bool poll_until(const std::function<bool()>& done, int timeout_ms, int interval_ms) {
for (int waited = 0; waited < timeout_ms; waited += interval_ms) {
if (done()) {
return true;
}
sleep_ms(interval_ms);
}
return done();
}
// Strategy 1: preferred whenever the session has a non-empty dedicated
// cgroup. Most complete -- reaches every process the session ever started,
// daemonized/reparented or not, regardless of pid namespace support.
bool kill_via_cgroup(const SessionCgroup& cgroup, int grace_period_seconds) {
for (pid_t t : session_cgroup_pids(cgroup)) {
kill(t, SIGTERM);
}
auto is_stopped = [&] { return session_cgroup_pids(cgroup).empty(); };
if (poll_until(is_stopped, grace_period_seconds * 1000, 200)) {
return true;
}
// Re-read cgroup.procs fresh here (not the earlier SIGTERM-sweep
// snapshot) -- a process that forked a new child after the sweep but
// before dying would otherwise be missed entirely.
if (!(session_cgroup_supports_kill(cgroup) && kill_session_cgroup(cgroup))) {
for (pid_t t : session_cgroup_pids(cgroup)) {
kill(t, SIGKILL);
}
}
return poll_until(is_stopped, 2000, 200);
}
// Strategy 2: used when no cgroup exists for the session, but --unshare-pid
// was genuinely in effect (the sandboxed child is actually isolated in its
// own pid namespace). Reliable because reparenting within an isolated pid
// namespace always lands back on that namespace's own pid 1 (`ns_pid`
// itself) -- collect_descendant_pids() still finds everything for the
// graceful sweep, and the final SIGKILL is *guaranteed* complete by the
// kernel itself (a pid namespace's pid 1 dying forcibly kills every
// remaining process in it), independent of whatever the graceful sweep
// missed.
bool kill_via_pid_namespace(pid_t ns_pid, int grace_period_seconds) {
for (pid_t t : collect_descendant_pids(ns_pid)) {
kill(t, SIGTERM);
}
auto is_stopped = [&] { return kill(ns_pid, 0) != 0; }; // ESRCH once gone
if (poll_until(is_stopped, grace_period_seconds * 1000, 200)) {
return true;
}
kill(ns_pid, SIGKILL);
return poll_until(is_stopped, 2000, 200);
}
// Strategy 3: fallback, always available, least complete -- exactly today's
// manual `kill <tracked_pid>` behavior. The only "stopped" signal available
// here is the pid file itself (list_sessions()), since there's neither a
// cgroup nor an isolated pid namespace to check directly.
bool kill_via_tracked_pid(pid_t pid, int grace_period_seconds) {
kill(pid, SIGTERM);
auto is_stopped = [&] {
auto sessions = list_sessions();
return std::none_of(sessions.begin(), sessions.end(),
[&](const SessionInfo& s) { return s.pid == pid && s.running; });
};
if (poll_until(is_stopped, grace_period_seconds * 1000, 200)) {
return true;
}
kill(pid, SIGKILL);
return poll_until(is_stopped, 2000, 200);
}
} // namespace
int kill_session(pid_t pid, int grace_period_seconds) {
if (pid <= 0) {
spdlog::error("invalid pid: {}", pid);
return 1;
}
auto sessions = list_sessions();
auto it = std::find_if(sessions.begin(), sessions.end(),
[&](const SessionInfo& session) { return session.pid == pid; });
if (it == sessions.end()) {
spdlog::error("no tracked slocker-lite session with pid {}", pid);
return 1;
}
if (!it->running) {
spdlog::error("session '{}' (pid {}) is no longer running", it->container_name, pid);
return 1;
}
// Strategy selection happens once, up front: whichever is picked runs its
// own complete SIGTERM -> wait -> forced-kill escalation internally --
// there's no cross-strategy fallback-after-failure chaining, since if the
// best-available strategy's own forced step doesn't finish the job, a
// weaker one wouldn't either.
SessionCgroup cgroup{session_cgroup_path(it->container_name, pid)};
bool stopped;
std::string_view method;
if (std::filesystem::is_directory(cgroup.path) && !session_cgroup_pids(cgroup).empty()) {
method = "cgroup";
stopped = kill_via_cgroup(cgroup, grace_period_seconds);
} else if (pid_t ns_pid = resolve_namespace_pid(pid); ns_pid != pid && pid_namespace_isolated(pid, ns_pid)) {
method = "pid namespace";
stopped = kill_via_pid_namespace(ns_pid, grace_period_seconds);
} else {
method = "tracked process only";
stopped = kill_via_tracked_pid(pid, grace_period_seconds);
}
spdlog::debug("stopping session '{}' (pid {}) via {}: {}", it->container_name, pid, method, stopped);
if (stopped) {
fmt::print("session '{}' (pid {}) stopped\n", it->container_name, pid);
return 0;
}
spdlog::error("session '{}' (pid {}) could not be fully stopped", it->container_name, pid);
return 1;
}
+31
View File
@@ -0,0 +1,31 @@
// 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.
#pragma once
#include <sys/types.h>
// Stops a tracked, running -r/--run session identified by `pid` (the same
// tracked pid --list-processes/-e/--exec use) -- see CLAUDE.md's
// kill_session.{h,cpp} entry for the three strategies this picks between,
// depending on what's actually available for that session (a dedicated
// cgroup, an isolated pid namespace, or neither). Sends SIGTERM first, waits
// up to `grace_period_seconds` for the session to actually stop, then forces
// it with SIGKILL if it's still there. Returns 0 once confirmed stopped, or 1
// if `pid` isn't a tracked/running session (logged with spdlog::error) or it
// still hadn't stopped a few seconds after the forced kill (also logged with
// spdlog::error).
int kill_session(pid_t pid, int grace_period_seconds = 10);
+2 -2
View File
@@ -29,8 +29,6 @@
#include <fmt/core.h>
#include <spdlog/spdlog.h>
namespace {
std::string sanitize_for_filename(std::string_view name) {
std::string result;
result.reserve(name.size());
@@ -44,6 +42,8 @@ std::string sanitize_for_filename(std::string_view name) {
return result.empty() ? "container" : result;
}
namespace {
std::filesystem::path xdg_state_dir() {
const char* xdg_state_home = std::getenv("XDG_STATE_HOME");
std::filesystem::path state_home;
+7
View File
@@ -24,6 +24,13 @@
#include <sys/types.h>
// Replaces every character outside [A-Za-z0-9._-] in `name` with '_' (image
// names/tags can contain '/' (registry paths) or ':'), falling back to
// "container" if that leaves nothing. Shared with session_cgroup.{h,cpp},
// which needs the exact same <name>-<pid> naming rule for its own per-session
// cgroup directory, so the two don't drift apart.
std::string sanitize_for_filename(std::string_view name);
// Tracks one running -r/--run session (a live bwrap process) as a PID file with an
// advisory flock() held for as long as this process is running it -- so a *different*
// process can tell a stale leftover file apart from a genuinely still-running
+141
View File
@@ -0,0 +1,141 @@
// 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.
#include "sandbox_process.h"
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <fstream>
#include <map>
#include <optional>
#include <sstream>
#include <string>
#include <system_error>
#include <fmt/core.h>
#include <spdlog/spdlog.h>
namespace {
// Single /proc pass building a ppid -> direct-children map, shared by
// find_child_by_scanning_proc() and collect_descendant_pids() below so the
// (identical) stat-parsing work only happens once per call site.
std::map<pid_t, std::vector<pid_t>> build_ppid_map() {
std::map<pid_t, std::vector<pid_t>> children_of;
std::error_code ec;
for (const auto& entry : std::filesystem::directory_iterator("/proc", ec)) {
if (ec) {
break;
}
const std::string name = entry.path().filename().string();
if (name.empty() || !std::all_of(name.begin(), name.end(),
[](unsigned char c) { return std::isdigit(c) != 0; })) {
continue;
}
std::ifstream stat_file(entry.path() / "stat");
std::string line;
if (!std::getline(stat_file, line)) {
continue;
}
// comm field is parenthesized and can contain spaces/parens itself;
// ppid is the first whitespace-separated field after the closing ')'.
auto close_paren = line.rfind(')');
if (close_paren == std::string::npos) {
continue;
}
std::istringstream rest(line.substr(close_paren + 1));
std::string state;
pid_t ppid = 0;
if (!(rest >> state >> ppid)) {
continue;
}
children_of[ppid].push_back(std::stoi(name));
}
return children_of;
}
// Fast path: /proc/<pid>/task/<pid>/children lists direct children with no
// scanning needed. Requires CONFIG_CHECKPOINT_RESTORE, which not every kernel
// enables -- absent (not just unreadable), confirmed by direct testing on a
// real Android target, where the file simply doesn't exist.
std::optional<pid_t> find_child_via_children_file(pid_t pid) {
std::ifstream children(fmt::format("/proc/{}/task/{}/children", pid, pid));
pid_t child = 0;
if (children >> child && child > 0) {
return child;
}
return std::nullopt;
}
// Portable fallback for kernels without the children file: the same
// information pstree itself reads to build its tree. Picks the lowest
// matching pid if more than one child exists, for a deterministic result.
std::optional<pid_t> find_child_by_scanning_proc(pid_t pid) {
auto children_of = build_ppid_map();
auto it = children_of.find(pid);
if (it == children_of.end() || it->second.empty()) {
return std::nullopt;
}
return *std::min_element(it->second.begin(), it->second.end());
}
std::optional<std::string> read_ns_link(const std::filesystem::path& path) {
std::error_code ec;
auto target = std::filesystem::read_symlink(path, ec);
if (ec) {
return std::nullopt;
}
return target.string();
}
} // namespace
pid_t resolve_namespace_pid(pid_t pid) {
if (auto child = find_child_via_children_file(pid)) {
return *child;
}
if (auto child = find_child_by_scanning_proc(pid)) {
spdlog::debug("pid {}: found sandboxed child {} by scanning /proc (no .../task/.../children file)",
pid, *child);
return *child;
}
spdlog::debug("could not determine pid {}'s sandboxed child process; joining its own namespaces only",
pid);
return pid;
}
bool pid_namespace_isolated(pid_t outer_pid, pid_t ns_pid) {
auto outer_ns = read_ns_link(fmt::format("/proc/{}/ns/pid", outer_pid));
auto inner_ns = read_ns_link(fmt::format("/proc/{}/ns/pid", ns_pid));
return outer_ns && inner_ns && *outer_ns != *inner_ns;
}
std::vector<pid_t> collect_descendant_pids(pid_t root) {
auto children_of = build_ppid_map();
std::vector<pid_t> result = {root};
for (size_t i = 0; i < result.size(); ++i) {
auto it = children_of.find(result[i]);
if (it != children_of.end()) {
result.insert(result.end(), it->second.begin(), it->second.end());
}
}
return result;
}
+59
View File
@@ -0,0 +1,59 @@
// 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.
#pragma once
#include <sys/types.h>
#include <vector>
// Finds the real sandboxed child of bwrap's own outer/tracked pid `pid` --
// bwrap sets up the mount/user namespaces itself, then clone()s the actual
// sandboxed command into fresh pid/uts/ipc/cgroup namespaces, so the outer
// process itself never actually enters those namespaces (confirmed by direct
// testing: /proc/<outer_pid>/ns/{pid,uts,ipc,cgroup,net} all matched the
// caller's own, while only mnt/user differed for the outer process). Prefers
// /proc/<pid>/task/<pid>/children (the direct-children list procfs exposes);
// falls back to scanning /proc/<n>/stat for a process whose ppid is `pid` --
// the same information pstree itself reads -- when that file doesn't exist
// (requires CONFIG_CHECKPOINT_RESTORE, absent on some kernels, confirmed
// missing on a real Android target). Falls back to `pid` itself (best-effort,
// not fatal) if no child can be found either way. Shared by exec_in_session()
// (exec_session.h) and kill_session() (kill_session.h).
pid_t resolve_namespace_pid(pid_t pid);
// True if `ns_pid` is in a different pid namespace than `outer_pid` -- i.e.
// bwrap's clone() actually created a separate pid namespace for its child
// (--unshare-pid was requested by build_bwrap_args() and the kernel supported
// it). This is the precondition for the kernel's own guarantee that killing a
// pid namespace's pid 1 forcibly tears down every remaining process in it --
// used by kill_session() (kill_session.h) to decide whether that mechanism
// actually applies to a given session (on a kernel without pid namespace
// support -- e.g. this project's real stock-Android target -- it never does,
// since a daemonized process there reparents to the *host's* pid 1 instead).
bool pid_namespace_isolated(pid_t outer_pid, pid_t ns_pid);
// Every transitive descendant of `root` (root included), found via a single
// /proc/<n>/stat pass matching ppid chains back to `root` -- the same
// mechanism resolve_namespace_pid()'s own fallback uses, generalized to
// collect the whole tree instead of just one child. Reliable specifically
// when `root` is a genuinely isolated pid namespace's own pid 1: anything
// that reparents within it (e.g. a daemonizing service) is guaranteed by the
// kernel to land back on `root` itself, so nothing escapes the tree the way
// it would on a kernel without pid namespace support. Used by kill_session()
// (kill_session.h) to send a best-effort graceful signal to everything in a
// session's pid namespace, not just its own pid 1.
std::vector<pid_t> collect_descendant_pids(pid_t root);
+101
View File
@@ -0,0 +1,101 @@
// 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.
#include "session_cgroup.h"
#include <fstream>
#include <string>
#include <system_error>
#include <fmt/core.h>
#include <spdlog/spdlog.h>
#include "pid_file.h"
namespace {
constexpr const char* cgroup_root = "/sys/fs/cgroup/slocker-lite";
} // namespace
bool cgroup_v2_available() {
std::error_code ec;
return std::filesystem::exists("/sys/fs/cgroup/cgroup.controllers", ec);
}
std::filesystem::path session_cgroup_path(std::string_view container_name, pid_t pid) {
return std::filesystem::path(cgroup_root) / fmt::format("{}-{}", sanitize_for_filename(container_name), pid);
}
std::optional<SessionCgroup> create_session_cgroup(std::string_view container_name, pid_t pid) {
if (!cgroup_v2_available()) {
spdlog::debug(
"cgroup v2 not available; -k/--kill will fall back to a less complete method for this session");
return std::nullopt;
}
auto path = session_cgroup_path(container_name, pid);
// create_directories() also creates cgroup_root itself the first time --
// a plain grouping cgroup, no controllers ever enabled on it via
// cgroup.subtree_control, so it never carries the "no internal processes"
// restriction that applies once a parent actually delegates controllers.
std::error_code ec;
std::filesystem::create_directories(path, ec);
if (ec) {
spdlog::warn("failed to create session cgroup {}: {} (no delegated subtree when running rootless, "
"or an SELinux policy blocking cgroupfs writes, are both common causes)",
path.string(), ec.message());
return std::nullopt;
}
std::ofstream procs(path / "cgroup.procs");
if (!(procs << pid << "\n")) {
spdlog::warn("failed to move pid {} into session cgroup {}", pid, path.string());
std::filesystem::remove(path, ec);
return std::nullopt;
}
return SessionCgroup{path};
}
void remove_session_cgroup(const SessionCgroup& cgroup) {
std::error_code ec;
std::filesystem::remove(cgroup.path, ec);
if (ec) {
spdlog::warn("failed to remove session cgroup {}: {} (a process it started may have outlived the "
"session's own main command)",
cgroup.path.string(), ec.message());
}
}
std::vector<pid_t> session_cgroup_pids(const SessionCgroup& cgroup) {
std::vector<pid_t> pids;
std::ifstream procs(cgroup.path / "cgroup.procs");
pid_t pid = 0;
while (procs >> pid) {
pids.push_back(pid);
}
return pids;
}
bool session_cgroup_supports_kill(const SessionCgroup& cgroup) {
std::error_code ec;
return std::filesystem::exists(cgroup.path / "cgroup.kill", ec);
}
bool kill_session_cgroup(const SessionCgroup& cgroup) {
std::ofstream kill_file(cgroup.path / "cgroup.kill");
return static_cast<bool>(kill_file << "1");
}
+88
View File
@@ -0,0 +1,88 @@
// 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.
#pragma once
#include <filesystem>
#include <optional>
#include <string_view>
#include <vector>
#include <sys/types.h>
// True if /sys/fs/cgroup/cgroup.controllers exists -- the standard signal
// (systemd's own unified-hierarchy detection works the same way) that this is
// an actual cgroup v2 unified hierarchy mount, not v1 (which splits per
// controller into e.g. /sys/fs/cgroup/cpu, /sys/fs/cgroup/memory, with no
// unified cgroup.controllers/cgroup.procs at the top) or nothing mounted at
// all. Only cgroup v2 is supported here -- cgroup v1 is scoped out, since it
// would require moving a pid into multiple separate per-controller
// hierarchies for the same tracking purpose, and this project's real target
// (Android) has used the unified v2 hierarchy by default since Android 12.
bool cgroup_v2_available();
// Deterministic path -- /sys/fs/cgroup/slocker-lite/<sanitized container_name>-<pid>/,
// reusing pid_file.h's own sanitize_for_filename() so this never drifts from
// the exact same naming rule the pid file itself already uses. No separate
// lookup state needed: kill_session() (kill_session.h) derives this directly
// from the same (container_name, pid) list_sessions() already reports.
std::filesystem::path session_cgroup_path(std::string_view container_name, pid_t pid);
struct SessionCgroup {
std::filesystem::path path;
};
// Creates the session's own dedicated cgroup (a plain grouping leaf -- no
// resource controllers are ever enabled on it via cgroup.subtree_control, so
// the "no internal processes" constraint that applies once a parent cgroup
// delegates real controllers never comes into play here) and moves `pid` into
// it by writing to its cgroup.procs. Every process `pid` (or anything it later
// execs into) forks from this point on inherits this cgroup automatically,
// permanently -- including anything that later daemonizes/double-forks and
// gets reparented, unlike pid-namespace child membership or process-group
// membership, neither of which survives that.
//
// Best-effort, mirroring create_session_lock() (pid_file.h): returns nullopt
// (logging a warning, never fatal -- session tracking must never block or
// fail -r/--run itself) if cgroup v2 isn't available, or the directory can't
// be created/written (e.g. no delegated subtree when running rootless, or an
// SELinux policy blocking cgroupfs writes even for a root-euid process).
std::optional<SessionCgroup> create_session_cgroup(std::string_view container_name, pid_t pid);
// Removes the cgroup directory -- only possible once it's empty (no live
// processes left in it). Best-effort, mirroring release_session_lock(): a
// straggler process still alive (e.g. a natural, non-killed exit where a
// daemonized process outlived the main sandboxed command -- a pre-existing
// exposure independent of this feature) makes the underlying rmdir() fail
// with ENOTEMPTY; just warn and leave the directory in place.
void remove_session_cgroup(const SessionCgroup& cgroup);
// Every live pid currently in the cgroup (its cgroup.procs, one pid per
// line), or empty if the cgroup doesn't exist or can't be read. This is what
// actually answers "gather all the processes running inside the container":
// unlike anything derived from /proc parent-pid chains or pid namespaces,
// cgroup membership reliably includes every process the session ever
// started, however deeply forked/daemonized/reparented.
std::vector<pid_t> session_cgroup_pids(const SessionCgroup& cgroup);
// Whether this cgroup has a cgroup.kill knob (Linux 5.14+).
bool session_cgroup_supports_kill(const SessionCgroup& cgroup);
// Writes "1" to the cgroup's own cgroup.kill, atomically SIGKILLing every
// process currently in it. Returns false (not fatal -- callers fall back to
// signaling session_cgroup_pids() directly) if cgroup.kill isn't present or
// the write fails.
bool kill_session_cgroup(const SessionCgroup& cgroup);