agentmux_cef\wrr/
position_debounce.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase B.9.1 — position-event debounce.
5//
6// `EVENT_OBJECT_LOCATIONCHANGE` fires on every WM_WINDOWPOSCHANGED
7// dispatch, including during a drag (60+ events/sec). The reducer
8// only needs the FINAL rect after the burst settles — the
9// `OffMonitor` classification is the same at every intermediate
10// position as at the final position, but emitting 60 redundant
11// drift events spams the launcher log.
12//
13// Strategy: per-HWND last-emit timestamp. When a new event fires,
14// drop it if the previous emit was less than `DEBOUNCE_MS` ago.
15// We accept up to `DEBOUNCE_MS` of staleness in the launcher's
16// last-known rect — fine for B.9.1's observation purpose.
17//
18// This is NOT a heartbeat — there is no thread waking periodically.
19// We only ever emit in response to OS events, just sometimes drop
20// them.
21
22use std::collections::HashMap;
23use std::sync::Mutex;
24use std::time::Instant;
25
26/// Maximum frequency at which any single HWND will produce a
27/// position report. Currently 50ms ≈ 20 Hz.
28const DEBOUNCE_MS: u128 = 50;
29
30/// Per-HWND last-emit time. Lock is held only for HashMap
31/// operations — never across IPC. Mutex-poisoning is treated as
32/// unrecoverable (poison = a panic in the WRR hook callback,
33/// which means the hook is broken anyway); we recover via
34/// `into_inner` so the next caller starts fresh.
35fn map() -> &'static Mutex<HashMap<u64, Instant>> {
36    static M: std::sync::OnceLock<Mutex<HashMap<u64, Instant>>> = std::sync::OnceLock::new();
37    M.get_or_init(|| Mutex::new(HashMap::new()))
38}
39
40/// Phase B.9.1 — should we emit a position report for this HWND
41/// right now? Returns `true` if at least `DEBOUNCE_MS` has
42/// elapsed since the last emit for this HWND (or this is the
43/// first emit for it). Updates the timestamp atomically with the
44/// decision.
45pub fn should_emit(hwnd: u64) -> bool {
46    let now = Instant::now();
47    let mut m = match map().lock() {
48        Ok(g) => g,
49        Err(poisoned) => {
50            // Recover: clear the poisoned state and continue.
51            let mut g = poisoned.into_inner();
52            g.clear();
53            g
54        }
55    };
56    let last = m.get(&hwnd).copied();
57    let allow = match last {
58        None => true,
59        Some(t) => now.duration_since(t).as_millis() >= DEBOUNCE_MS,
60    };
61    if allow {
62        m.insert(hwnd, now);
63    }
64    allow
65}
66
67/// Phase B.9.1 — drop the debounce entry for an HWND. Called from
68/// the destroy hook so a recycled HWND value doesn't inherit the
69/// previous occupant's debounce state.
70pub fn forget(hwnd: u64) {
71    let mut m = match map().lock() {
72        Ok(g) => g,
73        Err(poisoned) => poisoned.into_inner(),
74    };
75    m.remove(&hwnd);
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use std::thread::sleep;
82    use std::time::Duration;
83
84    #[test]
85    fn first_emit_for_an_hwnd_is_allowed() {
86        // Use a unique value to avoid interference with other tests.
87        let h = 0xABCD_0001;
88        forget(h);
89        assert!(should_emit(h));
90    }
91
92    #[test]
93    fn rapid_second_emit_is_dropped() {
94        let h = 0xABCD_0002;
95        forget(h);
96        assert!(should_emit(h));
97        assert!(!should_emit(h));
98    }
99
100    #[test]
101    fn after_debounce_window_emits_again() {
102        let h = 0xABCD_0003;
103        forget(h);
104        assert!(should_emit(h));
105        sleep(Duration::from_millis(60));
106        assert!(should_emit(h));
107    }
108
109    #[test]
110    fn forget_resets_state() {
111        let h = 0xABCD_0004;
112        forget(h);
113        assert!(should_emit(h));
114        forget(h);
115        // Forget cleared the entry, so the next emit is the "first" again.
116        assert!(should_emit(h));
117    }
118}