: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>
113 lines
5.3 KiB
C++
113 lines
5.3 KiB
C++
// 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);
|
|
}
|