Repository: qtile/qtile

Issue:
Notify widget should un-register from dbus when finalized
Without doing so, reloading the config when a `Notify` widget was previously used but now is not, the dbus interface remains in a registered state. This prevents other processes from registering on that interface.


Discussion:
This one needs a little work. If I call `org.freedesktop.DBus.ReleaseName` I get a `RELEASED` reply but the service is still running...

EDIT: scrap that. It's working now.

Resolving patch:
diff --git a/libqtile/notify.py b/libqtile/notify.py
--- a/libqtile/notify.py
+++ b/libqtile/notify.py
@@ -22,10 +22,11 @@
 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
-
+import asyncio
 from typing import Any
 
 try:
+    from dbus_next import ReleaseNameReply
     from dbus_next.aio import MessageBus
     from dbus_next.service import ServiceInterface, method, signal
 
@@ -133,10 +134,10 @@ def __init__(self):
         async def service(self):
             if self._service is None:
                 try:
-                    bus = await MessageBus().connect()
+                    self.bus = await MessageBus().connect()
                     self._service = NotificationService(self)
-                    bus.export(SERVICE_PATH, self._service)
-                    await bus.request_name(BUS_NAME)
+                    self.bus.export(SERVICE_PATH, self._service)
+                    await self.bus.request_name(BUS_NAME)
                 except Exception:
                     logger.exception("Dbus connection failed")
                     self._service = None
@@ -155,6 +156,35 @@ async def register(self, callback, capabilities=None, on_close=None):
             if on_close:
                 self.close_callbacks.append(on_close)
 
+        def unregister(self, callback, on_close=None):
+            try:
+                self.callbacks.remove(callback)
+            except ValueError:
+                logger.error("Unable to remove notify callback. Unknown callback.")
+
+            if on_close:
+                try:
+                    self.close_callbacks.remove(on_close)
+                except ValueError:
+                    logger.error("Unable to remove notify on_close callback. Unknown callback.")
+
+            if not self.callbacks:
+                return asyncio.create_task(self._release())
+
+        async def _release(self):
+            """
+            If the manager has no more callbacks then we need to release the service name
+            from dbus and reset _service to None (to force subsequent calls to `register` to
+            re-register the name on dbus.)
+            """
+            reply = await self.bus.release_name(BUS_NAME)
+
+            if reply != ReleaseNameReply.RELEASED:
+                logger.error(f"Could not release {BUS_NAME}.")
+                return
+
+            self._service = None
+
         def add(self, notif):
             self.notifications.append(notif)
             notif.id = len(self.notifications)
diff --git a/libqtile/widget/notify.py b/libqtile/widget/notify.py
--- a/libqtile/widget/notify.py
+++ b/libqtile/widget/notify.py
@@ -26,7 +26,7 @@
 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
-
+import asyncio
 from os import path
 
 from libqtile import bar, pangocffi, utils
@@ -189,3 +189,17 @@ def cmd_invoke(self):
         """Invoke the notification's default action"""
         if self.action:
             self.invoke()
+
+    def finalize(self):
+        asyncio.create_task(self._finalize())
+
+    async def _finalize(self):
+        task = notifier.unregister(self.update)
+
+        # If the notifier has no more callbacks then it needs to be stopped.
+        # The returned task will handle the release of the service name from
+        # dbus. We await it here to make sure it's finished before we
+        # complete the finalisation of this widget.
+        if task:
+            await task
+        base._TextBox.finalize(self)


Test patch:
diff --git a/test/widgets/test_notify.py b/test/widgets/test_notify.py
--- a/test/widgets/test_notify.py
+++ b/test/widgets/test_notify.py
@@ -21,7 +21,7 @@
 # NOTE: This test only tests the functionality of the widget and parts of the manager
 # The notification service (in libqtile/notify.py) is tested separately
 # TO DO: notification service test ;)
-
+import asyncio
 import shutil
 import subprocess
 import textwrap
@@ -30,6 +30,7 @@
 
 import libqtile.config
 from libqtile.bar import Bar
+from libqtile.notify import notifier
 from libqtile.widget import notify
 
 
@@ -320,3 +321,30 @@ def test_parser(text):
     notif_1.extend(NOTIFICATION_1)
     subprocess.run(notif_1)
     assert obj.info()["text"] == f"TEST:{MESSAGE_1}"
+
+
+@pytest.mark.usefixtures("dbus")
+def test_unregister():
+    """Short test to check if notifier deregisters correctly."""
+
+    async def deregister():
+        def no_op(*args, **kwargs):
+            pass
+
+        do_nothing = no_op
+
+        await notifier.register(no_op)
+        await notifier.register(do_nothing)
+
+        # Remove one callback: service will not be stopped
+        task = notifier.unregister(no_op)
+        assert task is None
+        assert notifier._service
+
+        # Remove last callback: service will now be stopped
+        task = notifier.unregister(do_nothing)
+        assert task
+        await task
+        assert notifier._service is None
+
+    asyncio.run(deregister())