Add event causality tracking (id + caused_by chains)

Every BreadEvent now gets a unique id at construction (via
BreadEvent::new/with_timestamp, and the normalizer's struct-literal call
sites, which all now assign one explicitly). A new caused_by field is
populated only when a Lua bread.emit() call runs synchronously inside a
bread.on subscriber's handler: LuaEngine tracks the currently-dispatching
event's id (set/restored around each handle_event invocation, single-
threaded so a plain Mutex-guarded slot suffices) and bread.emit()'s Rust
binding reads it when constructing the outgoing event. This lets chains of
Lua modules that react to each other's events be reconstructed instead of
timestamp-guessed from a live `bread events` log.

Also:
- bread events --tree renders the caused_by chain as an indented,
  live-streaming-friendly tree instead of a flat line-per-event stream.
- API_VERSION bumped 1.4.0 -> 1.5.0 (additive-only change).
- Documentation.md's event envelope, bread.emit, and debugging-tips
  sections updated with Since: v1.5 markers.
- New end-to-end regression test spawning the real daemon with 3 chained
  Lua handlers (A emits X on trigger, B emits Y on X, C emits Z on Y) and
  asserting caused_by threads correctly through all three hops.
This commit is contained in:
Breadway 2026-08-04 17:55:31 +08:00
parent 96639516b1
commit 6ff1ee910b
10 changed files with 420 additions and 9 deletions

View file

