diff --git a/doc/api/timers.md b/doc/api/timers.md index 108c102db51383..7cff2cb7b65271 100644 --- a/doc/api/timers.md +++ b/doc/api/timers.md @@ -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]]])` + + +* `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 diff --git a/lib/timers.js b/lib/timers.js index 3cce2e37b007e8..3697ed9f69941e 100644 --- a/lib/timers.js +++ b/lib/timers.js @@ -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: diff --git a/lib/timers/promises.js b/lib/timers/promises.js index c46b98f798ccaf..c7fe0c2d1c3701 100644 --- a/lib/timers/promises.js +++ b/lib/timers/promises.js @@ -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, }; diff --git a/test/parallel/test-timers-promisified.js b/test/parallel/test-timers-promisified.js index be73984b4fa602..b7cff574faaa83 100644 --- a/test/parallel/test-timers-promisified.js +++ b/test/parallel/test-timers-promisified.js @@ -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()); @@ -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; @@ -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(); @@ -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(); @@ -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); + })); + } +}