194 lines
5.6 KiB
JavaScript
194 lines
5.6 KiB
JavaScript
|
|
function isNonFatalNetworkError(err) {
|
||
|
|
if (!err) return false;
|
||
|
|
// Any error with an ssh2 `level` property is a connection/auth-level error,
|
||
|
|
// never a reason to kill the entire multi-session app.
|
||
|
|
if (err.level) return true;
|
||
|
|
|
||
|
|
const candidates = [err, err.cause].filter(Boolean);
|
||
|
|
for (const candidate of candidates) {
|
||
|
|
const code = candidate.code;
|
||
|
|
// Common TCP/DNS/routing errors that can surface from Node.js sockets
|
||
|
|
// without an ssh2 `level` (e.g. proxy sockets, raw net.connect calls).
|
||
|
|
switch (code) {
|
||
|
|
case "ECONNRESET":
|
||
|
|
case "ECONNREFUSED":
|
||
|
|
case "ECONNABORTED":
|
||
|
|
case "ETIMEDOUT":
|
||
|
|
case "ENOTFOUND":
|
||
|
|
case "EHOSTUNREACH":
|
||
|
|
case "EHOSTDOWN":
|
||
|
|
case "ENETUNREACH":
|
||
|
|
case "ENETDOWN":
|
||
|
|
case "EADDRNOTAVAIL":
|
||
|
|
case "EPROTO":
|
||
|
|
case "EPERM":
|
||
|
|
return true;
|
||
|
|
default:
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Chromium/Electron networking often rejects with a message like
|
||
|
|
// "net::ERR_NETWORK_CHANGED" but without a useful `code` property.
|
||
|
|
// These are transport failures for background fetch/update/sync work,
|
||
|
|
// not reasons to kill the whole app.
|
||
|
|
const message = String(candidate.message || "");
|
||
|
|
if (/net::ERR_(?:NETWORK_[A-Z_]+|INTERNET_DISCONNECTED|NAME_NOT_RESOLVED|CONNECTION_[A-Z_]+|ADDRESS_[A-Z_]+|SSL_[A-Z_]+|CERT_[A-Z_]+|PROXY_[A-Z_]+|TUNNEL_[A-Z_]+|SOCKS_[A-Z_]+)/.test(message)) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
function isBenignStreamError(err) {
|
||
|
|
const code = err?.code;
|
||
|
|
return code === "EPIPE" || code === "ERR_STREAM_DESTROYED";
|
||
|
|
}
|
||
|
|
|
||
|
|
function classifyProcessError(err, options = {}) {
|
||
|
|
const runtimeStarted = options.runtimeStarted === true;
|
||
|
|
|
||
|
|
if (isBenignStreamError(err)) {
|
||
|
|
return {
|
||
|
|
action: "ignore",
|
||
|
|
reason: "benign stream teardown",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isNonFatalNetworkError(err)) {
|
||
|
|
return {
|
||
|
|
action: "suppress",
|
||
|
|
reason: "non-fatal network error",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
if (runtimeStarted) {
|
||
|
|
return {
|
||
|
|
action: "suppress",
|
||
|
|
reason: "runtime error after startup",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
action: "fatal",
|
||
|
|
reason: "startup error before app became usable",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function createProcessErrorController(options = {}) {
|
||
|
|
const captureError = typeof options.captureError === "function" ? options.captureError : () => {};
|
||
|
|
const onFatalError = typeof options.onFatalError === "function"
|
||
|
|
? options.onFatalError
|
||
|
|
: (err) => { throw err; };
|
||
|
|
const logError = typeof options.logError === "function" ? options.logError : (...args) => console.error(...args);
|
||
|
|
const logWarn = typeof options.logWarn === "function" ? options.logWarn : (...args) => console.warn(...args);
|
||
|
|
|
||
|
|
let hasShownMainWindow = false;
|
||
|
|
let pendingMainWindowStartupCount = 0;
|
||
|
|
|
||
|
|
const isRuntimeProtectionActive = () => (
|
||
|
|
hasShownMainWindow && pendingMainWindowStartupCount === 0
|
||
|
|
);
|
||
|
|
|
||
|
|
const beginMainWindowStartup = () => {
|
||
|
|
pendingMainWindowStartupCount += 1;
|
||
|
|
};
|
||
|
|
|
||
|
|
const completeMainWindowStartup = ({ windowShown = false } = {}) => {
|
||
|
|
if (pendingMainWindowStartupCount > 0) {
|
||
|
|
pendingMainWindowStartupCount -= 1;
|
||
|
|
}
|
||
|
|
if (windowShown) {
|
||
|
|
hasShownMainWindow = true;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleUncaughtException = (err) => {
|
||
|
|
const decision = classifyProcessError(err, {
|
||
|
|
runtimeStarted: isRuntimeProtectionActive(),
|
||
|
|
origin: "uncaughtException",
|
||
|
|
});
|
||
|
|
|
||
|
|
if (decision.action === "ignore") {
|
||
|
|
logWarn("Ignored process error:", decision.reason, err?.code || err?.message || err);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (decision.action === "suppress") {
|
||
|
|
if (!err?.__fromUnhandledRejection) {
|
||
|
|
captureError("uncaughtException", err);
|
||
|
|
}
|
||
|
|
logError(`Suppressed uncaught exception (${decision.reason}):`, err);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!err?.__fromUnhandledRejection) {
|
||
|
|
captureError("uncaughtException", err);
|
||
|
|
}
|
||
|
|
onFatalError(err, {
|
||
|
|
origin: "uncaughtException",
|
||
|
|
decision,
|
||
|
|
reason: err,
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleUnhandledRejection = (reason) => {
|
||
|
|
const decision = classifyProcessError(reason, {
|
||
|
|
runtimeStarted: isRuntimeProtectionActive(),
|
||
|
|
origin: "unhandledRejection",
|
||
|
|
});
|
||
|
|
|
||
|
|
if (decision.action === "ignore") {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (decision.action === "suppress") {
|
||
|
|
captureError("unhandledRejection", reason);
|
||
|
|
logError(`Suppressed unhandled rejection (${decision.reason}):`, reason);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
captureError("unhandledRejection", reason);
|
||
|
|
const err = reason instanceof Error ? reason : new Error(String(reason));
|
||
|
|
err.__fromUnhandledRejection = true;
|
||
|
|
onFatalError(err, {
|
||
|
|
origin: "unhandledRejection",
|
||
|
|
decision,
|
||
|
|
reason,
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
return {
|
||
|
|
beginMainWindowStartup,
|
||
|
|
completeMainWindowStartup,
|
||
|
|
handleUncaughtException,
|
||
|
|
handleUnhandledRejection,
|
||
|
|
isRuntimeProtectionActive,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function installProcessErrorHandlers(processObject, controller) {
|
||
|
|
if (!processObject?.on || !processObject?.removeListener) {
|
||
|
|
throw new Error("A process-like EventEmitter is required");
|
||
|
|
}
|
||
|
|
if (!controller?.handleUncaughtException || !controller?.handleUnhandledRejection) {
|
||
|
|
throw new Error("A process error controller is required");
|
||
|
|
}
|
||
|
|
|
||
|
|
processObject.on("uncaughtException", controller.handleUncaughtException);
|
||
|
|
processObject.on("unhandledRejection", controller.handleUnhandledRejection);
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
processObject.removeListener("uncaughtException", controller.handleUncaughtException);
|
||
|
|
processObject.removeListener("unhandledRejection", controller.handleUnhandledRejection);
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
classifyProcessError,
|
||
|
|
createProcessErrorController,
|
||
|
|
installProcessErrorHandlers,
|
||
|
|
isBenignStreamError,
|
||
|
|
isNonFatalNetworkError,
|
||
|
|
};
|