[gjs/ewlsh/main-loop-hooks: 1/2] Introduce runAsync() to run mainloops without blocking module resolution




commit 0d2b4564b3ef92ddc03fa5f2fe56a1d264049668
Author: Evan Welsh <contact evanwelsh com>
Date:   Sun Feb 27 21:31:10 2022 -0800

    Introduce runAsync() to run mainloops without blocking module resolution
    
    With top-level await all modules are now promises, if Gtk.Application.run()
    or GLib.MainLoop.run() is called within a module it will block
    all other promises as run() is a synchronous, blocking function.
    To work around this there is now a setMainLoopHook function exposed
    which runs a callback after module resolution is complete. This allows
    APIs to "install" a mainloop asynchronously.
    
    For Gio.Application, Gtk.Application, and GLib.MainLoop there are now
    runAsync() versions of their run() functions. runAsync() returns a
    Promise which will resolve once the mainloop has exited.
    
    Fixes #468

 gjs/context-private.h          | 15 +++++++
 gjs/context.cpp                | 91 ++++++++++++++++++++++++++++++++++++++----
 gjs/mainloop.cpp               |  2 +
 gjs/promise.cpp                | 29 +++++++++++++-
 modules/core/overrides/GLib.js | 14 +++++++
 modules/core/overrides/Gio.js  | 14 +++++++
 modules/core/overrides/Gtk.js  | 14 +++++++
 7 files changed, 170 insertions(+), 9 deletions(-)
