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
219
tests/Platform/main.cpp
Normal file
219
tests/Platform/main.cpp
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// The machine as the driver sees it.
|
||||
//
|
||||
// - Shared files: an atomic write never leaves half a file and gives the
|
||||
// temp file the mode asked for; LockedUpdate re-reads under the lock, keeps
|
||||
// keys it does not know, falls back on a damaged file, and serialises
|
||||
// concurrent writers — including another process holding flock(2) on the
|
||||
// same side file, as every driver on the machine does.
|
||||
// - /proc: the start time read after the last ')' (a command name may hold
|
||||
// spaces and parentheses), a zombie reading as gone, MemInfo in MB.
|
||||
// - Identity: natively a driver's own pid and start time; in the sandbox the
|
||||
// instance's host pid from bwrapinfo.json with the start time of /proc/1.
|
||||
// On a fake /proc and a fake instance directory, because the suite itself
|
||||
// runs inside `flatpak build` in the Flatpak.
|
||||
// - The lifetime lock: held while its driver lives, refused to a second
|
||||
// driver under the same name, gone when it ends, and a stale one reads as
|
||||
// dead.
|
||||
// - The host XDG directories coming back in the sandbox.
|
||||
|
||||
#include <sys/stat.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
void Write(const fs::path& path, std::string_view text) {
|
||||
fs::create_directories(path.parent_path());
|
||||
std::ofstream(path, std::ios::binary) << text;
|
||||
}
|
||||
|
||||
// A /proc/<pid>/stat whose command name is designed to trip a naive split.
|
||||
std::string Stat(std::int64_t pid, char state, std::int64_t start) {
|
||||
std::string fields = std::format("{} (we (ird) name) {} 1", pid, state);
|
||||
for (std::int32_t field = 5; field <= 21; ++field) fields += " 0";
|
||||
return std::format("{} {} 0 0\n", fields, start);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
const fs::path dir = fs::temp_directory_path() / std::format("crafter-review-platform-{}", getpid());
|
||||
fs::remove_all(dir);
|
||||
fs::create_directories(dir);
|
||||
Platform::SetMode(Platform::Mode::Native);
|
||||
|
||||
{
|
||||
const mode_t previous = umask(022);
|
||||
const fs::path file = dir / "atomic.json";
|
||||
Platform::WriteAtomic(file, "{\"a\": 1}", 0600);
|
||||
struct stat info {};
|
||||
stat(file.c_str(), &info);
|
||||
Check(Platform::ReadFile(file) == "{\"a\": 1}", "the atomic write lands");
|
||||
Check((info.st_mode & 0777) == 0600, std::format("with the mode asked for ({:o})", info.st_mode & 0777));
|
||||
Check(!fs::exists(std::format("{}.{}.tmp", file.string(), Platform::PublishedPid())), "and leaves no temp file");
|
||||
umask(previous);
|
||||
}
|
||||
|
||||
{
|
||||
const fs::path file = dir / "board.json";
|
||||
Write(file, "{\"version\": 1, \"cost7d\": 3.5, \"seats\": {}}");
|
||||
const Json seen = Platform::LockedUpdate(file, [](Json& data) {
|
||||
data["seats"]["local"] = { { "id", "local" } };
|
||||
return true;
|
||||
}, Json::object(), 0600, {});
|
||||
const Json back = Json::parse(*Platform::ReadFile(file));
|
||||
Check(back["cost7d"] == 3.5 && back["seats"]["local"]["id"] == "local", "a key this driver does not know survives the rewrite");
|
||||
Check(*Platform::ReadFile(file) == DumpFile(back), "written in DumpFile's layout");
|
||||
Check(seen == back, "LockedUpdate returns what the change saw");
|
||||
|
||||
Write(file, "{not json");
|
||||
const Json fallback = { { "version", 1 } };
|
||||
Platform::LockedUpdate(file, [](Json& data) {
|
||||
return data.contains("version");
|
||||
}, fallback, 0600, {});
|
||||
Check(Json::parse(*Platform::ReadFile(file)) == fallback, "a damaged file is replaced by the fallback the change kept");
|
||||
|
||||
const std::string before = *Platform::ReadFile(file);
|
||||
Platform::LockedUpdate(file, [](Json& data) {
|
||||
data["ignored"] = true;
|
||||
return false;
|
||||
}, Json::object(), 0600, {});
|
||||
Check(*Platform::ReadFile(file) == before, "a change that returns false leaves the file alone");
|
||||
}
|
||||
|
||||
{
|
||||
// Two writers in one process: flock is per open file description, so
|
||||
// two FileLocks exclude each other just as two processes would.
|
||||
const fs::path file = dir / "counter.json";
|
||||
Write(file, "{\"n\": 0}");
|
||||
auto bump = [&file] {
|
||||
for (std::int32_t i = 0; i < 100; ++i) {
|
||||
Platform::LockedUpdate(file, [](Json& data) {
|
||||
data["n"] = data["n"].get<std::int64_t>() + 1;
|
||||
return true;
|
||||
}, Json::object(), 0600, {});
|
||||
}
|
||||
};
|
||||
std::thread a(bump);
|
||||
std::thread b(bump);
|
||||
a.join();
|
||||
b.join();
|
||||
Check(Json::parse(*Platform::ReadFile(file))["n"] == 200, "concurrent read-modify-writes lose nothing");
|
||||
}
|
||||
|
||||
{
|
||||
// Another process holds flock on the side file for a moment (flock(1),
|
||||
// util-linux): the lock here must wait for it, as every driver on the
|
||||
// machine shares these files.
|
||||
const fs::path file = dir / "shared.json";
|
||||
const fs::path lock = std::format("{}.lock", file.string());
|
||||
const fs::path ready = dir / "other-holds";
|
||||
try {
|
||||
const Process::Child other = Process::Start({ .argv = { "flock", lock.string(), "sh", "-c", "touch \"$1\" && sleep 0.4", "sh", ready.string() } });
|
||||
for (std::int32_t i = 0; i < 100 && !fs::exists(ready); ++i) std::this_thread::sleep_for(20ms);
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
{
|
||||
const Platform::FileLock ours(lock);
|
||||
Check(ours.Held(), "the lock is taken once the other process lets go");
|
||||
}
|
||||
const auto waited = std::chrono::steady_clock::now() - start;
|
||||
Process::Wait(other.pid);
|
||||
Check(waited >= 200ms, std::format("and not before: flock(2) excludes the other process's flock ({} ms)", std::chrono::duration_cast<std::chrono::milliseconds>(waited).count()));
|
||||
} catch (const std::runtime_error&) {
|
||||
std::println("SKIP no flock here to hold the other end of the lock");
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const fs::path proc = dir / "proc";
|
||||
Write(proc / "4242" / "stat", Stat(4242, 'S', 987654));
|
||||
Write(proc / "4243" / "stat", Stat(4243, 'Z', 5));
|
||||
Write(proc / "sys" / "kernel" / "random" / "boot_id", "0123456789abcdef-the-rest\n");
|
||||
Write(dir / "meminfo", "MemTotal: 65536000 kB\nMemAvailable: 32768000 kB\nHugePages_Total: 0\n");
|
||||
Platform::SetPaths({ .proc = proc, .flatpakInfo = dir / "no-flatpak-info", .runtimeDir = dir / "run" });
|
||||
Check(Platform::ProcStart(4242) == 987654, "the start time is field 22, read after the last ')'");
|
||||
Check(!Platform::ProcStart(4243).has_value(), "a zombie reads as gone");
|
||||
Check(!Platform::ProcStart(4244).has_value(), "so does a pid that is not there");
|
||||
Check(Platform::BootId() == "01234567", "boot_id's first eight characters");
|
||||
const auto memory = Platform::MemInfo(dir / "meminfo");
|
||||
Check(memory.at("MemTotal") == 64000 && memory.at("MemAvailable") == 32000 && memory.at("HugePages_Total") == 0, "meminfo in MB, unitless fields as they are");
|
||||
|
||||
Write(proc / std::to_string(getpid()) / "stat", Stat(getpid(), 'R', 111));
|
||||
Platform::SetMode(Platform::Mode::Native);
|
||||
const std::optional<Platform::Identity> native = Platform::Self();
|
||||
Check(native && native->pid == getpid() && native->start == 111 && native->Key() == std::format("{}:111", getpid()), "natively a driver is its own pid and start time");
|
||||
|
||||
Write(dir / "flatpak-info", "[Application]\nname=net.catcrafts.CrafterReview\n\n[Instance]\ninstance-id=2509661707\napp-path=/x\n");
|
||||
Write(dir / "run" / ".flatpak" / "2509661707" / "bwrapinfo.json", "{\n \"child-pid\": 416559,\n \"pid-namespace\": 4026536671\n}\n");
|
||||
Write(proc / "1" / "stat", Stat(1, 'S', 841601));
|
||||
Platform::SetPaths({ .proc = proc, .flatpakInfo = dir / "flatpak-info", .runtimeDir = dir / "run" });
|
||||
Platform::SetMode(Platform::Mode::Sandbox);
|
||||
const std::optional<Platform::Identity> sandboxed = Platform::Self();
|
||||
Check(sandboxed && sandboxed->Key() == "416559:841601", "in the sandbox: the instance's host pid and /proc/1's start time");
|
||||
|
||||
fs::remove(dir / "run" / ".flatpak" / "2509661707" / "bwrapinfo.json");
|
||||
Check(!Platform::Self().has_value(), "no bwrapinfo.json (flatpak build): no identity a host driver could check");
|
||||
Platform::SetMode(Platform::Mode::Native);
|
||||
}
|
||||
|
||||
{
|
||||
const fs::path drivers = dir / "drivers";
|
||||
const Platform::Identity me { .pid = 416559, .start = 841601 };
|
||||
{
|
||||
const Platform::LifetimeLock lock(drivers, me);
|
||||
Check(fs::exists(drivers / "416559-841601.lock"), "the lifetime lock is named <pid>-<start>.lock");
|
||||
Check(Platform::ProbeLifetimeLock(drivers, me.pid, me.start) == true, "a probe finds it held");
|
||||
bool refused = false;
|
||||
try {
|
||||
const Platform::LifetimeLock second(drivers, me);
|
||||
} catch (const std::runtime_error&) {
|
||||
refused = true;
|
||||
}
|
||||
Check(refused, "a second driver under the same name is refused");
|
||||
const std::vector<fs::directory_entry> entries(fs::directory_iterator(drivers), {});
|
||||
Check(entries.size() == 1, "and leaves no temp file behind");
|
||||
}
|
||||
Check(!Platform::ProbeLifetimeLock(drivers, me.pid, me.start).has_value(), "a driver that ended leaves no lock file");
|
||||
Write(Platform::LifetimeLockFile(drivers, 7, 8), "");
|
||||
Check(Platform::ProbeLifetimeLock(drivers, 7, 8) == false, "a lock file nobody holds is a dead driver's");
|
||||
Check(!Platform::ProbeLifetimeLock(drivers, 9, 9).has_value(), "no lock file: a driver that takes none");
|
||||
}
|
||||
|
||||
{
|
||||
const std::string home = Platform::Home();
|
||||
setenv("XDG_CONFIG_HOME", "/home/x/.var/app/net.catcrafts.CrafterReview/config", 1);
|
||||
setenv("XDG_CACHE_HOME", "/home/x/.var/app/net.catcrafts.CrafterReview/cache", 1);
|
||||
setenv("HOST_XDG_CONFIG_HOME", "/elsewhere/config", 1);
|
||||
unsetenv("HOST_XDG_CACHE_HOME");
|
||||
Platform::RestoreHostXdg();
|
||||
Check(Platform::GetEnv("XDG_CONFIG_HOME") == "/elsewhere/config", "a host XDG directory the host set comes back");
|
||||
Check(Platform::GetEnv("XDG_CACHE_HOME") == Dyn::JoinPath(home, ".cache"), "one it left unset is the XDG default under $HOME");
|
||||
Check(Platform::GetEnv("XDG_STATE_HOME") == Dyn::JoinPath(home, ".local/state"), "state too");
|
||||
}
|
||||
|
||||
{
|
||||
const std::string today = Platform::TodayIso();
|
||||
Check(today.size() == 10 && today[4] == '-' && today[7] == '-', "date.today().isoformat()");
|
||||
Check(Platform::LocalTime("%H:%M:%S", 0).size() == 8, "strftime in local time");
|
||||
Check(Platform::SelfExe().is_absolute(), "the running binary's path");
|
||||
}
|
||||
|
||||
fs::remove_all(dir);
|
||||
return Failures == 0 ? 0 : 1;
|
||||
}
|
||||
207
tests/Process/main.cpp
Normal file
207
tests/Process/main.cpp
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
// 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;
|
||||
}
|
||||
Loading…
Reference in a new issue