[gjs/ewlsh/whatwg-timers: 10/10] Implement WHATWG Timers API




commit d962d988ca984c41396b6932fb2f44db7d835015
Author: Evan Welsh <contact evanwelsh com>
Date:   Wed Aug 18 03:05:16 2021 -0500

    Implement WHATWG Timers API

 .eslintrc.yml                     |   4 +
 examples/timers.js                |  18 +++
 gjs/context.cpp                   |   2 +
 gjs/promise.cpp                   |  29 ++++
 gjs/promise.h                     |   5 +
 installed-tests/js/.eslintrc.yml  |   5 -
 installed-tests/js/meson.build    |   1 +
 installed-tests/js/minijasmine.js |  17 --
 installed-tests/js/testTimers.js  | 317 ++++++++++++++++++++++++++++++++++++++
 js.gresource.xml                  |   4 +-
 modules/esm/_bootstrap/default.js |   2 +
 modules/esm/_timers.js            | 184 ++++++++++++++++++++++
 12 files changed, 565 insertions(+), 23 deletions(-)
---
diff --git a/.eslintrc.yml b/.eslintrc.yml
index 24b1eaca..63af1fdd 100644
--- a/.eslintrc.yml
+++ b/.eslintrc.yml
@@ -259,5 +259,9 @@ globals:
   TextEncoder: readonly
   TextDecoder: readonly
   console: readonly
+  setTimeout: readonly
+  setInterval: readonly
+  clearTimeout: readonly
+  clearInterval: readonly
 parserOptions:
   ecmaVersion: 2022
diff --git a/examples/timers.js b/examples/timers.js
new file mode 100644
index 00000000..12816c4d
--- /dev/null
+++ b/examples/timers.js
@@ -0,0 +1,18 @@
+// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
+// SPDX-FileCopyrightText: 2021 Evan Welsh <contact evanwelsh com>
+
+const promise = new Promise(r => {
+    let i = 100;
+    while (i--)
+        ;
+
+    r();
+});
+
+setTimeout(() => {
+    promise.then(() => log('java'));
+});
+
+setTimeout(() => {
+    log('script');
+});
diff --git a/gjs/context.cpp b/gjs/context.cpp
index c0d1428c..f2267c3c 100644
--- a/gjs/context.cpp
+++ b/gjs/context.cpp
@@ -326,6 +326,8 @@ gjs_context_class_init(GjsContextClass *klass)
         g_irepository_prepend_search_path(priv_typelib_dir);
     }
 
+    gjs_register_native_module("_promiseNative",
+                               gjs_define_native_promise_stuff);
     gjs_register_native_module("_byteArrayNative", gjs_define_byte_array_stuff);
     gjs_register_native_module("_encodingNative",
                                gjs_define_text_encoding_stuff);
diff --git a/gjs/promise.cpp b/gjs/promise.cpp
index c017c939..2a621556 100644
--- a/gjs/promise.cpp
+++ b/gjs/promise.cpp
@@ -9,8 +9,15 @@
 #include <gio/gio.h>
 #include <glib-object.h>
 
+#include <js/CallArgs.h>
+#include <js/PropertySpec.h>
+#include <js/RootingAPI.h>
+#include <js/TypeDecls.h>
+#include <jsapi.h>  // for JS_DefineFunctions, JS_NewPlainObject
+
 #include "gjs/context-private.h"
 #include "gjs/jsapi-util.h"
