Release 1.0
This commit is contained in:
parent
009ea6da0e
commit
730a8b61d7
32 changed files with 6629 additions and 0 deletions
503
DAEMON.md
Normal file
503
DAEMON.md
Normal file
|
|
@ -0,0 +1,503 @@
|
|||
# breadd — Daemon Architecture
|
||||
### The Bread Runtime Daemon
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
`breadd` is the long-running Rust daemon at the center of Bread. It is the canonical source of truth for all desktop runtime state: what hardware is connected, what the compositor is doing, what profile is active, and what events have occurred.
|
||||
|
||||
Everything else in Bread — Lua modules, the CLI, profile logic, automation behavior — exists as a consumer of what `breadd` tracks and exposes. The daemon is the foundation.
|
||||
|
||||
`breadd` does not implement automation. It makes automation possible.
|
||||
|
||||
---
|
||||
|
||||
## Responsibilities
|
||||
|
||||
At a high level, `breadd` is responsible for six things:
|
||||
|
||||
1. **Adapter management** — spawn and supervise connections to external systems (Hyprland IPC, udev, power, network)
|
||||
2. **Event ingestion** — receive raw signals from adapters and push them into the pipeline
|
||||
3. **Event normalization** — transform raw signals into stable, semantic Bread events
|
||||
4. **State maintenance** — keep a live, structured model of the desktop
|
||||
5. **Subscription dispatch** — deliver normalized events to Lua module subscribers
|
||||
6. **IPC** — expose runtime state and control to the CLI and external consumers
|
||||
|
||||
The daemon does not decide what to do when events occur. That is Lua's job. The daemon decides what is true about the system, and tells Lua about it.
|
||||
|
||||
---
|
||||
|
||||
## Process Model
|
||||
|
||||
`breadd` is a single long-running process started at login (via systemd user service or similar). It runs for the duration of the session.
|
||||
|
||||
```
|
||||
breadd (main process)
|
||||
├── Adapter threads
|
||||
│ ├── HyprlandAdapter (async task — IPC socket reader)
|
||||
│ ├── UdevAdapter (async task — netlink listener)
|
||||
│ ├── PowerAdapter (async task — sysfs / UPower watcher)
|
||||
│ └── NetworkAdapter (async task — netlink / D-Bus watcher)
|
||||
├── State Engine (async task — central coordinator)
|
||||
├── Lua Runtime (dedicated thread — Lua is not Send)
|
||||
├── IPC Server (async task — Unix socket listener)
|
||||
└── Watcher (async task — config file watcher, optional)
|
||||
```
|
||||
|
||||
The daemon uses Tokio as its async runtime. Most work is non-blocking and event-driven. The Lua runtime runs on a dedicated OS thread because Lua's C bindings are not `Send`-safe; it communicates with the async side through a bounded channel.
|
||||
|
||||
---
|
||||
|
||||
## Internal Event Pipeline
|
||||
|
||||
Every signal that enters `breadd` flows through the same pipeline before it reaches a Lua module:
|
||||
|
||||
```
|
||||
External System
|
||||
│
|
||||
▼
|
||||
Adapter
|
||||
(raw ingestion)
|
||||
│
|
||||
│ RawEvent
|
||||
▼
|
||||
Normalizer
|
||||
(semantic interpretation)
|
||||
│
|
||||
│ BreadEvent
|
||||
▼
|
||||
State Engine
|
||||
(state update + fan-out)
|
||||
│
|
||||
├──► State Store (updated)
|
||||
│
|
||||
└──► Subscription Dispatcher
|
||||
│
|
||||
│ BreadEvent (per subscriber)
|
||||
▼
|
||||
Lua Runtime
|
||||
(module handlers)
|
||||
```
|
||||
|
||||
No step is skipped. Raw events never reach Lua directly. Lua never reads from sysfs or a compositor socket directly. The pipeline enforces clean separation between "what the system said" and "what it means."
|
||||
|
||||
---
|
||||
|
||||
## Core Data Structures
|
||||
|
||||
### RawEvent
|
||||
|
||||
A `RawEvent` is what an adapter produces. It is uninterpreted — it contains only what the external system reported.
|
||||
|
||||
```rust
|
||||
pub struct RawEvent {
|
||||
pub source: AdapterSource, // Hyprland | Udev | Power | Network
|
||||
pub kind: String, // raw event type string from the source
|
||||
pub payload: serde_json::Value, // raw data, source-specific shape
|
||||
pub timestamp: u64, // unix milliseconds
|
||||
}
|
||||
```
|
||||
|
||||
### BreadEvent
|
||||
|
||||
A `BreadEvent` is what the normalizer produces. It is stable, versioned, and typed.
|
||||
|
||||
```rust
|
||||
pub struct BreadEvent {
|
||||
pub event: String, // "bread.device.dock.connected"
|
||||
pub timestamp: u64,
|
||||
pub source: AdapterSource,
|
||||
pub data: serde_json::Value, // normalized, structured payload
|
||||
}
|
||||
```
|
||||
|
||||
The `event` field follows the namespace convention `bread.<subsystem>.<noun>.<verb>`. This string is stable across Bread versions; modules can rely on it without breaking.
|
||||
|
||||
### RuntimeState
|
||||
|
||||
The `RuntimeState` is the daemon's live model of the desktop. It is updated atomically as events arrive.
|
||||
|
||||
```rust
|
||||
pub struct RuntimeState {
|
||||
pub monitors: Vec<Monitor>,
|
||||
pub workspaces: Vec<Workspace>,
|
||||
pub active_workspace: Option<WorkspaceId>,
|
||||
pub active_window: Option<WindowId>,
|
||||
pub devices: DeviceTopology,
|
||||
pub network: NetworkState,
|
||||
pub power: PowerState,
|
||||
pub profile: ProfileState,
|
||||
pub modules: Vec<ModuleStatus>,
|
||||
}
|
||||
```
|
||||
|
||||
State is stored behind an `Arc<RwLock<RuntimeState>>`. Readers (IPC, Lua state queries) take a read lock briefly. The state engine holds the write lock only during update. Contention is minimal because updates are infrequent relative to query frequency.
|
||||
|
||||
---
|
||||
|
||||
## Adapters
|
||||
|
||||
Each adapter is an independent async task. It owns its connection to an external system and is responsible for reconnection if that connection is lost.
|
||||
|
||||
### Adapter Trait
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait Adapter: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
async fn run(&self, tx: Sender<RawEvent>) -> Result<()>;
|
||||
async fn on_connect(&self) {}
|
||||
async fn on_disconnect(&self) {}
|
||||
}
|
||||
```
|
||||
|
||||
Each adapter runs its `run` loop indefinitely, pushing `RawEvent`s into the shared channel. Failures inside `run` trigger a reconnect cycle with exponential backoff — the adapter never terminates the daemon.
|
||||
|
||||
### HyprlandAdapter
|
||||
|
||||
Connects to Hyprland's event socket (`$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock`). Reads newline-delimited event strings and forwards them as `RawEvent`s.
|
||||
|
||||
Handles:
|
||||
- `monitoradded` / `monitorremoved`
|
||||
- `workspace` / `workspacev2`
|
||||
- `activewindow` / `activewindowv2`
|
||||
- `openwindow` / `closewindow`
|
||||
- `focusedmon`
|
||||
|
||||
Reconnects if the socket disappears (compositor restart). Buffers events during reconnect to avoid losing the first few signals after the compositor comes back.
|
||||
|
||||
### UdevAdapter
|
||||
|
||||
Uses `tokio-udev` to listen on a netlink socket for kernel device events. Monitors all subsystems relevant to desktop hardware:
|
||||
|
||||
- `usb` — docks, peripherals, hubs
|
||||
- `input` — keyboards, mice, tablets
|
||||
- `drm` — display connectors
|
||||
- `power_supply` — batteries, chargers
|
||||
|
||||
Unlike most adapters, `UdevAdapter` also performs an initial enumeration on startup so the state engine has a full picture of currently-connected hardware before any hotplug events arrive.
|
||||
|
||||
### PowerAdapter
|
||||
|
||||
Reads battery and AC state from `/sys/class/power_supply/`. Polls on a configurable interval (default: 30s) and emits events on meaningful state transitions:
|
||||
|
||||
- AC plugged / unplugged
|
||||
- Battery level crossing thresholds (20%, 10%, 5%)
|
||||
- Battery full
|
||||
|
||||
Also subscribes to UPower over D-Bus when available for faster event delivery.
|
||||
|
||||
### NetworkAdapter
|
||||
|
||||
Monitors network interface state via netlink. Emits events when interfaces transition between up and down states, and when the system gains or loses default-route connectivity.
|
||||
|
||||
---
|
||||
|
||||
## Normalizer
|
||||
|
||||
The normalizer sits between the adapter channel and the state engine. It is a pure function: given a `RawEvent`, produce zero or more `BreadEvent`s.
|
||||
|
||||
```rust
|
||||
pub trait Normalizer: Send + Sync {
|
||||
fn normalize(&self, raw: &RawEvent) -> Vec<BreadEvent>;
|
||||
}
|
||||
```
|
||||
|
||||
Each adapter source has its own normalizer implementation. Normalization is where domain knowledge lives: knowing that a udev `add` event on a `usb` device with certain vendor/product IDs means "dock connected" rather than "generic USB device."
|
||||
|
||||
### Device Classification
|
||||
|
||||
The `UdevNormalizer` maintains a device classifier that maps hardware identifiers to semantic device types:
|
||||
|
||||
```rust
|
||||
pub enum DeviceClass {
|
||||
Dock,
|
||||
Keyboard,
|
||||
Mouse,
|
||||
Tablet,
|
||||
Display,
|
||||
Storage,
|
||||
Audio,
|
||||
Unknown,
|
||||
}
|
||||
```
|
||||
|
||||
Classification is based on udev properties (`ID_INPUT_KEYBOARD`, `ID_USB_CLASS`, subsystem, driver name). Unknown devices are classified as `Unknown` and still emit a generic `bread.device.connected` event — they are never silently dropped.
|
||||
|
||||
### Event Deduplication
|
||||
|
||||
The normalizer tracks recent events and suppresses duplicates within a configurable window (default: 100ms). This prevents rapid-fire hardware oscillation (e.g., a dock that briefly disconnects and reconnects during power negotiation) from flooding the event bus with spurious events.
|
||||
|
||||
---
|
||||
|
||||
## State Engine
|
||||
|
||||
The state engine is the coordinator. It receives `BreadEvent`s from the normalizer, updates `RuntimeState`, and dispatches events to subscribers.
|
||||
|
||||
```rust
|
||||
pub struct StateEngine {
|
||||
state: Arc<RwLock<RuntimeState>>,
|
||||
subscriptions: Arc<RwLock<SubscriptionTable>>,
|
||||
lua_tx: Sender<LuaMessage>,
|
||||
}
|
||||
```
|
||||
|
||||
On each event:
|
||||
|
||||
1. Acquire write lock on `RuntimeState`
|
||||
2. Apply the state update corresponding to the event
|
||||
3. Release write lock
|
||||
4. Look up matching subscriptions in `SubscriptionTable`
|
||||
5. For each match, send a `LuaMessage::Event` to the Lua runtime channel
|
||||
|
||||
State updates are synchronous and must be fast. No I/O, no blocking, no external calls inside the update path.
|
||||
|
||||
### Subscription Table
|
||||
|
||||
The `SubscriptionTable` maps event patterns to subscriber IDs. Patterns support exact matches and wildcard suffix matching (`bread.device.*`).
|
||||
|
||||
```rust
|
||||
pub struct SubscriptionTable {
|
||||
entries: Vec<Subscription>,
|
||||
}
|
||||
|
||||
pub struct Subscription {
|
||||
pub id: SubscriptionId,
|
||||
pub pattern: EventPattern,
|
||||
pub once: bool,
|
||||
}
|
||||
```
|
||||
|
||||
Matching is O(n) over the subscription list. For typical module counts (tens of subscriptions), this is negligible. If subscription counts grow into the thousands, an index structure would be warranted — but that is not a V1 concern.
|
||||
|
||||
---
|
||||
|
||||
## Lua Runtime
|
||||
|
||||
The Lua runtime runs on a dedicated OS thread. It owns the `mlua` `Lua` instance and processes messages from the async side through a `tokio::sync::mpsc` channel.
|
||||
|
||||
```rust
|
||||
pub enum LuaMessage {
|
||||
Event(BreadEvent),
|
||||
Reload,
|
||||
Exec(String),
|
||||
StateQuery { key: String, reply: oneshot::Sender<serde_json::Value> },
|
||||
Shutdown,
|
||||
}
|
||||
```
|
||||
|
||||
### Module Loading
|
||||
|
||||
On startup (and on reload), the Lua runtime:
|
||||
|
||||
1. Creates a fresh `Lua` instance (on reload, the old one is dropped)
|
||||
2. Registers all built-in `bread.*` API functions
|
||||
3. Evaluates `~/.config/bread/init.lua`
|
||||
4. Resolves module dependency order
|
||||
5. Loads each module in order, calling `on_load` if defined
|
||||
6. Registers all `bread.on` subscriptions with the state engine
|
||||
|
||||
### Error Isolation
|
||||
|
||||
Lua errors during event handler execution are caught with `pcall`. The error message and stack trace are logged. The handler is removed from the subscription table if it is a `once` subscription; otherwise it remains registered and will be called again on the next matching event.
|
||||
|
||||
Errors during module load are fatal to that module but not to the daemon. The failed module is marked as `LoadError` in module state; remaining modules continue loading.
|
||||
|
||||
### Lua ↔ Rust Boundary
|
||||
|
||||
All calls across the Lua/Rust 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 functions are caught by mlua and converted to Lua errors — they do not unwind into the Lua thread.
|
||||
|
||||
---
|
||||
|
||||
## IPC
|
||||
|
||||
`breadd` exposes a Unix domain socket at `$XDG_RUNTIME_DIR/bread/breadd.sock`. The protocol is newline-delimited JSON.
|
||||
|
||||
### Request / Response
|
||||
|
||||
```json
|
||||
{ "id": "1", "method": "state.get", "params": { "key": "monitors" } }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "id": "1", "result": [ { "name": "HDMI-A-1", "resolution": "2560x1440" } ] }
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `state.get` | Read a value from RuntimeState by key path |
|
||||
| `state.dump` | Return full RuntimeState as JSON |
|
||||
| `events.subscribe` | Subscribe to a stream of BreadEvents (persistent connection) |
|
||||
| `modules.list` | List loaded modules and their status |
|
||||
| `modules.reload` | Trigger a hot reload |
|
||||
| `profile.list` | List defined profiles |
|
||||
| `profile.activate` | Activate a named profile |
|
||||
| `emit` | Inject a synthetic BreadEvent |
|
||||
| `ping` | Health check |
|
||||
|
||||
### Event Streaming
|
||||
|
||||
`events.subscribe` upgrades the connection to a streaming mode. The daemon pushes `BreadEvent` JSON objects line-by-line as they occur. The CLI's `bread events` command uses this to implement its live event stream. The connection remains open until the client disconnects.
|
||||
|
||||
### IPC Security
|
||||
|
||||
The socket is created with `0600` permissions, owned by the user. No authentication is performed — any process running as the same user can connect. This is intentional for V1 and consistent with how tools like Hyprland and sway handle their IPC sockets.
|
||||
|
||||
---
|
||||
|
||||
## Hot Reload
|
||||
|
||||
Hot reload is a first-class feature of `breadd`. The daemon persists; the Lua layer restarts.
|
||||
|
||||
Reload sequence:
|
||||
|
||||
```
|
||||
bread reload (CLI)
|
||||
│
|
||||
▼
|
||||
IPC: modules.reload
|
||||
│
|
||||
▼
|
||||
StateEngine: pause event dispatch to Lua
|
||||
│
|
||||
▼
|
||||
LuaRuntime: receive Reload message
|
||||
│
|
||||
├── call on_unload() on each module (reverse dependency order)
|
||||
├── cancel all active timers and intervals
|
||||
├── send subscription cancellations to SubscriptionTable
|
||||
├── drop Lua instance (all state cleared)
|
||||
├── create new Lua instance
|
||||
├── re-register built-in API
|
||||
├── re-load 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. The previous Lua instance cannot be restored (it was dropped), so the daemon enters a degraded state: no Lua handlers active, but the daemon itself remains running and IPC-accessible. The CLI reports the error and the user can fix the Lua and reload again.
|
||||
|
||||
This tradeoff (no rollback on failed reload) is intentional for V1. Rollback would require snapshotting the previous Lua state before initiating reload, which adds complexity. The user experience is acceptable: a syntax error in a module gives a clear error message via `bread reload`, and the daemon stays alive.
|
||||
|
||||
---
|
||||
|
||||
## Startup Sequence
|
||||
|
||||
```
|
||||
1. Parse config (breadd.toml or default)
|
||||
2. Initialize logging (tracing subscriber)
|
||||
3. Create RuntimeState (empty)
|
||||
4. Create SubscriptionTable (empty)
|
||||
5. Bind IPC socket
|
||||
6. Spawn adapter tasks:
|
||||
a. UdevAdapter (enumerate existing devices → populate initial state)
|
||||
b. HyprlandAdapter (connect to compositor socket)
|
||||
c. PowerAdapter (read initial battery state)
|
||||
d. NetworkAdapter (read initial interface state)
|
||||
7. Spawn StateEngine task
|
||||
8. Spawn Lua runtime thread
|
||||
9. Send Lua runtime: load init.lua
|
||||
10. Lua loads modules, registers subscriptions
|
||||
11. StateEngine fires bread.system.startup event
|
||||
12. Daemon enters steady-state event loop
|
||||
```
|
||||
|
||||
Step 6a (UdevAdapter enumeration) is synchronous before other adapters start. This ensures that when Lua modules first run, `bread.state.get("devices")` returns an accurate picture of what's already connected rather than an empty list.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
`breadd` reads from `~/.config/bread/breadd.toml`. All values have defaults; the file is optional.
|
||||
|
||||
```toml
|
||||
[daemon]
|
||||
log_level = "info" # trace | debug | info | warn | error
|
||||
socket_path = "" # default: $XDG_RUNTIME_DIR/bread/breadd.sock
|
||||
|
||||
[lua]
|
||||
entry_point = "~/.config/bread/init.lua"
|
||||
module_path = "~/.config/bread/modules"
|
||||
|
||||
[adapters.hyprland]
|
||||
enabled = true
|
||||
reconnect_delay_ms = 500
|
||||
reconnect_max_attempts = 10
|
||||
|
||||
[adapters.udev]
|
||||
enabled = true
|
||||
subsystems = ["usb", "input", "drm", "power_supply"]
|
||||
|
||||
[adapters.power]
|
||||
enabled = true
|
||||
poll_interval_secs = 30
|
||||
|
||||
[adapters.network]
|
||||
enabled = true
|
||||
|
||||
[events]
|
||||
dedup_window_ms = 100 # suppress duplicate events within this window
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Observability
|
||||
|
||||
### Logging
|
||||
|
||||
`breadd` uses `tracing` for structured logging. Log level is configurable. At `debug` level, every `RawEvent` and `BreadEvent` is logged with full payloads. At `info` level, only significant lifecycle events and errors are logged.
|
||||
|
||||
### `bread doctor`
|
||||
|
||||
The `bread doctor` command queries the daemon over IPC and produces a diagnostic report:
|
||||
|
||||
- Daemon version and uptime
|
||||
- IPC socket status
|
||||
- Adapter connection status (connected / disconnected / reconnecting)
|
||||
- Module load status (loaded / error / not found)
|
||||
- Active subscriptions count
|
||||
- Recent errors (last 10 Lua errors with stack traces)
|
||||
- RuntimeState summary
|
||||
|
||||
### `bread events`
|
||||
|
||||
Streams the live `BreadEvent` log to the terminal. Supports optional pattern filtering:
|
||||
|
||||
```bash
|
||||
bread events # all events
|
||||
bread events --filter "bread.device.*" # device events only
|
||||
bread events --filter "bread.monitor.*" # monitor events only
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Failure Modes & Recovery
|
||||
|
||||
| Failure | Behavior |
|
||||
|---------|----------|
|
||||
| Hyprland socket unavailable | HyprlandAdapter retries with backoff; other adapters unaffected |
|
||||
| Compositor restart | HyprlandAdapter detects disconnect, reconnects when socket reappears |
|
||||
| Lua syntax error on reload | Reload aborts; daemon enters degraded mode; reports error via IPC |
|
||||
| Lua runtime error in handler | Error caught and logged; handler remains registered; daemon continues |
|
||||
| IPC client disconnect | Connection cleaned up; no effect on daemon |
|
||||
| udev socket error | UdevAdapter logs error and retries; events may be missed during outage |
|
||||
| Panic in Rust async task | Task restarts via supervisor; logged as critical error |
|
||||
|
||||
The daemon is designed to never require a full restart due to a recoverable failure. The only cases that warrant a daemon restart are: daemon binary update, unrecoverable OS-level error, or explicit user action.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
`breadd` is a narrow, focused daemon. It does not automate. It does not configure the compositor. It does not manage packages or provision machines.
|
||||
|
||||
It does one thing well: maintain a live, coherent model of the desktop runtime and deliver that model — as structured state and semantic events — to the Lua automation layer that acts on it.
|
||||
|
||||
Everything complex lives in Lua. Everything reliable lives in `breadd`.
|
||||
Loading…
Add table
Add a link
Reference in a new issue