agentmux_cef/
launcher_event_bridge.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase B.7.3.1 — host outbound JS bridge for launcher typed events.
5//
6// Single function `dispatch_to_renderers(state, event)` called from
7// `launcher_ipc::apply_event_to_shadow` after each event is applied
8// to host shadows. Iterates `state.browsers`, calls
9// `Frame::ExecuteJavaScript` per top-level browser to invoke
10// `window.__agentmux_launcher_event(<json>)` in the renderer.
11//
12// Filtering: pool windows (`window-pool-*`) and browser-pane child
13// HWNDs (`browser-pane-*`) are skipped. They have no UI to react.
14//
15// Cross-platform: `Frame::ExecuteJavaScript` is portable across
16// CEF's Windows / macOS / Linux backends. No platform specifics.
17//
18// Phase B.7.3.3 — typed events are the SOLE channel for
19// InstancePanel state. The bespoke `window-instances-changed`
20// event and its 4 sync emit sites in the host are retired.
21//
22// See `docs/specs/SPEC_B_7_3_LAUNCHER_EVENTS_TO_RENDERER_2026_04_29.md`.
23
24use std::collections::{HashMap, VecDeque};
25use std::sync::Arc;
26
27use agentmux_common::ipc::Event;
28use cef::{CefString, ImplBrowser, ImplFrame};
29
30/// Phase F.7 host-bridge dedup cache. Bounded FIFO map keyed by
31/// `"{event_kind}|{label}|{hwnd}"` → max version dispatched.
32///
33/// FIFO insertion order is tracked explicitly via `insertion_order`
34/// because std `HashMap::keys().next()` iteration order is undefined
35/// per-rebuild — the previous implementation could evict the
36/// just-inserted key (reagent P1 PR #722 round 1).
37///
38/// Reset on launcher restart sentinel (codex P1 PR #722 round 1):
39/// when the launcher's `event_version` resets to 1, any cached key
40/// holding a higher version blocks the v=1 event. Mirror the
41/// renderer-side guard's heuristic — clear the cache when we see a
42/// v=1 event AND any cached entry has a version > 0.
43#[derive(Default)]
44pub struct DedupCache {
45    seen: HashMap<String, u64>,
46    insertion_order: VecDeque<String>,
47}
48
49impl DedupCache {
50    pub fn new() -> Self {
51        Self {
52            seen: HashMap::new(),
53            insertion_order: VecDeque::new(),
54        }
55    }
56
57    /// Returns true if the event should be dispatched (strictly newer
58    /// for its key). Updates the cache as a side-effect when admitted.
59    /// Bounded at `cap`; on overflow, evicts the oldest insertion in
60    /// FIFO order.
61    pub fn check_and_record(&mut self, key: String, version: u64, cap: usize) -> bool {
62        if let Some(&seen) = self.seen.get(&key) {
63            if version <= seen {
64                return false;
65            }
66            // Update existing entry — don't reorder for this case;
67            // FIFO ordering is "first inserted, first evicted" not
68            // "least recently used".
69            self.seen.insert(key, version);
70            return true;
71        }
72        self.seen.insert(key.clone(), version);
73        self.insertion_order.push_back(key);
74        if self.seen.len() > cap {
75            if let Some(victim) = self.insertion_order.pop_front() {
76                self.seen.remove(&victim);
77            }
78        }
79        true
80    }
81
82    /// Clear the cache. Called on launcher-restart sentinel.
83    pub fn clear(&mut self) {
84        self.seen.clear();
85        self.insertion_order.clear();
86    }
87
88    /// True if any cached entry has a version above 0 — used as the
89    /// guard for the v=1 restart sentinel.
90    pub fn has_any_versioned_entry(&self) -> bool {
91        self.seen.values().any(|&v| v > 0)
92    }
93
94    pub fn len(&self) -> usize {
95        self.seen.len()
96    }
97}
98
99/// Forward a launcher event to every top-level renderer.
100///
101/// Excluded:
102///   - Pool **inventory** labels (`window-pool-*` in
103///     `pool.unpromoted` OR `pool.queue`): no user UI yet. Two
104///     sub-states:
105///       * `pool.unpromoted` — spawning, renderer not ready.
106///       * `pool.queue` — renderer ready, waiting for promote.
107///     Both are hidden off-screen and would build stale InstancePanel
108///     state from launcher events the user never sees. The bridge
109///     uses `state.user_visibility_snapshot()` which atomically
110///     reads the pool inventory (unpromoted ∪ queue) and the
111///     browser registry under one host_state lock — a two-lock
112///     variant would race against `promote_pool_window` and let a
113///     just-promoted window slip through (or, worse, count a
114///     real user window in the close-cascade gate's exclusion).
115///   - Browser-pane labels (`browser-pane-*`): not top-level
116///     windows; have no InstancePanel.
117///
118/// Promoted pool windows (label still has the `window-pool-*`
119/// prefix but the entry is in NEITHER pool set) ARE included —
120/// they're the user-visible torn-off windows. Pre-fix, a
121/// label-prefix-only check excluded them too, so torn-off windows
122/// stopped receiving launcher events post-promotion (InstancePanel
123/// drift, plus anything else listening to launcher events).
124///
125/// JSON payload uses `serde_json::to_string`, so any string content
126/// from the Event is escaped against quote / backtick injection at
127/// the JS-string boundary.
128pub fn dispatch_to_renderers(state: &Arc<crate::state::AppState>, event: &Event) {
129    // Phase F.7 host-bridge dedup. Mirror of the renderer-side guard
130    // (`shouldDispatchLauncherEvent` in launcher-events.ts), but at
131    // the host so a fresh V8 context post-crash, multi-context fan-
132    // out, or any renderer-side guard failure mode can't amplify the
133    // launcher's single emit. Skip the entire dispatch if the event's
134    // version is not strictly higher than the per-key max we've
135    // already sent.
136    if !should_dispatch(state, event) {
137        return;
138    }
139
140    let json = match serde_json::to_string(event) {
141        Ok(s) => s,
142        Err(e) => {
143            tracing::warn!(
144                target: "launcher-event-bridge",
145                "[launcher-event-bridge] serialize failed: {}",
146                e
147            );
148            return;
149        }
150    };
151
152    let script = format!(
153        "if (window.__agentmux_launcher_event) {{ try {{ window.__agentmux_launcher_event({}) }} catch(e) {{ console.error('[launcher-event] dispatch failed', e) }} }}",
154        json
155    );
156    let code = CefString::from(script.as_str());
157    let url = CefString::from("");
158
159    // Atomic snapshot — pool inventory + browsers under ONE lock.
160    // Two-lock variants race against `promote_pool_window` between
161    // the reads.
162    let (pool_inventory, browsers) = state.user_visibility_snapshot();
163
164    for (label, browser) in browsers {
165        if label.starts_with("browser-pane-") {
166            continue;
167        }
168        if pool_inventory.contains(label.as_str()) {
169            // Pool inventory (unpromoted or ready-queued) — no user
170            // UI yet, skip.
171            continue;
172        }
173        if let Some(frame) = browser.main_frame() {
174            frame.execute_java_script(Some(&code), Some(&url), 0);
175        }
176    }
177}
178
179/// Phase F.7 dedup gate. Returns `true` if the event is strictly
180/// newer for its key and should be dispatched; `false` if a
181/// higher-or-equal version was already sent.
182///
183/// Mirrors the renderer-side `shouldDispatchLauncherEvent`. Bounded
184/// at 4096 keys with FIFO eviction so a long-running host can't
185/// leak unbounded state. Re-arrival of an evicted key bypasses the
186/// host gate but the renderer guard still catches it.
187///
188/// Restart sentinel (codex P1 PR #722 round 1): clear the cache
189/// when the launcher's event_version resets to 1 and any cached
190/// entry holds a higher version. Mirrors the renderer guard.
191fn should_dispatch(state: &Arc<crate::state::AppState>, event: &Event) -> bool {
192    const MAX_DEDUP_KEYS: usize = 4096;
193    let (key, version) = dedup_key(event);
194    let mut cache = state.launcher_bridge_dedup.lock();
195    if version == 1 && cache.has_any_versioned_entry() {
196        cache.clear();
197    }
198    cache.check_and_record(key, version, MAX_DEDUP_KEYS)
199}
200
201/// Build the dedup cache key + extract the version for an event.
202/// Returns `("{kind}|{label}|{hwnd}", version)`.
203///
204/// `kind` for `HwndDriftDetected` is `"hwnd_drift_detected:{drift_kind}"`
205/// so HiddenSinceOpen and OffMonitor for the same `(label, hwnd)`
206/// don't collide (reagent P2 PR #722 round 2).
207///
208/// Unhandled variants are tagged with their serde discriminant
209/// (the `event` field of the JSON tagged-union) so different
210/// variants don't share the same `__catchall__` key (reagent P1
211/// PR #722 round 1).
212fn dedup_key(event: &Event) -> (String, u64) {
213    use Event::*;
214    let (kind, label, hwnd, version) = match event {
215        WindowOpened { label, version, .. } => ("window_opened".to_string(), label.as_str(), 0u64, *version),
216        WindowClosed { label, version, .. } => ("window_closed".to_string(), label.as_str(), 0, *version),
217        WindowInstanceAssigned { label, version, .. } => ("window_instance_assigned".to_string(), label.as_str(), 0, *version),
218        WindowInstanceReleased { label, version, .. } => ("window_instance_released".to_string(), label.as_str(), 0, *version),
219        BackendWindowIdRegistered { label, version, .. } => ("backend_window_id_registered".to_string(), label.as_str(), 0, *version),
220        BackendWindowIdUnregistered { label, version, .. } => ("backend_window_id_unregistered".to_string(), label.as_str(), 0, *version),
221        PoolWindowAdded { label, version, .. } => ("pool_window_added".to_string(), label.as_str(), 0, *version),
222        PoolWindowRemoved { label, version, .. } => ("pool_window_removed".to_string(), label.as_str(), 0, *version),
223        PoolWindowPromoted { label, version, .. } => ("pool_window_promoted".to_string(), label.as_str(), 0, *version),
224        HwndDriftDetected { kind: drift_kind, label, hwnd, version, .. } => (
225            format!("hwnd_drift_detected:{:?}", drift_kind),
226            label.as_deref().unwrap_or(""),
227            hwnd.unwrap_or(0),
228            *version,
229        ),
230        DriftDetected { kind: drift_kind, version, .. } => (
231            format!("drift_detected:{:?}", drift_kind),
232            "",
233            0,
234            *version,
235        ),
236        CorrectiveWindowMove { hwnd, version, .. } => ("corrective_window_move".to_string(), "", *hwnd, *version),
237        HostShouldQuit { version, .. } => ("host_should_quit".to_string(), "", 0, *version),
238        // Catchall: extract the serde discriminant ("event" tag in
239        // the JSON tagged-union) so different unhandled variants
240        // don't collide on the same `__catchall__` key.
241        other => {
242            let value = serde_json::to_value(other).ok();
243            let event_tag = value
244                .as_ref()
245                .and_then(|v| v.get("event"))
246                .and_then(|v| v.as_str())
247                .unwrap_or("__unknown__")
248                .to_string();
249            let v = value
250                .as_ref()
251                .and_then(|v| v.get("version"))
252                .and_then(|x| x.as_u64())
253                .unwrap_or(0);
254            (event_tag, "", 0, v)
255        }
256    };
257    (format!("{}|{}|{}", kind, label, hwnd), version)
258}
259
260#[cfg(test)]
261mod bridge_dedup_tests {
262    //! Phase F.7 host-bridge dedup. The bridge's job is to amplify-zero —
263    //! one launcher event per `(kind, label, hwnd)` key, monotonic by
264    //! version. v0.33.688 smoke surfaced a 164× amplification (single
265    //! launcher emit at v=78, 164 lines logged by the renderer);
266    //! these tests pin the cap.
267    use super::*;
268    use agentmux_common::ipc::{HwndDriftKind, Severity};
269    use std::sync::Arc;
270
271    fn fresh_state() -> Arc<crate::state::AppState> {
272        Arc::new(crate::state::AppState::default())
273    }
274
275    fn drift_event(version: u64, label: &str, hwnd: u64) -> Event {
276        Event::HwndDriftDetected {
277            kind: HwndDriftKind::HiddenSinceOpen,
278            label: Some(label.to_string()),
279            hwnd: Some(hwnd),
280            detail: "test".to_string(),
281            severity: Severity::Warn,
282            version,
283        }
284    }
285
286    #[test]
287    fn first_dispatch_passes_gate() {
288        let state = fresh_state();
289        assert!(should_dispatch(&state, &drift_event(1, "window-x", 100)));
290    }
291
292    #[test]
293    fn duplicate_same_version_drops() {
294        let state = fresh_state();
295        assert!(should_dispatch(&state, &drift_event(78, "window-x", 100)));
296        for _ in 0..200 {
297            assert!(
298                !should_dispatch(&state, &drift_event(78, "window-x", 100)),
299                "same (kind,label,hwnd,version) must drop"
300            );
301        }
302    }
303
304    #[test]
305    fn higher_version_for_same_key_passes() {
306        let state = fresh_state();
307        assert!(should_dispatch(&state, &drift_event(5, "window-x", 100)));
308        assert!(should_dispatch(&state, &drift_event(6, "window-x", 100)));
309    }
310
311    #[test]
312    fn lower_version_for_same_key_drops() {
313        let state = fresh_state();
314        assert!(should_dispatch(&state, &drift_event(10, "window-x", 100)));
315        assert!(!should_dispatch(&state, &drift_event(9, "window-x", 100)));
316    }
317
318    #[test]
319    fn different_labels_dont_collide() {
320        let state = fresh_state();
321        assert!(should_dispatch(&state, &drift_event(78, "window-a", 100)));
322        assert!(should_dispatch(&state, &drift_event(78, "window-b", 200)));
323        assert!(should_dispatch(&state, &drift_event(78, "window-c", 300)));
324    }
325
326    #[test]
327    fn different_hwnds_dont_collide() {
328        let state = fresh_state();
329        assert!(should_dispatch(&state, &drift_event(78, "window-x", 100)));
330        // Same label, different hwnd (e.g. mid-promote re-link).
331        assert!(should_dispatch(&state, &drift_event(78, "window-x", 200)));
332    }
333
334    #[test]
335    fn drift_storm_replay_collapses_to_one() {
336        // Reproduce the v0.33.688 smoke pattern: 164 dispatches of
337        // an identical HiddenSinceOpen event. The bridge must emit
338        // at most ONE through `should_dispatch`.
339        let state = fresh_state();
340        let evt = drift_event(78, "window-ee4504a143984a4db9a1559f5b66ac21", 6162460);
341        let mut admitted = 0;
342        for _ in 0..164 {
343            if should_dispatch(&state, &evt) {
344                admitted += 1;
345            }
346        }
347        assert_eq!(admitted, 1, "bridge must amplify-zero same-version drift");
348    }
349
350    #[test]
351    fn dedup_cache_bounded() {
352        let state = fresh_state();
353        // 5000 unique labels — cache must not exceed MAX_DEDUP_KEYS (4096).
354        for i in 0..5000 {
355            let label = format!("window-{}", i);
356            let _ = should_dispatch(&state, &drift_event(1, &label, i as u64));
357        }
358        let len = state.launcher_bridge_dedup.lock().len();
359        assert!(
360            len <= 4096,
361            "cache size {} exceeds bound 4096 — eviction broken",
362            len
363        );
364    }
365
366    #[test]
367    fn distinct_event_kinds_dont_collide() {
368        let state = fresh_state();
369        let label = "window-x";
370        // Both at v=10, different kinds, same label — must both pass.
371        assert!(should_dispatch(
372            &state,
373            &Event::WindowOpened {
374                label: label.to_string(),
375                kind: agentmux_common::ipc::WindowKind::FullInstance,
376                parent_label: None,
377                version: 10,
378            }
379        ));
380        assert!(should_dispatch(&state, &drift_event(10, label, 100)));
381    }
382
383    #[test]
384    fn distinct_drift_kinds_dont_collide() {
385        // Reagent P2 PR #722 round 2: HwndDriftDetected key must
386        // include the drift kind, otherwise HiddenSinceOpen and
387        // OffMonitor for the same (label, hwnd) collide.
388        let state = fresh_state();
389        let label = "window-x";
390        let hwnd = 100u64;
391        let hidden = Event::HwndDriftDetected {
392            kind: HwndDriftKind::HiddenSinceOpen,
393            label: Some(label.to_string()),
394            hwnd: Some(hwnd),
395            detail: "h".into(),
396            severity: Severity::Warn,
397            version: 10,
398        };
399        let off = Event::HwndDriftDetected {
400            kind: HwndDriftKind::OffMonitor,
401            label: Some(label.to_string()),
402            hwnd: Some(hwnd),
403            detail: "o".into(),
404            severity: Severity::Warn,
405            version: 10,
406        };
407        assert!(should_dispatch(&state, &hidden));
408        assert!(
409            should_dispatch(&state, &off),
410            "different drift kinds at same (label, hwnd, version) must NOT collide"
411        );
412    }
413
414    #[test]
415    fn launcher_restart_sentinel_clears_cache() {
416        // Codex P1 PR #722 round 1: when launcher restarts and
417        // event_version resets to 1, prior cached entries with
418        // higher versions block the v=1 sentinel and subsequent
419        // low-version events. Cache must clear on the sentinel.
420        let state = fresh_state();
421        // Establish some prior-incarnation cache entries.
422        assert!(should_dispatch(&state, &drift_event(10, "window-a", 100)));
423        assert!(should_dispatch(&state, &drift_event(15, "window-b", 200)));
424        assert!(state.launcher_bridge_dedup.lock().has_any_versioned_entry());
425
426        // Launcher restarts: emits v=1 for some new window. Without
427        // the cache reset, the v=1 wouldn't necessarily collide
428        // (different label) but SUBSEQUENT low-version events for
429        // pre-existing labels would block. The reset is keyed off
430        // "v=1 with prior versions cached" — fires once, clears all.
431        let sentinel = Event::WindowOpened {
432            label: "main".into(),
433            kind: agentmux_common::ipc::WindowKind::FullInstance,
434            parent_label: None,
435            version: 1,
436        };
437        assert!(should_dispatch(&state, &sentinel));
438        // Cache should now contain only the sentinel + nothing else.
439        assert_eq!(
440            state.launcher_bridge_dedup.lock().len(),
441            1,
442            "restart sentinel clears cache before recording new entry"
443        );
444
445        // Subsequent low-version events for the same key must admit
446        // (cache no longer holds the stale higher version).
447        assert!(should_dispatch(
448            &state,
449            &Event::WindowOpened {
450                label: "window-a".into(),
451                kind: agentmux_common::ipc::WindowKind::FullInstance,
452                parent_label: None,
453                version: 2,
454            }
455        ));
456    }
457
458    #[test]
459    fn cold_v1_event_into_empty_cache_admits() {
460        // Anti-vacuity guard: a cold v=1 event with no prior cache
461        // is the first event of a fresh launcher and admits cleanly.
462        // The sentinel logic only fires when v=1 arrives WITH a
463        // pre-existing cache entry (renderer-side mirror), so a
464        // truly-empty-cache v=1 just admits without ceremony.
465        let state = fresh_state();
466        let evt = Event::WindowOpened {
467            label: "main".into(),
468            kind: agentmux_common::ipc::WindowKind::FullInstance,
469            parent_label: None,
470            version: 1,
471        };
472        assert!(should_dispatch(&state, &evt));
473        assert_eq!(state.launcher_bridge_dedup.lock().len(), 1);
474    }
475
476    #[test]
477    fn fifo_eviction_drops_oldest_not_newest() {
478        // Reagent P1 PR #722 round 1: HashMap iteration order is
479        // not insertion order, so the previous implementation could
480        // evict the just-inserted key. Now: VecDeque tracks insert
481        // order; pop_front drops the oldest.
482        //
483        // Use version=2 (NOT 1) for every event so the v=1 restart
484        // sentinel doesn't fire each iteration and clear the cache
485        // (reagent P2 PR #722 round 3 anti-vacuity guard).
486        let state = fresh_state();
487        for i in 0..5000 {
488            let label = format!("window-{}", i);
489            assert!(should_dispatch(&state, &drift_event(2, &label, i as u64)));
490        }
491        let cache = state.launcher_bridge_dedup.lock();
492        assert!(cache.len() <= 4096, "cache bounded at 4096");
493        // First 904 keys (5000 - 4096) should have been evicted.
494        assert!(!cache.seen.contains_key("hwnd_drift_detected:HiddenSinceOpen|window-0|0"));
495        assert!(!cache.seen.contains_key("hwnd_drift_detected:HiddenSinceOpen|window-100|100"));
496        // Recent keys retained.
497        assert!(cache.seen.contains_key("hwnd_drift_detected:HiddenSinceOpen|window-4999|4999"));
498    }
499}