mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(electron): preserve runtime rejection and exception policies (#8542)
* fix(electron): preserve runtime rejection and exception policies * fix(electron): retain rejection handlers for revoked proxies
This commit is contained in:
1 parent
85013da0ce
commit
46c13b9981
3 files changed
+214
-19
No files matched your search
@@ -201,6 +201,9 @@ neither result means the requested host operation occurred. The existing
|
||||
`openExternal`, `openPath`, and `trashItem` blocks reject their promises.
|
||||
The `trashItem` block prevents that call from reaching the OS trash operation;
|
||||
it does not intercept other ways the application can remove files.
|
||||
Process rejection and uncaught-exception events are observed at dispatch without
|
||||
adding exception handlers. The runtime and application retain their warning,
|
||||
handler, and fatal-exit policies, including for rejected shell-block promises.
|
||||
CLI SIGINT/SIGTERM requests cancellation and awaits owned-process cleanup.
|
||||
Window IDs, URLs, and cached titles come from one main-process Electron snapshot,
|
||||
so a crashed renderer does not require a successful renderer-side title query.
|
||||
|
||||
@@ -78,7 +78,6 @@ let correlationSequence = 0;
|
||||
|
||||
const shape = (value, depth = 0) => {
|
||||
if (depth > 2 || value === null) return value === null ? "null" : "nested";
|
||||
if (arrayIsArray(value)) return "array";
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
return "string";
|
||||
@@ -91,7 +90,12 @@ const shape = (value, depth = 0) => {
|
||||
case "function":
|
||||
return "function";
|
||||
case "object":
|
||||
return "object";
|
||||
try {
|
||||
return arrayIsArray(value) ? "array" : "object";
|
||||
} catch {
|
||||
// Revoked proxies retain their typeof identity but reject IsArray.
|
||||
return "object";
|
||||
}
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
@@ -1321,20 +1325,25 @@ try {
|
||||
boundaries.patchApplicationEffects(electron.app);
|
||||
boundaries.patchNativeAddonLoading();
|
||||
boundaries.patchChildProcess();
|
||||
process.once("uncaughtException", (cause, origin) => {
|
||||
recordRuntime("process-lifecycle", "uncaught-exception", "observed", {
|
||||
argument_shape_values: [cause, origin],
|
||||
error: true,
|
||||
});
|
||||
throw cause;
|
||||
});
|
||||
process.once("unhandledRejection", (reason, promise) => {
|
||||
recordRuntime("process-lifecycle", "unhandled-rejection", "observed", {
|
||||
argument_shape_values: [reason, promise],
|
||||
error: true,
|
||||
});
|
||||
throw reason;
|
||||
});
|
||||
// Observe dispatch without adding listeners: their presence changes Node's
|
||||
// rejection fallback and Electron's uncaught-exception listener-count policy.
|
||||
const processEmit = process.emit;
|
||||
process.emit = function observedProcessEmit(event, ...args) {
|
||||
if (this === process) {
|
||||
const kind =
|
||||
event === "unhandledRejection"
|
||||
? "unhandled-rejection"
|
||||
: event === "uncaughtExceptionMonitor"
|
||||
? "uncaught-exception"
|
||||
: null;
|
||||
if (kind !== null)
|
||||
recordRuntime("process-lifecycle", kind, "observed", {
|
||||
argument_shape_values: args,
|
||||
error: true,
|
||||
});
|
||||
}
|
||||
return applyFunction(processEmit, this, arguments);
|
||||
};
|
||||
} catch (cause) {
|
||||
globalThis.__reaElectronActiveHookError = true;
|
||||
recordRuntime(
|
||||
|
||||
@@ -3305,6 +3305,188 @@ process.stdout.write(JSON.stringify({
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves warning-only rejections and records later blocked shell rejections", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-electron-hook-rejection-warn-",
|
||||
);
|
||||
temporary.push(root);
|
||||
await writeElectronFixture(root);
|
||||
const script = join(root, "warnings.cjs");
|
||||
await writeFile(
|
||||
script,
|
||||
`const { shell } = require("electron");
|
||||
Promise.reject(new Error("controlled rejection"));
|
||||
setImmediate(() => {
|
||||
shell.openExternal("https://example.invalid");
|
||||
setImmediate(() => {
|
||||
console.log(JSON.stringify({
|
||||
listeners: process.listenerCount("unhandledRejection"),
|
||||
events: globalThis.__reaElectronActiveSnapshot().events.filter(event =>
|
||||
event.kind === "process-lifecycle" && event.event === "unhandled-rejection").map(event => ({
|
||||
phase: event.phase, argument_shapes: event.argument_shapes, error: event.error,
|
||||
})),
|
||||
}));
|
||||
});
|
||||
});
|
||||
`,
|
||||
);
|
||||
const result = await runNode(script, root, undefined, [
|
||||
"--unhandled-rejections=warn",
|
||||
]);
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(result.stderr).toContain("UnhandledPromiseRejectionWarning");
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
listeners: 0,
|
||||
events: Array.from({ length: 2 }, () => ({
|
||||
phase: "observed",
|
||||
argument_shapes: ["object", "object"],
|
||||
error: true,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves application rejection listeners and borrowed process emit behavior", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-electron-hook-rejection-listener-",
|
||||
);
|
||||
temporary.push(root);
|
||||
await writeElectronFixture(root);
|
||||
const script = join(root, "listeners.cjs");
|
||||
await writeFile(
|
||||
script,
|
||||
`const { EventEmitter } = require("node:events");
|
||||
const reason = new Error("controlled listener reason");
|
||||
const promise = Promise.resolve();
|
||||
const sentinel = new Error("controlled listener throw");
|
||||
let calls = 0;
|
||||
process.on("unhandledRejection", (actualReason, actualPromise) => {
|
||||
if (actualReason !== reason || actualPromise !== promise) throw new Error("identity changed");
|
||||
calls++;
|
||||
if (calls === 2) throw sentinel;
|
||||
});
|
||||
const returned = process.emit("unhandledRejection", reason, promise);
|
||||
let sameThrow = false;
|
||||
try { process.emit("unhandledRejection", reason, promise); } catch (cause) { sameThrow = cause === sentinel; }
|
||||
const borrowed = process.emit.call(new EventEmitter(), "unhandledRejection", reason, promise);
|
||||
console.log(JSON.stringify({ calls, returned, sameThrow, borrowed,
|
||||
listeners: process.listenerCount("unhandledRejection"),
|
||||
observed: globalThis.__reaElectronActiveSnapshot().events.filter(event => event.kind === "process-lifecycle" && event.event === "unhandled-rejection").length,
|
||||
}));
|
||||
`,
|
||||
);
|
||||
const result = await runNode(script, root);
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
calls: 2,
|
||||
returned: true,
|
||||
sameThrow: true,
|
||||
borrowed: false,
|
||||
listeners: 1,
|
||||
observed: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers revoked proxy rejections to the application's original handler", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-electron-hook-revoked-rejection-",
|
||||
);
|
||||
temporary.push(root);
|
||||
await writeElectronFixture(root);
|
||||
const script = join(root, "revoked-rejections.cjs");
|
||||
await writeFile(
|
||||
script,
|
||||
`const entries = [{}, [], () => undefined, []].map((target, index) => {
|
||||
const { proxy, revoke } = Proxy.revocable(target, {});
|
||||
if (index < 3) revoke();
|
||||
return { reason: proxy, promise: undefined };
|
||||
});
|
||||
const received = [];
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
received.push(entries.some(entry => entry.reason === reason && entry.promise === promise));
|
||||
});
|
||||
for (const entry of entries) entry.promise = Promise.reject(entry.reason);
|
||||
setImmediate(() => console.log(JSON.stringify({
|
||||
received,
|
||||
listeners: process.listenerCount("unhandledRejection"),
|
||||
shapes: globalThis.__reaElectronActiveSnapshot().events.filter(event =>
|
||||
event.kind === "process-lifecycle" && event.event === "unhandled-rejection").map(event => event.argument_shapes),
|
||||
})));
|
||||
`,
|
||||
);
|
||||
const result = await runNode(script, root);
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
received: [true, true, true, true],
|
||||
listeners: 1,
|
||||
shapes: [
|
||||
["object", "object"],
|
||||
["object", "object"],
|
||||
["function", "object"],
|
||||
["array", "object"],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an Electron-style uncaught exception listener policy", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-electron-hook-uncaught-policy-",
|
||||
);
|
||||
temporary.push(root);
|
||||
await writeElectronFixture(
|
||||
root,
|
||||
electronModuleSource +
|
||||
`
|
||||
process.on("uncaughtException", () => {
|
||||
if (process.listenerCount("uncaughtException") > 1) return;
|
||||
console.log(JSON.stringify({ listeners: process.listenerCount("uncaughtException"),
|
||||
kinds: globalThis.__reaElectronActiveSnapshot().events.filter(event => event.kind === "process-lifecycle").map(event => event.event),
|
||||
}));
|
||||
});
|
||||
`,
|
||||
);
|
||||
const script = join(root, "uncaught.cjs");
|
||||
await writeFile(script, 'throw new Error("controlled uncaught");\n');
|
||||
const result = await runNode(script, root);
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
listeners: 1,
|
||||
kinds: ["uncaught-exception"],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["throw", "strict"])(
|
||||
"preserves fatal rejection policy in %s mode",
|
||||
async (mode) => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-electron-hook-rejection-fatal-",
|
||||
);
|
||||
temporary.push(root);
|
||||
await writeElectronFixture(root);
|
||||
const script = join(root, "fatal-rejection.cjs");
|
||||
await writeFile(
|
||||
script,
|
||||
`process.on("exit", () => console.log(JSON.stringify({
|
||||
listeners: process.listenerCount("uncaughtException"),
|
||||
kinds: globalThis.__reaElectronActiveSnapshot().events.filter(event => event.kind === "process-lifecycle").map(event => event.event),
|
||||
})));
|
||||
Promise.reject(new Error("controlled fatal rejection"));
|
||||
`,
|
||||
);
|
||||
const result = await runNode(script, root, undefined, [
|
||||
"--unhandled-rejections=" + mode,
|
||||
]);
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain("controlled fatal rejection");
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
listeners: 0,
|
||||
kinds:
|
||||
mode === "throw"
|
||||
? ["unhandled-rejection", "uncaught-exception"]
|
||||
: ["uncaught-exception"],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves fatal process termination after recording uncaught exceptions", async () => {
|
||||
const root = await createTestTempDirectory("rea-electron-hook-fatal-");
|
||||
temporary.push(root);
|
||||
@@ -3362,7 +3544,7 @@ it.each([{ sandboxPrefix: [] }, { sandboxPrefix: ["--no-sandbox"] }])(
|
||||
`,
|
||||
);
|
||||
// The bootstrap script establishes Electron's observed argv before loading the hook.
|
||||
const result = await runNode(script, root, undefined, false);
|
||||
const result = await runNode(script, root, undefined, [], false);
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(JSON.parse(result.stdout.trim())).toEqual({
|
||||
argv: ["/runner/electron", "/selected/app.asar", ...callerArguments],
|
||||
@@ -3409,7 +3591,7 @@ it.each([
|
||||
hook_error: globalThis.__reaElectronActiveSnapshot().hook_error }));
|
||||
`,
|
||||
);
|
||||
const result = await runNode(script, root, undefined, false);
|
||||
const result = await runNode(script, root, undefined, [], false);
|
||||
expect(result.code, result.stderr).toBe(0);
|
||||
expect(JSON.parse(result.stdout.trim())).toEqual({
|
||||
unchanged: true,
|
||||
@@ -3454,6 +3636,7 @@ const runNode = async (
|
||||
script: string,
|
||||
root: string,
|
||||
sourceHook?: string,
|
||||
nodeArguments: readonly string[] = [],
|
||||
preloadHook = true,
|
||||
): Promise<{
|
||||
readonly code: number | null;
|
||||
@@ -3464,7 +3647,7 @@ const runNode = async (
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
preloadHook ? ["-r", hook, script] : [script],
|
||||
[...nodeArguments, ...(preloadHook ? ["-r", hook] : []), script],
|
||||
{
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
|
||||
Reference in new issue
Block a user