Skip to content

Commit

Permalink
timers: introduce setInterval async iterator
Browse files Browse the repository at this point in the history
Added setInterval async generator to timers\promises.
Utilises async generators to provide an iterator compatible with
`for await`.

Co-Authored-By: Fabian Cook <[email protected]>
  • Loading branch information
Linkgoron and fabiancook committed Jan 31, 2021
1 parent d0a92e2 commit 42a7f52
Show file tree
Hide file tree
Showing 4 changed files with 285 additions and 0 deletions.
20 changes: 20 additions & 0 deletions doc/api/timers.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,26 @@ added: v15.0.0
* `signal` {AbortSignal} An optional `AbortSignal` that can be used to
cancel the scheduled `Immediate`.

### `timersPromises.setInterval([delay[, value[, options]]])`
<!-- YAML
added: REPLACEME
-->

* `delay` {number} The number of milliseconds to wait between iterations.
**Default**: `1`.
* `value` {any} A value with which the iterator returns.
* `options` {Object}
* `ref` {boolean} Set to `false` to indicate that the scheduled `Timeout`
between iterations should not require the Node.js event loop to
remain active.
**Default**: `true`.
* `signal` {AbortSignal} An optional `AbortSignal` that can be used to
cancel the scheduled `Timeout` between operations.
* `throwOnAbort` {boolean} Set to `true` to indicate that the iterator
should finish regularly when the signal is aborted. When set to `false`
the iterator throws after it yields all values.
**Default**: `false`

[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
Expand Down
10 changes: 10 additions & 0 deletions lib/timers.js
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,16 @@ function setInterval(callback, repeat, arg1, arg2, arg3) {
return timeout;
}


ObjectDefineProperty(setInterval, customPromisify, {
enumerable: true,
get() {
if (!timersPromises)
timersPromises = require('timers/promises');
return timersPromises.setInterval;
}
});

function clearInterval(timer) {
// clearTimeout and clearInterval can be used to clear timers created from
// both setTimeout and setInterval, as specified by HTML Living Standard:
Expand Down
79 changes: 79 additions & 0 deletions lib/timers/promises.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,86 @@ function setImmediate(value, options = {}) {
() => signal.removeEventListener('abort', oncancel)) : ret;
}

async function* setInterval(after, value, options = {}) {
if (options == null || typeof options !== 'object') {
throw new ERR_INVALID_ARG_TYPE(
'options',
'Object',
options);
}
const { signal, ref = true, throwOnAbort = true } = options;
validateAbortSignal(signal, 'options.signal');
if (typeof ref !== 'boolean') {
throw new ERR_INVALID_ARG_TYPE(
'options.ref',
'boolean',
ref);
}

if (typeof throwOnAbort !== 'boolean') {
throw new ERR_INVALID_ARG_TYPE(
'options.throwOnAbort',
'boolean',
ref);
}

if (signal?.aborted) {
if (throwOnAbort) throw new AbortError();
return;
}

let onCancel;
let notYielded = 0;
let passCallback;
let abortCallback;
const interval = new Timeout(() => {
notYielded++;
if (passCallback) {
passCallback();
passCallback = undefined;
abortCallback = undefined;
}
}, after, undefined, true, true);
if (!ref) interval.unref();
insert(interval, interval._idleTimeout);
if (signal) {
onCancel = () => {
// eslint-disable-next-line no-undef
clearInterval(interval);
if (abortCallback) {
abortCallback(new AbortError());
passCallback = undefined;
abortCallback = undefined;
}
};
signal.addEventListener('abort', onCancel, { once: true });
}

while (!signal?.aborted) {
if (notYielded === 0) {
try {
await new Promise((resolve, reject) => {
passCallback = resolve;
abortCallback = reject;
});
} catch (err) {
if (throwOnAbort) {
throw err;
}
return;
}
}
for (; notYielded > 0; notYielded--) {
yield value;
}
}
if (throwOnAbort) {
throw new AbortError();
}
}

module.exports = {
setTimeout,
setImmediate,
setInterval,
};
176 changes: 176 additions & 0 deletions test/parallel/test-timers-promisified.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ const timerPromises = require('timers/promises');

const setTimeout = promisify(timers.setTimeout);
const setImmediate = promisify(timers.setImmediate);
const setInterval = promisify(timers.setInterval);
const exec = promisify(child_process.exec);

assert.strictEqual(setTimeout, timerPromises.setTimeout);
assert.strictEqual(setImmediate, timerPromises.setImmediate);
assert.strictEqual(setInterval, timerPromises.setInterval);

process.on('multipleResolves', common.mustNotCall());

Expand Down Expand Up @@ -50,6 +52,66 @@ process.on('multipleResolves', common.mustNotCall());
}));
}

{
const controller = new AbortController();
const { signal } = controller;
const iterable = setInterval(1, undefined, { signal });
const iterator = iterable[Symbol.asyncIterator]();
const promise = iterator.next();
promise.then(common.mustCall((result) => {
assert.ok(!result.done);
assert.strictEqual(result.value, undefined);
controller.abort();
return assert.rejects(iterator.next(), /AbortError/);
}));
}

