From ddea36c21138f7936c62b6172f3a63f1b4430b2d Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Thu, 30 Jul 2026 21:30:02 -0400 Subject: [PATCH 1/4] Fix AT-SPI queue flush deadlock --- src/cthulhu/event_manager.py | 89 +++++++++++++------ ..._manager_compositor_context_regressions.py | 58 ++++++++++++ 2 files changed, 119 insertions(+), 28 deletions(-) diff --git a/src/cthulhu/event_manager.py b/src/cthulhu/event_manager.py index 332696f..55d9eff 100644 --- a/src/cthulhu/event_manager.py +++ b/src/cthulhu/event_manager.py @@ -115,6 +115,7 @@ class EventManager: self._churnSuppressed: bool = cthulhu_state.pauseAtspiChurn self._prioritizedContextToken: Optional[str] = cthulhu_state.prioritizedDesktopContextToken self._desktopContextConfirmedEmpty: bool = False + self._flushingStaleAtspiEvents: bool = False self._relevanceBurstWindow: float = 0.15 self._relevanceBurstHistory: Dict[Tuple[str, str, str], float] = {} @@ -924,32 +925,32 @@ class EventManager: return False - def _addToQueue(self, event: Any, asyncMode: bool) -> None: + def _addToQueue(self, event: Any, asyncMode: bool) -> bool: debugging = debug.debugEventQueue if debugging: debug.printMessage(debug.LEVEL_ALL, " acquiring lock...") - self._gidleLock.acquire() - if debugging: - debug.printMessage(debug.LEVEL_ALL, " ...acquired") - debug.printMessage(debug.LEVEL_ALL, " calling queue.put...") - debug.printMessage(debug.LEVEL_ALL, " (full=%s)" \ - % self._eventQueue.full()) + with self._gidleLock: + effectiveAsyncMode = asyncMode or self._flushingStaleAtspiEvents + if debugging: + debug.printMessage(debug.LEVEL_ALL, " ...acquired") + debug.printMessage(debug.LEVEL_ALL, " calling queue.put...") + debug.printMessage(debug.LEVEL_ALL, " (full=%s)" \ + % self._eventQueue.full()) - self._eventQueue.put(event) - if debugging: - debug.printMessage(debug.LEVEL_ALL, " ...put complete") + self._eventQueue.put(event) + if debugging: + debug.printMessage(debug.LEVEL_ALL, " ...put complete") - if asyncMode and not self._gidleId: - if self._gilSleepTime: - time.sleep(self._gilSleepTime) - self._gidleId = GLib.idle_add(self._dequeue) + if effectiveAsyncMode and not self._gidleId: + if self._gilSleepTime: + time.sleep(self._gilSleepTime) + self._gidleId = GLib.idle_add(self._dequeue) - if debugging: - debug.printMessage(debug.LEVEL_ALL, " releasing lock...") - self._gidleLock.release() if debug.debugEventQueue: + debug.printMessage(debug.LEVEL_ALL, " releasing lock...") debug.printMessage(debug.LEVEL_ALL, " ...released") + return effectiveAsyncMode def _queuePrintln(self, e: Any, isEnqueue: bool = True, isPrune: Optional[bool] = None) -> None: """Convenience method to output queue-related debugging info.""" @@ -1118,7 +1119,7 @@ class EventManager: script = cthulhu.cthulhuApp.scriptManager.get_script(AXObject.get_application(e.source), e.source) script.eventCache[e.type] = (e, time.time()) - self._addToQueue(e, asyncMode) + asyncMode = self._addToQueue(e, asyncMode) if not asyncMode: self._dequeue() @@ -1727,26 +1728,58 @@ class EventManager: def _flush_stale_atspi_events(self) -> None: """Drops queued events that no longer match the compositor context.""" - self._gidleLock.acquire() - try: + with self._gidleLock: + self._flushingStaleAtspiEvents = True originalQueue = self._eventQueue - newQueue: queue.Queue[Any] = queue.Queue(0) + self._eventQueue = queue.Queue(0) + + retainedEvents: list[Any] = [] + try: 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): + # Accessible property calls can dispatch nested AT-SPI events. + # Keep them outside _gidleLock so a nested enqueue cannot deadlock. + try: + isStale = self._event_is_from_stale_context(event) + shouldPreserve = ( + isStale + and self._should_preserve_during_suppression(event) + ) + except Exception: + retainedEvents.append(event) + raise + + if isStale and not shouldPreserve: 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) + retainedEvents.append(event) finally: - self._gidleLock.release() + while not originalQueue.empty(): + try: + retainedEvents.append(originalQueue.get_nowait()) + except queue.Empty: + break + + with self._gidleLock: + eventsQueuedDuringFlush = self._eventQueue + mergedQueue: queue.Queue[Any] = queue.Queue(0) + for event in retainedEvents: + mergedQueue.put(event) + + while not eventsQueuedDuringFlush.empty(): + try: + mergedQueue.put(eventsQueuedDuringFlush.get_nowait()) + except queue.Empty: + break + + self._eventQueue = mergedQueue + self._flushingStaleAtspiEvents = False + if self._asyncMode and not self._eventQueue.empty() and not self._gidleId: + self._gidleId = GLib.idle_add(self._dequeue) def _inFlood(self) -> bool: size = self._eventQueue.qsize() diff --git a/tests/test_event_manager_compositor_context_regressions.py b/tests/test_event_manager_compositor_context_regressions.py index 587f69b..b6d0b0d 100644 --- a/tests/test_event_manager_compositor_context_regressions.py +++ b/tests/test_event_manager_compositor_context_regressions.py @@ -186,6 +186,64 @@ class EventManagerCompositorContextRegressionTests(unittest.TestCase): self.assertEqual(list(self.manager._eventQueue.queue), [currentEvent]) + def test_flush_classifies_outside_queue_lock_and_preserves_nested_event(self) -> None: + staleEvent = FakeEvent("object:children-changed:add", source="stale") + currentEvent = FakeEvent("object:children-changed:add", source="current") + nestedEvent = FakeEvent("object:state-changed:focused", source="nested", detail1=1) + self.manager._eventQueue.put(staleEvent) + self.manager._eventQueue.put(currentEvent) + self.manager._ignore = mock.Mock(return_value=False) + self.manager._prioritizeSelfHostedFocusedEvent = mock.Mock(return_value=False) + self.manager._queuePrintln = mock.Mock() + self.manager._inFlood = mock.Mock(return_value=False) + self.manager._shouldSuspendEventsFor = mock.Mock(return_value=False) + self.manager._dequeue = mock.Mock(return_value=False) + script = types.SimpleNamespace(eventCache={}) + + def classify_event(event) -> bool: + self.assertFalse(self.manager._gidleLock.locked()) + if event is staleEvent: + self.manager._enqueue(nestedEvent) + return True + return False + + self.manager._event_is_from_stale_context = mock.Mock(side_effect=classify_event) + self.manager._should_preserve_during_suppression = mock.Mock(return_value=False) + + with ( + mock.patch.object(event_manager.AXObject, "get_application", return_value=object()), + mock.patch.object(event_manager.GLib, "idle_add", return_value=1), + mock.patch.object( + event_manager.cthulhu.cthulhuApp.scriptManager, + "get_script", + return_value=script, + ), + mock.patch.object( + event_manager.AXUtilities, + "get_application_toolkit_name", + return_value="VCL", + ), + ): + self.manager._flush_stale_atspi_events() + + self.assertEqual(list(self.manager._eventQueue.queue), [currentEvent, nestedEvent]) + self.manager._dequeue.assert_not_called() + + def test_flush_restores_retained_and_unexamined_events_after_exception(self) -> None: + firstEvent = FakeEvent("object:children-changed:add", source="first") + secondEvent = FakeEvent("object:children-changed:add", source="second") + self.manager._eventQueue.put(firstEvent) + self.manager._eventQueue.put(secondEvent) + self.manager._event_is_from_stale_context = mock.Mock( + side_effect=RuntimeError("property lookup failed"), + ) + + with self.assertRaisesRegex(RuntimeError, "property lookup failed"): + self.manager._flush_stale_atspi_events() + + self.assertEqual(list(self.manager._eventQueue.queue), [firstEvent, secondEvent]) + self.assertFalse(self.manager._flushingStaleAtspiEvents) + def test_stale_background_event_does_not_activate_script_during_suppression(self) -> None: script = mock.Mock() script.isActivatableEvent.return_value = True From ed80fb0ea7b1e9da4b7c175669d8041d5493adba Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Fri, 31 Jul 2026 14:29:31 -0400 Subject: [PATCH 2/4] Split the wine helper into it's own package. --- .../Arch-Linux/cthulhu-git/PKGBUILD | 30 ++-------- .../cthulhu-wine-access-git/PKGBUILD | 55 +++++++++++++++++++ .../Arch-Linux/cthulhu-wine-access/PKGBUILD | 37 +++++++++++++ distro-packages/Arch-Linux/cthulhu/PKGBUILD | 26 +-------- tests/test_arch_pkgbuild_optional_wine.py | 45 +++++++++++++++ tests/test_wine_access_manager.py | 12 ++++ 6 files changed, 157 insertions(+), 48 deletions(-) create mode 100644 distro-packages/Arch-Linux/cthulhu-wine-access-git/PKGBUILD create mode 100644 distro-packages/Arch-Linux/cthulhu-wine-access/PKGBUILD create mode 100644 tests/test_arch_pkgbuild_optional_wine.py diff --git a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD index c3c0251..82aa2ea 100644 --- a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD @@ -2,10 +2,10 @@ # shellcheck shell=bash disable=SC2034,SC2154,SC2164 pkgbase=cthulhu-git -pkgname=(cthulhu-git cthulhu-wine-access-git) +pkgname=cthulhu-git _pkgname=cthulhu -pkgver=2026.05.25.r423.g0576b6f -pkgrel=2 +pkgver=2026.07.18.r465.g050ae25 +pkgrel=1 pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca" url="https://git.stormux.org/storm/cthulhu" arch=(x86_64 aarch64 armv7h) @@ -73,10 +73,6 @@ makedepends=( git meson ) -makedepends_x86_64=( - cmake - wine -) source=("${_pkgname}::git+https://git.stormux.org/storm/${_pkgname}.git#branch=master") sha512sums=('SKIP') @@ -96,35 +92,19 @@ pkgver() { build() { cd "${_pkgname}" - arch-meson _build + arch-meson _build -Dwine-access=disabled meson compile -C _build } -package_cthulhu-git() { +package() { arch=(any) provides=("cthulhu=${pkgver}") conflicts=(cthulhu) cd "${_pkgname}" meson install -C _build --destdir "$pkgdir" - rm -rf "$pkgdir/usr/libexec/cthulhu/wine" - # Remove icon cache - it will be generated by post-install hooks rm -f "$pkgdir/usr/share/icons/hicolor/icon-theme.cache" } -package_cthulhu-wine-access-git() { - pkgdesc="Wine and Proton accessibility bridge for Cthulhu" - arch=(x86_64) - depends=("cthulhu-git=${pkgver}" glib2 wine) - provides=(cthulhu-wine-access) - conflicts=(cthulhu-wine-access) - - cd "${_pkgname}" - install -Dm755 _build/cthulhu-wine-access.exe \ - "$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe" - install -Dm755 _build/cthulhu-wine-access.exe.so \ - "$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe.so" -} - # vim:set sw=2 sts=-1 et: diff --git a/distro-packages/Arch-Linux/cthulhu-wine-access-git/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-wine-access-git/PKGBUILD new file mode 100644 index 0000000..a7ae5fa --- /dev/null +++ b/distro-packages/Arch-Linux/cthulhu-wine-access-git/PKGBUILD @@ -0,0 +1,55 @@ +# Maintainer: Storm Dragon +# shellcheck shell=bash disable=SC2034,SC2154,SC2164 + +pkgname=cthulhu-wine-access-git +_pkgname=cthulhu +pkgver=2026.07.18.r465.g050ae25 +pkgrel=1 +pkgdesc="Optional Wine and Proton accessibility bridge for Cthulhu" +url="https://git.stormux.org/storm/cthulhu" +arch=(x86_64) +license=(LGPL-2.1-or-later) +depends=( + "cthulhu-git=${pkgver}" + glib2 + wine +) +makedepends=( + cmake + git + meson +) +provides=(cthulhu-wine-access) +conflicts=(cthulhu-wine-access) +source=("${_pkgname}::git+https://git.stormux.org/storm/${_pkgname}.git#branch=master") +sha512sums=('SKIP') + +pkgver() { + cd "${_pkgname}" + local projectVersion revisionCount commitHash + + projectVersion=$(sed -n "s/^[[:space:]]*version: '\([^']*\)'.*/\1/p" meson.build) + projectVersion=${projectVersion%-master} + projectVersion=${projectVersion//-/.} + [[ -n "${projectVersion}" ]] || return 1 + + revisionCount=$(git rev-list --count HEAD) + commitHash=$(git rev-parse --short=7 HEAD) + printf "%s.r%s.g%s\n" "${projectVersion}" "${revisionCount}" "${commitHash}" +} + +build() { + cd "${_pkgname}" + arch-meson _build -Dwine-access=enabled + meson compile -C _build +} + +package() { + cd "${_pkgname}" + install -Dm755 _build/cthulhu-wine-access.exe \ + "$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe" + install -Dm755 _build/cthulhu-wine-access.exe.so \ + "$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe.so" +} + +# vim:set sw=2 sts=-1 et: diff --git a/distro-packages/Arch-Linux/cthulhu-wine-access/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-wine-access/PKGBUILD new file mode 100644 index 0000000..57f854e --- /dev/null +++ b/distro-packages/Arch-Linux/cthulhu-wine-access/PKGBUILD @@ -0,0 +1,37 @@ +# Maintainer: Storm Dragon +# shellcheck shell=bash disable=SC2034,SC2154,SC2164 + +pkgname=cthulhu-wine-access +pkgver=2026.07.18 +pkgrel=1 +pkgdesc="Optional Wine and Proton accessibility bridge for Cthulhu" +url="https://git.stormux.org/storm/cthulhu" +arch=(x86_64) +license=(LGPL-2.1-or-later) +depends=( + "cthulhu=${pkgver}" + glib2 + wine +) +makedepends=( + cmake + meson +) +source=("git+https://git.stormux.org/storm/cthulhu.git#tag=${pkgver}") +sha512sums=('SKIP') + +build() { + cd cthulhu + arch-meson _build -Dwine-access=enabled + meson compile -C _build +} + +package() { + cd cthulhu + install -Dm755 _build/cthulhu-wine-access.exe \ + "$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe" + install -Dm755 _build/cthulhu-wine-access.exe.so \ + "$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe.so" +} + +# vim:set sw=2 sts=-1 et: diff --git a/distro-packages/Arch-Linux/cthulhu/PKGBUILD b/distro-packages/Arch-Linux/cthulhu/PKGBUILD index 50d1c34..67983b1 100644 --- a/distro-packages/Arch-Linux/cthulhu/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu/PKGBUILD @@ -2,7 +2,7 @@ # shellcheck shell=bash disable=SC2034,SC2154,SC2164 pkgbase=cthulhu -pkgname=(cthulhu cthulhu-wine-access) +pkgname=cthulhu pkgver=2026.07.18 pkgrel=1 pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca" @@ -70,44 +70,24 @@ makedepends=( git meson ) -makedepends_x86_64=( - cmake - wine -) source=("git+https://git.stormux.org/storm/cthulhu.git#tag=${pkgver}") sha512sums=('SKIP') build() { cd cthulhu - arch-meson _build + arch-meson _build -Dwine-access=disabled meson compile -C _build } -package_cthulhu() { +package() { arch=(any) provides=("cthulhu=${pkgver}") conflicts=(cthulhu) cd cthulhu meson install -C _build --destdir "$pkgdir" - rm -rf "$pkgdir/usr/libexec/cthulhu/wine" - # Remove icon cache - it will be generated by post-install hooks rm -f "$pkgdir/usr/share/icons/hicolor/icon-theme.cache" } -package_cthulhu-wine-access() { - pkgdesc="Wine and Proton accessibility bridge for Cthulhu" - arch=(x86_64) - depends=("cthulhu=${pkgver}" glib2 wine) - provides=(cthulhu-wine-access) - conflicts=(cthulhu-wine-access) - - cd cthulhu - install -Dm755 _build/cthulhu-wine-access.exe \ - "$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe" - install -Dm755 _build/cthulhu-wine-access.exe.so \ - "$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe.so" -} - # vim:set sw=2 sts=-1 et: diff --git a/tests/test_arch_pkgbuild_optional_wine.py b/tests/test_arch_pkgbuild_optional_wine.py new file mode 100644 index 0000000..bbd76a1 --- /dev/null +++ b/tests/test_arch_pkgbuild_optional_wine.py @@ -0,0 +1,45 @@ +import unittest +from pathlib import Path + + +class ArchPkgbuildOptionalWineTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + repository = Path(__file__).resolve().parents[1] + cls.pkgbuilds = [ + repository / "distro-packages" / "Arch-Linux" / name / "PKGBUILD" + for name in ("cthulhu", "cthulhu-git") + ] + cls.helperPkgbuilds = [ + repository / "distro-packages" / "Arch-Linux" / name / "PKGBUILD" + for name in ("cthulhu-wine-access", "cthulhu-wine-access-git") + ] + + def test_main_packages_disable_wine_helper_build(self): + for pkgbuild in self.pkgbuilds: + with self.subTest(pkgbuild=pkgbuild): + contents = pkgbuild.read_text(encoding="utf-8") + self.assertIn("-Dwine-access=disabled", contents) + + def test_main_packages_do_not_depend_on_wine(self): + for pkgbuild in self.pkgbuilds: + with self.subTest(pkgbuild=pkgbuild): + contents = pkgbuild.read_text(encoding="utf-8") + dependencySection = contents.split("source=(", 1)[0] + dependencyLines = { + line.strip().strip("'") + for line in dependencySection.splitlines() + if line.strip() and not line.lstrip().startswith("#") + } + self.assertNotIn("wine", dependencyLines) + + def test_optional_helper_packages_own_the_wine_dependency(self): + for pkgbuild in self.helperPkgbuilds: + with self.subTest(pkgbuild=pkgbuild): + contents = pkgbuild.read_text(encoding="utf-8") + self.assertIn("-Dwine-access=enabled", contents) + self.assertRegex(contents, r"(?m)^ wine$") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_wine_access_manager.py b/tests/test_wine_access_manager.py index fd1c724..d80b47d 100644 --- a/tests/test_wine_access_manager.py +++ b/tests/test_wine_access_manager.py @@ -5,6 +5,18 @@ from cthulhu.wine_access_manager import PrefixProcess, WineAccessManager class WineAccessManagerTest(unittest.TestCase): + @mock.patch.object(WineAccessManager, "_find_helper", return_value=None) + @mock.patch.object(WineAccessManager, "discover_prefixes") + def test_poll_without_installed_helper_is_a_no_op( + self, discover_prefixes, _find_helper + ): + manager = WineAccessManager(helperPath=None) + + self.assertIsNone(manager.helperPath) + self.assertTrue(manager.poll()) + discover_prefixes.assert_not_called() + self.assertEqual(manager.prefixes, {}) + def test_prefix_prefers_explicit_wineprefix(self): environment = { "WINEPREFIX": "/tmp/custom-prefix", From fcd10069aeca60bd1d95f3f1b7a28873dcd3f487 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 1 Aug 2026 03:51:31 -0400 Subject: [PATCH 3/4] Tighten up relase and reclaim clode for terminals. --- src/cthulhu/compositor_state_adapter.py | 9 ++++ src/cthulhu/compositor_state_i3.py | 5 +++ src/cthulhu/event_manager.py | 3 ++ src/cthulhu/input_event_manager.py | 14 +++++++ ...st_compositor_state_adapter_regressions.py | 14 +++++++ tests/test_compositor_state_i3_regressions.py | 20 +++++++++ ..._manager_compositor_context_regressions.py | 10 +++++ ...put_event_manager_x11_focus_regressions.py | 41 +++++++++++++++++++ 8 files changed, 116 insertions(+) diff --git a/src/cthulhu/compositor_state_adapter.py b/src/cthulhu/compositor_state_adapter.py index 8a77cfd..6a4ecc1 100644 --- a/src/cthulhu/compositor_state_adapter.py +++ b/src/cthulhu/compositor_state_adapter.py @@ -63,6 +63,15 @@ class CompositorStateAdapter: def get_snapshot(self) -> DesktopContextSnapshot: return self._snapshot + def refresh_workspace_context(self, reason: str) -> DesktopContextSnapshot: + """Refreshes and returns the selected backend's current workspace context.""" + + backend = self._workspaceBackend + refreshContext = getattr(backend, "refresh_context", None) + if callable(refreshContext): + refreshContext(reason) + return self._snapshot + def get_session_type(self) -> str: return get_session_type() diff --git a/src/cthulhu/compositor_state_i3.py b/src/cthulhu/compositor_state_i3.py index 8187bd8..ad13091 100644 --- a/src/cthulhu/compositor_state_i3.py +++ b/src/cthulhu/compositor_state_i3.py @@ -92,6 +92,11 @@ class I3WorkspaceBackend: self._emitSignal = None self._lastContext = None + def refresh_context(self, reason: str) -> None: + """Refreshes the cached i3 focus context without emitting a transition.""" + + self._refresh_context(reason, transition=False) + def _ensure_connection(self) -> bool: if self._connection is not None: return True diff --git a/src/cthulhu/event_manager.py b/src/cthulhu/event_manager.py index 55d9eff..a67506a 100644 --- a/src/cthulhu/event_manager.py +++ b/src/cthulhu/event_manager.py @@ -174,6 +174,7 @@ class EventManager: debug.printMessage(debug.LEVEL_INFO, 'EVENT MANAGER: Activating keyboard handling', True) self._inputEventManager = input_event_manager.get_manager() + self._inputEventManager.set_compositor_state_adapter(self._compositorStateAdapter) self._inputEventManager.start_key_watcher() cthulhu_state.device = self._inputEventManager._device self._keyHandlingActive = True @@ -277,6 +278,8 @@ class EventManager: self._compositorStateAdapter.remove_listener(self._handle_compositor_signal) self._compositorStateAdapter = adapter + if self._inputEventManager is not None: + self._inputEventManager.set_compositor_state_adapter(adapter) if adapter is not None and hasattr(adapter, "add_listener"): adapter.add_listener(self._handle_compositor_signal) diff --git a/src/cthulhu/input_event_manager.py b/src/cthulhu/input_event_manager.py index 4059ad3..ffd81c1 100644 --- a/src/cthulhu/input_event_manager.py +++ b/src/cthulhu/input_event_manager.py @@ -134,6 +134,12 @@ class InputEventManager: self._xtermRecoverySourceId: int = 0 self._xtermRecoveryPollCount: int = 0 self._xtermHandoffHistory: List[str] = [] + self._compositorStateAdapter: Optional[Any] = None + + def set_compositor_state_adapter(self, adapter: Optional[Any]) -> None: + """Stores the compositor adapter used to refresh XTerm handoff state.""" + + self._compositorStateAdapter = adapter def activate_device(self) -> Atspi.Device: """Creates and returns the AT-SPI device used by this manager.""" @@ -847,9 +853,17 @@ class InputEventManager: snapshot = cthulhu_state.compositorSnapshot usingI3Context = snapshot is not None and snapshot.backend_name == "i3-ipc" + if usingI3Context and self._lastEwmhXtermMatch is True: + adapter = self._compositorStateAdapter + refreshContext = getattr(adapter, "refresh_workspace_context", None) + if callable(refreshContext): + snapshot = refreshContext("xterm-handoff-check") + usingI3Context = snapshot is not None and snapshot.backend_name == "i3-ipc" if usingI3Context and snapshot.focused_workspace_empty is True: self._record_xterm_handoff_action("i3:confirmed-empty-workspace") self._lastEwmhReadWindowId = None + self._lastEwmhActiveWindowId = None + self._lastEwmhXtermMatch = False return False if usingI3Context and snapshot.focused_window_id is not None: diff --git a/tests/test_compositor_state_adapter_regressions.py b/tests/test_compositor_state_adapter_regressions.py index 45f8917..a3f4c37 100644 --- a/tests/test_compositor_state_adapter_regressions.py +++ b/tests/test_compositor_state_adapter_regressions.py @@ -42,6 +42,7 @@ class FakeWorkspaceBackend: self.name = name self.activate_calls = [] self.deactivate_calls = [] + self.refresh_calls = [] def is_available(self, session_type: str | None = None) -> bool: return self.available @@ -52,6 +53,9 @@ class FakeWorkspaceBackend: def deactivate(self, emit_signal=None) -> None: self.deactivate_calls.append(emit_signal) + def refresh_context(self, reason: str) -> None: + self.refresh_calls.append(reason) + class CompositorStateAdapterRegressionTests(unittest.TestCase): def setUp(self) -> None: @@ -111,6 +115,16 @@ class CompositorStateAdapterRegressionTests(unittest.TestCase): self.assertEqual(snapshot.focused_window_title, "9 devel") self.assertFalse(snapshot.focused_workspace_empty) + def test_refresh_workspace_context_queries_selected_backend(self) -> None: + backend = FakeWorkspaceBackend(True, "i3-ipc") + adapter = compositor_state_adapter.CompositorStateAdapter(workspace_backends=[backend]) + adapter.activate() + + snapshot = adapter.refresh_workspace_context("xterm-handoff-check") + + self.assertEqual(backend.refresh_calls, ["xterm-handoff-check"]) + self.assertIs(snapshot, adapter.get_snapshot()) + def test_confirmed_empty_workspace_clears_stale_accessible_context(self) -> None: backend = FakeWorkspaceBackend(True, "i3-ipc") adapter = compositor_state_adapter.CompositorStateAdapter(workspace_backends=[backend]) diff --git a/tests/test_compositor_state_i3_regressions.py b/tests/test_compositor_state_i3_regressions.py index 69ffbdd..3f80b14 100644 --- a/tests/test_compositor_state_i3_regressions.py +++ b/tests/test_compositor_state_i3_regressions.py @@ -153,6 +153,26 @@ class I3WorkspaceBackendRegressionTests(unittest.TestCase): ], ) + def test_public_refresh_emits_updated_context_without_transition(self) -> None: + backend = compositor_state_i3.I3WorkspaceBackend(i3ipc_module=types.SimpleNamespace()) + backend._active = True + backend._emitSignal = mock.Mock() + payload = { + "focused_window_id": 0x2600003, + "focused_window_title": "Browser", + "focused_workspace_empty": False, + } + + with mock.patch.object(backend, "_query_context", return_value=({"8"}, payload)): + backend.refresh_context("xterm-handoff-check") + + backend._emitSignal.assert_called_once_with( + compositor_state_types.WORKSPACE_STATE_CHANGED, + {"8"}, + "xterm-handoff-check", + payload, + ) + def test_query_failure_emits_unknown_instead_of_preserving_stale_i3_context(self): backend = compositor_state_i3.I3WorkspaceBackend(i3ipc_module=types.SimpleNamespace()) backend._active = True diff --git a/tests/test_event_manager_compositor_context_regressions.py b/tests/test_event_manager_compositor_context_regressions.py index b6d0b0d..1396ca9 100644 --- a/tests/test_event_manager_compositor_context_regressions.py +++ b/tests/test_event_manager_compositor_context_regressions.py @@ -64,6 +64,16 @@ class EventManagerCompositorContextRegressionTests(unittest.TestCase): adapter.add_listener.assert_called_once_with(self.manager._handle_compositor_signal) + def test_set_compositor_state_adapter_updates_active_input_manager(self) -> None: + adapter = mock.Mock() + self.manager._inputEventManager = mock.Mock() + + self.manager.set_compositor_state_adapter(adapter) + + self.manager._inputEventManager.set_compositor_state_adapter.assert_called_once_with( + adapter + ) + def test_pause_signal_updates_churn_state_and_resume_clears_it(self) -> None: snapshot = compositor_state_types.DesktopContextSnapshot(session_type="wayland") diff --git a/tests/test_input_event_manager_x11_focus_regressions.py b/tests/test_input_event_manager_x11_focus_regressions.py index cd4677f..1f92260 100644 --- a/tests/test_input_event_manager_x11_focus_regressions.py +++ b/tests/test_input_event_manager_x11_focus_regressions.py @@ -658,6 +658,43 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase): matcher.assert_called_once_with(0x2600003) getEwmhWindowId.assert_not_called() + def test_xterm_match_refreshes_i3_context_before_reusing_cached_match(self) -> None: + manager = input_event_manager.InputEventManager() + staleSnapshot = compositor_state_types.DesktopContextSnapshot( + session_type="x11", + backend_name="i3-ipc", + focused_window_id=0x200000C, + focused_workspace_empty=False, + ) + freshSnapshot = compositor_state_types.DesktopContextSnapshot( + session_type="x11", + backend_name="i3-ipc", + focused_window_id=0x2600003, + focused_workspace_empty=False, + ) + adapter = mock.Mock() + + def refresh_context(_reason: str) -> compositor_state_types.DesktopContextSnapshot: + input_event_manager.cthulhu_state.compositorSnapshot = freshSnapshot + return freshSnapshot + + adapter.refresh_workspace_context.side_effect = refresh_context + input_event_manager.cthulhu_state.compositorSnapshot = staleSnapshot + manager._lastEwmhActiveWindowId = 0x200000C + manager._lastEwmhXtermMatch = True + + manager.set_compositor_state_adapter(adapter) + with mock.patch.object( + manager, + "_x11_window_id_xterm_match", + return_value=False, + ) as matcher: + result = manager._active_x11_window_xterm_match() + + self.assertFalse(result) + adapter.refresh_workspace_context.assert_called_once_with("xterm-handoff-check") + matcher.assert_called_once_with(0x2600003) + def test_i3_exact_xid_initializes_x11_before_matching_xterm(self): manager = input_event_manager.InputEventManager() snapshot = compositor_state_types.DesktopContextSnapshot( @@ -696,6 +733,8 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase): def test_confirmed_empty_i3_workspace_is_definitely_not_xterm(self): manager = input_event_manager.InputEventManager() + manager._lastEwmhActiveWindowId = 0x200000C + manager._lastEwmhXtermMatch = True snapshot = compositor_state_types.DesktopContextSnapshot( session_type="x11", backend_name="i3-ipc", @@ -710,6 +749,8 @@ class InputEventManagerX11FocusRegressionTests(unittest.TestCase): result = manager._active_x11_window_xterm_match() self.assertFalse(result) + self.assertIsNone(manager._lastEwmhActiveWindowId) + self.assertFalse(manager._lastEwmhXtermMatch) getEwmhWindowId.assert_not_called() matcher.assert_not_called() From 191317eb1f5fc92ceac771e1cbe1cee9147d2d55 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 1 Aug 2026 04:33:49 -0400 Subject: [PATCH 4/4] PKGBUILD work in preparation for release. --- .gitignore | 8 ++-- AGENTS.md | 7 ++++ .../Arch-Linux/cthulhu-git/PKGBUILD | 4 +- .../cthulhu-wine-access-git/PKGBUILD | 1 + .../Arch-Linux/cthulhu-wine-access/PKGBUILD | 37 ---------------- distro-packages/Arch-Linux/cthulhu/PKGBUILD | 7 +--- tests/test_arch_pkgbuild_optional_wine.py | 42 ++++++++++++++++--- 7 files changed, 54 insertions(+), 52 deletions(-) delete mode 100644 distro-packages/Arch-Linux/cthulhu-wine-access/PKGBUILD diff --git a/.gitignore b/.gitignore index 9618e76..c493f16 100644 --- a/.gitignore +++ b/.gitignore @@ -74,9 +74,11 @@ debug.log # Package artifacts *.pkg.tar.zst -distro-packages/*/cthulhu/ -distro-packages/*/cthulhu-git/ -distro-packages/*/pkg/ +distro-packages/**/.SRCINFO +distro-packages/**/*.log +distro-packages/*/*/cthulhu/ +distro-packages/*/*/cthulhu-git/ +distro-packages/*/*/pkg/ # Generated makefiles (should not be committed) Makefile diff --git a/AGENTS.md b/AGENTS.md index 38f1021..54c782e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,5 +125,12 @@ Run the narrowest relevant checks first, then broaden testing based on the affec - In particular, update both Arch Linux `PKGBUILD` files and, when relevant, Slint's `cthulhu-info` and `README`. Review the Slackware and Slint build files for any corresponding build-dependency changes. - Preserve the distinction between required, optional, and build-only dependencies so package builds install what Cthulhu actually needs without forcing optional features on every user. +## Arch package verification +- After changing an Arch Linux `PKGBUILD`, build every affected recipe in a clean chroot before considering the packaging work complete. Use `pkgctl build --clean` from the `devtools` package; a normal `makepkg` or `yay` build is not a substitute for this check. +- Validate both stable and `-git` recipes when a shared packaging change affects both. +- Build dependency packages first. When a split helper depends on a locally built package that is unavailable in the configured repositories, pass that package artifact to the helper build with `pkgctl build --clean -I /path/to/package.pkg.tar.zst`. +- Run `bash -n`, `shellcheck`, and `makepkg --printsrcinfo` on each changed recipe before the clean-chroot build. +- Report which recipes completed clean-chroot builds. If sudo access, network access, or an unavailable dependency prevents a build, state that explicitly instead of describing the package as verified. + ## Common Cthulhu agent mistakes - Do not edit or diagnose a stale installed copy under `~/.local/...`; update the repo and rebuild with `./build-local.sh`. diff --git a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD index 82aa2ea..9e9de4f 100644 --- a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD @@ -65,8 +65,8 @@ optdepends=( 'python-pillow: Image processing for OCR' 'python-pytesseract: Python wrapper for Tesseract OCR engine' 'tesseract: OCR engine for text recognition' -) -optdepends_x86_64=( + + # The helper is x86_64-only, but this package is architecture-independent. 'cthulhu-wine-access-git: Wine, Proton, NVDA Controller, and Tolk integration' ) makedepends=( diff --git a/distro-packages/Arch-Linux/cthulhu-wine-access-git/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-wine-access-git/PKGBUILD index a7ae5fa..1fd358e 100644 --- a/distro-packages/Arch-Linux/cthulhu-wine-access-git/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu-wine-access-git/PKGBUILD @@ -23,6 +23,7 @@ provides=(cthulhu-wine-access) conflicts=(cthulhu-wine-access) source=("${_pkgname}::git+https://git.stormux.org/storm/${_pkgname}.git#branch=master") sha512sums=('SKIP') +options=(!lto) pkgver() { cd "${_pkgname}" diff --git a/distro-packages/Arch-Linux/cthulhu-wine-access/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-wine-access/PKGBUILD deleted file mode 100644 index 57f854e..0000000 --- a/distro-packages/Arch-Linux/cthulhu-wine-access/PKGBUILD +++ /dev/null @@ -1,37 +0,0 @@ -# Maintainer: Storm Dragon -# shellcheck shell=bash disable=SC2034,SC2154,SC2164 - -pkgname=cthulhu-wine-access -pkgver=2026.07.18 -pkgrel=1 -pkgdesc="Optional Wine and Proton accessibility bridge for Cthulhu" -url="https://git.stormux.org/storm/cthulhu" -arch=(x86_64) -license=(LGPL-2.1-or-later) -depends=( - "cthulhu=${pkgver}" - glib2 - wine -) -makedepends=( - cmake - meson -) -source=("git+https://git.stormux.org/storm/cthulhu.git#tag=${pkgver}") -sha512sums=('SKIP') - -build() { - cd cthulhu - arch-meson _build -Dwine-access=enabled - meson compile -C _build -} - -package() { - cd cthulhu - install -Dm755 _build/cthulhu-wine-access.exe \ - "$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe" - install -Dm755 _build/cthulhu-wine-access.exe.so \ - "$pkgdir/usr/libexec/cthulhu/wine/cthulhu-wine-access.exe.so" -} - -# vim:set sw=2 sts=-1 et: diff --git a/distro-packages/Arch-Linux/cthulhu/PKGBUILD b/distro-packages/Arch-Linux/cthulhu/PKGBUILD index 67983b1..fcf8181 100644 --- a/distro-packages/Arch-Linux/cthulhu/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu/PKGBUILD @@ -3,7 +3,7 @@ pkgbase=cthulhu pkgname=cthulhu -pkgver=2026.07.18 +pkgver=2026.05.25 pkgrel=1 pkgdesc="Desktop-agnostic screen reader with plugin system, forked from Orca" url="https://git.stormux.org/storm/cthulhu" @@ -63,9 +63,6 @@ optdepends=( 'python-pytesseract: Python wrapper for Tesseract OCR engine' 'tesseract: OCR engine for text recognition' ) -optdepends_x86_64=( - 'cthulhu-wine-access: Wine, Proton, NVDA Controller, and Tolk integration' -) makedepends=( git meson @@ -75,7 +72,7 @@ sha512sums=('SKIP') build() { cd cthulhu - arch-meson _build -Dwine-access=disabled + arch-meson _build meson compile -C _build } diff --git a/tests/test_arch_pkgbuild_optional_wine.py b/tests/test_arch_pkgbuild_optional_wine.py index bbd76a1..bde7168 100644 --- a/tests/test_arch_pkgbuild_optional_wine.py +++ b/tests/test_arch_pkgbuild_optional_wine.py @@ -12,14 +12,15 @@ class ArchPkgbuildOptionalWineTests(unittest.TestCase): ] cls.helperPkgbuilds = [ repository / "distro-packages" / "Arch-Linux" / name / "PKGBUILD" - for name in ("cthulhu-wine-access", "cthulhu-wine-access-git") + for name in ("cthulhu-wine-access-git",) ] def test_main_packages_disable_wine_helper_build(self): - for pkgbuild in self.pkgbuilds: - with self.subTest(pkgbuild=pkgbuild): - contents = pkgbuild.read_text(encoding="utf-8") - self.assertIn("-Dwine-access=disabled", contents) + stableContents = self.pkgbuilds[0].read_text(encoding="utf-8") + self.assertNotIn("wine-access", stableContents) + + gitContents = self.pkgbuilds[1].read_text(encoding="utf-8") + self.assertIn("-Dwine-access=disabled", gitContents) def test_main_packages_do_not_depend_on_wine(self): for pkgbuild in self.pkgbuilds: @@ -33,6 +34,21 @@ class ArchPkgbuildOptionalWineTests(unittest.TestCase): } self.assertNotIn("wine", dependencyLines) + def test_main_packages_advertise_matching_optional_helper(self): + stableContents = self.pkgbuilds[0].read_text(encoding="utf-8") + self.assertNotIn("cthulhu-wine-access", stableContents) + + gitContents = self.pkgbuilds[1].read_text(encoding="utf-8") + self.assertIn( + "'cthulhu-wine-access-git: Wine, Proton, NVDA Controller, and Tolk integration'", + gitContents, + ) + self.assertNotIn("optdepends_x86_64", gitContents) + + def test_stable_packages_use_current_release_version(self): + contents = self.pkgbuilds[0].read_text(encoding="utf-8") + self.assertRegex(contents, r"(?m)^pkgver=2026\.05\.25$") + def test_optional_helper_packages_own_the_wine_dependency(self): for pkgbuild in self.helperPkgbuilds: with self.subTest(pkgbuild=pkgbuild): @@ -40,6 +56,22 @@ class ArchPkgbuildOptionalWineTests(unittest.TestCase): self.assertIn("-Dwine-access=enabled", contents) self.assertRegex(contents, r"(?m)^ wine$") + def test_optional_helper_packages_disable_lto(self): + for pkgbuild in self.helperPkgbuilds: + with self.subTest(pkgbuild=pkgbuild): + contents = pkgbuild.read_text(encoding="utf-8") + self.assertRegex(contents, r"(?m)^options=\(!lto\)$") + + def test_git_sources_declare_git_as_a_build_dependency(self): + for pkgbuild in self.pkgbuilds + self.helperPkgbuilds: + with self.subTest(pkgbuild=pkgbuild): + contents = pkgbuild.read_text(encoding="utf-8") + self.assertIn("git+", contents) + makeDependencies = contents.split("makedepends=(", 1)[1].split( + ")", 1 + )[0] + self.assertRegex(makeDependencies, r"(?m)^ git$") + if __name__ == "__main__": unittest.main()