mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
120 lines
3.7 KiB
JavaScript
120 lines
3.7 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { mkdir } from "node:fs/promises";
|
|
import { join, resolve } from "node:path";
|
|
import { DatabaseSync } from "node:sqlite";
|
|
|
|
const [, , lockName, ...command] = process.argv;
|
|
const lockRoot = resolve(process.cwd(), ".cache", "rea-command-locks");
|
|
const signalExitCodes = { SIGINT: 130, SIGTERM: 143 };
|
|
|
|
let database;
|
|
let child;
|
|
let forceTimer;
|
|
let shutdownSignal;
|
|
|
|
process.on("SIGINT", () => handleSignal("SIGINT"));
|
|
process.on("SIGTERM", () => handleSignal("SIGTERM"));
|
|
process.on("disconnect", handleDisconnect);
|
|
process.on("message", (message) => {
|
|
if (message === "SIGINT" || message === "SIGTERM") handleSignal(message);
|
|
});
|
|
|
|
if (!process.connected) {
|
|
// The public wrapper can disappear before ESM startup installs listeners.
|
|
// A disconnected supervisor has no authority to start a new command.
|
|
process.exitCode = signalExitCodes.SIGTERM;
|
|
} else if (
|
|
typeof lockName !== "string" ||
|
|
!/^[a-z][a-z0-9-]*$/u.test(lockName) ||
|
|
command.length === 0
|
|
) {
|
|
console.error("Usage: run-exclusive.mjs <lock-name> <command> [args...]");
|
|
process.exitCode = 64;
|
|
disconnectParent();
|
|
} else {
|
|
await run();
|
|
}
|
|
|
|
async function run() {
|
|
try {
|
|
await mkdir(lockRoot, { recursive: true });
|
|
// SQLite owns acquisition and OS-backed lock release, including process
|
|
// death. Keep the database pathname stable; deleting it would split owners.
|
|
database = new DatabaseSync(join(lockRoot, `${lockName}.sqlite`));
|
|
try {
|
|
database.exec("BEGIN EXCLUSIVE");
|
|
} catch (cause) {
|
|
if (isBusyError(cause))
|
|
throw new Error(
|
|
`Another ${lockName} command is already running. Wait for it to finish.`,
|
|
{ cause },
|
|
);
|
|
throw cause;
|
|
}
|
|
if (shutdownSignal === undefined) {
|
|
const result = await runCommand();
|
|
process.exitCode = result.code ?? 1;
|
|
}
|
|
} catch (cause) {
|
|
console.error(cause instanceof Error ? cause.message : String(cause));
|
|
process.exitCode = 1;
|
|
} finally {
|
|
database?.close();
|
|
clearTimeout(forceTimer);
|
|
if (shutdownSignal !== undefined)
|
|
process.exitCode = signalExitCodes[shutdownSignal];
|
|
disconnectParent();
|
|
}
|
|
}
|
|
|
|
function runCommand() {
|
|
return new Promise((resolveResult, reject) => {
|
|
child = spawn(command[0], command.slice(1), {
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
shell: process.platform === "win32",
|
|
stdio: "inherit",
|
|
});
|
|
child.once("error", reject);
|
|
child.once("close", (code) => {
|
|
// During POSIX cancellation the group leader must stay alive, retaining
|
|
// both its group identity and SQLite ownership until the entire group is
|
|
// killed. A completed command can still have uncooperative descendants.
|
|
if (shutdownSignal === undefined || process.platform === "win32")
|
|
resolveResult({ code });
|
|
});
|
|
});
|
|
}
|
|
|
|
function disconnectParent() {
|
|
process.off("disconnect", handleDisconnect);
|
|
if (process.connected) process.disconnect();
|
|
}
|
|
|
|
function handleDisconnect() {
|
|
handleSignal("SIGTERM");
|
|
}
|
|
|
|
function handleSignal(signal) {
|
|
if (shutdownSignal !== undefined) return;
|
|
shutdownSignal = signal;
|
|
if (child === undefined || process.platform === "win32") return;
|
|
// Establish cancellation before signalling the group. This supervisor is
|
|
// its live leader, so the group identifier cannot be reused during cleanup.
|
|
process.kill(-process.pid, signal);
|
|
forceTimer = setTimeout(() => process.kill(-process.pid, "SIGKILL"), 1_000);
|
|
}
|
|
|
|
function isBusyError(cause) {
|
|
return (
|
|
typeof cause === "object" &&
|
|
cause !== null &&
|
|
"code" in cause &&
|
|
cause.code === "ERR_SQLITE_ERROR" &&
|
|
"errcode" in cause &&
|
|
(cause.errcode === 5 || cause.errcode === 6)
|
|
);
|
|
}
|