fix: bridge respawned child signals (#933) (thanks @roshanasingh4)

Co-authored-by: Roshan Singh <roshanasingh4@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-15 06:37:27 +00:00
parent d9f2ee40f7
commit 154b8e3e0e
6 changed files with 80 additions and 50 deletions

View File

@@ -1,10 +1,11 @@
import { spawn } from "node:child_process";
import net from "node:net";
import path from "node:path";
import process from "node:process";
import { afterEach, describe, expect, it } from "vitest";
import { spawnWithSignalForwarding } from "./spawn-with-signal-forwarding.js";
import { attachChildProcessBridge } from "./child-process-bridge.js";
function waitForLine(stream: NodeJS.ReadableStream, timeoutMs = 10_000): Promise<string> {
return new Promise((resolve, reject) => {
@@ -52,10 +53,19 @@ function canConnect(port: number): Promise<boolean> {
});
}
describe("spawnWithSignalForwarding", () => {
describe("attachChildProcessBridge", () => {
const children: Array<{ kill: (signal?: NodeJS.Signals) => boolean }> = [];
const detachments: Array<() => void> = [];
afterEach(() => {
for (const detach of detachments) {
try {
detach();
} catch {
// ignore
}
}
detachments.length = 0;
for (const child of children) {
try {
child.kill("SIGKILL");
@@ -67,15 +77,16 @@ describe("spawnWithSignalForwarding", () => {
});
it(
"forwards SIGTERM to spawned child",
"forwards SIGTERM to the wrapped child",
async () => {
const tsxPath = path.resolve(process.cwd(), "node_modules/.bin/tsx");
const childPath = path.resolve(process.cwd(), "test/fixtures/signal-forwarding/child.ts");
const childPath = path.resolve(process.cwd(), "test/fixtures/child-process-bridge/child.js");
const { child } = spawnWithSignalForwarding(tsxPath, [childPath], {
const child = spawn(process.execPath, [childPath], {
stdio: ["ignore", "pipe", "inherit"],
env: process.env,
});
const { detach } = attachChildProcessBridge(child);
detachments.push(detach);
children.push(child);
if (!child.stdout) throw new Error("expected stdout");

View File

@@ -0,0 +1,47 @@
import type { ChildProcess } from "node:child_process";
import process from "node:process";
export type ChildProcessBridgeOptions = {
signals?: NodeJS.Signals[];
onSignal?: (signal: NodeJS.Signals) => void;
};
const defaultSignals: NodeJS.Signals[] =
process.platform === "win32"
? ["SIGTERM", "SIGINT", "SIGBREAK"]
: ["SIGTERM", "SIGINT", "SIGHUP", "SIGQUIT"];
export function attachChildProcessBridge(
child: ChildProcess,
{ signals = defaultSignals, onSignal }: ChildProcessBridgeOptions = {},
): { detach: () => void } {
const listeners = new Map<NodeJS.Signals, () => void>();
for (const signal of signals) {
const listener = (): void => {
onSignal?.(signal);
try {
child.kill(signal);
} catch {
// ignore
}
};
try {
process.on(signal, listener);
listeners.set(signal, listener);
} catch {
// Unsupported signal on this platform.
}
}
const detach = (): void => {
for (const [signal, listener] of listeners) {
process.off(signal, listener);
}
listeners.clear();
};
child.once("exit", detach);
child.once("error", detach);
return { detach };
}

View File

@@ -1,40 +0,0 @@
import type { ChildProcess, SpawnOptions } from "node:child_process";
import { spawn } from "node:child_process";
import process from "node:process";
export type SpawnWithSignalForwardingOptions = {
signals?: NodeJS.Signals[];
};
export function spawnWithSignalForwarding(
command: string,
args: string[],
options: SpawnOptions,
{ signals = ["SIGTERM", "SIGINT", "SIGHUP", "SIGQUIT"] }: SpawnWithSignalForwardingOptions = {},
): { child: ChildProcess; detach: () => void } {
const child = spawn(command, args, options);
const listeners = new Map<NodeJS.Signals, () => void>();
for (const signal of signals) {
const listener = (): void => {
try {
child.kill(signal);
} catch {
// ignore
}
};
listeners.set(signal, listener);
process.on(signal, listener);
}
const detach = (): void => {
for (const [signal, listener] of listeners) {
process.off(signal, listener);
}
listeners.clear();
};
child.once("exit", detach);
return { child, detach };
}