agentmux_srv/server/mux_obj_bridge.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! MuxObjUpdate broadcast bridge.
5//!
6//! Subscribes to `srv_events_tx` (the internal sidecar event bus that the
7//! reducer publishes mutations to) and translates each event into one or
8//! more `MuxObjUpdate` records, broadcast to all connected WS clients via
9//! the existing `event_bus.broadcast_event(...)` plumbing — the same path
10//! `service/mod.rs`'s `run_service_call` response broadcast ends in (via
11//! `EventBus::broadcast_mux_obj_updates`, as one batched frame).
12//!
13//! Why this exists: per-RPC handlers were responsible for attaching
14//! `MuxObjUpdate`s to their responses (`success_with_updates(...)`).
15//! Forgetting that call left the frontend MOS cache stale (e.g. workspace
16//! renames not propagating to the OS title or the InstancePanel — see
17//! `docs/specs/SPEC_REACTIVE_WORKSPACE_SYNC_2026-05-14.md`).
18//!
19//! With this bridge in place, any reducer event automatically reaches the
20//! frontend, so the per-handler convention becomes belt-and-suspenders
21//! instead of load-bearing.
22//!
23//! Spec: `docs/specs/SPEC_OBJ_UPDATE_BRIDGE_2026-05-14.md`.
24//!
25//! Phase 1 scope (this implementation): workspace events only —
26//! immediately fixes the user-reported bug. Phase 2 expands to tabs /
27//! blocks / windows / layouts; Phase 3 retires the per-handler
28//! `success_with_updates(...)` calls now that the bridge covers them.
29
30use std::sync::Arc;
31
32use agentmux_common::ipc::Event;
33use tokio::sync::broadcast;
34
35use crate::backend::eventbus::EventBus;
36use crate::backend::obj::{
37 mux_obj_to_value, Client, Tab, StoreObj, OTYPE_BLOCK, OTYPE_CLIENT, OTYPE_LAYOUT, OTYPE_TAB,
38 OTYPE_WINDOW, OTYPE_WORKSPACE,
39};
40use crate::backend::storage::store::Store;
41
42/// JSON shape that gets broadcast as the `data` payload of a
43/// `waveobj:update` WS event. Matches the shape of the `MuxObjUpdate` struct
44/// in `crates/srv/src/backend/obj.rs` so the frontend's existing
45/// `updateMuxObject` handler accepts it without changes.
46fn build_update_payload(
47 updatetype: &str,
48 otype: &str,
49 oid: &str,
50 obj: Option<serde_json::Value>,
51) -> crate::backend::obj::MuxObjUpdate {
52 crate::backend::obj::MuxObjUpdate { updatetype: updatetype.into(), otype: otype.into(), oid: oid.into(), obj }
53}
54
55/// Push one `MuxObjUpdate` payload to all connected WS clients via the
56/// shared event_bus — the single-update counterpart of the batched response
57/// broadcast in `service/mod.rs`'s `run_service_call`.
58fn emit(event_bus: &EventBus, otype: &str, oid: &str, payload: crate::backend::obj::MuxObjUpdate) {
59 event_bus.broadcast_event(&EventBus::typed_ws_event::<crate::backend::mps_events::MuxObjUpdated>(&payload, format!("{otype}:{oid}")));
60}
61
62/// Fetch one StoreObj by oid and broadcast it as a `waveobj:update`. The
63/// SQLite read is offloaded to the blocking thread pool per ReAgent P1
64/// on PR #852 (Store is `std::sync::Mutex<Connection>`; brief in
65/// steady state but a long reducer transaction would block the tokio
66/// worker thread). Silently logs + skips on missing/error to satisfy
67/// the §8.15 idempotency contract — duplicate or stale events fold to
68/// no-op.
69async fn emit_fetched<T: StoreObj + Send + 'static>(
70 mstore: &Arc<Store>,
71 event_bus: &Arc<EventBus>,
72 otype: &'static str,
73 oid: String,
74 context: &'static str,
75) {
76 let id = oid.clone();
77 let store = Arc::clone(mstore);
78 let result = tokio::task::spawn_blocking(move || store.get::<T>(&id)).await;
79 match result {
80 Ok(Ok(Some(obj))) => {
81 let payload = build_update_payload(
82 "update",
83 otype,
84 &oid,
85 Some(mux_obj_to_value(&obj)),
86 );
87 emit(event_bus, otype, &oid, payload);
88 }
89 Ok(Ok(None)) => {
90 tracing::warn!(
91 target: "wave-obj-bridge",
92 oid = %oid, otype = otype, ctx = context,
93 "object not found in mstore; skipping broadcast"
94 );
95 }
96 Ok(Err(e)) => {
97 tracing::error!(
98 target: "wave-obj-bridge",
99 oid = %oid, otype = otype, ctx = context, error = %e,
100 "mstore.get failed; skipping broadcast"
101 );
102 }
103 Err(join_err) => {
104 tracing::error!(
105 target: "wave-obj-bridge",
106 oid = %oid, otype = otype, ctx = context, error = %join_err,
107 "spawn_blocking join failed (likely panicked); skipping broadcast"
108 );
109 }
110 }
111}
112
113/// Broadcast a "delete" `waveobj:update` for the given oid. No fetch
114/// needed — the frontend's `updateMuxObject` (`mos.ts`) handles
115/// the delete arm with just the oid.
116fn emit_delete(event_bus: &EventBus, otype: &'static str, oid: &str) {
117 let payload = build_update_payload("delete", otype, oid, None);
118 emit(event_bus, otype, oid, payload);
119}
120
121/// Broadcast the singleton `Client` StoreObj. SrvWindowOpened /
122/// SrvWindowClosed mutate `Client.windowids` (per
123/// `apply_srv_window_opened` in persist_subscriber.rs) so renderers
124/// holding a pinned Client need to see the new windowids list — without
125/// this broadcast they'd render stale window membership until reload.
126/// Codex P2 on PR #861.
127///
128/// Client is a singleton — the first `get_all::<Client>()` row is THE
129/// client. Same lookup pattern persist_subscriber uses.
130async fn emit_client_singleton(
131 mstore: &Arc<Store>,
132 event_bus: &Arc<EventBus>,
133 context: &'static str,
134) {
135 let store = Arc::clone(mstore);
136 let result = tokio::task::spawn_blocking(move || store.get_all::<Client>()).await;
137 match result {
138 Ok(Ok(clients)) => {
139 if let Some(client) = clients.into_iter().next() {
140 let oid = client.oid.clone();
141 let payload = build_update_payload(
142 "update",
143 OTYPE_CLIENT,
144 &oid,
145 Some(mux_obj_to_value(&client)),
146 );
147 emit(event_bus, OTYPE_CLIENT, &oid, payload);
148 } else {
149 tracing::warn!(
150 target: "wave-obj-bridge",
151 ctx = context,
152 "no Client row in mstore; skipping Client broadcast"
153 );
154 }
155 }
156 Ok(Err(e)) => {
157 tracing::error!(
158 target: "wave-obj-bridge",
159 ctx = context, error = %e,
160 "mstore.get_all::<Client> failed; skipping broadcast"
161 );
162 }
163 Err(je) => {
164 tracing::error!(
165 target: "wave-obj-bridge",
166 ctx = context, error = %je,
167 "spawn_blocking join failed during Client lookup"
168 );
169 }
170 }
171}
172
173/// Layout events all reference a `tab_id`; the affected StoreObj is the
174/// `LayoutState` referenced by the tab's `layoutstate` field. Two
175/// SQLite reads chained inside one `spawn_blocking` to keep the lock
176/// hold short.
177async fn emit_layout_for_tab(
178 mstore: &Arc<Store>,
179 event_bus: &Arc<EventBus>,
180 tab_id: String,
181 context: &'static str,
182) {
183 use crate::backend::obj::LayoutState;
184 let id_for_log = tab_id.clone();
185 let store = Arc::clone(mstore);
186 let result = tokio::task::spawn_blocking(move || -> Result<Option<LayoutState>, _> {
187 match store.get::<Tab>(&tab_id) {
188 Ok(Some(tab)) => {
189 if tab.layoutstate.is_empty() {
190 Ok(None)
191 } else {
192 store.get::<LayoutState>(&tab.layoutstate)
193 }
194 }
195 Ok(None) => Ok(None),
196 Err(e) => Err(e),
197 }
198 })
199 .await;
200 match result {
201 Ok(Ok(Some(layout))) => {
202 let layout_id = layout.oid.clone();
203 let payload = build_update_payload(
204 "update",
205 OTYPE_LAYOUT,
206 &layout_id,
207 Some(mux_obj_to_value(&layout)),
208 );
209 emit(event_bus, OTYPE_LAYOUT, &layout_id, payload);
210 }
211 Ok(Ok(None)) => {
212 tracing::warn!(
213 target: "wave-obj-bridge",
214 tab_id = %id_for_log, ctx = context,
215 "layout event but tab/layoutstate not found; skipping broadcast"
216 );
217 }
218 Ok(Err(e)) => {
219 tracing::error!(
220 target: "wave-obj-bridge",
221 tab_id = %id_for_log, ctx = context, error = %e,
222 "mstore.get failed during layout resolution; skipping broadcast"
223 );
224 }
225 Err(join_err) => {
226 tracing::error!(
227 target: "wave-obj-bridge",
228 tab_id = %id_for_log, ctx = context, error = %join_err,
229 "spawn_blocking join failed during layout resolution"
230 );
231 }
232 }
233}
234
235/// Translate one reducer event into zero or more `waveobj:update` broadcasts.
236///
237/// **Read source — post-event state guarantee:**
238/// For events emitted via the HTTP `server/service` RPC handlers,
239/// `apply_event_to_mstore` is called synchronously
240/// (`workspace_lifecycle.rs`'s `handle_update_workspace` for workspace;
241/// equivalent path for tab/block/window/layout commands) before
242/// `publish_events` (`reducer_helpers.rs`). So when the bridge
243/// receives such an event, SQLite is already up-to-date.
244///
245/// **IPC-path caveat:** the launcher → IPC path in `srv_ipc/server.rs`'s
246/// `handle_connection` dispatches reducer events directly without first
247/// calling `apply_event_to_mstore`; the persist subscriber and bridge then
248/// race.
249/// At time of writing none of the events the bridge handles are emitted
250/// via that path (verified for `Command::UpdateWindowMeta` and the
251/// workspace family). When that changes, options are: (a) make the IPC
252/// path apply synchronously like HTTP does, or (b) read from the
253/// in-memory `srv_state` reducer rather than SQLite. Tracked in
254/// `SPEC_OBJ_UPDATE_BRIDGE §11.1`.
255///
256/// **Lock discipline (per ReAgent P1 on PR #852):** every `mstore.get<T>()`
257/// is wrapped in `tokio::task::spawn_blocking` via the helpers above so
258/// the async runtime stays responsive even under reducer-transaction
259/// contention.
260///
261/// **Coverage:** Phase 1 + 2 covers workspace, window, tab, block, layout
262/// events. Saga events, OS facts, launcher-domain events all fall through
263/// to the catch-all `_ => {}` arm.
264async fn dispatch_event(event: Event, mstore: Arc<Store>, event_bus: Arc<EventBus>) {
265 use crate::backend::obj::{Block, Window, Workspace};
266
267 match event {
268 // ----- Workspace -----
269 Event::WorkspaceRenamed { workspace_id, .. }
270 | Event::WorkspaceMetaUpdated { workspace_id, .. }
271 | Event::WorkspaceCreated { workspace_id, .. } => {
272 emit_fetched::<Workspace>(
273 &mstore, &event_bus, OTYPE_WORKSPACE, workspace_id, "Workspace*",
274 )
275 .await;
276 }
277 Event::WorkspaceDeleted { workspace_id, .. } => {
278 emit_delete(&event_bus, OTYPE_WORKSPACE, &workspace_id);
279 }
280
281 // ----- Window (Phase 2 + #855) -----
282 Event::WindowMetaUpdated { window_id, .. } => {
283 emit_fetched::<Window>(
284 &mstore, &event_bus, OTYPE_WINDOW, window_id, "WindowMetaUpdated",
285 )
286 .await;
287 }
288 Event::SrvWindowWorkspaceChanged { window_id, .. } => {
289 emit_fetched::<Window>(
290 &mstore, &event_bus, OTYPE_WINDOW, window_id, "SrvWindowWorkspaceChanged",
291 )
292 .await;
293 }
294 // SrvWindowOpened/Closed: the persist path
295 // (apply_srv_window_opened / apply_srv_window_closed) ALSO
296 // mutates Client.windowids inside the same transaction, so
297 // renderers with a pinned Client need to see the updated
298 // singleton too — otherwise their window list lags behind
299 // until reload. (Codex P2 on PR #861.)
300 Event::SrvWindowOpened { window_id, .. } => {
301 emit_fetched::<Window>(
302 &mstore, &event_bus, OTYPE_WINDOW, window_id, "SrvWindowOpened",
303 )
304 .await;
305 emit_client_singleton(&mstore, &event_bus, "SrvWindowOpened").await;
306 }
307 // Parent (Client singleton) update first, window delete second —
308 // same delete-ordering rationale as the TabDeleted arm below.
309 Event::SrvWindowClosed { window_id, .. } => {
310 emit_client_singleton(&mstore, &event_bus, "SrvWindowClosed").await;
311 emit_delete(&event_bus, OTYPE_WINDOW, &window_id);
312 }
313
314 // ----- Tab (Phase 2) -----
315 // TabCreated also touches the parent workspace's tab_ids field
316 // (reducer mutates both in one dispatch). Broadcast both so the
317 // frontend MOS sees the new Tab AND the updated parent ordering.
318 Event::TabCreated {
319 workspace_id,
320 tab_id,
321 ..
322 } => {
323 emit_fetched::<Tab>(&mstore, &event_bus, OTYPE_TAB, tab_id, "TabCreated").await;
324 emit_fetched::<Workspace>(
325 &mstore, &event_bus, OTYPE_WORKSPACE, workspace_id, "TabCreated parent",
326 )
327 .await;
328 }
329 // Delete ordering: PARENT UPDATE FIRST, child delete second — the
330 // reverse of the create arms above. These are two separate WS
331 // frames the renderer applies (and paints) independently; if the
332 // tab delete lands first, the still-mounted <Tab>'s own signal
333 // goes null and it blanks in place for a paint before the
334 // workspace update finally unmounts it (the §6 flash of
335 // SPEC_TAB_CLOSE_BUTTON_SELECT_FLASH_2026_08_25.md, riding in
336 // through the WS push path — §7). Parent-first, the component
337 // unmounts with its data still intact and the late child delete
338 // touches an unsubscribed signal — nothing paints.
339 Event::TabDeleted {
340 workspace_id,
341 tab_id,
342 ..
343 } => {
344 emit_fetched::<Workspace>(
345 &mstore, &event_bus, OTYPE_WORKSPACE, workspace_id, "TabDeleted parent",
346 )
347 .await;
348 emit_delete(&event_bus, OTYPE_TAB, &tab_id);
349 }
350 Event::TabRenamed { tab_id, .. } | Event::TabMetaUpdated { tab_id, .. } => {
351 emit_fetched::<Tab>(&mstore, &event_bus, OTYPE_TAB, tab_id, "Tab*").await;
352 }
353 Event::ActiveTabChanged { workspace_id, .. }
354 | Event::TabReordered { workspace_id, .. }
355 | Event::TabsReorderedBulk { workspace_id, .. } => {
356 emit_fetched::<Workspace>(
357 &mstore,
358 &event_bus,
359 OTYPE_WORKSPACE,
360 workspace_id,
361 "ActiveTab/Reorder",
362 )
363 .await;
364 }
365 Event::TabMoved {
366 tab_id,
367 src_workspace_id,
368 dst_workspace_id,
369 ..
370 } => {
371 emit_fetched::<Tab>(&mstore, &event_bus, OTYPE_TAB, tab_id, "TabMoved").await;
372 emit_fetched::<Workspace>(
373 &mstore,
374 &event_bus,
375 OTYPE_WORKSPACE,
376 src_workspace_id,
377 "TabMoved src",
378 )
379 .await;
380 emit_fetched::<Workspace>(
381 &mstore,
382 &event_bus,
383 OTYPE_WORKSPACE,
384 dst_workspace_id,
385 "TabMoved dst",
386 )
387 .await;
388 }
389
390 // ----- Block (Phase 2) -----
391 // BlockCreated/BlockDeleted touch the parent tab's blockids field.
392 Event::BlockCreated {
393 tab_id, block_id, ..
394 } => {
395 emit_fetched::<Block>(
396 &mstore, &event_bus, OTYPE_BLOCK, block_id, "BlockCreated",
397 )
398 .await;
399 emit_fetched::<Tab>(
400 &mstore, &event_bus, OTYPE_TAB, tab_id, "BlockCreated parent",
401 )
402 .await;
403 }
404 // Parent update first, block delete second — same delete-ordering
405 // rationale as the TabDeleted arm above.
406 Event::BlockDeleted {
407 tab_id, block_id, ..
408 } => {
409 emit_fetched::<Tab>(
410 &mstore, &event_bus, OTYPE_TAB, tab_id, "BlockDeleted parent",
411 )
412 .await;
413 emit_delete(&event_bus, OTYPE_BLOCK, &block_id);
414 }
415 Event::BlockMetaUpdated { block_id, .. } => {
416 emit_fetched::<Block>(
417 &mstore,
418 &event_bus,
419 OTYPE_BLOCK,
420 block_id,
421 "BlockMetaUpdated",
422 )
423 .await;
424 }
425 Event::BlockMoved {
426 block_id,
427 src_tab_id,
428 dst_tab_id,
429 ..
430 } => {
431 emit_fetched::<Block>(&mstore, &event_bus, OTYPE_BLOCK, block_id, "BlockMoved").await;
432 emit_fetched::<Tab>(&mstore, &event_bus, OTYPE_TAB, src_tab_id, "BlockMoved src")
433 .await;
434 emit_fetched::<Tab>(&mstore, &event_bus, OTYPE_TAB, dst_tab_id, "BlockMoved dst")
435 .await;
436 }
437
438 // ----- Layout (Phase 2 — partial) -----
439 // Focused/Magnified + Cleared/TreeReplaced are now persisted by
440 // `apply_event_to_mstore` (persist_subscriber.rs), so the bridge
441 // can safely re-read LayoutState and broadcast it: the HTTP RPC
442 // path applies SQLite synchronously before publishing the event
443 // (see the post-event-state guarantee above), so the read sees
444 // post-event tree state.
445 //
446 // The remaining tree events ARE persisted by the subscriber now
447 // (SPEC_864 phases 2-5, #1970-#1981 — wcore-direct layout writes
448 // are retired; this comment predated that migration). They are
449 // still deliberately NOT bridged, but for a different reason than
450 // the original wcore-direct race: see the srv-IPC-path caveat on
451 // the granular-events note below. They remain covered by their
452 // handlers' existing `success_with_updates(...)` response
453 // broadcasts (Codex P2 on PR #861).
454 Event::FocusedNodeChanged { tab_id, .. }
455 | Event::MagnifiedNodeChanged { tab_id, .. } => {
456 emit_layout_for_tab(&mstore, &event_bus, tab_id, "Focused/MagnifiedNodeChanged").await;
457 }
458 Event::LayoutCleared { tab_id, .. } | Event::LayoutTreeReplaced { tab_id, .. } => {
459 emit_layout_for_tab(&mstore, &event_bus, tab_id, "LayoutCleared/TreeReplaced").await;
460 }
461 // Without this, a backend-queued layout action reaches a frontend
462 // ONLY if that frontend happens to construct its `LayoutModel`
463 // afterward — `initializeFromMuxObject` reads
464 // `pendingbackendactions` once at init
465 // (`frontend/layout/lib/layoutPersistence.ts`). That was invisible
466 // for as long as the queue's only producer was tear-off/redock,
467 // which creates a NEW window (hence a fresh model) as part of the
468 // same gesture. Close-on-exit is the first producer that queues an
469 // action for an ALREADY-OPEN window, and there the queue simply
470 // sat in SQLite unread: the block row was deleted and the
471 // backend's own tree pruned, but the frontend kept its leaf,
472 // rendering a pane whose block resolves to null (a permanent
473 // loading spinner) instead of closing. `onBackendUpdate`'s
474 // `processPendingBackendActions` call already handles the queue
475 // correctly — it just never heard that the queue had changed. See
476 // docs/specs/SPEC_TERM_EXIT_RESPAWN_LOOP_2026_09_15.md §14.
477 //
478 // Race-free for the reason the granular-events note below gives:
479 // the ONLY dispatcher of `LayoutQueueBackendActions` is
480 // `service::reducer_helpers::queue_layout_actions_via_reducer`,
481 // which applies every emitted event to the store synchronously
482 // before calling `publish_events`, so this re-read of LayoutState
483 // cannot observe pre-event state.
484 Event::LayoutBackendActionsQueued { tab_id, .. } => {
485 emit_layout_for_tab(&mstore, &event_bus, tab_id, "LayoutBackendActionsQueued").await;
486 }
487 // The 7 granular structural events (LayoutNodeMoved/…/InsertedAtIndex)
488 // are persisted by the subscriber (they carry `new_tree`), but are
489 // deliberately NOT bridged here. `emit_layout_for_tab` re-reads
490 // LayoutState, which is only guaranteed post-event on the HTTP-RPC
491 // path (synchronous apply before publish). On the srv-IPC path,
492 // `srv_ipc/server.rs` publishes to `events_tx` without a synchronous
493 // apply, so a bridge broadcast could race ahead of the persist
494 // subscriber and emit pre-event state (codex P2 on #1883 — the IPC-path
495 // caveat documented above). These events have no production dispatcher
496 // yet; their bridge projection lands in the Phase-4 frontend
497 // intent-flip, where the path is HTTP-RPC (synchronous, race-free).
498
499 // Saga lifecycle, launcher-domain events, OS facts, etc. — not
500 // StoreObj changes. The catch-all keeps the bridge future-proof
501 // for new event variants the reducer may add.
502 _ => {}
503 }
504}
505
506/// Spawn the bridge task. Returns the `JoinHandle` so callers can keep it
507/// alive (typically forever — the task lives for the lifetime of the srv
508/// process). Per ReAgent P1 on PR #852: the loop is panic-resilient — a
509/// panic inside `dispatch_event` is caught and logged, and the loop
510/// continues processing subsequent events. Without this, a single
511/// malformed event could silently kill the entire bridge task and
512/// frontend MOS would stop seeing updates.
513///
514/// Subscribe ordering: per `SPEC §11.1` the bridge can subscribe in any
515/// order relative to the persist subscriber. For Phase 1's workspace
516/// events the HTTP RPC handler applies SQLite synchronously before
517/// publishing the event, so the bridge always sees post-event state.
518pub fn spawn_mux_obj_bridge(
519 events_rx: broadcast::Receiver<Event>,
520 mstore: Arc<Store>,
521 event_bus: Arc<EventBus>,
522) -> tokio::task::JoinHandle<()> {
523 tokio::spawn(run_mux_obj_bridge(events_rx, mstore, event_bus))
524}
525
526async fn run_mux_obj_bridge(
527 mut events_rx: broadcast::Receiver<Event>,
528 mstore: Arc<Store>,
529 event_bus: Arc<EventBus>,
530) {
531 tracing::info!(target: "wave-obj-bridge", "[wave-obj-bridge] started (Phase 1: workspace events)");
532 loop {
533 match events_rx.recv().await {
534 Ok(event) => {
535 // Per-event panic isolation (ReAgent P1 on PR #852): use
536 // FuturesUnordered with a catch_unwind future would be the
537 // textbook fix, but for a single event-at-a-time loop the
538 // simpler pattern is to spawn the dispatch as its own task
539 // and observe the JoinError if it panics. We `await` it
540 // immediately so events still process serially (matching
541 // the broadcast channel's send order), but a panic in one
542 // event can't kill the bridge.
543 let store = Arc::clone(&mstore);
544 let bus = Arc::clone(&event_bus);
545 let event_dbg = format!("{:?}", &event);
546 let join = tokio::spawn(dispatch_event(event, store, bus)).await;
547 if let Err(join_err) = join {
548 if join_err.is_panic() {
549 tracing::error!(
550 target: "wave-obj-bridge",
551 event = %event_dbg,
552 "dispatch_event panicked; bridge continues with next event. Panic: {}",
553 join_err,
554 );
555 } else {
556 tracing::error!(
557 target: "wave-obj-bridge",
558 event = %event_dbg,
559 error = %join_err,
560 "dispatch_event task aborted unexpectedly"
561 );
562 }
563 }
564 }
565 Err(broadcast::error::RecvError::Lagged(n)) => {
566 // The broadcast channel has 1024 capacity
567 // (`spawn_reducer_plumbing` in bootstrap/plumbing.rs).
568 // If we lag, frontend MOS state diverges silently — log it
569 // loudly so operators can correlate with user-visible drift
570 // (e.g. the InstancePanel/title showing stale names).
571 // No automatic recovery; the next event resyncs the affected
572 // object and frontend reads everything else from its cache.
573 tracing::error!(
574 target: "wave-obj-bridge",
575 skipped = n,
576 "broadcast channel lagged; some waveobj:update events were dropped — frontend MOS may show stale state until the affected object is mutated again"
577 );
578 }
579 Err(broadcast::error::RecvError::Closed) => {
580 tracing::info!(target: "wave-obj-bridge", "events channel closed; bridge exiting");
581 return;
582 }
583 }
584 }
585}
586
587// ====================================================================
588// Tests
589// ====================================================================
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594 use crate::backend::obj::{MetaMapType, Workspace};
595
596 fn test_workspace(oid: &str, tabids: Vec<String>) -> Workspace {
597 Workspace {
598 oid: oid.to_string(),
599 version: 1,
600 name: "test-ws".to_string(),
601 tabids,
602 pinnedtabids: vec![],
603 activetabid: String::new(),
604 meta: MetaMapType::new(),
605 }
606 }
607
608 fn test_tab(oid: &str) -> Tab {
609 Tab {
610 oid: oid.to_string(),
611 version: 1,
612 name: "test-tab".to_string(),
613 layoutstate: String::new(),
614 blockids: vec![],
615 meta: MetaMapType::new(),
616 }
617 }
618
619 /// SPEC_TAB_CLOSE_BUTTON_SELECT_FLASH_2026_08_25.md §7: the bridge's
620 /// TabDeleted arm must broadcast the PARENT workspace update BEFORE the
621 /// tab delete. These are two separate WS frames the renderer applies
622 /// (and paints) independently — delete-first blanks the still-mounted
623 /// <Tab> in place for a paint before the workspace update unmounts it.
624 #[tokio::test]
625 async fn test_tab_deleted_broadcasts_parent_update_before_tab_delete() {
626 let tmp = tempfile::tempdir().unwrap();
627 let mstore = Arc::new(Store::open(&tmp.path().join("objects.db")).unwrap());
628 let event_bus = Arc::new(EventBus::new());
629
630 let ws_id = "11111111-1111-1111-1111-111111111111";
631 let deleted_tab_id = "22222222-2222-2222-2222-222222222222";
632 let surviving_tab_id = "33333333-3333-3333-3333-333333333333";
633 // Post-delete state: the workspace no longer lists the deleted tab.
634 let mut ws = test_workspace(ws_id, vec![surviving_tab_id.to_string()]);
635 mstore.insert(&mut ws).unwrap();
636
637 let mut receivers = event_bus.register_ws("test-conn", "test-tab");
638
639 dispatch_event(
640 Event::TabDeleted {
641 workspace_id: ws_id.to_string(),
642 tab_id: deleted_tab_id.to_string(),
643 block_ids: vec![],
644 version: 1,
645 },
646 Arc::clone(&mstore),
647 Arc::clone(&event_bus),
648 )
649 .await;
650
651 let first = tokio::time::timeout(std::time::Duration::from_secs(2), receivers.priority.recv())
652 .await
653 .expect("timed out waiting for first broadcast")
654 .expect("priority channel closed");
655 assert_eq!(first.get("eventtype").and_then(|v| v.as_str()), Some("waveobj:update"));
656 assert_eq!(
657 first.get("oref").and_then(|v| v.as_str()),
658 Some(format!("workspace:{ws_id}").as_str()),
659 "first frame must be the parent workspace update, got: {first}"
660 );
661 assert_eq!(
662 first.get("data").and_then(|d| d.get("updatetype")).and_then(|v| v.as_str()),
663 Some("update"),
664 );
665
666 let second = tokio::time::timeout(std::time::Duration::from_secs(2), receivers.priority.recv())
667 .await
668 .expect("timed out waiting for second broadcast")
669 .expect("priority channel closed");
670 assert_eq!(
671 second.get("oref").and_then(|v| v.as_str()),
672 Some(format!("tab:{deleted_tab_id}").as_str()),
673 "second frame must be the tab delete, got: {second}"
674 );
675 assert_eq!(
676 second.get("data").and_then(|d| d.get("updatetype")).and_then(|v| v.as_str()),
677 Some("delete"),
678 );
679 }
680
681 /// Same delete-ordering contract for BlockDeleted: parent tab update
682 /// first, block delete second.
683 #[tokio::test]
684 async fn test_block_deleted_broadcasts_parent_update_before_block_delete() {
685 let tmp = tempfile::tempdir().unwrap();
686 let mstore = Arc::new(Store::open(&tmp.path().join("objects.db")).unwrap());
687 let event_bus = Arc::new(EventBus::new());
688
689 let tab_id = "44444444-4444-4444-4444-444444444444";
690 let deleted_block_id = "55555555-5555-5555-5555-555555555555";
691 let mut tab = test_tab(tab_id);
692 mstore.insert(&mut tab).unwrap();
693
694 let mut receivers = event_bus.register_ws("test-conn", "test-tab");
695
696 dispatch_event(
697 Event::BlockDeleted {
698 tab_id: tab_id.to_string(),
699 block_id: deleted_block_id.to_string(),
700 version: 1,
701 },
702 Arc::clone(&mstore),
703 Arc::clone(&event_bus),
704 )
705 .await;
706
707 let first = tokio::time::timeout(std::time::Duration::from_secs(2), receivers.priority.recv())
708 .await
709 .expect("timed out waiting for first broadcast")
710 .expect("priority channel closed");
711 assert_eq!(
712 first.get("oref").and_then(|v| v.as_str()),
713 Some(format!("tab:{tab_id}").as_str()),
714 "first frame must be the parent tab update, got: {first}"
715 );
716
717 let second = tokio::time::timeout(std::time::Duration::from_secs(2), receivers.priority.recv())
718 .await
719 .expect("timed out waiting for second broadcast")
720 .expect("priority channel closed");
721 assert_eq!(
722 second.get("oref").and_then(|v| v.as_str()),
723 Some(format!("block:{deleted_block_id}").as_str()),
724 "second frame must be the block delete, got: {second}"
725 );
726 assert_eq!(
727 second.get("data").and_then(|d| d.get("updatetype")).and_then(|v| v.as_str()),
728 Some("delete"),
729 );
730 }
731
732 /// SPEC_TERM_EXIT_RESPAWN_LOOP_2026_09_15.md §14: queueing a backend
733 /// layout action must broadcast the owning tab's LayoutState, or the
734 /// queue reaches a frontend only if that frontend happens to build its
735 /// `LayoutModel` afterward (`initializeFromMuxObject` reads
736 /// `pendingbackendactions` once, at init). Tear-off/redock hid this for
737 /// years by always creating a new window; close-on-exit is the first
738 /// producer that queues for an already-open one, and without this arm
739 /// the pane never closed — it rendered a leaf whose block was already
740 /// deleted, i.e. a permanent loading spinner.
741 #[tokio::test]
742 async fn test_backend_actions_queued_broadcasts_layout_update() {
743 use crate::backend::obj::LayoutState;
744
745 let tmp = tempfile::tempdir().unwrap();
746 let mstore = Arc::new(Store::open(&tmp.path().join("objects.db")).unwrap());
747 let event_bus = Arc::new(EventBus::new());
748
749 let tab_id = "66666666-6666-6666-6666-666666666666";
750 let layout_id = "77777777-7777-7777-7777-777777777777";
751 let mut layout = LayoutState {
752 oid: layout_id.to_string(),
753 version: 1,
754 ..Default::default()
755 };
756 mstore.insert(&mut layout).unwrap();
757 let mut tab = test_tab(tab_id);
758 tab.layoutstate = layout_id.to_string();
759 mstore.insert(&mut tab).unwrap();
760
761 let mut receivers = event_bus.register_ws("test-conn", "test-tab");
762
763 dispatch_event(
764 Event::LayoutBackendActionsQueued {
765 tab_id: tab_id.to_string(),
766 actions: serde_json::json!([{ "actiontype": "delete", "blockid": "dead-block" }]),
767 correlation_id: String::new(),
768 version: 1,
769 },
770 Arc::clone(&mstore),
771 Arc::clone(&event_bus),
772 )
773 .await;
774
775 let frame = tokio::time::timeout(std::time::Duration::from_secs(2), receivers.priority.recv())
776 .await
777 .expect("timed out — LayoutBackendActionsQueued must bridge to a LayoutState update")
778 .expect("priority channel closed");
779 assert_eq!(frame.get("eventtype").and_then(|v| v.as_str()), Some("waveobj:update"));
780 assert_eq!(
781 frame.get("oref").and_then(|v| v.as_str()),
782 Some(format!("layout:{layout_id}").as_str()),
783 "must broadcast the owning tab's LayoutState, got: {frame}"
784 );
785 }
786}