From ac80a3be43e55283dd64d4b90246da630342972d Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 13 Jul 2026 18:14:55 +0200 Subject: [PATCH] events: avoid retaining removed event names Restore shape mode for preallocated EventEmitters while deleting dynamic names from ordinary EventEmitters. Keeping every removed name as an undefined property causes unbounded memory growth when event names are unstructured. Refs: https://github.com/nodejs/node/pull/64373 Signed-off-by: Matteo Collina --- lib/events.js | 19 +++++++++++++------ .../test-event-emitter-remove-listeners.js | 13 +++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/lib/events.js b/lib/events.js index 5719d948c739d1..8b20ea994d6cee 100644 --- a/lib/events.js +++ b/lib/events.js @@ -86,6 +86,7 @@ const { addAbortListener } = require('internal/events/abort_listener'); const kCapture = Symbol('kCapture'); const kErrorMonitor = Symbol('events.errorMonitor'); +const kShapeMode = Symbol('shapeMode'); const kEmitting = Symbol('events.emitting'); const kMaxEventTargetListeners = Symbol('events.maxEventTargetListeners'); const kMaxEventTargetListenersWarned = @@ -334,6 +335,9 @@ EventEmitter.init = function(opts) { this._events === ObjectGetPrototypeOf(this)._events) { this._events = { __proto__: null }; this._eventsCount = 0; + this[kShapeMode] = false; + } else { + this[kShapeMode] = true; } this._maxListeners ||= undefined; @@ -687,12 +691,13 @@ EventEmitter.prototype.removeListener = if (list === listener || list.listener === listener) { this._eventsCount -= 1; - // Leave the key in place with an `undefined` value: repeatedly - // adding and removing a listener for the same event this way keeps - // the `events` object in the same shape and avoids both a `delete` - // (which would put the object into dictionary mode) and allocating - // a fresh object when the last listener is removed. - events[type] = undefined; + if (this[kShapeMode]) { + events[type] = undefined; + } else if (this._eventsCount === 0) { + this._events = { __proto__: null }; + } else { + delete events[type]; + } if (events.removeListener !== undefined) this.emit('removeListener', type, list.listener || listener); @@ -752,6 +757,7 @@ EventEmitter.prototype.removeAllListeners = else delete events[type]; } + this[kShapeMode] = false; return this; } @@ -764,6 +770,7 @@ EventEmitter.prototype.removeAllListeners = this.removeAllListeners('removeListener'); this._events = { __proto__: null }; this._eventsCount = 0; + this[kShapeMode] = false; return this; } diff --git a/test/parallel/test-event-emitter-remove-listeners.js b/test/parallel/test-event-emitter-remove-listeners.js index 7b94d31f45d221..e9e4a74a5b633e 100644 --- a/test/parallel/test-event-emitter-remove-listeners.js +++ b/test/parallel/test-event-emitter-remove-listeners.js @@ -144,6 +144,19 @@ function listener2() {} assert.deepStrictEqual(ee, ee.removeListener('foo', () => {})); } +{ + const ee = new EventEmitter(); + ee.on('persistent', listener1); + + for (let i = 0; i < 100; i++) { + const eventName = `event-${i}`; + ee.on(eventName, listener2); + ee.removeListener(eventName, listener2); + } + + assert.deepStrictEqual(Reflect.ownKeys(ee._events), ['persistent']); +} + { const ee = new EventEmitter(); const listener = () => {};