Lua Runtime Overview¶
This section explains how EasyBar runs Lua widgets internally.
It is for contributors. For the public widget API, see Lua Widgets.
Overview¶
EasyBar does not embed Lua in-process.
It starts a separate Lua process and communicates with it over a dedicated Unix socket, while keeping stderr reserved for logs.
That gives the project:
- crash isolation
- simpler reloads
- clean widget state reset on restart
- plain JSON transport between Swift and Lua
- transport isolation from process logs
High-level flow¶
flowchart TD
Start["Swift starts the Lua runtime process"]
Load["Lua loads widget files"]
Subscriptions["Lua reports required driver events"]
Sources["Swift starts required native event sources"]
Events["Swift sends normalized events over the Lua socket"]
State["Lua updates widget state"]
Trees["Lua emits rendered trees"]
Store["Swift updates WidgetStore"]
Start --> Load
Load --> Subscriptions
Subscriptions --> Sources
Sources --> Events
Events --> State
State --> Trees
Trees --> Store
- Swift starts the Lua runtime process.
- Lua loads every widget file from the widget directory.
- Lua reports which driver events it needs.
- Swift starts only those event sources.
- Swift sends normalized events to Lua as JSON lines over the Lua socket.
EasyBarLuaRuntimeconnects that socket and then execs Lua, so the Lua runtime still speaks line I/O while Swift owns the socket lifecycle.- Lua updates widget state and emits rendered trees as JSON lines over that same socket.
- Swift decodes those trees and updates the UI store.
Main Swift pieces¶
LuaProcessController.swiftstarts and stops the Lua processLuaTransport.swiftowns the dedicated Lua socket plus stderr log handlingEasyBarLuaRuntimeconnects the configured Lua socket and then execs the Lua interpreterLuaLogBridge.swiftconverts structured Lua stderr lines into normal Swift logsLuaRuntime.swiftsmall facade over process and socket transportWidgetEngine.swiftowns the runtime handshake, subscriptions, tree updates, and routing for command and timer requestsLuaCommandService.swiftandLuaCommandRunner.swiftexecute bounded shell commands or direct argument vectors and return structured resultsLuaTimerService.swiftowns cancellable one-shot timers without consuming command slotsEventHub.swiftsends app and widget events to both Swift listeners and LuaEventManager.swiftstarts only the native event sources Lua actually subscribed toRuntimeCoordinator.swiftowns startup, shutdown, reload, file watching, and socket-command orchestrationWidgetStore.swiftstores the latest rendered node trees
Main Lua pieces¶
runtime.luaruntime bootstrap and main loop over socket-backed stdin/stdoutloader.luaconfigures user module paths and loads top-level widget files into per-file environments that still fall back to_Gapi.luapubliceasybarAPI, node handles, and registry bridgeregistry.luastores node state and applies property normalizationsubscriptions.luaowns node subscriptions and interval callbacksevents.luanormalizes raw payloads and dispatches themrender.luaconverts registry state into flat node treesjson.luasmall JSON encoder/decoderlog.luastructured stderr logging
Trust model¶
The Lua runtime is isolated as a separate process, but widget code is still trusted code.
Per-file widget environments help keep locals and defaults separate between widget files. They do
not sandbox execution, because the environment falls back to _G. Any widget file you load should
be treated like any other local script you chose to execute on your machine.
Host-owned asynchronous primitives¶
The Lua process remains blocked on its transport read loop when idle. It therefore delegates both external process execution and one-shot scheduling to Swift:
command_requestcarries either a shellcommandor a directargumentsarray.command_cancelcancels the active process group for one asynchronous command token.timer_requestschedules a one-shot host timer withdelay_seconds. Command and timer tokens are nonempty, contain no control characters, and are limited to 256 UTF-8 bytes.timer_cancelremoves a pending host timer.- Swift sends
command_response,timer_fired, ortimer_rejectedback to Lua, which dispatches or releases the stored callback and flushes any resulting tree mutations.
This keeps retries and backoff orchestration in Lua while process lifecycle, PATH resolution, timeouts, output limits, and scheduling remain host-owned.
Event delivery backpressure¶
Lua event delivery keeps at most 512 queued must-deliver actions and 128 coalescing state entries. The state queue replaces older values by event and target. The action queue never silently evicts an action: reaching its hard limit records luaEventQueueOverflows, clears and suspends the failed session queue, and restarts the Lua child. A fresh runtime session resets delivery and begins with an empty queue.
Runtime input backpressure¶
The host accepts at most 256 complete Lua protocol lines waiting for actor-side processing. A full queue is treated as an unhealthy runtime rather than silently dropping an ordered protocol message. EasyBar records luaRuntimeInputOverflows, terminates the current child as an unexpected failure, and restarts it through normal bounded-backoff supervision.
Scheduling and retry architecture¶
system_woke remains an immediate event. EasyBar does not delay it globally because widgets that do
not depend on network recovery may need to react immediately.
Network-dependent widgets schedule their own settling delay with easybar.after(...). Their retry
policy stays in Lua, while every delay and external process remains owned by the Swift host.
flowchart TD
Wake["system_woke event"]
WidgetDelay["Widget schedules easybar.after(3, refresh)"]
TimerRequest["Lua sends timer_request"]
HostTimer["LuaTimerService schedules host timer"]
TimerFired["Swift sends timer_fired"]
Retry["retry.run starts refresh attempt"]
Spawn["easybar.spawn_async(arguments, options, callback)"]
CommandRequest["Lua sends command_request with arguments"]
CommandRunner["LuaCommandRunner starts direct process"]
Result{"Command succeeded?"}
Publish["Widget decodes and publishes data"]
Transient{"Transient failure and delays remain?"}
Backoff["retry.run schedules next backoff delay"]
Failure["Widget publishes final error"]
Cancel["Widget cancels RetryOperation"]
CancelTimer["timer_cancel"]
CancelCommand["command_cancel"]
Wake --> WidgetDelay
WidgetDelay --> TimerRequest
TimerRequest --> HostTimer
HostTimer --> TimerFired
TimerFired --> Retry
Retry --> Spawn
Spawn --> CommandRequest
CommandRequest --> CommandRunner
CommandRunner --> Result
Result -- Yes --> Publish
Result -- No --> Transient
Transient -- Yes --> Backoff
Backoff --> TimerRequest
Transient -- No --> Failure
Retry -. returns cancellable operation .-> Cancel
Cancel --> CancelTimer
Cancel --> CancelCommand
The layers have deliberately narrow responsibilities:
| Layer | Responsibility |
|---|---|
| Widget | Chooses when to refresh and how to present the final result. |
retry.lua |
Decides whether to retry and selects the next backoff delay. |
easybar.after(...) |
Requests a cancellable, non-blocking one-shot timer. |
easybar.spawn_async(...) |
Requests direct executable invocation without shell parsing. |
| Lua runtime | Tracks callbacks and routes timer and command responses. |
LuaTimerService |
Owns pending host timers and timer cancellation. |
LuaCommandService |
Enforces command concurrency and routes execution requests. |
LuaCommandRunner |
Resolves executables, starts process groups, captures output, and enforces limits. |
A typical network-backed inbox refresh follows this sequence:
system_wokeis delivered immediately.- The widget schedules a short delayed refresh.
retry.run(...)starts the first read-only request.easybar.spawn_async(...)runsgh,glab, orbrewwithout a shell.- Successful output is decoded and published.
- A transient network failure schedules another attempt after the configured backoff.
- Authentication, parsing, configuration, and other permanent failures are returned immediately.
- Cancelling the operation stops either its pending timer or active process.
Only idempotent reads should use automatic retries. Commands that acknowledge notifications, update Homebrew metadata, install packages, or otherwise mutate state remain one-shot unless repeating them is proven safe.
Why direct process execution is preferred¶
easybar.spawn_async(...) passes an argument vector directly to the executable. It avoids shell
quoting, interpolation, wildcard expansion, and command substitution.
easybar.spawn_async({
"gh",
"api",
"--paginate",
"notifications?all=false&per_page=100",
}, {}, callback)
Use easybar.exec_async(...) only when a command genuinely requires shell behavior such as pipes,
redirection, command substitution, or a compound script.
Keeping retry policy outside the command string means:
- retry state remains visible to Lua
- pending delays can be cancelled
- retries do not consume command slots while waiting
- transient and permanent failures can be classified separately
- mutation commands are not accidentally repeated
- external commands remain simple, single-attempt operations
See Commands for the public API behavior and Reusable Modules for the bundled retry helper.