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}