diff --git a/docs/electron-observation.md b/docs/electron-observation.md index 353af97872..391aa5c17f 100644 --- a/docs/electron-observation.md +++ b/docs/electron-observation.md @@ -193,8 +193,25 @@ Caller-defined `.listener` properties can change the identity exposed by wrapper recognition. Re-registering a raw recording wrapper on another channel retains the original registration's channel attribution. Mutating `.listener` on a raw recording wrapper can also prevent removal through -application decorators; the reproduced identity conflict is tracked in -[#4513](https://github.com/morluto/rea/issues/4513). +application decorators that bypass the hook's removal methods. When a removal +request reaches the hook's methods and its registration order is known, private +callback ownership preserves removal by the original function and the newest +matching registration when public metadata is changed or deleted. Native removal +still determines getter execution, exceptions and removal meta-events. + +If registration or removal methods differ from the Node methods captured at hook +initialization, the hook leaves listener callbacks unwrapped and records a +`listener-registration` observation gap. The application keeps its original +callback and receiver; callback execution remains unobserved. This comparison +assumes Node's EventEmitter prototype has not itself been globally decorated +before hook initialization. The provider loads the hook before the application +and excludes `NODE_OPTIONS` from the child environment. + +An opaque later listener or unsupported emitter state can prevent proof of +removal order. The hook then delegates removal +to the native method and records a `listener-removal` gap rather than claiming +which registration was removed. These gaps are distinct from dropped records +and do not establish IPC delivery or successful removal. ### Target fuse prerequisites diff --git a/scripts/electron-active-hook.cjs b/scripts/electron-active-hook.cjs index 6e535ee338..5a801101c2 100644 --- a/scripts/electron-active-hook.cjs +++ b/scripts/electron-active-hook.cjs @@ -5,6 +5,7 @@ const applyArguments = Function.prototype.apply; const bindFunction = Function.prototype.bind; const callFunction = Function.prototype.call; const arrayFrom = Array.from; +const arrayIsArray = Array.isArray; const defineProperty = Object.defineProperty; const ownPropertyDescriptor = Object.getOwnPropertyDescriptor; const getPrototypeOf = Object.getPrototypeOf; @@ -20,11 +21,28 @@ const sliceArguments = bindBuiltinMethod(Array.prototype.slice); const weakMapHas = bindBuiltinMethod(WeakMap.prototype.has); const weakMapGet = bindBuiltinMethod(WeakMap.prototype.get); const weakMapSet = bindBuiltinMethod(WeakMap.prototype.set); +const weakSetAdd = bindBuiltinMethod(WeakSet.prototype.add); +const weakSetHas = bindBuiltinMethod(WeakSet.prototype.has); const setHas = bindBuiltinMethod(Set.prototype.has); const setAdd = bindBuiltinMethod(Set.prototype.add); const hasOwnProperty = bindBuiltinMethod(Object.prototype.hasOwnProperty); const { isProxy } = require("node:util").types; +const { EventEmitter: NodeEventEmitter } = require("node:events"); +const nodeEventEmitterMethods = Object.create(null); +for (const method of [ + "on", + "once", + "off", + "removeListener", + "removeAllListeners", +]) { + const descriptor = ownPropertyDescriptor(NodeEventEmitter.prototype, method); + nodeEventEmitterMethods[method] = + descriptor !== undefined && hasOwnProperty(descriptor, "value") + ? descriptor.value + : undefined; +} const { createElectronActiveBoundaryPatches, } = require("./electron-active-hook-boundaries.cjs"); @@ -59,7 +77,7 @@ let correlationSequence = 0; const shape = (value, depth = 0) => { if (depth > 2 || value === null) return value === null ? "null" : "nested"; - if (Array.isArray(value)) return "array"; + if (arrayIsArray(value)) return "array"; switch (typeof value) { case "string": return "string"; @@ -215,12 +233,12 @@ const record = (event) => { error: false, ...event, }; - const shapeGroups = Array.isArray(raw.argument_shape_value_groups) + const shapeGroups = arrayIsArray(raw.argument_shape_value_groups) ? raw.argument_shape_value_groups - : Array.isArray(raw.argument_shape_values) + : arrayIsArray(raw.argument_shape_values) ? [raw.argument_shape_values] : []; - const existingShapes = Array.isArray(raw.argument_shapes) + const existingShapes = arrayIsArray(raw.argument_shapes) ? raw.argument_shapes : []; const retainedEvent = { @@ -268,7 +286,7 @@ const record = (event) => { retentionBudgetBytes; }; for (const group of shapeGroups) { - if (!Array.isArray(group)) continue; + if (!arrayIsArray(group)) continue; for (const argument of group) { countShape(argument); if (shapeOverflow) break; @@ -318,7 +336,7 @@ const record = (event) => { return null; } for (const group of shapeGroups) { - if (!Array.isArray(group)) continue; + if (!arrayIsArray(group)) continue; for (const argument of group) retainedEvent.argument_shapes.push(shape(argument)); } @@ -337,7 +355,7 @@ const recordRuntime = (kind, event, phase, details = {}) => const recordIpc = (kind, channel, args, details = {}) => (() => { - const values = Array.isArray(args) ? args : []; + const values = arrayIsArray(args) ? args : []; return record({ kind, channel: typeof channel === "string" ? channel : null, @@ -346,14 +364,41 @@ const recordIpc = (kind, channel, args, details = {}) => }); })(); -const recordIpcObservationGap = (channel, event, error = false) => +const recordIpcObservationGap = ( + channel, + event, + error = false, + target = "ipcMain.on", +) => recordRuntime("ipc-observation-gap", event, error ? "failed" : "observed", { channel, - target: "ipcMain.on", + target, process_type: "main", error, }); +const forwardUnobservedRegistration = ( + method, + receiver, + args, + recordGap, + target, +) => { + const gap = + recordGap && typeof args[1] === "function" + ? recordIpcObservationGap(args[0], "listener-registration", false, target) + : null; + try { + return applyFunction(method, receiver, args); + } catch (cause) { + if (gap !== null) { + gap.error = true; + gap.phase = "failed"; + } + throw cause; + } +}; + const patchEmitter = (emitter, observeEvent) => { if (emitter === null || emitter === undefined) return; const original = emitter.emit; @@ -409,6 +454,7 @@ const invokeObservedEventListener = (thisArg, channel, listener, args) => { // wrapper, or the wrapper itself for "once". Public .listener is mutable; // it is not the authority for the original function's identity. const hookListenerRemovalTargets = new WeakMap(); +const knownCanonicalListeners = new WeakSet(); const MAX_LISTENER_CHAIN_HOPS = 16; // Parse native property lookup without invoking application getters or Proxy @@ -458,6 +504,50 @@ const classifyHookListenerWrapper = (listener) => { return typeof candidate === "function" ? "unknown" : "unowned"; }; +const createObservedEventListener = (channel, listener) => { + const wrapper = function observedEventListener() { + return invokeObservedEventListener(this, channel, listener, arguments); + }; + defineProperty(wrapper, "apply", { + configurable: true, + writable: true, + value: function nativeApply(...methodArgs) { + let applicationApply; + try { + applicationApply = listener.apply; + } catch (cause) { + recordIpcObservationGap(channel, "custom-apply", true); + throw cause; + } + if (applicationApply !== applyArguments) { + const gap = recordIpcObservationGap(channel, "custom-apply"); + try { + return applyFunction( + applicationApply, + this === wrapper ? listener : this, + methodArgs, + ); + } catch (cause) { + if (gap !== null) { + gap.error = true; + gap.phase = "failed"; + } + throw cause; + } + } + return applyFunction(applyArguments, this, methodArgs); + }, + }); + defineProperty(wrapper, "listener", { + configurable: true, + enumerable: true, + writable: true, + value: listener, + }); + weakMapSet(hookListenerRemovalTargets, wrapper, listener); + return wrapper; +}; + const createObservedOnceCallback = (target, channel, listener, receiver) => { // A private callable target avoids Proxy get invariants on an app's // non-configurable, read-only call/apply properties. It never escapes @@ -517,7 +607,45 @@ const createObservedOnceCallback = (target, channel, listener, receiver) => { const patchIpcMain = (ipcMain) => { if (ipcMain === null || ipcMain === undefined) return; - const { handle, on, once, removeAllListeners } = ipcMain; + if (isProxy(ipcMain)) { + recordIpcObservationGap(null, "listener-registration"); + return; + } + const methodDescriptors = Object.create(null); + let methodsKnown = true; + for (const method of [ + "on", + "once", + "off", + "removeListener", + "removeAllListeners", + ]) { + const descriptor = lookupPropertyDescriptor(ipcMain, method); + if ( + descriptor === null || + descriptor === undefined || + !hasOwnProperty(descriptor, "value") + ) { + methodsKnown = false; + methodDescriptors[method] = undefined; + } else { + methodDescriptors[method] = descriptor.value; + if (descriptor.value !== nodeEventEmitterMethods[method]) + methodsKnown = false; + } + } + const on = methodDescriptors.on; + const once = methodDescriptors.once; + if (!methodsKnown && (typeof on !== "function" || typeof once !== "function")) + recordIpcObservationGap(null, "listener-registration"); + const handle = ipcMain.handle; + const removeAllListeners = methodsKnown + ? methodDescriptors.removeAllListeners + : undefined; + const nativeRemoveListener = methodsKnown + ? methodDescriptors.removeListener + : undefined; + const nativeOff = methodsKnown ? methodDescriptors.off : undefined; let unobservedOnceScope = null; if (typeof handle === "function") ipcMain.handle = function patchedIpcMainHandle(channel, listener) { @@ -546,11 +674,28 @@ const patchIpcMain = (ipcMain) => { }; if (typeof on !== "function") return; ipcMain.on = function patchedIpcMainOn(channel, listener) { + if (this !== ipcMain) + return forwardUnobservedRegistration( + on, + this, + arguments, + true, + "ipcMain.on", + ); + if (!methodsKnown) { + return forwardUnobservedRegistration( + on, + this, + arguments, + unobservedOnceScope === null, + "ipcMain.on", + ); + } if (typeof listener !== "function") return on.call(this, channel, listener); if (unobservedOnceScope !== null) { const scope = unobservedOnceScope; const result = on.call(this, channel, listener); - recordIpcObservationGap(channel, "once-metadata"); + recordIpcObservationGap(channel, "once-metadata", false, "ipcMain.once"); if (scope.channel === channel) scope.gapRecorded = true; return result; } @@ -561,66 +706,48 @@ const patchIpcMain = (ipcMain) => { recordIpcObservationGap(channel, "listener-chain"); return result; } - const wrapped = function wrappedEventListener() { - return invokeObservedEventListener(this, channel, listener, arguments); - }; - // Native single-listener dispatch reads .apply; array dispatch invokes the - // function directly. Let Node select that path without inspecting or - // copying the emitter's listener storage, including during reentrant emits. - defineProperty(wrapped, "apply", { - configurable: true, - writable: true, - value: function nativeApply(...methodArgs) { - let applicationApply; - try { - applicationApply = listener.apply; - } catch (cause) { - recordIpcObservationGap(channel, "custom-apply", true); - throw cause; - } - if (applicationApply !== applyArguments) { - // Custom apply owns the original list's interpretation. Forward it - // unchanged rather than executing getters to invent callback facts. - const gap = recordIpcObservationGap(channel, "custom-apply"); - try { - return applyFunction( - applicationApply, - this === wrapped ? listener : this, - methodArgs, - ); - } catch (cause) { - if (gap !== null) { - gap.error = true; - gap.phase = "failed"; - } - throw cause; - } - } - // Native Function.apply performs its own list conversion before the - // recording wrapper observes an actual invocation frame. - // Borrowing the exposed method keeps its dynamic receiver. - return applyFunction(applyArguments, this, methodArgs); - }, - }); - defineProperty(wrapped, "listener", { - configurable: true, - enumerable: true, - writable: true, - value: listener, - }); - weakMapSet(hookListenerRemovalTargets, wrapped, listener); - return on.call(this, channel, wrapped); + const wrapped = createObservedEventListener(channel, listener); + const result = on.call(this, channel, wrapped); + weakSetAdd(knownCanonicalListeners, listener); + return result; }; // Native once owns firing, removal and callback dispatch. Node versions // differ in whether they read callback.call/apply. Observe actual invocation // through a private callable Proxy instead of copying either implementation. if (typeof once === "function") ipcMain.once = function patchedIpcMainOnce(channel, listener) { - if ( - typeof listener !== "function" || - this === null || - (typeof this !== "object" && typeof this !== "function") - ) + if (this !== ipcMain) + return forwardUnobservedRegistration( + once, + this, + arguments, + true, + "ipcMain.once", + ); + if (!methodsKnown) { + if (typeof listener !== "function") + return forwardUnobservedRegistration( + once, + this, + arguments, + false, + "ipcMain.once", + ); + const previousScope = unobservedOnceScope; + unobservedOnceScope = { channel }; + try { + return forwardUnobservedRegistration( + once, + this, + arguments, + true, + "ipcMain.once", + ); + } finally { + unobservedOnceScope = previousScope; + } + } + if (typeof listener !== "function") return once.call(this, channel, listener); const target = this; const metadata = lookupPropertyDescriptor( @@ -650,7 +777,12 @@ const patchIpcMain = (ipcMain) => { // An app decorator can register through an unhooked alias and then // throw. Preserve a gap for either outcome, not proof of registration. if (!scope.gapRecorded) - recordIpcObservationGap(channel, "once-metadata", failed); + recordIpcObservationGap( + channel, + "once-metadata", + failed, + "ipcMain.once", + ); } } const receiver = new ProxyConstructor( @@ -685,9 +817,169 @@ const patchIpcMain = (ipcMain) => { receiver, ); const result = applyFunction(once, receiver, [channel, callback]); + weakSetAdd(knownCanonicalListeners, listener); return result === receiver ? target : result; }; - if (typeof removeAllListeners !== "function") return; + const selectCanonicalRemovalArgument = (receiver, channel, removalTarget) => { + if (isProxy(receiver)) return { kind: "unknown" }; + const eventMapDescriptor = ownPropertyDescriptor(receiver, "_events"); + if ( + eventMapDescriptor === undefined || + !hasOwnProperty(eventMapDescriptor, "value") + ) + return { kind: "unknown" }; + const eventMap = eventMapDescriptor.value; + if (eventMap === undefined) return { kind: "absent" }; + if ( + eventMap === null || + typeof eventMap !== "object" || + isProxy(eventMap) || + getPrototypeOf(eventMap) !== null + ) + return { kind: "unknown" }; + const eventDescriptor = ownPropertyDescriptor(eventMap, channel); + if (eventDescriptor === undefined) return { kind: "absent" }; + if (!hasOwnProperty(eventDescriptor, "value")) return { kind: "unknown" }; + const list = eventDescriptor.value; + if (list === undefined) return { kind: "absent" }; + const examineEntry = (entry) => { + if (entry === removalTarget) + return { kind: "matched", nativeArgument: removalTarget }; + const ownedTarget = weakMapGet(hookListenerRemovalTargets, entry); + if (ownedTarget === removalTarget) { + const descriptor = listenerDataDescriptor(entry); + return { + kind: "matched", + nativeArgument: + descriptor !== null && + descriptor !== undefined && + hasOwnProperty(descriptor, "value") && + descriptor.value === removalTarget + ? removalTarget + : entry, + }; + } + const descriptor = listenerDataDescriptor(entry); + // An application getter, Proxy or opaque descriptor may match or throw + // during native removal. Do not pre-read it or choose an earlier entry; + // let Node execute the original removal path exactly once. + if (descriptor === null) return { kind: "unknown" }; + const linkedTarget = descriptor?.value; + if (linkedTarget === removalTarget) + return { kind: "matched", nativeArgument: removalTarget }; + if ( + weakMapGet(hookListenerRemovalTargets, linkedTarget) === removalTarget + ) + return { kind: "matched", nativeArgument: linkedTarget }; + return null; + }; + if (typeof list === "function") + return examineEntry(list) ?? { kind: "absent" }; + if (isProxy(list)) return { kind: "unknown" }; + if (!arrayIsArray(list)) return { kind: "unknown" }; + const lengthDescriptor = ownPropertyDescriptor(list, "length"); + if ( + lengthDescriptor === undefined || + !hasOwnProperty(lengthDescriptor, "value") || + typeof lengthDescriptor.value !== "number" || + lengthDescriptor.value < 0 || + lengthDescriptor.value > 0xffffffff || + lengthDescriptor.value % 1 !== 0 + ) + return { kind: "unknown" }; + for (let index = lengthDescriptor.value - 1; index >= 0; index -= 1) { + const entryDescriptor = ownPropertyDescriptor(list, index); + if ( + entryDescriptor === undefined || + !hasOwnProperty(entryDescriptor, "value") || + typeof entryDescriptor.value !== "function" + ) + return { kind: "unknown" }; + const selected = examineEntry(entryDescriptor.value); + if (selected !== null) return selected; + } + return { kind: "absent" }; + }; + const removeWithCanonicalOwnership = ( + nativeMethod, + receiver, + args, + boundary, + ) => { + const channel = args[0]; + const removalTarget = args[1]; + if ( + receiver !== ipcMain || + (typeof channel !== "string" && typeof channel !== "symbol") || + !weakSetHas(knownCanonicalListeners, removalTarget) + ) + return applyFunction(nativeMethod, receiver, args); + const selected = selectCanonicalRemovalArgument( + receiver, + channel, + removalTarget, + ); + if (selected.kind === "unknown") + recordIpcObservationGap( + typeof channel === "string" ? channel : null, + "listener-removal", + false, + boundary, + ); + if ( + selected.kind !== "matched" || + selected.nativeArgument === removalTarget + ) + return applyFunction(nativeMethod, receiver, args); + // Native removal must receive the argument identity that proves its newest + // match. Preserve native ownership of traversal, mutation and meta-events. + const nativeReceiver = new ProxyConstructor( + { __proto__: null }, + { + get(_target, key) { + const value = getProperty(receiver, key, receiver); + if (key !== "emit" || typeof value !== "function") return value; + return function emitWithCanonicalRemovalMeta(event, ...eventArgs) { + if (event === "removeListener") { + if (eventArgs[1] === selected.nativeArgument) { + eventArgs[1] = removalTarget; + } else { + const canonical = weakMapGet( + hookListenerRemovalTargets, + eventArgs[1], + ); + if (canonical !== undefined && canonical !== eventArgs[1]) + eventArgs[1] = canonical; + } + } + return applyFunction(value, receiver, [event, ...eventArgs]); + }; + }, + set(_target, key, value) { + return setProperty(receiver, key, value, receiver); + }, + }, + ); + const nativeArgs = [channel, selected.nativeArgument]; + for (let index = 2; index < args.length; index += 1) + nativeArgs[index] = args[index]; + const result = applyFunction(nativeMethod, nativeReceiver, nativeArgs); + return result === nativeReceiver ? receiver : result; + }; + if (methodsKnown && typeof nativeRemoveListener === "function") + ipcMain.removeListener = function patchedIpcMainRemoveListener(...args) { + return removeWithCanonicalOwnership( + nativeRemoveListener, + this, + args, + "ipcMain.removeListener", + ); + }; + if (methodsKnown && typeof nativeOff === "function") + ipcMain.off = function patchedIpcMainOff(...args) { + return removeWithCanonicalOwnership(nativeOff, this, args, "ipcMain.off"); + }; + if (!methodsKnown || typeof removeAllListeners !== "function") return; // Node owns its live-array traversal. A forwarding receiver translates only // our "on" entries before calling the current application removal method, // including decorators installed/replaced during traversal. Application @@ -697,6 +989,8 @@ const patchIpcMain = (ipcMain) => { ...args ) { const emitter = this; + if (emitter !== ipcMain) + return applyFunction(removeAllListeners, emitter, args); // A separate empty target also permits forwarding a non-writable app // method; a Proxy of the emitter itself would violate get invariants. const receiver = new ProxyConstructor( @@ -716,13 +1010,10 @@ const patchIpcMain = (ipcMain) => { hookListenerRemovalTargets, entry, ); - // Translate only a still-valid data link. If app code changed it - // to an accessor, native removal owns when that getter executes. - if ( - removalTarget !== undefined && - removalTarget !== entry && - listenerDataDescriptor(entry)?.value === removalTarget - ) + // The registration registry owns the original callback even + // when its public data link was deleted or changed. The native + // boundary below still owns any accessor read and its outcome. + if (removalTarget !== undefined && removalTarget !== entry) methodArgs[1] = removalTarget; } return applyFunction(value, emitter, methodArgs); diff --git a/src/browser/PlaywrightElectronActiveProjection.test.ts b/src/browser/PlaywrightElectronActiveProjection.test.ts index 5fcc931a09..0884b5b795 100644 --- a/src/browser/PlaywrightElectronActiveProjection.test.ts +++ b/src/browser/PlaywrightElectronActiveProjection.test.ts @@ -24,7 +24,13 @@ const event = { error: false, }; -it.each(["custom-apply", "listener-chain"])( +it.each([ + "custom-apply", + "listener-chain", + "once-metadata", + "listener-registration", + "listener-removal", +])( "keeps %s gaps distinct from captured IPC and retention loss", (boundary) => { const result = projectElectronActiveCapture( diff --git a/src/browser/PlaywrightElectronActiveProjection.ts b/src/browser/PlaywrightElectronActiveProjection.ts index facb233f17..cc14e8c77b 100644 --- a/src/browser/PlaywrightElectronActiveProjection.ts +++ b/src/browser/PlaywrightElectronActiveProjection.ts @@ -186,7 +186,7 @@ export const projectElectronActiveCapture = ( "IPC payloads are represented by value shapes; payload values are never retained.", ...(observations.observedEventFamilies.includes("ipc-observation-gap") ? [ - "IPC observation gaps have unobserved callback arguments and execution. Available ipc-observation-gap timeline records identify the custom-apply or listener-chain boundary and registered channel; dropped gap records are counted by retention. Listener-chain gaps describe registration uncertainty, not proof of IPC delivery.", + "IPC observation gaps have unobserved callback arguments and execution. Available ipc-observation-gap timeline records identify the callable, registration or removal boundary and channel where known; dropped gap records are counted by retention. Registration gaps do not prove IPC delivery, and removal gaps do not confirm which registration was removed.", ] : []), "IPC direction and sender/receiver identifiers are observed only where Electron exposes them at the hooked boundary.", diff --git a/tests/boundary/browser/electronActiveHook.test.ts b/tests/boundary/browser/electronActiveHook.test.ts index 7547d6f0ca..102d570d55 100644 --- a/tests/boundary/browser/electronActiveHook.test.ts +++ b/tests/boundary/browser/electronActiveHook.test.ts @@ -1381,6 +1381,48 @@ console.log(JSON.stringify({ plain, hooked, }, ); +it("uses the captured Array.isArray intrinsic while selecting a native listener entry", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-captured-array-is-array-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "captured-array-is-array.cjs"); + await writeFile( + script, + `const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const scenario = (emitter) => { + const listener = function original() {}; + const marker = function marker() {}; + const foreign = function foreign() {}; + emitter.on("ch", listener); + emitter.on("ch", marker); + emitter.rawListeners("ch")[0].listener = foreign; + const originalIsArray = Array.isArray; + let failure = null; + Array.isArray = () => { throw new Error("late application Array.isArray"); }; + try { emitter.removeListener("ch", listener); } + catch (cause) { failure = cause.message; } + finally { Array.isArray = originalIsArray; } + return { failure, remaining: emitter.listenerCount("ch"), eventNames: emitter.eventNames() }; +}; +console.log(JSON.stringify({ plain: scenario(new EventEmitter()), hooked: scenario(ipcMain), + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse(execution.stdout); + expect(result.hook_error).toBe(false); + expect(result.hooked).toEqual(result.plain); + expect(result.plain).toEqual({ + failure: null, + remaining: 1, + eventNames: ["ch"], + }); +}); + it.each([ "defineProperty", "getOwnPropertyDescriptor", @@ -1420,7 +1462,8 @@ const scenario = (target) => { const seen = []; let failure = null; try { - target.on("ch", () => seen.push("on")); + const onListener = () => seen.push("on"); + target.on("ch", onListener); target.once("one", () => seen.push("once")); target.on("removeListener", () => {}); target.emit("ch", null, 42); @@ -1835,6 +1878,1360 @@ process.stdout.write(JSON.stringify({ }); }); +it("does not read an application rawListeners accessor during hook setup", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-raw-listeners-accessor-", + ); + temporary.push(root); + const source = electronModuleSource.replace( + "const ipcMain = new EventEmitter();", + `const ipcMain = new EventEmitter(); +let rawListenersReads = 0; +Object.defineProperty(ipcMain, "rawListeners", { configurable: true, get() { + rawListenersReads++; + throw new Error("application rawListeners getter"); +}}); +globalThis.__rawListenersReads = () => rawListenersReads;`, + ); + await writeElectronFixture(root, source); + const script = join(root, "raw-listeners-accessor.cjs"); + await writeFile( + script, + `const { ipcMain } = require("electron"); +let calls = 0; +ipcMain.on("ch", () => calls++); +ipcMain.emit("ch"); +ipcMain.removeAllListeners("ch"); +console.log(JSON.stringify({ calls, reads: globalThis.__rawListenersReads(), + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + expect(JSON.parse(execution.stdout)).toEqual({ + calls: 1, + reads: 0, + hook_error: false, + }); +}); + +it("leaves an accessor removal method untouched and suppresses uncertain invocation evidence", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-removal-method-accessor-", + ); + temporary.push(root); + const source = electronModuleSource.replace( + "const ipcMain = new EventEmitter();", + `const ipcMain = new EventEmitter(); +let removeListenerReads = 0; +Object.defineProperty(ipcMain, "removeListener", { configurable: true, get() { + removeListenerReads++; + return EventEmitter.prototype.removeListener; +}}); +globalThis.__removeListenerReads = () => removeListenerReads;`, + ); + await writeElectronFixture(root, source); + const script = join(root, "removal-method-accessor.cjs"); + await writeFile( + script, + `const { ipcMain } = require("electron"); +const listener = function original() {}; +ipcMain.on("ch", listener); +ipcMain.emit("ch"); +ipcMain.removeAllListeners("ch"); +const snapshot = globalThis.__reaElectronActiveSnapshot(); +console.log(JSON.stringify({ reads: globalThis.__removeListenerReads(), + invocations: snapshot.events.filter((event) => event.kind === "main-event-invocation"), + gaps: snapshot.events.filter((event) => event.kind === "ipc-observation-gap"), + remaining: ipcMain.listenerCount("ch"), hook_error: snapshot.hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse(execution.stdout) as { + readonly reads: number; + readonly invocations: readonly unknown[]; + readonly gaps: readonly { + readonly event: string; + readonly channel: string | null; + }[]; + readonly remaining: number; + readonly hook_error: boolean; + }; + expect(result).toMatchObject({ + reads: 0, + invocations: [], + remaining: 0, + hook_error: false, + }); + expect(result.gaps).toEqual([ + expect.objectContaining({ event: "listener-registration", channel: "ch" }), + ]); +}); + +it("preserves native last-registration order when the last raw listener link is mutated", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-removal-mutated-lifo-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "removal-mutated-lifo.cjs"); + await writeFile( + script, + `const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const scenario = (emitter) => { + const calls = []; + const listener = () => calls.push("listener"); + const marker = () => calls.push("marker"); + const foreign = () => calls.push("foreign"); + const nativeRemove = emitter.removeListener; + let removals = 0; + let failure = null; + emitter.on("removeListener", () => {}); + emitter.on("ch", listener); + emitter.on("ch", marker); + emitter.on("ch", listener); + emitter.rawListeners("ch").at(-1).listener = foreign; + emitter.removeListener = function(channel, candidate) { + if (channel === "ch" && ++removals === 2) + throw new Error("second callback removal rejected"); + return Reflect.apply(nativeRemove, this, arguments); + }; + try { emitter.removeAllListeners("ch"); } catch (cause) { failure = cause.message; } + emitter.removeListener = nativeRemove; + calls.length = 0; + emitter.emit("ch"); + return { failure, removals, remaining: emitter.listenerCount("ch"), calls }; +}; +process.stdout.write(JSON.stringify({ + plain: scenario(new EventEmitter()), + hooked: scenario(ipcMain), + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error, +}) + "\\n"); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse( + execution.stdout.trim().split("\n").at(-1) ?? "null", + ) as { + readonly plain: { + readonly failure: string; + readonly removals: number; + readonly remaining: number; + readonly calls: readonly string[]; + }; + readonly hooked: { + readonly failure: string; + readonly removals: number; + readonly remaining: number; + readonly calls: readonly string[]; + }; + readonly hook_error: boolean; + }; + expect(result.hook_error).toBe(false); + expect(result.hooked).toEqual(result.plain); + expect(result.plain).toEqual({ + failure: "second callback removal rejected", + removals: 2, + remaining: 2, + calls: ["listener", "marker"], + }); +}); + +it("lets a later data listener link win over an earlier owned callback", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-removal-later-data-link-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "removal-later-data-link.cjs"); + await writeFile( + script, + `const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const scenario = (emitter) => { + const calls = []; + const original = function original() { calls.push("original"); }; + const later = function later() { calls.push("later"); }; + const removed = []; + emitter.on("removeListener", (_channel, callback) => removed.push(callback === original ? "original" : callback === later ? "later" : "other")); + emitter.on("ch", original); + emitter.on("ch", later); + emitter.rawListeners("ch")[1].listener = original; + emitter.removeListener("ch", original); + return { remaining: emitter.listenerCount("ch"), removed, + originalStillRegistered: emitter.listeners("ch").includes(original), + laterStillRegistered: emitter.listeners("ch").includes(later) }; +}; +console.log(JSON.stringify({ plain: scenario(new EventEmitter()), hooked: scenario(ipcMain), + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse(execution.stdout) as { + readonly plain: { + readonly remaining: number; + readonly removed: readonly string[]; + readonly originalStillRegistered: boolean; + readonly laterStillRegistered: boolean; + }; + readonly hooked: { + readonly remaining: number; + readonly removed: readonly string[]; + readonly originalStillRegistered: boolean; + readonly laterStillRegistered: boolean; + }; + readonly hook_error: boolean; + }; + expect(result.hook_error).toBe(false); + expect(result.hooked).toEqual(result.plain); + expect(result.plain).toEqual({ + remaining: 1, + removed: ["original"], + originalStillRegistered: true, + laterStillRegistered: false, + }); +}); + +it("normalizes a later raw link to an earlier owned registration", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-removal-owned-link-chain-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "removal-owned-link-chain.cjs"); + await writeFile( + script, + `const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const scenario = (emitter) => { + const calls = []; + const original = function original() { calls.push("original"); }; + const later = function later() { calls.push("later"); }; + const foreign = function foreign() {}; + const removed = []; + emitter.on("removeListener", (_channel, callback) => removed.push(callback === original ? "original" : callback === later ? "later" : "other")); + emitter.on("ch", original); + emitter.on("ch", later); + const first = emitter.rawListeners("ch")[0]; + const second = emitter.rawListeners("ch")[1]; + first.listener = foreign; + second.listener = first; + emitter.removeListener("ch", original); + emitter.emit("ch"); + return { remaining: emitter.listenerCount("ch"), removed, calls }; +}; +console.log(JSON.stringify({ plain: scenario(new EventEmitter()), hooked: scenario(ipcMain), + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse(execution.stdout) as { + readonly plain: { + readonly remaining: number; + readonly removed: readonly string[]; + readonly calls: readonly string[]; + }; + readonly hooked: { + readonly remaining: number; + readonly removed: readonly string[]; + readonly calls: readonly string[]; + }; + readonly hook_error: boolean; + }; + expect(result.hook_error).toBe(false); + expect(result.hooked).toEqual(result.plain); + expect(result.plain).toEqual({ + remaining: 1, + removed: ["original"], + calls: ["original"], + }); +}); + +it("preserves native direct-removal cleanup with throwing listener metadata", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-removal-accessor-gap-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "removal-accessor-gap.cjs"); + await writeFile( + script, + `const { ipcMain } = require("electron"); +const marker = new Error("listener metadata getter"); +let reads = 0; +const listener = function original() {}; +ipcMain.on("ch", listener); +Object.defineProperty(ipcMain.rawListeners("ch")[0], "listener", { get() { + reads++; + throw marker; +}}); +let failure; +try { ipcMain.removeListener("ch", listener); } catch (cause) { failure = cause; } +const snapshot = globalThis.__reaElectronActiveSnapshot(); +console.log(JSON.stringify({ reads, sameFailure: failure === marker, + remaining: ipcMain.listenerCount("ch"), + gaps: snapshot.events.filter((event) => event.kind === "ipc-observation-gap"), + hook_error: snapshot.hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse(execution.stdout) as { + readonly reads: number; + readonly sameFailure: boolean; + readonly remaining: number; + readonly gaps: readonly Record[]; + readonly hook_error: boolean; + }; + // Pinned Node24.18 removes the exact callback without reading its public + // metadata when no removeListener observer is registered. + expect(result).toMatchObject({ + reads: 0, + sameFailure: false, + remaining: 0, + hook_error: false, + }); + expect(result.gaps).toEqual([]); +}); + +it("forwards native direct-removal bookkeeping to the real emitter", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-removal-bookkeeping-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "removal-bookkeeping.cjs"); + await writeFile( + script, + `const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const scenario = (emitter) => { + const listener = function original() {}; + const foreign = function foreign() {}; + const payloads = []; + emitter.on("removeListener", (_channel, callback) => payloads.push(callback === foreign ? "foreign" : callback === listener ? "original" : "other")); + emitter.on("ch", listener); + emitter.rawListeners("ch")[0].listener = foreign; + emitter.removeListener("ch", listener); + const afterRemoval = { eventCount: emitter._eventsCount, names: emitter.eventNames().map(String) }; + emitter.on("after", () => {}); + const afterRegister = { eventCount: emitter._eventsCount, names: emitter.eventNames().map(String) }; + return { payloads, afterRemoval, afterRegister }; +}; +console.log(JSON.stringify({ plain: scenario(new EventEmitter()), hooked: scenario(ipcMain), + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse(execution.stdout) as { + readonly plain: { + readonly payloads: readonly string[]; + readonly afterRemoval: { + readonly eventCount: number; + readonly names: readonly string[]; + }; + readonly afterRegister: { + readonly eventCount: number; + readonly names: readonly string[]; + }; + }; + readonly hooked: { + readonly payloads: readonly string[]; + readonly afterRemoval: { + readonly eventCount: number; + readonly names: readonly string[]; + }; + readonly afterRegister: { + readonly eventCount: number; + readonly names: readonly string[]; + }; + }; + readonly hook_error: boolean; + }; + expect(result.hook_error).toBe(false); + expect(result.hooked).toEqual(result.plain); + expect(result.plain).toEqual({ + payloads: ["foreign"], + afterRemoval: { eventCount: 1, names: ["removeListener"] }, + afterRegister: { eventCount: 2, names: ["removeListener", "after"] }, + }); +}); + +it.each([1, 2] as const)( + "preserves owned listener accessor meta-event behavior with %s registration(s)", + async (registrationCount) => { + const root = await createTestTempDirectory( + "rea-electron-hook-removal-meta-accessor-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "removal-meta-accessor.cjs"); + await writeFile( + script, + `const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const registrationCount = ${JSON.stringify(registrationCount)}; +const scenario = (emitter) => { + const stages = []; + const listener = function original() {}; + const marker = function marker() {}; + let reads = 0; + emitter.on("removeListener", (channel, callback) => { + stages.push({ kind: "meta", channel, callbackIsOriginal: callback === listener, + callbackIsMarker: callback === marker, remaining: emitter.listenerCount("ch") }); + }); + for (let index = 0; index < registrationCount; index++) emitter.on("ch", listener); + if (registrationCount === 2) emitter.on("ch", marker); + Object.defineProperty(emitter.rawListeners("ch")[0], "listener", { configurable: true, get() { + reads++; + stages.push({ kind: "getter" }); + return listener; + }}); + let failure = null; + try { emitter.removeListener("ch", listener); } catch (cause) { failure = cause.message; } + return { reads, failure, remaining: emitter.listenerCount("ch"), stages }; +}; +const plain = scenario(new EventEmitter()); +const hooked = scenario(ipcMain); +console.log(JSON.stringify({ plain, hooked, + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse(execution.stdout) as { + readonly plain: { + readonly reads: number; + readonly failure: string | null; + readonly remaining: number; + readonly stages: readonly Record[]; + }; + readonly hooked: { + readonly reads: number; + readonly failure: string | null; + readonly remaining: number; + readonly stages: readonly Record[]; + }; + readonly hook_error: boolean; + }; + expect(result.hook_error).toBe(false); + expect(result.hooked).toEqual(result.plain); + expect(result.plain.reads).toBe(registrationCount === 1 ? 1 : 0); + expect(result.plain.failure).toBeNull(); + expect(result.plain.stages[0]).toMatchObject( + registrationCount === 1 ? { kind: "getter" } : { kind: "meta" }, + ); + expect(result.plain.stages.at(-1)).toMatchObject({ + kind: "meta", + channel: "ch", + callbackIsOriginal: true, + remaining: registrationCount === 1 ? 0 : 2, + }); + }, +); + +it("keeps listener ownership when native function prototype metadata changes", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-wrapper-constructor-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "wrapper-constructor.cjs"); + await writeFile( + script, + `const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const scenario = (emitter) => { + const listener = function original() {}; + const foreign = function foreign() {}; + emitter.on("ch", listener); + const entry = emitter.rawListeners("ch")[0]; + const constructor = entry.prototype.constructor; + const constructorIsPublicEntry = constructor === entry; + constructor.listener = foreign; + emitter.removeListener("ch", listener); + return { constructorIsPublicEntry, remaining: emitter.listenerCount("ch") }; +}; +console.log(JSON.stringify({ plain: scenario(new EventEmitter()), hooked: scenario(ipcMain), + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse(execution.stdout); + expect(result.hook_error).toBe(false); + expect(result.hooked).toEqual(result.plain); + expect(result.plain).toEqual({ + constructorIsPublicEntry: true, + remaining: 0, + }); +}); + +it("matches native custom inspection that mutates the raw listener", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-removal-inspect-mutation-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "removal-inspect-mutation.cjs"); + await writeFile( + script, + `const { inspect } = require("node:util"); +const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const scenario = (emitter) => { + const listener = function original() {}; + const foreign = function foreign() {}; + emitter.on("ch", listener); + const raw = emitter.rawListeners("ch")[0]; + let inspectorReceiverIsRaw = false; + raw[inspect.custom] = function() { + inspectorReceiverIsRaw = this === raw; + this.listener = foreign; + return "listener inspected"; + }; + const description = inspect(raw, { showProxy: true }); + emitter.removeListener("ch", listener); + return { inspectorReceiverIsRaw, description, + remaining: emitter.listenerCount("ch") }; +}; +console.log(JSON.stringify({ plain: scenario(new EventEmitter()), hooked: scenario(ipcMain), + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse(execution.stdout); + expect(result.hook_error).toBe(false); + expect(result.hooked).toEqual(result.plain); + expect(result.plain).toEqual({ + inspectorReceiverIsRaw: true, + description: "listener inspected", + remaining: 0, + }); +}); + +it("normalizes an earlier raw owner in a later removal meta payload", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-removal-owned-meta-link-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "removal-owned-meta-link.cjs"); + await writeFile( + script, + `const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const scenario = (emitter) => { + const original = function original() {}; + const later = function later() {}; + const payloads = []; + emitter.on("removeListener", (_channel, callback) => payloads.push( + callback === original ? "original" : callback === later ? "later" : "other")); + emitter.on("ch", original); + const firstRaw = emitter.rawListeners("ch")[0]; + emitter.removeListener("ch", original); + emitter.on("ch", later); + emitter.rawListeners("ch")[0].listener = firstRaw; + emitter.removeListener("ch", original); + return { payloads, remaining: emitter.listenerCount("ch") }; +}; +console.log(JSON.stringify({ plain: scenario(new EventEmitter()), hooked: scenario(ipcMain), + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse(execution.stdout); + expect(result.hook_error).toBe(false); + expect(result.hooked).toEqual(result.plain); + expect(result.plain).toEqual({ + payloads: ["original", "original"], + remaining: 0, + }); +}); + +it("preserves the native argument that proves a chained removal match", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-removal-chain-argument-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "removal-chain-argument.cjs"); + await writeFile( + script, + `const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const scenario = (emitter) => { + const calls = []; + const original = function original() { calls.push("original"); }; + const g = function g() { calls.push("g"); }; + const h = function h() { calls.push("h"); }; + const removed = []; + emitter.on("removeListener", (_channel, callback) => removed.push( + callback === original ? "original" : callback === g ? "g" : callback === h ? "h" : "other")); + emitter.on("ch", original); + const firstRaw = emitter.rawListeners("ch")[0]; + g.listener = firstRaw; + emitter.on("ch", g); + h.listener = g; + emitter.on("ch", h); + emitter.removeListener("ch", original); + emitter.emit("ch"); + return { remaining: emitter.listenerCount("ch"), removed, calls }; +}; +console.log(JSON.stringify({ plain: scenario(new EventEmitter()), hooked: scenario(ipcMain), + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse(execution.stdout); + expect(result.hook_error).toBe(false); + expect(result.hooked).toEqual(result.plain); + expect(result.plain).toEqual({ + remaining: 2, + removed: ["original"], + calls: ["original", "h"], + }); +}); + +it("falls back to native errors for a revoked listener-array proxy", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-revoked-listener-array-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "revoked-listener-array.cjs"); + await writeFile( + script, + `const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const scenario = (emitter) => { + const listener = function original() {}; + const marker = function marker() {}; + emitter.on("ch", listener); + emitter.on("ch", marker); + const entry = emitter.rawListeners("ch")[0]; + entry.listener = function foreign() {}; + const revocable = Proxy.revocable(emitter._events.ch, {}); + emitter._events.ch = revocable.proxy; + revocable.revoke(); + let failure = null; + try { emitter.removeListener("ch", listener); } catch (cause) { failure = { name: cause.name, message: cause.message }; } + return failure; +}; +const plain = scenario(new EventEmitter()); +const hooked = scenario(ipcMain); +console.log(JSON.stringify({ plain, hooked, + gaps: globalThis.__reaElectronActiveSnapshot().events.filter((event) => event.kind === "ipc-observation-gap"), + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse(execution.stdout) as { + readonly plain: { readonly name: string; readonly message: string }; + readonly hooked: { readonly name: string; readonly message: string }; + readonly gaps: readonly { + readonly event: string; + readonly target: string; + }[]; + readonly hook_error: boolean; + }; + expect(result.hook_error).toBe(false); + expect(result.hooked).toEqual(result.plain); + expect(result.gaps).toContainEqual( + expect.objectContaining({ + event: "listener-removal", + target: "ipcMain.removeListener", + }), + ); +}); + +it("records a gap when an opaque later registration blocks ownership ordering", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-removal-opaque-gap-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "removal-opaque-gap.cjs"); + await writeFile( + script, + `const { ipcMain } = require("electron"); +const listener = function original() {}; +const opaque = function opaque() {}; +ipcMain.on("ch", listener); +ipcMain.on("ch", opaque); +ipcMain.rawListeners("ch")[0].listener = listener; +Object.defineProperty(ipcMain.rawListeners("ch")[1], "listener", { get() { return undefined; } }); +ipcMain.removeListener("ch", listener); +const snapshot = globalThis.__reaElectronActiveSnapshot(); +console.log(JSON.stringify({ remaining: ipcMain.listenerCount("ch"), + gaps: snapshot.events.filter((event) => event.kind === "ipc-observation-gap"), + hook_error: snapshot.hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse(execution.stdout) as { + readonly remaining: number; + readonly gaps: readonly { + readonly event: string; + readonly channel: string | null; + }[]; + readonly hook_error: boolean; + }; + expect(result.remaining).toBe(1); + expect(result.gaps).toContainEqual( + expect.objectContaining({ + kind: "ipc-observation-gap", + event: "listener-removal", + phase: "observed", + channel: "ch", + target: "ipcMain.removeListener", + process_type: "main", + error: false, + }), + ); + expect(result.hook_error).toBe(false); +}); + +it("preserves pre-hook removeListener decorator receiver and callback identity", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-preinstalled-removal-decorator-", + ); + temporary.push(root); + const source = electronModuleSource.replace( + "const ipcMain = new EventEmitter();", + `const ipcMain = new EventEmitter(); +globalThis.__preHookRemovalCalls = []; +const originalRemoveListener = EventEmitter.prototype.removeListener; +ipcMain.removeListener = function(channel, callback) { + globalThis.__preHookRemovalCalls.push({ + receiverIsEmitter: this === ipcMain, + callbackName: callback.name, + }); + return Reflect.apply(originalRemoveListener, this, arguments); +};`, + ); + await writeElectronFixture(root, source); + const script = join(root, "preinstalled-removal-decorator.cjs"); + await writeFile( + script, + `const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const observations = globalThis.__preHookRemovalCalls; +const decorate = (emitter) => { + const native = EventEmitter.prototype.removeListener; + emitter.removeListener = function(channel, callback) { + observations.push({ receiverIsEmitter: this === emitter, callbackName: callback.name }); + return Reflect.apply(native, this, arguments); + }; + return emitter; +}; +const scenario = (emitter) => { + const original = function original() {}; + emitter.on("ch", original); + emitter.rawListeners("ch")[0].listener = () => {}; + emitter.removeListener("ch", original); + return emitter.listenerCount("ch"); +}; +const plain = scenario(decorate(new EventEmitter())); +const hooked = scenario(ipcMain); +console.log(JSON.stringify({ plain, hooked, observations, + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse(execution.stdout) as { + readonly plain: number; + readonly hooked: number; + readonly observations: readonly { + readonly receiverIsEmitter: boolean; + readonly callbackName: string; + }[]; + readonly hook_error: boolean; + }; + expect(result.hook_error).toBe(false); + expect(result.plain).toBe(0); + expect(result.hooked).toBe(0); + expect(result.observations[0]).toEqual({ + receiverIsEmitter: true, + callbackName: "original", + }); + expect(result.observations[1]).toEqual(result.observations[0]); +}); + +it("forwards pre-hook on and once decorators exact registrations with an explicit gap", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-preinstalled-registration-decorators-", + ); + temporary.push(root); + const source = electronModuleSource.replace( + "const ipcMain = new EventEmitter();", + `const ipcMain = new EventEmitter(); +globalThis.__preHookRegistrationCalls = []; +const install = (emitter) => { + const nativeOn = EventEmitter.prototype.on; + const nativeOnce = EventEmitter.prototype.once; + emitter.on = function(channel, callback) { + globalThis.__preHookRegistrationCalls.push({ + method: "on", receiverIsEmitter: this === emitter, callbackName: callback.name, + }); + return Reflect.apply(nativeOn, this, arguments); + }; + emitter.once = function(channel, callback) { + globalThis.__preHookRegistrationCalls.push({ + method: "once", receiverIsEmitter: this === emitter, callbackName: callback.name, + }); + return Reflect.apply(nativeOnce, this, arguments); + }; +}; +install(ipcMain);`, + ); + await writeElectronFixture(root, source); + const script = join(root, "preinstalled-registration-decorators.cjs"); + await writeFile( + script, + `const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const observations = globalThis.__preHookRegistrationCalls; +const decorate = (emitter) => { + const nativeOn = EventEmitter.prototype.on; + const nativeOnce = EventEmitter.prototype.once; + emitter.on = function(channel, callback) { + observations.push({ method: "on", receiverIsEmitter: this === emitter, callbackName: callback.name }); + return Reflect.apply(nativeOn, this, arguments); + }; + emitter.once = function(channel, callback) { + observations.push({ method: "once", receiverIsEmitter: this === emitter, callbackName: callback.name }); + return Reflect.apply(nativeOnce, this, arguments); + }; + return emitter; +}; +const scenario = (emitter) => { + const calls = []; + const ordinary = function ordinary() { calls.push("ordinary"); }; + const oneShot = function oneShot() { calls.push("oneShot"); }; + emitter.on("ch", ordinary); + emitter.once("ch", oneShot); + emitter.emit("ch"); + emitter.removeAllListeners("ch"); + return calls; +}; +const plain = scenario(decorate(new EventEmitter())); +const hooked = scenario(ipcMain); +const snapshot = globalThis.__reaElectronActiveSnapshot(); +console.log(JSON.stringify({ plain, hooked, observations, + calls: snapshot.events.filter((event) => event.kind === "main-event-invocation"), + gaps: snapshot.events.filter((event) => event.kind === "ipc-observation-gap"), + hook_error: snapshot.hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse(execution.stdout) as { + readonly plain: readonly string[]; + readonly hooked: readonly string[]; + readonly observations: readonly { + readonly method: string; + readonly receiverIsEmitter: boolean; + readonly callbackName: string; + }[]; + readonly calls: readonly unknown[]; + readonly gaps: readonly { + readonly event: string; + readonly channel: string | null; + }[]; + readonly hook_error: boolean; + }; + expect(result.hook_error).toBe(false); + expect(result.plain).toEqual(["ordinary", "oneShot"]); + expect(result.hooked).toEqual(result.plain); + expect(result.observations).toEqual([ + { method: "on", receiverIsEmitter: true, callbackName: "ordinary" }, + { method: "once", receiverIsEmitter: true, callbackName: "oneShot" }, + { + method: "on", + receiverIsEmitter: true, + callbackName: "bound onceWrapper", + }, + { method: "on", receiverIsEmitter: true, callbackName: "ordinary" }, + { method: "once", receiverIsEmitter: true, callbackName: "oneShot" }, + { + method: "on", + receiverIsEmitter: true, + callbackName: "bound onceWrapper", + }, + ]); + expect(result.calls).toEqual([]); + expect( + result.gaps.filter((gap) => gap.event === "listener-registration"), + ).toHaveLength(2); + expect(result.gaps.map((gap) => gap.channel)).toEqual(["ch", "ch"]); +}); + +it("forwards borrowed on and once registrations without attributing calls to ipcMain", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-borrowed-registration-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "borrowed-registration.cjs"); + await writeFile( + script, + `const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const scenario = (hooked) => { + const target = new EventEmitter(); + const registrations = []; + const calls = []; + const originalOn = hooked ? ipcMain.on : EventEmitter.prototype.on; + const originalOnce = hooked ? ipcMain.once : EventEmitter.prototype.once; + const onListener = function onListener() { calls.push("on"); }; + const onceListener = function onceListener() { calls.push("once"); }; + const foreign = function foreign() {}; + EventEmitter.prototype.on.call(target, "newListener", (channel, callback) => { + if (channel === "on") registrations.push(callback === onListener); + if (channel === "once") registrations.push(callback === onceListener); + }); + Reflect.apply(originalOn, target, ["on", onListener]); + target.rawListeners("on")[0].listener = foreign; + target.removeListener("on", onListener); + target.emit("on"); + Reflect.apply(originalOnce, target, ["once", onceListener]); + target.rawListeners("once")[0].listener = foreign; + target.removeListener("once", onceListener); + target.emit("once"); + return { registrations, calls, remaining: target.eventNames().map(String) }; +}; +const plain = scenario(false); +const hooked = scenario(true); +const invalid = (method, target) => { + try { Reflect.apply(target[method], null, ["invalid", () => {}]); return null; } + catch (cause) { return { name: cause.name, message: cause.message }; } +}; +const failures = { plain: {}, hooked: {} }; +for (const method of ["on", "once"]) { + failures.plain[method] = invalid(method, EventEmitter.prototype); + failures.hooked[method] = invalid(method, ipcMain); +} +const snapshot = globalThis.__reaElectronActiveSnapshot(); +console.log(JSON.stringify({ plain, hooked, failures, + gaps: snapshot.events.filter((event) => event.kind === "ipc-observation-gap").map((event) => ({ + channel: event.channel, event: event.event, target: event.target, error: event.error, + })), + invocations: snapshot.events.filter((event) => event.kind === "main-event-invocation").length, + hook_error: snapshot.hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse(execution.stdout); + expect(result.hook_error).toBe(false); + expect(result.hooked).toEqual(result.plain); + expect(result.plain).toEqual({ + registrations: [true, true], + calls: ["once"], + remaining: ["newListener"], + }); + expect(result.failures.hooked).toEqual(result.failures.plain); + expect(result.failures.plain.on.name).toBe("TypeError"); + expect(result.failures.plain.once.name).toBe("TypeError"); + expect(result.gaps).toEqual([ + { + channel: "on", + event: "listener-registration", + target: "ipcMain.on", + error: false, + }, + { + channel: "once", + event: "listener-registration", + target: "ipcMain.once", + error: false, + }, + { + channel: "invalid", + event: "listener-registration", + target: "ipcMain.on", + error: true, + }, + { + channel: "invalid", + event: "listener-registration", + target: "ipcMain.once", + error: true, + }, + ]); + expect(result.invocations).toBe(0); +}); + +it("does not inspect object event channels while recording registration gaps", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-object-channel-gap-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "object-channel-gap.cjs"); + await writeFile( + script, + `const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const scenario = (hooked) => { + const target = new EventEmitter(); + const effects = []; + const channel = { + [Symbol.toPrimitive](hint) { effects.push("coerce:" + hint); return "object-channel"; }, + get toJSON() { effects.push("get:toJSON"); throw new Error("unexpected channel serialization"); }, + }; + const listener = function original() {}; + const registration = hooked ? ipcMain.on : EventEmitter.prototype.on; + Reflect.apply(registration, target, [channel, listener]); + return { effects, eventNames: target.eventNames() }; +}; +const plain = scenario(false); +const hooked = scenario(true); +const snapshot = globalThis.__reaElectronActiveSnapshot(); +console.log(JSON.stringify({ plain, hooked, + gaps: snapshot.events.filter((event) => event.kind === "ipc-observation-gap").map((event) => ({ + channel: event.channel, event: event.event, target: event.target, + })), hook_error: snapshot.hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse(execution.stdout); + expect(result.hook_error).toBe(false); + expect(result.hooked).toEqual(result.plain); + expect(result.plain.effects).toEqual(["coerce:string", "coerce:string"]); + expect(result.plain.eventNames).toEqual(["object-channel"]); + expect(result.gaps).toEqual([ + { channel: null, event: "listener-registration", target: "ipcMain.on" }, + ]); +}); + +it("preserves borrowed removeAllListeners callback identity", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-borrowed-remove-all-identity-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "borrowed-remove-all-identity.cjs"); + await writeFile( + script, + `const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const publicListener = (() => { + const listener = function original() {}; + ipcMain.on("source", listener); + return ipcMain.rawListeners("source")[0]; +})(); +const makeForeignEmitter = () => { + const emitter = new EventEmitter(); + const received = []; + emitter.on("removeListener", () => {}); + emitter.on("ch", publicListener); + const nativeRemove = EventEmitter.prototype.removeListener; + emitter.removeListener = function(channel, callback) { + if (channel === "ch") { + received.push(callback); + if (callback !== publicListener) throw new Error("wrong removal callback"); + } + return Reflect.apply(nativeRemove, this, arguments); + }; + return { emitter, received }; +}; +const control = makeForeignEmitter(); +const borrowed = makeForeignEmitter(); +EventEmitter.prototype.removeAllListeners.call(control.emitter, "ch"); +let failure = null; +try { Reflect.apply(ipcMain.removeAllListeners, borrowed.emitter, ["ch"]); } +catch (cause) { failure = cause.message; } +console.log(JSON.stringify({ + controlReceivedPublic: control.received.length === 1 && control.received[0] === publicListener, + borrowedReceivedPublic: borrowed.received.length === 1 && borrowed.received[0] === publicListener, + controlRemaining: control.emitter.listenerCount("ch"), + borrowedRemaining: borrowed.emitter.listenerCount("ch"), + failure, + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error, +})); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + expect(JSON.parse(execution.stdout)).toEqual({ + controlReceivedPublic: true, + borrowedReceivedPublic: true, + controlRemaining: 0, + borrowedRemaining: 0, + failure: null, + hook_error: false, + }); +}); + +it("does not coerce object channels before native listener removal", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-object-channel-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "object-channel.cjs"); + await writeFile( + script, + `const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const scenario = (emitter) => { + let conversions = 0; + const listener = () => {}; + emitter.on("ch", listener); + const rawListener = emitter.rawListeners("ch")[0]; + rawListener.listener = () => {}; + rawListener.listener = listener; + const channel = { [Symbol.toPrimitive]() { conversions++; return "ch"; } }; + emitter.removeListener(channel, listener); + return { conversions, remaining: emitter.listenerCount("ch") }; +}; +console.log(JSON.stringify({ plain: scenario(new EventEmitter()), hooked: scenario(ipcMain), + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse(execution.stdout) as { + readonly plain: { + readonly conversions: number; + readonly remaining: number; + }; + readonly hooked: { + readonly conversions: number; + readonly remaining: number; + }; + readonly hook_error: boolean; + }; + expect(result.hook_error).toBe(false); + expect(result.hooked).toEqual(result.plain); + expect(result.plain).toEqual({ conversions: 1, remaining: 0 }); +}); + +it("removes observed ipcMain registrations after the public listener link is deleted", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-removal-deleted-link-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "removal-deleted-link.cjs"); + await writeFile( + script, + `const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const scenario = (emitter) => { + const listener = () => {}; + const removed = []; + const nativeRemove = emitter.removeListener; + let seenOriginal = false; + emitter.on("removeListener", (_channel, callback) => removed.push(callback === listener)); + emitter.on("ch", listener); + delete emitter.rawListeners("ch")[0].listener; + emitter.removeListener = function(channel, callback) { + if (channel === "ch") seenOriginal = callback === listener; + return Reflect.apply(nativeRemove, this, arguments); + }; + emitter.removeAllListeners("ch"); + emitter.removeListener = nativeRemove; + return { seenOriginal, remaining: emitter.listenerCount("ch"), removed }; +}; +process.stdout.write(JSON.stringify({ + plain: scenario(new EventEmitter()), + hooked: scenario(ipcMain), + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error, +}) + "\\n"); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse( + execution.stdout.trim().split("\n").at(-1) ?? "null", + ) as { + readonly plain: { + readonly seenOriginal: boolean; + readonly remaining: number; + readonly removed: readonly boolean[]; + }; + readonly hooked: { + readonly seenOriginal: boolean; + readonly remaining: number; + readonly removed: readonly boolean[]; + }; + readonly hook_error: boolean; + }; + expect(result.hook_error).toBe(false); + expect(result.hooked).toEqual(result.plain); + expect(result.plain).toEqual({ + seenOriginal: true, + remaining: 0, + removed: [true], + }); +}); + +it("passes canonical owned callbacks through post-hook removeAll decorators despite accessor metadata", async () => { + const root = await createTestTempDirectory( + "rea-electron-hook-remove-all-decorator-accessor-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "remove-all-decorator-accessor.cjs"); + await writeFile( + script, + `const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const scenario = (emitter, hooked) => { + const listener = function original() {}; + const removals = []; + let reads = 0; + emitter.on("removeListener", () => {}); + emitter.on("ch", listener); + Object.defineProperty(emitter.rawListeners("ch")[0], "listener", { configurable: true, get() { + reads++; + throw new Error("metadata getter"); + }}); + const capturedRemove = hooked ? emitter.removeListener : EventEmitter.prototype.removeListener; + emitter.removeListener = function(channel, callback) { + if (channel === "ch") { + removals.push({ receiverIsEmitter: this === emitter, callbackIsOriginal: callback === listener }); + if (callback !== listener) throw new Error("decorator received noncanonical callback"); + } + return Reflect.apply(capturedRemove, this, arguments); + }; + let failure = null; + try { emitter.removeAllListeners("ch"); } catch (cause) { failure = cause.message; } + return { reads, failure, remaining: emitter.listenerCount("ch"), removals }; +}; +const plain = scenario(new EventEmitter(), false); +const hooked = scenario(ipcMain, true); +console.log(JSON.stringify({ plain, hooked, + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error })); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse(execution.stdout) as { + readonly plain: { + readonly reads: number; + readonly failure: string | null; + readonly remaining: number; + readonly removals: readonly { + readonly receiverIsEmitter: boolean; + readonly callbackIsOriginal: boolean; + }[]; + }; + readonly hooked: { + readonly reads: number; + readonly failure: string | null; + readonly remaining: number; + readonly removals: readonly { + readonly receiverIsEmitter: boolean; + readonly callbackIsOriginal: boolean; + }[]; + }; + readonly hook_error: boolean; + }; + expect(result.hook_error).toBe(false); + expect(result.hooked).toEqual(result.plain); + expect(result.plain).toEqual({ + reads: 1, + failure: "metadata getter", + remaining: 0, + removals: [{ receiverIsEmitter: true, callbackIsOriginal: true }], + }); +}); + +it.each(["removeListener", "off"] as const)( + "preserves direct %s cleanup for changed and deleted raw listener links", + async (method) => { + const root = await createTestTempDirectory( + "rea-electron-hook-removal-direct-link-", + ); + temporary.push(root); + await writeElectronFixture(root); + const script = join(root, "removal-direct-link.cjs"); + await writeFile( + script, + `const { EventEmitter } = require("node:events"); +const { ipcMain } = require("electron"); +const method = ${JSON.stringify(method)}; +const scenario = (emitter, deletion) => { + const listener = () => {}; + emitter.on("ch", listener); + const entry = emitter.rawListeners("ch")[0]; + if (deletion) delete entry.listener; + else entry.listener = () => {}; + const returned = emitter[method]("ch", listener); + return { remaining: emitter.listenerCount("ch"), returnedEmitter: returned === emitter }; +}; +const outcomes = {}; +for (const deletion of [false, true]) { + const plain = new EventEmitter(); + outcomes[deletion ? "deleted" : "changed"] = { + plain: scenario(plain, deletion), + hooked: scenario(ipcMain, deletion), + }; + ipcMain.removeAllListeners(); +} +process.stdout.write(JSON.stringify({ + outcomes, + hook_error: globalThis.__reaElectronActiveSnapshot().hook_error, +}) + "\\n"); +`, + ); + const execution = await runNode(script, root); + expect(execution.code, execution.stderr).toBe(0); + const result = JSON.parse( + execution.stdout.trim().split("\n").at(-1) ?? "null", + ) as { + readonly outcomes: Record< + string, + { + readonly plain: { + readonly remaining: number; + readonly returnedEmitter: boolean; + }; + readonly hooked: { + readonly remaining: number; + readonly returnedEmitter: boolean; + }; + } + >; + readonly hook_error: boolean; + }; + expect(result.hook_error).toBe(false); + expect(result.outcomes.changed?.hooked).toEqual( + result.outcomes.changed?.plain, + ); + expect(result.outcomes.deleted?.hooked).toEqual( + result.outcomes.deleted?.plain, + ); + expect(result.outcomes).toEqual({ + changed: { + plain: { remaining: 0, returnedEmitter: true }, + hooked: { remaining: 0, returnedEmitter: true }, + }, + deleted: { + plain: { remaining: 0, returnedEmitter: true }, + hooked: { remaining: 0, returnedEmitter: true }, + }, + }); + }, +); + it("preserves fatal process termination after recording uncaught exceptions", async () => { const root = await createTestTempDirectory("rea-electron-hook-fatal-"); temporary.push(root);