+#include "gjs/macros.h"
 #include "gjs/promise.h"
 
 /**
@@ -177,3 +184,25 @@ void PromiseJobDispatcher::start() {
 void PromiseJobDispatcher::stop() { m_source->cancel(); }
 
 };  // namespace Gjs
+
+GJS_JSAPI_RETURN_CONVENTION
+bool drain_microtask_queue(JSContext* cx, unsigned argc, JS::Value* vp) {
+    JS::CallArgs args = JS::CallArgsFromVp(argc, vp);
+
+    auto* gjs = GjsContextPrivate::from_cx(cx);
+    gjs->runJobs(cx);
+
+    args.rval().setUndefined();
+    return true;
+}
+
+JSFunctionSpec gjs_native_promise_module_funcs[] = {
+    JS_FN("drainMicrotaskQueue", &drain_microtask_queue, 0, 0), JS_FS_END};
+
+bool gjs_define_native_promise_stuff(JSContext* cx,
+                                     JS::MutableHandleObject module) {
+    module.set(JS_NewPlainObject(cx));
+    if (!module)
+        return false;
+    return JS_DefineFunctions(cx, module, gjs_native_promise_module_funcs);
+}
diff --git a/gjs/promise.h b/gjs/promise.h
index 8fec2aeb..7752e9d6 100644
--- a/gjs/promise.h
+++ b/gjs/promise.h
@@ -9,6 +9,8 @@
 
 #include <glib.h>
 
+#include <js/TypeDecls.h>
+
 #include "gjs/jsapi-util.h"
 
 class GjsContextPrivate;
@@ -52,3 +54,6 @@ class PromiseJobDispatcher {
 };
 
 };  // namespace Gjs
+
+bool gjs_define_native_promise_stuff(JSContext* cx,
+                                     JS::MutableHandleObject module);
diff --git a/installed-tests/js/.eslintrc.yml b/installed-tests/js/.eslintrc.yml
index abc9c527..6763b5f8 100644
--- a/installed-tests/js/.eslintrc.yml
+++ b/installed-tests/js/.eslintrc.yml
@@ -24,11 +24,6 @@ rules:
       message: Arrow functions can mess up some Jasmine APIs. Use function () instead
     - selector: CallExpression[callee.name="afterAll"] > ArrowFunctionExpression
       message: Arrow functions can mess up some Jasmine APIs. Use function () instead
-globals:
-  clearInterval: writable
-  clearTimeout: writable
-  setInterval: writable
-  setTimeout: writable
 overrides:
   - files:
       - matchers.js
diff --git a/installed-tests/js/meson.build b/installed-tests/js/meson.build
index 5ca37103..762b7360 100644
--- a/installed-tests/js/meson.build
+++ b/installed-tests/js/meson.build
@@ -140,6 +140,7 @@ jasmine_tests = [
     'Regress',
     'Signals',
     'System',
+    'Timers',
     'Tweener',
 ]
 
diff --git a/installed-tests/js/minijasmine.js b/installed-tests/js/minijasmine.js
index a82251a4..c1771c44 100644
--- a/installed-tests/js/minijasmine.js
+++ b/installed-tests/js/minijasmine.js
@@ -19,23 +19,6 @@ function _filterStack(stack) {
         .join('\n');
 }
 
-function _setTimeoutInternal(continueTimeout, func, time) {
-    return GLib.timeout_add(GLib.PRIORITY_DEFAULT, time, function () {
-        func();
-        return continueTimeout;
-    });
-}
-
-function _clearTimeoutInternal(id) {
-    if (id > 0)
-        GLib.source_remove(id);
-}
-
-// Install the browser setTimeout/setInterval API on the global object
-globalThis.setTimeout = _setTimeoutInternal.bind(undefined, GLib.SOURCE_REMOVE);
-globalThis.setInterval = _setTimeoutInternal.bind(undefined, GLib.SOURCE_CONTINUE);
-globalThis.clearTimeout = globalThis.clearInterval = _clearTimeoutInternal;
-
 let jasmineRequire = imports.jasmine.getJasmineRequireObj();
 let jasmineCore = jasmineRequire.core(jasmineRequire);
 globalThis._jasmineEnv = jasmineCore.getEnv();
diff --git a/installed-tests/js/testTimers.js b/installed-tests/js/testTimers.js
new file mode 100644
index 00000000..2b49662d
--- /dev/null
+++ b/installed-tests/js/testTimers.js
@@ -0,0 +1,317 @@
+// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
+// SPDX-FileCopyrightText: 2018-2019 the Deno authors. All rights reserved.
+
+const {GLib} = imports.gi;
+
+function deferred() {
+    let resolve_;
+    let reject_;
+    function resolve() {
+        resolve_();
+    }
+    function reject() {
+        reject_();
+    }
+    const promise = new Promise((res, rej) => {
+        resolve_ = res;
+        reject_ = rej;
+    });
+    return {
+        promise,
+        resolve,
+        reject,
+    };
+}
+
+// eslint-disable-next-line require-await
+async function waitForMs(ms) {
+    return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+describe('Timers', function () {
+    it('times out successfully', async function timeoutSuccess() {
+        const {promise, resolve} = deferred();
+        let count = 0;
+        setTimeout(() => {
+            count++;
+            resolve();
+        }, 500);
+        await promise;
+
+        // count should increment
+        expect(count).toBe(1);
+
+
+        return 5;
+    });
+
+    it('has correct timeout args', async function timeoutArgs() {
+        const {promise, resolve} = deferred();
+        const arg = 1;
+
+        setTimeout(
+            (a, b, c) => {
+                expect(a).toBe(arg);
+                expect(b).toBe(arg.toString());
+                expect(c).toEqual([arg]);
+                resolve();
+            },
+            10,
+            arg,
+            arg.toString(),
+            [arg]
+        );
+        await promise;
+    });
+
+    it('cancels successfully', async function timeoutCancelSuccess() {
+        let count = 0;
+        const id = setTimeout(() => {
+            count++;
+        }, 1);
+        // Cancelled, count should not increment
+        clearTimeout(id);
+        await waitForMs(600);
+        expect(count).toBe(0);
+    });
+
+    it('cancels multiple correctly', async function timeoutCancelMultiple() {
+        const uncalled = jasmine.createSpy('uncalled');
+
+        // Set timers and cancel them in the same order.
+        const t1 = setTimeout(uncalled, 10);
+        const t2 = setTimeout(uncalled, 10);
+        const t3 = setTimeout(uncalled, 10);
+        clearTimeout(t1);
+        clearTimeout(t2);
+        clearTimeout(t3);
+
+        // Set timers and cancel them in reverse order.
+        const t4 = setTimeout(uncalled, 20);
+        const t5 = setTimeout(uncalled, 20);
+        const t6 = setTimeout(uncalled, 20);
+        clearTimeout(t6);
+        clearTimeout(t5);
+        clearTimeout(t4);
+
+        // Sleep until we're certain that the cancelled timers aren't gonna fire.
+        await waitForMs(50);
+
+        expect(uncalled).not.toHaveBeenCalled();
+    });
+
+    it('cancels invalid silent fail', async function timeoutCancelInvalidSilentFail() {
+        // Expect no panic
+        const {promise, resolve} = deferred();
+        let count = 0;
+        const id = setTimeout(() => {
+            count++;
+            // Should have no effect
+            clearTimeout(id);
+            resolve();
+        }, 500);
+        await promise;
+        expect(count).toBe(1);
+
+        // Should silently fail (no panic)
+        clearTimeout(2147483647);
+    });
+
+    it('interval success', async function intervalSuccess() {
+        const {promise, resolve} = deferred();
+        let count = 0;
+        const id = setInterval(() => {
+            count++;
+            clearInterval(id);
+            resolve();
+        }, 100);
+        await promise;
+        // Clear interval
+        clearInterval(id);
+        // count should increment twice
+        expect(count).toBe(1);
+    });
+
+    it('cancels interval successfully', async function intervalCancelSuccess() {
+        let count = 0;
+        const id = setInterval(() => {
+            count++;
+        }, 1);
+        clearInterval(id);
+        await waitForMs(500);
+        expect(count).toBe(0);
+    });
+
+    it('ordering interval', async function intervalOrdering() {
+        const timers = [];
+        let timeouts = 0;
+        function onTimeout() {
+            ++timeouts;
+            for (let i = 1; i < timers.length; i++)
+                clearTimeout(timers[i]);
+        }
+        for (let i = 0; i < 10; i++)
+            timers[i] = setTimeout(onTimeout, 1);
+
+        await waitForMs(500);
+        expect(timeouts).toBe(1);
+    });
+
+    it('cancel invalid silent fail',
+        // eslint-disable-next-line require-await
+        async function intervalCancelInvalidSilentFail() {
+            // Should silently fail (no panic)
+            clearInterval(2147483647);
+        });
+
+    it('fire immediately', async function fireCallbackImmediatelyWhenDelayOverMaxValue() {
+        GLib.test_expect_message('Gjs', GLib.LogLevelFlags.LEVEL_WARNING,
+            '*does not fit into*');
+
+        let count = 0;
+        setTimeout(() => {
+            count++;
+        }, 2 ** 31);
+        await waitForMs(1);
+        expect(count).toBe(1);
+    });
+
+    it('callback this', async function timeoutCallbackThis() {
+        const {promise, resolve} = deferred();
+        const obj = {
+            foo() {
+                expect(this).toBe(window);
+                resolve();
+            },
+        };
+        setTimeout(obj.foo, 1);
+        await promise;
+    });
+
+    it('bind this',
+        // eslint-disable-next-line require-await
+        async function timeoutBindThis() {
+            function noop() { }
+
+            const thisCheckPassed = [null, undefined, window, globalThis];
+
+            const thisCheckFailed = [
+                0,
+                '',
+                true,
+                false,
+                {},
+                [],
+                'foo',
+                () => { },
+                Object.prototype,
+            ];
+
+            thisCheckPassed.forEach(
+                thisArg => {
+                    expect(() => {
+                        setTimeout.call(thisArg, noop, 1);
+                    }).not.toThrow();
+                });
+
+            thisCheckFailed.forEach(
+                thisArg => {
+                    expect(() => {
+                        setTimeout.call(thisArg, noop, 1);
+                    }).toThrowError(TypeError);
+                }
+            );
+        });
+
+    it('clearTimeout converts to number',
+        // eslint-disable-next-line require-await
+        async function clearTimeoutShouldConvertToNumber() {
+            let called = false;
+            const obj = {
+                valueOf() {
+                    called = true;
+                    return 1;
+                },
+            };
+            clearTimeout(obj);
+            expect(called).toBe(true);
+        });
+
+    it('throw on bigint', function setTimeoutShouldThrowWithBigint() {
+        expect(() => {
+            setTimeout(() => { }, 1n);
+        }).toThrowError(TypeError);
+    });
+
+    it('throw on bigint', function clearTimeoutShouldThrowWithBigint() {
+        expect(() => {
+            clearTimeout(1n);
+        }).toThrowError(TypeError);
+    });
+
+    it('', function testFunctionName() {
+        expect(clearTimeout.name).toBe('clearTimeout');
+        expect(clearInterval.name).toBe('clearInterval');
+    });
+
+    it('length', function testFunctionParamsLength() {
+        expect(setTimeout.length).toBe(1);
+        expect(setInterval.length).toBe(1);
+        expect(clearTimeout.length).toBe(0);
+        expect(clearInterval.length).toBe(0);
+    });
+
+    it('clear and interval', function clearTimeoutAndClearIntervalNotBeEquals() {
+        expect(clearTimeout).not.toBe(clearInterval);
+    });
+
+    it('microtask ordering', async function timerBasicMicrotaskOrdering() {
+        let s = '';
+        let count = 0;
+        const {promise, resolve} = deferred();
+        setTimeout(() => {
+            Promise.resolve().then(() => {
+                count++;
+                s += 'de';
+                if (count === 2)
+                    resolve();
+            });
+        });
+        setTimeout(() => {
+            count++;
+            s += 'no';
+            if (count === 2)
+                resolve();
+        });
+        await promise;
+        expect(s).toBe('deno');
+    });
+
+    it('nested microtask ordering', async function timerNestedMicrotaskOrdering() {
+        let s = '';
+        const {promise, resolve} = deferred();
+        s += '0';
+        setTimeout(() => {
+            s += '4';
+            setTimeout(() => (s += '8'));
+            Promise.resolve().then(() => {
+                setTimeout(() => {
+                    s += '9';
+                    resolve();
+                });
+            });
+        });
+        setTimeout(() => (s += '5'));
+        Promise.resolve().then(() => (s += '2'));
+        Promise.resolve().then(() =>
+            setTimeout(() => {
+                s += '6';
+                Promise.resolve().then(() => (s += '7'));
+            })
+        );
+        Promise.resolve().then(() => Promise.resolve().then(() => (s += '3')));
+        s += '1';
+        await promise;
+        expect(s).toBe('0123456789');
+    });
+});
diff --git a/js.gresource.xml b/js.gresource.xml
index a730f2b8..4d3fde35 100644
--- a/js.gresource.xml
+++ b/js.gresource.xml
@@ -13,7 +13,9 @@
     <file>modules/esm/_encoding/encoding.js</file>
     <file>modules/esm/_encoding/encodingMap.js</file>
     <file>modules/esm/_encoding/util.js</file>
-  
+
+    <file>modules/esm/_timers.js</file>
+
     <file>modules/esm/cairo.js</file>
     <file>modules/esm/gettext.js</file>
     <file>modules/esm/console.js</file>
diff --git a/modules/esm/_bootstrap/default.js b/modules/esm/_bootstrap/default.js
index afb155b0..ff1f28bf 100644
--- a/modules/esm/_bootstrap/default.js
+++ b/modules/esm/_bootstrap/default.js
@@ -7,3 +7,5 @@
 import '_encoding/encoding';
 // Bootstrap the Console API
 import 'console';
+// Bootstrap the Timers API
+import '_timers';
diff --git a/modules/esm/_timers.js b/modules/esm/_timers.js
new file mode 100644
index 00000000..01448be6
--- /dev/null
+++ b/modules/esm/_timers.js
@@ -0,0 +1,184 @@
+// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
+// SPDX-FileCopyrightText: 2021 Evan Welsh <contact evanwelsh com>
+
+/* exported setTimeout, setInterval, clearTimeout, clearInterval */
+/* eslint no-implicit-coercion: ["error", {"allow": ["+"]}] */
+// Note: implicit coercion with + is used to perform the ToNumber algorithm from
+// the timers specification
+
+import GLib from 'gi://GLib';
+
+const PromiseNative = import.meta.importSync('_promiseNative');
+
+// It should not be possible to remove or destroy sources from outside this library.
+const ids = new Map();
+const releasedIds = [];
+let idIncrementor = 1;
+
+/**
+ * @param {number} sourceId the source ID to generate a timer ID for
+ * @returns {number}
+ */
+function nextId(sourceId) {
+    let id;
+
+    if (releasedIds.length > 0) {
+        id = releasedIds.shift();
+    } else {
+        idIncrementor++;
+
+        id = idIncrementor;
+    }
+
+    ids.set(id, sourceId);
+    return id;
+}
+
+/**
+ * @param {number} sourceId a GLib.Source ID
+ */
+function releaseId(sourceId) {
+    ids.delete(sourceId);
+    releasedIds.push(sourceId);
+}
+
+const TIMEOUT_MAX = 2 ** 31 - 1;
+
+/**
+ * @param {unknown} thisArg 'this' argument
+ * @returns {asserts thisArg is (null | undefined | typeof globalThis)}
+ */
+function checkThis(thisArg) {
+    if (thisArg !== null && thisArg !== undefined && thisArg !== globalThis)
+        throw new TypeError('Illegal invocation');
+}
+
+/**
+ * @this {typeof globalThis}
+ * @param {(...args) => any} callback a callback function
+ * @param {number} delay the duration in milliseconds to wait before running callback
+ * @param {...any} args arguments to pass to callback
+ */
+function setTimeout(callback, delay = 0, ...args) {
+    checkThis(this);
+
+    delay = wrapDelay(+delay);
+    const cb = callback.bind(globalThis, ...args);
+    const id = nextId(
+        GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, () => {
+            if (!ids.has(id))
+                return GLib.SOURCE_REMOVE;
+
+            cb();
+            releaseId(id);
+            PromiseNative.drainMicrotaskQueue();
+
+            return GLib.SOURCE_REMOVE;
+        })
+    );
+
+    return id;
+}
+
+/**
+ * @param {number} delay a number value (in milliseconds)
+ */
+function wrapDelay(delay) {
+    if (delay > TIMEOUT_MAX) {
+        console.info(
+            `${delay} does not fit into` +
+                ' a 32-bit signed integer.' +
+                '\nTimeout duration was set to 1.'
+        );
+        delay = 1;
+    }
+    return Math.max(0, delay | 0);
+}
+
+/**
+ * @this {typeof globalThis}
+ * @param {(...args) => any} callback a callback function
+ * @param {number} delay the duration in milliseconds to wait between calling callback
+ * @param {...any} args arguments to pass to callback
+ */
+function setInterval(callback, delay = 0, ...args) {
+    checkThis(this);
+
+    delay = wrapDelay(+delay);
+    const cb = callback.bind(globalThis, ...args);
+    const id = nextId(
+        GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, () => {
+            if (!ids.has(id))
+                return GLib.SOURCE_REMOVE;
+
+            cb();
+            PromiseNative.drainMicrotaskQueue();
+
+            return GLib.SOURCE_CONTINUE;
+        })
+    );
+
+    return id;
+}
+
+/**
+ * @param {number} id a timer id to clear
+ */
+function _clearTimer(id) {
+    const timerId = +id;
+
+    if (!ids.has(timerId))
+        return;
+
+    const cx = GLib.MainContext.default();
+    const sourceId = ids.get(timerId);
+    const source = cx.find_source_by_id(sourceId);
+
+    if (sourceId > 0 && source) {
+        GLib.source_remove(sourceId);
+        source.destroy();
+        releaseId(timerId);
+    }
+}
+
+/**
+ * @param {number} id the timer id to clear
+ */
+function clearTimeout(id = 0) {
+    _clearTimer(id);
+}
+
+/**
+ * @param {number} id the interval id to clear
+ */
+function clearInterval(id = 0) {
+    _clearTimer(id);
+}
+
+Object.defineProperty(globalThis, 'setTimeout', {
+    configurable: false,
+    enumerable: true,
+    writable: true,
+    value: setTimeout,
+});
+
+Object.defineProperty(globalThis, 'setInterval', {
+    configurable: false,
+    enumerable: true,
+    writable: true,
+    value: setInterval,
+});
+
+Object.defineProperty(globalThis, 'clearTimeout', {
+    configurable: false,
+    enumerable: true,
+    writable: true,
+    value: clearTimeout,
+});
+
+Object.defineProperty(globalThis, 'clearInterval', {
+    configurable: false,
+    enumerable: true,
+    writable: true,
+    value: clearInterval,
+});


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]