From 309822a0ca07f716d0e2c2fe345c29dd660784dc Mon Sep 17 00:00:00 2001 From: Hunter Jozwiak Date: Thu, 9 Apr 2026 09:27:45 -0400 Subject: [PATCH] feat: suppress stale AT-SPI churn from compositor state --- src/cthulhu/event_manager.py | 148 ++++++++++++++++++ ..._manager_compositor_context_regressions.py | 108 +++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 tests/test_event_manager_compositor_context_regressions.py diff --git a/src/cthulhu/event_manager.py b/src/cthulhu/event_manager.py index 5a6bc0d..2560fa4 100644 --- a/src/cthulhu/event_manager.py +++ b/src/cthulhu/event_manager.py @@ -49,6 +49,16 @@ from . import script_manager from . import settings from .ax_object import AXObject from .ax_utilities import AXUtilities +from .compositor_state_types import ( + CompositorStateEvent, + DESKTOP_FOCUS_CONTEXT_CHANGED, + DESKTOP_TRANSITION_FINISHED, + DESKTOP_TRANSITION_STARTED, + FLUSH_STALE_ATSPI_EVENTS, + PAUSE_ATSPI_CHURN, + PRIORITIZE_FOCUS, + RESUME_ATSPI_CHURN, +) if TYPE_CHECKING: from .cthulhu import Cthulhu @@ -187,7 +197,56 @@ class EventManager: def set_compositor_state_adapter(self, adapter: Any) -> None: """Stores the compositor state adapter used for startup wiring.""" + if self._compositorStateAdapter is adapter: + return + + if self._compositorStateAdapter is not None and hasattr(self._compositorStateAdapter, "remove_listener"): + self._compositorStateAdapter.remove_listener(self._handle_compositor_signal) + self._compositorStateAdapter = adapter + if adapter is not None and hasattr(adapter, "add_listener"): + adapter.add_listener(self._handle_compositor_signal) + + def _handle_compositor_signal(self, signal: CompositorStateEvent) -> None: + """Updates churn suppression state from compositor normalization signals.""" + + snapshot = signal.snapshot + signalType = signal.type + contextToken = getattr(signal, "context_token", None) + if contextToken is None and signal.payload: + contextToken = signal.payload.get("context_token") + if contextToken is None and snapshot is not None: + contextToken = snapshot.active_window_token or snapshot.locus_of_focus_token or None + if snapshot is not None: + snapshotToken = snapshot.active_window_token or snapshot.locus_of_focus_token or "" + if not contextToken: + contextToken = snapshotToken or None + + if signalType in (DESKTOP_FOCUS_CONTEXT_CHANGED, PRIORITIZE_FOCUS): + self._prioritizedContextToken = contextToken + cthulhu_state.prioritizedDesktopContextToken = contextToken + return + + if signalType in (PAUSE_ATSPI_CHURN, DESKTOP_TRANSITION_STARTED): + self._churnSuppressed = True + cthulhu_state.pauseAtspiChurn = True + if contextToken: + self._prioritizedContextToken = contextToken + cthulhu_state.prioritizedDesktopContextToken = contextToken + return + + if signalType in (RESUME_ATSPI_CHURN, DESKTOP_TRANSITION_FINISHED): + self._churnSuppressed = False + cthulhu_state.pauseAtspiChurn = False + if contextToken: + self._prioritizedContextToken = contextToken + cthulhu_state.prioritizedDesktopContextToken = contextToken + if signalType == DESKTOP_TRANSITION_FINISHED: + self._flush_stale_atspi_events() + return + + if signalType == FLUSH_STALE_ATSPI_EVENTS: + self._flush_stale_atspi_events() def ignoreEventTypes(self, eventTypeList: List[str]) -> None: for eventType in eventTypeList: @@ -450,6 +509,12 @@ class EventManager: if event.type.startswith('mouse:button'): return _allow_with_reason("mouse-event", "event type is never ignored") + if self._is_obsolete_by_context(event): + return _ignore_with_reason("compositor-stale-context", "event is from a stale compositor context") + + if self._churnSuppressed and not self._should_preserve_during_suppression(event): + return _ignore_with_reason("compositor-churn-paused", "event is suppressed during compositor churn") + if self._isDuplicateEvent(event): return _ignore_with_reason("duplicate", "duplicate event") @@ -1343,6 +1408,89 @@ class EventManager: msg = f"EVENT MANAGER: {oldSize - newSize} events pruned. New size: {newSize}" debug.printMessage(debug.LEVEL_INFO, msg, True) + def _context_token_for_accessible(self, accessible: Any) -> Optional[str]: + if accessible is None: + return None + + application = AXObject.get_application(accessible) + if application is None: + return None + + pid = AXObject.get_process_id(application) + name = (AXObject.get_name(accessible) or "").strip() + if not name: + name = AXObject.get_role_name(accessible) or AXObject.get_accessible_id(accessible) or "unknown" + if pid < 0 and not name: + return None + + return f"{max(pid, 0)}:{name}" + + def _context_token_for_event(self, event: Any) -> Optional[str]: + return self._context_token_for_accessible(getattr(event, "source", None)) + + def _event_is_from_stale_context(self, event: Any) -> bool: + prioritizedContextToken = self._prioritizedContextToken or cthulhu_state.prioritizedDesktopContextToken + if not prioritizedContextToken: + return False + + if getattr(event, "type", "").startswith("window:"): + return False + + eventToken = self._context_token_for_event(event) + if eventToken is None: + return False + + return eventToken != prioritizedContextToken + + def _is_obsolete_by_context(self, event: Any) -> bool: + if not (self._churnSuppressed or cthulhu_state.pauseAtspiChurn): + return False + + return self._event_is_from_stale_context(event) + + def _should_preserve_during_suppression(self, event: Any) -> bool: + eventType = getattr(event, "type", "") + if eventType.startswith("window:"): + return True + + if eventType.startswith("object:state-changed:focused") and getattr(event, "detail1", 0): + return True + + if eventType.startswith("object:state-changed:active"): + return AXUtilities.is_frame(event.source) or AXUtilities.is_window(event.source) + + if eventType.startswith("object:text-selection-changed"): + return True + + if eventType.startswith("object:selection-changed"): + return self._context_token_for_event(event) == (self._prioritizedContextToken or cthulhu_state.prioritizedDesktopContextToken) + + return False + + def _flush_stale_atspi_events(self) -> None: + """Drops queued events that no longer match the compositor context.""" + + self._gidleLock.acquire() + try: + originalQueue = self._eventQueue + newQueue: queue.Queue[Any] = queue.Queue(0) + while not originalQueue.empty(): + try: + event = originalQueue.get_nowait() + except queue.Empty: + break + + if self._event_is_from_stale_context(event) and not self._should_preserve_during_suppression(event): + continue + + newQueue.put(event) + + self._eventQueue = newQueue + if self._asyncMode and not self._eventQueue.empty() and not self._gidleId: + self._gidleId = GLib.idle_add(self._dequeue) + finally: + self._gidleLock.release() + def _inFlood(self) -> bool: size = self._eventQueue.qsize() if size > 50: diff --git a/tests/test_event_manager_compositor_context_regressions.py b/tests/test_event_manager_compositor_context_regressions.py new file mode 100644 index 0000000..d5efd4b --- /dev/null +++ b/tests/test_event_manager_compositor_context_regressions.py @@ -0,0 +1,108 @@ +import sys +import types +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from cthulhu import cthulhu_state +from cthulhu import compositor_state_types + +stubCthulhu = types.ModuleType("cthulhu.cthulhu") +stubCthulhu.cthulhuApp = mock.Mock() +sys.modules.setdefault("cthulhu.cthulhu", stubCthulhu) + +from cthulhu import event_manager + + +class FakeEvent: + def __init__(self, event_type, source="same", detail1=0, detail2=0, any_data=None): + self.type = event_type + self.source = source + self.detail1 = detail1 + self.detail2 = detail2 + self.any_data = any_data + + +class EventManagerCompositorContextRegressionTests(unittest.TestCase): + def setUp(self) -> None: + self.listener = mock.Mock() + self.listenerPatch = mock.patch.object( + event_manager.Atspi.EventListener, + "new", + return_value=self.listener, + ) + self.listenerPatch.start() + self.addCleanup(self.listenerPatch.stop) + + self.manager = event_manager.EventManager(mock.Mock(), asyncMode=False) + self.manager._active = True + self.manager._context_token_for_event = mock.Mock(side_effect=lambda event: event.source) + cthulhu_state.pauseAtspiChurn = False + cthulhu_state.prioritizedDesktopContextToken = None + cthulhu_state.compositorSnapshot = None + + def test_set_compositor_state_adapter_registers_compositor_listener(self) -> None: + adapter = mock.Mock() + + self.manager.set_compositor_state_adapter(adapter) + + adapter.add_listener.assert_called_once_with(self.manager._handle_compositor_signal) + + def test_pause_signal_updates_churn_state_and_resume_clears_it(self) -> None: + snapshot = compositor_state_types.DesktopContextSnapshot(session_type="wayland") + + self.manager._handle_compositor_signal( + compositor_state_types.CompositorStateEvent( + compositor_state_types.PAUSE_ATSPI_CHURN, + reason="workspace-transition", + snapshot=snapshot, + payload={"context_token": "current"}, + ) + ) + + self.assertTrue(cthulhu_state.pauseAtspiChurn) + self.assertTrue(self.manager._churnSuppressed) + + self.manager._handle_compositor_signal( + compositor_state_types.CompositorStateEvent( + compositor_state_types.RESUME_ATSPI_CHURN, + reason="workspace-transition", + snapshot=snapshot, + payload={"context_token": "current"}, + ) + ) + + self.assertFalse(cthulhu_state.pauseAtspiChurn) + self.assertFalse(self.manager._churnSuppressed) + + def test_stale_context_event_is_obsolete_while_churn_is_paused(self) -> None: + self.manager._churnSuppressed = True + self.manager._prioritizedContextToken = "current" + event = FakeEvent("object:children-changed:add", source="stale") + + self.assertTrue(self.manager._is_obsolete_by_context(event)) + + def test_flush_signal_removes_stale_events_from_queue(self) -> None: + self.manager._churnSuppressed = False + self.manager._prioritizedContextToken = "current" + staleEvent = FakeEvent("object:children-changed:add", source="stale") + currentEvent = FakeEvent("object:children-changed:add", source="current") + self.manager._eventQueue.put(staleEvent) + self.manager._eventQueue.put(currentEvent) + + self.manager._handle_compositor_signal( + compositor_state_types.CompositorStateEvent( + compositor_state_types.FLUSH_STALE_ATSPI_EVENTS, + reason="resume", + snapshot=compositor_state_types.DesktopContextSnapshot(session_type="wayland"), + payload={"context_token": "current"}, + ) + ) + + self.assertEqual(list(self.manager._eventQueue.queue), [currentEvent]) + + +if __name__ == "__main__": + unittest.main()