{
const controller = new AbortController();
const { signal } = controller;
const iterable = setInterval(1, undefined, { signal, throwOnAbort: false });
const iterator = iterable[Symbol.asyncIterator]();
const promise = iterator.next();
promise.then(common.mustCall((result) => {
assert.ok(!result.done);
assert.strictEqual(result.value, undefined);
controller.abort();
return iterator.next();
})).then(common.mustCall((result) => {
assert.ok(result.done);
}));
}

{
const controller = new AbortController();
const { signal } = controller;
const iterable = setInterval(1, 'foobar', { signal });
const iterator = iterable[Symbol.asyncIterator]();
const promise = iterator.next();
promise.then(common.mustCall((result) => {
assert.ok(!result.done);
assert.strictEqual(result.value, 'foobar');
controller.abort();
return assert.rejects(iterator.next(), /AbortError/);
}));
}

{
const controller = new AbortController();
const { signal } = controller;
const iterable = setInterval(1, 'foobar', { signal, throwOnAbort: false });
const iterator = iterable[Symbol.asyncIterator]();
const promise = iterator.next();
promise.then(common.mustCall((result) => {
assert.ok(!result.done);
assert.strictEqual(result.value, 'foobar');
controller.abort();
return iterator.next();
})).then(common.mustCall((result) => {
assert.ok(result.done);
}));
}

{
const ac = new AbortController();
const signal = ac.signal;
Expand Down Expand Up @@ -78,6 +140,33 @@ process.on('multipleResolves', common.mustNotCall());
assert.rejects(setImmediate(10, { signal }), /AbortError/);
}

{
const ac = new AbortController();
const { signal } = ac;
ac.abort(); // Abort in advance

const iterable = setInterval(1, undefined, { signal });
const iterator = iterable[Symbol.asyncIterator]();

assert.rejects(iterator.next(), /AbortError/);
}

{
const ac = new AbortController();
const { signal } = ac;

const iterable = setInterval(100, undefined, { signal });
const iterator = iterable[Symbol.asyncIterator]();

// This promise should take 100 seconds to resolve, so now aborting it should
// mean we abort early
const promise = iterator.next();

ac.abort(); // Abort in after we have a next promise

assert.rejects(promise, /AbortError/);
}

{
// Check that aborting after resolve will not reject.
const ac = new AbortController();
Expand All @@ -95,6 +184,23 @@ process.on('multipleResolves', common.mustNotCall());
});
}

{
[1, '', Infinity, null, {}].forEach((ref) => {
const iterable = setInterval(10, undefined, { ref });
assert.rejects(() => iterable[Symbol.asyncIterator]().next(), /ERR_INVALID_ARG_TYPE/);
});

[1, '', Infinity, null, {}].forEach((signal) => {
const iterable = setInterval(10, undefined, { signal });
assert.rejects(() => iterable[Symbol.asyncIterator]().next(), /ERR_INVALID_ARG_TYPE/);
});

[1, '', Infinity, null, true, false].forEach((options) => {
const iterable = setInterval(10, undefined, options);
assert.rejects(() => iterable[Symbol.asyncIterator]().next(), /ERR_INVALID_ARG_TYPE/);
});
}

{
// Check that timer adding signals does not leak handlers
const signal = new NodeEventTarget();
Expand Down Expand Up @@ -165,3 +271,73 @@ process.on('multipleResolves', common.mustNotCall());
assert.strictEqual(stderr, '');
}));
}

{
exec(`${process.execPath} -pe "const assert = require('assert');` +
'const interval = require(\'timers/promises\')' +
'.setInterval(1000, null, { ref: false });' +
'interval[Symbol.asyncIterator]().next()' +
'.then(assert.fail)"').then(common.mustCall(({ stderr }) => {
assert.strictEqual(stderr, '');
}));
}

{
async function runInterval(fn, intervalTime, signal) {
const input = 'foobar';
const interval = setInterval(intervalTime, input, { signal });
let iteration = 0;
for await (const value of interval) {
const time = Date.now();
assert.strictEqual(value, input);
await fn(time, iteration);
iteration++;
}
}

{
const controller = new AbortController();
const { signal } = controller;

let prevTime;
let looped = 0;
const delay = 20;
const timeoutLoop = runInterval((time) => {
looped++;
if (looped === 5) controller.abort();
if (looped > 5) throw new Error('ran too many times');
if (prevTime && time - prevTime < delay) {
const diff = time - prevTime;
throw new Error(`${diff} between iterations, lower than ${delay}`);
}
prevTime = time;
}, delay, signal);

assert.rejects(timeoutLoop, /AbortError/);
timeoutLoop.catch(common.mustCall(() => {
assert.strictEqual(5, looped);
}));
}

{
// Check that if we abort when we have some callbacks left,
// we actually call them.
const controller = new AbortController();
const { signal } = controller;
const delay = 10;
let totalIterations = 0;
const timeoutLoop = runInterval(async (time, iterationNumber) => {
if (iterationNumber === 1) {
await setTimeout(delay * 3);
controller.abort();
}
if (iterationNumber > totalIterations) {
totalIterations = iterationNumber;
}
}, delay, signal);

timeoutLoop.catch(common.mustCall(() => {
assert.ok(totalIterations >= 3);
}));
}
}

0 comments on commit 42a7f52

Please sign in to comment.