Skip to content

Commit bfbdc84

Browse files
committed
timers: allow promisified timeouts/immediates to be canceled
Using the new experimental AbortController... Signed-off-by: James M Snell <jasnell@gmail.com> PR-URL: #33833 Reviewed-By: Anna Henningsen <anna@addaleax.net> Reviewed-By: Matteo Collina <matteo.collina@gmail.com> Reviewed-By: Gus Caplan <me@gus.host>
1 parent 0ef6956 commit bfbdc84

File tree

3 files changed

+171
-6
lines changed

3 files changed

+171
-6
lines changed

doc/api/timers.md

+42-2
Original file line numberDiff line numberDiff line change
@@ -232,8 +232,47 @@ The [`setImmediate()`][], [`setInterval()`][], and [`setTimeout()`][] methods
232232
each return objects that represent the scheduled timers. These can be used to
233233
cancel the timer and prevent it from triggering.
234234

235-
It is not possible to cancel timers that were created using the promisified
236-
variants of [`setImmediate()`][], [`setTimeout()`][].
235+
For the promisified variants of [`setImmediate()`][] and [`setTimeout()`][],
236+
an [`AbortController`][] may be used to cancel the timer. When canceled, the
237+
returned Promises will be rejected with an `'AbortError'`.
238+
239+
For `setImmediate()`:
240+
241+
```js
242+
const util = require('util');
243+
const setImmediatePromise = util.promisify(setImmediate);
244+
245+
const ac = new AbortController();
246+
const signal = ac.signal;
247+
248+
setImmediatePromise('foobar', { signal })
249+
.then(console.log)
250+
.catch((err) => {
251+
if (err.message === 'AbortError')
252+
console.log('The immediate was aborted');
253+
});
254+
255+
ac.abort();
256+
```
257+
258+
For `setTimeout()`:
259+
260+
```js
261+
const util = require('util');
262+
const setTimeoutPromise = util.promisify(setTimeout);
263+
264+
const ac = new AbortController();
265+
const signal = ac.signal;
266+
267+
setTimeoutPromise(1000, 'foobar', { signal })
268+
.then(console.log)
269+
.catch((err) => {
270+
if (err.message === 'AbortError')
271+
console.log('The timeout was aborted');
272+
});
273+
274+
ac.abort();
275+
```
237276

238277
### `clearImmediate(immediate)`
239278
<!-- YAML
@@ -264,6 +303,7 @@ added: v0.0.1
264303
Cancels a `Timeout` object created by [`setTimeout()`][].
265304

266305
[Event Loop]: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/#setimmediate-vs-settimeout
306+
[`AbortController`]: globals.html#globals_class_abortcontroller
267307
[`TypeError`]: errors.html#errors_class_typeerror
268308
[`clearImmediate()`]: timers.html#timers_clearimmediate_immediate
269309
[`clearInterval()`]: timers.html#timers_clearinterval_timeout

lib/timers.js

+75-4
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ const {
2626
Promise,
2727
} = primordials;
2828

