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)