MeshChatX/electron/backendProcess.js

423 lines
13 KiB
JavaScript

const path = require("node:path");
const { spawn: defaultSpawn } = require("child_process");
const { verifyBackendIntegrity } = require("./backendIntegrity");
const {
clearCrashReport,
getDiagnosticPaths,
getLogsDir,
loadCrashReport,
persistCrashReport,
} = require("./backendCrashReport");
const { killOrphanBackendProcesses } = require("./backendProcessOrphans");
const LOG_LINE_CAP = 200;
function createInitialRuntimeState() {
return {
started: false,
running: false,
pid: null,
lastExitCode: null,
lastError: "",
lastEventAt: null,
};
}
function createBackendProcessManager(deps) {
const {
log,
getDefaultStorageDir,
getDefaultReticulumConfigDir,
getMainWindowPageKind,
notifyRenderer,
showCrashPage,
spawn: spawnFn = defaultSpawn,
} = deps;
let childProcess = null;
let runtimeState = createInitialRuntimeState();
let logBuffers = { stdout: [], stderr: [] };
let lastCrash = null;
let resolvedExePath = null;
let userProvidedArguments = [];
const storageDir = () => getDefaultStorageDir();
const reticulumConfigDir = () => getDefaultReticulumConfigDir();
function hydratePersistedCrash() {
const persisted = loadCrashReport(storageDir());
if (persisted) {
lastCrash = persisted;
}
}
hydratePersistedCrash();
function isRunning() {
return !!childProcess && childProcess.exitCode === null && childProcess.signalCode === null;
}
function getRuntimeState() {
return {
...runtimeState,
running: isRunning() && runtimeState.started,
};
}
function pushLogLine(buffer, line) {
buffer.push(line);
if (buffer.length > LOG_LINE_CAP) {
buffer.shift();
}
}
function getJoinedLogs() {
return {
stdout: logBuffers.stdout.join(""),
stderr: logBuffers.stderr.join(""),
};
}
function getLastCrash() {
return lastCrash;
}
function getStartupDiagnostics() {
const paths = getDiagnosticPaths(storageDir(), reticulumConfigDir());
return {
runtime: getRuntimeState(),
crash: lastCrash,
paths,
};
}
function setUserProvidedArguments(args) {
userProvidedArguments = Array.isArray(args) ? args : [];
}
function resolveExecutablePath(findExePath) {
resolvedExePath = findExePath();
return resolvedExePath;
}
function recordCrash(code) {
const logs = getJoinedLogs();
lastCrash = {
code,
stdout: logs.stdout,
stderr: logs.stderr,
at: Date.now(),
pid: runtimeState.pid,
platform: process.platform,
};
try {
persistCrashReport(storageDir(), lastCrash);
} catch (error) {
log(`Failed to persist backend crash report: ${error && error.message ? error.message : error}`);
}
}
function notifyStartupFailure(code) {
const paths = getDiagnosticPaths(storageDir(), reticulumConfigDir());
notifyRenderer("backend-startup-failed", {
code,
at: lastCrash?.at ?? Date.now(),
stdout: lastCrash?.stdout || "",
stderr: lastCrash?.stderr || "",
paths,
});
notifyRenderer("backend-process-exited", { code, at: lastCrash?.at ?? Date.now() });
}
function attachChildHandlers(proc) {
logBuffers = { stdout: [], stderr: [] };
proc.stdout.setEncoding("utf8");
proc.stdout.on("data", (data) => {
const text = data.toString();
log(text);
pushLogLine(logBuffers.stdout, text);
});
proc.stderr.setEncoding("utf8");
proc.stderr.on("data", (data) => {
const text = data.toString();
log(text);
pushLogLine(logBuffers.stderr, text);
});
proc.on("error", (error) => {
log(error);
runtimeState.lastError = error && error.message ? error.message : String(error);
runtimeState.lastEventAt = Date.now();
});
proc.on("exit", async (code) => {
runtimeState.running = false;
runtimeState.lastExitCode = code;
runtimeState.lastEventAt = Date.now();
childProcess = null;
if (code == null || deps.isQuiting()) {
return;
}
recordCrash(code);
const page = getMainWindowPageKind();
if (page === "loading") {
notifyStartupFailure(code);
return;
}
notifyRenderer("backend-process-exited", { code, at: lastCrash.at });
if (page === "app") {
return;
}
if (page === "crash") {
return;
}
await showCrashPage(lastCrash);
});
}
function buildSpawnEnv() {
const logsDir = getLogsDir(storageDir());
return {
...process.env,
MESHCHAT_LOG_DIR: logsDir,
MESHCHAT_STORAGE_DIR: storageDir(),
MESHCHAT_RETICULUM_CONFIG_DIR: reticulumConfigDir(),
};
}
function shouldUseAppContainerLauncher() {
if (process.platform !== "win32") {
return false;
}
const raw = process.env.MESHCHAT_APPCONTAINER;
if (raw !== undefined && raw !== null && String(raw).trim() !== "") {
const val = String(raw).trim().toLowerCase();
if (["false", "0", "no", "off"].includes(val)) {
return false;
}
if (["true", "1", "yes", "on"].includes(val)) {
return true;
}
}
// Opt-in only: set MESHCHAT_APPCONTAINER=1 to use the AppContainer launcher.
return false;
}
function buildSpawnArgs(extraArgs = []) {
const backendArgs = buildBackendArgs(extraArgs);
if (!shouldUseAppContainerLauncher()) {
return backendArgs;
}
return ["--meshchatx-run-module", "meshchatx.src.backend.appcontainer_launcher", ...backendArgs];
}
async function spawnBackend(exePath, integrityStatusRef) {
if (!exePath) {
throw new Error("Backend executable path is not set.");
}
if (isRunning()) {
return { ok: true, alreadyRunning: true };
}
const removed = killOrphanBackendProcesses(null);
if (removed > 0) {
log(`Removed ${removed} orphan backend process(es) before startup.`);
}
resolvedExePath = exePath;
const exeDir = path.dirname(exePath);
integrityStatusRef.backend = verifyBackendIntegrity(exeDir);
if (
integrityStatusRef.backend.ok &&
integrityStatusRef.backend.issues.length === 1 &&
integrityStatusRef.backend.issues[0] === "Manifest missing"
) {
log("Backend integrity manifest missing, skipping check.");
}
if (!integrityStatusRef.backend.ok) {
log(
`INTEGRITY WARNING: Backend tampering detected! Issues: ${integrityStatusRef.backend.issues.join(", ")}`
);
}
if (shouldUseAppContainerLauncher()) {
log("Starting Windows backend via AppContainer launcher.");
}
const proc = spawnFn(exePath, buildSpawnArgs(), {
env: buildSpawnEnv(),
windowsHide: true,
});
if (!proc || !proc.pid) {
throw new Error("Failed to start backend process (no PID).");
}
childProcess = proc;
runtimeState = {
started: true,
running: true,
pid: proc.pid,
lastExitCode: null,
lastError: "",
lastEventAt: Date.now(),
};
attachChildHandlers(proc);
return { ok: true, pid: proc.pid };
}
function getChildProcess() {
return childProcess;
}
function killChild(signal) {
if (!childProcess) {
return;
}
if (childProcess.exitCode !== null || childProcess.signalCode !== null) {
return;
}
if (process.platform === "win32") {
try {
const { execFileSync } = require("node:child_process");
execFileSync("taskkill", ["/F", "/T", "/PID", String(childProcess.pid)], {
stdio: "ignore",
windowsHide: true,
});
} catch (error) {
log(error);
}
return;
}
childProcess.kill(signal);
}
async function restartBackend(integrityStatusRef) {
if (!resolvedExePath) {
return { ok: false, error: "Backend executable is not configured." };
}
if (isRunning()) {
return { ok: false, error: "Backend is already running." };
}
try {
const result = await spawnBackend(resolvedExePath, integrityStatusRef);
return { ok: true, pid: result.pid };
} catch (error) {
return { ok: false, error: error && error.message ? error.message : String(error) };
}
}
function buildBackendArgs(extraArgs = []) {
const requiredArguments = ["--headless", "--port", "9337"];
if (!userProvidedArguments.includes("--reticulum-config-dir")) {
requiredArguments.push("--reticulum-config-dir", reticulumConfigDir());
}
if (!userProvidedArguments.includes("--storage-dir")) {
requiredArguments.push("--storage-dir", storageDir());
}
return [...requiredArguments, ...userProvidedArguments, ...extraArgs];
}
async function runMaintenanceTask(extraArgs) {
if (!resolvedExePath) {
return { ok: false, error: "Backend executable is not configured." };
}
if (isRunning()) {
killChild("SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 400));
}
return await new Promise((resolve) => {
const stdoutChunks = [];
const stderrChunks = [];
const proc = spawnFn(resolvedExePath, buildSpawnArgs(extraArgs), {
env: buildSpawnEnv(),
windowsHide: true,
});
if (!proc || !proc.pid) {
resolve({ ok: false, error: "Failed to start backend maintenance task." });
return;
}
proc.stdout?.on("data", (chunk) => {
stdoutChunks.push(String(chunk));
});
proc.stderr?.on("data", (chunk) => {
stderrChunks.push(String(chunk));
});
proc.on("error", (error) => {
resolve({
ok: false,
error: error && error.message ? error.message : String(error),
stdout: stdoutChunks.join(""),
stderr: stderrChunks.join(""),
});
});
proc.on("exit", (code) => {
const stdout = stdoutChunks.join("");
const stderr = stderrChunks.join("");
if (code === 0) {
resolve({ ok: true, exitCode: code, stdout, stderr });
return;
}
resolve({
ok: false,
exitCode: code,
error: `Maintenance task exited with code ${code}`,
stdout,
stderr,
});
});
});
}
function getResolvedExecutablePath() {
return resolvedExePath;
}
async function openCrashReport(showCrashPageFn) {
if (!lastCrash) {
return { ok: false, error: "No backend crash report is available." };
}
await showCrashPageFn(lastCrash);
return { ok: true };
}
function markBackendHealthy() {
runtimeState.lastExitCode = null;
runtimeState.lastError = "";
clearCrashReport(storageDir());
lastCrash = null;
}
return {
createInitialRuntimeState,
setUserProvidedArguments,
resolveExecutablePath,
spawnBackend,
restartBackend,
openCrashReport,
getRuntimeState,
getLastCrash,
getStartupDiagnostics,
markBackendHealthy,
getChildProcess,
isRunning,
killChild,
getJoinedLogs,
runMaintenanceTask,
getResolvedExecutablePath,
};
}
module.exports = {
createBackendProcessManager,
};