Add --clean-processes to remove stale session pid files

Scans the same $XDG_STATE_HOME/slocker-lite/run/ directory as
--list-processes and removes every pid file that's genuinely stale.
The liveness check and the removal happen atomically per file (the
same non-blocking flock() used to test it is held across the
remove() call itself), rather than reusing a separate earlier scan,
closing the race window where a new session could start in between.
Only files actually removed are reported, one line each; sessions
still running are left untouched and silently skipped.

Refactored list_sessions() and the new clean_stale_sessions() to
share a private open_session_file() helper for the open/read-pid/
recover-name step they both need.

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 09:16:57 +00:00
parent 8c55e5c288
commit d4c0e8f0a1
5 changed files with 157 additions and 42 deletions
+29 -8
View File
@@ -57,14 +57,15 @@ enum class Mode {
kDeleteVolume,
kDeleteVolumeFull,
kInspect,
kListProcesses
kListProcesses,
kCleanProcesses
};
// --log-level/--user/--group/--list-volumes/--delete-volume[-full]/--hostname/
// --list-processes have no short form (--log-level's was freed up so -l could
// become --list-images; -u is already --umount; the rest have no natural free
// letter left, or don't need one), so they need long-option vals outside the
// printable-char range short options use.
// --list-processes/--clean-processes have no short form (--log-level's was freed
// up so -l could become --list-images; -u is already --umount; the rest have no
// natural free letter left, or don't need one), so they need long-option vals
// outside the printable-char range short options use.
constexpr int kLogLevelOpt = 256;
constexpr int kUserOpt = 257;
constexpr int kGroupOpt = 258;
@@ -73,8 +74,9 @@ constexpr int kDeleteVolumeOpt = 260;
constexpr int kDeleteVolumeFullOpt = 261;
constexpr int kHostnameOpt = 262;
constexpr int kListProcessesOpt = 263;
constexpr int kCleanProcessesOpt = 264;
constexpr std::array<struct option, 20> kLongOptions = {{
constexpr std::array<struct option, 21> kLongOptions = {{
{"help", no_argument, nullptr, 'h'},
{"version", no_argument, nullptr, 'V'},
{"test", no_argument, nullptr, 't'},
@@ -94,6 +96,7 @@ constexpr std::array<struct option, 20> kLongOptions = {{
{"inspect", required_argument, nullptr, 'i'},
{"hostname", required_argument, nullptr, kHostnameOpt},
{"list-processes", no_argument, nullptr, kListProcessesOpt},
{"clean-processes", no_argument, nullptr, kCleanProcessesOpt},
{nullptr, 0, nullptr, 0},
}};
@@ -110,6 +113,7 @@ void print_usage(const char* prog) {
" {0} --delete-volume <name>\n"
" {0} --delete-volume-full <name>\n"
" {0} --list-processes\n"
" {0} --clean-processes\n"
" {0} -t|--test\n"
" {0} -h|--help\n"
" {0} -V|--version\n"
@@ -170,6 +174,9 @@ void print_usage(const char* prog) {
" files under $XDG_STATE_HOME/slocker-lite/run/,\n"
" with their pid, container name, and status\n"
" (running or exited)\n"
" --clean-processes remove stale pid files (see --list-processes)\n"
" left behind by sessions that are no longer\n"
" running\n"
" -t, --test run the test suite\n"
" --log-level <level> set log verbosity (trace, debug, info, warn,\n"
" error, critical, off)\n"
@@ -469,6 +476,13 @@ int list_processes_command() {
return 0;
}
int clean_processes_command() {
for (const auto& session : clean_stale_sessions()) {
fmt::print("removed stale pid file for '{}' (pid {})\n", session.container_name, session.pid);
}
return 0;
}
int delete_volume_command(const std::string& name, const std::filesystem::path& config_path,
AppConfig& config, bool delete_directory) {
auto it = std::find_if(config.volumes.begin(), config.volumes.end(),
@@ -628,7 +642,8 @@ int main(int argc, char* argv[]) {
case kListVolumesOpt:
case kDeleteVolumeOpt:
case kDeleteVolumeFullOpt:
case kListProcessesOpt: {
case kListProcessesOpt:
case kCleanProcessesOpt: {
Mode requested;
switch (opt) {
case 't':
@@ -661,9 +676,12 @@ int main(int argc, char* argv[]) {
case kDeleteVolumeFullOpt:
requested = Mode::kDeleteVolumeFull;
break;
default:
case kListProcessesOpt:
requested = Mode::kListProcesses;
break;
default:
requested = Mode::kCleanProcesses;
break;
}
if (mode != Mode::kNone && mode != requested) {
spdlog::error("multiple actions specified");
@@ -776,6 +794,9 @@ int main(int argc, char* argv[]) {
if (mode == Mode::kListProcesses) {
return list_processes_command();
}
if (mode == Mode::kCleanProcesses) {
return clean_processes_command();
}
if (mode == Mode::kRun) {
std::vector<std::string> command(argv + optind, argv + argc);
// As root, containers-storage mount doesn't need to reexec into a private
+84 -25
View File
@@ -56,6 +56,46 @@ std::filesystem::path session_run_dir() {
return state_home / "slocker-lite" / "run";
}
struct ParsedSessionFile {
pid_t pid;
std::string container_name;
int fd;
};
// Opens `path` and reads back its pid (from the file's own contents, not parsed
// from the filename -- ambiguous for names that themselves contain '-') and
// recovers container_name by stripping the exact "-<pid>" suffix that pid implies
// back off the filename. Returns nullopt (logged at debug level, never an error --
// scanning a live directory is inherently racy) if the file can't be opened or its
// contents don't parse as a pid. The caller owns the returned fd and is
// responsible for closing it.
std::optional<ParsedSessionFile> open_session_file(const std::filesystem::path& path) {
int fd = open(path.c_str(), O_RDWR);
if (fd < 0) {
spdlog::debug("failed to open session file {}: {}", path.string(), strerror(errno));
return std::nullopt;
}
char buf[32] = {};
ssize_t n = read(fd, buf, sizeof(buf) - 1);
pid_t pid = n > 0 ? static_cast<pid_t>(std::atoi(buf)) : 0;
if (pid <= 0) {
spdlog::debug("skipping malformed session pid file {}", path.string());
close(fd);
return std::nullopt;
}
std::string filename = path.filename().string();
std::string suffix = fmt::format("-{}", pid);
std::string container_name =
filename.size() > suffix.size() &&
filename.compare(filename.size() - suffix.size(), suffix.size(), suffix) == 0
? filename.substr(0, filename.size() - suffix.size())
: filename;
return ParsedSessionFile{pid, container_name, fd};
}
} // namespace
std::filesystem::path session_pid_file_path(std::string_view container_name, pid_t pid) {
@@ -117,39 +157,58 @@ std::vector<SessionInfo> list_sessions() {
for (auto it = std::filesystem::directory_iterator(dir, it_ec);
!it_ec && it != std::filesystem::directory_iterator(); it.increment(it_ec)) {
const auto& path = it->path();
int fd = open(path.c_str(), O_RDWR);
if (fd < 0) {
spdlog::debug("failed to open session file {}: {}", path.string(), strerror(errno));
auto parsed = open_session_file(path);
if (!parsed) {
continue;
}
char buf[32] = {};
ssize_t n = read(fd, buf, sizeof(buf) - 1);
pid_t pid = n > 0 ? static_cast<pid_t>(std::atoi(buf)) : 0;
bool running = true;
if (flock(fd, LOCK_EX | LOCK_NB) == 0) {
if (flock(parsed->fd, LOCK_EX | LOCK_NB) == 0) {
running = false;
flock(fd, LOCK_UN);
flock(parsed->fd, LOCK_UN);
}
close(fd);
close(parsed->fd);
if (pid <= 0) {
spdlog::debug("skipping malformed session pid file {}", path.string());
continue;
}
std::string filename = path.filename().string();
std::string suffix = fmt::format("-{}", pid);
std::string container_name =
filename.size() > suffix.size() &&
filename.compare(filename.size() - suffix.size(), suffix.size(), suffix) == 0
? filename.substr(0, filename.size() - suffix.size())
: filename;
sessions.push_back(SessionInfo{pid, container_name, running});
sessions.push_back(SessionInfo{parsed->pid, parsed->container_name, running, path});
}
return sessions;
}
std::vector<SessionInfo> clean_stale_sessions() {
std::vector<SessionInfo> removed;
auto dir = session_run_dir();
std::error_code dir_ec;
if (!std::filesystem::is_directory(dir, dir_ec)) {
return removed;
}
std::error_code it_ec;
for (auto it = std::filesystem::directory_iterator(dir, it_ec);
!it_ec && it != std::filesystem::directory_iterator(); it.increment(it_ec)) {
const auto& path = it->path();
auto parsed = open_session_file(path);
if (!parsed) {
continue;
}
// Holding the same flock() used to test liveness across the removal
// itself (rather than reusing a separate, earlier list_sessions() result)
// keeps the check-then-remove atomic against a new session starting in
// between.
if (flock(parsed->fd, LOCK_EX | LOCK_NB) == 0) {
std::error_code rm_ec;
std::filesystem::remove(path, rm_ec);
if (rm_ec) {
spdlog::warn("failed to remove stale pid file {}: {}", path.string(), rm_ec.message());
} else {
removed.push_back(SessionInfo{parsed->pid, parsed->container_name, false, path});
}
flock(parsed->fd, LOCK_UN);
}
close(parsed->fd);
}
return removed;
}
+12
View File
@@ -61,6 +61,7 @@ struct SessionInfo {
pid_t pid;
std::string container_name; // as recovered from the pid file's own name (sanitized)
bool running;
std::filesystem::path path;
};
// Scans $XDG_STATE_HOME/slocker-lite/run/ (see session_pid_file_path()) for pid
@@ -75,3 +76,14 @@ struct SessionInfo {
// that's inherent to scanning a live directory) is skipped, not reported as an
// error. An empty or missing run directory yields an empty result, not an error.
std::vector<SessionInfo> list_sessions();
// Scans the same run directory as list_sessions() and removes every pid file
// that's genuinely stale, returning the ones actually removed (running=false in
// each). Unlike calling list_sessions() and removing what it reports as not
// running, the stale check and the removal happen atomically per file (the same
// flock() acquired to test liveness is held across the removal itself), closing
// the race window where a new session could start between a separate check and a
// later removal. A file that can't be opened, doesn't parse as a pid, is still
// locked (still running), or fails to remove is left alone/skipped -- logged at
// most as a warning, never fatal; this is best-effort cleanup.
std::vector<SessionInfo> clean_stale_sessions();