From 9c213cff9155060e3f8d7181cbbb1d9eda533ebe Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 21 Nov 2021 08:04:21 -0800 Subject: [PATCH] timers: add experimental scheduler api Adds experimental implementations of the yield and wait APIs being explored at https://github.com/WICG/scheduling-apis. When I asked the WHATWG folks about the possibility of standardizing the [awaitable versions of setTimeout/setImmediate](https://github.com/whatwg/html/issues/7340) that we have implemented in `timers/promises`, they pointed at the work in progress scheduling APIs draft as they direction they'll be going. While there is definitely a few thing in that draft that have questionable utility to Node.js, the yield and wait APIs map cleanly to the setImmediate and setTimeout we already have. Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/40909 Reviewed-By: Matteo Collina Reviewed-By: Antoine du Hamel Reviewed-By: Benjamin Gruenbaum Reviewed-By: Darshan Sen --- doc/api/timers.md | 45 +++++++++++++++++ lib/timers/promises.js | 47 ++++++++++++++++- .../test-timers-promises-scheduler.js | 50 +++++++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-timers-promises-scheduler.js diff --git a/doc/api/timers.md b/doc/api/timers.md index 725b9cb1894670..3859f0b9c85ffd 100644 --- a/doc/api/timers.md +++ b/doc/api/timers.md @@ -472,7 +472,52 @@ const interval = 100; })(); ``` +### `timersPromises.scheduler.wait(delay[, options])` + + + +> Stability: 1 - Experimental + +* `delay` {number} The number of milliseconds to wait before resolving the + promise. +* `options` {Object} + * `signal` {AbortSignal} An optional `AbortSignal` that can be used to + cancel waiting. +* Returns: {Promise} + +An experimental API defined by the [Scheduling APIs][] draft specification +being developed as a standard Web Platform API. + +Calling `timersPromises.scheduler.wait(delay, options)` is roughly equivalent +to calling `timersPromises.setTimeout(delay, undefined, options)` except that +the `ref` option is not supported. + +```mjs +import { scheduler } from 'timers/promises'; + +await scheduler.wait(1000); // Wait one second before continuing +``` + +### `timersPromises.scheduler.yield()` + + + +> Stability: 1 - Experimental + +* Returns: {Promise} + +An experimental API defined by the [Scheduling APIs][] draft specification +being developed as a standard Web Platform API. + +Calling `timersPromises.scheduler.yield()` is equivalent to calling +`timersPromises.setImmediate()` with no arguments. + [Event Loop]: https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/#setimmediate-vs-settimeout +[Scheduling APIs]: https://github.com/WICG/scheduling-apis [`AbortController`]: globals.md#class-abortcontroller [`TypeError`]: errors.md#class-typeerror [`clearImmediate()`]: #clearimmediateimmediate diff --git a/lib/timers/promises.js b/lib/timers/promises.js index 75b7f443401656..5e5ead5f3e7601 100644 --- a/lib/timers/promises.js +++ b/lib/timers/promises.js @@ -4,7 +4,9 @@ const { FunctionPrototypeBind, Promise, PromiseReject, + ReflectConstruct, SafePromisePrototypeFinally, + Symbol, } = primordials; const { @@ -15,7 +17,11 @@ const { const { AbortError, - codes: { ERR_INVALID_ARG_TYPE } + codes: { + ERR_ILLEGAL_CONSTRUCTOR, + ERR_INVALID_ARG_TYPE, + ERR_INVALID_THIS, + } } = require('internal/errors'); const { @@ -24,6 +30,8 @@ const { validateObject, } = require('internal/validators'); +const kScheduler = Symbol('kScheduler'); + function cancelListenerHandler(clear, reject, signal) { if (!this._destroyed) { clear(this); @@ -173,8 +181,45 @@ async function* setInterval(after, value, options = {}) { } } +// TODO(@jasnell): Scheduler is an API currently being discussed by WICG +// for Web Platform standardization: https://github.com/WICG/scheduling-apis +// The scheduler.yield() and scheduler.wait() methods correspond roughly to +// the awaitable setTimeout and setImmediate implementations here. This api +// should be considered to be experimental until the spec for these are +// finalized. Note, also, that Scheduler is expected to be defined as a global, +// but while the API is experimental we shouldn't expose it as such. +class Scheduler { + constructor() { + throw new ERR_ILLEGAL_CONSTRUCTOR(); + } + + /** + * @returns {Promise} + */ + yield() { + if (!this[kScheduler]) + throw new ERR_INVALID_THIS('Scheduler'); + return setImmediate(); + } + + /** + * @typedef {import('../internal/abort_controller').AbortSignal} AbortSignal + * @param {number} delay + * @param {{ signal?: AbortSignal }} [options] + * @returns {Promise} + */ + wait(delay, options) { + if (!this[kScheduler]) + throw new ERR_INVALID_THIS('Scheduler'); + return setTimeout(delay, undefined, { signal: options?.signal }); + } +} + module.exports = { setTimeout, setImmediate, setInterval, + scheduler: ReflectConstruct(function() { + this[kScheduler] = true; + }, [], Scheduler), }; diff --git a/test/parallel/test-timers-promises-scheduler.js b/test/parallel/test-timers-promises-scheduler.js new file mode 100644 index 00000000000000..7caf92fdf6a74d --- /dev/null +++ b/test/parallel/test-timers-promises-scheduler.js @@ -0,0 +1,50 @@ +'use strict'; + +const common = require('../common'); + +const { scheduler } = require('timers/promises'); +const { setTimeout } = require('timers'); +const { + strictEqual, + rejects, +} = require('assert'); + +async function testYield() { + await scheduler.yield(); + process.emit('foo'); +} +testYield().then(common.mustCall()); +queueMicrotask(common.mustCall(() => { + process.addListener('foo', common.mustCall()); +})); + +async function testWait() { + let value = 0; + setTimeout(() => value++, 10); + await scheduler.wait(15); + strictEqual(value, 1); +} + +testWait().then(common.mustCall()); + +async function testCancelableWait1() { + const ac = new AbortController(); + const wait = scheduler.wait(1e6, { signal: ac.signal }); + ac.abort(); + await rejects(wait, { + code: 'ABORT_ERR', + message: 'The operation was aborted', + }); +} + +testCancelableWait1().then(common.mustCall()); + +async function testCancelableWait2() { + const wait = scheduler.wait(10000, { signal: AbortSignal.abort() }); + await rejects(wait, { + code: 'ABORT_ERR', + message: 'The operation was aborted', + }); +} + +testCancelableWait2().then(common.mustCall());