nodejs / nodejs/node

events: addAbortListener ignores disposal and passes undefined event when signal is already aborted

Open
#65,637 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
JavaScript
Stars
122k
Forks
37.3k
Avg merge
4d 2h
Merged PRs (30d)
283

Description

Version

Tested on v24.7.0, reproduces on main (commit a48e33fb6f).

Platform
All platforms (pure JS logic in `lib/internal/events/abort_listener.js`).
Subsystem

events

What steps will reproduce the bug?
'use strict';
const { addAbortListener } = require('node:events');

// --- Bug 1: Disposing the listener does not cancel it if the signal was already aborted ---
const ac = new AbortController();
ac.abort();

let listenerCalledAfterDispose = false;
{
  using _ = addAbortListener(ac.signal, (event) => {
    listenerCalledAfterDispose = true;
    console.log('Event argument received:', event); // Bug 2: logs 'undefined' instead of an Event object
  });
  // Scope exits here -> disposable[Symbol.dispose]() is invoked synchronously.
}

queueMicrotask(() => {
  console.log('Listener called after disposal:', listenerCalledAfterDispose);
  // Prints: true (Expected: false)
});
How often does it reproduce? Is there a required condition?

100% reproducible whenever addAbortListener() is called on an AbortSignal where signal.aborted === true.

What is the expected behavior? Why is that the expected behavior?
  1. Calling disposable[Symbol.dispose]() (or exiting a using scope) before the microtask executes should disarm/cancel the scheduled listener. The main motivation for addAbortListener() is TC39 explicit resource management, so disposal should always prevent the callback from firing.
  2. The listener callback should receive an Event object (type === 'abort'), consistent with when the listener is triggered by an active signal.
What do you see instead?
  1. The listener runs unconditionally in the microtask, ignoring Symbol.dispose().
  2. The listener receives undefined instead of an Event instance. Code following the documented (e) => { ... } signature that accesses properties like e.type crashes with a TypeError inside the microtask.
Additional information

In lib/internal/events/abort_listener.js:

  let removeEventListener;
  if (signal.aborted) {
    queueMicrotask ??= require('internal/process/task_queues').queueMicrotask;
    queueMicrotask(() => listener()); // <-- 1. Listener called with no argument
  } else {
    kResistStopPropagation ??= require('internal/event_target').kResistStopPropagation;
    abortListenerOptions ??= ObjectFreeze({ __proto__: null, once: true, [kResistStopPropagation]: true });
    signal.addEventListener('abort', listener, abortListenerOptions);
    removeEventListener = () => {
      signal.removeEventListener('abort', listener);
    };
  }
  return {
    __proto__: null,
    [SymbolDispose]() {
      removeEventListener?.(); // <-- 2. removeEventListener is undefined on the aborted branch
    },
  };

When signal.aborted is true:

  • removeEventListener is left undefined, making SymbolDispose a no-op.
  • queueMicrotask(() => listener()) invokes listener without passing an Event object.

A possible fix is tracking a disposed flag for the microtask and creating an Event('abort') instance:

  let removeEventListener;
  if (signal.aborted) {
    queueMicrotask ??= require('internal/process/task_queues').queueMicrotask;
    let disposed = false;
    queueMicrotask(() => {
      if (!disposed) {
        const { Event } = require('internal/event_target');
        listener(new Event('abort', { cancelable: false, bubbles: false }));
      }
    });
    removeEventListener = () => {
      disposed = true;
    };
  } else {
    // ...

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reproducing the supplied script, then read lib/internal/events/abort_listener.js, focusing on the signal.aborted branch and SymbolDispose handling. Add regression coverage for disposal before the queued microtask and for the callback argument, then verify the listener is suppressed after disposal and receives an abort Event otherwise.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
backend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
75/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.