// 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); }