Skip to content

Commit 42a7f52

Browse files
Linkgoronfabiancook
andcommitted
timers: introduce setInterval async iterator
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]>
1 parent d0a92e2 commit 42a7f52

File tree

4 files changed

+285
-0
lines changed

4 files changed

+285
-0
lines changed

doc/api/timers.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,26 @@ added: v15.0.0
363363
* `signal` {AbortSignal} An optional `AbortSignal` that can be used to
364364
cancel the scheduled `Immediate`.
365365

366+
### `timersPromises.setInterval([delay[, value[, options]]])`
367+
<!-- YAML
368+
added: REPLACEME
369+
-->
370+
371+
* `delay` {number} The number of milliseconds to wait between iterations.
372+
**Default**: `1`.
373+
* `value` {any} A value with which the iterator returns.
374+
* `options` {Object}
375+
* `ref` {boolean} Set to `false` to indicate that the scheduled `Timeout`
376+
between iterations should not require the Node.js event loop to
377+
remain active.
378+
**Default**: `true`.
379+
* `signal` {AbortSignal} An optional `AbortSignal` that can be used to
380+
cancel the scheduled `Timeout` between operations.
381+
* `throwOnAbort` {boolean} Set to `true` to indicate that the iterator
382+
should finish regularly when the signal is aborted. When set to `false`
383+
the iterator throws after it yields all values.
384+
**Default**: `false`
385+
366386
[Event Loop]: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/#setimmediate-vs-settimeout
367387
[`AbortController`]: globals.md#globals_class_abortcontroller
368388
[`TypeError`]: errors.md#errors_class_typeerror

lib/timers.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,16 @@ function setInterval(callback, repeat, arg1, arg2, arg3) {
215215
return timeout;
216216
}
217217

218+
219+
ObjectDefineProperty(setInterval, customPromisify, {
220+
enumerable: true,
221+
get() {
222+
if (!timersPromises)
223+
timersPromises = require('timers/promises');
224+
return timersPromises.setInterval;
225+
}
226+
});
227+
218228
function clearInterval(timer) {
219229
// clearTimeout and clearInterval can be used to clear timers created from
220230
// both setTimeout and setInterval, as specified by HTML Living Standard:

lib/timers/promises.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,86 @@ function setImmediate(value, options = {}) {
111111
() => signal.removeEventListener('abort', oncancel)) : ret;
112112
}
113113

114+
async function* setInterval(after, value, options = {}) {
115+
if (options == null || typeof options !== 'object') {
116+
throw new ERR_INVALID_ARG_TYPE(
117+
'options',
118+
'Object',
119+
options);
120+
}
121+
const { signal, ref = true, throwOnAbort = true } = options;
122+
validateAbortSignal(signal, 'options.signal');
123+
if (typeof ref !== 'boolean') {
124+
throw new ERR_INVALID_ARG_TYPE(
125+
'options.ref',
126+
'boolean',
127+
ref);
128+
}
129+
130+
if (typeof throwOnAbort !== 'boolean') {
131+
throw new ERR_INVALID_ARG_TYPE(
132+
'options.throwOnAbort',
133+
'boolean',
134+
ref);
135+
}
136+
137+
if (signal?.aborted) {
138+
if (throwOnAbort) throw new AbortError();
139+
return;
140+
}
141+
142+
let onCancel;
143+
let notYielded = 0;
144+
let passCallback;
145+
let abortCallback;
146+
const interval = new Timeout(() => {
147+
notYielded++;
148+
if (passCallback) {
149+
passCallback();
150+
passCallback = undefined;
151+
abortCallback = undefined;
152+
}
153+
}, after, undefined, true, true);
154+
if (!ref) interval.unref();
155+
insert(interval, interval._idleTimeout);
156+
if (signal) {
157+
onCancel = () => {
158+
// eslint-disable-next-line no-undef
159+
clearInterval(interval);
160+
if (abortCallback) {
161+
abortCallback(new AbortError());
162+
passCallback = undefined;
163+
abortCallback = undefined;
164+
}
165+
};
166+
signal.addEventListener('abort', onCancel, { once: true });
167+
}
168+
169+
while (!signal?.aborted) {
170+
if (notYielded === 0) {
171+
try {
172+
await new Promise((resolve, reject) => {
173+
passCallback = resolve;
174+
abortCallback = reject;
175+
});
176+
} catch (err) {
177+
if (throwOnAbort) {
178+
throw err;
179+
}
180+
return;
181+
}
182+
}
183+
for (; notYielded > 0; notYielded--) {
184+
yield value;
185+
}
186+
}
187+
if (throwOnAbort) {
188+
throw new AbortError();
189+
}
190+
}
191+
114192
module.exports = {
115193
setTimeout,
116194
setImmediate,
195+
setInterval,
117196
};

