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:
ShuaixinHuang authored and GitHub committed 2026-11-02 01:15:08 +08:00
1 parent 85013da0ce
commit 46c13b9981
3 files changed
+214 -19

No files matched your search

+3
View File
@@ -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.
+25 -16
View File
@@ -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,