feat(platform): processes that never outlive their pass, shared files, the board identity
: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>
This commit is contained in:
parent
36852edf63
commit
f94d895701
8 changed files with 1611 additions and 3 deletions
415
implementations/Crafter.Review-Platform.cpp
Normal file
415
implementations/Crafter.Review-Platform.cpp
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
473
implementations/Crafter.Review-Process.cpp
Normal file
473
implementations/Crafter.Review-Process.cpp
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module;
|
||||
#include <fcntl.h>
|
||||
#include <linux/close_range.h>
|
||||
#include <poll.h>
|
||||
#include <signal.h>
|
||||
#include <sys/prctl.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
#include <cerrno>
|
||||
|
||||
module Crafter.Review:Process_impl;
|
||||
import :Process;
|
||||
import :Platform;
|
||||
import :Text;
|
||||
import std;
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace Crafter::Review::Process {
|
||||
namespace {
|
||||
// ---- the reaper's registry and the spawner's queue ------------------------
|
||||
struct Entry {
|
||||
std::optional<std::int32_t> status;
|
||||
};
|
||||
struct Request;
|
||||
struct Shared {
|
||||
std::mutex registryMutex;
|
||||
std::condition_variable registryChanged;
|
||||
std::map<pid_t, Entry> registry;
|
||||
std::int32_t unreaped = 0;
|
||||
std::mutex spawnMutex;
|
||||
std::condition_variable spawnReady;
|
||||
std::deque<Request*> spawnQueue;
|
||||
};
|
||||
// Never destroyed. The reaper and the spawner are detached threads
|
||||
// that wait on these until the process ends, and a condition variable
|
||||
// destroyed under a waiter blocks exit(): glibc's pthread_cond_destroy
|
||||
// waits for its waiters, which here never come back.
|
||||
Shared& State() {
|
||||
static Shared* state = new Shared;
|
||||
return *state;
|
||||
}
|
||||
|
||||
std::int32_t ReturnCode(std::int32_t status) {
|
||||
if (WIFEXITED(status)) return WEXITSTATUS(status);
|
||||
if (WIFSIGNALED(status)) return -WTERMSIG(status);
|
||||
return -1;
|
||||
}
|
||||
|
||||
void ReaperLoop() {
|
||||
while (true) {
|
||||
std::int32_t status = 0;
|
||||
const pid_t pid = waitpid(-1, &status, 0);
|
||||
if (pid > 0) {
|
||||
const std::lock_guard lock(State().registryMutex);
|
||||
// A pid nobody registered is an orphan of one of our
|
||||
// children, reparented to us as the subreaper: reaped,
|
||||
// and that is all.
|
||||
if (const auto found = State().registry.find(pid); found != State().registry.end() && !found->second.status) {
|
||||
found->second.status = ReturnCode(status);
|
||||
--State().unreaped;
|
||||
State().registryChanged.notify_all();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (errno == EINTR) continue;
|
||||
// ECHILD: no child at all. Wait for Start() to register one;
|
||||
// wake now and then for orphans reparented in the meantime.
|
||||
std::unique_lock lock(State().registryMutex);
|
||||
State().registryChanged.wait_for(lock, std::chrono::seconds(1), [] { return State().unreaped > 0; });
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the spawner -------------------------------------------------------------
|
||||
struct Request {
|
||||
const Spec* spec;
|
||||
std::promise<Child> result;
|
||||
};
|
||||
std::once_flag ThreadsStarted;
|
||||
|
||||
std::optional<std::string> FindOnPath(const std::string& name, const std::string& path) {
|
||||
if (name.contains('/')) return name;
|
||||
for (const std::string& dir : Dyn::Split(path, ":")) {
|
||||
const std::string candidate = Dyn::JoinPath(dir.empty() ? std::string(".") : dir, name);
|
||||
if (access(candidate.c_str(), X_OK) == 0) return candidate;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::int32_t OpenStream(const Stream& stream, bool reading, std::int32_t& ours) {
|
||||
switch (stream.kind) {
|
||||
case Stream::Kind::Inherit:
|
||||
return -1;
|
||||
case Stream::Kind::Null:
|
||||
return open("/dev/null", (reading ? O_RDONLY : O_WRONLY) | O_CLOEXEC);
|
||||
case Stream::Kind::File:
|
||||
return reading ? open(stream.file.c_str(), O_RDONLY | O_CLOEXEC)
|
||||
: open(stream.file.c_str(), O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644);
|
||||
case Stream::Kind::Pipe: {
|
||||
std::array<std::int32_t, 2> ends {};
|
||||
if (pipe2(ends.data(), O_CLOEXEC) != 0) return -2;
|
||||
ours = reading ? ends[1] : ends[0];
|
||||
return reading ? ends[0] : ends[1];
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
Child DoSpawn(const Spec& spec) {
|
||||
if (spec.argv.empty()) throw std::runtime_error("Start: no argv");
|
||||
// Everything the child needs is made here: between fork and exec
|
||||
// it may only make async-signal-safe calls.
|
||||
std::vector<std::string> envStrings;
|
||||
std::string pathVariable = "/usr/local/bin:/usr/bin:/bin";
|
||||
if (spec.environment) {
|
||||
for (const auto& [name, value] : *spec.environment) envStrings.push_back(std::format("{}={}", name, value));
|
||||
if (const auto found = spec.environment->find("PATH"); found != spec.environment->end()) pathVariable = found->second;
|
||||
} else {
|
||||
for (char** entry = environ; entry != nullptr && *entry != nullptr; ++entry) envStrings.emplace_back(*entry);
|
||||
if (const std::optional<std::string> path = Platform::GetEnv("PATH")) pathVariable = *path;
|
||||
}
|
||||
const std::optional<std::string> program = FindOnPath(spec.argv.front(), pathVariable);
|
||||
if (!program) {
|
||||
throw std::runtime_error(std::format("{}: {}", Dyn::ErrnoText(ENOENT), Dyn::Repr(spec.argv.front())));
|
||||
}
|
||||
std::vector<char*> argv;
|
||||
for (const std::string& arg : spec.argv) argv.push_back(const_cast<char*>(arg.c_str()));
|
||||
argv.push_back(nullptr);
|
||||
std::vector<char*> envp;
|
||||
for (const std::string& entry : envStrings) envp.push_back(const_cast<char*>(entry.c_str()));
|
||||
envp.push_back(nullptr);
|
||||
const std::string cwd = spec.cwd ? spec.cwd->string() : std::string();
|
||||
|
||||
Child child;
|
||||
const std::int32_t in = OpenStream(spec.input, true, child.input);
|
||||
const std::int32_t out = OpenStream(spec.output, false, child.output);
|
||||
const std::int32_t err = OpenStream(spec.error, false, child.error);
|
||||
auto closeAll = [&] {
|
||||
for (const std::int32_t fd : { in, out, err, child.input, child.output, child.error }) {
|
||||
if (fd >= 0) close(fd);
|
||||
}
|
||||
};
|
||||
if (in == -2 || out == -2 || err == -2 || (spec.input.kind != Stream::Kind::Inherit && in < 0)
|
||||
|| (spec.output.kind != Stream::Kind::Inherit && out < 0) || (spec.error.kind != Stream::Kind::Inherit && err < 0)) {
|
||||
const std::int32_t failure = errno;
|
||||
closeAll();
|
||||
throw std::runtime_error(std::format("{}: {}", Dyn::ErrnoText(failure), Dyn::Repr(spec.argv.front())));
|
||||
}
|
||||
std::array<std::int32_t, 2> failurePipe {};
|
||||
if (pipe2(failurePipe.data(), O_CLOEXEC) != 0) {
|
||||
closeAll();
|
||||
throw std::runtime_error(Dyn::ErrnoText(errno));
|
||||
}
|
||||
sigset_t empty;
|
||||
sigemptyset(&empty);
|
||||
const pid_t parent = getpid();
|
||||
const std::int32_t deathSignal = spec.deathSignal;
|
||||
|
||||
std::unique_lock registry(State().registryMutex);
|
||||
const pid_t pid = fork();
|
||||
if (pid == 0) {
|
||||
struct sigaction defaults {};
|
||||
defaults.sa_handler = SIG_DFL;
|
||||
sigaction(SIGPIPE, &defaults, nullptr);
|
||||
sigaction(SIGXFSZ, &defaults, nullptr);
|
||||
sigprocmask(SIG_SETMASK, &empty, nullptr);
|
||||
prctl(PR_SET_PDEATHSIG, deathSignal);
|
||||
if (getppid() != parent) _exit(127);
|
||||
if (in >= 0) dup2(in, 0);
|
||||
if (out >= 0) dup2(out, 1);
|
||||
if (err >= 0) dup2(err, 2);
|
||||
std::int32_t failure = 0;
|
||||
if (!cwd.empty() && chdir(cwd.c_str()) != 0) {
|
||||
failure = errno;
|
||||
} else {
|
||||
// Nothing but the three streams reaches the program: a lock
|
||||
// or a socket of the driver's held open by a child would
|
||||
// outlive the driver.
|
||||
syscall(SYS_close_range, 3U, ~0U, CLOSE_RANGE_CLOEXEC);
|
||||
execve(program->c_str(), argv.data(), envp.data());
|
||||
failure = errno;
|
||||
}
|
||||
(void)!write(failurePipe[1], &failure, sizeof(failure));
|
||||
_exit(127);
|
||||
}
|
||||
if (pid < 0) {
|
||||
const std::int32_t failure = errno;
|
||||
registry.unlock();
|
||||
closeAll();
|
||||
close(failurePipe[0]);
|
||||
close(failurePipe[1]);
|
||||
throw std::runtime_error(std::format("fork: {}", Dyn::ErrnoText(failure)));
|
||||
}
|
||||
State().registry[pid] = {};
|
||||
++State().unreaped;
|
||||
registry.unlock();
|
||||
State().registryChanged.notify_all();
|
||||
|
||||
for (const std::int32_t fd : { in, out, err }) {
|
||||
if (fd >= 0) close(fd);
|
||||
}
|
||||
close(failurePipe[1]);
|
||||
std::int32_t failure = 0;
|
||||
ssize_t got = 0;
|
||||
do {
|
||||
got = read(failurePipe[0], &failure, sizeof(failure));
|
||||
} while (got < 0 && errno == EINTR);
|
||||
close(failurePipe[0]);
|
||||
if (got == static_cast<ssize_t>(sizeof(failure))) {
|
||||
Wait(pid);
|
||||
for (const std::int32_t fd : { child.input, child.output, child.error }) {
|
||||
if (fd >= 0) close(fd);
|
||||
}
|
||||
throw std::runtime_error(std::format("{}: {}", Dyn::ErrnoText(failure), Dyn::Repr(spec.argv.front())));
|
||||
}
|
||||
child.pid = pid;
|
||||
return child;
|
||||
}
|
||||
|
||||
void SpawnerLoop() {
|
||||
while (true) {
|
||||
Request* request = nullptr;
|
||||
{
|
||||
std::unique_lock lock(State().spawnMutex);
|
||||
State().spawnReady.wait(lock, [] { return !State().spawnQueue.empty(); });
|
||||
request = State().spawnQueue.front();
|
||||
State().spawnQueue.pop_front();
|
||||
}
|
||||
try {
|
||||
request->result.set_value(DoSpawn(*request->spec));
|
||||
} catch (...) {
|
||||
request->result.set_exception(std::current_exception());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void EnsureThreads() {
|
||||
std::call_once(ThreadsStarted, [] {
|
||||
std::thread(ReaperLoop).detach();
|
||||
std::thread(SpawnerLoop).detach();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Child Start(const Spec& spec) {
|
||||
EnsureThreads();
|
||||
Request request { .spec = &spec };
|
||||
std::future<Child> result = request.result.get_future();
|
||||
{
|
||||
const std::lock_guard lock(State().spawnMutex);
|
||||
State().spawnQueue.push_back(&request);
|
||||
}
|
||||
State().spawnReady.notify_one();
|
||||
return result.get();
|
||||
}
|
||||
|
||||
std::optional<std::int32_t> Wait(std::int64_t pid, std::optional<std::chrono::milliseconds> timeout) {
|
||||
std::unique_lock lock(State().registryMutex);
|
||||
auto exited = [pid] {
|
||||
const auto found = State().registry.find(static_cast<pid_t>(pid));
|
||||
return found == State().registry.end() || found->second.status.has_value();
|
||||
};
|
||||
if (timeout) {
|
||||
if (!State().registryChanged.wait_for(lock, *timeout, exited)) return std::nullopt;
|
||||
} else {
|
||||
State().registryChanged.wait(lock, exited);
|
||||
}
|
||||
const auto found = State().registry.find(static_cast<pid_t>(pid));
|
||||
if (found == State().registry.end()) return -1;
|
||||
const std::int32_t code = *found->second.status;
|
||||
State().registry.erase(found);
|
||||
return code;
|
||||
}
|
||||
|
||||
void Signal(std::int64_t pid, std::int32_t signal) {
|
||||
if (pid > 0) kill(static_cast<pid_t>(pid), signal);
|
||||
}
|
||||
|
||||
void KillTree(std::int64_t root, bool includeRoot) {
|
||||
std::set<std::int64_t> stopped;
|
||||
for (std::int32_t round = 0; round < 50; ++round) {
|
||||
const auto kids = Platform::PpidMap();
|
||||
bool added = false;
|
||||
for (const std::int64_t pid : Platform::Tree(root, kids)) {
|
||||
if (pid == root && !includeRoot) continue;
|
||||
if (pid <= 1) continue;
|
||||
if (stopped.insert(pid).second) {
|
||||
kill(static_cast<pid_t>(pid), SIGSTOP);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
if (!added) break;
|
||||
}
|
||||
for (const std::int64_t pid : stopped) kill(static_cast<pid_t>(pid), SIGKILL);
|
||||
}
|
||||
|
||||
void KillDescendants() {
|
||||
KillTree(getpid(), false);
|
||||
}
|
||||
|
||||
Captured Run(const std::vector<std::string>& argv, const std::optional<fs::path>& cwd,
|
||||
std::optional<std::chrono::milliseconds> timeout, std::string_view input,
|
||||
const std::optional<std::map<std::string, std::string>>& environment) {
|
||||
Spec spec { .argv = argv, .environment = environment, .cwd = cwd };
|
||||
spec.input.kind = input.empty() ? Stream::Kind::Null : Stream::Kind::Pipe;
|
||||
spec.output.kind = Stream::Kind::Pipe;
|
||||
spec.error.kind = Stream::Kind::Pipe;
|
||||
Child child = Start(spec);
|
||||
Captured captured;
|
||||
const auto deadline = timeout ? std::optional(std::chrono::steady_clock::now() + *timeout) : std::nullopt;
|
||||
std::size_t sent = 0;
|
||||
if (child.input >= 0) fcntl(child.input, F_SETFL, fcntl(child.input, F_GETFL) | O_NONBLOCK);
|
||||
std::array<char, 65536> buffer {};
|
||||
while (child.output >= 0 || child.error >= 0 || child.input >= 0) {
|
||||
std::vector<pollfd> watched;
|
||||
if (child.input >= 0) watched.push_back({ .fd = child.input, .events = POLLOUT, .revents = 0 });
|
||||
if (child.output >= 0) watched.push_back({ .fd = child.output, .events = POLLIN, .revents = 0 });
|
||||
if (child.error >= 0) watched.push_back({ .fd = child.error, .events = POLLIN, .revents = 0 });
|
||||
std::int32_t waitMs = -1;
|
||||
if (deadline) {
|
||||
const auto left = std::chrono::duration_cast<std::chrono::milliseconds>(*deadline - std::chrono::steady_clock::now()).count();
|
||||
if (left <= 0) {
|
||||
Signal(child.pid, SIGKILL);
|
||||
captured.timedOut = true;
|
||||
break;
|
||||
}
|
||||
waitMs = static_cast<std::int32_t>(std::min<std::int64_t>(left, 1000));
|
||||
}
|
||||
const std::int32_t ready = poll(watched.data(), watched.size(), waitMs);
|
||||
if (ready < 0 && errno != EINTR) break;
|
||||
for (const pollfd& entry : watched) {
|
||||
if (entry.revents == 0) continue;
|
||||
if (entry.fd == child.input) {
|
||||
if (entry.revents & (POLLERR | POLLHUP)) {
|
||||
close(child.input);
|
||||
child.input = -1;
|
||||
continue;
|
||||
}
|
||||
const ssize_t wrote = write(child.input, input.data() + sent, input.size() - sent);
|
||||
if (wrote > 0) sent += static_cast<std::size_t>(wrote);
|
||||
if (sent >= input.size() || (wrote < 0 && errno != EAGAIN && errno != EINTR)) {
|
||||
close(child.input);
|
||||
child.input = -1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const ssize_t got = read(entry.fd, buffer.data(), buffer.size());
|
||||
if (got > 0) {
|
||||
(entry.fd == child.output ? captured.out : captured.err).append(buffer.data(), static_cast<std::size_t>(got));
|
||||
} else if (got == 0 || (errno != EINTR && errno != EAGAIN)) {
|
||||
close(entry.fd);
|
||||
(entry.fd == child.output ? child.output : child.error) = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const std::int32_t fd : { child.input, child.output, child.error }) {
|
||||
if (fd >= 0) close(fd);
|
||||
}
|
||||
captured.returnCode = Wait(child.pid).value_or(-1);
|
||||
return captured;
|
||||
}
|
||||
|
||||
void InstallSignalThread(std::function<void(std::int32_t)> onSignal) {
|
||||
sigset_t set;
|
||||
sigemptyset(&set);
|
||||
sigaddset(&set, SIGINT);
|
||||
sigaddset(&set, SIGTERM);
|
||||
sigaddset(&set, SIGHUP);
|
||||
pthread_sigmask(SIG_BLOCK, &set, nullptr);
|
||||
struct sigaction ignore {};
|
||||
ignore.sa_handler = SIG_IGN;
|
||||
sigaction(SIGPIPE, &ignore, nullptr);
|
||||
std::thread([set, onSignal = std::move(onSignal)] {
|
||||
while (true) {
|
||||
std::int32_t signal = 0;
|
||||
if (sigwait(&set, &signal) == 0) onSignal(signal);
|
||||
}
|
||||
}).detach();
|
||||
}
|
||||
|
||||
void BecomeSubreaper() {
|
||||
prctl(PR_SET_CHILD_SUBREAPER, 1);
|
||||
}
|
||||
|
||||
std::vector<std::string> Supervised(std::span<const std::string> argv) {
|
||||
std::vector<std::string> out { Platform::SelfExe().string(), std::string(SuperviseVerb) };
|
||||
out.insert(out.end(), argv.begin(), argv.end());
|
||||
return out;
|
||||
}
|
||||
|
||||
std::int32_t SupervisedReturnCode(std::int32_t supervisorCode) {
|
||||
if (supervisorCode > 128 && supervisorCode <= 128 + 64) return -(supervisorCode - 128);
|
||||
return supervisorCode;
|
||||
}
|
||||
|
||||
std::int32_t SuperviseMain(std::span<const std::string_view> args) {
|
||||
if (args.empty()) {
|
||||
std::println(std::cerr, "{}: nothing to run", SuperviseVerb);
|
||||
return 64;
|
||||
}
|
||||
prctl(PR_SET_CHILD_SUBREAPER, 1);
|
||||
sigset_t watched;
|
||||
sigemptyset(&watched);
|
||||
sigaddset(&watched, SIGTERM);
|
||||
sigaddset(&watched, SIGINT);
|
||||
sigaddset(&watched, SIGHUP);
|
||||
sigaddset(&watched, SIGCHLD);
|
||||
sigprocmask(SIG_BLOCK, &watched, nullptr);
|
||||
sigset_t empty;
|
||||
sigemptyset(&empty);
|
||||
|
||||
std::vector<std::string> strings(args.begin(), args.end());
|
||||
std::vector<char*> argv;
|
||||
for (const std::string& arg : strings) argv.push_back(const_cast<char*>(arg.c_str()));
|
||||
argv.push_back(nullptr);
|
||||
const std::optional<std::string> program = FindOnPath(strings.front(), Platform::GetEnv("PATH").value_or("/usr/bin:/bin"));
|
||||
if (!program) {
|
||||
std::println(std::cerr, "{}: {}", Dyn::ErrnoText(ENOENT), Dyn::Repr(strings.front()));
|
||||
return 127;
|
||||
}
|
||||
const pid_t self = getpid();
|
||||
const pid_t target = fork();
|
||||
if (target == 0) {
|
||||
struct sigaction defaults {};
|
||||
defaults.sa_handler = SIG_DFL;
|
||||
sigaction(SIGPIPE, &defaults, nullptr);
|
||||
sigaction(SIGXFSZ, &defaults, nullptr);
|
||||
sigprocmask(SIG_SETMASK, &empty, nullptr);
|
||||
prctl(PR_SET_PDEATHSIG, SIGKILL);
|
||||
if (getppid() != self) _exit(127);
|
||||
execv(program->c_str(), argv.data());
|
||||
_exit(127);
|
||||
}
|
||||
if (target < 0) return 127;
|
||||
|
||||
std::optional<std::int32_t> status;
|
||||
while (!status) {
|
||||
siginfo_t info {};
|
||||
const std::int32_t signal = sigwaitinfo(&watched, &info);
|
||||
if (signal < 0) continue;
|
||||
if (signal == SIGCHLD) {
|
||||
while (true) {
|
||||
std::int32_t raw = 0;
|
||||
const pid_t pid = waitpid(-1, &raw, WNOHANG);
|
||||
if (pid <= 0) break;
|
||||
if (pid == target) status = raw;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A timeout, an abort, or the driver gone: the whole tree goes,
|
||||
// the target with it, and its status is what that left.
|
||||
KillTree(self, false);
|
||||
std::int32_t raw = 0;
|
||||
while (waitpid(target, &raw, 0) < 0 && errno == EINTR) {}
|
||||
status = raw;
|
||||
}
|
||||
// The target is gone; whatever it started goes too, and every orphan
|
||||
// that was reparented to this subreaper is reaped.
|
||||
KillTree(self, false);
|
||||
while (true) {
|
||||
const pid_t pid = waitpid(-1, nullptr, 0);
|
||||
if (pid < 0 && errno == EINTR) continue;
|
||||
if (pid < 0) break;
|
||||
}
|
||||
if (WIFEXITED(*status)) return WEXITSTATUS(*status);
|
||||
if (WIFSIGNALED(*status)) return 128 + WTERMSIG(*status);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
173
interfaces/Crafter.Review-Platform.cppm
Normal file
173
interfaces/Crafter.Review-Platform.cppm
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
export module Crafter.Review:Platform;
|
||||
import std;
|
||||
import :Json;
|
||||
|
||||
// The machine as the driver sees it: whether it runs inside the Flatpak's
|
||||
// sandbox, its environment, the files it shares with other drivers and how
|
||||
// they are locked, /proc, and the identity it is known by on the seat board.
|
||||
//
|
||||
// The sandbox is decided once, by Init(). Everything that reads /proc or the
|
||||
// sandbox's instance files goes through Paths(), which a test points at a
|
||||
// fake tree: flatpak-builder runs the suite inside `flatpak build`, where
|
||||
// /.flatpak-info exists, and a test that read the real files would test the
|
||||
// build sandbox instead of the code.
|
||||
export namespace Crafter::Review::Platform {
|
||||
enum class Mode {
|
||||
Native,
|
||||
// Inside a Flatpak: its own PID namespace, XDG directories under
|
||||
// ~/.var/app, a private /tmp unless the manifest shares the host's.
|
||||
Sandbox,
|
||||
};
|
||||
|
||||
struct SystemPaths {
|
||||
std::filesystem::path proc = "/proc";
|
||||
std::filesystem::path flatpakInfo = "/.flatpak-info";
|
||||
// $XDG_RUNTIME_DIR, where the instance's bwrapinfo.json is mounted:
|
||||
// /run/user/<uid>/.flatpak/<instance-id>/bwrapinfo.json.
|
||||
std::filesystem::path runtimeDir;
|
||||
};
|
||||
|
||||
// Decides the mode from /.flatpak-info and, in the sandbox, restores the
|
||||
// host's XDG directories (RestoreHostXdg) before any thread starts.
|
||||
void Init();
|
||||
Mode CurrentMode();
|
||||
bool InSandbox();
|
||||
// For tests: pin the mode and the paths the identity code reads.
|
||||
void SetMode(Mode mode);
|
||||
const SystemPaths& Paths();
|
||||
void SetPaths(SystemPaths paths);
|
||||
|
||||
// ---- environment -----------------------------------------------------------
|
||||
std::optional<std::string> GetEnv(std::string_view name);
|
||||
// The process environment.
|
||||
std::map<std::string, std::string> Environment();
|
||||
// $HOME, else the password database's.
|
||||
std::string Home();
|
||||
// Flatpak points XDG_{CONFIG,CACHE,DATA,STATE}_HOME at ~/.var/app/<id>/…;
|
||||
// they go back to the host's (HOST_XDG_*, else the XDG defaults under
|
||||
// $HOME), so git reads ~/.config/git/config, Playwright finds
|
||||
// ~/.cache/ms-playwright, and claude and npx behave as on the host.
|
||||
void RestoreHostXdg();
|
||||
// The absolute path of the running binary (/proc/self/exe).
|
||||
std::filesystem::path SelfExe();
|
||||
|
||||
// ---- time ----------------------------------------------------------------------
|
||||
// time.time()
|
||||
double Now();
|
||||
// time.strftime(format) in local time.
|
||||
std::string LocalTime(std::string_view format);
|
||||
// time.strftime(format, time.localtime(epochSeconds))
|
||||
std::string LocalTime(std::string_view format, double epochSeconds);
|
||||
// date.today().isoformat()
|
||||
std::string TodayIso();
|
||||
|
||||
// ---- files ------------------------------------------------------------------------
|
||||
// The file's bytes, or nullopt when it cannot be read.
|
||||
std::optional<std::string> ReadFile(const std::filesystem::path& path);
|
||||
// `content` into `<path>.<pid>.tmp`, created with `mode` (the umask
|
||||
// applies), then renamed over `path`: a reader never sees half a file, and
|
||||
// two writers never share a temp name. The pid is PublishedPid(), which in
|
||||
// the sandbox is a host pid, where every driver would otherwise be pid 2.
|
||||
// Directories are created as needed.
|
||||
void WriteAtomic(const std::filesystem::path& path, std::string_view content, std::uint32_t mode = 0644);
|
||||
|
||||
// An exclusive flock(2) on `lockPath`, held while the object lives. flock,
|
||||
// not fcntl or OFD locks: every driver on the machine takes flock on the
|
||||
// same side files, and on Linux the two kinds do not exclude each other.
|
||||
class FileLock {
|
||||
public:
|
||||
explicit FileLock(const std::filesystem::path& lockPath);
|
||||
~FileLock();
|
||||
FileLock(const FileLock&) = delete;
|
||||
FileLock& operator=(const FileLock&) = delete;
|
||||
// False when the lock file could not be opened or locked; the caller
|
||||
// carries on best-effort.
|
||||
bool Held() const;
|
||||
// Why not, when it is not held.
|
||||
const std::string& Problem() const;
|
||||
|
||||
private:
|
||||
std::int32_t fd = -1;
|
||||
std::string problem;
|
||||
};
|
||||
|
||||
// The one shape every file shared between drivers is written in (the
|
||||
// board, the ledger, memory.json, unit-state.json): lock `<path>.lock`,
|
||||
// re-read the file, let `change` edit it in place — a false return leaves
|
||||
// the file as it is — write it back atomically with `mode`, release.
|
||||
// A file that does not parse as a JSON object hands `change` a copy of
|
||||
// `fallback`. Without the lock the write is still made, best-effort;
|
||||
// `warn` hears about that and about a write that failed. Returns what
|
||||
// `change` saw.
|
||||
Json LockedUpdate(const std::filesystem::path& path, const std::function<bool(Json&)>& change, const Json& fallback,
|
||||
std::uint32_t mode, const std::function<void(const std::string&)>& warn);
|
||||
|
||||
// ---- /proc ----------------------------------------------------------------------------
|
||||
// The start time of a live process, in clock ticks since boot (field 22
|
||||
// of /proc/<pid>/stat, read after the last ')' because the command name
|
||||
// may hold spaces and parentheses); nullopt for no such process or a
|
||||
// zombie. Under Paths().proc.
|
||||
std::optional<std::int64_t> ProcStart(std::int64_t pid);
|
||||
// The first 8 characters of /proc/sys/kernel/random/boot_id, "" when
|
||||
// unreadable.
|
||||
std::string BootId();
|
||||
// /proc/meminfo as {field: MB}; a field without a kB unit keeps its value.
|
||||
std::map<std::string, std::int64_t> MemInfo(const std::filesystem::path& path = "/proc/meminfo");
|
||||
// {ppid: [pids]} of every process now, from one read of each
|
||||
// /proc/<pid>/stat. Always the real /proc: a process tree is this
|
||||
// driver's own business.
|
||||
std::map<std::int64_t, std::vector<std::int64_t>> PpidMap();
|
||||
// The pid and every descendant in `kids`.
|
||||
std::vector<std::int64_t> Tree(std::int64_t pid, const std::map<std::int64_t, std::vector<std::int64_t>>& kids);
|
||||
// The summed Pss of the processes, in MB; one that is gone counts nothing.
|
||||
double PssMb(std::span<const std::int64_t> pids);
|
||||
|
||||
// ---- identity -------------------------------------------------------------------------
|
||||
// How the other drivers on this machine know a driver: `<pid>:<start>`.
|
||||
struct Identity {
|
||||
std::int64_t pid = 0;
|
||||
std::int64_t start = 0;
|
||||
std::string Key() const;
|
||||
};
|
||||
// This process as every driver on the machine can check it. Natively its
|
||||
// own pid and start time. In the sandbox its pid means nothing outside
|
||||
// (every Flatpak driver is pid 2), so it is the instance's: the host pid
|
||||
// of the sandbox's init (`child-pid` in bwrapinfo.json) and the start time
|
||||
// of /proc/1 inside, which is the same process — a driver outside that
|
||||
// checks the pid then sees it alive exactly as long as the sandbox runs.
|
||||
// nullopt in a sandbox without bwrapinfo.json (`flatpak build`), after
|
||||
// waiting up to 2 s for flatpak to write it.
|
||||
std::optional<Identity> Self();
|
||||
// The pid to put in temp names and lock information: Self()'s, else
|
||||
// getpid().
|
||||
std::int64_t PublishedPid();
|
||||
|
||||
// The lifetime lock of a driver: an exclusive flock on
|
||||
// `<dir>/<pid>-<start>.lock`, held as long as the driver lives. It is
|
||||
// created under a temporary name, locked, then renamed into place, so a
|
||||
// prober never finds the file unlocked while its owner lives. Another
|
||||
// driver tells a live one from a dead one by it across sandboxes, where
|
||||
// pids are not comparable.
|
||||
class LifetimeLock {
|
||||
public:
|
||||
// Throws std::runtime_error when the name is already held by a live
|
||||
// driver: a second full run in one sandbox instance.
|
||||
LifetimeLock(const std::filesystem::path& dir, const Identity& identity);
|
||||
~LifetimeLock();
|
||||
LifetimeLock(const LifetimeLock&) = delete;
|
||||
LifetimeLock& operator=(const LifetimeLock&) = delete;
|
||||
const std::filesystem::path& File() const;
|
||||
|
||||
private:
|
||||
std::int32_t fd = -1;
|
||||
std::filesystem::path file;
|
||||
};
|
||||
std::filesystem::path LifetimeLockFile(const std::filesystem::path& dir, std::int64_t pid, std::int64_t start);
|
||||
// true: a live driver holds the lock; false: the file is there and nobody
|
||||
// holds it (its driver died); nullopt: there is no lock file (a driver
|
||||
// that takes none).
|
||||
std::optional<bool> ProbeLifetimeLock(const std::filesystem::path& dir, std::int64_t pid, std::int64_t start);
|
||||
}
|
||||
113
interfaces/Crafter.Review-Process.cppm
Normal file
113
interfaces/Crafter.Review-Process.cppm
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
export module Crafter.Review:Process;
|
||||
import std;
|
||||
|
||||
// Child processes, and making sure none outlives what it was started for.
|
||||
//
|
||||
// SIGKILL to the claude process alone would leave the Playwright server and
|
||||
// Chromium under it running after a timeout, and every pass running after a
|
||||
// SIGKILLed driver. So:
|
||||
// - every child is started from one long-lived spawner thread, because
|
||||
// PR_SET_PDEATHSIG fires when the *thread* that forked the child ends;
|
||||
// - the driver is a child subreaper, so whatever its children leave
|
||||
// behind becomes its own child rather than init's, and one reaper thread
|
||||
// owns waitpid(-1) for all of them;
|
||||
// - a claude pass runs under a supervisor — this binary again, as
|
||||
// `crafter-review __supervise <argv>` — that is the subreaper of the
|
||||
// pass's whole tree. Playwright starts Chromium detached, as the leader of
|
||||
// a process group of its own, so killing a process group would miss it; a
|
||||
// tree walk does not. The supervisor kills the tree when claude exits, on
|
||||
// SIGTERM (a timeout or an abort), and when the driver dies however it
|
||||
// dies, through PR_SET_PDEATHSIG.
|
||||
//
|
||||
// Every subprocess goes through Start(); popen() and system() are not used.
|
||||
export namespace Crafter::Review::Process {
|
||||
// Where a child's standard stream goes.
|
||||
struct Stream {
|
||||
enum class Kind {
|
||||
Inherit,
|
||||
Null,
|
||||
// Opened for reading (stdin) or created and truncated (stdout,
|
||||
// stderr).
|
||||
File,
|
||||
// A pipe whose other end Start() returns.
|
||||
Pipe,
|
||||
};
|
||||
Kind kind = Kind::Inherit;
|
||||
std::filesystem::path file;
|
||||
};
|
||||
|
||||
struct Spec {
|
||||
// argv[0] is looked up on the child's PATH when it has no '/'.
|
||||
std::vector<std::string> argv;
|
||||
// nullopt: this process's environment.
|
||||
std::optional<std::map<std::string, std::string>> environment;
|
||||
std::optional<std::filesystem::path> cwd;
|
||||
Stream input;
|
||||
Stream output;
|
||||
Stream error;
|
||||
// Sent to the child when this process ends (9, SIGKILL; a supervisor
|
||||
// is started with 15, SIGTERM, so it can take its tree down first).
|
||||
std::int32_t deathSignal = 9;
|
||||
};
|
||||
|
||||
struct Child {
|
||||
std::int64_t pid = 0;
|
||||
// Our ends of the pipes the spec asked for, -1 otherwise; the caller
|
||||
// closes them.
|
||||
std::int32_t input = -1;
|
||||
std::int32_t output = -1;
|
||||
std::int32_t error = -1;
|
||||
};
|
||||
|
||||
// Throws std::runtime_error when the child cannot be started, with the
|
||||
// errno text: "[Errno 2] No such file or directory: 'claude'".
|
||||
Child Start(const Spec& spec);
|
||||
// The return code once the child has exited — its exit status, or minus
|
||||
// the signal that killed it — or nullopt when `timeout` ran out first, the
|
||||
// child still running.
|
||||
std::optional<std::int32_t> Wait(std::int64_t pid, std::optional<std::chrono::milliseconds> timeout = std::nullopt);
|
||||
void Signal(std::int64_t pid, std::int32_t signal);
|
||||
// Every descendant of `root` (and root itself with includeRoot) is
|
||||
// stopped with SIGSTOP, the tree is scanned again until no new process
|
||||
// appears — a stopped process cannot fork — and then all are killed.
|
||||
void KillTree(std::int64_t root, bool includeRoot);
|
||||
// KillTree of this process, at exit: never pid 1, never a process this
|
||||
// process did not start, such as a debug shell in the same sandbox.
|
||||
void KillDescendants();
|
||||
|
||||
// A command run to its end with both outputs captured: stdin from
|
||||
// `input`, or /dev/null; both outputs read as they come, so neither can
|
||||
// fill its pipe and stall the child. With a timeout the child is killed
|
||||
// when it runs out, and timedOut is set.
|
||||
struct Captured {
|
||||
std::int32_t returnCode = -1;
|
||||
std::string out;
|
||||
std::string err;
|
||||
bool timedOut = false;
|
||||
};
|
||||
Captured Run(const std::vector<std::string>& argv, const std::optional<std::filesystem::path>& cwd = std::nullopt,
|
||||
std::optional<std::chrono::milliseconds> timeout = std::nullopt, std::string_view input = {},
|
||||
const std::optional<std::map<std::string, std::string>>& environment = std::nullopt);
|
||||
|
||||
// Main() calls both first, before any other thread starts. SIGINT,
|
||||
// SIGTERM and SIGHUP are blocked in every thread and handed to `onSignal`
|
||||
// on a thread of their own; SIGPIPE is ignored, so a helper that goes away
|
||||
// mid-call cannot end a run that has been going for hours. Children get
|
||||
// both back at their defaults.
|
||||
void InstallSignalThread(std::function<void(std::int32_t)> onSignal);
|
||||
void BecomeSubreaper();
|
||||
|
||||
constexpr std::string_view SuperviseVerb = "__supervise";
|
||||
// The argv that runs `argv` under a supervisor: this binary, the verb,
|
||||
// then `argv`.
|
||||
std::vector<std::string> Supervised(std::span<const std::string> argv);
|
||||
// The supervisor itself, given everything after the verb. Exits with the
|
||||
// target's status, or 128 + the signal that killed it.
|
||||
std::int32_t SuperviseMain(std::span<const std::string_view> argv);
|
||||
// A supervisor's exit code as the target's return code: 128 + n up to
|
||||
// 128 + 64 is "killed by signal n", -n.
|
||||
std::int32_t SupervisedReturnCode(std::int32_t supervisorCode);
|
||||
}
|
||||
|
|
@ -7,5 +7,7 @@ export import :Unicode;
|
|||
export import :Json;
|
||||
export import :Text;
|
||||
export import :Crypto;
|
||||
export import :Platform;
|
||||
export import :Process;
|
||||
export import :Core;
|
||||
export import :Driver;
|
||||
|
|
|
|||
12
project.cpp
12
project.cpp
|
|
@ -13,27 +13,33 @@ using namespace Crafter;
|
|||
namespace {
|
||||
// The umbrella, then the partitions in import order: each after the ones
|
||||
// it imports, which is also the order the module map in AGENTS.md uses.
|
||||
constexpr std::array<std::string_view, 7> Interfaces = {
|
||||
constexpr std::array<std::string_view, 9> Interfaces = {
|
||||
"interfaces/Crafter.Review",
|
||||
"interfaces/Crafter.Review-Unicode",
|
||||
"interfaces/Crafter.Review-Json",
|
||||
"interfaces/Crafter.Review-Text",
|
||||
"interfaces/Crafter.Review-Crypto",
|
||||
"interfaces/Crafter.Review-Platform",
|
||||
"interfaces/Crafter.Review-Process",
|
||||
"interfaces/Crafter.Review-Core",
|
||||
"interfaces/Crafter.Review-Driver",
|
||||
};
|
||||
constexpr std::array<std::string_view, 4> Implementations = {
|
||||
constexpr std::array<std::string_view, 6> Implementations = {
|
||||
"implementations/Crafter.Review-Text",
|
||||
"implementations/Crafter.Review-Crypto",
|
||||
"implementations/Crafter.Review-Platform",
|
||||
"implementations/Crafter.Review-Process",
|
||||
"implementations/Crafter.Review-Core",
|
||||
"implementations/Crafter.Review-Driver",
|
||||
};
|
||||
|
||||
// One per directory under tests/, each an ordinary main() that links the
|
||||
// library: exit 0 passes, nonzero fails, 77 skips.
|
||||
constexpr std::array<std::string_view, 2> Tests = {
|
||||
constexpr std::array<std::string_view, 4> Tests = {
|
||||
"Crypto",
|
||||
"Text",
|
||||
"Platform",
|
||||
"Process",
|
||||
};
|
||||
|
||||
// The version the binary reports: CI sets it from the tag, a checkout
|
||||
|
|
|
|||
219
tests/Platform/main.cpp
Normal file
219
tests/Platform/main.cpp
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// The machine as the driver sees it.
|
||||
//
|
||||
// - Shared files: an atomic write never leaves half a file and gives the
|
||||
// temp file the mode asked for; LockedUpdate re-reads under the lock, keeps
|
||||
// keys it does not know, falls back on a damaged file, and serialises
|
||||
// concurrent writers — including another process holding flock(2) on the
|
||||
// same side file, as every driver on the machine does.
|
||||
// - /proc: the start time read after the last ')' (a command name may hold
|
||||
// spaces and parentheses), a zombie reading as gone, MemInfo in MB.
|
||||
// - Identity: natively a driver's own pid and start time; in the sandbox the
|
||||
// instance's host pid from bwrapinfo.json with the start time of /proc/1.
|
||||
// On a fake /proc and a fake instance directory, because the suite itself
|
||||
// runs inside `flatpak build` in the Flatpak.
|
||||
// - The lifetime lock: held while its driver lives, refused to a second
|
||||
// driver under the same name, gone when it ends, and a stale one reads as
|
||||
// dead.
|
||||
// - The host XDG directories coming back in the sandbox.
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
|
||||
import std;
|
||||
import Crafter.Review;
|
||||
namespace fs = std::filesystem;
|
||||
using namespace Crafter::Review;
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
namespace {
|
||||
std::int32_t Failures = 0;
|
||||
|
||||
void Check(bool cond, std::string_view msg) {
|
||||
if (!cond) {
|
||||
std::println(std::cerr, "FAIL: {}", msg);
|
||||
++Failures;
|
||||
}
|
||||
}
|
||||
|
||||
void Write(const fs::path& path, std::string_view text) {
|
||||
fs::create_directories(path.parent_path());
|
||||
std::ofstream(path, std::ios::binary) << text;
|
||||
}
|
||||
|
||||
// A /proc/<pid>/stat whose command name is designed to trip a naive split.
|
||||
std::string Stat(std::int64_t pid, char state, std::int64_t start) {
|
||||
std::string fields = std::format("{} (we (ird) name) {} 1", pid, state);
|
||||
for (std::int32_t field = 5; field <= 21; ++field) fields += " 0";
|
||||
return std::format("{} {} 0 0\n", fields, start);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
const fs::path dir = fs::temp_directory_path() / std::format("crafter-review-platform-{}", getpid());
|
||||
fs::remove_all(dir);
|
||||
fs::create_directories(dir);
|
||||
Platform::SetMode(Platform::Mode::Native);
|
||||
|
||||
{
|
||||
const mode_t previous = umask(022);
|
||||
const fs::path file = dir / "atomic.json";
|
||||
Platform::WriteAtomic(file, "{\"a\": 1}", 0600);
|
||||
struct stat info {};
|
||||
stat(file.c_str(), &info);
|
||||
Check(Platform::ReadFile(file) == "{\"a\": 1}", "the atomic write lands");
|
||||
Check((info.st_mode & 0777) == 0600, std::format("with the mode asked for ({:o})", info.st_mode & 0777));
|
||||
Check(!fs::exists(std::format("{}.{}.tmp", file.string(), Platform::PublishedPid())), "and leaves no temp file");
|
||||
umask(previous);
|
||||
}
|
||||
|
||||
{
|
||||
const fs::path file = dir / "board.json";
|
||||
Write(file, "{\"version\": 1, \"cost7d\": 3.5, \"seats\": {}}");
|
||||
const Json seen = Platform::LockedUpdate(file, [](Json& data) {
|
||||
data["seats"]["local"] = { { "id", "local" } };
|
||||
return true;
|
||||
}, Json::object(), 0600, {});
|
||||
const Json back = Json::parse(*Platform::ReadFile(file));
|
||||
Check(back["cost7d"] == 3.5 && back["seats"]["local"]["id"] == "local", "a key this driver does not know survives the rewrite");
|
||||
Check(*Platform::ReadFile(file) == DumpFile(back), "written in DumpFile's layout");
|
||||
Check(seen == back, "LockedUpdate returns what the change saw");
|
||||
|
||||
Write(file, "{not json");
|
||||
const Json fallback = { { "version", 1 } };
|
||||
Platform::LockedUpdate(file, [](Json& data) {
|
||||
return data.contains("version");
|
||||
}, fallback, 0600, {});
|
||||
Check(Json::parse(*Platform::ReadFile(file)) == fallback, "a damaged file is replaced by the fallback the change kept");
|
||||
|
||||
const std::string before = *Platform::ReadFile(file);
|
||||
Platform::LockedUpdate(file, [](Json& data) {
|
||||
data["ignored"] = true;
|
||||
return false;
|
||||
}, Json::object(), 0600, {});
|
||||
Check(*Platform::ReadFile(file) == before, "a change that returns false leaves the file alone");
|
||||
}
|
||||
|
||||
{
|
||||
// Two writers in one process: flock is per open file description, so
|
||||
// two FileLocks exclude each other just as two processes would.
|
||||
const fs::path file = dir / "counter.json";
|
||||
Write(file, "{\"n\": 0}");
|
||||
auto bump = [&file] {
|
||||
for (std::int32_t i = 0; i < 100; ++i) {
|
||||
Platform::LockedUpdate(file, [](Json& data) {
|
||||
data["n"] = data["n"].get<std::int64_t>() + 1;
|
||||
return true;
|
||||
}, Json::object(), 0600, {});
|
||||
}
|
||||
};
|
||||
std::thread a(bump);
|
||||
std::thread b(bump);
|
||||
a.join();
|
||||
b.join();
|
||||
Check(Json::parse(*Platform::ReadFile(file))["n"] == 200, "concurrent read-modify-writes lose nothing");
|
||||
}
|
||||
|
||||
{
|
||||
// Another process holds flock on the side file for a moment (flock(1),
|
||||
// util-linux): the lock here must wait for it, as every driver on the
|
||||
// machine shares these files.
|
||||
const fs::path file = dir / "shared.json";
|
||||
const fs::path lock = std::format("{}.lock", file.string());
|
||||
const fs::path ready = dir / "other-holds";
|
||||
try {
|
||||
const Process::Child other = Process::Start({ .argv = { "flock", lock.string(), "sh", "-c", "touch \"$1\" && sleep 0.4", "sh", ready.string() } });
|
||||
for (std::int32_t i = 0; i < 100 && !fs::exists(ready); ++i) std::this_thread::sleep_for(20ms);
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
{
|
||||
const Platform::FileLock ours(lock);
|
||||
Check(ours.Held(), "the lock is taken once the other process lets go");
|
||||
}
|
||||
const auto waited = std::chrono::steady_clock::now() - start;
|
||||
Process::Wait(other.pid);
|
||||
Check(waited >= 200ms, std::format("and not before: flock(2) excludes the other process's flock ({} ms)", std::chrono::duration_cast<std::chrono::milliseconds>(waited).count()));
|
||||
} catch (const std::runtime_error&) {
|
||||
std::println("SKIP no flock here to hold the other end of the lock");
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const fs::path proc = dir / "proc";
|
||||
Write(proc / "4242" / "stat", Stat(4242, 'S', 987654));
|
||||
Write(proc / "4243" / "stat", Stat(4243, 'Z', 5));
|
||||
Write(proc / "sys" / "kernel" / "random" / "boot_id", "0123456789abcdef-the-rest\n");
|
||||
Write(dir / "meminfo", "MemTotal: 65536000 kB\nMemAvailable: 32768000 kB\nHugePages_Total: 0\n");
|
||||
Platform::SetPaths({ .proc = proc, .flatpakInfo = dir / "no-flatpak-info", .runtimeDir = dir / "run" });
|
||||
Check(Platform::ProcStart(4242) == 987654, "the start time is field 22, read after the last ')'");
|
||||
Check(!Platform::ProcStart(4243).has_value(), "a zombie reads as gone");
|
||||
Check(!Platform::ProcStart(4244).has_value(), "so does a pid that is not there");
|
||||
Check(Platform::BootId() == "01234567", "boot_id's first eight characters");
|
||||
const auto memory = Platform::MemInfo(dir / "meminfo");
|
||||
Check(memory.at("MemTotal") == 64000 && memory.at("MemAvailable") == 32000 && memory.at("HugePages_Total") == 0, "meminfo in MB, unitless fields as they are");
|
||||
|
||||
Write(proc / std::to_string(getpid()) / "stat", Stat(getpid(), 'R', 111));
|
||||
Platform::SetMode(Platform::Mode::Native);
|
||||
const std::optional<Platform::Identity> native = Platform::Self();
|
||||
Check(native && native->pid == getpid() && native->start == 111 && native->Key() == std::format("{}:111", getpid()), "natively a driver is its own pid and start time");
|
||||
|
||||
Write(dir / "flatpak-info", "[Application]\nname=net.catcrafts.CrafterReview\n\n[Instance]\ninstance-id=2509661707\napp-path=/x\n");
|
||||
Write(dir / "run" / ".flatpak" / "2509661707" / "bwrapinfo.json", "{\n \"child-pid\": 416559,\n \"pid-namespace\": 4026536671\n}\n");
|
||||
Write(proc / "1" / "stat", Stat(1, 'S', 841601));
|
||||
Platform::SetPaths({ .proc = proc, .flatpakInfo = dir / "flatpak-info", .runtimeDir = dir / "run" });
|
||||
Platform::SetMode(Platform::Mode::Sandbox);
|
||||
const std::optional<Platform::Identity> sandboxed = Platform::Self();
|
||||
Check(sandboxed && sandboxed->Key() == "416559:841601", "in the sandbox: the instance's host pid and /proc/1's start time");
|
||||
|
||||
fs::remove(dir / "run" / ".flatpak" / "2509661707" / "bwrapinfo.json");
|
||||
Check(!Platform::Self().has_value(), "no bwrapinfo.json (flatpak build): no identity a host driver could check");
|
||||
Platform::SetMode(Platform::Mode::Native);
|
||||
}
|
||||
|
||||
{
|
||||
const fs::path drivers = dir / "drivers";
|
||||
const Platform::Identity me { .pid = 416559, .start = 841601 };
|
||||
{
|
||||
const Platform::LifetimeLock lock(drivers, me);
|
||||
Check(fs::exists(drivers / "416559-841601.lock"), "the lifetime lock is named <pid>-<start>.lock");
|
||||
Check(Platform::ProbeLifetimeLock(drivers, me.pid, me.start) == true, "a probe finds it held");
|
||||
bool refused = false;
|
||||
try {
|
||||
const Platform::LifetimeLock second(drivers, me);
|
||||
} catch (const std::runtime_error&) {
|
||||
refused = true;
|
||||
}
|
||||
Check(refused, "a second driver under the same name is refused");
|
||||
const std::vector<fs::directory_entry> entries(fs::directory_iterator(drivers), {});
|
||||
Check(entries.size() == 1, "and leaves no temp file behind");
|
||||
}
|
||||
Check(!Platform::ProbeLifetimeLock(drivers, me.pid, me.start).has_value(), "a driver that ended leaves no lock file");
|
||||
Write(Platform::LifetimeLockFile(drivers, 7, 8), "");
|
||||
Check(Platform::ProbeLifetimeLock(drivers, 7, 8) == false, "a lock file nobody holds is a dead driver's");
|
||||
Check(!Platform::ProbeLifetimeLock(drivers, 9, 9).has_value(), "no lock file: a driver that takes none");
|
||||
}
|
||||
|
||||
{
|
||||
const std::string home = Platform::Home();
|
||||
setenv("XDG_CONFIG_HOME", "/home/x/.var/app/net.catcrafts.CrafterReview/config", 1);
|
||||
setenv("XDG_CACHE_HOME", "/home/x/.var/app/net.catcrafts.CrafterReview/cache", 1);
|
||||
setenv("HOST_XDG_CONFIG_HOME", "/elsewhere/config", 1);
|
||||
unsetenv("HOST_XDG_CACHE_HOME");
|
||||
Platform::RestoreHostXdg();
|
||||
Check(Platform::GetEnv("XDG_CONFIG_HOME") == "/elsewhere/config", "a host XDG directory the host set comes back");
|
||||
Check(Platform::GetEnv("XDG_CACHE_HOME") == Dyn::JoinPath(home, ".cache"), "one it left unset is the XDG default under $HOME");
|
||||
Check(Platform::GetEnv("XDG_STATE_HOME") == Dyn::JoinPath(home, ".local/state"), "state too");
|
||||
}
|
||||
|
||||
{
|
||||
const std::string today = Platform::TodayIso();
|
||||
Check(today.size() == 10 && today[4] == '-' && today[7] == '-', "date.today().isoformat()");
|
||||
Check(Platform::LocalTime("%H:%M:%S", 0).size() == 8, "strftime in local time");
|
||||
Check(Platform::SelfExe().is_absolute(), "the running binary's path");
|
||||
}
|
||||
|
||||
fs::remove_all(dir);
|
||||
return Failures == 0 ? 0 : 1;
|
||||
}
|
||||
207
tests/Process/main.cpp
Normal file
207
tests/Process/main.cpp
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// Child processes and their lifetime.
|
||||
//
|
||||
// SIGKILL to the claude process alone would leave the Playwright server and
|
||||
// Chromium under it running after a timeout, and every pass running after a
|
||||
// SIGKILLed driver, so a pass runs under a supervisor that is the subreaper
|
||||
// of its tree. This test starts a target that leaves a detached grandchild —
|
||||
// `setsid sleep`, a process group of its own, the way Playwright starts
|
||||
// Chromium — and checks the grandchild is gone when the target exits, when
|
||||
// the supervisor is told to stop, and when the driver above it is SIGKILLed.
|
||||
//
|
||||
// Also: return codes (minus the signal for a killed child), a missing
|
||||
// program reported with its errno text, captured output from both streams
|
||||
// without a deadlock, a timeout, nothing but the three streams reaching the
|
||||
// child, and the child getting SIGPIPE back at its default with an empty
|
||||
// signal mask while the driver ignores SIGPIPE.
|
||||
//
|
||||
// The test binary plays the supervisor (`__supervise`) and the driver
|
||||
// (`--as-driver`) itself, by re-running its own executable.
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <signal.h>
|
||||
#include <unistd.h>
|
||||
#include <cerrno>
|
||||
|
||||
import std;
|
||||
import Crafter.Review;
|
||||
namespace fs = std::filesystem;
|
||||
using namespace Crafter::Review;
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
namespace {
|
||||
std::int32_t Failures = 0;
|
||||
|
||||
void Check(bool cond, std::string_view msg) {
|
||||
if (!cond) {
|
||||
std::println(std::cerr, "FAIL: {}", msg);
|
||||
++Failures;
|
||||
}
|
||||
}
|
||||
|
||||
fs::path Stage() {
|
||||
const fs::path dir = fs::temp_directory_path() / std::format("crafter-review-process-{}", getpid());
|
||||
fs::remove_all(dir);
|
||||
fs::create_directories(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
bool Alive(std::int64_t pid) {
|
||||
return pid > 0 && kill(static_cast<pid_t>(pid), 0) == 0;
|
||||
}
|
||||
|
||||
// The pid a target wrote to `file`, waited for up to 5 s.
|
||||
std::int64_t PidFrom(const fs::path& file) {
|
||||
for (std::int32_t i = 0; i < 100; ++i) {
|
||||
if (const std::optional<std::string> text = Platform::ReadFile(file)) {
|
||||
if (const std::optional<std::int64_t> pid = Dyn::ParseInt(*text); pid && *pid > 0) return *pid;
|
||||
}
|
||||
std::this_thread::sleep_for(50ms);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool GoneWithin(std::int64_t pid, std::chrono::milliseconds limit) {
|
||||
const auto deadline = std::chrono::steady_clock::now() + limit;
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
if (!Alive(pid)) return true;
|
||||
std::this_thread::sleep_for(20ms);
|
||||
}
|
||||
return !Alive(pid);
|
||||
}
|
||||
|
||||
// `sh -c '<script>'` under a supervisor, started the way the pool starts
|
||||
// a pass: SIGTERM as its death signal.
|
||||
Process::Child StartSupervised(const std::string& script) {
|
||||
const std::vector<std::string> target { "sh", "-c", script };
|
||||
Process::Spec spec { .argv = Process::Supervised(target) };
|
||||
spec.deathSignal = SIGTERM;
|
||||
return Process::Start(spec);
|
||||
}
|
||||
|
||||
// The driver this test SIGKILLs: it starts a supervised target that
|
||||
// leaves a detached grandchild, then waits to be killed.
|
||||
std::int32_t AsDriver(std::string_view pidFile) {
|
||||
Process::BecomeSubreaper();
|
||||
StartSupervised(std::format("setsid sleep 300 & echo $! > {}; wait", pidFile));
|
||||
std::this_thread::sleep_for(60s);
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::uint64_t HexMask(const std::string& status, std::string_view field) {
|
||||
for (const std::string& line : Dyn::SplitLines(status)) {
|
||||
if (!line.starts_with(field)) continue;
|
||||
return std::stoull(Dyn::Strip(line.substr(field.size())), nullptr, 16);
|
||||
}
|
||||
return ~0ULL;
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
const std::vector<std::string_view> args(argv, argv + argc);
|
||||
if (args.size() > 1 && args[1] == Process::SuperviseVerb) return Process::SuperviseMain(std::span(args).subspan(2));
|
||||
if (args.size() > 2 && args[1] == "--as-driver") return AsDriver(args[2]);
|
||||
|
||||
Process::InstallSignalThread([](std::int32_t) {});
|
||||
const fs::path dir = Stage();
|
||||
|
||||
{
|
||||
const Process::Child child = Process::Start({ .argv = { "sh", "-c", "exit 3" } });
|
||||
Check(Process::Wait(child.pid) == 3, "an exit status comes back as the return code");
|
||||
const Process::Child killed = Process::Start({ .argv = { "sh", "-c", "kill -TERM $$" } });
|
||||
Check(Process::Wait(killed.pid) == -15, "a child killed by a signal returns minus the signal, as Popen does");
|
||||
}
|
||||
|
||||
{
|
||||
std::string message;
|
||||
try {
|
||||
Process::Start({ .argv = { "no-such-program-for-crafter-review" } });
|
||||
} catch (const std::runtime_error& e) {
|
||||
message = e.what();
|
||||
}
|
||||
Check(message == "[Errno 2] No such file or directory: 'no-such-program-for-crafter-review'",
|
||||
std::format("a missing program fails the way Popen fails ({})", message));
|
||||
}
|
||||
|
||||
{
|
||||
const Process::Captured both = Process::Run({ "sh", "-c", "echo out; echo err >&2; exit 4" });
|
||||
Check(both.out == "out\n" && both.err == "err\n" && both.returnCode == 4, "both streams and the return code are captured");
|
||||
const Process::Captured fed = Process::Run({ "cat" }, std::nullopt, std::nullopt, "fed through stdin");
|
||||
Check(fed.out == "fed through stdin", "input reaches the child's stdin");
|
||||
const Process::Captured large = Process::Run({ "sh", "-c", "head -c 300000 /dev/zero; head -c 300000 /dev/zero >&2" });
|
||||
Check(large.out.size() == 300000 && large.err.size() == 300000, "large output on both streams does not stall the child");
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
const Process::Captured slow = Process::Run({ "sleep", "5" }, std::nullopt, 200ms);
|
||||
Check(slow.timedOut && std::chrono::steady_clock::now() - start < 3s, "a timeout kills the child and comes back promptly");
|
||||
const Process::Captured where = Process::Run({ "pwd" }, dir);
|
||||
Check(Dyn::Strip(where.out) == fs::canonical(dir).string(), "the child runs in the directory asked for");
|
||||
const Process::Captured env = Process::Run({ "sh", "-c", "echo $ONLY_HERE" }, std::nullopt, std::nullopt, {},
|
||||
std::map<std::string, std::string> { { "ONLY_HERE", "yes" }, { "PATH", "/usr/bin:/bin" } });
|
||||
Check(Dyn::Strip(env.out) == "yes", "an environment given replaces this process's");
|
||||
}
|
||||
|
||||
{
|
||||
// An fd this process left open without O_CLOEXEC must not reach the child.
|
||||
const std::int32_t leaked = open("/dev/null", O_RDONLY);
|
||||
const std::int32_t high = dup2(leaked, 57);
|
||||
const Process::Captured fds = Process::Run({ "sh", "-c", "ls /proc/$$/fd" });
|
||||
const std::vector<std::string> open = Dyn::Split(fds.out);
|
||||
Check(std::ranges::find(open, "57") == open.end() && open.size() <= 4, std::format("only the three streams reach the child (it had: {})", fds.out));
|
||||
close(high);
|
||||
close(leaked);
|
||||
}
|
||||
|
||||
{
|
||||
const Process::Captured status = Process::Run({ "sh", "-c", "cat /proc/self/status" });
|
||||
const std::uint64_t ignored = HexMask(status.out, "SigIgn:");
|
||||
const std::uint64_t blocked = HexMask(status.out, "SigBlk:");
|
||||
Check((ignored & (1ULL << (SIGPIPE - 1))) == 0, "the child has SIGPIPE back at its default");
|
||||
Check(blocked == 0, "the child's signal mask is empty");
|
||||
}
|
||||
|
||||
{
|
||||
// The target exits and leaves a detached grandchild behind.
|
||||
const fs::path pidFile = dir / "left-behind";
|
||||
const Process::Child supervisor = StartSupervised(std::format("setsid sleep 300 & echo $! > {}", pidFile.string()));
|
||||
const std::int64_t grandchild = PidFrom(pidFile);
|
||||
const std::optional<std::int32_t> code = Process::Wait(supervisor.pid, 10s);
|
||||
Check(grandchild > 0 && code == 0, "a supervised target's exit status comes back");
|
||||
Check(GoneWithin(grandchild, 2s), "what a target leaves behind dies with it, a detached process group included");
|
||||
}
|
||||
|
||||
{
|
||||
// A timeout or an abort: SIGTERM to the supervisor.
|
||||
const fs::path pidFile = dir / "terminated";
|
||||
const Process::Child supervisor = StartSupervised(std::format("setsid sleep 300 & echo $! > {}; sleep 300", pidFile.string()));
|
||||
const std::int64_t grandchild = PidFrom(pidFile);
|
||||
Check(grandchild > 0 && Alive(grandchild), "the grandchild runs while the target does");
|
||||
Process::Signal(supervisor.pid, SIGTERM);
|
||||
const std::optional<std::int32_t> code = Process::Wait(supervisor.pid, 10s);
|
||||
Check(code.has_value() && Process::SupervisedReturnCode(*code) == -SIGKILL, std::format("a stopped target reports the kill (supervisor exit {})", code.value_or(-1)));
|
||||
Check(GoneWithin(grandchild, 2s), "SIGTERM to the supervisor takes the whole tree down");
|
||||
}
|
||||
|
||||
{
|
||||
// The driver itself is SIGKILLed: nothing of the pass may outlive it.
|
||||
const fs::path pidFile = dir / "driver-killed";
|
||||
const Process::Child driver = Process::Start({ .argv = { Platform::SelfExe().string(), "--as-driver", pidFile.string() } });
|
||||
const std::int64_t grandchild = PidFrom(pidFile);
|
||||
Check(grandchild > 0 && Alive(grandchild), "the driver's pass is running");
|
||||
Process::Signal(driver.pid, SIGKILL);
|
||||
Process::Wait(driver.pid, 10s);
|
||||
Check(GoneWithin(grandchild, 3s), "a SIGKILLed driver's pass dies with it, through the supervisor's death signal");
|
||||
}
|
||||
|
||||
{
|
||||
const Process::Child running = Process::Start({ .argv = { "sleep", "5" } });
|
||||
Check(!Process::Wait(running.pid, 100ms).has_value(), "a wait that times out leaves the child running");
|
||||
Process::Signal(running.pid, SIGKILL);
|
||||
Check(Process::Wait(running.pid, 5s) == -SIGKILL, "and the child can still be waited for");
|
||||
Check(Process::SupervisedReturnCode(137) == -9 && Process::SupervisedReturnCode(3) == 3, "a supervisor's 128+n is signal n");
|
||||
}
|
||||
|
||||
fs::remove_all(dir);
|
||||
return Failures == 0 ? 0 : 1;
|
||||
}
|
||||
Loading…
Reference in a new issue