Skip to content
zit Artifact [b02841f18e]
Login

Artifact [b02841f18e]

Artifact b02841f18ecee8001f3e621bf61f883647f4762db46a7405464ae489ed5577a8:


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