MeshChatX/electron/safeConsole.js

188 lines
5.2 KiB
JavaScript

// SPDX-License-Identifier: 0BSD
/**
* Main-process log policy for Electron.
*
* Desktop launchers (AppImage, .desktop) often start MeshChatX with stdout
* attached to a pipe that later closes. Writing console.log then raises EPIPE.
* Prefer a durable file under the storage logs dir, and only mirror to stdout
* when it is still a TTY (or when forced via env).
*/
const fs = require("node:fs");
const path = require("node:path");
const ELECTRON_LOG_PREFIX = "[electron] ";
function isBrokenPipeError(err) {
if (!err || typeof err !== "object") {
return false;
}
if (err.code === "EPIPE" || err.errno === "EPIPE") {
return true;
}
const message = typeof err.message === "string" ? err.message : "";
return /\bEPIPE\b/i.test(message);
}
/**
* Attach no-op error listeners so closed stdout/stderr do not become
* uncaughtException dialogs when a write races the pipe close.
* @param {{ stdout?: NodeJS.WritableStream, stderr?: NodeJS.WritableStream } | null | undefined} [proc]
*/
function installBrokenPipeGuards(proc = process) {
const attach = (stream) => {
if (!stream || typeof stream.on !== "function") {
return;
}
if (stream.__meshchatxBrokenPipeGuarded) {
return;
}
stream.__meshchatxBrokenPipeGuarded = true;
stream.on("error", (err) => {
if (isBrokenPipeError(err)) {
return;
}
});
};
attach(proc?.stdout);
attach(proc?.stderr);
}
/**
* True when stdout is a live terminal, or MESHCHAT_FORCE_STDOUT_LOG=1.
* Packaged AppImage / desktop launches usually have isTTY false.
* @param {{ stdout?: { isTTY?: boolean } } | null | undefined} [proc]
* @param {NodeJS.ProcessEnv} [env]
*/
function shouldMirrorStdout(proc = process, env = process.env) {
if (env?.MESHCHAT_DISABLE_STDOUT_LOG === "1") {
return false;
}
if (env?.MESHCHAT_FORCE_STDOUT_LOG === "1") {
return true;
}
return Boolean(proc?.stdout && proc.stdout.isTTY);
}
/**
* console.log that never throws on a closed stdout pipe.
* @param {...unknown} args
*/
function safeConsoleLog(...args) {
try {
console.log(...args);
} catch (err) {
if (!isBrokenPipeError(err)) {
throw err;
}
}
}
/**
* @param {string} message
* @returns {string}
*/
function formatElectronLogLine(message) {
const text = String(message ?? "").replace(/\s+$/u, "");
return `${new Date().toISOString()} ${ELECTRON_LOG_PREFIX}${text}\n`;
}
/**
* Create a durable main-process logger that appends to meshchatx.log.
* @param {{
* getLogsDir: () => string | null | undefined,
* appendFileSync?: typeof fs.appendFileSync,
* mkdirSync?: typeof fs.mkdirSync,
* now?: () => Date,
* }} options
*/
function createMainProcessLogger(options) {
const getLogsDir = options.getLogsDir;
const appendFileSync = options.appendFileSync || fs.appendFileSync.bind(fs);
const mkdirSync = options.mkdirSync || fs.mkdirSync.bind(fs);
const pending = [];
let disabled = false;
function resolveLogPath() {
let logsDir;
try {
logsDir = getLogsDir?.();
} catch {
return null;
}
if (!logsDir || typeof logsDir !== "string") {
return null;
}
return path.join(logsDir, "meshchatx.log");
}
function flushPending() {
if (!pending.length) {
return;
}
const logPath = resolveLogPath();
if (!logPath) {
return;
}
const chunk = pending.splice(0, pending.length).join("");
try {
mkdirSync(path.dirname(logPath), { recursive: true });
appendFileSync(logPath, chunk, { encoding: "utf8" });
} catch {
disabled = true;
}
}
function writeToFile(message) {
if (disabled) {
return;
}
const line = formatElectronLogLine(message);
const logPath = resolveLogPath();
if (!logPath) {
pending.push(line);
return;
}
flushPending();
try {
mkdirSync(path.dirname(logPath), { recursive: true });
appendFileSync(logPath, line, { encoding: "utf8" });
} catch {
disabled = true;
}
}
/**
* Durable file write plus best-effort stdout when a TTY is present.
* @param {unknown} message
* @param {{ mirrorStdout?: boolean, proc?: NodeJS.Process, env?: NodeJS.ProcessEnv }} [opts]
*/
function write(message, opts = {}) {
writeToFile(message);
const mirror =
opts.mirrorStdout !== undefined
? opts.mirrorStdout
: shouldMirrorStdout(opts.proc || process, opts.env || process.env);
if (mirror) {
safeConsoleLog(message);
}
}
return {
write,
flushPending,
_pendingForTests: pending,
_formatLineForTests: formatElectronLogLine,
};
}
module.exports = {
isBrokenPipeError,
installBrokenPipeGuards,
safeConsoleLog,
shouldMirrorStdout,
formatElectronLogLine,
createMainProcessLogger,
ELECTRON_LOG_PREFIX,
};