Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

timers: allow promisified timeouts/immediates to be canceled #33833

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions doc/api/timers.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,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 @@ -264,6 +303,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.html#globals_class_abortcontroller
[`TypeError`]: errors.html#errors_class_typeerror
[`clearImmediate()`]: timers.html#timers_clearimmediate_immediate
[`clearInterval()`]: timers.html#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 @@ -26,6 +26,12 @@ const {
Promise,
} = primordials;

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

let DOMException;

const {
immediateInfo,
toggleImmediateRef
Expand Down Expand Up @@ -118,6 +124,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 @@ -149,11 +160,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 @@ -272,8 +312,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]);
jasnell marked this conversation as resolved.
Show resolved Hide resolved
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
'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());
}