29+
const {
30+
codes: { ERR_INVALID_ARG_TYPE }
31+
} = require('internal/errors');
32+
33+
let DOMException;
34+
2935
const {
3036
immediateInfo,
3137
toggleImmediateRef
@@ -118,6 +124,11 @@ function enroll(item, msecs) {
118124
* DOM-style timers
119125
*/
120126

127+
function lazyDOMException(message) {
128+
if (DOMException === undefined)
129+
DOMException = internalBinding('messaging').DOMException;
130+
return new DOMException(message);
131+
}
121132

122133
function setTimeout(callback, after, arg1, arg2, arg3) {
123134
validateCallback(callback);
@@ -149,11 +160,40 @@ function setTimeout(callback, after, arg1, arg2, arg3) {
149160
return timeout;
150161
}
151162

152-
setTimeout[customPromisify] = function(after, value) {
163+
setTimeout[customPromisify] = function(after, value, options = {}) {
153164
const args = value !== undefined ? [value] : value;
154-
return new Promise((resolve) => {
165+
if (options == null || typeof options !== 'object') {
166+
return Promise.reject(
167+
new ERR_INVALID_ARG_TYPE(
168+
'options',
169+
'Object',
170+
options));
171+
}
172+
const { signal } = options;
173+
if (signal !== undefined &&
174+
(signal === null ||
175+
typeof signal !== 'object' ||
176+
!('aborted' in signal))) {
177+
return Promise.reject(
178+
new ERR_INVALID_ARG_TYPE(
179+
'options.signal',
180+
'AbortSignal',
181+
signal));
182+
}
183+
// TODO(@jasnell): If a decision is made that this cannot be backported
184+
// to 12.x, then this can be converted to use optional chaining to
185+
// simplify the check.
186+
if (signal && signal.aborted)
187+
return Promise.reject(lazyDOMException('AbortError'));
188+
return new Promise((resolve, reject) => {
155189
const timeout = new Timeout(resolve, after, args, false, true);
156190
insert(timeout, timeout._idleTimeout);
191+
if (signal) {
192+
signal.addEventListener('abort', () => {
193+
clearTimeout(timeout);
194+
reject(lazyDOMException('AbortError'));
195+
}, { once: true });
196+
}
157197
});
158198
};
159199

@@ -272,8 +312,39 @@ function setImmediate(callback, arg1, arg2, arg3) {
272312
return new Immediate(callback, args);
273313
}
274314

275-
setImmediate[customPromisify] = function(value) {
276-
return new Promise((resolve) => new Immediate(resolve, [value]));
315+
setImmediate[customPromisify] = function(value, options = {}) {
316+
if (options == null || typeof options !== 'object') {
317+
return Promise.reject(
318+
new ERR_INVALID_ARG_TYPE(
319+
'options',
320+
'Object',
321+
options));
322+
}
323+
const { signal } = options;
324+
if (signal !== undefined &&
325+
(signal === null ||
326+
typeof signal !== 'object' ||
327+
!('aborted' in signal))) {
328+
return Promise.reject(
329+
new ERR_INVALID_ARG_TYPE(
330+
'options.signal',
331+
'AbortSignal',
332+
signal));
333+
}
334+
// TODO(@jasnell): If a decision is made that this cannot be backported
335+
// to 12.x, then this can be converted to use optional chaining to
336+
// simplify the check.
337+
if (signal && signal.aborted)
338+
return Promise.reject(lazyDOMException('AbortError'));
339+
return new Promise((resolve, reject) => {
340+
const immediate = new Immediate(resolve, [value]);
341+
if (signal) {
342+
signal.addEventListener('abort', () => {
343+
clearImmediate(immediate);
344+
reject(lazyDOMException('AbortError'));
345+
}, { once: true });
346+
}
347+
});
277348
};
278349

279350
function clearImmediate(immediate) {

test/parallel/test-timers-promisified.js

+54
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// Flags: --no-warnings
12
'use strict';
23
const common = require('../common');
34
const assert = require('assert');
@@ -36,3 +37,56 @@ const setImmediate = promisify(timers.setImmediate);
3637
assert.strictEqual(value, 'foobar');
3738
}));
3839
}
40+
41+
{
42+
const ac = new AbortController();
43+
const signal = ac.signal;
44+
assert.rejects(setTimeout(10, undefined, { signal }), /AbortError/);
45+
ac.abort();
46+
}
47+
48+
{
49+
const ac = new AbortController();
50+
const signal = ac.signal;
51+
ac.abort(); // Abort in advance
52+
assert.rejects(setTimeout(10, undefined, { signal }), /AbortError/);
53+
}
54+
55+
{
56+
const ac = new AbortController();
57+
const signal = ac.signal;
58+
assert.rejects(setImmediate(10, { signal }), /AbortError/);
59+
ac.abort();
60+
}
61+
62+
{
63+
const ac = new AbortController();
64+
const signal = ac.signal;
65+
ac.abort(); // Abort in advance
66+
assert.rejects(setImmediate(10, { signal }), /AbortError/);
67+
}
68+
69+
{
70+
Promise.all(
71+
[1, '', false, Infinity].map((i) => assert.rejects(setImmediate(10, i)), {
72+
code: 'ERR_INVALID_ARG_TYPE'
73+
})).then(common.mustCall());
74+
75+
Promise.all(
76+
[1, '', false, Infinity, null, {}].map(
77+
(signal) => assert.rejects(setImmediate(10, { signal })), {
78+
code: 'ERR_INVALID_ARG_TYPE'
79+
})).then(common.mustCall());
80+
81+
Promise.all(
82+
[1, '', false, Infinity].map(
83+
(i) => assert.rejects(setTimeout(10, null, i)), {
84+
code: 'ERR_INVALID_ARG_TYPE'
85+
})).then(common.mustCall());
86+
87+
Promise.all(
88+
[1, '', false, Infinity, null, {}].map(
89+
(signal) => assert.rejects(setTimeout(10, null, { signal })), {
90+
code: 'ERR_INVALID_ARG_TYPE'
91+
})).then(common.mustCall());
92+
}

0 commit comments

Comments
 (0)