Crafter.Review/tests/Process/main.cpp

207 lines
10 KiB
C++
Raw Permalink Normal View History

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®
// 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 <fcntl.h>
#include <signal.h>
#include <unistd.h>
#include <cerrno>
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_t>(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<std::string> text = Platform::ReadFile(file)) {
if (const std::optional<std::int64_t> 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 '<script>'` under a supervisor, started the way the pool starts
// a pass: SIGTERM as its death signal.
Process::Child StartSupervised(const std::string& script) {
const std::vector<std::string> target { "sh", "-c", script };
Process::Spec spec { .argv = Process::Supervised(target) };
spec.deathSignal = SIGTERM;
return Process::Start(spec);
}
// The driver this test SIGKILLs: it starts a supervised target that
// leaves a detached grandchild, then waits to be killed.
std::int32_t AsDriver(std::string_view pidFile) {
Process::BecomeSubreaper();
StartSupervised(std::format("setsid sleep 300 & echo $! > {}; wait", pidFile));
std::this_thread::sleep_for(60s);
return 0;
}
std::uint64_t HexMask(const std::string& status, std::string_view field) {
for (const std::string& line : Dyn::SplitLines(status)) {
if (!line.starts_with(field)) continue;
return std::stoull(Dyn::Strip(line.substr(field.size())), nullptr, 16);
}
return ~0ULL;
}
}
int main(int argc, char** argv) {
const std::vector<std::string_view> args(argv, argv + argc);
if (args.size() > 1 && args[1] == Process::SuperviseVerb) return Process::SuperviseMain(std::span(args).subspan(2));
if (args.size() > 2 && args[1] == "--as-driver") return AsDriver(args[2]);
Process::InstallSignalThread([](std::int32_t) {});
const fs::path dir = Stage();
{
const Process::Child child = Process::Start({ .argv = { "sh", "-c", "exit 3" } });
Check(Process::Wait(child.pid) == 3, "an exit status comes back as the return code");
const Process::Child killed = Process::Start({ .argv = { "sh", "-c", "kill -TERM $$" } });
Check(Process::Wait(killed.pid) == -15, "a child killed by a signal returns minus the signal, as Popen does");
}
{
std::string message;
try {
Process::Start({ .argv = { "no-such-program-for-crafter-review" } });
} catch (const std::runtime_error& e) {
message = e.what();
}
Check(message == "[Errno 2] No such file or directory: 'no-such-program-for-crafter-review'",
std::format("a missing program fails the way Popen fails ({})", message));
}
{
const Process::Captured both = Process::Run({ "sh", "-c", "echo out; echo err >&2; exit 4" });
Check(both.out == "out\n" && both.err == "err\n" && both.returnCode == 4, "both streams and the return code are captured");
const Process::Captured fed = Process::Run({ "cat" }, std::nullopt, std::nullopt, "fed through stdin");
Check(fed.out == "fed through stdin", "input reaches the child's stdin");
const Process::Captured large = Process::Run({ "sh", "-c", "head -c 300000 /dev/zero; head -c 300000 /dev/zero >&2" });
Check(large.out.size() == 300000 && large.err.size() == 300000, "large output on both streams does not stall the child");
const auto start = std::chrono::steady_clock::now();
const Process::Captured slow = Process::Run({ "sleep", "5" }, std::nullopt, 200ms);
Check(slow.timedOut && std::chrono::steady_clock::now() - start < 3s, "a timeout kills the child and comes back promptly");
const Process::Captured where = Process::Run({ "pwd" }, dir);
Check(Dyn::Strip(where.out) == fs::canonical(dir).string(), "the child runs in the directory asked for");
const Process::Captured env = Process::Run({ "sh", "-c", "echo $ONLY_HERE" }, std::nullopt, std::nullopt, {},
std::map<std::string, std::string> { { "ONLY_HERE", "yes" }, { "PATH", "/usr/bin:/bin" } });
Check(Dyn::Strip(env.out) == "yes", "an environment given replaces this process's");
}
{
// An fd this process left open without O_CLOEXEC must not reach the child.
const std::int32_t leaked = open("/dev/null", O_RDONLY);
const std::int32_t high = dup2(leaked, 57);
const Process::Captured fds = Process::Run({ "sh", "-c", "ls /proc/$$/fd" });
const std::vector<std::string> open = Dyn::Split(fds.out);
Check(std::ranges::find(open, "57") == open.end() && open.size() <= 4, std::format("only the three streams reach the child (it had: {})", fds.out));
close(high);
close(leaked);
}
{
const Process::Captured status = Process::Run({ "sh", "-c", "cat /proc/self/status" });
const std::uint64_t ignored = HexMask(status.out, "SigIgn:");
const std::uint64_t blocked = HexMask(status.out, "SigBlk:");
Check((ignored & (1ULL << (SIGPIPE - 1))) == 0, "the child has SIGPIPE back at its default");
Check(blocked == 0, "the child's signal mask is empty");
}
{
// The target exits and leaves a detached grandchild behind.
const fs::path pidFile = dir / "left-behind";
const Process::Child supervisor = StartSupervised(std::format("setsid sleep 300 & echo $! > {}", pidFile.string()));
const std::int64_t grandchild = PidFrom(pidFile);
const std::optional<std::int32_t> code = Process::Wait(supervisor.pid, 10s);
Check(grandchild > 0 && code == 0, "a supervised target's exit status comes back");
Check(GoneWithin(grandchild, 2s), "what a target leaves behind dies with it, a detached process group included");
}
{
// A timeout or an abort: SIGTERM to the supervisor.
const fs::path pidFile = dir / "terminated";
const Process::Child supervisor = StartSupervised(std::format("setsid sleep 300 & echo $! > {}; sleep 300", pidFile.string()));
const std::int64_t grandchild = PidFrom(pidFile);
Check(grandchild > 0 && Alive(grandchild), "the grandchild runs while the target does");
Process::Signal(supervisor.pid, SIGTERM);
const std::optional<std::int32_t> code = Process::Wait(supervisor.pid, 10s);
Check(code.has_value() && Process::SupervisedReturnCode(*code) == -SIGKILL, std::format("a stopped target reports the kill (supervisor exit {})", code.value_or(-1)));
Check(GoneWithin(grandchild, 2s), "SIGTERM to the supervisor takes the whole tree down");
}
{
// The driver itself is SIGKILLed: nothing of the pass may outlive it.
const fs::path pidFile = dir / "driver-killed";
const Process::Child driver = Process::Start({ .argv = { Platform::SelfExe().string(), "--as-driver", pidFile.string() } });
const std::int64_t grandchild = PidFrom(pidFile);
Check(grandchild > 0 && Alive(grandchild), "the driver's pass is running");
Process::Signal(driver.pid, SIGKILL);
Process::Wait(driver.pid, 10s);
Check(GoneWithin(grandchild, 3s), "a SIGKILLed driver's pass dies with it, through the supervisor's death signal");
}
{
const Process::Child running = Process::Start({ .argv = { "sleep", "5" } });
Check(!Process::Wait(running.pid, 100ms).has_value(), "a wait that times out leaves the child running");
Process::Signal(running.pid, SIGKILL);
Check(Process::Wait(running.pid, 5s) == -SIGKILL, "and the child can still be waited for");
Check(Process::SupervisedReturnCode(137) == -9 && Process::SupervisedReturnCode(3) == 3, "a supervisor's 128+n is signal n");
}
fs::remove_all(dir);
return Failures == 0 ? 0 : 1;
}