//! Long-running children of the test harnesses (servers, proxies) that
//! cannot outlive the process that started them.
//!
//! A `defer child.kill(io)` covers a test that returns, but not one that
//! panics, nor a test process the build runner kills on a timeout or a user
//! interrupts: no defer runs, and the server is orphaned with its port bound
//! for as long as the machine stays up. On a self-hosted CI runner that is
//! days. So the child is started under a watcher instead. `sh` starts a
//! watcher subshell and then `exec`s into the real command, so the returned
//! `Child` is the server itself and `kill`/`wait` act on it exactly as before.
//! The watcher blocks reading a pipe whose only write end this process holds.
//! The kernel closes that end however this process ends, and `kill`/`wait`
//! close it when they reap the child; either way the watcher wakes and kills
//! the child's process group, which holds the server and anything it forked.
//! Pipe EOF works the same on macOS and Linux, unlike PR_SET_PDEATHSIG, and
//! needs nothing from the server.
const std = @import("std");
const builtin = @import("builtin");
const root = @import("root");
const posix = std.posix;
/// `$@` is the real command. fd 3 keeps the pipe for the watcher; the
/// server gets /dev/null as stdin and never holds the pipe itself.
const watcher_script =
\\exec 3<&0 </dev/null
\\(/bin/cat <&3; kill -KILL 0) >/dev/null 2>&1 &
\\exec "$@" 3<&-
;
/// Spawn `options.argv` as a child that dies with this process. The caller
/// still stops it with `kill` (or reaps it with `wait`), as for any child.
pub fn spawn(io: std.Io, options: std.process.SpawnOptions) std.process.SpawnError!std.process.Child {
if (comptime builtin.os.tag == .windows) return std.process.spawn(io, options);
// The watcher owns stdin (the child reads /dev/null), and its `kill 0`
// must reach this child's own group, never the test process's.
std.debug.assert(options.stdin == .inherit or options.stdin == .ignore);
std.debug.assert(options.pgid == null or options.pgid.? == 0);
const head = [_][]const u8{ "/bin/sh", "-c", watcher_script, "sh" };
var argv: [head.len + 12][]const u8 = undefined;
std.debug.assert(options.argv.len <= argv.len - head.len);
@memcpy(argv[0..head.len], &head);
@memcpy(argv[head.len..][0..options.argv.len], options.argv);
var wrapped = options;
wrapped.argv = argv[0 .. head.len + options.argv.len];
wrapped.stdin = .pipe;
wrapped.pgid = 0;
const child = try std.process.spawn(io, wrapped);
track(child.id.?);
return child;
}
/// Process groups spawned since the last `reapLeaked`. A test spawns a
/// handful; a long harness run (smoke) spawns more, so a full table first
/// drops the groups that are already gone. The harnesses spawn servers from
/// one thread at a time.
var groups: [64]posix.pid_t = undefined;
var groups_len: usize = 0;
fn track(pgid: posix.pid_t) void {
if (groups_len == groups.len) {
var kept: usize = 0;
for (groups) |g| {
if (!groupAlive(g)) continue;
groups[kept] = g;
kept += 1;
}
groups_len = kept;
}
std.debug.assert(groups_len < groups.len);
groups[groups_len] = pgid;
groups_len += 1;
// A test runner that declares the hook checks after every test.
if (@hasDecl(root, "child_leak_check")) root.child_leak_check = &reapLeaked;
}
/// Kill and count every tracked process group that still has a member,
/// then forget them all. A stopped child's watcher exits a moment after the
/// pipe closes, so the groups get a grace (generous for a loaded machine)
/// before they count; a run with no leak spends milliseconds here.
pub fn reapLeaked(io: std.Io) usize {
const len = groups_len;
groups_len = 0;
const deadline: i96 = std.Io.Clock.now(.awake, io).nanoseconds + 10 * std.time.ns_per_s;
var leaked: usize = 0;
for (groups[0..len]) |pgid| {
while (groupAlive(pgid)) {
if (std.Io.Clock.now(.awake, io).nanoseconds >= deadline) {
std.debug.print("child process group {d} was still running after its owner returned; killed it\n", .{pgid});
posix.kill(-pgid, .KILL) catch {};
leaked += 1;
break;
}
io.sleep(.fromMilliseconds(5), .awake) catch {};
}
}
return leaked;
}
fn groupAlive(pgid: posix.pid_t) bool {
posix.kill(-pgid, @fromBackingInt(@intCast(0))) catch |err| return err != error.ProcessNotFound;
return true;
}