feat: Enhance NodeCG process management and add IPC security tests

This commit is contained in:
2026-05-24 22:13:04 +02:00
parent 2e1d3a170c
commit 54ab1fcb9f
5 changed files with 260 additions and 53 deletions
+90 -50
View File
@@ -32,12 +32,14 @@ type NodecgProcessManagerDeps = {
};
export type NodecgProcessManager = {
startNodecgProcess: () => Promise<ChildProcess>;
startNodecgProcess: () => Promise<void>;
waitForNodecgReady: (startTime: number) => Promise<void>;
stopNodecgProcessGracefully: () => Promise<void>;
getProcess: () => ChildProcess | null;
getState: () => NodecgProcessState;
};
export type NodecgProcessState = "idle" | "starting" | "running" | "stopping" | "stopped" | "failed";
export function createNodecgProcessManager({
isDev,
nodecgRootPath,
@@ -49,64 +51,97 @@ export function createNodecgProcessManager({
const resolvedDeps = resolveDeps(deps);
let nodecgProcess: ChildProcess | 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 = async (): Promise<ChildProcess> => {
// 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 startNodecgProcess = (): Promise<void> => {
if (nodecgProcess && nodecgState === "running") {
return Promise.resolve();
}
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,
});
if (startNodecgPromise) {
return startNodecgPromise;
}
child.stdout?.on("data", (chunk) => {
resolvedDeps.stdoutWrite(String(chunk));
});
if (nodecgState === "stopping") {
return Promise.reject(new Error("Cannot start NodeCG while shutdown is in progress."));
}
child.stderr?.on("data", (chunk) => {
const line = String(chunk);
lastStderrLine = line.trim().length > 0 ? line.trim() : lastStderrLine;
resolvedDeps.stderrWrite(line);
});
nodecgState = "starting";
startNodecgPromise = (async () => {
// Fail fast with actionable errors before spawning child processes.
validateNodecgInstall(
nodecgRootPath,
appConfig.bundleName,
resolvedDeps.pathExists,
resolvedDeps.hasReadWriteAccess,
);
log(`NodeCG started with pid=${child.pid} using ${NODE_RUNTIME_NAME}`);
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.`,
);
}
child.on("exit", (code, signal) => {
log(`NodeCG exited code=${code} signal=${signal ?? "none"}`);
lastExit = { code, signal };
nodecgProcess = null;
});
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,
});
lastExit = null;
lastStderrLine = null;
nodecgProcess = child;
return child;
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> => {
@@ -150,6 +185,7 @@ export function createNodecgProcessManager({
}
if (!nodecgProcess || nodecgProcess.killed) {
nodecgState = "stopped";
return Promise.resolve();
}
@@ -158,9 +194,12 @@ export function createNodecgProcessManager({
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,
@@ -183,6 +222,7 @@ export function createNodecgProcessManager({
nodecgProcess = null;
}
nodecgState = "stopped";
stopNodecgPromise = null;
resolve();
};
@@ -215,7 +255,7 @@ export function createNodecgProcessManager({
startNodecgProcess,
waitForNodecgReady,
stopNodecgProcessGracefully,
getProcess: () => nodecgProcess,
getState: () => nodecgState,
};
}