test/parallel/test-timers-promisified.js

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ const timerPromises = require('timers/promises');
1515

1616
const setTimeout = promisify(timers.setTimeout);
1717
const setImmediate = promisify(timers.setImmediate);
18+
const setInterval = promisify(timers.setInterval);
1819
const exec = promisify(child_process.exec);
1920

2021
assert.strictEqual(setTimeout, timerPromises.setTimeout);
2122
assert.strictEqual(setImmediate, timerPromises.setImmediate);
23+
assert.strictEqual(setInterval, timerPromises.setInterval);
2224

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

@@ -50,6 +52,66 @@ process.on('multipleResolves', common.mustNotCall());
5052
}));
5153
}
5254

55+
{
56+
const controller = new AbortController();
57+
const { signal } = controller;
58+
const iterable = setInterval(1, undefined, { signal });
59+
const iterator = iterable[Symbol.asyncIterator]();
60+
const promise = iterator.next();
61+
promise.then(common.mustCall((result) => {
62+
assert.ok(!result.done);
63+
assert.strictEqual(result.value, undefined);
64+
controller.abort();
65+
return assert.rejects(iterator.next(), /AbortError/);
66+
}));
67+
}
68+
69+
{
70+
const controller = new AbortController();
71+
const { signal } = controller;
72+
const iterable = setInterval(1, undefined, { signal, throwOnAbort: false });
73+
const iterator = iterable[Symbol.asyncIterator]();
74+
const promise = iterator.next();
75+
promise.then(common.mustCall((result) => {
76+
assert.ok(!result.done);
77+
assert.strictEqual(result.value, undefined);
78+
controller.abort();
79+
return iterator.next();
80+
})).then(common.mustCall((result) => {
81+
assert.ok(result.done);
82+
}));
83+
}
84+
85+
{
86+
const controller = new AbortController();
87+
const { signal } = controller;
88+
const iterable = setInterval(1, 'foobar', { signal });
89+
const iterator = iterable[Symbol.asyncIterator]();
90+
const promise = iterator.next();
91+
promise.then(common.mustCall((result) => {
92+
assert.ok(!result.done);
93+
assert.strictEqual(result.value, 'foobar');
94+
controller.abort();
95+
return assert.rejects(iterator.next(), /AbortError/);
96+
}));
97+
}
98+
99+
{
100+
const controller = new AbortController();
101+
const { signal } = controller;
102+
const iterable = setInterval(1, 'foobar', { signal, throwOnAbort: false });
103+
const iterator = iterable[Symbol.asyncIterator]();
104+
const promise = iterator.next();
105+
promise.then(common.mustCall((result) => {
106+
assert.ok(!result.done);
107+
assert.strictEqual(result.value, 'foobar');
108+
controller.abort();
109+
return iterator.next();
110+
})).then(common.mustCall((result) => {
111+
assert.ok(result.done);
112+
}));
113+
}
114+
53115
{
54116
const ac = new AbortController();
55117
const signal = ac.signal;
@@ -78,6 +140,33 @@ process.on('multipleResolves', common.mustNotCall());
78140
assert.rejects(setImmediate(10, { signal }), /AbortError/);
79141
}
80142

143+
{
144+
const ac = new AbortController();
145+
const { signal } = ac;
146+
ac.abort(); // Abort in advance
147+
148+
const iterable = setInterval(1, undefined, { signal });
149+
const iterator = iterable[Symbol.asyncIterator]();
150+
151+
assert.rejects(iterator.next(), /AbortError/);
152+
}
153+
154+
{
155+
const ac = new AbortController();
156+
const { signal } = ac;
157+
158+
const iterable = setInterval(100, undefined, { signal });
159+
const iterator = iterable[Symbol.asyncIterator]();
160+
161+
// This promise should take 100 seconds to resolve, so now aborting it should
162+
// mean we abort early
163+
const promise = iterator.next();
164+
165+
ac.abort(); // Abort in after we have a next promise
166+
167+
assert.rejects(promise, /AbortError/);
168+
}
169+
81170
{
82171
// Check that aborting after resolve will not reject.
83172
const ac = new AbortController();
@@ -95,6 +184,23 @@ process.on('multipleResolves', common.mustNotCall());
95184
});
96185
}
97186

187+
{
188+
[1, '', Infinity, null, {}].forEach((ref) => {
189+
const iterable = setInterval(10, undefined, { ref });
190+
assert.rejects(() => iterable[Symbol.asyncIterator]().next(), /ERR_INVALID_ARG_TYPE/);
191+
});
192+
193+
[1, '', Infinity, null, {}].forEach((signal) => {
194+
const iterable = setInterval(10, undefined, { signal });
195+
assert.rejects(() => iterable[Symbol.asyncIterator]().next(), /ERR_INVALID_ARG_TYPE/);
196+
});
197+
198+
[1, '', Infinity, null, true, false].forEach((options) => {
199+
const iterable = setInterval(10, undefined, options);
200+
assert.rejects(() => iterable[Symbol.asyncIterator]().next(), /ERR_INVALID_ARG_TYPE/);
201+
});
202+
}
203+
98204
{
99205
// Check that timer adding signals does not leak handlers
100206
const signal = new NodeEventTarget();
@@ -165,3 +271,73 @@ process.on('multipleResolves', common.mustNotCall());
165271
assert.strictEqual(stderr, '');
166272
}));
167273
}
274+
275+
{
276+
exec(`${process.execPath} -pe "const assert = require('assert');` +
277+
'const interval = require(\'timers/promises\')' +
278+
'.setInterval(1000, null, { ref: false });' +
279+
'interval[Symbol.asyncIterator]().next()' +
280+
'.then(assert.fail)"').then(common.mustCall(({ stderr }) => {
281+
assert.strictEqual(stderr, '');
282+
}));
283+
}
284+
285+
{
286+
async function runInterval(fn, intervalTime, signal) {
287+
const input = 'foobar';
288+
const interval = setInterval(intervalTime, input, { signal });
289+
let iteration = 0;
290+
for await (const value of interval) {
291+
const time = Date.now();
292+
assert.strictEqual(value, input);
293+
await fn(time, iteration);
294+
iteration++;
295+
}
296+
}
297+
298+
{
299+
const controller = new AbortController();
300+
const { signal } = controller;
301+
302+
let prevTime;
303+
let looped = 0;
304+
const delay = 20;
305+
const timeoutLoop = runInterval((time) => {
306+
looped++;
307+
if (looped === 5) controller.abort();
308+
if (looped > 5) throw new Error('ran too many times');
309+
if (prevTime && time - prevTime < delay) {
310+
const diff = time - prevTime;
311+
throw new Error(`${diff} between iterations, lower than ${delay}`);
312+
}
313+
prevTime = time;
314+
}, delay, signal);
315+
316+
assert.rejects(timeoutLoop, /AbortError/);
317+
timeoutLoop.catch(common.mustCall(() => {
318+
assert.strictEqual(5, looped);
319+
}));
320+
}
321+
322+
{
323+
// Check that if we abort when we have some callbacks left,
324+
// we actually call them.
325+
const controller = new AbortController();
326+
const { signal } = controller;
327+
const delay = 10;
328+
let totalIterations = 0;
329+
const timeoutLoop = runInterval(async (time, iterationNumber) => {
330+
if (iterationNumber === 1) {
331+
await setTimeout(delay * 3);
332+
controller.abort();
333+
}
334+
if (iterationNumber > totalIterations) {
335+
totalIterations = iterationNumber;
336+
}
337+
}, delay, signal);
338+
339+
timeoutLoop.catch(common.mustCall(() => {
340+
assert.ok(totalIterations >= 3);
341+
}));
342+
}
343+
}

0 commit comments

Comments
 (0)