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
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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue