import { spawn } from "node:child_process"; export const SERVER_JOB_PROCESS_SCHEMA = 1; function invalid(message) { throw new Error(`SERVER_JOB_PROCESS_INVALID: ${message}`); } function signalTree(pid, signal) { if (!Number.isInteger(pid) || pid <= 0) return false; if (process.platform === "win32") return false; try { process.kill(-pid, signal); return true; } catch (error) { if (error?.code === "ESRCH") return false; throw error; } } function processTreeAlive(pid) { if (!Number.isInteger(pid) || pid <= 0) return false; try { process.kill(process.platform === "win32" ? pid : -pid, 0); return true; } catch (error) { if (error?.code === "ESRCH") return false; throw error; } } async function waitForTreeExit(pid, timeoutMs) { const deadline = Date.now() + timeoutMs; while (processTreeAlive(pid) && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 10)); } return !processTreeAlive(pid); } function waitForClose(child) { if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve({ code: child.exitCode, signal: child.signalCode }); return new Promise((resolve) => child.once("close", (code, signal) => resolve({ code, signal }))); } export function startServerJobProcess(command, args = [], options = {}) { if (typeof command !== "string" || command.length === 0) invalid("command is required"); if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string")) invalid("args must be strings"); const child = spawn(command, args, { cwd: options.cwd, env: options.env, detached: process.platform !== "win32", stdio: options.stdio ?? ["ignore", "pipe", "pipe"], windowsHide: true, }); // Drain output even when the caller is testing cancellation before M13-04F receipt handling. child.stdout?.resume(); child.stderr?.resume(); const receipt = { schemaVersion: SERVER_JOB_PROCESS_SCHEMA, pid: child.pid, detached: process.platform !== "win32", state: "RUNNING", cancelRequested: false, treeSignal: null, result: null, cleanupCount: 0, orphanCount: 0, }; const completion = waitForClose(child).then((result) => { receipt.state = receipt.cancelRequested ? "CANCELLED" : "EXITED"; receipt.result = result; return Object.freeze({ ...receipt }); }); return Object.freeze({ child, receipt, completion }); } export async function cancelServerJobProcess(handle, cleanup, options = {}) { if (!handle || !handle.child || !handle.receipt || handle.receipt.schemaVersion !== SERVER_JOB_PROCESS_SCHEMA) invalid("process handle is invalid"); if (typeof cleanup !== "function") invalid("cleanup callback is required"); const graceMs = Number.isSafeInteger(options.graceMs) && options.graceMs >= 0 ? options.graceMs : 500; if (handle.receipt.state === "CANCELLED" || handle.receipt.state === "EXITED") { if (handle.receipt.cleanupCount === 0) { await cleanup(); handle.receipt.cleanupCount = 1; } return Object.freeze({ ...handle.receipt }); } handle.receipt.cancelRequested = true; if (process.platform === "win32") { const treeSignal = spawn("taskkill", ["/PID", String(handle.receipt.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }); handle.receipt.treeSignal = "TASKKILL_TREE"; await waitForClose(treeSignal); } else { handle.receipt.treeSignal = signalTree(handle.receipt.pid, "SIGTERM") ? "SIGTERM_GROUP" : "ALREADY_EXITED"; } const settled = await Promise.race([ handle.completion, new Promise((resolve) => setTimeout(() => resolve(null), graceMs)), ]); if (!settled && process.platform !== "win32") { signalTree(handle.receipt.pid, "SIGKILL"); handle.receipt.treeSignal = "SIGKILL_GROUP"; } const result = settled ?? await handle.completion; let treeExited = process.platform === "win32" || await waitForTreeExit(handle.receipt.pid, graceMs); if (!treeExited && process.platform !== "win32") { signalTree(handle.receipt.pid, "SIGKILL"); handle.receipt.treeSignal = "SIGKILL_GROUP"; treeExited = await waitForTreeExit(handle.receipt.pid, graceMs); } if (handle.receipt.cleanupCount === 0) { await cleanup(); handle.receipt.cleanupCount = 1; } handle.receipt.orphanCount = treeExited ? 0 : 1; return Object.freeze({ ...result, treeSignal: handle.receipt.treeSignal, cleanupCount: handle.receipt.cleanupCount, orphanCount: handle.receipt.orphanCount }); }