mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
264 lines
7.0 KiB
TypeScript
264 lines
7.0 KiB
TypeScript
import { fork, spawn, type ChildProcess } from "child_process";
|
|
import { appendFileSync } from "node:fs";
|
|
|
|
type WorkerLifecycleMessage =
|
|
| {
|
|
type: "paseo:shutdown";
|
|
}
|
|
| {
|
|
type: "paseo:restart";
|
|
reason?: string;
|
|
};
|
|
|
|
type SupervisorOptions = {
|
|
name: string;
|
|
startupMessage: string;
|
|
launchLogPath?: string;
|
|
resolveWorkerEntry: () => string;
|
|
workerArgs?: string[];
|
|
workerEnv?: NodeJS.ProcessEnv;
|
|
workerExecArgv?: string[];
|
|
resolveWorkerSpawnSpec?: (workerEntry: string) => {
|
|
command: string;
|
|
args: string[];
|
|
env?: NodeJS.ProcessEnv;
|
|
} | null;
|
|
restartOnCrash?: boolean;
|
|
onSupervisorExit?: () => Promise<void> | void;
|
|
};
|
|
|
|
function describeExit(code: number | null, signal: NodeJS.Signals | null): string {
|
|
return signal ?? (typeof code === "number" ? `code ${code}` : "unknown");
|
|
}
|
|
|
|
function parseLifecycleMessage(msg: unknown): WorkerLifecycleMessage | null {
|
|
if (typeof msg !== "object" || msg === null || !("type" in msg)) {
|
|
return null;
|
|
}
|
|
const type = (msg as { type?: unknown }).type;
|
|
if (type === "paseo:shutdown") {
|
|
return { type: "paseo:shutdown" };
|
|
}
|
|
if (type === "paseo:restart") {
|
|
const reason = (msg as { reason?: unknown }).reason;
|
|
return {
|
|
type: "paseo:restart",
|
|
...(typeof reason === "string" && reason.trim().length > 0 ? { reason } : {}),
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function runSupervisor(options: SupervisorOptions): void {
|
|
const restartOnCrash = options.restartOnCrash ?? false;
|
|
const workerArgs = options.workerArgs ?? process.argv.slice(2);
|
|
const workerEnv = options.workerEnv ?? process.env;
|
|
const workerExecArgv = options.workerExecArgv ?? ["--import", "tsx"];
|
|
const resolveWorkerSpawnSpec = options.resolveWorkerSpawnSpec;
|
|
|
|
let child: ChildProcess | null = null;
|
|
let restarting = false;
|
|
let shuttingDown = false;
|
|
let exiting = false;
|
|
|
|
const log = (message: string): void => {
|
|
process.stderr.write(`[${options.name}] ${message}\n`);
|
|
};
|
|
|
|
const logLaunch = (message: string, details?: Record<string, unknown>): void => {
|
|
if (!options.launchLogPath) {
|
|
return;
|
|
}
|
|
|
|
const line = [
|
|
new Date().toISOString(),
|
|
`[${options.name}]`,
|
|
`pid=${process.pid}`,
|
|
message,
|
|
details ? JSON.stringify(details) : "",
|
|
]
|
|
.filter((part) => part.length > 0)
|
|
.join(" ");
|
|
|
|
try {
|
|
appendFileSync(options.launchLogPath, `${line}\n`, "utf-8");
|
|
} catch {
|
|
// Keep launch debugging best-effort so daemon startup behavior is unchanged.
|
|
}
|
|
};
|
|
|
|
const exitSupervisor = (code: number): void => {
|
|
if (exiting) {
|
|
return;
|
|
}
|
|
exiting = true;
|
|
Promise.resolve(options.onSupervisorExit?.())
|
|
.catch((error) => {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
log(`Supervisor exit cleanup failed: ${message}`);
|
|
})
|
|
.finally(() => {
|
|
process.exit(code);
|
|
});
|
|
};
|
|
|
|
const spawnWorker = () => {
|
|
let workerEntry: string;
|
|
try {
|
|
// Resolve at spawn time so restarts pick up current filesystem state.
|
|
workerEntry = options.resolveWorkerEntry();
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
log(`Failed to resolve worker entry: ${message}`);
|
|
exitSupervisor(1);
|
|
return;
|
|
}
|
|
|
|
const spawnSpec = resolveWorkerSpawnSpec?.(workerEntry) ?? null;
|
|
if (spawnSpec) {
|
|
logLaunch("spawning worker via explicit spawn spec", {
|
|
workerEntry,
|
|
command: spawnSpec.command,
|
|
args: spawnSpec.args,
|
|
workerArgs,
|
|
});
|
|
child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
stdio: ["inherit", "inherit", "inherit", "ipc"],
|
|
env: spawnSpec.env ?? workerEnv,
|
|
});
|
|
} else {
|
|
logLaunch("spawning worker via fork", {
|
|
workerEntry,
|
|
workerArgs,
|
|
workerExecArgv,
|
|
});
|
|
child = fork(workerEntry, workerArgs, {
|
|
stdio: "inherit",
|
|
env: workerEnv,
|
|
execArgv: workerExecArgv,
|
|
});
|
|
}
|
|
|
|
logLaunch("worker spawn returned", {
|
|
childPid: child.pid ?? null,
|
|
connected: child.connected,
|
|
spawnfile: child.spawnfile,
|
|
spawnargs: child.spawnargs,
|
|
});
|
|
|
|
child.on("spawn", () => {
|
|
logLaunch("worker emitted spawn", {
|
|
childPid: child?.pid ?? null,
|
|
});
|
|
});
|
|
|
|
child.on("error", (error) => {
|
|
logLaunch("worker emitted error", {
|
|
childPid: child?.pid ?? null,
|
|
message: error.message,
|
|
});
|
|
});
|
|
|
|
child.on("message", (msg: unknown) => {
|
|
const lifecycleMessage = parseLifecycleMessage(msg);
|
|
logLaunch("worker emitted ipc message", {
|
|
childPid: child?.pid ?? null,
|
|
rawType:
|
|
typeof msg === "object" && msg !== null && "type" in msg
|
|
? (msg as { type?: unknown }).type
|
|
: typeof msg,
|
|
});
|
|
if (!lifecycleMessage) {
|
|
return;
|
|
}
|
|
|
|
if (lifecycleMessage.type === "paseo:shutdown") {
|
|
requestShutdown("Shutdown requested by worker");
|
|
return;
|
|
}
|
|
|
|
requestRestart("Restart requested by worker");
|
|
});
|
|
|
|
child.on("exit", (code, signal) => {
|
|
logLaunch("worker emitted exit", {
|
|
childPid: child?.pid ?? null,
|
|
code,
|
|
signal,
|
|
restarting,
|
|
shuttingDown,
|
|
});
|
|
const exitDescriptor = describeExit(code, signal);
|
|
|
|
if (shuttingDown) {
|
|
log(`Worker exited (${exitDescriptor}). Supervisor shutting down.`);
|
|
exitSupervisor(0);
|
|
return;
|
|
}
|
|
|
|
if (restarting || (restartOnCrash && code !== 0 && code !== null)) {
|
|
restarting = false;
|
|
log(`Worker exited (${exitDescriptor}). Restarting worker...`);
|
|
spawnWorker();
|
|
return;
|
|
}
|
|
|
|
log(`Worker exited (${exitDescriptor}). Supervisor exiting.`);
|
|
exitSupervisor(typeof code === "number" ? code : 0);
|
|
});
|
|
|
|
child.on("close", (code, signal) => {
|
|
logLaunch("worker emitted close", {
|
|
childPid: child?.pid ?? null,
|
|
code,
|
|
signal,
|
|
});
|
|
});
|
|
|
|
child.on("disconnect", () => {
|
|
logLaunch("worker emitted disconnect", {
|
|
childPid: child?.pid ?? null,
|
|
});
|
|
});
|
|
};
|
|
|
|
const requestRestart = (reason: string) => {
|
|
if (!child || restarting || shuttingDown) {
|
|
return;
|
|
}
|
|
restarting = true;
|
|
log(`${reason}. Stopping worker for restart...`);
|
|
child.kill("SIGTERM");
|
|
};
|
|
|
|
const requestShutdown = (reason: string) => {
|
|
if (shuttingDown) {
|
|
return;
|
|
}
|
|
shuttingDown = true;
|
|
restarting = false;
|
|
log(`${reason}. Stopping worker...`);
|
|
if (!child) {
|
|
exitSupervisor(0);
|
|
return;
|
|
}
|
|
child.kill("SIGTERM");
|
|
};
|
|
|
|
const forwardSignal = (signal: NodeJS.Signals) => {
|
|
requestShutdown(`Received ${signal}`);
|
|
};
|
|
|
|
process.on("SIGINT", () => forwardSignal("SIGINT"));
|
|
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
|
|
|
|
logLaunch("supervisor starting", {
|
|
startupMessage: options.startupMessage,
|
|
restartOnCrash,
|
|
workerArgs,
|
|
workerExecArgv,
|
|
});
|
|
process.stdout.write(`[${options.name}] ${options.startupMessage}\n`);
|
|
spawnWorker();
|
|
}
|