Checkpoint web parity through Chromium input tasks
This commit is contained in:
117
tools/web/server-job-process.mjs
Normal file
117
tools/web/server-job-process.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user