Compare commits

...

3 Commits

Author SHA1 Message Date
Claude Bot
a04b78a681 Rewrite test using node:test for better Node.js compatibility
Changed from bun:test to node:test to ensure the test runs correctly
in both Node.js and Bun. Uses node:assert for assertions.

Verified:
-  Passes in Node.js
-  Passes in Bun debug build
-  Fails in system Bun (as expected before fix)
2025-10-28 07:34:40 +00:00
Claude Bot
077ff15568 Fix EventEmitter removeAllListeners crash with removeListener meta-listener
When removeAllListeners() was called from within an event handler while
a removeListener meta-listener was registered, this._events could become
undefined, causing a crash.

The root cause was that removeAllListeners() was iterating over a
listeners array while calling removeListener() on each item. If
removeListener() triggered the removeListener meta-listener event, and
that event handler called removeAllListeners() again (even on a
different event type), the recursive call could reset this._events,
causing the outer iteration to access a stale reference.

The fix clones the listeners array before iterating, similar to what the
emit() functions already do. This ensures that modifications to
this._events during iteration don't affect the loop.

Fixes #24147
2025-10-28 07:31:02 +00:00
Claude Bot
8d50624ec0 Add failing tests for EventEmitter removeAllListeners bug (#24147)
Tests demonstrate that calling removeAllListeners() from within an
event handler while a removeListener meta-listener is registered
causes this._events to become undefined, resulting in a crash.

Currently 3 of 5 tests fail with TypeError: undefined is not an object.

Issue: #24147
2025-10-28 07:28:28 +00:00
2 changed files with 107 additions and 1 deletions

View File

@@ -392,7 +392,12 @@ EventEmitterPrototype.removeAllListeners = function removeAllListeners(type) {
// emit in LIFO order
const listeners = events[type];
for (let i = listeners.length - 1; i >= 0; i--) this.removeListener(type, listeners[i]);
// Clone the listeners array to avoid issues if removeListener triggers
// another removeAllListeners call that modifies this._events
if (listeners) {
const listenersCopy = listeners.slice();
for (let i = listenersCopy.length - 1; i >= 0; i--) this.removeListener(type, listenersCopy[i]);
}
return this;
};

View File

@@ -0,0 +1,101 @@
import assert from "node:assert";
import { EventEmitter } from "node:events";
import { describe, test } from "node:test";
describe("EventEmitter - issue 24147", () => {
test("removeAllListeners() called from event handler with removeListener meta-listener should not crash", () => {
const emitter = new EventEmitter();
emitter.on("test", () => {
emitter.removeAllListeners("foo");
});
emitter.on("removeListener", () => {});
// Should not throw TypeError: undefined is not an object
assert.doesNotThrow(() => {
emitter.emit("test");
});
});
test("removeAllListeners() should work correctly with removeListener meta-listener", () => {
const emitter = new EventEmitter();
let testCalled = false;
emitter.on("test", () => {
testCalled = true;
emitter.removeAllListeners("foo");
});
emitter.on("removeListener", () => {});
emitter.on("foo", () => {});
emitter.emit("test");
assert.strictEqual(testCalled, true);
assert.strictEqual(emitter.listenerCount("foo"), 0);
});
test("_events should remain intact after removeAllListeners in nested call", () => {
const emitter = new EventEmitter();
emitter.on("test", () => {
emitter.removeAllListeners("foo");
// _events should still be accessible
assert.ok(emitter.eventNames());
});
emitter.on("removeListener", () => {});
emitter.emit("test");
// Should still be able to add listeners after
emitter.on("bar", () => {});
assert.strictEqual(emitter.listenerCount("bar"), 1);
});
test("removeAllListeners() without event name should work with removeListener meta-listener", () => {
const emitter = new EventEmitter();
emitter.on("test", () => {
emitter.removeAllListeners(); // Remove ALL listeners
});
emitter.on("removeListener", () => {});
emitter.on("foo", () => {});
emitter.on("bar", () => {});
assert.doesNotThrow(() => {
emitter.emit("test");
});
// All listeners should be removed (including removeListener)
assert.deepStrictEqual(emitter.eventNames(), []);
});
test("multiple nested removeAllListeners calls should work", () => {
const emitter = new EventEmitter();
emitter.on("test", () => {
emitter.removeAllListeners("foo");
emitter.removeAllListeners("bar");
emitter.removeAllListeners("baz");
});
emitter.on("removeListener", () => {});
emitter.on("foo", () => {});
emitter.on("bar", () => {});
emitter.on("baz", () => {});
assert.doesNotThrow(() => {
emitter.emit("test");
});
assert.strictEqual(emitter.listenerCount("foo"), 0);
assert.strictEqual(emitter.listenerCount("bar"), 0);
assert.strictEqual(emitter.listenerCount("baz"), 0);
});
});