# bread — Lua Runtime Architecture ### The Bread Scripting and Automation Layer --- ## Overview The Lua runtime is the automation half of Bread. Where `breadd` maintains truth about the desktop, the Lua layer decides what to do about it. Modules written in Lua subscribe to events, read state, execute shell commands, and activate profiles. The entire scripting surface is exposed through a single `bread.*` global API — stable, versioned, and designed to be hostile to accidents. The runtime lives on a dedicated OS thread inside `breadd`. It is reachable from the async side only through a bounded message channel. Lua never touches sockets, sysfs, or compositor IPC directly. Everything flows through the daemon. --- ## Phase 1 — Runtime Core These capabilities exist in the codebase today. Phase 1 is the foundation the Lua runtime is built on. ### Daemon Stability `breadd` is a single long-running Rust process. It survives compositor restarts, module load errors, and Lua runtime panics. The daemon never terminates because a Lua file has a syntax error. The Lua runtime thread is spawned once at startup: ```rust std::thread::Builder::new() .name("breadd-lua".to_string()) .spawn(move || { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("failed to create lua runtime thread"); rt.block_on(async move { let mut engine = LuaEngine::new(config, state_handle, emit_tx)?; engine.reload_internal()?; while let Some(msg) = rx.recv().await { match msg { /* ... */ } } }); })?; ``` If the initial module load fails, the daemon enters degraded mode: no Lua handlers are active, but the daemon itself remains alive. IPC stays responsive and `bread reload` can be used to recover after the user fixes their config. ### Event Ingestion Every signal that enters `breadd` from an external system flows through a strict pipeline before it reaches Lua: ``` External System (Hyprland / udev / power / network) │ ▼ Adapter — raw ingestion, owns the connection │ RawEvent ▼ Normalizer — semantic interpretation │ BreadEvent ▼ State Engine — state update + fan-out │ ├──► RuntimeState (updated atomically) └──► Subscription Dispatcher │ BreadEvent (per subscriber) ▼ Lua Runtime (module handlers) ``` Raw events never reach Lua directly. Lua never observes a `RawEvent` — it only ever sees a normalized `BreadEvent` with a stable namespace string like `bread.device.dock.connected`. ### Subscriptions Modules subscribe to events by pattern. The subscription table maps pattern strings to `(SubscriptionId, is_once)` pairs. The state engine evaluates each incoming `BreadEvent` against the table and dispatches to every matching subscriber. ```rust pub struct SubscriptionId(pub u64); ``` The Lua side registers subscriptions via `bread.on` and `bread.once`. Each call allocates a monotonically increasing `SubscriptionId`, stores the callback in the Lua registry, and registers the pattern with the state engine: ```lua bread.on("bread.device.dock.*", function(event) bread.exec("~/.config/bread/scripts/dock.sh") end) bread.once("bread.system.startup", function(event) bread.profile.activate("default") end) ``` `bread.once` subscriptions are automatically cancelled after first delivery. The handler is removed from both the Lua registry and the subscription table. ### IPC `breadd` exposes a Unix domain socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. The protocol is newline-delimited JSON. All IPC requests that affect the Lua runtime route through the `LuaMessage` channel — IPC never touches the Lua thread directly. Relevant IPC methods: | Method | Description | |--------|-------------| | `modules.list` | List loaded modules and their status | | `modules.reload` | Trigger a hot reload of the Lua layer | | `emit` | Inject a synthetic `BreadEvent` into the pipeline | | `state.get` | Read a value from `RuntimeState` by key path | | `state.dump` | Return the full `RuntimeState` as JSON | The `emit` method is particularly useful for testing: it allows injecting arbitrary `BreadEvent`s without needing the real hardware event that would normally produce them. ### Hot Reload Hot reload is a first-class feature. The daemon persists; the Lua layer restarts. No process restart required. Reload sequence: ``` bread reload (CLI) │ ▼ IPC: modules.reload │ ▼ StateEngine: pause event dispatch to Lua │ ▼ LuaRuntime: receive Reload message │ ├── cancel all active subscriptions ├── clear handler registry ├── drop Lua instance (all state cleared) ├── create fresh Lua instance ├── re-register bread.* API ├── re-evaluate init.lua and all modules └── re-register subscriptions with SubscriptionTable │ ▼ StateEngine: resume event dispatch │ ▼ IPC: reload complete response ``` If any module fails to load during reload, the reload aborts and the daemon enters degraded mode. There is no rollback — the previous Lua state was dropped before the reload began. This is intentional for V1. A syntax error in a module produces a clear error message from `bread reload`, and the daemon stays alive. ### State Registry The daemon maintains a live `RuntimeState` behind an `Arc>`. It is the authoritative record of what is true about the desktop right now. ```rust pub struct RuntimeState { pub monitors: Vec, pub workspaces: Vec, pub active_workspace: Option, pub active_window: Option, pub devices: DeviceTopology, pub network: NetworkState, pub power: PowerState, pub profile: ProfileState, pub modules: Vec, } ``` Lua accesses this via `bread.state.get(path)`. The call takes a brief read lock, serializes the requested subtree to JSON, and converts it to a Lua value. Lua never holds the lock — the lock is dropped before control returns to the Lua callback: ```lua local monitors = bread.state.get("monitors") local power = bread.state.get("power") local active = bread.state.get("active_workspace") ``` Dotted paths are supported for nested access: ```lua local online = bread.state.get("network.online") ``` State is read-only from Lua. Lua cannot write to `RuntimeState` directly — it can only influence state indirectly by activating a profile or emitting an event that the state engine processes. --- ## Phase 2 — Lua Runtime Phase 2 covers what is not yet built: the features required to make the Lua layer a complete, ergonomic automation platform. ### Module Loader Currently, `breadd` loads modules by scanning `~/.config/bread/modules/` and executing every `.lua` file in sorted order. There is no concept of module identity, exports, or dependency declarations. Phase 2 introduces a proper module system: ``` ~/.config/bread/ ├── init.lua ← entry point; declares module list └── modules/ ├── dock.lua ├── display.lua ├── power.lua └── lib/ └── utils.lua ← shared library, loaded on require ``` **`bread.module` declaration** — each module declares itself at the top of the file: ```lua local M = bread.module({ name = "dock", version = "1.0.0", after = { "display" }, -- load after display.lua }) ``` The runtime resolves the dependency graph and loads modules in topological order. Circular dependencies are detected at load time and reported as a load error on the offending module. **`require` support** — modules in `lib/` are loadable via `require`: ```lua local utils = require("bread.lib.utils") ``` The module loader intercepts `require` calls that begin with `bread.` and resolves them relative to `~/.config/bread/`. Standard Lua `require` semantics apply for everything else. **Module status tracking** — each module's load state is reflected in `RuntimeState.modules` and visible via `bread doctor` and `modules.list`: ```rust pub enum ModuleLoadState { Loaded, LoadError, NotFound, } ``` Phase 2 extends this with `Degraded` (loaded but encountered a runtime error since last reload) and `Disabled` (explicitly disabled in config). ### Lifecycle Hooks Currently, modules have no way to run code at load time or cleanup code at unload time. Phase 2 adds four lifecycle hooks. ```lua function M.on_load() -- called once when the module is first loaded -- register subscriptions, initialize module state end function M.on_reload() -- called after a hot reload completes -- re-apply any external side effects the module manages end function M.on_unload() -- called before the Lua instance is dropped -- cancel external resources, write state if needed end function M.on_error(err) -- called when a subscription handler in this module throws -- return true to keep the subscription, false to cancel it end ``` The runtime calls hooks in a defined order: - **Load**: `on_load` is called after the module file executes successfully, in dependency order. - **Reload**: `on_unload` is called in reverse dependency order. After the new Lua instance is ready, `on_load` runs on every module. `on_reload` runs after all `on_load` calls complete. - **Error**: `on_error` is called on the Lua thread immediately after a handler throws. If the module does not define `on_error`, the default behavior is to log the error and keep the subscription alive. All hooks are optional. A module with no lifecycle hooks continues to work exactly as it does today. ### Event APIs Phase 2 expands the event surface available to Lua modules. **Pattern syntax** — the current subscription API matches event names against patterns using glob-style `*` wildcards. Phase 2 adds `**` for recursive matching and `?` for single-character wildcards: ```lua bread.on("bread.device.*", handler) -- matches bread.device.dock.connected bread.on("bread.device.**", handler) -- matches any depth under bread.device bread.on("bread.monitor.?", handler) -- single-segment wildcard ``` **`bread.off`** — cancel a subscription by the ID returned from `bread.on`: ```lua local id = bread.on("bread.power.*", handler) -- later: bread.off(id) ``` **`bread.wait`** — yield until a matching event arrives, with an optional timeout: ```lua local event = bread.wait("bread.device.dock.connected", { timeout = 5000 }) if event then -- dock arrived within 5 seconds end ``` `bread.wait` is syntactic sugar over a `bread.once` subscription combined with a coroutine yield. It can only be used inside a coroutine context; calling it from a top-level module body is a load error. **`bread.filter`** — attach a predicate to a subscription. The handler is only called when the predicate returns true: ```lua bread.on("bread.device.*", handler, { filter = function(event) return event.data.class == "dock" end }) ``` **Timers** — schedule callbacks without relying on an external timer process: ```lua local id = bread.after(500, function() -- called once, 500ms from now end) local id = bread.every(30000, function() -- called every 30 seconds end) bread.cancel(id) -- cancel either kind ``` Timers are cancelled automatically on reload. A module does not need to track its own timer IDs for cleanup. ### State Access Phase 2 extends `bread.state` from a read-only snapshot query into a richer interface. **Typed helpers** — convenience wrappers for the most common state subtrees: ```lua bread.state.monitors() -- Vec bread.state.active_workspace() -- string | nil bread.state.active_window() -- string | nil bread.state.devices() -- Vec bread.state.power() -- PowerState bread.state.network() -- NetworkState bread.state.profile() -- ProfileState ``` These are thin wrappers over `bread.state.get` — they add no locking overhead. **Reactive state** — watch a state path for changes and receive a callback when it changes: ```lua bread.state.watch("power.ac_connected", function(new_val, old_val) if new_val then bread.exec("notify-send 'AC connected'") end end) ``` State watches are implemented as synthetic subscriptions: the state engine compares the watched path before and after each `RuntimeState` update and synthesizes a `bread.state.changed.` event when a difference is detected. From the Lua runtime's perspective, watches are ordinary subscriptions. **Module-scoped storage** — a key-value store persisted across reloads (but not across daemon restarts): ```lua M.store.set("last_profile", "docked") local p = M.store.get("last_profile") -- "docked" ``` Storage is scoped per module. A module cannot read another module's store. The store is backed by a `HashMap` in the `RuntimeState.modules` entry for that module, so it survives hot reload. ### Hyprland Bindings Phase 2 exposes a `bread.hyprland` namespace for direct interaction with the Hyprland compositor. This is the only place in the Lua API that is compositor-specific; all other APIs are compositor-agnostic. The bindings communicate over Hyprland's IPC request socket (`$HYPRLAND_INSTANCE_SIGNATURE/.socket.sock`), not the event socket. Calls are dispatched to a Tokio task on the async side and awaited transparently from Lua via coroutine suspension. **Dispatch** ```lua bread.hyprland.dispatch("workspace", "2") bread.hyprland.dispatch("movetoworkspace", "2,address:0x...") bread.hyprland.dispatch("exec", "kitty") ``` **Keyword** ```lua local result = bread.hyprland.keyword("monitor", "HDMI-A-1, 2560x1440, 0x0, 1") ``` **Active window** ```lua local win = bread.hyprland.active_window() -- { address, title, class, workspace, monitor, ... } ``` **Monitor and workspace queries** ```lua local monitors = bread.hyprland.monitors() local workspaces = bread.hyprland.workspaces() local clients = bread.hyprland.clients() ``` All calls return deserialized Lua tables matching Hyprland's JSON response shape. Errors from the compositor (malformed dispatch, unknown keyword) are surfaced as Lua errors catchable with `pcall`. **Hyprland-specific events** — the existing `bread.monitor.*` and `bread.workspace.*` event namespaces already cover the most common Hyprland signals. The Phase 2 bindings add lower-level passthrough for events that do not yet have a normalized `BreadEvent` representation: ```lua bread.hyprland.on_raw("activewindow", function(raw) -- raw is the unparsed string from Hyprland's event socket end) ``` Raw subscriptions bypass normalization. They are intended for power users and for features not yet covered by the normalized event namespace. Once a raw event pattern is common enough, it graduates to a stable `BreadEvent` and the raw subscription is deprecated. --- ## Lua ↔ Rust Boundary All calls across the boundary go through `mlua`'s safe API. Rust functions registered as Lua globals return `mlua::Result` and handle their own error mapping. Panics inside registered Rust functions are caught by mlua and converted to Lua errors — they do not unwind into the Lua thread and they do not crash the daemon. The `LuaMessage` enum is the only channel between the async Tokio runtime and the Lua thread: ```rust pub enum LuaMessage { Event { subscription_id: SubscriptionId, event: BreadEvent, }, SubscriptionCancelled { id: SubscriptionId, }, Reload { reply: oneshot::Sender>, }, Shutdown, } ``` Lua is not `Send`. The `LuaEngine` and the `Lua` instance live exclusively on the dedicated Lua OS thread. The async side communicates only by sending `LuaMessage` values through the channel — it never holds a reference to anything inside the Lua VM. --- ## Error Isolation ### Handler errors Lua errors during event handler execution are caught with `pcall` at the Rust boundary: ```rust fn handle_event(&self, id: SubscriptionId, event: BreadEvent) -> Result<()> { let callback: Function = self.lua.registry_value(reg)?; let event_value = self.lua.to_value(&event)?; if let Err(err) = callback.call::<_, ()>(event_value) { error!(subscription = id.0, error = %err, "lua callback failed"); } Ok(()) } ``` The error is logged with the subscription ID and full Lua stack trace. The handler remains registered and will fire again on the next matching event. A persistently failing handler is the module's responsibility to cancel via `bread.off`. Phase 2's `on_error` hook gives modules a structured way to respond to handler failures rather than relying solely on the daemon log. ### Module load errors Errors during module load are fatal to that module but not to the daemon or to other modules. The failed module is marked `LoadError` in `RuntimeState.modules`. Remaining modules continue loading in dependency order; only modules that declared `after` the failed module are also skipped (their dependency is broken). ### Degraded mode If the initial load or a hot reload fails such that no Lua instance is running, the daemon enters degraded mode: - No Lua handlers are active. - IPC remains fully operational. - `bread reload` can be retried after the user fixes their config. - `bread doctor` reports the load error with the full stack trace. The daemon never requires a full restart to recover from a Lua error. --- ## `bread.*` API Surface Summary ### Phase 1 (implemented) | Function | Description | |----------|-------------| | `bread.on(pattern, fn)` | Subscribe to a pattern; returns subscription ID | | `bread.once(pattern, fn)` | Subscribe once; auto-cancelled after first delivery | | `bread.emit(event, payload)` | Inject a synthetic `BreadEvent` | | `bread.exec(cmd)` | Fire-and-forget shell command | | `bread.state.get(path)` | Read a value from `RuntimeState` by dotted path | | `bread.profile.activate(name)` | Activate a named profile | ### Phase 2 (planned) | Function | Description | |----------|-------------| | `bread.off(id)` | Cancel a subscription by ID | | `bread.wait(pattern, opts)` | Yield until a matching event arrives | | `bread.filter(pattern, fn, opts)` | Subscribe with a predicate guard | | `bread.after(ms, fn)` | One-shot timer | | `bread.every(ms, fn)` | Repeating timer | | `bread.cancel(id)` | Cancel a timer | | `bread.state.watch(path, fn)` | React to state changes at a path | | `bread.state.monitors()` | Typed shorthand for `bread.state.get("monitors")` | | `bread.state.power()` | Typed shorthand for `bread.state.get("power")` | | `bread.state.network()` | Typed shorthand for `bread.state.get("network")` | | `bread.hyprland.dispatch(cmd, args)` | Send a Hyprland dispatch | | `bread.hyprland.keyword(key, val)` | Set a Hyprland keyword | | `bread.hyprland.active_window()` | Query the active window | | `bread.hyprland.monitors()` | Query all monitors | | `bread.hyprland.workspaces()` | Query all workspaces | | `bread.hyprland.clients()` | Query all open clients | | `bread.hyprland.on_raw(event, fn)` | Subscribe to a raw Hyprland event string | | `bread.module(decl)` | Declare a module with name, version, and dependencies | | `M.store.get(key)` | Read from module-scoped persistent storage | | `M.store.set(key, val)` | Write to module-scoped persistent storage | --- ## Summary The Lua runtime is where Bread becomes useful. The daemon provides a reliable, normalized view of the desktop; the Lua layer acts on it. Phase 1 delivers the mechanical minimum: a stable thread, a working `bread.*` API, event subscriptions, state access, hot reload, and IPC. That foundation is in the codebase today. Phase 2 builds the ergonomics: module identity, lifecycle hooks, reactive state, timers, richer event APIs, and Hyprland control bindings. Each Phase 2 feature is additive — nothing in Phase 1 needs to change to support it. The boundary between Rust and Lua is intentionally narrow. The daemon knows nothing about what modules do. Modules know nothing about how events arrive. The `bread.*` API is the entire contract between them.