Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
254 lines
7.5 KiB
JavaScript
254 lines
7.5 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const { EventEmitter } = require("node:events");
|
|
const {
|
|
classifyProcessError,
|
|
createProcessErrorController,
|
|
installProcessErrorHandlers,
|
|
isNonFatalNetworkError,
|
|
} = require("./processErrorGuards.cjs");
|
|
|
|
test("treats Chromium ERR_NETWORK_CHANGED as non-fatal", () => {
|
|
assert.equal(
|
|
isNonFatalNetworkError(new Error("net::ERR_NETWORK_CHANGED")),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("treats other Chromium net::ERR_* failures as non-fatal network errors", () => {
|
|
assert.equal(
|
|
isNonFatalNetworkError(new Error("net::ERR_INTERNET_DISCONNECTED")),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
isNonFatalNetworkError(new Error("net::ERR_NAME_NOT_RESOLVED")),
|
|
true,
|
|
);
|
|
});
|
|
|
|
test("treats Node socket error codes as non-fatal network errors", () => {
|
|
const err = new Error("socket reset");
|
|
err.code = "ECONNRESET";
|
|
assert.equal(isNonFatalNetworkError(err), true);
|
|
|
|
const dnsErr = new Error("dns failed");
|
|
dnsErr.code = "ENOTFOUND";
|
|
assert.equal(isNonFatalNetworkError(dnsErr), true);
|
|
});
|
|
|
|
test("keeps non-network errors fatal", () => {
|
|
assert.equal(
|
|
isNonFatalNetworkError(new Error("Something else broke")),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("generic startup exceptions stay fatal before the app is up", () => {
|
|
const result = classifyProcessError(new Error("boom"), {
|
|
runtimeStarted: false,
|
|
});
|
|
|
|
assert.equal(result.action, "fatal");
|
|
});
|
|
|
|
test("generic runtime exceptions are suppressed after startup", () => {
|
|
const result = classifyProcessError(new Error("boom"), {
|
|
runtimeStarted: true,
|
|
});
|
|
|
|
assert.equal(result.action, "suppress");
|
|
assert.match(result.reason, /runtime/i);
|
|
});
|
|
|
|
test("generic runtime promise rejections are also suppressed after startup", () => {
|
|
const result = classifyProcessError(new Error("promise boom"), {
|
|
runtimeStarted: true,
|
|
origin: "unhandledRejection",
|
|
});
|
|
|
|
assert.equal(result.action, "suppress");
|
|
assert.match(result.reason, /runtime/i);
|
|
});
|
|
|
|
test("controller keeps startup strict until the main window is actually shown", () => {
|
|
const controller = createProcessErrorController();
|
|
|
|
controller.beginMainWindowStartup();
|
|
assert.equal(controller.isRuntimeProtectionActive(), false);
|
|
|
|
controller.completeMainWindowStartup({ windowShown: true });
|
|
assert.equal(controller.isRuntimeProtectionActive(), true);
|
|
});
|
|
|
|
test("controller becomes strict again while recreating a missing main window", () => {
|
|
const controller = createProcessErrorController();
|
|
|
|
controller.beginMainWindowStartup();
|
|
controller.completeMainWindowStartup({ windowShown: true });
|
|
assert.equal(controller.isRuntimeProtectionActive(), true);
|
|
|
|
controller.beginMainWindowStartup();
|
|
assert.equal(controller.isRuntimeProtectionActive(), false);
|
|
|
|
controller.completeMainWindowStartup({ windowShown: false });
|
|
assert.equal(controller.isRuntimeProtectionActive(), true);
|
|
});
|
|
|
|
test("startup-period errors stay fatal while recreating the main window", () => {
|
|
const fakeProcess = new EventEmitter();
|
|
const fatals = [];
|
|
const controller = createProcessErrorController({
|
|
captureError() {},
|
|
onFatalError(err) {
|
|
fatals.push(err.message);
|
|
throw err;
|
|
},
|
|
logError() {},
|
|
logWarn() {},
|
|
});
|
|
|
|
installProcessErrorHandlers(fakeProcess, controller);
|
|
controller.completeMainWindowStartup({ windowShown: true });
|
|
controller.beginMainWindowStartup();
|
|
|
|
assert.throws(() => {
|
|
fakeProcess.emit("uncaughtException", new Error("recreate boom"));
|
|
}, /recreate boom/);
|
|
assert.deepEqual(fatals, ["recreate boom"]);
|
|
});
|
|
|
|
test("fatal startup failures uninstall listeners and keep throwing", () => {
|
|
const fakeProcess = new EventEmitter();
|
|
const captured = [];
|
|
const fatals = [];
|
|
let uninstall = null;
|
|
const controller = createProcessErrorController({
|
|
captureError(source, err) {
|
|
captured.push([source, err.message]);
|
|
},
|
|
onFatalError(err) {
|
|
fatals.push(err.message);
|
|
uninstall?.();
|
|
throw err;
|
|
},
|
|
logError() {},
|
|
logWarn() {},
|
|
});
|
|
|
|
uninstall = installProcessErrorHandlers(fakeProcess, controller);
|
|
|
|
assert.throws(() => {
|
|
fakeProcess.emit("uncaughtException", new Error("startup boom"));
|
|
}, /startup boom/);
|
|
assert.deepEqual(fatals, ["startup boom"]);
|
|
assert.deepEqual(captured, [["uncaughtException", "startup boom"]]);
|
|
assert.equal(fakeProcess.listenerCount("uncaughtException"), 0);
|
|
assert.equal(fakeProcess.listenerCount("unhandledRejection"), 0);
|
|
});
|
|
|
|
test("installed handlers suppress runtime failures after startup", () => {
|
|
const fakeProcess = new EventEmitter();
|
|
const captured = [];
|
|
const errors = [];
|
|
const warnings = [];
|
|
const controller = createProcessErrorController({
|
|
captureError(source, err) {
|
|
captured.push([source, err.message]);
|
|
},
|
|
onFatalError(err) {
|
|
throw err;
|
|
},
|
|
logError(...args) {
|
|
errors.push(args.map(String).join(" "));
|
|
},
|
|
logWarn(...args) {
|
|
warnings.push(args.map(String).join(" "));
|
|
},
|
|
});
|
|
|
|
installProcessErrorHandlers(fakeProcess, controller);
|
|
|
|
controller.beginMainWindowStartup();
|
|
controller.completeMainWindowStartup({ windowShown: true });
|
|
|
|
fakeProcess.emit("uncaughtException", new Error("runtime boom"));
|
|
fakeProcess.emit("unhandledRejection", new Error("runtime rejection"));
|
|
assert.deepEqual(captured, [
|
|
["uncaughtException", "runtime boom"],
|
|
["unhandledRejection", "runtime rejection"],
|
|
]);
|
|
assert.equal(errors.some((line) => line.includes("runtime error after startup")), true);
|
|
assert.equal(warnings.length, 0);
|
|
});
|
|
|
|
test("unhandled rejection marks the forwarded error so uncaught follow-up is not double-captured", () => {
|
|
const captured = [];
|
|
const fatals = [];
|
|
const controller = createProcessErrorController({
|
|
captureError(source, err) {
|
|
captured.push([source, err.message]);
|
|
},
|
|
onFatalError(err) {
|
|
fatals.push(err);
|
|
},
|
|
logError() {},
|
|
logWarn() {},
|
|
});
|
|
|
|
controller.handleUnhandledRejection(new Error("startup rejection"));
|
|
assert.equal(fatals.length, 1);
|
|
assert.equal(fatals[0].__fromUnhandledRejection, true);
|
|
assert.deepEqual(captured, [["unhandledRejection", "startup rejection"]]);
|
|
|
|
controller.handleUncaughtException(fatals[0]);
|
|
assert.deepEqual(captured, [["unhandledRejection", "startup rejection"]]);
|
|
});
|
|
|
|
test("benign stream teardown errors are ignored by the installed handlers", () => {
|
|
const fakeProcess = new EventEmitter();
|
|
let captureCount = 0;
|
|
let fatalCount = 0;
|
|
const controller = createProcessErrorController({
|
|
captureError() {
|
|
captureCount += 1;
|
|
},
|
|
onFatalError() {
|
|
fatalCount += 1;
|
|
},
|
|
logError() {},
|
|
logWarn() {},
|
|
});
|
|
|
|
installProcessErrorHandlers(fakeProcess, controller);
|
|
const err = new Error("broken pipe");
|
|
err.code = "EPIPE";
|
|
fakeProcess.emit("uncaughtException", err);
|
|
|
|
assert.equal(captureCount, 0);
|
|
assert.equal(fatalCount, 0);
|
|
});
|
|
|
|
test("controller suppresses wrapped network errors from err.cause", () => {
|
|
const err = new Error("request failed");
|
|
err.cause = new Error("net::ERR_NETWORK_CHANGED");
|
|
|
|
const result = classifyProcessError(err, {
|
|
runtimeStarted: false,
|
|
});
|
|
|
|
assert.equal(isNonFatalNetworkError(err), true);
|
|
assert.equal(result.action, "suppress");
|
|
});
|
|
|
|
test("controller suppresses ssh-style errors with a level property", () => {
|
|
const err = new Error("connection lost before handshake");
|
|
err.level = "client-socket";
|
|
|
|
const result = classifyProcessError(err, {
|
|
runtimeStarted: false,
|
|
});
|
|
|
|
assert.equal(isNonFatalNetworkError(err), true);
|
|
assert.equal(result.action, "suppress");
|
|
});
|