Skip to content

Commit 84b2863

Browse files
jasnelltargos
authored andcommitted
timers: allow promisified timeouts/immediates to be canceled
Using the new experimental AbortController... Signed-off-by: James M Snell <[email protected]> PR-URL: #33833 Backport-PR-URL: #38386 Reviewed-By: Anna Henningsen <[email protected]> Reviewed-By: Matteo Collina <[email protected]> Reviewed-By: Gus Caplan <[email protected]>
1 parent f30f931 commit 84b2863

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
@@ -248,8 +248,47 @@ The [`setImmediate()`][], [`setInterval()`][], and [`setTimeout()`][] methods
248248
each return objects that represent the scheduled timers. These can be used to
249249
cancel the timer and prevent it from triggering.
250250

251-
It is not possible to cancel timers that were created using the promisified
252-
variants of [`setImmediate()`][], [`setTimeout()`][].
251+
For the promisified variants of [`setImmediate()`][] and [`setTimeout()`][],
252+
an [`AbortController`][] may be used to cancel the timer. When canceled, the
253+
returned Promises will be rejected with an `'AbortError'`.
254+
255+
For `setImmediate()`:
256+
257+
```js
258+
const util = require('util');
259+
const setImmediatePromise = util.promisify(setImmediate);
260+
261+
const ac = new AbortController();
262+
const signal = ac.signal;
263+
264+
setImmediatePromise('foobar', { signal })
265+
.then(console.log)
266+
.catch((err) => {
267+
if (err.message === 'AbortError')
268+
console.log('The immediate was aborted');
269+
});
270+
271+
ac.abort();
272+
```
273+
274+
For `setTimeout()`:
275+
276+
```js
277+
const util = require('util');
278+
const setTimeoutPromise = util.promisify(setTimeout);
279+
280+
const ac = new AbortController();
281+
const signal = ac.signal;
282+
283+
setTimeoutPromise(1000, 'foobar', { signal })
284+
.then(console.log)
285+
.catch((err) => {
286+
if (err.message === 'AbortError')
287+
console.log('The timeout was aborted');
288+
});
289+
290+
ac.abort();
291+
```
253292

254293
### `clearImmediate(immediate)`
255294
<!-- YAML
@@ -280,6 +319,7 @@ added: v0.0.1
280319
Cancels a `Timeout` object created by [`setTimeout()`][].
281320

282321
[Event Loop]: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/#setimmediate-vs-settimeout
322+
[`AbortController`]: globals.md#globals_class_abortcontroller
283323
[`TypeError`]: errors.md#errors_class_typeerror
284324
[`clearImmediate()`]: timers.md#timers_clearimmediate_immediate
285325
[`clearInterval()`]: timers.md#timers_clearinterval_timeout

lib/timers.js

+75-4
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ const {
2828
SymbolToPrimitive
2929
} = primordials;
3030

31+
const {
32+
codes: { ERR_INVALID_ARG_TYPE }
33+
} = require('internal/errors');
34+
35+
let DOMException;
36+
3137
const {
3238
immediateInfo,
3339
toggleImmediateRef
@@ -129,6 +135,11 @@ function enroll(item, msecs) {
129135
* DOM-style timers
130136
*/
131137

138+
function lazyDOMException(message) {
139+
if (DOMException === undefined)
140+
DOMException = internalBinding('messaging').DOMException;
141+
return new DOMException(message);
142+
}
132143

133144
function setTimeout(callback, after, arg1, arg2, arg3) {
134145
validateCallback(callback);
@@ -160,11 +171,40 @@ function setTimeout(callback, after, arg1, arg2, arg3) {
160171
return timeout;
161172
}
162173

163-
setTimeout[customPromisify] = function(after, value) {
174+
setTimeout[customPromisify] = function(after, value, options = {}) {
164175
const args = value !== undefined ? [value] : value;
165-
return new Promise((resolve) => {
176+
if (options == null || typeof options !== 'object') {
177+
return Promise.reject(
178+
new ERR_INVALID_ARG_TYPE(
179+
'options',
180+
'Object',
181+
options));
182+
}
183+
const { signal } = options;
184+
if (signal !== undefined &&
185+
(signal === null ||
186+
typeof signal !== 'object' ||
187+
!('aborted' in signal))) {
188+
return Promise.reject(
189+
new ERR_INVALID_ARG_TYPE(
190+
'options.signal',
191+
'AbortSignal',
192+
signal));
193+
}
194+
// TODO(@jasnell): If a decision is made that this cannot be backported
195+
// to 12.x, then this can be converted to use optional chaining to
196+
// simplify the check.
197+
if (signal && signal.aborted)
198+
return Promise.reject(lazyDOMException('AbortError'));
199+
return new Promise((resolve, reject) => {
166200
const timeout = new Timeout(resolve, after, args, false, true);
167201
insert(timeout, timeout._idleTimeout);
202+
if (signal) {
203+
signal.addEventListener('abort', () => {
204+
clearTimeout(timeout);
205+
reject(lazyDOMException('AbortError'));
206+
}, { once: true });
207+
}
168208
});
169209
};
170210

@@ -300,8 +340,39 @@ function setImmediate(callback, arg1, arg2, arg3) {
300340
return new Immediate(callback, args);
301341
}
302342

303-
setImmediate[customPromisify] = function(value) {
304-
return new Promise((resolve) => new Immediate(resolve, [value]));
343+
setImmediate[customPromisify] = function(value, options = {}) {
344+
if (options == null || typeof options !== 'object') {
345+
return Promise.reject(
346+
new ERR_INVALID_ARG_TYPE(
347+
'options',
348+
'Object',
349+
options));
350+
}
351+
const { signal } = options;
352+
if (signal !== undefined &&
353+
(signal === null ||
354+
typeof signal !== 'object' ||
355+
!('aborted' in signal))) {
356+
return Promise.reject(
357+
new ERR_INVALID_ARG_TYPE(
358+
'options.signal',
359+
'AbortSignal',
360+
signal));
361+
}
362+
// TODO(@jasnell): If a decision is made that this cannot be backported
363+
// to 12.x, then this can be converted to use optional chaining to
364+
// simplify the check.
365+
if (signal && signal.aborted)
366+
return Promise.reject(lazyDOMException('AbortError'));
367+
return new Promise((resolve, reject) => {
368+
const immediate = new Immediate(resolve, [value]);
369+
if (signal) {
370+
signal.addEventListener('abort', () => {
371+
clearImmediate(immediate);
372+
reject(lazyDOMException('AbortError'));
373+
}, { once: true });
374+
}
375+
});
305376
};
306377

307378
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 --experimental-abortcontroller
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)