[gjs/ewlsh/implicit-mainloop] Implement implicit mainloop and timer API.




commit 981e4e72fa100d66cced1815a3015effb7b00c39
Author: Evan Welsh <contact evanwelsh com>
Date:   Wed Mar 31 22:16:18 2021 -0700

    Implement implicit mainloop and timer API.

 .eslintrc.yml                        |   4 +
 examples/timers.js                   |  18 ++
 gjs/context-private.h                |   5 +-
 gjs/context.cpp                      |  60 +++----
 gjs/mainloop.cpp                     |  39 +++++
 gjs/mainloop.h                       |  16 ++
 gjs/promise.cpp                      | 128 ++++++++++++++
 gjs/promise.h                        |  22 +++
 installed-tests/js/meson.build       |   1 +
 installed-tests/js/minijasmine.js    |  17 --
 installed-tests/js/testMainloop.js   |  21 +--
 installed-tests/js/testTimers.js     | 317 +++++++++++++++++++++++++++++++++++
 js.gresource.xml                     |   1 +
 meson.build                          |   2 +
 modules/core/.eslintrc.yml           |   5 +
 modules/core/_timers.js              | 133 +++++++++++++++
 modules/print.cpp                    |  31 ++++
 modules/script/_bootstrap/default.js |  25 +++
 18 files changed, 787 insertions(+), 58 deletions(-)
---
diff --git a/.eslintrc.yml b/.eslintrc.yml
index 733db371..5f22aa52 100644
--- a/.eslintrc.yml
+++ b/.eslintrc.yml
@@ -253,5 +253,9 @@ globals:
   print: readonly
   printerr: readonly
   window: readonly
+  setTimeout: readonly
+  setInterval: readonly
+  clearTimeout: readonly
+  clearInterval: readonly
 parserOptions:
   ecmaVersion: 2020