---
diff --git a/gjs/context-private.h b/gjs/context-private.h
index 3bc7d50fa..83b9c646b 100644
--- a/gjs/context-private.h
+++ b/gjs/context-private.h
@@ -65,6 +65,7 @@ class GjsContextPrivate : public JS::JobQueue {
  private:
     GjsContext* m_public_context;
     JSContext* m_cx;
+    JS::Heap<JSFunction*> m_main_loop_hook;
     JS::Heap<JSObject*> m_global;
     JS::Heap<JSObject*> m_internal_global;
     std::thread::id m_owner_thread;
@@ -176,6 +177,20 @@ class GjsContextPrivate : public JS::JobQueue {
     [[nodiscard]] GjsContext* public_context() const {
         return m_public_context;
     }
+    [[nodiscard]] bool set_main_loop_hook(JSFunction* callback) {
+        if (!callback) {
+            m_main_loop_hook = callback;
+            return true;
+        }
+
+        if (m_main_loop_hook)
+            return false;
+
+        m_main_loop_hook = callback;
+        return true;
+    }
+    [[nodiscard]] bool has_main_loop_hook() { return !!m_main_loop_hook; }
+    GJS_JSAPI_RETURN_CONVENTION bool run_main_loop_hook();
     [[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 b1cb934bc..9917b2c27 100644
--- a/gjs/context.cpp
+++ b/gjs/context.cpp
@@ -349,6 +349,8 @@ void GjsContextPrivate::trace(JSTracer* trc, void* data) {
     JS::TraceEdge<JSObject*>(trc, &gjs->m_global, "GJS global object");
     JS::TraceEdge<JSObject*>(trc, &gjs->m_internal_global,
                              "GJS internal global object");
+    JS::TraceEdge<JSFunction*>(trc, &gjs->m_main_loop_hook,
+                               "GJS main loop hook");
     gjs->m_atoms->trace(trc);
     gjs->m_job_queue.trace(trc);
     gjs->m_object_init_list.trace(trc);
@@ -460,6 +462,7 @@ void GjsContextPrivate::dispose(void) {
         JS_RemoveExtraGCRootsTracer(m_cx, &GjsContextPrivate::trace, this);
         m_global = nullptr;
         m_internal_global = nullptr;
+        m_main_loop_hook = nullptr;
 
         gjs_debug(GJS_DEBUG_CONTEXT, "Freeing allocated resources");
         delete m_fundamental_table;
@@ -1354,6 +1357,14 @@ bool GjsContextPrivate::handle_exit_code(bool no_sync_error_pending,
     return false;
 }
 
+bool GjsContextPrivate::run_main_loop_hook() {
+    JS::RootedFunction hook(m_cx, m_main_loop_hook.get());
+    m_main_loop_hook = nullptr;
+    JS::RootedValue ignored_rval(m_cx);
+    return JS_CallFunction(m_cx, nullptr, hook, JS::HandleValueArray::empty(),
+                           &ignored_rval);
+}
+
 bool GjsContextPrivate::eval(const char* script, size_t script_len,
                              const char* filename, int* exit_status_p,
                              GError** error) {
@@ -1366,15 +1377,47 @@ bool GjsContextPrivate::eval(const char* script, size_t script_len,
     JS::RootedValue retval(m_cx);
     bool ok = eval_with_scope(nullptr, script, script_len, filename, &retval);
 
-    /* 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.
+    /**
+     * If there are no errors and the mainloop hook
+     * is set, cal it.
+     */
+    if (ok && m_main_loop_hook)
+        ok = run_main_loop_hook();
+
+    bool exiting = false;
+
+    /**
+     * Spin the internal loop until the main loop hook
+     * is set or no holds remain.
      *
      * If the main loop returns false we cannot guarantee the state
      * of our promise queue (a module promise could be pending) so
      * instead of draining the queue we instead just exit.
      */
-    if (!ok || m_main_loop.spin(this)) {
+    if (ok && !m_main_loop.spin(this)) {
+        exiting = true;
+    }
+
+    // If the hook has been set again, enter a loop
+    // until an error is encountered or the main loop
+    // is quit.
+    while (ok && !exiting && m_main_loop_hook) {
+        ok = run_main_loop_hook();
+
+        // Additional jobs could have been enqueued from the
+        // main loop hook
+        if (ok && !m_main_loop.spin(this)) {
+            exiting = true;
+        }
+    }
+
+    /* 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.
+     *
+     * We do not drain if we are exiting.
+     */
+    if (!ok && !exiting) {
         JS::AutoSaveExceptionState saved_exc(m_cx);
         ok = run_jobs_fallible() && ok;
     }
@@ -1439,15 +1482,47 @@ bool GjsContextPrivate::eval_module(const char* identifier,
             on_context_module_rejected_log_exception, "context");
     }
 
-    /* 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.
+    /**
+     * If there are no errors and the mainloop hook
+     * is set, cal it.
+     */
+    if (ok && m_main_loop_hook)
+        ok = run_main_loop_hook();
+
+    bool exiting = false;
+
+    /**
+     * Spin the internal loop until the main loop hook
+     * is set or no holds remain.
      *
      * If the main loop returns false we cannot guarantee the state
      * of our promise queue (a module promise could be pending) so
      * instead of draining the queue we instead just exit.
      */
-    if (!ok || m_main_loop.spin(this)) {
+    if (ok && !m_main_loop.spin(this)) {
+        exiting = true;
+    }
+
+    // If the hook has been set again, enter a loop
+    // until an error is encountered or the main loop
+    // is quit.
+    while (ok && !exiting && m_main_loop_hook) {
+        ok = run_main_loop_hook();
+
+        // Additional jobs could have been enqueued from the
+        // main loop hook
+        if (ok && !m_main_loop.spin(this)) {
+            exiting = true;
+        }
+    }
+
+    /* 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.
+     *
+     * We do not drain if we are exiting.
+     */
+    if (!ok && !exiting) {
         JS::AutoSaveExceptionState saved_exc(m_cx);
         ok = run_jobs_fallible() && ok;
     }
diff --git a/gjs/mainloop.cpp b/gjs/mainloop.cpp
index 91109d61a..b69d45a85 100644
--- a/gjs/mainloop.cpp
+++ b/gjs/mainloop.cpp
@@ -40,6 +40,8 @@ bool MainLoop::spin(GjsContextPrivate* gjs) {
             return false;
         }
     } while (
+        // and there is not a pending main loop hook
+        !gjs->has_main_loop_hook() &&
         // and there are pending sources or the job queue is not empty
         // continue spinning the event loop.
         (can_block() || !gjs->empty()));
diff --git a/gjs/promise.cpp b/gjs/promise.cpp
index 0ce4bd7c5..d8521150e 100644
--- a/gjs/promise.cpp
+++ b/gjs/promise.cpp
@@ -14,8 +14,10 @@
 #include <js/RootingAPI.h>
 #include <js/TypeDecls.h>
 #include <jsapi.h>  // for JS_DefineFunctions, JS_NewPlainObject
+#include <jsfriendapi.h>
 
 #include "gjs/context-private.h"
+#include "gjs/jsapi-util-args.h"
 #include "gjs/jsapi-util.h"
 #include "gjs/macros.h"
 #include "gjs/promise.h"
@@ -194,8 +196,33 @@ bool drain_microtask_queue(JSContext* cx, unsigned argc, JS::Value* vp) {
     return true;
 }
 
+GJS_JSAPI_RETURN_CONVENTION
+bool set_main_loop_hook(JSContext* cx, unsigned argc, JS::Value* vp) {
+    JS::CallArgs args = JS::CallArgsFromVp(argc, vp);
+
+    JS::RootedObject callback(cx);
+    if (!gjs_parse_call_args(cx, "setMainLoopHook", args, "o", "callback",
+                             &callback)) {
+        return false;
+    }
+
+    JS::RootedFunction func(cx, JS_GetObjectFunction(callback));
+
+    GjsContextPrivate* priv = GjsContextPrivate::from_cx(cx);
+    if (!priv->set_main_loop_hook(func)) {
+        gjs_throw(
+            cx,
+            "A mainloop is already running. Did you already call runAsync()?");
+        return false;
+    }
+
+    args.rval().setUndefined();
+    return true;
+}
+
 JSFunctionSpec gjs_native_promise_module_funcs[] = {
-    JS_FN("drainMicrotaskQueue", &drain_microtask_queue, 0, 0), JS_FS_END};
+    JS_FN("drainMicrotaskQueue", &drain_microtask_queue, 0, 0),
+    JS_FN("setMainLoopHook", &set_main_loop_hook, 1, 0), JS_FS_END};
 
 bool gjs_define_native_promise_stuff(JSContext* cx,
                                      JS::MutableHandleObject module) {
diff --git a/modules/core/overrides/GLib.js b/modules/core/overrides/GLib.js
index cb8f177e7..ef92d648c 100644
--- a/modules/core/overrides/GLib.js
+++ b/modules/core/overrides/GLib.js
@@ -2,6 +2,7 @@
 // SPDX-FileCopyrightText: 2011 Giovanni Campagna
 
 const ByteArray = imports.byteArray;
+const {setMainLoopHook} = imports._promiseNative;
 
 let GLib;
 
@@ -261,6 +262,19 @@ function _init() {
 
     GLib = this;
 
+    GLib.MainLoop.prototype.runAsync = function (...args) {
+        return new Promise((resolve, reject) => {
+            setMainLoopHook(() => {
+                try {
+                    this.run(...args);
+                    resolve();
+                } catch (error) {
+                    reject(error);
+                }
+            });
+        });
+    };
+
     // For convenience in property min or max values, since GLib.MAXINT64 and
     // friends will log a warning when used
     this.MAXINT64_BIGINT = 0x7fff_ffff_ffff_ffffn;
diff --git a/modules/core/overrides/Gio.js b/modules/core/overrides/Gio.js
index be2e52470..6b382bfc8 100644
--- a/modules/core/overrides/Gio.js
+++ b/modules/core/overrides/Gio.js
@@ -4,6 +4,7 @@
 var GLib = imports.gi.GLib;
 var GjsPrivate = imports.gi.GjsPrivate;
 var Signals = imports.signals;
+var {setMainLoopHook} = imports._promiseNative;
 var Gio;
 
 // Ensures that a Gio.UnixFDList being passed into or out of a DBus method with
@@ -442,6 +443,19 @@ function _promisify(proto, asyncFunc,
 function _init() {
     Gio = this;
 
+    Gio.Application.prototype.runAsync = function (...args) {
+        return new Promise((resolve, reject) => {
+            setMainLoopHook(() => {
+                try {
+                    this.run(...args);
+                    resolve();
+                } catch (error) {
+                    reject(error);
+                }
+            });
+        });
+    };
+
     Gio.DBus = {
         get session() {
             return Gio.bus_get_sync(Gio.BusType.SESSION, null);
diff --git a/modules/core/overrides/Gtk.js b/modules/core/overrides/Gtk.js
index ce63ba4e7..5dfc0e69a 100644
--- a/modules/core/overrides/Gtk.js
+++ b/modules/core/overrides/Gtk.js
@@ -5,6 +5,7 @@
 const Legacy = imports._legacy;
 const {Gio, GjsPrivate, GObject} = imports.gi;
 const {_registerType} = imports._common;
+const {setMainLoopHook} = imports._promiseNative;
 
 let Gtk;
 let BuilderScope;
@@ -12,6 +13,19 @@ let BuilderScope;
 function _init() {
     Gtk = this;
 
+    Gtk.Application.prototype.runAsync = function (...args) {
+        return new Promise((resolve, reject) => {
+            setMainLoopHook(() => {
+                try {
+                    this.run(...args);
+                    resolve();
+                } catch (error) {
+                    reject(error);
+                }
+            });
+        });
+    };
+
     Gtk.children = GObject.__gtkChildren__;
     Gtk.cssName = GObject.__gtkCssName__;
     Gtk.internalChildren = GObject.__gtkInternalChildren__;


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