Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/lib/run-command-with-timeout.mjs
T

254 lines
6.0 KiB
JavaScript
Executable File

#!/usr/bin/env node
import os from "node:os";
import process from "node:process";
import { spawn } from "node:child_process";
const TIMEOUT_EXIT_CODE = 124;
const SPAWN_ERROR_EXIT_CODE = 127;
const INVALID_ARGUMENT_EXIT_CODE = 2;
const DEFAULT_GRACE_SECONDS = 2;
const MAX_TIMEOUT_SECONDS = 86_400;
const MAX_GRACE_SECONDS = 60;
const STAGE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/u;
function failArguments() {
process.stderr.write(`[docker-smoke] FAIL stage=timeout-runner reason=invalid_arguments exit_code=${INVALID_ARGUMENT_EXIT_CODE}\n`);
process.exit(INVALID_ARGUMENT_EXIT_CODE);
}
function parseSeconds(value, { allowZero, maximum }) {
if (typeof value !== "string" || !/^(?:0|[1-9]\d*)(?:\.\d+)?$/u.test(value)) {
failArguments();
}
const parsed = Number(value);
const validLowerBound = allowZero ? parsed >= 0 : parsed > 0;
if (!Number.isFinite(parsed) || !validLowerBound || parsed > maximum) {
failArguments();
}
return parsed;
}
function parseArguments(argv) {
let stage;
let timeoutSeconds;
let graceSeconds = DEFAULT_GRACE_SECONDS;
let sawGraceSeconds = false;
let command;
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--") {
command = argv.slice(index + 1);
break;
}
if (argument === "--stage" && stage === undefined) {
stage = argv[index + 1];
index += 1;
continue;
}
if (argument === "--timeout-seconds" && timeoutSeconds === undefined) {
timeoutSeconds = argv[index + 1];
index += 1;
continue;
}
if (argument === "--grace-seconds" && !sawGraceSeconds) {
graceSeconds = argv[index + 1];
sawGraceSeconds = true;
index += 1;
continue;
}
failArguments();
}
if (!STAGE_PATTERN.test(stage ?? "") || timeoutSeconds === undefined || !command?.length) {
failArguments();
}
return {
stage,
timeoutSeconds: parseSeconds(timeoutSeconds, { allowZero: false, maximum: MAX_TIMEOUT_SECONDS }),
graceSeconds: parseSeconds(String(graceSeconds), { allowZero: true, maximum: MAX_GRACE_SECONDS }),
command,
};
}
function signalExitCode(signal) {
const signalNumber = os.constants.signals[signal];
return typeof signalNumber === "number" ? 128 + signalNumber : 1;
}
const { stage, timeoutSeconds, graceSeconds, command } = parseArguments(process.argv.slice(2));
const startedAt = performance.now();
const timeoutMilliseconds = timeoutSeconds * 1_000;
const graceMilliseconds = graceSeconds * 1_000;
process.stderr.write(`[docker-smoke] START stage=${stage} timeout_seconds=${timeoutSeconds} grace_seconds=${graceSeconds}\n`);
const child = spawn(command[0], command.slice(1), {
detached: true,
env: process.env,
shell: false,
stdio: "inherit",
});
let childClosed = false;
let finished = false;
let termination;
let timeoutTimer;
let forceKillTimer;
function durationMilliseconds() {
return Math.max(0, Math.round(performance.now() - startedAt));
}
function processGroupExists() {
if (!child.pid) {
return false;
}
try {
process.kill(-child.pid, 0);
return true;
} catch (error) {
return error?.code === "EPERM";
}
}
function signalProcessGroup(signal) {
if (!child.pid) {
return;
}
try {
process.kill(-child.pid, signal);
} catch (error) {
if (error?.code !== "ESRCH") {
try {
child.kill(signal);
} catch {
// The child may have exited between the process-group and direct kill.
}
}
}
}
function clearTimers() {
if (timeoutTimer) {
clearTimeout(timeoutTimer);
}
if (forceKillTimer) {
clearTimeout(forceKillTimer);
}
}
function finish(exitCode, outcome, extraMetadata = "") {
if (finished) {
return;
}
finished = true;
clearTimers();
process.removeListener("SIGINT", handleInterrupt);
process.removeListener("SIGTERM", handleTerminate);
const suffix = extraMetadata ? ` ${extraMetadata}` : "";
process.stderr.write(`[docker-smoke] ${outcome} stage=${stage} duration_ms=${durationMilliseconds()}${suffix} exit_code=${exitCode}\n`);
process.exitCode = exitCode;
}
function finishTerminationIfReady() {
if (!termination || (!childClosed && !termination.forceKillSent)) {
return;
}
if (processGroupExists() && !termination.forceKillSent) {
return;
}
if (termination.kind === "timeout") {
finish(TIMEOUT_EXIT_CODE, "TIMEOUT");
return;
}
const exitCode = signalExitCode(termination.signal);
finish(exitCode, "FAIL", `signal=${termination.signal}`);
}
function forceKillTermination() {
if (!termination || finished) {
return;
}
termination.forceKillSent = true;
signalProcessGroup("SIGKILL");
child.unref();
finishTerminationIfReady();
}
function beginTermination(nextTermination) {
if (finished) {
return;
}
if (termination) {
forceKillTermination();
return;
}
termination = { ...nextTermination, forceKillSent: false };
if (timeoutTimer) {
clearTimeout(timeoutTimer);
}
signalProcessGroup("SIGTERM");
forceKillTimer = setTimeout(forceKillTermination, graceMilliseconds);
finishTerminationIfReady();
}
function handleInterrupt() {
beginTermination({ kind: "signal", signal: "SIGINT" });
}
function handleTerminate() {
beginTermination({ kind: "signal", signal: "SIGTERM" });
}
process.on("SIGINT", handleInterrupt);
process.on("SIGTERM", handleTerminate);
child.once("error", () => {
childClosed = true;
if (termination) {
finishTerminationIfReady();
return;
}
finish(SPAWN_ERROR_EXIT_CODE, "FAIL", "reason=spawn_error");
});
child.once("close", (code, signal) => {
childClosed = true;
if (termination) {
finishTerminationIfReady();
return;
}
if (signal) {
const exitCode = signalExitCode(signal);
finish(exitCode, "FAIL", `signal=${signal}`);
return;
}
const exitCode = typeof code === "number" ? code : 1;
finish(exitCode, exitCode === 0 ? "PASS" : "FAIL");
});
timeoutTimer = setTimeout(() => {
beginTermination({ kind: "timeout" });
}, timeoutMilliseconds);