From 85f71f4c8fca59d5d30625432c2be05592106b03 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 20 Jun 2026 13:39:16 +0200 Subject: [PATCH] fix(scripts): kill managed child trees on windows --- scripts/lib/managed-child-process.mjs | 22 ++++++++++++++--- test/scripts/managed-child-process.test.ts | 28 +++++++++++++++++++++- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/scripts/lib/managed-child-process.mjs b/scripts/lib/managed-child-process.mjs index 403a0ab00a58..63b6d918c393 100644 --- a/scripts/lib/managed-child-process.mjs +++ b/scripts/lib/managed-child-process.mjs @@ -1,5 +1,5 @@ // Runs child commands with process-group signal forwarding and Windows shell normalization. -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { constants as osConstants } from "node:os"; import { buildCmdExeCommandLine } from "../windows-cmd-helpers.mjs"; @@ -22,14 +22,19 @@ export function signalExitCode(signal) { /** * @param {import("node:child_process").ChildProcess} child * @param {NodeJS.Signals} [signal] + * @param {{ platform?: NodeJS.Platform; runTaskkill?: typeof spawnSync }} [options] */ -function terminateManagedChild(child, signal = "SIGTERM") { +export function terminateManagedChild( + child, + signal = "SIGTERM", + { platform = process.platform, runTaskkill = spawnSync } = {}, +) { if (!child.pid) { return; } try { - if (process.platform !== "win32") { + if (platform !== "win32") { process.kill(-child.pid, signal); return; } @@ -44,6 +49,17 @@ function terminateManagedChild(child, signal = "SIGTERM") { return; } + if (platform === "win32") { + const args = ["/PID", String(child.pid), "/T"]; + if (signal === "SIGKILL") { + args.push("/F"); + } + const result = runTaskkill("taskkill", args, { stdio: "ignore" }); + if (!result?.error && result?.status === 0) { + return; + } + } + child.kill(signal); } diff --git a/test/scripts/managed-child-process.test.ts b/test/scripts/managed-child-process.test.ts index 694b462562a3..07aac7d5864c 100644 --- a/test/scripts/managed-child-process.test.ts +++ b/test/scripts/managed-child-process.test.ts @@ -4,11 +4,12 @@ import fs from "node:fs"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { pathToFileURL } from "node:url"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createManagedCommandSpawnSpec, runManagedCommand, signalExitCode, + terminateManagedChild, } from "../../scripts/lib/managed-child-process.mjs"; import { createScriptTestHarness } from "./test-helpers.js"; @@ -110,6 +111,31 @@ describe("managed-child-process", () => { ).toThrow("unsafe Windows cmd.exe argument detected"); }); + it("signals Windows managed process trees with taskkill", () => { + const child = { + kill: vi.fn(), + pid: 12345, + }; + const runTaskkill = vi.fn(() => ({ error: undefined, status: 0 })); + + terminateManagedChild(child, "SIGTERM", { + platform: "win32", + runTaskkill, + }); + expect(runTaskkill).toHaveBeenNthCalledWith(1, "taskkill", ["/PID", "12345", "/T"], { + stdio: "ignore", + }); + + terminateManagedChild(child, "SIGKILL", { + platform: "win32", + runTaskkill, + }); + expect(runTaskkill).toHaveBeenNthCalledWith(2, "taskkill", ["/PID", "12345", "/T", "/F"], { + stdio: "ignore", + }); + expect(child.kill).not.toHaveBeenCalled(); + }); + it("shares process signal listeners across parallel managed commands", async () => { const signals = ["SIGHUP", "SIGINT", "SIGTERM"] as const; const baseline = new Map(signals.map((signal) => [signal, process.listenerCount(signal)]));