Version
Tested on v24.7.0, reproduces on main (commit a48e33f).
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?
- 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.
- 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?
- The listener runs unconditionally in the microtask, ignoring
Symbol.dispose().
- 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 {
// ...
Version
Tested on v24.7.0, reproduces on
main(commit a48e33f).Platform
Subsystem
events
What steps will reproduce the bug?
How often does it reproduce? Is there a required condition?
100% reproducible whenever
addAbortListener()is called on anAbortSignalwheresignal.aborted === true.What is the expected behavior? Why is that the expected behavior?
disposable[Symbol.dispose]()(or exiting ausingscope) before the microtask executes should disarm/cancel the scheduled listener. The main motivation foraddAbortListener()is TC39 explicit resource management, so disposal should always prevent the callback from firing.Eventobject (type === 'abort'), consistent with when the listener is triggered by an active signal.What do you see instead?
Symbol.dispose().undefinedinstead of anEventinstance. Code following the documented(e) => { ... }signature that accesses properties likee.typecrashes with aTypeErrorinside the microtask.Additional information
In
lib/internal/events/abort_listener.js:When
signal.abortedis true:removeEventListeneris leftundefined, makingSymbolDisposea no-op.queueMicrotask(() => listener())invokeslistenerwithout passing anEventobject.A possible fix is tracking a
disposedflag for the microtask and creating anEvent('abort')instance: