Skip to content

EventTarget.addEventListener(..., {signal}) abort signal listeners can get GC'd and never remove event listeners #63954

@1kilobit

Description

@1kilobit

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 });
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions