Skip to content

Commit 1f8b21c

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 <hello@fabiancook.dev>
1 parent d0a92e2 commit 1f8b21c

File tree

4 files changed

+303
-9
lines changed

4 files changed

+303
-9
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: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ const {
1818
codes: { ERR_INVALID_ARG_TYPE }
1919
} = require('internal/errors');
2020

21-
const { validateAbortSignal } = require('internal/validators');
21+
const {
22+
validateAbortSignal,
23+
validateBoolean,
24+
validateObject,
25+
} = require('internal/validators');
2226

2327
function cancelListenerHandler(clear, reject) {
2428
if (!this._destroyed) {
@@ -111,7 +115,70 @@ function setImmediate(value, options = {}) {
111115
() => signal.removeEventListener('abort', oncancel)) : ret;
112116
}
113117

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

test/parallel/test-timers-promisified.js

Lines changed: 205 additions & 8 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,48 +52,159 @@ 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+
})).then(common.mustCall());
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+
})).then(common.mustCall());
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;
56-
assert.rejects(setTimeout(10, undefined, { signal }), /AbortError/);
118+
assert.rejects(setTimeout(10, undefined, { signal }), /AbortError/)
119+
.then(common.mustCall());
57120
ac.abort();
58121
}
59122

60123
{
61124
const ac = new AbortController();
62125
const signal = ac.signal;
63126
ac.abort(); // Abort in advance
64-
assert.rejects(setTimeout(10, undefined, { signal }), /AbortError/);
127+
assert.rejects(setTimeout(10, undefined, { signal }), /AbortError/)
128+
.then(common.mustCall());
65129
}
66130

67131
{
68132
const ac = new AbortController();
69133
const signal = ac.signal;
70-
assert.rejects(setImmediate(10, { signal }), /AbortError/);
134+
assert.rejects(setImmediate(10, { signal }), /AbortError/)
135+
.then(common.mustCall());
71136
ac.abort();
72137
}
73138

74139
{
75140
const ac = new AbortController();
76141
const signal = ac.signal;
77142
ac.abort(); // Abort in advance
78-
assert.rejects(setImmediate(10, { signal }), /AbortError/);
143+
assert.rejects(setImmediate(10, { signal }), /AbortError/)
144+
.then(common.mustCall());
145+
}
146+
147+
{
148+
const ac = new AbortController();
149+
const { signal } = ac;
150+
ac.abort(); // Abort in advance
151+
152+
const iterable = setInterval(1, undefined, { signal });
153+
const iterator = iterable[Symbol.asyncIterator]();
154+
assert.rejects(iterator.next(), /AbortError/).then(common.mustCall());
155+
}
156+
157+
{
158+
const ac = new AbortController();
159+
const { signal } = ac;
160+
161+
const iterable = setInterval(100, undefined, { signal });
162+
const iterator = iterable[Symbol.asyncIterator]();
163+
164+
// This promise should take 100 seconds to resolve, so now aborting it should
165+
// mean we abort early
166+
const promise = iterator.next();
167+
168+
ac.abort(); // Abort in after we have a next promise
169+
170+
assert.rejects(promise, /AbortError/).then(common.mustCall());
79171
}
80172

81173
{
82174
// Check that aborting after resolve will not reject.
83175
const ac = new AbortController();
84176
const signal = ac.signal;
85-
setTimeout(10, undefined, { signal }).then(() => {
86-
ac.abort();
87-
});
177+
assert.doesNotReject(setTimeout(10, undefined, { signal })
178+
.then(common.mustCall(() => {
179+
ac.abort();
180+
}))).then(common.mustCall());
88181
}
89182
{
90183
// Check that aborting after resolve will not reject.
91184
const ac = new AbortController();
92185
const signal = ac.signal;
93-
setImmediate(10, { signal }).then(() => {
186+
assert.doesNotReject(setImmediate(10, { signal }).then(common.mustCall(() => {
94187
ac.abort();
188+
}))).then(common.mustCall());
189+
}
190+
191+
{
192+
[1, '', Infinity, null, {}].forEach((ref) => {
193+
const iterable = setInterval(10, undefined, { ref });
194+
assert.rejects(() => iterable[Symbol.asyncIterator]().next(), /ERR_INVALID_ARG_TYPE/)
195+
.then(common.mustCall());
196+
});
197+
198+
[1, '', Infinity, null, {}].forEach((signal) => {
199+
const iterable = setInterval(10, undefined, { signal });
200+
assert.rejects(() => iterable[Symbol.asyncIterator]().next(), /ERR_INVALID_ARG_TYPE/)
201+
.then(common.mustCall());
202+
});
203+
204+
[1, '', Infinity, null, true, false].forEach((options) => {
205+
const iterable = setInterval(10, undefined, options);
206+
assert.rejects(() => iterable[Symbol.asyncIterator]().next(), /ERR_INVALID_ARG_TYPE/)
207+
.then(common.mustCall());
95208
});
96209
}
97210

@@ -165,3 +278,87 @@ process.on('multipleResolves', common.mustNotCall());
165278
assert.strictEqual(stderr, '');
166279
}));
167280
}
281+
282+
{
283+
exec(`${process.execPath} -pe "const assert = require('assert');` +
284+
'const interval = require(\'timers/promises\')' +
285+
'.setInterval(1000, null, { ref: false });' +
286+
'interval[Symbol.asyncIterator]().next()' +
287+
'.then(assert.fail)"').then(common.mustCall(({ stderr }) => {
288+
assert.strictEqual(stderr, '');
289+
}));
290+
}
291+
292+
{
293+
async function runInterval(fn, intervalTime, signal) {
294+
const input = 'foobar';
295+
const interval = setInterval(intervalTime, input, { signal });
296+
let iteration = 0;
297+
for await (const value of interval) {
298+
const time = Date.now();
299+
assert.strictEqual(value, input);
300+
await fn(time, iteration);
301+
iteration++;
302+
}
303+
}
304+
305+
{
306+
// Check that we call the correct amount of times.
307+
const controller = new AbortController();
308+
const { signal } = controller;
309+
310+
let loopCount = 0;
311+
const delay = 20;
312+
const timeoutLoop = runInterval(() => {
313+
loopCount++;
314+
if (loopCount === 5) controller.abort();
315+
if (loopCount > 5) throw new Error('ran too many times');
316+
}, delay, signal);
317+
318+
assert.rejects(timeoutLoop, /AbortError/).then(common.mustCall(() => {
319+
assert.strictEqual(loopCount, 5);
320+
}));
321+
}
322+
323+
{
324+
// Check that if we abort when we delay long enough
325+
const controller = new AbortController();
326+
const { signal } = controller;
327+
328+
let prevTime;
329+
const delay = 25;
330+
const timeoutLoop = runInterval((time, iteration) => {
331+
if (iteration === 5) controller.abort();
332+
// Give some slack because of timers
333+
if (prevTime && (time - prevTime < (delay - 5))) {
334+
const diff = time - prevTime;
335+
throw new Error(`${diff} between iterations, lower than ${delay}`);
336+
}
337+
prevTime = time;
338+
}, delay, signal);
339+
340+
assert.rejects(timeoutLoop, /AbortError/).then(common.mustCall());
341+
}
342+
343+
{
344+
// Check that if we abort when we have some callbacks left,
345+
// we actually call them.
346+
const controller = new AbortController();
347+
const { signal } = controller;
348+
const delay = 10;
349+
let totalIterations = 0;
350+
const timeoutLoop = runInterval(async (time, iterationNumber) => {
351+
if (iterationNumber === 1) {
352+
await setTimeout(delay * 3);
353+
controller.abort();
354+
}
355+
if (iterationNumber > totalIterations) {
356+
totalIterations = iterationNumber;
357+
}
358+
}, delay, signal);
359+
360+
timeoutLoop.catch(common.mustCall(() => {
361+
assert.ok(totalIterations >= 3);
362+
}));
363+
}
364+
}

0 commit comments

Comments
 (0)