diff --git a/implementations/Crafter.Review-Platform.cpp b/implementations/Crafter.Review-Platform.cpp new file mode 100644 index 0000000..4ed6cdc --- /dev/null +++ b/implementations/Crafter.Review-Platform.cpp @@ -0,0 +1,415 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module; +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 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 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 Environment() { + std::map 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 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, 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 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(since).count(); + } + + std::string LocalTime(std::string_view format, double epochSeconds) { + const std::time_t seconds = static_cast(std::floor(epochSeconds)); + std::tm local {}; + localtime_r(&seconds, &local); + std::array 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 ReadFile(const fs::path& path) { + std::ifstream in(path, std::ios::binary); + if (!in) return std::nullopt; + std::string bytes((std::istreambuf_iterator(in)), std::istreambuf_iterator()); + 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)); + 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(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& change, const Json& fallback, + std::uint32_t mode, const std::function& 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 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 ProcStart(std::int64_t pid) { + const std::optional 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 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 text = ReadFile(Paths().proc / "sys" / "kernel" / "random" / "boot_id"); + if (!text) return ""; + return Dyn::Slice(Dyn::Strip(*text), 0, 8); + } + + std::map MemInfo(const fs::path& path) { + std::map out; + const std::optional 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 parts = Dyn::Split(std::string_view(line).substr(colon + 1)); + if (parts.empty()) continue; + const std::optional 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> PpidMap() { + std::map> 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 pid = ToInt(name); + if (!pid) continue; + const std::optional stat = ReadFile(entry.path() / "stat"); + if (!stat) continue; + const std::size_t close = stat->rfind(')'); + if (close == std::string::npos) continue; + const std::vector rest = Dyn::Split(std::string_view(*stat).substr(close + 1)); + if (rest.size() < 2) continue; + if (const std::optional ppid = ToInt(rest[1])) out[*ppid].push_back(*pid); + } + return out; + } + + std::vector Tree(std::int64_t pid, const std::map>& kids) { + std::vector out; + std::vector todo { pid }; + std::set 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 pids) { + std::int64_t kb = 0; + for (const std::int64_t pid : pids) { + const std::optional 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 parts = Dyn::Split(line); + if (parts.size() > 1) { + if (const std::optional value = ToInt(parts[1])) kb += *value; + } + break; + } + } + return static_cast(kb) / 1024; + } + + std::string Identity::Key() const { + return std::format("{}:{}", pid, start); + } + + std::optional Self() { + if (!InSandbox()) { + const std::int64_t pid = getpid(); + return Identity { .pid = pid, .start = ProcStart(pid).value_or(0) }; + } + const std::optional info = ReadFile(Paths().flatpakInfo); + const std::optional 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 text = ReadFile(bwrapinfo)) { + try { + const Json parsed = Json::parse(*text); + if (parsed.contains("child-pid") && parsed["child-pid"].is_number_integer()) { + const std::optional start = ProcStart(1); + if (start) return Identity { .pid = parsed["child-pid"].get(), .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 self = Self()) return self->pid; + return static_cast(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 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; + } +} diff --git a/implementations/Crafter.Review-Process.cpp b/implementations/Crafter.Review-Process.cpp new file mode 100644 index 0000000..bff1a1a --- /dev/null +++ b/implementations/Crafter.Review-Process.cpp @@ -0,0 +1,473 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module; +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 status; + }; + struct Request; + struct Shared { + std::mutex registryMutex; + std::condition_variable registryChanged; + std::map registry; + std::int32_t unreaped = 0; + std::mutex spawnMutex; + std::condition_variable spawnReady; + std::deque 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 result; + }; + std::once_flag ThreadsStarted; + + std::optional 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 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 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 path = Platform::GetEnv("PATH")) pathVariable = *path; + } + const std::optional program = FindOnPath(spec.argv.front(), pathVariable); + if (!program) { + throw std::runtime_error(std::format("{}: {}", Dyn::ErrnoText(ENOENT), Dyn::Repr(spec.argv.front()))); + } + std::vector argv; + for (const std::string& arg : spec.argv) argv.push_back(const_cast(arg.c_str())); + argv.push_back(nullptr); + std::vector envp; + for (const std::string& entry : envStrings) envp.push_back(const_cast(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 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(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 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 Wait(std::int64_t pid, std::optional timeout) { + std::unique_lock lock(State().registryMutex); + auto exited = [pid] { + const auto found = State().registry.find(static_cast(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)); + 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), signal); + } + + void KillTree(std::int64_t root, bool includeRoot) { + std::set 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), SIGSTOP); + added = true; + } + } + if (!added) break; + } + for (const std::int64_t pid : stopped) kill(static_cast(pid), SIGKILL); + } + + void KillDescendants() { + KillTree(getpid(), false); + } + + Captured Run(const std::vector& argv, const std::optional& cwd, + std::optional timeout, std::string_view input, + const std::optional>& 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 buffer {}; + while (child.output >= 0 || child.error >= 0 || child.input >= 0) { + std::vector 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(*deadline - std::chrono::steady_clock::now()).count(); + if (left <= 0) { + Signal(child.pid, SIGKILL); + captured.timedOut = true; + break; + } + waitMs = static_cast(std::min(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(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(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 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 Supervised(std::span argv) { + std::vector 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 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 strings(args.begin(), args.end()); + std::vector argv; + for (const std::string& arg : strings) argv.push_back(const_cast(arg.c_str())); + argv.push_back(nullptr); + const std::optional 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 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; + } +} diff --git a/interfaces/Crafter.Review-Platform.cppm b/interfaces/Crafter.Review-Platform.cppm new file mode 100644 index 0000000..ef55298 --- /dev/null +++ b/interfaces/Crafter.Review-Platform.cppm @@ -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//.flatpak//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 GetEnv(std::string_view name); + // The process environment. + std::map Environment(); + // $HOME, else the password database's. + std::string Home(); + // Flatpak points XDG_{CONFIG,CACHE,DATA,STATE}_HOME at ~/.var/app//…; + // 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 ReadFile(const std::filesystem::path& path); + // `content` into `..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 `.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& change, const Json& fallback, + std::uint32_t mode, const std::function& warn); + + // ---- /proc ---------------------------------------------------------------------------- + // The start time of a live process, in clock ticks since boot (field 22 + // of /proc//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 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 MemInfo(const std::filesystem::path& path = "/proc/meminfo"); + // {ppid: [pids]} of every process now, from one read of each + // /proc//stat. Always the real /proc: a process tree is this + // driver's own business. + std::map> PpidMap(); + // The pid and every descendant in `kids`. + std::vector Tree(std::int64_t pid, const std::map>& kids); + // The summed Pss of the processes, in MB; one that is gone counts nothing. + double PssMb(std::span pids); + + // ---- identity ------------------------------------------------------------------------- + // How the other drivers on this machine know a driver: `:`. + 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 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 + // `/-.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 ProbeLifetimeLock(const std::filesystem::path& dir, std::int64_t pid, std::int64_t start); +} diff --git a/interfaces/Crafter.Review-Process.cppm b/interfaces/Crafter.Review-Process.cppm new file mode 100644 index 0000000..28a5c73 --- /dev/null +++ b/interfaces/Crafter.Review-Process.cppm @@ -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 ` — 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 argv; + // nullopt: this process's environment. + std::optional> environment; + std::optional 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 Wait(std::int64_t pid, std::optional 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& argv, const std::optional& cwd = std::nullopt, + std::optional timeout = std::nullopt, std::string_view input = {}, + const std::optional>& 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 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 Supervised(std::span 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 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); +} diff --git a/interfaces/Crafter.Review.cppm b/interfaces/Crafter.Review.cppm index 652c381..fe0a7f2 100644 --- a/interfaces/Crafter.Review.cppm +++ b/interfaces/Crafter.Review.cppm @@ -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; diff --git a/project.cpp b/project.cpp index 4ea4b5d..0b6ed82 100644 --- a/project.cpp +++ b/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 Interfaces = { + constexpr std::array 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 Implementations = { + constexpr std::array 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 Tests = { + constexpr std::array Tests = { "Crypto", "Text", + "Platform", + "Process", }; // The version the binary reports: CI sets it from the tag, a checkout diff --git a/tests/Platform/main.cpp b/tests/Platform/main.cpp new file mode 100644 index 0000000..bb0b9af --- /dev/null +++ b/tests/Platform/main.cpp @@ -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 +#include +#include + +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//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() + 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(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 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 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 -.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 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; +} diff --git a/tests/Process/main.cpp b/tests/Process/main.cpp new file mode 100644 index 0000000..efe9252 --- /dev/null +++ b/tests/Process/main.cpp @@ -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 +#include +#include +#include + +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), 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 text = Platform::ReadFile(file)) { + if (const std::optional 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 '