`EventTarget.addEventListener(..., {signal})` abort signal listeners can get GC'd and never remove event listeners · Issue #63954 · nodejs/node · GitHub
You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
EventTarget.addEventListener(..., {signal}) abort signal listeners can get GC'd and never remove event listeners #63954
import{getEventListeners}from"node:events"consteventTarget=newEventTarget()constaborterA=newAbortController()constaborterB=newAbortController()eventTarget.addEventListener("a",console.log,{signal: aborterA.signal})eventTarget.addEventListener("b",console.log,{signal: aborterB.signal})console.log("Post-addEventListener A aborter listeners (expected: 1):",getEventListeners(aborterA.signal,"abort").length)// actual: 1console.log("Post-addEventListener B aborter listeners (expected: 1):",getEventListeners(aborterB.signal,"abort").length)// actual: 1setTimeout(()=>{gc()aborterA.abort()aborterB.abort()console.log("Post-abort A aborter listeners (expected: 0):",getEventListeners(aborterA.signal,"abort").length)// actual: 0console.log("Post-abort B aborter listeners (expected: 0):",getEventListeners(aborterB.signal,"abort").length)// actual: 0console.log("Remaining a event listeners (expected 0):",getEventListeners(eventTarget,"a").length)// actual: 1console.log("Remaining b event listeners (expected 0):",getEventListeners(eventTarget,"b").length)// actual: 0eventTarget.dispatchEvent(newEvent("a"))// incorrectly logseventTarget.dispatchEvent(newEvent("b"))// correctly doesn't log},0)
How often does it reproduce? Is there a required condition?
Consistently happens when calling .addEventListener(), with the signal option set, more than once on the same EventTarget.
What is the expected behavior? Why is that the expected behavior?
Aborting a signal passed as an EventTarget.addEventListener() option should always remove the event listener, regardless of how many other listeners with abort signals you add to the same EventTarget.
What do you see instead?
If a GC cycle happens in between multiple sameEventTarget.addEventListener(..., {signal: anySignal}) calls & any referenced signal aborting, only the last listener with a signal specified actually gets removed.
Additional information
Maybe caused by, when setting up the underlying "abort" event listener for passed signal option values in EventTarget.addEventListener(), reusing [kWeakHandler]: (the EventTarget) in the abort listener options? Spent some time in the debugger & saw "abort" event listener callbacks becoming undefined by the time the "abort" event was dispatching
Okay, confirmed it by reproducing it and testing with your script. The weakListeners() map in lib/internal/event_target.js
is a single module-level WeakMap keyed by the retainer object (kWeakHandler target).
Multiple addEventListener(..., {signal}) calls on the same EventTarget all use the
same key, so each .set() overwrites the previous entry, the earlier abort-cleanup
callback loses its only strong reference and gets collected before the signal aborts.
This isn't isolated to addEventListener, the same weakListeners() primitive is
reused in lib/internal/abort_controller.js 'events.aborted()' and
lib/internal/streams/operators.js, with the same collision risk.
fix would be changing the map value from a single listener to a Set of listeners
per key, fixed centrally in event_target.js so all three call sites are covered by
one change. Will follow up with a PR
Activity
aryansaves commentedon Jun 19, 2026
Okay, confirmed it by reproducing it and testing with your script. The
weakListeners()map in lib/internal/event_target.jsis a single module-level WeakMap keyed by the retainer object (
kWeakHandlertarget).Multiple
addEventListener(..., {signal})calls on the same EventTarget all use thesame key, so each
.set()overwrites the previous entry, the earlier abort-cleanupcallback loses its only strong reference and gets collected before the signal aborts.
This isn't isolated to addEventListener, the same
weakListeners()primitive isreused in lib/internal/abort_controller.js 'events.aborted()' and
lib/internal/streams/operators.js, with the same collision risk.
fix would be changing the map value from a single listener to a Set of listeners
per key, fixed centrally in event_target.js so all three call sites are covered by
one change. Will follow up with a PR
events: fix weak listener retention overwrite
events: fix weak listener retention overwrite
events: fix weak listener retention overwrite
events: fix weak listener retention overwrite
events: fix weak listener retention overwrite
events: fix weak listener retention overwrite