Commit0d675862Recorded21 Jul 2026Repositorylantern
loop: make the event pump and bridge fully event-driven for near-zero idle CPU
Message
Idle CPU on a hosted app (Slate) dropped from ~44% to ~1%, in line with a native WebKitGTK app (MiniBrowser idles at ~0.1% on the same page).
Three compounding causes, all in the run loop:
1. The GTK pump acquired/released the main context every iteration and
drove it with a non-blocking g_main_context_iteration, which
self-signalled GLib's wakeup eventfd and spun. Replaced with the full
single-cycle external-mainloop protocol (prepare, query, await the poll
fds through the scheduler, g_poll, check, dispatch), with the context
acquired once at startup like g_main_loop_run does. 2. The bridge dispatcher polled its queue every 3ms and the quit watcher
every 50ms (plus a 20ms main wait), three loops that kept the Sigil run
queue perpetually non-empty, so the scheduler never reached its blocking
path and spun on 0-timeout polls. This was the dominant cost. The bridge
drain and quit handling now run as an after-pump hook off the pump loop,
which itself sleeps on GLib's fds, so nothing polls when idle. Page
messages land during the pump's dispatch and are drained in the same
iteration, so bridge latency is equal or better than the old 3ms tick. 3. Programmatic quit (app.quit) now wakes the main context via
g_main_context_wakeup so it is noticed at once instead of waiting for
the pump's idle cap.The blocking pump uses await-readable-fds from sigil-stdlib 0.17.18.
Changed
lantern/src/lantern/app.sgl | 23 ++++++++++++++---------
lantern/src/lantern/backend/gtk.sgl | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
lantern/src/lantern/bridge.sgl | 28 ++++++++++++++--------------
lantern/src/lantern/loop.sgl | 58 +++++++++++++++++++++-------------------------------------
lantern/src/lantern/webview.sgl | 17 +++++++++--------
5 files changed, 125 insertions(+), 96 deletions(-)Diff
lantern/src/lantern/app.sglmodified
@@ -265,12 +265,17 @@
265
;; masters, so child shells get SIGHUP). Teardown-on-exit quirks are 266
;; cosmetic (see the framework note); a live headless zombie is not. 267
(apply run-loop wv−268
(append−269
(list (bridge-dispatcher bridge))−270
service-thunks−271
(list (lambda ()−272
(let watch ()−273
(sleep 0.05)−274
(if (webview-should-quit? wv)−275
(begin (bridge-stop! bridge) (exit 0))−276
(watch)))))))))))))+268
;; after-pump: service the bridge queue the pump just filled,+269
;; then, on quit, stop the bridge and exit the process outright.+270
;; This replaces the old 3ms bridge-dispatcher poll and 50ms+271
;; quit-watcher poll - both are now event-driven off the pump,+272
;; which itself sleeps on GLib's fds. A window app's close IS its+273
;; exit (kernel cleanup closes pty masters, so child shells get+274
;; SIGHUP), avoiding the headless-zombie "instance proliferation"+275
;; pile that a lingering with-async join would leave.+276
(lambda ()+277
(bridge-drain-once! bridge)+278
(when (webview-should-quit? wv)+279
(bridge-stop! bridge)+280
(exit 0)))+281
service-thunks)))))))lantern/src/lantern/backend/gtk.sglmodified
@@ -26,6 +26,7 @@
26
(import (sigil core) 27
(sigil env) 28
(sigil ffi)+29
(sigil async) 30
(lantern lib) 31
(lantern gobject) 32
(lantern webview))@@ -117,6 +118,11 @@
118
(define g-main-context-release #f) 119
(define g-main-context-prepare #f) 120
(define g-main-context-query #f)+121
(define g-main-context-check #f)+122
(define g-main-context-dispatch #f)+123
(define g-main-context-default #f)+124
(define g-main-context-wakeup #f)+125
(define g-poll #f) 126
(define g-memory-input-stream-new-from-data #f) 127
(define g-app-info-launch-default-for-uri #f) 128
@@ -393,6 +399,11 @@
399
(set! g-main-context-release (c-function libglib "g_main_context_release" (list ffi/pointer) ffi/void)) 400
(set! g-main-context-prepare (c-function libglib "g_main_context_prepare" (list ffi/pointer ffi/pointer) ffi/int)) 401
(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))+402
(set! g-main-context-check (c-function libglib "g_main_context_check" (list ffi/pointer ffi/int ffi/pointer ffi/int) ffi/int))+403
(set! g-main-context-dispatch (c-function libglib "g_main_context_dispatch" (list ffi/pointer) ffi/void))+404
(set! g-main-context-default (c-function libglib "g_main_context_default" (list) ffi/pointer))+405
(set! g-main-context-wakeup (c-function libglib "g_main_context_wakeup" (list ffi/pointer) ffi/void))+406
(set! g-poll (c-function libglib "g_poll" (list ffi/pointer ffi/uint ffi/int) ffi/int)) 407
(set! g-memory-input-stream-new-from-data 408
(c-function libgio "g_memory_input_stream_new_from_data" (list ffi/pointer ffi/int64 ffi/pointer) ffi/pointer)) 409
(set! g-app-info-launch-default-for-uri@@ -531,7 +542,14 @@
542
543
(define (should-quit?) *quit-requested*) 544
−534
(define (quit) (set! *quit-requested* #t))+545
;; Flag quit AND wake the main context: the run-loop pump is blocked in+546
;; g_poll via the scheduler, so a programmatic quit (app.quit from a+547
;; handler goroutine) would otherwise go unnoticed until the pump's idle+548
;; cap (~1s). g_main_context_wakeup makes it return at once.+549
(define (quit)+550
(set! *quit-requested* #t)+551
(when g-main-context-wakeup+552
(g-main-context-wakeup (g-main-context-default)))) 553
554
;; Read a little-endian signed 32-bit int out of a bytevector. GC-safe: 555
;; reads the live bytevector object (whose contents the GC relocates with@@ -543,41 +561,62 @@
561
(* 16777216 (bytevector-u8-ref bv (+ off 3)))))) 562
(if (>= v 2147483648) (- v 4294967296) v))) 563
−546
;; Query the default GMainContext for the fds it polls plus the timeout it−547
;; wants, so the run-loop can BLOCK on them (await-readable-fds) instead of−548
;; polling on a fixed interval. Returns (fds . timeout-ms): the list of raw−549
;; fds and GLib's requested timeout in milliseconds (-1 = wait until an fd−550
;; fires; 0 = work is already pending). GLib fills a GPollFD[] — each is−551
;; { gint fd; gushort events; gushort revents } = 8 bytes, fd at offset 0.−552
;; We only READ the fds to wait on; pump-once (g_main_context_iteration)−553
;; still runs the real prepare/check/dispatch, so no revents marshaling is−554
;; needed here. The context is acquired around prepare/query as GLib−555
;; requires (trivially satisfied on the single-threaded VM).+564
;; Collect the fds GLib wants to poll out of the GPollFD[] buffer (each is+565
;; { gint fd; gushort events; gushort revents } = 8 bytes, fd at offset 0).+566
(define (read-poll-fds fds-buf count)+567
(let loop ((i 0) (acc '()))+568
(if (>= i count) acc+569
(loop (+ i 1) (cons (bv-s32 fds-buf (* i 8)) acc)))))+570
+571
;; -1 timeout (no GLib deadline) caps to ~1Hz so a source we don't model+572
;; still gets a chance; an fd event still wakes us immediately. 573
(define max-poll-fds 32)−557
(define (query-fds)−558
(let ((prio (make-bytevector 4 0))+574
(define gtk-idle-cap-ms 1000)+575
+576
;; ONE GLib main-context iteration that BLOCKS through the Sigil scheduler:+577
;; prepare -> query -> (await the poll fds, or GLib's timeout) -> g_poll to+578
;; fill revents -> check -> dispatch. This is the standard external-mainloop+579
;; integration (what g_main_loop_run does internally), except the wait is a+580
;; scheduler yield (await-readable-fds) so other goroutines keep running and+581
;; the loop TRULY sleeps when idle. The context is acquired ONCE at startup+582
;; (see the factory below), not per-iteration: acquiring/releasing every+583
;; cycle churns GLib's ownership and self-signals the wakeup eventfd.+584
(define (pump-blocking)+585
(let ((ctx (g-main-context-default))+586
(prio (make-bytevector 4 0)) 587
(timeout (make-bytevector 4 0)) 588
(fds-buf (make-bytevector (* max-poll-fds 8) 0)))−561
(g-main-context-acquire NULL)−562
(g-main-context-prepare NULL (bytevector->pointer prio))−563
(let ((n (g-main-context-query NULL (bv-s32 prio 0)−564
(bytevector->pointer timeout)−565
(bytevector->pointer fds-buf)−566
max-poll-fds)))−567
(g-main-context-release NULL)−568
(let ((count (if (> n max-poll-fds) max-poll-fds n)))−569
(cons−570
(let loop ((i 0) (acc '()))−571
(if (>= i count)−572
acc−573
(loop (+ i 1) (cons (bv-s32 fds-buf (* i 8)) acc))))−574
(bv-s32 timeout 0))))))+589
(let* ((ready (g-main-context-prepare ctx (bytevector->pointer prio)))+590
(max-prio (bv-s32 prio 0))+591
(n (g-main-context-query ctx max-prio+592
(bytevector->pointer timeout)+593
(bytevector->pointer fds-buf)+594
max-poll-fds))+595
(count (if (> n max-poll-fds) max-poll-fds n))+596
(glib-ms (bv-s32 timeout 0)))+597
;; Block on the poll fds unless a source is already ready (prepare+598
;; returned non-zero) or GLib asked for no wait (timeout 0).+599
(when (and (= ready 0) (not (= glib-ms 0)) (> count 0))+600
(await-readable-fds (read-poll-fds fds-buf count)+601
timeout-ms: (cond ((< glib-ms 0) gtk-idle-cap-ms)+602
((< glib-ms gtk-idle-cap-ms) glib-ms)+603
(else gtk-idle-cap-ms))))+604
;; Non-blocking poll to fill revents (GPollFD == struct pollfd), then+605
;; the check/dispatch half of the same cycle.+606
(g-poll (bytevector->pointer fds-buf) count 0)+607
(g-main-context-check ctx max-prio (bytevector->pointer fds-buf) count)+608
(g-main-context-dispatch ctx)))) 609
610
;; Bind the FFI, then init GTK. All GTK calls must come from the thread 611
;; that initialized it; the factory runs once at startup on that thread. 612
;; (Every internal define above must precede these first expressions.) 613
(ensure!) 614
(gtk-init)+615
;; Own the default main context for the process lifetime, exactly as+616
;; g_main_loop_run does. The pump goroutine (single scheduler thread) is the+617
;; sole iterator; acquiring per-iteration instead would self-signal the+618
;; wakeup eventfd and spin the loop.+619
(g-main-context-acquire (g-main-context-default)) 620
621
(make-backend 'gtk 622
(dict@@ -592,7 +631,7 @@
631
set-close-handler: set-close-handler 632
window-op: window-op 633
pump-once: pump-once−595
query-fds: query-fds+634
pump-blocking: pump-blocking 635
get-title: get-title 636
should-quit?: should-quit? 637
quit: quit)))lantern/src/lantern/bridge.sglmodified
@@ -46,7 +46,7 @@
46
(lantern registry)) 47
(export make-bridge 48
bridge-enqueue−49
bridge-dispatcher+49
bridge-drain-once! 50
bridge-handle 51
bridge-emit 52
bridge-emit-bulk@@ -217,18 +217,18 @@
217
(define (stopped? bridge) 218
(box-ref (dict-ref bridge stopped: #f))) 219
−220
;; Returned as a zero-argument thunk for run-loop to (go ...). Drains the−221
;; queue on a short tick, spawning a goroutine per JSON message. Bulk−222
;; frames are stored INLINE (sequentially) so a frame is always in the+220
;; Drain the queue ONCE, spawning a handler goroutine per JSON message and+221
;; storing bulk frames INLINE (sequentially) so a frame is always in the 222
;; table before its request's handler goroutine can look for it - the page 223
;; posts the frame immediately before the request, and the queue is FIFO.−225
(define (bridge-dispatcher bridge)−226
(lambda ()−227
(let loop ()−228
(for-each (lambda (m)−229
(if (bulk-frame? m)−230
(bulk-store! bridge m)−231
(go (bridge-handle bridge m))))−232
(drain! bridge))−233
(sleep 0.003)−234
(unless (stopped? bridge) (loop)))))))+224
;;+225
;; The run-loop calls this right after each native pump. Page->host messages+226
;; land during the pump's GLib dispatch, so draining immediately after is+227
;; exactly event-driven: the bridge is serviced when messages actually+228
;; arrive, with no idle polling tick.+229
(define (bridge-drain-once! bridge)+230
(for-each (lambda (m)+231
(if (bulk-frame? m)+232
(bulk-store! bridge m)+233
(go (bridge-handle bridge m))))+234
(drain! bridge)))))lantern/src/lantern/loop.sglmodified
@@ -23,46 +23,30 @@
23
default-pump-interval) 24
(begin 25
−26
;; Fallback pump interval for a backend that cannot report its poll fds−27
;; (webview-query-fds returns #f): ~200Hz, bounds worst-case input latency.+26
;; Fallback pump interval for a backend without a blocking pump+27
;; (webview-pump-blocking returns #f): ~200Hz, bounds worst-case input latency. 28
(define default-pump-interval 0.005) 29
−30
;; When GLib reports no timer deadline (timeout -1: purely fd-driven), block−31
;; on the fds but re-poll at least this often as a safety net against a non-fd−32
;; source we do not model. 1s => ~1Hz idle, negligible CPU. An event on any−33
;; GLib fd still wakes the loop immediately, so this is not input latency.−34
(define idle-cap-ms 1000)−35
−36
;; Run the app's event loop. `goroutines` are zero-argument thunks started−37
;; alongside the pump inside the same async scope (e.g. the bridge−38
;; dispatcher). Returns 0 when a quit is requested.+30
;; Run the app's event loop. `after-pump` is a thunk (or #f) run once after+31
;; every native pump iteration - the app uses it to drain the bridge queue+32
;; the pump just filled and to act on a quit request. `goroutines` are+33
;; zero-argument thunks started alongside the pump in the same async scope+34
;; (plugin service pumps, which block on channel-receive). Returns 0 when a+35
;; quit is requested. 36
;;−40
;; The pump drains GLib non-blocking, then BLOCKS on the GMainContext's poll−41
;; fds (await-readable-fds) until an event arrives or GLib's requested timeout−42
;; elapses, instead of waking every 5ms. Idle CPU drops from a constant−43
;; ~200Hz poll to ~0, while input still wakes the loop instantly. A backend−44
;; that cannot report its fds falls back to the interval poll.−45
(define (run-loop wv . goroutines)+37
;; The pump IS the main task, not a `go` beside a polling wait loop: it+38
;; blocks in the backend's BLOCKING pump (one native iteration that sleeps on+39
;; its poll fds through the scheduler until an event or its own timeout),+40
;; services `after-pump`, then loops until quit. Nothing here polls, so an+41
;; idle app truly sleeps (~0% CPU) like a normal GTK app. A backend without a+42
;; blocking pump falls back to drain + a short interval sleep.+43
(define (run-loop wv after-pump . goroutines) 44
(with-async 45
(for-each (lambda (thunk) (go (thunk))) goroutines)−48
;; The GLib pump.−49
(go (let loop ()−50
(webview-pump-once wv)−51
(let ((q (webview-query-fds wv)))−52
(if (and q (pair? (car q)))−53
(let* ((fds (car q))−54
(glib-ms (cdr q))−55
;; -1 (no timer deadline) => idle cap; else GLib's own−56
;; requested timeout, capped so we never oversleep it.−57
(cap (cond ((< glib-ms 0) idle-cap-ms)−58
((< glib-ms idle-cap-ms) glib-ms)−59
(else idle-cap-ms))))−60
(await-readable-fds fds timeout-ms: cap))−61
;; No fds reported (or backend can't): interval poll.−62
(sleep default-pump-interval)))−63
(unless (webview-should-quit? wv) (loop))))−64
;; Main task: idle until quit, then fall out of with-async.−65
(let wait ()−66
(sleep 0.02)−67
(unless (webview-should-quit? wv) (wait))))+46
(let loop ()+47
(unless (webview-pump-blocking wv)+48
(webview-pump-once wv)+49
(sleep default-pump-interval))+50
(when after-pump (after-pump))+51
(unless (webview-should-quit? wv) (loop)))) 52
0)))lantern/src/lantern/webview.sglmodified
@@ -49,7 +49,7 @@
49
webview-set-close-handler 50
webview-window-op 51
webview-pump-once−52
webview-query-fds+52
webview-pump-blocking 53
webview-get-title 54
webview-should-quit? 55
webview-quit)@@ -117,13 +117,14 @@
117
(apply (op wv window-op:) window op-name args)) 118
(define (webview-pump-once wv) 119
((op wv pump-once:)))−120
;; Optional op: a backend that can expose its event-loop poll fds returns−121
;; (fds . timeout-ms) so the run-loop can BLOCK on them (await-readable-fds)−122
;; instead of polling. Returns #f when the backend does not implement it, so−123
;; the run-loop falls back to interval polling.−124
(define (webview-query-fds wv)−125
(let ((f (dict-ref wv query-fds: #f)))−126
(and f (f))))+120
;; Optional op: run ONE blocking iteration of the backend's native event+121
;; loop (drain + sleep on its poll fds until an event or its own timeout),+122
;; yielding to the scheduler while it waits. Returns #t if it ran, #f when+123
;; the backend does not implement it, so the run-loop falls back to the+124
;; drain-then-interval-sleep pump.+125
(define (webview-pump-blocking wv)+126
(let ((f (dict-ref wv pump-blocking: #f)))+127
(and f (begin (f) #t)))) 128
(define (webview-get-title wv window) 129
((op wv get-title:) window)) 130
(define (webview-should-quit? wv)