diff --git a/src/cthulhu/compositor_state_wayland.py b/src/cthulhu/compositor_state_wayland.py index c9b0fea..19d243e 100644 --- a/src/cthulhu/compositor_state_wayland.py +++ b/src/cthulhu/compositor_state_wayland.py @@ -15,6 +15,8 @@ import os from collections.abc import Mapping, Sequence from typing import Any +from gi.repository import GLib + from .compositor_state_types import ( DESKTOP_TRANSITION_FINISHED, DESKTOP_TRANSITION_STARTED, @@ -62,6 +64,7 @@ class WaylandSharedProtocolsBackend: self._workspaceStates: dict[Any, dict[str, Any]] = {} self._batchDirty = False self._transitionPending = False + self._dispatchSourceId = 0 def is_available(self, session_type: str | None = None) -> bool: effectiveSessionType = (session_type or get_session_type()).strip().lower() @@ -94,6 +97,7 @@ class WaylandSharedProtocolsBackend: self._bind_listener(self._registry, "global", self._handle_registry_global) self._bind_listener(self._registry, "global_remove", self._handle_registry_global_remove) self._roundtrip() + self._install_dispatch_watch() except Exception: self.deactivate() @@ -101,6 +105,7 @@ class WaylandSharedProtocolsBackend: self._workspaceStates = {} self._batchDirty = False self._transitionPending = False + self._remove_dispatch_watch() self._safe_close_proxy(self._workspaceManager) self._safe_close_proxy(self._registry) self._workspaceManager = None @@ -154,6 +159,67 @@ class WaylandSharedProtocolsBackend: method() break + def _install_dispatch_watch(self) -> None: + if self._display is None or self._dispatchSourceId: + return + + getFd = getattr(self._display, "get_fd", None) + if not callable(getFd): + return + + try: + displayFd = int(getFd()) + except Exception: + return + + if displayFd < 0: + return + + self._dispatchSourceId = GLib.io_add_watch( + displayFd, + GLib.PRIORITY_DEFAULT, + GLib.IO_IN | GLib.IO_ERR | GLib.IO_HUP, + self._dispatch_display_events, + ) + + def _remove_dispatch_watch(self) -> None: + if not self._dispatchSourceId: + return + + try: + GLib.source_remove(self._dispatchSourceId) + except Exception: + pass + + self._dispatchSourceId = 0 + + def _dispatch_display_events(self, _fd: int, condition: Any) -> bool: + if condition & (GLib.IO_ERR | GLib.IO_HUP): + self.deactivate() + return False + + dispatch = getattr(self._display, "dispatch", None) + if not callable(dispatch): + self.deactivate() + return False + + try: + while True: + dispatched = dispatch(block=False) + if not dispatched: + break + except TypeError: + try: + dispatch() + except Exception: + self.deactivate() + return False + except Exception: + self.deactivate() + return False + + return True + def _safe_close_proxy(self, proxy: Any) -> None: if proxy is None: return diff --git a/tests/test_compositor_state_adapter_regressions.py b/tests/test_compositor_state_adapter_regressions.py index f511881..5c1570e 100644 --- a/tests/test_compositor_state_adapter_regressions.py +++ b/tests/test_compositor_state_adapter_regressions.py @@ -74,6 +74,42 @@ class CompositorStateAdapterRegressionTests(unittest.TestCase): self.assertFalse(backend.is_available("x11")) self.assertFalse(backend.is_available("wayland")) + def test_wayland_backend_installs_dispatch_watch_and_processes_runtime_events(self) -> None: + fakeDisplay = mock.Mock() + fakeRegistry = mock.Mock() + fakeDisplay.connect = mock.Mock() + fakeDisplay.get_registry = mock.Mock(return_value=fakeRegistry) + fakeDisplay.get_fd = mock.Mock(return_value=17) + fakeDisplay.roundtrip = mock.Mock() + fakeDisplay.dispatch = mock.Mock(side_effect=[1, 0]) + fakeProtocols = mock.Mock() + fakeProtocols.INTERFACE_NAME = "ext_workspace_manager_v1" + fakeProtocols.INTERFACE_VERSION = 1 + fakeProtocols.ACTIVE_STATE_VALUE = 1 + fakeProtocols.has_runtime_support.return_value = True + fakeProtocols.get_display_class.return_value = mock.Mock(return_value=fakeDisplay) + emitSignal = mock.Mock() + backend = compositor_state_wayland.WaylandSharedProtocolsBackend( + environment={"WAYLAND_DISPLAY": "wayland-0"}, + protocols=fakeProtocols, + ) + + with ( + mock.patch.object(compositor_state_wayland, "get_session_type", return_value="wayland"), + mock.patch.object(compositor_state_wayland.GLib, "io_add_watch", return_value=41) as ioAddWatch, + mock.patch.object(compositor_state_wayland.GLib, "source_remove") as sourceRemove, + ): + backend.activate(emitSignal) + + ioAddWatch.assert_called_once() + watchCallback = ioAddWatch.call_args.args[3] + self.assertTrue(watchCallback(17, compositor_state_wayland.GLib.IO_IN)) + self.assertEqual(fakeDisplay.dispatch.call_count, 2) + + backend.deactivate() + + sourceRemove.assert_called_once_with(41) + def test_local_ext_workspace_wrapper_supports_base_pywayland_without_distro_protocol_module(self) -> None: fakeClientModule = types.ModuleType("pywayland.client")