feat(gateway): add child process registry for cleanup

This commit is contained in:
Trevin Chow 2026-01-29 12:29:55 -08:00 committed by Trevin Chow
parent bdbef04ac3
commit 13d0644c01
2 changed files with 289 additions and 0 deletions

View File

@ -0,0 +1,136 @@
// src/infra/child-registry.test.ts
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { spawn, type ChildProcess } from "node:child_process";
import {
registerChild,
unregisterChild,
killAllChildren,
killAllChildrenSync,
getRegisteredChildren,
clearRegistry,
} from "./child-registry.js";
describe("child-registry", () => {
beforeEach(() => {
clearRegistry();
});
afterEach(() => {
clearRegistry();
});
it("registers a child process with PID", () => {
const mockProc = {
pid: 12345,
killed: false,
exitCode: null,
signalCode: null,
kill: vi.fn(),
on: vi.fn(),
once: vi.fn(),
} as unknown as ChildProcess;
registerChild("test-child", mockProc);
const children = getRegisteredChildren();
expect(children).toHaveLength(1);
expect(children[0].pid).toBe(12345);
expect(children[0].name).toBe("test-child");
});
it("does not register if no PID", () => {
const mockProc = {
pid: undefined,
on: vi.fn(),
} as unknown as ChildProcess;
registerChild("no-pid", mockProc);
expect(getRegisteredChildren()).toHaveLength(0);
});
it("respects managedExternally flag", () => {
const mockProc = {
pid: 11111,
killed: false,
exitCode: null,
signalCode: null,
kill: vi.fn(),
on: vi.fn(),
once: vi.fn(),
} as unknown as ChildProcess;
registerChild("managed", mockProc, { managedExternally: true });
const children = getRegisteredChildren();
expect(children[0].managedExternally).toBe(true);
});
it("unregisters a child by PID", () => {
const mockProc = {
pid: 22222,
killed: false,
exitCode: null,
signalCode: null,
kill: vi.fn(),
on: vi.fn(),
once: vi.fn(),
} as unknown as ChildProcess;
registerChild("to-remove", mockProc);
expect(getRegisteredChildren()).toHaveLength(1);
unregisterChild(22222);
expect(getRegisteredChildren()).toHaveLength(0);
});
it("killAllChildrenSync sends SIGKILL to all children", () => {
const killFn = vi.fn();
const mockProc = {
pid: 33333,
killed: false,
exitCode: null,
signalCode: null,
kill: killFn,
on: vi.fn(),
once: vi.fn(),
} as unknown as ChildProcess;
registerChild("to-kill", mockProc);
killAllChildrenSync();
expect(killFn).toHaveBeenCalledWith("SIGKILL");
});
it("skips already-dead processes", () => {
const killFn = vi.fn();
const mockProc = {
pid: 44444,
killed: false,
exitCode: 0, // Already exited
signalCode: null,
kill: killFn,
on: vi.fn(),
once: vi.fn(),
} as unknown as ChildProcess;
registerChild("already-dead", mockProc);
killAllChildrenSync();
expect(killFn).not.toHaveBeenCalled();
});
});
describe("child-registry integration", () => {
it("kills a real spawned process", async () => {
const proc = spawn("sleep", ["60"], { detached: false });
registerChild("sleep-test", proc);
expect(getRegisteredChildren()).toHaveLength(1);
await killAllChildren("SIGTERM", { timeoutMs: 1000 });
// Process should be killed
expect(proc.killed || proc.exitCode !== null || proc.signalCode !== null).toBe(true);
});
});

153
src/infra/child-registry.ts Normal file
View File

@ -0,0 +1,153 @@
// src/infra/child-registry.ts
import type { ChildProcess } from "node:child_process";
import { createSubsystemLogger } from "../logging/subsystem.js";
const log = createSubsystemLogger("child-registry");
type ChildEntry = {
name: string;
process: ChildProcess;
managedExternally: boolean;
};
const children = new Map<number, ChildEntry>();
export function registerChild(
name: string,
proc: ChildProcess,
opts?: { managedExternally?: boolean },
): void {
if (!proc.pid) {
log.warn(`Cannot register child "${name}": no PID (spawn may have failed)`);
return;
}
children.set(proc.pid, {
name,
process: proc,
managedExternally: opts?.managedExternally ?? false,
});
const cleanup = () => {
if (proc.pid) children.delete(proc.pid);
};
proc.on("exit", cleanup);
proc.on("error", cleanup);
}
export function unregisterChild(pid: number): void {
children.delete(pid);
}
export async function killAllChildren(
signal: NodeJS.Signals = "SIGTERM",
opts?: { excludeManaged?: boolean; timeoutMs?: number },
): Promise<void> {
const timeoutMs = opts?.timeoutMs ?? (signal === "SIGKILL" ? 500 : 3000);
const excludeManaged = opts?.excludeManaged ?? false;
const promises: Promise<void>[] = [];
// Copy entries to avoid mutation during iteration
const entries = [...children.entries()];
for (const [pid, entry] of entries) {
const { name, process: proc, managedExternally } = entry;
if (excludeManaged && managedExternally) {
continue;
}
if (proc.killed || proc.exitCode !== null || proc.signalCode !== null) {
children.delete(pid);
continue;
}
log.info(`Killing child process: ${name} (pid=${pid}) with ${signal}`);
promises.push(killWithTimeout(pid, proc, signal, timeoutMs, name));
}
await Promise.all(promises);
}
async function killWithTimeout(
pid: number,
proc: ChildProcess,
signal: NodeJS.Signals,
timeoutMs: number,
name: string,
): Promise<void> {
if (proc.exitCode !== null || proc.signalCode !== null) {
return;
}
try {
proc.kill(signal);
} catch (err) {
const errnoErr = err as NodeJS.ErrnoException;
if (errnoErr.code !== "ESRCH") {
log.warn(`Failed to send ${signal} to ${name}: ${errnoErr.message}`);
}
return;
}
if (timeoutMs <= 0) return;
await Promise.race([
new Promise<void>((resolve) => proc.once("exit", resolve)),
new Promise<void>((resolve) => setTimeout(resolve, timeoutMs)),
]);
if (proc.exitCode === null && proc.signalCode === null) {
if (signal !== "SIGKILL") {
log.warn(`${name} did not exit after ${timeoutMs}ms; sending SIGKILL`);
try {
proc.kill("SIGKILL");
} catch {
/* ignore */
}
await new Promise<void>((resolve) => setTimeout(resolve, 500));
if (proc.exitCode === null && proc.signalCode === null) {
log.warn(`Process ${name} (pid=${pid}) did not respond to SIGKILL; proceeding anyway`);
}
} else {
log.warn(
`Process ${name} (pid=${pid}) did not respond to SIGKILL after ${timeoutMs}ms; proceeding anyway`,
);
}
}
children.delete(pid);
}
export function killAllChildrenSync(): void {
// Copy entries to avoid mutation during iteration
const entries = [...children.entries()];
for (const [pid, { name, process: proc }] of entries) {
if (proc.exitCode !== null || proc.signalCode !== null) continue;
try {
// Use console.error since logging may be unreliable during process exit
console.error(`[child-registry] Force-killing child process: ${name} (pid=${pid})`);
proc.kill("SIGKILL");
} catch {
/* ignore */
}
}
children.clear();
}
export function getRegisteredChildren(): Array<{
pid: number;
name: string;
managedExternally: boolean;
}> {
return [...children.entries()].map(([pid, entry]) => ({
pid,
name: entry.name,
managedExternally: entry.managedExternally,
}));
}
export function clearRegistry(): void {
children.clear();
}