118 lines
4.4 KiB
JavaScript
118 lines
4.4 KiB
JavaScript
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 });
|
|
}
|