Commitf8cd8e66Recorded20 Jul 2026Repositorylantern
lantern-app: add services: hook for background goroutines
Message
Apps can declare services: — one-arg procedures (lambda (ctx) ...) run as goroutines INSIDE the loop's async scope, so they may freely go/await (unlike on-event 'ready, which fires before run-loop establishes the scheduler). Each service gets a ctx carrying wv:/window:/registry:/app:/emit:/emit-bulk, so a resident host service (a control socket, a file watcher, a timer) can both reach the page and emit events. First consumer: the slate-client control socket.
Framework tests unchanged (39 pass).
Changed
lantern/src/lantern/app.sgl | 43 +++++++++++++++++++++++++++++++++++--------
1 file changed, 35 insertions(+), 8 deletions(-)Diff
lantern/src/lantern/app.sglmodified
@@ -56,11 +56,22 @@
56
;; can prompt (e.g. save unsaved work) and then call `app.quit` itself. Default 57
;; #f keeps the plain close-is-exit behavior. (Requires a backend that can veto 58
;; its window close; degrades to the default on backends that cannot.)+59
;; `services:` are background goroutines the app runs FOR ITS WHOLE LIFETIME,+60
;; started INSIDE the loop's async scope (unlike `on-event 'ready`, which fires+61
;; before run-loop establishes the scheduler, so a service may freely `go` /+62
;; `await-*`). Each service is a one-arg procedure `(lambda (ctx) ...)` where+63
;; ctx carries wv:/window:/registry:/app: + emit:/emit-bulk: (same shape a+64
;; command handler gets), so a service can both reach the page (webview-eval-js+65
;; wv window …) and emit events. This is the seam a resident host service — a+66
;; control socket, a file watcher, a timer — plugs into. A service thunk that+67
;; returns simply ends that goroutine; one that loops runs until the window+68
;; quits (the quit-watcher exits the process out from under it). 69
(define (lantern-app (keys: (name "lantern-app") 70
(assets "./dist") 71
(modules (list)) 72
(commands (list)) 73
(plugins (list))+74
(services (list)) 75
(on-event #f) 76
(intercept-close #f))) 77
(dict lantern/app: #t@@ -69,6 +80,7 @@
80
modules: modules 81
commands: commands 82
plugins: plugins+83
services: services 84
on-event: on-event 85
intercept-close: intercept-close)) 86
@@ -225,6 +237,19 @@
237
(let ((on-event (dict-ref app on-event: #f))) 238
(when (procedure? on-event) (on-event 'ready (dict wv: wv window: win)))) 239
+240
;; App services (§ lantern-app services:) run as goroutines inside the+241
;; loop's async scope, each handed a ctx like a command handler's (wv/+242
;; window for page access, emit/emit-bulk for events). Bound here so they+243
;; start alongside the bridge dispatcher under run-loop's with-async.+244
(let* ((make-service-thunk+245
(lambda (svc)+246
(lambda ()+247
(svc (dict wv: wv window: win registry: registry app: app+248
emit: (lambda (event payload) (bridge-emit bridge event payload))+249
emit-bulk: (lambda (event meta b64) (bridge-emit-bulk bridge event meta b64)))))))+250
(service-thunks+251
(map make-service-thunk (dict-ref app services: (list)))))+252
253
;; Hand the thread to the Option A loop; the bridge dispatcher drains 254
;; the queue and runs each command in its own goroutine (may await). 255
;;@@ -239,11 +264,13 @@
264
;; a window app's close IS its exit (kernel cleanup closes pty 265
;; masters, so child shells get SIGHUP). Teardown-on-exit quirks are 266
;; cosmetic (see the framework note); a live headless zombie is not.−242
(run-loop wv−243
(bridge-dispatcher bridge)−244
(lambda ()−245
(let watch ()−246
(sleep 0.05)−247
(if (webview-should-quit? wv)−248
(begin (bridge-stop! bridge) (exit 0))−249
(watch))))))))))+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)))))))))))))