:Process makes sure no child outlives what it was started for. SIGKILL to the direct child alone would leave the Playwright server and Chromium running after a timeout, and every pass running after a SIGKILLed driver: - every child is forked by one long-lived spawner thread, the reaper's registry locked across fork, so PR_SET_PDEATHSIG holds and a fast exit is never reaped unregistered; one reaper thread owns waitpid(-1); - the child gets an empty signal mask, SIGPIPE and SIGXFSZ at their defaults, and nothing but its three streams (close_range); the driver ignores SIGPIPE and takes SIGINT/SIGTERM/SIGHUP on a thread of its own; - `crafter-review __supervise <argv>` is a pass's subreaper. It kills the pass's whole tree, Chromium's detached process group included, when the target exits, on SIGTERM, and when the driver dies, by its death signal. - Run() captures both outputs without the deadlock. The shared state the detached threads wait on is never destroyed: a condition variable destroyed under a waiter makes glibc's pthread_cond_destroy, and with it exit(), wait forever. :Platform: the Flatpak sandbox decided once, the host's XDG directories restored there; WriteAtomic and LockedUpdate, the read-modify-write of the board, ledger, memory.json and unit-state.json every driver on the machine shares, under flock(2) on the same side files; /proc readers; and the identity other drivers can check: natively pid and start time, in the sandbox the instance's host pid from bwrapinfo.json with /proc/1's start time. The lifetime lock is created under a temporary name and renamed into place, so no prober ever finds it unlocked while its driver lives. Tests: Process (the three ways a pass's tree must die, return codes, a missing program, capture, timeouts, fds, the child's signal state), Platform (atomic writes, LockedUpdate against a damaged file, two writers, and another process holding flock on the side file; /proc on a fake tree; both identities; the lifetime lock; the XDG restore). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
415 lines
16 KiB
C++
415 lines
16 KiB
C++
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
module;
|
|
#include <fcntl.h>
|
|
#include <pwd.h>
|
|
#include <sys/file.h>
|
|
#include <sys/stat.h>
|
|
#include <stdio.h>
|
|
#include <unistd.h>
|
|
#include <cerrno>
|
|
#include <cstdlib>
|
|
#include <ctime>
|
|
|
|
module Crafter.Review:Platform_impl;
|
|
import :Platform;
|
|
import :Json;
|
|
import :Text;
|
|
import :Crypto;
|
|
import std;
|
|
|
|
namespace fs = std::filesystem;
|
|
|
|
namespace Crafter::Review::Platform {
|
|
namespace {
|
|
Mode CurrentModeValue = Mode::Native;
|
|
|
|
SystemPaths& PathsValue() {
|
|
static SystemPaths paths = [] {
|
|
SystemPaths defaults;
|
|
if (const char* runtime = std::getenv("XDG_RUNTIME_DIR"); runtime && *runtime) {
|
|
defaults.runtimeDir = runtime;
|
|
} else {
|
|
defaults.runtimeDir = std::format("/run/user/{}", getuid());
|
|
}
|
|
return defaults;
|
|
}();
|
|
return paths;
|
|
}
|
|
|
|
// [section] key=value, as GLib's key files are written.
|
|
std::optional<std::string> KeyFileValue(std::string_view text, std::string_view section, std::string_view key) {
|
|
bool inSection = false;
|
|
for (const std::string& line : Dyn::SplitLines(text)) {
|
|
const std::string_view view(line);
|
|
if (view.starts_with('[')) {
|
|
inSection = view == std::format("[{}]", section);
|
|
continue;
|
|
}
|
|
if (!inSection) continue;
|
|
const std::size_t equals = view.find('=');
|
|
if (equals == std::string_view::npos) continue;
|
|
if (Dyn::Strip(view.substr(0, equals)) == key) return Dyn::Strip(view.substr(equals + 1));
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
std::optional<std::int64_t> ToInt(std::string_view text) {
|
|
std::int64_t value = 0;
|
|
const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), value);
|
|
if (error != std::errc() || end != text.data() + text.size()) return std::nullopt;
|
|
return value;
|
|
}
|
|
}
|
|
|
|
void Init() {
|
|
CurrentModeValue = fs::exists(PathsValue().flatpakInfo) ? Mode::Sandbox : Mode::Native;
|
|
if (CurrentModeValue == Mode::Sandbox) RestoreHostXdg();
|
|
}
|
|
|
|
Mode CurrentMode() {
|
|
return CurrentModeValue;
|
|
}
|
|
|
|
bool InSandbox() {
|
|
return CurrentModeValue == Mode::Sandbox;
|
|
}
|
|
|
|
void SetMode(Mode mode) {
|
|
CurrentModeValue = mode;
|
|
}
|
|
|
|
const SystemPaths& Paths() {
|
|
return PathsValue();
|
|
}
|
|
|
|
void SetPaths(SystemPaths paths) {
|
|
PathsValue() = std::move(paths);
|
|
}
|
|
|
|
std::optional<std::string> GetEnv(std::string_view name) {
|
|
const std::string key(name);
|
|
const char* value = std::getenv(key.c_str());
|
|
if (value == nullptr) return std::nullopt;
|
|
return std::string(value);
|
|
}
|
|
|
|
std::map<std::string, std::string> Environment() {
|
|
std::map<std::string, std::string> out;
|
|
for (char** entry = environ; entry != nullptr && *entry != nullptr; ++entry) {
|
|
const std::string_view text(*entry);
|
|
const std::size_t equals = text.find('=');
|
|
if (equals == std::string_view::npos) continue;
|
|
out.emplace(std::string(text.substr(0, equals)), std::string(text.substr(equals + 1)));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
std::string Home() {
|
|
if (const std::optional<std::string> home = GetEnv("HOME"); home && !home->empty()) return *home;
|
|
if (const passwd* entry = getpwuid(getuid()); entry != nullptr && entry->pw_dir != nullptr) return entry->pw_dir;
|
|
return "/";
|
|
}
|
|
|
|
void RestoreHostXdg() {
|
|
const std::string home = Home();
|
|
const std::array<std::pair<std::string_view, std::string_view>, 4> defaults = { {
|
|
{ "CONFIG", ".config" },
|
|
{ "CACHE", ".cache" },
|
|
{ "DATA", ".local/share" },
|
|
{ "STATE", ".local/state" },
|
|
} };
|
|
for (const auto& [kind, relative] : defaults) {
|
|
const std::string name = std::format("XDG_{}_HOME", kind);
|
|
const std::optional<std::string> host = GetEnv(std::format("HOST_XDG_{}_HOME", kind));
|
|
const std::string value = host && !host->empty() ? *host : Dyn::JoinPath(home, relative);
|
|
setenv(name.c_str(), value.c_str(), 1);
|
|
}
|
|
}
|
|
|
|
fs::path SelfExe() {
|
|
std::error_code error;
|
|
const fs::path exe = fs::read_symlink("/proc/self/exe", error);
|
|
return error ? fs::path() : exe;
|
|
}
|
|
|
|
double Now() {
|
|
const auto since = std::chrono::system_clock::now().time_since_epoch();
|
|
return std::chrono::duration<double>(since).count();
|
|
}
|
|
|
|
std::string LocalTime(std::string_view format, double epochSeconds) {
|
|
const std::time_t seconds = static_cast<std::time_t>(std::floor(epochSeconds));
|
|
std::tm local {};
|
|
localtime_r(&seconds, &local);
|
|
std::array<char, 128> buffer {};
|
|
const std::string pattern(format);
|
|
const std::size_t length = std::strftime(buffer.data(), buffer.size(), pattern.c_str(), &local);
|
|
return std::string(buffer.data(), length);
|
|
}
|
|
|
|
std::string LocalTime(std::string_view format) {
|
|
return LocalTime(format, Now());
|
|
}
|
|
|
|
std::string TodayIso() {
|
|
return LocalTime("%Y-%m-%d");
|
|
}
|
|
|
|
std::optional<std::string> ReadFile(const fs::path& path) {
|
|
std::ifstream in(path, std::ios::binary);
|
|
if (!in) return std::nullopt;
|
|
std::string bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
|
if (in.bad()) return std::nullopt;
|
|
return bytes;
|
|
}
|
|
|
|
void WriteAtomic(const fs::path& path, std::string_view content, std::uint32_t mode) {
|
|
if (path.has_parent_path()) fs::create_directories(path.parent_path());
|
|
const fs::path temp = std::format("{}.{}.tmp", path.string(), PublishedPid());
|
|
const std::int32_t fd = open(temp.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, static_cast<mode_t>(mode));
|
|
if (fd < 0) throw std::runtime_error(std::format("{}: {}", temp.string(), Dyn::ErrnoText(errno)));
|
|
std::size_t written = 0;
|
|
while (written < content.size()) {
|
|
const ssize_t step = write(fd, content.data() + written, content.size() - written);
|
|
if (step < 0) {
|
|
if (errno == EINTR) continue;
|
|
const std::int32_t failure = errno;
|
|
close(fd);
|
|
unlink(temp.c_str());
|
|
throw std::runtime_error(std::format("{}: {}", temp.string(), Dyn::ErrnoText(failure)));
|
|
}
|
|
written += static_cast<std::size_t>(step);
|
|
}
|
|
close(fd);
|
|
if (rename(temp.c_str(), path.c_str()) != 0) {
|
|
const std::int32_t failure = errno;
|
|
unlink(temp.c_str());
|
|
throw std::runtime_error(std::format("{}: {}", path.string(), Dyn::ErrnoText(failure)));
|
|
}
|
|
}
|
|
|
|
FileLock::FileLock(const fs::path& lockPath) {
|
|
std::error_code error;
|
|
if (lockPath.has_parent_path()) fs::create_directories(lockPath.parent_path(), error);
|
|
fd = open(lockPath.c_str(), O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644);
|
|
if (fd < 0) {
|
|
problem = Dyn::ErrnoText(errno);
|
|
return;
|
|
}
|
|
while (flock(fd, LOCK_EX) != 0) {
|
|
if (errno == EINTR) continue;
|
|
problem = Dyn::ErrnoText(errno);
|
|
close(fd);
|
|
fd = -1;
|
|
return;
|
|
}
|
|
}
|
|
|
|
FileLock::~FileLock() {
|
|
if (fd >= 0) {
|
|
flock(fd, LOCK_UN);
|
|
close(fd);
|
|
}
|
|
}
|
|
|
|
bool FileLock::Held() const {
|
|
return fd >= 0;
|
|
}
|
|
|
|
const std::string& FileLock::Problem() const {
|
|
return problem;
|
|
}
|
|
|
|
Json LockedUpdate(const fs::path& path, const std::function<bool(Json&)>& change, const Json& fallback,
|
|
std::uint32_t mode, const std::function<void(const std::string&)>& warn) {
|
|
const FileLock lock(std::format("{}.lock", path.string()));
|
|
if (!lock.Held() && warn) {
|
|
warn(std::format("{}: no lock ({}) - best-effort write", path.filename().string(), lock.Problem()));
|
|
}
|
|
Json data;
|
|
try {
|
|
if (const std::optional<std::string> text = ReadFile(path)) data = Json::parse(*text);
|
|
} catch (const std::exception&) {
|
|
data = Json();
|
|
}
|
|
if (!data.is_object()) data = fallback.is_object() ? fallback : Json::object();
|
|
if (change(data)) {
|
|
try {
|
|
WriteAtomic(path, DumpFile(data), mode);
|
|
} catch (const std::exception& e) {
|
|
if (warn) warn(std::format("{}: not saved ({})", path.filename().string(), e.what()));
|
|
}
|
|
}
|
|
return data;
|
|
}
|
|
|
|
std::optional<std::int64_t> ProcStart(std::int64_t pid) {
|
|
const std::optional<std::string> stat = ReadFile(Paths().proc / std::to_string(pid) / "stat");
|
|
if (!stat) return std::nullopt;
|
|
const std::size_t close = stat->rfind(')');
|
|
if (close == std::string::npos) return std::nullopt;
|
|
const std::vector<std::string> rest = Dyn::Split(std::string_view(*stat).substr(close + 1));
|
|
if (rest.size() < 20) return std::nullopt;
|
|
if (rest[0] == "Z" || rest[0] == "X") return std::nullopt;
|
|
return ToInt(rest[19]);
|
|
}
|
|
|
|
std::string BootId() {
|
|
const std::optional<std::string> text = ReadFile(Paths().proc / "sys" / "kernel" / "random" / "boot_id");
|
|
if (!text) return "";
|
|
return Dyn::Slice(Dyn::Strip(*text), 0, 8);
|
|
}
|
|
|
|
std::map<std::string, std::int64_t> MemInfo(const fs::path& path) {
|
|
std::map<std::string, std::int64_t> out;
|
|
const std::optional<std::string> text = ReadFile(path);
|
|
if (!text) return out;
|
|
for (const std::string& line : Dyn::SplitLines(*text)) {
|
|
const std::size_t colon = line.find(':');
|
|
if (colon == std::string::npos) continue;
|
|
const std::vector<std::string> parts = Dyn::Split(std::string_view(line).substr(colon + 1));
|
|
if (parts.empty()) continue;
|
|
const std::optional<std::int64_t> value = ToInt(parts[0]);
|
|
if (!value) continue;
|
|
out[line.substr(0, colon)] = parts.size() > 1 && parts[1] == "kB" ? *value / 1024 : *value;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
std::map<std::int64_t, std::vector<std::int64_t>> PpidMap() {
|
|
std::map<std::int64_t, std::vector<std::int64_t>> out;
|
|
std::error_code error;
|
|
for (const fs::directory_entry& entry : fs::directory_iterator("/proc", error)) {
|
|
const std::string name = entry.path().filename().string();
|
|
const std::optional<std::int64_t> pid = ToInt(name);
|
|
if (!pid) continue;
|
|
const std::optional<std::string> stat = ReadFile(entry.path() / "stat");
|
|
if (!stat) continue;
|
|
const std::size_t close = stat->rfind(')');
|
|
if (close == std::string::npos) continue;
|
|
const std::vector<std::string> rest = Dyn::Split(std::string_view(*stat).substr(close + 1));
|
|
if (rest.size() < 2) continue;
|
|
if (const std::optional<std::int64_t> ppid = ToInt(rest[1])) out[*ppid].push_back(*pid);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
std::vector<std::int64_t> Tree(std::int64_t pid, const std::map<std::int64_t, std::vector<std::int64_t>>& kids) {
|
|
std::vector<std::int64_t> out;
|
|
std::vector<std::int64_t> todo { pid };
|
|
std::set<std::int64_t> seen;
|
|
while (!todo.empty()) {
|
|
const std::int64_t next = todo.back();
|
|
todo.pop_back();
|
|
if (!seen.insert(next).second) continue;
|
|
out.push_back(next);
|
|
if (const auto found = kids.find(next); found != kids.end()) todo.insert(todo.end(), found->second.begin(), found->second.end());
|
|
}
|
|
return out;
|
|
}
|
|
|
|
double PssMb(std::span<const std::int64_t> pids) {
|
|
std::int64_t kb = 0;
|
|
for (const std::int64_t pid : pids) {
|
|
const std::optional<std::string> text = ReadFile(std::format("/proc/{}/smaps_rollup", pid));
|
|
if (!text) continue;
|
|
for (const std::string& line : Dyn::SplitLines(*text)) {
|
|
if (!line.starts_with("Pss:")) continue;
|
|
const std::vector<std::string> parts = Dyn::Split(line);
|
|
if (parts.size() > 1) {
|
|
if (const std::optional<std::int64_t> value = ToInt(parts[1])) kb += *value;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
return static_cast<double>(kb) / 1024;
|
|
}
|
|
|
|
std::string Identity::Key() const {
|
|
return std::format("{}:{}", pid, start);
|
|
}
|
|
|
|
std::optional<Identity> Self() {
|
|
if (!InSandbox()) {
|
|
const std::int64_t pid = getpid();
|
|
return Identity { .pid = pid, .start = ProcStart(pid).value_or(0) };
|
|
}
|
|
const std::optional<std::string> info = ReadFile(Paths().flatpakInfo);
|
|
const std::optional<std::string> instance = info ? KeyFileValue(*info, "Instance", "instance-id") : std::nullopt;
|
|
if (!instance) return std::nullopt;
|
|
const fs::path bwrapinfo = Paths().runtimeDir / ".flatpak" / *instance / "bwrapinfo.json";
|
|
// flatpak writes the file once bwrap reports the child's pid, which
|
|
// can be a moment after the application is already running.
|
|
for (std::int32_t attempt = 0; attempt < 20; ++attempt) {
|
|
if (const std::optional<std::string> text = ReadFile(bwrapinfo)) {
|
|
try {
|
|
const Json parsed = Json::parse(*text);
|
|
if (parsed.contains("child-pid") && parsed["child-pid"].is_number_integer()) {
|
|
const std::optional<std::int64_t> start = ProcStart(1);
|
|
if (start) return Identity { .pid = parsed["child-pid"].get<std::int64_t>(), .start = *start };
|
|
}
|
|
} catch (const std::exception&) {
|
|
}
|
|
}
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
std::int64_t PublishedPid() {
|
|
static const std::int64_t pid = [] {
|
|
if (const std::optional<Identity> self = Self()) return self->pid;
|
|
return static_cast<std::int64_t>(getpid());
|
|
}();
|
|
return pid;
|
|
}
|
|
|
|
fs::path LifetimeLockFile(const fs::path& dir, std::int64_t pid, std::int64_t start) {
|
|
return dir / std::format("{}-{}.lock", pid, start);
|
|
}
|
|
|
|
LifetimeLock::LifetimeLock(const fs::path& dir, const Identity& identity) : file(LifetimeLockFile(dir, identity.pid, identity.start)) {
|
|
fs::create_directories(dir);
|
|
if (ProbeLifetimeLock(dir, identity.pid, identity.start) == true) {
|
|
throw std::runtime_error(std::format("another driver already runs as {} (its lock {} is held): start each driver with its own `flatpak run`", identity.Key(), file.string()));
|
|
}
|
|
const fs::path temp = dir / std::format(".{}-{}.{}.tmp", identity.pid, identity.start, TokenHex(4));
|
|
fd = open(temp.c_str(), O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0644);
|
|
if (fd < 0) throw std::runtime_error(std::format("{}: {}", temp.string(), Dyn::ErrnoText(errno)));
|
|
if (flock(fd, LOCK_EX | LOCK_NB) != 0 || rename(temp.c_str(), file.c_str()) != 0) {
|
|
const std::int32_t failure = errno;
|
|
close(fd);
|
|
fd = -1;
|
|
unlink(temp.c_str());
|
|
throw std::runtime_error(std::format("{}: {}", file.string(), Dyn::ErrnoText(failure)));
|
|
}
|
|
}
|
|
|
|
LifetimeLock::~LifetimeLock() {
|
|
if (fd < 0) return;
|
|
// Unlinked while still held: a prober that opens the file after this
|
|
// finds none, which every rule reads as "this driver is gone or never
|
|
// took a lock" and the board's own leave() has already removed it.
|
|
unlink(file.c_str());
|
|
flock(fd, LOCK_UN);
|
|
close(fd);
|
|
}
|
|
|
|
const fs::path& LifetimeLock::File() const {
|
|
return file;
|
|
}
|
|
|
|
std::optional<bool> ProbeLifetimeLock(const fs::path& dir, std::int64_t pid, std::int64_t start) {
|
|
const fs::path file = LifetimeLockFile(dir, pid, start);
|
|
const std::int32_t fd = open(file.c_str(), O_RDONLY | O_CLOEXEC);
|
|
if (fd < 0) return std::nullopt;
|
|
// Shared: two probers looking at one dead driver both see it dead,
|
|
// where an exclusive probe would show each the other's lock.
|
|
const bool free = flock(fd, LOCK_SH | LOCK_NB) == 0;
|
|
if (free) flock(fd, LOCK_UN);
|
|
close(fd);
|
|
return !free;
|
|
}
|
|
}
|