@ -43,6 +43,8 @@ impl EventNormalizer {
event: raw.kind.clone(),
timestamp: raw.timestamp,
source: raw.source.clone(),
id: bread_shared::new_event_id(),
caused_by: None,
data: raw.payload.clone(),
}],
};
@ -143,6 +145,8 @@ impl EventNormalizer {
event: format!("bread.device.{}", verb),
timestamp: raw.timestamp,
source: AdapterSource::Udev,
id: bread_shared::new_event_id(),
caused_by: None,
data: json!({
"id": id,
"device": "unknown",
@ -173,36 +177,48 @@ impl EventNormalizer {
event: "bread.workspace.changed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
id: bread_shared::new_event_id(),
caused_by: None,
data: raw.payload.clone(),
}],
"createworkspace" => vec![BreadEvent {
event: "bread.workspace.created".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
id: bread_shared::new_event_id(),
caused_by: None,
data: json!({ "workspace": data }),
}],
"destroyworkspace" => vec![BreadEvent {
event: "bread.workspace.destroyed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
id: bread_shared::new_event_id(),
caused_by: None,
data: json!({ "workspace": data }),
}],
"monitoradded" => vec![BreadEvent {
event: "bread.monitor.connected".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
id: bread_shared::new_event_id(),
caused_by: None,
data: json!({ "name": data }),
}],
"monitorremoved" => vec![BreadEvent {
event: "bread.monitor.disconnected".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
id: bread_shared::new_event_id(),
caused_by: None,
data: json!({ "name": data }),
}],
"activewindow" => vec![BreadEvent {
event: "bread.window.focus.changed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
id: bread_shared::new_event_id(),
caused_by: None,
data: raw.payload.clone(),
}],
"activewindowv2" => {
@ -211,6 +227,8 @@ impl EventNormalizer {
event: "bread.window.focused".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
id: bread_shared::new_event_id(),
caused_by: None,
data: json!({
"address": fields.first().unwrap_or(&"")
}),
@ -222,6 +240,8 @@ impl EventNormalizer {
event: "bread.window.opened".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
id: bread_shared::new_event_id(),
caused_by: None,
data: json!({
"address": fields.first().unwrap_or(&""),
"workspace": fields.get(1).unwrap_or(&""),
@ -236,6 +256,8 @@ impl EventNormalizer {
event: "bread.window.closed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
id: bread_shared::new_event_id(),
caused_by: None,
data: json!({ "address": fields.first().unwrap_or(&"") }),
}]
}
@ -245,6 +267,8 @@ impl EventNormalizer {
event: "bread.window.moved".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
id: bread_shared::new_event_id(),
caused_by: None,
data: json!({
"address": fields.first().unwrap_or(&""),
"workspace": fields.get(1).unwrap_or(&""),
@ -255,6 +279,8 @@ impl EventNormalizer {
event: "bread.hyprland.event".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Hyprland,
id: bread_shared::new_event_id(),
caused_by: None,
data: raw.payload.clone(),
}],
}
@ -272,6 +298,8 @@ impl EventNormalizer {
},
timestamp: raw.timestamp,
source: AdapterSource::Power,
id: bread_shared::new_event_id(),
caused_by: None,
data: raw.payload.clone(),
});
}
@ -294,6 +322,8 @@ impl EventNormalizer {
event: event.to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Power,
id: bread_shared::new_event_id(),
caused_by: None,
data: raw.payload.clone(),
});
}
@ -304,6 +334,8 @@ impl EventNormalizer {
event: "bread.power.changed".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Power,
id: bread_shared::new_event_id(),
caused_by: None,
data: raw.payload.clone(),
});
}
@ -339,6 +371,8 @@ impl EventNormalizer {
event: "bread.device.connected".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Bluetooth,
id: bread_shared::new_event_id(),
caused_by: None,
data: json!({
"id": path,
"device": "unknown",
@ -352,6 +386,8 @@ impl EventNormalizer {
event: "bread.device.disconnected".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Bluetooth,
id: bread_shared::new_event_id(),
caused_by: None,
data: json!({
"id": path,
"device": "unknown",
@ -365,6 +401,8 @@ impl EventNormalizer {
event: "bread.bluetooth.device.paired".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Bluetooth,
id: bread_shared::new_event_id(),
caused_by: None,
data: json!({
"id": path,
"name": name,
@ -377,6 +415,8 @@ impl EventNormalizer {
event: "bread.bluetooth.device.unpaired".to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Bluetooth,
id: bread_shared::new_event_id(),
caused_by: None,
data: json!({
"id": path,
"address": address,
@ -421,6 +461,8 @@ impl EventNormalizer {
event: name.to_string(),
timestamp: raw.timestamp,
source: AdapterSource::Network,
id: bread_shared::new_event_id(),
caused_by: None,
data,
}]
}
@ -435,6 +477,8 @@ impl EventNormalizer {
event: format!("bread.terminal.{}", raw.kind),
timestamp: raw.timestamp,
source: raw.source.clone(),
id: bread_shared::new_event_id(),
caused_by: None,
data: raw.payload.clone(),
}]
}
@ -444,6 +488,8 @@ impl EventNormalizer {
event: format!("bread.remote.{}", raw.kind),
timestamp: raw.timestamp,
source: raw.source.clone(),
id: bread_shared::new_event_id(),
caused_by: None,
data: raw.payload.clone(),
}]
}
@ -453,6 +499,8 @@ impl EventNormalizer {
event: format!("bread.git.{}", raw.kind),
timestamp: raw.timestamp,
source: raw.source.clone(),
id: bread_shared::new_event_id(),
caused_by: None,
data: raw.payload.clone(),
}]
}
@ -462,6 +510,8 @@ impl EventNormalizer {
event: format!("bread.project.{}", raw.kind),
timestamp: raw.timestamp,
source: raw.source.clone(),
id: bread_shared::new_event_id(),
caused_by: None,
data: raw.payload.clone(),
}]
}
@ -474,6 +524,8 @@ impl EventNormalizer {
event: format!("bread.service.{suffix}"),
timestamp: raw.timestamp,
source: raw.source.clone(),
id: bread_shared::new_event_id(),
caused_by: None,
data: raw.payload.clone(),
}]
}
@ -490,6 +542,8 @@ impl EventNormalizer {
event,
timestamp: raw.timestamp,
source: raw.source.clone(),
id: bread_shared::new_event_id(),
caused_by: None,
data: raw.payload.clone(),
}]
}
@ -512,6 +566,8 @@ impl EventNormalizer {
event: raw.kind.clone(),
timestamp: raw.timestamp,
source: raw.source.clone(),
id: bread_shared::new_event_id(),
caused_by: None,
data: raw.payload.clone(),
}]
}

View file

@ -656,6 +656,8 @@ mod tests {
timestamp: 0,
source: AdapterSource::System,
data,
id: bread_shared::new_event_id(),
caused_by: None,
}
}

View file

