Files
scoreko-electron-dev/src/main/nodecg/process-manager.ts
T

390 lines
12 KiB
TypeScript

import { spawn, SpawnOptions } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
import path from "node:path";
import { AppRuntimeConfig } from "../config/runtime-config";
import { NODE_RUNTIME_NAME } from "../constants";
import { killProcessTree } from "./process-killer";
type NodecgProcessManagerConfig = {
isDev: boolean;
nodecgRootPath: string;
nodecgBaseUrl: string;
appConfig: AppRuntimeConfig;
log: (...args: unknown[]) => void;
deps?: Partial<NodecgProcessManagerDeps>;
};
type NodecgProcessManagerDeps = {
spawnProcess: (command: string, args: string[], options: SpawnOptions) => NodecgChildProcess;
pathExists: (candidatePath: string) => boolean;
fetchUrl: typeof fetch;
platform: NodeJS.Platform;
execPath: string;
env: NodeJS.ProcessEnv;
killProcess: (pid: number, signal: NodeJS.Signals) => void;
setTimer: (handler: () => void, timeoutMs: number) => unknown;
stdoutWrite: (chunk: string) => void;
stderrWrite: (chunk: string) => void;
probePortAvailable: (port: number) => Promise<boolean>;
hasReadWriteAccess: (candidatePath: string) => boolean;
};
type NodecgChildProcess = {
pid?: number;
killed: boolean;
exitCode: number | null;
signalCode: NodeJS.Signals | null;
stdout?: ProcessOutputStream | null;
stderr?: ProcessOutputStream | null;
on(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
on(event: "error", listener: (error: Error) => void): unknown;
once(event: "exit", listener: () => void): unknown;
};
type ProcessOutputStream = {
on(event: "data", listener: (chunk: unknown) => void): unknown;
};
export type NodecgProcessManager = {
startNodecgProcess: () => Promise<void>;
waitForNodecgReady: (startTime: number) => Promise<void>;
stopNodecgProcessGracefully: () => Promise<void>;
getState: () => NodecgProcessState;
};
type NodecgProcessState = "idle" | "starting" | "running" | "stopping" | "stopped" | "failed";
export function createNodecgProcessManager({
isDev,
nodecgRootPath,
nodecgBaseUrl,
appConfig,
log,
deps,
}: NodecgProcessManagerConfig): NodecgProcessManager {
const resolvedDeps = resolveDeps(deps);
let nodecgProcess: NodecgChildProcess | null = null;
let nodecgState: NodecgProcessState = "idle";
let startNodecgPromise: Promise<void> | null = null;
let stopNodecgPromise: Promise<void> | null = null;
let lastExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
let lastStderrLine: string | null = null;
const startNodecgProcess = (): Promise<void> => {
if (nodecgProcess && nodecgState === "running") {
return Promise.resolve();
}
if (startNodecgPromise) {
return startNodecgPromise;
}
if (nodecgState === "stopping") {
return Promise.reject(new Error("Cannot start NodeCG while shutdown is in progress."));
}
nodecgState = "starting";
startNodecgPromise = (async () => {
// Fail fast with actionable errors before spawning child processes.
validateNodecgInstall(
nodecgRootPath,
appConfig.bundleName,
resolvedDeps.pathExists,
resolvedDeps.hasReadWriteAccess,
);
const portAsNumber = Number.parseInt(appConfig.nodecgPort, 10);
const isPortAvailable = await resolvedDeps.probePortAvailable(portAsNumber);
if (!isPortAvailable) {
throw new Error(
`Port ${appConfig.nodecgPort} is already in use. Stop the process using it or set NODECG_PORT before starting.`,
);
}
const indexPath = path.join(nodecgRootPath, "index.js");
const child = resolvedDeps.spawnProcess(resolvedDeps.execPath, [indexPath], {
cwd: nodecgRootPath,
env: {
...resolvedDeps.env,
NODE_ENV: isDev ? "development" : "production",
NODECG_PORT: appConfig.nodecgPort,
ELECTRON_RUN_AS_NODE: "1",
},
stdio: ["ignore", "pipe", "pipe"],
detached: resolvedDeps.platform !== "win32",
shell: false,
windowsHide: true,
});
child.stdout?.on("data", (chunk) => {
resolvedDeps.stdoutWrite(String(chunk));
});
child.stderr?.on("data", (chunk) => {
const line = String(chunk);
lastStderrLine = line.trim().length > 0 ? line.trim() : lastStderrLine;
resolvedDeps.stderrWrite(line);
});
log(`NodeCG started with pid=${child.pid} using ${NODE_RUNTIME_NAME}`);
child.on("exit", (code, signal) => {
log(`NodeCG exited code=${code} signal=${signal ?? "none"}`);
lastExit = { code, signal };
if (nodecgProcess === child) {
nodecgProcess = null;
}
if (nodecgState !== "stopping") {
nodecgState = code === 0 ? "stopped" : "failed";
}
});
lastExit = null;
lastStderrLine = null;
nodecgProcess = child;
nodecgState = "running";
})()
.catch((error: unknown) => {
nodecgState = "failed";
throw error;
})
.finally(() => {
startNodecgPromise = null;
});
return startNodecgPromise;
};
const waitForNodecgReady = async (startTime: number): Promise<void> => {
// Poll the local NodeCG URL until it answers or we hit the configured timeout.
while (Date.now() - startTime < appConfig.startupTimeoutMs) {
if (!nodecgProcess) {
const exitDetails = lastExit
? `Last recorded exit: code=${lastExit.code ?? "null"}, signal=${lastExit.signal ?? "none"}.`
: "No NodeCG process exit code was recorded.";
const stderrDetails = lastStderrLine ? `Last stderr: ${lastStderrLine}` : "No stderr output captured.";
throw new Error(
[
"NodeCG exited before becoming ready.",
exitDetails,
stderrDetails,
`NodeCG path: ${nodecgRootPath}`,
"Check that the packaged runtime was installed correctly and the bundle exists.",
].join("\n"),
);
}
try {
const response = await resolvedDeps.fetchUrl(nodecgBaseUrl, { method: "GET" });
if (response.ok || response.status === 404) {
return;
}
} catch {
// retry until timeout
}
await sleep(500, resolvedDeps.setTimer);
}
throw new Error(`Timeout waiting for NodeCG at ${nodecgBaseUrl} (${appConfig.startupTimeoutMs}ms).`);
};
const stopNodecgProcessGracefully = (): Promise<void> => {
// Reuse the same stop promise to avoid sending multiple kill signals during app shutdown.
if (stopNodecgPromise) {
return stopNodecgPromise;
}
if (!nodecgProcess || nodecgProcess.killed) {
nodecgState = "stopped";
return Promise.resolve();
}
const processToStop = nodecgProcess;
const pid = processToStop.pid;
if (typeof pid !== "number") {
log("NodeCG pid unavailable, skipping graceful stop");
nodecgProcess = null;
nodecgState = "stopped";
return Promise.resolve();
}
nodecgState = "stopping";
log(`Stopping NodeCG pid=${pid}`);
killProcessTree(pid, "SIGTERM", {
platform: resolvedDeps.platform,
spawnProcess: resolvedDeps.spawnProcess,
killProcess: resolvedDeps.killProcess,
log,
});
stopNodecgPromise = new Promise((resolve) => {
let completed = false;
const complete = () => {
if (completed) {
return;
}
completed = true;
if (nodecgProcess === processToStop) {
nodecgProcess = null;
}
nodecgState = "stopped";
stopNodecgPromise = null;
resolve();
};
processToStop.once("exit", () => {
complete();
});
resolvedDeps.setTimer(
() => {
if (processToStop.exitCode === null && processToStop.signalCode === null) {
log(`NodeCG did not exit after SIGTERM, forcing SIGKILL pid=${pid}`);
killProcessTree(pid, "SIGKILL", {
platform: resolvedDeps.platform,
spawnProcess: resolvedDeps.spawnProcess,
killProcess: resolvedDeps.killProcess,
log,
});
complete();
}
},
Math.max(0, appConfig.nodecgKillTimeoutMs),
);
});
return stopNodecgPromise;
};
return {
startNodecgProcess,
waitForNodecgReady,
stopNodecgProcessGracefully,
getState: () => nodecgState,
};
}
function resolveDeps(deps?: Partial<NodecgProcessManagerDeps>): NodecgProcessManagerDeps {
return {
spawnProcess: deps?.spawnProcess ?? spawn,
pathExists: deps?.pathExists ?? fs.existsSync,
fetchUrl: deps?.fetchUrl ?? fetch,
platform: deps?.platform ?? process.platform,
execPath: deps?.execPath ?? process.execPath,
env: deps?.env ?? process.env,
killProcess: deps?.killProcess ?? process.kill,
setTimer: deps?.setTimer ?? setTimeout,
stdoutWrite: deps?.stdoutWrite ?? ((chunk) => process.stdout.write(chunk)),
stderrWrite: deps?.stderrWrite ?? ((chunk) => process.stderr.write(chunk)),
probePortAvailable: deps?.probePortAvailable ?? probePortAvailable,
hasReadWriteAccess: deps?.hasReadWriteAccess ?? hasReadWriteAccess,
};
}
function validateNodecgInstall(
nodecgRootPath: string,
bundleName: string,
pathExists: (candidatePath: string) => boolean,
hasReadWriteAccessToPath: (candidatePath: string) => boolean,
): void {
const indexPath = path.join(nodecgRootPath, "index.js");
const nodecgBootstrapPath = path.join(nodecgRootPath, "node_modules", "nodecg", "dist", "server", "bootstrap.js");
const bundlePath = path.join(nodecgRootPath, "bundles", bundleName);
if (!pathExists(nodecgRootPath)) {
throw new Error(`NodeCG folder does not exist: ${nodecgRootPath}`);
}
if (!hasReadWriteAccessToPath(nodecgRootPath)) {
throw new Error(`No read/write permissions on NodeCG: ${nodecgRootPath}`);
}
if (!pathExists(indexPath)) {
throw new Error(`${indexPath} was not found. Build the packaged NodeCG runtime before starting Electron.`);
}
if (!pathExists(nodecgBootstrapPath)) {
throw new Error(
[
"NodeCG is present but internal dependencies are missing.",
`Not found: ${nodecgBootstrapPath}`,
"Solution: rebuild the packaged runtime:",
" npm run prepare:runtime",
].join("\n"),
);
}
if (!pathExists(bundlePath)) {
throw new Error(
[
`Bundle '${bundleName}' was not found.`,
`Expected path: ${bundlePath}`,
"Build and package the Scoreko bundle before running Electron.",
].join("\n"),
);
}
}
function hasReadWriteAccess(candidatePath: string): boolean {
try {
fs.accessSync(candidatePath, fs.constants.R_OK | fs.constants.W_OK);
return true;
} catch {
return false;
}
}
function probePortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
// A successful TCP connection means some process is already listening on the port.
const socket = net.createConnection({ host: "127.0.0.1", port });
let resolved = false;
const complete = (isAvailable: boolean): void => {
if (resolved) {
return;
}
resolved = true;
socket.destroy();
resolve(isAvailable);
};
socket.setTimeout(1000);
socket.once("connect", () => {
complete(false);
});
socket.once("timeout", () => {
complete(true);
});
socket.once("error", (error: NodeJS.ErrnoException) => {
if (error.code === "ECONNREFUSED" || error.code === "EHOSTUNREACH") {
complete(true);
return;
}
complete(false);
});
});
}
function sleep(ms: number, setTimer: (handler: () => void, timeoutMs: number) => unknown): Promise<void> {
return new Promise((resolve) => {
setTimer(resolve, ms);
});
}