Skip to content

Commit

Permalink
timers: allow promisified timeouts/immediates to be canceled
Browse files Browse the repository at this point in the history
Using the new experimental AbortController...

Signed-off-by: James M Snell <jasnell@gmail.com>

PR-URL: nodejs#33833
Reviewed-By: Anna Henningsen <anna@addaleax.net>
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Gus Caplan <me@gus.host>
  • Loading branch information
jasnell authored and targos committed Apr 30, 2021
1 parent 1cff8fd commit c86e131
Show file tree
Hide file tree
Showing 3 changed files with 171 additions and 6 deletions.
44 changes: 42 additions & 2 deletions doc/api/timers.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,8 +248,47 @@ The [`setImmediate()`][], [`setInterval()`][], and [`setTimeout()`][] methods
each return objects that represent the scheduled timers. These can be used to
cancel the timer and prevent it from triggering.

It is not possible to cancel timers that were created using the promisified
variants of [`setImmediate()`][], [`setTimeout()`][].
For the promisified variants of [`setImmediate()`][] and [`setTimeout()`][],
an [`AbortController`][] may be used to cancel the timer. When canceled, the
returned Promises will be rejected with an `'AbortError'`.

For `setImmediate()`:

```js
const util = require('util');
const setImmediatePromise = util.promisify(setImmediate);

const ac = new AbortController();
const signal = ac.signal;

setImmediatePromise('foobar', { signal })
.then(console.log)
.catch((err) => {
if (err.message === 'AbortError')
console.log('The immediate was aborted');
});

ac.abort();
```

For `setTimeout()`:

```js
const util = require('util');
const setTimeoutPromise = util.promisify(setTimeout);

const ac = new AbortController();
const signal = ac.signal;

setTimeoutPromise(1000, 'foobar', { signal })
.then(console.log)
.catch((err) => {
if (err.message === 'AbortError')
console.log('The timeout was aborted');
});

ac.abort();
```

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

[Event Loop]: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/#setimmediate-vs-settimeout
[`AbortController`]: globals.md#globals_class_abortcontroller
[`TypeError`]: errors.md#errors_class_typeerror
[`clearImmediate()`]: timers.md#timers_clearimmediate_immediate
[`clearInterval()`]: timers.md#timers_clearinterval_timeout
Expand Down
79 changes: 75 additions & 4 deletions lib/timers.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ const {
SymbolToPrimitive
} = primordials;

const {
codes: { ERR_INVALID_ARG_TYPE }
} = require('internal/errors');

let DOMException;

const {
immediateInfo,
toggleImmediateRef
Expand Down Expand Up @@ -129,6 +135,11 @@ function enroll(item, msecs) {
* DOM-style timers
*/

function lazyDOMException(message) {
if (DOMException === undefined)
DOMException = internalBinding('messaging').DOMException;
return new DOMException(message);
}

function setTimeout(callback, after, arg1, arg2, arg3) {
validateCallback(callback);
Expand Down Expand Up @@ -160,11 +171,40 @@ function setTimeout(callback, after, arg1, arg2, arg3) {
return timeout;
}

setTimeout[customPromisify] = function(after, value) {
setTimeout[customPromisify] = function(after, value, options = {}) {
const args = value !== undefined ? [value] : value;
return new Promise((resolve) => {
if (options == null || typeof options !== 'object') {
return Promise.reject(
new ERR_INVALID_ARG_TYPE(
'options',
'Object',
options));
}
const { signal } = options;
if (signal !== undefined &&
(signal === null ||
typeof signal !== 'object' ||
!('aborted' in signal))) {
return Promise.reject(
new ERR_INVALID_ARG_TYPE(
'options.signal',
'AbortSignal',
signal));
}
// TODO(@jasnell): If a decision is made that this cannot be backported
// to 12.x, then this can be converted to use optional chaining to
// simplify the check.
if (signal && signal.aborted)
return Promise.reject(lazyDOMException('AbortError'));
return new Promise((resolve, reject) => {
const timeout = new Timeout(resolve, after, args, false, true);
insert(timeout, timeout._idleTimeout);
if (signal) {
signal.addEventListener('abort', () => {
clearTimeout(timeout);
reject(lazyDOMException('AbortError'));
}, { once: true });
}
});
};

Expand Down Expand Up @@ -300,8 +340,39 @@ function setImmediate(callback, arg1, arg2, arg3) {
return new Immediate(callback, args);
}

setImmediate[customPromisify] = function(value) {
return new Promise((resolve) => new Immediate(resolve, [value]));
setImmediate[customPromisify] = function(value, options = {}) {
if (options == null || typeof options !== 'object') {
return Promise.reject(
new ERR_INVALID_ARG_TYPE(
'options',
'Object',
options));
}
const { signal } = options;
if (signal !== undefined &&
(signal === null ||
typeof signal !== 'object' ||
!('aborted' in signal))) {
return Promise.reject(
new ERR_INVALID_ARG_TYPE(
'options.signal',
'AbortSignal',
signal));
}
// TODO(@jasnell): If a decision is made that this cannot be backported
// to 12.x, then this can be converted to use optional chaining to
// simplify the check.
if (signal && signal.aborted)
return Promise.reject(lazyDOMException('AbortError'));
return new Promise((resolve, reject) => {
const immediate = new Immediate(resolve, [value]);
if (signal) {
signal.addEventListener('abort', () => {
clearImmediate(immediate);
reject(lazyDOMException('AbortError'));
}, { once: true });
}
});
};

function clearImmediate(immediate) {
Expand Down
54 changes: 54 additions & 0 deletions test/parallel/test-timers-promisified.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// Flags: --no-warnings --experimental-abortcontroller
'use strict';
const common = require('../common');
const assert = require('assert');
Expand Down Expand Up @@ -36,3 +37,56 @@ const setImmediate = promisify(timers.setImmediate);
assert.strictEqual(value, 'foobar');
}));
}

{
const ac = new AbortController();
const signal = ac.signal;
assert.rejects(setTimeout(10, undefined, { signal }), /AbortError/);
ac.abort();
}

{
const ac = new AbortController();
const signal = ac.signal;
ac.abort(); // Abort in advance
assert.rejects(setTimeout(10, undefined, { signal }), /AbortError/);
}

{
const ac = new AbortController();
const signal = ac.signal;
assert.rejects(setImmediate(10, { signal }), /AbortError/);
ac.abort();
}

{
const ac = new AbortController();
const signal = ac.signal;
ac.abort(); // Abort in advance
assert.rejects(setImmediate(10, { signal }), /AbortError/);
}

{
Promise.all(
[1, '', false, Infinity].map((i) => assert.rejects(setImmediate(10, i)), {
code: 'ERR_INVALID_ARG_TYPE'
})).then(common.mustCall());

Promise.all(
[1, '', false, Infinity, null, {}].map(
(signal) => assert.rejects(setImmediate(10, { signal })), {
code: 'ERR_INVALID_ARG_TYPE'
})).then(common.mustCall());

Promise.all(
[1, '', false, Infinity].map(
(i) => assert.rejects(setTimeout(10, null, i)), {
code: 'ERR_INVALID_ARG_TYPE'
})).then(common.mustCall());

Promise.all(
[1, '', false, Infinity, null, {}].map(
(signal) => assert.rejects(setTimeout(10, null, { signal })), {
code: 'ERR_INVALID_ARG_TYPE'
})).then(common.mustCall());
}

0 comments on commit c86e131

Please sign in to comment.