Track running -r/--run sessions with a locked PID file

Each bwrap session is now recorded under
$XDG_STATE_HOME/slocker-lite/run/<container-name>-<pid> (falling back
to $HOME/.local/state/...), holding an exclusive advisory flock() for
as long as it's running -- so any tool can tell a stale leftover file
apart from a live session by attempting the same non-blocking flock().
The file is removed once the run ends, on every exit path including a
forwarded Ctrl-C.

run_process_foreground() gained an optional on_start(pid) callback,
fired right after fork() succeeds -- the only point the real bwrap pid
is knowable, since exec() (including nsenter handing off to bwrap)
never changes it. run_bwrap() uses this to create/release the session
lock. The container name comes from read_image_ref(), promoted from a
list_oci_images()-only helper to public API in oci_image.h so
run_container() can reuse the same name/tag derivation for a single
image tar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv3s5jckJKzh6JkMoi2Akz
This commit is contained in:
2026-08-22 08:56:12 +00:00
parent 9877ffe7f1
commit 23f380e180
12 changed files with 319 additions and 67 deletions
+11 -2
View File
@@ -32,6 +32,7 @@
#include <fmt/core.h>
#include <spdlog/spdlog.h>
#include "pid_file.h"
#include "process.h"
namespace {
@@ -305,7 +306,7 @@ std::optional<std::vector<std::string>> wrap_for_root_namespace(const std::strin
int run_bwrap(const std::string& root, const std::vector<std::string>& command, bool use_nsenter,
const std::vector<ResolvedVolumeMount>& volumes, std::optional<ResolvedUser> user,
const std::optional<std::string>& hostname) {
const std::optional<std::string>& hostname, const std::string& container_name) {
if (user && !find_priv_drop_helper()) {
spdlog::error("could not find the {} helper next to this binary; --user/--group requires it",
kPrivDropHelperName);
@@ -318,5 +319,13 @@ int run_bwrap(const std::string& root, const std::vector<std::string>& command,
return -1;
}
return run_process_foreground(*argv);
std::optional<SessionLock> session_lock;
int exit_code = run_process_foreground(
*argv, [&](pid_t pid) { session_lock = create_session_lock(container_name, pid); });
if (session_lock) {
release_session_lock(*session_lock);
}
return exit_code;
}
+5 -3
View File
@@ -75,8 +75,10 @@ std::vector<std::string> build_bwrap_args(const std::string& root,
// If `user` is set, this process's own binary is bind-mounted into the sandbox and
// used to drop privileges to that uid/gid before running `command` -- see
// build_bwrap_args(). `hostname`, if set, is forwarded to build_bwrap_args() --
// see there for when it does/doesn't take effect. Returns bwrap's exit code, or -1
// on failure to launch.
// see there for when it does/doesn't take effect. While bwrap is running,
// `container_name` (paired with its actual pid) is recorded as a locked session
// pid file under $XDG_STATE_HOME (see pid_file.h) -- removed again once it exits.
// Returns bwrap's exit code, or -1 on failure to launch.
int run_bwrap(const std::string& root, const std::vector<std::string>& command, bool use_nsenter,
const std::vector<ResolvedVolumeMount>& volumes, std::optional<ResolvedUser> user,
const std::optional<std::string>& hostname);
const std::optional<std::string>& hostname, const std::string& container_name);
+6 -1
View File
@@ -522,9 +522,14 @@ int run_container(const std::filesystem::path& image_tar,
: std::vector<std::string>{"/bin/sh"};
}
auto image_ref = read_image_ref(image_tar);
std::string container_name =
image_ref ? fmt::format("{}:{}", image_ref->name, image_ref->tag) : image_tar.stem().string();
int exit_code = -1;
if (ok) {
exit_code = run_bwrap(mounted->merged_path, command, use_nsenter, volume_mounts, resolved_user, hostname);
exit_code = run_bwrap(mounted->merged_path, command, use_nsenter, volume_mounts, resolved_user, hostname,
container_name);
if (exit_code < 0) {
spdlog::error("failed to run bwrap");
}
+51 -51
View File
@@ -135,57 +135,6 @@ ParsedRef parse_image_ref(const std::string& ref) {
return {"", ref};
}
std::optional<OciImageRef> read_image_ref(const std::filesystem::path& tar_path) {
auto layout = read_entry_to_string(tar_path, "oci-layout");
if (!layout) {
spdlog::debug("{}: not an OCI image tar (missing oci-layout)", tar_path.string());
return std::nullopt;
}
auto index_content = read_entry_to_string(tar_path, "index.json");
if (!index_content) {
spdlog::debug("{}: not an OCI image tar (missing index.json)", tar_path.string());
return std::nullopt;
}
json index;
try {
index = json::parse(*index_content);
} catch (const json::parse_error& e) {
spdlog::debug("{}: index.json is not valid JSON: {}", tar_path.string(), e.what());
return std::nullopt;
}
json manifest_entry;
bool found = false;
for (const auto& m : index.value("manifests", json::array())) {
if (m.value("mediaType", "") == "application/vnd.oci.image.manifest.v1+json") {
manifest_entry = m;
found = true;
break;
}
}
if (!found) {
spdlog::debug("{}: index.json has no OCI image manifest entry", tar_path.string());
return std::nullopt;
}
OciImageRef ref;
ref.path = tar_path;
auto annotations = manifest_entry.value("annotations", json::object());
std::string annotation_ref = annotations.value("io.containerd.image.name", "");
if (annotation_ref.empty()) {
annotation_ref = annotations.value("org.opencontainers.image.ref.name", "");
}
ParsedRef parsed = annotation_ref.empty() ? ParsedRef{} : parse_image_ref(annotation_ref);
ref.name = parsed.name.empty() ? archive_basename(tar_path) : parsed.name;
ref.tag = parsed.tag.empty() ? "latest" : parsed.tag;
return ref;
}
struct OciManifest {
std::string digest;
json data;
@@ -284,6 +233,57 @@ std::optional<OciExposedPort> parse_exposed_port(const std::string& key) {
} // namespace
std::optional<OciImageRef> read_image_ref(const std::filesystem::path& tar_path) {
auto layout = read_entry_to_string(tar_path, "oci-layout");
if (!layout) {
spdlog::debug("{}: not an OCI image tar (missing oci-layout)", tar_path.string());
return std::nullopt;
}
auto index_content = read_entry_to_string(tar_path, "index.json");
if (!index_content) {
spdlog::debug("{}: not an OCI image tar (missing index.json)", tar_path.string());
return std::nullopt;
}
json index;
try {
index = json::parse(*index_content);
} catch (const json::parse_error& e) {
spdlog::debug("{}: index.json is not valid JSON: {}", tar_path.string(), e.what());
return std::nullopt;
}
json manifest_entry;
bool found = false;
for (const auto& m : index.value("manifests", json::array())) {
if (m.value("mediaType", "") == "application/vnd.oci.image.manifest.v1+json") {
manifest_entry = m;
found = true;
break;
}
}
if (!found) {
spdlog::debug("{}: index.json has no OCI image manifest entry", tar_path.string());
return std::nullopt;
}
OciImageRef ref;
ref.path = tar_path;
auto annotations = manifest_entry.value("annotations", json::object());
std::string annotation_ref = annotations.value("io.containerd.image.name", "");
if (annotation_ref.empty()) {
annotation_ref = annotations.value("org.opencontainers.image.ref.name", "");
}
ParsedRef parsed = annotation_ref.empty() ? ParsedRef{} : parse_image_ref(annotation_ref);
ref.name = parsed.name.empty() ? archive_basename(tar_path) : parsed.name;
ref.tag = parsed.tag.empty() ? "latest" : parsed.tag;
return ref;
}
std::string oci_digest_hex(std::string_view digest) {
constexpr std::string_view prefix = "sha256:";
if (digest.substr(0, prefix.size()) == prefix) {
+8
View File
@@ -49,6 +49,14 @@ struct OciImageRef {
std::filesystem::path path; // the archive file this was found in
};
// Determines a single image tar's own name/tag the same way list_oci_images() does
// for each file it scans: from its index.json manifest annotations
// (io.containerd.image.name preferred, else org.opencontainers.image.ref.name),
// falling back to the archive's filename (name) and "latest" (tag) when unset or
// unparseable. Returns nullopt if `tar_path` isn't a valid OCI Image Layout tar
// with at least one image manifest entry.
std::optional<OciImageRef> read_image_ref(const std::filesystem::path& tar_path);
// Scans `dir` (non-recursively) for files matching *.tar or *.tar.*, and for each one
// that's a valid OCI Image Layout archive, determines an image name/tag from its
// index.json manifest annotations (io.containerd.image.name or
+105
View File
@@ -0,0 +1,105 @@
// 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 "pid_file.h"
#include <fcntl.h>
#include <sys/file.h>
#include <unistd.h>
#include <cctype>
#include <cerrno>
#include <cstdlib>
#include <cstring>
#include <system_error>
#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());
for (char c : name) {
if (std::isalnum(static_cast<unsigned char>(c)) || c == '-' || c == '_' || c == '.') {
result += c;
} else {
result += '_';
}
}
return result.empty() ? "container" : result;
}
std::filesystem::path session_run_dir() {
const char* xdg_state_home = std::getenv("XDG_STATE_HOME");
std::filesystem::path state_home;
if (xdg_state_home && *xdg_state_home) {
state_home = xdg_state_home;
} else {
const char* home = std::getenv("HOME");
state_home = std::filesystem::path(home ? home : "") / ".local" / "state";
}
return state_home / "slocker-lite" / "run";
}
} // namespace
std::filesystem::path session_pid_file_path(std::string_view container_name, pid_t pid) {
return session_run_dir() / fmt::format("{}-{}", sanitize_for_filename(container_name), pid);
}
std::optional<SessionLock> create_session_lock(std::string_view container_name, pid_t pid) {
auto path = session_pid_file_path(container_name, pid);
std::error_code ec;
std::filesystem::create_directories(path.parent_path(), ec);
if (ec) {
spdlog::warn("failed to create session state directory {}: {}", path.parent_path().string(),
ec.message());
return std::nullopt;
}
int fd = open(path.c_str(), O_CREAT | O_WRONLY | O_TRUNC | O_CLOEXEC, 0644);
if (fd < 0) {
spdlog::warn("failed to create session pid file {}: {}", path.string(), strerror(errno));
return std::nullopt;
}
if (flock(fd, LOCK_EX | LOCK_NB) != 0) {
spdlog::warn("failed to lock session pid file {}: {}", path.string(), strerror(errno));
close(fd);
return std::nullopt;
}
std::string contents = fmt::format("{}\n", pid);
if (write(fd, contents.data(), contents.size()) < 0) {
spdlog::warn("failed to write session pid file {}: {}", path.string(), strerror(errno));
}
return SessionLock{path, fd};
}
void release_session_lock(const SessionLock& lock) {
if (lock.fd >= 0) {
close(lock.fd);
}
std::error_code ec;
std::filesystem::remove(lock.path, ec);
if (ec) {
spdlog::warn("failed to remove session pid file {}: {}", lock.path.string(), ec.message());
}
}
+57
View File
@@ -0,0 +1,57 @@
// 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>
#include <string_view>
#include <sys/types.h>
// 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
// session: attempt the same exclusive, non-blocking flock() on the same file --
// succeeding means nothing holds it anymore (stale, safe to remove), failing
// (EWOULDBLOCK) means a live process still does. The lock is held only by `fd`
// (opened O_CLOEXEC), so it's never visible to the sandboxed command itself, and
// its lifetime tracks this process's own lifetime -- released automatically if
// this process exits for any reason, including a crash.
struct SessionLock {
std::filesystem::path path;
int fd = -1;
};
// $XDG_STATE_HOME/slocker-lite/run/<container_name>-<pid>, or
// $HOME/.local/state/slocker-lite/run/<container_name>-<pid> if XDG_STATE_HOME is
// unset/empty (mirrors config_file_path()'s XDG_CONFIG_HOME pattern). Characters in
// container_name outside [A-Za-z0-9._-] are replaced with '_', since image
// names/tags can contain '/' (registry paths) or ':'.
std::filesystem::path session_pid_file_path(std::string_view container_name, pid_t pid);
// Creates the pid file for (container_name, pid) (creating its parent directory if
// needed), writes `pid` as text, and takes an exclusive advisory flock() on it (see
// SessionLock above). Returns nullopt (logging a warning, never fatal -- session
// tracking is best-effort and must never block or fail -r/--run itself) if the
// directory/file can't be created or the lock can't be taken.
std::optional<SessionLock> create_session_lock(std::string_view container_name, pid_t pid);
// Closes fd (releasing the flock immediately) and removes the file. Best-effort:
// logs a warning on failure, never treated as fatal, mirroring run_container()'s
// own unmount/cleanup-failure handling.
void release_session_lock(const SessionLock& lock);
+5 -1
View File
@@ -108,7 +108,8 @@ ProcessResult run_process(const std::vector<std::string>& argv) {
return {exit_code, output};
}
int run_process_foreground(const std::vector<std::string>& argv) {
int run_process_foreground(const std::vector<std::string>& argv,
const std::function<void(pid_t)>& on_start) {
spdlog::debug("running external command: {}", fmt::join(argv, " "));
pid_t pid = fork();
@@ -125,6 +126,9 @@ int run_process_foreground(const std::vector<std::string>& argv) {
}
g_foreground_child_pid = pid;
if (on_start) {
on_start(pid);
}
struct sigaction action = {};
action.sa_handler = forward_signal_to_foreground_child;
+11 -2
View File
@@ -17,11 +17,14 @@
#pragma once
#include <filesystem>
#include <functional>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
#include <sys/types.h>
struct ProcessResult {
int exit_code;
std::string stdout_output;
@@ -33,8 +36,14 @@ ProcessResult run_process(const std::vector<std::string>& argv);
// Runs argv[0] with the given arguments via fork/execvp, with stdin/stdout/stderr
// all inherited from the caller (no output capture) for interactive/foreground use.
// Returns the exit code, or -1 if fork or exec failed.
int run_process_foreground(const std::vector<std::string>& argv);
// If `on_start` is set, it's called with the child's pid immediately after fork()
// succeeds and before this blocks in waitpid() -- e.g. so a caller can record the
// real pid of what it just launched. This stays accurate even when argv itself
// execs into something else before the real target (e.g. nsenter handing off to
// the final command), since exec() never changes the pid. Returns the exit code,
// or -1 if fork or exec failed (on_start is not called in that case).
int run_process_foreground(const std::vector<std::string>& argv,
const std::function<void(pid_t)>& on_start = nullptr);
// Searches $PATH for an executable regular file named `name`, in PATH order.
// Returns its full path, or nullopt if not found.