Files
NetMesh/electron/terminalWorker/workerProcessGuards.test.cjs

186 lines
6.9 KiB
JavaScript
Raw Permalink Normal View History

const assert = require("node:assert/strict");
const { spawnSync } = require("node:child_process");
const test = require("node:test");
const { installTerminalWorkerErrorGuards } = require("./workerProcessGuards.cjs");
function createFakeProcess() {
const listeners = new Map();
return {
on(name, callback) {
listeners.set(name, callback);
},
removeListener(name, callback) {
if (listeners.get(name) === callback) listeners.delete(name);
},
emit(name, err) {
const callback = listeners.get(name);
if (!callback) throw new Error(`no listener for ${name}`);
callback(err);
},
};
}
test("worker guards suppress uncaught exceptions and report them", () => {
const fakeProcess = createFakeProcess();
const reports = [];
const logs = [];
installTerminalWorkerErrorGuards({
processObject: fakeProcess,
report: (origin, err, decision) => reports.push({ origin, err, reason: decision.reason }),
logError: (...args) => logs.push(args),
});
const err = Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" });
fakeProcess.emit("uncaughtException", err);
assert.equal(reports.length, 1);
assert.equal(reports[0].origin, "uncaughtException");
assert.equal(reports[0].err, err);
assert.equal(reports[0].reason, "non-fatal network error");
assert.equal(logs.length, 1);
});
test("worker guards suppress unhandled rejections with non-Error reasons", () => {
const fakeProcess = createFakeProcess();
const reports = [];
installTerminalWorkerErrorGuards({
processObject: fakeProcess,
report: (origin, err) => reports.push({ origin, err }),
logError: () => {},
});
assert.doesNotThrow(() => fakeProcess.emit("unhandledRejection", "plain string reason"));
assert.equal(reports.length, 1);
assert.equal(reports[0].origin, "unhandledRejection");
assert.equal(reports[0].err, "plain string reason");
});
test("worker guards absorb report failures instead of rethrowing", () => {
const fakeProcess = createFakeProcess();
installTerminalWorkerErrorGuards({
processObject: fakeProcess,
report: () => {
throw new Error("report route is dead");
},
logError: () => {},
});
assert.doesNotThrow(() => fakeProcess.emit("uncaughtException", new Error("boom")));
});
test("uninstall removes the installed handlers", () => {
const fakeProcess = createFakeProcess();
const uninstall = installTerminalWorkerErrorGuards({
processObject: fakeProcess,
logError: () => {},
});
assert.doesNotThrow(() => fakeProcess.emit("uncaughtException", new Error("before")));
uninstall();
assert.throws(
() => fakeProcess.emit("uncaughtException", new Error("after")),
/no listener for uncaughtException/u,
);
});
test("guards require a process-like EventEmitter", () => {
assert.throws(
() => installTerminalWorkerErrorGuards({ processObject: {} }),
/process-like EventEmitter/u,
);
});
const startupErrors = [
{ label: "generic", properties: {} },
{ label: "network", properties: { code: "ECONNRESET" } },
{ label: "permissions", properties: { code: "EPERM" } },
{ label: "broken pipe", properties: { code: "EPIPE" } },
{ label: "destroyed stream", properties: { code: "ERR_STREAM_DESTROYED" } },
{ label: "SSH", properties: { level: "client-timeout" } },
];
test("every startup error is fatal, including normally recoverable errors", () => {
for (const origin of ["uncaughtException", "unhandledRejection"]) {
for (const { label, properties } of startupErrors) {
const fakeProcess = createFakeProcess();
const reports = [];
installTerminalWorkerErrorGuards({
processObject: fakeProcess,
isRuntimeStarted: () => false,
logError() {},
report: (_origin, err, decision) => reports.push({ err, decision }),
});
const err = Object.assign(new Error(`startup ${label}`), properties);
assert.throws(() => fakeProcess.emit(origin, err), err, `${origin}: ${label}`);
assert.equal(reports.length, 1);
assert.equal(reports[0].decision.action, "fatal");
}
}
});
test("protection becomes active only after successful startup", () => {
const fakeProcess = createFakeProcess();
const reports = [];
let started = false;
installTerminalWorkerErrorGuards({
processObject: fakeProcess,
isRuntimeStarted: () => started,
logError() {},
report: (_origin, _err, decision) => reports.push(decision.action),
});
assert.throws(() => fakeProcess.emit("uncaughtException", new Error("startup")));
started = true;
for (const { properties } of startupErrors) {
assert.doesNotThrow(() => fakeProcess.emit(
"uncaughtException",
Object.assign(new Error("runtime"), properties),
));
}
assert.equal(reports[0], "fatal");
assert.ok(reports.slice(1).every((action) => action !== "fatal"));
});
test("a failed bridge load exits the worker instead of leaving requests hanging", () => {
for (const { label, properties } of startupErrors) {
const child = spawnSync(process.execPath, ["-e", `
const { EventEmitter } = require("node:events");
const Module = require("node:module");
const worker = require(${JSON.stringify(require.resolve("./process.cjs"))});
process.parentPort = new EventEmitter();
process.parentPort.postMessage = (message) => {
if (message.kind === "worker-error") {
process.stdout.write(JSON.stringify(message) + "\\n");
}
};
const originalLoad = Module._load;
Module._load = function(request, ...args) {
if (request === "./terminalDataPipeline.cjs") {
throw Object.assign(new Error("injected bridge startup failure"), ${JSON.stringify(properties)});
}
return originalLoad.call(this, request, ...args);
};
// Retain the event loop, as the Electron parent port does in production.
setTimeout(() => process.exit(0), 200);
setImmediate(() => worker.main());
`], { encoding: "utf8", timeout: 5_000 });
assert.ifError(child.error);
assert.notEqual(child.status, 0, `${label}: an unusable worker must not survive startup`);
const report = JSON.parse(child.stdout.trim());
assert.equal(report.kind, "worker-error");
assert.match(report.message, /injected bridge startup failure/);
assert.match(report.reason, /startup/);
}
});
test("startup rejection terminates a real process even for benign stream errors", () => {
const child = spawnSync(process.execPath, ["-e", `
const { installTerminalWorkerErrorGuards } = require(${JSON.stringify(require.resolve("./workerProcessGuards.cjs"))});
installTerminalWorkerErrorGuards({ isRuntimeStarted: () => false, logError() {} });
setTimeout(() => process.exit(0), 200);
Promise.reject(Object.assign(new Error("injected startup rejection"), { code: "EPIPE" }));
`], { encoding: "utf8", timeout: 5_000 });
assert.ifError(child.error);
assert.notEqual(child.status, 0);
assert.match(child.stderr, /injected startup rejection/);
});