Skip to content

Commit b708f4d

Browse files
committed
util: allow safely adding listener to abortSignal
1 parent 1936160 commit b708f4d

File tree

4 files changed

+146
-0
lines changed

4 files changed

+146
-0
lines changed

doc/api/util.md

+59
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,64 @@ it:
1414
const util = require('node:util');
1515
```
1616

17+
## `util.addAbortListener(signal, resource)`
18+
19+
<!-- YAML
20+
added: REPLACEME
21+
-->
22+
23+
> Stability: 1 - Experimental
24+
25+
* `signal` {AbortSignal}
26+
* `listener` {Function|EventListener}
27+
* Returns: {Disposable} that removes the `abort` listener.
28+
29+
Listens once to the `abort` event on the provided `signal`.
30+
31+
Listening to the `abort` event on abort signals is unsafe and may
32+
lead to resource leaks since another third party with the signal can
33+
call [`e.stopImmediatePropagation()`][]. Unfortunately Node.js cannot change
34+
this since it would violate the web standard. Additionally, the original
35+
API makes it easy to forget to remove listeners.
36+
37+
This API allows safely using `AbortSignal`s in Node.js APIs by solving these
38+
two issues by listening to the event such that `stopImmediatePropagation` does
39+
not prevent the listener from running.
40+
41+
Returns a disposable so that it may be unsubscribed from more easily.
42+
43+
```cjs
44+
const { addAbortListener } = require('node:util');
45+
46+
function example(signal) {
47+
let disposable;
48+
try {
49+
signal.addEventListener('abort', (e) => e.stopImmediatePropagation());
50+
disposable = addAbortListener(signal, (e) => {
51+
// Do something when signal is aborted.
52+
});
53+
} finally {
54+
disposable?.[Symbol.dispose]();
55+
}
56+
}
57+
```
58+
59+
```mjs
60+
import { addAbortListener } from 'node:util';
61+
62+
function example(signal) {
63+
let disposable;
64+
try {
65+
signal.addEventListener('abort', (e) => e.stopImmediatePropagation());
66+
disposable = addAbortListener(signal, (e) => {
67+
// Do something when signal is aborted.
68+
});
69+
} finally {
70+
disposable?.[Symbol.dispose]();
71+
}
72+
}
73+
```
74+
1775
## `util.callbackify(original)`
1876
1977
<!-- YAML
@@ -3354,6 +3412,7 @@ util.log('Timestamped message.');
33543412
[`WebAssembly.Module`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WebAssembly/Module
33553413
[`assert.deepStrictEqual()`]: assert.md#assertdeepstrictequalactual-expected-message
33563414
[`console.error()`]: console.md#consoleerrordata-args
3415+
[`e.stopImmediatePropagation()`]: events.md#eventstopimmediatepropagation
33573416
[`mime.toString()`]: #mimetostring
33583417
[`mimeParams.entries()`]: #mimeparamsentries
33593418
[`napi_create_external()`]: n-api.md#napi_create_external

lib/util.js

+31
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const {
4343
ObjectValues,
4444
ReflectApply,
4545
StringPrototypePadStart,
46+
SymbolDispose,
4647
} = primordials;
4748

4849
const {
@@ -63,6 +64,7 @@ const {
6364
} = require('internal/util/inspect');
6465
const { debuglog } = require('internal/util/debuglog');
6566
const {
67+
validateAbortSignal,
6668
validateFunction,
6769
validateNumber,
6870
} = require('internal/validators');
@@ -77,6 +79,7 @@ const {
7779
toUSVString,
7880
defineLazyProperties,
7981
} = require('internal/util');
82+
const { queueMicrotask } = require('internal/process/task_queues');
8083

8184
let abortController;
8285

@@ -86,6 +89,7 @@ function lazyAbortController() {
8689
}
8790

8891
let internalDeepEqual;
92+
let kResistStopPropagation;
8993

9094
/**
9195
* @deprecated since v4.0.0
@@ -345,11 +349,38 @@ function getSystemErrorName(err) {
345349
return internalErrorName(err);
346350
}
347351

352+
function addAbortListener(signal, listener) {
353+
if (signal === undefined) {
354+
throw new ERR_INVALID_ARG_TYPE('signal', 'AbortSignal', signal);
355+
}
356+
validateAbortSignal(signal, 'signal');
357+
validateFunction(listener, 'listener');
358+
359+
let removeEventListener = () => {};
360+
if (signal.aborted) {
361+
queueMicrotask(() => listener());
362+
} else {
363+
kResistStopPropagation ??= require('internal/event_target').kResistStopPropagation;
364+
// TODO(atlowChemi) add { subscription: true } and return directly
365+
signal.addEventListener('abort', listener, { __proto__: null, once: true, [kResistStopPropagation]: true });
366+
removeEventListener = () => {
367+
signal.removeEventListener('abort', listener);
368+
};
369+
}
370+
return {
371+
__proto__: null,
372+
[SymbolDispose]() {
373+
removeEventListener();
374+
},
375+
};
376+
}
377+
348378
// Keep the `exports =` so that various functions can still be monkeypatched
349379
module.exports = {
350380
_errnoException: errnoException,
351381
_exceptionWithHostPort: exceptionWithHostPort,
352382
_extend,
383+
addAbortListener,
353384
callbackify,
354385
debug: debuglog,
355386
debuglog,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import * as common from '../common/index.mjs';
2+
import * as util from 'node:util';
3+
import * as assert from 'node:assert';
4+
import { describe, it } from 'node:test';
5+
6+
describe('util.addAbortListener', () => {
7+
it('should throw if signal not provided', () => {
8+
assert.throws(() => util.addAbortListener(), { code: 'ERR_INVALID_ARG_TYPE' });
9+
});
10+
11+
it('should throw if provided signal is invalid', () => {
12+
assert.throws(() => util.addAbortListener(undefined), { code: 'ERR_INVALID_ARG_TYPE' });
13+
assert.throws(() => util.addAbortListener(null), { code: 'ERR_INVALID_ARG_TYPE' });
14+
assert.throws(() => util.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(() => util.addAbortListener(signal), { code: 'ERR_INVALID_ARG_TYPE' });
20+
assert.throws(() => util.addAbortListener(signal, {}), { code: 'ERR_INVALID_ARG_TYPE' });
21+
assert.throws(() => util.addAbortListener(signal, undefined), { code: 'ERR_INVALID_ARG_TYPE' });
22+
});
23+
24+
it('should return a Disposable', () => {
25+
const { signal } = new AbortController();
26+
const disposable = util.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 = util.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+
util.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 = util.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
@@ -270,6 +270,7 @@ const customTypesMap = {
270270
'Headers': 'https://developer.mozilla.org/en-US/docs/Web/API/Headers',
271271
'Response': 'https://developer.mozilla.org/en-US/docs/Web/API/Response',
272272
'Request': 'https://developer.mozilla.org/en-US/docs/Web/API/Request',
273+
'Disposable': 'https://tc39.es/proposal-explicit-resource-management/#sec-disposable-interface',
273274
};
274275

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

0 commit comments

Comments
 (0)