diff --git a/src/js/builtins/EventEmitterOnce.ts b/src/js/builtins/EventEmitterOnce.ts new file mode 100644 index 000000000000..942c5d2ade47 --- /dev/null +++ b/src/js/builtins/EventEmitterOnce.ts @@ -0,0 +1,16 @@ +// The native EventEmitter backing `process` stores `once()` listeners as the original function plus +// a flag, so there is no wrapper to hand back from rawListeners(). Materialize one on demand to +// expose the `.listener` back-pointer Node documents. +export function createOnceWrapper(target, type, listener) { + var fired = false; + function onceWrapper() { + if (fired) return undefined; + fired = true; + // removeListener() resolves a wrapper to the listener it wraps, so this removes whichever of the + // two is actually registered. + target.removeListener(type, onceWrapper); + return listener.$apply(target, arguments); + } + onceWrapper.listener = listener; + return onceWrapper; +} diff --git a/src/js/node/events.ts b/src/js/node/events.ts index 4ddc95614f72..70ef6e1205e9 100644 --- a/src/js/node/events.ts +++ b/src/js/node/events.ts @@ -865,4 +865,12 @@ Object.assign(EventEmitter, { listenerCount, }); +// `process` is backed by the native EventEmitter, which gives it a prototype of its own that already +// implements every method above. Splice this prototype in underneath it so `process instanceof +// EventEmitter` holds, as it does in Node. +const processPrototype = Object.getPrototypeOf(process); +if (processPrototype !== EventEmitterPrototype && Object.getPrototypeOf(processPrototype) === Object.prototype) { + Object.setPrototypeOf(processPrototype, EventEmitterPrototype); +} + export default EventEmitter as any as typeof import("node:events"); diff --git a/src/jsc/bindings/webcore/EventEmitter.cpp b/src/jsc/bindings/webcore/EventEmitter.cpp index c9ab566b4103..0a4301c005ec 100644 --- a/src/jsc/bindings/webcore/EventEmitter.cpp +++ b/src/jsc/bindings/webcore/EventEmitter.cpp @@ -48,12 +48,30 @@ void EventEmitter::addListenerForBindings(const Identifier& eventType, RefPtr&& listener) +static inline Identifier removeListenerEventName(JSC::VM& vm) { - if (!listener) + return Identifier::fromString(vm, "removeListener"_s); +} + +// https://nodejs.org/api/events.html#event-removelistener +void EventEmitter::emitRemoveListenerEvent(const Identifier& eventType, JSC::JSObject* listener) +{ + auto* context = scriptExecutionContext(); + if (!context) + return; + + auto& vm = context->vm(); + auto removeListenerEventType = removeListenerEventName(vm); + if (!hasEventListeners(removeListenerEventType)) return; - removeListener(eventType, *listener); + MarkedArgumentBuffer args; + args.append(JSC::identifierToSafePublicJSValue(vm, eventType)); + args.append(listener ? JSC::JSValue(listener) : JSC::jsUndefined()); + if (args.hasOverflowed()) [[unlikely]] + return; + + emit(removeListenerEventType, args); } bool EventEmitter::removeListener(const Identifier& eventType, EventListener& listener) @@ -62,16 +80,43 @@ bool EventEmitter::removeListener(const Identifier& eventType, EventListener& li if (!data) return false; + // Read before removing: the registration holds the reference that keeps this listener alive. + auto* listenerFunction = listener.jsFunction(); + if (data->eventListenerMap.remove(eventType, listener)) { eventListenersDidChange(); if (this->onDidChangeListener) this->onDidChangeListener(*this, eventType, false); + + emitRemoveListenerEvent(eventType, listenerFunction); return true; } return false; } +// `once()` listeners are stored unwrapped, so removeListener() has to accept the wrapper that +// rawListeners() handed out and resolve it back to the registration it was made for. +bool EventEmitter::removeOnceListenerByWrapper(const Identifier& eventType, JSC::JSObject* wrapper) +{ + auto* data = eventTargetData(); + if (!data) + return false; + + auto* listeners = data->eventListenerMap.find(eventType); + if (!listeners) + return false; + + for (auto& registration : *listeners) { + if (registration->onceWrapper() != wrapper) + continue; + // Keep the callback alive: removing the registration drops the map's reference to it. + Ref callback = registration->callback(); + return removeListener(eventType, callback.get()); + } + return false; +} + void EventEmitter::removeAllListenersForBindings(const Identifier& eventType) { removeAllListeners(eventType); @@ -79,23 +124,68 @@ void EventEmitter::removeAllListenersForBindings(const Identifier& eventType) bool EventEmitter::removeAllListeners() { + // 'removeListener' handlers run user JS in this frame, and `map` points into this object. + Ref protectedThis(*this); + auto* data = eventTargetData(); if (!data) return false; auto& map = data->eventListenerMap; - bool any = !map.isEmpty(); + if (map.isEmpty()) + return false; + + if (auto* context = scriptExecutionContext()) { + auto removeListenerEventType = removeListenerEventName(context->vm()); + // Drain one event type at a time so 'removeListener' and onDidChangeListener both fire, + // leaving the 'removeListener' event itself for last as Node does. + for (auto& eventType : map.eventTypes()) { + if (eventType == removeListenerEventType) + continue; + removeAllListeners(eventType); + } + removeAllListeners(removeListenerEventType); + } + + // A 'removeListener' handler may have registered listeners for event types the drain above had + // already snapshotted past. Node wipes those silently (`_events = {}`), so no event fires for + // them here either, but the native side still has to hear about it or an OS signal handler + // installed by that late registration would outlive the listener that asked for it. + auto orphaned = map.eventTypes(); map.clear(); + if (this->onDidChangeListener) { + for (auto& eventType : orphaned) + this->onDidChangeListener(*this, eventType, false); + } + this->m_thisObject.clear(); - return any; + return true; } bool EventEmitter::removeAllListeners(const Identifier& eventType) { + // 'removeListener' handlers run user JS between iterations, and `data` points into this object. + Ref protectedThis(*this); + auto* data = eventTargetData(); if (!data) return false; + auto* context = scriptExecutionContext(); + if (context && hasEventListeners(removeListenerEventName(context->vm()))) { + auto* listenersVector = data->eventListenerMap.find(eventType); + if (!listenersVector) + return false; + + // Node removes listeners in LIFO order, emitting 'removeListener' for each one. Iterate a + // copy: each removal can run JS that mutates the live vector. + SimpleEventListenerVector listeners = *listenersVector; + bool removedAny = false; + for (size_t i = listeners.size(); i > 0; --i) + removedAny |= removeListener(eventType, listeners[i - 1]->callback()); + return removedAny; + } + if (data->eventListenerMap.removeAll(eventType)) { eventListenersDidChange(); if (this->onDidChangeListener) @@ -228,15 +318,32 @@ bool EventEmitter::innerInvokeEventListeners(const Identifier& eventType, Simple auto& callback = registeredListener->callback(); + JSObject* jsFunction = callback.jsFunction(); + const bool isOnce = registeredListener->isOnce(); + + if (isOnce) { + // Removing a once() listener emits 'removeListener', which can re-enter and reach this + // registration through another in-flight snapshot. Node's once() wrapper guards against + // running twice with a `fired` flag; this is that guard. + if (registeredListener->hasFired()) [[unlikely]] + continue; + registeredListener->markAsFired(); + + // Node stores once() listeners wrapped and fires the wrapper. We only materialize one on + // demand, so fire it when it exists, or a wrapper the caller is still holding from + // rawListeners() would invoke the listener a second time. + if (auto* onceWrapper = registeredListener->onceWrapper()) + jsFunction = onceWrapper; + } + // Make sure the JS wrapper and function stay alive until the end of this scope. Otherwise, // event listeners with 'once' flag may get collected as soon as they get unregistered below, // before we call the js function. - JSObject* jsFunction = callback.jsFunction(); JSC::EnsureStillAliveScope wrapperProtector(callback.wrapper()); JSC::EnsureStillAliveScope jsFunctionProtector(jsFunction); // Do this before invocation to avoid reentrancy issues. - if (registeredListener->isOnce()) + if (isOnce) removeListener(eventType, callback); if (!jsFunction) [[unlikely]] diff --git a/src/jsc/bindings/webcore/EventEmitter.h b/src/jsc/bindings/webcore/EventEmitter.h index 989239a2aa67..f61f7aac7e32 100644 --- a/src/jsc/bindings/webcore/EventEmitter.h +++ b/src/jsc/bindings/webcore/EventEmitter.h @@ -47,12 +47,12 @@ class EventEmitter final : public ScriptWrappable, public CanMakeWeakPtr&&, bool, bool); - WEBCORE_EXPORT void removeListenerForBindings(const Identifier& eventType, RefPtr&&); WEBCORE_EXPORT void removeAllListenersForBindings(const Identifier& eventType); WEBCORE_EXPORT bool emitForBindings(const Identifier&, const MarkedArgumentBuffer&); WEBCORE_EXPORT bool addListener(const Identifier& eventType, Ref&&, bool, bool); WEBCORE_EXPORT bool removeListener(const Identifier& eventType, EventListener&); + WEBCORE_EXPORT bool removeOnceListenerByWrapper(const Identifier& eventType, JSC::JSObject* wrapper); WEBCORE_EXPORT bool removeAllListeners(const Identifier& eventType); WEBCORE_EXPORT bool emit(const Identifier&, const MarkedArgumentBuffer&); @@ -109,6 +109,7 @@ class EventEmitter final : public ScriptWrappable, public CanMakeWeakPtr #include #include +#include +#include #include "EventListener.h" namespace WebCore { @@ -23,17 +25,31 @@ class SimpleRegisteredEventListener : public RefCounted(wrapper); } + private: SimpleRegisteredEventListener(Ref&& listener, bool once) : m_isOnce(once) , m_wasRemoved(false) + , m_hasFired(false) , m_callback(WTF::move(listener)) { } bool m_isOnce : 1; bool m_wasRemoved : 1; + bool m_hasFired : 1; Ref m_callback; + JSC::Weak m_onceWrapper; }; using SimpleEventListenerVector = Vector, 2, CrashOnOverflow, 6>; diff --git a/src/jsc/bindings/webcore/JSEventEmitter.cpp b/src/jsc/bindings/webcore/JSEventEmitter.cpp index 25168b83f179..f399362ba71e 100644 --- a/src/jsc/bindings/webcore/JSEventEmitter.cpp +++ b/src/jsc/bindings/webcore/JSEventEmitter.cpp @@ -25,6 +25,7 @@ #include "JSEventListenerOptions.h" #include "JavaScriptCore/JSCJSValue.h" #include "ScriptExecutionContext.h" +#include "WebCoreJSBuiltins.h" #include "WebCoreJSClientData.h" #include #include @@ -53,6 +54,7 @@ static JSC_DECLARE_HOST_FUNCTION(jsEventEmitterPrototypeFunction_emit); static JSC_DECLARE_HOST_FUNCTION(jsEventEmitterPrototypeFunction_eventNames); static JSC_DECLARE_HOST_FUNCTION(jsEventEmitterPrototypeFunction_listenerCount); static JSC_DECLARE_HOST_FUNCTION(jsEventEmitterPrototypeFunction_listeners); +static JSC_DECLARE_HOST_FUNCTION(jsEventEmitterPrototypeFunction_rawListeners); static JSC_DECLARE_HOST_FUNCTION(jsEventEmitterPrototypeFunction_setMaxListeners); static JSC_DECLARE_HOST_FUNCTION(jsEventEmitterPrototypeFunction_getMaxListeners); @@ -91,7 +93,10 @@ class JSEventEmitterPrototype final : public JSC::JSNonFinalObject { void finishCreation(JSC::VM&); public: - static constexpr unsigned StructureFlags = Base::StructureFlags | JSC::IsImmutablePrototypeExoticObject; + // Unlike EventTarget.prototype, which WebIDL marks as an immutable prototype exotic object, Node's + // EventEmitter.prototype is an ordinary object. node:events relies on that to splice itself in + // underneath this prototype so `process instanceof EventEmitter` holds. + static constexpr unsigned StructureFlags = Base::StructureFlags; }; STATIC_ASSERT_ISO_SUBSPACE_SHARABLE(JSEventEmitterPrototype, JSEventEmitterPrototype::Base); @@ -191,8 +196,7 @@ static const HashTableValue JSEventEmitterPrototypeTableValues[] = { { "eventNames"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsEventEmitterPrototypeFunction_eventNames, 0 } }, { "listenerCount"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsEventEmitterPrototypeFunction_listenerCount, 1 } }, { "listeners"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsEventEmitterPrototypeFunction_listeners, 1 } }, - // TODO: Need to double check the difference between rawListeners and listeners. - { "rawListeners"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsEventEmitterPrototypeFunction_listeners, 1 } }, + { "rawListeners"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsEventEmitterPrototypeFunction_rawListeners, 1 } }, { "setMaxListeners"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsEventEmitterPrototypeFunction_setMaxListeners, 1 } }, { "getMaxListeners"_s, static_cast(JSC::PropertyAttribute::Function), NoIntrinsic, { HashTableValue::NativeFunctionType, jsEventEmitterPrototypeFunction_getMaxListeners, 0 } } @@ -387,7 +391,14 @@ inline JSC::EncodedJSValue JSEventEmitter::removeListener(JSC::JSGlobalObject* l EnsureStillAliveScope argument1 = callFrame->uncheckedArgument(1); auto listener = convert>>(*lexicalGlobalObject, argument1.value(), *castedThis, [](JSC::JSGlobalObject& lexicalGlobalObject, JSC::ThrowScope& scope) { throwArgumentMustBeObjectError(lexicalGlobalObject, scope, 1, "listener"_s, "EventEmitter"_s, "removeListener"_s); }); RETURN_IF_EXCEPTION(throwScope, {}); - JSValue::encode(toJS(*lexicalGlobalObject, throwScope, [&]() -> decltype(auto) { return impl.removeListenerForBindings(WTF::move(eventType), WTF::move(listener)); })); + + bool removed = listener && impl.removeListener(eventType, *listener); + if (!removed) { + // Node accepts the wrapper a `once()` listener was registered with; ours come from + // rawListeners(). + if (auto* listenerObject = argument1.value().getObject()) + impl.removeOnceListenerByWrapper(eventType, listenerObject); + } RETURN_IF_EXCEPTION(throwScope, {}); vm.writeBarrier(&static_cast(*castedThis), argument1.value()); impl.setThisObject(actualThis); @@ -506,6 +517,74 @@ JSC_DEFINE_HOST_FUNCTION(jsEventEmitterPrototypeFunction_listeners, (JSGlobalObj return IDLOperation::call(*lexicalGlobalObject, *callFrame, "listeners"); } +// Unlike listeners(), rawListeners() exposes `once()` listeners as wrappers carrying a `.listener` +// back-pointer to the original function. +static inline JSC::EncodedJSValue jsEventEmitterPrototypeFunction_rawListenersBody(JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame, typename IDLOperation::ClassParameter castedThis) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + auto& impl = castedThis->wrapped(); + if (callFrame->argumentCount() < 1) [[unlikely]] + return throwVMError(lexicalGlobalObject, throwScope, createNotEnoughArgumentsError(lexicalGlobalObject)); + auto eventType = callFrame->uncheckedArgument(0).toPropertyKey(lexicalGlobalObject); + RETURN_IF_EXCEPTION(throwScope, {}); + + // Copy: creating a wrapper runs JS, which may mutate the live vector. + SimpleEventListenerVector registrations; + if (auto* found = impl.eventListenerMap().find(eventType)) + registrations = *found; + + JSValue eventTypeKey = JSC::identifierToSafePublicJSValue(vm, eventType); + JSC::JSFunction* createOnceWrapper = nullptr; + JSC::MarkedArgumentBuffer args; + + for (auto& registration : registrations) { + if (registration->wasRemoved()) [[unlikely]] + continue; + auto* listener = registration->callback().jsFunction(); + if (!listener) + continue; + if (!registration->isOnce()) { + args.append(listener); + continue; + } + + auto* wrapper = registration->onceWrapper(); + if (!wrapper) { + if (!createOnceWrapper) + createOnceWrapper = JSC::JSFunction::create(vm, lexicalGlobalObject, eventEmitterOnceCreateOnceWrapperCodeGenerator(vm), lexicalGlobalObject); + + JSC::MarkedArgumentBuffer wrapperArgs; + wrapperArgs.append(callFrame->thisValue()); + wrapperArgs.append(eventTypeKey); + wrapperArgs.append(listener); + ASSERT(!wrapperArgs.hasOverflowed()); + + JSValue result = JSC::call(lexicalGlobalObject, createOnceWrapper, JSC::getCallData(createOnceWrapper), JSC::jsUndefined(), wrapperArgs); + RETURN_IF_EXCEPTION(throwScope, {}); + wrapper = result.getObject(); + if (!wrapper) [[unlikely]] + continue; + registration->setOnceWrapper(wrapper); + } + args.append(wrapper); + } + + if (args.hasOverflowed()) [[unlikely]] { + throwOutOfMemoryError(lexicalGlobalObject, throwScope); + return {}; + } + + auto array = JSC::constructArray(lexicalGlobalObject, static_cast(nullptr), WTF::move(args)); + RETURN_IF_EXCEPTION(throwScope, {}); + RELEASE_AND_RETURN(throwScope, JSC::JSValue::encode(array)); +} + +JSC_DEFINE_HOST_FUNCTION(jsEventEmitterPrototypeFunction_rawListeners, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + return IDLOperation::call(*lexicalGlobalObject, *callFrame, "rawListeners"); +} + JSC::GCClient::IsoSubspace* JSEventEmitter::subspaceForImpl(JSC::VM& vm) { return WebCore::subspaceForImpl( diff --git a/test/js/node/events/event-emitter.test.ts b/test/js/node/events/event-emitter.test.ts index aae4efd51561..0c6d4cba22c0 100644 --- a/test/js/node/events/event-emitter.test.ts +++ b/test/js/node/events/event-emitter.test.ts @@ -1,5 +1,6 @@ import { sleep } from "bun"; import { describe, expect, mock, test } from "bun:test"; +import { bunEnv, bunExe, isLinux, isPosix } from "harness"; import { createRequire } from "module"; // this is also testing that imports with default and named imports in the same statement work @@ -912,3 +913,287 @@ test("getEventListeners", () => { test("EventEmitter.name", () => { expect(EventEmitter.name).toBe("EventEmitter"); }); + +// `process` is backed by a native EventEmitter rather than the one above, so it gets its own coverage +// of the EventEmitter contract. +describe("process", () => { + // Anything that touches process-wide listener state runs in a child, so it cannot disturb the test + // runner's own handlers. + async function runFixture(source: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", source], + env: bunEnv, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) throw new Error(`fixture exited with code ${exitCode}\n${stderr}`); + return { stdout, stderr, exitCode }; + } + + async function runJSON(source: string) { + const { stdout } = await runFixture(source); + return JSON.parse(stdout); + } + + test("emits 'removeListener' from off(), removeListener() and removeAllListeners()", async () => { + expect( + await runJSON(` + const seen = []; + const f = () => {}; + process.on("removeListener", (name, listener) => { + if (name === "foo" || name === "SIGWINCH") seen.push([name, listener === f]); + }); + + process.on("foo", f); + process.off("foo", f); + + process.on("SIGWINCH", f); + process.removeListener("SIGWINCH", f); + + process.on("SIGWINCH", f); + process.removeAllListeners("SIGWINCH"); + + console.log(JSON.stringify(seen)); + `), + ).toEqual([ + ["foo", true], + ["SIGWINCH", true], + ["SIGWINCH", true], + ]); + }); + + test("emits 'removeListener' when a once() listener fires", async () => { + expect( + await runJSON(` + const seen = []; + const f = () => {}; + process.on("removeListener", name => name === "foo" && seen.push(name)); + process.once("foo", f); + process.emit("foo"); + console.log(JSON.stringify({ seen, remaining: process.listenerCount("foo") })); + `), + ).toEqual({ seen: ["foo"], remaining: 0 }); + }); + + test("each once('removeListener') handler fires exactly once", async () => { + // Removing the first handler emits 'removeListener', which re-entrantly reaches the second one + // while the outer emit still holds it in its snapshot. + expect( + await runJSON(` + const calls = []; + process.once("removeListener", n => calls.push("h1:" + n)); + process.once("removeListener", n => calls.push("h2:" + n)); + process.on("foo", () => {}); + process.removeAllListeners("foo"); + console.log(JSON.stringify(calls)); + `), + ).toEqual(["h2:removeListener", "h1:foo"]); + }); + + test("a once() listener removed mid-emit still fires", () => { + // The guard above keys off the listener having already run, not off it having been removed: an + // emit in flight still invokes a once() listener that a prior handler unregistered. + const order: string[] = []; + const h2 = () => order.push("h2"); + const h1 = () => { + process.removeListener("bun-removed-mid-emit", h2); + order.push("h1"); + }; + try { + process.on("bun-removed-mid-emit", h1); + process.once("bun-removed-mid-emit", h2); + process.emit("bun-removed-mid-emit"); + expect(order).toEqual(["h1", "h2"]); + } finally { + process.removeAllListeners("bun-removed-mid-emit"); + } + }); + + test("removeAllListeners() with no arguments emits 'removeListener' for every listener", async () => { + expect( + await runJSON(` + const seen = []; + const f = () => {}; + process.on("foo", f); + process.on("foo", () => {}); + process.on("bar", f); + process.on("removeListener", name => (name === "foo" || name === "bar") && seen.push(name)); + process.removeAllListeners(); + console.log(JSON.stringify({ seen: seen.sort(), names: process.eventNames() })); + `), + ).toEqual({ seen: ["bar", "foo", "foo"], names: [] }); + }); + + // Once the handler is uninstalled, SIGUSR2 is no longer swallowed, so it terminates the child + // before it can reach the "survived" print. Linux reports the delivered SIGUSR2; macOS x64 reports + // SIGSYS for the same default-terminate outcome, so assert the portable shape (killed by a signal, + // empty stdout) and pin the exact signal only where it is stable. + async function expectKilledBySignal(source: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", source], + env: bunEnv, + stderr: "pipe", + }); + const [stdout] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe(""); + expect(typeof proc.signalCode).toBe("string"); + if (isLinux) expect(proc.signalCode).toBe("SIGUSR2"); + } + + test.skipIf(!isPosix)("removeAllListeners() uninstalls the OS signal handler", async () => { + await expectKilledBySignal(` + process.on("SIGUSR2", () => {}); + process.removeAllListeners(); + process.kill(process.pid, "SIGUSR2"); + console.log("survived"); + `); + }); + + test.skipIf(!isPosix)("removeAllListeners() uninstalls a signal handler added mid-drain", async () => { + // The late registration lands after removeAllListeners() snapshotted the event types, so it is + // wiped without a 'removeListener'. Its OS handler still has to come down with it. + await expectKilledBySignal(` + process.on("removeListener", name => { + if (name === "trigger") process.on("SIGUSR2", () => {}); + }); + process.on("trigger", () => {}); + process.removeAllListeners(); + process.kill(process.pid, "SIGUSR2"); + console.log("survived"); + `); + }); + + test("is an instanceof EventEmitter", () => { + expect(process instanceof EventEmitter).toBe(true); + expect(EventEmitter.prototype.isPrototypeOf(process)).toBe(true); + }); + + test("rawListeners() exposes once() wrappers with a .listener back-pointer", () => { + const f = () => {}; + try { + process.once("bun-raw-listeners", f); + + const [wrapper] = process.rawListeners("bun-raw-listeners"); + expect(typeof wrapper).toBe("function"); + expect(wrapper.listener).toBe(f); + // listeners() stays unwrapped, and the wrapper keeps its identity across calls. + expect(process.listeners("bun-raw-listeners")).toEqual([f]); + expect(process.rawListeners("bun-raw-listeners")[0]).toBe(wrapper); + } finally { + process.removeAllListeners("bun-raw-listeners"); + } + }); + + test("rawListeners() returns plain listeners for on()", () => { + const f = () => {}; + try { + process.on("bun-raw-listeners-plain", f); + expect(process.rawListeners("bun-raw-listeners-plain")).toEqual([f]); + } finally { + process.removeAllListeners("bun-raw-listeners-plain"); + } + }); + + test("removeListener() accepts a wrapper returned by rawListeners()", () => { + const f = mock(() => {}); + try { + process.once("bun-raw-listeners-off", f); + process.removeListener("bun-raw-listeners-off", process.rawListeners("bun-raw-listeners-off")[0]); + expect(process.listenerCount("bun-raw-listeners-off")).toBe(0); + + // Calling the wrapper removes the registration it wraps and invokes the original listener. + process.once("bun-raw-listeners-off", f); + process.rawListeners("bun-raw-listeners-off")[0](); + expect(f).toHaveBeenCalledTimes(1); + expect(process.listenerCount("bun-raw-listeners-off")).toBe(0); + } finally { + process.removeAllListeners("bun-raw-listeners-off"); + } + }); + + test("a rawListeners() wrapper held across an emit() becomes a no-op", () => { + const f = mock(() => {}); + try { + process.once("bun-raw-listeners-held", f); + const [wrapper] = process.rawListeners("bun-raw-listeners-held"); + + process.emit("bun-raw-listeners-held"); + expect(f).toHaveBeenCalledTimes(1); + + // The emit already consumed the listener, so the wrapper has nothing left to do. + wrapper(); + expect(f).toHaveBeenCalledTimes(1); + } finally { + process.removeAllListeners("bun-raw-listeners-held"); + } + }); + + test("a rawListeners() wrapper that never fired still invokes its listener", () => { + const f = mock(() => {}); + try { + process.once("bun-raw-listeners-unfired", f); + const [wrapper] = process.rawListeners("bun-raw-listeners-unfired"); + process.removeAllListeners("bun-raw-listeners-unfired"); + + // Node gates only on whether the wrapper itself ran, not on the listener still being + // registered, so this invokes f even though the registration is gone. + wrapper(); + expect(f).toHaveBeenCalledTimes(1); + } finally { + process.removeAllListeners("bun-raw-listeners-unfired"); + } + }); + + test("a re-registered rawListeners() wrapper still fires only once", () => { + const f = mock(() => {}); + try { + process.once("bun-raw-listeners-re", f); + const [wrapper] = process.rawListeners("bun-raw-listeners-re"); + process.removeAllListeners("bun-raw-listeners-re"); + + process.on("bun-raw-listeners-re", wrapper); + process.emit("bun-raw-listeners-re"); + process.emit("bun-raw-listeners-re"); + + expect(f).toHaveBeenCalledTimes(1); + expect(process.listenerCount("bun-raw-listeners-re")).toBe(0); + } finally { + process.removeAllListeners("bun-raw-listeners-re"); + } + }); + + test("symbol event names survive the once() wrapper round-trip", () => { + const eventName = Symbol("bun-symbol-event"); + const f = () => {}; + const seen: unknown[] = []; + const onRemove = (name: unknown) => name === eventName && seen.push(name); + try { + process.on("removeListener", onRemove); + + process.once(eventName, f); + const [wrapper] = process.rawListeners(eventName); + expect(wrapper.listener).toBe(f); + + process.removeListener(eventName, wrapper); + expect(process.listenerCount(eventName)).toBe(0); + expect(seen).toEqual([eventName]); + } finally { + process.off("removeListener", onRemove); + process.removeAllListeners(eventName); + } + }); + + test("newListener still receives the original function for once()", () => { + const f = () => {}; + const seen: unknown[] = []; + const onNewListener = (name: string, listener: unknown) => name === "bun-new-listener" && seen.push(listener); + try { + process.on("newListener", onNewListener); + process.once("bun-new-listener", f); + expect(seen).toEqual([f]); + } finally { + process.off("newListener", onNewListener); + process.removeAllListeners("bun-new-listener"); + } + }); +});