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; }; 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; 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; waitForNodecgReady: (startTime: number) => Promise; stopNodecgProcessGracefully: () => Promise; 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 | null = null; let stopNodecgPromise: Promise | null = null; let lastExit: { code: number | null; signal: NodeJS.Signals | null } | null = null; let lastStderrLine: string | null = null; const startNodecgProcess = (): Promise => { 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 => { // 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 => { // 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 { 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 { 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 { return new Promise((resolve) => { setTimer(resolve, ms); }); }