Skip to main content

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}