feat(gateway): add child process registry for cleanup
This commit is contained in:
parent
bdbef04ac3
commit
13d0644c01
136
src/infra/child-registry.test.ts
Normal file
136
src/infra/child-registry.test.ts
Normal 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
153
src/infra/child-registry.ts
Normal 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();
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user