Skip to content

Commit a68a67f

Browse files
atlowChemiruyadorno
authored andcommitted
events: allow safely adding listener to abortSignal
PR-URL: #48596 Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Moshe Atlow <moshe@atlow.co.il> Reviewed-By: Benjamin Gruenbaum <benjamingr@gmail.com> Reviewed-By: Robert Nagy <ronagy@icloud.com>
1 parent a79112b commit a68a67f

File tree

4 files changed

+146
-0
lines changed

4 files changed

+146
-0
lines changed

doc/api/events.md

+59
Original file line numberDiff line numberDiff line change
@@ -1799,6 +1799,64 @@ const emitter = new EventEmitter();
17991799
setMaxListeners(5, target, emitter);
18001800
```
18011801

1802+
## `events.addAbortListener(signal, resource)`
1803+
1804+
<!-- YAML
1805+
added: REPLACEME
1806+
-->
1807+
1808+
> Stability: 1 - Experimental
1809+
1810+
* `signal` {AbortSignal}
1811+
* `listener` {Function|EventListener}
1812+
* Returns: {Disposable} that removes the `abort` listener.
1813+
1814+
Listens once to the `abort` event on the provided `signal`.
1815+
1816+
Listening to the `abort` event on abort signals is unsafe and may
1817+
lead to resource leaks since another third party with the signal can
1818+
call [`e.stopImmediatePropagation()`][]. Unfortunately Node.js cannot change
1819+
this since it would violate the web standard. Additionally, the original
1820+
API makes it easy to forget to remove listeners.
1821+
1822+
This API allows safely using `AbortSignal`s in Node.js APIs by solving these
1823+
two issues by listening to the event such that `stopImmediatePropagation` does
1824+
not prevent the listener from running.
1825+
1826+
Returns a disposable so that it may be unsubscribed from more easily.
1827+
1828+
```cjs
1829+
const { addAbortListener } = require('node:events');
1830+
1831+
function example(signal) {
1832+
let disposable;
1833+
try {
1834+
signal.addEventListener('abort', (e) => e.stopImmediatePropagation());
1835+
disposable = addAbortListener(signal, (e) => {
1836+
// Do something when signal is aborted.
1837+
});
1838+
} finally {
1839+
disposable?.[Symbol.dispose]();
1840+
}
1841+
}
1842+
```
1843+
1844+
```mjs
1845+
import { addAbortListener } from 'node:events';
1846+
1847+
function example(signal) {
1848+
let disposable;
1849+
try {
1850+
signal.addEventListener('abort', (e) => e.stopImmediatePropagation());
1851+
disposable = addAbortListener(signal, (e) => {
1852+
// Do something when signal is aborted.
1853+
});
1854+
} finally {
1855+
disposable?.[Symbol.dispose]();
1856+
}
1857+
}
1858+
```
1859+
18021860
## Class: `events.EventEmitterAsyncResource extends EventEmitter`
18031861
18041862
<!-- YAML
@@ -2520,6 +2578,7 @@ to the `EventTarget`.
25202578
[`EventTarget` error handling]: #eventtarget-error-handling
25212579
[`Event` Web API]: https://dom.spec.whatwg.org/#event
25222580
[`domain`]: domain.md
2581+
[`e.stopImmediatePropagation()`]: #eventstopimmediatepropagation
25232582
[`emitter.listenerCount()`]: #emitterlistenercounteventname-listener
25242583
[`emitter.removeListener()`]: #emitterremovelistenereventname-listener
25252584
[`emitter.setMaxListeners(n)`]: #emittersetmaxlistenersn

lib/events.js

+31
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const {
4848
Symbol,
4949
SymbolFor,
5050
SymbolAsyncIterator,
51+
SymbolDispose,
5152
} = primordials;
5253
const kRejection = SymbolFor('nodejs.rejection');
5354

@@ -214,6 +215,7 @@ function EventEmitter(opts) {
214215
EventEmitter.init.call(this, opts);
215216
}
216217
module.exports = EventEmitter;
218+
module.exports.addAbortListener = addAbortListener;
217219
module.exports.once = once;
218220
module.exports.on = on;
219221
module.exports.getEventListeners = getEventListeners;
@@ -1160,3 +1162,32 @@ function on(emitter, event, options = kEmptyObject) {
11601162
}
11611163
return iterator;
11621164
}
1165+
1166+
let queueMicrotask;
1167+
1168+
function addAbortListener(signal, listener) {
1169+
if (signal === undefined) {
1170+
throw new ERR_INVALID_ARG_TYPE('signal', 'AbortSignal', signal);
1171+
}
1172+
validateAbortSignal(signal, 'signal');
1173+
validateFunction(listener, 'listener');
1174+
1175+
let removeEventListener;
1176+
if (signal.aborted) {
1177+
queueMicrotask ??= require('internal/process/task_queues').queueMicrotask;
1178+
queueMicrotask(() => listener());
1179+
} else {
1180+
kResistStopPropagation ??= require('internal/event_target').kResistStopPropagation;
1181+
// TODO(atlowChemi) add { subscription: true } and return directly
1182+
signal.addEventListener('abort', listener, { __proto__: null, once: true, [kResistStopPropagation]: true });
1183+
removeEventListener = () => {
1184+
signal.removeEventListener('abort', listener);
1185+
};
1186+
}
1187+
return {
1188+
__proto__: null,
1189+
[SymbolDispose]() {
1190+
removeEventListener?.();
1191+
},
1192+
};
1193+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import * as common from '../common/index.mjs';
2+
import * as events from 'node:events';
3+
import * as assert from 'node:assert';
4+
import { describe, it } from 'node:test';
5+
6+
describe('events.addAbortListener', () => {
7+
it('should throw if signal not provided', () => {
8+
assert.throws(() => events.addAbortListener(), { code: 'ERR_INVALID_ARG_TYPE' });
9+
});
10+
11+
it('should throw if provided signal is invalid', () => {
12+
assert.throws(() => events.addAbortListener(undefined), { code: 'ERR_INVALID_ARG_TYPE' });
13+
assert.throws(() => events.addAbortListener(null), { code: 'ERR_INVALID_ARG_TYPE' });
14+
assert.throws(() => events.addAbortListener({}), { code: 'ERR_INVALID_ARG_TYPE' });
15+
});
16+
17+
it('should throw if listener is not a function', () => {
18+
const { signal } = new AbortController();
19+
assert.throws(() => events.addAbortListener(signal), { code: 'ERR_INVALID_ARG_TYPE' });
20+
assert.throws(() => events.addAbortListener(signal, {}), { code: 'ERR_INVALID_ARG_TYPE' });
21+
assert.throws(() => events.addAbortListener(signal, undefined), { code: 'ERR_INVALID_ARG_TYPE' });
22+
});
23+
24+
it('should return a Disposable', () => {
25+
const { signal } = new AbortController();
26+
const disposable = events.addAbortListener(signal, common.mustNotCall());
27+
28+
assert.strictEqual(typeof disposable[Symbol.dispose], 'function');
29+
});
30+
31+
it('should execute the listener immediately for aborted runners', () => {
32+
const disposable = events.addAbortListener(AbortSignal.abort(), common.mustCall());
33+
assert.strictEqual(typeof disposable[Symbol.dispose], 'function');
34+
});
35+
36+
it('should execute the listener even when event propagation stopped', () => {
37+
const controller = new AbortController();
38+
const { signal } = controller;
39+
40+
signal.addEventListener('abort', (e) => e.stopImmediatePropagation());
41+
events.addAbortListener(
42+
signal,
43+
common.mustCall((e) => assert.strictEqual(e.target, signal)),
44+
);
45+
46+
controller.abort();
47+
});
48+
49+
it('should remove event listeners when disposed', () => {
50+
const controller = new AbortController();
51+
const disposable = events.addAbortListener(controller.signal, common.mustNotCall());
52+
disposable[Symbol.dispose]();
53+
controller.abort();
54+
});
55+
});

tools/doc/type-parser.mjs

+1
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ const customTypesMap = {
267267
'Headers': 'https://developer.mozilla.org/en-US/docs/Web/API/Headers',
268268
'Response': 'https://developer.mozilla.org/en-US/docs/Web/API/Response',
269269
'Request': 'https://developer.mozilla.org/en-US/docs/Web/API/Request',
270+
'Disposable': 'https://tc39.es/proposal-explicit-resource-management/#sec-disposable-interface',
270271
};
271272

272273
const arrayPart = /(?:\[])+$/;

0 commit comments

Comments
 (0)