From 74c1900e630f241b8b9a9dc93c56556bc06bf0da Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 15:43:39 -0700 Subject: [PATCH] refactor(tooling): unify managed child process cleanup (#127480) --- scripts/bench-cli-startup.ts | 76 +++---- scripts/dev/tui-pty-test-watch.ts | 75 +------ scripts/e2e/parallels/host-command.ts | 105 ++++----- .../lib/cross-os-release-checks/process.ts | 16 +- scripts/lib/gateway-bench-child.ts | 117 ++++------ scripts/lib/managed-child-process.mts | 201 +++++++++++++----- scripts/run-additional-boundary-checks.mts | 42 ++-- scripts/run-oxlint-shards.mts | 90 +++----- scripts/test-docker-all.mts | 48 ++--- scripts/test-group-report.mts | 107 ++-------- scripts/tsdown-build.mts | 48 ++--- .../bench-gateway-child-test-support.ts | 118 ---------- test/scripts/dev-tooling-safety.test.ts | 94 -------- test/scripts/docker-all-scheduler.test.ts | 2 + test/scripts/managed-child-process.test.ts | 141 ++++++++++++ test/scripts/test-group-report.test.ts | 75 ------- 16 files changed, 514 insertions(+), 841 deletions(-) diff --git a/scripts/bench-cli-startup.ts b/scripts/bench-cli-startup.ts index c7b950a7e720..a1620e4eec67 100644 --- a/scripts/bench-cli-startup.ts +++ b/scripts/bench-cli-startup.ts @@ -5,6 +5,11 @@ import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { expectDefined } from "../packages/normalization-core/src/expect.js"; +import { + inspectManagedProcessGroup, + terminateManagedChild, + waitForManagedProcessGroupExit, +} from "./lib/managed-child-process.mts"; type CommandCase = { id: string; @@ -116,7 +121,6 @@ const DEFAULT_WARMUP = 1; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_TIMEOUT_KILL_GRACE_MS = 1_000; const TIMEOUT_KILL_GRACE_MS = resolveTimeoutKillGraceMs(process.env); -const PROCESS_GROUP_EXIT_POLL_MS = 25; const DEFAULT_ENTRY = "openclaw.mjs"; const MAX_RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__="; @@ -799,10 +803,9 @@ async function runSample(params: { try { return await new Promise((resolve) => { - const useProcessGroup = process.platform !== "win32"; const proc = spawn(process.execPath, nodeArgs, { cwd: process.cwd(), - detached: useProcessGroup, + detached: process.platform !== "win32", env: { ...process.env, HOME: runRoot, @@ -846,10 +849,10 @@ async function runSample(params: { const timeout = setTimeout(() => { timedOut = true; - signalSampleProcess(proc, "SIGTERM", useProcessGroup); + signalSampleProcess(proc, "SIGTERM"); forceKillAt = Date.now() + TIMEOUT_KILL_GRACE_MS; forceKillTimer = setTimeout(() => { - signalSampleProcess(proc, "SIGKILL", useProcessGroup); + signalSampleProcess(proc, "SIGKILL"); }, TIMEOUT_KILL_GRACE_MS).unref?.(); }, params.timeoutMs); timeout.unref?.(); @@ -889,12 +892,11 @@ async function runSample(params: { stderrTail: tailLines(stderr, 20), }), }); - if (timedOut && isSampleProcessGroupAlive(proc, useProcessGroup)) { + if (timedOut && isSampleProcessGroupAlive(proc)) { void finishAfterTimeoutCleanup({ complete, forceKillAt, proc, - useProcessGroup, }); return; } @@ -912,74 +914,48 @@ async function finishAfterTimeoutCleanup(params: { complete: () => void; forceKillAt: number | null; proc: ReturnType; - useProcessGroup: boolean; }): Promise { const graceRemainingMs = params.forceKillAt === null ? TIMEOUT_KILL_GRACE_MS : Math.max(0, params.forceKillAt - Date.now()); if (graceRemainingMs > 0) { - await waitForSampleProcessGroupExit(params.proc, params.useProcessGroup, graceRemainingMs); + await waitForSampleProcessGroupExit(params.proc, graceRemainingMs); } - if (isSampleProcessGroupAlive(params.proc, params.useProcessGroup)) { - signalSampleProcess(params.proc, "SIGKILL", params.useProcessGroup); + if (isSampleProcessGroupAlive(params.proc)) { + signalSampleProcess(params.proc, "SIGKILL"); } - await waitForSampleProcessGroupExit(params.proc, params.useProcessGroup, TIMEOUT_KILL_GRACE_MS); + await waitForSampleProcessGroupExit(params.proc, TIMEOUT_KILL_GRACE_MS); params.complete(); } -function signalSampleProcess( - proc: ReturnType, - signal: NodeJS.Signals, - useProcessGroup: boolean, -): void { +function signalSampleProcess(proc: ReturnType, signal: NodeJS.Signals): void { if (!proc.pid) { return; } - try { - if (useProcessGroup) { - process.kill(-proc.pid, signal); - } else { - proc.kill(signal); - } - } catch (error) { + const handleSignalError = (error: unknown) => { const code = (error as NodeJS.ErrnoException | undefined)?.code; if (code !== "ESRCH" && code !== "EPERM") { throw error; } - } + }; + terminateManagedChild(proc, signal, { + onChildSignalError: handleSignalError, + onProcessGroupSignalError: handleSignalError, + processGroupFallback: "never", + useWindowsTaskkill: false, + }); } -function isSampleProcessGroupAlive( - proc: ReturnType, - useProcessGroup: boolean, -): boolean { - if (!useProcessGroup || !proc.pid) { - return false; - } - try { - process.kill(-proc.pid, 0); - return true; - } catch (error) { - return (error as NodeJS.ErrnoException | undefined)?.code === "EPERM"; - } +function isSampleProcessGroupAlive(proc: ReturnType): boolean { + return inspectManagedProcessGroup(proc, { errorPolicy: "alive-on-eperm" }) === "live"; } -async function waitForSampleProcessGroupExit( +function waitForSampleProcessGroupExit( proc: ReturnType, - useProcessGroup: boolean, timeoutMs: number, ): Promise { - const deadlineAt = Date.now() + timeoutMs; - while (Date.now() < deadlineAt) { - if (!isSampleProcessGroupAlive(proc, useProcessGroup)) { - return true; - } - await new Promise((resolvePoll) => { - setTimeout(resolvePoll, PROCESS_GROUP_EXIT_POLL_MS); - }); - } - return !isSampleProcessGroupAlive(proc, useProcessGroup); + return waitForManagedProcessGroupExit(proc, timeoutMs, { errorPolicy: "alive-on-eperm" }); } async function runCase(params: { diff --git a/scripts/dev/tui-pty-test-watch.ts b/scripts/dev/tui-pty-test-watch.ts index 08d0acddfc25..b16577aed8a7 100644 --- a/scripts/dev/tui-pty-test-watch.ts +++ b/scripts/dev/tui-pty-test-watch.ts @@ -1,11 +1,11 @@ // Tui Pty Test Watch script supports OpenClaw repository automation. -import { spawn, spawnSync } from "node:child_process"; +import { spawn } from "node:child_process"; import { mkdir, open, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { terminateManagedChild } from "../lib/managed-child-process.mts"; import { sleep as delay } from "../lib/sleep.mjs"; -import { resolveWindowsTaskkillPath } from "../lib/windows-taskkill.mjs"; type Options = { altScreen: boolean; @@ -48,12 +48,6 @@ type ChildStopper = { type SignalChild = (child: KillableChild, signal: NodeJS.Signals) => void; -type RunTaskkill = ( - command: string, - args: string[], - options: { stdio: "ignore" }, -) => { error?: unknown; status?: number | null } | undefined; - function unrefTimer(timer: ReturnType): void { (timer as { unref?: () => void }).unref?.(); } @@ -133,60 +127,6 @@ function currentTerminalDimension(value: number | undefined, fallback: number): return String(value && value > 0 ? value : fallback); } -function signalWindowsProcessTree( - pid: number, - signal: NodeJS.Signals, - runTaskkill: RunTaskkill = spawnSync, -): boolean { - const args = ["/PID", String(pid), "/T"]; - if (signal === "SIGKILL") { - args.push("/F"); - } - const result = runTaskkill(resolveWindowsTaskkillPath(), args, { stdio: "ignore" }); - return !result?.error && result?.status === 0; -} - -function signalWindowsProcessTreeOrForce( - pid: number, - signal: NodeJS.Signals, - runTaskkill: RunTaskkill = spawnSync, -): boolean { - if (signalWindowsProcessTree(pid, signal, runTaskkill)) { - return true; - } - return signal !== "SIGKILL" && signalWindowsProcessTree(pid, "SIGKILL", runTaskkill); -} - -function signalChildProcessTree( - child: KillableChild, - signal: NodeJS.Signals, - { - platform = process.platform, - runTaskkill = spawnSync, - useProcessGroup = platform !== "win32", - }: { - platform?: NodeJS.Platform; - runTaskkill?: RunTaskkill; - useProcessGroup?: boolean; - } = {}, -): void { - if (useProcessGroup && typeof child.pid === "number") { - try { - process.kill(-child.pid, signal); - return; - } catch { - // Non-detached fallback or already-exited group; direct child signaling is - // still useful on platforms without process groups. - } - } - if (platform === "win32" && typeof child.pid === "number") { - if (signalWindowsProcessTreeOrForce(child.pid, signal, runTaskkill)) { - return; - } - } - child.kill(signal); -} - function createChildStopper( child: KillableChild, options: { @@ -195,7 +135,15 @@ function createChildStopper( sigkillGraceMs?: number; } = {}, ): ChildStopper { - const signalChild = options.signalChild ?? signalChildProcessTree; + const signalChild = + options.signalChild ?? + ((targetChild, signal) => + terminateManagedChild(targetChild, signal, { + onChildSignalError(error) { + throw error; + }, + taskkillTimeoutMs: null, + })); const sigtermGraceMs = options.sigtermGraceMs ?? CHILD_SIGTERM_GRACE_MS; const sigkillGraceMs = options.sigkillGraceMs ?? CHILD_SIGKILL_GRACE_MS; let stopping = false; @@ -523,5 +471,4 @@ export const testing = { drainNewMirrorData, parseOptions, readNewMirrorData, - signalChildProcessTree, }; diff --git a/scripts/e2e/parallels/host-command.ts b/scripts/e2e/parallels/host-command.ts index 8823d6f3b653..ef1738cfa5ed 100644 --- a/scripts/e2e/parallels/host-command.ts +++ b/scripts/e2e/parallels/host-command.ts @@ -8,6 +8,11 @@ import { addTimerTimeoutGraceMs, clampTimerTimeoutMs, } from "@openclaw/normalization-core/number-coercion"; +import { + inspectManagedProcessGroup, + terminateManagedChild, + waitForManagedProcessGroupExit, +} from "../../lib/managed-child-process.mts"; import { resolveNpmRunner } from "../../npm-runner.mts"; import { resolvePnpmRunner } from "../../pnpm-runner.mts"; import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "../../windows-cmd-helpers.mjs"; @@ -20,7 +25,6 @@ const HOST_COMMAND_WRAPPER_EXTRA_BUFFER_BYTES = 1024 * 1024; const HOST_COMMAND_WRAPPER_BACKSTOP_MS = 5_000; const HOST_COMMAND_TIMEOUT_KILL_GRACE_MS = 100; const HOST_COMMAND_STREAMING_TIMEOUT_KILL_GRACE_MS = 2_000; -const HOST_COMMAND_PROCESS_GROUP_EXIT_POLL_MS = 25; const HOST_COMMAND_POST_FORCE_KILL_WAIT_MS = 100; const HOST_COMMAND_CHILD_PID_PREFIX = "__OPENCLAW_HOST_COMMAND_CHILD_PID__"; const HOST_COMMAND_SPAWN_ERROR_PREFIX = "__OPENCLAW_HOST_COMMAND_SPAWN_ERROR__"; @@ -79,36 +83,31 @@ function signalHostCommandProcess(pid: number | undefined, signal: NodeJS.Signal if (!pid) { return; } - if (process.platform === "win32") { - try { - process.kill(pid, signal); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== "ESRCH") { - warn(`failed to send ${signal} to host command process ${pid}: ${code ?? String(error)}`); - } - } - return; - } - try { - process.kill(-pid, signal); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ESRCH") { - return; - } - try { - process.kill(pid, signal); - } catch (fallbackError) { - const fallbackCode = (fallbackError as NodeJS.ErrnoException).code; - if (fallbackCode === "ESRCH") { - return; - } - warn( - `failed to send ${signal} to host command process ${pid}: group ${code ?? String(error)}, leader ${fallbackCode ?? String(fallbackError)}`, - ); - } - } + let processGroupError: NodeJS.ErrnoException | undefined; + terminateManagedChild( + { + kill: (childSignal) => process.kill(pid, childSignal), + pid, + }, + signal, + { + onChildSignalError(error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ESRCH") { + return; + } + const reason = processGroupError + ? `group ${processGroupError.code ?? processGroupError.toString()}, leader ${code ?? String(error)}` + : (code ?? String(error)); + warn(`failed to send ${signal} to host command process ${pid}: ${reason}`); + }, + onProcessGroupSignalError(error) { + processGroupError = error as NodeJS.ErrnoException; + }, + processGroupFallback: "nonmissing", + useWindowsTaskkill: false, + }, + ); } const POSIX_TIMEOUT_WRAPPER = String.raw` @@ -604,40 +603,16 @@ export async function runStreaming( } } }; - const streamingProcessGroupAlive = (): boolean => { - if (!detached || !childPid) { - return false; - } - try { - process.kill(-childPid, 0); - return true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EPERM") { - return false; - } - if (child.exitCode !== null || child.signalCode !== null) { - return false; - } - try { - process.kill(childPid, 0); - return true; - } catch { - return false; - } - } - }; - const waitForStreamingProcessGroupExit = async (timeoutBudgetMs: number): Promise => { - const deadlineAt = Date.now() + timeoutBudgetMs; - while (Date.now() < deadlineAt) { - if (!streamingProcessGroupAlive()) { - return true; - } - await new Promise((resolvePoll) => { - setTimeout(resolvePoll, HOST_COMMAND_PROCESS_GROUP_EXIT_POLL_MS); - }); - } - return !streamingProcessGroupAlive(); - }; + const streamingProcessGroupAlive = (): boolean => + inspectManagedProcessGroup(child, { + errorPolicy: "verify-leader", + useProcessGroup: detached, + }) === "live"; + const waitForStreamingProcessGroupExit = (timeoutBudgetMs: number): Promise => + waitForManagedProcessGroupExit(child, timeoutBudgetMs, { + errorPolicy: "verify-leader", + useProcessGroup: detached, + }); logStream?.on("error", (error) => { logStreamError = error; signalStreamingChild("SIGTERM"); diff --git a/scripts/lib/cross-os-release-checks/process.ts b/scripts/lib/cross-os-release-checks/process.ts index ac3e0ba065e0..f274d1a9e656 100644 --- a/scripts/lib/cross-os-release-checks/process.ts +++ b/scripts/lib/cross-os-release-checks/process.ts @@ -16,6 +16,7 @@ import { dirname } from "node:path"; import { StringDecoder } from "node:string_decoder"; import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "../../windows-cmd-helpers.mjs"; import { toStringifiedError } from "../error-format.mts"; +import { terminateManagedChild } from "../managed-child-process.mts"; import { resolveWindowsTaskkillPath } from "../windows-taskkill.mjs"; import type { Cleanup, @@ -195,15 +196,12 @@ export async function stopGateway(gateway: GatewayHandle | null) { } function signalChildProcessTree(child: ChildProcess, signal: NodeJS.Signals) { - if (process.platform !== "win32" && child.pid) { - try { - process.kill(-child.pid, signal); - return; - } catch { - // The child may have exited before its process group was signaled. - } - } - child.kill(signal); + terminateManagedChild(child, signal, { + onChildSignalError(error) { + throw error; + }, + useWindowsTaskkill: false, + }); } export function registerActiveChildProcessTree(child: ChildProcess) { diff --git a/scripts/lib/gateway-bench-child.ts b/scripts/lib/gateway-bench-child.ts index c531fa86f6ed..7b96aef8006b 100644 --- a/scripts/lib/gateway-bench-child.ts +++ b/scripts/lib/gateway-bench-child.ts @@ -1,7 +1,11 @@ // Gateway Bench Child script supports OpenClaw repository automation. -import { spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { + inspectManagedProcessGroup, + terminateManagedChild, + waitForManagedProcessGroupExit, +} from "./managed-child-process.mts"; import { sleep as delay } from "./sleep.mjs"; -import { resolveWindowsTaskkillPath } from "./windows-taskkill.mjs"; export { delay }; @@ -20,8 +24,6 @@ export type StopChildResult = ChildExit & { type StopChildOptions = { killGraceMs?: number; - platform?: NodeJS.Platform; - runTaskkill?: typeof spawnSync; teardownGraceMs?: number; }; @@ -31,9 +33,27 @@ export async function stopChild( ): Promise { const teardownGraceMs = options.teardownGraceMs ?? TEARDOWN_GRACE_MS; const killGraceMs = options.killGraceMs ?? TEARDOWN_KILL_GRACE_MS; - const processTreeOptions = { - platform: options.platform ?? process.platform, - runTaskkill: options.runTaskkill ?? spawnSync, + const processTreeAlive = () => + inspectManagedProcessGroup(child, { errorPolicy: "alive-on-eperm" }) === "live"; + const signalProcessTree = (signal: NodeJS.Signals): boolean => { + let delivered = true; + terminateManagedChild( + { + kill(childSignal) { + delivered = child.kill(childSignal); + return delivered; + }, + pid: child.pid, + }, + signal, + { + onChildSignalError(error) { + throw error; + }, + taskkillTimeoutMs: null, + }, + ); + return delivered; }; let observedExit: ChildExit | null = null; const directExit = (): ChildExit | null => @@ -43,34 +63,30 @@ export async function stopChild( : null); const currentExit = (): ChildExit | null => { const exit = directExit(); - if (exit == null || isProcessTreeAlive(child, processTreeOptions)) { + if (exit == null || processTreeAlive()) { return null; } return exit; }; - const waitForProcessTreeExit = async (ms: number): Promise => { - const deadlineAt = Date.now() + ms; - while (Date.now() < deadlineAt) { - if (!isProcessTreeAlive(child, processTreeOptions)) { - return true; - } - await delay(Math.min(EXIT_POLL_MS, deadlineAt - Date.now())); - } - return !isProcessTreeAlive(child, processTreeOptions); - }; + const waitForProcessTreeExit = (ms: number): Promise => + waitForManagedProcessGroupExit(child, ms, { + clampPollToDeadline: true, + errorPolicy: "alive-on-eperm", + pollIntervalMs: EXIT_POLL_MS, + }); const cleanupExitedProcessTree = async ( exit: ChildExit, exitedBeforeTeardown: boolean, ): Promise => { - if (!isProcessTreeAlive(child, processTreeOptions)) { + if (!processTreeAlive()) { return { ...exit, exitedBeforeTeardown }; } - const sentTeardownSignal = killProcessTree(child, "SIGTERM", processTreeOptions); + const sentTeardownSignal = signalProcessTree("SIGTERM"); if (sentTeardownSignal) { await waitForProcessTreeExit(teardownGraceMs); } - if (sentTeardownSignal && isProcessTreeAlive(child, processTreeOptions)) { - killProcessTree(child, "SIGKILL", processTreeOptions); + if (sentTeardownSignal && processTreeAlive()) { + signalProcessTree("SIGKILL"); await waitForProcessTreeExit(killGraceMs); } if (!sentTeardownSignal) { @@ -115,7 +131,7 @@ export async function stopChild( return await cleanupExitedProcessTree(queuedExit, true); } - const sentTeardownSignal = killProcessTree(child, "SIGTERM", processTreeOptions); + const sentTeardownSignal = signalProcessTree("SIGTERM"); const gracefulExit = await waitForExit(teardownGraceMs); if (gracefulExit != null) { return { ...gracefulExit, exitedBeforeTeardown: !sentTeardownSignal }; @@ -130,7 +146,7 @@ export async function stopChild( return { exitCode: null, exitedBeforeTeardown: true, signal: null }; } - killProcessTree(child, "SIGKILL", processTreeOptions); + signalProcessTree("SIGKILL"); const killedExit = await waitForExit(killGraceMs); const finalExit = killedExit ?? currentExit(); if (finalExit != null) { @@ -147,56 +163,3 @@ function releaseUnsettledChild(child: ChildProcessWithoutNullStreams): void { child.stderr.destroy(); child.unref(); } - -function isProcessTreeAlive( - child: ChildProcessWithoutNullStreams, - { platform = process.platform }: Pick = {}, -): boolean { - if (platform === "win32" || child.pid === undefined) { - return false; - } - try { - process.kill(-child.pid, 0); - return true; - } catch (error) { - return isProcessStillExistsError(error); - } -} - -function isProcessStillExistsError(error: unknown): boolean { - const code = (error as { code?: unknown }).code; - return code === "EPERM"; -} - -function killProcessTree( - child: ChildProcessWithoutNullStreams, - signal: NodeJS.Signals, - { platform = process.platform, runTaskkill = spawnSync }: StopChildOptions = {}, -): boolean { - if (platform !== "win32" && child.pid !== undefined) { - try { - process.kill(-child.pid, signal); - return true; - } catch { - // Fall back to the direct child below. - } - } - if (platform === "win32" && child.pid !== undefined) { - const args = ["/PID", String(child.pid), "/T"]; - if (signal === "SIGKILL") { - args.push("/F"); - } - const taskkillPath = resolveWindowsTaskkillPath(); - const result = runTaskkill(taskkillPath, args, { stdio: "ignore" }); - if (!result?.error && result?.status === 0) { - return true; - } - if (signal !== "SIGKILL") { - const forceResult = runTaskkill(taskkillPath, [...args, "/F"], { stdio: "ignore" }); - if (!forceResult?.error && forceResult?.status === 0) { - return true; - } - } - } - return child.kill(signal); -} diff --git a/scripts/lib/managed-child-process.mts b/scripts/lib/managed-child-process.mts index aac61e0031b4..2bddb5dbb934 100644 --- a/scripts/lib/managed-child-process.mts +++ b/scripts/lib/managed-child-process.mts @@ -12,11 +12,33 @@ const PROCESS_GROUP_POLL_MS = 25; const TASKKILL_TIMEOUT_MS = 10_000; type ProcessTreeState = "indeterminate" | "live" | "signaled" | "terminated"; type ManagedChildTermination = { processTreeState: Exclude }; +type ManagedProcessGroupErrorPolicy = "alive-on-eperm" | "indeterminate" | "verify-leader"; +type ManagedProcessGroupChild = { + exitCode?: number | null; + pid?: number; + signalCode?: string | null; +}; +type ManagedProcessGroupOptions = { + errorPolicy: ManagedProcessGroupErrorPolicy; + inspectLeaderWhenNoGroup?: boolean; + platform?: NodeJS.Platform; + useProcessGroup?: boolean; +}; type TaskkillRunner = ( command: string, args: string[], options: { killSignal?: NodeJS.Signals; stdio?: StdioOptions; timeout?: number }, ) => { error?: Error; status: number | null } | undefined; +type ManagedChildTerminationOptions = { + onChildSignalError?: (error: unknown) => void; + onProcessGroupSignalError?: (error: unknown) => void; + platform?: NodeJS.Platform; + processGroupFallback?: "always" | "never" | "nonmissing"; + runTaskkill?: TaskkillRunner; + taskkillTimeoutMs?: number | null; + useProcessGroup?: boolean; + useWindowsTaskkill?: boolean; +}; type ManagedCommandOptions = { bin: string; @@ -60,28 +82,22 @@ export function signalExitCode(signal: NodeJS.Signals) { /** * @param {import("node:child_process").ChildProcess} child * @param {NodeJS.Signals} [signal] - * @param {{ - * onProcessGroupSignalError?: (error: unknown) => void; - * platform?: NodeJS.Platform; - * runTaskkill?: typeof spawnSync; - * useProcessGroup?: boolean; - * }} [options] + * @param {ManagedChildTerminationOptions} [options] * @returns {{ processTreeState: "indeterminate" | "signaled" | "terminated" } | undefined} */ export function terminateManagedChild( - child: { kill(signal?: NodeJS.Signals): unknown; pid?: number }, + child: { kill(signal: NodeJS.Signals): unknown; pid?: number }, signal: NodeJS.Signals = "SIGTERM", { + onChildSignalError, onProcessGroupSignalError, platform = process.platform, + processGroupFallback = "always", runTaskkill = spawnSync, + taskkillTimeoutMs = TASKKILL_TIMEOUT_MS, useProcessGroup = platform !== "win32", - }: { - onProcessGroupSignalError?: (error: unknown) => void; - platform?: NodeJS.Platform; - runTaskkill?: TaskkillRunner; - useProcessGroup?: boolean; - } = {}, + useWindowsTaskkill = true, + }: ManagedChildTerminationOptions = {}, ): ManagedChildTermination | undefined { if (!child.pid) { try { @@ -89,7 +105,8 @@ export function terminateManagedChild( if (platform !== "win32") { return { processTreeState: delivered === false ? "terminated" : "signaled" }; } - } catch { + } catch (error) { + onChildSignalError?.(error); // A child that never acquired a PID may already have failed to spawn. } return platform === "win32" ? { processTreeState: "indeterminate" } : undefined; @@ -101,49 +118,130 @@ export function terminateManagedChild( return { processTreeState: "signaled" }; } } catch (error) { - if (!isMissingProcessError(error)) { + const processGroupIsMissing = isMissingProcessError(error); + if (!processGroupIsMissing) { onProcessGroupSignalError?.(error); } + if ( + processGroupFallback === "never" || + (processGroupFallback === "nonmissing" && processGroupIsMissing) + ) { + return processGroupIsMissing ? { processTreeState: "terminated" } : undefined; + } } - if (platform !== "win32") { + if (platform !== "win32" || !useWindowsTaskkill) { try { const delivered = child.kill(signal); return { processTreeState: delivered === false ? "terminated" : "signaled" }; } catch (error) { + onChildSignalError?.(error); return isMissingProcessError(error) ? { processTreeState: "terminated" } : undefined; } } - if (platform === "win32") { - const taskkillPath = resolveWindowsTaskkillPath(); - const args = ["/PID", String(child.pid), "/T"]; - if (signal === "SIGKILL") { - args.push("/F"); - } - const taskkillOptions = { - killSignal: "SIGKILL", - stdio: "ignore", - timeout: TASKKILL_TIMEOUT_MS, - } satisfies Parameters[2]; - const result = runTaskkill(taskkillPath, args, taskkillOptions); - if (!result?.error && result?.status === 0) { + const taskkillPath = resolveWindowsTaskkillPath(); + const args = ["/PID", String(child.pid), "/T"]; + if (signal === "SIGKILL") { + args.push("/F"); + } + const taskkillOptions: Parameters[2] = + taskkillTimeoutMs === null + ? { stdio: "ignore" } + : { killSignal: "SIGKILL", stdio: "ignore", timeout: taskkillTimeoutMs }; + const result = runTaskkill(taskkillPath, args, taskkillOptions); + if (!result?.error && result?.status === 0) { + return { processTreeState: "terminated" }; + } + if (signal !== "SIGKILL") { + const forceResult = runTaskkill(taskkillPath, [...args, "/F"], taskkillOptions); + if (!forceResult?.error && forceResult?.status === 0) { return { processTreeState: "terminated" }; } - if (signal !== "SIGKILL") { - const forceResult = runTaskkill(taskkillPath, [...args, "/F"], taskkillOptions); - if (!forceResult?.error && forceResult?.status === 0) { - return { processTreeState: "terminated" }; - } + } + try { + child.kill(signal); + } catch (error) { + onChildSignalError?.(error); + // The leader may already be gone, but failed taskkill leaves descendants unverified. + } + return { processTreeState: "indeterminate" }; +} + +export function inspectManagedProcessGroup( + child: ManagedProcessGroupChild, + { + errorPolicy, + inspectLeaderWhenNoGroup = false, + platform = process.platform, + useProcessGroup = platform !== "win32", + }: ManagedProcessGroupOptions, +): "dead" | "indeterminate" | "live" { + if (!useProcessGroup) { + return inspectLeaderWhenNoGroup && + child.pid && + child.exitCode === null && + child.signalCode === null + ? "live" + : "dead"; + } + const { pid } = child; + if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 1 || pid > 0x7fffffff) { + return "indeterminate"; + } + try { + process.kill(-pid, 0); + return "live"; + } catch (error) { + if (isMissingProcessError(error)) { + return "dead"; + } + if (errorPolicy === "indeterminate") { + return "indeterminate"; + } + if (!hasProcessErrorCode(error, "EPERM")) { + return "dead"; + } + if (errorPolicy === "alive-on-eperm") { + return "live"; + } + if (child.exitCode != null || child.signalCode != null) { + return "dead"; } try { - child.kill(signal); + process.kill(pid, 0); + return "live"; } catch { - // The leader may already be gone, but failed taskkill leaves descendants unverified. + return "dead"; } - return { processTreeState: "indeterminate" }; } - return undefined; +} + +export async function waitForManagedProcessGroupExit( + child: ManagedProcessGroupChild, + timeoutMs: number, + { + clampPollToDeadline = false, + pollIntervalMs = PROCESS_GROUP_POLL_MS, + ...groupOptions + }: ManagedProcessGroupOptions & { + clampPollToDeadline?: boolean; + pollIntervalMs?: number; + }, +): Promise { + const deadlineAt = Date.now() + timeoutMs; + while (Date.now() < deadlineAt) { + if (inspectManagedProcessGroup(child, groupOptions) !== "live") { + return true; + } + const waitMs = clampPollToDeadline + ? Math.min(pollIntervalMs, deadlineAt - Date.now()) + : pollIntervalMs; + await new Promise((resolve) => { + setTimeout(resolve, waitMs); + }); + } + return inspectManagedProcessGroup(child, groupOptions) !== "live"; } /** @@ -318,18 +416,6 @@ function createManagedCommandSetupCleanupError(error: unknown, cleanupError: unk ); } -function processGroupStatus(pid: number | undefined): "dead" | "indeterminate" | "live" { - if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 1 || pid > 0x7fffffff) { - return "indeterminate"; - } - try { - process.kill(-pid, 0); - return "live"; - } catch (error) { - return isMissingProcessError(error) ? "dead" : "indeterminate"; - } -} - async function ensureManagedProcessTreeExit( child: ChildProcess, platform: NodeJS.Platform, @@ -349,11 +435,14 @@ async function ensureManagedProcessTreeExit( } return; } - const initialStatus = processGroupStatus(child.pid); + const initialStatus = inspectManagedProcessGroup(child, { + errorPolicy: "indeterminate", + platform, + }); if (initialStatus === "dead") { return; } - let status: ReturnType = initialStatus; + let status: ReturnType = initialStatus; // A missing group at signal time supersedes the earlier racy liveness probe. const termination = terminateIfLive ? terminateManagedChild(child, "SIGKILL", { platform }) @@ -363,7 +452,7 @@ async function ensureManagedProcessTreeExit( await new Promise((resolve) => { setTimeout(resolve, PROCESS_GROUP_POLL_MS); }); - status = processGroupStatus(child.pid); + status = inspectManagedProcessGroup(child, { errorPolicy: "indeterminate", platform }); if (status === "dead") { if (terminateIfLive && termination?.processTreeState !== "terminated") { throw createManagedCommandCleanupError( @@ -568,5 +657,9 @@ function signalNumberFor(signal: NodeJS.Signals) { } function isMissingProcessError(error: unknown) { - return Boolean(error && typeof error === "object" && "code" in error && error.code === "ESRCH"); + return hasProcessErrorCode(error, "ESRCH"); +} + +function hasProcessErrorCode(error: unknown, code: string) { + return Boolean(error && typeof error === "object" && "code" in error && error.code === code); } diff --git a/scripts/run-additional-boundary-checks.mts b/scripts/run-additional-boundary-checks.mts index 46172397f458..46bee1ed5c30 100644 --- a/scripts/run-additional-boundary-checks.mts +++ b/scripts/run-additional-boundary-checks.mts @@ -10,12 +10,16 @@ import { resolveTimerTimeoutMs, } from "../packages/normalization-core/src/number-coercion.ts"; import { isDirectRunUrl } from "./lib/direct-run.mjs"; +import { + inspectManagedProcessGroup, + terminateManagedChild, + waitForManagedProcessGroupExit, +} from "./lib/managed-child-process.mts"; const DEFAULT_CHECK_TIMEOUT_MS = 10 * 60 * 1000; const DEFAULT_OUTPUT_MAX_BYTES = 512 * 1024; // Boundary checks are disposable subprocesses; bound descendant cleanup after timeout. const TIMEOUT_KILL_GRACE_MS = 250; -const PROCESS_GROUP_EXIT_POLL_MS = 25; const POST_FORCE_KILL_WAIT_MS = 250; type ProcessSignal = `SIG${string}`; @@ -294,38 +298,20 @@ export function createBoundedOutputBuffer(maxBytes = DEFAULT_OUTPUT_MAX_BYTES) { } function terminateChild(child: ChildProcess, signal: ProcessSignal) { - if (process.platform !== "win32" && child.pid) { - try { - process.kill(-child.pid, signal as NodeJS.Signals); - return; - } catch {} - } - child.kill(signal as NodeJS.Signals); + terminateManagedChild(child, signal as NodeJS.Signals, { + onChildSignalError(error) { + throw error; + }, + useWindowsTaskkill: false, + }); } function processGroupAlive(child: ChildProcess) { - if (process.platform === "win32" || !child.pid) { - return false; - } - try { - process.kill(-child.pid, 0); - return true; - } catch (error) { - return typeof error === "object" && error !== null && "code" in error && error.code === "EPERM"; - } + return inspectManagedProcessGroup(child, { errorPolicy: "alive-on-eperm" }) === "live"; } -async function waitForProcessGroupExit(child: ChildProcess, timeoutMs: number) { - const deadlineAt = Date.now() + timeoutMs; - while (Date.now() < deadlineAt) { - if (!processGroupAlive(child)) { - return true; - } - await new Promise((resolvePoll) => { - setTimeout(resolvePoll, PROCESS_GROUP_EXIT_POLL_MS); - }); - } - return !processGroupAlive(child); +function waitForProcessGroupExit(child: ChildProcess, timeoutMs: number) { + return waitForManagedProcessGroupExit(child, timeoutMs, { errorPolicy: "alive-on-eperm" }); } async function finishTerminatedProcessTree( diff --git a/scripts/run-oxlint-shards.mts b/scripts/run-oxlint-shards.mts index c5b619ba0201..3d9f25316bd6 100644 --- a/scripts/run-oxlint-shards.mts +++ b/scripts/run-oxlint-shards.mts @@ -8,6 +8,11 @@ import { resolveLocalCheckEnv, resolveRepoToolBinPath, } from "./lib/local-check-runtime.mts"; +import { + inspectManagedProcessGroup, + terminateManagedChild, + waitForManagedProcessGroupExit, +} from "./lib/managed-child-process.mts"; import { shouldPrepareExtensionPackageBoundaryArtifacts } from "./run-oxlint.mts"; const DEFAULT_WINDOWS_EXTENSION_CHUNK_SIZE = 8; @@ -15,7 +20,6 @@ const DEFAULT_SHARD_HEARTBEAT_MS = 30_000; const DEFAULT_SHARD_TIMEOUT_MS = 15 * 60_000; const DEFAULT_SHARD_KILL_GRACE_MS = 5_000; const POST_FORCE_KILL_WAIT_MS = 1_000; -const PROCESS_GROUP_EXIT_POLL_MS = 25; const DEFAULT_SPLIT_CORE_SHARD_CONCURRENCY = 4; const FAST_LOCAL_CHECK_MIN_CPUS = 12; const FAST_LOCAL_CHECK_MIN_MEMORY_BYTES = 48 * 1024 ** 3; @@ -46,13 +50,10 @@ type RunnerOptions = { }; type ShardRunnerOptions = RunnerOptions & { shard: OxlintShard }; type ShardBatchOptions = RunnerOptions & { concurrency: number; entries: OxlintShard[] }; -type ChildProcessGroupOptions = { child: ChildProcess; useProcessGroup: boolean }; -type ActiveShardChild = ChildProcessGroupOptions & { killGraceMs: number }; -type SignalOptions = ChildProcessGroupOptions & { signal: NodeJS.Signals }; -type WaitOptions = ChildProcessGroupOptions & { timeoutMs: number }; +type ActiveShardChild = { child: ChildProcess; killGraceMs: number }; const ACTIVE_SHARD_CHILDREN = new Set(); -let parentTerminationSignal: NodeJS.Signals | null = null; +let parentTerminationSignal: (typeof PARENT_TERMINATION_SIGNALS)[number] | null = null; let parentTerminationForceKill: ReturnType | null = null; let parentSignalForwardingInstalled = false; @@ -508,16 +509,15 @@ export async function runShard({ env, extraArgs, runner, shard }: ShardRunnerOpt const heartbeatMs = resolveShardHeartbeatMs(env); const timeoutMs = resolveShardTimeoutMs(env); const killGraceMs = resolveShardKillGraceMs(env); - const useProcessGroup = process.platform !== "win32"; const child = spawn(process.execPath, [runner, ...shard.args, ...extraArgs], { stdio: "inherit", - detached: useProcessGroup, + detached: process.platform !== "win32", env: { ...env, OPENCLAW_OXLINT_SKIP_PREPARE: "1", }, }); - const unregisterShardChild = registerShardChild({ child, killGraceMs, useProcessGroup }); + const unregisterShardChild = registerShardChild({ child, killGraceMs }); return await new Promise((resolve) => { let finished = false; @@ -540,16 +540,16 @@ export async function runShard({ env, extraArgs, runner, shard }: ShardRunnerOpt console.error( `[oxlint:${shard.name}] timed out after ${elapsedSeconds}s; terminating shard`, ); - signalChildProcess({ child, signal: "SIGTERM", useProcessGroup }); + signalChildProcess(child, "SIGTERM"); if (killGraceMs > 0) { forceKillAt = Date.now() + killGraceMs; forceKill = setTimeout(() => { console.error(`[oxlint:${shard.name}] did not exit cleanly; killing shard`); - signalChildProcess({ child, signal: "SIGKILL", useProcessGroup }); + signalChildProcess(child, "SIGKILL"); }, killGraceMs); forceKill.unref(); } else { - signalChildProcess({ child, signal: "SIGKILL", useProcessGroup }); + signalChildProcess(child, "SIGKILL"); } }, timeoutMs) : null; @@ -577,20 +577,12 @@ export async function runShard({ env, extraArgs, runner, shard }: ShardRunnerOpt const graceRemainingMs = forceKillAt === null ? killGraceMs : Math.max(0, forceKillAt - Date.now()); if (graceRemainingMs > 0) { - await waitForChildProcessGroupExit({ - child, - timeoutMs: graceRemainingMs, - useProcessGroup, - }); + await waitForChildProcessGroupExit(child, graceRemainingMs); } - if (isChildProcessGroupAlive({ child, useProcessGroup })) { - signalChildProcess({ child, signal: "SIGKILL", useProcessGroup }); + if (isChildProcessGroupAlive(child)) { + signalChildProcess(child, "SIGKILL"); } - await waitForChildProcessGroupExit({ - child, - timeoutMs: POST_FORCE_KILL_WAIT_MS, - useProcessGroup, - }); + await waitForChildProcessGroupExit(child, POST_FORCE_KILL_WAIT_MS); finish(status); }; child.once("error", (error) => { @@ -603,10 +595,7 @@ export async function runShard({ env, extraArgs, runner, shard }: ShardRunnerOpt : timedOut ? 124 : (status ?? 1); - if ( - (timedOut || parentTerminationSignal) && - isChildProcessGroupAlive({ child, useProcessGroup }) - ) { + if ((timedOut || parentTerminationSignal) && isChildProcessGroupAlive(child)) { void finishAfterForcedTeardown(exitStatus); return; } @@ -699,47 +688,30 @@ function parsePositiveEnvInt(rawValue: string, key: string) { return parsedValue; } -function signalChildProcess({ child, signal, useProcessGroup }: SignalOptions) { +function signalChildProcess(child: ChildProcess, signal: NodeJS.Signals) { if (!child.pid) { return; } - try { - if (useProcessGroup) { - process.kill(-child.pid, signal); - } else { - child.kill(signal); - } - } catch (error) { + const reportSignalError = (error: unknown) => { if (!isNodeErrorCode(error, "ESRCH")) { console.error(error); } - } + }; + terminateManagedChild(child, signal, { + onChildSignalError: reportSignalError, + onProcessGroupSignalError: reportSignalError, + processGroupFallback: "never", + useWindowsTaskkill: false, + }); } -function isChildProcessGroupAlive({ child, useProcessGroup }: ChildProcessGroupOptions) { - if (!useProcessGroup || !child.pid) { - return false; - } - try { - process.kill(-child.pid, 0); - return true; - } catch (error) { - return isNodeErrorCode(error, "EPERM"); - } +function isChildProcessGroupAlive(child: ChildProcess) { + return inspectManagedProcessGroup(child, { errorPolicy: "alive-on-eperm" }) === "live"; } -async function waitForChildProcessGroupExit({ child, timeoutMs, useProcessGroup }: WaitOptions) { - const deadlineAt = Date.now() + timeoutMs; - while (Date.now() < deadlineAt) { - if (!isChildProcessGroupAlive({ child, useProcessGroup })) { - return true; - } - await new Promise((resolvePoll) => { - setTimeout(resolvePoll, PROCESS_GROUP_EXIT_POLL_MS); - }); - } - return !isChildProcessGroupAlive({ child, useProcessGroup }); +function waitForChildProcessGroupExit(child: ChildProcess, timeoutMs: number) { + return waitForManagedProcessGroupExit(child, timeoutMs, { errorPolicy: "alive-on-eperm" }); } function registerShardChild(entry: ActiveShardChild) { @@ -781,7 +753,7 @@ function isParentTerminationRequested() { function signalActiveShardChildren(signal: NodeJS.Signals) { for (const entry of ACTIVE_SHARD_CHILDREN) { - signalChildProcess({ ...entry, signal }); + signalChildProcess(entry.child, signal); } } diff --git a/scripts/test-docker-all.mts b/scripts/test-docker-all.mts index 00d19d194391..0349b101f591 100644 --- a/scripts/test-docker-all.mts +++ b/scripts/test-docker-all.mts @@ -31,6 +31,11 @@ import { resolveDockerE2ePlan, } from "./lib/docker-e2e-plan.mts"; import type { DockerE2eLane } from "./lib/docker-e2e-scenarios.mts"; +import { + inspectManagedProcessGroup, + terminateManagedChild, + waitForManagedProcessGroupExit, +} from "./lib/managed-child-process.mts"; import { sleep } from "./lib/sleep.mjs"; import { createPrepublishPluginRegistryArtifact, @@ -54,7 +59,6 @@ export const SHELL_CAPTURE_MAX_CHARS = 1024 * 1024; export const LOG_TAIL_MAX_BYTES = 1024 * 1024; const SHELL_TIMEOUT_KILL_GRACE_MS = 10_000; const SHELL_POST_FORCE_KILL_WAIT_MS = 1_000; -const SHELL_PROCESS_GROUP_EXIT_POLL_MS = 25; const MAX_TIMER_TIMEOUT_MS = 2_147_000_000; const DEFAULT_TIMINGS_FILE = path.join(ROOT_DIR, ".artifacts/docker-tests/lane-timings.json"); const DEFAULT_GITHUB_WORKFLOW = "openclaw-live-and-e2e-checks-reusable.yml"; @@ -82,10 +86,10 @@ type SchedulerLane = Pick & type TimingStore = Awaited>; type ShellCommandResult = Omit, "signal"> & { - signal: NodeJS.Signals | null; + signal: ChildProcess["signalCode"]; }; type ShellCaptureResult = Omit, "signal"> & { - signal: NodeJS.Signals | null; + signal: ChildProcess["signalCode"]; }; type ShellCommandOptions = { @@ -1625,28 +1629,11 @@ function shellCaptureSkippedForShutdown(label: string, signal: ShutdownSignal | } function shellProcessGroupAlive(child: ChildProcess) { - if (process.platform === "win32" || !child.pid) { - return false; - } - try { - process.kill(-child.pid, 0); - return true; - } catch (error) { - return error instanceof Error && "code" in error && error.code === "EPERM"; - } + return inspectManagedProcessGroup(child, { errorPolicy: "alive-on-eperm" }) === "live"; } -async function waitForShellProcessGroupExit(child: ChildProcess, timeoutMs: number) { - const deadlineAt = Date.now() + timeoutMs; - while (Date.now() < deadlineAt) { - if (!shellProcessGroupAlive(child)) { - return true; - } - await new Promise((resolvePoll) => { - setTimeout(resolvePoll, SHELL_PROCESS_GROUP_EXIT_POLL_MS); - }); - } - return !shellProcessGroupAlive(child); +function waitForShellProcessGroupExit(child: ChildProcess, timeoutMs: number) { + return waitForManagedProcessGroupExit(child, timeoutMs, { errorPolicy: "alive-on-eperm" }); } async function finishTimedOutShellProcessTree( @@ -1669,15 +1656,12 @@ async function finishTimedOutShellProcessTree( } function terminateChild(child: ChildProcess, signal: ShutdownSignal) { - if (process.platform !== "win32" && child.pid) { - try { - process.kill(-child.pid, signal); - return; - } catch { - // Fall back to killing the direct child below. - } - } - child.kill(signal); + terminateManagedChild(child, signal, { + onChildSignalError(error) { + throw error; + }, + useWindowsTaskkill: false, + }); } function terminateActiveChildren(signal: ShutdownSignal) { diff --git a/scripts/test-group-report.mts b/scripts/test-group-report.mts index 337c93f2bf08..56d0d7f0bdde 100644 --- a/scripts/test-group-report.mts +++ b/scripts/test-group-report.mts @@ -1,5 +1,5 @@ // Builds grouped Vitest duration reports or compares two grouped reports. -import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { spawn } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -7,6 +7,11 @@ import { pathToFileURL } from "node:url"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import pMap from "p-map"; import { coerceErrorMessage } from "./lib/error-format.mts"; +import { + inspectManagedProcessGroup, + terminateManagedChild, + waitForManagedProcessGroupExit, +} from "./lib/managed-child-process.mts"; import { parsePositiveInt } from "./lib/numeric-options.mjs"; import { buildGroupedTestComparison, @@ -17,7 +22,6 @@ import { renderGroupedTestReport, } from "./lib/test-group-report.mts"; import { formatMs } from "./lib/vitest-report-cli-utils.mts"; -import { resolveWindowsTaskkillPath } from "./lib/windows-taskkill.mjs"; import { resolveVitestNodeArgs } from "./run-vitest.mts"; import { applyParallelVitestCachePaths, @@ -31,7 +35,6 @@ const DEFAULT_TIMEOUT_KILL_GRACE_MS = 10_000; const DEFAULT_SPAWN_LOG_MAX_BYTES = 1024 * 1024 * 256; const DEFAULT_SPAWN_OUTPUT_MAX_BYTES = 1024 * 1024 * 64; const DEFAULT_SPAWN_OUTPUT_TAIL_BYTES = 1024 * 256; -const PROCESS_GROUP_EXIT_POLL_MS = 25; type ProcessSignal = `SIG${string}`; type TimerHandle = ReturnType; @@ -97,12 +100,6 @@ type RunVitestParams = TestGroupRunSpec & reportPath: string; }; -type TaskkillRunner = ( - command: string, - args: string[], - options: { stdio: "ignore" }, -) => { error?: Error; status: number | null }; - function usage() { return [ "Usage: node --import tsx scripts/test-group-report.mts [options] [-- ]", @@ -335,57 +332,6 @@ function parseMaxRssBytes(output: string) { return null; } -function hasErrorCode(error: unknown, code: string) { - return isRecord(error) && error.code === code; -} - -export function signalTestGroupReportChild( - child: Pick, - signal: ProcessSignal, - { - appendDiagnostic = () => {}, - platform = process.platform, - runTaskkill = spawnSync, - useProcessGroup = platform !== "win32", - }: { - appendDiagnostic?: (message: string) => void; - platform?: typeof process.platform; - runTaskkill?: TaskkillRunner; - useProcessGroup?: boolean; - } = {}, -) { - if (useProcessGroup && typeof child.pid === "number") { - try { - process.kill(-child.pid, signal as NodeJS.Signals); - return; - } catch (error) { - if (error && !hasErrorCode(error, "ESRCH")) { - appendDiagnostic( - `[test-group-report] failed to send ${signal} to process group: ${coerceErrorMessage(error)}\n`, - ); - } - } - } - if (platform === "win32" && typeof child.pid === "number") { - const args = ["/PID", String(child.pid), "/T"]; - if (signal === "SIGKILL") { - args.push("/F"); - } - const taskkillPath = resolveWindowsTaskkillPath(); - const result = runTaskkill(taskkillPath, args, { stdio: "ignore" }); - if (!result?.error && result?.status === 0) { - return; - } - if (signal !== "SIGKILL") { - const forceResult = runTaskkill(taskkillPath, [...args, "/F"], { stdio: "ignore" }); - if (!forceResult?.error && forceResult?.status === 0) { - return; - } - } - } - child.kill(signal as NodeJS.Signals); -} - /** * Runs a command, captures text output, and terminates timed-out process groups. */ @@ -422,7 +368,17 @@ export function spawnText(command: string, args: readonly string[], options: Spa let childClosedResult: SpawnTextResult | null = null; let waitingForKillGrace = false; const signalChild = (signal: ProcessSignal) => - signalTestGroupReportChild(child, signal, { appendDiagnostic, useProcessGroup }); + terminateManagedChild(child, signal as NodeJS.Signals, { + onChildSignalError(error) { + throw error; + }, + onProcessGroupSignalError(error) { + appendDiagnostic( + `[test-group-report] failed to send ${signal} to process group: ${coerceErrorMessage(error)}\n`, + ); + }, + taskkillTimeoutMs: null, + }); const parentSignalHandlers: { signal: ProcessSignal; handler: () => void }[] = []; const cleanupParentSignalHandlers = () => { for (const { signal, handler } of parentSignalHandlers) { @@ -448,34 +404,15 @@ export function spawnText(command: string, args: readonly string[], options: Spa relayParentSignal("SIGINT"); relayParentSignal("SIGTERM"); } - const processGroupIsAlive = () => { - if (!useProcessGroup || typeof child.pid !== "number") { - return false; - } - try { - process.kill(-child.pid, 0); - return true; - } catch (error) { - return Boolean(error && hasErrorCode(error, "EPERM")); - } - }; - const waitForProcessGroupExit = async (timeoutMsToWait: number) => { - const deadlineAt = Date.now() + timeoutMsToWait; - while (Date.now() < deadlineAt) { - if (!processGroupIsAlive()) { - return true; - } - await new Promise((resolvePoll) => { - setTimeout(resolvePoll, PROCESS_GROUP_EXIT_POLL_MS); - }); - } - return !processGroupIsAlive(); - }; + const processGroupIsAlive = () => + inspectManagedProcessGroup(child, { errorPolicy: "alive-on-eperm" }) === "live"; const finishAfterProcessGroupCleanup = async (result: SpawnTextResult) => { const graceRemainingMs = killGraceDeadline === null ? killGraceMs : Math.max(0, killGraceDeadline - Date.now()); if (graceRemainingMs > 0) { - await waitForProcessGroupExit(graceRemainingMs); + await waitForManagedProcessGroupExit(child, graceRemainingMs, { + errorPolicy: "alive-on-eperm", + }); } if (settled) { return; diff --git a/scripts/tsdown-build.mts b/scripts/tsdown-build.mts index cdba28dd34f8..f359f4b51e08 100644 --- a/scripts/tsdown-build.mts +++ b/scripts/tsdown-build.mts @@ -13,7 +13,11 @@ import fs from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { BUNDLED_PLUGIN_PATH_PREFIX } from "./lib/bundled-plugin-paths.mjs"; -import { terminateManagedChild } from "./lib/managed-child-process.mts"; +import { + inspectManagedProcessGroup, + terminateManagedChild, + waitForManagedProcessGroupExit, +} from "./lib/managed-child-process.mts"; import { parsePositiveInt } from "./lib/numeric-options.mjs"; import { assertRealOutputRoot } from "./lib/output-root-guard.mjs"; import { @@ -51,7 +55,6 @@ const PROC_MEMINFO_PATH = "/proc/meminfo"; const tsdownStdio = () => ["ignore", "pipe", "pipe"] satisfies ["ignore", "pipe", "pipe"]; // Build descendants get a short cleanup window; a timed-out build must not hold CI for seconds. const TERMINATION_GRACE_MS = 250; -const PROCESS_GROUP_EXIT_POLL_MS = 25; const POST_FORCE_KILL_WAIT_MS = 250; const ROOT_TSDOWN_OUTPUT_ROOTS = ["dist", "dist-runtime"]; const PRESERVED_TSDOWN_OUTPUT_FILES = ["dist/cli-startup-metadata.json"]; @@ -899,35 +902,18 @@ export async function runTsdownBuildInvocation( relayParentSignal("SIGHUP"); } - function processTreeAlive() { - if (!child.pid) { - return false; - } - if (!useProcessGroup) { - return child.exitCode === null && child.signalCode === null; - } - try { - process.kill(-child.pid, 0); - return true; - } catch (error) { - return ( - typeof error === "object" && error !== null && "code" in error && error.code === "EPERM" - ); - } - } - - async function waitForProcessTreeExit(timeoutMsToWait: number) { - const deadlineAt = Date.now() + timeoutMsToWait; - while (Date.now() < deadlineAt) { - if (!processTreeAlive()) { - return true; - } - await new Promise((resolvePoll) => { - setTimeout(resolvePoll, PROCESS_GROUP_EXIT_POLL_MS); - }); - } - return !processTreeAlive(); - } + const processTreeAlive = () => + inspectManagedProcessGroup(child, { + errorPolicy: "alive-on-eperm", + inspectLeaderWhenNoGroup: true, + platform, + }) === "live"; + const waitForProcessTreeExit = (timeoutMsToWait: number) => + waitForManagedProcessGroupExit(child, timeoutMsToWait, { + errorPolicy: "alive-on-eperm", + inspectLeaderWhenNoGroup: true, + platform, + }); async function finishTimedOutProcessTree() { const graceRemainingMs = diff --git a/test/scripts/bench-gateway-child-test-support.ts b/test/scripts/bench-gateway-child-test-support.ts index d53f2febcace..6b8510a57d9b 100644 --- a/test/scripts/bench-gateway-child-test-support.ts +++ b/test/scripts/bench-gateway-child-test-support.ts @@ -1,11 +1,6 @@ // Gateway benchmark child test support simulates child process behavior for script tests. import { EventEmitter } from "node:events"; import { expect, it, vi } from "vitest"; -import { resolveWindowsTaskkillPath } from "../../scripts/lib/windows-taskkill.mjs"; - -function expectedTaskkillPath(): string { - return resolveWindowsTaskkillPath(); -} type StopChildResult = { exitedBeforeTeardown: boolean; @@ -17,8 +12,6 @@ type StopChild = ( child: TChild, options?: { killGraceMs?: number; - platform?: NodeJS.Platform; - runTaskkill?: typeof spawnSync; teardownGraceMs?: number; }, ) => Promise; @@ -111,116 +104,6 @@ export function registerStopChildBehaviorTests(params: { expect(child.unref).toHaveBeenCalledOnce(); }); - it("signals Windows child process trees with taskkill", async () => { - const child = new EventEmitter() as EventEmitter & { - exitCode: number | null; - kill: ReturnType; - pid: number; - signalCode: NodeJS.Signals | null; - stderr: { destroy: ReturnType }; - stdin: { destroy: ReturnType }; - stdout: { destroy: ReturnType }; - unref: ReturnType; - }; - child.exitCode = null; - child.kill = vi.fn(() => true); - child.pid = 4450; - child.signalCode = null; - child.stderr = { destroy: vi.fn() }; - child.stdin = { destroy: vi.fn() }; - child.stdout = { destroy: vi.fn() }; - child.unref = vi.fn(); - const runTaskkill = vi.fn(() => ({ error: undefined, status: 0 })); - - await expect( - params.stopChild(child as unknown as TChild, { - killGraceMs: 1, - platform: "win32", - runTaskkill: runTaskkill as unknown as typeof spawnSync, - teardownGraceMs: 1, - }), - ).resolves.toEqual({ - exitedBeforeTeardown: false, - exitCode: null, - signal: "SIGKILL", - }); - expect(runTaskkill).toHaveBeenNthCalledWith(1, expectedTaskkillPath(), ["/PID", "4450", "/T"], { - stdio: "ignore", - }); - expect(runTaskkill).toHaveBeenNthCalledWith( - 2, - expectedTaskkillPath(), - ["/PID", "4450", "/T", "/F"], - { - stdio: "ignore", - }, - ); - expect(child.kill).not.toHaveBeenCalled(); - expect(child.stdin.destroy).toHaveBeenCalledOnce(); - expect(child.stdout.destroy).toHaveBeenCalledOnce(); - expect(child.stderr.destroy).toHaveBeenCalledOnce(); - expect(child.unref).toHaveBeenCalledOnce(); - }); - - it("force-kills Windows child process trees when graceful taskkill fails", async () => { - const child = new EventEmitter() as EventEmitter & { - exitCode: number | null; - kill: ReturnType; - pid: number; - signalCode: NodeJS.Signals | null; - stderr: { destroy: ReturnType }; - stdin: { destroy: ReturnType }; - stdout: { destroy: ReturnType }; - unref: ReturnType; - }; - child.exitCode = null; - child.kill = vi.fn(() => true); - child.pid = 4450; - child.signalCode = null; - child.stderr = { destroy: vi.fn() }; - child.stdin = { destroy: vi.fn() }; - child.stdout = { destroy: vi.fn() }; - child.unref = vi.fn(); - const runTaskkill = vi - .fn() - .mockReturnValueOnce({ error: undefined, status: 1 }) - .mockReturnValueOnce({ error: undefined, status: 0 }) - .mockReturnValueOnce({ error: undefined, status: 0 }); - - await expect( - params.stopChild(child as unknown as TChild, { - killGraceMs: 1, - platform: "win32", - runTaskkill, - teardownGraceMs: 1, - }), - ).resolves.toEqual({ - exitedBeforeTeardown: false, - exitCode: null, - signal: "SIGKILL", - }); - expect(runTaskkill).toHaveBeenNthCalledWith(1, expectedTaskkillPath(), ["/PID", "4450", "/T"], { - stdio: "ignore", - }); - expect(runTaskkill).toHaveBeenNthCalledWith( - 2, - expectedTaskkillPath(), - ["/PID", "4450", "/T", "/F"], - { - stdio: "ignore", - }, - ); - expect(runTaskkill).toHaveBeenNthCalledWith( - 3, - expectedTaskkillPath(), - ["/PID", "4450", "/T", "/F"], - { - stdio: "ignore", - }, - ); - expect(child.kill).not.toHaveBeenCalled(); - }); - it.skipIf(process.platform === "win32")( "preserves pre-teardown wrapper exits while cleaning the process group", async () => { @@ -348,4 +231,3 @@ export function registerStopChildBehaviorTests(params: { }, ); } -import type { spawnSync } from "node:child_process"; diff --git a/test/scripts/dev-tooling-safety.test.ts b/test/scripts/dev-tooling-safety.test.ts index f3b2d62d8cc9..4c20448551f6 100644 --- a/test/scripts/dev-tooling-safety.test.ts +++ b/test/scripts/dev-tooling-safety.test.ts @@ -22,14 +22,9 @@ import { redactHomePath, redactJsonValueForDevToolLog, } from "../../scripts/lib/dev-tooling-safety.ts"; -import { resolveWindowsTaskkillPath } from "../../scripts/lib/windows-taskkill.mjs"; const tempDirs: string[] = []; -function expectedTaskkillPath(): string { - return resolveWindowsTaskkillPath(); -} - async function waitForCondition(predicate: () => boolean, timeoutMs = 5_000): Promise { const started = Date.now(); while (Date.now() - started < timeoutMs) { @@ -501,95 +496,6 @@ describe("script-specific dev tooling hardening", () => { expect(retained.toString("utf8")).toBe("89abcdef"); }); - it.runIf(process.platform !== "win32")( - "signals the TUI PTY watch process group before falling back to the child", - () => { - const kill = vi.spyOn(process, "kill").mockReturnValue(true); - const childKill = vi.fn(() => true); - - try { - tuiPtyWatchTesting.signalChildProcessTree({ pid: 123, kill: childKill }, "SIGTERM"); - expect(kill).toHaveBeenCalledWith(-123, "SIGTERM"); - expect(childKill).not.toHaveBeenCalled(); - } finally { - kill.mockRestore(); - } - }, - ); - - it.runIf(process.platform !== "win32")( - "falls back to direct TUI PTY watch child signaling when the process group is gone", - () => { - const kill = vi.spyOn(process, "kill").mockImplementation(() => { - const error = new Error("missing process group") as NodeJS.ErrnoException; - error.code = "ESRCH"; - throw error; - }); - const childKill = vi.fn(() => true); - - try { - tuiPtyWatchTesting.signalChildProcessTree({ pid: 123, kill: childKill }, "SIGTERM"); - expect(kill).toHaveBeenCalledWith(-123, "SIGTERM"); - expect(childKill).toHaveBeenCalledWith("SIGTERM"); - } finally { - kill.mockRestore(); - } - }, - ); - - it("signals Windows TUI PTY watch process trees with taskkill", () => { - const childKill = vi.fn(() => true); - const runTaskkill = vi.fn(() => ({ error: undefined, status: 0 })); - - tuiPtyWatchTesting.signalChildProcessTree({ pid: 123, kill: childKill }, "SIGTERM", { - platform: "win32", - runTaskkill, - }); - expect(runTaskkill).toHaveBeenNthCalledWith(1, expectedTaskkillPath(), ["/PID", "123", "/T"], { - stdio: "ignore", - }); - - tuiPtyWatchTesting.signalChildProcessTree({ pid: 123, kill: childKill }, "SIGKILL", { - platform: "win32", - runTaskkill, - }); - expect(runTaskkill).toHaveBeenNthCalledWith( - 2, - expectedTaskkillPath(), - ["/PID", "123", "/T", "/F"], - { - stdio: "ignore", - }, - ); - expect(childKill).not.toHaveBeenCalled(); - }); - - it("force-kills Windows TUI PTY watch process trees when graceful taskkill fails", () => { - const childKill = vi.fn(() => true); - const runTaskkill = vi - .fn() - .mockReturnValueOnce({ error: undefined, status: 1 }) - .mockReturnValueOnce({ error: undefined, status: 0 }); - - tuiPtyWatchTesting.signalChildProcessTree({ pid: 123, kill: childKill }, "SIGTERM", { - platform: "win32", - runTaskkill, - }); - - expect(runTaskkill).toHaveBeenNthCalledWith(1, expectedTaskkillPath(), ["/PID", "123", "/T"], { - stdio: "ignore", - }); - expect(runTaskkill).toHaveBeenNthCalledWith( - 2, - expectedTaskkillPath(), - ["/PID", "123", "/T", "/F"], - { - stdio: "ignore", - }, - ); - expect(childKill).not.toHaveBeenCalled(); - }); - it("aborts stalled OpenAI realtime smoke fetches at the request timeout", async () => { let signal: AbortSignal | undefined; const request = realtimeSmokeTesting.createOpenAIClientSecret("test-key", { diff --git a/test/scripts/docker-all-scheduler.test.ts b/test/scripts/docker-all-scheduler.test.ts index 00744f8f4940..1b71df46c6c7 100644 --- a/test/scripts/docker-all-scheduler.test.ts +++ b/test/scripts/docker-all-scheduler.test.ts @@ -627,9 +627,11 @@ describe("scripts/test-docker-all scheduler", () => { for (const fileName of [ "docker-e2e-plan.mts", "docker-e2e-scenarios.mts", + "managed-child-process.mts", "official-external-channel-catalog.json", "release-version.mjs", "sleep.mjs", + "windows-taskkill.mjs", ]) { copyFileSync(path.join("scripts/lib", fileName), path.join(libDir, fileName)); } diff --git a/test/scripts/managed-child-process.test.ts b/test/scripts/managed-child-process.test.ts index 9fbd2d01c71a..0d4490bb8989 100644 --- a/test/scripts/managed-child-process.test.ts +++ b/test/scripts/managed-child-process.test.ts @@ -7,9 +7,11 @@ import { pathToFileURL } from "node:url"; import { describe, expect, it, vi } from "vitest"; import { createManagedCommandSpawnSpec, + inspectManagedProcessGroup, runManagedCommand, signalExitCode, terminateManagedChild, + waitForManagedProcessGroupExit, } from "../../scripts/lib/managed-child-process.mts"; import { createScriptTestHarness } from "./test-helpers.js"; @@ -199,6 +201,145 @@ describe("managed-child-process", () => { }); }); + it("preserves stdio-only taskkill and falls back after both trusted attempts fail", () => { + withDefaultWindowsSystemRoot(() => { + const child = { kill: vi.fn(() => true), pid: 12345 }; + const runTaskkill = vi.fn(() => ({ error: undefined, status: 1 })); + + expect( + terminateManagedChild(child, "SIGTERM", { + platform: "win32", + runTaskkill, + taskkillTimeoutMs: null, + }), + ).toEqual({ processTreeState: "indeterminate" }); + expect(runTaskkill).toHaveBeenNthCalledWith(1, taskkillPath, ["/PID", "12345", "/T"], { + stdio: "ignore", + }); + expect(runTaskkill).toHaveBeenNthCalledWith(2, taskkillPath, ["/PID", "12345", "/T", "/F"], { + stdio: "ignore", + }); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + }); + }); + + it("preserves direct Windows signaling when a caller does not own taskkill", () => { + const child = { kill: vi.fn(() => true), pid: 12345 }; + const runTaskkill = vi.fn(); + + expect( + terminateManagedChild(child, "SIGTERM", { + platform: "win32", + runTaskkill, + useWindowsTaskkill: false, + }), + ).toEqual({ processTreeState: "signaled" }); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + expect(runTaskkill).not.toHaveBeenCalled(); + }); + + it("signals POSIX process groups without signaling their leaders twice", () => { + const kill = vi.spyOn(process, "kill").mockReturnValue(true); + const child = { kill: vi.fn(), pid: 12345 }; + + try { + expect(terminateManagedChild(child, "SIGTERM", { platform: "linux" })).toEqual({ + processTreeState: "signaled", + }); + expect(kill).toHaveBeenCalledWith(-12345, "SIGTERM"); + expect(child.kill).not.toHaveBeenCalled(); + } finally { + kill.mockRestore(); + } + }); + + it.each([ + { code: "ESRCH", processGroupFallback: "nonmissing" as const }, + { code: "EPERM", processGroupFallback: "never" as const }, + ])("preserves caller-owned direct fallback for $code", ({ code, processGroupFallback }) => { + const error = Object.assign(new Error("process group unavailable"), { code }); + const kill = vi.spyOn(process, "kill").mockImplementation(() => { + throw error; + }); + const child = { kill: vi.fn(), pid: 12345 }; + + try { + terminateManagedChild(child, "SIGTERM", { platform: "linux", processGroupFallback }); + expect(child.kill).not.toHaveBeenCalled(); + } finally { + kill.mockRestore(); + } + }); + + it("preserves distinct group permission policies and verifies the leader when requested", () => { + const permissionError = Object.assign(new Error("group signal denied"), { code: "EPERM" }); + const child = { exitCode: null, pid: 12345, signalCode: null }; + const kill = vi.spyOn(process, "kill").mockImplementation((pid) => { + if (pid === -12345) { + throw permissionError; + } + return true; + }); + + try { + expect( + inspectManagedProcessGroup(child, { errorPolicy: "alive-on-eperm", platform: "linux" }), + ).toBe("live"); + expect( + inspectManagedProcessGroup(child, { errorPolicy: "indeterminate", platform: "linux" }), + ).toBe("indeterminate"); + expect( + inspectManagedProcessGroup(child, { errorPolicy: "verify-leader", platform: "linux" }), + ).toBe("live"); + expect(kill).toHaveBeenCalledWith(12345, 0); + expect( + inspectManagedProcessGroup( + { ...child, exitCode: 0 }, + { errorPolicy: "verify-leader", platform: "linux" }, + ), + ).toBe("dead"); + } finally { + kill.mockRestore(); + } + }); + + it("inspects direct child liveness only when nongroup cleanup explicitly requires it", () => { + const child = { exitCode: null, pid: 12345, signalCode: null }; + + expect( + inspectManagedProcessGroup(child, { errorPolicy: "alive-on-eperm", platform: "win32" }), + ).toBe("dead"); + expect( + inspectManagedProcessGroup(child, { + errorPolicy: "alive-on-eperm", + inspectLeaderWhenNoGroup: true, + platform: "win32", + }), + ).toBe("live"); + expect( + inspectManagedProcessGroup( + { ...child, exitCode: 0 }, + { errorPolicy: "alive-on-eperm", inspectLeaderWhenNoGroup: true, platform: "win32" }, + ), + ).toBe("dead"); + }); + + it("bounds process-group waiting when the group remains live", async () => { + const kill = vi.spyOn(process, "kill").mockReturnValue(true); + + try { + await expect( + waitForManagedProcessGroupExit({ pid: 12345 }, 5, { + errorPolicy: "alive-on-eperm", + platform: "linux", + pollIntervalMs: 1, + }), + ).resolves.toBe(false); + } finally { + kill.mockRestore(); + } + }); + it("signals the direct child when process-group ownership is disabled", () => { const child = { kill: vi.fn(() => true), pid: 12345 }; diff --git a/test/scripts/test-group-report.test.ts b/test/scripts/test-group-report.test.ts index b58a0954b200..b4d873a4bbdd 100644 --- a/test/scripts/test-group-report.test.ts +++ b/test/scripts/test-group-report.test.ts @@ -13,7 +13,6 @@ import { resolveGroupKey, resolveTestArea, } from "../../scripts/lib/test-group-report.mts"; -import { resolveWindowsTaskkillPath } from "../../scripts/lib/windows-taskkill.mjs"; import { parseTestGroupReportArgs, resolveFullSuiteVitestEnv, @@ -23,7 +22,6 @@ import { resolveRunPlanConcurrency, resolveRunPlans, runReportPlans, - signalTestGroupReportChild, spawnText, } from "../../scripts/test-group-report.mts"; import { withEnv } from "../../src/test-utils/env.js"; @@ -73,10 +71,6 @@ async function waitForDead(pid: number, timeoutMs: number): Promise { throw new Error(`timed out waiting for pid ${pid} to exit`); } -function expectedTaskkillPath(): string { - return resolveWindowsTaskkillPath(); -} - function waitForChildClose( child: ReturnType, timeoutMs = 5_000, @@ -936,75 +930,6 @@ describe("scripts/test-group-report arg parsing", () => { }); describe("scripts/test-group-report child process guard", () => { - it("signals Windows child process trees with taskkill", () => { - const child = { - kill: vi.fn(), - pid: 12345, - }; - const runTaskkill = vi.fn(() => ({ error: undefined, status: 0 })); - - signalTestGroupReportChild(child, "SIGTERM", { - platform: "win32", - runTaskkill, - }); - expect(runTaskkill).toHaveBeenNthCalledWith( - 1, - expectedTaskkillPath(), - ["/PID", "12345", "/T"], - { - stdio: "ignore", - }, - ); - - signalTestGroupReportChild(child, "SIGKILL", { - platform: "win32", - runTaskkill, - }); - expect(runTaskkill).toHaveBeenNthCalledWith( - 2, - expectedTaskkillPath(), - ["/PID", "12345", "/T", "/F"], - { - stdio: "ignore", - }, - ); - expect(child.kill).not.toHaveBeenCalled(); - }); - - it("force-kills Windows child process trees when graceful taskkill fails", () => { - const child = { - kill: vi.fn(), - pid: 12345, - }; - const runTaskkill = vi - .fn() - .mockReturnValueOnce({ error: undefined, status: 1 }) - .mockReturnValueOnce({ error: undefined, status: 0 }); - - signalTestGroupReportChild(child, "SIGTERM", { - platform: "win32", - runTaskkill, - }); - - expect(runTaskkill).toHaveBeenNthCalledWith( - 1, - expectedTaskkillPath(), - ["/PID", "12345", "/T"], - { - stdio: "ignore", - }, - ); - expect(runTaskkill).toHaveBeenNthCalledWith( - 2, - expectedTaskkillPath(), - ["/PID", "12345", "/T", "/F"], - { - stdio: "ignore", - }, - ); - expect(child.kill).not.toHaveBeenCalled(); - }); - it.concurrent("times out a child that ignores SIGTERM", async () => { if (process.platform === "win32") { return;