diff --git a/examples/timers.js b/examples/timers.js
new file mode 100644
index 00000000..092770cc
--- /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('no'));
+});
+
+setTimeout(() => {
+    log('de');
+});
diff --git a/gjs/context-private.h b/gjs/context-private.h
index b0746192..d0ec91fb 100644
--- a/gjs/context-private.h
+++ b/gjs/context-private.h
@@ -88,7 +88,8 @@ class GjsContextPrivate : public JS::JobQueue {
     std::vector<std::string> m_args;
 
     JobQueueStorage m_job_queue;
-    unsigned m_idle_drain_handler;
+    GSource* m_promise_queue_source;
+    GMainLoop* m_event_loop;
 
     std::unordered_map<uint64_t, GjsAutoChar> m_unhandled_rejection_stacks;
 
@@ -136,7 +137,6 @@ class GjsContextPrivate : public JS::JobQueue {
     class SavedQueue;
     void start_draining_job_queue(void);
     void stop_draining_job_queue(void);
-    static gboolean drain_job_queue_idle_handler(void* data);
 
     void warn_about_unhandled_promise_rejections(void);
 
@@ -174,6 +174,7 @@ class GjsContextPrivate : public JS::JobQueue {
     [[nodiscard]] GjsContext* public_context() const {
         return m_public_context;
     }
+    [[nodiscard]] GMainLoop* loop() { return m_event_loop; }
     [[nodiscard]] JSContext* context() const { return m_cx; }
     [[nodiscard]] JSObject* global() const { return m_global.get(); }
     [[nodiscard]] JSObject* internal_global() const {
diff --git a/gjs/context.cpp b/gjs/context.cpp
index ea0be37c..711c933e 100644
--- a/gjs/context.cpp
+++ b/gjs/context.cpp
@@ -69,12 +69,14 @@
 #include "gjs/importer.h"
 #include "gjs/internal.h"
 #include "gjs/jsapi-util.h"
+#include "gjs/mainloop.h"
 #include "gjs/mem.h"
 #include "gjs/module.h"
 #include "gjs/native.h"
 #include "gjs/objectbox.h"
 #include "gjs/profiler-private.h"
 #include "gjs/profiler.h"
+#include "gjs/promise.h"
 #include "modules/modules.h"
 #include "util/log.h"
 
@@ -312,6 +314,8 @@ gjs_context_class_init(GjsContextClass *klass)
     g_free (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("_gi", gjs_define_private_gi_stuff);
     gjs_register_native_module("gi", gjs_define_repo);
@@ -427,6 +431,7 @@ void GjsContextPrivate::dispose(void) {
 }
 
 GjsContextPrivate::~GjsContextPrivate(void) {
+    g_clear_pointer(&m_event_loop, g_main_loop_unref);
     g_clear_pointer(&m_search_path, g_strfreev);
     g_clear_pointer(&m_program_path, g_free);
     g_clear_pointer(&m_program_name, g_free);
@@ -475,6 +480,7 @@ GjsContextPrivate::GjsContextPrivate(JSContext* cx, GjsContext* public_context)
       m_cx(cx),
       m_environment_preparer(cx) {
     m_owner_thread = g_thread_self();
+    m_event_loop = g_main_loop_new(nullptr, false);
 
     const char *env_profiler = g_getenv("GJS_ENABLE_PROFILER");
     if (env_profiler || m_should_listen_sigusr2)
@@ -752,34 +758,26 @@ bool GjsContextPrivate::should_exit(uint8_t* exit_code_p) const {
 }
 
 void GjsContextPrivate::start_draining_job_queue(void) {
-    if (!m_idle_drain_handler) {
+    if (!m_promise_queue_source) {
         gjs_debug(GJS_DEBUG_CONTEXT, "Starting promise job queue handler");
-        m_idle_drain_handler = g_idle_add_full(
-            G_PRIORITY_DEFAULT, drain_job_queue_idle_handler, this, nullptr);
+
+        m_promise_queue_source = gjs_promise_queue_source_new(this, nullptr);
+        g_source_attach(m_promise_queue_source, nullptr);
     }
+
+    g_source_set_ready_time(m_promise_queue_source, 0);
 }
 
 void GjsContextPrivate::stop_draining_job_queue(void) {
     m_draining_job_queue = false;
-    if (m_idle_drain_handler) {
+    if (m_promise_queue_source) {
         gjs_debug(GJS_DEBUG_CONTEXT, "Stopping promise job queue handler");
-        g_source_remove(m_idle_drain_handler);
-        m_idle_drain_handler = 0;
+        g_source_destroy(m_promise_queue_source);
+        g_source_unref(m_promise_queue_source);
+        m_promise_queue_source = nullptr;
     }
 }
 
-gboolean GjsContextPrivate::drain_job_queue_idle_handler(void* data) {
-    gjs_debug(GJS_DEBUG_CONTEXT, "Promise job queue handler");
-    auto* gjs = static_cast<GjsContextPrivate*>(data);
-    gjs->runJobs(gjs->context());
-    /* Uncatchable exceptions are swallowed here - no way to get a handle on
-     * the main loop to exit it from this idle handler */
-    gjs_debug(GJS_DEBUG_CONTEXT, "Promise job queue handler finished");
-    g_assert(gjs->empty() && gjs->m_idle_drain_handler == 0 &&
-             "GjsContextPrivate::runJobs() should have emptied queue");
-    return G_SOURCE_REMOVE;
-}
-
 JSObject* GjsContextPrivate::getIncumbentGlobal(JSContext* cx) {
     // This is equivalent to SpiderMonkey's behavior.
     return JS::CurrentGlobalOrNull(cx);
@@ -800,11 +798,6 @@ bool GjsContextPrivate::enqueuePromiseJob(JSContext* cx [[maybe_unused]],
               gjs_debug_object(job).c_str(), gjs_debug_object(promise).c_str(),
               gjs_debug_object(allocation_site).c_str());
 
-    if (m_idle_drain_handler)
-        g_assert(!empty());
-    else
-        g_assert(empty());
-
     if (!m_job_queue.append(job)) {
         JS_ReportOutOfMemory(m_cx);
         return false;
@@ -889,8 +882,8 @@ bool GjsContextPrivate::run_jobs_fallible(void) {
         }
     }
 
+    m_draining_job_queue = false;
     m_job_queue.clear();
-    stop_draining_job_queue();
     JS::JobQueueIsEmpty(m_cx);
     return retval;
 }
@@ -899,14 +892,12 @@ class GjsContextPrivate::SavedQueue : public JS::JobQueue::SavedJobQueue {
  private:
     GjsContextPrivate* m_gjs;
     JS::PersistentRooted<JobQueueStorage> m_queue;
-    bool m_idle_was_pending : 1;
     bool m_was_draining : 1;
 
  public:
     explicit SavedQueue(GjsContextPrivate* gjs)
         : m_gjs(gjs),
           m_queue(gjs->m_cx, std::move(gjs->m_job_queue)),
-          m_idle_was_pending(gjs->m_idle_drain_handler != 0),
           m_was_draining(gjs->m_draining_job_queue) {
         gjs_debug(GJS_DEBUG_CONTEXT, "Pausing job queue");
         gjs->stop_draining_job_queue();
@@ -916,8 +907,7 @@ class GjsContextPrivate::SavedQueue : public JS::JobQueue::SavedJobQueue {
         gjs_debug(GJS_DEBUG_CONTEXT, "Unpausing job queue");
         m_gjs->m_job_queue = std::move(m_queue.get());
         m_gjs->m_draining_job_queue = m_was_draining;
-        if (m_idle_was_pending)
-            m_gjs->start_draining_job_queue();
+        m_gjs->start_draining_job_queue();
     }
 };
 
@@ -1119,12 +1109,16 @@ bool GjsContextPrivate::eval(const char* script, ssize_t script_len,
     JS::RootedValue retval(m_cx);
     bool ok = eval_with_scope(nullptr, script, script_len, filename, &retval);
 
+    gjs_spin_event_loop(m_cx);
+
     /* The promise job queue should be drained even on error, to finish
      * outstanding async tasks before the context is torn down. Drain after
      * uncaught exceptions have been reported since draining runs callbacks. */
     {
         JS::AutoSaveExceptionState saved_exc(m_cx);
         ok = run_jobs_fallible() && ok;
+
+        stop_draining_job_queue();
     }
 
     auto_profile_exit(auto_profile);
@@ -1135,13 +1129,17 @@ bool GjsContextPrivate::eval(const char* script, ssize_t script_len,
     }
 
     if (exit_status_p) {
+        uint8_t code;
         if (retval.isInt32()) {
             int code = retval.toInt32();
             gjs_debug(GJS_DEBUG_CONTEXT,
                       "Script returned integer code %d", code);
             *exit_status_p = code;
+        } else if (should_exit(&code)) {
+            *exit_status_p = code;
         } else {
-            /* Assume success if no integer was returned */
+            /* Assume success if no integer was returned and should exit isn't
+             * set */
             *exit_status_p = 0;
         }
     }
@@ -1179,6 +1177,8 @@ bool GjsContextPrivate::eval_module(const char* identifier,
     if (!JS::ModuleEvaluate(m_cx, obj))
         ok = false;
 
+    gjs_spin_event_loop(m_cx);
+
     /* The promise job queue should be drained even on error, to finish
      * outstanding async tasks before the context is torn down. Drain after
      * uncaught exceptions have been reported since draining runs callbacks.
@@ -1186,6 +1186,8 @@ bool GjsContextPrivate::eval_module(const char* identifier,
     {
         JS::AutoSaveExceptionState saved_exc(m_cx);
         ok = run_jobs_fallible() && ok;
+
+        stop_draining_job_queue();
     }
 
     auto_profile_exit(auto_profile);
diff --git a/gjs/mainloop.cpp b/gjs/mainloop.cpp
new file mode 100644
index 00000000..5340665d
--- /dev/null
+++ b/gjs/mainloop.cpp
@@ -0,0 +1,39 @@
+/* -*- mode: C++; c-basic-offset: 4; indent-tabs-mode: nil; -*- */
+// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
+// SPDX-FileCopyrightText: 2021 Evan Welsh <contact evanwelsh com>
+
+#include <gio/gio.h>
+#include <glib.h>
+
+#include "gjs/context-private.h"
+#include "gjs/mainloop.h"
+
+void gjs_spin_event_loop(JSContext* cx) {
+    auto priv = GjsContextPrivate::from_cx(cx);
+
+    if (priv->should_exit(nullptr))
+        return;
+
+    bool has_pending;
+
+    do {
+        if (priv->should_exit(nullptr))
+            break;
+
+        has_pending = g_main_context_pending(nullptr);
+
+        if (!priv->empty())
+            g_main_loop_run(priv->loop());
+
+        if (priv->should_exit(nullptr))
+            break;
+
+        has_pending = g_main_context_pending(nullptr);
+
+        if (has_pending && !priv->should_exit(nullptr))
+            continue;
+
+        has_pending = g_main_context_pending(nullptr);
+    } while (has_pending == true && !priv->empty() &&
+             !priv->should_exit(nullptr));
+}
\ No newline at end of file
diff --git a/gjs/mainloop.h b/gjs/mainloop.h
new file mode 100644
index 00000000..900dadfc
--- /dev/null
+++ b/gjs/mainloop.h
@@ -0,0 +1,16 @@
+/* -*- mode: C++; c-basic-offset: 4; indent-tabs-mode: nil; -*- */
+/*
+ * SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
+ * SPDX-FileCopyrightText: 2021 Evan Welsh <contact evanwelsh com>
+ */
+
+#ifndef GJS_MAINLOOP_H_
+#define GJS_MAINLOOP_H_
+
+#include <config.h>
+
+#include <js/TypeDecls.h>
+
+void gjs_spin_event_loop(JSContext* cx);
+
+#endif
\ No newline at end of file
diff --git a/gjs/promise.cpp b/gjs/promise.cpp
new file mode 100644
index 00000000..00e9cb53
--- /dev/null
+++ b/gjs/promise.cpp
@@ -0,0 +1,128 @@
+/* -*- mode: C++; c-basic-offset: 4; indent-tabs-mode: nil; -*- */
+// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
+// SPDX-FileCopyrightText: 2021 Evan Welsh <contact evanwelsh com>
+
+#include <js/ArrayBuffer.h>
+#include <js/CallArgs.h>
+#include <js/GCAPI.h>  // for AutoCheckCannotGC
+#include <js/PropertySpec.h>
+#include <js/RootingAPI.h>
+#include <js/TypeDecls.h>
+#include <js/Utility.h>   // for UniqueChars
+#include <jsapi.h>        // for JS_DefineFunctionById, JS_DefineFun...
+#include <jsfriendapi.h>  // for JS_NewUint8ArrayWithBuffer, GetUint...
+
+#include "gjs/context-private.h"
+#include "gjs/promise.h"
+
+typedef struct {
+    GSource parent;
+    bool dispatching;
+    GjsContextPrivate* cx;
+} PromiseQueueSource;
+
+static gboolean promise_queue_source_prepare(GSource* source,
+                                             gint* timeout [[maybe_unused]]) {
+    auto promise_queue_source = reinterpret_cast<PromiseQueueSource*>(source);
+
+    GjsContextPrivate* cx = promise_queue_source->cx;
+    if (!cx->empty()) {
+        promise_queue_source->dispatching = true;
+        return true;
+    }
+
+    g_main_loop_quit(cx->loop());
+    return false;
+}
+
+static gboolean promise_queue_source_dispatch(GSource* source,
+                                              GSourceFunc callback
+                                              [[maybe_unused]],
+                                              gpointer data [[maybe_unused]]) {
+    g_source_set_ready_time(source, -1);
+
+    PromiseQueueSource* promise_queue_source =
+        reinterpret_cast<PromiseQueueSource*>(source);
+    promise_queue_source->dispatching = false;
+
+    GjsContextPrivate* cx = promise_queue_source->cx;
+    if (cx->empty()) {
+        g_main_loop_quit(cx->loop());
+
+        return G_SOURCE_CONTINUE;
+    }
+
+    if (cx->context()) {
+        cx->runJobs(cx->context());
+    }
+
+    return G_SOURCE_CONTINUE;
+}
+
+static void promise_queue_source_finalize(GSource* source) {
+    auto promise_queue_source = reinterpret_cast<PromiseQueueSource*>(source);
+
+    promise_queue_source->cx = nullptr;
+}
+
+static GSourceFuncs promise_queue_source_funcs = {
+    promise_queue_source_prepare,
+    nullptr, /* check */
+    promise_queue_source_dispatch,
+    promise_queue_source_finalize,
+    nullptr,
+    nullptr,
+};
+
+GSource* gjs_promise_queue_source_new(GjsContextPrivate* cx,
+
+                                      GCancellable* cancellable) {
+    g_return_val_if_fail(cx != nullptr, nullptr);
+    g_return_val_if_fail(
+        cancellable == nullptr || G_IS_CANCELLABLE(cancellable), nullptr);
+
+    GSource* source =
+        g_source_new(&promise_queue_source_funcs, sizeof(PromiseQueueSource));
+    g_source_set_priority(source, -1000);
+    g_source_set_name(source, "PromiseQueueSource");
+
+    // TODO(ewlsh): Do we need this?
+    // g_source_set_can_recurse(source, true);
+    PromiseQueueSource* promise_queue_source =
+        reinterpret_cast<PromiseQueueSource*>(source);
+    promise_queue_source->cx = cx;
+    promise_queue_source->dispatching = false;
+
+    /* Add a cancellable source. */
+    if (cancellable != nullptr) {
+        GSource* cancellable_source;
+
+        cancellable_source = g_cancellable_source_new(cancellable);
+        g_source_set_dummy_callback(cancellable_source);
+        g_source_add_child_source(source, cancellable_source);
+        g_source_unref(cancellable_source);
+    }
+
+    return source;
+}
+
+GJS_JSAPI_RETURN_CONVENTION
+static bool run_func(JSContext* cx, unsigned argc, JS::Value* vp) {
+    JS::CallArgs args = JS::CallArgsFromVp(argc, vp);
+
+    GjsContextPrivate* gjs = GjsContextPrivate::from_cx(cx);
+
+    gjs->runJobs(cx);
+
+    args.rval().setUndefined();
+    return true;
+}
+
+static JSFunctionSpec gjs_native_promise_module_funcs[] = {
+    JS_FN("run", run_func, 2, 0), JS_FS_END};
+
+bool gjs_define_native_promise_stuff(JSContext* cx,
+                                     JS::MutableHandleObject module) {
+    module.set(JS_NewPlainObject(cx));
+    return JS_DefineFunctions(cx, module, gjs_native_promise_module_funcs);
+}
diff --git a/gjs/promise.h b/gjs/promise.h
new file mode 100644
index 00000000..2851c88a
--- /dev/null
+++ b/gjs/promise.h
@@ -0,0 +1,22 @@
+/* -*- mode: C++; c-basic-offset: 4; indent-tabs-mode: nil; -*- */
+/*
+ * SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
+ * SPDX-FileCopyrightText: 2021 Evan Welsh <contact evanwelsh com>
+ */
+
+#ifndef GJS_PROMISE_H_
+#define GJS_PROMISE_H_
+
+#include <gio/gio.h>
+#include <glib.h>
+
+#include <js/TypeDecls.h>
+#include "gjs/context-private.h"
+
+GSource* gjs_promise_queue_source_new(GjsContextPrivate* cx,
+                                      GCancellable* cancellable);
+
+bool gjs_define_native_promise_stuff(JSContext* cx,
+                                     JS::MutableHandleObject module);
+
+#endif
\ No newline at end of file
diff --git a/installed-tests/js/meson.build b/installed-tests/js/meson.build
index 8026f903..7fde4091 100644
--- a/installed-tests/js/meson.build
+++ b/installed-tests/js/meson.build
@@ -118,6 +118,7 @@ jasmine_tests = [
     'Regress',
     'Signals',
     'System',
+    'Timers',
     'Tweener',
     'WarnLib',
 ]
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/testMainloop.js b/installed-tests/js/testMainloop.js
index aae9a140..6fce2d4c 100644
--- a/installed-tests/js/testMainloop.js
+++ b/installed-tests/js/testMainloop.js
@@ -86,14 +86,15 @@ describe('Mainloop.idle_add()', function () {
         });
     });
 
-    // Add an idle before exit, then never run main loop again.
-    // This is to test that we remove idle callbacks when the associated
-    // JSContext is blown away. The leak check in minijasmine will
-    // fail if the idle function is not garbage collected.
-    it('does not leak idle callbacks', function () {
-        Mainloop.idle_add(() => {
-            fail('This should never have been called');
-            return true;
-        });
-    });
+    // TODO(ewlsh): This no longer works with our implicit mainloop.
+    // // Add an idle before exit, then never run main loop again.
+    // // This is to test that we remove idle callbacks when the associated
+    // // JSContext is blown away. The leak check in minijasmine will
+    // // fail if the idle function is not garbage collected.
+    // it('does not leak idle callbacks', function () {
+    //     Mainloop.idle_add(() => {
+    //         fail('This should never have been called');
+    //         return true;
+    //     });
+    // });
 });
diff --git a/installed-tests/js/testTimers.js b/installed-tests/js/testTimers.js
new file mode 100644
index 00000000..1d3398cc
--- /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() {
+        function uncalled() {
+            throw new Error('This function should not be called.');
+        }
+
+        // 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);
+    });
+
+    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 fc55e597..226ec59f 100644
--- a/js.gresource.xml
+++ b/js.gresource.xml
@@ -45,5 +45,6 @@
     <file>modules/core/_format.js</file>
     <file>modules/core/_gettext.js</file>
     <file>modules/core/_signals.js</file>
+    <file>modules/core/_timers.js</file>
   </gresource>
 </gresources>
diff --git a/meson.build b/meson.build
index 58dd2dd0..d8a354e9 100644
--- a/meson.build
+++ b/meson.build
@@ -398,11 +398,13 @@ libgjs_sources = [
     'gjs/global.cpp', 'gjs/global.h',
     'gjs/importer.cpp', 'gjs/importer.h',
     'gjs/internal.cpp', 'gjs/internal.h',
+    'gjs/mainloop.cpp', 'gjs/mainloop.h',
     'gjs/mem.cpp', 'gjs/mem-private.h',
     'gjs/module.cpp', 'gjs/module.h',
     'gjs/native.cpp', 'gjs/native.h',
     'gjs/objectbox.cpp', 'gjs/objectbox.h',
     'gjs/profiler.cpp', 'gjs/profiler-private.h',
+    'gjs/promise.cpp', 'gjs/promise.h',
     'gjs/stack.cpp',
     'modules/console.cpp', 'modules/console.h',
     'modules/modules.cpp', 'modules/modules.h',
diff --git a/modules/core/.eslintrc.yml b/modules/core/.eslintrc.yml
index 6c9c0253..037dcc81 100644
--- a/modules/core/.eslintrc.yml
+++ b/modules/core/.eslintrc.yml
@@ -3,3 +3,8 @@
 # SPDX-FileCopyrightText: 2020 Evan Welsh <contact evanwelsh com>
 rules:
   jsdoc/require-jsdoc: 'off'
+globals:
+  setTimeout: off
+  setInterval: off
+  clearTimeout: off
+  clearInterval: off
diff --git a/modules/core/_timers.js b/modules/core/_timers.js
new file mode 100644
index 00000000..5ee2f81f
--- /dev/null
+++ b/modules/core/_timers.js
@@ -0,0 +1,133 @@
+// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
+// SPDX-FileCopyrightText: 2021 Evan Welsh <contact evanwelsh com>
+
+/* exported setTimeout, setInterval, clearTimeout, clearInterval */
+
+const {GLib} = imports.gi;
+
+const jobs = imports._promiseNative;
+
+// It should not be possible to remove or destroy sources from outside this library.
+const ids = new Map();
+let idIncrementor = 1;
+
+/**
+ * @param {number} sourceId the source ID to generate a timer ID for
+ * @returns {number}
+ */
+function nextId(sourceId) {
+    idIncrementor++;
+
+    ids.set(idIncrementor, sourceId);
+
+    return idIncrementor;
+}
+
+const TIMEOUT_MAX = 2 ** 31 - 1;
+
+function checkThis(thisArg) {
+    if (thisArg !== null && thisArg !== undefined && thisArg !== globalThis)
+        throw new TypeError('Illegal invocation');
+}
+
+function checkBigInt(n) {
+    if (typeof n === 'bigint')
+        throw new TypeError('Cannot convert a BigInt value to a number');
+}
+
+function ToNumber(interval) {
+    /* eslint-disable no-implicit-coercion */
+    if (typeof interval === 'number')
+        return interval;
+    else if (typeof interval === 'object')
+        return +interval.valueOf() || +interval;
+
+
+    return +interval;
+    /* eslint-enable */
+}
+
+function setTimeout(callback, delay = 0, ...args) {
+    checkThis(this);
+    checkBigInt(delay);
+
+    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();
+        ids.delete(id);
+        // Drain the microtask queue.
+        jobs.run();
+
+
+        return GLib.SOURCE_REMOVE;
+    }));
+
+    return id;
+}
+
+function wrapDelay(delay) {
+    if (delay > TIMEOUT_MAX) {
+        imports._print.warn(
+            `${delay} does not fit into` +
+            ' a 32-bit signed integer.' +
+            '\nTimeout duration was set to 1.'
+        );
+        delay = 1;
+    }
+    return Math.max(0, delay | 0);
+}
+
+function setInterval(callback, delay = 0, ...args) {
+    checkThis(this);
+    checkBigInt(delay);
+
+    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();
+
+        // Drain the microtask queue.
+        jobs.run();
+
+        return GLib.SOURCE_CONTINUE;
+    }));
+
+    return id;
+}
+
+function _clearTimer(id) {
+    checkBigInt(id);
+
+    const _id = ToNumber(id);
+
+    if (!ids.has(_id))
+        return;
+
+
+    const cx = GLib.MainContext.default();
+    const source_id = ids.get(_id);
+    const source = cx.find_source_by_id(source_id);
+
+    if (source_id > 0 && source) {
+        GLib.source_remove(source_id);
+        source.destroy();
+        ids.delete(_id);
+    }
+}
+
+function clearTimeout(id = 0) {
+    _clearTimer(id);
+}
+
+function clearInterval(id = 0) {
+    _clearTimer(id);
+}
diff --git a/modules/print.cpp b/modules/print.cpp
index cb0f1e3b..965d6f95 100644
--- a/modules/print.cpp
+++ b/modules/print.cpp
@@ -58,6 +58,36 @@ static bool gjs_log(JSContext* cx, unsigned argc, JS::Value* vp) {
     return true;
 }
 
+GJS_JSAPI_RETURN_CONVENTION
+static bool gjs_warn(JSContext* cx, unsigned argc, JS::Value* vp) {
+    JS::CallArgs argv = JS::CallArgsFromVp(argc, vp);
+
+    if (argc != 1) {
+        gjs_throw(cx, "Must pass a single argument to warn()");
+        return false;
+    }
+
+    /* JS::ToString might throw, in which case we will only log that the value
+     * could not be converted to string */
+    JS::AutoSaveExceptionState exc_state(cx);
+    JS::RootedString jstr(cx, JS::ToString(cx, argv[0]));
+    exc_state.restore();
+
+    if (!jstr) {
+        g_message("JS LOG: <cannot convert value to string>");
+        return true;
+    }
+
+    JS::UniqueChars s(JS_EncodeStringToUTF8(cx, jstr));
+    if (!s)
+        return false;
+
+    g_warning("%s", s.get());
+
+    argv.rval().setUndefined();
+    return true;
+}
+
 GJS_JSAPI_RETURN_CONVENTION
 static bool gjs_log_error(JSContext* cx, unsigned argc, JS::Value* vp) {
     JS::CallArgs argv = JS::CallArgsFromVp(argc, vp);
@@ -144,6 +174,7 @@ static bool gjs_printerr(JSContext* context, unsigned argc, JS::Value* vp) {
 // clang-format off
 static constexpr JSFunctionSpec funcs[] = {
     JS_FN("log", gjs_log, 1, GJS_MODULE_PROP_FLAGS),
+    JS_FN("warn", gjs_warn, 1, GJS_MODULE_PROP_FLAGS),
     JS_FN("logError", gjs_log_error, 2, GJS_MODULE_PROP_FLAGS),
     JS_FN("print", gjs_print, 0, GJS_MODULE_PROP_FLAGS),
     JS_FN("printerr", gjs_printerr, 0, GJS_MODULE_PROP_FLAGS),
diff --git a/modules/script/_bootstrap/default.js b/modules/script/_bootstrap/default.js
index 952d7fe3..f000734b 100644
--- a/modules/script/_bootstrap/default.js
+++ b/modules/script/_bootstrap/default.js
@@ -6,6 +6,7 @@
     'use strict';
 
     const {print, printerr, log, logError} = imports._print;
+    const {setTimeout, setInterval, clearTimeout, clearInterval} = imports._timers;
 
     Object.defineProperties(exports, {
         ARGV: {
@@ -16,6 +17,30 @@
                 return imports.system.programArgs;
             },
         },
+        setTimeout: {
+            configurable: false,
+            enumerable: true,
+            writable: true,
+            value: setTimeout,
+        },
+        setInterval: {
+            configurable: false,
+            enumerable: true,
+            writable: true,
+            value: setInterval,
+        },
+        clearTimeout: {
+            configurable: false,
+            enumerable: true,
+            writable: true,
+            value: clearTimeout,
+        },
+        clearInterval: {
+            configurable: false,
+            enumerable: true,
+            writable: true,
+            value: clearInterval,
+        },
         print: {
             configurable: false,
             enumerable: true,


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