Version
v26.3.0, v22.14.0
Platform
* Linux computer 6.12.90+deb13.1-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.90-2 (2026-05-27) x86_64 GNU/Linux
* Microsoft Windows NT 10.0.26200.0 x64
Subsystem
events
What steps will reproduce the bug?
(run with node --expose-gc)
Same signal, same EventTarget:
import { getEventListeners } from "node:events"
const eventTarget = new EventTarget()
const aborter = new AbortController()
eventTarget.addEventListener("a", console.log, { signal: aborter.signal })
eventTarget.addEventListener("b", console.log, { signal: aborter.signal })
console.log("Internal abort listeners (expected: 2):", getEventListeners(aborter.signal, "abort").length) // actual: 2
setTimeout(() => {
gc()
aborter.abort()
console.log("Internal abort listeners (expected: 0):", getEventListeners(aborter.signal, "abort").length) // actual: 0
console.log("Remaining a event listeners (expected 0):", getEventListeners(eventTarget, "a").length) // actual: 1
console.log("Remaining b event listeners (expected 0):", getEventListeners(eventTarget, "b").length) // actual: 0
eventTarget.dispatchEvent(new Event("a")) // incorrectly logs
eventTarget.dispatchEvent(new Event("b")) // correctly doesn't log
}, 0)
Different signals, same EventTarget:
import { getEventListeners } from "node:events"
const eventTarget = new EventTarget()
const aborterA = new AbortController()
const aborterB = new AbortController()
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: 1
console.log("Post-addEventListener B aborter listeners (expected: 1):", getEventListeners(aborterB.signal, "abort").length) // actual: 1
setTimeout(() => {
gc()
aborterA.abort()
aborterB.abort()
console.log("Post-abort A aborter listeners (expected: 0):", getEventListeners(aborterA.signal, "abort").length) // actual: 0
console.log("Post-abort B aborter listeners (expected: 0):", getEventListeners(aborterB.signal, "abort").length) // actual: 0
console.log("Remaining a event listeners (expected 0):", getEventListeners(eventTarget, "a").length) // actual: 1
console.log("Remaining b event listeners (expected 0):", getEventListeners(eventTarget, "b").length) // actual: 0
eventTarget.dispatchEvent(new Event("a")) // incorrectly logs
eventTarget.dispatchEvent(new Event("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
|
if (signal) { |
|
if (signal.aborted) { |
|
return; |
|
} |
|
signal.addEventListener('abort', () => { |
|
this.removeEventListener(type, listener, options); |
|
}, { __proto__: null, once: true, [kWeakHandler]: this, [kResistStopPropagation]: true }); |
|
} |
Version
v26.3.0, v22.14.0
Platform
Subsystem
events
What steps will reproduce the bug?
(run with
node --expose-gc)Same signal, same EventTarget:
Different signals, same EventTarget:
How often does it reproduce? Is there a required condition?
Consistently happens when calling
.addEventListener(), with thesignaloption set, more than once on the sameEventTarget.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 passedsignaloption values inEventTarget.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 dispatchingnode/lib/internal/event_target.js
Lines 638 to 645 in b09155d