loop: block on the GMainContext poll fds instead of a 5ms poll
The run-loop pumped GLib every 5ms (~200Hz) even when idle. It now drains non-blocking, then blocks on the context's poll fds via await-readable-fds (sigil 0.17.18+) until an event arrives or GLib's requested timeout elapses, falling back to the interval poll on a backend that cannot report its fds.
gtk backend: bind gmaincontext_acquire/release/prepare/query and expose query-fds, which reports the default GMainContext's poll fds and timeout (GPollFD read GC-safely out of a bytevector). webview: webview-query-fds dispatches to it (optional op).
Measured (lantern run, 8s idle, this Mesa/sway box): 49.0% -> 39.8% on a blank page, 49.6% -> 39.9% on the full Slate web build. Blank ~= Slate, so the remaining ~40% is the WebKitGTK/GTK4/Mesa webview compositing (it signals the main loop at ~140Hz regardless of content), not the pump: this fix removes the forced 200Hz poll but cannot go below WebKit's own event rate.
lantern/src/lantern/backend/gtk.sgl | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++
lantern/src/lantern/loop.sgl | 28 ++++++++++++++++++++++++++--
lantern/src/lantern/webview.sgl | 8 ++++++++
3 files changed, 85 insertions(+), 2 deletions(-)lantern/src/lantern/backend/gtk.sglmodified
(define jsc-value-to-string #f) (define g-main-context-iteration #f) ;; Idle-block support: query the default GMainContext's poll fds so the ;; run-loop can sleep on them (await-readable-fds) instead of polling. (define g-main-context-acquire #f) (define g-main-context-release #f) (define g-main-context-prepare #f) (define g-main-context-query #f) (define g-memory-input-stream-new-from-data #f) (define g-app-info-launch-default-for-uri #f) (set! jsc-value-to-string (c-function libjsc "jsc_value_to_string" (list ffi/pointer) ffi/string)) (set! g-main-context-iteration (c-function libglib "g_main_context_iteration" (list ffi/pointer ffi/int) ffi/int)) (set! g-main-context-acquire (c-function libglib "g_main_context_acquire" (list ffi/pointer) ffi/int)) (set! g-main-context-release (c-function libglib "g_main_context_release" (list ffi/pointer) ffi/void)) (set! g-main-context-prepare (c-function libglib "g_main_context_prepare" (list ffi/pointer ffi/pointer) ffi/int)) (set! g-main-context-query (c-function libglib "g_main_context_query" (list ffi/pointer ffi/int ffi/pointer ffi/pointer ffi/int) ffi/int)) (set! g-memory-input-stream-new-from-data (c-function libgio "g_memory_input_stream_new_from_data" (list ffi/pointer ffi/int64 ffi/pointer) ffi/pointer)) (set! g-app-info-launch-default-for-uri (define (quit) (set! *quit-requested* #t)) ;; Read a little-endian signed 32-bit int out of a bytevector. GC-safe: ;; reads the live bytevector object (whose contents the GC relocates with ;; it), never a captured C pointer that a moving collection could stale. (define (bv-s32 bv off) (let ((v (+ (bytevector-u8-ref bv off) (* 256 (bytevector-u8-ref bv (+ off 1))) (* 65536 (bytevector-u8-ref bv (+ off 2))) (* 16777216 (bytevector-u8-ref bv (+ off 3)))))) (if (>= v 2147483648) (- v 4294967296) v))) ;; Query the default GMainContext for the fds it polls plus the timeout it ;; wants, so the run-loop can BLOCK on them (await-readable-fds) instead of ;; polling on a fixed interval. Returns (fds . timeout-ms): the list of raw ;; fds and GLib's requested timeout in milliseconds (-1 = wait until an fd ;; fires; 0 = work is already pending). GLib fills a GPollFD[] — each is ;; { gint fd; gushort events; gushort revents } = 8 bytes, fd at offset 0. ;; We only READ the fds to wait on; pump-once (g_main_context_iteration) ;; still runs the real prepare/check/dispatch, so no revents marshaling is ;; needed here. The context is acquired around prepare/query as GLib ;; requires (trivially satisfied on the single-threaded VM). (define max-poll-fds 32) (define (query-fds) (let ((prio (make-bytevector 4 0)) (timeout (make-bytevector 4 0)) (fds-buf (make-bytevector (* max-poll-fds 8) 0))) (g-main-context-acquire NULL) (g-main-context-prepare NULL (bytevector->pointer prio)) (let ((n (g-main-context-query NULL (bv-s32 prio 0) (bytevector->pointer timeout) (bytevector->pointer fds-buf) max-poll-fds))) (g-main-context-release NULL) (let ((count (if (> n max-poll-fds) max-poll-fds n))) (cons (let loop ((i 0) (acc '())) (if (>= i count) acc (loop (+ i 1) (cons (bv-s32 fds-buf (* i 8)) acc)))) (bv-s32 timeout 0)))))) ;; Bind the FFI, then init GTK. All GTK calls must come from the thread ;; that initialized it; the factory runs once at startup on that thread. ;; (Every internal define above must precede these first expressions.) set-close-handler: set-close-handler window-op: window-op pump-once: pump-once query-fds: query-fds get-title: get-title should-quit?: should-quit? quit: quit)))lantern/src/lantern/loop.sglmodified
default-pump-interval) (begin ;; ~200Hz pump ceiling; bounds worst-case input-servicing latency. ;; Fallback pump interval for a backend that cannot report its poll fds ;; (webview-query-fds returns #f): ~200Hz, bounds worst-case input latency. (define default-pump-interval 0.005) ;; When GLib reports no timer deadline (timeout -1: purely fd-driven), block ;; on the fds but re-poll at least this often as a safety net against a non-fd ;; source we do not model. 1s => ~1Hz idle, negligible CPU. An event on any ;; GLib fd still wakes the loop immediately, so this is not input latency. (define idle-cap-ms 1000) ;; Run the app's event loop. `goroutines` are zero-argument thunks started ;; alongside the pump inside the same async scope (e.g. the bridge ;; dispatcher). Returns 0 when a quit is requested. ;; ;; The pump drains GLib non-blocking, then BLOCKS on the GMainContext's poll ;; fds (await-readable-fds) until an event arrives or GLib's requested timeout ;; elapses, instead of waking every 5ms. Idle CPU drops from a constant ;; ~200Hz poll to ~0, while input still wakes the loop instantly. A backend ;; that cannot report its fds falls back to the interval poll. (define (run-loop wv . goroutines) (with-async (for-each (lambda (thunk) (go (thunk))) goroutines) ;; The GLib pump. (go (let loop () (webview-pump-once wv) (sleep default-pump-interval) (let ((q (webview-query-fds wv))) (if (and q (pair? (car q))) (let* ((fds (car q)) (glib-ms (cdr q)) ;; -1 (no timer deadline) => idle cap; else GLib's own ;; requested timeout, capped so we never oversleep it. (cap (cond ((< glib-ms 0) idle-cap-ms) ((< glib-ms idle-cap-ms) glib-ms) (else idle-cap-ms)))) (await-readable-fds fds timeout-ms: cap)) ;; No fds reported (or backend can't): interval poll. (sleep default-pump-interval))) (unless (webview-should-quit? wv) (loop)))) ;; Main task: idle until quit, then fall out of with-async. (let wait ()lantern/src/lantern/webview.sglmodified
webview-set-close-handler webview-window-op webview-pump-once webview-query-fds webview-get-title webview-should-quit? webview-quit) (apply (op wv window-op:) window op-name args)) (define (webview-pump-once wv) ((op wv pump-once:))) ;; Optional op: a backend that can expose its event-loop poll fds returns ;; (fds . timeout-ms) so the run-loop can BLOCK on them (await-readable-fds) ;; instead of polling. Returns #f when the backend does not implement it, so ;; the run-loop falls back to interval polling. (define (webview-query-fds wv) (let ((f (dict-ref wv query-fds: #f))) (and f (f)))) (define (webview-get-title wv window) ((op wv get-title:) window)) (define (webview-should-quit? wv)