@ -27,7 +27,7 @@ use crate::lua::RuntimeHandle;
/// something new-but-additive (a binding, an event, an IPC param); bump the
/// major version only for a breaking change, which should not happen inside
/// this daemon's v1 lifetime per that section's stated policy.
const API_VERSION: &str = "1.4.0";
const API_VERSION: &str = "1.5.0";
#[derive(Clone)]
pub struct Server {

View file

@ -211,6 +211,19 @@ struct LuaEngine {
next_sub_id: Arc<AtomicU64>,
next_timer_id: Arc<AtomicU64>,
current_module: Arc<Mutex<Option<String>>>,
/// The `id` of the `BreadEvent` whose subscriber callback is currently
/// executing, if any. Set immediately before invoking a handler in
/// `handle_event` and restored (not merely cleared) immediately after,
/// so nested/reentrant dispatch and a single event fan-out to multiple
/// subscriptions both see the correct parent id. Read synchronously by
/// `bread.emit()`'s binding to populate the outgoing event's
/// `caused_by`. The Lua engine runs single-threaded/cooperatively (one
/// engine instance processes `LuaMessage`s serially on a dedicated
/// thread), so a callback invocation and any `bread.emit()` inside it
/// happen synchronously within one `handle_event` call — no additional
/// synchronization beyond the existing `Mutex` (mirroring
/// `current_module` above) is needed.
current_dispatch_id: Arc<Mutex<Option<String>>>,
modules: Arc<Mutex<HashMap<String, ModuleInfo>>>,
module_decls: Arc<Mutex<HashMap<String, ModuleDecl>>>,
module_order: Arc<Mutex<Vec<String>>>,
@ -240,6 +253,7 @@ impl LuaEngine {
next_sub_id: Arc::new(AtomicU64::new(1)),
next_timer_id: Arc::new(AtomicU64::new(1)),
current_module: Arc::new(Mutex::new(None)),
current_dispatch_id: Arc::new(Mutex::new(None)),
modules: Arc::new(Mutex::new(HashMap::new())),
module_decls: Arc::new(Mutex::new(HashMap::new())),
module_order: Arc::new(Mutex::new(Vec::new())),
@ -438,6 +452,7 @@ impl LuaEngine {
bread.set("off", off_fn)?;
let emit_tx = self.emit_tx.clone();
let current_dispatch_id = self.current_dispatch_id.clone();
let emit_fn =
self.lua
.create_function(move |lua, (event_name, payload): (String, Value)| {
@ -447,8 +462,19 @@ impl LuaEngine {
.from_value::<serde_json::Value>(other)
.unwrap_or_else(|_| serde_json::json!({})),
};
// Daemon-internal emit — same trusted path as adapter/IPC
// construction, tagged System. If this runs synchronously
// inside a subscriber callback (see `handle_event`), thread
// the currently-dispatching event's id through as
// `caused_by` so the causality chain can be reconstructed.
let caused_by = current_dispatch_id
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone();
let mut event = BreadEvent::new(event_name, AdapterSource::System, data);
event.caused_by = caused_by;
emit_tx
.send(BreadEvent::new(event_name, AdapterSource::System, data))
.send(event)
.map_err(|_| LuaError::external("event channel closed"))?;
Ok(())
})?;
@ -1489,6 +1515,11 @@ impl LuaEngine {
}
self.set_current_module(module.clone());
// Every subscriber invocation triggered by this event should see the
// same parent id, and a `bread.emit()` call made synchronously
// inside the callback should attribute its `caused_by` to *this*
// event, not whatever was dispatching (if anything) before it.
let previous_dispatch_id = self.set_current_dispatch_id(Some(event.id.clone()));
let result = match kind {
HandlerKind::Event => {
let event_value = json_to_lua(&self.lua, &event)?;
@ -1502,6 +1533,9 @@ impl LuaEngine {
callback.call::<_, ()>((new_lua, old_lua))
}
};
// Restore rather than clear, so this correctly unwinds if dispatch
// is ever reentrant (e.g. a callback that pumps messages itself).
self.set_current_dispatch_id(previous_dispatch_id);
self.set_current_module(None);
if let Err(err) = result {
@ -1678,6 +1712,18 @@ impl LuaEngine {
}
}
/// Set the "currently dispatching" event id, returning whatever id was
/// there before. Callers restore the previous value (rather than
/// clearing to `None`) when the handler invocation finishes, so nested/
/// reentrant dispatch unwinds correctly — see `current_dispatch_id`'s
/// doc comment on the struct definition.
fn set_current_dispatch_id(&self, id: Option<String>) -> Option<String> {
match self.current_dispatch_id.lock() {
Ok(mut guard) => std::mem::replace(&mut *guard, id),
Err(poisoned) => std::mem::replace(&mut *poisoned.into_inner(), id),
}
}
fn cancel_all_timers(&self) {
if let Ok(mut map) = self.timers.lock() {
for (_, entry) in map.drain() {