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>
2026-10-31 05:49:46 +01:00
|
|
|
// 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();
|
2026-10-31 16:09:40 +01:00
|
|
|
// Flatpak sets SHELL to /bin/sh, the host's (zsh here) being no file of
|
|
|
|
|
// the Sdk's. claude's Bash tool runs commands with bash or zsh, and the
|
|
|
|
|
// Sdk has bash: SHELL becomes /bin/bash unless it already names a bash
|
|
|
|
|
// or zsh that is there.
|
|
|
|
|
void RestoreShell();
|
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>
2026-10-31 05:49:46 +01:00
|
|
|
// 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);
|
|
|
|
|
|
feat(seatpool): the seat pool and the board several drivers share
The seats of .env and the ledger, the probe, placement, settling a
finished pass, the child's credentials, the OpenRouter seats, and the
board every driver on the machine shares, in one shape and under one
lock. A driver is alive while its lifetime lock is held; with no lock file
natively its pid and start time decide, and in the sandbox it is taken for
alive.
:Http is the HTTP the driver needs, on FetchHTTP1: a status outside 2xx is
HttpError, every other failure carries a fixed text. :Platform gains
MakeDirs, with the last directory's own mode, and errno texts for its
writes and locks.
The tests cover the seat pool and several drivers at once, less the checks
that drive the pool's Scheduler, Admission and Stacks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 08:15:03 +01:00
|
|
|
// `path` and its missing parents: the parents made as mkdir makes them,
|
|
|
|
|
// the last directory with `mode`, both less the umask; a directory that is
|
|
|
|
|
// there keeps its mode. Throws with the errno text ("[Errno 13] Permission
|
|
|
|
|
// denied: '/x'").
|
|
|
|
|
void MakeDirs(const std::filesystem::path& path, std::uint32_t mode = 0777);
|
|
|
|
|
|
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>
2026-10-31 05:49:46 +01:00
|
|
|
// 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
|
feat(pipeline): the pure pipeline, checked against its goldens
The modules that turn a frozen range into passes and passes into a
verdict: :Schema, :Tools (the DENY, git and base lists, a leaf, so the
config and the pool both read them), :Plan, :Artifact, :Inventory,
:Dispositions, :State, :Normalize, :Stream, :Prompt, :Config, and the URL
half of :PgWire. Units, jobs, results, the config and the claude stream
stay JSON from end to end, so every key keeps its place; :Text gives JSON
values truthiness, get, ==, membership and Str(), and throws Dyn::Error
under a name for each kind of unexpected data.
Exactness where the bytes are seen:
- the stream classifiers are hand-written scanners with case-insensitive
folds (the Kelvin sign is a k), Unicode \b and \s, and `.` stopping at
\n; the SQL call detection takes `<exe> sql` with any path before it;
- config problems carry the JSON scanner's and the UTF-8 decoder's own
texts, positions included;
- a float total is Neumaier's compensated sum, and the build passes
-ffp-contract=off: -march=native would fuse a*b+c;
- `crafter-review inventory` and `config` print what the goldens record,
byte for byte (the fixture and 14 configs, broken ones included); a
message names this binary as the command to run.
tests/Goldens: the recorded answers over the fixtures next to them (the
commits fixture, a rich versioned series with CRLF, a folded subject,
Markdown headings in and out of fences, the code-key rewrites); the suite
compares 2304 results as DumpFile writes them, so a key out of order fails
too. All agree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:48:55 +01:00
|
|
|
// same side files, and on Linux the two kinds do not exclude each other. A
|
|
|
|
|
// missing lock file is created 0666 less the umask.
|
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>
2026-10-31 05:49:46 +01:00
|
|
|
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);
|
|
|
|
|
}
|